Repository: CycloneDX/cdxgen (js)

Issue:
cdxgen does not follow CycloneDX 1.4 spec for SPDX license expressions
Per the [CycloneDX 1.4 Spec](https://cyclonedx.org/docs/1.4/json/#components_items_licenses_items_expression), SPDX expressions should be added under the licenses array as the `expression` object, not the `license` object. This requires the utility to match the input license against SPDX expressions and make the appropriate choice.

Current behavior:

`type-fest`:

```json
    {
      "group": "",
      "name": "type-fest",
      "version": "0.20.2",
      "hashes": [
        {
          "alg": "SHA-512",
          "content": "35ef9e138af4fe25a7a40c43f39db3dc0f8dd01b7944dfff36327045dd95147126af2c317f9bec66587847a962c65e81fb0cfff1dfa669348090dd452242372d"
        }
      ],
      "licenses": [
        {
          "license": {
            "name": "(MIT OR CC0-1.0)"
          }
        }
      ],
      "purl": "pkg:npm/type-fest@0.20.2",
      "type": "library",
      "bom-ref": "pkg:npm/type-fest@0.20.2",
      "properties": [
        {
          "name": "SrcFile",
          "value": "/app/package-lock.json"
        }
      ]
    },
```

Expected Behavior:

`type-fest`:
```json
    {
      "group": "",
      "name": "type-fest",
      "version": "0.20.2",
      "hashes": [
        {
          "alg": "SHA-512",
          "content": "35ef9e138af4fe25a7a40c43f39db3dc0f8dd01b7944dfff36327045dd95147126af2c317f9bec66587847a962c65e81fb0cfff1dfa669348090dd452242372d"
        }
      ],
      "licenses": [
        {
          "expression": "(MIT AND CC0-1.0)"
        }
      ],
      "purl": "pkg:npm/type-fest@0.20.2",
      "type": "library",
      "bom-ref": "pkg:npm/type-fest@0.20.2",
      "properties": [
        {
          "name": "SrcFile",
          "value": "/app/package-lock.json"
        }
      ]
    },
```

My example came from an npm package. The `license` field in package.json can be an SPDX expression.

`type-fest`:

```json
    "node_modules/serialize-error/node_modules/type-fest": {
      "version": "0.20.2",
      "resolved": "<url>",
      "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
      "inBundle": true,
      "license": "(MIT OR CC0-1.0)",
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
```

This issue may relate to https://github.com/CycloneDX/cdxgen/issues/41

Pull request description:
Follow CycloneDX 1.5 spec for SPDX license expressions
Somewhat duplicate of pull request #690.

Closes #679.

@prabhu, I am not sure if you want the implementation to check all of those cases also considering there is a list of known licenses. If you think something is wrong let me know.


Patch:
diff --git a/.gitignore b/.gitignore
index 3c460e0c..13332ad0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@ tmp/
 
 # BOM generated by CDXGEN during local development
 bom.json
+bomresults
 
 # Logs
 logs
diff --git a/binary.js b/binary.js
index 63c23be2..3d568063 100644
--- a/binary.js
+++ b/binary.js
@@ -12,7 +12,13 @@ import {
 import { basename, dirname, join, resolve } from "node:path";
 import { spawnSync } from "node:child_process";
 import { PackageURL } from "packageurl-js";
-import { DEBUG_MODE, TIMEOUT_MS, findLicenseId } from "./utils.js";
+import {
+  DEBUG_MODE,
+  TIMEOUT_MS,
+  findLicenseId,
+  adjustLicenseInformation,
+  isSpdxLicenseExpression
+} from "./utils.js";
 
 import { URL, fileURLToPath } from "node:url";
 
@@ -572,33 +578,28 @@ export function getOSPackages(src) {
               comp.licenses.length
             ) {
               const newLicenses = [];
-              for (const alic of comp.licenses) {
-                if (alic.license.name) {
-                  // Licenses array can either be made of expressions or id/name but not both
-                  if (
-                    comp.licenses.length == 1 &&
-                    (alic.license.name.toUpperCase().includes(" AND ") ||
-                      alic.license.name.toUpperCase().includes(" OR "))
-                  ) {
-                    newLicenses.push({ expression: alic.license.name });
+              for (const aLic of comp.licenses) {
+                if (aLic.license.name) {
+                  if (isSpdxLicenseExpression(aLic.license.name)) {
+                    newLicenses.push({ expression: aLic.license.name });
                   } else {
-                    const possibleId = findLicenseId(alic.license.name);
-                    if (possibleId !== alic.license.name) {
+                    const possibleId = findLicenseId(aLic.license.name);
+                    if (possibleId !== aLic.license.name) {
                       newLicenses.push({ license: { id: possibleId } });
                     } else {
                       newLicenses.push({
-                        license: { name: alic.license.name }
+                        license: { name: aLic.license.name }
                       });
                     }
                   }
                 } else if (
-                  Object.keys(alic).length &&
-                  Object.keys(alic.license).length
+                  Object.keys(aLic).length &&
+                  Object.keys(aLic.license).length
                 ) {
-                  newLicenses.push(alic);
+                  newLicenses.push(aLic);
                 }
               }
-              comp.licenses = newLicenses;
+              comp.licenses = adjustLicenseInformation(newLicenses);
             }
             // Fix hashes
             if (
diff --git a/evinser.js b/evinser.js
index a920a8b3..a1036529 100644
--- a/evinser.js
+++ b/evinser.js
@@ -5,7 +5,8 @@ import {
   executeAtom,
   getAllFiles,
   getGradleCommand,
-  getMavenCommand
+  getMavenCommand,
+  getTimestamp
 } from "./utils.js";
 import { findCryptoAlgos } from "./cbomutils.js";
 import { tmpdir } from "node:os";
@@ -1085,7 +1086,7 @@ export const createEvinseFile = (sliceArtefacts, options) => {
       bomJson.annotations.push({
         subjects: [bomJson.serialNumber],
         annotator: { component: bomJson.metadata.tools.components[0] },
-        timestamp: new Date().toISOString(),
+        timestamp: getTimestamp(),
         text: fs.readFileSync(usagesSlicesFile, "utf8")
       });
     }
@@ -1093,7 +1094,7 @@ export const createEvinseFile = (sliceArtefacts, options) => {
       bomJson.annotations.push({
         subjects: [bomJson.serialNumber],
         annotator: { component: bomJson.metadata.tools.components[0] },
-        timestamp: new Date().toISOString(),
+        timestamp: getTimestamp(),
         text: fs.readFileSync(dataFlowSlicesFile, "utf8")
       });
     }
@@ -1101,7 +1102,7 @@ export const createEvinseFile = (sliceArtefacts, options) => {
       bomJson.annotations.push({
         subjects: [bomJson.serialNumber],
         annotator: { component: bomJson.metadata.tools.components[0] },
-        timestamp: new Date().toISOString(),
+        timestamp: getTimestamp(),
         text: fs.readFileSync(reachablesSlicesFile, "utf8")
       });
     }
@@ -1109,7 +1110,7 @@ export const createEvinseFile = (sliceArtefacts, options) => {
   // Increment the version
   bomJson.version = (bomJson.version || 1) + 1;
   // Set the current timestamp to indicate this is newer
-  bomJson.metadata.timestamp = new Date().toISOString();
+  bomJson.metadata.timestamp = getTimestamp();
   delete bomJson.signature;
   fs.writeFileSync(evinseOutFile, JSON.stringify(bomJson, null, null));
   if (occEvidencePresent || csEvidencePresent || servicesPresent) {
diff --git a/index.js b/index.js
index 650e965a..94cdf94e 100644
--- a/index.js
+++ b/index.js
@@ -114,7 +114,8 @@ import {
   parseSwiftJsonTree,
   parseSwiftResolved,
   parseYarnLock,
-  readZipEntry
+  readZipEntry,
+  getTimestamp
 } from "./utils.js";
 import {
   collectEnvInfo,
@@ -479,7 +480,7 @@ function addMetadata(parentComponent = {}, options = {}) {
   const lifecycles =
     options.specVersion >= 1.5 ? addLifecyclesSection(options) : undefined;
   const metadata = {
-    timestamp: new Date().toISOString(),
+    timestamp: getTimestamp(),
     tools,
     authors,
     supplier: undefined
diff --git a/utils.js b/utils.js
index 24f57aea..eb84f40d 100644
--- a/utils.js
+++ b/utils.js
@@ -274,6 +274,71 @@ function toBase64(hexString) {
   return Buffer.from(hexString, "hex").toString("base64");
 }
 
+/**
+ * Return the current timestamp in YYYY-MM-DDTHH:MM:SSZ format.
+ *
+ * @returns {string} ISO formatted timestamp, without milliseconds.
+ */
+export function getTimestamp() {
+  return new Date().toISOString().split(".")[0] + "Z";
+}
+
+/**
+ * Method to determine if a license is a valid SPDX license expression
+ *
+ * @param {string} license License string
+ * @returns {boolean} true if the license is a valid SPDX license expression
+ * @see https://spdx.dev/learn/handling-license-info/
+ **/
+export function isSpdxLicenseExpression(license) {
+  if (!license) {
+    return false;
+  }
+
+  if (/[(\s]+/g.test(license)) {
+    return true;
+  }
+
+  if (license.endsWith("+")) {
+    return true; // GPL-2.0+ means GPL-2.0 or any later version, at the licensee’s option.
+  }
+
+  return false;
+}
+
+/**
+ * Convert the array of licenses to a CycloneDX 1.5 compliant license array.
+ * This should return an array containing:
+ * - one or more SPDX license if no expression is present
+ * - the first license expression if at least one is present
+ *
+ * @param {Array} licenses Array of licenses
+ * @returns {Array} CycloneDX 1.5 compliant license array
+ */
+export function adjustLicenseInformation(licenses) {
+  if (!licenses || !Array.isArray(licenses)) {
+    return [];
+  }
+
+  const expressions = licenses.filter((f) => {
+    return f.expression;
+  });
+  if (expressions.length >= 1) {
+    if (expressions.length > 1) {
+      console.warn("multiple license expressions found", expressions);
+    }
+    return [{ expression: expressions[0].expression }];
+  } else {
+    return licenses.map((l) => {
+      if (typeof l.license === "object") {
+        return l;
+      } else {
+        return { license: l };
+      }
+    });
+  }
+}
+
 /**
  * Performs a lookup + validation of the license specified in the
  * package. If the license is a valid SPDX license ID, set the 'id'
@@ -286,8 +351,8 @@ export function getLicenses(pkg) {
     if (!Array.isArray(license)) {
       license = [license];
     }
-    return license
-      .map((l) => {
+    return adjustLicenseInformation(
+      license.map((l) => {
         let licenseContent = {};
         if (typeof l === "string" || l instanceof String) {
           if (
@@ -309,6 +374,8 @@ export function getLicenses(pkg) {
               licenseContent.name = "CUSTOM";
             }
             licenseContent.url = l;
+          } else if (isSpdxLicenseExpression(l)) {
+            licenseContent.expression = l;
           } else {
             licenseContent.name = l;
           }
@@ -322,7 +389,7 @@ export function getLicenses(pkg) {
         }
         return licenseContent;
       })
-      .map((l) => ({ license: l }));
+    );
   } else {
     const knownLicense = getKnownLicense(undefined, pkg);
     if (knownLicense) {
diff --git a/validator.js b/validator.js
index eba59368..a162a1f8 100644
--- a/validator.js
+++ b/validator.js
@@ -50,6 +50,9 @@ export const validateBom = (bomJson) => {
   );
   const isValid = validate(bomJson);
   if (!isValid) {
+    console.log(
+      `Schema validation failed for ${bomJson.metadata.component.name}`
+    );
     console.log(validate.errors);
     return false;
   }


Test patch:
diff --git a/utils.test.js b/utils.test.js
index 866398cd..20ff8064 100644
--- a/utils.test.js
+++ b/utils.test.js
@@ -2058,6 +2058,46 @@ test("get licenses", () => {
       }
     }
   ]);
+
+  licenses = getLicenses({
+    license: "GPL-2.0+"
+  });
+  expect(licenses).toEqual([
+    {
+      license: {
+        id: "GPL-2.0+",
+        url: "https://opensource.org/licenses/GPL-2.0+"
+      }
+    }
+  ]);
+
+  licenses = getLicenses({
+    license: "(MIT or Apache-2.0)"
+  });
+  expect(licenses).toEqual([
+    {
+      expression: "(MIT or Apache-2.0)"
+    }
+  ]);
+
+  // In case this is not a known license in the current build but it is a valid SPDX license expression
+  licenses = getLicenses({
+    license: "NOT-GPL-2.1+"
+  });
+  expect(licenses).toEqual([
+    {
+      expression: "NOT-GPL-2.1+"
+    }
+  ]);
+
+  licenses = getLicenses({
+    license: "GPL-3.0-only WITH Classpath-exception-2.0"
+  });
+  expect(licenses).toEqual([
+    {
+      expression: "GPL-3.0-only WITH Classpath-exception-2.0"
+    }
+  ]);
 });
 
 test("parsePkgJson", async () => {