Skip to content

Commit 4ef8df6

Browse files
ruromeroclaude
andcommitted
feat: implement license resolution and identification
Signed-off-by: Ruben Romero Montes <rromerom@redhat.com> Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 477151f commit 4ef8df6

23 files changed

Lines changed: 919 additions & 80 deletions

README.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,13 @@ Use as CLI Script
8383
```shell
8484
$ npx @trustify-da/trustify-da-javascript-client help
8585

86-
Usage: trustify-da-javascript-client {component|stack|image|validate-token}
86+
Usage: trustify-da-javascript-client {component|stack|image|validate-token|license}
8787

8888
Commands:
8989
trustify-da-javascript-client stack </path/to/manifest> [--html|--summary] produce stack report for manifest path
9090
trustify-da-javascript-client component <path/to/manifest> [--summary] produce component report for a manifest type and content
9191
trustify-da-javascript-client image <image-refs..> [--html|--summary] produce image analysis report for OCI image references
92+
trustify-da-javascript-client license </path/to/manifest> display project license information from manifest and LICENSE file in JSON format
9293

9394
Options:
9495
--help Show help [boolean]
@@ -123,6 +124,9 @@ $ npx @trustify-da/trustify-da-javascript-client image docker.io/library/node:18
123124

124125
# specify architecture using ^^ notation (e.g., httpd:2.4.49^^amd64)
125126
$ npx @trustify-da/trustify-da-javascript-client image httpd:2.4.49^^amd64
127+
128+
# get project license information
129+
$ npx @trustify-da/trustify-da-javascript-client license /path/to/package.json
126130
```
127131
</li>
128132

@@ -161,6 +165,9 @@ $ trustify-da-javascript-client image docker.io/library/node:18 docker.io/librar
161165

