Skip to content

Instantly share code, notes, and snippets.

@JPBM135
Created March 31, 2026 17:38
Show Gist options
  • Select an option

  • Save JPBM135/4644c3b4f1e45407180cb9f4a1077e90 to your computer and use it in GitHub Desktop.

Select an option

Save JPBM135/4644c3b4f1e45407180cb9f4a1077e90 to your computer and use it in GitHub Desktop.
Dump and Scan SBOMMs

SBoomDump

Dump SBOMs (Software Bill of Materials) from a GitHub organization and scan them for known vulnerable packages.

Requirements

  • Node.js 18+
  • A GitHub personal access token with repo scope

Quick Start

1. Dump SBOMs

Export SPDX 2.3 JSON SBOMs for every repository in a GitHub org:

GITHUB_TOKEN=ghp_xxx node dump-sboms.mjs <org-name> [--output ./sboms] [--per-page 100]

This creates one .spdx.json file per repository in the output directory.

2. Define Vulnerabilities

Create a vulns.json file describing the packages and version ranges to flag:

[
  {
    "name": "lodash",
    "ecosystem": "npm",
    "vulnerableRange": "<4.17.21",
    "severity": "HIGH",
    "id": "CVE-2021-23337",
    "description": "Prototype pollution in lodash"
  }
]
Field Required Description
name Yes Package name
ecosystem Yes npm, pip, maven, nuget, go, cargo, etc.
vulnerableRange Yes Semver range (<4.17.21, ^3.0.0, >=1.0.0 <1.5.0, *, etc.)
severity No CRITICAL, HIGH, MEDIUM, LOW, or WARNING
id No CVE or other identifier
description No Human-readable description

See vulns.example.json for a working example.

3. Scan for Vulnerabilities

node find-vulnerabilities.mjs --vulns ./vulns.json [--input ./sboms] [--output ./report.json]

The report is written as JSON listing every matched package, the repository it was found in, and the matched vulnerability rule.

#!/usr/bin/env node
/**
* Dumps all SBOM (Software Bill of Materials) files from every repository
* in a GitHub organization using the GitHub dependency graph API.
*
* Usage:
* GITHUB_TOKEN=ghp_xxx node dump-sboms.mjs <org-name> [--output ./sboms]
*
* Requirements:
* - A GitHub personal access token with `repo` scope (or `read:org` + repo access)
* - Node.js 18+ (uses native fetch)
*/
import { writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
const GITHUB_API = "https://api.github.com";
// ---------------------------------------------------------------------------
// CLI args
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
function usage() {
console.error(
"Usage: GITHUB_TOKEN=ghp_xxx node dump-sboms.mjs <org> [--output ./sboms] [--per-page 100]"
);
process.exit(1);
}
if (args.includes("--help") || args.includes("-h") || args.length === 0) {
usage();
}
const org = args[0];
const outputDir = args.includes("--output")
? args[args.indexOf("--output") + 1]
: "./sboms";
const perPage = args.includes("--per-page")
? Number(args[args.indexOf("--per-page") + 1])
: 100;
const token = process.env.GITHUB_TOKEN;
if (!token) {
console.error("Error: GITHUB_TOKEN environment variable is required.");
process.exit(1);
}
// ---------------------------------------------------------------------------
// GitHub helpers
// ---------------------------------------------------------------------------
const headers = {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2022-11-28",
};
async function ghFetch(url) {
const res = await fetch(url, { headers });
if (res.status === 403 && res.headers.get("x-ratelimit-remaining") === "0") {
const reset = Number(res.headers.get("x-ratelimit-reset")) * 1000;
const wait = Math.max(reset - Date.now(), 0) + 1000;
console.warn(`Rate-limited. Waiting ${Math.ceil(wait / 1000)}s…`);
await new Promise((r) => setTimeout(r, wait));
return ghFetch(url);
}
if (!res.ok) {
const body = await res.text();
throw new Error(`GitHub API ${res.status}: ${url}\n${body}`);
}
return res;
}
async function ghFetchJson(url) {
const res = await ghFetch(url);
return res.json();
}
// ---------------------------------------------------------------------------
// Paginated repo listing
// ---------------------------------------------------------------------------
async function listAllRepos(org) {
const repos = [];
let page = 1;
while (true) {
const url = `${GITHUB_API}/orgs/${encodeURIComponent(org)}/repos?per_page=${perPage}&page=${page}&type=all&sort=full_name`;
const res = await ghFetch(url);
const batch = await res.json();
if (!Array.isArray(batch) || batch.length === 0) break;
repos.push(...batch);
console.log(` Fetched repos page ${page} (${repos.length} total)…`);
// Check Link header for next page
const link = res.headers.get("link") || "";
if (!link.includes('rel="next"')) break;
page++;
}
return repos;
}
// ---------------------------------------------------------------------------
// SBOM export
// ---------------------------------------------------------------------------
async function fetchSbom(owner, repo) {
const url = `${GITHUB_API}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/dependency-graph/sbom`;
try {
return await ghFetchJson(url);
} catch (err) {
// 404 = dependency graph not enabled or no dependencies
if (err.message.includes("404")) return null;
// 403 = no access to dependency graph for this repo
if (err.message.includes("403")) return null;
throw err;
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
console.log(`Fetching repositories for org "${org}"…\n`);
const repos = await listAllRepos(org);
console.log(`\nFound ${repos.length} repositories.\n`);
await mkdir(outputDir, { recursive: true });
let exported = 0;
let skipped = 0;
let errors = 0;
for (const repo of repos) {
const name = repo.name;
process.stdout.write(`[${exported + skipped + errors + 1}/${repos.length}] ${name}… `);
try {
const sbom = await fetchSbom(repo.owner.login, name);
if (!sbom) {
console.log("skipped (no SBOM available)");
skipped++;
continue;
}
const filename = `${name}.spdx.json`;
await writeFile(join(outputDir, filename), JSON.stringify(sbom, null, 2));
console.log(`✓ saved ${filename}`);
exported++;
} catch (err) {
console.log(`✗ error: ${err.message}`);
errors++;
}
}
console.log(
`\nDone! Exported: ${exported} | Skipped: ${skipped} | Errors: ${errors}`
);
console.log(`SBOM files saved to: ${outputDir}/`);
}
main().catch((err) => {
console.error("Fatal error:", err);
process.exit(1);
});
#!/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);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment