Skip to content

Commit f2c4df7

Browse files
ruromeroclaude
andauthored
feat: fall back to LICENSE file (#409)
Signed-off-by: Ruben Romero Montes <rromerom@redhat.com> Co-authored-by: Claude Sonnet <noreply@anthropic.com>
1 parent ff266a3 commit f2c4df7

15 files changed

+296
-146
lines changed

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,21 @@ let imageAnalysisWithArch = await client.imageAnalysis(['httpd:2.4.49^^amd64'])
4343
```
4444
</li>
4545
</ul>
46+
47+
<h3>License Detection</h3>
48+
<p>
49+
The client automatically detects your project's license with intelligent fallback:
50+
</p>
51+
<ul>
52+
<li><strong>Manifest-first:</strong> For ecosystems with license support (Maven, JavaScript), reads from manifest file (<code>pom.xml</code>, <code>package.json</code>)</li>
53+
<li><strong>LICENSE file fallback:</strong> If no license in manifest, or for ecosystems without license support (Gradle, Go, Python), automatically reads from <code>LICENSE</code>, <code>LICENSE.md</code>, or <code>LICENSE.txt</code></li>
54+
<li><strong>SBOM integration:</strong> Detected licenses are included in generated SBOMs for all ecosystems</li>
55+
<li><strong>SPDX support:</strong> Automatically detects common licenses (Apache-2.0, MIT, GPL, BSD) from LICENSE file content</li>
56+
</ul>
57+
<p>
58+
See <a href="./docs/license-resolution-and-compliance.md">License Resolution and Compliance</a> for detailed documentation.
59+
</p>
60+
4661
<ul>
4762
<li>
4863
Use as ESM Module from Common-JS module
@@ -183,6 +198,21 @@ $ trustify-da-javascript-client license /path/to/package.json
183198
<li><a href="https://gradle.org/">Gradle (Groovy and Kotlin DSL)</a> - <a href="https://gradle.org/install/">Gradle Installation</a></li>
184199
</ul>
185200

201+
<h3>License Detection</h3>
202+
<p>
203+
The client automatically detects your project's license with intelligent fallback:
204+
</p>
205+
<ul>
206+
<li><strong>Manifest-first:</strong> For ecosystems with license support (Maven, JavaScript), reads from manifest file (<code>pom.xml</code>, <code>package.json</code>)</li>
207+
<li><strong>LICENSE file fallback:</strong> If no license in manifest, or for ecosystems without license support (Gradle, Go, Python), automatically reads from <code>LICENSE</code>, <code>LICENSE.md</code>, or <code>LICENSE.txt</code></li>
208+
<li><strong>SBOM integration:</strong> Detected licenses are included in generated SBOMs for all ecosystems</li>
209+
<li><strong>SPDX support:</strong> Automatically detects common licenses (Apache-2.0, MIT, GPL, BSD) from LICENSE file content</li>
210+
</ul>
211+
<p>
212+
See <a href="./docs/license-resolution-and-compliance.md">License Resolution and Compliance</a> for detailed documentation.
213+
</p>
214+
215+
186216
<h3>Excluding Packages</h3>
187217
<p>
188218
Excluding a package from any analysis can be achieved by marking the package for exclusion.

docs/license-resolution-and-compliance.md

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,23 @@ License analysis is **enabled by default** and provides:
1515

1616
### Project License Detection
1717

18-
The client looks for your project’s license in two places:
18+
The client looks for your project’s license with **automatic fallback**:
1919

20-
1. **Manifest file** — Reads the license field from:
20+
1. **Primary: Manifest file** — Reads the license field from:
2121
- `package.json`: `license` field
2222
- `pom.xml`: `<licenses><license><name>` element
23-
- Other ecosystems: varies by ecosystem (some don’t have standard license fields)
23+
- `build.gradle` / `build.gradle.kts`: No standard license field (falls back to LICENSE file)
24+
- `go.mod`: No standard license field (falls back to LICENSE file)
25+
- `requirements.txt`: No standard license field (falls back to LICENSE file)
2426

25-
2. **LICENSE file**Searches for `LICENSE`, `LICENSE.md`, or `LICENSE.txt` in the same directory as your manifest
27+
2. **Fallback: LICENSE file**If no license is found in the manifest, searches for `LICENSE`, `LICENSE.md`, or `LICENSE.txt` in the same directory as your manifest
2628

27-
The backend’s license identification API is used for accurate LICENSE file detection.
29+
**How the fallback works:**
30+
- **Ecosystems with manifest license support** (Maven, JavaScript): Uses manifest license if present, otherwise falls back to LICENSE file
31+
- **Ecosystems without manifest license support** (Gradle, Go, Python): Automatically reads from LICENSE file
32+
- **SPDX detection**: Common licenses (Apache-2.0, MIT, GPL-2.0/3.0, LGPL-2.1/3.0, AGPL-3.0, BSD-2-Clause/3-Clause) are automatically detected from LICENSE file content
33+
34+
The backend’s license identification API is used for accurate LICENSE file detection when available.
2835

2936
### Compatibility Checking
3037

@@ -156,3 +163,9 @@ If you have a permissive-licensed project (MIT, Apache) but depend on GPL-licens
156163
## SBOM Integration
157164

158165
Project license information is automatically included in generated SBOMs (CycloneDX format) in the root component’s `licenses` field.
166+
167+
**LICENSE file fallback in SBOMs:**
168+
- **All ecosystems** now include license information in the SBOM when available
169+
- **Gradle, Go, Python projects**: Even though these ecosystems don’t have manifest license fields, the SBOM will include the license from your LICENSE file
170+
- **Maven, JavaScript projects**: The SBOM uses the manifest license, or falls back to LICENSE file if not specified in manifest
171+
- If neither manifest nor LICENSE file contains a license, the SBOM root component will have no `licenses` field

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@
4141
"tests": "mocha --config .mocharc.json --grep \".*analysis module.*\" --invert",
4242
"tests:rep": "mocha --reporter-option maxDiffSize=0 --reporter json > unit-tests-result.json",
4343
"precompile": "rm -rf dist",
44-
"compile": "tsc -p tsconfig.json"
44+
"compile": "tsc -p tsconfig.json",
45+
"compile:dev": "tsc -p tsconfig.dev.json"
4546
},
4647
"dependencies": {
4748
"@babel/core": "^7.23.2",

src/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import * as url from 'url';
99

1010
export { parseImageRef } from "./oci_image/utils.js";
1111
export { ImageRef } from "./oci_image/images.js";
12-
export { getProjectLicense, findLicenseFilePath, identifyLicenseViaBackend, getLicenseDetails, licensesFromReport, normalizeLicensesResponse, runLicenseCheck, getCompatibility } from "./license/index.js";
12+
export { getProjectLicense, findLicenseFilePath, identifyLicense, getLicenseDetails, licensesFromReport, normalizeLicensesResponse, runLicenseCheck, getCompatibility } from "./license/index.js";
1313

1414
export default { componentAnalysis, stackAnalysis, imageAnalysis, validateToken }
1515

src/license/compatibility.js

Lines changed: 0 additions & 53 deletions
This file was deleted.

src/license/index.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44

55
import { getProjectLicense, findLicenseFilePath, identifyLicense } from './project_license.js';
66
import { licensesFromReport, getLicenseDetails } from './licenses_api.js';
7-
import { getCompatibility } from './compatibility.js';
7+
import { getCompatibility } from './license_utils.js';
88

9-
export { getProjectLicense, findLicenseFilePath, identifyLicense as identifyLicenseViaBackend } from './project_license.js';
9+
export { getProjectLicense, findLicenseFilePath, identifyLicense } from './project_license.js';
1010
export { licensesFromReport, normalizeLicensesResponse, getLicenseDetails } from './licenses_api.js';
11-
export { getCompatibility } from './compatibility.js';
11+
export { getCompatibility } from './license_utils.js';
1212

1313
/**
1414
* Run full license check: resolve project license (with backend identification and details),
@@ -23,7 +23,7 @@ export { getCompatibility } from './compatibility.js';
2323
*/
2424
export async function runLicenseCheck(sbomContent, manifestPath, url, opts = {}, analysisResult = null) {
2525
// Resolve project license from manifest and LICENSE file
26-
const projectLicense = getProjectLicense(manifestPath, opts);
26+
const projectLicense = getProjectLicense(manifestPath);
2727

2828
// Try backend identification for LICENSE file (more accurate than local pattern matching)
2929
const licenseFilePath = findLicenseFilePath(manifestPath);

src/license/license_utils.js

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* License utilities: file reading, SPDX detection, normalization, compatibility.
3+
* This module has NO dependencies on providers or backend to avoid circular dependencies.
4+
*/
5+
6+
import fs from 'node:fs';
7+
import path from 'node:path';
8+
9+
const LICENSE_FILES = ['LICENSE', 'LICENSE.md', 'LICENSE.txt'];
10+
11+
/**
12+
* Find LICENSE file path in the same directory as the manifest.
13+
* @param {string} manifestPath
14+
* @returns {string|null} - path to LICENSE file or null if not found
15+
*/
16+
export function findLicenseFilePath(manifestPath) {
17+
const manifestDir = path.dirname(path.resolve(manifestPath));
18+
19+
for (const name of LICENSE_FILES) {
20+
const filePath = path.join(manifestDir, name);
21+
try {
22+
if (fs.statSync(filePath).isFile()) {
23+
return filePath;
24+
}
25+
} catch {
26+
// skip
27+
}
28+
}
29+
return null;
30+
}
31+
32+
/**
33+
* Very simple SPDX detection from common license text (first ~500 chars).
34+
* @param {string} text
35+
* @returns {string|null}
36+
*/
37+
export function detectSpdxFromText(text) {
38+
const head = text.slice(0, 500);
39+
if (/Apache License,?\s*Version 2\.0/i.test(head)) { return 'Apache-2.0'; }
40+
if (/MIT License/i.test(head) && /Permission is hereby granted/i.test(head)) { return 'MIT'; }
41+
if (/GNU AFFERO GENERAL PUBLIC LICENSE\s+Version 3/i.test(head)) { return 'AGPL-3.0-only'; }
42+
if (/GNU LESSER GENERAL PUBLIC LICENSE\s+Version 3/i.test(head)) { return 'LGPL-3.0-only'; }
43+
if (/GNU LESSER GENERAL PUBLIC LICENSE\s+Version 2\.1/i.test(head)) { return 'LGPL-2.1-only'; }
44+
if (/GNU GENERAL PUBLIC LICENSE\s+Version 2/i.test(head)) { return 'GPL-2.0-only'; }
45+
if (/GNU GENERAL PUBLIC LICENSE\s+Version 3/i.test(head)) { return 'GPL-3.0-only'; }
46+
if (/BSD 2-Clause/i.test(head)) { return 'BSD-2-Clause'; }
47+
if (/BSD 3-Clause/i.test(head)) { return 'BSD-3-Clause'; }
48+
return null;
49+
}
50+
51+
/**
52+
* Read LICENSE file and detect SPDX identifier.
53+
* @param {string} manifestPath - path to manifest
54+
* @returns {string|null} - SPDX identifier from LICENSE file or null
55+
*/
56+
export function readLicenseFile(manifestPath) {
57+
const licenseFilePath = findLicenseFilePath(manifestPath);
58+
if (!licenseFilePath) { return null; }
59+
60+
try {
61+
const content = fs.readFileSync(licenseFilePath, 'utf-8');
62+
return detectSpdxFromText(content) || content.split('\n')[0]?.trim() || null;
63+
} catch {
64+
return null;
65+
}
66+
}
67+
68+
/**
69+
* Get project license from manifest or LICENSE file.
70+
* Returns manifestLicense if provided, otherwise tries LICENSE file.
71+
* @param {string|null} manifestLicense - license from manifest (or null)
72+
* @param {string} manifestPath - path to manifest
73+
* @returns {string|null} - SPDX identifier or null
74+
*/
75+
export function getLicense(manifestLicense, manifestPath) {
76+
return manifestLicense || readLicenseFile(manifestPath) || null;
77+
}
78+
79+
/**
80+
* Normalize SPDX identifier for comparison (lowercase, strip common suffixes).
81+
* @param {string} spdxOrName
82+
* @returns {string}
83+
*/
84+
export function normalizeSpdx(spdxOrName) {
85+
const s = String(spdxOrName).trim().toLowerCase();
86+
if (s.endsWith(' license')) { return s.slice(0, -8); }
87+
return s;
88+
}
89+
90+
/**
91+
* Check if a dependency's license is compatible with the project license based on backend categories.
92+
*
93+
* @param {string} [projectCategory] - backend category for project license: PERMISSIVE | WEAK_COPYLEFT | STRONG_COPYLEFT | UNKNOWN
94+
* @param {string} [dependencyCategory] - backend category for dependency license: PERMISSIVE | WEAK_COPYLEFT | STRONG_COPYLEFT | UNKNOWN
95+
* @returns {'compatible'|'incompatible'|'unknown'}
96+
*/
97+
export function getCompatibility(projectCategory, dependencyCategory) {
98+
if (!projectCategory || !dependencyCategory) {
99+
return 'unknown';
100+
}
101+
102+
const proj = projectCategory.toUpperCase();
103+
const dep = dependencyCategory.toUpperCase();
104+
105+
if (proj === 'UNKNOWN' || dep === 'UNKNOWN') {
106+
return 'unknown';
107+
}
108+
109+
const restrictiveness = {
110+
'PERMISSIVE': 1,
111+
'WEAK_COPYLEFT': 2,
112+
'STRONG_COPYLEFT': 3
113+
};
114+
115+
const projLevel = restrictiveness[proj];
116+
const depLevel = restrictiveness[dep];
117+
118+
if (projLevel === undefined || depLevel === undefined) {
119+
return 'unknown';
120+
}
121+
122+
if (depLevel > projLevel) {
123+
return 'incompatible';
124+
}
125+
126+
return 'compatible';
127+
}

0 commit comments

Comments
 (0)