162166
# specify architecture using ^^ notation (e.g., httpd:2.4.49^^amd64)
163167
$ trustify-da-javascript-client image httpd:2.4.49^^amd64
168+
169+
# get project license information
170+
$ trustify-da-javascript-client license /path/to/package.json
164171
```
165172
</li>
166173
</ul>
@@ -372,6 +379,11 @@ const options = {
372379
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.
373380
</p>
374381

382+
<h4>License resolution and dependency license compliance</h4>
383+
<p>
384+
The client can resolve the <strong>project license</strong> from the manifest (e.g. <code>package.json</code> <code>license</code>, <code>pom.xml</code> <code>&lt;licenses&gt;</code>) and from a <code>LICENSE</code> or <code>LICENSE.md</code> file in the project, and report when they differ. For <strong>component analysis</strong>, 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 <a href="docs/license-resolution-and-compliance.md">License resolution and compliance</a> for design and behavior. To disable the check on component analysis, set <code>TRUSTIFY_DA_LICENSE_CHECK=false</code> or pass <code>licenseCheck: false</code> in the options.
385+
</p>
386+
375387
<h4>Customizing Executables</h4>
376388
<p>
377389
This project uses each ecosystem's executable for creating dependency trees. These executables are expected to be
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# License resolution and compliance
2+
3+
This document describes how the client resolves the **project license** (from manifest vs repository file), how it uses the **backend licenses-by-purl endpoint** to get dependency licenses, and how **component analysis** can report when the project license is missing, inconsistent, or incompatible with dependency licenses.
4+
5+
## Goals
6+
7+
1. **Resolve project license** from:
8+
- The manifest (e.g. `package.json` `license`, `pom.xml` `<licenses>`) when present.
9+
- A `LICENSE`, `LICENSE.md`, or `LICENSE.txt` file in the same directory as the manifest.
10+
2. **Report to the user** when the license declared in the manifest differs from the license text file.
11+
3. **Component analysis**: when the user runs component analysis (e.g. on opening a manifest), optionally check dependency licenses (via backend) and report if any are **incompatible** with the project license.
12+
13+
## Resolving the project license
14+
15+
### From the manifest
16+
17+
| Ecosystem | Manifest | Where license is read |
18+
|----------------|-----------------|------------------------|
19+
| JavaScript | package.json | `license` (string) or `licenses` (array); can be SPDX id or "SEE LICENSE IN FILE" |
20+
| Java (Maven) | pom.xml | Effective POM: `<licenses><license><name>` / `<url>`; map common names to SPDX where possible |
21+
| Java (Gradle) | build.gradle(*) | No standard; some projects set extra/license in properties; optional best-effort |
22+
| Go | go.mod | No standard license field |
23+
| Python | requirements.txt| No license in manifest |
24+
25+
(*) Gradle: license is often in a separate file or NOTICE; we do not parse build logic.
26+
27+
### From the repository (LICENSE / LICENSE.md)
28+
29+
- Look in the **manifest directory** for `LICENSE`, `LICENSE.md`, or `LICENSE.txt`.
30+
- Only searches the same directory as the manifest (not parent directories or git root).
31+
- Read file content; try to **detect** or **normalize** to an SPDX identifier (e.g. "Apache-2.0") for comparison using local pattern matching or the backend `/licenses/identify` endpoint for more accuracy.
32+
33+
### Reporting manifest vs file mismatch
34+
35+
- If both manifest and file are present and indicate different licenses → set `licenseSummary.manifestVsFileMismatch: true` and include both values so the user can fix the inconsistency.
36+
- If only one is present, we still expose both `fromManifest` and `fromFile` so the user sees what was found.
37+
38+
## Backend: License Analysis API (v5) and license data in the analysis report
39+
40+
The Trustify Dependency Analytics backend provides:
41+
42+
1. **License data in the dependency analysis report** — When using the analysis API (e.g. stack or component analysis), the JSON report includes a **`licenses`** field with license information for all dependencies. This is a **LicensesResponse**: an array of provider results, each with `status`, `summary`, and `packages` (object keyed by purl). Each package has `concluded` (with `identifiers`, `expression`, `name`, `category`) and `evidence`. Categories are: **PERMISSIVE**, **WEAK_COPYLEFT**, **STRONG_COPYLEFT**, **UNKNOWN** (see [OpenAPI v5](https://github.com/guacsec/trustify-da-api-spec/blob/main/api/v5/openapi.yaml)).
43+
44+
2. **License identification endpoint**`POST /licenses/identify` accepts a LICENSE file (binary/text) and returns the identified SPDX license. This is used to accurately identify the project's LICENSE file when more precision is needed than local pattern matching.
45+
46+
3. **License details endpoint**`GET /api/v5/licenses/{spdx}` returns detailed information about a specific license by SPDX identifier, including `category`, `name`, `identifiers`, `expression`, `source`, etc. Used by the CLI `license` command to provide rich license information.
47+
48+
The client:
49+
50+
- When running the license check **after component analysis**, it uses the `result.licenses` data from the analysis response (no extra request needed for dependency licenses).
51+
- For the **project LICENSE file**, it first attempts local pattern matching (fast, synchronous). During the license check (async), it can optionally call `POST /licenses/identify` for more accurate backend-based identification.
52+
- Normalizes the license response into a map of purl → `{ licenses: string[], category? }` and uses the backend **category** for compatibility checking (e.g. project permissive + dependency STRONG_COPYLEFT → incompatible).
53+
54+
## Component analysis: license incompatibility report
55+
56+
When the user runs **component analysis** (e.g. upon opening a manifest):
57+
58+
1. The client builds the SBOM (direct dependencies only) and sends it to the backend for the usual component analysis.
59+
2. By default or unless `TRUSTIFY_DA_LICENSE_CHECK=false` or an option like `licenseCheck: false` is set:
60+
- **Resolve project license** from manifest (synchronous).
61+
- If a LICENSE file exists, optionally call `POST /licenses/identify` to accurately identify it via the backend.
62+
- Set `licenseSummary.manifestVsFileMismatch` if manifest and LICENSE file licenses differ.
63+
- **Extract purls** from the SBOM that was sent (from `provided.content`).
64+
- **Get dependency licenses** from the analysis result’s `licenses` array (already included in the response).
65+
- For each dependency, determine if its license(s) are **compatible** with the project license, using the backend category (PERMISSIVE / WEAK_COPYLEFT / STRONG_COPYLEFT).
66+
- Attach a **license summary** to the component analysis result, e.g.:
67+
- `licenseSummary.projectLicenseFromManifest`
68+
- `licenseSummary.projectLicenseFromFile`
69+
- `licenseSummary.manifestVsFileMismatch`
70+
- `licenseSummary.incompatibleDependencies`: `[{ purl, licenses, category, reason }]`
71+
- `licenseSummary.dependencyLicenses`: `[{ purl, licenses, category }]`
72+
- So the user can see which dependencies have licenses incompatible with the project license.
73+
74+
The client uses the backend **category** from the analysis report’s `licenses` field, plus a small in-client compatibility matrix (e.g. permissive project + STRONG_COPYLEFT dependency → incompatible).
75+
76+
## Disabling the license check in component analysis
77+
78+
- **Environment variable**: `TRUSTIFY_DA_LICENSE_CHECK=false`
79+
- **Option**: `opts.licenseCheck = false` when calling `client.componentAnalysis(manifest, opts)`
80+
81+
By default (unless disabled), after the component analysis response is received, the client:
82+
1. Resolves the project license from manifest and LICENSE file (with optional backend identification for LICENSE file)
83+
2. Extracts dependency licenses from `result.licenses` in the analysis response
84+
3. Computes compatibility and attaches a `licenseSummary` to the report
85+
86+
## API surface (client)
87+
88+
- **Project license resolution** (local, synchronous):
89+
- `getProjectLicense(manifestPath, opts)``{ fromManifest, fromFile, mismatch }`
90+
- `getProjectLicenseFromManifestOnly(manifestPath, opts)``{ fromManifest, fromFile: null, mismatch: false }`
91+
- `findLicenseFilePath(manifestPath)``string|null` (path to LICENSE file)
92+
93+
- **Backend license identification and details**:
94+
- `identifyLicenseViaBackend(licenseFilePath, backendUrl, opts)``Promise<string|null>` (SPDX id from `POST /licenses/identify`)
95+
- `getLicenseDetails(spdxId, backendUrl, opts)``Promise<Object|null>` (detailed license info from `GET /api/v5/licenses/{spdx}`, includes category, name, identifiers, etc.)
96+
97+
- **Dependency licenses from analysis report**:
98+
- `licenseMapFromAnalysisReport(analysisReport, purls?)``Map<purl, { licenses, category? }>`
99+
- Extracts license data from the analysis response's `licenses` field (no extra request)
100+
101+
- **Full license check** (for component analysis):
102+
- `runLicenseCheck(sbomContent, manifestPath, backendUrl, opts, analysisResult)``Promise<LicenseSummary>`
103+
- Uses `analysisResult.licenses` for dependency licenses. Optionally calls `POST /licenses/identify` for accurate project LICENSE file identification. Used inside `componentAnalysis()` when license check is enabled; result is attached as `report.licenseSummary`.
104+
105+
- **Compatibility checking**:
106+
- `getCompatibility(projectLicense, dependencyLicenses[], dependencyCategory?)``'compatible' | 'incompatible' | 'unknown'`
107+
108+
- **Utility** (typically not needed):
109+
- `getLicensesByPurl(purls, backendUrl, opts)``Promise<Map<purl, { licenses, category? }>>` (standalone call to `POST /api/v5/licenses`)
110+
111+
Options (e.g. proxy, token) are passed through to the backend fetch in the same way as for analysis requests.

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
},
6060
"devDependencies": {
6161
"@babel/core": "^7.23.2",
62-
"@trustify-da/trustify-da-api-model": "^2.0.1",
62+
"@trustify-da/trustify-da-api-model": "^2.0.7",
6363
"@types/node": "^20.17.30",
6464
"@types/which": "^3.0.4",
6565
"babel-plugin-rewire": "^1.2.0",

src/analysis.js

Lines changed: 15 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,12 @@ import { EOL } from "os";
55
import { HttpsProxyAgent } from "https-proxy-agent";
66

77
import { generateImageSBOM, parseImageRef } from "./oci_image/utils.js";
8-
import { RegexNotToBeLogged, getCustom } from "./tools.js";
8+
import { runLicenseCheck } from "./license/index.js";
9+
import { getCustom, getTokenHeaders } from "./tools.js";
10+
import { TRUSTIFY_DA_OPERATION_TYPE_HEADER, TRUSTIFY_DA_PACKAGE_MANAGER_HEADER } from "./tools.js";
911

1012
export default { requestComponent, requestStack, requestImages, validateToken }
1113

12-
const rhdaTokenHeader = "trust-da-token";
13-
const rhdaTelemetryId = "telemetry-anonymous-id";
14-
const rhdaSourceHeader = "trust-da-source"
15-
const rhdaOperationTypeHeader = "trust-da-operation-type"
16-
const rhdaPackageManagerHeader = "trust-da-pkg-manager"
17-
1814
/**
1915
* Adds proxy agent configuration to fetch options if a proxy URL is specified
2016
* @param {RequestInit} options - The base fetch options
@@ -43,13 +39,13 @@ async function requestStack(provider, manifest, url, html = false, opts = {}) {
4339
opts["manifest-type"] = path.parse(manifest).base
4440
let provided = await provider.provideStack(manifest, opts) // throws error if content providing failed
4541
opts["source-manifest"] = ""
46-
opts[rhdaOperationTypeHeader.toUpperCase().replaceAll("-", "_")] = "stack-analysis"
42+
opts[TRUSTIFY_DA_OPERATION_TYPE_HEADER.toUpperCase().replaceAll("-", "_")] = "stack-analysis"
4743
let startTime = new Date()
4844
let endTime
4945
if (process.env["TRUSTIFY_DA_DEBUG"] === "true") {
5046
console.log("Starting time of sending stack analysis request to the dependency analytics server= " + startTime)
5147
}
52-
opts[rhdaPackageManagerHeader.toUpperCase().replaceAll("-", "_")] = provided.ecosystem
48+
opts[TRUSTIFY_DA_PACKAGE_MANAGER_HEADER.toUpperCase().replaceAll("-", "_")] = provided.ecosystem
5349

5450
const fetchOptions = addProxyAgent({
5551
method: 'POST',
@@ -107,11 +103,11 @@ async function requestComponent(provider, manifest, url, opts = {}) {
107103

108104
let provided = await provider.provideComponent(manifest, opts) // throws error if content providing failed
109105
opts["source-manifest"] = ""
110-
opts[rhdaOperationTypeHeader.toUpperCase().replaceAll("-", "_")] = "component-analysis"
106+
opts[TRUSTIFY_DA_OPERATION_TYPE_HEADER.toUpperCase().replaceAll("-", "_")] = "component-analysis"
111107
if (process.env["TRUSTIFY_DA_DEBUG"] === "true") {
112108
console.log("Starting time of sending component analysis request to Trustify DA backend server= " + new Date())
113109
}
114-
opts[rhdaPackageManagerHeader.toUpperCase().replaceAll("-", "_")] = provided.ecosystem
110+
opts[TRUSTIFY_DA_PACKAGE_MANAGER_HEADER.toUpperCase().replaceAll("-", "_")] = provided.ecosystem
115111

116112
const fetchOptions = addProxyAgent({
117113
method: 'POST',
@@ -143,6 +139,14 @@ async function requestComponent(provider, manifest, url, opts = {}) {
143139

144140

145141
}
142+
const licenseCheckEnabled = getCustom('TRUSTIFY_DA_LICENSE_CHECK', 'true', opts) !== 'false' && opts.licenseCheck !== false
143+
if (licenseCheckEnabled) {
144+
try {
145+
result.licenseSummary = await runLicenseCheck(provided.content, manifest, url, opts, result)
146+
} catch (licenseErr) {
147+
result.licenseSummary = { error: licenseErr.message }
148+
}
149+
}
146150
} else {
147151
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()}`)
148152
}
@@ -224,42 +228,3 @@ async function validateToken(url, opts = {}) {
224228
}
225229
return resp.status
226230
}
227-
228-
/**
229-
*
230-
* @param {string} headerName - the header name to populate in request
231-
* @param headers
232-
* @param {string} optsKey - key in the options object to use the value for
233-
* @param {import("index.js").Options} [opts={}] - options input object to fetch header values from
234-
* @private
235-
*/
236-
function setRhdaHeader(headerName, headers, optsKey, opts) {
237-
let rhdaHeaderValue = getCustom(optsKey, null, opts);
238-
if (rhdaHeaderValue) {
239-
headers[headerName] = rhdaHeaderValue
240-
}
241-
}
242-
243-
/**
244-
* Utility function for fetching vendor tokens
245-
* @param {import("index.js").Options} [opts={}] - optional various options to pass along the application
246-
* @returns {{}}
247-
*/
248-
export function getTokenHeaders(opts = {}) {
249-
let headers = {}
250-
setRhdaHeader(rhdaTokenHeader, headers, 'TRUSTIFY_DA_TOKEN', opts);
251-
setRhdaHeader(rhdaSourceHeader, headers, 'TRUSTIFY_DA_SOURCE', opts);
252-
setRhdaHeader(rhdaOperationTypeHeader, headers, rhdaOperationTypeHeader.toUpperCase().replaceAll("-", "_"), opts);
253-
setRhdaHeader(rhdaPackageManagerHeader, headers, rhdaPackageManagerHeader.toUpperCase().replaceAll("-", "_"), opts)
254-
setRhdaHeader(rhdaTelemetryId, headers, 'TRUSTIFY_DA_TELEMETRY_ID', opts);
255-
256-
if (getCustom("TRUSTIFY_DA_DEBUG", null, opts) === "true") {
257-
console.log("Headers Values to be sent to Trustify DA backend:" + EOL)
258-
for (const headerKey in headers) {
259-
if (!headerKey.match(RegexNotToBeLogged)) {
260-
console.log(`${headerKey}: ${headers[headerKey]}`)
261-
}
262-
}
263-
}
264-
return headers
265-
}

0 commit comments

Comments
 (0)