cra-audit 1.1.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 +34 -6
- package/package.json +2 -1
- package/src/cli.js +7 -1
- package/src/commands/audit.js +6 -3
- package/src/commands/visualize.js +8 -4
- package/src/core/auditor.js +61 -8
- package/src/core/kev.js +44 -0
- package/src/core/osv.js +211 -0
- package/src/core/policy.js +5 -0
- package/src/core/vuln-scanner.js +161 -11
- package/src/index.js +1 -1
- package/src/reporters/console.js +53 -11
- package/src/reporters/json.js +1 -1
- package/src/utils/http.js +11 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/) and the project uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
Releases are automated: pushing a `vX.Y.Z` tag publishes the package to npm
|
|
8
|
+
(with provenance) and creates the GitHub Release from the matching section
|
|
9
|
+
below, so add the section before running `npm version`.
|
|
10
|
+
|
|
11
|
+
## [2.0.0] — OSV.dev, CISA KEV & malicious package detection
|
|
12
|
+
|
|
13
|
+
### Highlights
|
|
14
|
+
|
|
15
|
+
**Known, actively exploited and malicious dependencies — in one check**
|
|
16
|
+
|
|
17
|
+
- **OSV.dev** is now the default vulnerability source: every dependency is checked at the exact version pinned in the lockfile (npm, Yarn, pnpm), with severity, CVE aliases and the version that fixes it. `npm` is no longer required.
|
|
18
|
+
- **Malicious packages**: compromised releases from the OpenSSF malicious-packages feed (`MAL-*`, e.g. the Shai-Hulud worm versions) always fail the audit and cannot be allowlisted.
|
|
19
|
+
- **CISA KEV**: vulnerabilities with evidence of active exploitation fail the audit by default and print the **CRA Art. 14 reporting clock** (24 h / 72 h / 14 days). Use `--no-fail-on-kev` to report them as a warning.
|
|
20
|
+
- `--production` now follows the dependency graph, so it works for Yarn and pnpm projects too.
|
|
21
|
+
- The allowlist accepts GHSA and CVE identifiers.
|
|
22
|
+
|
|
23
|
+
### New options
|
|
24
|
+
|
|
25
|
+
- `--vuln-source osv|npm` / policy `vulnerabilitySource` (default `osv`)
|
|
26
|
+
- `--no-fail-on-kev` / policy `failOnKev` (default `true`)
|
|
27
|
+
|
|
28
|
+
### ⚠️ Breaking changes
|
|
29
|
+
|
|
30
|
+
- **CLI / CI usage is unchanged** (`npx cra-audit`), but the audit is stricter: malicious packages and actively exploited (KEV) vulnerabilities now fail it.
|
|
31
|
+
- **Programmatic API**: `runAudit()` and `scanVulnerabilities()` are now async — add `await`.
|
|
32
|
+
- Requires network access to `api.osv.dev` and `cisa.gov`. If OSV.dev is unreachable, the audit falls back to `npm audit` and says so.
|
|
33
|
+
|
|
34
|
+
## [1.1.0] — TR-03183-2 v2.1 SBOM
|
|
35
|
+
|
|
36
|
+
### Highlights
|
|
37
|
+
|
|
38
|
+
**CycloneDX 1.6 SBOM conforming to BSI TR-03183-2 v2.1.0**
|
|
39
|
+
|
|
40
|
+
- Full dependency graph from npm (v1–v3), Yarn classic/Berry and pnpm (v5–v9) lockfiles, with completeness declared in `compositions`
|
|
41
|
+
- SBOM creator (`--creator` flag or `sbomCreator` policy, defaulting to package.json) and component creators read from installed packages
|
|
42
|
+
- `bsi:component:filename` / `executable` / `archive` / `structured` properties, SHA-512 on the distribution reference, declared/concluded licenses, source code URI
|
|
43
|
+
- `sbom check` validates every TR-03183-2 v2.1 required field
|
|
44
|
+
|
|
45
|
+
### Fixes
|
|
46
|
+
|
|
47
|
+
- Licenses of Yarn/pnpm projects now reach the SBOM
|
|
48
|
+
- Yarn Berry workspace entries are no longer listed as third-party components
|
|
49
|
+
- pnpm v5 lockfile keys with peer suffixes are parsed correctly
|
|
50
|
+
- Malformed integrity digests no longer produce schema-invalid hashes
|
|
51
|
+
|
|
52
|
+
### ⚠️ Stricter validation
|
|
53
|
+
|
|
54
|
+
- CycloneDX < 1.6 and SPDX < 3.0.1 are flagged (use the default CycloneDX output)
|
|
55
|
+
- Yarn Berry projects fail the SHA-512 check (the lockfile doesn't store the npm tarball hash)
|
|
56
|
+
- Run it after installing dependencies: component creators and licenses are read from `node_modules`
|
|
57
|
+
|
|
58
|
+
## [1.0.0]
|
|
59
|
+
|
|
60
|
+
- Yarn (classic and Berry) and pnpm lockfile support
|
|
61
|
+
- CRA audit for npm projects: SBOM (CycloneDX/SPDX), `npm audit` vulnerabilities, licenses and an interactive HTML maintenance report
|
package/README.md
CHANGED
|
@@ -47,11 +47,29 @@ Known limits: Yarn Berry lockfiles only store Yarn's own cache checksum, not the
|
|
|
47
47
|
|
|
48
48
|
> CRA Annex I · TR-03183 Part 2 — *"Transparency through SBOM"*.
|
|
49
49
|
|
|
50
|
-
### 2.
|
|
50
|
+
### 2. Known, actively exploited and malicious dependencies
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
Checks every **direct and transitive** dependency, at the exact version pinned in the lockfile, against:
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
| Source | What it finds | Effect on the audit |
|
|
55
|
+
| --- | --- | --- |
|
|
56
|
+
| [OSV.dev](https://osv.dev) — GitHub Advisory Database | Known vulnerabilities, with severity, CVE aliases and the version that fixes them | Fails at or above `--fail-on` (default `high`) |
|
|
57
|
+
| OSV.dev — [OpenSSF malicious packages](https://github.com/ossf/malicious-packages) | Compromised releases (`MAL-*`), e.g. the Shai-Hulud worm versions | **Always fails**; cannot be allowlisted |
|
|
58
|
+
| [CISA KEV](https://www.cisa.gov/known-exploited-vulnerabilities-catalog) | Vulnerabilities with evidence of **active exploitation** | Fails by default (`--no-fail-on-kev` to only warn) and prints the CRA Art. 14 reporting clock |
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
HIGH [KEV] vite@6.2.3 (fix: vite@6.4.3)
|
|
62
|
+
GHSA-4r4m-qw57-chr8 / CVE-2025-31125 Vite has a `server.fs.deny` bypassed … [KEV since 2026-01-22]
|
|
63
|
+
|
|
64
|
+
⚠ CRA Art. 14 — actively exploited vulnerability in a dependency
|
|
65
|
+
• Early warning ........ within 24 hours of becoming aware
|
|
66
|
+
• Notification ......... within 72 hours
|
|
67
|
+
• Final report ......... within 14 days after a corrective measure is available
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Only package names and versions are sent to `api.osv.dev`; the KEV catalogue is downloaded from cisa.gov (or CISA's GitHub mirror). If KEV cannot be reached the report says so instead of silently passing, and if OSV.dev is unreachable the audit falls back to `npm audit`. `--vuln-source npm` uses `npm audit` directly.
|
|
71
|
+
|
|
72
|
+
> CRA Annex I Part I (2)(a) — *placed on the market without known exploitable vulnerabilities*. Art. 14 — *actively exploited vulnerabilities must be reported within 24 hours*.
|
|
55
73
|
|
|
56
74
|
### 3. Third-party component check
|
|
57
75
|
|
|
@@ -138,6 +156,8 @@ cra-audit --help
|
|
|
138
156
|
| `--format <fmt>` | SBOM format: `cyclonedx` (default) or `spdx`. |
|
|
139
157
|
| `--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`. |
|
|
140
158
|
| `--fail-on <sev>` | Minimum severity that fails the audit: `info`, `low`, `moderate`, `high`, `critical`. |
|
|
159
|
+
| `--vuln-source <src>` | `osv` (default: OSV.dev + CISA KEV) or `npm` (`npm audit`). |
|
|
160
|
+
| `--no-fail-on-kev` | Report actively exploited (CISA KEV) vulnerabilities as a warning instead of failing. |
|
|
141
161
|
| `--production`, `--prod` | Audit production dependencies only. |
|
|
142
162
|
| `--no-sbom` | Do not require an SBOM in the full audit. |
|
|
143
163
|
| `--json` | Machine-readable JSON output. |
|
|
@@ -190,6 +210,8 @@ Create a `.cra-audit.json` file at the project root to customize the rules (ther
|
|
|
190
210
|
```json
|
|
191
211
|
{
|
|
192
212
|
"failOn": "high",
|
|
213
|
+
"failOnKev": true,
|
|
214
|
+
"vulnerabilitySource": "osv",
|
|
193
215
|
"requireSbom": true,
|
|
194
216
|
"sbomFormat": "cyclonedx",
|
|
195
217
|
"sbomCreator": "security@example.com",
|
|
@@ -206,11 +228,13 @@ Create a `.cra-audit.json` file at the project root to customize the rules (ther
|
|
|
206
228
|
```
|
|
207
229
|
|
|
208
230
|
- `failOn`: minimum severity that blocks the audit.
|
|
231
|
+
- `failOnKev`: fail when a dependency has an actively exploited vulnerability (CISA KEV). Default `true`.
|
|
232
|
+
- `vulnerabilitySource`: `osv` (OSV.dev + CISA KEV) or `npm` (`npm audit`).
|
|
209
233
|
- `requireSbom`: require the SBOM to meet the TR-03183-2 required data fields.
|
|
210
234
|
- `sbomFormat`: `cyclonedx` or `spdx`.
|
|
211
235
|
- `sbomCreator`: email or URL of the entity that creates the SBOM (usually the manufacturer).
|
|
212
236
|
- `productionOnly`: audit production dependencies only.
|
|
213
|
-
- `vulnerabilities.allowlist`: advisory
|
|
237
|
+
- `vulnerabilities.allowlist`: package names or advisory ids (GHSA, CVE) accepted with documented justification, e.g. when the vulnerable code is not reachable in your product. Malicious packages (`MAL-*`) cannot be allowlisted.
|
|
214
238
|
- `licenses.allow` / `licenses.deny`: allowed / denied lists (SPDX id).
|
|
215
239
|
- `licenses.failOnMissing`: treat undocumented licenses as a failure.
|
|
216
240
|
|
|
@@ -245,7 +269,7 @@ const {
|
|
|
245
269
|
} = require('cra-audit');
|
|
246
270
|
|
|
247
271
|
const { policy, source } = loadPolicy(process.cwd());
|
|
248
|
-
const report = runAudit(process.cwd(), policy, source);
|
|
272
|
+
const report = await runAudit(process.cwd(), policy, source);
|
|
249
273
|
console.log(report.gate.passed ? 'OK' : 'FAILED');
|
|
250
274
|
|
|
251
275
|
// Maintenance data + custom HTML report
|
|
@@ -258,9 +282,13 @@ const signals = await enrichComponents(components, { network: true, github: true
|
|
|
258
282
|
## Requirements
|
|
259
283
|
|
|
260
284
|
- Node.js >= 18 (uses native `fetch` and `node --test`).
|
|
261
|
-
- `npm`
|
|
285
|
+
- Network access to `api.osv.dev` and `cisa.gov` (or `raw.githubusercontent.com` for the KEV mirror). `npm` on the `PATH` is only needed for `--vuln-source npm` or the offline fallback.
|
|
262
286
|
- A lockfile present: `package-lock.json` / `npm-shrinkwrap.json` (npm), `yarn.lock` (Yarn classic or Berry) or `pnpm-lock.yaml` (pnpm). Run `npm install` / `yarn` / `pnpm install` if missing.
|
|
263
287
|
|
|
288
|
+
## See also
|
|
289
|
+
|
|
290
|
+
- [hulud-party-scanner](https://www.npmjs.com/package/hulud-party-scanner) — incident response for a machine that may have installed a compromised package: lifecycle-hook analysis, malicious code patterns and Shai-Hulud artifacts in the home directory.
|
|
291
|
+
|
|
264
292
|
## Legal notice
|
|
265
293
|
|
|
266
294
|
`cra-audit` is a technical support tool. It helps verify controls associated with the CRA and TR-03183, but it **does not constitute legal advice** nor does it guarantee regulatory compliance on its own.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cra-audit",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.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
5
|
"homepage": "https://github.com/migohe14/cra-audit#readme",
|
|
6
6
|
"repository": {
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"bin",
|
|
44
44
|
"src",
|
|
45
45
|
"README.md",
|
|
46
|
+
"CHANGELOG.md",
|
|
46
47
|
"LICENSE"
|
|
47
48
|
],
|
|
48
49
|
"scripts": {
|
package/src/cli.js
CHANGED
|
@@ -20,6 +20,7 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
20
20
|
'--help', '--version', '--json', '--sbom', '--no-sbom',
|
|
21
21
|
'--production', '--prod', '--no-color',
|
|
22
22
|
'--visualize', '--offline', '--github', '--no-open',
|
|
23
|
+
'--fail-on-kev', '--no-fail-on-kev',
|
|
23
24
|
]);
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -152,6 +153,9 @@ function setFlag(flags, name, value) {
|
|
|
152
153
|
case '--fail-on': flags.failOn = String(value).toLowerCase(); break;
|
|
153
154
|
case '--cwd': flags.cwd = value; break;
|
|
154
155
|
case '--creator': flags.creator = value; break;
|
|
156
|
+
case '--vuln-source': flags.vulnSource = String(value).toLowerCase(); break;
|
|
157
|
+
case '--fail-on-kev': flags.failOnKev = value; break;
|
|
158
|
+
case '--no-fail-on-kev': flags.noFailOnKev = value; break;
|
|
155
159
|
default:
|
|
156
160
|
// Unknown flag stored under its raw name for forward compatibility.
|
|
157
161
|
flags[name.replace(/^--/, '')] = value;
|
|
@@ -172,7 +176,7 @@ ${c.bold('COMMANDS')}
|
|
|
172
176
|
${c.cyan('visualize')} Generate an interactive HTML report (SBOM, licenses, versions and maintenance).
|
|
173
177
|
${c.cyan('sbom generate')} Generate an SBOM (CycloneDX or SPDX) and print or save it.
|
|
174
178
|
${c.cyan('sbom check')} Validate the SBOM against the TR-03183-2 v2.1 data fields.
|
|
175
|
-
${c.cyan('vulnerabilities')}
|
|
179
|
+
${c.cyan('vulnerabilities')} Known, actively exploited (KEV) and malicious packages only (alias: vuln).
|
|
176
180
|
${c.cyan('licenses')} Analyze dependency licenses only.
|
|
177
181
|
${c.cyan('help')} Show this help.
|
|
178
182
|
|
|
@@ -185,6 +189,8 @@ ${c.bold('OPTIONS')}
|
|
|
185
189
|
${c.cyan('--format <fmt>')} SBOM format: cyclonedx (default) | spdx.
|
|
186
190
|
${c.cyan('--creator <contact>')} Email or URL of the SBOM creator (default: package.json author/homepage).
|
|
187
191
|
${c.cyan('--fail-on <sev>')} Minimum severity that fails the audit: info|low|moderate|high|critical.
|
|
192
|
+
${c.cyan('--vuln-source <src>')} Vulnerability source: osv (default: OSV.dev + CISA KEV) | npm (npm audit).
|
|
193
|
+
${c.cyan('--no-fail-on-kev')} Report actively exploited (CISA KEV) vulnerabilities without failing.
|
|
188
194
|
${c.cyan('--production, --prod')} Audit production dependencies only (skips devDependencies).
|
|
189
195
|
${c.cyan('--no-sbom')} Do not require an SBOM in the full audit.
|
|
190
196
|
${c.cyan('--json')} Machine-readable JSON output.
|
package/src/commands/audit.js
CHANGED
|
@@ -13,9 +13,9 @@ const { reportJson } = require('../reporters/json');
|
|
|
13
13
|
*
|
|
14
14
|
* @param {object} flags Parsed CLI flags.
|
|
15
15
|
* @param {'vulnerabilities'|'sbom'|'licenses'} [only]
|
|
16
|
-
* @returns {number} Process exit code.
|
|
16
|
+
* @returns {Promise<number>} Process exit code.
|
|
17
17
|
*/
|
|
18
|
-
function auditCommand(flags, only) {
|
|
18
|
+
async function auditCommand(flags, only) {
|
|
19
19
|
const projectRoot = resolveRoot(flags);
|
|
20
20
|
if (!projectRoot) return 1;
|
|
21
21
|
|
|
@@ -28,7 +28,7 @@ function auditCommand(flags, only) {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
const policy = applyFlagOverrides(policyResult.policy, flags);
|
|
31
|
-
const report = runAudit(projectRoot, policy, policyResult.source, { only });
|
|
31
|
+
const report = await runAudit(projectRoot, policy, policyResult.source, { only });
|
|
32
32
|
|
|
33
33
|
if (flags.json) {
|
|
34
34
|
reportJson(report, { outputPath: flags.output });
|
|
@@ -56,6 +56,9 @@ function applyFlagOverrides(policy, flags) {
|
|
|
56
56
|
if (flags.production) merged.productionOnly = true;
|
|
57
57
|
if (flags.format) merged.sbomFormat = flags.format;
|
|
58
58
|
if (flags.noSbom) merged.requireSbom = false;
|
|
59
|
+
if (flags.vulnSource) merged.vulnerabilitySource = flags.vulnSource;
|
|
60
|
+
if (flags.failOnKev) merged.failOnKev = true;
|
|
61
|
+
if (flags.noFailOnKev) merged.failOnKev = false;
|
|
59
62
|
return merged;
|
|
60
63
|
}
|
|
61
64
|
|
|
@@ -44,8 +44,11 @@ async function visualizeCommand(flags) {
|
|
|
44
44
|
|
|
45
45
|
logger.info(`Analyzing ${parsed.components.length} components from ${parsed.root.name}@${parsed.root.version}…`);
|
|
46
46
|
|
|
47
|
-
//
|
|
48
|
-
const vulns = scanVulnerabilities(projectRoot, {
|
|
47
|
+
// --offline keeps the previous behaviour: npm audit instead of OSV.dev/KEV.
|
|
48
|
+
const vulns = await scanVulnerabilities(projectRoot, {
|
|
49
|
+
production: policy.productionOnly,
|
|
50
|
+
source: flags.offline ? 'npm' : policy.vulnerabilitySource,
|
|
51
|
+
});
|
|
49
52
|
const licenses = checkLicenses(projectRoot, policy.licenses || {});
|
|
50
53
|
|
|
51
54
|
// Maintenance enrichment over the network (opt-out with --offline).
|
|
@@ -87,9 +90,10 @@ async function visualizeCommand(flags) {
|
|
|
87
90
|
|
|
88
91
|
/** Merges all data sources into the view model consumed by the HTML reporter. */
|
|
89
92
|
function buildModel(parsed, vulns, licenses, enrichment) {
|
|
93
|
+
// OSV findings are per name@version; npm audit findings only per name.
|
|
90
94
|
const vulnByName = new Map();
|
|
91
95
|
if (vulns.ok) {
|
|
92
|
-
for (const v of vulns.vulnerabilities) vulnByName.set(v.name, v);
|
|
96
|
+
for (const v of vulns.vulnerabilities) vulnByName.set(v.version ? `${v.name}@${v.version}` : v.name, v);
|
|
93
97
|
}
|
|
94
98
|
const licByKey = new Map();
|
|
95
99
|
if (licenses.ok) {
|
|
@@ -98,7 +102,7 @@ function buildModel(parsed, vulns, licenses, enrichment) {
|
|
|
98
102
|
|
|
99
103
|
const components = parsed.components.map((c) => {
|
|
100
104
|
const key = `${c.name}@${c.version}`;
|
|
101
|
-
const vuln = vulnByName.get(c.name) || null;
|
|
105
|
+
const vuln = vulnByName.get(key) || vulnByName.get(c.name) || null;
|
|
102
106
|
const lic = licByKey.get(key) || null;
|
|
103
107
|
const enr = enrichment.get(key) || {};
|
|
104
108
|
|
package/src/core/auditor.js
CHANGED
|
@@ -21,21 +21,28 @@ const { checkLicenses } = require('./license-checker');
|
|
|
21
21
|
* @param {object} policy
|
|
22
22
|
* @param {string|null} policySource
|
|
23
23
|
* @param {{ only?: 'vulnerabilities'|'sbom'|'licenses' }} [options]
|
|
24
|
-
* @returns {AuditResult}
|
|
24
|
+
* @returns {Promise<AuditResult>}
|
|
25
25
|
*/
|
|
26
|
-
function runAudit(projectRoot, policy, policySource, options = {}) {
|
|
26
|
+
async function runAudit(projectRoot, policy, policySource, options = {}) {
|
|
27
27
|
const { only } = options;
|
|
28
28
|
const sections = {};
|
|
29
29
|
const reasons = [];
|
|
30
30
|
|
|
31
31
|
// --- 1. Vulnerabilities -------------------------------------------------
|
|
32
32
|
if (!only || only === 'vulnerabilities') {
|
|
33
|
-
const vulns = scanVulnerabilities(projectRoot, {
|
|
33
|
+
const vulns = await scanVulnerabilities(projectRoot, {
|
|
34
|
+
production: policy.productionOnly,
|
|
35
|
+
source: policy.vulnerabilitySource,
|
|
36
|
+
});
|
|
34
37
|
sections.vulnerabilities = vulns;
|
|
35
38
|
|
|
36
39
|
if (!vulns.ok) {
|
|
37
40
|
reasons.push({ label: `Vulnerabilities could not be analyzed: ${vulns.error}`, passed: false });
|
|
38
41
|
} else {
|
|
42
|
+
for (const warning of vulns.warnings || []) {
|
|
43
|
+
reasons.push({ label: warning, passed: true, warning: true });
|
|
44
|
+
}
|
|
45
|
+
reasons.push(...exploitationReasons(vulns, policy));
|
|
39
46
|
const blocking = countBlocking(vulns, policy);
|
|
40
47
|
reasons.push({
|
|
41
48
|
label: blocking === 0
|
|
@@ -97,22 +104,68 @@ function runAudit(projectRoot, policy, policySource, options = {}) {
|
|
|
97
104
|
};
|
|
98
105
|
}
|
|
99
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Gate reasons for the OSV source: malicious packages always block (they are
|
|
109
|
+
* compromised releases, not bugs, so the allowlist cannot accept them), and
|
|
110
|
+
* actively exploited vulnerabilities (CISA KEV) block unless `failOnKev` is off.
|
|
111
|
+
*/
|
|
112
|
+
function exploitationReasons(vulns, policy) {
|
|
113
|
+
if (vulns.source !== 'osv') return [];
|
|
114
|
+
const reasons = [];
|
|
115
|
+
|
|
116
|
+
const malicious = vulns.vulnerabilities.filter((v) => v.malicious);
|
|
117
|
+
reasons.push({
|
|
118
|
+
label: malicious.length === 0
|
|
119
|
+
? 'No malicious packages (OpenSSF malicious-packages via OSV.dev)'
|
|
120
|
+
: `${malicious.length} malicious package(s): ${malicious.map((v) => `${v.name}@${v.version}`).join(', ')}`,
|
|
121
|
+
passed: malicious.length === 0,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (!vulns.kev || !vulns.kev.checked) {
|
|
125
|
+
reasons.push({
|
|
126
|
+
label: `Actively exploited vulnerabilities not checked: ${(vulns.kev && vulns.kev.error) || 'CISA KEV unavailable'}`,
|
|
127
|
+
passed: true,
|
|
128
|
+
warning: true,
|
|
129
|
+
});
|
|
130
|
+
return reasons;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const exploited = vulns.vulnerabilities.filter((v) => v.kev && !isAllowlisted(v, policy));
|
|
134
|
+
reasons.push({
|
|
135
|
+
label: exploited.length === 0
|
|
136
|
+
? 'No actively exploited vulnerabilities (CISA KEV)'
|
|
137
|
+
: `${exploited.length} component(s) with actively exploited vulnerabilities (CISA KEV) — CRA Art. 14 reporting may apply`,
|
|
138
|
+
passed: exploited.length === 0 || policy.failOnKev === false,
|
|
139
|
+
warning: exploited.length > 0 && policy.failOnKev === false,
|
|
140
|
+
});
|
|
141
|
+
return reasons;
|
|
142
|
+
}
|
|
143
|
+
|
|
100
144
|
function countBlocking(vulns, policy) {
|
|
101
145
|
const threshold = SEVERITY_ORDER.indexOf(policy.failOn);
|
|
102
146
|
if (threshold === -1) return 0;
|
|
103
|
-
const allowlist = new Set((policy.vulnerabilities && policy.vulnerabilities.allowlist) || []);
|
|
104
147
|
|
|
105
148
|
let count = 0;
|
|
106
149
|
for (const vuln of vulns.vulnerabilities) {
|
|
107
150
|
if (SEVERITY_ORDER.indexOf(vuln.severity) < threshold) continue;
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
) || allowlist.has(vuln.name);
|
|
111
|
-
if (!isAllowlisted) count++;
|
|
151
|
+
if (!vuln.malicious && isAllowlisted(vuln, policy)) continue;
|
|
152
|
+
count++;
|
|
112
153
|
}
|
|
113
154
|
return count;
|
|
114
155
|
}
|
|
115
156
|
|
|
157
|
+
/**
|
|
158
|
+
* A finding is accepted when the policy allowlist names the package, or every
|
|
159
|
+
* advisory on it by id/alias (GHSA, CVE) or advisory URL.
|
|
160
|
+
*/
|
|
161
|
+
function isAllowlisted(vuln, policy) {
|
|
162
|
+
const allowlist = (policy.vulnerabilities && policy.vulnerabilities.allowlist) || [];
|
|
163
|
+
if (!allowlist.length) return false;
|
|
164
|
+
if (allowlist.includes(vuln.name)) return true;
|
|
165
|
+
return vuln.sources.every((src) => allowlist.some((id) =>
|
|
166
|
+
[src.id, ...(src.aliases || [])].includes(id) || (src.url && src.url.includes(id))));
|
|
167
|
+
}
|
|
168
|
+
|
|
116
169
|
function getProject(projectRoot, sections) {
|
|
117
170
|
// Derive project identity from any section that parsed the lockfile/root.
|
|
118
171
|
if (sections.sbom && sections.sbom.ok && sections.sbom.document) {
|
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 };
|
package/src/core/osv.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { fetchJson, mapLimit } = require('../utils/http');
|
|
4
|
+
|
|
5
|
+
const OSV_API = 'https://api.osv.dev/v1';
|
|
6
|
+
const BATCH_SIZE = 1000; // OSV querybatch limit
|
|
7
|
+
|
|
8
|
+
/**
|
|
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.
|
|
13
|
+
*
|
|
14
|
+
* Only package names and versions leave the machine.
|
|
15
|
+
*
|
|
16
|
+
* @param {Array<{ name: string, version: string }>} components
|
|
17
|
+
* @param {{ timeout?: number, concurrency?: number }} [options]
|
|
18
|
+
* @returns {Promise<{ ok: true, byComponent: Map<string, object[]> } | { ok: false, error: string }>}
|
|
19
|
+
* `byComponent` is keyed by `name@version` and holds full OSV records.
|
|
20
|
+
*/
|
|
21
|
+
async function queryOsv(components, { timeout = 30000, concurrency = 8 } = {}) {
|
|
22
|
+
const idsByKey = new Map();
|
|
23
|
+
|
|
24
|
+
for (let i = 0; i < components.length; i += BATCH_SIZE) {
|
|
25
|
+
const chunk = components.slice(i, i + BATCH_SIZE);
|
|
26
|
+
const res = await fetchJson(`${OSV_API}/querybatch`, {
|
|
27
|
+
timeout,
|
|
28
|
+
body: { queries: chunk.map((c) => ({ package: { name: c.name, ecosystem: 'npm' }, version: c.version })) },
|
|
29
|
+
});
|
|
30
|
+
if (!res || !res.ok || !res.json || !Array.isArray(res.json.results)) {
|
|
31
|
+
return { ok: false, error: `OSV.dev query failed${res ? ` (HTTP ${res.status})` : ' (network unreachable)'}` };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (let j = 0; j < chunk.length; j++) {
|
|
35
|
+
const result = res.json.results[j] || {};
|
|
36
|
+
const ids = (result.vulns || []).map((v) => v.id);
|
|
37
|
+
// A package with many advisories is paginated: follow the token.
|
|
38
|
+
let pageToken = result.next_page_token;
|
|
39
|
+
while (pageToken) {
|
|
40
|
+
const page = await fetchJson(`${OSV_API}/query`, {
|
|
41
|
+
timeout,
|
|
42
|
+
body: { package: { name: chunk[j].name, ecosystem: 'npm' }, version: chunk[j].version, page_token: pageToken },
|
|
43
|
+
});
|
|
44
|
+
if (!page || !page.ok || !page.json) break;
|
|
45
|
+
ids.push(...(page.json.vulns || []).map((v) => v.id));
|
|
46
|
+
pageToken = page.json.next_page_token;
|
|
47
|
+
}
|
|
48
|
+
if (ids.length) idsByKey.set(`${chunk[j].name}@${chunk[j].version}`, ids);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// querybatch only returns ids: fetch each distinct record once.
|
|
53
|
+
const uniqueIds = [...new Set([].concat(...idsByKey.values()))];
|
|
54
|
+
const records = new Map();
|
|
55
|
+
let failed = 0;
|
|
56
|
+
await mapLimit(uniqueIds, async (id) => {
|
|
57
|
+
const res = await fetchJson(`${OSV_API}/vulns/${encodeURIComponent(id)}`, { timeout });
|
|
58
|
+
if (res && res.ok && res.json) records.set(id, res.json);
|
|
59
|
+
else failed++;
|
|
60
|
+
}, concurrency);
|
|
61
|
+
if (failed) {
|
|
62
|
+
return { ok: false, error: `Could not download ${failed} advisory record(s) from OSV.dev` };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const byComponent = new Map();
|
|
66
|
+
for (const [key, ids] of idsByKey) {
|
|
67
|
+
const list = ids.map((id) => records.get(id)).filter((r) => r && !r.withdrawn);
|
|
68
|
+
// The same advisory can appear under two ids (e.g. GHSA and its CVE).
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
const unique = [];
|
|
71
|
+
for (const record of list) {
|
|
72
|
+
if (seen.has(record.id)) continue;
|
|
73
|
+
[record.id, ...(record.aliases || [])].forEach((a) => seen.add(a));
|
|
74
|
+
unique.push(record);
|
|
75
|
+
}
|
|
76
|
+
byComponent.set(key, unique);
|
|
77
|
+
}
|
|
78
|
+
return { ok: true, byComponent };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Severity of an OSV record in npm-audit terms. Malicious packages are always
|
|
83
|
+
* critical; GHSA records carry a reviewed rating; otherwise the CVSS v3 base
|
|
84
|
+
* score is computed from the vector.
|
|
85
|
+
*
|
|
86
|
+
* @returns {'low'|'moderate'|'high'|'critical'|'unknown'}
|
|
87
|
+
*/
|
|
88
|
+
function osvSeverity(record) {
|
|
89
|
+
if (isMalicious(record)) return 'critical';
|
|
90
|
+
|
|
91
|
+
const rated = record.database_specific && record.database_specific.severity;
|
|
92
|
+
if (typeof rated === 'string') {
|
|
93
|
+
const s = rated.toLowerCase();
|
|
94
|
+
if (s === 'medium') return 'moderate';
|
|
95
|
+
if (['low', 'moderate', 'high', 'critical'].includes(s)) return s;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const score = cvssScore(record);
|
|
99
|
+
if (score === null) return 'unknown';
|
|
100
|
+
if (score >= 9) return 'critical';
|
|
101
|
+
if (score >= 7) return 'high';
|
|
102
|
+
if (score >= 4) return 'moderate';
|
|
103
|
+
return 'low';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** CVSS v3 base score of the record, or null when it has no v3 vector. */
|
|
107
|
+
function cvssScore(record) {
|
|
108
|
+
const v3 = (record.severity || []).find((s) => s.type === 'CVSS_V3');
|
|
109
|
+
return v3 ? cvss3BaseScore(v3.score) : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** OpenSSF malicious-packages advisories use the MAL- prefix. */
|
|
113
|
+
function isMalicious(record) {
|
|
114
|
+
return typeof record.id === 'string' && record.id.startsWith('MAL-');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Lowest version that fixes the advisory for the installed version, taken from
|
|
119
|
+
* the OSV `affected[].ranges[].events` of this package.
|
|
120
|
+
*
|
|
121
|
+
* @returns {string|null}
|
|
122
|
+
*/
|
|
123
|
+
function fixedVersion(record, name, version) {
|
|
124
|
+
let best = null;
|
|
125
|
+
for (const affected of record.affected || []) {
|
|
126
|
+
if (!affected.package || affected.package.name !== name) continue;
|
|
127
|
+
for (const range of affected.ranges || []) {
|
|
128
|
+
if (range.type !== 'SEMVER' && range.type !== 'ECOSYSTEM') continue;
|
|
129
|
+
let introduced = null;
|
|
130
|
+
for (const event of range.events || []) {
|
|
131
|
+
if (event.introduced !== undefined) introduced = event.introduced;
|
|
132
|
+
if (event.fixed === undefined) continue;
|
|
133
|
+
const inRange = (introduced === '0' || introduced === null || compareSemver(version, introduced) >= 0) &&
|
|
134
|
+
compareSemver(version, event.fixed) < 0;
|
|
135
|
+
if (inRange && (!best || compareSemver(event.fixed, best) < 0)) best = event.fixed;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return best;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Compares two semver strings (major.minor.patch[-prerelease]).
|
|
144
|
+
* Build metadata is ignored.
|
|
145
|
+
*/
|
|
146
|
+
function compareSemver(a, b) {
|
|
147
|
+
const parse = (v) => {
|
|
148
|
+
const [core, pre] = String(v).split('+')[0].split(/-(.*)/s);
|
|
149
|
+
return { nums: core.split('.').map((n) => parseInt(n, 10) || 0), pre: pre ? pre.split('.') : [] };
|
|
150
|
+
};
|
|
151
|
+
const pa = parse(a);
|
|
152
|
+
const pb = parse(b);
|
|
153
|
+
for (let i = 0; i < 3; i++) {
|
|
154
|
+
const diff = (pa.nums[i] || 0) - (pb.nums[i] || 0);
|
|
155
|
+
if (diff) return diff;
|
|
156
|
+
}
|
|
157
|
+
// A version without prerelease is greater than one with it.
|
|
158
|
+
if (!pa.pre.length || !pb.pre.length) return pb.pre.length - pa.pre.length;
|
|
159
|
+
for (let i = 0; i < Math.max(pa.pre.length, pb.pre.length); i++) {
|
|
160
|
+
const x = pa.pre[i];
|
|
161
|
+
const y = pb.pre[i];
|
|
162
|
+
if (x === undefined) return -1;
|
|
163
|
+
if (y === undefined) return 1;
|
|
164
|
+
const nx = /^\d+$/.test(x);
|
|
165
|
+
const ny = /^\d+$/.test(y);
|
|
166
|
+
if (nx && ny && Number(x) !== Number(y)) return Number(x) - Number(y);
|
|
167
|
+
if (nx !== ny) return nx ? -1 : 1;
|
|
168
|
+
if (x !== y) return x < y ? -1 : 1;
|
|
169
|
+
}
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const CVSS3_WEIGHTS = {
|
|
174
|
+
AV: { N: 0.85, A: 0.62, L: 0.55, P: 0.2 },
|
|
175
|
+
AC: { L: 0.77, H: 0.44 },
|
|
176
|
+
UI: { N: 0.85, R: 0.62 },
|
|
177
|
+
C: { H: 0.56, L: 0.22, N: 0 },
|
|
178
|
+
I: { H: 0.56, L: 0.22, N: 0 },
|
|
179
|
+
A: { H: 0.56, L: 0.22, N: 0 },
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/** CVSS v3.0/v3.1 base score from a vector string (FIRST specification). */
|
|
183
|
+
function cvss3BaseScore(vector) {
|
|
184
|
+
const m = {};
|
|
185
|
+
for (const part of String(vector).split('/')) {
|
|
186
|
+
const [k, v] = part.split(':');
|
|
187
|
+
m[k] = v;
|
|
188
|
+
}
|
|
189
|
+
const scopeChanged = m.S === 'C';
|
|
190
|
+
const pr = { N: 0.85, L: scopeChanged ? 0.68 : 0.62, H: scopeChanged ? 0.5 : 0.27 }[m.PR];
|
|
191
|
+
const w = CVSS3_WEIGHTS;
|
|
192
|
+
const values = [w.AV[m.AV], w.AC[m.AC], pr, w.UI[m.UI], w.C[m.C], w.I[m.I], w.A[m.A]];
|
|
193
|
+
if (values.some((x) => x === undefined)) return null;
|
|
194
|
+
const [av, ac, , ui, c, i, a] = values;
|
|
195
|
+
|
|
196
|
+
const iss = 1 - (1 - c) * (1 - i) * (1 - a);
|
|
197
|
+
const impact = scopeChanged ? 7.52 * (iss - 0.029) - 3.25 * Math.pow(iss - 0.02, 15) : 6.42 * iss;
|
|
198
|
+
if (impact <= 0) return 0;
|
|
199
|
+
const exploitability = 8.22 * av * ac * pr * ui;
|
|
200
|
+
const raw = scopeChanged ? Math.min(1.08 * (impact + exploitability), 10) : Math.min(impact + exploitability, 10);
|
|
201
|
+
return roundUp(raw);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function roundUp(value) {
|
|
205
|
+
const int = Math.round(value * 100000);
|
|
206
|
+
return int % 10000 === 0 ? int / 100000 : (Math.floor(int / 10000) + 1) / 10;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
module.exports = {
|
|
210
|
+
queryOsv, osvSeverity, cvssScore, isMalicious, fixedVersion, compareSemver, cvss3BaseScore,
|
|
211
|
+
};
|
package/src/core/policy.js
CHANGED
|
@@ -18,6 +18,11 @@ const DEFAULT_POLICY = {
|
|
|
18
18
|
// Email or URL of the SBOM creator (TR-03183-2 §5.2.1). Defaults to the
|
|
19
19
|
// project's package.json author, homepage or repository.
|
|
20
20
|
sbomCreator: null,
|
|
21
|
+
// Vulnerability source: `osv` (OSV.dev + CISA KEV) or `npm` (npm audit).
|
|
22
|
+
vulnerabilitySource: 'osv',
|
|
23
|
+
// Fail when a dependency has an actively exploited vulnerability (CISA KEV),
|
|
24
|
+
// the trigger of the CRA Art. 14 reporting obligation.
|
|
25
|
+
failOnKev: true,
|
|
21
26
|
// Audit only production dependencies (recommended for shipped products).
|
|
22
27
|
productionOnly: false,
|
|
23
28
|
vulnerabilities: {
|
package/src/core/vuln-scanner.js
CHANGED
|
@@ -4,24 +4,174 @@ const fs = require('node:fs');
|
|
|
4
4
|
const os = require('node:os');
|
|
5
5
|
const path = require('node:path');
|
|
6
6
|
const { runJson } = require('../utils/exec');
|
|
7
|
-
const { exists } = require('../utils/fs');
|
|
8
|
-
const { parseLockfile, toNpmLockfile } = require('./lockfile-parser');
|
|
7
|
+
const { exists, readJson } = require('../utils/fs');
|
|
8
|
+
const { parseLockfile, toNpmLockfile, componentKey } = require('./lockfile-parser');
|
|
9
|
+
const {
|
|
10
|
+
queryOsv, osvSeverity, cvssScore, isMalicious, fixedVersion, compareSemver,
|
|
11
|
+
} = require('./osv');
|
|
12
|
+
const { loadKev } = require('./kev');
|
|
9
13
|
|
|
10
14
|
const SEVERITY_ORDER = ['info', 'low', 'moderate', 'high', 'critical'];
|
|
11
15
|
|
|
12
16
|
/**
|
|
13
|
-
* Scans direct and transitive dependencies for known vulnerabilities
|
|
14
|
-
*
|
|
15
|
-
*
|
|
17
|
+
* Scans direct and transitive dependencies for known vulnerabilities. The CRA
|
|
18
|
+
* requires products to ship "without known exploitable vulnerabilities"
|
|
19
|
+
* (Annex I) and, since 11 September 2026, the reporting of actively exploited
|
|
20
|
+
* ones (Art. 14), so this is the core gate of the audit.
|
|
16
21
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
22
|
+
* Sources:
|
|
23
|
+
* - `osv` (default): every exact `name@version` from the lockfile is checked
|
|
24
|
+
* against OSV.dev (GitHub advisories + OpenSSF malicious packages), and the
|
|
25
|
+
* CVEs found are cross-checked with the CISA KEV catalogue.
|
|
26
|
+
* - `npm`: `npm audit`. Also the fallback when OSV.dev is unreachable.
|
|
20
27
|
*
|
|
21
28
|
* @param {string} projectRoot
|
|
22
|
-
* @param {{ production?: boolean }} [options]
|
|
29
|
+
* @param {{ production?: boolean, source?: 'osv'|'npm', kev?: boolean }} [options]
|
|
30
|
+
* @returns {Promise<object>}
|
|
23
31
|
*/
|
|
24
|
-
function scanVulnerabilities(projectRoot, { production = false } = {}) {
|
|
32
|
+
async function scanVulnerabilities(projectRoot, { production = false, source = 'osv', kev = true } = {}) {
|
|
33
|
+
if (source === 'npm') return npmAuditSection(projectRoot, { production });
|
|
34
|
+
|
|
35
|
+
const parsed = parseLockfile(projectRoot);
|
|
36
|
+
if (!parsed.ok) {
|
|
37
|
+
return { ok: false, error: parsed.error, counts: emptyCounts(), vulnerabilities: [] };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const rootPkg = readJson(path.join(projectRoot, 'package.json')) || {};
|
|
41
|
+
const components = production ? productionComponents(parsed, rootPkg) : parsed.components;
|
|
42
|
+
const scannable = components.filter((c) => c.name && c.version);
|
|
43
|
+
|
|
44
|
+
const [osv, catalogue] = await Promise.all([
|
|
45
|
+
queryOsv(scannable),
|
|
46
|
+
kev ? loadKev() : Promise.resolve(null),
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
if (!osv.ok) {
|
|
50
|
+
const fallback = npmAuditSection(projectRoot, { production });
|
|
51
|
+
fallback.warnings = [`${osv.error}; fell back to \`npm audit\` (no malicious-package or KEV check).`];
|
|
52
|
+
return fallback;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const direct = new Set(parsed.root.dependsOn);
|
|
56
|
+
const vulnerabilities = [];
|
|
57
|
+
for (const c of scannable) {
|
|
58
|
+
const key = componentKey(c.name, c.version);
|
|
59
|
+
const records = osv.byComponent.get(key);
|
|
60
|
+
if (!records || !records.length) continue;
|
|
61
|
+
vulnerabilities.push(toFinding(c, records, direct.has(key), catalogue));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const counts = emptyCounts();
|
|
65
|
+
for (const v of vulnerabilities) {
|
|
66
|
+
counts[v.severity] = (counts[v.severity] || 0) + 1;
|
|
67
|
+
if (v.malicious) counts.malicious++;
|
|
68
|
+
if (v.kev) counts.kev++;
|
|
69
|
+
}
|
|
70
|
+
counts.total = vulnerabilities.length;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
ok: true,
|
|
74
|
+
source: 'osv',
|
|
75
|
+
scanned: scannable.length,
|
|
76
|
+
kev: catalogue
|
|
77
|
+
? (catalogue.ok
|
|
78
|
+
? { checked: true, catalogVersion: catalogue.catalogVersion, entries: catalogue.count }
|
|
79
|
+
: { checked: false, error: catalogue.error })
|
|
80
|
+
: { checked: false, error: 'KEV check disabled' },
|
|
81
|
+
counts,
|
|
82
|
+
vulnerabilities: sortBySeverity(vulnerabilities),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** One finding per vulnerable component, aggregating its OSV records. */
|
|
87
|
+
function toFinding(component, records, isDirect, catalogue) {
|
|
88
|
+
const sources = records.map((record) => {
|
|
89
|
+
const aliases = record.aliases || [];
|
|
90
|
+
const cves = [record.id, ...aliases].filter((id) => /^CVE-/i.test(id));
|
|
91
|
+
const kevEntry = catalogue && catalogue.ok
|
|
92
|
+
? cves.map((cve) => catalogue.byCve.get(cve.toUpperCase())).find(Boolean) || null
|
|
93
|
+
: null;
|
|
94
|
+
return {
|
|
95
|
+
id: record.id,
|
|
96
|
+
aliases,
|
|
97
|
+
title: record.summary || (isMalicious(record) ? 'Malicious package' : record.id),
|
|
98
|
+
url: `https://osv.dev/vulnerability/${record.id}`,
|
|
99
|
+
severity: osvSeverity(record),
|
|
100
|
+
cvss: cvssScore(record),
|
|
101
|
+
cwe: (record.database_specific && record.database_specific.cwe_ids) || [],
|
|
102
|
+
malicious: isMalicious(record),
|
|
103
|
+
fixed: fixedVersion(record, component.name, component.version),
|
|
104
|
+
kev: kevEntry,
|
|
105
|
+
};
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const severity = sources.map((s) => s.severity)
|
|
109
|
+
.reduce((max, s) => (severityRank(s) > severityRank(max) ? s : max), 'unknown');
|
|
110
|
+
const fixes = sources.map((s) => s.fixed);
|
|
111
|
+
// Only claim a fix when every advisory has one; suggest the highest of them.
|
|
112
|
+
const fix = fixes.every(Boolean)
|
|
113
|
+
? fixes.reduce((a, b) => (compareSemver(a, b) >= 0 ? a : b))
|
|
114
|
+
: null;
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
name: component.name,
|
|
118
|
+
version: component.version,
|
|
119
|
+
severity,
|
|
120
|
+
direct: isDirect,
|
|
121
|
+
range: null,
|
|
122
|
+
malicious: sources.some((s) => s.malicious),
|
|
123
|
+
kev: sources.some((s) => s.kev),
|
|
124
|
+
fixAvailable: fix
|
|
125
|
+
? { name: component.name, version: fix, breaking: major(fix) !== major(component.version) }
|
|
126
|
+
: false,
|
|
127
|
+
// Malicious and actively exploited advisories first, then by severity.
|
|
128
|
+
sources: sources.sort((a, b) => Number(b.malicious) - Number(a.malicious) ||
|
|
129
|
+
Number(Boolean(b.kev)) - Number(Boolean(a.kev)) ||
|
|
130
|
+
severityRank(b.severity) - severityRank(a.severity)),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Components reachable from the production dependencies declared in
|
|
136
|
+
* package.json (`dependencies` + `optionalDependencies`), following the graph.
|
|
137
|
+
*/
|
|
138
|
+
function productionComponents(parsed, rootPkg) {
|
|
139
|
+
const prodNames = new Set([
|
|
140
|
+
...Object.keys(rootPkg.dependencies || {}),
|
|
141
|
+
...Object.keys(rootPkg.optionalDependencies || {}),
|
|
142
|
+
]);
|
|
143
|
+
const byKey = new Map(parsed.components.map((c) => [componentKey(c.name, c.version), c]));
|
|
144
|
+
const queue = parsed.root.dependsOn.filter((key) => prodNames.has(byKey.has(key) ? byKey.get(key).name : null));
|
|
145
|
+
const seen = new Set(queue);
|
|
146
|
+
while (queue.length) {
|
|
147
|
+
const comp = byKey.get(queue.shift());
|
|
148
|
+
if (!comp) continue;
|
|
149
|
+
for (const dep of comp.dependsOn) {
|
|
150
|
+
if (!seen.has(dep)) { seen.add(dep); queue.push(dep); }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return parsed.components.filter((c) => seen.has(componentKey(c.name, c.version)));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function severityRank(severity) {
|
|
157
|
+
return SEVERITY_ORDER.indexOf(severity);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function major(version) {
|
|
161
|
+
return parseInt(String(version).split('.')[0], 10);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** `npm audit` source, used on request or as the OSV fallback. */
|
|
165
|
+
function npmAuditSection(projectRoot, { production }) {
|
|
166
|
+
const result = npmAudit(projectRoot, { production });
|
|
167
|
+
return {
|
|
168
|
+
...result,
|
|
169
|
+
source: 'npm-audit',
|
|
170
|
+
kev: { checked: false, error: 'npm audit reports no CVE identifiers' },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function npmAudit(projectRoot, { production = false } = {}) {
|
|
25
175
|
if (hasNpmLockfile(projectRoot)) {
|
|
26
176
|
return runNpmAudit(projectRoot, { production });
|
|
27
177
|
}
|
|
@@ -187,7 +337,7 @@ function normalizeFix(fixAvailable) {
|
|
|
187
337
|
}
|
|
188
338
|
|
|
189
339
|
function emptyCounts() {
|
|
190
|
-
return { info: 0, low: 0, moderate: 0, high: 0, critical: 0, total: 0 };
|
|
340
|
+
return { info: 0, low: 0, moderate: 0, high: 0, critical: 0, unknown: 0, malicious: 0, kev: 0, total: 0 };
|
|
191
341
|
}
|
|
192
342
|
|
|
193
343
|
function sumCounts(counts) {
|
package/src/index.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Example:
|
|
7
7
|
* const { runAudit, loadPolicy } = require('cra-audit');
|
|
8
8
|
* const { policy, source } = loadPolicy(process.cwd());
|
|
9
|
-
* const report = runAudit(process.cwd(), policy, source);
|
|
9
|
+
* const report = await runAudit(process.cwd(), policy, source);
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
const { runAudit } = require('./core/auditor');
|
package/src/reporters/console.js
CHANGED
|
@@ -30,36 +30,78 @@ function reportConsole(report) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
function renderVulnerabilities(section) {
|
|
33
|
-
logger.heading('1) Known vulnerabilities (CRA Art.
|
|
33
|
+
logger.heading('1) Known vulnerabilities (CRA Annex I · Art. 14)');
|
|
34
34
|
if (!section.ok) {
|
|
35
35
|
logger.error(section.error);
|
|
36
36
|
return;
|
|
37
37
|
}
|
|
38
|
+
logger.detail(describeSources(section));
|
|
39
|
+
for (const warning of section.warnings || []) logger.warn(warning);
|
|
38
40
|
|
|
39
41
|
const c = section.counts;
|
|
40
42
|
if (c.total === 0) {
|
|
41
|
-
logger.success('No known vulnerabilities. Meets the "
|
|
43
|
+
logger.success('No known vulnerabilities. Meets the "no known exploitable vulnerabilities" requirement.');
|
|
42
44
|
return;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
logger.log(
|
|
46
48
|
` ${color.red(`${c.critical} critical`)} · ${color.red(`${c.high} high`)} · ` +
|
|
47
|
-
`${color.yellow(`${c.moderate} moderate`)} · ${color.yellow(`${c.low} low`)} · ${color.gray(`${c.info} info`)}`
|
|
49
|
+
`${color.yellow(`${c.moderate} moderate`)} · ${color.yellow(`${c.low} low`)} · ${color.gray(`${c.info} info`)}` +
|
|
50
|
+
(c.unknown ? color.gray(` · ${c.unknown} unrated`) : '') +
|
|
51
|
+
(c.kev ? ` · ${color.red(color.bold(`${c.kev} actively exploited (KEV)`))}` : '') +
|
|
52
|
+
(c.malicious ? ` · ${color.red(color.bold(`${c.malicious} MALICIOUS`))}` : '')
|
|
48
53
|
);
|
|
49
54
|
|
|
50
|
-
|
|
55
|
+
// Malicious and exploited findings first: they are the urgent ones.
|
|
56
|
+
const urgency = (v) => (v.malicious ? 2 : v.kev ? 1 : 0);
|
|
57
|
+
const ordered = [...section.vulnerabilities].sort((a, b) => urgency(b) - urgency(a));
|
|
58
|
+
const top = ordered.slice(0, 15);
|
|
51
59
|
for (const vuln of top) {
|
|
52
60
|
const sev = (SEVERITY_COLORS[vuln.severity] || color.gray)(vuln.severity.toUpperCase().padEnd(8));
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
61
|
+
const tags = (vuln.malicious ? color.red(color.bold('[MALICIOUS] ')) : '') + (vuln.kev ? color.red(color.bold('[KEV] ')) : '');
|
|
62
|
+
const label = vuln.version ? `${vuln.name}@${vuln.version}` : vuln.name;
|
|
63
|
+
const fix = vuln.malicious ? color.red('(remove it and rotate any exposed credentials)') : describeFix(vuln.fixAvailable);
|
|
64
|
+
logger.log(` ${sev} ${tags}${color.bold(label)} ${color.gray(vuln.range || '')} ${fix}`);
|
|
65
|
+
for (const adv of vuln.sources.slice(0, 3)) {
|
|
66
|
+
const kev = adv.kev ? color.red(` [KEV since ${adv.kev.dateAdded}]`) : '';
|
|
67
|
+
const id = adv.id ? `${adv.id}${(adv.aliases || []).filter((a) => a.startsWith('CVE-')).map((a) => ` / ${a}`).join('')} ` : '';
|
|
68
|
+
logger.detail(`${id}${adv.title || ''}${kev}${adv.url ? ' — ' + adv.url : ''}`);
|
|
69
|
+
}
|
|
70
|
+
if (vuln.sources.length > 3) logger.detail(`… and ${vuln.sources.length - 3} more advisories.`);
|
|
71
|
+
}
|
|
72
|
+
if (ordered.length > top.length) {
|
|
73
|
+
logger.detail(`… and ${ordered.length - top.length} more.`);
|
|
57
74
|
}
|
|
58
|
-
|
|
59
|
-
|
|
75
|
+
|
|
76
|
+
if (c.kev) renderArticle14Notice();
|
|
77
|
+
if (c.malicious) {
|
|
78
|
+
logger.log('');
|
|
79
|
+
logger.error(color.bold('Malicious package(s) detected: treat this as a security incident.'));
|
|
80
|
+
logger.detail('Remove the package, reinstall from a clean lockfile, and rotate every credential');
|
|
81
|
+
logger.detail('available to machines that installed it (npm/GitHub tokens, cloud keys, CI secrets).');
|
|
60
82
|
}
|
|
61
83
|
}
|
|
62
84
|
|
|
85
|
+
function describeSources(section) {
|
|
86
|
+
if (section.source !== 'osv') return 'Source: npm audit';
|
|
87
|
+
const kev = section.kev && section.kev.checked
|
|
88
|
+
? `CISA KEV ${section.kev.catalogVersion || ''} (${section.kev.entries} entries)`.replace(' ', ' ')
|
|
89
|
+
: `CISA KEV not checked (${(section.kev && section.kev.error) || 'unavailable'})`;
|
|
90
|
+
return `Sources: OSV.dev (GitHub advisories + OpenSSF malicious packages) · ${kev} · ${section.scanned} components`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** CRA Art. 14 reporting clock, shown when a KEV-listed vulnerability is found. */
|
|
94
|
+
function renderArticle14Notice() {
|
|
95
|
+
logger.log('');
|
|
96
|
+
logger.warn(color.bold('CRA Art. 14 — actively exploited vulnerability in a dependency'));
|
|
97
|
+
logger.detail('If it affects a product with digital elements you place on the EU market, notify');
|
|
98
|
+
logger.detail('the coordinating CSIRT and ENISA through the Single Reporting Platform:');
|
|
99
|
+
logger.detail(' • Early warning ........ within 24 hours of becoming aware');
|
|
100
|
+
logger.detail(' • Notification ......... within 72 hours');
|
|
101
|
+
logger.detail(' • Final report ......... within 14 days after a corrective measure is available');
|
|
102
|
+
logger.detail('Assess exploitability in your product first; document the decision either way.');
|
|
103
|
+
}
|
|
104
|
+
|
|
63
105
|
function describeFix(fix) {
|
|
64
106
|
if (fix === true) return color.green('(fix available)');
|
|
65
107
|
if (fix && typeof fix === 'object') {
|
|
@@ -122,7 +164,7 @@ function renderLicenses(section) {
|
|
|
122
164
|
function renderSummary(report) {
|
|
123
165
|
logger.heading('Result');
|
|
124
166
|
for (const item of report.gate.reasons) {
|
|
125
|
-
const mark = item.passed ? color.
|
|
167
|
+
const mark = !item.passed ? color.red('✖') : item.warning ? color.yellow('!') : color.green('✔');
|
|
126
168
|
logger.log(` ${mark} ${item.label}`);
|
|
127
169
|
}
|
|
128
170
|
logger.log('');
|
package/src/reporters/json.js
CHANGED
|
@@ -11,7 +11,7 @@ const { writeJson } = require('../utils/fs');
|
|
|
11
11
|
function reportJson(report, { outputPath } = {}) {
|
|
12
12
|
if (outputPath) {
|
|
13
13
|
writeJson(outputPath, report);
|
|
14
|
-
logger.success(`
|
|
14
|
+
logger.success(`JSON report written to: ${outputPath}`);
|
|
15
15
|
} else {
|
|
16
16
|
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
17
17
|
}
|
package/src/utils/http.js
CHANGED
|
@@ -8,21 +8,28 @@
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* @param {string} url
|
|
11
|
-
* @param {{ timeout?: number, headers?: object }} [options]
|
|
11
|
+
* @param {{ timeout?: number, headers?: object, body?: any }} [options]
|
|
12
|
+
* `body`: sent as a JSON POST when present.
|
|
12
13
|
* @returns {Promise<{ ok: boolean, status: number, json: any, headers: Headers }|null>}
|
|
13
14
|
*/
|
|
14
|
-
async function fetchJson(url, { timeout = 8000, headers = {} } = {}) {
|
|
15
|
+
async function fetchJson(url, { timeout = 8000, headers = {}, body } = {}) {
|
|
15
16
|
if (typeof fetch !== 'function') return null;
|
|
16
17
|
|
|
17
18
|
const controller = new AbortController();
|
|
18
19
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
19
20
|
|
|
20
21
|
try {
|
|
21
|
-
const
|
|
22
|
+
const init = {
|
|
22
23
|
headers: { Accept: 'application/json', ...headers },
|
|
23
24
|
signal: controller.signal,
|
|
24
25
|
redirect: 'follow',
|
|
25
|
-
}
|
|
26
|
+
};
|
|
27
|
+
if (body !== undefined) {
|
|
28
|
+
init.method = 'POST';
|
|
29
|
+
init.headers['Content-Type'] = 'application/json';
|
|
30
|
+
init.body = JSON.stringify(body);
|
|
31
|
+
}
|
|
32
|
+
const res = await fetch(url, init);
|
|
26
33
|
let json = null;
|
|
27
34
|
try {
|
|
28
35
|
json = await res.json();
|