diff --git a/lib/allowlist.ts b/lib/allowlist.ts index f83e262c..885144f9 100644 --- a/lib/allowlist.ts +++ b/lib/allowlist.ts @@ -1,8 +1,10 @@ +import type { GitHubAdvisoryId } from "audit-types"; +import { isGitHubAdvisoryId } from "./common"; import { AuditCiPreprocessedConfig } from "./config"; class Allowlist { modules: string[]; - advisories: string[]; + advisories: GitHubAdvisoryId[]; paths: string[]; /** * @param input the allowlisted module names, advisories, and module paths @@ -23,7 +25,7 @@ class Allowlist { if (allowlist.includes(">") || allowlist.includes("|")) { this.paths.push(allowlist); - } else if (allowlist.startsWith("GHSA")) { + } else if (isGitHubAdvisoryId(allowlist)) { this.advisories.push(allowlist); } else { this.modules.push(allowlist); diff --git a/lib/common.ts b/lib/common.ts index 9159c0d2..7756ca9f 100644 --- a/lib/common.ts +++ b/lib/common.ts @@ -1,3 +1,4 @@ +import { GitHubAdvisoryId } from "audit-types"; import { SpawnOptionsWithoutStdio } from "child_process"; import { spawn } from "cross-spawn"; import escapeStringRegexp from "escape-string-regexp"; @@ -168,8 +169,12 @@ export function matchString(template: string, string_: string) { : template === string_; } -export function gitHubAdvisoryUrlToAdvisoryId(url: string) { - return url.split("/")[4]; +export function isGitHubAdvisoryId(id: string): id is GitHubAdvisoryId { + return id.startsWith("GHSA"); +} + +export function gitHubAdvisoryUrlToAdvisoryId(url: string): GitHubAdvisoryId { + return url.split("/")[4] as GitHubAdvisoryId; } export function gitHubAdvisoryIdToUrl( diff --git a/lib/model.ts b/lib/model.ts index 5be97497..5f32dac8 100644 --- a/lib/model.ts +++ b/lib/model.ts @@ -1,3 +1,10 @@ +import type { + GitHubAdvisoryId, + NPMAuditReportV1, + NPMAuditReportV2, + PNPMAuditReport, + YarnAudit, +} from "audit-types"; import Allowlist from "./allowlist"; import { gitHubAdvisoryUrlToAdvisoryId, @@ -6,6 +13,7 @@ import { } from "./common"; import { AuditCiConfig } from "./config"; import { VulnerabilityLevels } from "./map-vulnerability"; +import type { DeepWriteable } from "./types"; const SUPPORTED_SEVERITY_LEVELS = new Set([ "critical", @@ -18,26 +26,35 @@ const prependPath = (newItem: string, currentPath: string) => `${newItem}>${currentPath}`; export interface Summary { - advisoriesFound: string[]; + advisoriesFound: GitHubAdvisoryId[]; failedLevelsFound: string[]; allowlistedAdvisoriesNotFound: string[]; allowlistedModulesNotFound: string[]; allowlistedPathsNotFound: string[]; - allowlistedAdvisoriesFound: string[]; + allowlistedAdvisoriesFound: GitHubAdvisoryId[]; allowlistedModulesFound: string[]; allowlistedPathsFound: string[]; advisoryPathsFound: string[]; } +interface ProcessedAdvisory { + id: number; + github_advisory_id: GitHubAdvisoryId; + severity: string; + module_name: string; + url: string; + findings: { paths: string[] }[]; +} + class Model { failingSeverities: { [K in keyof VulnerabilityLevels]: VulnerabilityLevels[K]; }; allowlist: Allowlist; allowlistedModulesFound: string[]; - allowlistedAdvisoriesFound: string[]; + allowlistedAdvisoriesFound: GitHubAdvisoryId[]; allowlistedPathsFound: string[]; - advisoriesFound: any[]; + advisoriesFound: ProcessedAdvisory[]; advisoryPathsFound: string[]; constructor(config: AuditCiConfig) { @@ -60,7 +77,7 @@ class Model { this.advisoryPathsFound = []; } - process(advisory) { + process(advisory: ProcessedAdvisory) { if (!this.failingSeverities[advisory.severity]) { return; } @@ -107,162 +124,149 @@ class Model { this.advisoryPathsFound.push(...falsy); } - load(parsedOutput) { - /** NPM 6 */ - - if (parsedOutput.advisories) { - for (const advisory of Object.values(parsedOutput.advisories)) { - const advisoryAny = advisory as any; - // eslint-disable-next-line no-param-reassign, prefer-destructuring - advisoryAny.github_advisory_id = gitHubAdvisoryUrlToAdvisoryId( - advisoryAny.url + load( + parsedOutput: + | NPMAuditReportV2.Audit + | NPMAuditReportV1.Audit + | YarnAudit.AuditAdvisory + | PNPMAuditReport.Audit + ) { + /** NPM 6 & PNPM */ + + if ("advisories" in parsedOutput && parsedOutput.advisories) { + for (const advisory of Object.values< + DeepWriteable + >(parsedOutput.advisories)) { + advisory.github_advisory_id = gitHubAdvisoryUrlToAdvisoryId( + advisory.url ); // PNPM paths have a leading `.>` // "paths": [ // ".>module-name" //] - for (const finding of advisoryAny.findings) { - const findingAny = finding as any; - findingAny.paths = findingAny.paths.map((path) => - path.replace(".>", "") - ); + for (const finding of advisory.findings) { + finding.paths = finding.paths.map((path) => path.replace(".>", "")); } this.process(advisory); } return this.getSummary(); } - /** NPM 7+ & PNPM */ - - // This is incomplete. Rather than filling it out, plan on consuming an external dependency to manage. - type NPM7Vulnerability = { - name: string; - via: (string | object)[]; - isDirect: boolean; - effects: string[]; - }; - - const advisoryMap = new Map< - string, - { - id: number; - github_advisory_id: string; - severity: string; - module_name: string; - url: string; - findingsSet: Set; - findings: { paths: string[] }[]; - } - >(); - // First, let's deal with building a structure that's as close to NPM 6 as we can - // without dealing with the findings. - for (const vulnerability of Object.values( - parsedOutput.vulnerabilities - )) { - const { via: vias, isDirect } = vulnerability; - for (const via of vias.filter( - (via) => typeof via !== "string" - ) as any[]) { - if (!advisoryMap.has(via.source)) { - advisoryMap.set(via.source, { - id: via.source, - github_advisory_id: gitHubAdvisoryUrlToAdvisoryId(via.url), - module_name: via.name, - severity: via.severity, - url: via.url, - // This will eventually be an array. - // However, to improve the performance of deduplication, - // start with a set. - findingsSet: new Set( - [isDirect ? via.name : undefined].filter(Boolean) - ), - findings: [], - }); + /** NPM 7+ */ + if ("vulnerabilities" in parsedOutput && parsedOutput.vulnerabilities) { + const advisoryMap = new Map< + number, + ProcessedAdvisory & { + findingsSet: Set; + } + >(); + // First, let's deal with building a structure that's as close to NPM 6 as we can + // without dealing with the findings. + for (const vulnerability of Object.values( + parsedOutput.vulnerabilities + )) { + const { via: vias, isDirect } = vulnerability; + for (const via of vias.filter((via) => typeof via !== "string")) { + if (!advisoryMap.has(via.source)) { + advisoryMap.set(via.source, { + id: via.source, + github_advisory_id: gitHubAdvisoryUrlToAdvisoryId(via.url), + module_name: via.name, + severity: via.severity, + url: via.url, + // This will eventually be an array. + // However, to improve the performance of deduplication, + // start with a set. + findingsSet: new Set(isDirect ? [via.name] : []), + findings: [], + }); + } } } - } - // Now, all we have to deal with is develop the 'findings' property by traversing - // the audit tree. + // Now, all we have to deal with is develop the 'findings' property by traversing + // the audit tree. - const visitedModules = new Map(); + const visitedModules = new Map(); - for (const vuln of Object.entries( - parsedOutput.vulnerabilities - )) { - // Did this approach rather than destructuring within the forEach to type vulnerability - const moduleName = vuln[0]; - const vulnerability = vuln[1]; - const { via: vias, isDirect } = vulnerability; + for (const vuln of Object.entries( + parsedOutput.vulnerabilities + )) { + // Did this approach rather than destructuring within the forEach to type vulnerability + const moduleName = vuln[0]; + const vulnerability = vuln[1]; + const { via: vias, isDirect } = vulnerability; - if (vias.length === 0 || typeof vias[0] === "string") { - continue; - } - - const visited = new Set(); - - const recursiveMagic = ( - cVuln: NPM7Vulnerability, - dependencyPath: string - ): string[] => { - const visitedModule = visitedModules.get(cVuln.name); - if (visitedModule) { - return visitedModule.map((name) => { - const resultWithExtraCarat = prependPath(name, dependencyPath); - return resultWithExtraCarat.slice( - 0, - Math.max(0, resultWithExtraCarat.length - 1) - ); - }); + if (vias.length === 0 || typeof vias[0] === "string") { + continue; } - if (visited.has(cVuln.name)) { - // maybe undefined and filter? - return [dependencyPath]; + const visited = new Set(); + + const recursiveMagic = ( + cVuln: NPMAuditReportV2.Advisory, + dependencyPath: string + ): string[] => { + const visitedModule = visitedModules.get(cVuln.name); + if (visitedModule) { + return visitedModule.map((name) => { + const resultWithExtraCarat = prependPath(name, dependencyPath); + return resultWithExtraCarat.slice( + 0, + Math.max(0, resultWithExtraCarat.length - 1) + ); + }); + } + + if (visited.has(cVuln.name)) { + // maybe undefined and filter? + return [dependencyPath]; + } + visited.add(cVuln.name); + const newPath = prependPath(cVuln.name, dependencyPath); + if (cVuln.effects.length === 0) { + return [newPath.slice(0, Math.max(0, newPath.length - 1))]; + } + const result = cVuln.effects.flatMap((effect) => + recursiveMagic(parsedOutput.vulnerabilities[effect], newPath) + ); + return result; + }; + + const result = recursiveMagic(vulnerability, ""); + if (isDirect) { + result.push(moduleName); } - visited.add(cVuln.name); - const newPath = prependPath(cVuln.name, dependencyPath); - if (cVuln.effects.length === 0) { - return [newPath.slice(0, Math.max(0, newPath.length - 1))]; + const advisories = ( + vias.filter((via) => typeof via !== "string") as any[] + ) + .map((via) => via.source) + // Filter boolean makes the next line non-nullable. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + .map((id) => advisoryMap.get(id)!) + .filter(Boolean); + for (const advisory of advisories) { + for (const path of result) { + advisory.findingsSet.add(path); + } } - const result = cVuln.effects.flatMap((effect) => - recursiveMagic(parsedOutput.vulnerabilities[effect], newPath) - ); - return result; - }; - - const result = recursiveMagic(vulnerability, ""); - if (isDirect) { - result.push(moduleName); + // Optimization to prevent extra traversals. + visitedModules.set(moduleName, result); } - const advisories = ( - vias.filter((via) => typeof via !== "string") as any[] - ) - .map((via) => via.source) - // Filter boolean makes the next line non-nullable. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - .map((id) => advisoryMap.get(id)!) - .filter(Boolean); - for (const advisory of advisories) { - for (const path of result) { - advisory.findingsSet.add(path); - } + for (const [, advisory] of advisoryMap) { + advisory.findings = [{ paths: [...advisory.findingsSet] }]; + // @ts-expect-error don't care about findingSet anymore + delete advisory.findingsSet; + this.process(advisory); } - // Optimization to prevent extra traversals. - visitedModules.set(moduleName, result); - } - - for (const [, advisory] of advisoryMap) { - advisory.findings = [{ paths: [...advisory.findingsSet] }]; - // @ts-expect-error don't care about findingSet anymore - delete advisory.findingsSet; - this.process(advisory); } return this.getSummary(); } - getSummary(advisoryMapper = (a) => a.github_advisory_id) { + getSummary( + advisoryMapper: (advisory) => GitHubAdvisoryId = (a) => a.github_advisory_id + ) { const foundSeverities = new Set(); for (const { severity } of this.advisoriesFound) foundSeverities.add(severity); diff --git a/lib/npm-auditer.ts b/lib/npm-auditer.ts index 05c8cd01..674de60f 100644 --- a/lib/npm-auditer.ts +++ b/lib/npm-auditer.ts @@ -1,9 +1,12 @@ +import type { NPMAuditReportV1, NPMAuditReportV2 } from "audit-types"; import { blue } from "./colors"; import { reportAudit, runProgram } from "./common"; import { AuditCiConfig } from "./config"; import Model from "./model"; -async function runNpmAudit(config: AuditCiConfig) { +async function runNpmAudit( + config: AuditCiConfig +): Promise { const { directory, registry, @@ -38,10 +41,18 @@ async function runNpmAudit(config: AuditCiConfig) { } return stdoutBuffer; } +export function isV2Audit( + parsedOutput: NPMAuditReportV1.Audit | NPMAuditReportV2.Audit +): parsedOutput is NPMAuditReportV2.Audit { + return ( + "auditReportVersion" in parsedOutput && + parsedOutput.auditReportVersion === 2 + ); +} function printReport( - parsedOutput: any, - levels: any, + parsedOutput: NPMAuditReportV1.Audit | NPMAuditReportV2.Audit, + levels: AuditCiConfig["levels"], reportType: "full" | "important" | "summary", outputFormat: "text" | "json" ) { @@ -56,10 +67,9 @@ function printReport( printReportObject("NPM audit report JSON:", parsedOutput); break; case "important": { - const advisories = - parsedOutput.auditReportVersion === 2 - ? parsedOutput.vulnerabilities - : parsedOutput.advisories; + const advisories = isV2Audit(parsedOutput) + ? parsedOutput.vulnerabilities + : parsedOutput.advisories; const relevantAdvisoryLevels = Object.keys(advisories).filter( (advisory) => levels[advisories[advisory].severity] @@ -106,7 +116,7 @@ export function report(parsedOutput, config: AuditCiConfig, reporter) { */ export async function audit(config: AuditCiConfig, reporter = reportAudit) { const parsedOutput = await runNpmAudit(config); - if (parsedOutput.error) { + if ("error" in parsedOutput) { const { code, summary } = parsedOutput.error; throw new Error(`code ${code}: ${summary}`); } diff --git a/lib/pnpm-auditer.ts b/lib/pnpm-auditer.ts index 1d49dfd9..358620bf 100644 --- a/lib/pnpm-auditer.ts +++ b/lib/pnpm-auditer.ts @@ -1,9 +1,12 @@ +import type { PNPMAuditReport } from "audit-types"; import { blue, yellow } from "./colors"; import { reportAudit, runProgram } from "./common"; import { AuditCiConfig } from "./config"; -import Model from "./model"; +import Model, { Summary } from "./model"; -async function runPnpmAudit(config: AuditCiConfig) { +async function runPnpmAudit( + config: AuditCiConfig +): Promise { const { directory, registry, @@ -40,8 +43,8 @@ async function runPnpmAudit(config: AuditCiConfig) { } function printReport( - parsedOutput: any, - levels: any, + parsedOutput: PNPMAuditReport.Audit, + levels: AuditCiConfig["levels"], reportType: "full" | "important" | "summary", outputFormat: "text" | "json" ) { @@ -84,7 +87,15 @@ function printReport( } } -export function report(parsedOutput, config: AuditCiConfig, reporter) { +export function report( + parsedOutput: PNPMAuditReport.Audit, + config: AuditCiConfig, + reporter: ( + summary: Summary, + config: AuditCiConfig, + audit?: PNPMAuditReport.Audit + ) => Summary +) { const { levels, "report-type": reportType, @@ -103,7 +114,7 @@ export function report(parsedOutput, config: AuditCiConfig, reporter) { */ export async function audit(config: AuditCiConfig, reporter = reportAudit) { const parsedOutput = await runPnpmAudit(config); - if (parsedOutput.error) { + if ("error" in parsedOutput) { const { code, summary } = parsedOutput.error; throw new Error(`code ${code}: ${summary}`); } diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 00000000..0e1e6a81 --- /dev/null +++ b/lib/types.ts @@ -0,0 +1,3 @@ +export type DeepWriteable = { + -readonly [P in keyof T]: DeepWriteable; +}; diff --git a/lib/yarn-auditer.ts b/lib/yarn-auditer.ts index ff3d7b1d..483dc842 100644 --- a/lib/yarn-auditer.ts +++ b/lib/yarn-auditer.ts @@ -1,9 +1,10 @@ +import type { YarnAudit, YarnBerryAuditReport } from "audit-types"; import * as childProcess from "child_process"; import * as semver from "semver"; import { blue, red, yellow } from "./colors"; import { reportAudit, runProgram } from "./common"; import { AuditCiConfig } from "./config"; -import Model from "./model"; +import Model, { Summary } from "./model"; const MINIMUM_YARN_CLASSIC_VERSION = "1.12.3"; const MINIMUM_YARN_BERRY_VERSION = "2.4.0"; @@ -52,7 +53,7 @@ const printJson = (data: unknown) => { export async function audit( config: AuditCiConfig, reporter = reportAudit -): Promise { +): Promise { const { levels, registry, @@ -76,6 +77,12 @@ export async function audit( const isYarnClassic = yarnSupportsClassicAudit(yarnVersion); const yarnName = isYarnClassic ? `Yarn` : `Yarn Berry`; + function isClassicGuard( + response: YarnAudit.AuditResponse | YarnBerryAuditReport.AuditResponse + ): response is YarnAudit.AuditResponse { + return isYarnClassic; + } + const printHeader = (text: string) => { if (outputFormat === "text") { console.log(blue, text); @@ -136,9 +143,11 @@ export async function audit( ); } - function outListener(line) { + function outListener( + line: YarnAudit.AuditResponse | YarnBerryAuditReport.AuditResponse + ) { try { - if (isYarnClassic) { + if (isClassicGuard(line)) { const { type, data } = line; printAuditData(line); @@ -155,8 +164,12 @@ export async function audit( } else { printAuditData(line); - for (const advisory of Object.values(line.advisories)) { - model.process(advisory); + if ("advisories" in line) { + for (const advisory of Object.values( + line.advisories + )) { + model.process(advisory); + } } } } catch (error) { diff --git a/package-lock.json b/package-lock.json index 045fcfdd..f2859c07 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "@types/yargs": "^17.0.9", "@typescript-eslint/eslint-plugin": "^5.14.0", "@typescript-eslint/parser": "^5.14.0", + "audit-types": "^0.5.0", "chai": "^4.3.6", "eslint": "^8.11.0", "eslint-config-prettier": "^8.5.0", @@ -679,6 +680,12 @@ "node": "*" } }, + "node_modules/audit-types": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/audit-types/-/audit-types-0.5.0.tgz", + "integrity": "sha512-YyNLLGa9n+CDZT4cl98tYGEnn9Z+3Np37ZMY5kkSfb7++E9wVCOMi3N4aAsnIr+9sAYWpvE8SMHw64KfEEBJ0A==", + "dev": true + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -4433,6 +4440,12 @@ "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", "dev": true }, + "audit-types": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/audit-types/-/audit-types-0.5.0.tgz", + "integrity": "sha512-YyNLLGa9n+CDZT4cl98tYGEnn9Z+3Np37ZMY5kkSfb7++E9wVCOMi3N4aAsnIr+9sAYWpvE8SMHw64KfEEBJ0A==", + "dev": true + }, "balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", diff --git a/package.json b/package.json index de38a0e3..7512a64e 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@types/yargs": "^17.0.9", "@typescript-eslint/eslint-plugin": "^5.14.0", "@typescript-eslint/parser": "^5.14.0", + "audit-types": "^0.5.0", "chai": "^4.3.6", "eslint": "^8.11.0", "eslint-config-prettier": "^8.5.0", @@ -80,4 +81,4 @@ "pre-push": "npm run lint && npm run test" } } -} \ No newline at end of file +}