diff --git a/README.md b/README.md index 56e6f5d5..b74c62a3 100644 --- a/README.md +++ b/README.md @@ -83,12 +83,13 @@ Use as CLI Script ```shell $ npx @trustify-da/trustify-da-javascript-client help -Usage: trustify-da-javascript-client {component|stack|image|validate-token} +Usage: trustify-da-javascript-client {component|stack|image|validate-token|license} Commands: trustify-da-javascript-client stack [--html|--summary] produce stack report for manifest path trustify-da-javascript-client component [--summary] produce component report for a manifest type and content trustify-da-javascript-client image [--html|--summary] produce image analysis report for OCI image references + trustify-da-javascript-client license display project license information from manifest and LICENSE file in JSON format Options: --help Show help [boolean] @@ -123,6 +124,9 @@ $ npx @trustify-da/trustify-da-javascript-client image docker.io/library/node:18 # specify architecture using ^^ notation (e.g., httpd:2.4.49^^amd64) $ npx @trustify-da/trustify-da-javascript-client image httpd:2.4.49^^amd64 + +# get project license information +$ npx @trustify-da/trustify-da-javascript-client license /path/to/package.json ``` @@ -161,6 +165,9 @@ $ trustify-da-javascript-client image docker.io/library/node:18 docker.io/librar # specify architecture using ^^ notation (e.g., httpd:2.4.49^^amd64) $ trustify-da-javascript-client image httpd:2.4.49^^amd64 + +# get project license information +$ trustify-da-javascript-client license /path/to/package.json ``` @@ -372,6 +379,11 @@ const options = { The proxy URL should be in the format: `http://host:port` or `https://host:port`. The API will automatically use the appropriate protocol (HTTP or HTTPS) based on the proxy URL provided.

+

License resolution and dependency license compliance

+

+The client can resolve the project license from the manifest (e.g. package.json license, pom.xml <licenses>) and from a LICENSE or LICENSE.md file in the project, and report when they differ. For component analysis, you can optionally run a license check: the client fetches dependency licenses from the backend (by purl) and reports dependencies whose licenses are incompatible with the project license. See License resolution and compliance for design and behavior. To disable the check on component analysis, set TRUSTIFY_DA_LICENSE_CHECK=false or pass licenseCheck: false in the options. +

+

Customizing Executables

