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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Charly.marketing
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,214 @@
1
+ # 🔎 El Filtro
2
+
3
+ **Escanea todos tus repos de una pasada y te dice, en lenguaje simple, qué dependencias hay que arreglar ya y cuáles pueden esperar.**
4
+
5
+ Reemplaza el ritual de entrar repo por repo, correr `npm audit`, y descifrar qué tan grave es cada hallazgo. El Filtro descubre tus proyectos, corre la auditoría que toque (npm o pip), detecta paquetes abandonados, y traduce todo a **dos niveles de urgencia** — sin jerga de CVE.
6
+
7
+ Es 100% determinístico: **sin LLM, sin API key, sin registro**.
8
+
9
+ Forma parte del kit de **Charly.marketing**:
10
+
11
+ | Herramienta | Qué hace |
12
+ |---|---|
13
+ | El Freno de Mano | Previene |
14
+ | El Doctor | Diagnostica la salud del proyecto |
15
+ | La Alarma | Busca secretos expuestos |
16
+ | **El Filtro** | **Audita las dependencias** |
17
+ | El Repuesto | Propone los reemplazos |
18
+
19
+ ## Instalación y uso
20
+
21
+ ```bash
22
+ npx el-filtro # escanea desde la carpeta actual
23
+ npx el-filtro --path ./mis-proyectos # escanea otra ruta
24
+ npx el-filtro --json # salida JSON máquina-legible
25
+ npx el-filtro --no-write # no escribe el reporte a disco
26
+ ```
27
+
28
+ **Cero configuración.** Te paras en tu carpeta de proyectos, corres el comando, y ya.
29
+
30
+ Para auditar repos Python necesitas `pip-audit` (no viene con pip):
31
+
32
+ ```bash
33
+ pip install pip-audit
34
+ ```
35
+
36
+ Si falta, El Filtro te lo dice con el comando exacto y **salta solo ese repo** — el resto del escaneo sigue.
37
+
38
+ ## Qué hace
39
+
40
+ 1. **Descubre** todos los repos con `.git` bajo la carpeta que le indiques, sin bajar a `node_modules` ni a repos anidados.
41
+ 2. **Detecta el ecosistema** de cada uno (npm, pip, o ninguno).
42
+ 3. **Audita**: `npm audit` o `pip-audit`, según toque.
43
+ 4. **Detecta paquetes abandonados** (flag `deprecated` del registro de npm).
44
+ 5. **Clasifica** cada hallazgo en 🔴 o 🟡 con una explicación de una línea.
45
+ 6. **Reporta** en consola y en `.el-filtro/report-<timestamp>.json`.
46
+
47
+ ### Los dos niveles
48
+
49
+ | | Cuándo |
50
+ |---|---|
51
+ | 🔴 **Arréglalo ya** | Vulnerabilidad `critical` o `high` · paquete abandonado sin reemplazo conocido |
52
+ | 🟡 **Puede esperar** | Vulnerabilidad `moderate` o `low` · paquete abandonado que sí señala reemplazo · gravedad que no se pudo determinar |
53
+
54
+ ## Configuración opcional
55
+
56
+ Un `.el-filtro.json` en la raíz escaneada permite excluir carpetas:
57
+
58
+ ```json
59
+ { "exclude": ["archivado", "experimentos"] }
60
+ ```
61
+
62
+ ## El reporte JSON
63
+
64
+ Es el contrato estable que consume **El Repuesto**. `schemaVersion: 1`.
65
+
66
+ ```jsonc
67
+ {
68
+ "schemaVersion": 1,
69
+ "tool": "el-filtro",
70
+ "generatedAt": "2026-07-24T19:30:14.735Z",
71
+ "root": "C:\\...\\proyectos",
72
+ "summary": {
73
+ "reposScanned": 22, "reposAudited": 22, "reposSkipped": 0,
74
+ "findings": { "total": 53, "fixNow": 28, "canWait": 25 },
75
+ "bySeverity": { "critical": 8, "high": 19, "moderate": 25, "low": 0, "info": 0 },
76
+ "unknownSeverity": 0, // vulnerabilidades sin gravedad determinada
77
+ "abandonedPackages": 1
78
+ },
79
+ "repos": [{
80
+ "name": "mi-repo",
81
+ "path": "C:\\...\\mi-repo",
82
+ "ecosystem": "npm", // npm | pip | none
83
+ "secondaryEcosystems": [], // otros ecosistemas presentes, NO auditados
84
+ "status": "audited", // audited | no-audit | not-applicable
85
+ "statusReason": null,
86
+ "findings": [{
87
+ "type": "vulnerability", // vulnerability | deprecated
88
+ "package": "lodash",
89
+ "installedVersions": ["4.17.11"],
90
+ "severity": "critical", // null si no se pudo determinar, o si es deprecated
91
+ "bucket": "fix-now", // fix-now | can-wait — SIEMPRE presente
92
+ "direct": true,
93
+ "fixAvailable": true,
94
+ "fix": { // QUÉ instalar; null si npm no nombró versión
95
+ "package": "lodash", // ojo: puede ser OTRO paquete (ver abajo)
96
+ "version": "4.18.1",
97
+ "isSemVerMajor": false // true = el salto puede romper
98
+ },
99
+ "explanation": "Esta librería tiene una vulnerabilidad conocida...",
100
+ "advisory": { "titles": [...], "urls": [...], "range": "<4.17.21" },
101
+ "unresolvedAdvisories": 0 // advisories sin gravedad resuelta dentro de este hallazgo
102
+ }]
103
+ }]
104
+ }
105
+ ```
106
+
107
+ **Para quien consuma este JSON:** `severity` puede venir en `null` aunque el hallazgo sea una vulnerabilidad (ver limitaciones). `bucket` siempre viene poblado — úsalo como señal principal.
108
+
109
+ ### `fixAvailable` + `fix`: tres estados, no dos
110
+
111
+ Los dos campos se leen juntos. Colapsarlos pierde información en dos direcciones distintas:
112
+
113
+ | `fixAvailable` | `fix` | Qué significa |
114
+ |---|---|---|
115
+ | `true` | objeto | Hay que instalar **algo distinto de lo declarado**: npm nombra qué y qué versión. |
116
+ | `true` | `null` | Se arregla **dentro del rango que ya declaras** (`npm update <paquete>`). npm no nombra versión porque no hace falta. Es el arreglo **más barato**, no uno dudoso. |
117
+ | `false` | `null` | No hay versión segura publicada. |
118
+
119
+ **`fix.package` puede no ser el paquete vulnerable.** En un fallo transitivo npm apunta al padre que hay que subir: `esbuild` vulnerable → `fix.package: "vitest"`. Recomendar el paquete vulnerable ahí mandaría a tocar algo que ni está en el `package.json`.
120
+
121
+ El campo `fix` es **aditivo** al `schemaVersion: 1` y siempre viene presente (en `null` si no aplica). Su ausencia significa que el reporte lo generó una versión anterior de El Filtro.
122
+
123
+ 📄 **Ejemplo completo:** [`examples/report-ejemplo.json`](examples/report-ejemplo.json) — generado de corridas reales (rutas saneadas). Cubre los tres estados de repo, los dos tipos de hallazgo, un caso de gravedad sin resolver y un repo políglota.
124
+
125
+ ### Invariantes garantizados
126
+
127
+ - `findings.total === findings.fixNow + findings.canWait`
128
+ - `suma(bySeverity) + unknownSeverity === número de hallazgos type:"vulnerability"`
129
+ - `reposAudited + reposSkipped === reposScanned`
130
+
131
+ ## ⚠️ Limitaciones conocidas
132
+
133
+ ### La detección de "¿hay reemplazo?" es imperfecta — y falla a propósito hacia 🔴
134
+
135
+ Cuando un paquete está marcado como `deprecated`, El Filtro decide entre 🔴 y 🟡 según si el mensaje **nombra un reemplazo**. Ese mensaje es **texto libre** que cada quien escribe distinto (`"Use uuid module instead"`, `"we recommend using babel-preset-env now"`, `"replaced by X"`…), así que la detección es por patrones y **no puede ser perfecta**.
136
+
137
+ **El sesgo es deliberado: ante la duda, 🔴.** Si El Filtro no reconoce la frase, asume que **no hay salida conocida** y marca el hallazgo como urgente. Preferimos molestarte de más a hacerte creer que hay una alternativa cuando no la hay.
138
+
139
+ En concreto:
140
+ - Se descartan primero los contextos negativos (`"do not use"`, `"use at your own risk"`, `"no longer in use"`) — ahí no hay reemplazo, hay advertencia.
141
+ - Solo cuentan marcadores explícitos: `use X instead`, `recommend using X`, `replaced by X`, `superseded by X`, `in favor of X`, `migrate to X`, `switch to X`.
142
+ - Cualquier otra redacción → 🔴.
143
+
144
+ Si ves un 🔴 por abandono, **lee el mensaje original** en `advisory.titles[0]`: puede que sí haya reemplazo escrito de una forma que no reconocimos.
145
+
146
+ ### La gravedad en pip no viene de pip-audit
147
+
148
+ `pip-audit` **no expone severidad** en su JSON (ni con el servicio `pypi` ni con `osv`): solo devuelve `id`, `fix_versions`, `aliases` y `description`. El Filtro la resuelve consultando la **API pública de OSV** (sin API key), cruzando por el alias `GHSA` de cada advisory.
149
+
150
+ Consecuencias:
151
+ - **Sin conexión**, o si un advisory no tiene etiqueta de gravedad, el hallazgo queda con `severity: null` → 🟡 y lo dice explícitamente.
152
+ - Si un paquete tiene varios advisories y solo algunos resuelven, se usa **la gravedad más alta conocida** y se reporta cuántos quedaron sin evaluar (`unresolvedAdvisories`), porque la gravedad real podría ser mayor.
153
+
154
+ ### Otras
155
+
156
+ - **Paquetes abandonados: solo npm.** PyPI no tiene un flag `deprecated` estándar equivalente.
157
+ - **Solo dependencias directas** se revisan por abandono (las de `package.json`) — son las que puedes reemplazar de verdad.
158
+ - **Solo la señal `deprecated` explícita.** No usamos "meses sin publicar" como señal: marcaría como muerto algo que solo está maduro y estable.
159
+ - **Monorepos / workspaces**: los subproyectos anidados *dentro* de un repo git no se auditan por separado.
160
+ - **Una carpeta con `package.json` y `requirements.txt` a la vez** se audita como npm; el otro ecosistema se reporta en `secondaryEcosystems` pero **no se audita**.
161
+ - **`pyproject.toml` / Poetry sin `requirements.txt`**: no soportado todavía; el repo se marca como no auditado con la razón.
162
+
163
+ ## Cómo se comporta cuando algo falla
164
+
165
+ Un repo que no se puede auditar **nunca tumba el escaneo**: se marca `no-audit` con la razón en lenguaje simple y el resto continúa. Vale para: falta `pip-audit`, no hay lockfile y no hay red, `requirements.txt` con versiones en conflicto, o registro caído.
166
+
167
+ Si un repo npm **no tiene lockfile**, El Filtro genera uno en una **carpeta temporal** para poder auditar — **nunca modifica tu repo**.
168
+
169
+ ## Desarrollo
170
+
171
+ ```bash
172
+ npm install
173
+ npm test # build + suite completa (TDD, fixtures reales como oráculo)
174
+ npm run test:watch
175
+ ```
176
+
177
+ ### Nota de verificación del campo `fix` (2026-07-26)
178
+
179
+ El end-to-end que valida los tres estados de `fixAvailable`/`fix` se corrió con la variable
180
+ de entorno `npm_config_registry` apuntando a un proxy local de tránsito, en vez de ir directo
181
+ al registro. Queda escrito para que no haya duda de qué se probó y en qué condiciones.
182
+
183
+ **Por qué:** en esa fecha, `npm audit` fallaba desde este entorno contra
184
+ `registry.npmjs.org/-/npm/v1/security/advisories/bulk`:
185
+
186
+ | Cliente | Resultado |
187
+ |---|---|
188
+ | npm 11.8.0 · Node v24.13.1 (Windows 11) | `invalid json response body` — respuesta gzip que el cliente no decodifica |
189
+ | npm 11.4.2 (vía `npx`) | idéntico |
190
+ | npm 9 y npm 10 (vía `npx`) | `400 Bad Request` contra el endpoint legado `/-/npm/v1/security/audits/quick` |
191
+ | npm 10.8.2 · Node v20.20.2 (VPS Ubuntu, **otra red**) | mismo 400; y npm 11 en esa máquina, mismo error de gzip |
192
+ | `curl` con las mismas cabeceras, en ambas redes | **HTTP 200 con JSON correcto en texto plano** |
193
+
194
+ Descartado que fuera la red (falla igual en dos redes y dos versiones de Node) y que fuera el
195
+ registro (el status oficial reportaba Security Audit operativo, y `curl` obtenía la respuesta
196
+ correcta). El fallo está en la capa HTTP del cliente npm.
197
+
198
+ **Qué hacía el proxy:** reenviar cada petición a `registry.npmjs.org` pidiendo
199
+ `Accept-Encoding: identity` y devolver la respuesta sin tocarla. No cachea, no altera ni
200
+ sustituye datos — **los advisories siguen viniendo del registro real**. Solo cambia la
201
+ compresión de un salto de transporte.
202
+
203
+ **Qué NO se tocó:** la variable se exportó únicamente para los procesos de esa corrida.
204
+ No se modificó `.npmrc` ni ninguna configuración de la máquina (`npm config get registry`
205
+ siguió devolviendo `https://registry.npmjs.org/` durante y después).
206
+
207
+ **Qué se verificó así:** los 24 repos del corpus se auditaron (0 sin auditar) y los tres
208
+ estados aparecieron en datos frescos — 46 hallazgos con `fix` poblado, 11 con `fixAvailable:
209
+ true` y `fix: null` (caso `postcss`), y el resto sin arreglo. Los tests unitarios usan
210
+ fixtures capturados de corridas reales y no dependen del proxy.
211
+
212
+ ## Licencia
213
+
214
+ MIT © [Charly.marketing](https://charly.marketing)
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../dist/cli.js';
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Detección de paquetes abandonados (§6.4 del brief).
3
+ *
4
+ * Solo la **Señal 1**: el flag `deprecated` explícito del registro de npm — 100% confiable,
5
+ * lo pone quien mantiene el paquete. La Señal 2 (umbral de meses sin publicar) queda fuera
6
+ * a propósito: marcaría como muerto algo que solo está maduro y estable, y un falso positivo
7
+ * el día 1 rompe la confianza más rápido de lo que la construye un acierto.
8
+ */
9
+ const REGISTRY = 'https://registry.npmjs.org';
10
+ // Packument abreviado: mucho más liviano que el completo y trae `deprecated` por versión.
11
+ const ABBREVIATED = 'application/vnd.npm.install-v1+json';
12
+ function asRecord(v) {
13
+ return v && typeof v === 'object' ? v : null;
14
+ }
15
+ // Contextos NEGATIVOS: el mensaje usa "use/using" para advertir, no para ofrecer salida
16
+ // ("Do not use this package", "use at your own risk"). Si aparece uno, no hay alternativa.
17
+ const NEGATIVE_CONTEXT = /\b(?:do\s+not|don'?t|never|avoid|no\s+longer|not)\s+(?:use|using)\b|\bat\s+your\s+own\s+risk\b|\bin\s+use\b/i;
18
+ // Un token de paquete: nombre npm (admite @scope/nombre, guiones, puntos).
19
+ const PKG = String.raw `[\w@][\w@/.-]*`;
20
+ /**
21
+ * Formas EXPLÍCITAS de nombrar un reemplazo. Deliberadamente estrictas: cada patrón exige
22
+ * un marcador inequívoco ("instead", "replaced by", "in favor of"…) además del nombre del
23
+ * paquete. No basta con que aparezca el verbo "use" suelto.
24
+ */
25
+ const REPLACEMENT_PATTERNS = [
26
+ new RegExp(String.raw `\buse\s+${PKG}(?:\s+\w+){0,3}\s+instead\b`, 'i'), // "Use uuid module instead"
27
+ new RegExp(String.raw `\brecommend(?:s|ed)?\s+using\s+${PKG}`, 'i'), // "we recommend using babel-preset-env"
28
+ new RegExp(String.raw `\breplaced\s+by\s+${PKG}`, 'i'),
29
+ new RegExp(String.raw `\bsuperseded\s+by\s+${PKG}`, 'i'),
30
+ new RegExp(String.raw `\bin\s+favou?r\s+of\s+${PKG}`, 'i'),
31
+ new RegExp(String.raw `\bmigrate\s+to\s+${PKG}`, 'i'),
32
+ new RegExp(String.raw `\bswitch\s+to\s+${PKG}`, 'i'),
33
+ ];
34
+ /**
35
+ * ¿El mensaje de deprecación nombra un reemplazo? Determinístico, sin IA.
36
+ *
37
+ * SESGO DELIBERADO: el mensaje es texto libre que cada quien escribe distinto, así que esta
38
+ * detección es imposible de hacer perfecta. Ante la duda se responde `false` → el hallazgo
39
+ * cae en 🔴 (más urgente de lo necesario). El error inaceptable es el contrario: dar 🟡 y
40
+ * hacer creer que hay salida cuando no la hay. Por eso primero se descartan los contextos
41
+ * negativos y luego se exige un marcador explícito de reemplazo.
42
+ */
43
+ export function hasKnownAlternative(message) {
44
+ if (typeof message !== 'string' || message.trim() === '')
45
+ return false;
46
+ if (NEGATIVE_CONTEXT.test(message))
47
+ return false;
48
+ return REPLACEMENT_PATTERNS.some((re) => re.test(message));
49
+ }
50
+ /** Mensaje de deprecación de una versión concreta, o null si esa versión no está marcada. */
51
+ export function deprecatedFromPackument(packument, version) {
52
+ const versions = asRecord(asRecord(packument)?.versions);
53
+ const entry = asRecord(versions?.[version]);
54
+ const deprecated = entry?.deprecated;
55
+ return typeof deprecated === 'string' && deprecated.trim() !== '' ? deprecated : null;
56
+ }
57
+ /**
58
+ * Dependencias DIRECTAS declaradas en package.json (prod + dev).
59
+ *
60
+ * Solo las directas: son las que de verdad puedes cambiar, y son las que El Repuesto podrá
61
+ * proponer reemplazar. Revisar el árbol completo dispararía cientos de consultas al registro
62
+ * por repo para hallazgos sobre los que no puedes actuar directamente.
63
+ */
64
+ export function directDependencies(packageJson) {
65
+ const pkg = asRecord(packageJson);
66
+ const names = new Set();
67
+ for (const field of ['dependencies', 'devDependencies']) {
68
+ const deps = asRecord(pkg?.[field]);
69
+ if (!deps)
70
+ continue;
71
+ for (const name of Object.keys(deps))
72
+ names.add(name);
73
+ }
74
+ return [...names];
75
+ }
76
+ /**
77
+ * Construye el hallazgo de paquete abandonado. Regla de bucket del brief §6.5:
78
+ * 🔴 deprecated SIN alternativa conocida (estás varado) · 🟡 CON alternativa (hay a dónde ir).
79
+ * `severity` es null a propósito: estar abandonado no es una vulnerabilidad con gravedad.
80
+ */
81
+ export function buildDeprecatedFinding(packageName, version, message) {
82
+ const conAlternativa = hasKnownAlternative(message);
83
+ return {
84
+ type: 'deprecated',
85
+ package: packageName,
86
+ installedVersions: version ? [version] : [],
87
+ severity: null,
88
+ bucket: conAlternativa ? 'can-wait' : 'fix-now',
89
+ direct: true,
90
+ fixAvailable: conAlternativa,
91
+ explanation: conAlternativa
92
+ ? 'Quien mantiene esta librería la dio por abandonada y señala con qué reemplazarla — cambia cuando puedas, no corre prisa hoy.'
93
+ : 'Quien mantiene esta librería la dio por abandonada y no señala reemplazo: ya no recibirá parches de seguridad, así que conviene salir de ella.',
94
+ advisory: { titles: [message], urls: [], range: '' },
95
+ unresolvedAdvisories: 0,
96
+ };
97
+ }
98
+ /**
99
+ * BORDE de red: consulta el registro de npm (sin credenciales) y cachea el packument en
100
+ * memoria durante el escaneo — la familia de repos comparte muchas dependencias, así que
101
+ * la caché evita repetir la misma consulta repo tras repo. Nunca lanza: sin red devuelve
102
+ * null y simplemente no se reportan abandonados.
103
+ */
104
+ export function createDeprecationResolver() {
105
+ const cache = new Map();
106
+ return async (packageName, version) => {
107
+ try {
108
+ if (!cache.has(packageName)) {
109
+ const res = await fetch(`${REGISTRY}/${encodeURIComponent(packageName)}`, {
110
+ headers: { Accept: ABBREVIATED },
111
+ signal: AbortSignal.timeout(15_000),
112
+ });
113
+ cache.set(packageName, res.ok ? await res.json() : null);
114
+ }
115
+ return deprecatedFromPackument(cache.get(packageName), version);
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ };
121
+ }
@@ -0,0 +1,231 @@
1
+ import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { classifySeverity, vulnerabilityExplanation } from '../classify.js';
5
+ import { buildDeprecatedFinding, directDependencies, } from './deprecated.js';
6
+ function asRecord(v) {
7
+ return v && typeof v === 'object' ? v : null;
8
+ }
9
+ const SEVERITIES = new Set(['critical', 'high', 'moderate', 'low', 'info']);
10
+ function asSeverity(v) {
11
+ return SEVERITIES.has(v) ? v : 'info';
12
+ }
13
+ function asStringArray(v) {
14
+ return Array.isArray(v) ? v.filter((x) => typeof x === 'string') : [];
15
+ }
16
+ /**
17
+ * `fixAvailable` de npm audit tiene tres formas: objeto (hay que instalar algo distinto),
18
+ * `true` pelado (basta actualizar dentro del rango declarado) y `false` (no hay arreglo).
19
+ * Solo la primera trae versión objetivo; las otras dos devuelven null y se distinguen
20
+ * entre sí por el booleano `fixAvailable`.
21
+ */
22
+ function asFixTarget(v) {
23
+ const fix = asRecord(v);
24
+ if (!fix || typeof fix.name !== 'string' || typeof fix.version !== 'string')
25
+ return null;
26
+ return {
27
+ package: fix.name,
28
+ version: fix.version,
29
+ isSemVerMajor: fix.isSemVerMajor === true,
30
+ };
31
+ }
32
+ /**
33
+ * Convierte el JSON ya parseado de `npm audit --json` (auditReportVersion 2, npm v7+)
34
+ * en un hallazgo por paquete vulnerable. Lanza si el input no es un reporte válido
35
+ * (p.ej. `{error:...}` cuando falta el lockfile) para que el orquestador lo marque
36
+ * como "no se pudo auditar" en vez de reportar 0 vulnerabilidades falsamente.
37
+ */
38
+ export function parseNpmAudit(auditJson) {
39
+ const root = asRecord(auditJson);
40
+ if (!root || 'error' in root || !asRecord(root.vulnerabilities)) {
41
+ throw new Error('npm audit no devolvió un reporte válido (¿falta el lockfile o hubo un error?).');
42
+ }
43
+ const vulnerabilities = root.vulnerabilities;
44
+ const findings = [];
45
+ for (const [name, raw] of Object.entries(vulnerabilities)) {
46
+ const v = asRecord(raw);
47
+ if (!v)
48
+ continue;
49
+ // `via` mezcla strings (paquetes transitivos que introducen el fallo) y objetos
50
+ // advisory {title,url,severity,range}. Solo los objetos traen título/url.
51
+ const titles = [];
52
+ const urls = [];
53
+ if (Array.isArray(v.via)) {
54
+ for (const entry of v.via) {
55
+ const adv = asRecord(entry);
56
+ if (!adv)
57
+ continue; // entrada string → se ignora aquí
58
+ if (typeof adv.title === 'string' && !titles.includes(adv.title))
59
+ titles.push(adv.title);
60
+ if (typeof adv.url === 'string' && !urls.includes(adv.url))
61
+ urls.push(adv.url);
62
+ }
63
+ }
64
+ findings.push({
65
+ package: name,
66
+ severity: asSeverity(v.severity),
67
+ direct: v.isDirect === true,
68
+ fixAvailable: Boolean(v.fixAvailable),
69
+ fix: asFixTarget(v.fixAvailable),
70
+ range: typeof v.range === 'string' ? v.range : '',
71
+ nodes: asStringArray(v.nodes),
72
+ titles,
73
+ urls,
74
+ });
75
+ }
76
+ return findings;
77
+ }
78
+ /**
79
+ * Resuelve la(s) versión(es) instalada(s) hoy leyendo el `package-lock.json`
80
+ * (lockfileVersion 2/3), donde `packages` está indexado por el path exacto que
81
+ * `npm audit` reporta en `nodes` (p.ej. "node_modules/axios"). Devuelve versiones
82
+ * únicas; ignora paths ausentes. Es el dato puntual que consume El Repuesto.
83
+ */
84
+ export function installedVersionsFromLockfile(lockJson, nodes) {
85
+ const lock = asRecord(lockJson);
86
+ const packages = asRecord(lock?.packages);
87
+ if (!packages)
88
+ return [];
89
+ const versions = [];
90
+ for (const node of nodes) {
91
+ const entry = asRecord(packages[node]);
92
+ const version = entry?.version;
93
+ if (typeof version === 'string' && !versions.includes(version))
94
+ versions.push(version);
95
+ }
96
+ return versions;
97
+ }
98
+ /**
99
+ * Combina el parseo del audit con las versiones del lockfile y la clasificación
100
+ * determinística para producir los Findings finales del contrato (§6.5, §6.6).
101
+ * Pura: recibe ambos JSON ya parseados; la orquestación de I/O vive en auditNpmRepo.
102
+ */
103
+ export function enrichFindings(auditJson, lockJson) {
104
+ return parseNpmAudit(auditJson).map((v) => {
105
+ const bucket = classifySeverity(v.severity);
106
+ return {
107
+ type: 'vulnerability',
108
+ package: v.package,
109
+ installedVersions: installedVersionsFromLockfile(lockJson, v.nodes),
110
+ severity: v.severity,
111
+ bucket,
112
+ direct: v.direct,
113
+ fixAvailable: v.fixAvailable,
114
+ fix: v.fix,
115
+ explanation: vulnerabilityExplanation(bucket),
116
+ advisory: { titles: v.titles, urls: v.urls, range: v.range },
117
+ };
118
+ });
119
+ }
120
+ /**
121
+ * Hallazgos de paquetes abandonados de un repo npm (§6.4): recorre las dependencias
122
+ * DIRECTAS del package.json, resuelve la versión instalada en el lockfile y pregunta al
123
+ * registro si esa versión está marcada como deprecated. Un paquete sin versión en el
124
+ * lockfile se omite (no se puede afirmar qué versión tienes). Nunca lanza: si el
125
+ * resolvedor falla (sin red), simplemente no se reportan abandonados.
126
+ */
127
+ export async function deprecatedFindingsFor(packageJson, lockJson, resolve) {
128
+ const findings = [];
129
+ for (const name of directDependencies(packageJson)) {
130
+ const [version] = installedVersionsFromLockfile(lockJson, [`node_modules/${name}`]);
131
+ if (!version)
132
+ continue;
133
+ try {
134
+ const message = await resolve(name, version);
135
+ if (message)
136
+ findings.push(buildDeprecatedFinding(name, version, message));
137
+ }
138
+ catch {
139
+ // Sin red o registro caído: se omite este paquete, el resto del escaneo sigue.
140
+ }
141
+ }
142
+ return findings;
143
+ }
144
+ const LOCKFILES = ['package-lock.json', 'npm-shrinkwrap.json'];
145
+ function findLockfile(dir) {
146
+ for (const name of LOCKFILES) {
147
+ const p = join(dir, name);
148
+ if (existsSync(p))
149
+ return p;
150
+ }
151
+ return null;
152
+ }
153
+ function npmErrorReason(err) {
154
+ const e = err;
155
+ if (e?.code === 'ENOENT')
156
+ return 'npm no está disponible en el PATH';
157
+ return e instanceof Error ? e.message : String(err);
158
+ }
159
+ /**
160
+ * Corre `npm audit --json` en `dir` y arma los Findings usando el lockfile dado.
161
+ * `packageJsonPath` es siempre el del repo REAL (no la copia temporal), y si se pasa un
162
+ * resolvedor se suman los hallazgos de paquetes abandonados (§6.4).
163
+ */
164
+ async function runAuditIn(dir, lockPath, run, packageJsonPath, resolveDeprecation) {
165
+ let result;
166
+ try {
167
+ result = await run('npm', ['audit', '--json'], { cwd: dir });
168
+ }
169
+ catch (err) {
170
+ return { ok: false, reason: npmErrorReason(err) };
171
+ }
172
+ // npm audit sale con code 1 cuando HAY vulnerabilidades: se parsea el stdout igual.
173
+ let findings;
174
+ let lockJson;
175
+ try {
176
+ const auditJson = JSON.parse(result.stdout);
177
+ lockJson = JSON.parse(readFileSync(lockPath, 'utf8'));
178
+ findings = enrichFindings(auditJson, lockJson);
179
+ }
180
+ catch {
181
+ return { ok: false, reason: 'npm audit no devolvió un reporte válido' };
182
+ }
183
+ // Paquetes abandonados: se suman a los hallazgos de vulnerabilidad. Que falle esta parte
184
+ // (sin red) no invalida la auditoría de vulnerabilidades que ya salió bien.
185
+ if (resolveDeprecation) {
186
+ try {
187
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
188
+ findings = [...findings, ...(await deprecatedFindingsFor(packageJson, lockJson, resolveDeprecation))];
189
+ }
190
+ catch {
191
+ // package.json ilegible o registro caído: se reporta lo que sí se pudo.
192
+ }
193
+ }
194
+ return { ok: true, findings };
195
+ }
196
+ /**
197
+ * Motor npm real para UN repo (BORDE). Si hay lockfile, audita in-place (audit es
198
+ * solo lectura). Si falta, usa una COPIA TEMPORAL: copia package.json a un dir temp,
199
+ * genera ahí el lockfile con `npm install --package-lock-only --ignore-scripts`,
200
+ * audita y borra — sin tocar el repo del usuario (decisión confirmada del brief).
201
+ * Nunca lanza: todo fallo se traduce a ok:false para no tumbar el escaneo (§7).
202
+ */
203
+ export async function auditNpmRepo(repoPath, run, resolveDeprecation) {
204
+ const packageJsonPath = join(repoPath, 'package.json');
205
+ const existingLock = findLockfile(repoPath);
206
+ if (existingLock) {
207
+ return runAuditIn(repoPath, existingLock, run, packageJsonPath, resolveDeprecation);
208
+ }
209
+ if (!existsSync(packageJsonPath)) {
210
+ return { ok: false, reason: 'no hay package.json para auditar' };
211
+ }
212
+ let tmp = null;
213
+ try {
214
+ tmp = mkdtempSync(join(tmpdir(), 'el-filtro-audit-'));
215
+ copyFileSync(join(repoPath, 'package.json'), join(tmp, 'package.json'));
216
+ await run('npm', ['install', '--package-lock-only', '--ignore-scripts'], { cwd: tmp });
217
+ const tmpLock = findLockfile(tmp);
218
+ if (!tmpLock) {
219
+ return { ok: false, reason: 'no se pudo generar el lockfile (¿sin conexión?). Corre: npm install' };
220
+ }
221
+ // El package.json que se lee para deprecated es el del repo real, no la copia temporal.
222
+ return await runAuditIn(tmp, tmpLock, run, packageJsonPath, resolveDeprecation);
223
+ }
224
+ catch (err) {
225
+ return { ok: false, reason: npmErrorReason(err) };
226
+ }
227
+ finally {
228
+ if (tmp)
229
+ rmSync(tmp, { recursive: true, force: true });
230
+ }
231
+ }