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 CHANGED
@@ -1,5 +1,7 @@
1
1
  # cra-audit
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/cra-audit)](https://www.npmjs.com/package/cra-audit) [![CI](https://github.com/migohe14/cra-audit/actions/workflows/ci.yml/badge.svg)](https://github.com/migohe14/cra-audit/actions/workflows/ci.yml) [![license](https://img.shields.io/npm/l/cra-audit)](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.5** or **SPDX 2.3**— from the `package-lock.json` / `npm-shrinkwrap.json`, `yarn.lock` or `pnpm-lock.yaml`. It verifies the **minimum elements** required by TR-03183 §6 for every component: name, version, unique identifier (`purl`), cryptographic integrity hash and license.
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 §6 — license governance and hash integrity.
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 minimum elements. |
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 minimum elements.
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.0.0",
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 that the SBOM meets the TR-03183 minimum elements.
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.
@@ -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 minimum elements
14
- * required by BSI TR-03183 §6.
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 §6 (minimum elements)');
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 minimum elements required by the CRA.'));
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 };
@@ -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 minimum elements'
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 || readLicenseFromDisk(projectRoot, c.path, c.name);
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';