From 12c043148658a5632df1c0721c69bcfdd797a3bd Mon Sep 17 00:00:00 2001 From: Andrew Cremins Date: Thu, 7 Mar 2024 07:17:01 -0800 Subject: [PATCH] Add dist because it is needed for the gha to run --- .gitignore | 1 - dist/index.d.ts | 1 + dist/index.js | 86 +++++++++ dist/libs/index.d.ts | 2 + dist/libs/index.js | 18 ++ dist/libs/jira-lib.d.ts | 25 +++ dist/libs/jira-lib.js | 305 ++++++++++++++++++++++++++++++ dist/libs/security-hub-lib.d.ts | 32 ++++ dist/libs/security-hub-lib.js | 97 ++++++++++ dist/macfc-security-hub-sync.d.ts | 39 ++++ dist/macfc-security-hub-sync.js | 251 ++++++++++++++++++++++++ dist/run-sync.d.ts | 1 + dist/run-sync.js | 11 ++ 13 files changed, 868 insertions(+), 1 deletion(-) create mode 100644 dist/index.d.ts create mode 100644 dist/index.js create mode 100644 dist/libs/index.d.ts create mode 100644 dist/libs/index.js create mode 100644 dist/libs/jira-lib.d.ts create mode 100644 dist/libs/jira-lib.js create mode 100644 dist/libs/security-hub-lib.d.ts create mode 100644 dist/libs/security-hub-lib.js create mode 100644 dist/macfc-security-hub-sync.d.ts create mode 100644 dist/macfc-security-hub-sync.js create mode 100644 dist/run-sync.d.ts create mode 100644 dist/run-sync.js diff --git a/.gitignore b/.gitignore index 081bff6..ba9c3c1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -dist node_modules .secrets .env diff --git a/dist/index.d.ts b/dist/index.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/index.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..48bb6f0 --- /dev/null +++ b/dist/index.js @@ -0,0 +1,86 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const core = __importStar(require("@actions/core")); +const macfc_security_hub_sync_1 = require("./macfc-security-hub-sync"); +// Utility function to get input with fallback to environment variable +function getInputOrEnv(inputName, envName) { + const inputValue = core.getInput(inputName); + if (inputValue !== '') { + process.env[envName] = inputValue; + return; + } +} +function validateAndFilterSeverities(inputSeverities) { + if (!inputSeverities) { + return undefined; // Potentially update with default severities + } + const allowedSeverities = ["INFORMATIONAL", "LOW", "MEDIUM", "HIGH", "CRITICAL"]; + const inputSeveritiesArray = inputSeverities.split(',') + .map(severity => severity.trim().toUpperCase()) + .filter(severity => severity); + // Check each severity in the array against the allowed severities + inputSeveritiesArray.forEach(severity => { + if (!allowedSeverities.includes(severity)) { + throw new Error(`Invalid severity level detected: '${severity}'. Allowed severities are: ${allowedSeverities.join(', ')}.`); + } + }); + return inputSeveritiesArray; +} +async function run() { + try { + getInputOrEnv('jira-base-uri', 'JIRA_BASE_URI'); + getInputOrEnv('jira-host', 'JIRA_HOST'); + getInputOrEnv('jira-username', 'JIRA_USERNAME'); + getInputOrEnv('jira-token', 'JIRA_TOKEN'); + getInputOrEnv('jira-project-key', 'JIRA_PROJECT'); + getInputOrEnv('jira-ignore-statuses', 'JIRA_CLOSED_STATUSES'); + getInputOrEnv('auto-close', 'AUTO_CLOSE'); + getInputOrEnv('assign-jira-ticket-to', 'ASSIGNEE'); + getInputOrEnv('aws-region', 'AWS_REGION'); + getInputOrEnv('aws-severities', 'AWS_SEVERITIES'); + let customJiraFields; + getInputOrEnv('jira-custom-fields', 'JIRA_CUSTOM_FIELDS'); + if (process.env.JIRA_CUSTOM_FIELDS) { + try { + customJiraFields = JSON.parse(process.env.JIRA_CUSTOM_FIELDS); + } + catch (e) { + throw new Error(`Error parsing JSON string for jira-custom-fields input: ${e}`); + } + } + core.info('Syncing Security Hub and Jira'); + await new macfc_security_hub_sync_1.SecurityHubJiraSync({ + region: process.env.AWS_REGION, + severities: validateAndFilterSeverities(process.env.AWS_SEVERITIES), + epicKey: process.env.JIRA_EPIC_KEY, + customJiraFields + }).sync(); + } + catch (e) { + core.setFailed(`Sync failed: ${e}`); + } +} +run(); diff --git a/dist/libs/index.d.ts b/dist/libs/index.d.ts new file mode 100644 index 0000000..82a47b9 --- /dev/null +++ b/dist/libs/index.d.ts @@ -0,0 +1,2 @@ +export * from "./jira-lib"; +export * from "./security-hub-lib"; diff --git a/dist/libs/index.js b/dist/libs/index.js new file mode 100644 index 0000000..667464c --- /dev/null +++ b/dist/libs/index.js @@ -0,0 +1,18 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __exportStar = (this && this.__exportStar) || function(m, exports) { + for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p); +}; +Object.defineProperty(exports, "__esModule", { value: true }); +__exportStar(require("./jira-lib"), exports); +__exportStar(require("./security-hub-lib"), exports); diff --git a/dist/libs/jira-lib.d.ts b/dist/libs/jira-lib.d.ts new file mode 100644 index 0000000..8e14e10 --- /dev/null +++ b/dist/libs/jira-lib.d.ts @@ -0,0 +1,25 @@ +export interface Issue { + [name: string]: any; +} +export declare class Jira { + private isDryRun; + private dryRunIssueCounter; + private axiosInstance; + jiraClosedStatuses: string[]; + constructor(); + getCurrentUser(): Promise; + getIssueTransitions(issueId: string): Promise; + transitionIssue(issueId: string, transitionData: any): Promise; + getPriorities(): Promise; + removeCurrentUserAsWatcher(issueId: string): Promise; + private static checkEnvVars; + private static formatLabelQuery; + getAllSecurityHubIssuesInJiraProject(identifyingLabels: string[]): Promise; + getPriorityIdsInDescendingOrder(): Promise; + createNewIssue(issue: Issue): Promise; + updateIssueTitleById(issueId: string, updatedIssue: Partial): Promise; + addCommentToIssueById(issueId: string, comment: string): Promise; + findPathToClosure(transitions: any, currentStatus: string): Promise; + completeWorkflow(issueId: string): Promise; + closeIssue(issueId: string): Promise; +} diff --git a/dist/libs/jira-lib.js b/dist/libs/jira-lib.js new file mode 100644 index 0000000..1fe06a5 --- /dev/null +++ b/dist/libs/jira-lib.js @@ -0,0 +1,305 @@ +"use strict"; +var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + var desc = Object.getOwnPropertyDescriptor(m, k); + if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { + desc = { enumerable: true, get: function() { return m[k]; } }; + } + Object.defineProperty(o, k2, desc); +}) : (function(o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; +})); +var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); +}) : function(o, v) { + o["default"] = v; +}); +var __importStar = (this && this.__importStar) || function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; +}; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.Jira = void 0; +const dotenv = __importStar(require("dotenv")); +const axios_1 = __importDefault(require("axios")); +dotenv.config(); +class Jira { + isDryRun; + dryRunIssueCounter = 0; + axiosInstance; + jiraClosedStatuses; + constructor() { + Jira.checkEnvVars(); + // Interpret DRY_RUN environment variable flexibly + this.isDryRun = process.env.DRY_RUN?.trim().toLowerCase() === 'true'; + this.axiosInstance = axios_1.default.create({ + baseURL: process.env.JIRA_BASE_URI, + headers: { + "Authorization": `Bearer ${process.env.JIRA_TOKEN}`, + "Content-Type": "application/json", + }, + }); + this.jiraClosedStatuses = process.env.JIRA_CLOSED_STATUSES + ? process.env.JIRA_CLOSED_STATUSES.split(",").map((status) => status.trim()) + : ["Done"]; + } + async getCurrentUser() { + try { + const response = await this.axiosInstance.get('/rest/api/2/myself'); + return response.data; + } + catch (error) { + throw new Error(`Error fetching current user: ${error}`); + } + } + async getIssueTransitions(issueId) { + try { + const response = await this.axiosInstance.get(`/rest/api/2/issue/${issueId}/transitions`); + return response.data.transitions; + } + catch (error) { + throw new Error(`Error fetching issue transitions: ${error}`); + } + } + async transitionIssue(issueId, transitionData) { + if (this.isDryRun) { + console.log(`[Dry Run] Would transition issue ${issueId} with data:`, transitionData); + return; + } + try { + await this.axiosInstance.post(`/rest/api/2/issue/${issueId}/transitions`, transitionData); + console.log(`Issue ${issueId} transitioned successfully.`); + } + catch (error) { + throw new Error(`Error transitioning issue ${issueId}: ${error}`); + } + } + async getPriorities() { + try { + const response = await this.axiosInstance.get('/rest/api/2/priority'); + return response.data; + } + catch (error) { + throw new Error(`Error fetching priorities: ${error}`); + } + } + async removeCurrentUserAsWatcher(issueId) { + try { + const currentUser = await this.getCurrentUser(); + console.log("Remove watcher: " + currentUser.name); + if (this.isDryRun) { + console.log(`[Dry Run] Would remove ${currentUser.name} from ${issueId} as watcher.`); + return; // Skip the actual API call + } + await this.axiosInstance.delete(`/rest/api/2/issue/${issueId}/watchers`, { + params: { + username: currentUser.name, + }, + }); + } + catch (error) { + throw new Error(`Error creating issue or removing watcher: ${error}`); + } + } + static checkEnvVars() { + const requiredEnvVars = [ + "JIRA_HOST", + "JIRA_USERNAME", + "JIRA_TOKEN", + "JIRA_PROJECT", + ]; + const missingEnvVars = requiredEnvVars.filter((envVar) => !process.env[envVar]); + if (missingEnvVars.length) { + throw new Error(`Missing required environment variables: ${missingEnvVars.join(", ")}`); + } + } + static formatLabelQuery(label) { + return `labels = '${label}'`; + } + async getAllSecurityHubIssuesInJiraProject(identifyingLabels) { + const labelQueries = [...identifyingLabels, "security-hub"].map((label) => Jira.formatLabelQuery(label)); + const projectQuery = `project = '${process.env.JIRA_PROJECT}'`; + const statusQuery = `status not in ('${this.jiraClosedStatuses.join("','" // wrap each closed status in single quotes + )}')`; + const fullQuery = [...labelQueries, projectQuery, statusQuery].join(" AND "); + // We want to do everything possible to prevent matching tickets that we shouldn't + if (!fullQuery.includes(Jira.formatLabelQuery("security-hub"))) { + throw new Error("ERROR: Your query does not include the 'security-hub' label, and is too broad. Refusing to continue"); + } + if (!fullQuery.match(Jira.formatLabelQuery("[0-9]{12}"))) { + throw new Error("ERROR: Your query does not include an AWS Account ID as a label, and is too broad. Refusing to continue"); + } + console.log(fullQuery); + let totalIssuesReceived = 0; + let allIssues = []; + let startAt = 0; + let total = 0; + do { + try { + const response = await this.axiosInstance.post('/rest/api/2/search', { + jql: fullQuery, + startAt: startAt, + maxResults: 50, + fields: ["*all"] + }); + const results = response.data; + allIssues = allIssues.concat(results.issues); + totalIssuesReceived += results.issues.length; + startAt = totalIssuesReceived; + total = results.total; + } + catch (error) { + throw new Error(`Error getting Security Hub issues from Jira: ${error}`); + } + } while (totalIssuesReceived < total); + return allIssues; + } + async getPriorityIdsInDescendingOrder() { + try { + const priorities = await this.getPriorities(); + // Get priority IDs in descending order + const descendingPriorityIds = priorities.map((priority) => priority.id); + return descendingPriorityIds; + } + catch (error) { + throw new Error(`Error fetching priority IDs: ${error}`); + } + } + async createNewIssue(issue) { + try { + const assignee = process.env.ASSIGNEE ?? ""; + if (assignee) { + issue.fields.assignee = { name: assignee }; + } + issue.fields.project = { key: process.env.JIRA_PROJECT }; + if (this.isDryRun) { + console.log(`[Dry Run] Would create a new issue with the following details:`, issue); + // Return a dry run issue object + this.dryRunIssueCounter++; + const dryRunIssue = { + id: `dryrun-id-${this.dryRunIssueCounter}`, + key: `DRYRUN-KEY-${this.dryRunIssueCounter}`, + fields: { + summary: issue.fields.summary || `Dry Run Summary ${this.dryRunIssueCounter}`, + }, + webUrl: `${process.env.JIRA_BASE_URI}/browse/DRYRUN-KEY-${this.dryRunIssueCounter}`, + }; + return dryRunIssue; // Return a dummy issue + } + const response = await this.axiosInstance.post('/rest/api/2/issue', issue); + const newIssue = response.data; + // Construct the webUrl for the new issue + newIssue["webUrl"] = `${process.env.JIRA_BASE_URI}/browse/${newIssue.key}`; + await this.removeCurrentUserAsWatcher(newIssue.key); + return newIssue; + } + catch (error) { + throw new Error(`Error creating Jira issue: ${error}`); + } + } + async updateIssueTitleById(issueId, updatedIssue) { + if (this.isDryRun) { + console.log(`[Dry Run] Would update issue title for issue ${issueId} with:`, updatedIssue); + return; + } + try { + const response = await this.axiosInstance.put(`/rest/api/2/issue/${issueId}`, updatedIssue); + console.log("Issue title updated successfully:", response.data); + } + catch (error) { + throw new Error(`Error updating issue title: ${error}`); + } + } + async addCommentToIssueById(issueId, comment) { + if (this.isDryRun) { + console.log(`[Dry Run] Would add comment to issue ${issueId}:`, comment); + return; + } + try { + await this.axiosInstance.post(`/rest/api/2/issue/${issueId}/comment`, { body: comment }); + await this.removeCurrentUserAsWatcher(issueId); // Commenting on the issue adds the user as a watcher, so we remove them + } + catch (error) { + throw new Error(`Error adding comment to issue: ${error}`); + } + } + async findPathToClosure(transitions, currentStatus) { + const visited = new Set(); + const queue = [ + { path: [], status: currentStatus }, + ]; + while (queue.length > 0) { + const { path, status } = queue.shift(); + visited.add(status); + const possibleTransitions = transitions.filter((transition) => transition.from.name === status); + for (const transition of possibleTransitions) { + const newPath = [...path, transition.id]; + const newStatus = transition.to.name; + if (newStatus.toLowerCase().includes("close") || + newStatus.toLowerCase().includes("done")) { + return newPath; // Found a path to closure + } + if (!visited.has(newStatus)) { + queue.push({ path: newPath, status: newStatus }); + } + } + } + return []; // No valid path to closure found + } + async completeWorkflow(issueId) { + const opposedStatuses = ["canceled", "backout", "rejected"]; + try { + do { + const availableTransitions = await this.getIssueTransitions(issueId); + const processedTransitions = []; + console.log(availableTransitions); + if (availableTransitions.length > 0) { + const targetTransitions = availableTransitions.transitions.filter((transition) => !opposedStatuses.includes(transition.name.toLowerCase()) && + !processedTransitions.includes(transition.name.toLowerCase())); + const transitionId = targetTransitions[0].id; + processedTransitions.push(targetTransitions[0].name); + await this.transitionIssue(issueId, { + transition: { id: transitionId }, + }); + console.log(`Transitioned issue ${issueId} to the next step.`); + } + else { + break; + } + } while (true); + } + catch (error) { + throw new Error(`Error completing the workflow: ${error}`); + } + } + async closeIssue(issueId) { + if (this.isDryRun) { + console.log(`[Dry Run] Would close issue ${issueId}`); + return; + } + if (!issueId) + return; + try { + const transitions = await this.getIssueTransitions(issueId); + const doneTransition = transitions.find((t) => t.name === "Done"); + if (!doneTransition) { + this.completeWorkflow(issueId); + return; + } + await this.transitionIssue(issueId, { + transition: { id: doneTransition.id }, + }); + } + catch (error) { + throw new Error(`Error closing issue ${issueId}: ${error}`); + } + } +} +exports.Jira = Jira; diff --git a/dist/libs/security-hub-lib.d.ts b/dist/libs/security-hub-lib.d.ts new file mode 100644 index 0000000..744b8fb --- /dev/null +++ b/dist/libs/security-hub-lib.d.ts @@ -0,0 +1,32 @@ +import { Remediation, AwsSecurityFinding } from "@aws-sdk/client-securityhub"; +export interface SecurityHubFinding { + title?: string; + region?: string; + accountAlias?: string; + awsAccountId?: string; + severity?: string; + description?: string; + standardsControlArn?: string; + remediation?: Remediation; +} +export declare class SecurityHub { + private readonly region; + private readonly severityLabels; + private accountAlias; + constructor({ region, severities, }?: { + region?: string | undefined; + severities?: string[] | undefined; + }); + private getAccountAlias; + getAllActiveFindings(): Promise<{ + title?: string | undefined; + region?: string | undefined; + accountAlias: string; + awsAccountId?: string | undefined; + severity?: string | undefined; + description?: string | undefined; + standardsControlArn?: string | undefined; + remediation?: Remediation | undefined; + }[]>; + awsSecurityFindingToSecurityHubFinding(finding: AwsSecurityFinding): SecurityHubFinding; +} diff --git a/dist/libs/security-hub-lib.js b/dist/libs/security-hub-lib.js new file mode 100644 index 0000000..7b82546 --- /dev/null +++ b/dist/libs/security-hub-lib.js @@ -0,0 +1,97 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SecurityHub = void 0; +const client_iam_1 = require("@aws-sdk/client-iam"); +const client_securityhub_1 = require("@aws-sdk/client-securityhub"); +class SecurityHub { + region; + severityLabels; + accountAlias = ""; + constructor({ region = "us-east-1", severities = ["HIGH", "CRITICAL"], } = {}) { + this.region = region; + this.severityLabels = severities.map((severity) => ({ + Comparison: "EQUALS", + Value: severity, + })); + this.getAccountAlias().catch((error) => console.error(error)); + } + async getAccountAlias() { + const iamClient = new client_iam_1.IAMClient({ region: this.region }); + const response = await iamClient.send(new client_iam_1.ListAccountAliasesCommand({})); + this.accountAlias = response.AccountAliases?.[0] || ""; + } + async getAllActiveFindings() { + try { + const securityHubClient = new client_securityhub_1.SecurityHubClient({ region: this.region }); + const currentTime = new Date(); + // delay for filtering out ephemeral issues + const delayForNewIssues = +(process.env.SECURITY_HUB_NEW_ISSUE_DELAY ?? "86400000"); // 24 * 60 * 60 * 1000 + const maxDatetime = new Date(currentTime.getTime() - delayForNewIssues); + const filters = { + RecordState: [{ Comparison: "EQUALS", Value: "ACTIVE" }], + WorkflowStatus: [ + { Comparison: "EQUALS", Value: "NEW" }, + { Comparison: "EQUALS", Value: "NOTIFIED" }, + ], + ProductName: [{ Comparison: "EQUALS", Value: "Security Hub" }], + SeverityLabel: this.severityLabels, + CreatedAt: [ + { + Start: "1970-01-01T00:00:00Z", + End: maxDatetime.toISOString(), + }, + ], + }; + // use an object to store unique findings by title + const uniqueFindings = {}; + // use a variable to track pagination + let nextToken = undefined; + do { + const response = await securityHubClient.send(new client_securityhub_1.GetFindingsCommand({ + Filters: filters, + MaxResults: 100, // this is the maximum allowed per page + NextToken: nextToken, + })); + if (response && response.Findings) { + for (const finding of response.Findings) { + const findingForJira = this.awsSecurityFindingToSecurityHubFinding(finding); + if (findingForJira.title) + uniqueFindings[findingForJira.title] = findingForJira; + } + } + if (response && response.NextToken) + nextToken = response.NextToken; + else + nextToken = undefined; + } while (nextToken); + return Object.values(uniqueFindings).map((finding) => { + return { + accountAlias: this.accountAlias, + ...finding, + }; + }); + } + catch (e) { + throw new Error(`Error getting Security Hub findings: ${e.message}`); + } + } + awsSecurityFindingToSecurityHubFinding(finding) { + if (!finding) + return {}; + return { + title: finding.Title, + region: finding.Region, + accountAlias: this.accountAlias, + awsAccountId: finding.AwsAccountId, + severity: finding.Severity && finding.Severity.Label + ? finding.Severity.Label + : "", + description: finding.Description, + standardsControlArn: finding.ProductFields && finding.ProductFields.StandardsControlArn + ? finding.ProductFields.StandardsControlArn + : "", + remediation: finding.Remediation, + }; + } +} +exports.SecurityHub = SecurityHub; diff --git a/dist/macfc-security-hub-sync.d.ts b/dist/macfc-security-hub-sync.d.ts new file mode 100644 index 0000000..2b13d1e --- /dev/null +++ b/dist/macfc-security-hub-sync.d.ts @@ -0,0 +1,39 @@ +import { SecurityHubFinding } from "./libs"; +import { Issue } from "./libs/jira-lib"; +interface SecurityHubJiraSyncOptions { + region?: string; + severities?: string[]; + customJiraFields?: { + [id: string]: any; + }; + epicKey?: string; +} +interface UpdateForReturn { + action: string; + webUrl: string; + summary: string; +} +export declare class SecurityHubJiraSync { + private readonly jira; + private readonly securityHub; + private readonly customJiraFields; + private readonly region; + private readonly severities; + private readonly epicKey; + constructor(options?: SecurityHubJiraSyncOptions); + sync(): Promise; + getAWSAccountID(): Promise; + closeIssuesForResolvedFindings(jiraIssues: Issue[], shFindings: SecurityHubFinding[]): Promise; + createIssueBody(finding: SecurityHubFinding): string; + createSecurityHubFindingUrl(standardsControlArn?: string): string; + getSeverityMapping: (severity: string) => "3" | "5" | "4" | "2" | "1"; + getPriorityId: (severity: string, priorities: any[]) => any; + getPriorityNumber: (severity: string, isEnterprise?: boolean) => string; + createJiraIssueFromFinding(finding: SecurityHubFinding, identifyingLabels: string[]): Promise<{ + action: string; + webUrl: any; + summary: any; + }>; + createJiraIssuesForNewFindings(jiraIssues: Issue[], shFindings: SecurityHubFinding[], identifyingLabels: string[]): Promise; +} +export {}; diff --git a/dist/macfc-security-hub-sync.js b/dist/macfc-security-hub-sync.js new file mode 100644 index 0000000..fe2905a --- /dev/null +++ b/dist/macfc-security-hub-sync.js @@ -0,0 +1,251 @@ +"use strict"; +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; + severities; + 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.severities = severities; + 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 + console.log("Getting active Security Hub Findings with severities: " + this.severities); + 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; + } + createIssueBody(finding) { + const { remediation: { Recommendation: { Url: remediationUrl = "", Text: remediationText = "", } = {}, } = {}, 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} + + h2. Remediation: + + ${remediationUrl} + ${remediationText} + + h2. AWS Account: + ${awsAccountId} (${accountAlias}) + + h2. Severity: + ${severity} + + h2. SecurityHubFindingUrl: + ${this.createSecurityHubFindingUrl(standardsControlArn)} + + 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}`); + } + }; + async createJiraIssueFromFinding(finding, identifyingLabels) { + const priorities = await this.jira.getPriorityIdsInDescendingOrder(); + console.log(priorities); + const newIssueData = { + fields: { + summary: `SecurityHub Finding - ${finding.title}`, + description: this.createIssueBody(finding), + issuetype: { name: "Task" }, + labels: [ + "security-hub", + finding.severity, + finding.accountAlias, + ...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 (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); + } + 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; diff --git a/dist/run-sync.d.ts b/dist/run-sync.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/run-sync.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/dist/run-sync.js b/dist/run-sync.js new file mode 100644 index 0000000..882ee17 --- /dev/null +++ b/dist/run-sync.js @@ -0,0 +1,11 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const macfc_security_hub_sync_1 = require("./macfc-security-hub-sync"); +new macfc_security_hub_sync_1.SecurityHubJiraSync({ + region: "us-east-1", + severities: ["CRITICAL", "HIGH"], + customJiraFields: { + customfield_14117: [{ value: "Dev Team" }], + customfield_14151: [{ value: "OneMac" }], + }, +}).sync();