Skip to content

Commit a6e7947

Browse files
authored
Follow CycloneDX 1.5 spec for SPDX license expressions (#975)
* Reuse same object on retry. Signed-off-by: Valentin Dide <[email protected]> * Add license expression handling. Signed-off-by: Valentin Dide <[email protected]> --------- Signed-off-by: Valentin Dide <[email protected]>
1 parent 083f8a6 commit a6e7947

File tree

7 files changed

+141
-27
lines changed

7 files changed

+141
-27
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ tmp/
22

33
# BOM generated by CDXGEN during local development
44
bom.json
5+
bomresults
56

67
# Logs
78
logs

binary.js

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ import {
1212
import { basename, dirname, join, resolve } from "node:path";
1313
import { spawnSync } from "node:child_process";
1414
import { PackageURL } from "packageurl-js";
15-
import { DEBUG_MODE, TIMEOUT_MS, findLicenseId } from "./utils.js";
15+
import {
16+
DEBUG_MODE,
17+
TIMEOUT_MS,
18+
findLicenseId,
19+
adjustLicenseInformation,
20+
isSpdxLicenseExpression
21+
} from "./utils.js";
1622

1723
import { URL, fileURLToPath } from "node:url";
1824

@@ -572,33 +578,28 @@ export function getOSPackages(src) {
572578
comp.licenses.length
573579
) {
574580
const newLicenses = [];
575-
for (const alic of comp.licenses) {
576-
if (alic.license.name) {
577-
// Licenses array can either be made of expressions or id/name but not both
578-
if (
579-
comp.licenses.length == 1 &&
580-
(alic.license.name.toUpperCase().includes(" AND ") ||
581-
alic.license.name.toUpperCase().includes(" OR "))
582-
) {
583-
newLicenses.push({ expression: alic.license.name });
581+
for (const aLic of comp.licenses) {
582+
if (aLic.license.name) {
583+
if (isSpdxLicenseExpression(aLic.license.name)) {
584+
newLicenses.push({ expression: aLic.license.name });
584585
} else {
585-
const possibleId = findLicenseId(alic.license.name);
586-
if (possibleId !== alic.license.name) {
586+
const possibleId = findLicenseId(aLic.license.name);
587+
if (possibleId !== aLic.license.name) {
587588
newLicenses.push({ license: { id: possibleId } });
588589
} else {
589590
newLicenses.push({
590-
license: { name: alic.license.name }
591+
license: { name: aLic.license.name }
591592
});
592593
}
593594
}
594595
} else if (
595-
Object.keys(alic).length &&
596-
Object.keys(alic.license).length
596+
Object.keys(aLic).length &&
597+
Object.keys(aLic.license).length
597598
) {
598-
newLicenses.push(alic);
599+
newLicenses.push(aLic);
599600
}
600601
}
601-
comp.licenses = newLicenses;
602+
comp.licenses = adjustLicenseInformation(newLicenses);
602603
}
603604
// Fix hashes
604605
if (

evinser.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import {
55
executeAtom,
66
getAllFiles,
77
getGradleCommand,
8-
getMavenCommand
8+
getMavenCommand,
9+
getTimestamp
910
} from "./utils.js";
1011
import { findCryptoAlgos } from "./cbomutils.js";
1112
import { tmpdir } from "node:os";
@@ -1085,31 +1086,31 @@ export const createEvinseFile = (sliceArtefacts, options) => {
10851086
bomJson.annotations.push({
10861087
subjects: [bomJson.serialNumber],
10871088
annotator: { component: bomJson.metadata.tools.components[0] },
1088-
timestamp: new Date().toISOString(),
1089+
timestamp: getTimestamp(),
10891090
text: fs.readFileSync(usagesSlicesFile, "utf8")
10901091
});
10911092
}
10921093
if (dataFlowSlicesFile && fs.existsSync(dataFlowSlicesFile)) {
10931094
bomJson.annotations.push({
10941095
subjects: [bomJson.serialNumber],
10951096
annotator: { component: bomJson.metadata.tools.components[0] },
1096-
timestamp: new Date().toISOString(),
1097+
timestamp: getTimestamp(),
10971098
text: fs.readFileSync(dataFlowSlicesFile, "utf8")
10981099
});
10991100
}
11001101
if (reachablesSlicesFile && fs.existsSync(reachablesSlicesFile)) {
11011102
bomJson.annotations.push({
11021103
subjects: [bomJson.serialNumber],
11031104
annotator: { component: bomJson.metadata.tools.components[0] },
1104-
timestamp: new Date().toISOString(),
1105+
timestamp: getTimestamp(),
11051106
text: fs.readFileSync(reachablesSlicesFile, "utf8")
11061107
});
11071108
}
11081109
}
11091110
// Increment the version
11101111
bomJson.version = (bomJson.version || 1) + 1;
11111112
// Set the current timestamp to indicate this is newer
1112-
bomJson.metadata.timestamp = new Date().toISOString();
1113+
bomJson.metadata.timestamp = getTimestamp();
11131114
delete bomJson.signature;
11141115
fs.writeFileSync(evinseOutFile, JSON.stringify(bomJson, null, null));
11151116
if (occEvidencePresent || csEvidencePresent || servicesPresent) {

index.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,8 @@ import {
114114
parseSwiftJsonTree,
115115
parseSwiftResolved,
116116
parseYarnLock,
117-
readZipEntry
117+
readZipEntry,
118+
getTimestamp
118119
} from "./utils.js";
119120
import {
120121
collectEnvInfo,
@@ -479,7 +480,7 @@ function addMetadata(parentComponent = {}, options = {}) {
479480
const lifecycles =
480481
options.specVersion >= 1.5 ? addLifecyclesSection(options) : undefined;
481482
const metadata = {
482-
timestamp: new Date().toISOString(),
483+
timestamp: getTimestamp(),
483484
tools,
484485
authors,
485486
supplier: undefined

utils.js

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,71 @@ function toBase64(hexString) {
274274
return Buffer.from(hexString, "hex").toString("base64");
275275
}
276276

277+
/**
278+
* Return the current timestamp in YYYY-MM-DDTHH:MM:SSZ format.
279+
*
280+
* @returns {string} ISO formatted timestamp, without milliseconds.
281+
*/
282+
export function getTimestamp() {
283+
return new Date().toISOString().split(".")[0] + "Z";
284+
}
285+
286+
/**
287+
* Method to determine if a license is a valid SPDX license expression
288+
*
289+
* @param {string} license License string
290+
* @returns {boolean} true if the license is a valid SPDX license expression
291+
* @see https://spdx.dev/learn/handling-license-info/
292+
**/
293+
export function isSpdxLicenseExpression(license) {
294+
if (!license) {
295+
return false;
296+
}
297+
298+
if (/[(\s]+/g.test(license)) {
299+
return true;
300+
}
301+
302+
if (license.endsWith("+")) {
303+
return true; // GPL-2.0+ means GPL-2.0 or any later version, at the licensee’s option.
304+
}
305+
306+
return false;
307+
}
308+
309+
/**
310+
* Convert the array of licenses to a CycloneDX 1.5 compliant license array.
311+
* This should return an array containing:
312+
* - one or more SPDX license if no expression is present
313+
* - the first license expression if at least one is present
314+
*
315+
* @param {Array} licenses Array of licenses
316+
* @returns {Array} CycloneDX 1.5 compliant license array
317+
*/
318+
export function adjustLicenseInformation(licenses) {
319+
if (!licenses || !Array.isArray(licenses)) {
320+
return [];
321+
}
322+
323+
const expressions = licenses.filter((f) => {
324+
return f.expression;
325+
});
326+
if (expressions.length >= 1) {
327+
if (expressions.length > 1) {
328+
console.warn("multiple license expressions found", expressions);
329+
}
330+
return [{ expression: expressions[0].expression }];
331+
} else {
332+
return licenses.map((l) => {
333+
if (typeof l.license === "object") {
334+
return l;
335+
} else {
336+
return { license: l };
337+
}
338+
});
339+
}
340+
}
341+
277342
/**
278343
* Performs a lookup + validation of the license specified in the
279344
* package. If the license is a valid SPDX license ID, set the 'id'
@@ -286,8 +351,8 @@ export function getLicenses(pkg) {
286351
if (!Array.isArray(license)) {
287352
license = [license];
288353
}
289-
return license
290-
.map((l) => {
354+
return adjustLicenseInformation(
355+
license.map((l) => {
291356
let licenseContent = {};
292357
if (typeof l === "string" || l instanceof String) {
293358
if (
@@ -309,6 +374,8 @@ export function getLicenses(pkg) {
309374
licenseContent.name = "CUSTOM";
310375
}
311376
licenseContent.url = l;
377+
} else if (isSpdxLicenseExpression(l)) {
378+
licenseContent.expression = l;
312379
} else {
313380
licenseContent.name = l;
314381
}
@@ -322,7 +389,7 @@ export function getLicenses(pkg) {
322389
}
323390
return licenseContent;
324391
})
325-
.map((l) => ({ license: l }));
392+
);
326393
} else {
327394
const knownLicense = getKnownLicense(undefined, pkg);
328395
if (knownLicense) {

utils.test.js

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2058,6 +2058,46 @@ test("get licenses", () => {
20582058
}
20592059
}
20602060
]);
2061+
2062+
licenses = getLicenses({
2063+
license: "GPL-2.0+"
2064+
});
2065+
expect(licenses).toEqual([
2066+
{
2067+
license: {
2068+
id: "GPL-2.0+",
2069+
url: "https://opensource.org/licenses/GPL-2.0+"
2070+
}
2071+
}
2072+
]);
2073+
2074+
licenses = getLicenses({
2075+
license: "(MIT or Apache-2.0)"
2076+
});
2077+
expect(licenses).toEqual([
2078+
{
2079+
expression: "(MIT or Apache-2.0)"
2080+
}
2081+
]);
2082+
2083+
// In case this is not a known license in the current build but it is a valid SPDX license expression
2084+
licenses = getLicenses({
2085+
license: "NOT-GPL-2.1+"
2086+
});
2087+
expect(licenses).toEqual([
2088+
{
2089+
expression: "NOT-GPL-2.1+"
2090+
}
2091+
]);
2092+
2093+
licenses = getLicenses({
2094+
license: "GPL-3.0-only WITH Classpath-exception-2.0"
2095+
});
2096+
expect(licenses).toEqual([
2097+
{
2098+
expression: "GPL-3.0-only WITH Classpath-exception-2.0"
2099+
}
2100+
]);
20612101
});
20622102

20632103
test("parsePkgJson", async () => {

validator.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ export const validateBom = (bomJson) => {
5050
);
5151
const isValid = validate(bomJson);
5252
if (!isValid) {
53+
console.log(
54+
`Schema validation failed for ${bomJson.metadata.component.name}`
55+
);
5356
console.log(validate.errors);
5457
return false;
5558
}

0 commit comments

Comments
 (0)