el-filtro 0.2.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 +214 -0
- package/bin/el-filtro.js +2 -0
- package/dist/audit/deprecated.js +121 -0
- package/dist/audit/npm.js +231 -0
- package/dist/audit/pip.js +211 -0
- package/dist/classify.js +27 -0
- package/dist/cli.js +53 -0
- package/dist/discovery.js +61 -0
- package/dist/ecosystem.js +38 -0
- package/dist/osv.js +80 -0
- package/dist/report.js +149 -0
- package/dist/runners/exec.js +47 -0
- package/dist/scan.js +54 -0
- package/dist/types.js +1 -0
- package/package.json +28 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { classifySeverity, classifyUnknownSeverity, unknownSeverityExplanation, vulnerabilityExplanation, } from '../classify.js';
|
|
4
|
+
function asRecord(v) {
|
|
5
|
+
return v && typeof v === 'object' ? v : null;
|
|
6
|
+
}
|
|
7
|
+
function asStringArray(v) {
|
|
8
|
+
return Array.isArray(v) ? v.filter((x) => typeof x === 'string') : [];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Convierte el JSON ya parseado de `pip-audit --format json` en una vulnerabilidad por
|
|
12
|
+
* (paquete, id). Deduplica: pip-audit emite entradas repetidas de verdad (lo vi con
|
|
13
|
+
* PYSEC-2021-142 y PYSEC-2020-96 en pyyaml). Lanza si el payload no es un reporte válido
|
|
14
|
+
* para que el orquestador lo marque "no se pudo auditar" en vez de reportar 0 falsamente.
|
|
15
|
+
*/
|
|
16
|
+
export function parsePipAudit(auditJson) {
|
|
17
|
+
const root = asRecord(auditJson);
|
|
18
|
+
if (!root || !Array.isArray(root.dependencies)) {
|
|
19
|
+
throw new Error('pip-audit no devolvió un reporte válido.');
|
|
20
|
+
}
|
|
21
|
+
const seen = new Set();
|
|
22
|
+
const vulns = [];
|
|
23
|
+
for (const entry of root.dependencies) {
|
|
24
|
+
const dep = asRecord(entry);
|
|
25
|
+
if (!dep || typeof dep.name !== 'string')
|
|
26
|
+
continue;
|
|
27
|
+
const version = typeof dep.version === 'string' ? dep.version : '';
|
|
28
|
+
if (!Array.isArray(dep.vulns))
|
|
29
|
+
continue; // dependencia sin vulnerabilidades: se ignora
|
|
30
|
+
for (const rawVuln of dep.vulns) {
|
|
31
|
+
const v = asRecord(rawVuln);
|
|
32
|
+
if (!v || typeof v.id !== 'string')
|
|
33
|
+
continue;
|
|
34
|
+
const key = `${dep.name}::${v.id}`;
|
|
35
|
+
if (seen.has(key))
|
|
36
|
+
continue; // dedup
|
|
37
|
+
seen.add(key);
|
|
38
|
+
vulns.push({
|
|
39
|
+
package: dep.name,
|
|
40
|
+
version,
|
|
41
|
+
id: v.id,
|
|
42
|
+
fixVersions: asStringArray(v.fix_versions),
|
|
43
|
+
aliases: asStringArray(v.aliases),
|
|
44
|
+
description: typeof v.description === 'string' ? v.description : '',
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return vulns;
|
|
49
|
+
}
|
|
50
|
+
/** Busca la severidad por el id propio o por cualquiera de sus alias (PYSEC → GHSA). */
|
|
51
|
+
function severityFor(vuln, index) {
|
|
52
|
+
const direct = index[vuln.id];
|
|
53
|
+
if (direct)
|
|
54
|
+
return direct;
|
|
55
|
+
for (const alias of vuln.aliases) {
|
|
56
|
+
if (index[alias])
|
|
57
|
+
return index[alias];
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
// Orden de gravedad para quedarse con la peor de un paquete.
|
|
62
|
+
const SEVERITY_RANK = {
|
|
63
|
+
critical: 5,
|
|
64
|
+
high: 4,
|
|
65
|
+
moderate: 3,
|
|
66
|
+
low: 2,
|
|
67
|
+
info: 1,
|
|
68
|
+
};
|
|
69
|
+
function worstSeverity(a, b) {
|
|
70
|
+
if (!a)
|
|
71
|
+
return b;
|
|
72
|
+
if (!b)
|
|
73
|
+
return a;
|
|
74
|
+
return SEVERITY_RANK[a] >= SEVERITY_RANK[b] ? a : b;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Combina las vulnerabilidades de pip-audit con el índice de severidades de OSV para
|
|
78
|
+
* producir los Findings del contrato.
|
|
79
|
+
*
|
|
80
|
+
* AGREGA POR PAQUETE, igual que `npm audit` (que devuelve una entrada por paquete, no por
|
|
81
|
+
* advisory). pip-audit sí emite una entrada por advisory, así que sin agregar saldría el
|
|
82
|
+
* mismo paquete repetido N veces con el mismo texto: ruido en consola, conteos no
|
|
83
|
+
* comparables entre ecosistemas, y El Repuesto proponiendo el mismo reemplazo N veces.
|
|
84
|
+
* Se conserva la severidad más alta y se reúnen todos los ids de advisory.
|
|
85
|
+
*
|
|
86
|
+
* Reutiliza `classifySeverity` sin cambios, así que 🔴/🟡 significan exactamente lo mismo
|
|
87
|
+
* que en npm. Sin severidad conocida: `severity: null` y 🟡 con explicación honesta.
|
|
88
|
+
*/
|
|
89
|
+
export function enrichPipFindings(vulns, index) {
|
|
90
|
+
const porPaquete = new Map();
|
|
91
|
+
for (const v of vulns) {
|
|
92
|
+
const severity = severityFor(v, index);
|
|
93
|
+
const prev = porPaquete.get(v.package);
|
|
94
|
+
const ghsaUrls = v.aliases
|
|
95
|
+
.filter((a) => a.startsWith('GHSA-'))
|
|
96
|
+
.map((a) => `https://github.com/advisories/${a}`);
|
|
97
|
+
if (!prev) {
|
|
98
|
+
porPaquete.set(v.package, {
|
|
99
|
+
version: v.version,
|
|
100
|
+
severity,
|
|
101
|
+
fixAvailable: v.fixVersions.length > 0,
|
|
102
|
+
titles: [v.id],
|
|
103
|
+
urls: [...new Set(ghsaUrls)],
|
|
104
|
+
fixVersions: [...v.fixVersions],
|
|
105
|
+
unresolved: severity ? 0 : 1,
|
|
106
|
+
});
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
prev.severity = worstSeverity(prev.severity, severity);
|
|
110
|
+
prev.fixAvailable = prev.fixAvailable || v.fixVersions.length > 0;
|
|
111
|
+
if (!severity)
|
|
112
|
+
prev.unresolved += 1;
|
|
113
|
+
if (!prev.titles.includes(v.id))
|
|
114
|
+
prev.titles.push(v.id);
|
|
115
|
+
for (const u of ghsaUrls)
|
|
116
|
+
if (!prev.urls.includes(u))
|
|
117
|
+
prev.urls.push(u);
|
|
118
|
+
for (const f of v.fixVersions)
|
|
119
|
+
if (!prev.fixVersions.includes(f))
|
|
120
|
+
prev.fixVersions.push(f);
|
|
121
|
+
}
|
|
122
|
+
return [...porPaquete].map(([pkg, agg]) => {
|
|
123
|
+
const bucket = agg.severity ? classifySeverity(agg.severity) : classifyUnknownSeverity();
|
|
124
|
+
return {
|
|
125
|
+
type: 'vulnerability',
|
|
126
|
+
package: pkg,
|
|
127
|
+
installedVersions: agg.version ? [agg.version] : [],
|
|
128
|
+
severity: agg.severity,
|
|
129
|
+
bucket,
|
|
130
|
+
// pip-audit no distingue directa/transitiva; se audita el requirements.txt declarado.
|
|
131
|
+
direct: true,
|
|
132
|
+
fixAvailable: agg.fixAvailable,
|
|
133
|
+
explanation: agg.severity ? vulnerabilityExplanation(bucket) : unknownSeverityExplanation(),
|
|
134
|
+
advisory: {
|
|
135
|
+
titles: agg.titles,
|
|
136
|
+
urls: agg.urls,
|
|
137
|
+
range: agg.fixVersions.length > 0 ? `< ${agg.fixVersions.join(' / ')}` : '',
|
|
138
|
+
},
|
|
139
|
+
unresolvedAdvisories: agg.unresolved,
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
const REQUIREMENTS = 'requirements.txt';
|
|
144
|
+
const MISSING_PIP_AUDIT = 'falta pip-audit. Corre: pip install pip-audit';
|
|
145
|
+
/**
|
|
146
|
+
* En Windows corremos con `shell: true` (para resolver .cmd/.exe), y ahí un binario
|
|
147
|
+
* ausente NO produce ENOENT: el shell devuelve exit code con un mensaje **localizado**
|
|
148
|
+
* ("no se reconoce como un comando…" en español). Sin esto, la persona vería un error
|
|
149
|
+
* críptico en vez del comando exacto para instalarlo — justo lo que el brief §6.3 pide evitar.
|
|
150
|
+
*/
|
|
151
|
+
export function looksLikeMissingPipAudit(stderr) {
|
|
152
|
+
return /no se reconoce como un comando|is not recognized as an internal or external command|command not found/i.test(stderr);
|
|
153
|
+
}
|
|
154
|
+
function pipErrorReason(err) {
|
|
155
|
+
const e = err;
|
|
156
|
+
if (e?.code === 'ENOENT')
|
|
157
|
+
return MISSING_PIP_AUDIT;
|
|
158
|
+
return e instanceof Error ? e.message : String(err);
|
|
159
|
+
}
|
|
160
|
+
/** Recorta la salida de error de pip para un statusReason legible. */
|
|
161
|
+
function firstErrorLine(stderr) {
|
|
162
|
+
const line = stderr
|
|
163
|
+
.split(/\r?\n/)
|
|
164
|
+
.map((l) => l.trim())
|
|
165
|
+
.find((l) => /^ERROR/i.test(l));
|
|
166
|
+
return line ? line.replace(/^ERROR:\s*/i, '').slice(0, 160) : 'pip-audit falló';
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Motor pip real para UN repo (BORDE). Corre `pip-audit -r requirements.txt --format json
|
|
170
|
+
* --no-deps` y enriquece la severidad contra OSV (1 llamada por paquete vulnerable).
|
|
171
|
+
* Nunca lanza: todo fallo se traduce a ok:false para no tumbar el escaneo. Si OSV falla,
|
|
172
|
+
* el repo SÍ queda auditado — solo pierde la severidad (🟡 + explicación).
|
|
173
|
+
*/
|
|
174
|
+
export async function auditPipRepo(repoPath, run, resolveSeverities) {
|
|
175
|
+
if (!existsSync(join(repoPath, REQUIREMENTS))) {
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
reason: 'sin requirements.txt (soporte de pyproject/Poetry: no en esta versión)',
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
let result;
|
|
182
|
+
try {
|
|
183
|
+
result = await run('pip-audit', ['-r', REQUIREMENTS, '--format', 'json', '--no-deps'], {
|
|
184
|
+
cwd: repoPath,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
return { ok: false, reason: pipErrorReason(err) };
|
|
189
|
+
}
|
|
190
|
+
// Binario ausente vía shell (Windows): mensaje claro con el comando exacto.
|
|
191
|
+
if (looksLikeMissingPipAudit(result.stderr))
|
|
192
|
+
return { ok: false, reason: MISSING_PIP_AUDIT };
|
|
193
|
+
// pip-audit sale con code 1 cuando HAY vulnerabilidades: eso es normal, se parsea igual.
|
|
194
|
+
// Un fallo real (ResolutionImpossible, etc.) deja stdout vacío/no-JSON.
|
|
195
|
+
let vulns;
|
|
196
|
+
try {
|
|
197
|
+
vulns = parsePipAudit(JSON.parse(result.stdout));
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return { ok: false, reason: firstErrorLine(result.stderr) };
|
|
201
|
+
}
|
|
202
|
+
// Severidad: 1 llamada a OSV por paquete vulnerable (no por advisory).
|
|
203
|
+
const index = {};
|
|
204
|
+
const porPaquete = new Map();
|
|
205
|
+
for (const v of vulns)
|
|
206
|
+
porPaquete.set(v.package, v.version);
|
|
207
|
+
for (const [name, version] of porPaquete) {
|
|
208
|
+
Object.assign(index, await resolveSeverities(name, version));
|
|
209
|
+
}
|
|
210
|
+
return { ok: true, findings: enrichPipFindings(vulns, index) };
|
|
211
|
+
}
|
package/dist/classify.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Mapeo 100% determinístico severidad → urgencia (§6.5). Sin IA, sin API key.
|
|
2
|
+
const FIX_NOW = new Set(['critical', 'high']);
|
|
3
|
+
/** 🔴 fix-now ← critical/high · 🟡 can-wait ← moderate/low/info. */
|
|
4
|
+
export function classifySeverity(severity) {
|
|
5
|
+
return FIX_NOW.has(severity) ? 'fix-now' : 'can-wait';
|
|
6
|
+
}
|
|
7
|
+
// Plantillas fijas: explican el hallazgo en lenguaje simple, no en jerga de CVE.
|
|
8
|
+
const VULN_EXPLANATION = {
|
|
9
|
+
'fix-now': 'Esta librería tiene una vulnerabilidad conocida que se puede explotar directamente — actualízala antes de publicar algo que dependa de ella.',
|
|
10
|
+
'can-wait': 'Esta librería tiene una vulnerabilidad menor o difícil de explotar — conviene actualizarla, pero no es urgente.',
|
|
11
|
+
};
|
|
12
|
+
/** Explicación en lenguaje simple para un hallazgo de vulnerabilidad, según su bucket. */
|
|
13
|
+
export function vulnerabilityExplanation(bucket) {
|
|
14
|
+
return VULN_EXPLANATION[bucket];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Bucket cuando no se pudo determinar la gravedad (hallazgo pip cuyo advisory no tiene
|
|
18
|
+
* etiqueta de severidad, o escaneo sin red). Cae en 🟡 a propósito: sin evidencia no se
|
|
19
|
+
* infla la urgencia — el brief advierte que un falso positivo el día 1 rompe la confianza.
|
|
20
|
+
*/
|
|
21
|
+
export function classifyUnknownSeverity() {
|
|
22
|
+
return 'can-wait';
|
|
23
|
+
}
|
|
24
|
+
/** Explicación honesta para severidad desconocida: dice que hay que mirarlo a mano. */
|
|
25
|
+
export function unknownSeverityExplanation() {
|
|
26
|
+
return 'Esta librería tiene una vulnerabilidad conocida, pero no se pudo determinar qué tan grave es — revísala a mano para decidir si corre prisa.';
|
|
27
|
+
}
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { scan } from './scan.js';
|
|
4
|
+
import { auditNpmRepo } from './audit/npm.js';
|
|
5
|
+
import { auditPipRepo } from './audit/pip.js';
|
|
6
|
+
import { createDeprecationResolver } from './audit/deprecated.js';
|
|
7
|
+
import { fetchOsvSeverities } from './osv.js';
|
|
8
|
+
import { runCommand } from './runners/exec.js';
|
|
9
|
+
import { renderConsole, renderJson, writeReport } from './report.js';
|
|
10
|
+
// \r vuelve al inicio de la línea; ESC[K borra hasta el final. fromCharCode(27) es
|
|
11
|
+
// el byte ESC, escrito así para evitar ambigüedad de escape en el fuente.
|
|
12
|
+
const CLEAR_LINE = '\r' + String.fromCharCode(27) + '[K';
|
|
13
|
+
const program = new Command();
|
|
14
|
+
program
|
|
15
|
+
.name('el-filtro')
|
|
16
|
+
.description('🔎 Escanea tus repos npm/pip y te dice, en lenguaje simple, qué dependencias arreglar ya')
|
|
17
|
+
.version('0.2.0')
|
|
18
|
+
.option('--path <ruta>', 'carpeta raíz a escanear', '.')
|
|
19
|
+
.option('--json', 'salida JSON máquina-legible, sin colores ni progreso')
|
|
20
|
+
.option('--no-write', 'no escribir el reporte a disco')
|
|
21
|
+
.action(async (opts) => {
|
|
22
|
+
const root = resolve(opts.path);
|
|
23
|
+
// Un solo resolvedor para todo el escaneo: cachea packuments entre repos (la familia
|
|
24
|
+
// comparte muchas dependencias).
|
|
25
|
+
const resolveDeprecation = createDeprecationResolver();
|
|
26
|
+
const report = await scan(root, {
|
|
27
|
+
auditNpm: (repoPath) => auditNpmRepo(repoPath, runCommand, resolveDeprecation),
|
|
28
|
+
auditPip: (repoPath) => auditPipRepo(repoPath, runCommand, fetchOsvSeverities),
|
|
29
|
+
onProgress: opts.json
|
|
30
|
+
? undefined
|
|
31
|
+
: (current, total, name) => {
|
|
32
|
+
// Progreso en stderr para no ensuciar stdout (§11).
|
|
33
|
+
process.stderr.write(`${CLEAR_LINE} Escaneando repo ${current}/${total}: ${name}...`);
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
if (!opts.json)
|
|
37
|
+
process.stderr.write(CLEAR_LINE); // limpia la línea de progreso
|
|
38
|
+
if (opts.json) {
|
|
39
|
+
console.log(renderJson(report));
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
console.log(renderConsole(report, { colors: process.stdout.isTTY === true }));
|
|
43
|
+
}
|
|
44
|
+
if (opts.write !== false) {
|
|
45
|
+
const written = writeReport(report, root);
|
|
46
|
+
if (!opts.json)
|
|
47
|
+
console.log(`\nReporte guardado en: ${written}`);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
program.parseAsync().catch((err) => {
|
|
51
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
52
|
+
process.exitCode = 1;
|
|
53
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { detectEcosystem } from './ecosystem.js';
|
|
4
|
+
// Nunca se barren ni se tratan como repos candidatos.
|
|
5
|
+
const ALWAYS_IGNORE = new Set(['node_modules', '.git']);
|
|
6
|
+
function isRepo(dir) {
|
|
7
|
+
return existsSync(join(dir, '.git'));
|
|
8
|
+
}
|
|
9
|
+
/** Lee `exclude` de un `.el-filtro.json` en la raíz (config opcional, §6.1). */
|
|
10
|
+
function readConfigExcludes(root) {
|
|
11
|
+
const p = join(root, '.el-filtro.json');
|
|
12
|
+
if (!existsSync(p))
|
|
13
|
+
return [];
|
|
14
|
+
try {
|
|
15
|
+
const cfg = JSON.parse(readFileSync(p, 'utf8'));
|
|
16
|
+
return Array.isArray(cfg.exclude)
|
|
17
|
+
? cfg.exclude.filter((x) => typeof x === 'string')
|
|
18
|
+
: [];
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return []; // config rota no debe tumbar el barrido
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Barre `root` buscando repos (carpetas con `.git`). No desciende una vez hallado
|
|
26
|
+
* un repo (evita repos anidados y su node_modules) ni entra a node_modules (§6.1).
|
|
27
|
+
* Si el root mismo es un repo, lo devuelve a él. Orden determinístico.
|
|
28
|
+
*/
|
|
29
|
+
export function discoverRepos(root, opts = {}) {
|
|
30
|
+
const exclude = new Set([...(opts.exclude ?? []), ...readConfigExcludes(root)]);
|
|
31
|
+
const found = [];
|
|
32
|
+
function walk(dir) {
|
|
33
|
+
if (isRepo(dir)) {
|
|
34
|
+
found.push(dir);
|
|
35
|
+
return; // no descender dentro de un repo
|
|
36
|
+
}
|
|
37
|
+
let entries;
|
|
38
|
+
try {
|
|
39
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return; // carpeta ilegible: se ignora, el resto sigue
|
|
43
|
+
}
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
if (!entry.isDirectory())
|
|
46
|
+
continue;
|
|
47
|
+
if (ALWAYS_IGNORE.has(entry.name) || exclude.has(entry.name))
|
|
48
|
+
continue;
|
|
49
|
+
walk(join(dir, entry.name));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
walk(root);
|
|
53
|
+
// Fallback: no todo proyecto auditable es un repo git propio. Caso real: proyectos pip
|
|
54
|
+
// con requirements.txt anidados dentro del repo de otra cosa. Si el barrido no encontró
|
|
55
|
+
// NINGÚN repo, y la carpeta apuntada es en sí un proyecto, se audita esa carpeta. Así
|
|
56
|
+
// funciona "párate en tu proyecto y corre el comando". Los subproyectos anidados dentro
|
|
57
|
+
// de un repo siguen fuera de alcance (limitación conocida).
|
|
58
|
+
if (found.length === 0 && detectEcosystem(root) !== 'none')
|
|
59
|
+
return [root];
|
|
60
|
+
return found.sort();
|
|
61
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
// Marcadores de un repo Python (§6.2). npm se detecta aparte con package.json.
|
|
4
|
+
const PIP_MARKERS = ['requirements.txt', 'pyproject.toml', 'Pipfile'];
|
|
5
|
+
/**
|
|
6
|
+
* Detecta el ecosistema de un repo por sus archivos marcadores (§6.2). npm tiene
|
|
7
|
+
* precedencia: si hay package.json, es el motor de la Etapa 1. Un repo sin ninguno
|
|
8
|
+
* devuelve 'none' — se lista como "no aplica", nunca como error.
|
|
9
|
+
*/
|
|
10
|
+
export function detectEcosystem(repoPath) {
|
|
11
|
+
if (existsSync(join(repoPath, 'package.json')))
|
|
12
|
+
return 'npm';
|
|
13
|
+
if (PIP_MARKERS.some((marker) => existsSync(join(repoPath, marker))))
|
|
14
|
+
return 'pip';
|
|
15
|
+
return 'none';
|
|
16
|
+
}
|
|
17
|
+
function hasNpm(repoPath) {
|
|
18
|
+
return existsSync(join(repoPath, 'package.json'));
|
|
19
|
+
}
|
|
20
|
+
function hasPip(repoPath) {
|
|
21
|
+
return PIP_MARKERS.some((marker) => existsSync(join(repoPath, marker)));
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Ecosistemas presentes que NO ganaron la precedencia de `detectEcosystem`.
|
|
25
|
+
*
|
|
26
|
+
* La regla es explícita: npm gana cuando conviven (mismo early return de arriba, fijado por
|
|
27
|
+
* test). Pero no debe ser silenciosa — una carpeta políglota tiene dependencias del otro
|
|
28
|
+
* ecosistema sin auditar, y El Filtro las reporta en vez de esconderlas. Auditar ambos
|
|
29
|
+
* queda fuera de esta versión: `ecosystem` es un valor único del contrato que lee El Repuesto.
|
|
30
|
+
*/
|
|
31
|
+
export function detectSecondaryEcosystems(repoPath) {
|
|
32
|
+
const primary = detectEcosystem(repoPath);
|
|
33
|
+
if (primary === 'npm' && hasPip(repoPath))
|
|
34
|
+
return ['pip'];
|
|
35
|
+
if (primary === 'pip' && hasNpm(repoPath))
|
|
36
|
+
return ['npm']; // inalcanzable hoy; explícito por si cambia la precedencia
|
|
37
|
+
return [];
|
|
38
|
+
}
|
package/dist/osv.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolución de severidad para el motor pip.
|
|
3
|
+
*
|
|
4
|
+
* `pip-audit` NO expone severidad en su JSON (verificado con pip-audit 2.10.1: las únicas
|
|
5
|
+
* claves por vulnerabilidad son id/fix_versions/aliases/description, tanto con el servicio
|
|
6
|
+
* `pypi` como con `osv`). La API pública de OSV sí la trae, sin API key ni registro, y con
|
|
7
|
+
* el MISMO vocabulario que npm audit (CRITICAL/HIGH/MODERATE/LOW) — por eso `classify.ts`
|
|
8
|
+
* se reutiliza tal cual y 🔴/🟡 significan lo mismo en ambos ecosistemas.
|
|
9
|
+
*/
|
|
10
|
+
const OSV_QUERY_URL = 'https://api.osv.dev/v1/query';
|
|
11
|
+
const LABELS = {
|
|
12
|
+
critical: 'critical',
|
|
13
|
+
high: 'high',
|
|
14
|
+
moderate: 'moderate',
|
|
15
|
+
low: 'low',
|
|
16
|
+
};
|
|
17
|
+
/** Etiqueta de OSV → severidad interna. Desconocida/ausente → null (nunca inventar). */
|
|
18
|
+
export function normalizeOsvSeverity(label) {
|
|
19
|
+
if (typeof label !== 'string')
|
|
20
|
+
return null;
|
|
21
|
+
return LABELS[label.trim().toLowerCase()] ?? null;
|
|
22
|
+
}
|
|
23
|
+
function asRecord(v) {
|
|
24
|
+
return v && typeof v === 'object' ? v : null;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Construye el índice id → severidad desde un `POST /v1/query` de OSV.
|
|
28
|
+
*
|
|
29
|
+
* Clave del diseño: solo las entradas GHSA traen `database_specific.severity`; las PYSEC
|
|
30
|
+
* (que son las que devuelve pip-audit) NO. Pero ambas se referencian mutuamente vía
|
|
31
|
+
* `aliases`, así que cada severidad se indexa bajo el id propio Y bajo todos sus alias —
|
|
32
|
+
* de ahí que un id PYSEC herede la severidad de su gemelo GHSA.
|
|
33
|
+
*/
|
|
34
|
+
export function severityIndexFromOsv(osvJson) {
|
|
35
|
+
const root = asRecord(osvJson);
|
|
36
|
+
const vulns = root?.vulns;
|
|
37
|
+
if (!Array.isArray(vulns))
|
|
38
|
+
return {};
|
|
39
|
+
const index = {};
|
|
40
|
+
for (const entry of vulns) {
|
|
41
|
+
const v = asRecord(entry);
|
|
42
|
+
if (!v)
|
|
43
|
+
continue;
|
|
44
|
+
const dbSpecific = asRecord(v.database_specific);
|
|
45
|
+
const severity = normalizeOsvSeverity(dbSpecific?.severity);
|
|
46
|
+
if (!severity)
|
|
47
|
+
continue;
|
|
48
|
+
if (typeof v.id === 'string')
|
|
49
|
+
index[v.id] = severity;
|
|
50
|
+
if (Array.isArray(v.aliases)) {
|
|
51
|
+
for (const alias of v.aliases) {
|
|
52
|
+
if (typeof alias === 'string' && !index[alias])
|
|
53
|
+
index[alias] = severity;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return index;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* BORDE de red: consulta OSV por paquete+versión (1 llamada por paquete vulnerable) y
|
|
61
|
+
* devuelve el índice de severidades. Nunca lanza: si no hay red o la respuesta es rara,
|
|
62
|
+
* devuelve {} y los hallazgos quedan con severidad desconocida (🟡 + explicación), sin
|
|
63
|
+
* tumbar la auditoría del repo.
|
|
64
|
+
*/
|
|
65
|
+
export const fetchOsvSeverities = async (packageName, version) => {
|
|
66
|
+
try {
|
|
67
|
+
const res = await fetch(OSV_QUERY_URL, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
headers: { 'Content-Type': 'application/json' },
|
|
70
|
+
body: JSON.stringify({ package: { name: packageName, ecosystem: 'PyPI' }, version }),
|
|
71
|
+
signal: AbortSignal.timeout(15_000),
|
|
72
|
+
});
|
|
73
|
+
if (!res.ok)
|
|
74
|
+
return {};
|
|
75
|
+
return severityIndexFromOsv(await res.json());
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
};
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import picocolors from 'picocolors';
|
|
4
|
+
const SCHEMA_VERSION = 1;
|
|
5
|
+
const SEVERITY_ORDER = ['critical', 'high', 'moderate', 'low', 'info'];
|
|
6
|
+
/**
|
|
7
|
+
* Envuelve los resultados por repo en el contrato JSON estable (§6.6) y calcula el
|
|
8
|
+
* resumen. Función pura: recibe `now` para ser determinística en tests. Invariantes:
|
|
9
|
+
* total === fixNow + canWait, y sum(bySeverity) === # hallazgos vulnerability.
|
|
10
|
+
*/
|
|
11
|
+
export function buildReport(root, repos, now = new Date()) {
|
|
12
|
+
const allFindings = repos.flatMap((r) => r.findings);
|
|
13
|
+
const reposAudited = repos.filter((r) => r.status === 'audited').length;
|
|
14
|
+
const bySeverity = { critical: 0, high: 0, moderate: 0, low: 0, info: 0 };
|
|
15
|
+
for (const f of allFindings) {
|
|
16
|
+
if (f.type === 'vulnerability' && f.severity)
|
|
17
|
+
bySeverity[f.severity] += 1;
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
schemaVersion: SCHEMA_VERSION,
|
|
21
|
+
tool: 'el-filtro',
|
|
22
|
+
generatedAt: now.toISOString(),
|
|
23
|
+
root,
|
|
24
|
+
summary: {
|
|
25
|
+
reposScanned: repos.length,
|
|
26
|
+
reposAudited,
|
|
27
|
+
reposSkipped: repos.length - reposAudited,
|
|
28
|
+
findings: {
|
|
29
|
+
total: allFindings.length,
|
|
30
|
+
fixNow: allFindings.filter((f) => f.bucket === 'fix-now').length,
|
|
31
|
+
canWait: allFindings.filter((f) => f.bucket === 'can-wait').length,
|
|
32
|
+
},
|
|
33
|
+
bySeverity,
|
|
34
|
+
unknownSeverity: allFindings.filter((f) => f.type === 'vulnerability' && !f.severity).length,
|
|
35
|
+
abandonedPackages: allFindings.filter((f) => f.type === 'deprecated').length,
|
|
36
|
+
},
|
|
37
|
+
// Normalizado: el JSON siempre trae secondaryEcosystems, unresolvedAdvisories y fix,
|
|
38
|
+
// aunque vengan vacíos/en cero/null — contrato estable para El Repuesto. Que `fix`
|
|
39
|
+
// esté SIEMPRE presente es lo que le permite distinguir "reporte viejo sin el campo"
|
|
40
|
+
// de "hallazgo sin versión objetivo".
|
|
41
|
+
repos: repos.map((r) => ({
|
|
42
|
+
...r,
|
|
43
|
+
secondaryEcosystems: r.secondaryEcosystems ?? [],
|
|
44
|
+
findings: r.findings.map((f) => ({
|
|
45
|
+
...f,
|
|
46
|
+
unresolvedAdvisories: f.unresolvedAdvisories ?? 0,
|
|
47
|
+
fix: f.fix ?? null,
|
|
48
|
+
})),
|
|
49
|
+
})),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Salida JSON máquina-legible (el contrato que consume El Repuesto). */
|
|
53
|
+
export function renderJson(report) {
|
|
54
|
+
return JSON.stringify(report, null, 2);
|
|
55
|
+
}
|
|
56
|
+
const HEADER_BOX = [
|
|
57
|
+
'╔════════════════════════════════════════╗',
|
|
58
|
+
'║ 🔎 EL FILTRO — Dependencias ║',
|
|
59
|
+
'╚════════════════════════════════════════╝',
|
|
60
|
+
].join('\n');
|
|
61
|
+
function findingLine(repo, f) {
|
|
62
|
+
const version = f.installedVersions[0] ? ` ${f.installedVersions[0]}` : '';
|
|
63
|
+
// Una vulnerabilidad sin severidad resuelta (pip sin dato en OSV) se nombra en palabras,
|
|
64
|
+
// no con un paréntesis vacío.
|
|
65
|
+
const tag = f.type === 'deprecated' ? 'abandonado' : (f.severity ?? 'gravedad desconocida');
|
|
66
|
+
return ` • ${repo.name} › ${f.package}${version} (${tag})`;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Aviso cuando la gravedad de un paquete quedó resuelta a medias: la severidad mostrada es
|
|
70
|
+
* la más alta de las que SÍ se resolvieron, así que la real podría ser mayor. Solo se dice
|
|
71
|
+
* si además hay una severidad mostrada — si no hay ninguna, la explicación ya lo cubre.
|
|
72
|
+
*/
|
|
73
|
+
function unresolvedNote(f) {
|
|
74
|
+
const n = f.unresolvedAdvisories ?? 0;
|
|
75
|
+
if (n === 0 || !f.severity)
|
|
76
|
+
return null;
|
|
77
|
+
return `(${n} advisor${n === 1 ? 'y' : 'ies'} sin evaluar: la gravedad real podría ser mayor)`;
|
|
78
|
+
}
|
|
79
|
+
/** Resumen humano agrupado por los 2 buckets, con desglose por repo (§6.6). */
|
|
80
|
+
export function renderConsole(report, opts = {}) {
|
|
81
|
+
const pc = picocolors.createColors(opts.colors ?? false);
|
|
82
|
+
const s = report.summary;
|
|
83
|
+
const lines = [HEADER_BOX, ''];
|
|
84
|
+
lines.push(`Carpeta: ${report.root}`);
|
|
85
|
+
lines.push(`Repos escaneados: ${s.reposScanned} · auditados: ${s.reposAudited} · sin auditar: ${s.reposSkipped}`);
|
|
86
|
+
lines.push(`Hallazgos: ${s.findings.total} ${pc.red(`🔴 ${s.findings.fixNow} arréglalo ya`)} ${pc.yellow(`🟡 ${s.findings.canWait} pueden esperar`)}`);
|
|
87
|
+
const withFindings = report.repos.filter((r) => r.findings.length > 0);
|
|
88
|
+
const collect = (bucket) => withFindings.flatMap((r) => r.findings.filter((f) => f.bucket === bucket).map((f) => ({ r, f })));
|
|
89
|
+
const fixNow = collect('fix-now');
|
|
90
|
+
const canWait = collect('can-wait');
|
|
91
|
+
lines.push('', pc.red(`🔴 ARRÉGLALO YA (${fixNow.length})`));
|
|
92
|
+
if (fixNow.length === 0)
|
|
93
|
+
lines.push(pc.dim(' (nada urgente)'));
|
|
94
|
+
for (const { r, f } of fixNow) {
|
|
95
|
+
lines.push(findingLine(r, f));
|
|
96
|
+
lines.push(pc.dim(` ${f.explanation}`));
|
|
97
|
+
const nota = unresolvedNote(f);
|
|
98
|
+
if (nota)
|
|
99
|
+
lines.push(pc.dim(` ${nota}`));
|
|
100
|
+
}
|
|
101
|
+
lines.push('', pc.yellow(`🟡 PUEDEN ESPERAR (${canWait.length})`));
|
|
102
|
+
if (canWait.length === 0)
|
|
103
|
+
lines.push(pc.dim(' (nada por aquí)'));
|
|
104
|
+
for (const { r, f } of canWait) {
|
|
105
|
+
lines.push(findingLine(r, f));
|
|
106
|
+
const nota = unresolvedNote(f);
|
|
107
|
+
if (nota)
|
|
108
|
+
lines.push(pc.dim(` ${nota}`));
|
|
109
|
+
}
|
|
110
|
+
const notAudited = report.repos.filter((r) => r.status === 'no-audit');
|
|
111
|
+
if (notAudited.length > 0) {
|
|
112
|
+
lines.push('', 'Repos sin auditar:');
|
|
113
|
+
for (const r of notAudited) {
|
|
114
|
+
lines.push(` • ${r.name} — ${r.statusReason ?? 'razón desconocida'}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const pending = report.repos.filter((r) => r.status === 'pending');
|
|
118
|
+
if (pending.length > 0) {
|
|
119
|
+
lines.push('', 'Repos pendientes:');
|
|
120
|
+
for (const r of pending) {
|
|
121
|
+
lines.push(` • ${r.name} — ${r.statusReason ?? 'pendiente'}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// La precedencia de ecosistema no es silenciosa: si el repo también tiene dependencias
|
|
125
|
+
// del otro ecosistema, se dice. Esconderlas sería ocultar deuda sin auditar.
|
|
126
|
+
const poliglotas = report.repos.filter((r) => (r.secondaryEcosystems ?? []).length > 0);
|
|
127
|
+
if (poliglotas.length > 0) {
|
|
128
|
+
lines.push('', 'Con dependencias de otro ecosistema (no auditadas en esta versión):');
|
|
129
|
+
for (const r of poliglotas) {
|
|
130
|
+
lines.push(` • ${r.name} — también tiene dependencias ${r.secondaryEcosystems.join(', ')}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return lines.join('\n');
|
|
134
|
+
}
|
|
135
|
+
function safeTimestamp(iso) {
|
|
136
|
+
return iso.replace(/[:.]/g, '-'); // Windows no permite ':' en nombres de archivo
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Escribe el reporte JSON en `<baseDir>/.el-filtro/report-<ts>.json` y devuelve la
|
|
140
|
+
* ruta escrita. Borde de I/O — cubierto por su test de temp dir y por e2e.
|
|
141
|
+
*/
|
|
142
|
+
export function writeReport(report, baseDir) {
|
|
143
|
+
const dir = join(baseDir, '.el-filtro');
|
|
144
|
+
mkdirSync(dir, { recursive: true });
|
|
145
|
+
const file = join(dir, `report-${safeTimestamp(report.generatedAt)}.json`);
|
|
146
|
+
writeFileSync(file, renderJson(report));
|
|
147
|
+
return file;
|
|
148
|
+
}
|
|
149
|
+
export { SEVERITY_ORDER };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adaptador de I/O para correr procesos reales (npm). Es el BORDE: no se testea por
|
|
3
|
+
* unidad; la lógica que lo usa se prueba con fakes de esta misma firma, y este
|
|
4
|
+
* adaptador queda cubierto por el e2e y la demo real.
|
|
5
|
+
*
|
|
6
|
+
* Nunca rechaza por exit code ≠ 0 (npm audit sale con 1 cuando HAY vulnerabilidades,
|
|
7
|
+
* lo cual es normal): devuelve el `code` para que la capa de arriba decida. Solo
|
|
8
|
+
* rechaza si el binario no se pudo lanzar (ENOENT). Mismo patrón que Las Llantas.
|
|
9
|
+
*/
|
|
10
|
+
import { execFile } from 'node:child_process';
|
|
11
|
+
/** true si el error es "no se pudo lanzar el binario" (binario ausente). */
|
|
12
|
+
function isSpawnFailure(err) {
|
|
13
|
+
const e = err;
|
|
14
|
+
return typeof e?.code === 'string' && typeof e.syscall === 'string' && e.syscall.startsWith('spawn');
|
|
15
|
+
}
|
|
16
|
+
// Solo tokens simples: letras, dígitos y `-_@:./\+=~,`. Sin espacios ni metacaracteres.
|
|
17
|
+
const SAFE_ARG = /^[\w@:./\\+=~,-]*$/;
|
|
18
|
+
export function isShellSafeArg(arg) {
|
|
19
|
+
return SAFE_ARG.test(arg);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Con shell:true (necesario en Windows para resolver npm.cmd) Node NO escapa los
|
|
23
|
+
* argumentos. Esta barrera impide que un valor externo se vuelva un comando aparte
|
|
24
|
+
* vía `&`, `;`, `|`… El cwd (que sí puede tener espacios) va como opción, no como arg.
|
|
25
|
+
*/
|
|
26
|
+
function assertShellSafe(command, args) {
|
|
27
|
+
for (const value of [command, ...args]) {
|
|
28
|
+
if (!isShellSafeArg(value)) {
|
|
29
|
+
throw new Error(`Argumento no seguro para exec (posible inyección de comandos): ${JSON.stringify(value)}.`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export const runCommand = (command, args, options = {}) => {
|
|
34
|
+
assertShellSafe(command, args);
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
execFile(command, args, { cwd: options.cwd, shell: process.platform === 'win32', maxBuffer: 32 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
37
|
+
if (error && isSpawnFailure(error)) {
|
|
38
|
+
reject(error);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const code = error && typeof error.code === 'number'
|
|
42
|
+
? error.code
|
|
43
|
+
: 0;
|
|
44
|
+
resolve({ code, stdout: String(stdout), stderr: String(stderr) });
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
};
|