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
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__/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..f7dfcb9 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);
@@ -514,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/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..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.
@@ -39,6 +51,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),
}));
@@ -107,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);
@@ -120,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
@@ -128,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 {
@@ -160,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 ---
/**
@@ -239,9 +484,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;
}