From 7c7f9a96bcc5501444f56548eabdffc5cf22d556 Mon Sep 17 00:00:00 2001 From: Chao Wang Date: Wed, 18 Mar 2026 08:48:02 +0800 Subject: [PATCH 1/2] fix: use PackageURL to canonicalize purls when matching deps.dev license response to SBOM purls The deps.dev backend returns package purls with qualifiers like ?scope=compile (e.g. pkg:maven/org.mariadb.jdbc/mariadb-java-client@3.1.4?scope=compile), while the SBOM generated by the client uses plain purls without qualifiers. The strict purls.includes(purl) check in normalizeLicensesResponse never matched qualifier-suffixed purls, causing incompatibleDependencies to always be empty even when LGPL/copyleft dependencies were present. Fix by using PackageURL.fromString() to parse purls and reconstructing them without qualifiers or subpath via new PackageURL(..., null, null). This produces canonical core purls for both the allowed set and the backend keys, making the match resilient to any qualifiers the backend may return. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/license/licenses_api.js | 13 +++++++-- test/providers/license.test.js | 50 ++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/license/licenses_api.js b/src/license/licenses_api.js index 132f3bb2..de267ced 100644 --- a/src/license/licenses_api.js +++ b/src/license/licenses_api.js @@ -5,6 +5,7 @@ * @see https://github.com/guacsec/trustify-da-api-spec/blob/main/api/v5/openapi.yaml */ +import { PackageURL } from 'packageurl-js'; import { selectTrustifyDABackend } from '../index.js'; import { addProxyAgent, getTokenHeaders } from '../tools.js'; @@ -42,6 +43,11 @@ export async function getLicenseDetails(spdxId, opts = {}) { } } +function normalizePurlString(purl) { + const parsed = PackageURL.fromString(purl); + return new PackageURL(parsed.type, parsed.namespace, parsed.name, parsed.version, null, null).toString(); +} + /** * Normalize the LicensesResponse shape (array of LicenseProviderResult) into a map of purl -> license info. * Each provider result has { status, summary, packages } where packages is { [purl]: { concluded, evidence } }. @@ -55,6 +61,8 @@ export function normalizeLicensesResponse(data, purls = []) { const map = new Map(); if (!data || !Array.isArray(data)) {return map;} + const normalizedPurlsSet = purls.length > 0 ? new Set(purls.map(normalizePurlString)) : null; + for (const providerResult of data) { const packages = providerResult?.packages; if (!packages || typeof packages !== 'object') {continue;} @@ -64,8 +72,9 @@ export function normalizeLicensesResponse(data, purls = []) { const expression = concluded?.expression; const licenses = identifiers.length > 0 ? identifiers : (expression ? [expression] : []); const category = concluded?.category; // PERMISSIVE | WEAK_COPYLEFT | STRONG_COPYLEFT | UNKNOWN - if (purls.length === 0 || purls.includes(purl)) { - map.set(purl, { licenses: licenses.filter(Boolean), category }); + const normalizedPurl = normalizePurlString(purl); + if (normalizedPurlsSet === null || normalizedPurlsSet.has(normalizedPurl)) { + map.set(normalizedPurl, { licenses: licenses.filter(Boolean), category }); } } // Use first provider that has packages; backend may return multiple (e.g. deps.dev) diff --git a/test/providers/license.test.js b/test/providers/license.test.js index 2e7e1438..a3c99071 100644 --- a/test/providers/license.test.js +++ b/test/providers/license.test.js @@ -10,6 +10,56 @@ import Javascript_npm from '../../src/providers/javascript_npm.js' import Javascript_pnpm from '../../src/providers/javascript_pnpm.js' import Javascript_yarn from '../../src/providers/javascript_yarn.js' import pythonPipProvider from '../../src/providers/python_pip.js' +import { normalizeLicensesResponse } from '../../src/license/licenses_api.js' + +suite('normalizeLicensesResponse', () => { + const lgpl = { id: 'LGPL-2.1', name: 'GNU Lesser General Public License v2.1 only', category: 'WEAK_COPYLEFT' } + const apache = { id: 'Apache-2.0', name: 'Apache License 2.0', category: 'PERMISSIVE' } + + const backendResponse = [ + { + status: { ok: true, name: 'deps.dev' }, + packages: { + // backend returns purls with ?scope=compile qualifier + 'pkg:maven/org.mariadb.jdbc/mariadb-java-client@3.1.4?scope=compile': { + concluded: { identifiers: [lgpl], expression: 'LGPL-2.1', category: 'WEAK_COPYLEFT' } + }, + 'pkg:maven/javassist/javassist@3.12.1.GA?scope=compile': { + concluded: { identifiers: [lgpl], expression: 'LGPL-2.1', category: 'WEAK_COPYLEFT' } + }, + 'pkg:maven/commons-collections/commons-collections@3.2.1': { + concluded: { identifiers: [apache], expression: 'Apache-2.0', category: 'PERMISSIVE' } + } + } + } + ] + + // SBOM purls have no qualifier + const sbomPurls = [ + 'pkg:maven/org.mariadb.jdbc/mariadb-java-client@3.1.4', + 'pkg:maven/javassist/javassist@3.12.1.GA', + 'pkg:maven/commons-collections/commons-collections@3.2.1' + ] + + test('matches backend purls with ?scope=compile qualifier against plain SBOM purls', () => { + const map = normalizeLicensesResponse(backendResponse, sbomPurls) + expect(map.size).to.equal(3) + }) + + test('stores purl without qualifier as map key', () => { + const map = normalizeLicensesResponse(backendResponse, sbomPurls) + expect(map.has('pkg:maven/org.mariadb.jdbc/mariadb-java-client@3.1.4')).to.be.true + expect(map.has('pkg:maven/javassist/javassist@3.12.1.GA')).to.be.true + expect(map.has('pkg:maven/org.mariadb.jdbc/mariadb-java-client@3.1.4?scope=compile')).to.be.false + }) + + test('preserves correct license category for qualifier-stripped purls', () => { + const map = normalizeLicensesResponse(backendResponse, sbomPurls) + expect(map.get('pkg:maven/org.mariadb.jdbc/mariadb-java-client@3.1.4').category).to.equal('WEAK_COPYLEFT') + expect(map.get('pkg:maven/javassist/javassist@3.12.1.GA').category).to.equal('WEAK_COPYLEFT') + expect(map.get('pkg:maven/commons-collections/commons-collections@3.2.1').category).to.equal('PERMISSIVE') + }) +}) suite('testing readLicenseFromManifest with existing test manifests', () => { From f0857ba6c4cbb783e724d70566d3fe8e7bed1910 Mon Sep 17 00:00:00 2001 From: Chao Wang Date: Wed, 18 Mar 2026 11:43:47 +0800 Subject: [PATCH 2/2] chore: remove unused getLicenseForSbom function Co-Authored-By: Claude Opus 4.6 (1M context) --- src/license/project_license.js | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/license/project_license.js b/src/license/project_license.js index aec3f64f..b34fef3f 100644 --- a/src/license/project_license.js +++ b/src/license/project_license.js @@ -72,15 +72,3 @@ export async function identifyLicense(licenseFilePath, opts = {}) { return null; // Fallback to local detection on error } } - -/** - * Get license for SBOM root component with fallback to LICENSE file. - * Priority: manifest license > LICENSE file > null - * This is used by providers to populate the license field in the SBOM. - * @param {string} manifestPath - path to manifest - * @returns {string|null} - SPDX identifier or null - */ -export function getLicenseForSbom(manifestPath) { - const { fromManifest, fromFile } = getProjectLicense(manifestPath); - return fromManifest || fromFile || null; -}