AWS Security Hub makes your cloud workload visible and can continuously compare your configuration to best practices. Topcoder has been using AWS for nearly a decade, and a lot has changed with Topcoder and AWS.
Some legacy practices used in AWS are no longer in line with best practices, and it is a struggle to keep up to date. AWS Security Hub was released in June of 2019, and since its introduction, AWS has incorporated multiple reporting sources into Security Hub, including CIS Benchmarks.
These industry best practices are evaluated against your current configuration, and if a variance exists, a finding is created in Security Hub. This information can be used to develop enterprise-specific baselines, provide pedagogy for security engineers, devops, and builders, and provide a baseline for certification.
After enabling AWS Security Hub and CIS benchmarks, several findings were created across every account in our organization. To ensure we could track remediation efforts and monitor progress in implementing standards, we integrated Security Hub into Atlassian Jira.
Requirements
To effectively manage the number of findings and ensure change happened as we adopted and built standards, we needed a solution to automate the documentation of the findings and the lifecycle. This solution needed to have the following features:
- Event-driven — The solution must be driven by events from Security Hub.
- Self-healing — The solution must be a closed loop. Whether an engineer transacts on a Jira ticket to remediate a finding or if work is done in AWS shouldn’t matter. Once the issue is resolved, the corresponding ticket should close.
- Support existing workflow — Use our existing workflow for issue management in Jira.
- Scalable — The solution must allow hundreds or thousands of issues to be created quickly.
Solution
AWS documented a solution to integrate AWS Security Hub into Jira. This solution is ideal for a mature organization that has standards in place and wants to allow for variance. The solution is not self-healing and requires a custom workflow.
To achieve the requirements above, we needed a more robust solution that provided the flexibility to manage the issue from either the AWS Console or Jira. We implemented a step function in AWS with the flow below. The flow was triggered by an AWS event bridge rule that filtered for Security Hub events.
AWS Environment
We used an AWS organization account that was the delegated administrator for the security hub; it consolidated the Security Hub findings. The AWS Step Function was deployed in the delegated administrator account. An event bridge rule was deployed with a step function target as part of the deployment process.
Here’s what the code looks like:
{
"detail-type": ["Security Hub Findings - Imported"],
"source": ["aws.securityhub"],
"detail": {
"findings": {
"Severity": {
"Label": ["CRITICAL", "HIGH"]
}
}
}
}
We started with issues marked as CRITICAL to focus on the most important issues and ensure we could manage the process and quantity of issues generated from Security Hub.
Credentials were stored in the AWS Parameter Store and retrieved by lambda.
Jira Environment
We used a company-manged Jira software project in Jira Cloud to manage issues. AWS security hub issues were added to an existing project to track vulnerabilities with additional fields. Updating Jira required administrative privileges and is beyond the scope of this article. There are multiple resources and videos on adding fields, including Jira’s documentation.
Here’s a view of the layout screen in Jira:
The Jira project has a custom field called external ID that is used to store the AWS Security Hub findings id. Here’s the code:
{
"id": "customfield_10053",
"key": "customfield_10053",
"name": "external ID",
"untranslatedName": "external ID",
"custom": true,
"orderable": true,
"navigable": true,
"searchable": true,
"clauseNames": [
"cf[10053]",
"external ID",
"external ID[Short text]"
],
"schema": {
"type": "string",
"custom": "com.atlassian.jira.plugin.system.customfieldtypes:textfield",
"customId": 10053
}
}
Several additional fields were added to assist in data analysis, including the following:
- AWS Account Id
- AWS Region
- AWS Resource Type
- ARN
- AWS Record State
- AWS Compliance Status
- AWS Security control ID
The definition for AWS Account ID is below. An AWS account is a numeric value, and I made it a string. It’s important to know the id when you begin implementing the step function. These can be seen by querying your fields with https://mydomain.atlassian.net/rest/api/3/field. You can also identify the field id in the URL when you edit the details.
{
"id": "customfield_10324",
"key": "customfield_10324",
"name": "AWS Account Id",
"untranslatedName": "AWS Account Id",
"custom": true,
"orderable": true,
"navigable": true,
"searchable": true,
"clauseNames": [
"AWS Account Id",
"AWS Account Id[Short text]",
"cf[10324]"
],
"schema": {
"type": "string",
"custom": "com.atlassian.jira.plugin.system.customfieldtypes:textfield",
"customId": 10324
}
}
Similarly, I used a string field for AWS Region, AWS Resource Type, ARN, and AWS Secure Control ID to capture the values from Security Hub.
Here’s what that looks like:
{
"id": "customfield_10325",
"key": "customfield_10325",
"name": "AWS Region",
"untranslatedName": "AWS Region",
"custom": true,
"orderable": true,
"navigable": true,
"searchable": true,
"clauseNames": [
"AWS Region",
"AWS Region[Short text]",
"cf[10325]"
],
"schema": {
"type": "string",
"custom": "com.atlassian.jira.plugin.system.customfieldtypes:textfield",
"customId": 10325
}
},
{
"id": "customfield_10326",
"key": "customfield_10326",
"name": "AWS Resource Type",
"untranslatedName": "AWS Resource Type",
"custom": true,
"orderable": true,
"navigable": true,
"searchable": true,
"clauseNames": [
"AWS Resource Type",
"AWS Resource Type[Short text]",
"cf[10326]"
],
"schema": {
"type": "string",
"custom": "com.atlassian.jira.plugin.system.customfieldtypes:textfield",
"customId": 10326
}
},
{
"id": "customfield_10335",
"key": "customfield_10335",
"name": "AWS Security control ID",
"untranslatedName": "AWS Security control ID",
"custom": true,
"orderable": true,
"navigable": true,
"searchable": true,
"clauseNames": [
"AWS Security control ID",
"AWS Security control ID[Short text]",
"cf[10335]"
],
"schema": {
"type": "string",
"custom": "com.atlassian.jira.plugin.system.customfieldtypes:textfield",
"customId": 10335
}
}
For AWS Compliance Status, I used an option or enumeration with values NOT_AVAILABLE and FAILED.
{
"id": "customfield_10334",
"key": "customfield_10334",
"name": "AWS Compliance Status",
"untranslatedName": "AWS Compliance Status",
"custom": true,
"orderable": true,
"navigable": true,
"searchable": true,
"clauseNames": [
"AWS Compliance Status",
"AWS Compliance Status[Dropdown]",
"cf[10334]"
],
"schema": {
"type": "option",
"custom": "com.atlassian.jira.plugin.system.customfieldtypes:select",
"customId": 10334
}
}
AWS Step Function Details
The step function is triggered by an event from Security Hub. The first task is to search Jira for the finding identifier from AWS Security Hub.
The finding id is unique and should return a single result from Jira if an issue was previously created. It is stored in a custom field called external id. This step appends the search result to the JSON payload in the step function using ResultPath.
searchForJiraIssue:
Type: Task
Comment: State machine gets an event from event bus with a finding ID. This ID is searched for in Jira externalId field
Resource: arn:aws:lambda:${aws:region}:${aws:accountId}:function:${self:service}-${opt:stage}-searchForJiraIssue
ResultPath: "$.jirasearchresult"
Next: CreateOrUpdateJiraIssue
After the search, a choice state was entered; it had three possible outcomes. If one issue were found, we would need to update that issue with the new information from this event. If no issues were found, then a new issue would be created. If more than one issue was found, something had gone wrong, and a failed state was reached.
CreateOrUpdateJiraIssue:
Type: Choice
Choices:
- Variable: "$.jirasearchresult.body.total"
NumericEquals: 0
Next: CreateJiraIssue
- Variable: "$.jirasearchresult.body.total"
NumericEquals: 1
Next: SetJiraKeyFromSearch
Default: MoreThanOneJiraIssue
The step above checks the search result in the first step stored in the path $.jirasearchresult.body.total. With one issue, the next step, SetJiraKeyFromSearch, used a Pass state to extract the Jira issue key for the next step. I realized later that I didn’t need this step, as I could have extracted the key in the Lambda function.
SetJiraKeyFromSearch:
Type: Pass
Comment: Need to set the jira key for subsequent states
Parameters:
issuekey.$: "$.jirasearchresult.body.issues[0].key"
ResultPath: "$.jira"
Next: TransitionOrUpdateJiraIssue
The pass state then calls TransitionOrUpdateJiraIssue. If the issue is already closed, it will be reopened. If the issue is open, it will be updated.
TransitionOrUpdateJiraIssue:
Type: Choice
Choices:
- Variable: "$.jirasearchresult.body.issues[0].fields.status.name"
StringEquals: Done
Next: TransitionJiraIssue
Default: UpdateJiraIssue
Transitioning the Jira issue required a simple Lambda, as shown below:
TransitionJiraIssue:
Type: Task
Resource: arn:aws:lambda:${aws:region}:${aws:accountId}:function:${self:service}-${opt:stage}-transitionJiraIssue
ResultPath:
Next: UpdateJiraIssue
This lambda had not been implemented yet. Both the Transition and choice called UpdateJiraIssue.
UpdateJiraIssue:
Type: Task
Resource: arn:aws:lambda:${aws:region}:${aws:accountId}:function:${self:service}-${opt:stage}-updateJiraIssue
ResultPath: #pass the input to the next stage
Next: CloseJiraIssueChoicek
This Lambda updated the issue with the current values from the event.
module.exports.updateJiraIssue = async (event) => {
console.log(JSON.stringify(event))
let body = null;
const jiratoken = (await jiratokenpromise).Parameter.Value;
const reqbody = buildpayload(event)
return new Promise((resolve, reject) => {
const options = {
hostname: 'topcoder.atlassian.net',
port: 443,
path: '/rest/api/3/issue/'+ event.jirasearchresult.body.issues[0].key,
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': 'Basic ' +jiratoken
}
};
const request = https.request(options, response => {
response.on('data', chunk => body+= chunk);
response.on('end', () => {
resolve({"statusCode": response.statusCode, "body": JSON.parse(body)});
});
});
request.on('error', (err) => {
console.log('get in error function');
reject(err);
});
console.log(JSON.stringify(reqbody));
request.write(JSON.stringify(reqbody));
request.end();
});
};
The buildpayload function performed all the field mapping below. Several of the values must be transformed or translated. For the datebase fields in Jira (Created, First, Last, etc.), the time component was removed. For fields defined as options, you must set the option value.
Here’s what the code looks like:
function buildpayload (event) {
const finding = event.detail.findings[0];
reqbody.fields.summary = finding.Title;
reqbody.fields.customfield_10053 = finding.Id;
reqbody.fields.customfield_10324 = finding.AwsAccountId;
reqbody.fields.customfield_10325 = finding.Region;
reqbody.fields.customfield_10326 = finding.Resources[0].Type; //making assumption about the number of Resources
reqbody.fields.customfield_10327 = finding.Resources[0].Id; //making assumption about the number of Resources
reqbody.fields.customfield_10328 = {value:finding.RecordState}; //making assumption about the number of Resources
reqbody.fields.customfield_10334 = {value:finding.Compliance.Status}; //making assumption about the number of Resources
reqbody.fields.customfield_10335 = finding.Compliance.SecurityControlId; //AWS Secuirty control ID
reqbody.fields.customfield_10054 = {value:finding.Severity.Label.charAt(0) + finding.Severity.Label.substr(1).toLowerCase() };
reqbody.fields.customfield_10057 = finding.FirstObservedAt.slice(0,10);
reqbody.fields.customfield_10055 = finding.CreatedAt.slice(0,10);
reqbody.fields.customfield_10051 = finding.LastObservedAt.slice(0,10);
reqbody.fields.description.content[0].content[0].text = finding.Description
reqbody.fields.customfield_10058.content[0].content[0].text = finding.Remediation.Recommendation.Text;
return reqbody
}
The following function operates on the request body:
const reqbody = {
fields: {
summary: "",
issuetype: {
id:"10339"
},
project: {
id: "10012"
},
customfield_10053: "", //external Id
customfield_10054: "", //Severity
customfield_10055: "", //Reported
customfield_10057: "", //First Seen
customfield_10051: "", //Last Seen
customfield_10324: "", //AWS Account Id
customfield_10325: "", //AWS Region
customfield_10326: "", //AWS Resource Type
customfield_10327: "", //ARN
customfield_10328: "", //AWS Record State
customfield_10334: "", //AWS Compliance Status
customfield_10335: "", //AWS Security control Id
description: {
type:"doc",
version:1,
content: [
{
type: "paragraph",
content: [
{
type:"text",
text: "default"
}
]
}
]
},
customfield_10058: { //Recommendation
type:"doc",
version:1,
content: [
{
type: "paragraph",
content: [
{
type:"text",
text:"default"
}
]
}
]
},
labels: [
"securityHub"
]
}
}
Once the issues were updated, they followed the same path as a newly created issue. Looking back at the first choice we had to make, the create issue path was much simpler.
CreateJiraIssue:
Type: Task
Resource: arn:aws:lambda:${aws:region}:${aws:accountId}:function:${self:service}-${opt:stage}-createJiraIssue
ResultPath: "$.jiracreateresult"
Next: SetJiraKeyFromCreate
The following task called a lambda function that was similar to the update process:
module.exports.createJiraIssue = async (event) => {
console.log(JSON.stringify(event))
event.detail.findings.length != 1 && console.error("More that one finding!")
let body = '';
const jiratoken = (await jiratokenpromise).Parameter.Value;
const reqbody = buildpayload(event)
return new Promise((resolve, reject) => {
const options = {
hostname: 'topcoder.atlassian.net',
port: 443,
path: '/rest/api/3/issue',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': 'Basic ' +jiratoken
}
};
const request = https.request(options, response => {
response.on('data', chunk => body+= chunk);
response.on('end', () => {
console.log('get in end function');
console.log(body);
resolve({"statusCode": response.statusCode, "body": JSON.parse(body)});
});
});
request.on('error', (err) => {
console.log('get in error function');
reject(err);
});
console.log(JSON.stringify(reqbody));
request.write(JSON.stringify(reqbody));
request.end();
});
};
The lambda called the same buildpayload function as the update function and operated on the same JSON constant, reqbody.
Once either path of creating an issue or updating an issue had been completed, both paths converged on the same choice, CloseJiraIssueChoice. This state checked the value of the RecordState attribute in the event. If the issue was set to ARCHIVED, then it must be closed. Otherwise, no more operations were required, and the step function moved to the terminal state, NoMoreUpdates.
CloseJiraIssueChoice:
Type: Choice
Choices:
- Variable: "$.detail.findings[0].RecordState"
StringEquals: "ARCHIVED"
Next: CloseJiraIssueReason
Default: NoMoreUpdates
For closed issues, we want to capture the closed reason. For issues that are closed because the out-of-compliance object that created the issue has been deleted, AWS Security Hub sets the compliance status to NOT_AVAILABLE.
This means that it could not evaluate the compliance status because the object is no longer available. For issues that are closed because the object has been remediated and brought into compliance, the compliance status will read FAILED. If the compliance status of a previously failing object now passes, a new finding ID is created for that object, and the existing failed finding ID is moved to the ARCHIVED status.
The choice state evaluates the Compliance.Status value for these two values.
Here’s what that looks like:
CloseJiraIssueReason:
Type: Choice
Choices:
- Variable: "$.detail.findings[0].Compliance.Status"
StringEquals: "NOT_AVAILABLE"
Next: CloseJiraIssueCNR
- Variable: "$.detail.findings[0].Compliance.Status"
StringEquals: "FAILED"
Next: CloseJiraIssueDONE
Default: UnknownComplianceStatus
The two close issue functions are nearly identical, with a transition state being the only difference.
module.exports.closeJiraIssueDONE = async (event) => {
console.log(JSON.stringify(event))
event.detail.findings.length != 1 && console.error("More that one finding!")
let body = '';
const jiratoken = (await jiratokenpromise).Parameter.Value;
const reqbody = {
transition: {
id: 271 // Done
}
}
return new Promise((resolve, reject) => {
const options = {
hostname: 'topcoder.atlassian.net',
port: 443,
path: '/rest/api/3/issue/'+event.jira.issuekey+'/transitions',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': 'Basic ' +jiratoken
}
};
const request = https.request(options, response => {
console.log('statusCode:'+response.statusCode);
response.on('data', chunk => body+= chunk);
response.on('end', () => {
console.log('get in end function');
console.log(body);
resolve({"statusCode": response.statusCode, "body": JSON.parse(body || "null")});
});
});
request.on('error', (err) => {
console.log('get in error function');
reject(err);
});
console.log(JSON.stringify(reqbody));
request.write(JSON.stringify(reqbody));
request.end();
});
};
Below is the payload for the issue. It has transitioned to Cannot Reproduce because of a NOT_AVAILABLE compliance status.
const reqbody = {
transition: {
id: 391 // Cannot Reproduce
}
}
Deployment
The serverless framework was used to deploy this project. The full YAML file is below:
service: sechub-jira
frameworkVersion: '3'
provider:
name: aws
runtime: nodejs16.x
# profile: tcsec
stage: dev
iamRoleStatements:
- Effect: "Allow"
Action:
- "ssm:GetParameter"
Resource:
- "arn:aws:ssm:${aws:region}:${aws:accountId}:parameter/sechub2jira/jiratoken"
functions:
searchForJiraIssue:
handler: handler.searchForJiraIssue
updateJiraIssue:
handler: handler.updateJiraIssue
createJiraIssue:
handler: handler.createJiraIssue
closeJiraIssueCNR:
handler: handler.closeJiraIssueCNR
closeJiraIssueDONE:
handler: handler.closeJiraIssueDONE
stepFunctions:
stateMachines:
sechub2jira:
events:
- eventBridge:
event:
source:
- "aws.securityhub"
detail-type:
- "Security Hub Findings - Imported"
detail:
findings:
Severity:
Label:
- "CRITICAL"
- "HIGH"
Resources:
Type:
- anything-but:
- "AwsEcrContainerImage"
- "AwsLambdaFunction"
loggingConfig:
level: ALL
includeExecutionData: true
destinations:
- Fn::GetAtt: [StepFunctionLogs, Arn]
type: EXPRESS
name: ${self:service}-createOrUpdateIsssue
definition:
Comment: Machine to create issues in Jira for Security Hub findings
StartAt: searchForJiraIssue
States:
searchForJiraIssue:
Type: Task
Comment: State machine gets an event from event bus with a finding ID. This ID is searched for in Jira externalId field
Resource: arn:aws:lambda:${aws:region}:${aws:accountId}:function:${self:service}-${opt:stage}-searchForJiraIssue
ResultPath: "$.jirasearchresult"
Next: CreateOrUpdateJiraIssue
CreateOrUpdateJiraIssue:
Type: Choice
Choices:
- And:
- Variable: "$.jirasearchresult.body.total"
NumericEquals: 0
- Variable: "$.detail.findings[0].Compliance.Status"
StringEquals: "FAILED"
Next: CreateJiraIssue
- Variable: "$.jirasearchresult.body.total"
NumericEquals: 1
Next: SetJiraKeyFromSearch
Default: MoreThanOneJiraIssue
CreateJiraIssue:
Type: Task
Resource: arn:aws:lambda:${aws:region}:${aws:accountId}:function:${self:service}-${opt:stage}-createJiraIssue
ResultPath: "$.jiracreateresult"
Next: SetJiraKeyFromCreate
SetJiraKeyFromCreate:
Type: Pass
Comment: Need to set the jira key for subsequent states after create
Parameters:
issuekey.$: "$.jiracreateresult.body.key"
ResultPath: "$.jira"
Next: CloseJiraIssueChoice
SetJiraKeyFromSearch:
Type: Pass
Comment: Need to set the jira key for subsequent states
Parameters:
issuekey.$: "$.jirasearchresult.body.issues[0].key"
ResultPath: "$.jira"
Next: TransitionOrUpdateJiraIssue
TransitionOrUpdateJiraIssue:
Type: Choice
Choices:
- Variable: "$.jirasearchresult.body.issues[0].fields.status.name"
StringEquals: Done
Next: TransitionJiraIssue
Default: UpdateJiraIssue
TransitionJiraIssue:
Type: Task
Resource: arn:aws:lambda:${aws:region}:${aws:accountId}:function:${self:service}-${opt:stage}-transitionJiraIssue
ResultPath:
Next: UpdateJiraIssue
UpdateJiraIssue:
Type: Task
Resource: arn:aws:lambda:${aws:region}:${aws:accountId}:function:${self:service}-${opt:stage}-updateJiraIssue
ResultPath: #pass the input to the next stage
Next: CloseJiraIssueChoice
CloseJiraIssueChoice:
Type: Choice
Choices:
- Variable: "$.detail.findings[0].RecordState"
StringEquals: "ARCHIVED"
Next: CloseJiraIssueReason
Default: NoMoreUpdates
CloseJiraIssueReason:
Type: Choice
Choices:
- Variable: "$.detail.findings[0].Compliance.Status"
StringEquals: "NOT_AVAILABLE"
Next: CloseJiraIssueCNR
- Variable: "$.detail.findings[0].Compliance.Status"
StringEquals: "FAILED"
Next: CloseJiraIssueDONE
Default: UnknownComplianceStatus
CloseJiraIssueDONE:
Type: Task
Resource: arn:aws:lambda:${aws:region}:${aws:accountId}:function:${self:service}-${opt:stage}-closeJiraIssueDONE
End: true
CloseJiraIssueCNR:
Type: Task
Resource: arn:aws:lambda:${aws:region}:${aws:accountId}:function:${self:service}-${opt:stage}-closeJiraIssueCNR
End: true
NoMoreUpdates:
Type: Pass
End: true
MoreThanOneJiraIssue:
Type: Fail
Cause: More than one issue was returned in the search for this id
Error: Duplicate issues in Jira
UnknownComplianceStatus:
Type: Fail
Cause: Unknow compliance staus. Update choice CloseJiraIssueReason to account for this
Error: Unknown compliance stuatus
resources:
Resources:
StepFunctionLogs:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: /aws/stepfunction/${self:service}-logs
plugins:
- serverless-step-functions
Some prerequisites are required, like the step function plugin. Once those are met, you can deploy this to the AWS Security Hub administrator account.
Next Steps
- Remediation is the result of bringing something into compliance. We want to avoid remediation and focus on prevention. Where possible, we will implement AWS Service Control Policies to prevent actions that will trigger findings.
This type of preventative control is preferred but not always possible based on the operation that causes non-compliance. Where SCP can’t be used, we’ve implemented AWS Lambda functions to block or remove the offending configurations. - Finding IDs and compliance status did not behave how I expected. I haven’t found clear documentation on this, but I observed this:
When an item is found non-compliant, a finding with a unique ID is created with a Compliance.Status of FAILED.
I expected that once the item was remediated (assuming it wasn’t deleted), the compliance status for that finding would change to PASSED. This was not the case.
For compliance tests, there is a separate finding ID for items that passed vs items that failed. The finding ID does not go through the full lifecycle of finding status. You can see this in Security Hub if you look for failed items and then remediate the item. After the compliance check, you will see that a new item is created with a new ID. The failed item remains after three to five days and is then set to ARCHIVED.
A finding that has a RecordState set to ARCHIVED. Archiving a finding indicates that the finding provider believes that the finding is no longer relevant. The record state is separate from the workflow status, which tracks the status of an investigation into a finding.
Finding providers can use the BatchImportFindings operation of the Security Hub API to archive findings that they created. Security Hub automatically archives findings for controls if the control is disabled or the associated resource is deleted, based on one of the following criteria.
* The finding is not updated in three to five days (note that this is best effort and not guaranteed).
* The associated AWS Config evaluation returns NOT_APPLICABLE.
By default, archived findings are excluded from findings lists in the Security Hub console. You can update the filter to include archived findings.
This solution only creates issues for AWS Security Hub compliance findings. I’ll be updating this to create issues for AWS Inspector as well.
Integrating AWS Security Hub With Jira Cloud Using Step Functions was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.