cra-audit 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -4
- package/package.json +15 -2
- package/src/cli.js +3 -1
- package/src/commands/sbom.js +17 -6
- package/src/core/auditor.js +2 -2
- package/src/core/installed-metadata.js +179 -0
- package/src/core/license-checker.js +2 -27
- package/src/core/lockfile-parser.js +306 -67
- package/src/core/policy.js +3 -0
- package/src/core/sbom-generator.js +330 -146
- package/src/core/sbom-validator.js +228 -124
- package/src/reporters/console.js +2 -2
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# cra-audit
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/cra-audit) [](https://github.com/migohe14/cra-audit/actions/workflows/ci.yml) [](LICENSE)
|
|
4
|
+
|
|
3
5
|
> Compliance audit for the **Cyber Resilience Act** (Regulation EU 2024/2847) and the **BSI TR-03183** technical guideline, for npm projects.
|
|
4
6
|
|
|
5
7
|
`cra-audit` audits the installed dependencies of an npm project and checks the key requirements that the CRA imposes on "products with digital elements". It runs directly with `npx`, **without installation**, and has **no production dependencies** to minimize its own supply-chain surface.
|
|
@@ -24,7 +26,24 @@ Under the hood, the package runs the checks derived from the CRA legal obligatio
|
|
|
24
26
|
|
|
25
27
|
### 1. Automatic SBOM validation
|
|
26
28
|
|
|
27
|
-
Generates and validates a **Software Bill of Materials (SBOM)** in a machine-readable format —**CycloneDX 1.
|
|
29
|
+
Generates and validates a **Software Bill of Materials (SBOM)** in a machine-readable format —**CycloneDX 1.6** (default) or **SPDX 2.3**— from the `package-lock.json` / `npm-shrinkwrap.json`, `yarn.lock` or `pnpm-lock.yaml`, following the field mapping of **BSI TR-03183-2 v2.1.0**:
|
|
30
|
+
|
|
31
|
+
| TR-03183-2 data field | Where it comes from | CycloneDX 1.6 field |
|
|
32
|
+
| --- | --- | --- |
|
|
33
|
+
| Creator of the SBOM (email or URL) | `--creator`, `sbomCreator` policy, or the project's `author` / `homepage` / `repository` | `metadata.manufacturer` |
|
|
34
|
+
| Timestamp | generation time (UTC) | `metadata.timestamp` |
|
|
35
|
+
| Component creator (email or URL) | installed `package.json`: `author`, `maintainers`, `homepage`, `repository` | `components[].manufacturer` |
|
|
36
|
+
| Name, version, purl | lockfile | `name`, `version`, `purl` |
|
|
37
|
+
| Filename | tarball name (`left-pad-1.3.0.tgz`) | property `bsi:component:filename` |
|
|
38
|
+
| SHA-512 of the deployable component | lockfile `integrity` | `externalReferences[distribution].hashes` |
|
|
39
|
+
| Executable / archive / structured | npm tarball: `non-executable`, `archive`, `structured` | properties `bsi:component:*` |
|
|
40
|
+
| Dependencies + completeness | lockfile dependency graph (npm v1–v3, Yarn classic/Berry, pnpm v5–v9) | `dependencies`, `compositions[].aggregate` |
|
|
41
|
+
| Distribution / original licences | lockfile or installed `package.json` | `licenses[]` with `acknowledgement` `concluded` / `declared` |
|
|
42
|
+
| Source code URI | `repository` | `externalReferences[source-distribution]` |
|
|
43
|
+
|
|
44
|
+
`sbom check` verifies every one of those fields for every component. Licenses (for Yarn/pnpm) and component creators are read from `node_modules`, so **run it after installing dependencies** (e.g. after `npm ci` in CI).
|
|
45
|
+
|
|
46
|
+
Known limits: Yarn Berry lockfiles only store Yarn's own cache checksum, not the npm tarball SHA-512, so Berry projects fail the hash check. TR-03183-2 v2.1 requires **SPDX ≥ 3.0.1**; the SPDX output is still 2.3, so use CycloneDX for a conforming SBOM.
|
|
28
47
|
|
|
29
48
|
> CRA Annex I · TR-03183 Part 2 — *"Transparency through SBOM"*.
|
|
30
49
|
|
|
@@ -44,7 +63,7 @@ Identifies every third-party open-source library present in the dependency tree
|
|
|
44
63
|
|
|
45
64
|
Verifies that **all** dependencies have their license documented and compliant with the policy (allowlist / denylist), and that they carry unique identifiers (`purl`) and integrity hashes (SHA-512/384/256) as recommended by TR-03183.
|
|
46
65
|
|
|
47
|
-
> TR-03183 §
|
|
66
|
+
> TR-03183-2 §5.2 — license governance and hash integrity.
|
|
48
67
|
|
|
49
68
|
### 5. Visualization (`--visualize`)
|
|
50
69
|
|
|
@@ -102,7 +121,7 @@ cra-audit --help
|
|
|
102
121
|
| `cra-audit` / `cra-audit audit` | Full CRA compliance audit (vulnerabilities + SBOM + licenses). **Default.** |
|
|
103
122
|
| `cra-audit visualize` (aliases `view`, `report`) | Interactive HTML report with SBOM, licenses, versions and maintenance. |
|
|
104
123
|
| `cra-audit sbom generate` | Generate an SBOM (CycloneDX/SPDX). |
|
|
105
|
-
| `cra-audit sbom check` | Validate the SBOM against the TR-03183
|
|
124
|
+
| `cra-audit sbom check` | Validate the SBOM against the TR-03183-2 v2.1 data fields. |
|
|
106
125
|
| `cra-audit vulnerabilities` (alias `vuln`) | Vulnerability analysis only. |
|
|
107
126
|
| `cra-audit licenses` | License analysis only. |
|
|
108
127
|
| `cra-audit help` | Show help. |
|
|
@@ -117,6 +136,7 @@ cra-audit --help
|
|
|
117
136
|
| `--no-open` | Do not open the HTML report in the browser automatically. |
|
|
118
137
|
| `--sbom` | Shortcut equivalent to `sbom check`. |
|
|
119
138
|
| `--format <fmt>` | SBOM format: `cyclonedx` (default) or `spdx`. |
|
|
139
|
+
| `--creator <contact>` | Email or URL of the SBOM creator (TR-03183-2 §5.2.1). Defaults to the project's `package.json` `author` / `homepage` / `repository`. |
|
|
120
140
|
| `--fail-on <sev>` | Minimum severity that fails the audit: `info`, `low`, `moderate`, `high`, `critical`. |
|
|
121
141
|
| `--production`, `--prod` | Audit production dependencies only. |
|
|
122
142
|
| `--no-sbom` | Do not require an SBOM in the full audit. |
|
|
@@ -172,6 +192,7 @@ Create a `.cra-audit.json` file at the project root to customize the rules (ther
|
|
|
172
192
|
"failOn": "high",
|
|
173
193
|
"requireSbom": true,
|
|
174
194
|
"sbomFormat": "cyclonedx",
|
|
195
|
+
"sbomCreator": "security@example.com",
|
|
175
196
|
"productionOnly": false,
|
|
176
197
|
"vulnerabilities": {
|
|
177
198
|
"allowlist": []
|
|
@@ -185,8 +206,9 @@ Create a `.cra-audit.json` file at the project root to customize the rules (ther
|
|
|
185
206
|
```
|
|
186
207
|
|
|
187
208
|
- `failOn`: minimum severity that blocks the audit.
|
|
188
|
-
- `requireSbom`: require the SBOM to meet the TR-03183
|
|
209
|
+
- `requireSbom`: require the SBOM to meet the TR-03183-2 required data fields.
|
|
189
210
|
- `sbomFormat`: `cyclonedx` or `spdx`.
|
|
211
|
+
- `sbomCreator`: email or URL of the entity that creates the SBOM (usually the manufacturer).
|
|
190
212
|
- `productionOnly`: audit production dependencies only.
|
|
191
213
|
- `vulnerabilities.allowlist`: advisory/CVE identifiers accepted with documented justification.
|
|
192
214
|
- `licenses.allow` / `licenses.deny`: allowed / denied lists (SPDX id).
|
package/package.json
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cra-audit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Audits npm, yarn, and pnpm projects for compliance with the European Cyber Resilience Act (CRA, EU Regulation 2024/2847) and BSI TR-03183: SBOM (CycloneDX/SPDX), known vulnerabilities, third-party components, and licenses.",
|
|
5
|
+
"homepage": "https://github.com/migohe14/cra-audit#readme",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/migohe14/cra-audit.git"
|
|
9
|
+
},
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/migohe14/cra-audit/issues"
|
|
12
|
+
},
|
|
13
|
+
"author": "Miguel Gonzalez (https://github.com/migohe14)",
|
|
5
14
|
"keywords": [
|
|
6
15
|
"cra",
|
|
7
16
|
"cyber-resilience-act",
|
|
@@ -15,7 +24,11 @@
|
|
|
15
24
|
"compliance",
|
|
16
25
|
"vulnerabilities",
|
|
17
26
|
"licenses",
|
|
18
|
-
"supply-chain"
|
|
27
|
+
"supply-chain",
|
|
28
|
+
"yarn",
|
|
29
|
+
"pnpm",
|
|
30
|
+
"eu",
|
|
31
|
+
"sbom-validation"
|
|
19
32
|
],
|
|
20
33
|
"license": "MIT",
|
|
21
34
|
"type": "commonjs",
|
package/src/cli.js
CHANGED
|
@@ -151,6 +151,7 @@ function setFlag(flags, name, value) {
|
|
|
151
151
|
case '--format': flags.format = String(value).toLowerCase(); break;
|
|
152
152
|
case '--fail-on': flags.failOn = String(value).toLowerCase(); break;
|
|
153
153
|
case '--cwd': flags.cwd = value; break;
|
|
154
|
+
case '--creator': flags.creator = value; break;
|
|
154
155
|
default:
|
|
155
156
|
// Unknown flag stored under its raw name for forward compatibility.
|
|
156
157
|
flags[name.replace(/^--/, '')] = value;
|
|
@@ -170,7 +171,7 @@ ${c.bold('COMMANDS')}
|
|
|
170
171
|
${c.cyan('audit')} Run ALL the checks (vulnerabilities + SBOM + licenses). Default.
|
|
171
172
|
${c.cyan('visualize')} Generate an interactive HTML report (SBOM, licenses, versions and maintenance).
|
|
172
173
|
${c.cyan('sbom generate')} Generate an SBOM (CycloneDX or SPDX) and print or save it.
|
|
173
|
-
${c.cyan('sbom check')} Validate
|
|
174
|
+
${c.cyan('sbom check')} Validate the SBOM against the TR-03183-2 v2.1 data fields.
|
|
174
175
|
${c.cyan('vulnerabilities')} Analyze known vulnerabilities only (alias: vuln).
|
|
175
176
|
${c.cyan('licenses')} Analyze dependency licenses only.
|
|
176
177
|
${c.cyan('help')} Show this help.
|
|
@@ -182,6 +183,7 @@ ${c.bold('OPTIONS')}
|
|
|
182
183
|
${c.cyan('--no-open')} Do not open the HTML report in the browser automatically.
|
|
183
184
|
${c.cyan('--sbom')} Shortcut equivalent to "sbom check".
|
|
184
185
|
${c.cyan('--format <fmt>')} SBOM format: cyclonedx (default) | spdx.
|
|
186
|
+
${c.cyan('--creator <contact>')} Email or URL of the SBOM creator (default: package.json author/homepage).
|
|
185
187
|
${c.cyan('--fail-on <sev>')} Minimum severity that fails the audit: info|low|moderate|high|critical.
|
|
186
188
|
${c.cyan('--production, --prod')} Audit production dependencies only (skips devDependencies).
|
|
187
189
|
${c.cyan('--no-sbom')} Do not require an SBOM in the full audit.
|
package/src/commands/sbom.js
CHANGED
|
@@ -5,13 +5,14 @@ const { findProjectRoot, readJson, writeJson, exists } = require('../utils/fs');
|
|
|
5
5
|
const { logger, color } = require('../utils/logger');
|
|
6
6
|
const { generateSbom } = require('../core/sbom-generator');
|
|
7
7
|
const { validateSbom } = require('../core/sbom-validator');
|
|
8
|
+
const { loadPolicy } = require('../core/policy');
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* `cra-audit sbom <generate|check>` and the `--sbom` shortcut.
|
|
11
12
|
*
|
|
12
13
|
* - generate: builds a CycloneDX/SPDX SBOM and prints it or writes it to disk.
|
|
13
|
-
* - check: generates (or reads) an SBOM and validates the
|
|
14
|
-
* required by BSI TR-03183 §
|
|
14
|
+
* - check: generates (or reads) an SBOM and validates the data fields
|
|
15
|
+
* required by BSI TR-03183-2 v2.1.0 §5.2.
|
|
15
16
|
*/
|
|
16
17
|
function sbomCommand(subcommand, flags) {
|
|
17
18
|
const projectRoot = findProjectRoot(flags.cwd || process.cwd());
|
|
@@ -35,7 +36,7 @@ function sbomCommand(subcommand, flags) {
|
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
function sbomGenerate(projectRoot, format, flags) {
|
|
38
|
-
const result = generateSbom(projectRoot, { format });
|
|
39
|
+
const result = generateSbom(projectRoot, { format, creator: sbomCreator(projectRoot, flags) });
|
|
39
40
|
if (!result.ok) {
|
|
40
41
|
logger.error(result.error);
|
|
41
42
|
return 1;
|
|
@@ -63,7 +64,7 @@ function sbomCheck(projectRoot, format, flags) {
|
|
|
63
64
|
}
|
|
64
65
|
document = readJson(inPath);
|
|
65
66
|
} else {
|
|
66
|
-
const result = generateSbom(projectRoot, { format });
|
|
67
|
+
const result = generateSbom(projectRoot, { format, creator: sbomCreator(projectRoot, flags) });
|
|
67
68
|
if (!result.ok) {
|
|
68
69
|
logger.error(result.error);
|
|
69
70
|
return 1;
|
|
@@ -82,7 +83,7 @@ function sbomCheck(projectRoot, format, flags) {
|
|
|
82
83
|
return validation.valid ? 0 : 1;
|
|
83
84
|
}
|
|
84
85
|
|
|
85
|
-
logger.heading('SBOM validation · BSI TR-03183
|
|
86
|
+
logger.heading('SBOM validation · BSI TR-03183-2 v2.1.0 (required data fields)');
|
|
86
87
|
logger.detail(`Detected format: ${validation.format} · Components: ${validation.stats.total}`);
|
|
87
88
|
for (const check of validation.checks) {
|
|
88
89
|
const mark = check.passed ? color.green('✔') : color.red('✖');
|
|
@@ -90,11 +91,21 @@ function sbomCheck(projectRoot, format, flags) {
|
|
|
90
91
|
}
|
|
91
92
|
logger.log('');
|
|
92
93
|
if (validation.valid) {
|
|
93
|
-
logger.success(color.bold('SBOM VALID — meets the
|
|
94
|
+
logger.success(color.bold('SBOM VALID — meets the TR-03183-2 data fields required by the CRA.'));
|
|
94
95
|
return 0;
|
|
95
96
|
}
|
|
96
97
|
logger.error(color.bold(`SBOM INVALID — ${validation.failedChecks.length} requirement(s) not met.`));
|
|
97
98
|
return 1;
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
/** SBOM creator from --creator, else from the project policy (`sbomCreator`). */
|
|
102
|
+
function sbomCreator(projectRoot, flags) {
|
|
103
|
+
if (typeof flags.creator === 'string') return flags.creator;
|
|
104
|
+
try {
|
|
105
|
+
return loadPolicy(projectRoot, flags.config).policy.sbomCreator;
|
|
106
|
+
} catch {
|
|
107
|
+
return undefined; // A broken policy file must not block SBOM generation.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
100
111
|
module.exports = { sbomCommand };
|
package/src/core/auditor.js
CHANGED
|
@@ -48,7 +48,7 @@ function runAudit(projectRoot, policy, policySource, options = {}) {
|
|
|
48
48
|
|
|
49
49
|
// --- 2. SBOM ------------------------------------------------------------
|
|
50
50
|
if (!only || only === 'sbom') {
|
|
51
|
-
const sbom = generateSbom(projectRoot, { format: policy.sbomFormat });
|
|
51
|
+
const sbom = generateSbom(projectRoot, { format: policy.sbomFormat, creator: policy.sbomCreator });
|
|
52
52
|
if (sbom.ok) {
|
|
53
53
|
sbom.validation = validateSbom(sbom.document);
|
|
54
54
|
}
|
|
@@ -60,7 +60,7 @@ function runAudit(projectRoot, policy, policySource, options = {}) {
|
|
|
60
60
|
} else {
|
|
61
61
|
reasons.push({
|
|
62
62
|
label: sbom.validation.valid
|
|
63
|
-
? 'SBOM valid against the TR-03183
|
|
63
|
+
? 'SBOM valid against the TR-03183-2 required data fields'
|
|
64
64
|
: 'SBOM incomplete with respect to TR-03183',
|
|
65
65
|
passed: sbom.validation.valid,
|
|
66
66
|
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { readJson } = require('../utils/fs');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Reads the package.json of an installed component from node_modules.
|
|
9
|
+
* Lockfiles do not carry every field TR-03183 asks for (Yarn and pnpm have no
|
|
10
|
+
* licenses, none of them have the component creator), so the installed
|
|
11
|
+
* manifest is the best offline source for them.
|
|
12
|
+
*
|
|
13
|
+
* Looks in the npm path recorded in the lockfile, the hoisted location and
|
|
14
|
+
* pnpm's virtual store. Returns null when the package is not installed or the
|
|
15
|
+
* installed version differs from the locked one.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} projectRoot
|
|
18
|
+
* @param {{ name: string, version: string|null, path?: string }} component
|
|
19
|
+
* @returns {object|null}
|
|
20
|
+
*/
|
|
21
|
+
function readInstalledManifest(projectRoot, component) {
|
|
22
|
+
const { name, version } = component;
|
|
23
|
+
const candidates = [];
|
|
24
|
+
if (component.path) candidates.push(path.join(projectRoot, component.path, 'package.json'));
|
|
25
|
+
candidates.push(path.join(projectRoot, 'node_modules', name, 'package.json'));
|
|
26
|
+
if (version) {
|
|
27
|
+
const storeName = `${name.replace('/', '+')}@${version}`;
|
|
28
|
+
candidates.push(path.join(projectRoot, 'node_modules', '.pnpm', storeName, 'node_modules', name, 'package.json'));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for (const candidate of candidates) {
|
|
32
|
+
const pkg = readManifest(candidate);
|
|
33
|
+
if (!pkg) continue;
|
|
34
|
+
if (version && pkg.version && pkg.version !== version) continue;
|
|
35
|
+
return pkg;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Yarn records no install paths, so non-hoisted duplicates live somewhere
|
|
39
|
+
// under a nested node_modules: fall back to an index of the whole tree.
|
|
40
|
+
const nested = installedIndex(projectRoot).get(`${name}@${version}`);
|
|
41
|
+
return nested ? readManifest(nested) : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readManifest(file) {
|
|
45
|
+
try {
|
|
46
|
+
return readJson(file);
|
|
47
|
+
} catch {
|
|
48
|
+
return null; // Unreadable manifest: treat as not installed.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const indexCache = new Map();
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Maps `name@version` to the package.json path of every package installed
|
|
56
|
+
* under `<projectRoot>/node_modules`, including nested node_modules folders.
|
|
57
|
+
* pnpm's `.pnpm` store is skipped: it is looked up directly by name.
|
|
58
|
+
*/
|
|
59
|
+
function installedIndex(projectRoot) {
|
|
60
|
+
if (indexCache.has(projectRoot)) return indexCache.get(projectRoot);
|
|
61
|
+
const index = new Map();
|
|
62
|
+
|
|
63
|
+
const visitModules = (dir) => {
|
|
64
|
+
let entries;
|
|
65
|
+
try {
|
|
66
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
67
|
+
} catch {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
for (const entry of entries) {
|
|
71
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
72
|
+
const full = path.join(dir, entry.name);
|
|
73
|
+
if (entry.name.startsWith('@')) {
|
|
74
|
+
let scoped = [];
|
|
75
|
+
try {
|
|
76
|
+
scoped = fs.readdirSync(full, { withFileTypes: true });
|
|
77
|
+
} catch {
|
|
78
|
+
// Unreadable scope folder: skip it.
|
|
79
|
+
}
|
|
80
|
+
for (const s of scoped) if (s.isDirectory()) visitPackage(path.join(full, s.name));
|
|
81
|
+
} else {
|
|
82
|
+
visitPackage(full);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const visitPackage = (dir) => {
|
|
88
|
+
const manifestPath = path.join(dir, 'package.json');
|
|
89
|
+
const pkg = readManifest(manifestPath);
|
|
90
|
+
if (pkg && pkg.name && pkg.version) {
|
|
91
|
+
const key = `${pkg.name}@${pkg.version}`;
|
|
92
|
+
if (!index.has(key)) index.set(key, manifestPath);
|
|
93
|
+
}
|
|
94
|
+
visitModules(path.join(dir, 'node_modules'));
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
visitModules(path.join(projectRoot, 'node_modules'));
|
|
98
|
+
indexCache.set(projectRoot, index);
|
|
99
|
+
return index;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Extracts the declared license (SPDX id or expression) from a manifest. */
|
|
103
|
+
function licenseFromManifest(pkg) {
|
|
104
|
+
if (!pkg) return null;
|
|
105
|
+
if (typeof pkg.license === 'string') return pkg.license;
|
|
106
|
+
if (pkg.license && pkg.license.type) return pkg.license.type;
|
|
107
|
+
if (Array.isArray(pkg.licenses) && pkg.licenses.length) {
|
|
108
|
+
return pkg.licenses.map((l) => (typeof l === 'string' ? l : l.type)).filter(Boolean).join(' OR ') || null;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Derives the creator contact TR-03183 requires (§5.2.1 / §5.2.2): an email
|
|
115
|
+
* address, or a URL when no email is available. Uses `author`, then the first
|
|
116
|
+
* maintainer/contributor with an email, then `homepage` and `repository`.
|
|
117
|
+
*
|
|
118
|
+
* @returns {{ name: string|null, email: string|null, url: string|null }|null}
|
|
119
|
+
*/
|
|
120
|
+
function creatorFromManifest(pkg) {
|
|
121
|
+
if (!pkg) return null;
|
|
122
|
+
|
|
123
|
+
const author = parsePerson(pkg.author);
|
|
124
|
+
const people = [author, ...toArray(pkg.maintainers).map(parsePerson), ...toArray(pkg.contributors).map(parsePerson)]
|
|
125
|
+
.filter(Boolean);
|
|
126
|
+
const withEmail = people.find((p) => p.email);
|
|
127
|
+
const name = (author && author.name) || (withEmail && withEmail.name) || null;
|
|
128
|
+
|
|
129
|
+
if (withEmail) return { name, email: withEmail.email, url: null };
|
|
130
|
+
|
|
131
|
+
const url = (author && author.url) || httpUrl(pkg.homepage) || repositoryUrl(pkg.repository);
|
|
132
|
+
if (url) return { name, email: null, url };
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Normalizes `repository` (string or object) into a browsable https URL. */
|
|
137
|
+
function repositoryUrl(repository) {
|
|
138
|
+
if (!repository) return null;
|
|
139
|
+
let url = typeof repository === 'string' ? repository : repository.url;
|
|
140
|
+
if (!url || typeof url !== 'string') return null;
|
|
141
|
+
|
|
142
|
+
// Shorthands: "user/repo", "github:user/repo", "gitlab:user/repo".
|
|
143
|
+
const shorthand = url.match(/^(?:(github|gitlab|bitbucket):)?([\w.-]+\/[\w.-]+)$/);
|
|
144
|
+
if (shorthand) {
|
|
145
|
+
const host = { github: 'github.com', gitlab: 'gitlab.com', bitbucket: 'bitbucket.org' }[shorthand[1] || 'github'];
|
|
146
|
+
return `https://${host}/${shorthand[2]}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
url = url
|
|
150
|
+
.replace(/^git\+/, '')
|
|
151
|
+
.replace(/^git:\/\//, 'https://')
|
|
152
|
+
.replace(/^ssh:\/\/git@/, 'https://')
|
|
153
|
+
.replace(/^git@([^:]+):/, 'https://$1/')
|
|
154
|
+
.replace(/\.git$/, '');
|
|
155
|
+
return httpUrl(url);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Parses npm "person" fields: "Name <email> (url)" or { name, email, url }. */
|
|
159
|
+
function parsePerson(person) {
|
|
160
|
+
if (!person) return null;
|
|
161
|
+
if (typeof person === 'object') {
|
|
162
|
+
return { name: person.name || null, email: person.email || null, url: httpUrl(person.url) };
|
|
163
|
+
}
|
|
164
|
+
const str = String(person);
|
|
165
|
+
const email = (str.match(/<([^>]+)>/) || [])[1] || null;
|
|
166
|
+
const url = (str.match(/\(([^)]+)\)/) || [])[1] || null;
|
|
167
|
+
const name = str.replace(/<[^>]*>/, '').replace(/\([^)]*\)/, '').trim() || null;
|
|
168
|
+
return { name, email, url: httpUrl(url) };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function httpUrl(value) {
|
|
172
|
+
return typeof value === 'string' && /^https?:\/\//i.test(value) ? value : null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function toArray(value) {
|
|
176
|
+
return Array.isArray(value) ? value : [];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = { readInstalledManifest, licenseFromManifest, creatorFromManifest, repositoryUrl };
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const fs = require('node:fs');
|
|
4
|
-
const path = require('node:path');
|
|
5
|
-
const { readJson } = require('../utils/fs');
|
|
6
3
|
const { parseLockfile } = require('./lockfile-parser');
|
|
4
|
+
const { readInstalledManifest, licenseFromManifest } = require('./installed-metadata');
|
|
7
5
|
|
|
8
6
|
/**
|
|
9
7
|
* Resolves and evaluates the license of every third-party component.
|
|
@@ -22,7 +20,7 @@ function checkLicenses(projectRoot, policy = {}) {
|
|
|
22
20
|
const deny = normalizeList(policy.deny);
|
|
23
21
|
|
|
24
22
|
const components = parsed.components.map((c) => {
|
|
25
|
-
const license = c.license ||
|
|
23
|
+
const license = c.license || licenseFromManifest(readInstalledManifest(projectRoot, c));
|
|
26
24
|
const normalized = license ? String(license) : null;
|
|
27
25
|
const status = classify(normalized, allow, deny);
|
|
28
26
|
return {
|
|
@@ -44,29 +42,6 @@ function checkLicenses(projectRoot, policy = {}) {
|
|
|
44
42
|
return { ok: true, components, summary };
|
|
45
43
|
}
|
|
46
44
|
|
|
47
|
-
/** Reads the `license` field from an installed package's package.json. */
|
|
48
|
-
function readLicenseFromDisk(projectRoot, pkgPath, name) {
|
|
49
|
-
const candidates = [];
|
|
50
|
-
if (pkgPath) candidates.push(path.join(projectRoot, pkgPath, 'package.json'));
|
|
51
|
-
candidates.push(path.join(projectRoot, 'node_modules', name, 'package.json'));
|
|
52
|
-
|
|
53
|
-
for (const candidate of candidates) {
|
|
54
|
-
try {
|
|
55
|
-
if (!fs.existsSync(candidate)) continue;
|
|
56
|
-
const pkg = readJson(candidate);
|
|
57
|
-
if (!pkg) continue;
|
|
58
|
-
if (typeof pkg.license === 'string') return pkg.license;
|
|
59
|
-
if (pkg.license && pkg.license.type) return pkg.license.type;
|
|
60
|
-
if (Array.isArray(pkg.licenses) && pkg.licenses.length) {
|
|
61
|
-
return pkg.licenses.map((l) => (typeof l === 'string' ? l : l.type)).filter(Boolean).join(' OR ');
|
|
62
|
-
}
|
|
63
|
-
} catch {
|
|
64
|
-
// Ignore unreadable package and continue.
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
45
|
function classify(license, allow, deny) {
|
|
71
46
|
if (!license || /^(unknown|unlicensed|see license)/i.test(license)) {
|
|
72
47
|
return 'missing';
|