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
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)
|
package/bin/el-filtro.js
ADDED
|
@@ -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
|
+
}
|