@enterprise-cmcs/macpro-security-hub-sync
Version:
NPM module to create Jira issues for all findings in Security Hub for the current AWS account..
345 lines (327 loc) • 14.5 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.SecurityHubJiraSync = void 0;
const libs_1 = require("./libs");
const client_sts_1 = require("@aws-sdk/client-sts");
class SecurityHubJiraSync {
jira;
securityHub;
customJiraFields;
region;
epicKey;
constructor(options = {}) {
const { region = "us-east-1", severities = ["MEDIUM", "HIGH", "CRITICAL"], customJiraFields = {}, } = options;
this.securityHub = new libs_1.SecurityHub({ region, severities });
this.region = region;
this.jira = new libs_1.Jira();
this.customJiraFields = customJiraFields;
this.epicKey = options.epicKey;
}
async sync() {
const updatesForReturn = [];
// Step 0. Gather and set some information that will be used throughout this function
const accountId = await this.getAWSAccountID();
const identifyingLabels = [accountId, this.region];
// Step 1. Get all open Security Hub issues from Jira
const jiraIssues = await this.jira.getAllSecurityHubIssuesInJiraProject(identifyingLabels);
// Step 2. Get all current findings from Security Hub
const shFindingsObj = await this.securityHub.getAllActiveFindings();
const shFindings = Object.values(shFindingsObj);
console.log(shFindings);
// Step 3. Close existing Jira issues if their finding is no longer active/current
updatesForReturn.push(...(await this.closeIssuesForResolvedFindings(jiraIssues, shFindings)));
// Step 4. Create Jira issue for current findings that do not already have a Jira issue
updatesForReturn.push(...(await this.createJiraIssuesForNewFindings(jiraIssues, shFindings, identifyingLabels)));
console.log(JSON.stringify(updatesForReturn));
}
async getAWSAccountID() {
const client = new client_sts_1.STSClient({
region: this.region,
});
const command = new client_sts_1.GetCallerIdentityCommand({});
let response;
try {
response = await client.send(command);
}
catch (e) {
throw new Error(`Error getting AWS Account ID: ${e.message}`);
}
let accountID = response.Account || "";
if (!accountID.match("[0-9]{12}")) {
throw new Error("ERROR: An issue was encountered when looking up your AWS Account ID. Refusing to continue.");
}
return accountID;
}
async closeIssuesForResolvedFindings(jiraIssues, shFindings) {
const updatesForReturn = [];
const expectedJiraIssueTitles = Array.from(new Set(shFindings.map((finding) => `SecurityHub Finding - ${finding.title}`)));
try {
const makeComment = () => `As of ${new Date(Date.now()).toDateString()}, this Security Hub finding has been marked resolved`;
// close all security-hub labeled Jira issues that do not have an active finding
if (process.env.AUTO_CLOSE !== "false") {
for (var i = 0; i < jiraIssues.length; i++) {
if (!expectedJiraIssueTitles.includes(jiraIssues[i].fields.summary)) {
await this.jira.closeIssue(jiraIssues[i].key);
updatesForReturn.push({
action: "closed",
webUrl: `https://${process.env.JIRA_HOST}/browse/${jiraIssues[i].key}`,
summary: jiraIssues[i].fields.summary,
});
const comment = await this.jira.addCommentToIssueById(jiraIssues[i].id, makeComment());
}
}
}
else {
console.log("Skipping auto closing...");
for (var i = 0; i < jiraIssues.length; i++) {
if (!expectedJiraIssueTitles.includes(jiraIssues[i].fields.summary) &&
!jiraIssues[i].fields.summary.includes("Resolved") // skip already resolved issues
) {
try {
const res = await this.jira.updateIssueTitleById(jiraIssues[i].id, {
fields: {
summary: `Resolved ${jiraIssues[i].fields.summary}`,
},
});
const comment = await this.jira.addCommentToIssueById(jiraIssues[i].id, makeComment());
}
catch (e) {
console.log(`Title of ISSUE with id ${jiraIssues[i].id} is not changed with error: ${JSON.stringify(e)}`);
}
}
}
}
}
catch (e) {
throw new Error(`Error closing Jira issue for resolved finding: ${e.message}`);
}
return updatesForReturn;
}
makeResourceList(resources) {
if (!resources) {
return `No Resources`;
}
const maxLength = Math.max(...resources.map(({ Id }) => Id?.length || 0));
const title = "Resource Id".padEnd(maxLength + maxLength / 2 + 4);
let Table = `${title}| Partition | Region | Type \n`;
resources.forEach(({ Id, Partition, Region, Type }) => {
Table += `${Id.padEnd(maxLength + 2)}| ${(Partition ?? "").padEnd(11)} | ${(Region ?? "").padEnd(9)} | ${Type ?? ""} \n`;
});
Table += `------------------------------------------------------------------------------------------------`;
return Table;
}
createSecurityHubFindingUrlThroughFilters(findingId) {
let region, accountId;
if (findingId.startsWith("arn:")) {
// Extract region and account ID from the ARN
const arnParts = findingId.split(":");
if (arnParts.length >= 5) {
region = arnParts[3];
accountId = arnParts[4];
}
else {
return "Invalid URL";
}
}
else {
// Extract region and account ID from the non-ARN format
const parts = findingId.split("/");
if (parts.length >= 3) {
region = parts[1];
accountId = parts[2];
}
else {
return "Invalid URL";
}
}
const baseUrl = `https://${region}.console.aws.amazon.com/securityhub/home?region=${region}`;
const searchParam = `Id%3D%255Coperator%255C%253AEQUALS%255C%253A${findingId}`;
const url = `${baseUrl}#/findings?search=${searchParam}`;
return url;
}
createIssueBody(finding) {
const { remediation: { Recommendation: { Url: remediationUrl = "", Text: remediationText = "", } = {}, } = {}, id = "", title = "", description = "", accountAlias = "", awsAccountId = "", severity = "", standardsControlArn = "", } = finding;
return `----
*This issue was generated from Security Hub data and is managed through automation.*
Please do not edit the title or body of this issue, or remove the security-hub tag. All other edits/comments are welcome.
Finding Title: ${title}
----
h2. Type of Issue:
* Security Hub Finding
h2. Title:
${title}
h2. Description:
${description}
${remediationText || remediationUrl
? `h2. Remediation:
${remediationUrl}
${remediationText}`
: ""}
h2. AWS Account:
${awsAccountId} (${accountAlias})
h2. Severity:
${severity}
h2. SecurityHubFindingUrl:
${standardsControlArn
? this.createSecurityHubFindingUrl(standardsControlArn)
: this.createSecurityHubFindingUrlThroughFilters(id)}
h2. Resources:
Following are the resources those were non-compliant at the time of the issue creation
${this.makeResourceList(finding.Resources)}
To check the latest list of resources, kindly refer to the finding url
h2. AC:
* All findings of this type are resolved or suppressed, indicated by a Workflow Status of Resolved or Suppressed. (Note: this ticket will automatically close when the AC is met.)`;
}
createSecurityHubFindingUrl(standardsControlArn = "") {
if (!standardsControlArn) {
return "";
}
const [, partition, , region, , , securityStandards, , securityStandardsVersion, controlId,] = standardsControlArn.split(/[/:]+/);
return `https://${region}.console.${partition}.amazon.com/securityhub/home?region=${region}#/standards/${securityStandards}-${securityStandardsVersion}/${controlId}`;
}
getSeverityMapping = (severity) => {
switch (severity) {
case "INFORMATIONAL":
return "5";
case "LOW":
return "4";
case "MEDIUM":
return "3";
case "HIGH":
return "2";
case "CRITICAL":
return "1";
default:
throw new Error(`Invalid severity: ${severity}`);
}
};
getPriorityId = (severity, priorities) => {
const severityLevel = parseInt(this.getSeverityMapping(severity));
if (severityLevel >= priorities.length) {
return priorities[priorities.length - 1];
}
return priorities[severityLevel - 1];
};
getPriorityNumber = (severity, isEnterprise = false) => {
if (isEnterprise) {
return severity.charAt(0).toUpperCase() + severity.slice(1).toLowerCase();
}
switch (severity) {
case "INFORMATIONAL":
return "5";
case "LOW":
return "4";
case "MEDIUM":
return "3";
case "HIGH":
return "2";
case "CRITICAL":
return "1";
default:
throw new Error(`Invalid severity: ${severity}`);
}
};
createLabels(finding, identifyingLabels, config) {
const labels = [];
const fields = ["accountId", "region", "identify"];
const values = [...identifyingLabels, "security-hub"];
config.forEach(({ labelField: field, labelDelimiter: delim, labelPrefix: prefix }) => {
const delimiter = delim ?? "";
const labelPrefix = prefix ?? "";
if (fields.includes(field)) {
const index = fields.indexOf(field);
if (index >= 0) {
labels.push(`${labelPrefix}${delimiter}${values[index]
?.trim()
.replace(/ /g, "")}`);
}
}
else {
const value = (finding[field] ?? "")
.toString()
.trim()
.replace(/ /g, "");
labels.push(`${labelPrefix}${delimiter}${value}`);
}
});
return labels;
}
async createJiraIssueFromFinding(finding, identifyingLabels) {
const priorities = await this.jira.getPriorityIdsInDescendingOrder();
console.log(priorities);
const newIssueData = {
fields: {
summary: `SecurityHub Finding - ${finding.title}`.substring(0, 255),
description: this.createIssueBody(finding),
issuetype: { name: "Task" },
labels: [
"security-hub",
finding.severity,
finding.accountAlias,
finding.ProductName?.trim().replace(/ /g, ""),
...identifyingLabels,
],
priority: {
id: finding.severity
? this.getPriorityId(finding.severity, priorities)
: "3", // if severity is not specified, set 3 which is the middle of the default options.
},
...this.customJiraFields,
},
};
if (process.env.LABELS_CONFIG) {
try {
const config = JSON.parse(process.env.LABELS_CONFIG);
newIssueData.fields.labels = this.createLabels(finding, identifyingLabels, config);
}
catch (e) {
console.log("Invalid labels config - going with default labels");
}
}
if (finding.severity && process.env.JIRA_HOST?.includes("jiraent")) {
newIssueData.fields.priority = {
name: this.getPriorityNumber(finding.severity, true),
};
}
if (this.epicKey) {
newIssueData.fields.parent = { key: this.epicKey };
}
let newIssueInfo;
try {
newIssueInfo = await this.jira.createNewIssue(newIssueData);
const issue_id = process.env.JIRA_LINK_ID ?? "";
if (issue_id) {
let linkType = "Relates";
if (process.env.JIRA_LINK_TYPE) {
linkType = process.env.JIRA_LINK_TYPE;
}
const linkDirection = process.env.JIRA_LINK_DIRECTION ?? "inward";
await this.jira.linkIssues(newIssueInfo.key, issue_id, linkType, linkDirection);
}
}
catch (e) {
throw new Error(`Error creating Jira issue from finding: ${e.message}`);
}
return {
action: "created",
webUrl: newIssueInfo.webUrl,
summary: newIssueData.fields.summary,
};
}
async createJiraIssuesForNewFindings(jiraIssues, shFindings, identifyingLabels) {
const updatesForReturn = [];
const existingJiraIssueTitles = jiraIssues.map((i) => i.fields.summary);
const uniqueSecurityHubFindings = [
...new Set(shFindings.map((finding) => JSON.stringify(finding))),
].map((finding) => JSON.parse(finding));
for (let i = 0; i < uniqueSecurityHubFindings.length; i++) {
const finding = uniqueSecurityHubFindings[i];
if (!existingJiraIssueTitles.includes(`SecurityHub Finding - ${finding.title}`)) {
const update = await this.createJiraIssueFromFinding(finding, identifyingLabels);
updatesForReturn.push(update);
}
}
return updatesForReturn;
}
}
exports.SecurityHubJiraSync = SecurityHubJiraSync;