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.jsonlicense, 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