|
#!/usr/bin/env node |
|
|
|
/** |
|
* Scans SBOM files (SPDX 2.3 JSON) for vulnerable package versions using |
|
* a user-defined vulnerability list with semver range matching. |
|
* |
|
* Usage: |
|
* node find-vulnerabilities.mjs --vulns ./vulns.json [--input ./sboms] [--output ./report.json] |
|
* |
|
* The vulns.json file should be an array of objects: |
|
* [ |
|
* { |
|
* "name": "lodash", |
|
* "ecosystem": "npm", // npm, pip, maven, nuget, go, cargo, etc. |
|
* "vulnerableRange": "<4.17.21", // semver range: <, <=, >, >=, ^, ~, ||, exact |
|
* "severity": "HIGH", // CRITICAL, HIGH, MEDIUM, LOW (optional) |
|
* "id": "CVE-2021-23337", // identifier (optional) |
|
* "description": "Prototype pollution in lodash" // optional |
|
* } |
|
* ] |
|
* |
|
* Range examples: |
|
* "<4.17.21" — anything below 4.17.21 |
|
* "<=2.0.0" — anything at or below 2.0.0 |
|
* ">=1.0.0 <1.5.0" — 1.0.0 up to (not including) 1.5.0 |
|
* "^3.0.0" — >=3.0.0 <4.0.0 |
|
* "~3.1.0" — >=3.1.0 <3.2.0 |
|
* "^1.2.3 || ^2.0.0" — either range |
|
* "1.2.3" — exactly 1.2.3 |
|
* "*" — all versions |
|
* |
|
* Requirements: |
|
* - Node.js 18+ |
|
* - SBOM files generated by dump-sboms.mjs |
|
*/ |
|
|
|
import { readFile, readdir, writeFile } from "node:fs/promises"; |
|
import { join } from "node:path"; |
|
|
|
// --------------------------------------------------------------------------- |
|
// CLI args |
|
// --------------------------------------------------------------------------- |
|
|
|
const args = process.argv.slice(2); |
|
|
|
if (args.includes("--help") || args.includes("-h")) { |
|
console.error( |
|
"Usage: node find-vulnerabilities.mjs --vulns ./vulns.json [--input ./sboms] [--output ./report.json]", |
|
); |
|
process.exit(1); |
|
} |
|
|
|
const vulnsFile = args.includes("--vulns") |
|
? args[args.indexOf("--vulns") + 1] |
|
: null; |
|
const inputDir = args.includes("--input") |
|
? args[args.indexOf("--input") + 1] |
|
: "./sboms"; |
|
const outputFile = args.includes("--output") |
|
? args[args.indexOf("--output") + 1] |
|
: null; |
|
|
|
if (!vulnsFile) { |
|
console.error("Error: --vulns <file> is required. See --help for details."); |
|
process.exit(1); |
|
} |
|
|
|
// --------------------------------------------------------------------------- |
|
// Minimal semver implementation (no dependencies) |
|
// --------------------------------------------------------------------------- |
|
|
|
function parseSemver(version) { |
|
// Strip leading "v" and any build metadata |
|
const cleaned = version.replace(/^v/, "").replace(/\+.*$/, ""); |
|
const [core, prerelease] = cleaned.split("-", 2); |
|
const parts = core.split(".").map(Number); |
|
|
|
return { |
|
major: parts[0] || 0, |
|
minor: parts[1] || 0, |
|
patch: parts[2] || 0, |
|
prerelease: prerelease || "", |
|
valid: parts.every((p) => !isNaN(p)), |
|
}; |
|
} |
|
|
|
function compareSemver(a, b) { |
|
const va = parseSemver(a); |
|
const vb = parseSemver(b); |
|
|
|
if (va.major !== vb.major) return va.major - vb.major; |
|
if (va.minor !== vb.minor) return va.minor - vb.minor; |
|
if (va.patch !== vb.patch) return va.patch - vb.patch; |
|
|
|
// Pre-release versions have lower precedence |
|
if (va.prerelease && !vb.prerelease) return -1; |
|
if (!va.prerelease && vb.prerelease) return 1; |
|
if (va.prerelease && vb.prerelease) { |
|
return va.prerelease < vb.prerelease |
|
? -1 |
|
: va.prerelease > vb.prerelease |
|
? 1 |
|
: 0; |
|
} |
|
|
|
return 0; |
|
} |
|
|
|
function satisfiesComparator(version, comparator) { |
|
comparator = comparator.trim(); |
|
if (!comparator || comparator === "*") return true; |
|
|
|
// Handle ^ (compatible with) |
|
if (comparator.startsWith("^")) { |
|
const target = parseSemver(comparator.slice(1)); |
|
const v = parseSemver(version); |
|
|
|
if (!target.valid || !v.valid) return false; |
|
|
|
// ^1.2.3 := >=1.2.3 <2.0.0 |
|
// ^0.2.3 := >=0.2.3 <0.3.0 |
|
// ^0.0.3 := >=0.0.3 <0.0.4 |
|
if (compareSemver(version, comparator.slice(1)) < 0) return false; |
|
|
|
if (target.major !== 0) { |
|
return v.major === target.major; |
|
} else if (target.minor !== 0) { |
|
return v.major === 0 && v.minor === target.minor; |
|
} else { |
|
return v.major === 0 && v.minor === 0 && v.patch === target.patch; |
|
} |
|
} |
|
|
|
// Handle ~ (approximately) |
|
if (comparator.startsWith("~")) { |
|
const target = parseSemver(comparator.slice(1)); |
|
const v = parseSemver(version); |
|
|
|
if (!target.valid || !v.valid) return false; |
|
|
|
// ~1.2.3 := >=1.2.3 <1.3.0 |
|
if (compareSemver(version, comparator.slice(1)) < 0) return false; |
|
return v.major === target.major && v.minor === target.minor; |
|
} |
|
|
|
// Handle >=, <=, >, <, = |
|
if (comparator.startsWith(">=")) { |
|
return compareSemver(version, comparator.slice(2)) >= 0; |
|
} |
|
if (comparator.startsWith("<=")) { |
|
return compareSemver(version, comparator.slice(2)) <= 0; |
|
} |
|
if (comparator.startsWith(">")) { |
|
return compareSemver(version, comparator.slice(1)) > 0; |
|
} |
|
if (comparator.startsWith("<")) { |
|
return compareSemver(version, comparator.slice(1)) < 0; |
|
} |
|
if (comparator.startsWith("=")) { |
|
return compareSemver(version, comparator.slice(1)) === 0; |
|
} |
|
|
|
// Exact match |
|
return compareSemver(version, comparator) === 0; |
|
} |
|
|
|
/** |
|
* Check if a version satisfies a semver range. |
|
* Supports: <, <=, >, >=, ^, ~, =, exact, *, space-separated (AND), || (OR) |
|
*/ |
|
function satisfiesRange(version, range) { |
|
if (!parseSemver(version).valid) return false; |
|
if (!range || range === "*") return true; |
|
|
|
// Split by || for OR groups |
|
const orGroups = range.split("||").map((s) => s.trim()); |
|
|
|
return orGroups.some((group) => { |
|
// Split by space for AND conditions |
|
const comparators = group.split(/\s+/).filter(Boolean); |
|
return comparators.every((comp) => satisfiesComparator(version, comp)); |
|
}); |
|
} |
|
|
|
// --------------------------------------------------------------------------- |
|
// SBOM parsing |
|
// --------------------------------------------------------------------------- |
|
|
|
function extractPackages(sbomJson, repoName) { |
|
const packages = []; |
|
const sbom = sbomJson.sbom; |
|
|
|
if (!sbom?.packages) return packages; |
|
|
|
for (const pkg of sbom.packages) { |
|
if (pkg.SPDXID === "SPDXRef-DOCUMENT") continue; |
|
|
|
const purl = pkg.externalRefs?.find( |
|
(ref) => ref.referenceType === "purl", |
|
)?.referenceLocator; |
|
|
|
if (!purl) continue; |
|
|
|
// Parse purl: pkg:<ecosystem>/<name>@<version> |
|
const purlMatch = purl.match(/^pkg:([^/]+)\/(?:([^/]+)\/)?([^@]+)@(.+)$/); |
|
if (!purlMatch) continue; |
|
|
|
const ecosystem = purlMatch[1]; |
|
const namespace = purlMatch[2] || null; |
|
const name = purlMatch[3]; |
|
const version = purlMatch[4]; |
|
|
|
packages.push({ |
|
displayName: pkg.name, |
|
name, |
|
namespace, |
|
ecosystem, |
|
version, |
|
purl, |
|
repo: repoName, |
|
license: pkg.licenseConcluded || "NOASSERTION", |
|
}); |
|
} |
|
|
|
return packages; |
|
} |
|
|
|
// --------------------------------------------------------------------------- |
|
// Matching |
|
// --------------------------------------------------------------------------- |
|
|
|
function matchesVulnEntry(pkg, vuln) { |
|
// Match ecosystem (case-insensitive) |
|
if ( |
|
vuln.ecosystem && |
|
pkg.ecosystem.toLowerCase() !== vuln.ecosystem.toLowerCase() |
|
) { |
|
return false; |
|
} |
|
|
|
// Match name — support scoped packages like @scope/name |
|
const vulnName = vuln.name.toLowerCase(); |
|
const pkgFullName = pkg.namespace |
|
? `${pkg.namespace}/${pkg.name}`.toLowerCase() |
|
: pkg.name.toLowerCase(); |
|
const pkgDisplayName = pkg.displayName.toLowerCase(); |
|
|
|
if ( |
|
vulnName !== pkgFullName && |
|
vulnName !== pkg.name.toLowerCase() && |
|
vulnName !== pkgDisplayName |
|
) { |
|
return false; |
|
} |
|
|
|
// Match version range |
|
return satisfiesRange(pkg.version, vuln.vulnerableRange); |
|
} |
|
|
|
// --------------------------------------------------------------------------- |
|
// Main |
|
// --------------------------------------------------------------------------- |
|
|
|
const SEVERITY_ORDER = { |
|
CRITICAL: 0, |
|
HIGH: 1, |
|
MODERATE: 2, |
|
MEDIUM: 3, |
|
LOW: 4, |
|
UNKNOWN: 5, |
|
}; |
|
|
|
function severityRank(s) { |
|
return SEVERITY_ORDER[s?.toUpperCase()] ?? 5; |
|
} |
|
|
|
async function main() { |
|
// 1. Load vulnerability definitions |
|
console.log(`Loading vulnerability definitions from ${vulnsFile}…`); |
|
const vulnDefs = JSON.parse(await readFile(vulnsFile, "utf-8")); |
|
|
|
if (!Array.isArray(vulnDefs) || vulnDefs.length === 0) { |
|
console.error("Error: vulns file must be a non-empty JSON array."); |
|
process.exit(1); |
|
} |
|
|
|
console.log(`Loaded ${vulnDefs.length} vulnerability rule(s).\n`); |
|
|
|
// 2. Read all SBOM files |
|
console.log(`Reading SBOMs from ${inputDir}/…`); |
|
|
|
const files = (await readdir(inputDir)).filter((f) => |
|
f.endsWith(".spdx.json"), |
|
); |
|
|
|
if (files.length === 0) { |
|
console.error("No .spdx.json files found in", inputDir); |
|
process.exit(1); |
|
} |
|
|
|
console.log(`Found ${files.length} SBOM file(s).\n`); |
|
|
|
// 3. Extract packages and match against vuln defs |
|
const findings = []; // { pkg, vuln } |
|
|
|
let totalPkgs = 0; |
|
|
|
for (const file of files) { |
|
const repoName = file.replace(".spdx.json", ""); |
|
const raw = await readFile(join(inputDir, file), "utf-8"); |
|
const sbom = JSON.parse(raw); |
|
const packages = extractPackages(sbom, repoName); |
|
totalPkgs += packages.length; |
|
|
|
for (const pkg of packages) { |
|
for (const vuln of vulnDefs) { |
|
if (matchesVulnEntry(pkg, vuln)) { |
|
findings.push({ pkg, vuln }); |
|
} |
|
} |
|
} |
|
} |
|
|
|
console.log( |
|
`Scanned ${totalPkgs} package references across ${files.length} repos.\n`, |
|
); |
|
|
|
// 4. Group findings by vulnerability rule |
|
const grouped = new Map(); // vuln id/name -> { vuln, matches: [{ pkg }] } |
|
|
|
for (const { pkg, vuln } of findings) { |
|
const key = vuln.id || `${vuln.name}@${vuln.vulnerableRange}`; |
|
|
|
if (!grouped.has(key)) { |
|
grouped.set(key, { vuln, matches: [] }); |
|
} |
|
grouped.get(key).matches.push(pkg); |
|
} |
|
|
|
// Sort by severity |
|
const sortedGroups = [...grouped.values()].sort((a, b) => { |
|
return severityRank(a.vuln.severity) - severityRank(b.vuln.severity); |
|
}); |
|
|
|
// 5. Print results |
|
console.log("=".repeat(70)); |
|
console.log(" VULNERABILITY SCAN RESULTS"); |
|
console.log("=".repeat(70) + "\n"); |
|
|
|
const bySeverity = {}; |
|
for (const { vuln } of findings) { |
|
const sev = vuln.severity?.toUpperCase() || "UNKNOWN"; |
|
bySeverity[sev] = (bySeverity[sev] || 0) + 1; |
|
} |
|
|
|
console.log( |
|
`Total: ${findings.length} finding(s) across ${grouped.size} rule(s)\n`, |
|
); |
|
|
|
for (const sev of [ |
|
"CRITICAL", |
|
"HIGH", |
|
"MODERATE", |
|
"MEDIUM", |
|
"LOW", |
|
"UNKNOWN", |
|
]) { |
|
if (bySeverity[sev]) { |
|
console.log(` ${sev}: ${bySeverity[sev]}`); |
|
} |
|
} |
|
|
|
console.log(""); |
|
|
|
for (const { vuln, matches } of sortedGroups) { |
|
const sevBadge = `[${(vuln.severity || "UNKNOWN").toUpperCase()}]`; |
|
const id = vuln.id || "(no id)"; |
|
console.log(`${sevBadge} ${id} — ${vuln.name} ${vuln.vulnerableRange}`); |
|
|
|
if (vuln.description) { |
|
console.log(` ${vuln.description}`); |
|
} |
|
|
|
// Group matches by repo |
|
const byRepo = new Map(); |
|
for (const pkg of matches) { |
|
if (!byRepo.has(pkg.repo)) byRepo.set(pkg.repo, []); |
|
byRepo.get(pkg.repo).push(pkg); |
|
} |
|
|
|
for (const [repo, pkgs] of byRepo) { |
|
const versions = [...new Set(pkgs.map((p) => p.version))].join(", "); |
|
console.log(` ${repo}: ${pkgs[0].displayName}@${versions}`); |
|
} |
|
|
|
console.log(""); |
|
} |
|
|
|
// 6. Optionally write JSON report |
|
if (outputFile) { |
|
const report = { |
|
scanDate: new Date().toISOString(), |
|
inputDir, |
|
vulnsFile, |
|
totalRepos: files.length, |
|
totalPackages: totalPkgs, |
|
totalFindings: findings.length, |
|
findings: sortedGroups.map(({ vuln, matches }) => ({ |
|
rule: vuln, |
|
matches: matches.map((p) => ({ |
|
name: p.displayName, |
|
version: p.version, |
|
purl: p.purl, |
|
repo: p.repo, |
|
})), |
|
})), |
|
}; |
|
|
|
await writeFile(outputFile, JSON.stringify(report, null, 2)); |
|
console.log(`Full report saved to: ${outputFile}`); |
|
} |
|
|
|
if (findings.length === 0) { |
|
console.log("No vulnerabilities found — all clear!"); |
|
} |
|
} |
|
|
|
main().catch((err) => { |
|
console.error("Fatal error:", err); |
|
process.exit(1); |
|
}); |