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,100 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { findProjectRoot, readJson, writeJson, exists } = require('../utils/fs');
|
|
5
|
+
const { logger, color } = require('../utils/logger');
|
|
6
|
+
const { generateSbom } = require('../core/sbom-generator');
|
|
7
|
+
const { validateSbom } = require('../core/sbom-validator');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `cra-audit sbom <generate|check>` and the `--sbom` shortcut.
|
|
11
|
+
*
|
|
12
|
+
* - generate: builds a CycloneDX/SPDX SBOM and prints it or writes it to disk.
|
|
13
|
+
* - check: generates (or reads) an SBOM and validates the minimum elements
|
|
14
|
+
* required by BSI TR-03183 §6.
|
|
15
|
+
*/
|
|
16
|
+
function sbomCommand(subcommand, flags) {
|
|
17
|
+
const projectRoot = findProjectRoot(flags.cwd || process.cwd());
|
|
18
|
+
if (!projectRoot) {
|
|
19
|
+
logger.error('No se encontró ningún package.json. Ejecuta el comando dentro de un proyecto npm.');
|
|
20
|
+
return 1;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const action = subcommand || 'generate';
|
|
24
|
+
const format = flags.format || 'cyclonedx';
|
|
25
|
+
|
|
26
|
+
if (action === 'check') {
|
|
27
|
+
return sbomCheck(projectRoot, format, flags);
|
|
28
|
+
}
|
|
29
|
+
if (action === 'generate') {
|
|
30
|
+
return sbomGenerate(projectRoot, format, flags);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
logger.error(`Subcomando de SBOM desconocido: "${action}". Usa "generate" o "check".`);
|
|
34
|
+
return 1;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sbomGenerate(projectRoot, format, flags) {
|
|
38
|
+
const result = generateSbom(projectRoot, { format });
|
|
39
|
+
if (!result.ok) {
|
|
40
|
+
logger.error(result.error);
|
|
41
|
+
return 1;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (flags.output) {
|
|
45
|
+
const outPath = path.isAbsolute(flags.output) ? flags.output : path.join(projectRoot, flags.output);
|
|
46
|
+
writeJson(outPath, result.document);
|
|
47
|
+
logger.success(`SBOM (${result.format}) con ${result.componentCount} componentes escrito en: ${outPath}`);
|
|
48
|
+
} else {
|
|
49
|
+
process.stdout.write(JSON.stringify(result.document, null, 2) + '\n');
|
|
50
|
+
}
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function sbomCheck(projectRoot, format, flags) {
|
|
55
|
+
let document;
|
|
56
|
+
|
|
57
|
+
// If an SBOM file is provided, validate it directly; otherwise generate one.
|
|
58
|
+
if (flags.input) {
|
|
59
|
+
const inPath = path.isAbsolute(flags.input) ? flags.input : path.join(projectRoot, flags.input);
|
|
60
|
+
if (!exists(inPath)) {
|
|
61
|
+
logger.error(`No se encontró el SBOM indicado: ${flags.input}`);
|
|
62
|
+
return 1;
|
|
63
|
+
}
|
|
64
|
+
document = readJson(inPath);
|
|
65
|
+
} else {
|
|
66
|
+
const result = generateSbom(projectRoot, { format });
|
|
67
|
+
if (!result.ok) {
|
|
68
|
+
logger.error(result.error);
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
document = result.document;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const validation = validateSbom(document);
|
|
75
|
+
if (!validation.ok) {
|
|
76
|
+
logger.error(validation.error);
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (flags.json) {
|
|
81
|
+
process.stdout.write(JSON.stringify(validation, null, 2) + '\n');
|
|
82
|
+
return validation.valid ? 0 : 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
logger.heading('Validación de SBOM · BSI TR-03183 §6 (elementos mínimos)');
|
|
86
|
+
logger.detail(`Formato detectado: ${validation.format} · Componentes: ${validation.stats.total}`);
|
|
87
|
+
for (const check of validation.checks) {
|
|
88
|
+
const mark = check.passed ? color.green('✔') : color.red('✖');
|
|
89
|
+
logger.log(` ${mark} ${check.label}`);
|
|
90
|
+
}
|
|
91
|
+
logger.log('');
|
|
92
|
+
if (validation.valid) {
|
|
93
|
+
logger.success(color.bold('SBOM VÁLIDO — cumple los elementos mínimos exigidos por el CRA.'));
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
logger.error(color.bold(`SBOM INVÁLIDO — ${validation.failedChecks.length} requisito(s) sin cumplir.`));
|
|
97
|
+
return 1;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { sbomCommand };
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const { spawn } = require('node:child_process');
|
|
6
|
+
const { findProjectRoot } = require('../utils/fs');
|
|
7
|
+
const { logger, color } = require('../utils/logger');
|
|
8
|
+
const { parseLockfile } = require('../core/lockfile-parser');
|
|
9
|
+
const { scanVulnerabilities, SEVERITY_ORDER } = require('../core/vuln-scanner');
|
|
10
|
+
const { checkLicenses } = require('../core/license-checker');
|
|
11
|
+
const { enrichComponents } = require('../core/enrich');
|
|
12
|
+
const { loadPolicy } = require('../core/policy');
|
|
13
|
+
const { buildHtml } = require('../reporters/html');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `cra-audit visualize` / `--visualize`
|
|
17
|
+
*
|
|
18
|
+
* Builds a single, self-contained HTML report that visualises the SBOM,
|
|
19
|
+
* licenses, versions, known vulnerabilities and maintenance signals
|
|
20
|
+
* (last publish/commit, maintainers, contributors, stars) per dependency.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} flags
|
|
23
|
+
* @returns {Promise<number>} exit code
|
|
24
|
+
*/
|
|
25
|
+
async function visualizeCommand(flags) {
|
|
26
|
+
const projectRoot = findProjectRoot(flags.cwd || process.cwd());
|
|
27
|
+
if (!projectRoot) {
|
|
28
|
+
logger.error('No se encontró ningún package.json. Ejecuta el comando dentro de un proyecto npm.');
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const parsed = parseLockfile(projectRoot);
|
|
33
|
+
if (!parsed.ok) {
|
|
34
|
+
logger.error(parsed.error);
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let policy = {};
|
|
39
|
+
try {
|
|
40
|
+
policy = loadPolicy(projectRoot, flags.config).policy;
|
|
41
|
+
} catch {
|
|
42
|
+
policy = {};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
logger.info(`Analizando ${parsed.components.length} componentes de ${parsed.root.name}@${parsed.root.version}…`);
|
|
46
|
+
|
|
47
|
+
// Vulnerabilities + licenses run locally (no network).
|
|
48
|
+
const vulns = scanVulnerabilities(projectRoot, { production: policy.productionOnly });
|
|
49
|
+
const licenses = checkLicenses(projectRoot, policy.licenses || {});
|
|
50
|
+
|
|
51
|
+
// Maintenance enrichment over the network (opt-out with --offline).
|
|
52
|
+
const network = !flags.offline;
|
|
53
|
+
const github = Boolean(flags.github);
|
|
54
|
+
if (network) {
|
|
55
|
+
logger.info(
|
|
56
|
+
`Consultando metadatos de mantenimiento (npm${github ? ' + GitHub' : ''})…` +
|
|
57
|
+
(github && !process.env.GITHUB_TOKEN ? color.gray(' (sin GITHUB_TOKEN: límite de 60 req/h)') : '')
|
|
58
|
+
);
|
|
59
|
+
} else {
|
|
60
|
+
logger.info('Modo offline: se omiten metadatos de mantenimiento.');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let lastPct = -1;
|
|
64
|
+
const enrichment = await enrichComponents(parsed.components, {
|
|
65
|
+
network,
|
|
66
|
+
github,
|
|
67
|
+
onProgress: (done, total) => {
|
|
68
|
+
const pct = Math.floor((done / total) * 100);
|
|
69
|
+
if (pct !== lastPct && pct % 20 === 0) {
|
|
70
|
+
logger.detail(`metadatos npm: ${pct}%`);
|
|
71
|
+
lastPct = pct;
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const model = buildModel(parsed, vulns, licenses, enrichment);
|
|
77
|
+
const html = buildHtml(model);
|
|
78
|
+
|
|
79
|
+
const outPath = resolveOutputPath(projectRoot, flags.output);
|
|
80
|
+
fs.writeFileSync(outPath, html, 'utf8');
|
|
81
|
+
logger.success(`Informe visual escrito en: ${outPath}`);
|
|
82
|
+
|
|
83
|
+
if (!flags.noOpen) {
|
|
84
|
+
openInBrowser(outPath);
|
|
85
|
+
logger.detail('Abriendo en el navegador…');
|
|
86
|
+
} else {
|
|
87
|
+
logger.detail(`Ábrelo en tu navegador: file://${outPath.replace(/\\/g, '/')}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Merges all data sources into the view model consumed by the HTML reporter. */
|
|
94
|
+
function buildModel(parsed, vulns, licenses, enrichment) {
|
|
95
|
+
const vulnByName = new Map();
|
|
96
|
+
if (vulns.ok) {
|
|
97
|
+
for (const v of vulns.vulnerabilities) vulnByName.set(v.name, v);
|
|
98
|
+
}
|
|
99
|
+
const licByKey = new Map();
|
|
100
|
+
if (licenses.ok) {
|
|
101
|
+
for (const l of licenses.components) licByKey.set(`${l.name}@${l.version}`, l);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const components = parsed.components.map((c) => {
|
|
105
|
+
const key = `${c.name}@${c.version}`;
|
|
106
|
+
const vuln = vulnByName.get(c.name) || null;
|
|
107
|
+
const lic = licByKey.get(key) || null;
|
|
108
|
+
const enr = enrichment.get(key) || {};
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
name: c.name,
|
|
112
|
+
version: c.version,
|
|
113
|
+
license: (lic && lic.license) || c.license || null,
|
|
114
|
+
licenseStatus: lic ? lic.status : (c.license ? 'ok' : 'missing'),
|
|
115
|
+
severity: vuln ? vuln.severity : 'none',
|
|
116
|
+
fixAvailable: vuln ? Boolean(vuln.fixAvailable) : false,
|
|
117
|
+
latest: enr.latest || null,
|
|
118
|
+
outdated: Boolean(enr.outdated),
|
|
119
|
+
lastPublish: enr.lastPublish || null,
|
|
120
|
+
lastCommit: enr.lastCommit || null,
|
|
121
|
+
maintainers: enr.maintainers == null ? null : enr.maintainers,
|
|
122
|
+
contributors: enr.contributors == null ? null : enr.contributors,
|
|
123
|
+
stars: enr.stars == null ? null : enr.stars,
|
|
124
|
+
deprecated: Boolean(enr.deprecated),
|
|
125
|
+
archived: Boolean(enr.archived),
|
|
126
|
+
repoUrl: enr.repoUrl || null,
|
|
127
|
+
maintenanceScore: enr.maintenanceScore == null ? null : enr.maintenanceScore,
|
|
128
|
+
maintenanceLabel: enr.maintenanceLabel || 'unknown',
|
|
129
|
+
hasHash: Boolean(c.hashValue),
|
|
130
|
+
purl: c.purl || null,
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const summary = {
|
|
135
|
+
total: components.length,
|
|
136
|
+
vulnerable: components.filter((c) => c.severity !== 'none').length,
|
|
137
|
+
critical: components.filter((c) => c.severity === 'critical').length,
|
|
138
|
+
high: components.filter((c) => c.severity === 'high').length,
|
|
139
|
+
licenseIssues: components.filter((c) => !c.license || c.licenseStatus === 'denied' || c.licenseStatus === 'not-allowed').length,
|
|
140
|
+
outdated: components.filter((c) => c.outdated).length,
|
|
141
|
+
atRisk: components.filter((c) => c.maintenanceLabel === 'at-risk').length,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
project: parsed.root,
|
|
146
|
+
generatedAt: new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC',
|
|
147
|
+
summary,
|
|
148
|
+
components: components.sort(byRisk),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function byRisk(a, b) {
|
|
153
|
+
const r = (SEVERITY_ORDER.indexOf(b.severity) - SEVERITY_ORDER.indexOf(a.severity));
|
|
154
|
+
if (r !== 0) return r;
|
|
155
|
+
return a.name.localeCompare(b.name);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function resolveOutputPath(projectRoot, output) {
|
|
159
|
+
if (output) {
|
|
160
|
+
return path.isAbsolute(output) ? output : path.join(projectRoot, output);
|
|
161
|
+
}
|
|
162
|
+
return path.join(projectRoot, 'cra-audit-report.html');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Opens a file with the OS default handler, best-effort. */
|
|
166
|
+
function openInBrowser(filePath) {
|
|
167
|
+
try {
|
|
168
|
+
let cmd;
|
|
169
|
+
let args;
|
|
170
|
+
if (process.platform === 'win32') {
|
|
171
|
+
cmd = 'cmd';
|
|
172
|
+
args = ['/c', 'start', '', filePath];
|
|
173
|
+
} else if (process.platform === 'darwin') {
|
|
174
|
+
cmd = 'open';
|
|
175
|
+
args = [filePath];
|
|
176
|
+
} else {
|
|
177
|
+
cmd = 'xdg-open';
|
|
178
|
+
args = [filePath];
|
|
179
|
+
}
|
|
180
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
181
|
+
child.on('error', () => {});
|
|
182
|
+
child.unref();
|
|
183
|
+
} catch {
|
|
184
|
+
// Ignore: the path was already printed for manual opening.
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = { visualizeCommand };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { scanVulnerabilities, SEVERITY_ORDER } = require('./vuln-scanner');
|
|
4
|
+
const { generateSbom } = require('./sbom-generator');
|
|
5
|
+
const { validateSbom } = require('./sbom-validator');
|
|
6
|
+
const { checkLicenses } = require('./license-checker');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {object} AuditResult
|
|
10
|
+
* @property {object} project
|
|
11
|
+
* @property {string|null} policySource
|
|
12
|
+
* @property {object} sections
|
|
13
|
+
* @property {{ passed: boolean, reasons: Array<{label:string, passed:boolean}> }} gate
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Runs the full CRA audit: vulnerabilities + SBOM + licenses, then evaluates
|
|
18
|
+
* the result against the configured security policy.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} projectRoot
|
|
21
|
+
* @param {object} policy
|
|
22
|
+
* @param {string|null} policySource
|
|
23
|
+
* @param {{ only?: 'vulnerabilities'|'sbom'|'licenses' }} [options]
|
|
24
|
+
* @returns {AuditResult}
|
|
25
|
+
*/
|
|
26
|
+
function runAudit(projectRoot, policy, policySource, options = {}) {
|
|
27
|
+
const { only } = options;
|
|
28
|
+
const sections = {};
|
|
29
|
+
const reasons = [];
|
|
30
|
+
|
|
31
|
+
// --- 1. Vulnerabilities -------------------------------------------------
|
|
32
|
+
if (!only || only === 'vulnerabilities') {
|
|
33
|
+
const vulns = scanVulnerabilities(projectRoot, { production: policy.productionOnly });
|
|
34
|
+
sections.vulnerabilities = vulns;
|
|
35
|
+
|
|
36
|
+
if (!vulns.ok) {
|
|
37
|
+
reasons.push({ label: `No se pudieron analizar vulnerabilidades: ${vulns.error}`, passed: false });
|
|
38
|
+
} else {
|
|
39
|
+
const blocking = countBlocking(vulns, policy);
|
|
40
|
+
reasons.push({
|
|
41
|
+
label: blocking === 0
|
|
42
|
+
? `Sin vulnerabilidades de severidad >= ${policy.failOn}`
|
|
43
|
+
: `${blocking} vulnerabilidad(es) de severidad >= ${policy.failOn}`,
|
|
44
|
+
passed: blocking === 0,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- 2. SBOM ------------------------------------------------------------
|
|
50
|
+
if (!only || only === 'sbom') {
|
|
51
|
+
const sbom = generateSbom(projectRoot, { format: policy.sbomFormat });
|
|
52
|
+
if (sbom.ok) {
|
|
53
|
+
sbom.validation = validateSbom(sbom.document);
|
|
54
|
+
}
|
|
55
|
+
sections.sbom = sbom;
|
|
56
|
+
|
|
57
|
+
if (policy.requireSbom) {
|
|
58
|
+
if (!sbom.ok) {
|
|
59
|
+
reasons.push({ label: `No se pudo generar el SBOM: ${sbom.error}`, passed: false });
|
|
60
|
+
} else {
|
|
61
|
+
reasons.push({
|
|
62
|
+
label: sbom.validation.valid
|
|
63
|
+
? 'SBOM válido según los elementos mínimos de TR-03183'
|
|
64
|
+
: 'SBOM incompleto respecto a TR-03183',
|
|
65
|
+
passed: sbom.validation.valid,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// --- 3. Licenses --------------------------------------------------------
|
|
72
|
+
if (!only || only === 'licenses') {
|
|
73
|
+
const licenses = checkLicenses(projectRoot, policy.licenses);
|
|
74
|
+
sections.licenses = licenses;
|
|
75
|
+
|
|
76
|
+
if (!licenses.ok) {
|
|
77
|
+
reasons.push({ label: `No se pudieron analizar licencias: ${licenses.error}`, passed: false });
|
|
78
|
+
} else {
|
|
79
|
+
const s = licenses.summary;
|
|
80
|
+
const missingFails = policy.licenses.failOnMissing && s.missing.length > 0;
|
|
81
|
+
const licensesPassed = s.denied.length === 0 && s.notAllowed.length === 0 && !missingFails;
|
|
82
|
+
reasons.push({
|
|
83
|
+
label: licensesPassed
|
|
84
|
+
? 'Licencias documentadas y conformes con la política'
|
|
85
|
+
: 'Problemas de licencias detectados (prohibidas, no permitidas o sin documentar)',
|
|
86
|
+
passed: licensesPassed,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
project: getProject(projectRoot, sections),
|
|
93
|
+
policySource,
|
|
94
|
+
generatedAt: new Date().toISOString(),
|
|
95
|
+
sections,
|
|
96
|
+
gate: { passed: reasons.every((r) => r.passed), reasons },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function countBlocking(vulns, policy) {
|
|
101
|
+
const threshold = SEVERITY_ORDER.indexOf(policy.failOn);
|
|
102
|
+
if (threshold === -1) return 0;
|
|
103
|
+
const allowlist = new Set((policy.vulnerabilities && policy.vulnerabilities.allowlist) || []);
|
|
104
|
+
|
|
105
|
+
let count = 0;
|
|
106
|
+
for (const vuln of vulns.vulnerabilities) {
|
|
107
|
+
if (SEVERITY_ORDER.indexOf(vuln.severity) < threshold) continue;
|
|
108
|
+
const isAllowlisted = vuln.sources.some(
|
|
109
|
+
(src) => src.url && [...allowlist].some((id) => src.url.includes(id))
|
|
110
|
+
) || allowlist.has(vuln.name);
|
|
111
|
+
if (!isAllowlisted) count++;
|
|
112
|
+
}
|
|
113
|
+
return count;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function getProject(projectRoot, sections) {
|
|
117
|
+
// Derive project identity from any section that parsed the lockfile/root.
|
|
118
|
+
if (sections.sbom && sections.sbom.ok && sections.sbom.document) {
|
|
119
|
+
const comp = sections.sbom.document.metadata && sections.sbom.document.metadata.component;
|
|
120
|
+
if (comp) return { name: comp.name, version: comp.version };
|
|
121
|
+
}
|
|
122
|
+
const path = require('node:path');
|
|
123
|
+
const { readJson } = require('../utils/fs');
|
|
124
|
+
const pkg = readJson(path.join(projectRoot, 'package.json')) || {};
|
|
125
|
+
return { name: pkg.name || path.basename(projectRoot), version: pkg.version || '0.0.0' };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = { runAudit };
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { fetchJson, mapLimit } = require('../utils/http');
|
|
4
|
+
|
|
5
|
+
const NPM_REGISTRY = 'https://registry.npmjs.org';
|
|
6
|
+
const GITHUB_API = 'https://api.github.com';
|
|
7
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Enriches lockfile components with maintenance signals so the visual report
|
|
11
|
+
* can show how well each third-party dependency is maintained:
|
|
12
|
+
* - latest published version (and whether the installed one is outdated)
|
|
13
|
+
* - last publish date
|
|
14
|
+
* - number of maintainers
|
|
15
|
+
* - repository URL
|
|
16
|
+
* - deprecation status
|
|
17
|
+
* - (optional) GitHub last commit date, contributors and stars
|
|
18
|
+
*
|
|
19
|
+
* Network failures degrade gracefully: missing data is simply left null.
|
|
20
|
+
*
|
|
21
|
+
* @param {Array<object>} components Normalized components from the lockfile.
|
|
22
|
+
* @param {{
|
|
23
|
+
* network?: boolean,
|
|
24
|
+
* github?: boolean,
|
|
25
|
+
* concurrency?: number,
|
|
26
|
+
* timeout?: number,
|
|
27
|
+
* token?: string|null,
|
|
28
|
+
* onProgress?: (done: number, total: number) => void,
|
|
29
|
+
* }} [options]
|
|
30
|
+
* @returns {Promise<Map<string, object>>} keyed by `name@version`.
|
|
31
|
+
*/
|
|
32
|
+
async function enrichComponents(components, options = {}) {
|
|
33
|
+
const {
|
|
34
|
+
network = true,
|
|
35
|
+
github = false,
|
|
36
|
+
concurrency = 8,
|
|
37
|
+
timeout = 8000,
|
|
38
|
+
token = process.env.GITHUB_TOKEN || null,
|
|
39
|
+
onProgress,
|
|
40
|
+
} = options;
|
|
41
|
+
|
|
42
|
+
const result = new Map();
|
|
43
|
+
if (!network) return result;
|
|
44
|
+
|
|
45
|
+
// Deduplicate by package name to avoid redundant registry calls.
|
|
46
|
+
const uniqueNames = [...new Set(components.map((c) => c.name))];
|
|
47
|
+
let done = 0;
|
|
48
|
+
|
|
49
|
+
const npmData = await mapLimit(uniqueNames, async (name) => {
|
|
50
|
+
const meta = await fetchNpmMeta(name, timeout);
|
|
51
|
+
done++;
|
|
52
|
+
if (onProgress) onProgress(done, uniqueNames.length);
|
|
53
|
+
return [name, meta];
|
|
54
|
+
}, concurrency);
|
|
55
|
+
|
|
56
|
+
const npmByName = new Map(npmData);
|
|
57
|
+
|
|
58
|
+
// Optional GitHub enrichment, deduplicated by repository.
|
|
59
|
+
const ghByRepo = new Map();
|
|
60
|
+
if (github) {
|
|
61
|
+
const repos = new Set();
|
|
62
|
+
for (const meta of npmByName.values()) {
|
|
63
|
+
if (meta && meta.repo) repos.add(`${meta.repo.owner}/${meta.repo.name}`);
|
|
64
|
+
}
|
|
65
|
+
const ghData = await mapLimit([...repos], async (slug) => {
|
|
66
|
+
const [owner, name] = slug.split('/');
|
|
67
|
+
const info = await fetchGithubRepo(owner, name, timeout, token);
|
|
68
|
+
return [slug, info];
|
|
69
|
+
}, Math.min(concurrency, 4));
|
|
70
|
+
for (const [slug, info] of ghData) ghByRepo.set(slug, info);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const comp of components) {
|
|
74
|
+
const npm = npmByName.get(comp.name) || null;
|
|
75
|
+
let gh = null;
|
|
76
|
+
if (npm && npm.repo) {
|
|
77
|
+
gh = ghByRepo.get(`${npm.repo.owner}/${npm.repo.name}`) || null;
|
|
78
|
+
}
|
|
79
|
+
result.set(`${comp.name}@${comp.version}`, buildSignals(comp, npm, gh));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function fetchNpmMeta(name, timeout) {
|
|
86
|
+
const encoded = name.replace('/', '%2f');
|
|
87
|
+
const res = await fetchJson(`${NPM_REGISTRY}/${encoded}`, { timeout });
|
|
88
|
+
if (!res || !res.ok || !res.json) return null;
|
|
89
|
+
|
|
90
|
+
const doc = res.json;
|
|
91
|
+
const latest = doc['dist-tags'] && doc['dist-tags'].latest;
|
|
92
|
+
const time = doc.time || {};
|
|
93
|
+
const lastPublish = time.modified || (latest && time[latest]) || null;
|
|
94
|
+
const maintainers = Array.isArray(doc.maintainers) ? doc.maintainers.length : null;
|
|
95
|
+
const repo = parseRepoUrl(doc.repository);
|
|
96
|
+
const latestVersionDoc = (doc.versions && latest && doc.versions[latest]) || {};
|
|
97
|
+
const deprecated = Boolean(latestVersionDoc.deprecated || doc.deprecated);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
latest: latest || null,
|
|
101
|
+
lastPublish,
|
|
102
|
+
maintainers,
|
|
103
|
+
repo,
|
|
104
|
+
deprecated,
|
|
105
|
+
homepage: doc.homepage || null,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function fetchGithubRepo(owner, name, timeout, token) {
|
|
110
|
+
const headers = { 'User-Agent': 'cra-audit', Accept: 'application/vnd.github+json' };
|
|
111
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
112
|
+
|
|
113
|
+
const repoRes = await fetchJson(`${GITHUB_API}/repos/${owner}/${name}`, { timeout, headers });
|
|
114
|
+
if (!repoRes || !repoRes.ok || !repoRes.json) return null;
|
|
115
|
+
|
|
116
|
+
const repo = repoRes.json;
|
|
117
|
+
let contributors = null;
|
|
118
|
+
// Contributor count is read from the Link header of a 1-per-page request.
|
|
119
|
+
const contribRes = await fetchJson(
|
|
120
|
+
`${GITHUB_API}/repos/${owner}/${name}/contributors?per_page=1&anon=1`,
|
|
121
|
+
{ timeout, headers }
|
|
122
|
+
);
|
|
123
|
+
if (contribRes && contribRes.ok) {
|
|
124
|
+
contributors = contributorsFromLink(contribRes.headers.get('link'));
|
|
125
|
+
if (contributors === null && Array.isArray(contribRes.json)) {
|
|
126
|
+
contributors = contribRes.json.length;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
lastCommit: repo.pushed_at || null,
|
|
132
|
+
stars: typeof repo.stargazers_count === 'number' ? repo.stargazers_count : null,
|
|
133
|
+
openIssues: typeof repo.open_issues_count === 'number' ? repo.open_issues_count : null,
|
|
134
|
+
archived: Boolean(repo.archived),
|
|
135
|
+
contributors,
|
|
136
|
+
url: repo.html_url || null,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Parses the `rel="last"` page number out of a GitHub Link header. */
|
|
141
|
+
function contributorsFromLink(link) {
|
|
142
|
+
if (!link) return null;
|
|
143
|
+
const match = link.match(/[?&]page=(\d+)>;\s*rel="last"/);
|
|
144
|
+
return match ? Number(match[1]) : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Extracts { owner, name } from a package.json repository field. */
|
|
148
|
+
function parseRepoUrl(repository) {
|
|
149
|
+
if (!repository) return null;
|
|
150
|
+
const url = typeof repository === 'string' ? repository : repository.url;
|
|
151
|
+
if (!url || typeof url !== 'string') return null;
|
|
152
|
+
|
|
153
|
+
const match = url.match(/github\.com[/:]([^/]+)\/([^/.#?]+)/i);
|
|
154
|
+
if (!match) return null;
|
|
155
|
+
return { owner: match[1], name: match[2].replace(/\.git$/, '') };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Computes a coarse maintenance label/score from the gathered signals.
|
|
160
|
+
* Score 0-100; label: well-maintained | moderate | at-risk | unknown.
|
|
161
|
+
*/
|
|
162
|
+
function buildSignals(comp, npm, gh) {
|
|
163
|
+
const signals = {
|
|
164
|
+
name: comp.name,
|
|
165
|
+
version: comp.version,
|
|
166
|
+
license: comp.license || null,
|
|
167
|
+
latest: npm ? npm.latest : null,
|
|
168
|
+
outdated: Boolean(npm && npm.latest && comp.version && npm.latest !== comp.version),
|
|
169
|
+
lastPublish: npm ? npm.lastPublish : null,
|
|
170
|
+
maintainers: npm ? npm.maintainers : null,
|
|
171
|
+
deprecated: npm ? npm.deprecated : false,
|
|
172
|
+
repoUrl: npm && npm.repo ? `https://github.com/${npm.repo.owner}/${npm.repo.name}` : (gh && gh.url) || null,
|
|
173
|
+
lastCommit: gh ? gh.lastCommit : null,
|
|
174
|
+
contributors: gh ? gh.contributors : null,
|
|
175
|
+
stars: gh ? gh.stars : null,
|
|
176
|
+
archived: gh ? gh.archived : false,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const { score, label } = scoreMaintenance(signals);
|
|
180
|
+
signals.maintenanceScore = score;
|
|
181
|
+
signals.maintenanceLabel = label;
|
|
182
|
+
return signals;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function scoreMaintenance(s) {
|
|
186
|
+
// No data available at all.
|
|
187
|
+
if (!s.lastPublish && !s.lastCommit && s.maintainers === null) {
|
|
188
|
+
return { score: null, label: 'unknown' };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let score = 50;
|
|
192
|
+
const recencyDate = s.lastCommit || s.lastPublish;
|
|
193
|
+
if (recencyDate) {
|
|
194
|
+
const ageDays = (Date.now() - new Date(recencyDate).getTime()) / DAY;
|
|
195
|
+
if (ageDays <= 180) score += 30;
|
|
196
|
+
else if (ageDays <= 365) score += 15;
|
|
197
|
+
else if (ageDays <= 730) score -= 5;
|
|
198
|
+
else score -= 25;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (s.maintainers !== null) {
|
|
202
|
+
if (s.maintainers >= 3) score += 10;
|
|
203
|
+
else if (s.maintainers === 1) score -= 5;
|
|
204
|
+
}
|
|
205
|
+
if (s.contributors !== null) {
|
|
206
|
+
if (s.contributors >= 20) score += 10;
|
|
207
|
+
else if (s.contributors >= 5) score += 5;
|
|
208
|
+
else if (s.contributors <= 1) score -= 5;
|
|
209
|
+
}
|
|
210
|
+
if (s.deprecated) score -= 40;
|
|
211
|
+
if (s.archived) score -= 30;
|
|
212
|
+
|
|
213
|
+
score = Math.max(0, Math.min(100, Math.round(score)));
|
|
214
|
+
let label = 'moderate';
|
|
215
|
+
if (s.deprecated || s.archived || score < 40) label = 'at-risk';
|
|
216
|
+
else if (score >= 70) label = 'well-maintained';
|
|
217
|
+
return { score, label };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = { enrichComponents };
|