cra-audit 0.1.0 → 1.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/README.md +4 -4
- package/package.json +2 -3
- package/src/cli.js +42 -42
- package/src/commands/audit.js +1 -1
- package/src/commands/sbom.js +8 -8
- package/src/commands/visualize.js +16 -16
- package/src/core/auditor.js +9 -9
- package/src/core/enrich.js +27 -1
- package/src/core/lockfile-parser.js +261 -20
- package/src/core/policy.js +1 -1
- package/src/core/sbom-generator.js +1 -1
- package/src/core/sbom-validator.js +33 -33
- package/src/core/vuln-scanner.js +62 -6
- package/src/reporters/console.js +24 -24
- package/src/utils/logger.js +19 -0
- package/src/utils/yaml.js +225 -0
package/src/reporters/console.js
CHANGED
|
@@ -16,10 +16,10 @@ const SEVERITY_COLORS = {
|
|
|
16
16
|
* @param {import('../core/auditor').AuditResult} report
|
|
17
17
|
*/
|
|
18
18
|
function reportConsole(report) {
|
|
19
|
-
logger.heading(' CRA Audit — Cyber Resilience Act (
|
|
20
|
-
logger.detail(`
|
|
19
|
+
logger.heading(' CRA Audit — Cyber Resilience Act (EU 2024/2847) · BSI TR-03183');
|
|
20
|
+
logger.detail(`Project: ${report.project.name}@${report.project.version}`);
|
|
21
21
|
if (report.policySource) {
|
|
22
|
-
logger.detail(`
|
|
22
|
+
logger.detail(`Policy: ${report.policySource}`);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
if (report.sections.vulnerabilities) renderVulnerabilities(report.sections.vulnerabilities);
|
|
@@ -30,7 +30,7 @@ function reportConsole(report) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
function renderVulnerabilities(section) {
|
|
33
|
-
logger.heading('1)
|
|
33
|
+
logger.heading('1) Known vulnerabilities (CRA Art. 13)');
|
|
34
34
|
if (!section.ok) {
|
|
35
35
|
logger.error(section.error);
|
|
36
36
|
return;
|
|
@@ -38,13 +38,13 @@ function renderVulnerabilities(section) {
|
|
|
38
38
|
|
|
39
39
|
const c = section.counts;
|
|
40
40
|
if (c.total === 0) {
|
|
41
|
-
logger.success('
|
|
41
|
+
logger.success('No known vulnerabilities. Meets the "zero exploitable vulnerabilities" requirement.');
|
|
42
42
|
return;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
logger.log(
|
|
46
|
-
` ${color.red(`${c.critical}
|
|
47
|
-
`${color.yellow(`${c.moderate}
|
|
46
|
+
` ${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`)}`
|
|
48
48
|
);
|
|
49
49
|
|
|
50
50
|
const top = section.vulnerabilities.slice(0, 15);
|
|
@@ -56,25 +56,25 @@ function renderVulnerabilities(section) {
|
|
|
56
56
|
if (adv && adv.title) logger.detail(`${adv.title}${adv.url ? ' — ' + adv.url : ''}`);
|
|
57
57
|
}
|
|
58
58
|
if (section.vulnerabilities.length > top.length) {
|
|
59
|
-
logger.detail(`…
|
|
59
|
+
logger.detail(`… and ${section.vulnerabilities.length - top.length} more.`);
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
function describeFix(fix) {
|
|
64
|
-
if (fix === true) return color.green('(fix
|
|
64
|
+
if (fix === true) return color.green('(fix available)');
|
|
65
65
|
if (fix && typeof fix === 'object') {
|
|
66
66
|
return color.green(`(fix: ${fix.name}@${fix.version}${fix.breaking ? ', breaking' : ''})`);
|
|
67
67
|
}
|
|
68
|
-
return color.gray('(
|
|
68
|
+
return color.gray('(no fix)');
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
function renderSbom(section) {
|
|
72
|
-
logger.heading('2)
|
|
72
|
+
logger.heading('2) Software Bill of Materials · SBOM (CRA Annex I)');
|
|
73
73
|
if (!section.ok) {
|
|
74
74
|
logger.error(section.error);
|
|
75
75
|
return;
|
|
76
76
|
}
|
|
77
|
-
logger.detail(`
|
|
77
|
+
logger.detail(`Format: ${section.format} · Components: ${section.componentCount}`);
|
|
78
78
|
|
|
79
79
|
if (section.validation) {
|
|
80
80
|
const v = section.validation;
|
|
@@ -83,53 +83,53 @@ function renderSbom(section) {
|
|
|
83
83
|
logger.log(` ${mark} ${check.label}`);
|
|
84
84
|
}
|
|
85
85
|
if (v.valid) {
|
|
86
|
-
logger.success('
|
|
86
|
+
logger.success('The SBOM meets the TR-03183 §6 minimum elements.');
|
|
87
87
|
} else {
|
|
88
|
-
logger.warn(`
|
|
88
|
+
logger.warn(`The SBOM does not meet ${v.failedChecks.length} minimum requirement(s).`);
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
if (section.outputPath) {
|
|
92
|
-
logger.detail(`SBOM
|
|
92
|
+
logger.detail(`SBOM written to: ${section.outputPath}`);
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
function renderLicenses(section) {
|
|
97
|
-
logger.heading('3)
|
|
97
|
+
logger.heading('3) Third-party component licenses (TR-03183 §6)');
|
|
98
98
|
if (!section.ok) {
|
|
99
99
|
logger.error(section.error);
|
|
100
100
|
return;
|
|
101
101
|
}
|
|
102
102
|
const s = section.summary;
|
|
103
|
-
logger.detail(`
|
|
103
|
+
logger.detail(`Documented: ${s.documented}/${s.total}`);
|
|
104
104
|
|
|
105
105
|
if (s.missing.length) {
|
|
106
|
-
logger.warn(`${s.missing.length}
|
|
106
|
+
logger.warn(`${s.missing.length} component(s) without a documented license:`);
|
|
107
107
|
for (const c of s.missing.slice(0, 10)) logger.log(` ${color.gray('•')} ${c.name}@${c.version}`);
|
|
108
108
|
}
|
|
109
109
|
if (s.denied.length) {
|
|
110
|
-
logger.error(`${s.denied.length}
|
|
110
|
+
logger.error(`${s.denied.length} component(s) with a license denied by the policy:`);
|
|
111
111
|
for (const c of s.denied) logger.log(` ${color.red('•')} ${c.name}@${c.version} (${c.license})`);
|
|
112
112
|
}
|
|
113
113
|
if (s.notAllowed.length) {
|
|
114
|
-
logger.warn(`${s.notAllowed.length}
|
|
114
|
+
logger.warn(`${s.notAllowed.length} component(s) with a license outside the allowlist:`);
|
|
115
115
|
for (const c of s.notAllowed.slice(0, 10)) logger.log(` ${color.yellow('•')} ${c.name}@${c.version} (${c.license})`);
|
|
116
116
|
}
|
|
117
117
|
if (!s.missing.length && !s.denied.length && !s.notAllowed.length) {
|
|
118
|
-
logger.success('
|
|
118
|
+
logger.success('All licenses are documented and allowed.');
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
function renderSummary(report) {
|
|
123
|
-
logger.heading('
|
|
123
|
+
logger.heading('Result');
|
|
124
124
|
for (const item of report.gate.reasons) {
|
|
125
125
|
const mark = item.passed ? color.green('✔') : color.red('✖');
|
|
126
126
|
logger.log(` ${mark} ${item.label}`);
|
|
127
127
|
}
|
|
128
128
|
logger.log('');
|
|
129
129
|
if (report.gate.passed) {
|
|
130
|
-
logger.success(color.bold('
|
|
130
|
+
logger.success(color.bold('AUDIT PASSED — the project meets the configured CRA policy.'));
|
|
131
131
|
} else {
|
|
132
|
-
logger.error(color.bold('
|
|
132
|
+
logger.error(color.bold('AUDIT FAILED — CRA compliance issues were detected.'));
|
|
133
133
|
}
|
|
134
134
|
logger.log('');
|
|
135
135
|
}
|
package/src/utils/logger.js
CHANGED
|
@@ -80,6 +80,25 @@ const logger = {
|
|
|
80
80
|
detail(msg) {
|
|
81
81
|
this.log(` ${color.gray(msg)}`);
|
|
82
82
|
},
|
|
83
|
+
/**
|
|
84
|
+
* Renders an in-place progress bar with a percentage and a done/total count.
|
|
85
|
+
* On a TTY the same line is rewritten on each call; a trailing newline is
|
|
86
|
+
* emitted once `done >= total`. On non-TTY streams nothing is written to
|
|
87
|
+
* avoid flooding logs (CI pipelines), so callers should log a final summary.
|
|
88
|
+
*/
|
|
89
|
+
progress(label, done, total) {
|
|
90
|
+
if (!process.stdout.isTTY) return;
|
|
91
|
+
const ratio = total > 0 ? Math.min(1, done / total) : 1;
|
|
92
|
+
const pct = Math.floor(ratio * 100);
|
|
93
|
+
const width = 24;
|
|
94
|
+
const filled = Math.round(ratio * width);
|
|
95
|
+
const bar = '█'.repeat(filled) + '░'.repeat(width - filled);
|
|
96
|
+
const line =
|
|
97
|
+
` ${color.gray(label)} ${color.cyan(`[${bar}]`)} ` +
|
|
98
|
+
`${String(pct).padStart(3)}% ${color.gray(`(${done}/${total})`)}`;
|
|
99
|
+
process.stdout.write(`\r\x1b[K${line}`);
|
|
100
|
+
if (done >= total) process.stdout.write('\n');
|
|
101
|
+
},
|
|
83
102
|
};
|
|
84
103
|
|
|
85
104
|
module.exports = { logger, color, SYMBOLS };
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Minimal YAML parser covering the subset used by `pnpm-lock.yaml` and
|
|
5
|
+
* Yarn Berry (v2+) lockfiles: indentation-based maps and sequences, inline
|
|
6
|
+
* flow collections (`{ ... }` and `[ ... ]`) and single/double quoted scalars.
|
|
7
|
+
*
|
|
8
|
+
* It intentionally does NOT support anchors, aliases, block scalars (`|` / `>`),
|
|
9
|
+
* multi-document streams or complex keys. cra-audit ships with zero runtime
|
|
10
|
+
* dependencies, so this keeps us from pulling in a full YAML library just to
|
|
11
|
+
* read a lockfile.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} text
|
|
14
|
+
* @returns {object|Array|null}
|
|
15
|
+
*/
|
|
16
|
+
function parseYaml(text) {
|
|
17
|
+
const lines = [];
|
|
18
|
+
for (const raw of String(text).split(/\r?\n/)) {
|
|
19
|
+
const withoutComment = stripComment(raw);
|
|
20
|
+
if (withoutComment.trim() === '') continue;
|
|
21
|
+
const trimmedLeft = withoutComment.replace(/^ +/, '');
|
|
22
|
+
const indent = withoutComment.length - trimmedLeft.length;
|
|
23
|
+
lines.push({ indent, content: trimmedLeft.replace(/\s+$/, '') });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const state = { lines, pos: 0 };
|
|
27
|
+
if (state.lines.length === 0) return {};
|
|
28
|
+
return parseNode(state, state.lines[0].indent);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Removes an unquoted `#` comment, preserving `#` inside quoted scalars. */
|
|
32
|
+
function stripComment(line) {
|
|
33
|
+
let inSingle = false;
|
|
34
|
+
let inDouble = false;
|
|
35
|
+
for (let i = 0; i < line.length; i++) {
|
|
36
|
+
const ch = line[i];
|
|
37
|
+
if (ch === "'" && !inDouble) inSingle = !inSingle;
|
|
38
|
+
else if (ch === '"' && !inSingle) inDouble = !inDouble;
|
|
39
|
+
else if (ch === '#' && !inSingle && !inDouble && (i === 0 || /\s/.test(line[i - 1]))) {
|
|
40
|
+
return line.slice(0, i);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return line;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseNode(state, indent) {
|
|
47
|
+
const line = state.lines[state.pos];
|
|
48
|
+
if (line.content === '-' || line.content.startsWith('- ')) {
|
|
49
|
+
return parseSequence(state, indent);
|
|
50
|
+
}
|
|
51
|
+
return parseMap(state, indent);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseMap(state, indent) {
|
|
55
|
+
const map = {};
|
|
56
|
+
while (state.pos < state.lines.length) {
|
|
57
|
+
const line = state.lines[state.pos];
|
|
58
|
+
if (line.indent < indent) break;
|
|
59
|
+
if (line.indent > indent) { state.pos++; continue; }
|
|
60
|
+
|
|
61
|
+
const { key, rest } = splitKey(line.content);
|
|
62
|
+
if (key === null) { state.pos++; continue; }
|
|
63
|
+
state.pos++;
|
|
64
|
+
|
|
65
|
+
if (rest !== '') {
|
|
66
|
+
map[key] = parseScalarOrFlow(rest);
|
|
67
|
+
} else if (state.pos < state.lines.length && state.lines[state.pos].indent > indent) {
|
|
68
|
+
map[key] = parseNode(state, state.lines[state.pos].indent);
|
|
69
|
+
} else {
|
|
70
|
+
map[key] = null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return map;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseSequence(state, indent) {
|
|
77
|
+
const arr = [];
|
|
78
|
+
while (state.pos < state.lines.length) {
|
|
79
|
+
const line = state.lines[state.pos];
|
|
80
|
+
if (line.indent < indent) break;
|
|
81
|
+
if (line.indent > indent) { state.pos++; continue; }
|
|
82
|
+
if (line.content !== '-' && !line.content.startsWith('- ')) break;
|
|
83
|
+
|
|
84
|
+
const after = line.content === '-' ? '' : line.content.slice(2);
|
|
85
|
+
state.pos++;
|
|
86
|
+
if (after === '') {
|
|
87
|
+
if (state.pos < state.lines.length && state.lines[state.pos].indent > indent) {
|
|
88
|
+
arr.push(parseNode(state, state.lines[state.pos].indent));
|
|
89
|
+
} else {
|
|
90
|
+
arr.push(null);
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
arr.push(parseScalarOrFlow(after));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return arr;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Splits `key: value` honoring quoted keys; returns { key, rest }. */
|
|
100
|
+
function splitKey(content) {
|
|
101
|
+
let inSingle = false;
|
|
102
|
+
let inDouble = false;
|
|
103
|
+
for (let i = 0; i < content.length; i++) {
|
|
104
|
+
const ch = content[i];
|
|
105
|
+
if (ch === "'" && !inDouble) inSingle = !inSingle;
|
|
106
|
+
else if (ch === '"' && !inSingle) inDouble = !inDouble;
|
|
107
|
+
else if (ch === ':' && !inSingle && !inDouble) {
|
|
108
|
+
const next = content[i + 1];
|
|
109
|
+
if (next === undefined || next === ' ') {
|
|
110
|
+
return { key: unquote(content.slice(0, i).trim()), rest: content.slice(i + 1).trim() };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return { key: null, rest: '' };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseScalarOrFlow(str) {
|
|
118
|
+
const s = str.trim();
|
|
119
|
+
if (s.startsWith('{') || s.startsWith('[')) {
|
|
120
|
+
return parseFlow(s);
|
|
121
|
+
}
|
|
122
|
+
return parseScalar(s);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseScalar(s) {
|
|
126
|
+
if (s === '' || s === '~' || s === 'null') return null;
|
|
127
|
+
if (s === 'true') return true;
|
|
128
|
+
if (s === 'false') return false;
|
|
129
|
+
return unquote(s);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function unquote(s) {
|
|
133
|
+
const t = s.trim();
|
|
134
|
+
if (t.length >= 2) {
|
|
135
|
+
if (t[0] === '"' && t[t.length - 1] === '"') {
|
|
136
|
+
return t.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
137
|
+
}
|
|
138
|
+
if (t[0] === "'" && t[t.length - 1] === "'") {
|
|
139
|
+
return t.slice(1, -1).replace(/''/g, "'");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return t;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Parses an inline flow collection: `{ a: b, c: [d, e] }`. */
|
|
146
|
+
function parseFlow(s) {
|
|
147
|
+
const ctx = { s, i: 0 };
|
|
148
|
+
return parseFlowValue(ctx);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function parseFlowValue(ctx) {
|
|
152
|
+
skipWs(ctx);
|
|
153
|
+
const ch = ctx.s[ctx.i];
|
|
154
|
+
if (ch === '{') return parseFlowMap(ctx);
|
|
155
|
+
if (ch === '[') return parseFlowSeq(ctx);
|
|
156
|
+
return parseScalar(readFlowScalar(ctx));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function parseFlowMap(ctx) {
|
|
160
|
+
const map = {};
|
|
161
|
+
ctx.i++; // consume '{'
|
|
162
|
+
skipWs(ctx);
|
|
163
|
+
if (ctx.s[ctx.i] === '}') { ctx.i++; return map; }
|
|
164
|
+
while (ctx.i < ctx.s.length) {
|
|
165
|
+
skipWs(ctx);
|
|
166
|
+
const key = unquote(readFlowKey(ctx));
|
|
167
|
+
skipWs(ctx);
|
|
168
|
+
if (ctx.s[ctx.i] === ':') ctx.i++;
|
|
169
|
+
const value = parseFlowValue(ctx);
|
|
170
|
+
map[key] = value;
|
|
171
|
+
skipWs(ctx);
|
|
172
|
+
if (ctx.s[ctx.i] === ',') { ctx.i++; continue; }
|
|
173
|
+
if (ctx.s[ctx.i] === '}') { ctx.i++; break; }
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
return map;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function parseFlowSeq(ctx) {
|
|
180
|
+
const arr = [];
|
|
181
|
+
ctx.i++; // consume '['
|
|
182
|
+
skipWs(ctx);
|
|
183
|
+
if (ctx.s[ctx.i] === ']') { ctx.i++; return arr; }
|
|
184
|
+
while (ctx.i < ctx.s.length) {
|
|
185
|
+
arr.push(parseFlowValue(ctx));
|
|
186
|
+
skipWs(ctx);
|
|
187
|
+
if (ctx.s[ctx.i] === ',') { ctx.i++; continue; }
|
|
188
|
+
if (ctx.s[ctx.i] === ']') { ctx.i++; break; }
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
return arr;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function readFlowKey(ctx) {
|
|
195
|
+
return readUntil(ctx, [':', ',', '}']);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function readFlowScalar(ctx) {
|
|
199
|
+
return readUntil(ctx, [',', '}', ']']);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Reads a quoted or bare token until one of the stop characters at top level. */
|
|
203
|
+
function readUntil(ctx, stops) {
|
|
204
|
+
skipWs(ctx);
|
|
205
|
+
const start = ctx.i;
|
|
206
|
+
const quote = ctx.s[ctx.i];
|
|
207
|
+
if (quote === '"' || quote === "'") {
|
|
208
|
+
ctx.i++;
|
|
209
|
+
while (ctx.i < ctx.s.length) {
|
|
210
|
+
const ch = ctx.s[ctx.i];
|
|
211
|
+
if (ch === '\\' && quote === '"') { ctx.i += 2; continue; }
|
|
212
|
+
if (ch === quote) { ctx.i++; break; }
|
|
213
|
+
ctx.i++;
|
|
214
|
+
}
|
|
215
|
+
return ctx.s.slice(start, ctx.i).trim();
|
|
216
|
+
}
|
|
217
|
+
while (ctx.i < ctx.s.length && !stops.includes(ctx.s[ctx.i])) ctx.i++;
|
|
218
|
+
return ctx.s.slice(start, ctx.i).trim();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function skipWs(ctx) {
|
|
222
|
+
while (ctx.i < ctx.s.length && /\s/.test(ctx.s[ctx.i])) ctx.i++;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
module.exports = { parseYaml };
|