From 9973cfefd39fffb6b645e410435d0d0a1ad2d43e Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 7 Aug 2026 13:46:35 -0600 Subject: [PATCH 1/3] feat: parse component scope + --runtime-only filter for gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #56 Dev/test dependencies inflated diffs and tripped --fail-on gates, and the CycloneDX scope field was silently dropped — there was no way to gate on runtime risk only. - Parser: extract CycloneDX component scope (required/optional/excluded) - CLI: new --runtime-only flag filters dev/test (scope=optional/excluded) components out of the diff and the gate; unset scope = runtime (CDX default) and is kept - Help text documents the flag - 3 new tests: scope parsing, flag parsing, default false 115 tests pass, tsc clean. --- src/__tests__/cli.test.ts | 5 ++++ src/__tests__/parser.test.ts | 17 +++++++++++++ src/cli.ts | 48 +++++++++++++++++++++++++++++------- src/parser.ts | 19 +++++++++++--- src/types.ts | 6 +++++ 5 files changed, 83 insertions(+), 12 deletions(-) diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 0d0e520..1ab5cd1 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -62,6 +62,11 @@ describe('parseArgs', () => { it('throws when --fail-on is given without a value', () => { expect(() => parseArgs(['old.json', 'new.json', '--fail-on'])).toThrow(/Invalid --fail-on/); }); + + it('parses --runtime-only as true (default false)', () => { + expect(parseArgs(['old.json', 'new.json']).runtimeOnly).toBe(false); + expect(parseArgs(['old.json', 'new.json', '--runtime-only']).runtimeOnly).toBe(true); + }); }); describe('gateFailures', () => { diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts index 1ccfe0f..a2c8b8d 100644 --- a/src/__tests__/parser.test.ts +++ b/src/__tests__/parser.test.ts @@ -75,6 +75,23 @@ describe('parse (CycloneDX)', () => { expect(sbom.components[1].hashes).toBeUndefined(); }); + it('extracts the component scope (dev/test vs runtime) (issue #56)', () => { + const sbom = parse({ + bomFormat: 'CycloneDX', + specVersion: '1.4', + components: [ + { name: 'runtime-pkg', version: '1.0.0', scope: 'required' }, + { name: 'dev-pkg', version: '1.0.0', scope: 'optional' }, + { name: 'excluded-pkg', version: '1.0.0', scope: 'excluded' }, + { name: 'no-scope-pkg', version: '1.0.0' }, + ], + }); + expect(sbom.components[0].scope).toBe('required'); + expect(sbom.components[1].scope).toBe('optional'); + expect(sbom.components[2].scope).toBe('excluded'); + expect(sbom.components[3].scope).toBeUndefined(); + }); + it('parses vulnerabilities', () => { const sbom = parse(cyclonedxFixture); expect(sbom.vulnerabilities).toHaveLength(1); diff --git a/src/cli.ts b/src/cli.ts index d49b17b..fdc30c7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -49,18 +49,26 @@ Arguments: Options: --format Output format: text (default), json, or markdown + --fail-on Fail (exit 3) when a new CVE at/above this severity appears: + none (default), low, medium, high, critical, any + --runtime-only Only consider runtime components (scope=required); dev/test + (scope=optional/excluded) dependencies are filtered out of + the diff and the --fail-on gate -h, --help Show this help and exit -v, --version Print the installed version and exit Examples: sbom-diff old.json new.json sbom-diff old.json new.json --format json - sbom-diff old.json new.json --format markdown`; + sbom-diff old.json new.json --format markdown + sbom-diff old.json new.json --runtime-only --fail-on high`; export interface ParsedArgs { positional: string[]; format: ReportFormat; failOn: FailOn; + /** true when --runtime-only was requested (filter dev/test deps) */ + runtimeOnly: boolean; /** true when -h/--help was requested */ help: boolean; /** true when -v/--version was requested */ @@ -72,8 +80,8 @@ export interface ParsedArgs { * the CI/CD gate policy. * * Supports `--format text`, `--format=text`, `--fail-on high`, `--fail-on=high`, - * and flags appearing in any position relative to the positional file paths. - * Defaults to `text` format and a `none` gate policy. + * `--runtime-only`, and flags appearing in any position relative to the + * positional file paths. Defaults to `text` format and a `none` gate policy. * * `-h`/`--help` and `-v`/`--version` short-circuit parsing so they always * work — even alongside otherwise-invalid arguments — and never throw. @@ -82,15 +90,16 @@ export interface ParsedArgs { */ export function parseArgs(argv: string[]): ParsedArgs { if (argv.some(a => a === '-h' || a === '--help')) { - return { positional: [], format: 'text', failOn: 'none', help: true, version: false }; + return { positional: [], format: 'text', failOn: 'none', runtimeOnly: false, help: true, version: false }; } if (argv.some(a => a === '-v' || a === '-V' || a === '--version')) { - return { positional: [], format: 'text', failOn: 'none', help: false, version: true }; + return { positional: [], format: 'text', failOn: 'none', runtimeOnly: false, help: false, version: true }; } const positional: string[] = []; let format: ReportFormat = 'text'; let failOn: FailOn = 'none'; + let runtimeOnly = false; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; @@ -102,6 +111,8 @@ export function parseArgs(argv: string[]): ParsedArgs { failOn = assertFailOn(argv[++i]); } else if (arg.startsWith('--fail-on=')) { failOn = assertFailOn(arg.slice('--fail-on='.length)); + } else if (arg === '--runtime-only') { + runtimeOnly = true; } else if (arg.startsWith('-')) { throw new Error(`Unknown option: ${arg}\n${USAGE}`); } else { @@ -109,7 +120,7 @@ export function parseArgs(argv: string[]): ParsedArgs { } } - return { positional, format, failOn, help: false, version: false }; + return { positional, format, failOn, runtimeOnly, help: false, version: false }; } /** @@ -237,7 +248,7 @@ export async function loadSbom(path: string, label: string): Promise { } async function main(): Promise { - const { positional, format, failOn, help, version } = parseArgs(process.argv.slice(2)); + const { positional, format, failOn, runtimeOnly, help, version } = parseArgs(process.argv.slice(2)); if (help) { console.log(HELP); @@ -261,11 +272,17 @@ async function main(): Promise { loadSbom(newPath, 'new'), ]); - const report = diff(oldSBOM, newSBOM); + // With --runtime-only, drop dev/test (scope=optional/excluded) components so + // the diff and the --fail-on gate consider only production dependencies. + // A component without a scope is runtime by CycloneDX's default, so it stays. + const aFinal = runtimeOnly ? filterRuntimeOnly(oldSBOM) : oldSBOM; + const bFinal = runtimeOnly ? filterRuntimeOnly(newSBOM) : newSBOM; + + const report = diff(aFinal, bFinal); console.log(renderReport(report, format)); - const warning = gateWarning(oldSBOM, newSBOM, failOn); + const warning = gateWarning(aFinal, bFinal, failOn); if (warning) console.error(warning); const failures = gateFailures(report, failOn); @@ -278,6 +295,19 @@ async function main(): Promise { } } +/** + * Return a copy of the SBOM with only runtime components (those whose scope is + * "required" or unset). Dev/test/build dependencies (scope "optional" or + * "excluded") are filtered out. Vulnerabilities are kept as-is — they reference + * components by ref, and filtering them would misattribute blast radius. + */ +function filterRuntimeOnly(sbom: SBOM): SBOM { + return { + ...sbom, + components: sbom.components.filter(c => c.scope === undefined || c.scope === 'required'), + }; +} + // Only run when invoked directly (not when imported by tests). const invokedPath = process.argv[1]; if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href) { diff --git a/src/parser.ts b/src/parser.ts index 0b5accd..8c77996 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -39,6 +39,7 @@ export function parseCycloneDX(obj: Record): SBOM { license: extractCycloneDXLicense(c), ecosystem: extractEcosystemFromPurl(typeof c.purl === 'string' ? c.purl : ''), supplier: extractCycloneDXSupplier(c), + scope: extractCycloneDXScope(c), hashes: extractCycloneDXHashes(c), })); @@ -239,9 +240,21 @@ function extractSPDXLicense(pkg: Record): string | undefined { } function extractCycloneDXSupplier(c: Record): string | undefined { - const supplier = c.supplier as Record | undefined; - if (!supplier) return undefined; - return typeof supplier.name === 'string' ? supplier.name : undefined; + const supplier = c.supplier; + if (typeof supplier !== 'object' || supplier === null) return undefined; + return typeof (supplier as Record).name === 'string' ? (supplier as Record).name as string : undefined; +} + +/** + * Extract the CycloneDX component scope ("required" / "optional" / "excluded"). + * Returns undefined when absent, which is the meaning of "no scope" in CDX: + * scope defaults to "required" when omitted, but we keep it undefined so the + * reporter can show "default" rather than a misleading explicit value. + */ +function extractCycloneDXScope(c: Record): 'required' | 'optional' | 'excluded' | undefined { + const scope = c.scope; + if (scope === 'required' || scope === 'optional' || scope === 'excluded') return scope; + return undefined; } function extractCycloneDXAffects(v: Record): string[] { diff --git a/src/types.ts b/src/types.ts index 51b634f..9efec6f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,6 +21,12 @@ export interface Component { ecosystem?: string; /** Supplier / organization */ supplier?: string; + /** + * CycloneDX component scope: "required" (runtime), "optional" + * (dev/test/build), or "excluded". Lets gates/reports distinguish + * production dependencies from dev/test ones (issue #56). + */ + scope?: 'required' | 'optional' | 'excluded'; /** Hash values keyed by algorithm (sha256, sha1, md5) */ hashes?: Record; } From e2290a908636344625708d438a983c4b4daeba1f Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 7 Aug 2026 13:50:36 -0600 Subject: [PATCH 2/3] fix(release): derive tag from package.json and publish in the same run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #26 Three compounding defects in the release machinery: 1. Tag pushes used GITHUB_TOKEN, which cannot trigger publish.yml (GitHub blocks workflow re-entry from token-created events) — publish never fired. Now auto-tag.yml publishes in the SAME run after tagging. 2. The tagger derived the next version from the latest v* git tag (v0.0.x), diverging from package.json (1.0.1) forever. Now package.json is the source of truth and the tag is its next patch. 3. publish.yml (manual fallback) now warns when a tag version disagrees with package.json instead of silently overwriting. NPM_TOKEN secret doesn't exist on the repo yet: the publish step warns and skips gracefully when it's absent (tag still created). Add the secret to enable actual npm publication. --- .github/workflows/auto-tag.yml | 32 ++++++++++++++++++++++++-------- .github/workflows/publish.yml | 7 ++++++- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml index 522a97e..dca5d9c 100644 --- a/.github/workflows/auto-tag.yml +++ b/.github/workflows/auto-tag.yml @@ -1,4 +1,4 @@ -name: CI + Auto Tag +name: CI + Auto Tag & Publish on: push: @@ -6,11 +6,12 @@ on: jobs: ci-and-tag: - name: Test, Build & Tag + name: Test, Build, Tag & Publish runs-on: ubuntu-latest if: "!contains(github.event.head_commit.message, '[skip ci]')" permissions: contents: write + id-token: write # npm provenance steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -34,20 +35,35 @@ jobs: - name: Build run: npm run build + # Version source of truth is package.json, NOT the latest git tag. + # The old logic read the newest v* tag (v0.0.x) and bumped from there, + # so tags diverged from the package version forever (issue #26). - name: Tag HEAD with next patch version + id: version run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - # Determine next version from latest git tag (fallback to package.json) - LATEST=$(git tag -l 'v*' --sort=-version:refname | head -1) - if [ -z "$LATEST" ]; then - LATEST="v$(node -p "require('./package.json').version")" - fi - CURRENT="${LATEST#v}" + CURRENT="$(node -p "require('./package.json').version")" IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT" NEW_VERSION="$MAJOR.$MINOR.$((PATCH + 1))" git tag "v$NEW_VERSION" git push origin "v$NEW_VERSION" + echo "tagged=$NEW_VERSION" >> "$GITHUB_OUTPUT" echo "Tagged HEAD as v$NEW_VERSION" + + # Publish in the SAME run. A tag pushed with GITHUB_TOKEN cannot trigger + # publish.yml (GitHub blocks workflow re-entry from token-created events), + # so a separate tag-triggered workflow would never fire (issue #26). + - name: Publish to npm + if: steps.version.outputs.tagged != '' + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if [ -z "$NODE_AUTH_TOKEN" ]; then + echo "::warning::NPM_TOKEN secret not configured — tag created but npm publish skipped" + exit 0 + fi + npm version "${{ steps.version.outputs.tagged }}" --no-git-tag-version + npm publish --provenance --access public diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7f3602a..833e105 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -28,9 +28,14 @@ jobs: - name: Set version from tag run: | - # Use tag version if triggered by tag push, otherwise use package.json as-is + # Use tag version if triggered by tag push (keep the two in sync), + # otherwise use package.json as-is for manual dispatch. if [[ "$GITHUB_REF" == refs/tags/v* ]]; then TAG_VERSION="${GITHUB_REF#refs/tags/v}" + PKG_VERSION="$(node -p "require('./package.json').version")" + if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then + echo "warning: tag v$TAG_VERSION != package.json $PKG_VERSION; publishing as $TAG_VERSION" >&2 + fi npm version "$TAG_VERSION" --no-git-tag-version fi From 3523d2f7d3adfda918d780511c26302fbc2454e7 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 7 Aug 2026 14:26:30 -0600 Subject: [PATCH 3/3] feat(parser): support CycloneDX XML input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #27 CycloneDX XML (default output of cyclonedx-maven-plugin, cyclonedx-gradle-plugin, and many enterprise toolchains) was advertised ("CycloneDX (JSON/XML)") but parse() only handled JSON — XML input threw a cryptic "not valid JSON" SyntaxError. - parse() auto-routes a leading-< string to the new XML parser - parseCycloneDXXML maps bom components (name/version/purl/licenses/ supplier/hashes/scope), metadata, and vulnerabilities (id/ratings severity/affects/description/VEX state) onto the same canonical model as the JSON path — diff() and renderReport() need zero changes - Spec version extracted from the xmlns URI (…/bom/1.5), NOT the version attribute (which is the document version) - Clear ParseError for malformed/truncated/non-bom XML - Dependency: fast-xml-parser (MIT, zero native deps) 6 new tests including XML↔JSON ChangeReport equivalence. 121 pass. --- package-lock.json | 123 +++++++++++++++++ package.json | 7 +- src/__tests__/parser.test.ts | 105 +++++++++++++++ src/parser.ts | 254 ++++++++++++++++++++++++++++++++++- 4 files changed, 482 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index a082116..d7167e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "@hailbytes/sbom-diff", "version": "1.0.1", "license": "MIT", + "dependencies": { + "fast-xml-parser": "^5.10.1" + }, "bin": { "sbom-diff": "dist/cli.js" }, @@ -325,6 +328,18 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@oxc-project/types": { "version": "0.130.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", @@ -1083,6 +1098,18 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1447,6 +1474,45 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-builder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz", + "integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz", + "integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.1", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1617,6 +1683,18 @@ "node": ">=0.10.0" } }, + "node_modules/is-unsafe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz", + "integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2150,6 +2228,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -2337,6 +2430,21 @@ "dev": true, "license": "MIT" }, + "node_modules/strnum": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -2694,6 +2802,21 @@ "node": ">=0.10.0" } }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index e41aba1..3ea7176 100644 --- a/package.json +++ b/package.json @@ -54,11 +54,11 @@ "devDependencies": { "@types/node": "^26.1.2", "@vitest/coverage-v8": "^4.1.6", + "eslint": "^10.8.0", "globals": "^17.6.0", "typescript": "^6.0.3", "typescript-eslint": "^8.59.3", - "vitest": "^4.1.6", - "eslint": "^10.8.0" + "vitest": "^4.1.6" }, "publishConfig": { "access": "public", @@ -66,5 +66,8 @@ }, "engines": { "node": ">=20.19.0" + }, + "dependencies": { + "fast-xml-parser": "^5.10.1" } } diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts index a2c8b8d..f7dfcb9 100644 --- a/src/__tests__/parser.test.ts +++ b/src/__tests__/parser.test.ts @@ -531,3 +531,108 @@ describe('parse (input validation, issue #21)', () => { expect(parse({ spdxVersion: 'SPDX-2.3', packages: [] }).format).toBe('spdx'); }); }); + +describe('parse (CycloneDX XML, issue #27)', () => { + const xmlFixture = ` + + + + lodash + 4.17.20 + pkg:npm/lodash@4.17.20 + ABCDEF123456 + + + dev-tool + 1.0.0 + pkg:npm/dev-tool@1.0.0 + MIT + + + + + CVE-2021-44228 + critical10.0 + pkg:npm/lodash@4.17.20 + Log4Shell + exploitable + + +`; + + it('auto-routes a leading-< string to the XML parser', () => { + const sbom = parse(xmlFixture); + expect(sbom.format).toBe('cyclonedx'); + expect(sbom.components).toHaveLength(2); + expect(sbom.specVersion).toBe('1.5'); + }); + + it('maps XML components onto the canonical model (name/version/purl/hashes/scope/license)', () => { + const sbom = parse(xmlFixture); + expect(sbom.components[0]).toMatchObject({ + name: 'lodash', + version: '4.17.20', + purl: 'pkg:npm/lodash@4.17.20', + ecosystem: 'npm', + hashes: { 'sha-256': 'abcdef123456' }, + }); + expect(sbom.components[0].scope).toBeUndefined(); // absent scope = runtime default + expect(sbom.components[1].scope).toBe('optional'); + expect(sbom.components[1].license).toBe('MIT'); + }); + + it('maps XML vulnerabilities (id/severity/affects/description/VEX state)', () => { + const sbom = parse(xmlFixture); + expect(sbom.vulnerabilities).toHaveLength(1); + expect(sbom.vulnerabilities![0]).toMatchObject({ + id: 'CVE-2021-44228', + severity: 'critical', + cvssScore: 10.0, + affects: ['pkg:npm/lodash@4.17.20'], + description: 'Log4Shell', + analysisState: 'exploitable', + }); + }); + + it('produces the same ChangeReport as the equivalent JSON input', () => { + const xml = parse(` + + lodash4.17.20pkg:npm/lodash@4.17.20 + express4.18.2pkg:npm/express@4.18.2 + + `); + const json = parse({ + bomFormat: 'CycloneDX', + specVersion: '1.5', + components: [ + { name: 'lodash', version: '4.17.20', purl: 'pkg:npm/lodash@4.17.20' }, + { name: 'express', version: '4.18.2', purl: 'pkg:npm/express@4.18.2' }, + ], + }); + const xmlNew = parse(` + + lodash4.17.21pkg:npm/lodash@4.17.21 + express4.18.2pkg:npm/express@4.18.2 + new-pkg1.0.0pkg:npm/new-pkg@1.0.0 + + `); + const jsonNew = parse({ + bomFormat: 'CycloneDX', + specVersion: '1.5', + components: [ + { name: 'lodash', version: '4.17.21', purl: 'pkg:npm/lodash@4.17.21' }, + { name: 'express', version: '4.18.2', purl: 'pkg:npm/express@4.18.2' }, + { name: 'new-pkg', version: '1.0.0', purl: 'pkg:npm/new-pkg@1.0.0' }, + ], + }); + expect(diff(xml, xmlNew)).toEqual(diff(json, jsonNew)); + }); + + it('throws a clear ParseError for malformed XML', () => { + expect(() => parse('')).toThrow(/not valid XML|expected a |empty or truncated/); + }); + + it('throws a clear ParseError for non-bom XML', () => { + expect(() => parse('hi')).toThrow(/expected a /); + }); +}); diff --git a/src/parser.ts b/src/parser.ts index 8c77996..cddb7c7 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,4 +1,16 @@ import type { SBOM, Component, CVEEntry, SBOMFormat } from './types.js'; +import { XMLParser } from 'fast-xml-parser'; + +/** + * CycloneDX XML parser — shared instance (no per-call alloc overhead). + * Ignores attributes, collapses arrays, and preserves the xmlns namespace + * prefix so the XML tree maps to the same property names as the JSON parser. + */ +const _xmlParser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: '@_', + isArray: (name) => name === 'component' || name === 'vulnerability' || name === 'hash' || name === 'rating' || name === 'affects' || name === 'target' || name === 'license' || name === 'reference', +}); /** * Detect the SBOM format from a parsed JSON object. @@ -108,11 +120,230 @@ export function parseSPDX(obj: Record): SBOM { } /** - * Thrown when parse() is given input that is not a recognized SBOM document. - * The message explains what was expected so a wrong-format file (package.json, - * a truncated export, garbage JSON) fails loudly instead of silently passing - * a CI gate with "nothing changed". + * Parse a CycloneDX XML document into the canonical SBOM model. + * + * Field mapping mirrors the JSON parser so diff() and renderReport() are + * format-agnostic: components (name/version/purl/licenses/supplier/hashes/ + * scope), metadata (name/version/timestamp), and vulnerabilities (id/ratings + * severity/affects/description) all map onto the same shapes. + * + * Throws ParseError on malformed XML or a document that isn't a CycloneDX BOM. */ +export function parseCycloneDXXML(xml: string): SBOM { + let doc: unknown; + try { + doc = _xmlParser.parse(xml.replace(/^\uFEFF/, '')); + } catch (e) { + throw new ParseError(`input is not valid XML: ${(e as Error).message}`); + } + + if (typeof doc !== 'object' || doc === null) { + throw new ParseError('input is not a CycloneDX XML document: expected a root element'); + } + + // The root may be namespaced ("bom" plain or "bom:..."), and fast-xml-parser + // strips namespace prefixes from tag names by default. + const root = doc as Record; + const bom = (root.bom ?? root['cyclonedx:bom']) as Record | undefined; + if (!bom || typeof bom !== 'object') { + throw new ParseError('input is not a CycloneDX XML document: expected a root element'); + } + // A truncated/unclosed bom (e.g. "") parses leniently to an + // empty or string-valued node. Require at least one meaningful section. + const hasContent = + (bom.components !== undefined && bom.components !== '' && bom.components !== null) || + (bom.vulnerabilities !== undefined && bom.vulnerabilities !== '' && bom.vulnerabilities !== null) || + (bom.metadata !== undefined && bom.metadata !== '' && bom.metadata !== null) || + stringField(bom.serialNumber) !== undefined; + if (!hasContent) { + throw new ParseError('input is not a valid CycloneDX XML document: is empty or truncated'); + } + + const rawComponents = collectXMLElements(bom.components, 'component'); + const metadata = bom.metadata && typeof bom.metadata === 'object' ? bom.metadata as Record : {}; + const component = metadata.component && typeof metadata.component === 'object' + ? metadata.component as Record + : {}; + + const components: Component[] = rawComponents.map((c: Record) => ({ + purl: stringField(c.purl), + name: stringField(c.name) ?? 'unknown', + version: stringField(c.version) ?? extractVersionFromPurl(stringField(c.purl) ?? ''), + license: extractXMLLicense(c.licenses), + ecosystem: extractEcosystemFromPurl(stringField(c.purl) ?? ''), + supplier: extractXMLSupplier(c.supplier), + scope: extractXMLScope(c), + hashes: extractXMLHashes(c.hashes), + })); + + const vulnerabilities: CVEEntry[] = collectXMLElements(bom.vulnerabilities, 'vulnerability').map((v: Record) => { + const { severity, cvssScore } = extractXMlRating(v.ratings); + return { + id: stringField(v.id) ?? 'UNKNOWN', + affects: extractXMLAffects(v.affects), + severity, + cvssScore, + description: stringField(v.description), + analysisState: extractXMLAnalysisState(v), + }; + }); + + return { + format: 'cyclonedx', + // The spec version lives in the namespace URI (…/schema/bom/1.5), NOT the + // `version` attribute (which is the BOM document version, an incrementing + // integer). Prefer the xmlns; fall back to an explicit text. + specVersion: extractXMLSpecVersion(bom), + name: stringField(component.name) ?? stringField(bom.serialNumber) ?? undefined, + version: stringField(component.version) ?? undefined, + generatedAt: extractXMLTimestamp(metadata), + components, + vulnerabilities, + }; +} + +// --- XML helpers --- + +/** Read a string field, tolerating absent/empty values. */ +function stringField(v: unknown): string | undefined { + if (typeof v === 'string' && v.trim() !== '') return v.trim(); + return undefined; +} + +/** Collect a (possibly singular or namespaced) XML element list into an array. */ +function collectXMLElements(parent: unknown, tag: string): Record[] { + if (!parent || typeof parent !== 'object') return []; + const obj = parent as Record; + const direct = obj[tag] ?? obj[`cyclonedx:${tag}`]; + if (direct === undefined) return []; + const list = Array.isArray(direct) ? direct : [direct]; + return list.filter((x): x is Record => typeof x === 'object' && x !== null); +} + +/** Extract a license id/name from . */ +function extractXMLLicense(licenses: unknown): string | undefined { + const entries = collectXMLElements(licenses, 'license'); + for (const entry of entries) { + const id = stringField(entry.id) ?? stringField(entry['cyclonedx:id']); + if (id) return id; + const name = stringField(entry.name) ?? stringField(entry['cyclonedx:name']); + if (name) return name; + } + return undefined; +} + +/** Extract the supplier organization name from . */ +function extractXMLSupplier(supplier: unknown): string | undefined { + if (!supplier || typeof supplier !== 'object') return undefined; + const obj = supplier as Record; + return stringField(obj.name) ?? stringField(obj['cyclonedx:name']); +} + +/** Extract the CycloneDX scope attribute (type="required"|"optional"|"excluded"). */ +function extractXMLScope(c: Record): 'required' | 'optional' | 'excluded' | undefined { + const scope = stringField(c['@_scope']) ?? stringField(c.scope); + if (scope === 'required' || scope === 'optional' || scope === 'excluded') return scope; + return undefined; +} + +/** Extract into a {alg: value} map. */ +function extractXMLHashes(hashes: unknown): Record | undefined { + const entries = collectXMLElements(hashes, 'hash'); + if (entries.length === 0) return undefined; + const out: Record = {}; + for (const h of entries) { + const alg = stringField(h['@_alg']); + const value = stringField(h['#text']); + if (alg && value) out[alg.toLowerCase()] = value.toLowerCase(); + } + return Object.keys(out).length > 0 ? out : undefined; +} + +/** Extract the highest severity + CVSS from . */ +function extractXMlRating(ratings: unknown): { severity?: CVEEntry['severity']; cvssScore?: number } { + const entries = collectXMLElements(ratings, 'rating'); + let best: { severity?: CVEEntry['severity']; cvssScore?: number } = {}; + for (const r of entries) { + const severity = stringField(r.severity) as CVEEntry['severity'] | undefined; + // fast-xml-parser returns numeric elements as JS numbers, so accept both. + const raw = r.score; + const cvssScore = typeof raw === 'number' ? raw : stringField(raw) !== undefined ? Number(stringField(raw)) : undefined; + if ( + severity !== undefined && + severityRank(severity) > severityRank(best.severity) + ) { + best = { severity, cvssScore: cvssScore !== undefined && !Number.isNaN(cvssScore) ? cvssScore : undefined }; + } + } + return best; +} + +/** Extract the affected refs from . */ +function extractXMLAffects(affects: unknown): string[] { + // `` can contain one or more `` elements; fast-xml-parser + // gives us either a single target object or an array of them (possibly under + // the namespaced key). Normalize both shapes first. + let targets: unknown[] = []; + if (Array.isArray(affects)) { + for (const entry of affects) { + if (!entry || typeof entry !== 'object') continue; + const obj = entry as Record; + const t = obj.target ?? obj['cyclonedx:target']; + if (Array.isArray(t)) targets.push(...t); + else if (t !== undefined) targets.push(t); + } + } else if (affects && typeof affects === 'object') { + const obj = affects as Record; + const t = obj.target ?? obj['cyclonedx:target']; + if (Array.isArray(t)) targets = t; + else if (t !== undefined) targets = [t]; + } + const refs: string[] = []; + for (const t of targets) { + if (!t || typeof t !== 'object') continue; + const target = t as Record; + const ref = stringField(target.ref) ?? stringField(target['cyclonedx:ref']); + if (ref) refs.push(ref); + } + return refs.length > 0 ? refs : ['unknown']; +} + +/** Extract the VEX analysis state from . */ +function extractXMLAnalysisState(v: Record): string | undefined { + const analysis = v.analysis; + if (!analysis || typeof analysis !== 'object') return undefined; + const obj = analysis as Record; + const state = stringField(obj.state); + return state ? state.toLowerCase() : undefined; +} + +/** Extract the generation timestamp from . */ +function extractXMLTimestamp(metadata: Record): string | undefined { + return stringField(metadata.timestamp); +} + +/** Extract the CycloneDX spec version from the xmlns namespace URI. */ +function extractXMLSpecVersion(bom: Record): string | undefined { + const xmlns = stringField(bom['@_xmlns']) ?? stringField(bom['@_xmlns:cyclonedx']); + if (xmlns) { + const match = /\/bom\/([0-9]+\.[0-9]+)\/?$/.exec(xmlns); + if (match) return match[1]; + } + // Fallback: an explicit child (rare) — NOT the @version attribute, + // which is the document version, not the spec version. + return stringField(bom.version); +} + +/** Severity ordering used by the XML rating picker (shared with diff.ts logic). */ +function severityRank(sev: string | undefined): number { + switch (sev) { + case 'critical': return 4; + case 'high': return 3; + case 'medium': return 2; + case 'low': return 1; + default: return 0; + } +} export class ParseError extends Error { constructor(message: string) { super(message); @@ -121,7 +352,10 @@ export class ParseError extends Error { } /** - * Parse a JSON string or object into an SBOM, auto-detecting format. + * Parse a JSON string, XML string, or object into an SBOM, auto-detecting + * format. CycloneDX XML (the default output of cyclonedx-maven-plugin, + * cyclonedx-gradle-plugin, and many enterprise toolchains) is routed to the + * XML parser; JSON strings and objects go through the JSON path (issue #27). * * Throws ParseError when the input is not a recognized CycloneDX or SPDX * document. Silently accepting wrong-format input as an empty SBOM is a @@ -129,6 +363,10 @@ export class ParseError extends Error { * sail through a CI gate as if nothing changed (issue #21). */ export function parse(input: string | Record): SBOM { + if (typeof input === 'string' && looksLikeXML(input)) { + return parseCycloneDXXML(input); + } + let obj: unknown; if (typeof input === 'string') { try { @@ -161,6 +399,12 @@ export function parse(input: string | Record): SBOM { } } +/** True when a string starts with `<` (after optional BOM/whitespace), i.e. XML. */ +function looksLikeXML(input: string): boolean { + const trimmed = input.replace(/^\uFEFF/, '').trimStart(); + return trimmed.startsWith('<'); +} + // --- Helpers --- /**