cra-audit 0.1.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/LICENSE +21 -0
- package/README.md +248 -0
- package/bin/cra-audit.js +17 -0
- package/package.json +45 -0
- package/src/cli.js +217 -0
- package/src/commands/audit.js +62 -0
- package/src/commands/sbom.js +100 -0
- package/src/commands/visualize.js +188 -0
- package/src/core/auditor.js +128 -0
- package/src/core/enrich.js +220 -0
- package/src/core/license-checker.js +102 -0
- package/src/core/lockfile-parser.js +198 -0
- package/src/core/policy.js +76 -0
- package/src/core/sbom-generator.js +146 -0
- package/src/core/sbom-validator.js +124 -0
- package/src/core/vuln-scanner.js +148 -0
- package/src/index.js +33 -0
- package/src/reporters/console.js +137 -0
- package/src/reporters/html.js +267 -0
- package/src/reporters/json.js +20 -0
- package/src/utils/exec.js +57 -0
- package/src/utils/fs.js +44 -0
- package/src/utils/http.js +63 -0
- package/src/utils/logger.js +85 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { runJson } = require('../utils/exec');
|
|
4
|
+
|
|
5
|
+
const SEVERITY_ORDER = ['info', 'low', 'moderate', 'high', 'critical'];
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Scans direct and transitive dependencies for known vulnerabilities using
|
|
9
|
+
* `npm audit --json`. The CRA requires products to ship "without known
|
|
10
|
+
* exploitable vulnerabilities", so this is the core gate of the audit.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} projectRoot
|
|
13
|
+
* @param {{ production?: boolean }} [options]
|
|
14
|
+
*/
|
|
15
|
+
function scanVulnerabilities(projectRoot, { production = false } = {}) {
|
|
16
|
+
const args = ['audit', '--json'];
|
|
17
|
+
if (production) args.push('--omit=dev');
|
|
18
|
+
|
|
19
|
+
const result = runJson('npm', args, { cwd: projectRoot });
|
|
20
|
+
|
|
21
|
+
if (!result.data) {
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
error:
|
|
25
|
+
'No se pudo ejecutar `npm audit`. Asegúrate de tener npm instalado y un ' +
|
|
26
|
+
'package-lock.json presente. ' +
|
|
27
|
+
(result.stderr ? `Detalle: ${result.stderr.trim()}` : ''),
|
|
28
|
+
counts: emptyCounts(),
|
|
29
|
+
vulnerabilities: [],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// npm v7+ returns the modern audit report shape.
|
|
34
|
+
if (result.data.vulnerabilities || result.data.metadata) {
|
|
35
|
+
return parseModernReport(result.data);
|
|
36
|
+
}
|
|
37
|
+
// npm v6 (legacy) shape.
|
|
38
|
+
if (result.data.advisories) {
|
|
39
|
+
return parseLegacyReport(result.data);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
ok: true,
|
|
44
|
+
counts: emptyCounts(),
|
|
45
|
+
vulnerabilities: [],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseModernReport(data) {
|
|
50
|
+
const counts = emptyCounts();
|
|
51
|
+
const vulnerabilities = [];
|
|
52
|
+
|
|
53
|
+
const meta = data.metadata && data.metadata.vulnerabilities;
|
|
54
|
+
if (meta) {
|
|
55
|
+
for (const sev of SEVERITY_ORDER) {
|
|
56
|
+
if (typeof meta[sev] === 'number') counts[sev] = meta[sev];
|
|
57
|
+
}
|
|
58
|
+
counts.total = meta.total || sumCounts(counts);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const [name, info] of Object.entries(data.vulnerabilities || {})) {
|
|
62
|
+
const via = Array.isArray(info.via) ? info.via : [];
|
|
63
|
+
const advisories = via.filter((v) => typeof v === 'object');
|
|
64
|
+
const sources = advisories.length
|
|
65
|
+
? advisories.map((adv) => ({
|
|
66
|
+
title: adv.title || 'Vulnerabilidad conocida',
|
|
67
|
+
url: adv.url || null,
|
|
68
|
+
cwe: adv.cwe || [],
|
|
69
|
+
cvss: adv.cvss ? adv.cvss.score : null,
|
|
70
|
+
source: adv.source || null,
|
|
71
|
+
range: adv.range || null,
|
|
72
|
+
}))
|
|
73
|
+
: [{ title: `Dependencia vulnerable a través de ${via.join(', ')}`, url: null, cwe: [], cvss: null }];
|
|
74
|
+
|
|
75
|
+
vulnerabilities.push({
|
|
76
|
+
name,
|
|
77
|
+
severity: info.severity || 'unknown',
|
|
78
|
+
direct: Boolean(info.isDirect),
|
|
79
|
+
range: info.range || null,
|
|
80
|
+
fixAvailable: normalizeFix(info.fixAvailable),
|
|
81
|
+
sources,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!meta) {
|
|
86
|
+
for (const v of vulnerabilities) {
|
|
87
|
+
if (counts[v.severity] !== undefined) counts[v.severity]++;
|
|
88
|
+
}
|
|
89
|
+
counts.total = sumCounts(counts);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { ok: true, counts, vulnerabilities: sortBySeverity(vulnerabilities) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseLegacyReport(data) {
|
|
96
|
+
const counts = emptyCounts();
|
|
97
|
+
const vulnerabilities = [];
|
|
98
|
+
|
|
99
|
+
for (const adv of Object.values(data.advisories)) {
|
|
100
|
+
const severity = adv.severity || 'unknown';
|
|
101
|
+
if (counts[severity] !== undefined) counts[severity]++;
|
|
102
|
+
vulnerabilities.push({
|
|
103
|
+
name: adv.module_name,
|
|
104
|
+
severity,
|
|
105
|
+
direct: false,
|
|
106
|
+
range: adv.vulnerable_versions || null,
|
|
107
|
+
fixAvailable: Boolean(adv.patched_versions && adv.patched_versions !== '<0.0.0'),
|
|
108
|
+
sources: [{
|
|
109
|
+
title: adv.title,
|
|
110
|
+
url: adv.url || null,
|
|
111
|
+
cwe: adv.cwe ? [adv.cwe] : [],
|
|
112
|
+
cvss: adv.cvss_score || null,
|
|
113
|
+
}],
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
counts.total = sumCounts(counts);
|
|
117
|
+
return { ok: true, counts, vulnerabilities: sortBySeverity(vulnerabilities) };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function normalizeFix(fixAvailable) {
|
|
121
|
+
if (fixAvailable === true) return true;
|
|
122
|
+
if (fixAvailable === false || fixAvailable === undefined) return false;
|
|
123
|
+
if (typeof fixAvailable === 'object') {
|
|
124
|
+
return {
|
|
125
|
+
name: fixAvailable.name,
|
|
126
|
+
version: fixAvailable.version,
|
|
127
|
+
breaking: Boolean(fixAvailable.isSemVerMajor),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function emptyCounts() {
|
|
134
|
+
return { info: 0, low: 0, moderate: 0, high: 0, critical: 0, total: 0 };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function sumCounts(counts) {
|
|
138
|
+
return SEVERITY_ORDER.reduce((acc, sev) => acc + (counts[sev] || 0), 0);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function sortBySeverity(list) {
|
|
142
|
+
return list.sort(
|
|
143
|
+
(a, b) => SEVERITY_ORDER.indexOf(b.severity) - SEVERITY_ORDER.indexOf(a.severity) ||
|
|
144
|
+
a.name.localeCompare(b.name)
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = { scanVulnerabilities, SEVERITY_ORDER };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Programmatic API for cra-audit.
|
|
5
|
+
*
|
|
6
|
+
* Example:
|
|
7
|
+
* const { runAudit, loadPolicy } = require('cra-audit');
|
|
8
|
+
* const { policy, source } = loadPolicy(process.cwd());
|
|
9
|
+
* const report = runAudit(process.cwd(), policy, source);
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { runAudit } = require('./core/auditor');
|
|
13
|
+
const { loadPolicy, DEFAULT_POLICY } = require('./core/policy');
|
|
14
|
+
const { generateSbom } = require('./core/sbom-generator');
|
|
15
|
+
const { validateSbom } = require('./core/sbom-validator');
|
|
16
|
+
const { scanVulnerabilities } = require('./core/vuln-scanner');
|
|
17
|
+
const { checkLicenses } = require('./core/license-checker');
|
|
18
|
+
const { parseLockfile } = require('./core/lockfile-parser');
|
|
19
|
+
const { enrichComponents } = require('./core/enrich');
|
|
20
|
+
const { buildHtml } = require('./reporters/html');
|
|
21
|
+
|
|
22
|
+
module.exports = {
|
|
23
|
+
runAudit,
|
|
24
|
+
loadPolicy,
|
|
25
|
+
DEFAULT_POLICY,
|
|
26
|
+
generateSbom,
|
|
27
|
+
validateSbom,
|
|
28
|
+
scanVulnerabilities,
|
|
29
|
+
checkLicenses,
|
|
30
|
+
parseLockfile,
|
|
31
|
+
enrichComponents,
|
|
32
|
+
buildHtml,
|
|
33
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { logger, color } = require('../utils/logger');
|
|
4
|
+
|
|
5
|
+
const SEVERITY_COLORS = {
|
|
6
|
+
critical: color.red,
|
|
7
|
+
high: color.red,
|
|
8
|
+
moderate: color.yellow,
|
|
9
|
+
low: color.yellow,
|
|
10
|
+
info: color.gray,
|
|
11
|
+
unknown: color.gray,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Renders a full audit report to the terminal.
|
|
16
|
+
* @param {import('../core/auditor').AuditResult} report
|
|
17
|
+
*/
|
|
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}`);
|
|
21
|
+
if (report.policySource) {
|
|
22
|
+
logger.detail(`Política: ${report.policySource}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (report.sections.vulnerabilities) renderVulnerabilities(report.sections.vulnerabilities);
|
|
26
|
+
if (report.sections.sbom) renderSbom(report.sections.sbom);
|
|
27
|
+
if (report.sections.licenses) renderLicenses(report.sections.licenses);
|
|
28
|
+
|
|
29
|
+
renderSummary(report);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function renderVulnerabilities(section) {
|
|
33
|
+
logger.heading('1) Vulnerabilidades conocidas (Art. 13 CRA)');
|
|
34
|
+
if (!section.ok) {
|
|
35
|
+
logger.error(section.error);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const c = section.counts;
|
|
40
|
+
if (c.total === 0) {
|
|
41
|
+
logger.success('Sin vulnerabilidades conocidas. Cumple el requisito de "cero vulnerabilidades explotables".');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
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`)}`
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const top = section.vulnerabilities.slice(0, 15);
|
|
51
|
+
for (const vuln of top) {
|
|
52
|
+
const sev = (SEVERITY_COLORS[vuln.severity] || color.gray)(vuln.severity.toUpperCase().padEnd(8));
|
|
53
|
+
const fix = describeFix(vuln.fixAvailable);
|
|
54
|
+
logger.log(` ${sev} ${color.bold(vuln.name)} ${color.gray(vuln.range || '')} ${fix}`);
|
|
55
|
+
const adv = vuln.sources[0];
|
|
56
|
+
if (adv && adv.title) logger.detail(`${adv.title}${adv.url ? ' — ' + adv.url : ''}`);
|
|
57
|
+
}
|
|
58
|
+
if (section.vulnerabilities.length > top.length) {
|
|
59
|
+
logger.detail(`… y ${section.vulnerabilities.length - top.length} más.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function describeFix(fix) {
|
|
64
|
+
if (fix === true) return color.green('(fix disponible)');
|
|
65
|
+
if (fix && typeof fix === 'object') {
|
|
66
|
+
return color.green(`(fix: ${fix.name}@${fix.version}${fix.breaking ? ', breaking' : ''})`);
|
|
67
|
+
}
|
|
68
|
+
return color.gray('(sin fix)');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function renderSbom(section) {
|
|
72
|
+
logger.heading('2) Lista de Materiales de Software · SBOM (Anexo I CRA)');
|
|
73
|
+
if (!section.ok) {
|
|
74
|
+
logger.error(section.error);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
logger.detail(`Formato: ${section.format} · Componentes: ${section.componentCount}`);
|
|
78
|
+
|
|
79
|
+
if (section.validation) {
|
|
80
|
+
const v = section.validation;
|
|
81
|
+
for (const check of v.checks) {
|
|
82
|
+
const mark = check.passed ? color.green('✔') : color.red('✖');
|
|
83
|
+
logger.log(` ${mark} ${check.label}`);
|
|
84
|
+
}
|
|
85
|
+
if (v.valid) {
|
|
86
|
+
logger.success('El SBOM cumple los elementos mínimos de TR-03183 §6.');
|
|
87
|
+
} else {
|
|
88
|
+
logger.warn(`El SBOM no cumple ${v.failedChecks.length} requisito(s) mínimo(s).`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (section.outputPath) {
|
|
92
|
+
logger.detail(`SBOM escrito en: ${section.outputPath}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function renderLicenses(section) {
|
|
97
|
+
logger.heading('3) Licencias de componentes de terceros (TR-03183 §6)');
|
|
98
|
+
if (!section.ok) {
|
|
99
|
+
logger.error(section.error);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const s = section.summary;
|
|
103
|
+
logger.detail(`Documentadas: ${s.documented}/${s.total}`);
|
|
104
|
+
|
|
105
|
+
if (s.missing.length) {
|
|
106
|
+
logger.warn(`${s.missing.length} componente(s) sin licencia documentada:`);
|
|
107
|
+
for (const c of s.missing.slice(0, 10)) logger.log(` ${color.gray('•')} ${c.name}@${c.version}`);
|
|
108
|
+
}
|
|
109
|
+
if (s.denied.length) {
|
|
110
|
+
logger.error(`${s.denied.length} componente(s) con licencia prohibida por la política:`);
|
|
111
|
+
for (const c of s.denied) logger.log(` ${color.red('•')} ${c.name}@${c.version} (${c.license})`);
|
|
112
|
+
}
|
|
113
|
+
if (s.notAllowed.length) {
|
|
114
|
+
logger.warn(`${s.notAllowed.length} componente(s) con licencia fuera de la allowlist:`);
|
|
115
|
+
for (const c of s.notAllowed.slice(0, 10)) logger.log(` ${color.yellow('•')} ${c.name}@${c.version} (${c.license})`);
|
|
116
|
+
}
|
|
117
|
+
if (!s.missing.length && !s.denied.length && !s.notAllowed.length) {
|
|
118
|
+
logger.success('Todas las licencias están documentadas y permitidas.');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function renderSummary(report) {
|
|
123
|
+
logger.heading('Resultado');
|
|
124
|
+
for (const item of report.gate.reasons) {
|
|
125
|
+
const mark = item.passed ? color.green('✔') : color.red('✖');
|
|
126
|
+
logger.log(` ${mark} ${item.label}`);
|
|
127
|
+
}
|
|
128
|
+
logger.log('');
|
|
129
|
+
if (report.gate.passed) {
|
|
130
|
+
logger.success(color.bold('AUDITORÍA SUPERADA — el proyecto cumple la política CRA configurada.'));
|
|
131
|
+
} else {
|
|
132
|
+
logger.error(color.bold('AUDITORÍA FALLIDA — se han detectado incumplimientos del CRA.'));
|
|
133
|
+
}
|
|
134
|
+
logger.log('');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = { reportConsole };
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Builds a self-contained, dependency-free HTML report. All data is embedded
|
|
5
|
+
* as JSON and rendered client-side with vanilla JS (no external CDNs, so it
|
|
6
|
+
* works offline and adds no third-party script supply-chain risk).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {object} model The view model produced by the visualize command.
|
|
11
|
+
* @returns {string} Full HTML document.
|
|
12
|
+
*/
|
|
13
|
+
function buildHtml(model) {
|
|
14
|
+
const json = safeJson(model);
|
|
15
|
+
return `<!DOCTYPE html>
|
|
16
|
+
<html lang="en">
|
|
17
|
+
<head>
|
|
18
|
+
<meta charset="utf-8">
|
|
19
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
20
|
+
<title>CRA Audit — ${escapeHtml(model.project.name)}@${escapeHtml(model.project.version)}</title>
|
|
21
|
+
<style>${CSS}</style>
|
|
22
|
+
</head>
|
|
23
|
+
<body>
|
|
24
|
+
<header class="hdr">
|
|
25
|
+
<div>
|
|
26
|
+
<h1>CRA Audit</h1>
|
|
27
|
+
<p class="sub">Cyber Resilience Act (EU 2024/2847) · BSI TR-03183</p>
|
|
28
|
+
</div>
|
|
29
|
+
<div class="proj">
|
|
30
|
+
<span class="proj-name">${escapeHtml(model.project.name)}</span>
|
|
31
|
+
<span class="proj-ver">v${escapeHtml(model.project.version)}</span>
|
|
32
|
+
<span class="ts">${escapeHtml(model.generatedAt)}</span>
|
|
33
|
+
</div>
|
|
34
|
+
</header>
|
|
35
|
+
|
|
36
|
+
<section class="cards" id="cards"></section>
|
|
37
|
+
|
|
38
|
+
<section class="panel">
|
|
39
|
+
<div class="toolbar">
|
|
40
|
+
<input id="search" type="search" placeholder="Filter by name, license or status…" autocomplete="off">
|
|
41
|
+
<div class="filters" id="filters"></div>
|
|
42
|
+
</div>
|
|
43
|
+
<div class="table-wrap">
|
|
44
|
+
<table id="grid">
|
|
45
|
+
<thead>
|
|
46
|
+
<tr>
|
|
47
|
+
<th data-sort="name">Package</th>
|
|
48
|
+
<th data-sort="version">Version</th>
|
|
49
|
+
<th data-sort="latest">Latest</th>
|
|
50
|
+
<th data-sort="license">License</th>
|
|
51
|
+
<th data-sort="severity">Vulnerability</th>
|
|
52
|
+
<th data-sort="lastActivity">Last activity</th>
|
|
53
|
+
<th data-sort="maintainers">Maint.</th>
|
|
54
|
+
<th data-sort="contributors">Contrib.</th>
|
|
55
|
+
<th data-sort="maintenanceScore">Maintenance</th>
|
|
56
|
+
<th>Repo</th>
|
|
57
|
+
</tr>
|
|
58
|
+
</thead>
|
|
59
|
+
<tbody id="rows"></tbody>
|
|
60
|
+
</table>
|
|
61
|
+
</div>
|
|
62
|
+
<p class="empty" id="empty" hidden>No results for the current filter.</p>
|
|
63
|
+
</section>
|
|
64
|
+
|
|
65
|
+
<footer class="ftr">
|
|
66
|
+
Generated by <strong>cra-audit</strong> · Technical support tool; not legal advice.
|
|
67
|
+
</footer>
|
|
68
|
+
|
|
69
|
+
<script id="data" type="application/json">${json}</script>
|
|
70
|
+
<script>${SCRIPT}</script>
|
|
71
|
+
</body>
|
|
72
|
+
</html>`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Escapes a string for safe insertion into HTML text/attributes. */
|
|
76
|
+
function escapeHtml(value) {
|
|
77
|
+
return String(value == null ? '' : value)
|
|
78
|
+
.replace(/&/g, '&')
|
|
79
|
+
.replace(/</g, '<')
|
|
80
|
+
.replace(/>/g, '>')
|
|
81
|
+
.replace(/"/g, '"')
|
|
82
|
+
.replace(/'/g, ''');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Serializes JSON safely for embedding inside a <script> element. */
|
|
86
|
+
function safeJson(data) {
|
|
87
|
+
return JSON.stringify(data)
|
|
88
|
+
.replace(/</g, '\\u003c')
|
|
89
|
+
.replace(/>/g, '\\u003e')
|
|
90
|
+
.replace(/&/g, '\\u0026')
|
|
91
|
+
.replace(/\u2028/g, '\\u2028')
|
|
92
|
+
.replace(/\u2029/g, '\\u2029');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const CSS = `
|
|
96
|
+
:root{--bg:#0f1117;--panel:#171a23;--panel2:#1d2130;--line:#2a2f3e;--txt:#e6e8ee;--mut:#9aa3b2;
|
|
97
|
+
--ok:#3fb950;--warn:#d29922;--bad:#f85149;--info:#58a6ff;--accent:#7c8cff}
|
|
98
|
+
*{box-sizing:border-box}
|
|
99
|
+
body{margin:0;background:var(--bg);color:var(--txt);font:14px/1.5 system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif}
|
|
100
|
+
a{color:var(--info);text-decoration:none}a:hover{text-decoration:underline}
|
|
101
|
+
.hdr{display:flex;justify-content:space-between;align-items:center;padding:20px 28px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#141821,#0f1117)}
|
|
102
|
+
.hdr h1{margin:0;font-size:20px;letter-spacing:.3px}
|
|
103
|
+
.sub{margin:2px 0 0;color:var(--mut);font-size:12px}
|
|
104
|
+
.proj{text-align:right;display:flex;flex-direction:column;gap:2px}
|
|
105
|
+
.proj-name{font-weight:600}.proj-ver{color:var(--accent);font-size:12px}.ts{color:var(--mut);font-size:11px}
|
|
106
|
+
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:14px;padding:22px 28px}
|
|
107
|
+
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px}
|
|
108
|
+
.card .k{color:var(--mut);font-size:12px;text-transform:uppercase;letter-spacing:.5px}
|
|
109
|
+
.card .v{font-size:26px;font-weight:700;margin-top:6px}
|
|
110
|
+
.card.ok .v{color:var(--ok)}.card.warn .v{color:var(--warn)}.card.bad .v{color:var(--bad)}.card.info .v{color:var(--info)}
|
|
111
|
+
.card .d{color:var(--mut);font-size:12px;margin-top:4px}
|
|
112
|
+
.panel{margin:0 28px 28px;background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden}
|
|
113
|
+
.toolbar{display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:14px 16px;border-bottom:1px solid var(--line)}
|
|
114
|
+
#search{flex:1;min-width:220px;background:var(--panel2);border:1px solid var(--line);color:var(--txt);
|
|
115
|
+
border-radius:8px;padding:9px 12px;font-size:13px}
|
|
116
|
+
.filters{display:flex;gap:8px;flex-wrap:wrap}
|
|
117
|
+
.chip{background:var(--panel2);border:1px solid var(--line);color:var(--mut);border-radius:999px;
|
|
118
|
+
padding:6px 12px;font-size:12px;cursor:pointer;user-select:none}
|
|
119
|
+
.chip.active{color:#fff;border-color:var(--accent);background:#262c44}
|
|
120
|
+
.table-wrap{overflow:auto;max-height:70vh}
|
|
121
|
+
table{width:100%;border-collapse:collapse;font-size:13px}
|
|
122
|
+
thead th{position:sticky;top:0;background:var(--panel2);text-align:left;padding:11px 14px;
|
|
123
|
+
border-bottom:1px solid var(--line);cursor:pointer;white-space:nowrap;color:var(--mut);font-weight:600}
|
|
124
|
+
thead th:hover{color:var(--txt)}
|
|
125
|
+
tbody td{padding:10px 14px;border-bottom:1px solid var(--line);white-space:nowrap}
|
|
126
|
+
tbody tr:hover{background:#1b1f2c}
|
|
127
|
+
.pkg{font-weight:600}
|
|
128
|
+
.badge{display:inline-block;padding:2px 9px;border-radius:999px;font-size:11px;font-weight:600;border:1px solid transparent}
|
|
129
|
+
.b-ok{color:var(--ok);background:rgba(63,185,80,.12);border-color:rgba(63,185,80,.3)}
|
|
130
|
+
.b-warn{color:var(--warn);background:rgba(210,153,34,.12);border-color:rgba(210,153,34,.3)}
|
|
131
|
+
.b-bad{color:var(--bad);background:rgba(248,81,73,.12);border-color:rgba(248,81,73,.3)}
|
|
132
|
+
.b-mut{color:var(--mut);background:rgba(154,163,178,.1);border-color:rgba(154,163,178,.25)}
|
|
133
|
+
.b-info{color:var(--info);background:rgba(88,166,255,.12);border-color:rgba(88,166,255,.3)}
|
|
134
|
+
.bar{display:inline-flex;align-items:center;gap:8px}
|
|
135
|
+
.bar .track{width:74px;height:7px;border-radius:4px;background:var(--panel2);overflow:hidden}
|
|
136
|
+
.bar .fill{height:100%}
|
|
137
|
+
.mut{color:var(--mut)}
|
|
138
|
+
.empty{padding:26px;text-align:center;color:var(--mut)}
|
|
139
|
+
.ftr{padding:18px 28px;color:var(--mut);font-size:12px;border-top:1px solid var(--line)}
|
|
140
|
+
`;
|
|
141
|
+
|
|
142
|
+
const SCRIPT = `
|
|
143
|
+
(function(){
|
|
144
|
+
var MODEL = JSON.parse(document.getElementById('data').textContent);
|
|
145
|
+
var rows = MODEL.components.slice();
|
|
146
|
+
var sortKey = 'maintenanceScore', sortDir = 1, activeFilter = 'all', term = '';
|
|
147
|
+
|
|
148
|
+
var SEV_RANK = {critical:5,high:4,moderate:3,low:2,info:1,none:0};
|
|
149
|
+
|
|
150
|
+
function fmtDate(d){ if(!d) return '—'; var t=new Date(d); if(isNaN(t)) return '—';
|
|
151
|
+
return t.toISOString().slice(0,10); }
|
|
152
|
+
function ago(d){ if(!d) return ''; var days=Math.floor((Date.now()-new Date(d).getTime())/86400000);
|
|
153
|
+
if(days<0) return ''; if(days<31) return days+'d'; if(days<365) return Math.floor(days/30)+'mo';
|
|
154
|
+
return Math.floor(days/365)+'y'; }
|
|
155
|
+
function esc(s){ var e=document.createElement('div'); e.textContent=(s==null?'':s); return e.innerHTML; }
|
|
156
|
+
|
|
157
|
+
function sevBadge(r){
|
|
158
|
+
var s=r.severity||'none';
|
|
159
|
+
if(s==='none') return '<span class="badge b-ok">clean</span>';
|
|
160
|
+
var cls = (s==='critical'||s==='high')?'b-bad':(s==='moderate'||s==='low')?'b-warn':'b-mut';
|
|
161
|
+
return '<span class="badge '+cls+'">'+esc(s)+'</span>';
|
|
162
|
+
}
|
|
163
|
+
function licBadge(r){
|
|
164
|
+
if(!r.license) return '<span class="badge b-bad">no license</span>';
|
|
165
|
+
var cls = r.licenseStatus==='denied'?'b-bad':r.licenseStatus==='not-allowed'?'b-warn':'b-mut';
|
|
166
|
+
return '<span class="badge '+cls+'">'+esc(r.license)+'</span>';
|
|
167
|
+
}
|
|
168
|
+
function maint(r){
|
|
169
|
+
if(r.maintenanceScore==null) return '<span class="mut">unknown</span>';
|
|
170
|
+
var col = r.maintenanceLabel==='well-maintained'?'var(--ok)':r.maintenanceLabel==='moderate'?'var(--warn)':'var(--bad)';
|
|
171
|
+
return '<span class="bar"><span class="track"><span class="fill" style="width:'+r.maintenanceScore+'%;background:'+col+'"></span></span>'+
|
|
172
|
+
'<span class="mut">'+r.maintenanceScore+'</span></span>';
|
|
173
|
+
}
|
|
174
|
+
function repoCell(r){
|
|
175
|
+
if(!r.repoUrl) return '<span class="mut">—</span>';
|
|
176
|
+
var host=r.repoUrl.replace(/^https?:\\/\\//,'').replace(/\\/$/,'');
|
|
177
|
+
return '<a href="'+esc(r.repoUrl)+'" target="_blank" rel="noopener">'+esc(host.length>26?host.slice(0,26)+'…':host)+'</a>';
|
|
178
|
+
}
|
|
179
|
+
function verCell(r){
|
|
180
|
+
if(!r.latest) return esc(r.version)+' <span class="mut">/ ?</span>';
|
|
181
|
+
if(r.outdated) return esc(r.version)+' <span class="badge b-warn" title="Latest: '+esc(r.latest)+'">→ '+esc(r.latest)+'</span>';
|
|
182
|
+
return esc(r.version)+' <span class="badge b-ok">up to date</span>';
|
|
183
|
+
}
|
|
184
|
+
function lastActivity(r){
|
|
185
|
+
var d=r.lastCommit||r.lastPublish;
|
|
186
|
+
var label=r.lastCommit?'commit':'publish';
|
|
187
|
+
if(!d) return '<span class="mut">—</span>';
|
|
188
|
+
return fmtDate(d)+' <span class="mut">('+label+' '+ago(d)+')</span>';
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function passFilter(r){
|
|
192
|
+
if(activeFilter==='vuln') return (r.severity&&r.severity!=='none');
|
|
193
|
+
if(activeFilter==='outdated') return r.outdated;
|
|
194
|
+
if(activeFilter==='lic') return (!r.license||r.licenseStatus==='denied'||r.licenseStatus==='not-allowed');
|
|
195
|
+
if(activeFilter==='risk') return r.maintenanceLabel==='at-risk';
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
function passTerm(r){
|
|
199
|
+
if(!term) return true;
|
|
200
|
+
var hay=(r.name+' '+(r.license||'')+' '+(r.maintenanceLabel||'')+' '+(r.severity||'')).toLowerCase();
|
|
201
|
+
return hay.indexOf(term)>=0;
|
|
202
|
+
}
|
|
203
|
+
function sortVal(r){
|
|
204
|
+
if(sortKey==='severity') return SEV_RANK[r.severity||'none']||0;
|
|
205
|
+
if(sortKey==='lastActivity') return new Date(r.lastCommit||r.lastPublish||0).getTime();
|
|
206
|
+
var v=r[sortKey];
|
|
207
|
+
return (typeof v==='string')?v.toLowerCase():(v==null?-1:v);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function render(){
|
|
211
|
+
var list=rows.filter(function(r){return passFilter(r)&&passTerm(r);});
|
|
212
|
+
list.sort(function(a,b){var x=sortVal(a),y=sortVal(b);return x<y?-1*sortDir:x>y?1*sortDir:0;});
|
|
213
|
+
var tb=document.getElementById('rows'); tb.innerHTML='';
|
|
214
|
+
list.forEach(function(r){
|
|
215
|
+
var tr=document.createElement('tr');
|
|
216
|
+
tr.innerHTML='<td class="pkg">'+esc(r.name)+(r.deprecated?' <span class="badge b-bad">deprecated</span>':'')+(r.archived?' <span class="badge b-bad">archived</span>':'')+'</td>'+
|
|
217
|
+
'<td>'+verCell(r)+'</td>'+
|
|
218
|
+
'<td class="mut">'+esc(r.latest||'—')+'</td>'+
|
|
219
|
+
'<td>'+licBadge(r)+'</td>'+
|
|
220
|
+
'<td>'+sevBadge(r)+'</td>'+
|
|
221
|
+
'<td>'+lastActivity(r)+'</td>'+
|
|
222
|
+
'<td class="mut">'+(r.maintainers==null?'—':r.maintainers)+'</td>'+
|
|
223
|
+
'<td class="mut">'+(r.contributors==null?'—':r.contributors)+'</td>'+
|
|
224
|
+
'<td>'+maint(r)+'</td>'+
|
|
225
|
+
'<td>'+repoCell(r)+'</td>';
|
|
226
|
+
tb.appendChild(tr);
|
|
227
|
+
});
|
|
228
|
+
document.getElementById('empty').hidden = list.length>0;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function renderCards(){
|
|
232
|
+
var s=MODEL.summary, c=document.getElementById('cards');
|
|
233
|
+
function card(cls,k,v,d){return '<div class="card '+cls+'"><div class="k">'+k+'</div><div class="v">'+v+'</div><div class="d">'+(d||'')+'</div></div>';}
|
|
234
|
+
c.innerHTML =
|
|
235
|
+
card('info','Components', s.total, 'audited dependencies')+
|
|
236
|
+
card(s.vulnerable>0?'bad':'ok','Vulnerable', s.vulnerable, s.critical+' critical · '+s.high+' high')+
|
|
237
|
+
card(s.licenseIssues>0?'warn':'ok','Licenses', s.licenseIssues, 'undocumented or not allowed')+
|
|
238
|
+
card(s.outdated>0?'warn':'ok','Outdated', s.outdated, 'a newer version exists')+
|
|
239
|
+
card(s.atRisk>0?'bad':'ok','At risk', s.atRisk, 'questionable maintenance');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function renderFilters(){
|
|
243
|
+
var defs=[['all','All'],['vuln','Vulnerable'],['outdated','Outdated'],['lic','Licenses'],['risk','At risk']];
|
|
244
|
+
var f=document.getElementById('filters');
|
|
245
|
+
f.innerHTML=defs.map(function(d){return '<span class="chip'+(d[0]==='all'?' active':'')+'" data-f="'+d[0]+'">'+d[1]+'</span>';}).join('');
|
|
246
|
+
f.addEventListener('click',function(e){
|
|
247
|
+
var chip=e.target.closest('.chip'); if(!chip) return;
|
|
248
|
+
activeFilter=chip.getAttribute('data-f');
|
|
249
|
+
[].forEach.call(f.querySelectorAll('.chip'),function(x){x.classList.toggle('active',x===chip);});
|
|
250
|
+
render();
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
document.querySelectorAll('th[data-sort]').forEach(function(th){
|
|
255
|
+
th.addEventListener('click',function(){
|
|
256
|
+
var k=th.getAttribute('data-sort');
|
|
257
|
+
if(sortKey===k) sortDir*=-1; else {sortKey=k; sortDir=1;}
|
|
258
|
+
render();
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
document.getElementById('search').addEventListener('input',function(e){term=e.target.value.toLowerCase().trim();render();});
|
|
262
|
+
|
|
263
|
+
renderCards(); renderFilters(); render();
|
|
264
|
+
})();
|
|
265
|
+
`;
|
|
266
|
+
|
|
267
|
+
module.exports = { buildHtml };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { logger } = require('../utils/logger');
|
|
4
|
+
const { writeJson } = require('../utils/fs');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Emits the audit report as machine-readable JSON, either to stdout or to a file.
|
|
8
|
+
* @param {object} report
|
|
9
|
+
* @param {{ outputPath?: string }} [options]
|
|
10
|
+
*/
|
|
11
|
+
function reportJson(report, { outputPath } = {}) {
|
|
12
|
+
if (outputPath) {
|
|
13
|
+
writeJson(outputPath, report);
|
|
14
|
+
logger.success(`Informe JSON escrito en: ${outputPath}`);
|
|
15
|
+
} else {
|
|
16
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
module.exports = { reportJson };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawnSync } = require('node:child_process');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Runs a command synchronously and returns its stdout/stderr/status
|
|
7
|
+
* without throwing. Used to invoke `npm` safely.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} command
|
|
10
|
+
* @param {string[]} args
|
|
11
|
+
* @param {object} [options]
|
|
12
|
+
* @returns {{ status: number|null, stdout: string, stderr: string, error: Error|undefined }}
|
|
13
|
+
*/
|
|
14
|
+
function run(command, args = [], options = {}) {
|
|
15
|
+
const result = spawnSync(command, args, {
|
|
16
|
+
encoding: 'utf8',
|
|
17
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
18
|
+
shell: process.platform === 'win32',
|
|
19
|
+
...options,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
status: result.status,
|
|
24
|
+
stdout: result.stdout || '',
|
|
25
|
+
stderr: result.stderr || '',
|
|
26
|
+
error: result.error,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Runs a command and attempts to parse its stdout as JSON.
|
|
32
|
+
* Returns { ok, data, raw, status }.
|
|
33
|
+
*/
|
|
34
|
+
function runJson(command, args = [], options = {}) {
|
|
35
|
+
const result = run(command, args, options);
|
|
36
|
+
let data = null;
|
|
37
|
+
let parseError = null;
|
|
38
|
+
|
|
39
|
+
if (result.stdout) {
|
|
40
|
+
try {
|
|
41
|
+
data = JSON.parse(result.stdout);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
parseError = err;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
ok: !result.error && data !== null,
|
|
49
|
+
data,
|
|
50
|
+
raw: result.stdout,
|
|
51
|
+
status: result.status,
|
|
52
|
+
stderr: result.stderr,
|
|
53
|
+
error: result.error || parseError,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { run, runJson };
|