Skip to content

Commit 7028909

Browse files
feat: sanitize spdx document name during conversion
1 parent ee1913a commit 7028909

2 files changed

Lines changed: 286 additions & 6 deletions

File tree

src/spdx-to-cdx.test.ts

Lines changed: 235 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ describe('spdxToCdxBom', () => {
153153
assert.deepStrictEqual(result.metadata?.component, {
154154
'bom-ref': '@herodevs/eol-report-card@1.0.0',
155155
type: 'library',
156-
name: '@herodevs/eol-report-card',
156+
name: 'test-document',
157157
version: '1.0.0',
158158
description: '',
159159
purl: '',
@@ -168,6 +168,226 @@ describe('spdxToCdxBom', () => {
168168
// Non-root components should be in components array
169169
assert(result.components?.find((c) => c.name === 'some-dependency'));
170170
});
171+
172+
test('should use SPDX document name for metadata component name', () => {
173+
const result = buildSpdxAndConvert({
174+
name: 'My Application',
175+
documentDescribes: ['SPDXRef-Package-root'],
176+
packages: [
177+
{
178+
SPDXID: 'SPDXRef-Package-root',
179+
name: '@herodevs/eol-report-card',
180+
versionInfo: '1.0.0',
181+
downloadLocation: 'NOASSERTION',
182+
},
183+
],
184+
});
185+
186+
assert.equal(result.metadata?.component?.name, 'My Application');
187+
});
188+
189+
test('should strip trailing version from SPDX document name', () => {
190+
const result = buildSpdxAndConvert({
191+
name: 'Awesome App v1.2.3-beta.1',
192+
documentDescribes: ['SPDXRef-Package-root'],
193+
packages: [
194+
{
195+
SPDXID: 'SPDXRef-Package-root',
196+
name: '@herodevs/eol-report-card',
197+
versionInfo: '1.0.0',
198+
downloadLocation: 'NOASSERTION',
199+
},
200+
],
201+
});
202+
203+
assert.equal(result.metadata?.component?.name, 'Awesome App');
204+
});
205+
206+
test('should fall back to package name when document name is blank', () => {
207+
const result = buildSpdxAndConvert({
208+
name: ' ',
209+
documentDescribes: ['SPDXRef-Package-root'],
210+
packages: [
211+
{
212+
SPDXID: 'SPDXRef-Package-root',
213+
name: '@herodevs/eol-report-card',
214+
versionInfo: '1.0.0',
215+
downloadLocation: 'NOASSERTION',
216+
},
217+
],
218+
});
219+
220+
assert.equal(
221+
result.metadata?.component?.name,
222+
'@herodevs/eol-report-card',
223+
);
224+
});
225+
226+
test('should fall back to package name when document name is only a version', () => {
227+
const result = buildSpdxAndConvert({
228+
name: 'v1.2.3',
229+
documentDescribes: ['SPDXRef-Package-root'],
230+
packages: [
231+
{
232+
SPDXID: 'SPDXRef-Package-root',
233+
name: '@herodevs/eol-report-card',
234+
versionInfo: '1.0.0',
235+
downloadLocation: 'NOASSERTION',
236+
},
237+
],
238+
});
239+
240+
assert.equal(
241+
result.metadata?.component?.name,
242+
'@herodevs/eol-report-card',
243+
);
244+
});
245+
246+
test('should strip version from package name when falling back', () => {
247+
const cases = [
248+
{ packageName: 'myapp@1.2.3', expected: 'myapp' },
249+
{ packageName: 'myapp v1.0.0', expected: 'myapp' },
250+
{ packageName: 'myapp-1.2.3', expected: 'myapp' },
251+
{ packageName: 'my-app (v2.0.0)', expected: 'my-app' },
252+
];
253+
for (const { packageName, expected } of cases) {
254+
const result = buildSpdxAndConvert({
255+
name: '', // Empty document name forces fallback
256+
documentDescribes: ['SPDXRef-Package-root'],
257+
packages: [
258+
{
259+
SPDXID: 'SPDXRef-Package-root',
260+
name: packageName,
261+
versionInfo: '1.0.0',
262+
downloadLocation: 'NOASSERTION',
263+
},
264+
],
265+
});
266+
267+
assert.equal(
268+
result.metadata?.component?.name,
269+
expected,
270+
`Failed for package name: ${packageName}`,
271+
);
272+
}
273+
});
274+
275+
test('should handle Java/Maven style package names', () => {
276+
const result = buildSpdxAndConvert({
277+
name: 'org.springframework:spring-core-6.0.0',
278+
documentDescribes: ['SPDXRef-Package-root'],
279+
packages: [
280+
{
281+
SPDXID: 'SPDXRef-Package-root',
282+
name: 'org.springframework:spring-core',
283+
versionInfo: '6.0.0',
284+
downloadLocation: 'NOASSERTION',
285+
},
286+
],
287+
});
288+
289+
assert.equal(
290+
result.metadata?.component?.name,
291+
'org.springframework:spring-core',
292+
);
293+
});
294+
295+
test('should handle Java JAR-style names with versions', () => {
296+
const result = buildSpdxAndConvert({
297+
name: '', // Empty to test fallback
298+
documentDescribes: ['SPDXRef-Package-root'],
299+
packages: [
300+
{
301+
SPDXID: 'SPDXRef-Package-root',
302+
name: 'spring-core-6.0.0',
303+
versionInfo: '6.0.0',
304+
downloadLocation: 'NOASSERTION',
305+
},
306+
],
307+
});
308+
309+
assert.equal(result.metadata?.component?.name, 'spring-core');
310+
});
311+
312+
test('synthetic component should NOT be in dependencies array', () => {
313+
const result = buildSpdxAndConvert({
314+
name: 'My App',
315+
packages: [
316+
{
317+
SPDXID: 'SPDXRef-pkg',
318+
name: 'lodash',
319+
versionInfo: '4.17.21',
320+
downloadLocation: 'NOASSERTION',
321+
},
322+
],
323+
});
324+
325+
assert.equal(result.metadata?.component?.name, 'My App');
326+
assert.equal(
327+
result.dependencies?.find((d) => d.ref === 'My App'),
328+
undefined,
329+
);
330+
});
331+
332+
test('synthetic component should have type application', () => {
333+
const result = buildSpdxAndConvert({ name: 'My App', packages: [] });
334+
335+
assert.equal(result.metadata?.component?.type, 'application');
336+
});
337+
338+
test('should strip various version formats from document name', () => {
339+
const cases = [
340+
{ input: '@scope/pkg@1.0.0', expected: '@scope/pkg' },
341+
{ input: 'My App v2.0.0', expected: 'My App' },
342+
{ input: 'Project-1.0.0-beta.1', expected: 'Project' },
343+
{ input: 'App version 3.0', expected: 'App' },
344+
{ input: 'My App (v2.0.0)', expected: 'My App' },
345+
{ input: 'My App [2.0.0]', expected: 'My App' },
346+
{ input: 'Project 2024', expected: 'Project 2024' }, // NOT stripped - year only
347+
];
348+
for (const { input, expected } of cases) {
349+
const result = buildSpdxAndConvert({ name: input, packages: [] });
350+
assert.equal(
351+
result.metadata?.component?.name,
352+
expected,
353+
`Failed for: ${input}`,
354+
);
355+
}
356+
});
357+
358+
test('package component names should NOT use document name', () => {
359+
const result = buildSpdxAndConvert({
360+
name: 'My App v1.0.0',
361+
packages: [
362+
{
363+
SPDXID: 'SPDXRef-pkg',
364+
name: 'lodash',
365+
versionInfo: '4.17.21',
366+
downloadLocation: 'NOASSERTION',
367+
},
368+
],
369+
});
370+
371+
assert.equal(result.components?.[0]?.name, 'lodash');
372+
});
373+
374+
test('should have undefined metadata.component when no root package and no document name', () => {
375+
const result = buildSpdxAndConvert({
376+
name: '', // Empty document name
377+
packages: [
378+
{
379+
SPDXID: 'SPDXRef-pkg',
380+
name: 'lodash',
381+
versionInfo: '4.17.21',
382+
downloadLocation: 'NOASSERTION',
383+
},
384+
],
385+
// No documentDescribes
386+
});
387+
388+
assert.equal(result.metadata?.component, undefined);
389+
assert.equal(result.components?.length, 1);
390+
});
171391
});
172392

173393
describe('Component Mapping', () => {
@@ -606,6 +826,7 @@ describe('spdxToCdxBom', () => {
606826
describe('Root Component Identification', () => {
607827
test('should identify root component from documentDescribes', () => {
608828
const result = buildSpdxAndConvert({
829+
name: 'my-app',
609830
documentDescribes: ['SPDXRef-Package-root'],
610831
packages: [
611832
{
@@ -667,7 +888,7 @@ describe('spdxToCdxBom', () => {
667888
],
668889
});
669890

670-
assert.equal(result.metadata?.component, undefined);
891+
assert.equal(result.metadata?.component?.name, 'test-document');
671892
assert.equal(result.components?.length, 1);
672893
});
673894

@@ -691,7 +912,10 @@ describe('spdxToCdxBom', () => {
691912
});
692913

693914
// Should take the last one as root (implementation overwrites rootComponent)
694-
assert.equal(result.metadata?.component?.name, 'second-root');
915+
assert.equal(
916+
result.metadata?.component?.['bom-ref'],
917+
'second-root@2.0.0',
918+
);
695919
// Both components marked as root, so neither goes to components array
696920
assert.equal(result.components?.length, 0);
697921
});
@@ -1155,6 +1379,7 @@ describe('spdxToCdxBom', () => {
11551379
describe('Integration Tests', () => {
11561380
test('should convert complete real-world SPDX BOM', () => {
11571381
const complexSpdx = {
1382+
name: '@my/app',
11581383
documentDescribes: ['SPDXRef-Package-root'],
11591384
packages: [
11601385
{
@@ -1292,7 +1517,13 @@ describe('spdxToCdxBom', () => {
12921517

12931518
assert.deepStrictEqual(result.components, []);
12941519
assert.deepStrictEqual(result.dependencies, []);
1295-
assert.equal(result.metadata?.component, undefined);
1520+
assert.deepStrictEqual(result.metadata?.component, {
1521+
'bom-ref': 'test-document',
1522+
type: 'application',
1523+
name: 'test-document',
1524+
version: '',
1525+
description: '',
1526+
});
12961527
});
12971528

12981529
test('should handle components with special characters in names', () => {

src/spdx-to-cdx.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ const algorithmMap: Record<string, Enums.HashAlgorithm> = {
3737

3838
const LICENSE_EXPRESSION_REGEX = /\b(AND|OR|WITH)\b|\(|\)/;
3939
const TOOL_NAME_REGEX = /^(.+)[-@](\d.*)$/;
40+
// Remove common trailing version suffixes like "App v1.2.3", "pkg@1.0.0", "(version 2)" etc.
41+
const TRAILING_VERSION_REGEXES = [
42+
/(?:^|[\s\-_.()\[\]@])v(?:ersion)?\.?\s*\d+(?:\.\d+)*(?:[-+_.][0-9A-Za-z.-]+)?(?:\s*[\)\]\}])?$/i,
43+
/(?:^|[\s\-_.()\[\]@])\d+\.\d+(?:\.\d+)*(?:[-+_.][0-9A-Za-z.-]+)?(?:\s*[\)\]\}])?$/i,
44+
];
4045

4146
function upgrade(c: Component, next: Scope) {
4247
if (!c.scope || rank[next] > rank[c.scope]) c.scope = next;
@@ -58,6 +63,32 @@ function mapScope(rel: string): Scope {
5863
}
5964
}
6065

66+
function sanitizeSpdxDocumentName(name?: string): string | null {
67+
const trimmedName = name?.trim();
68+
if (!trimmedName) return null;
69+
70+
for (const regex of TRAILING_VERSION_REGEXES) {
71+
const sanitized = trimmedName.replace(regex, '').trim();
72+
if (sanitized !== trimmedName) {
73+
return sanitized || null;
74+
}
75+
}
76+
77+
return trimmedName;
78+
}
79+
80+
function resolveMetadataComponentName(
81+
spdxDocumentName: string | undefined,
82+
rootComponentName: string | null,
83+
): string | null {
84+
const documentName = sanitizeSpdxDocumentName(spdxDocumentName);
85+
if (documentName) return documentName;
86+
if (rootComponentName) {
87+
return sanitizeSpdxDocumentName(rootComponentName) || rootComponentName;
88+
}
89+
return null;
90+
}
91+
6192
/**
6293
* Converts an SPDX BOM to CycloneDX format.
6394
* Takes the most important package and relationship data from SPDX and translates them into CycloneDX components and dependencies as closely as possible.
@@ -156,9 +187,27 @@ export function spdxToCdxBom(spdx: SPDX23): CdxBom {
156187
idx.set(p.SPDXID, component);
157188
}
158189

159-
if (rootComponent) {
160-
bom.metadata!.component = rootComponent;
190+
const metadataName = resolveMetadataComponentName(
191+
spdx.name,
192+
rootComponent?.name ?? null,
193+
);
194+
195+
if (rootComponent && metadataName) {
196+
rootComponent.name = metadataName;
197+
}
198+
199+
if (rootComponent || metadataName) {
200+
bom.metadata!.component =
201+
rootComponent ||
202+
({
203+
'bom-ref': metadataName,
204+
type: Enums.ComponentType.Application,
205+
name: metadataName,
206+
version: '',
207+
description: '',
208+
} as Component);
161209
}
210+
162211
const deps = new Map<string, Dependency>();
163212

164213
for (const component of idx.values()) {

0 commit comments

Comments
 (0)