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.
@@ -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
+ }
@@ -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
+ };