This project uses each ecosystem's executable for creating dependency trees. These executables are expected to be diff --git a/docs/license-resolution-and-compliance.md b/docs/license-resolution-and-compliance.md new file mode 100644 index 00000000..bfd88acc --- /dev/null +++ b/docs/license-resolution-and-compliance.md @@ -0,0 +1,158 @@ +# License Resolution and Compliance + +This document describes the license analysis features that help you understand your project’s license and check compatibility with your dependencies. + +## Overview + +License analysis is **enabled by default** and provides: + +1. **Project license detection** from your manifest file (e.g., `package.json`, `pom.xml`) and LICENSE files +2. **Dependency license information** from the Trustify DA backend +3. **Compatibility checking** to identify potential license conflicts +4. **Mismatch detection** when your manifest and LICENSE file declare different licenses + +## How It Works + +### Project License Detection + +The client looks for your project’s license in two places: + +1. **Manifest file** — Reads the license field from: + - `package.json`: `license` field + - `pom.xml`: `` element + - Other ecosystems: varies by ecosystem (some don’t have standard license fields) + +2. **LICENSE file** — Searches for `LICENSE`, `LICENSE.md`, or `LICENSE.txt` in the same directory as your manifest + +The backend’s license identification API is used for accurate LICENSE file detection. + +### Compatibility Checking + +The client checks if dependency licenses are compatible with your project license. For example: +- Permissive project (MIT) + permissive dependencies → ✅ Compatible +- Permissive project (MIT) + strong copyleft dependency (GPL) → ⚠️ Potentially incompatible + +Compatibility results are included in the analysis report’s `licenseSummary`. + +## Configuration + +### Disable License Checking + +License analysis runs automatically during **component analysis only** (not stack analysis). To disable it: + +**Environment variable:** +```bash +export TRUSTIFY_DA_LICENSE_CHECK=false +``` + +**Programmatic option:** +```javascript +await componentAnalysis(‘pom.xml’, { licenseCheck: false }); +``` + +## CLI Usage + +### Get License Information + +```bash +exhort license path/to/pom.xml +``` + +**Example output:** +```json +{ + "manifestLicense": { + "spdxId": "Apache-2.0", + "category": "PERMISSIVE", + "name": "Apache License 2.0", + "identifiers": ["Apache-2.0"] + }, + "fileLicense": { + "spdxId": "Apache-2.0", + "category": "PERMISSIVE", + "name": "Apache License 2.0", + "identifiers": ["Apache-2.0"] + }, + "mismatch": false +} +``` + +Note: The `license` command shows only your project's license. For dependency license information, use component analysis. + +## Analysis Report Fields + +When license checking is enabled, component analysis includes a `licenseSummary` field: + +```javascript +{ + // ... standard analysis fields (providers, etc.) ... + "licenseSummary": { + "projectLicense": { + "manifest": { + "spdxId": "Apache-2.0", + "category": "PERMISSIVE", + "name": "Apache License 2.0", + "identifiers": ["Apache-2.0"] + }, + "file": { + "spdxId": "Apache-2.0", + "category": "PERMISSIVE", + "name": "Apache License 2.0", + "identifiers": ["Apache-2.0"] + }, + "mismatch": false + }, + "incompatibleDependencies": [ + { + "purl": "pkg:maven/org.example/gpl-lib@1.0.0", + "licenses": ["GPL-3.0"], + "category": "STRONG_COPYLEFT", + "reason": "Dependency license(s) are incompatible with the project license." + } + ] + } +} +``` + +**Note:** Dependency license information (for all dependencies, not just incompatible ones) is available in the standard backend response under the `licenses` field. The `licenseSummary` only includes project license details and flagged incompatibilities. + +## Common Scenarios + +### Mismatch Between Manifest and LICENSE File + +If your `package.json` says `"license": "MIT"` but your LICENSE file contains Apache-2.0 text, the component analysis report will show: +```json +{ + "licenseSummary": { + "projectLicense": { + "manifest": { + "spdxId": "MIT", + "category": "PERMISSIVE", + "name": "MIT License" + }, + "file": { + "spdxId": "Apache-2.0", + "category": "PERMISSIVE", + "name": "Apache License 2.0" + }, + "mismatch": true + }, + "incompatibleDependencies": [] + } +} +``` + +**Action:** Update your manifest or LICENSE file to match. + +### Incompatible Dependencies + +If you have a permissive-licensed project (MIT, Apache) but depend on GPL-licensed libraries, they’ll appear in `incompatibleDependencies`. + +**Action:** Review the flagged dependencies and consider: +- Finding alternative libraries with compatible licenses +- Consulting legal counsel if the dependency is necessary +- Understanding how you’re using the dependency (linking, distribution, etc.) + +## SBOM Integration + +Project license information is automatically included in generated SBOMs (CycloneDX format) in the root component’s `licenses` field. diff --git a/package-lock.json b/package-lock.json index caab1a01..64468568 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ }, "devDependencies": { "@babel/core": "^7.23.2", - "@trustify-da/trustify-da-api-model": "^2.0.1", + "@trustify-da/trustify-da-api-model": "^2.0.7", "@types/node": "^20.17.30", "@types/which": "^3.0.4", "babel-plugin-rewire": "^1.2.0", @@ -1005,9 +1005,9 @@ "license": "(Unlicense OR Apache-2.0)" }, "node_modules/@trustify-da/trustify-da-api-model": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@trustify-da/trustify-da-api-model/-/trustify-da-api-model-2.0.3.tgz", - "integrity": "sha512-nnw4nTIHCXNmkUKS4btl9JZJKPtyE+w33E2pjAbUTL6wpeLfmonrLs4Oqj3C/8FVZjGuizXi7ghVEztmQIi4IA==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@trustify-da/trustify-da-api-model/-/trustify-da-api-model-2.0.7.tgz", + "integrity": "sha512-42ApYWE4LYiCXD86AzPdLHSxFY7t2P9PhR+BwwFKBwHlcnJE2fPVELI0e8vbf+2Lxz0CpzU1g4WPHXOCp2K1oQ==", "dev": true, "license": "Apache-2.0" }, diff --git a/package.json b/package.json index 1df61e7e..13e80b15 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ }, "devDependencies": { "@babel/core": "^7.23.2", - "@trustify-da/trustify-da-api-model": "^2.0.1", + "@trustify-da/trustify-da-api-model": "^2.0.7", "@types/node": "^20.17.30", "@types/which": "^3.0.4", "babel-plugin-rewire": "^1.2.0", diff --git a/src/analysis.js b/src/analysis.js index 614c1f2d..2d56fad5 100644 --- a/src/analysis.js +++ b/src/analysis.js @@ -2,33 +2,12 @@ import fs from "node:fs"; import path from "node:path"; import { EOL } from "os"; -import { HttpsProxyAgent } from "https-proxy-agent"; - +import { runLicenseCheck } from "./license/index.js"; import { generateImageSBOM, parseImageRef } from "./oci_image/utils.js"; -import { RegexNotToBeLogged, getCustom } from "./tools.js"; +import { addProxyAgent, getCustom, getTokenHeaders , TRUSTIFY_DA_OPERATION_TYPE_HEADER, TRUSTIFY_DA_PACKAGE_MANAGER_HEADER } from "./tools.js"; export default { requestComponent, requestStack, requestImages, validateToken } -const rhdaTokenHeader = "trust-da-token"; -const rhdaTelemetryId = "telemetry-anonymous-id"; -const rhdaSourceHeader = "trust-da-source" -const rhdaOperationTypeHeader = "trust-da-operation-type" -const rhdaPackageManagerHeader = "trust-da-pkg-manager" - -/** - * Adds proxy agent configuration to fetch options if a proxy URL is specified - * @param {RequestInit} options - The base fetch options - * @param {import("index.js").Options} opts - The trustify DA options that may contain proxy configuration - * @returns {RequestInit} The fetch options with proxy agent if applicable - */ -function addProxyAgent(options, opts) { - const proxyUrl = getCustom('TRUSTIFY_DA_PROXY_URL', null, opts); - if (proxyUrl) { - options.agent = new HttpsProxyAgent(proxyUrl); - } - return options; -} - /** * Send a stack analysis request and get the report as 'text/html' or 'application/json'. * @param {import('./provider').Provider} provider - the provided data for constructing the request @@ -43,13 +22,13 @@ async function requestStack(provider, manifest, url, html = false, opts = {}) { opts["manifest-type"] = path.parse(manifest).base let provided = await provider.provideStack(manifest, opts) // throws error if content providing failed opts["source-manifest"] = "" - opts[rhdaOperationTypeHeader.toUpperCase().replaceAll("-", "_")] = "stack-analysis" + opts[TRUSTIFY_DA_OPERATION_TYPE_HEADER.toUpperCase().replaceAll("-", "_")] = "stack-analysis" let startTime = new Date() let endTime if (process.env["TRUSTIFY_DA_DEBUG"] === "true") { console.log("Starting time of sending stack analysis request to the dependency analytics server= " + startTime) } - opts[rhdaPackageManagerHeader.toUpperCase().replaceAll("-", "_")] = provided.ecosystem + opts[TRUSTIFY_DA_PACKAGE_MANAGER_HEADER.toUpperCase().replaceAll("-", "_")] = provided.ecosystem const fetchOptions = addProxyAgent({ method: 'POST', @@ -61,7 +40,7 @@ async function requestStack(provider, manifest, url, html = false, opts = {}) { body: provided.content }, opts); - const finalUrl = new URL(`${url}/api/v4/analysis`); + const finalUrl = new URL(`${url}/api/v5/analysis`); if (opts['TRUSTIFY_DA_RECOMMENDATIONS_ENABLED'] === 'false') { finalUrl.searchParams.append('recommend', 'false'); } @@ -107,11 +86,11 @@ async function requestComponent(provider, manifest, url, opts = {}) { let provided = await provider.provideComponent(manifest, opts) // throws error if content providing failed opts["source-manifest"] = "" - opts[rhdaOperationTypeHeader.toUpperCase().replaceAll("-", "_")] = "component-analysis" + opts[TRUSTIFY_DA_OPERATION_TYPE_HEADER.toUpperCase().replaceAll("-", "_")] = "component-analysis" if (process.env["TRUSTIFY_DA_DEBUG"] === "true") { console.log("Starting time of sending component analysis request to Trustify DA backend server= " + new Date()) } - opts[rhdaPackageManagerHeader.toUpperCase().replaceAll("-", "_")] = provided.ecosystem + opts[TRUSTIFY_DA_PACKAGE_MANAGER_HEADER.toUpperCase().replaceAll("-", "_")] = provided.ecosystem const fetchOptions = addProxyAgent({ method: 'POST', @@ -123,7 +102,7 @@ async function requestComponent(provider, manifest, url, opts = {}) { body: provided.content }, opts); - const finalUrl = new URL(`${url}/api/v4/analysis`); + const finalUrl = new URL(`${url}/api/v5/analysis`); if (opts['TRUSTIFY_DA_RECOMMENDATIONS_ENABLED'] === 'false') { finalUrl.searchParams.append('recommend', 'false'); } @@ -143,6 +122,14 @@ async function requestComponent(provider, manifest, url, opts = {}) { } + const licenseCheckEnabled = getCustom('TRUSTIFY_DA_LICENSE_CHECK', 'true', opts) !== 'false' && opts.licenseCheck !== false + if (licenseCheckEnabled) { + try { + result.licenseSummary = await runLicenseCheck(provided.content, manifest, url, opts, result) + } catch (licenseErr) { + result.licenseSummary = { error: licenseErr.message } + } + } } else { throw new Error(`Got error response from Trustify DA backend - http return code : ${resp.status}, ex-request-id: ${resp.headers.get("ex-request-id")} error message => ${await resp.text()}`) } @@ -164,7 +151,7 @@ async function requestImages(imageRefs, url, html = false, opts = {}) { imageSboms[parsedImageRef.getPackageURL().toString()] = generateImageSBOM(parsedImageRef, opts) } - const finalUrl = new URL(`${url}/api/v4/batch-analysis`); + const finalUrl = new URL(`${url}/api/v5/batch-analysis`); if (opts['TRUSTIFY_DA_RECOMMENDATIONS_ENABLED'] === 'false') { finalUrl.searchParams.append('recommend', 'false'); } @@ -215,7 +202,7 @@ async function validateToken(url, opts = {}) { } }, opts); - let resp = await fetch(`${url}/api/v4/token`, fetchOptions) + let resp = await fetch(`${url}/api/v5/token`, fetchOptions) if (process.env["TRUSTIFY_DA_DEBUG"] === "true") { let exRequestId = resp.headers.get("ex-request-id"); if (exRequestId) { @@ -224,42 +211,3 @@ async function validateToken(url, opts = {}) { } return resp.status } - -/** - * - * @param {string} headerName - the header name to populate in request - * @param headers - * @param {string} optsKey - key in the options object to use the value for - * @param {import("index.js").Options} [opts={}] - options input object to fetch header values from - * @private - */ -function setRhdaHeader(headerName, headers, optsKey, opts) { - let rhdaHeaderValue = getCustom(optsKey, null, opts); - if (rhdaHeaderValue) { - headers[headerName] = rhdaHeaderValue - } -} - -/** - * Utility function for fetching vendor tokens - * @param {import("index.js").Options} [opts={}] - optional various options to pass along the application - * @returns {{}} - */ -export function getTokenHeaders(opts = {}) { - let headers = {} - setRhdaHeader(rhdaTokenHeader, headers, 'TRUSTIFY_DA_TOKEN', opts); - setRhdaHeader(rhdaSourceHeader, headers, 'TRUSTIFY_DA_SOURCE', opts); - setRhdaHeader(rhdaOperationTypeHeader, headers, rhdaOperationTypeHeader.toUpperCase().replaceAll("-", "_"), opts); - setRhdaHeader(rhdaPackageManagerHeader, headers, rhdaPackageManagerHeader.toUpperCase().replaceAll("-", "_"), opts) - setRhdaHeader(rhdaTelemetryId, headers, 'TRUSTIFY_DA_TELEMETRY_ID', opts); - - if (getCustom("TRUSTIFY_DA_DEBUG", null, opts) === "true") { - console.log("Headers Values to be sent to Trustify DA backend:" + EOL) - for (const headerKey in headers) { - if (!headerKey.match(RegexNotToBeLogged)) { - console.log(`${headerKey}: ${headers[headerKey]}`) - } - } - } - return headers -} diff --git a/src/cli.js b/src/cli.js index b105773a..3e289c6e 100644 --- a/src/cli.js +++ b/src/cli.js @@ -5,7 +5,9 @@ import * as path from "path"; import yargs from 'yargs' import { hideBin } from 'yargs/helpers' -import client from './index.js' +import { getProjectLicense, getLicenseDetails } from './license/index.js' + +import client, { selectTrustifyDABackend } from './index.js' // command for component analysis take manifest type and content @@ -32,9 +34,8 @@ const validateToken = { builder: yargs => yargs.positional( 'token-provider', { - desc: 'the token provider', - type: 'string', - choices: ['snyk','oss-index'], + desc: 'the token provider name', + type: 'string' } ).options({ tokenValue: { @@ -48,7 +49,7 @@ const validateToken = { let opts={} if(args['tokenValue'] !== undefined && args['tokenValue'].trim() !=="" ) { let tokenValue = args['tokenValue'].trim() - opts[`TRUSTIFY_DA_${tokenProvider}_TOKEN`] = tokenValue + opts[`TRUSTIFY_DA_PROVIDER_${tokenProvider}_TOKEN`] = tokenValue } let res = await client.validateToken(opts) console.log(res) @@ -168,13 +169,86 @@ const stack = { } } +// command for license checking +const license = { + command: 'license ', + desc: 'Display project license information from manifest and LICENSE file in JSON format', + builder: yargs => yargs.positional( + '/path/to/manifest', + { + desc: 'manifest path for license analysis', + type: 'string', + normalize: true, + } + ), + handler: async args => { + let manifestPath = args['/path/to/manifest'] + + const opts = {} // CLI options can be extended in the future + try { + selectTrustifyDABackend(opts) + } catch (err) { + console.error(JSON.stringify({ error: err.message }, null, 2)) + process.exit(1) + } + + let localResult + try { + localResult = getProjectLicense(manifestPath) + } catch (err) { + console.error(JSON.stringify({ error: `Failed to read manifest: ${err.message}` }, null, 2)) + process.exit(1) + } + + const errors = [] + + // Build LicenseInfo objects + const buildLicenseInfo = async (spdxId) => { + if (!spdxId) {return null} + + const licenseInfo = { spdxId } + + try { + const details = await getLicenseDetails(spdxId, opts) + if (details) { + // Check if backend recognized the license as valid + if (details.category === 'UNKNOWN') { + errors.push(`"${spdxId}" is not a valid SPDX license identifier. Please use a valid SPDX expression (e.g., "Apache-2.0", "MIT"). See https://spdx.org/licenses/`) + } else { + Object.assign(licenseInfo, details) + } + } else { + errors.push(`No license details found for ${spdxId}`) + } + } catch (err) { + errors.push(`Failed to fetch details for ${spdxId}: ${err.message}`) + } + + return licenseInfo + } + + const output = { + manifestLicense: await buildLicenseInfo(localResult.fromManifest), + fileLicense: await buildLicenseInfo(localResult.fromFile), + mismatch: localResult.mismatch + } + + if (errors.length > 0) { + output.errors = errors + } + + console.log(JSON.stringify(output, null, 2)) + } +} + // parse and invoke the command yargs(hideBin(process.argv)) - .usage(`Usage: ${process.argv[0].includes("node") ? path.parse(process.argv[1]).base : path.parse(process.argv[0]).base} {component|stack|image|validate-token}`) + .usage(`Usage: ${process.argv[0].includes("node") ? path.parse(process.argv[1]).base : path.parse(process.argv[0]).base} {component|stack|image|validate-token|license}`) .command(stack) .command(component) .command(image) .command(validateToken) + .command(license) .scriptName('') .version(false) .demandCommand(1) diff --git a/src/cyclone_dx_sbom.js b/src/cyclone_dx_sbom.js index 7e6efaad..b90b74be 100644 --- a/src/cyclone_dx_sbom.js +++ b/src/cyclone_dx_sbom.js @@ -7,10 +7,11 @@ import {PackageURL} from "packageurl-js"; * @param component {PackageURL} * @param type type of package - application or library * @param scope scope of the component - runtime or compile - * @return {{"bom-ref": string, name, purl: string, type, version, scope}} + * @param licenses optional license string or array of licenses for the component + * @return {{"bom-ref": string, name, purl: string, type, version, scope, licenses?}} * @private */ -function getComponent(component, type, scope) { +function getComponent(component, type, scope, licenses) { let componentObject; if(component instanceof PackageURL) { @@ -41,6 +42,18 @@ function getComponent(component, type, scope) { { componentObject = component } + + // Add licenses if provided (CycloneDX format). Callers must provide valid SPDX identifiers. + if (licenses) { + const licenseArray = Array.isArray(licenses) ? licenses : [licenses]; + componentObject.licenses = licenseArray.map(lic => { + if (typeof lic === 'string') { + return { license: { id: lic } }; + } + return lic; + }); + } + return componentObject } @@ -73,12 +86,13 @@ export default class CycloneDxSbom { /** * @param {PackageURL} root - add main/root component for sbom + * @param {string|Array} [licenses] - optional license(s) for the root component * @return {CycloneDxSbom} the CycloneDxSbom Sbom Object */ - addRoot(root) { + addRoot(root, licenses) { this.rootComponent = - getComponent(root, "application") + getComponent(root, "application", undefined, licenses) this.components.push(this.rootComponent) return this } diff --git a/src/index.js b/src/index.js index 4ff21804..567a475b 100644 --- a/src/index.js +++ b/src/index.js @@ -9,6 +9,7 @@ import * as url from 'url'; export { parseImageRef } from "./oci_image/utils.js"; export { ImageRef } from "./oci_image/images.js"; +export { getProjectLicense, findLicenseFilePath, identifyLicenseViaBackend, getLicenseDetails, licensesFromReport, normalizeLicensesResponse, runLicenseCheck, getCompatibility } from "./license/index.js"; export default { componentAnalysis, stackAnalysis, imageAnalysis, validateToken } @@ -38,6 +39,7 @@ export default { componentAnalysis, stackAnalysis, imageAnalysis, validateToken * TRUSTIFY_DA_SYFT_CONFIG_PATH?: string | undefined, * TRUSTIFY_DA_SYFT_PATH?: string | undefined, * TRUSTIFY_DA_YARN_PATH?: string | undefined, + * TRUSTIFY_DA_LICENSE_CHECK?: string | undefined, * MATCH_MANIFEST_VERSIONS?: string | undefined, * TRUSTIFY_DA_SOURCE?: string | undefined, * TRUSTIFY_DA_TOKEN?: string | undefined, diff --git a/src/license/compatibility.js b/src/license/compatibility.js new file mode 100644 index 00000000..d8466b6f --- /dev/null +++ b/src/license/compatibility.js @@ -0,0 +1,53 @@ +/** + * License compatibility: whether a dependency license is compatible with the project license. + * Relies on backend-provided license categories. + * + * Compatibility is based on restrictiveness hierarchy: + * PERMISSIVE < WEAK_COPYLEFT < STRONG_COPYLEFT + * + * A dependency is compatible if it's equal or less restrictive than the project license. + * A dependency is incompatible if it's more restrictive than the project license. + */ + +/** + * Check if a dependency's license is compatible with the project license based on backend categories. + * + * @param {string} [projectCategory] - backend category for project license: PERMISSIVE | WEAK_COPYLEFT | STRONG_COPYLEFT | UNKNOWN + * @param {string} [dependencyCategory] - backend category for dependency license: PERMISSIVE | WEAK_COPYLEFT | STRONG_COPYLEFT | UNKNOWN + * @returns {'compatible'|'incompatible'|'unknown'} + */ +export function getCompatibility(projectCategory, dependencyCategory) { + if (!projectCategory || !dependencyCategory) { + return 'unknown'; + } + + const proj = projectCategory.toUpperCase(); + const dep = dependencyCategory.toUpperCase(); + + // Unknown categories + if (proj === 'UNKNOWN' || dep === 'UNKNOWN') { + return 'unknown'; + } + + // Define restrictiveness levels (higher number = more restrictive) + const restrictiveness = { + 'PERMISSIVE': 1, + 'WEAK_COPYLEFT': 2, + 'STRONG_COPYLEFT': 3 + }; + + const projLevel = restrictiveness[proj]; + const depLevel = restrictiveness[dep]; + + if (projLevel === undefined || depLevel === undefined) { + return 'unknown'; + } + + // Dependency is more restrictive than project → incompatible + if (depLevel > projLevel) { + return 'incompatible'; + } + + // Dependency is equal or less restrictive → compatible + return 'compatible'; +} diff --git a/src/license/index.js b/src/license/index.js new file mode 100644 index 00000000..de008967 --- /dev/null +++ b/src/license/index.js @@ -0,0 +1,111 @@ +/** + * License resolution and dependency license compatibility for component analysis. + */ + +import { getProjectLicense, findLicenseFilePath, identifyLicense } from './project_license.js'; +import { licensesFromReport, getLicenseDetails } from './licenses_api.js'; +import { getCompatibility } from './compatibility.js'; + +export { getProjectLicense, findLicenseFilePath, identifyLicense as identifyLicenseViaBackend } from './project_license.js'; +export { licensesFromReport, normalizeLicensesResponse, getLicenseDetails } from './licenses_api.js'; +export { getCompatibility } from './compatibility.js'; + +/** + * Run full license check: resolve project license (with backend identification and details), + * get dependency licenses from analysis report, and compute incompatibilities. + * + * @param {string} sbomContent - CycloneDX SBOM JSON string (the one sent for component analysis) + * @param {string} manifestPath - path to manifest + * @param {string} url - the backend url to send the request to + * @param {import('../index.js').Options} [opts={}] + * @param {import('@trustify-da/trustify-da-api-model/model/v5/AnalysisReport').AnalysisReport} [analysisResult] - analysis result that includes licenses array from backend + * @returns {Promise<{ projectLicense: { manifest: Object|null, file: Object|null, mismatch: boolean }, incompatibleDependencies: Array<{ purl: string, licenses: string[], category?: string, reason: string }>, error?: string }>} + */ +export async function runLicenseCheck(sbomContent, manifestPath, url, opts = {}, analysisResult = null) { + // Resolve project license from manifest and LICENSE file + const projectLicense = getProjectLicense(manifestPath, opts); + + // Try backend identification for LICENSE file (more accurate than local pattern matching) + const licenseFilePath = findLicenseFilePath(manifestPath); + let backendFileId = null; + if (licenseFilePath) { + try { + backendFileId = await identifyLicense(licenseFilePath, { ...opts, TRUSTIFY_DA_BACKEND_URL: url }); + } catch { + // Fall back to local detection + } + } + + // Determine final license identifiers + const manifestSpdx = projectLicense.fromManifest; + const fileSpdx = backendFileId || projectLicense.fromFile; + const mismatch = Boolean(manifestSpdx && fileSpdx && manifestSpdx.toLowerCase() !== fileSpdx.toLowerCase()); + + // Fetch detailed license info from backend (avoid duplicate calls if same license) + const licenseDetailsCache = new Map(); + + async function getDetails(spdxId) { + if (!spdxId || !url) return null; + if (licenseDetailsCache.has(spdxId)) return licenseDetailsCache.get(spdxId); + + try { + const details = await getLicenseDetails(spdxId, { ...opts, TRUSTIFY_DA_BACKEND_URL: url }); + licenseDetailsCache.set(spdxId, details); + return details; + } catch { + return null; + } + } + + const manifestLicenseInfo = await getDetails(manifestSpdx); + const fileLicenseInfo = await getDetails(fileSpdx); + + // Extract dependency purls from SBOM (exclude root component) + const sbomObj = typeof sbomContent === 'string' ? JSON.parse(sbomContent) : sbomContent; + const rootRef = sbomObj?.metadata?.component?.["bom-ref"] || sbomObj?.metadata?.component?.purl; + const purls = (sbomObj?.components || []) + .map(c => c.purl || c["bom-ref"]) + .filter(Boolean) + .filter(purl => !rootRef || purl !== rootRef); + + if (purls.length === 0) { + return { + projectLicense: { manifest: manifestLicenseInfo, file: fileLicenseInfo, mismatch }, + incompatibleDependencies: [] + }; + } + + // Get dependency licenses from analysis report + const licenseByPurl = licensesFromReport(analysisResult, purls); + if (licenseByPurl.size === 0 && analysisResult) { + return { + projectLicense: { manifest: manifestLicenseInfo, file: fileLicenseInfo, mismatch }, + incompatibleDependencies: [], + error: 'No license data available in analysis report' + }; + } + + // Check compatibility for each dependency + const projectCategory = manifestLicenseInfo?.category || fileLicenseInfo?.category; + const incompatibleDependencies = []; + + for (const purl of purls) { + const entry = licenseByPurl.get(purl); + if (!entry) continue; + + const status = getCompatibility(projectCategory, entry.category); + if (status === 'incompatible') { + incompatibleDependencies.push({ + purl, + licenses: entry.licenses, + category: entry.category, + reason: 'Dependency license(s) are incompatible with the project license.' + }); + } + } + + return { + projectLicense: { manifest: manifestLicenseInfo, file: fileLicenseInfo, mismatch }, + incompatibleDependencies + }; +} diff --git a/src/license/licenses_api.js b/src/license/licenses_api.js new file mode 100644 index 00000000..132f3bb2 --- /dev/null +++ b/src/license/licenses_api.js @@ -0,0 +1,88 @@ +/** + * Client for the Trustify DA backend License Analysis API (POST /api/v5/licenses). + * The same license data shape is returned in the dependency analysis JSON report (result.licenses). + * @see https://github.com/guacsec/trustify-dependency-analytics#license-analysis-apiv5licenses + * @see https://github.com/guacsec/trustify-da-api-spec/blob/main/api/v5/openapi.yaml + */ + +import { selectTrustifyDABackend } from '../index.js'; +import { addProxyAgent, getTokenHeaders } from '../tools.js'; + +/** + * Fetch license details by SPDX identifier from the backend GET /api/v5/licenses/{spdx}. + * Returns detailed information about a specific license including category, name, and text. + * + * @param {string} spdxId - SPDX identifier (e.g., "Apache-2.0", "MIT") + * @param {import('../index.js').Options} [opts={}] - options (proxy, token, TRUSTIFY_DA_BACKEND_URL, etc.) + * @returns {Promise} License details or null if not found + */ +export async function getLicenseDetails(spdxId, opts = {}) { + if (!spdxId) {return null;} + + const url = selectTrustifyDABackend(opts); + const finalUrl = new URL(`${url}/api/v5/licenses/${encodeURIComponent(spdxId)}`); + + const fetchOptions = addProxyAgent({ + method: 'GET', + headers: { + 'Accept': 'application/json', + ...getTokenHeaders(opts) + }, + }, opts); + + try { + const resp = await fetch(finalUrl, fetchOptions); + if (!resp.ok) { + const errorText = await resp.text().catch(() => ''); + throw new Error(`HTTP ${resp.status}: ${errorText || resp.statusText}`); + } + return await resp.json(); + } catch (err) { + throw new Error(`Failed to fetch license details: ${err.message}`); + } +} + +/** + * 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 } }. + * We merge the first successful provider's packages; concluded has identifiers[], category (PERMISSIVE | WEAK_COPYLEFT | STRONG_COPYLEFT | UNKNOWN). + * + * @param {unknown} data - LicensesResponse (array) or analysis report's licenses field + * @param {string[]} [purls] - optional list of purls to restrict to (for consistency with getLicensesByPurl) + * @returns {Map} + */ +export function normalizeLicensesResponse(data, purls = []) { + const map = new Map(); + if (!data || !Array.isArray(data)) {return map;} + + for (const providerResult of data) { + const packages = providerResult?.packages; + if (!packages || typeof packages !== 'object') {continue;} + for (const [purl, pkgLicense] of Object.entries(packages)) { + const concluded = pkgLicense?.concluded; + const identifiers = Array.isArray(concluded?.identifiers) ? concluded.identifiers : []; + 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 }); + } + } + // Use first provider that has packages; backend may return multiple (e.g. deps.dev) + if (map.size > 0) {break;} + } + return map; +} + +/** + * Build license map from an analysis report that already includes license data (result.licenses). + * Use this when the dependency analysis response already contains the licenses array to avoid a second request. + * + * @param {import('@trustify-da/trustify-da-api-model/model/v5/AnalysisReport').AnalysisReport} analysisReport - full analysis JSON + * @param {string[]} [purls] - optional list of purls to restrict to + * @returns {Map} + */ +export function licensesFromReport(analysisReport, purls = []) { + if (!analysisReport?.licenses) {return new Map();} + return normalizeLicensesResponse(analysisReport.licenses, purls); +} diff --git a/src/license/project_license.js b/src/license/project_license.js new file mode 100644 index 00000000..ce4b7095 --- /dev/null +++ b/src/license/project_license.js @@ -0,0 +1,135 @@ +/** + * Resolves the project license from the manifest and from a LICENSE / LICENSE.md file. + * Used to report manifest-vs-file mismatch and as the baseline for dependency license compatibility. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +import { selectTrustifyDABackend } from '../index.js'; +import { matchForLicense, availableProviders } from '../provider.js'; +import { addProxyAgent, getTokenHeaders } from '../tools.js'; + +const LICENSE_FILES = ['LICENSE', 'LICENSE.md', 'LICENSE.txt']; + +/** + * Resolve project license from manifest and from LICENSE / LICENSE.md in manifest dir or git root. + * Uses local pattern matching for LICENSE file identification (synchronous). + * For more accurate backend-based identification, use identifyLicense() separately. + * @param {string} manifestPath - path to manifest + * @returns {{ fromManifest: string|null, fromFile: string|null, mismatch: boolean }} + */ +export function getProjectLicense(manifestPath) { + const resolved = path.resolve(manifestPath); + const provider = matchForLicense(resolved, availableProviders); + const fromManifest = provider.readLicenseFromManifest(resolved); + const fromFile = readLicenseFromFile(resolved); + const mismatch = Boolean( + fromManifest && fromFile && normalizeSpdx(fromManifest) !== normalizeSpdx(fromFile) + ); + return { + fromManifest: fromManifest || null, + fromFile: fromFile || null, + mismatch + }; +} + +/** + * Find LICENSE file path in the same directory as the manifest. + * @param {string} manifestPath + * @returns {string|null} - path to LICENSE file or null if not found + */ +export function findLicenseFilePath(manifestPath) { + const manifestDir = path.dirname(path.resolve(manifestPath)); + + for (const name of LICENSE_FILES) { + const filePath = path.join(manifestDir, name); + try { + if (fs.statSync(filePath).isFile()) { + return filePath; + } + } catch { + // skip + } + } + return null; +} + +/** + * Call backend /licenses/identify endpoint to identify license from file. + * @param {string} licenseFilePath - path to LICENSE file + * @param {{}} [opts={}] - options (proxy, token, etc.) + * @returns {Promise} - SPDX identifier or null + */ +export async function identifyLicense(licenseFilePath, opts = {}) { + try { + const fileContent = fs.readFileSync(licenseFilePath); + const backendUrl = selectTrustifyDABackend(opts); + const url = new URL(`${backendUrl}/licenses/identify`); + const tokenHeaders = getTokenHeaders(opts); + const fetchOptions = addProxyAgent({ + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream', + ...tokenHeaders, + }, + body: fileContent, + }, opts); + + const resp = await fetch(url, fetchOptions); + if (!resp.ok) { + return null; // Fallback to local detection on error + } + + const data = await resp.json(); + // Extract SPDX identifier from backend response + return data?.license?.id || data?.spdx_id || data?.identifier || null; + } catch { + return null; // Fallback to local detection on error + } +} + +/** + * Find and read LICENSE or LICENSE.md; use local pattern matching for identification. + * @param {string} manifestPath + * @returns {string|null} + */ +function readLicenseFromFile(manifestPath) { + const licenseFilePath = findLicenseFilePath(manifestPath); + if (!licenseFilePath) {return null;} + + try { + const content = fs.readFileSync(licenseFilePath, 'utf-8'); + return detectSpdxFromText(content) || content.split('\n')[0]?.trim() || null; + } catch { + return null; + } +} + +/** + * Very simple SPDX detection from common license text (first ~500 chars). + * @param {string} text + * @returns {string|null} + */ +function detectSpdxFromText(text) { + const head = text.slice(0, 500); + if (/Apache License,?\s*Version 2\.0/i.test(head)) {return 'Apache-2.0';} + if (/MIT License/i.test(head) && /Permission is hereby granted/i.test(head)) {return 'MIT';} + if (/GNU GENERAL PUBLIC LICENSE\s+Version 2/i.test(head)) {return 'GPL-2.0-only';} + if (/GNU GENERAL PUBLIC LICENSE\s+Version 3/i.test(head)) {return 'GPL-3.0-only';} + if (/BSD 2-Clause/i.test(head)) {return 'BSD-2-Clause';} + if (/BSD 3-Clause/i.test(head)) {return 'BSD-3-Clause';} + return null; +} + +/** + * Normalize for comparison (lowercase, strip common suffixes). + * @param {string} spdxOrName + * @returns {string} + */ +function normalizeSpdx(spdxOrName) { + const s = String(spdxOrName).trim().toLowerCase(); + // e.g. "MIT" vs "MIT License" + if (s.endsWith(' license')) {return s.slice(0, -8);} + return s; +} diff --git a/src/provider.js b/src/provider.js index cb57998c..57652599 100644 --- a/src/provider.js +++ b/src/provider.js @@ -10,7 +10,7 @@ import Javascript_yarn from './providers/javascript_yarn.js'; import pythonPipProvider from './providers/python_pip.js' /** @typedef {{ecosystem: string, contentType: string, content: string}} Provided */ -/** @typedef {{isSupported: function(string): boolean, validateLockFile: function(string): void, provideComponent: function(string, {}): Provided | Promise, provideStack: function(string, {}): Provided | Promise}} Provider */ +/** @typedef {{isSupported: function(string): boolean, validateLockFile: function(string): void, provideComponent: function(string, {}): Provided | Promise, provideStack: function(string, {}): Provided | Promise, readLicenseFromManifest: function(string): string | null}} Provider */ /** * MUST include all providers here. @@ -26,6 +26,22 @@ export const availableProviders = [ golangGomodulesProvider, pythonPipProvider] +/** + * Match a provider by manifest type only (no lock file check). Used for license reading. + * @param {string} manifestPath - path or name of the manifest + * @param {[Provider]} providers - list of providers to iterate over + * @returns {Provider} + * @throws {Error} when the manifest is not supported and no provider was matched + */ +export function matchForLicense(manifestPath, providers) { + const base = path.parse(manifestPath).base + const provider = providers.find(prov => prov.isSupported(base)) + if (!provider) { + throw new Error(`${base} is not supported`) + } + return provider +} + /** * Match a provider from a list or providers based on file type. * Each provider MUST export 'isSupported' taking a file name-type and returning true if supported. diff --git a/src/providers/base_java.js b/src/providers/base_java.js index c16c48e2..e80a17a7 100644 --- a/src/providers/base_java.js +++ b/src/providers/base_java.js @@ -5,7 +5,6 @@ import { PackageURL } from 'packageurl-js' import { getCustomPath, getGitRootDir, getWrapperPreference, invokeCommand } from "../tools.js" - /** @typedef {import('../provider').Provider} */ /** @typedef {import('../provider').Provided} Provided */ diff --git a/src/providers/base_javascript.js b/src/providers/base_javascript.js index 365adb9e..236ca4c7 100644 --- a/src/providers/base_javascript.js +++ b/src/providers/base_javascript.js @@ -7,9 +7,9 @@ import { getCustom, getCustomPath, invokeCommand, toPurl, toPurlFromString } fro import Manifest from './manifest.js'; -/** @typedef {import('../provider.js').Provider} Provider */ +/** @typedef {import('../provider').Provider} */ -/** @typedef {import('../provider.js').Provided} Provided */ +/** @typedef {import('../provider').Provided} Provided */ /** * The ecosystem identifier for JavaScript/npm packages @@ -150,6 +150,28 @@ export default class Base_javascript { } } + /** + * Read license from manifest (package.json). Reused by npm, pnpm, yarn. + * @param {string} manifestPath - path to package.json + * @returns {string|null} + */ + readLicenseFromManifest(manifestPath) { + try { + const content = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + if (typeof content.license === 'string') { + return content.license.trim() || null; + } + if (Array.isArray(content.licenses) && content.licenses.length > 0) { + const first = content.licenses[0]; + const name = first.type || first.name; + return typeof name === 'string' ? name.trim() : null; + } + return null; + } catch { + return null; + } + } + /** * Builds the dependency tree for the project * @param {boolean} includeTransitive - Whether to include transitive dependencies @@ -176,9 +198,10 @@ export default class Base_javascript { const depsObject = this._buildDependencyTree(true); let mainComponent = toPurl(purlType, this.#manifest.name, this.#manifest.version); + const license = this.readLicenseFromManifest(this.#manifest.manifestPath); let sbom = new Sbom(); - sbom.addRoot(mainComponent); + sbom.addRoot(mainComponent, license); this._addDependenciesToSbom(sbom, depsObject); sbom.filterIgnoredDeps(this.#manifest.ignored); @@ -233,9 +256,10 @@ export default class Base_javascript { #getDirectDependencySbom(opts = {}) { const depTree = this._buildDependencyTree(false); let mainComponent = toPurl(purlType, this.#manifest.name, this.#manifest.version); + const license = this.readLicenseFromManifest(this.#manifest.manifestPath); let sbom = new Sbom(); - sbom.addRoot(mainComponent); + sbom.addRoot(mainComponent, license); const rootDeps = this._getRootDependencies(depTree); const sortedDepsKeys = Array diff --git a/src/providers/golang_gomodules.js b/src/providers/golang_gomodules.js index 676e9832..8b5efff5 100644 --- a/src/providers/golang_gomodules.js +++ b/src/providers/golang_gomodules.js @@ -8,7 +8,8 @@ import Sbom from '../sbom.js' import { getCustom, getCustomPath, invokeCommand } from "../tools.js"; -export default { isSupported, validateLockFile, provideComponent, provideStack } + +export default { isSupported, validateLockFile, provideComponent, provideStack, readLicenseFromManifest } /** @typedef {import('../provider').Provider} */ @@ -21,18 +22,26 @@ export default { isSupported, validateLockFile, provideComponent, provideStack } /** * @type {string} ecosystem for npm-npm is 'maven' * @private - */ +*/ const ecosystem = 'golang' const defaultMainModuleVersion = "v0.0.0"; /** * @param {string} manifestName - the subject manifest name-type * @returns {boolean} - return true if `pom.xml` is the manifest name-type - */ +*/ function isSupported(manifestName) { return 'go.mod' === manifestName } +/** + * Go modules have no standard license field in go.mod + * @param {string} manifestPath - path to go.mod + * @returns {string|null} +*/ +// eslint-disable-next-line no-unused-vars +function readLicenseFromManifest(manifestPath) { return null } + /** * @param {string} manifestDir - the directory where the manifest lies */ @@ -281,7 +290,8 @@ function getSBOM(manifest, opts = {}, includeTransitive) { } const mainModule = toPurl(root, "@") - sbom.addRoot(mainModule) + const license = readLicenseFromManifest(manifest); + sbom.addRoot(mainModule, license) const exhortGoMvsLogicEnabled = getCustom("TRUSTIFY_DA_GO_MVS_LOGIC_ENABLED", "true", opts) if(includeTransitive && exhortGoMvsLogicEnabled === "true") { rows = getFinalPackagesVersionsForModule(rows, manifest, goBin) diff --git a/src/providers/java_gradle.js b/src/providers/java_gradle.js index 4f3f0ae5..6481a154 100644 --- a/src/providers/java_gradle.js +++ b/src/providers/java_gradle.js @@ -69,6 +69,14 @@ export default class Java_gradle extends Base_java { */ validateLockFile() { return true; } + /** + * Gradle manifests (build.gradle, build.gradle.kts) have no standard license field. + * @param {string} manifestPath - path to manifest + * @returns {null} + */ + // eslint-disable-next-line no-unused-vars + readLicenseFromManifest(manifestPath) { return null; } + /** * Provide content and content type for stack analysis. * @param {string} manifest - the manifest path or name @@ -191,7 +199,8 @@ export default class Java_gradle extends Base_java { let sbom = new Sbom(); let root = `${properties.group}:${properties[ROOT_PROJECT_KEY_NAME].match(/Root project '(.+)'/)[1]}:jar:${properties.version}` let rootPurl = this.parseDep(root) - sbom.addRoot(rootPurl) + const license = this.readLicenseFromManifest(manifestPath); + sbom.addRoot(rootPurl, license) let ignoredDeps = this.#getIgnoredDeps(manifestPath); const [runtimeConfig, compileConfig] = this.#extractConfigurations(content); @@ -345,7 +354,8 @@ export default class Java_gradle extends Base_java { let sbom = new Sbom(); let root = `${properties.group}:${properties[ROOT_PROJECT_KEY_NAME].match(/Root project '(.+)'/)[1]}:jar:${properties.version}` let rootPurl = this.parseDep(root) - sbom.addRoot(rootPurl) + const license = this.readLicenseFromManifest(manifestPath); + sbom.addRoot(rootPurl, license) let ignoredDeps = this.#getIgnoredDeps(manifestPath); const [runtimeConfig, compileConfig] = this.#extractConfigurations(content); diff --git a/src/providers/java_maven.js b/src/providers/java_maven.js index 4e6d724b..f87127e1 100644 --- a/src/providers/java_maven.js +++ b/src/providers/java_maven.js @@ -65,6 +65,30 @@ export default class Java_maven extends Base_java { } } + /** + * Read license from pom.xml manifest + * @param {string} manifestPath - path to pom.xml + * @returns {string|null} + */ + readLicenseFromManifest(manifestPath) { + try { + const xml = fs.readFileSync(manifestPath, 'utf-8'); + const parser = new XMLParser({ ignoreAttributes: false }); + const obj = parser.parse(xml); + const project = obj?.project; + if (!project?.licenses?.license) { + return null; + } + const license = Array.isArray(project.licenses.license) + ? project.licenses.license[0] + : project.licenses.license; + const name = (license?.name && license.name.trim()) || null; + return name || null; + } catch { + return null; + } + } + /** * Create a Dot Graph dependency tree for a manifest path. * @param {string} manifest - path for pom.xml @@ -119,7 +143,7 @@ export default class Java_maven extends Base_java { if (process.env["TRUSTIFY_DA_DEBUG"] === "true") { console.error("Dependency tree that will be used as input for creating the BOM =>" + EOL + EOL + content.toString()) } - let sbom = this.createSbomFileFromTextFormat(content.toString(), ignoredDeps, opts); + let sbom = this.createSbomFileFromTextFormat(content.toString(), ignoredDeps, opts, manifest); // delete temp file and directory fs.rmSync(tmpDir, { recursive: true, force: true }) // return dependency graph as string @@ -130,15 +154,17 @@ export default class Java_maven extends Base_java { * * @param {String} textGraphList Text graph String of the manifest * @param {[String]} ignoredDeps List of ignored dependencies to be omitted from sbom + * @param {String} manifestPath Path to the pom.xml manifest * @return {String} formatted sbom Json String with all dependencies */ - createSbomFileFromTextFormat(textGraphList, ignoredDeps, opts) { + createSbomFileFromTextFormat(textGraphList, ignoredDeps, opts, manifestPath) { let lines = textGraphList.split(EOL); // get root component let root = lines[0]; let rootPurl = this.parseDep(root); + const license = this.readLicenseFromManifest(manifestPath); let sbom = new Sbom(); - sbom.addRoot(rootPurl); + sbom.addRoot(rootPurl, license); this.parseDependencyTree(root, 0, lines.slice(1), sbom); return sbom.filterIgnoredDeps(ignoredDeps).getAsJsonString(opts); } @@ -173,7 +199,8 @@ export default class Java_maven extends Base_java { let sbom = new Sbom(); let rootDependency = this.#getRootFromPom(tmpEffectivePom, manifestPath); let purlRoot = this.toPurl(rootDependency.groupId, rootDependency.artifactId, rootDependency.version) - sbom.addRoot(purlRoot) + const license = this.readLicenseFromManifest(manifestPath); + sbom.addRoot(purlRoot, license) dependencies.forEach(dep => { let currentPurl = this.toPurl(dep.groupId, dep.artifactId, dep.version) sbom.addDependency(purlRoot, currentPurl) diff --git a/src/providers/python_pip.js b/src/providers/python_pip.js index 8336e4a3..3fea30e1 100644 --- a/src/providers/python_pip.js +++ b/src/providers/python_pip.js @@ -13,7 +13,7 @@ import { import Python_controller from './python_controller.js' import { getParser, getIgnoreQuery, getPinnedVersionQuery } from './requirements_parser.js' -export default { isSupported, validateLockFile, provideComponent, provideStack } +export default { isSupported, validateLockFile, provideComponent, provideStack, readLicenseFromManifest } /** @typedef {{name: string, version: string, dependencies: DependencyEntry[]}} DependencyEntry */ @@ -31,6 +31,14 @@ function isSupported(manifestName) { return 'requirements.txt' === manifestName } +/** + * Python requirements.txt has no standard license field + * @param {string} manifestPath - path to requirements.txt + * @returns {string|null} + */ +// eslint-disable-next-line no-unused-vars +function readLicenseFromManifest(manifestPath) { return null } + /** * @param {string} manifestDir - the directory where the manifest lies */ @@ -189,7 +197,8 @@ async function createSbomStackAnalysis(manifest, opts = {}) { let dependencies = await pythonController.getDependencies(true); let sbom = new Sbom(); const rootPurl = toPurl(DEFAULT_PIP_ROOT_COMPONENT_NAME, DEFAULT_PIP_ROOT_COMPONENT_VERSION); - sbom.addRoot(rootPurl); + const license = readLicenseFromManifest(manifest); + sbom.addRoot(rootPurl, license); dependencies.forEach(dep => { addAllDependencies(rootPurl, dep, sbom) }) @@ -213,7 +222,8 @@ async function getSbomForComponentAnalysis(manifest, opts = {}) { let dependencies = await pythonController.getDependencies(false); let sbom = new Sbom(); const rootPurl = toPurl(DEFAULT_PIP_ROOT_COMPONENT_NAME, DEFAULT_PIP_ROOT_COMPONENT_VERSION); - sbom.addRoot(rootPurl); + const license = readLicenseFromManifest(manifest); + sbom.addRoot(rootPurl, license); dependencies.forEach(dep => { sbom.addDependency(rootPurl, toPurl(dep.name, dep.version)) }) diff --git a/src/sbom.js b/src/sbom.js index 1e4af9b2..a708410b 100644 --- a/src/sbom.js +++ b/src/sbom.js @@ -14,10 +14,11 @@ export default class Sbom { /** * @param {PackageURL} root - add main/root component for sbom + * @param {string|Array} [licenses] - optional license(s) for the root component * @return Sbom */ - addRoot(root) { - return this.sbomModel.addRoot(root) + addRoot (root, licenses) { + return this.sbomModel.addRoot(root, licenses) } /** diff --git a/src/tools.js b/src/tools.js index 8f6fd115..756968e5 100644 --- a/src/tools.js +++ b/src/tools.js @@ -1,6 +1,7 @@ import { execFileSync } from "child_process"; import { EOL } from "os"; +import { HttpsProxyAgent } from "https-proxy-agent"; import { PackageURL } from "packageurl-js"; export const RegexNotToBeLogged = /TRUSTIFY_DA_(.*_)?TOKEN|ex-.*-token|trust-.*-token/ @@ -170,3 +171,63 @@ export function invokeCommand(bin, args, opts={}) { return execFileSync(bin, args, {...{stdio: 'pipe', encoding: 'utf-8'}, ...opts}) } + + +export const TRUSTIFY_DA_TOKEN_HEADER = "trust-da-token"; +export const TRUSTIFY_DA_TELEMETRY_ID_HEADER = "telemetry-anonymous-id"; +export const TRUSTIFY_DA_SOURCE_HEADER = "trust-da-source" +export const TRUSTIFY_DA_OPERATION_TYPE_HEADER = "trust-da-operation-type" +export const TRUSTIFY_DA_PACKAGE_MANAGER_HEADER = "trust-da-pkg-manager" + +/** + * Adds proxy agent configuration to fetch options if a proxy URL is specified + * @param {RequestInit} options - The base fetch options + * @param {import("index.js").Options} opts - The trustify DA options that may contain proxy configuration + * @returns {RequestInit} The fetch options with proxy agent if applicable + */ +export function addProxyAgent(options, opts) { + const proxyUrl = getCustom('TRUSTIFY_DA_PROXY_URL', null, opts); + if (proxyUrl) { + options.agent = new HttpsProxyAgent(proxyUrl); + } + return options; +} + +/** + * Utility function for fetching vendor tokens + * @param {import("index.js").Options} [opts={}] - optional various options to pass along the application + * @returns {{}} + */ +export function getTokenHeaders(opts = {}) { + let headers = {} + setCustomHeader(TRUSTIFY_DA_TOKEN_HEADER, headers, 'TRUSTIFY_DA_TOKEN', opts); + setCustomHeader(TRUSTIFY_DA_SOURCE_HEADER, headers, 'TRUSTIFY_DA_SOURCE', opts); + setCustomHeader(TRUSTIFY_DA_OPERATION_TYPE_HEADER, headers, TRUSTIFY_DA_OPERATION_TYPE_HEADER.toUpperCase().replaceAll("-", "_"), opts); + setCustomHeader(TRUSTIFY_DA_PACKAGE_MANAGER_HEADER, headers, TRUSTIFY_DA_PACKAGE_MANAGER_HEADER.toUpperCase().replaceAll("-", "_"), opts) + setCustomHeader(TRUSTIFY_DA_TELEMETRY_ID_HEADER, headers, 'TRUSTIFY_DA_TELEMETRY_ID', opts); + + if (getCustom("TRUSTIFY_DA_DEBUG", null, opts) === "true") { + console.log("Headers Values to be sent to Trustify DA backend:" + EOL) + for (const headerKey in headers) { + if (!headerKey.match(RegexNotToBeLogged)) { + console.log(`${headerKey}: ${headers[headerKey]}`) + } + } + } + return headers +} + +/** + * + * @param {string} headerName - the header name to populate in request + * @param headers + * @param {string} optsKey - key in the options object to use the value for + * @param {import("index.js").Options} [opts={}] - options input object to fetch header values from + * @private + */ +function setCustomHeader(headerName, headers, optsKey, opts) { + let customHeaderValue = getCustom(optsKey, null, opts); + if (customHeaderValue) { + headers[headerName] = customHeaderValue + } +} diff --git a/test/analysis.test.js b/test/analysis.test.js index a19be85a..438d8598 100644 --- a/test/analysis.test.js +++ b/test/analysis.test.js @@ -23,10 +23,10 @@ function interceptAndRun(handler, test) { function determineResponse(req, res, ctx) { let response - if (req.headers.get("ex-snyk-token") == null) { + if (req.headers.get("ex-provider-1-token") == null) { response = res(ctx.status(400)); - } else if (req.headers.get("ex-snyk-token") === "good-dummy-token") { + } else if (req.headers.get("ex-provider-1-token") === "good-dummy-token") { response = res(ctx.status(200)); } else { response = res(ctx.status(401)); @@ -44,7 +44,7 @@ suite('testing the analysis module for sending api requests', () => { }; test('invoking the requestComponent should return a json report', interceptAndRun( - http.post(`${backendUrl}/api/v3/analysis`, (req, res, ctx) => { + http.post(`${backendUrl}/api/v5/analysis`, (req, res, ctx) => { // interception route, will return ok response for our fake content type if (fakeProvided.contentType === req.headers.get('content-type')) { return res(ctx.json({ dummy: 'response' })) @@ -83,7 +83,7 @@ suite('testing the analysis module for sending api requests', () => { test('invoking the requestStack for html should return a string report', interceptAndRun( // interception route, will return ok response for our fake content type - http.post(`${backendUrl}/api/v3/analysis`, (req, res, ctx) => { + http.post(`${backendUrl}/api/v5/analysis`, (req, res, ctx) => { if (fakeProvided.contentType === req.headers.get('content-type')) { return res(ctx.text('html-content')) } @@ -98,7 +98,7 @@ suite('testing the analysis module for sending api requests', () => { test('invoking the requestStack for non-html should return a json report', interceptAndRun( // interception route, will return ok response for our fake content type - http.post(`${backendUrl}/api/v3/analysis`, (req, res, ctx) => { + http.post(`${backendUrl}/api/v5/analysis`, (req, res, ctx) => { if (fakeProvided.contentType === req.headers.get('content-type')) { return res(ctx.json({ dummy: 'response' })) } @@ -115,13 +115,13 @@ suite('testing the analysis module for sending api requests', () => { test('invoking validateToken function with good token', interceptAndRun( // interception route, will return ok response for our fake content type - http.get(`${backendUrl}/api/v3/token`, (req, res, ctx) => { + http.get(`${backendUrl}/api/v5/token`, (req, res, ctx) => { return determineResponse(req, res, ctx); }), async () => { let options = { - 'TRUSTIFY_DA_SNYK_TOKEN': 'good-dummy-token' + 'TRUSTIFY_DA_PROVIDER_1_TOKEN': 'good-dummy-token' } // verify response as expected let res = await analysis.validateToken(backendUrl, options) @@ -130,13 +130,13 @@ suite('testing the analysis module for sending api requests', () => { )) test('invoking validateToken function with bad token', interceptAndRun( // interception route, will return ok response for our fake content type - http.get(`${backendUrl}/api/v3/token`, (req, res, ctx) => { + http.get(`${backendUrl}/api/v5/token`, (req, res, ctx) => { return determineResponse(req, res, ctx); }), async () => { let options = { - 'TRUSTIFY_DA_SNYK_TOKEN': 'bad-dummy-token' + 'TRUSTIFY_DA_PROVIDER_1_TOKEN': 'bad-dummy-token' } // verify response as expected let res = await analysis.validateToken(backendUrl, options) @@ -145,7 +145,7 @@ suite('testing the analysis module for sending api requests', () => { )) test('invoking validateToken function without token', interceptAndRun( // interception route, will return ok response for our fake content type - http.get(`${backendUrl}/api/v3/token`, (req, res, ctx) => { + http.get(`${backendUrl}/api/v5/token`, (req, res, ctx) => { return determineResponse(req, res, ctx); }), @@ -172,18 +172,18 @@ suite('testing the analysis module for sending api requests', () => { isSupported: () => { } // not required for this test }; - afterEach(() => delete process.env['TRUSTIFY_DA_SNYK_TOKEN']) + afterEach(() => delete process.env['TRUSTIFY_DA_PROVIDER_1_TOKEN']) test('when the relevant token environment variables are set, verify corresponding headers are included', interceptAndRun( // interception route, will return ok response if found the expected token - http.post(`${backendUrl}/api/v3/analysis`, (req, res, ctx) => { - if ('dummy-snyk-token' === req.headers.get('ex-snyk-token')) { + http.post(`${backendUrl}/api/v5/analysis`, (req, res, ctx) => { + if ('dummy-provider-1-token' === req.headers.get('ex-provider-1-token')) { return res(ctx.json({ ok: 'ok' })) } return res(ctx.status(400)) }), async () => { - process.env['TRUSTIFY_DA_SNYK_TOKEN'] = 'dummy-snyk-token' + process.env['TRUSTIFY_DA_PROVIDER_1_TOKEN'] = 'dummy-provider-1-token' let res = await analysis.requestStack(fakeProvider, fakeManifest, backendUrl) expect(res).to.deep.equal({ ok: 'ok' }) } @@ -191,8 +191,8 @@ suite('testing the analysis module for sending api requests', () => { test('when the relevant token environment variables are not set, verify no corresponding headers are included', interceptAndRun( // interception route, will return ok response if found the expected token - http.post(`${backendUrl}/api/v3/analysis`, (req, res, ctx) => { - if (!req.headers.get('ex-snyk-token')) { + http.post(`${backendUrl}/api/v5/analysis`, (req, res, ctx) => { + if (!req.headers.get('ex-provider-1-token')) { return res(ctx.json({ ok: 'ok' })) } return res(ctx.status(400)) @@ -219,7 +219,7 @@ suite('testing the analysis module for sending api requests', () => { }) test('when HTTP proxy is configured, verify agent is set correctly', interceptAndRun( - http.post(`${backendUrl}/api/v3/analysis`, (req, res, ctx) => { + http.post(`${backendUrl}/api/v5/analysis`, (req, res, ctx) => { // The request should go through the proxy return res(ctx.json({ ok: 'ok' })) }), @@ -234,7 +234,7 @@ suite('testing the analysis module for sending api requests', () => { )) test('when HTTPS proxy is configured, verify agent is set correctly', interceptAndRun( - http.post(`${backendUrl}/api/v3/analysis`, (req, res, ctx) => { + http.post(`${backendUrl}/api/v5/analysis`, (req, res, ctx) => { // The request should go through the proxy return res(ctx.json({ ok: 'ok' })) }), @@ -249,7 +249,7 @@ suite('testing the analysis module for sending api requests', () => { )) test('when proxy is configured via environment variable, verify agent is set correctly', interceptAndRun( - http.post(`${backendUrl}/api/v3/analysis`, (req, res, ctx) => { + http.post(`${backendUrl}/api/v5/analysis`, (req, res, ctx) => { // The request should go through the proxy return res(ctx.json({ ok: 'ok' })) }), @@ -261,7 +261,7 @@ suite('testing the analysis module for sending api requests', () => { )) test('when no proxy is configured, verify no agent is set', interceptAndRun( - http.post(`${backendUrl}/api/v3/analysis`, (req, res, ctx) => { + http.post(`${backendUrl}/api/v5/analysis`, (req, res, ctx) => { // The request should go directly without proxy return res(ctx.json({ ok: 'ok' })) }), diff --git a/test/exhort-backend-utils.test.js b/test/exhort-backend-utils.test.js index 13130a7d..838ddfcb 100644 --- a/test/exhort-backend-utils.test.js +++ b/test/exhort-backend-utils.test.js @@ -3,8 +3,8 @@ import * as chai from 'chai' import * as sinon from 'sinon' import sinonChai from 'sinon-chai' -import { getTokenHeaders } from '../src/analysis.js'; import { selectTrustifyDABackend } from '../src/index.js' +import { getTokenHeaders } from '../src/tools.js'; chai.use(sinonChai) diff --git a/test/provider.test.js b/test/provider.test.js index 2347aa54..40a87af7 100644 --- a/test/provider.test.js +++ b/test/provider.test.js @@ -1,6 +1,6 @@ import { expect } from 'chai' -import { match } from "../src/provider.js" +import { match, matchForLicense, availableProviders } from "../src/provider.js" suite('testing the provider utility function', () => { // create a dummy provider for 'dummy_file.typ' @@ -21,3 +21,123 @@ suite('testing the provider utility function', () => { .to.throw('unknown.manifest is not supported') }) }); + +suite('testing the matchForLicense utility function', () => { + // create dummy providers with different manifest types + const pomProvider = { + isSupported: nameType => 'pom.xml' === nameType, + validateLockFile: () => true, + readLicenseFromManifest: () => 'Apache-2.0', + provideComponent: () => {}, + provideStack: () => {} + }; + + const packageJsonProvider = { + isSupported: nameType => 'package.json' === nameType, + validateLockFile: () => false, // No lock file - should still match for license + readLicenseFromManifest: () => 'MIT', + provideComponent: () => {}, + provideStack: () => {} + }; + + const goModProvider = { + isSupported: nameType => 'go.mod' === nameType, + validateLockFile: () => true, + readLicenseFromManifest: () => null, + provideComponent: () => {}, + provideStack: () => {} + }; + + const testProviders = [pomProvider, packageJsonProvider, goModProvider]; + + test('should match pom.xml provider by manifest name', () => { + const provider = matchForLicense('/path/to/pom.xml', testProviders); + expect(provider).to.equal(pomProvider); + }); + + test('should match package.json provider by manifest name', () => { + const provider = matchForLicense('/path/to/package.json', testProviders); + expect(provider).to.equal(packageJsonProvider); + }); + + test('should match go.mod provider by manifest name', () => { + const provider = matchForLicense('/path/to/go.mod', testProviders); + expect(provider).to.equal(goModProvider); + }); + + test('should match provider even when lock file does not exist', () => { + // packageJsonProvider has validateLockFile returning false + // but matchForLicense should not check lock file + const provider = matchForLicense('/no/lock/here/package.json', testProviders); + expect(provider).to.equal(packageJsonProvider); + }); + + test('should match provider with just filename (no full path)', () => { + const provider = matchForLicense('pom.xml', testProviders); + expect(provider).to.equal(pomProvider); + }); + + test('should throw error when no provider matches manifest type', () => { + expect(() => matchForLicense('/path/to/unknown.txt', testProviders)) + .to.throw('unknown.txt is not supported'); + }); + + test('should throw error for empty manifest name', () => { + expect(() => matchForLicense('', testProviders)) + .to.throw('is not supported'); + }); + + suite('real provider matching', () => { + test('should match Java Maven provider for pom.xml', () => { + const provider = matchForLicense('/some/path/pom.xml', availableProviders); + expect(provider).to.exist; + expect(provider.isSupported('pom.xml')).to.be.true; + }); + + test('should match Java Gradle Groovy provider for build.gradle', () => { + const provider = matchForLicense('/some/path/build.gradle', availableProviders); + expect(provider).to.exist; + expect(provider.isSupported('build.gradle')).to.be.true; + }); + + test('should match Java Gradle Kotlin provider for build.gradle.kts', () => { + const provider = matchForLicense('/some/path/build.gradle.kts', availableProviders); + expect(provider).to.exist; + expect(provider.isSupported('build.gradle.kts')).to.be.true; + }); + + test('should match JavaScript provider for package.json', () => { + const provider = matchForLicense('/some/path/package.json', availableProviders); + expect(provider).to.exist; + expect(provider.isSupported('package.json')).to.be.true; + }); + + test('should match Golang provider for go.mod', () => { + const provider = matchForLicense('/some/path/go.mod', availableProviders); + expect(provider).to.exist; + expect(provider.isSupported('go.mod')).to.be.true; + }); + + test('should match Python provider for requirements.txt', () => { + const provider = matchForLicense('/some/path/requirements.txt', availableProviders); + expect(provider).to.exist; + expect(provider.isSupported('requirements.txt')).to.be.true; + }); + + test('all matched providers should have readLicenseFromManifest method', () => { + const manifests = [ + 'pom.xml', + 'build.gradle', + 'build.gradle.kts', + 'package.json', + 'go.mod', + 'requirements.txt' + ]; + + manifests.forEach(manifest => { + const provider = matchForLicense(`/test/${manifest}`, availableProviders); + expect(provider.readLicenseFromManifest).to.be.a('function'); + }); + }); + }); +}); diff --git a/test/providers/license.test.js b/test/providers/license.test.js new file mode 100644 index 00000000..2e7e1438 --- /dev/null +++ b/test/providers/license.test.js @@ -0,0 +1,136 @@ +import path from 'path' + +import { expect } from 'chai' + +import golangGomodulesProvider from '../../src/providers/golang_gomodules.js' +import Java_gradle_groovy from '../../src/providers/java_gradle_groovy.js' +import Java_gradle_kotlin from '../../src/providers/java_gradle_kotlin.js' +import Java_maven from '../../src/providers/java_maven.js' +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' + +suite('testing readLicenseFromManifest with existing test manifests', () => { + + suite('Java Maven provider', () => { + const provider = new Java_maven(); + + test('should read Apache-2.0 license when found', () => { + const pomPath = path.resolve('test/providers/tst_manifests/maven/pom_deps_with_ignore_version_from_property/pom.xml'); + const license = provider.readLicenseFromManifest(pomPath); + expect(license).to.equal('Apache-2.0'); + }); + + test('should return null when license not present', () => { + const pomPath = path.resolve('test/providers/tst_manifests/maven/pom_deps_with_no_ignore/pom.xml'); + const license = provider.readLicenseFromManifest(pomPath); + expect(license).to.be.null; + }); + }); + + suite('Java Gradle Groovy provider', () => { + const provider = new Java_gradle_groovy(); + + test('should always return null (no standard license field)', () => { + const gradlePath = path.resolve('test/providers/tst_manifests/gradle/deps_with_no_ignore_common_paths/build.gradle'); + const license = provider.readLicenseFromManifest(gradlePath); + expect(license).to.be.null; + }); + }); + + suite('Java Gradle Kotlin provider', () => { + const provider = new Java_gradle_kotlin(); + + test('should always return null (no standard license field)', () => { + const gradlePath = path.resolve('test/providers/tst_manifests/gradle/deps_with_no_ignore_common_paths/build.gradle.kts'); + const license = provider.readLicenseFromManifest(gradlePath); + expect(license).to.be.null; + }); + }); + + suite('JavaScript npm provider', () => { + const provider = new Javascript_npm(); + + test('should read ISC license when found', () => { + const packagePath = path.resolve('test/providers/tst_manifests/npm/package_json_deps_with_exhortignore_object/package.json'); + const license = provider.readLicenseFromManifest(packagePath); + expect(license).to.equal('ISC'); + }); + + test('should return null for non-existent file', () => { + const license = provider.readLicenseFromManifest('/fake/path/package.json'); + expect(license).to.be.null; + }); + }); + + suite('JavaScript pnpm provider', () => { + const provider = new Javascript_pnpm(); + + test('should read ISC license when found', () => { + const packagePath = path.resolve('test/providers/tst_manifests/pnpm/package_json_deps_with_exhortignore_object/package.json'); + const license = provider.readLicenseFromManifest(packagePath); + expect(license).to.equal('ISC'); + }); + + test('should return null for non-existent file', () => { + const license = provider.readLicenseFromManifest('/fake/path/package.json'); + expect(license).to.be.null; + }); + }); + + suite('JavaScript yarn provider', () => { + const provider = new Javascript_yarn(); + + test('should read ISC license when found', () => { + const packagePath = path.resolve('test/providers/tst_manifests/yarn-classic/package_json_deps_with_exhortignore_object/package.json'); + const license = provider.readLicenseFromManifest(packagePath); + expect(license).to.equal('ISC'); + }); + + test('should return null for non-existent file', () => { + const license = provider.readLicenseFromManifest('/fake/path/package.json'); + expect(license).to.be.null; + }); + }); + + suite('Golang go.mod provider', () => { + test('should always return null (no standard license field)', () => { + const goModPath = path.resolve('test/providers/tst_manifests/golang/go_mod_no_ignore/go.mod'); + const license = golangGomodulesProvider.readLicenseFromManifest(goModPath); + expect(license).to.be.null; + }); + }); + + suite('Python requirements.txt provider', () => { + test('should always return null (no standard license field)', () => { + const reqPath = path.resolve('test/providers/tst_manifests/pip/pip_requirements_txt_no_ignore/requirements.txt'); + const license = pythonPipProvider.readLicenseFromManifest(reqPath); + expect(license).to.be.null; + }); + }); + + suite('All providers have readLicenseFromManifest method', () => { + const allProviders = [ + { name: 'Java Maven', instance: new Java_maven() }, + { name: 'Java Gradle Groovy', instance: new Java_gradle_groovy() }, + { name: 'Java Gradle Kotlin', instance: new Java_gradle_kotlin() }, + { name: 'JavaScript npm', instance: new Javascript_npm() }, + { name: 'JavaScript pnpm', instance: new Javascript_pnpm() }, + { name: 'JavaScript yarn', instance: new Javascript_yarn() }, + { name: 'Golang', instance: golangGomodulesProvider }, + { name: 'Python', instance: pythonPipProvider } + ]; + + allProviders.forEach(({ name, instance }) => { + test(`${name} provider exports readLicenseFromManifest`, () => { + expect(instance.readLicenseFromManifest).to.be.a('function'); + }); + + test(`${name} provider readLicenseFromManifest accepts manifestPath parameter`, () => { + // Should not throw when called with a path argument + expect(() => instance.readLicenseFromManifest('/test/path')).to.not.throw(); + }); + }); + }); +}); diff --git a/test/providers/tst_manifests/maven/pom_deps_with_ignore_version_from_property/component_analysis_expected_sbom.json b/test/providers/tst_manifests/maven/pom_deps_with_ignore_version_from_property/component_analysis_expected_sbom.json index 915610fc..e2d6c173 100644 --- a/test/providers/tst_manifests/maven/pom_deps_with_ignore_version_from_property/component_analysis_expected_sbom.json +++ b/test/providers/tst_manifests/maven/pom_deps_with_ignore_version_from_property/component_analysis_expected_sbom.json @@ -10,7 +10,14 @@ "version": "0.0.1", "purl": "pkg:maven/pom-with-deps-and-ignore/pom-with-dependency-not-ignored-for-tests@0.0.1", "type": "application", - "bom-ref": "pkg:maven/pom-with-deps-and-ignore/pom-with-dependency-not-ignored-for-tests@0.0.1" + "bom-ref": "pkg:maven/pom-with-deps-and-ignore/pom-with-dependency-not-ignored-for-tests@0.0.1", + "licenses": [ + { + "license": { + "id": "Apache-2.0" + } + } + ] } }, "components": [ @@ -20,7 +27,14 @@ "version": "0.0.1", "purl": "pkg:maven/pom-with-deps-and-ignore/pom-with-dependency-not-ignored-for-tests@0.0.1", "type": "application", - "bom-ref": "pkg:maven/pom-with-deps-and-ignore/pom-with-dependency-not-ignored-for-tests@0.0.1" + "bom-ref": "pkg:maven/pom-with-deps-and-ignore/pom-with-dependency-not-ignored-for-tests@0.0.1", + "licenses": [ + { + "license": { + "id": "Apache-2.0" + } + } + ] }, { "group": "org.bouncycastle", diff --git a/test/providers/tst_manifests/maven/pom_deps_with_ignore_version_from_property/pom.xml b/test/providers/tst_manifests/maven/pom_deps_with_ignore_version_from_property/pom.xml index ad641fdb..6af16633 100644 --- a/test/providers/tst_manifests/maven/pom_deps_with_ignore_version_from_property/pom.xml +++ b/test/providers/tst_manifests/maven/pom_deps_with_ignore_version_from_property/pom.xml @@ -6,7 +6,16 @@ pom-with-deps-and-ignore pom-with-dependency-not-ignored-for-tests 0.0.1 - + + + + + Apache-2.0 + https://www.apache.org/licenses/LICENSE-2.0 + repo + Apache License, Version 2.0 + + 1.2.17 diff --git a/test/providers/tst_manifests/maven/pom_deps_with_ignore_version_from_property/stack_analysis_expected_sbom.json b/test/providers/tst_manifests/maven/pom_deps_with_ignore_version_from_property/stack_analysis_expected_sbom.json index 72759f37..bdf83b2e 100644 --- a/test/providers/tst_manifests/maven/pom_deps_with_ignore_version_from_property/stack_analysis_expected_sbom.json +++ b/test/providers/tst_manifests/maven/pom_deps_with_ignore_version_from_property/stack_analysis_expected_sbom.json @@ -10,7 +10,14 @@ "version": "0.0.1", "purl": "pkg:maven/pom-with-deps-and-ignore/pom-with-dependency-not-ignored-for-tests@0.0.1", "type": "application", - "bom-ref": "pkg:maven/pom-with-deps-and-ignore/pom-with-dependency-not-ignored-for-tests@0.0.1" + "bom-ref": "pkg:maven/pom-with-deps-and-ignore/pom-with-dependency-not-ignored-for-tests@0.0.1", + "licenses": [ + { + "license": { + "id": "Apache-2.0" + } + } + ] } }, "components": [ @@ -20,7 +27,14 @@ "version": "0.0.1", "purl": "pkg:maven/pom-with-deps-and-ignore/pom-with-dependency-not-ignored-for-tests@0.0.1", "type": "application", - "bom-ref": "pkg:maven/pom-with-deps-and-ignore/pom-with-dependency-not-ignored-for-tests@0.0.1" + "bom-ref": "pkg:maven/pom-with-deps-and-ignore/pom-with-dependency-not-ignored-for-tests@0.0.1", + "licenses": [ + { + "license": { + "id": "Apache-2.0" + } + } + ] }, { "group": "org.bouncycastle",