Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/__tests__/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
27 changes: 26 additions & 1 deletion src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ export function detectFormat(obj: Record<string, unknown>): SBOMFormat {
* Parse a CycloneDX JSON SBOM object into our canonical SBOM type.
*/
export function parseCycloneDX(obj: Record<string, unknown>): 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<string, unknown> : {};
const component = metadata.component && typeof metadata.component === 'object'
Expand Down Expand Up @@ -98,6 +102,27 @@ export function parse(input: string | Record<string, unknown>): 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<string, unknown>[] {
if (!Array.isArray(input)) return [];
const flat: Record<string, unknown>[] = [];
for (const raw of input) {
if (!raw || typeof raw !== 'object') continue;
const comp = raw as Record<string, unknown>;
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;
Expand Down
Loading