diff --git a/README.md b/README.md index e8ed38f..eaf1d6d 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,9 @@ export interface ExtendedStrategy< ) => Promise<(VulnFormat | StandardVulnerability)[]>; } -export type BaseStrategyFormat = "Standard"; +export type BaseStrategyFormat = + | "Standard" + | "OSV"; export interface BaseStrategyOptions { useFormat?: BaseStrategyFormat; @@ -124,6 +126,7 @@ Where `dependencies` is the dependencies **Map()** object of the NodeSecure Scan ### Formats - [Standard](./docs/formats/standard.md) +- [OSV](./docs/formats/osv.md) ### Databases - [OSV](./docs/database/osv.md) diff --git a/docs/database/osv.md b/docs/database/osv.md index c534d95..e8db3b1 100644 --- a/docs/database/osv.md +++ b/docs/database/osv.md @@ -8,33 +8,7 @@ Lean more at [osv.dev](https://osv.dev/) ## Format -The OSV interface is exported as root like `StandardVulnerability`. - -```ts -export interface OSV { - schema_version: string; - id: string; - modified: string; - published: string; - withdraw: string; - aliases: string[]; - related: string[]; - summary: string; - details: string; - severity: OSVSeverity[]; - affected: OSVAffected[]; - references: { - type: OSVReferenceType; - url: string; - }[]; - credits: { - name: string; - contact: string[]; - type: OSVCreditType; - }[]; - database_specific: Record; -} -``` +See the [OSV format](../formats/osv.md) documentation. ## API diff --git a/docs/formats/osv.md b/docs/formats/osv.md new file mode 100644 index 0000000..fa90233 --- /dev/null +++ b/docs/formats/osv.md @@ -0,0 +1,89 @@ +# OSV vulnerability format + +The [Open Source Vulnerability (OSV) schema](https://ossf.github.io/osv-schema/) is an open, precise, and human-readable format for describing vulnerabilities, maintained by the OpenSSF. It is designed to be interoperable across ecosystems and tooling. + +This format can be activated with the `useFormat` option set to `"OSV"`. + +## TypeScript interfaces + +```ts +export interface OSV { + schema_version?: string; + id: string; + modified: string; + published: string; + withdraw?: string; + aliases: string[]; + upstream: string[]; + related?: string[]; + summary: string; + details: string; + severity: OSVSeverity[]; + affected: OSVAffected[]; + references: { + type: OSVReferenceType; + url: string; + }[]; + credits: { + name: string; + contact: string[]; + type: OSVCreditType; + }[]; + database_specific: Record; +} + +export interface OSVAffected { + package: { + ecosystem: "npm"; + name: string; + purl: string; + }; + severity: OSVSeverity[]; + ranges: OSVRange[]; + versions: string[]; + ecosystem_specific: Record; + database_specific: Record; +} + +export interface OSVRange { + type: string; + repo?: string; // Only required for GIT type + events: { + introduced?: string; + fixed?: string; + last_affected?: string; + limit?: string; + }[]; + database_specific: Record; +} + +export interface OSVSeverity { + type: string; + score: string; +} + +export type OSVReferenceType = + | "ADVISORY" + | "ARTICLE" + | "DETECTION" + | "DISCUSSION" + | "REPORT" + | "FIX" + | "GIT" + | "INTRODUCED" + | "PACKAGE" + | "EVIDENCE" + | "WEB"; + +export type OSVCreditType = + | "FINDER" + | "REPORTER" + | "ANALYST" + | "COORDINATOR" + | "REMEDIATION_DEVELOPER" + | "REMEDIATION_REVIEWER" + | "REMEDIATION_VERIFIER" + | "TOOL" + | "SPONSOR" + | "OTHER"; +``` diff --git a/src/formats/index.ts b/src/formats/index.ts index 9d67efb..6ad84ee 100644 --- a/src/formats/index.ts +++ b/src/formats/index.ts @@ -5,12 +5,16 @@ import { standardVulnerabilityMapper, type StandardizeKind } from "./standard/index.ts"; +import { + osvVulnerabilityMapper, + type OSVKind +} from "./osv/index.ts"; export function formatVulnsPayload( format: BaseStrategyFormat | null = null ) { return function formatVulnerabilities( - strategy: StandardizeKind, + strategy: StandardizeKind | OSVKind, vulnerabilities: any[] ) { if (format === "Standard") { @@ -19,6 +23,12 @@ export function formatVulnsPayload( vulnerabilities ); } + if (format === "OSV") { + return osvVulnerabilityMapper( + strategy, + vulnerabilities + ); + } // identity function return vulnerabilities; diff --git a/src/formats/osv/index.ts b/src/formats/osv/index.ts index 6b6aed9..6ddbdba 100644 --- a/src/formats/osv/index.ts +++ b/src/formats/osv/index.ts @@ -1,15 +1,18 @@ +// Import Internal Dependencies +import { OSV_VULN_MAPPERS } from "./mappers.ts"; /** * @see https://ossf.github.io/osv-schema/ */ export interface OSV { - schema_version: string; + schema_version?: string; id: string; modified: string; published: string; - withdraw: string; + withdraw?: string; aliases: string[]; - related: string[]; + upstream: string[]; + related?: string[]; summary: string; details: string; severity: OSVSeverity[]; @@ -64,7 +67,7 @@ export interface OSVAffected { export interface OSVRange { type: string; - repo: string; + repo?: string; events: { introduced?: string; fixed?: string; @@ -78,3 +81,16 @@ export interface OSVSeverity { type: string; score: string; } + +export type OSVKind = keyof typeof OSV_VULN_MAPPERS; + +export function osvVulnerabilityMapper( + strategy: OSVKind, + vulnerabilities: any[] +): OSV[] { + if (!(strategy in OSV_VULN_MAPPERS)) { + return []; + } + + return vulnerabilities.map(OSV_VULN_MAPPERS[strategy]); +} diff --git a/src/formats/osv/mappers.ts b/src/formats/osv/mappers.ts new file mode 100644 index 0000000..43397be --- /dev/null +++ b/src/formats/osv/mappers.ts @@ -0,0 +1,254 @@ +// Import Internal Dependencies +import { VULN_MODE } from "../../constants.ts"; +import * as utils from "../../utils.ts"; + +import type { + OSV, + OSVRange +} from "./index.ts"; +import type { + SonatypeVulnerability, + SnykVulnerability, + NpmAuditAdvisory, + PnpmAuditAdvisory +} from "../../index.ts"; + +function extractGhsaId( + url: string +): string { + return url.match(/GHSA-[a-z0-9-]+/i)?.[0] ?? ""; +} + +function toPurl( + packageName: string +): string { + return `pkg:npm/${encodeURIComponent(packageName)}`; +} + +function semverRangeToOsvEvents( + range: string +): OSVRange["events"] { + const parts = range + .split(",") + .map((part) => part.trim()); + const events: OSVRange["events"] = []; + + for (const part of parts) { + const ltMatch = part.match(/^<([^\s=].*)$/); + const lteMatch = part.match(/^<=(.+)$/); + const gteMatch = part.match(/^>=(.+)$/); + const gtMatch = part.match(/^>([^=].*)$/); + + if (lteMatch) { + events.push({ last_affected: lteMatch[1].trim() }); + } + else if (ltMatch) { + events.push({ fixed: ltMatch[1].trim() }); + } + else if (gteMatch) { + events.push({ introduced: gteMatch[1].trim() }); + } + else if (gtMatch) { + events.push({ introduced: gtMatch[1].trim() }); + } + } + + const hasIntroduced = events.some((e) => "introduced" in e); + if (!hasIntroduced) { + events.unshift({ introduced: "0" }); + } + + return events; +} + +function mapFromNPM( + vuln: NpmAuditAdvisory +): OSV { + const id = extractGhsaId(vuln.url) || String(vuln.source); + + return { + id, + modified: new Date().toISOString(), + published: new Date().toISOString(), + aliases: vuln.cwe ?? [], + upstream: [], + summary: vuln.title, + details: vuln.title, + severity: vuln.cvss ? + [{ type: "CVSS_V3", score: vuln.cvss.vectorString }] : + [], + affected: [ + { + package: { + ecosystem: "npm", + name: vuln.name, + purl: toPurl(vuln.name) + }, + severity: [], + ranges: [ + { + type: "SEMVER", + events: semverRangeToOsvEvents(vuln.range), + database_specific: {} + } + ], + versions: utils.fromMaybeStringToArray(vuln.vulnerableVersions), + ecosystem_specific: {}, + database_specific: {} + } + ], + references: [ + { + type: "ADVISORY", + url: vuln.url + } + ], + credits: [], + database_specific: { + severity: vuln.severity + } + }; +} + +function mapFromPnpm( + vuln: PnpmAuditAdvisory +): OSV { + return { + id: vuln.github_advisory_id, + modified: new Date().toISOString(), + published: new Date().toISOString(), + aliases: utils.fromMaybeStringToArray(vuln.cwe), + upstream: [], + summary: vuln.title, + details: vuln.overview ?? vuln.title, + severity: vuln.cvss ? + [{ type: "CVSS_V3", score: vuln.cvss.vectorString }] : + [], + affected: [ + { + package: { + ecosystem: "npm", + name: vuln.module_name, + purl: toPurl(vuln.module_name) + }, + severity: [], + ranges: [], + versions: utils.fromMaybeStringToArray(vuln.vulnerable_versions), + ecosystem_specific: { patched_versions: vuln.patched_versions }, + database_specific: {} + } + ], + references: [{ type: "ADVISORY", url: vuln.url }], + credits: [], + database_specific: { severity: vuln.severity } + }; +} + +function mapFromSnyk( + vuln: SnykVulnerability +): OSV { + return { + id: vuln.id, + modified: vuln.publicationTime, + published: vuln.disclosureTime ?? vuln.publicationTime, + aliases: vuln.identifiers.CVE ?? [], + upstream: [], + summary: vuln.title, + details: vuln.description, + severity: [ + { type: "CVSS_V3", score: vuln.CVSSv3 } + ], + affected: [ + { + package: { + ecosystem: "npm", + name: vuln.package, + purl: toPurl(vuln.package) + }, + severity: [], + ranges: vuln.semver.vulnerable.map((range) => { + return { + type: "SEMVER", + events: semverRangeToOsvEvents(range), + database_specific: {} + }; + }), + versions: vuln.functions.flatMap((f) => f.version), + ecosystem_specific: {}, + database_specific: {} + } + ], + references: [ + { + type: "WEB", + url: vuln.url + } + ], + credits: vuln.credit.map((name) => { + return { + name, + contact: [], + type: "FINDER" as const + }; + }), + database_specific: { + severity: vuln.severity, + cvssScore: vuln.cvssScore + } + }; +} + +function mapFromSonatype( + vuln: SonatypeVulnerability +): OSV { + return { + id: vuln.id, + modified: new Date().toISOString(), + published: new Date().toISOString(), + aliases: utils.fromMaybeStringToArray(vuln.cve), + upstream: [], + summary: vuln.title, + details: vuln.description, + severity: [ + { type: "CVSS_V3", score: vuln.cvssVector } + ], + affected: [ + { + package: { + ecosystem: "npm", + name: vuln.package ?? "", + purl: vuln.package ? toPurl(vuln.package) : "" + }, + severity: [], + ranges: (vuln.versionRanges ?? []).map((range) => { + return { + type: "SEMVER", + events: semverRangeToOsvEvents(range), + database_specific: {} + }; + }), + versions: vuln.versionRanges ?? [], + ecosystem_specific: {}, + database_specific: {} + } + ], + references: [ + { type: "ADVISORY", url: vuln.reference }, + ...vuln.externalReferences.map((url) => { + return { type: "WEB" as const, url }; + }) + ], + credits: [], + database_specific: { + cwe: vuln.cwe, + cvssScore: vuln.cvssScore + } + }; +} + +export const OSV_VULN_MAPPERS = Object.freeze({ + [VULN_MODE.GITHUB_ADVISORY]: mapFromNPM, + "github-advisory_pnpm": mapFromPnpm, + [VULN_MODE.SNYK]: mapFromSnyk, + [VULN_MODE.SONATYPE]: mapFromSonatype +}); diff --git a/src/formats/snyk/index.ts b/src/formats/snyk/index.ts index d39261f..36b72e9 100644 --- a/src/formats/snyk/index.ts +++ b/src/formats/snyk/index.ts @@ -46,8 +46,8 @@ export interface SnykVulnerability { isPinnable: boolean; /** Additional vulnerability identifiers **/ identifiers: Record; - /** The reporter of the vulnerability **/ - credit: string; + /** The reporter(s) of the vulnerability **/ + credit: string[]; /** * Common Vulnerability Scoring System (CVSS) provides a way to capture the principal characteristics * of a vulnerability, and produce a numerical score reflecting its severity, @@ -64,7 +64,13 @@ export interface SnykVulnerability { isPatched: boolean; /** The snyk exploit maturity level **/ exploitMaturity: string; - functions: any; + functions: { + functionId: { + filePath: string; + functionName: string; + }; + version: string[]; + }[]; } export interface SnykAuditResponse { diff --git a/src/index.ts b/src/index.ts index f6bde63..d65b05d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,10 @@ import { import { ApiCredential, type ApiCredentialOptions } from "./credential.ts"; +import { + formatVulnsPayload +} from "./formats/index.js"; + import type { SnykVulnerability } from "./formats/snyk/index.ts"; @@ -135,5 +139,6 @@ export type { SnykVulnerability, SonatypeVulnerability, - OSV + OSV, + formatVulnsPayload }; diff --git a/src/strategies/types/api.ts b/src/strategies/types/api.ts index ae774c4..8e9a4a8 100644 --- a/src/strategies/types/api.ts +++ b/src/strategies/types/api.ts @@ -3,7 +3,9 @@ import type { Dependencies } from "./scanner.ts"; import type { StandardVulnerability } from "../../formats/standard/index.ts"; import type { Kind } from "../../constants.ts"; -export type BaseStrategyFormat = "Standard"; +export type BaseStrategyFormat = + | "Standard" + | "OSV"; export interface BaseStrategyOptions { useFormat?: BaseStrategyFormat; diff --git a/test/fixtures/vuln_payload/payloads.ts b/test/fixtures/vuln_payload/payloads.ts index 1baf725..f4f7c07 100644 --- a/test/fixtures/vuln_payload/payloads.ts +++ b/test/fixtures/vuln_payload/payloads.ts @@ -2,6 +2,7 @@ import { SNYK_VULNERABILITY, NPM_VULNERABILITY, + PNPM_VULNERABILITY, SONATYPE_VULNERABILITY } from "./vulns.ts"; @@ -76,3 +77,158 @@ export const SONATYPE_VULNS_PAYLOADS = { cvssScore: SONATYPE_VULNERABILITY.cvssScore } }; + +export const NPM_OSV_PAYLOAD = { + inputVulnsPayload: { + vulnerabilities: { + "@npmcli/git": { + via: [NPM_VULNERABILITY] + } + } + }, + outputOSVPayload: { + id: "GHSA-hxwm-x553-x359", + aliases: [], + upstream: [], + summary: NPM_VULNERABILITY.title, + details: NPM_VULNERABILITY.title, + severity: [], + affected: [ + { + package: { + ecosystem: "npm", + name: NPM_VULNERABILITY.name, + purl: `pkg:npm/${encodeURIComponent(NPM_VULNERABILITY.name)}` + }, + severity: [], + ranges: [ + { + type: "SEMVER", + events: [{ introduced: "0" }, { fixed: "2.0.8" }], + database_specific: {} + } + ], + versions: [], + ecosystem_specific: {}, + database_specific: {} + } + ], + references: [{ type: "ADVISORY", url: NPM_VULNERABILITY.url }], + credits: [], + database_specific: { severity: NPM_VULNERABILITY.severity } + } +}; + +export const PNPM_OSV_PAYLOAD = { + inputVulnsPayload: { + vulnerabilities: [PNPM_VULNERABILITY] + }, + outputOSVPayload: { + id: PNPM_VULNERABILITY.github_advisory_id, + aliases: PNPM_VULNERABILITY.cwe, + upstream: [], + summary: PNPM_VULNERABILITY.title, + details: PNPM_VULNERABILITY.overview, + severity: [{ type: "CVSS_V3", score: PNPM_VULNERABILITY.cvss.vectorString }], + affected: [ + { + package: { + ecosystem: "npm", + name: PNPM_VULNERABILITY.module_name, + purl: `pkg:npm/${encodeURIComponent(PNPM_VULNERABILITY.module_name)}` + }, + severity: [], + ranges: [], + versions: PNPM_VULNERABILITY.vulnerable_versions, + ecosystem_specific: { patched_versions: PNPM_VULNERABILITY.patched_versions }, + database_specific: {} + } + ], + references: [{ type: "ADVISORY", url: PNPM_VULNERABILITY.url }], + credits: [], + database_specific: { severity: PNPM_VULNERABILITY.severity } + } +}; + +export const SNYK_OSV_PAYLOAD = { + inputVulnsPayload: { + vulnerabilities: [SNYK_VULNERABILITY] + }, + outputOSVPayload: { + id: SNYK_VULNERABILITY.id, + modified: SNYK_VULNERABILITY.publicationTime, + published: SNYK_VULNERABILITY.disclosureTime, + aliases: SNYK_VULNERABILITY.identifiers.CVE, + upstream: [], + summary: SNYK_VULNERABILITY.title, + details: SNYK_VULNERABILITY.description, + severity: [{ type: "CVSS_V3", score: SNYK_VULNERABILITY.CVSSv3 }], + affected: [ + { + package: { + ecosystem: "npm", + name: SNYK_VULNERABILITY.package, + purl: `pkg:npm/${encodeURIComponent(SNYK_VULNERABILITY.package)}` + }, + severity: [], + ranges: [ + { + type: "SEMVER", + events: [{ fixed: "0.5.0" }, { introduced: "0.4.0" }], + database_specific: {} + }, + { + type: "SEMVER", + events: [{ fixed: "0.3.8" }, { introduced: "0.3.6" }], + database_specific: {} + } + ], + versions: [ + ...SNYK_VULNERABILITY.functions[0].version, + ...SNYK_VULNERABILITY.functions[1].version + ], + ecosystem_specific: {}, + database_specific: {} + } + ], + references: [{ type: "WEB", url: SNYK_VULNERABILITY.url }], + credits: SNYK_VULNERABILITY.credit.map((name) => { + return { name, contact: [], type: "FINDER" }; + }), + database_specific: { severity: SNYK_VULNERABILITY.severity, cvssScore: SNYK_VULNERABILITY.cvssScore } + } +}; + +export const SONATYPE_OSV_PAYLOAD = { + inputVulnsPayload: { + vulnerabilities: [SONATYPE_VULNERABILITY] + }, + outputOSVPayload: { + id: SONATYPE_VULNERABILITY.id, + aliases: [], + upstream: [], + summary: SONATYPE_VULNERABILITY.title, + details: SONATYPE_VULNERABILITY.description, + severity: [{ type: "CVSS_V3", score: SONATYPE_VULNERABILITY.cvssVector }], + affected: [ + { + package: { + ecosystem: "npm", + name: "", + purl: "" + }, + severity: [], + ranges: [], + versions: [], + ecosystem_specific: {}, + database_specific: {} + } + ], + references: [ + { type: "ADVISORY", url: SONATYPE_VULNERABILITY.reference }, + { type: "WEB", url: SONATYPE_VULNERABILITY.externalReferences[0] } + ], + credits: [], + database_specific: { cwe: SONATYPE_VULNERABILITY.cwe, cvssScore: SONATYPE_VULNERABILITY.cvssScore } + } +}; diff --git a/test/fixtures/vuln_payload/vulns.ts b/test/fixtures/vuln_payload/vulns.ts index fa55089..44fa4fa 100644 --- a/test/fixtures/vuln_payload/vulns.ts +++ b/test/fixtures/vuln_payload/vulns.ts @@ -118,6 +118,22 @@ export const SNYK_VULNERABILITY = { upgradePath: ["ms@0.7.1"] }; +export const PNPM_VULNERABILITY = { + id: 1005085, + github_advisory_id: "GHSA-hxwm-x553-x359", + npm_advisory_id: 1005085, + module_name: "@npmcli/git", + title: "Arbitrary Command Injection due to Improper Command Sanitization", + overview: "A vulnerability in @npmcli/git allows arbitrary command injection.", + url: "https://github.com/advisories/GHSA-hxwm-x553-x359", + severity: "moderate", + cwe: ["CWE-77"], + cves: ["CVE-2021-3807"], + patched_versions: ">=2.0.8", + vulnerable_versions: ["<2.0.8"], + cvss: { score: 7.5, vectorString: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H" } +}; + export const SONATYPE_VULNERABILITY = { id: "a917ab55-851f-4c8b-ac82-6f988881c329", displayName: "OSSINDEX-6f98-8881-c329", diff --git a/test/strategies/vuln_payload/osv.unit.spec.ts b/test/strategies/vuln_payload/osv.unit.spec.ts new file mode 100644 index 0000000..05ca8d8 --- /dev/null +++ b/test/strategies/vuln_payload/osv.unit.spec.ts @@ -0,0 +1,76 @@ +// Import Node.js Dependencies +import { test } from "node:test"; +import assert from "node:assert"; + +// Import Internal Dependencies +import { VULN_MODE } from "../../../src/constants.ts"; +import { formatVulnsPayload } from "../../../src/formats/index.ts"; +import { + NPM_OSV_PAYLOAD, + PNPM_OSV_PAYLOAD, + SNYK_OSV_PAYLOAD, + SONATYPE_OSV_PAYLOAD +} from "../../fixtures/vuln_payload/payloads.ts"; + +const formatVulnerabilities = formatVulnsPayload("OSV"); + +test("should convert NONE or unknown strategy into blank payload", () => { + let notFormatted = formatVulnerabilities(VULN_MODE.NONE as any, [{}, {}]); + assert.ok(notFormatted.length === 0); + + notFormatted = formatVulnerabilities("exploit" as any, []); + assert.ok(notFormatted.length === 0); +}); + +test("should convert NPM strategy vulns payload into OSV format", () => { + const { vulnerabilities } = NPM_OSV_PAYLOAD.inputVulnsPayload; + const [result] = formatVulnerabilities( + VULN_MODE.GITHUB_ADVISORY, + vulnerabilities["@npmcli/git"].via + ); + + assert.ok(typeof result.modified === "string" && !isNaN(Date.parse(result.modified))); + assert.ok(typeof result.published === "string" && !isNaN(Date.parse(result.published))); + + const { modified, published, ...rest } = result as any; + const { outputOSVPayload } = NPM_OSV_PAYLOAD; + assert.deepEqual(rest, outputOSVPayload); +}); + +test("should convert Pnpm strategy vulns payload into OSV format", () => { + const [result] = formatVulnerabilities( + "github-advisory_pnpm" as any, + PNPM_OSV_PAYLOAD.inputVulnsPayload.vulnerabilities + ); + + assert.ok(typeof result.modified === "string" && !isNaN(Date.parse(result.modified))); + assert.ok(typeof result.published === "string" && !isNaN(Date.parse(result.published))); + + const { modified, published, ...rest } = result as any; + const { outputOSVPayload } = PNPM_OSV_PAYLOAD; + assert.deepEqual(rest, outputOSVPayload); +}); + +test("should convert Snyk strategy payload into OSV format", () => { + const [result] = formatVulnerabilities( + VULN_MODE.SNYK, + SNYK_OSV_PAYLOAD.inputVulnsPayload.vulnerabilities + ); + + const { outputOSVPayload } = SNYK_OSV_PAYLOAD; + assert.deepEqual(result, outputOSVPayload); +}); + +test("should convert Sonatype strategy payload into OSV format", () => { + const [result] = formatVulnerabilities( + VULN_MODE.SONATYPE, + SONATYPE_OSV_PAYLOAD.inputVulnsPayload.vulnerabilities + ); + + assert.ok(typeof result.modified === "string" && !isNaN(Date.parse(result.modified))); + assert.ok(typeof result.published === "string" && !isNaN(Date.parse(result.published))); + + const { modified, published, ...rest } = result as any; + const { outputOSVPayload } = SONATYPE_OSV_PAYLOAD; + assert.deepEqual(rest, outputOSVPayload); +});