cra-audit 1.0.0 → 2.0.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/CHANGELOG.md +61 -0
- package/README.md +60 -10
- package/package.json +16 -2
- package/src/cli.js +10 -2
- package/src/commands/audit.js +6 -3
- package/src/commands/sbom.js +17 -6
- package/src/commands/visualize.js +8 -4
- package/src/core/auditor.js +63 -10
- package/src/core/installed-metadata.js +179 -0
- package/src/core/kev.js +44 -0
- package/src/core/license-checker.js +2 -27
- package/src/core/lockfile-parser.js +306 -67
- package/src/core/osv.js +211 -0
- package/src/core/policy.js +8 -0
- package/src/core/sbom-generator.js +330 -146
- package/src/core/sbom-validator.js +228 -124
- package/src/core/vuln-scanner.js +161 -11
- package/src/index.js +1 -1
- package/src/reporters/console.js +55 -13
- package/src/reporters/json.js +1 -1
- package/src/utils/http.js +11 -4
|
@@ -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 };
|
package/src/core/kev.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { fetchJson } = require('../utils/http');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* CISA Known Exploited Vulnerabilities catalogue: the most complete public
|
|
7
|
+
* list of vulnerabilities with evidence of active exploitation. Under CRA
|
|
8
|
+
* Art. 14 (applicable since 11 September 2026) manufacturers must report
|
|
9
|
+
* actively exploited vulnerabilities in their products within 24 hours.
|
|
10
|
+
*
|
|
11
|
+
* The GitHub mirror maintained by CISA is used when the primary feed fails.
|
|
12
|
+
*/
|
|
13
|
+
const KEV_SOURCES = [
|
|
14
|
+
'https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json',
|
|
15
|
+
'https://raw.githubusercontent.com/cisagov/kev-data/develop/known_exploited_vulnerabilities.json',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @returns {Promise<{ ok: true, catalogVersion: string|null, count: number, byCve: Map<string, object> }
|
|
20
|
+
* | { ok: false, error: string }>}
|
|
21
|
+
*/
|
|
22
|
+
async function loadKev({ timeout = 20000 } = {}) {
|
|
23
|
+
for (const url of KEV_SOURCES) {
|
|
24
|
+
const res = await fetchJson(url, { timeout });
|
|
25
|
+
const list = res && res.ok && res.json && res.json.vulnerabilities;
|
|
26
|
+
if (!Array.isArray(list)) continue;
|
|
27
|
+
|
|
28
|
+
const byCve = new Map();
|
|
29
|
+
for (const entry of list) {
|
|
30
|
+
if (!entry || !entry.cveID) continue;
|
|
31
|
+
byCve.set(entry.cveID.toUpperCase(), {
|
|
32
|
+
cve: entry.cveID,
|
|
33
|
+
name: entry.vulnerabilityName || null,
|
|
34
|
+
dateAdded: entry.dateAdded || null,
|
|
35
|
+
dueDate: entry.dueDate || null,
|
|
36
|
+
knownRansomwareCampaignUse: entry.knownRansomwareCampaignUse || null,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return { ok: true, catalogVersion: res.json.catalogVersion || null, count: byCve.size, byCve };
|
|
40
|
+
}
|
|
41
|
+
return { ok: false, error: 'CISA KEV catalogue unreachable' };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { loadKev };
|
|
@@ -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';
|