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/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; -} 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', () => {