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.
@@ -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 (UE 2024/2847) · BSI TR-03183');
20
- logger.detail(`Proyecto: ${report.project.name}@${report.project.version}`);
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(`Política: ${report.policySource}`);
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) Vulnerabilidades conocidas (Art. 13 CRA)');
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('Sin vulnerabilidades conocidas. Cumple el requisito de "cero vulnerabilidades explotables".');
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} críticas`)} · ${color.red(`${c.high} altas`)} · ` +
47
- `${color.yellow(`${c.moderate} moderadas`)} · ${color.yellow(`${c.low} bajas`)} · ${color.gray(`${c.info} info`)}`
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(`… y ${section.vulnerabilities.length - top.length} más.`);
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 disponible)');
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('(sin fix)');
68
+ return color.gray('(no fix)');
69
69
  }
70
70
 
71
71
  function renderSbom(section) {
72
- logger.heading('2) Lista de Materiales de Software · SBOM (Anexo I CRA)');
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(`Formato: ${section.format} · Componentes: ${section.componentCount}`);
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('El SBOM cumple los elementos mínimos de TR-03183 §6.');
86
+ logger.success('The SBOM meets the TR-03183 §6 minimum elements.');
87
87
  } else {
88
- logger.warn(`El SBOM no cumple ${v.failedChecks.length} requisito(s) mínimo(s).`);
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 escrito en: ${section.outputPath}`);
92
+ logger.detail(`SBOM written to: ${section.outputPath}`);
93
93
  }
94
94
  }
95
95
 
96
96
  function renderLicenses(section) {
97
- logger.heading('3) Licencias de componentes de terceros (TR-03183 §6)');
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(`Documentadas: ${s.documented}/${s.total}`);
103
+ logger.detail(`Documented: ${s.documented}/${s.total}`);
104
104
 
105
105
  if (s.missing.length) {
106
- logger.warn(`${s.missing.length} componente(s) sin licencia documentada:`);
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} componente(s) con licencia prohibida por la política:`);
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} componente(s) con licencia fuera de la allowlist:`);
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('Todas las licencias están documentadas y permitidas.');
118
+ logger.success('All licenses are documented and allowed.');
119
119
  }
120
120
  }
121
121
 
122
122
  function renderSummary(report) {
123
- logger.heading('Resultado');
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('AUDITORÍA SUPERADA — el proyecto cumple la política CRA configurada.'));
130
+ logger.success(color.bold('AUDIT PASSED — the project meets the configured CRA policy.'));
131
131
  } else {
132
- logger.error(color.bold('AUDITORÍA FALLIDA — se han detectado incumplimientos del CRA.'));
132
+ logger.error(color.bold('AUDIT FAILED — CRA compliance issues were detected.'));
133
133
  }
134
134
  logger.log('');
135
135
  }
@@ -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 };