cra-audit 2.1.1 → 2.3.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.
@@ -1,12 +1,14 @@
1
1
  'use strict';
2
2
 
3
3
  const path = require('node:path');
4
- const { findProjectRoot, readJson, writeJson } = require('../utils/fs');
4
+ const { readJson, writeJson } = require('../utils/fs');
5
5
  const { logger } = require('../utils/logger');
6
6
  const { loadPolicy } = require('../core/policy');
7
7
  const { scanVulnerabilities } = require('../core/vuln-scanner');
8
8
  const { buildVex } = require('../core/vex');
9
9
  const { creatorFromManifest } = require('../core/installed-metadata');
10
+ const { loadProject } = require('../core/project-source');
11
+ const { inputPath, resolveRoot } = require('./audit');
10
12
 
11
13
  /**
12
14
  * `cra-audit vex` — writes a VEX document (CycloneDX 1.6 or OpenVEX 0.2.0)
@@ -16,11 +18,9 @@ const { creatorFromManifest } = require('../core/installed-metadata');
16
18
  * @returns {Promise<number>} exit code
17
19
  */
18
20
  async function vexCommand(flags) {
19
- const projectRoot = findProjectRoot(flags.cwd || process.cwd());
20
- if (!projectRoot) {
21
- logger.error('No package.json found. Run the command inside an npm project.');
22
- return 1;
23
- }
21
+ const input = inputPath(flags);
22
+ const projectRoot = resolveRoot(flags);
23
+ if (!projectRoot) return 1;
24
24
 
25
25
  let policy;
26
26
  try {
@@ -36,9 +36,15 @@ async function vexCommand(flags) {
36
36
  return 1;
37
37
  }
38
38
 
39
+ const { mode, parsed } = loadProject(projectRoot, { input });
40
+ if (parsed && !parsed.ok) {
41
+ logger.error(parsed.error);
42
+ return 1;
43
+ }
39
44
  const vulns = await scanVulnerabilities(projectRoot, {
40
45
  production: flags.production || policy.productionOnly,
41
46
  source: flags.vulnSource || policy.vulnerabilitySource,
47
+ parsed,
42
48
  });
43
49
  if (!vulns.ok) {
44
50
  logger.error(vulns.error);
@@ -53,7 +59,9 @@ async function vexCommand(flags) {
53
59
  const pkg = readJson(path.join(projectRoot, 'package.json')) || {};
54
60
  const creator = creatorFromManifest(pkg);
55
61
  const author = policy.sbomCreator || (creator && (creator.email || creator.url)) || null;
56
- const product = { name: pkg.name || path.basename(projectRoot), version: pkg.version || '0.0.0' };
62
+ const product = parsed && mode !== 'npm+manifest'
63
+ ? { name: parsed.root.name, version: parsed.root.version, purl: parsed.root.purl }
64
+ : { name: pkg.name || path.basename(projectRoot), version: pkg.version || '0.0.0' };
57
65
  const document = buildVex(product, vulns, policy, { format, author });
58
66
 
59
67
  const count = format === 'openvex' ? document.statements.length : document.vulnerabilities.length;
@@ -5,6 +5,7 @@ const { generateSbom } = require('./sbom-generator');
5
5
  const { validateSbom } = require('./sbom-validator');
6
6
  const { checkLicenses } = require('./license-checker');
7
7
  const { acceptance } = require('./vex');
8
+ const { loadProject } = require('./project-source');
8
9
 
9
10
  /**
10
11
  * @typedef {object} AuditResult
@@ -18,22 +19,30 @@ const { acceptance } = require('./vex');
18
19
  * Runs the full CRA audit: vulnerabilities + SBOM + licenses, then evaluates
19
20
  * the result against the configured security policy.
20
21
  *
22
+ * The components come from the npm lockfile, an input SBOM (`input`, any
23
+ * ecosystem) or, without an npm lockfile, the manifests of other ecosystems
24
+ * (requirements.txt, poetry.lock, go.mod, pom.xml…). See ./project-source.js.
25
+ *
21
26
  * @param {string} projectRoot
22
27
  * @param {object} policy
23
28
  * @param {string|null} policySource
24
- * @param {{ only?: 'vulnerabilities'|'sbom'|'licenses' }} [options]
29
+ * @param {{ only?: 'vulnerabilities'|'sbom'|'licenses', input?: string }} [options]
25
30
  * @returns {Promise<AuditResult>}
26
31
  */
27
32
  async function runAudit(projectRoot, policy, policySource, options = {}) {
28
- const { only } = options;
33
+ const { only, input } = options;
29
34
  const sections = {};
30
35
  const reasons = [];
36
+ const project = loadProject(projectRoot, { input });
37
+ const parsed = project.parsed;
38
+ const fromManifests = project.mode === 'manifest';
31
39
 
32
40
  // --- 1. Vulnerabilities -------------------------------------------------
33
41
  if (!only || only === 'vulnerabilities') {
34
42
  const vulns = await scanVulnerabilities(projectRoot, {
35
43
  production: policy.productionOnly,
36
44
  source: policy.vulnerabilitySource,
45
+ parsed,
37
46
  });
38
47
  sections.vulnerabilities = vulns;
39
48
 
@@ -56,7 +65,39 @@ async function runAudit(projectRoot, policy, policySource, options = {}) {
56
65
  }
57
66
 
58
67
  // --- 2. SBOM ------------------------------------------------------------
59
- if (!only || only === 'sbom') {
68
+ if ((!only || only === 'sbom') && fromManifests) {
69
+ // TR-03183 SBOMs (hashes, creators, graph) are built from npm lockfiles;
70
+ // other ecosystems should commit the one their build tooling produces.
71
+ sections.sbom = {
72
+ ok: true, manifest: true, format: 'manifest', componentCount: parsed.components.length,
73
+ files: parsed.files, ecosystems: parsed.ecosystems,
74
+ };
75
+ if (policy.requireSbom) {
76
+ reasons.push({
77
+ label: `No SBOM generated for ${parsed.ecosystems.join(', ')}: commit one from your build tooling (Syft, cdxgen, CycloneDX plugins) and audit it with \`-i\``,
78
+ passed: true,
79
+ warning: true,
80
+ });
81
+ }
82
+ } else if ((!only || only === 'sbom') && project.mode === 'sbom') {
83
+ // An input SBOM comes from third-party tooling that rarely carries the
84
+ // manufacturer fields TR-03183 requires: report gaps without blocking.
85
+ const sbom = parsed.ok
86
+ ? { ok: true, input: parsed.sourceFile, format: parsed.format, componentCount: parsed.components.length, validation: validateSbom(parsed.document) }
87
+ : { ok: false, input: parsed.sourceFile, error: parsed.error };
88
+ sections.sbom = sbom;
89
+ if (!sbom.ok) {
90
+ reasons.push({ label: `The input SBOM could not be read: ${sbom.error}`, passed: false });
91
+ } else if (policy.requireSbom) {
92
+ reasons.push({
93
+ label: sbom.validation.valid
94
+ ? 'Input SBOM valid against the TR-03183-2 required data fields'
95
+ : `Input SBOM misses ${sbom.validation.failedChecks.length} TR-03183-2 requirement(s) (see \`cra-audit sbom check -i\`)`,
96
+ passed: true,
97
+ warning: !sbom.validation.valid,
98
+ });
99
+ }
100
+ } else if (!only || only === 'sbom') {
60
101
  const sbom = generateSbom(projectRoot, { format: policy.sbomFormat, creator: policy.sbomCreator });
61
102
  if (sbom.ok) {
62
103
  sbom.validation = validateSbom(sbom.document);
@@ -79,11 +120,18 @@ async function runAudit(projectRoot, policy, policySource, options = {}) {
79
120
 
80
121
  // --- 3. Licenses --------------------------------------------------------
81
122
  if (!only || only === 'licenses') {
82
- const licenses = checkLicenses(projectRoot, policy.licenses);
123
+ const licenses = checkLicenses(projectRoot, policy.licenses, { parsed: project.mode === 'npm+manifest' ? null : parsed });
83
124
  sections.licenses = licenses;
84
125
 
85
126
  if (!licenses.ok) {
86
127
  reasons.push({ label: `Licenses could not be analyzed: ${licenses.error}`, passed: false });
128
+ } else if (fromManifests) {
129
+ // Manifests do not declare licenses: report the gap instead of failing.
130
+ reasons.push({
131
+ label: `Licenses are not declared in ${parsed.files.join(', ')}: ${licenses.summary.missing.length} component(s) to review in your SBOM`,
132
+ passed: true,
133
+ warning: licenses.summary.missing.length > 0,
134
+ });
87
135
  } else {
88
136
  const s = licenses.summary;
89
137
  const missingFails = policy.licenses.failOnMissing && s.missing.length > 0;
@@ -98,7 +146,9 @@ async function runAudit(projectRoot, policy, policySource, options = {}) {
98
146
  }
99
147
 
100
148
  return {
101
- project: getProject(projectRoot, sections),
149
+ project: parsed && parsed.ok && project.mode !== 'npm+manifest'
150
+ ? { name: parsed.root.name, version: parsed.root.version, ...(project.mode === 'sbom' ? { sbom: parsed.sourceFile } : { manifests: parsed.files }) }
151
+ : getProject(projectRoot, sections),
102
152
  policySource,
103
153
  generatedAt: new Date().toISOString(),
104
154
  sections,
@@ -7,11 +7,16 @@ const { readInstalledManifest, licenseFromManifest } = require('./installed-meta
7
7
  * Resolves and evaluates the license of every third-party component.
8
8
  * CRA / TR-03183 require every component's license to be documented.
9
9
  *
10
+ * Pass `parsed` (from readSbom()) to check the licenses declared in an input
11
+ * SBOM instead of the lockfile and node_modules.
12
+ *
10
13
  * @param {string} projectRoot
11
14
  * @param {{ allow?: string[], deny?: string[] }} [policy]
15
+ * @param {{ parsed?: object }} [options]
12
16
  */
13
- function checkLicenses(projectRoot, policy = {}) {
14
- const parsed = parseLockfile(projectRoot);
17
+ function checkLicenses(projectRoot, policy = {}, { parsed: given } = {}) {
18
+ const parsed = given || parseLockfile(projectRoot);
19
+ const fromSbom = parsed.manager === 'sbom';
15
20
  if (!parsed.ok) {
16
21
  return { ok: false, error: parsed.error, components: [], summary: emptySummary() };
17
22
  }
@@ -20,12 +25,14 @@ function checkLicenses(projectRoot, policy = {}) {
20
25
  const deny = normalizeList(policy.deny);
21
26
 
22
27
  const components = parsed.components.map((c) => {
23
- const license = c.license || licenseFromManifest(readInstalledManifest(projectRoot, c));
28
+ const license = c.license || (fromSbom ? null : licenseFromManifest(readInstalledManifest(projectRoot, c)));
24
29
  const normalized = license ? String(license) : null;
25
30
  const status = classify(normalized, allow, deny);
26
31
  return {
27
32
  name: c.name,
28
33
  version: c.version,
34
+ purl: c.purl || null,
35
+ location: c.location || null,
29
36
  license: normalized,
30
37
  status, // 'ok' | 'missing' | 'denied' | 'not-allowed'
31
38
  };
@@ -0,0 +1,374 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ /**
7
+ * Reads the dependency manifests of non-npm ecosystems straight from the
8
+ * repository, without an SBOM generator:
9
+ *
10
+ * - Python: poetry.lock, uv.lock, Pipfile.lock (exact, preferred) or
11
+ * requirements*.txt (only `==` pins; `-r` includes are followed)
12
+ * - Go: go.mod (every required module, direct and `// indirect`)
13
+ * - Java: gradle.lockfile (exact), pom.xml (declared dependencies with
14
+ * `${property}` and dependencyManagement resolution)
15
+ *
16
+ * Returns the same shape as readSbom(), so the OSV/KEV scan, VEX and SARIF
17
+ * work unchanged. Every component records the file and line it came from.
18
+ * Versions that are not pinned cannot be audited reliably: they are skipped
19
+ * and reported, never guessed.
20
+ *
21
+ * @param {string} projectRoot
22
+ * @returns {null | {
23
+ * ok: true, manager: 'manifest', purlBased: true, format: 'manifest',
24
+ * lockfileName: string, sourceFile: null, files: string[],
25
+ * root: { name: string, version: string, purl: string, dependsOn: string[] },
26
+ * components: object[], unidentified: number, notes: string[],
27
+ * dependencyGraphComplete: false, ecosystems: string[]
28
+ * }} null when no supported manifest exists.
29
+ */
30
+ function readManifests(projectRoot) {
31
+ const found = [];
32
+ const notes = [];
33
+ let skipped = 0;
34
+
35
+ const collect = (result) => {
36
+ if (!result) return;
37
+ found.push(result);
38
+ skipped += result.skipped || 0;
39
+ if (result.note) notes.push(result.note);
40
+ };
41
+
42
+ // Python: an exact lockfile wins over requirements files.
43
+ const pyLock = firstExisting(projectRoot, ['poetry.lock', 'uv.lock', 'Pipfile.lock']);
44
+ if (pyLock) {
45
+ collect(pyLock === 'Pipfile.lock' ? readPipfileLock(projectRoot, pyLock) : readTomlLock(projectRoot, pyLock));
46
+ } else {
47
+ for (const file of requirementsFiles(projectRoot)) collect(readRequirements(projectRoot, file));
48
+ }
49
+
50
+ if (exists(projectRoot, 'go.mod')) collect(readGoMod(projectRoot, 'go.mod'));
51
+ if (exists(projectRoot, 'gradle.lockfile')) collect(readGradleLock(projectRoot, 'gradle.lockfile'));
52
+ if (exists(projectRoot, 'pom.xml')) collect(readPom(projectRoot, 'pom.xml'));
53
+
54
+ if (!found.length) return null;
55
+
56
+ const components = dedupe(found.flatMap((r) => r.components));
57
+ const files = found.map((r) => r.file);
58
+ const name = path.basename(path.resolve(projectRoot));
59
+ const rootName = found.map((r) => r.rootName).find(Boolean) || name;
60
+ const rootVersion = found.map((r) => r.rootVersion).find(Boolean) || '0.0.0';
61
+ return {
62
+ ok: true,
63
+ manager: 'manifest',
64
+ purlBased: true,
65
+ format: 'manifest',
66
+ lockfileName: files.join(', '),
67
+ sourceFile: null,
68
+ files,
69
+ root: {
70
+ name: rootName,
71
+ version: rootVersion,
72
+ purl: `pkg:generic/${encodeURIComponent(rootName)}@${encodeURIComponent(rootVersion)}`,
73
+ dependsOn: components.filter((c) => c.direct).map((c) => c.key),
74
+ },
75
+ components,
76
+ unidentified: skipped,
77
+ notes,
78
+ dependencyGraphComplete: false,
79
+ ecosystems: [...new Set(components.map((c) => c.ecosystem))].sort(),
80
+ };
81
+ }
82
+
83
+ /** True when the folder holds a manifest this reader understands. */
84
+ function hasManifests(projectRoot) {
85
+ return ['poetry.lock', 'uv.lock', 'Pipfile.lock', 'go.mod', 'gradle.lockfile', 'pom.xml']
86
+ .some((f) => exists(projectRoot, f)) || requirementsFiles(projectRoot).length > 0;
87
+ }
88
+
89
+ // --- Python ------------------------------------------------------------------------
90
+
91
+ function requirementsFiles(root) {
92
+ let names = [];
93
+ try {
94
+ names = fs.readdirSync(root);
95
+ } catch {
96
+ return [];
97
+ }
98
+ return names.filter((n) => /^requirements([-_.][\w.-]+)?\.txt$/i.test(n)).sort();
99
+ }
100
+
101
+ /**
102
+ * pip requirements: `name==1.2.3`, extras, environment markers, `--hash`
103
+ * continuation lines and `-r other.txt` includes. Anything that is not an
104
+ * exact pin (ranges, URLs, editable installs) is skipped and counted.
105
+ */
106
+ function readRequirements(root, file, seen = new Set()) {
107
+ const full = path.join(root, file);
108
+ if (seen.has(full)) return { file, components: [], skipped: 0 };
109
+ seen.add(full);
110
+ const components = [];
111
+ let skipped = 0;
112
+ const dev = /dev|test|lint|doc/i.test(path.basename(file));
113
+
114
+ lines(full).forEach((raw, index) => {
115
+ const line = raw.replace(/\s+#.*$/, '').replace(/\\$/, '').trim();
116
+ if (!line || line.startsWith('#') || line.startsWith('--hash')) return;
117
+ const include = line.match(/^(?:-r|--requirement)\s+(.+)$/);
118
+ if (include) {
119
+ const nested = readRequirements(root, path.join(path.dirname(file), include[1].trim()), seen);
120
+ components.push(...nested.components);
121
+ skipped += nested.skipped;
122
+ return;
123
+ }
124
+ if (line.startsWith('-')) return; // pip options: -c, -i, --index-url, -e…
125
+ const spec = line.split(';')[0].trim();
126
+ const m = spec.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s*===?\s*([^\s,]+)$/);
127
+ if (m) components.push(pypi(m[1], m[2], file, index + 1, { dev, direct: true }));
128
+ else skipped++;
129
+ });
130
+ const note = skipped ? `${file}: ${skipped} requirement(s) without an exact == pin were not audited` : null;
131
+ return { file, components, skipped, note };
132
+ }
133
+
134
+ /** poetry.lock / uv.lock: `[[package]]` tables with `name` and `version`. */
135
+ function readTomlLock(root, file) {
136
+ const components = [];
137
+ let current = null;
138
+ let rootName = null;
139
+ let rootVersion = null;
140
+ const flush = () => {
141
+ if (!current || !current.name || !current.version) return;
142
+ if (current.local) {
143
+ rootName = rootName || current.name;
144
+ rootVersion = rootVersion || current.version;
145
+ } else {
146
+ components.push(pypi(current.name, current.version, file, current.line, { direct: false }));
147
+ }
148
+ };
149
+ lines(path.join(root, file)).forEach((raw, index) => {
150
+ const line = raw.trim();
151
+ if (line === '[[package]]') {
152
+ flush();
153
+ current = { line: index + 1 };
154
+ return;
155
+ }
156
+ if (!current) return;
157
+ if (/^\[/.test(line)) { // a sub-table: [package.dependencies], [package.source]…
158
+ if (!line.startsWith('[package.')) { flush(); current = null; }
159
+ return;
160
+ }
161
+ const kv = line.match(/^(name|version)\s*=\s*"([^"]+)"/);
162
+ if (kv && !current[kv[1]]) current[kv[1]] = kv[2];
163
+ // uv marks the project itself with an editable/virtual source.
164
+ if (/^source\s*=\s*\{\s*(editable|virtual)\s*=/.test(line)) current.local = true;
165
+ });
166
+ flush();
167
+ return { file, components, rootName, rootVersion };
168
+ }
169
+
170
+ /** Pipfile.lock (JSON): `default` and `develop` maps of `"==version"`. */
171
+ function readPipfileLock(root, file) {
172
+ let doc;
173
+ try {
174
+ doc = JSON.parse(fs.readFileSync(path.join(root, file), 'utf8'));
175
+ } catch {
176
+ return { file, components: [], skipped: 0, note: `${file} could not be parsed` };
177
+ }
178
+ const text = lines(path.join(root, file));
179
+ const components = [];
180
+ for (const [group, dev] of [['default', false], ['develop', true]]) {
181
+ for (const [name, info] of Object.entries(doc[group] || {})) {
182
+ const version = info && typeof info.version === 'string' ? info.version.replace(/^===?/, '') : null;
183
+ if (!version) continue;
184
+ const line = text.findIndex((l) => l.includes(`"${name}": {`)) + 1 || 1;
185
+ components.push(pypi(name, version, file, line, { dev, direct: true }));
186
+ }
187
+ }
188
+ return { file, components };
189
+ }
190
+
191
+ function pypi(name, version, file, line, { dev = false, direct = false } = {}) {
192
+ // PEP 503 normalisation, as purl and OSV use it.
193
+ const normalized = name.toLowerCase().replace(/[-_.]+/g, '-');
194
+ return component({
195
+ ecosystem: 'pypi', name: normalized, osvName: normalized, version,
196
+ purl: `pkg:pypi/${normalized}@${version}`, file, line, dev, direct,
197
+ });
198
+ }
199
+
200
+ // --- Go ------------------------------------------------------------------------------
201
+
202
+ /** go.mod `require` directives (single and block form); `replace` is applied. */
203
+ function readGoMod(root, file) {
204
+ const text = lines(path.join(root, file));
205
+ const requires = [];
206
+ const replaces = new Map();
207
+ let block = null;
208
+ let moduleName = null;
209
+
210
+ text.forEach((raw, index) => {
211
+ const line = raw.replace(/\/\/(?!\s*indirect).*$/, '').trim();
212
+ const mod = line.match(/^module\s+(\S+)/);
213
+ if (mod) moduleName = mod[1];
214
+ if (/^(require|replace|exclude|retract)\s*\($/.test(line)) { block = line.split(/\s/)[0]; return; }
215
+ if (line === ')') { block = null; return; }
216
+ const single = line.match(/^(require|replace)\s+(.+)$/);
217
+ const kind = single ? single[1] : block;
218
+ const body = single ? single[2] : line;
219
+ if (kind === 'require') {
220
+ const m = body.match(/^(\S+)\s+(v\S+)(\s+\/\/\s*indirect)?/);
221
+ if (m) requires.push({ module: m[1], version: m[2], indirect: Boolean(m[3]), line: index + 1 });
222
+ } else if (kind === 'replace') {
223
+ const m = body.match(/^(\S+)(?:\s+v\S+)?\s+=>\s+(\S+)\s+(v\S+)/);
224
+ if (m) replaces.set(m[1], { module: m[2], version: m[3] });
225
+ }
226
+ });
227
+
228
+ const components = requires.map((r) => {
229
+ const target = replaces.get(r.module) || { module: r.module, version: r.version };
230
+ return component({
231
+ ecosystem: 'golang', name: target.module, osvName: target.module, version: target.version,
232
+ purl: `pkg:golang/${target.module}@${target.version}`, file, line: r.line, direct: !r.indirect,
233
+ });
234
+ });
235
+ return { file, components, rootName: moduleName ? moduleName.split('/').pop() : null };
236
+ }
237
+
238
+ // --- Java --------------------------------------------------------------------------------
239
+
240
+ /** gradle.lockfile: `group:artifact:version=configurations`. */
241
+ function readGradleLock(root, file) {
242
+ const components = [];
243
+ lines(path.join(root, file)).forEach((raw, index) => {
244
+ const m = raw.trim().match(/^([^:#\s]+):([^:\s]+):([^=\s]+)=(.*)$/);
245
+ if (!m) return;
246
+ const configs = m[4];
247
+ const dev = configs && !/(runtimeClasspath|compileClasspath)/.test(configs);
248
+ components.push(maven(m[1], m[2], m[3], file, index + 1, { dev, direct: false }));
249
+ });
250
+ return { file, components };
251
+ }
252
+
253
+ /**
254
+ * pom.xml declared dependencies. Versions come from the dependency itself,
255
+ * `${property}` values in <properties> (and project.version) or the
256
+ * <dependencyManagement> section. Transitive dependencies need Maven to be
257
+ * resolved, so only the declared ones are audited.
258
+ */
259
+ function readPom(root, file) {
260
+ const xml = fs.readFileSync(path.join(root, file), 'utf8');
261
+ const stripped = xml.replace(/<!--[\s\S]*?-->/g, (c) => c.replace(/[^\n]/g, ' '));
262
+
263
+ const tag = (block, name) => {
264
+ const m = block.match(new RegExp(`<${name}>\\s*([^<]*?)\\s*</${name}>`));
265
+ return m ? m[1] : null;
266
+ };
267
+ // Top-level project fields, ignoring <parent>, <dependencies> and the like.
268
+ const topLevel = stripped
269
+ .replace(/<parent>[\s\S]*?<\/parent>/, '')
270
+ .replace(/<(dependencies|dependencyManagement|build|profiles|plugins|reporting)>[\s\S]*?<\/\1>/g, '');
271
+ const parentBlock = (stripped.match(/<parent>([\s\S]*?)<\/parent>/) || [])[1] || '';
272
+ const props = {
273
+ 'project.version': tag(topLevel, 'version') || tag(parentBlock, 'version'),
274
+ 'project.groupId': tag(topLevel, 'groupId') || tag(parentBlock, 'groupId'),
275
+ };
276
+ const propsBlock = (stripped.match(/<properties>([\s\S]*?)<\/properties>/) || [])[1] || '';
277
+ for (const m of propsBlock.matchAll(/<([\w.-]+)>\s*([^<]*?)\s*<\/\1>/g)) props[m[1]] = m[2];
278
+ const resolve = (value) => {
279
+ let v = value;
280
+ for (let i = 0; i < 5 && v && /\$\{([^}]+)\}/.test(v); i++) {
281
+ v = v.replace(/\$\{([^}]+)\}/g, (_, key) => (props[key] !== undefined && props[key] !== null ? props[key] : `\${${key}}`));
282
+ }
283
+ return v;
284
+ };
285
+
286
+ const managedBlock = (stripped.match(/<dependencyManagement>([\s\S]*?)<\/dependencyManagement>/) || [])[1] || '';
287
+ const managed = new Map();
288
+ for (const m of managedBlock.matchAll(/<dependency>([\s\S]*?)<\/dependency>/g)) {
289
+ const g = resolve(tag(m[1], 'groupId'));
290
+ const a = resolve(tag(m[1], 'artifactId'));
291
+ const v = resolve(tag(m[1], 'version'));
292
+ if (g && a && v) managed.set(`${g}:${a}`, v);
293
+ }
294
+
295
+ const components = [];
296
+ let skipped = 0;
297
+ const outside = stripped.replace(/<dependencyManagement>[\s\S]*?<\/dependencyManagement>/, (c) => c.replace(/[^\n]/g, ' '));
298
+ for (const m of outside.matchAll(/<dependency>([\s\S]*?)<\/dependency>/g)) {
299
+ const block = m[1];
300
+ const g = resolve(tag(block, 'groupId'));
301
+ const a = resolve(tag(block, 'artifactId'));
302
+ let v = resolve(tag(block, 'version')) || managed.get(`${g}:${a}`) || null;
303
+ if (!g || !a || !v || /\$\{|[[\]()]/.test(v)) { skipped++; continue; } // unresolved or a version range
304
+ const scope = tag(block, 'scope');
305
+ const line = outside.slice(0, m.index).split('\n').length + block.slice(0, block.indexOf('<artifactId>')).split('\n').length - 1;
306
+ components.push(maven(g, a, v, file, line, { dev: scope === 'test' || scope === 'provided', direct: true }));
307
+ }
308
+ const notes = ['pom.xml: declared dependencies only; commit a Maven-generated SBOM (cyclonedx-maven-plugin) to audit transitive ones'];
309
+ if (skipped) notes.push(`pom.xml: ${skipped} dependency(ies) with a version inherited from a parent/BOM or a range were not audited`);
310
+ return {
311
+ file, components, skipped, note: notes.join('; '),
312
+ rootName: tag(topLevel, 'artifactId'), rootVersion: props['project.version'],
313
+ };
314
+ }
315
+
316
+ function maven(group, artifact, version, file, line, { dev = false, direct = false } = {}) {
317
+ return component({
318
+ ecosystem: 'maven', name: `${group}:${artifact}`, osvName: `${group}:${artifact}`, version,
319
+ purl: `pkg:maven/${group}/${artifact}@${version}`, file, line, dev, direct,
320
+ });
321
+ }
322
+
323
+ // --- Shared ------------------------------------------------------------------------------
324
+
325
+ function component({ ecosystem, name, osvName, version, purl, file, line, dev = false, direct = false }) {
326
+ return {
327
+ key: purl,
328
+ name,
329
+ version,
330
+ purl,
331
+ ecosystem,
332
+ osvName,
333
+ license: null,
334
+ scope: dev ? 'optional' : 'required',
335
+ dev,
336
+ optional: dev,
337
+ direct,
338
+ dependsOn: [],
339
+ location: { file, line },
340
+ };
341
+ }
342
+
343
+ function dedupe(components) {
344
+ const seen = new Map();
345
+ for (const c of components) {
346
+ const existing = seen.get(c.key);
347
+ if (!existing) seen.set(c.key, c);
348
+ else {
349
+ existing.direct = existing.direct || c.direct;
350
+ existing.optional = existing.optional && c.optional;
351
+ existing.dev = existing.optional;
352
+ existing.scope = existing.optional ? 'optional' : 'required';
353
+ }
354
+ }
355
+ return [...seen.values()].sort((a, b) => a.key.localeCompare(b.key));
356
+ }
357
+
358
+ function lines(file) {
359
+ try {
360
+ return fs.readFileSync(file, 'utf8').split(/\r?\n/);
361
+ } catch {
362
+ return [];
363
+ }
364
+ }
365
+
366
+ function exists(root, file) {
367
+ return fs.existsSync(path.join(root, file));
368
+ }
369
+
370
+ function firstExisting(root, files) {
371
+ return files.find((f) => exists(root, f)) || null;
372
+ }
373
+
374
+ module.exports = { readManifests, hasManifests };
package/src/core/osv.js CHANGED
@@ -7,25 +7,32 @@ const BATCH_SIZE = 1000; // OSV querybatch limit
7
7
 
8
8
  /**
9
9
  * Queries OSV.dev (https://osv.dev) for the advisories affecting each exact
10
- * `name@version`. OSV aggregates the GitHub Advisory Database (GHSA) and the
11
- * OpenSSF malicious-packages feed (MAL-*), which lists compromised releases
12
- * such as the ones published by the Shai-Hulud worm.
10
+ * component version. OSV aggregates the GitHub Advisory Database (GHSA), the
11
+ * ecosystem databases (PyPA, RustSec, Go…) and the OpenSSF malicious-packages
12
+ * feed (MAL-*), which lists compromised releases such as the ones published by
13
+ * the Shai-Hulud worm.
13
14
  *
14
- * Only package names and versions leave the machine.
15
+ * Components with a Package URL (from an input SBOM, any ecosystem) are
16
+ * queried by purl; lockfile components by npm name and version. Only package
17
+ * identifiers and versions leave the machine.
15
18
  *
16
- * @param {Array<{ name: string, version: string }>} components
19
+ * @param {Array<{ name: string, version: string, purl?: string, key?: string }>} components
17
20
  * @param {{ timeout?: number, concurrency?: number }} [options]
18
21
  * @returns {Promise<{ ok: true, byComponent: Map<string, object[]> } | { ok: false, error: string }>}
19
- * `byComponent` is keyed by `name@version` and holds full OSV records.
22
+ * `byComponent` is keyed by the component key and holds full OSV records.
20
23
  */
21
24
  async function queryOsv(components, { timeout = 30000, concurrency = 8 } = {}) {
22
25
  const idsByKey = new Map();
26
+ const queryFor = (c) => (c.ecosystem && c.purl
27
+ ? { package: { purl: c.purl } } // the version is part of the purl
28
+ : { package: { name: c.name, ecosystem: 'npm' }, version: c.version });
29
+ const keyFor = (c) => c.key || `${c.name}@${c.version}`;
23
30
 
24
31
  for (let i = 0; i < components.length; i += BATCH_SIZE) {
25
32
  const chunk = components.slice(i, i + BATCH_SIZE);
26
33
  const res = await fetchJson(`${OSV_API}/querybatch`, {
27
34
  timeout,
28
- body: { queries: chunk.map((c) => ({ package: { name: c.name, ecosystem: 'npm' }, version: c.version })) },
35
+ body: { queries: chunk.map(queryFor) },
29
36
  });
30
37
  if (!res || !res.ok || !res.json || !Array.isArray(res.json.results)) {
31
38
  return { ok: false, error: `OSV.dev query failed${res ? ` (HTTP ${res.status})` : ' (network unreachable)'}` };
@@ -39,13 +46,13 @@ async function queryOsv(components, { timeout = 30000, concurrency = 8 } = {}) {
39
46
  while (pageToken) {
40
47
  const page = await fetchJson(`${OSV_API}/query`, {
41
48
  timeout,
42
- body: { package: { name: chunk[j].name, ecosystem: 'npm' }, version: chunk[j].version, page_token: pageToken },
49
+ body: { ...queryFor(chunk[j]), page_token: pageToken },
43
50
  });
44
51
  if (!page || !page.ok || !page.json) break;
45
52
  ids.push(...(page.json.vulns || []).map((v) => v.id));
46
53
  pageToken = page.json.next_page_token;
47
54
  }
48
- if (ids.length) idsByKey.set(`${chunk[j].name}@${chunk[j].version}`, ids);
55
+ if (ids.length) idsByKey.set(keyFor(chunk[j]), ids);
49
56
  }
50
57
  }
51
58
 
@@ -123,7 +130,7 @@ function isMalicious(record) {
123
130
  function fixedVersion(record, name, version) {
124
131
  let best = null;
125
132
  for (const affected of record.affected || []) {
126
- if (!affected.package || affected.package.name !== name) continue;
133
+ if (!affected.package || normalizeName(affected.package.name) !== normalizeName(name)) continue;
127
134
  for (const range of affected.ranges || []) {
128
135
  if (range.type !== 'SEMVER' && range.type !== 'ECOSYSTEM') continue;
129
136
  let introduced = null;
@@ -140,17 +147,26 @@ function fixedVersion(record, name, version) {
140
147
  }
141
148
 
142
149
  /**
143
- * Compares two semver strings (major.minor.patch[-prerelease]).
144
- * Build metadata is ignored.
150
+ * OSV package names compare case-insensitively, and PyPI treats `-`, `_` and
151
+ * `.` as equivalent (PEP 503).
152
+ */
153
+ function normalizeName(name) {
154
+ return String(name || '').toLowerCase().replace(/[-_.]+/g, '-');
155
+ }
156
+
157
+ /**
158
+ * Compares two semver strings (major.minor.patch[-prerelease]). Also used,
159
+ * approximately, for other ecosystems: a leading `v` (Go) is ignored and any
160
+ * number of numeric parts is compared. Build metadata is ignored.
145
161
  */
146
162
  function compareSemver(a, b) {
147
163
  const parse = (v) => {
148
- const [core, pre] = String(v).split('+')[0].split(/-(.*)/s);
164
+ const [core, pre] = String(v).replace(/^v/i, '').split('+')[0].split(/-(.*)/s);
149
165
  return { nums: core.split('.').map((n) => parseInt(n, 10) || 0), pre: pre ? pre.split('.') : [] };
150
166
  };
151
167
  const pa = parse(a);
152
168
  const pb = parse(b);
153
- for (let i = 0; i < 3; i++) {
169
+ for (let i = 0; i < Math.max(pa.nums.length, pb.nums.length, 3); i++) {
154
170
  const diff = (pa.nums[i] || 0) - (pb.nums[i] || 0);
155
171
  if (diff) return diff;
156
172
  }