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.
- package/CHANGELOG.md +24 -0
- package/README.md +64 -3
- package/package.json +1 -1
- package/src/cli.js +5 -1
- package/src/commands/audit.js +85 -69
- package/src/commands/readiness.js +17 -6
- package/src/commands/sbom.js +122 -111
- package/src/commands/vex.js +15 -7
- package/src/core/auditor.js +55 -5
- package/src/core/license-checker.js +10 -3
- package/src/core/manifest-reader.js +374 -0
- package/src/core/osv.js +30 -14
- package/src/core/project-source.js +62 -0
- package/src/core/readiness.js +26 -2
- package/src/core/sbom-reader.js +297 -0
- package/src/core/sbom-validator.js +9 -3
- package/src/core/vex.js +3 -3
- package/src/core/vuln-scanner.js +34 -11
- package/src/reporters/console.js +20 -2
- package/src/reporters/sarif.js +40 -14
package/src/commands/vex.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const path = require('node:path');
|
|
4
|
-
const {
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
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 =
|
|
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;
|
package/src/core/auditor.js
CHANGED
|
@@ -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:
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* such as the ones published by
|
|
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
|
-
*
|
|
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
|
|
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(
|
|
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: {
|
|
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(
|
|
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
|
-
*
|
|
144
|
-
*
|
|
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
|
}
|