From 1f21d3dbbe5b55ac77ed63abcd140493f27ce76f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 03:04:51 +0000 Subject: [PATCH] fix(parser): flatten nested CycloneDX components so they are diffed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CycloneDX components form a tree: a component may carry its own nested `components` array (sub-assemblies / bundled dependencies), a valid and documented structure emitted by several generators. parseCycloneDX only read the top-level array, so every nested component was silently dropped and never appeared as added/removed/upgraded — a false-negative for a supply-chain tool. Recursively flatten the component tree depth-first (parent before children, preserving document order). The change is confined to the parser; the canonical SBOM model is unchanged, so diff/reporter/CLI are untouched. Adds a test covering two levels of nesting. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015zPMEojF5G1qFdQUrxfaXC --- src/__tests__/parser.test.ts | 27 +++++++++++++++++++++++++++ src/parser.ts | 27 ++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/__tests__/parser.test.ts b/src/__tests__/parser.test.ts index e0378fb..9bb2c1e 100644 --- a/src/__tests__/parser.test.ts +++ b/src/__tests__/parser.test.ts @@ -108,6 +108,33 @@ describe('parse (CycloneDX)', () => { expect(sbom.name).toBe('my-app'); expect(sbom.version).toBe('1.0.0'); }); + + it('flattens nested sub-components (assembly hierarchy)', () => { + const sbom = parse({ + bomFormat: 'CycloneDX', + specVersion: '1.5', + components: [ + { + name: 'app', + version: '1.0.0', + purl: 'pkg:npm/app@1.0.0', + 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', + // A second level of nesting must be picked up as well. + components: [{ name: 'qs', version: '6.11.0', purl: 'pkg:npm/qs@6.11.0' }], + }, + ], + }, + ], + }); + // Depth-first, parent before children, document order preserved. + expect(sbom.components.map((c) => c.name)).toEqual(['app', 'lodash', 'express', 'qs']); + expect(sbom.components.find((c) => c.name === 'qs')?.version).toBe('6.11.0'); + }); }); describe('parse (SPDX)', () => { diff --git a/src/parser.ts b/src/parser.ts index 6232fc2..6dc3cd0 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -19,7 +19,11 @@ export function detectFormat(obj: Record): SBOMFormat { * Parse a CycloneDX JSON SBOM object into our canonical SBOM type. */ export function parseCycloneDX(obj: Record): SBOM { - const rawComponents = Array.isArray(obj.components) ? obj.components : []; + // CycloneDX components form a tree: a component may carry its own nested + // `components` array (sub-assemblies / bundled dependencies). Flatten the + // whole tree so nested components are diffed too — otherwise an upgraded or + // newly tampered nested dependency would be silently invisible. + const rawComponents = flattenCycloneDXComponents(obj.components); const rawVulns = Array.isArray(obj.vulnerabilities) ? obj.vulnerabilities : []; const metadata = obj.metadata && typeof obj.metadata === 'object' ? obj.metadata as Record : {}; const component = metadata.component && typeof metadata.component === 'object' @@ -98,6 +102,27 @@ export function parse(input: string | Record): SBOM { // --- Helpers --- +/** + * Recursively flatten a CycloneDX `components` array, including any components + * nested under a parent component's own `components` array. Components are + * emitted depth-first (parent before its children), preserving document order. + * + * JSON input cannot contain reference cycles, so plain recursion terminates. + */ +function flattenCycloneDXComponents(input: unknown): Record[] { + if (!Array.isArray(input)) return []; + const flat: Record[] = []; + for (const raw of input) { + if (!raw || typeof raw !== 'object') continue; + const comp = raw as Record; + flat.push(comp); + if (Array.isArray(comp.components)) { + flat.push(...flattenCycloneDXComponents(comp.components)); + } + } + return flat; +} + function extractEcosystemFromPurl(purl: string): string | undefined { const match = purl.match(/^pkg:([^/]+)\//); return match ? match[1] : undefined;