@theoptimalpartner/npm-security-kit 2.0.0-beta.2 → 2.0.0-beta.4
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/CHANGELOG.md +33 -6
- package/README.md +54 -17
- package/package.json +1 -1
- package/src/checks/malware.js +2 -2
- package/src/checks/node-version.js +39 -12
- package/src/checks/npm-policy.js +34 -4
- package/src/checks/vulnerabilities.js +32 -1
- package/src/cli.js +5 -1
- package/src/context.js +2 -0
- package/src/init.js +110 -25
- package/src/lib/audit.js +16 -3
- package/src/lib/node-calendar.js +31 -3
- package/src/lib/node-target.js +55 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,11 +6,38 @@ Cambios del paquete `@theoptimalpartner/npm-security-kit`. Formato basado en
|
|
|
6
6
|
## [2.0.0] - sin publicar
|
|
7
7
|
|
|
8
8
|
Reescritura completa. La v2 es una CLI de Node sin dependencias que corre en el pipeline **antes de `npm ci`**.
|
|
9
|
-
Sale primero como beta (`2.0.0-beta.N`), con el dist-tag `beta`.
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
Sale primero como beta (`2.0.0-beta.N`), con el dist-tag `beta`.
|
|
10
|
+
|
|
11
|
+
### Betas
|
|
12
|
+
|
|
13
|
+
- **2.0.0-beta.1:** el step del pipeline e `init` usan `npx --yes --min-release-age=0`: `npx` no aplica
|
|
14
|
+
`min-release-age-exclude` y, sin el flag, una versión nueva del kit no se puede usar hasta cumplir 7 días.
|
|
15
|
+
- **2.0.0-beta.2:** `init` propone el Node que ya usa el repo si es una LTS con soporte, en vez de la LTS más nueva:
|
|
16
|
+
un `engines.node` más alto que el Node del repo rompe su `npm ci` con `engine-strict`.
|
|
17
|
+
- **2.0.0-beta.3** (ajustes de una prueba en un repo real):
|
|
18
|
+
- `node-version` da error en una línea de Node que no trae npm >= 11.17 en ninguna release (la 22 trae npm 10),
|
|
19
|
+
con la LTS a la que subir.
|
|
20
|
+
- `init` solo propone una línea de Node cuya release LTS traiga npm >= 11.17: un repo en la 22 (npm 10) recibe la
|
|
21
|
+
24, con la explicación. La imagen es la release LTS más nueva de esa línea, sin parches de seguridad pendientes.
|
|
22
|
+
- `npm-policy` dice qué imagen trae npm 11 (`node:24.22.1 (npm 11.19.1) o más nueva`) y qué líneas no.
|
|
23
|
+
- `init` dice qué líneas agregar al step que ya corre `npm ci`, sin reemplazarlo, y arma la condition con las
|
|
24
|
+
ramas de `pipelines.branches`.
|
|
25
|
+
- `vulnerabilities` avisa las `critical` que están solo en devDependencies (no bloquean) y sugiere `npm audit fix`.
|
|
26
|
+
- Las recetas de lockfile usan `npm install --package-lock-only --ignore-scripts` (sin el flag, con
|
|
27
|
+
`strict-allow-scripts=true` npm corta con `ESTRICTALLOWSCRIPTS`).
|
|
28
|
+
- README: los ejemplos usan la versión del paquete (la `2.0.0` todavía no existe), `install-scripts ls` necesita
|
|
29
|
+
`node_modules`, y `approve` fija la versión (`--no-allow-scripts-pin` para no fijarla).
|
|
30
|
+
- **2.0.0-beta.4** (ajustes de una segunda prueba):
|
|
31
|
+
- Un solo destino cuando hay que cambiar de línea (sin soporte, no LTS o sin npm 11), en `check`, `init` y el
|
|
32
|
+
mensaje de npm: la LTS con soporte más baja que trae npm 11, en la release más nueva que ya tiene imagen en
|
|
33
|
+
Docker Hub. Antes se proponían imágenes que todavía no existían, y la 18 y la 22 recibían destinos distintos.
|
|
34
|
+
- `init` no propone cambiar la imagen exacta que ya usa el pipeline si es de esa línea, salvo un parche de
|
|
35
|
+
seguridad que ya tenga imagen.
|
|
36
|
+
- La LTS nueva sin imagen oficial se informa sin avisar, como la que no tiene release LTS.
|
|
37
|
+
- En local, si la línea de Node no trae npm 11, el consejo es cambiar de línea con `nvm` (npm 11 sobre esa línea
|
|
38
|
+
da EBADENGINE con el `engines.node` de `init`). Los pasos siguientes de `init` incluyen el cambio de línea, el
|
|
39
|
+
lockfile y el `prepare` del proyecto; la sección del pipeline menciona `custom: ci`.
|
|
40
|
+
- `check` sin `package.json` termina con exit 2 ("no es un proyecto npm"), como `init`.
|
|
14
41
|
|
|
15
42
|
### BREAKING
|
|
16
43
|
|
|
@@ -41,7 +68,7 @@ nueva: un `engines.node` más alto que el Node del repo rompe su `npm ci` con `e
|
|
|
41
68
|
`scripts/security-check.sh`, `.husky/` (y el `"prepare": "husky install"`), los scripts `security:*` del
|
|
42
69
|
`package.json` y el `SECURITY.md` generado.
|
|
43
70
|
2. Si la v1 reemplazó el `bitbucket-pipelines.yml`, recuperar el del repo desde git.
|
|
44
|
-
3. Correr `npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit
|
|
71
|
+
3. Correr `npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@<versión> init` (la del README) y seguir los pasos que imprime (README).
|
|
45
72
|
|
|
46
73
|
## [1.0.2] y anteriores
|
|
47
74
|
|
package/README.md
CHANGED
|
@@ -3,13 +3,16 @@
|
|
|
3
3
|
Reglas de seguridad de dependencias npm en un solo comando que corre en el pipeline **antes de `npm ci`**. No tiene
|
|
4
4
|
dependencias: `npx` descarga un solo tarball y revisa el lockfile antes de que se ejecute un solo install script.
|
|
5
5
|
|
|
6
|
+
Los ejemplos usan la versión de este paquete (`2.0.0-beta.4`). Las publicadas, con sus dist-tags:
|
|
7
|
+
`npm view @theoptimalpartner/npm-security-kit dist-tags`.
|
|
8
|
+
|
|
6
9
|
## Qué revisa
|
|
7
10
|
|
|
8
11
|
| Chequeo | Qué mira | Error (bloquea) | Aviso |
|
|
9
12
|
|---|---|---|---|
|
|
10
13
|
| `malware` | `npm audit` sobre **todo** el lockfile, incluidas las devDependencies | Cualquier advisory de malware (CWE-506), con cualquier severidad | — |
|
|
11
|
-
| `vulnerabilities` | `npm audit --omit=dev` | Vulnerabilidades `critical` en producción | Resumen de las `high` |
|
|
12
|
-
| `node-version` | `.nvmrc`, `.node-version`, `.tool-versions`, `engines.node`, `volta.node`, `FROM node:` y `image:` del pipeline | Línea de Node sin soporte
|
|
14
|
+
| `vulnerabilities` | `npm audit --omit=dev` y, para las de desarrollo, el de todo el árbol | Vulnerabilidades `critical` en producción | Resumen de las `high` en producción; `critical` solo en devDependencies |
|
|
15
|
+
| `node-version` | `.nvmrc`, `.node-version`, `.tool-versions`, `engines.node`, `volta.node`, `FROM node:` y `image:` del pipeline | Línea de Node sin soporte, que no es LTS o que no trae npm >= 11.17 en ninguna release (Node 22 trae npm 10) | Hay una LTS más nueva (mientras no tenga su primera release LTS, solo se informa), pocos días de soporte, parche de seguridad pendiente, versiones mayores distintas, nada fijado |
|
|
13
16
|
| `npm-policy` | `.npmrc`, `engines.npm`, npm en uso, lockfile | Faltan `min-release-age`, `engine-strict` o `strict-allow-scripts`; npm < 11.17; no hay lockfile | Faltan `min-release-age-exclude[]`, `save-exact` o `audit-level`; lockfile < v3 |
|
|
14
17
|
| `install-scripts` | Paquetes con install scripts del lockfile contra `allowScripts` | Alguno sin aprobar (con `strict-allow-scripts`) | Alguno sin aprobar con el override por variable de entorno |
|
|
15
18
|
| `dockerfile` | `COPY` del `.npmrc` antes de `npm ci`, `.dockerignore` | Instala sin el `.npmrc` | Usa `npm install` en vez de `npm ci`; el `.dockerignore` deja fuera el lockfile |
|
|
@@ -40,12 +43,20 @@ audit-level=critical
|
|
|
40
43
|
|
|
41
44
|
Además, `engines.npm` en `>=11.17`: npm 10 ignora `min-release-age` sin avisar y no aplica `allowScripts`.
|
|
42
45
|
|
|
46
|
+
**Node y npm:** ninguna release de Node 20 ni de Node 22 trae npm 11 (traen npm 10). Un repo en la 22 tiene que
|
|
47
|
+
subir a una LTS que traiga npm 11. `check` da error en cada lugar que fija la 22 (`.nvmrc`, `engines.node`, `FROM`,
|
|
48
|
+
`image:`) e `init` propone la línea nueva. Cuando hay que cambiar de línea (sin soporte, no LTS o sin npm 11), el
|
|
49
|
+
destino es uno solo: la LTS con soporte más baja que trae npm 11 (hoy la 24), en la release más nueva que **ya tiene
|
|
50
|
+
imagen oficial en Docker Hub** (las imágenes salen días después que la release en nodejs.org). En local, cambiar de
|
|
51
|
+
línea con `nvm install 24 && nvm use 24`: npm 11 sobre la 22 no alcanza, porque `engines.node` pide la 24.
|
|
52
|
+
|
|
43
53
|
## Uso
|
|
44
54
|
|
|
45
55
|
### En el pipeline
|
|
46
56
|
|
|
47
|
-
|
|
48
|
-
|
|
57
|
+
En el step de test que ya existe, **sin reemplazarlo**: el kit antes de `npm ci` y `npm audit signatures` después.
|
|
58
|
+
El step corre solo en los PR a las ramas que despliegan (las de `pipelines.branches`). `init` imprime qué agregar y
|
|
59
|
+
en qué línea. Ejemplo de `bitbucket-pipelines.yml` (el pipeline que lee el chequeo de Node):
|
|
49
60
|
|
|
50
61
|
```yaml
|
|
51
62
|
definitions:
|
|
@@ -57,10 +68,10 @@ definitions:
|
|
|
57
68
|
- npm
|
|
58
69
|
script:
|
|
59
70
|
# Antes de instalar: malware, vulnerabilidades, Node, .npmrc, install scripts, Dockerfiles
|
|
60
|
-
- npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0 check
|
|
71
|
+
- npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.4 check
|
|
61
72
|
- npm ci # con strict-allow-scripts, los scripts no aprobados cortan acá
|
|
62
73
|
- npm audit signatures
|
|
63
|
-
|
|
74
|
+
# … y lo que ya corría el step: build, lint, tests
|
|
64
75
|
|
|
65
76
|
pipelines:
|
|
66
77
|
pull-requests:
|
|
@@ -68,23 +79,28 @@ pipelines:
|
|
|
68
79
|
- step:
|
|
69
80
|
<<: *test
|
|
70
81
|
condition:
|
|
71
|
-
|
|
82
|
+
# Las ramas de pipelines.branches (las que despliegan)
|
|
83
|
+
state: BITBUCKET_PR_DESTINATION_BRANCH == "main" || BITBUCKET_PR_DESTINATION_BRANCH == "develop"
|
|
84
|
+
custom:
|
|
85
|
+
ci: # para correrlo a mano ("Run pipeline")
|
|
86
|
+
- step: *test
|
|
72
87
|
```
|
|
73
88
|
|
|
74
89
|
- **Antes de `npm ci`:** `npm audit` funciona solo con el lockfile, así que el malware se detecta antes de que
|
|
75
90
|
corra su `postinstall`.
|
|
76
|
-
- **Versión fija** (`@2.0.0`): subir la política en un repo es un cambio visible en su PR.
|
|
91
|
+
- **Versión fija** (`@2.0.0-beta.4`): subir la política en un repo es un cambio visible en su PR.
|
|
77
92
|
- **`--min-release-age=0` solo para `npx`:** `npx` no aplica `min-release-age-exclude` (npm 11.19.1 lo aplica al
|
|
78
93
|
instalar, no al resolver el paquete de `npx`). Sin el flag, dentro de un repo con `min-release-age=7` una versión
|
|
79
94
|
nueva del kit no se puede usar hasta cumplir 7 días (`ETARGET … with a date before`). Afecta solo al kit: la
|
|
80
95
|
versión va fija y no tiene dependencias. El `npm ci` que sigue aplica los 7 días como siempre.
|
|
81
96
|
- **`npm audit signatures`** va después de `npm ci` porque necesita `node_modules`.
|
|
82
|
-
- Para guardar el resultado como artefacto: `npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0 check --json > npm-security-kit.json`.
|
|
97
|
+
- Para guardar el resultado como artefacto: `npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.4 check --json > npm-security-kit.json`.
|
|
83
98
|
|
|
84
99
|
### Dockerfile
|
|
85
100
|
|
|
86
101
|
El `.npmrc` se copia con el lockfile, antes de `npm ci`. Si no, dentro de la imagen no aplican
|
|
87
|
-
`strict-allow-scripts`, `min-release-age` ni `engine-strict
|
|
102
|
+
`strict-allow-scripts`, `min-release-age` ni `engine-strict`. El `FROM` tiene que traer npm 11 (Node 24 o más nueva;
|
|
103
|
+
ver **Node y npm** más arriba):
|
|
88
104
|
|
|
89
105
|
```dockerfile
|
|
90
106
|
COPY package.json package-lock.json .npmrc ./
|
|
@@ -98,20 +114,22 @@ Si el `.dockerignore` excluye el `.npmrc` (por ejemplo con `.*`), agregar `!.npm
|
|
|
98
114
|
Una vez por repo, en una rama:
|
|
99
115
|
|
|
100
116
|
```bash
|
|
101
|
-
npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0 init --dry-run
|
|
117
|
+
npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.4 init --dry-run
|
|
102
118
|
```
|
|
103
119
|
|
|
104
120
|
```bash
|
|
105
|
-
npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0 init
|
|
121
|
+
npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.4 init
|
|
106
122
|
```
|
|
107
123
|
|
|
108
124
|
`init`:
|
|
109
125
|
|
|
110
126
|
- agrega al `.npmrc` las claves de la política que faltan (con un comentario) y corrige las que tienen otro valor,
|
|
111
127
|
sin borrar ni reordenar nada;
|
|
112
|
-
- completa `engines.npm` (`>=11.17`) y, solo si falta, `engines.node
|
|
128
|
+
- completa `engines.npm` (`>=11.17`) y, solo si falta, `engines.node` (con la línea de Node del repo, si trae npm 11;
|
|
129
|
+
si no, la siguiente que lo trae);
|
|
113
130
|
- **no aprueba install scripts**: lista los pendientes y los comandos para revisarlos;
|
|
114
|
-
- **no edita** `bitbucket-pipelines.yml` ni los Dockerfiles:
|
|
131
|
+
- **no edita** `bitbucket-pipelines.yml` ni los Dockerfiles: dice qué líneas agregar al step que corre `npm ci`, la
|
|
132
|
+
condition con las ramas que despliegan y los `COPY` corregidos;
|
|
115
133
|
- se puede correr de nuevo sin cambiar nada.
|
|
116
134
|
|
|
117
135
|
Después: revisar el diff, aprobar o negar los install scripts, aplicar el step y el Dockerfile, y correr `check`.
|
|
@@ -119,7 +137,7 @@ Después: revisar el diff, aprobar o negar los install scripts, aplicar el step
|
|
|
119
137
|
### En local
|
|
120
138
|
|
|
121
139
|
```bash
|
|
122
|
-
npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0 check
|
|
140
|
+
npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.4 check
|
|
123
141
|
```
|
|
124
142
|
|
|
125
143
|
No se instala como devDependency ni usa husky: el `.npmrc` protege el `npm install` local y el pipeline es el
|
|
@@ -144,10 +162,12 @@ Dockerfile y pipeline) va una sola vez, con la lista de archivos.
|
|
|
144
162
|
|
|
145
163
|
### `npm ci` falla con EUSAGE (lockfile incompleto)
|
|
146
164
|
|
|
147
|
-
Pasa con lockfiles generados con npm 10, a los que les faltan opcionales de plataforma. Regenerarlo con npm 11
|
|
165
|
+
Pasa con lockfiles generados con npm 10, a los que les faltan opcionales de plataforma. Regenerarlo con npm 11, sin
|
|
166
|
+
scripts (con `strict-allow-scripts=true`, sin `--ignore-scripts` npm corta con `ESTRICTALLOWSCRIPTS` antes de
|
|
167
|
+
regenerarlo):
|
|
148
168
|
|
|
149
169
|
```bash
|
|
150
|
-
npm install --package-lock-only
|
|
170
|
+
npm install --package-lock-only --ignore-scripts
|
|
151
171
|
```
|
|
152
172
|
|
|
153
173
|
### Parche urgente con menos de 7 días
|
|
@@ -170,6 +190,17 @@ npm install-scripts approve esbuild # baja el binario de su plataforma: lo nec
|
|
|
170
190
|
npm install-scripts deny core-js # solo imprime un mensaje: no hace falta
|
|
171
191
|
```
|
|
172
192
|
|
|
193
|
+
`npm install-scripts ls` lee `node_modules`: si el `npm ci --ignore-scripts` falló (por ejemplo con EUSAGE), dice que
|
|
194
|
+
no hay nada pendiente. La lista de `check` e `init` sale del lockfile y no depende de eso.
|
|
195
|
+
|
|
196
|
+
`--ignore-scripts` tampoco corre los scripts del propio proyecto, como el `prepare` que instala husky: si el repo los
|
|
197
|
+
usa, `npm run prepare` después (si no, el hook de git falla en el próximo commit).
|
|
198
|
+
|
|
199
|
+
`approve` guarda la versión exacta (`"paquete@1.2.3": true`): cuando se actualiza el paquete, `check` vuelve a dar
|
|
200
|
+
error hasta aprobar la versión nueva. Es a propósito (cada versión nueva se revisa). Para un paquete de confianza que
|
|
201
|
+
se actualiza seguido, `npm install-scripts approve --no-allow-scripts-pin <paquete>` lo aprueba sin versión. `deny`
|
|
202
|
+
siempre guarda el nombre sin versión.
|
|
203
|
+
|
|
173
204
|
Se aprueba lo que necesita compilar o bajar binarios para funcionar, y se niega lo que es solo telemetría o
|
|
174
205
|
publicidad. El kit no aprueba nada en automático. Queda en `allowScripts` del `package.json`. Reglas (las mismas de
|
|
175
206
|
npm):
|
|
@@ -190,6 +221,12 @@ Si un repo no puede adoptar todavía la aprobación de install scripts, se defin
|
|
|
190
221
|
Con el override, npm ejecuta los scripts sin aprobar y el kit los informa como **aviso**, más un aviso de que el
|
|
191
222
|
override está activo. Dentro del `docker build` la variable no llega sola: hay que pasarla como `ARG`.
|
|
192
223
|
|
|
224
|
+
### Vulnerabilidades `critical` en devDependencies
|
|
225
|
+
|
|
226
|
+
No bloquean (no llegan a producción), pero corren en el pipeline y en las máquinas de los devs: `check` las informa
|
|
227
|
+
como aviso. Casi siempre las arregla `npm audit fix` (sin `--force`, que puede subir de major), o subir la
|
|
228
|
+
dependencia que las trae.
|
|
229
|
+
|
|
193
230
|
### El registro de npm no responde
|
|
194
231
|
|
|
195
232
|
El chequeo de malware falla cerrado: `no se pudo consultar los advisories del registro (…); reintentar el step`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@theoptimalpartner/npm-security-kit",
|
|
3
|
-
"version": "2.0.0-beta.
|
|
3
|
+
"version": "2.0.0-beta.4",
|
|
4
4
|
"description": "Revisa el lockfile antes de npm ci: malware, vulnerabilidades, versión de Node, .npmrc, install scripts y Dockerfiles",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/checks/malware.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Malware en todo el árbol (incluidas las devDependencies), con cualquier severidad.
|
|
2
2
|
// GitHub publica malware como `high` (debug@4.4.2): un umbral de severidad lo deja pasar.
|
|
3
|
-
import { AuditUnavailableError, advisoriesOf, advisoryKey, isMalware, lockfileName, runAudit, unavailableFinding } from '../lib/audit.js';
|
|
3
|
+
import { AuditUnavailableError, FULL_TREE, advisoriesOf, advisoryKey, isMalware, lockfileName, runAudit, unavailableFinding } from '../lib/audit.js';
|
|
4
4
|
|
|
5
5
|
export const id = 'malware';
|
|
6
6
|
export const title = 'Malware (todo el árbol, cualquier severidad)';
|
|
@@ -23,7 +23,7 @@ export async function run(ctx) {
|
|
|
23
23
|
if (!lockfile) return []; // sin lockfile no hay audit; lo informa npm-policy
|
|
24
24
|
let report;
|
|
25
25
|
try {
|
|
26
|
-
report = await runAudit(ctx,
|
|
26
|
+
report = await runAudit(ctx, FULL_TREE);
|
|
27
27
|
} catch (error) {
|
|
28
28
|
if (error instanceof AuditUnavailableError) return [unavailableFinding(error)];
|
|
29
29
|
throw error;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
// Versión de Node: pasa cualquier LTS con soporte (Active o Maintenance)
|
|
2
|
-
// más nueva, si quedan pocos días de soporte o si hay
|
|
3
|
-
// sin soporte
|
|
1
|
+
// Versión de Node: pasa cualquier LTS con soporte (Active o Maintenance) cuyas releases traigan el npm
|
|
2
|
+
// que pide la política; se avisa si hay una LTS más nueva, si quedan pocos días de soporte o si hay
|
|
3
|
+
// parches de seguridad pendientes. Una línea sin soporte, que no es LTS o que no trae ese npm (la 22
|
|
4
|
+
// trae npm 10) es error. Todo sale del calendario de nodejs.org.
|
|
4
5
|
import { findDockerfiles, nodeImageTag, parseStages } from '../lib/dockerfile.js';
|
|
5
6
|
import { jsonKeyLine } from '../lib/json-lines.js';
|
|
6
7
|
import { loadCalendar } from '../lib/node-calendar.js';
|
|
7
|
-
import {
|
|
8
|
+
import { imageExists, newestWithImage, upgradeTarget } from '../lib/node-target.js';
|
|
9
|
+
import { compareVersions, isRange, minVersion, parseVersion } from '../lib/semver.js';
|
|
8
10
|
|
|
9
11
|
export const id = 'node-version';
|
|
10
12
|
export const title = 'Versión de Node';
|
|
@@ -145,7 +147,10 @@ export function resolvePin(pin, cal) {
|
|
|
145
147
|
return { major: v[0], exact: parts === 3 ? v.join('.') : undefined };
|
|
146
148
|
}
|
|
147
149
|
|
|
148
|
-
|
|
150
|
+
// `target` es el destino único cuando hay que cambiar de línea (lib/node-target.js). En los pines de
|
|
151
|
+
// Docker se propone su imagen, que ya existe en Docker Hub.
|
|
152
|
+
async function evaluate(ctx, pin, r, cal, { target, newestLtsWithoutImage }) {
|
|
153
|
+
const { policy } = ctx;
|
|
149
154
|
const base = { file: pin.file, line: pin.line };
|
|
150
155
|
if (r.otherImage) {
|
|
151
156
|
return { ...base, level: 'info', message: `${pin.spec}: imagen sin Node fijado (la versión de Node la decide la imagen).`, data: { pin: pin.pin } };
|
|
@@ -154,9 +159,9 @@ function evaluate(pin, r, cal, policy) {
|
|
|
154
159
|
|
|
155
160
|
const sch = cal.lines.get(r.major);
|
|
156
161
|
const prefix = `${r.range ? 'el rango admite desde ' : ''}v${r.major}${r.floating ? ` (${r.floating})` : ''}`;
|
|
157
|
-
const rec = cal.recommended;
|
|
158
|
-
const engineHint = pin.kind === 'engines' ? ` (en engines: ">=${parseVersion(rec)[0]}")` : '';
|
|
159
|
-
const data = { pin: pin.pin, major: r.major, phase: cal.phaseOf(r.major), recommended:
|
|
162
|
+
const rec = target ? (pin.kind === 'docker' ? target.image : target.version) : cal.recommended;
|
|
163
|
+
const engineHint = pin.kind === 'engines' ? ` (en engines: ">=${target ? target.major : parseVersion(rec)[0]}")` : '';
|
|
164
|
+
const data = { pin: pin.pin, major: r.major, phase: cal.phaseOf(r.major), recommended: target?.version ?? cal.recommended };
|
|
160
165
|
if (!sch) return { ...base, level: 'warn', message: `${prefix}: no aparece en el calendario de Node.`, data };
|
|
161
166
|
|
|
162
167
|
const phase = cal.phaseOf(r.major);
|
|
@@ -167,6 +172,18 @@ function evaluate(pin, r, cal, policy) {
|
|
|
167
172
|
const why = sch.lts ? `todavía no es LTS (lo será el ${sch.lts})` : 'línea impar, nunca va a ser LTS';
|
|
168
173
|
return { ...base, level: 'error', message: `${prefix}: no es LTS, ${why}. Usar ${rec}${engineHint}.`, data };
|
|
169
174
|
}
|
|
175
|
+
// La política pide npm >= minNpmVersion y la imagen de Node trae el suyo: una línea que no lo trae en
|
|
176
|
+
// ninguna release (la 22 trae npm 10) deja el pipeline y el docker build con npm 10.
|
|
177
|
+
const lineNpm = cal.npmOfLine(r.major);
|
|
178
|
+
if (lineNpm && parseVersion(lineNpm) && compareVersions(lineNpm, policy.minNpmVersion) < 0) {
|
|
179
|
+
const to = target?.npm ? `${rec} (npm ${target.npm})` : rec;
|
|
180
|
+
return {
|
|
181
|
+
...base,
|
|
182
|
+
level: 'error',
|
|
183
|
+
message: `${prefix}: trae npm ${lineNpm}, y ninguna release de la ${r.major} trae npm >= ${policy.minNpmVersion}, que pide la política. Subir a una LTS con npm 11: ${to} o más nueva${engineHint}.`,
|
|
184
|
+
data: { ...data, npm: lineNpm },
|
|
185
|
+
};
|
|
186
|
+
}
|
|
170
187
|
|
|
171
188
|
const notes = [];
|
|
172
189
|
// Mientras la LTS nueva no tiene su primera release LTS nadie puede migrar: se informa sin avisar.
|
|
@@ -176,6 +193,8 @@ function evaluate(pin, r, cal, policy) {
|
|
|
176
193
|
const n = cal.lines.get(cal.newestLts);
|
|
177
194
|
if (cal.newestLtsWithoutRelease) {
|
|
178
195
|
infoNotes.push(`Hay una LTS nueva (v${cal.newestLts}, desde ${n.lts}), aún sin release LTS: planificar la migración cuando salga.`);
|
|
196
|
+
} else if (newestLtsWithoutImage) {
|
|
197
|
+
infoNotes.push(`Hay una LTS nueva (v${cal.newestLts}, desde ${n.lts}), aún sin imagen oficial en Docker Hub: planificar la migración cuando salga.`);
|
|
179
198
|
} else {
|
|
180
199
|
notes.push(`Hay una LTS nueva (v${cal.newestLts}, desde ${n.lts}): planificar la migración.`);
|
|
181
200
|
}
|
|
@@ -187,7 +206,11 @@ function evaluate(pin, r, cal, policy) {
|
|
|
187
206
|
}
|
|
188
207
|
if (r.exact) {
|
|
189
208
|
const sec = cal.securityAfter(r.major, r.exact);
|
|
190
|
-
if (sec.length)
|
|
209
|
+
if (sec.length) {
|
|
210
|
+
const first = sec[0].version.replace(/^v/, '');
|
|
211
|
+
const noImage = pin.kind === 'docker' && (await imageExists(ctx, first)) === false ? ' (todavía sin imagen en Docker Hub)' : '';
|
|
212
|
+
notes.push(`Hay releases de seguridad posteriores a ${r.exact}: como mínimo ${first}${noImage}.`);
|
|
213
|
+
}
|
|
191
214
|
}
|
|
192
215
|
const support = `con soporte hasta ${sch.end}${days < policy.nodeEolWarnDays ? ` (quedan ${days} días)` : ''}`;
|
|
193
216
|
if (!notes.length && !infoNotes.length && days >= policy.nodeEolWarnDays) {
|
|
@@ -209,6 +232,10 @@ export async function run(ctx) {
|
|
|
209
232
|
}
|
|
210
233
|
if (!cal.recommended) return [{ level: 'warn', message: 'el calendario de Node no trae releases LTS; se omite el chequeo.' }];
|
|
211
234
|
|
|
235
|
+
const target = await upgradeTarget(ctx, cal);
|
|
236
|
+
// Mientras la LTS más nueva no tiene imagen oficial nadie la puede usar en el pipeline: se informa.
|
|
237
|
+
const newestLtsWithoutImage =
|
|
238
|
+
cal.newestLts !== null && !cal.newestLtsWithoutRelease && (await newestWithImage(ctx, cal, cal.newestLts)) === null;
|
|
212
239
|
const pins = findPins(ctx);
|
|
213
240
|
const findings = [];
|
|
214
241
|
const majors = new Set();
|
|
@@ -217,13 +244,13 @@ export async function run(ctx) {
|
|
|
217
244
|
const r = resolvePin(pin, cal);
|
|
218
245
|
if (!r.otherImage) nodePins++;
|
|
219
246
|
if (r.major !== undefined && r.major !== null) majors.add(r.major);
|
|
220
|
-
findings.push(evaluate(pin, r, cal,
|
|
247
|
+
findings.push(await evaluate(ctx, pin, r, cal, { target, newestLtsWithoutImage }));
|
|
221
248
|
}
|
|
222
249
|
if (!nodePins) {
|
|
223
|
-
const
|
|
250
|
+
const version = target?.version ?? cal.recommended;
|
|
224
251
|
findings.push({
|
|
225
252
|
level: 'warn',
|
|
226
|
-
message: `No se fija la versión de Node en ningún lado (.nvmrc, engines.node, Dockerfile, pipeline). Fijarla, por ejemplo: .nvmrc con ${
|
|
253
|
+
message: `No se fija la versión de Node en ningún lado (.nvmrc, engines.node, Dockerfile, pipeline). Fijarla, por ejemplo: .nvmrc con ${version} y engines.node ">=${parseVersion(version)[0]}".`,
|
|
227
254
|
});
|
|
228
255
|
}
|
|
229
256
|
if (majors.size > 1) {
|
package/src/checks/npm-policy.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import { lockfileName } from '../lib/audit.js';
|
|
4
4
|
import { parseNpmrc, requiredSettings, settingProblem } from '../lib/npmrc.js';
|
|
5
5
|
import { jsonKeyLine } from '../lib/json-lines.js';
|
|
6
|
+
import { linesWithNpm, loadCalendar } from '../lib/node-calendar.js';
|
|
7
|
+
import { upgradeTarget } from '../lib/node-target.js';
|
|
6
8
|
import { compareVersions, minVersion, parseVersion } from '../lib/semver.js';
|
|
7
9
|
|
|
8
10
|
export const id = 'npm-policy';
|
|
@@ -10,6 +12,32 @@ export const title = 'npm y .npmrc';
|
|
|
10
12
|
|
|
11
13
|
const NPM10_WHY = 'npm 10 ignora min-release-age sin avisar y no aplica allowScripts';
|
|
12
14
|
|
|
15
|
+
// Qué hacer para tener ese npm: en el pipeline, la imagen del destino único (lib/node-target.js); en
|
|
16
|
+
// local, cambiar de línea si la del Node que corre no lo trae (con engine-strict y el engines.node de
|
|
17
|
+
// init, npm 11 sobre la 22 da EBADENGINE) o, si la trae, actualizar npm. Sin calendario, lo genérico.
|
|
18
|
+
async function npmHint(ctx, minNpm) {
|
|
19
|
+
const generic = { pipeline: 'usar la imagen de Node LTS recomendada', local: '`npm install -g npm@11`' };
|
|
20
|
+
let cal;
|
|
21
|
+
let target;
|
|
22
|
+
try {
|
|
23
|
+
cal = await loadCalendar(ctx);
|
|
24
|
+
target = await upgradeTarget(ctx, cal);
|
|
25
|
+
} catch {
|
|
26
|
+
return generic;
|
|
27
|
+
}
|
|
28
|
+
if (!target) return generic;
|
|
29
|
+
const major = parseVersion(minNpm)[0];
|
|
30
|
+
const { without } = linesWithNpm(cal, minNpm);
|
|
31
|
+
const none = without.length ? `; ninguna release de Node ${without.join(' ni ')} trae npm ${major}` : '';
|
|
32
|
+
const running = parseVersion(ctx.nodeVersion ?? '')?.[0];
|
|
33
|
+
return {
|
|
34
|
+
pipeline: `usar una imagen de Node que lo traiga: node:${target.image}${target.npm ? ` (npm ${target.npm})` : ''} o más nueva${none}`,
|
|
35
|
+
local: without.includes(running)
|
|
36
|
+
? `pasar a la ${target.major}: \`nvm install ${target.major} && nvm use ${target.major}\` (la ${running} no trae npm ${major})`
|
|
37
|
+
: '`npm install -g npm@11`',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
13
41
|
export async function run(ctx) {
|
|
14
42
|
const { policy } = ctx;
|
|
15
43
|
const findings = [];
|
|
@@ -88,7 +116,9 @@ export async function run(ctx) {
|
|
|
88
116
|
} else if (compareVersions(used, minNpm) < 0) {
|
|
89
117
|
findings.push({
|
|
90
118
|
level: 'error',
|
|
91
|
-
message: `npm ${used} en uso: hace falta npm >= ${minNpm} (${NPM10_WHY}). En el pipeline
|
|
119
|
+
message: (({ pipeline, local }) => `npm ${used} en uso: hace falta npm >= ${minNpm} (${NPM10_WHY}). En el pipeline y en el Dockerfile, ${pipeline}. En local, ${local}.`)(
|
|
120
|
+
await npmHint(ctx, minNpm),
|
|
121
|
+
),
|
|
92
122
|
data: { npm: used },
|
|
93
123
|
});
|
|
94
124
|
}
|
|
@@ -99,7 +129,7 @@ export async function run(ctx) {
|
|
|
99
129
|
const other = ['yarn.lock', 'pnpm-lock.yaml'].find((f) => ctx.exists(f));
|
|
100
130
|
findings.push({
|
|
101
131
|
level: 'error',
|
|
102
|
-
message: `No hay package-lock.json: sin lockfile no hay \`npm ci\` ni audit.${other ? ` (${other} no sirve: el kit revisa proyectos npm.)` : ' Generarlo con `npm install --package-lock-only`.'}`,
|
|
132
|
+
message: `No hay package-lock.json: sin lockfile no hay \`npm ci\` ni audit.${other ? ` (${other} no sirve: el kit revisa proyectos npm.)` : ' Generarlo con `npm install --package-lock-only --ignore-scripts`.'}`,
|
|
103
133
|
});
|
|
104
134
|
} else {
|
|
105
135
|
const lock = ctx.readJson(lockfile);
|
|
@@ -107,13 +137,13 @@ export async function run(ctx) {
|
|
|
107
137
|
findings.push({
|
|
108
138
|
level: 'error',
|
|
109
139
|
file: lockfile,
|
|
110
|
-
message: `lockfileVersion ${lock.lockfileVersion ?? '?'}: no trae \`packages\` (ni \`hasInstallScript\`), así que no se pueden revisar los install scripts. Regenerar con npm 11 (\`npm install --package-lock-only\`).`,
|
|
140
|
+
message: `lockfileVersion ${lock.lockfileVersion ?? '?'}: no trae \`packages\` (ni \`hasInstallScript\`), así que no se pueden revisar los install scripts. Regenerar con npm 11 (\`npm install --package-lock-only --ignore-scripts\`).`,
|
|
111
141
|
});
|
|
112
142
|
} else if (!(lock.lockfileVersion >= 3)) {
|
|
113
143
|
findings.push({
|
|
114
144
|
level: 'warn',
|
|
115
145
|
file: lockfile,
|
|
116
|
-
message: `lockfileVersion ${lock.lockfileVersion ?? '?'}: regenerar con npm 11 (\`npm install --package-lock-only\`).`,
|
|
146
|
+
message: `lockfileVersion ${lock.lockfileVersion ?? '?'}: regenerar con npm 11 (\`npm install --package-lock-only --ignore-scripts\`).`,
|
|
117
147
|
});
|
|
118
148
|
}
|
|
119
149
|
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// Vulnerabilidades (que no son malware) en dependencias de producción. Bloquea desde
|
|
2
|
-
// `policy.vulnerabilityLevel`; las `high` van en un solo aviso de resumen.
|
|
2
|
+
// `policy.vulnerabilityLevel`; las `high` van en un solo aviso de resumen. Las de ese nivel que están
|
|
3
|
+
// solo en devDependencies van en otro aviso: no llegan a producción, pero corren en el pipeline y en
|
|
4
|
+
// las máquinas de los devs.
|
|
3
5
|
import {
|
|
4
6
|
AuditUnavailableError,
|
|
7
|
+
FULL_TREE,
|
|
5
8
|
advisoriesOf,
|
|
6
9
|
advisoryKey,
|
|
7
10
|
isMalware,
|
|
@@ -75,5 +78,33 @@ export async function run(ctx) {
|
|
|
75
78
|
data: { severity: 'high', count: high.length, packages: high },
|
|
76
79
|
});
|
|
77
80
|
}
|
|
81
|
+
|
|
82
|
+
// El reporte de todo el árbol es el mismo que pide `malware` (una sola llamada). Si el registro no
|
|
83
|
+
// responde, ese chequeo ya da el error.
|
|
84
|
+
let full = null;
|
|
85
|
+
try {
|
|
86
|
+
full = await runAudit(ctx, FULL_TREE);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (!(error instanceof AuditUnavailableError)) throw error;
|
|
89
|
+
}
|
|
90
|
+
const devOnly = full
|
|
91
|
+
? [
|
|
92
|
+
...new Set(
|
|
93
|
+
advisoriesOf(full)
|
|
94
|
+
.filter((a) => !isMalware(a, ctx.policy) && severityAtLeast(a.severity, level) && !byPackage.has(a.name))
|
|
95
|
+
.map((a) => a.name),
|
|
96
|
+
),
|
|
97
|
+
].sort()
|
|
98
|
+
: [];
|
|
99
|
+
if (devOnly.length) {
|
|
100
|
+
findings.push({
|
|
101
|
+
level: 'warn',
|
|
102
|
+
file: lockfile,
|
|
103
|
+
message:
|
|
104
|
+
`${devOnly.length} paquete(s) con vulnerabilidades ${level} solo en devDependencies (${devOnly.join(', ')}). ` +
|
|
105
|
+
'No llegan a producción y no bloquean, pero corren en el pipeline y en las máquinas de los devs: `npm audit fix` (sin `--force`) o subir la dependencia que las trae.',
|
|
106
|
+
data: { severity: level, dev: true, count: devOnly.length, packages: devOnly },
|
|
107
|
+
});
|
|
108
|
+
}
|
|
78
109
|
return findings;
|
|
79
110
|
}
|
package/src/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Parseo de argumentos y despacho de comandos. Devuelve el exit code:
|
|
2
2
|
// 0 sin errores (puede haber avisos), 1 con al menos un error, 2 por uso incorrecto o fallo interno.
|
|
3
3
|
import { existsSync, statSync } from 'node:fs';
|
|
4
|
-
import { resolve } from 'node:path';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
5
|
import { parseArgs } from 'node:util';
|
|
6
6
|
import { checks } from './checks/index.js';
|
|
7
7
|
import { createContext, parseDay } from './context.js';
|
|
@@ -94,6 +94,10 @@ export async function main(argv, io = {}) {
|
|
|
94
94
|
stderr.write(`npm-security-kit: no existe el directorio ${dir}\n`);
|
|
95
95
|
return 2;
|
|
96
96
|
}
|
|
97
|
+
if (args.command === 'check' && !existsSync(join(dir, 'package.json'))) {
|
|
98
|
+
stderr.write(`npm-security-kit: no hay package.json en ${dir}. check se corre en la raíz de un proyecto npm.\n`);
|
|
99
|
+
return 2;
|
|
100
|
+
}
|
|
97
101
|
const ctx = createContext({
|
|
98
102
|
dir,
|
|
99
103
|
env,
|
package/src/context.js
CHANGED
|
@@ -51,6 +51,7 @@ export function createContext({
|
|
|
51
51
|
base,
|
|
52
52
|
fetchImpl = globalThis.fetch,
|
|
53
53
|
wait = sleep,
|
|
54
|
+
nodeVersion = process.versions.node,
|
|
54
55
|
} = {}) {
|
|
55
56
|
const abs = (file) => join(dir, file);
|
|
56
57
|
|
|
@@ -134,6 +135,7 @@ export function createContext({
|
|
|
134
135
|
platform,
|
|
135
136
|
arch,
|
|
136
137
|
base,
|
|
138
|
+
nodeVersion,
|
|
137
139
|
kitVersion,
|
|
138
140
|
readFile,
|
|
139
141
|
readJson,
|
package/src/init.js
CHANGED
|
@@ -10,7 +10,8 @@ import * as npmPolicyCheck from './checks/npm-policy.js';
|
|
|
10
10
|
import { loadAllowPolicy, verdict } from './lib/allow-scripts.js';
|
|
11
11
|
import { lockfileName } from './lib/audit.js';
|
|
12
12
|
import { unifiedDiff } from './lib/diff.js';
|
|
13
|
-
import { loadCalendar } from './lib/node-calendar.js';
|
|
13
|
+
import { linesWithNpm, loadCalendar } from './lib/node-calendar.js';
|
|
14
|
+
import { imageExists, newestWithImage, upgradeTarget } from './lib/node-target.js';
|
|
14
15
|
import { parseNpmrc, requiredSettings, settingProblem } from './lib/npmrc.js';
|
|
15
16
|
import { compareVersions, minVersion, parseVersion } from './lib/semver.js';
|
|
16
17
|
import { formatBlocks } from './report.js';
|
|
@@ -77,28 +78,79 @@ export function mergePackageJson(text, policy, nodeMajor) {
|
|
|
77
78
|
return { text: finalNewline ? `${out}${eol}` : out, changes };
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
// El Node que init propone (engines.node y la imagen del
|
|
81
|
-
// de una LTS con soporte, para que adoptar el kit no obligue a cambiar de Node.
|
|
82
|
-
//
|
|
83
|
-
// la
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
81
|
+
// El Node que init propone (engines.node, si falta, y la imagen del pipeline): el que ya usa el repo
|
|
82
|
+
// si es de una LTS con soporte, para que adoptar el kit no obligue a cambiar de Node. Tiene que traer
|
|
83
|
+
// el npm que pide la política: si la línea del repo no lo trae en ninguna release (la 22 trae npm 10),
|
|
84
|
+
// la siguiente que sí, y `from` dice de cuál viene. engines.node toma la línea más baja en uso (con
|
|
85
|
+
// engine-strict, un ">=" más alto rompe el npm ci de esa línea). La imagen es la exacta que ya usa el
|
|
86
|
+
// pipeline si es de esa línea y no hay un parche de seguridad posterior con imagen publicada; si no,
|
|
87
|
+
// la release más nueva de la línea con imagen en Docker Hub. Sin Node fijado (o solo líneas sin
|
|
88
|
+
// soporte), el destino único de lib/node-target.js.
|
|
89
|
+
export async function repoNode(ctx, cal) {
|
|
90
|
+
const target = await upgradeTarget(ctx, cal);
|
|
91
|
+
let inUse = null;
|
|
92
|
+
let pipelinePin = null;
|
|
88
93
|
for (const pin of nodeVersionCheck.findPins(ctx)) {
|
|
89
94
|
const r = nodeVersionCheck.resolvePin(pin, cal);
|
|
90
95
|
if (r.major === undefined || r.major === null || r.latest) continue;
|
|
91
96
|
if (!['active', 'maintenance'].includes(cal.phaseOf(r.major))) continue;
|
|
92
|
-
if (
|
|
93
|
-
if (!
|
|
97
|
+
if (inUse === null || r.major < inUse) inUse = r.major;
|
|
98
|
+
if (!pipelinePin && pin.file === 'bitbucket-pipelines.yml' && pin.kind === 'docker' && r.exact) pipelinePin = r;
|
|
94
99
|
}
|
|
95
|
-
return {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
100
|
+
if (!target) return { major: null, image: null, npm: null, from: null };
|
|
101
|
+
const major = inUse === null ? target.major : (linesWithNpm(cal, ctx.policy.minNpmVersion).ok.find((m) => m >= inUse) ?? target.major);
|
|
102
|
+
const from = inUse !== null && major !== inUse ? inUse : null;
|
|
103
|
+
if (pipelinePin?.major === major) {
|
|
104
|
+
let patched = false;
|
|
105
|
+
for (const s of cal.securityAfter(major, pipelinePin.exact)) {
|
|
106
|
+
if ((await imageExists(ctx, s.version.replace(/^v/, ''))) === true) patched = true;
|
|
107
|
+
}
|
|
108
|
+
if (!patched) return { major, image: pipelinePin.exact, npm: cal.npmOf(pipelinePin.exact), from };
|
|
109
|
+
}
|
|
110
|
+
if (major === target.major) return { major, image: target.image, npm: target.npm, from };
|
|
111
|
+
const found = await newestWithImage(ctx, cal, major);
|
|
112
|
+
return { major, image: found?.image ?? String(major), npm: found?.npm ?? cal.npmOfLine(major), from };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Las ramas que despliegan: las claves de `pipelines.branches` (sin globs), en orden.
|
|
116
|
+
export function deployBranches(text) {
|
|
117
|
+
const out = [];
|
|
118
|
+
let inPipelines = false;
|
|
119
|
+
let branchesIndent = null;
|
|
120
|
+
let keyIndent = null;
|
|
121
|
+
for (const raw of (text ?? '').split(/\r?\n/)) {
|
|
122
|
+
if (!raw.trim() || raw.trim().startsWith('#')) continue;
|
|
123
|
+
if (/^\S/.test(raw)) {
|
|
124
|
+
inPipelines = /^pipelines:\s*(#.*)?$/.test(raw);
|
|
125
|
+
branchesIndent = null;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (!inPipelines) continue;
|
|
129
|
+
const indent = raw.match(/^ */)[0].length;
|
|
130
|
+
const branches = raw.match(/^( *)branches:\s*(#.*)?$/);
|
|
131
|
+
if (branches) {
|
|
132
|
+
branchesIndent = branches[1].length;
|
|
133
|
+
keyIndent = null;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (branchesIndent === null) continue;
|
|
137
|
+
if (indent <= branchesIndent) {
|
|
138
|
+
branchesIndent = null;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
keyIndent ??= indent;
|
|
142
|
+
const key = raw.match(/^ *(['"]?)([^'"#:\s][^'"#:]*)\1:\s*(#.*)?$/);
|
|
143
|
+
if (indent === keyIndent && key && !/[*?{}[\]]/.test(key[2])) out.push(key[2].trim());
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
99
146
|
}
|
|
100
147
|
|
|
101
|
-
export function
|
|
148
|
+
export function prCondition(branches) {
|
|
149
|
+
const names = branches.length ? branches : ['develop', 'main'];
|
|
150
|
+
return names.map((b) => `BITBUCKET_PR_DESTINATION_BRANCH == "${b}"`).join(' || ');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function pipelineStep(kitVersion, nodeVersion, branches = []) {
|
|
102
154
|
return `- step: &test
|
|
103
155
|
name: Test (supply chain + build)
|
|
104
156
|
image: node:${nodeVersion}
|
|
@@ -109,14 +161,14 @@ export function pipelineStep(kitVersion, nodeVersion) {
|
|
|
109
161
|
- npx --yes --min-release-age=0 ${PACKAGE}@${kitVersion} check
|
|
110
162
|
- npm ci
|
|
111
163
|
- npm audit signatures
|
|
112
|
-
|
|
164
|
+
# … y lo que ya corre el step: build, lint, tests
|
|
113
165
|
pipelines:
|
|
114
166
|
pull-requests:
|
|
115
167
|
'**':
|
|
116
168
|
- step:
|
|
117
169
|
<<: *test
|
|
118
170
|
condition:
|
|
119
|
-
state:
|
|
171
|
+
state: ${prCondition(branches)}`;
|
|
120
172
|
}
|
|
121
173
|
|
|
122
174
|
// ctx que ve los archivos como quedarían después de init (para el resumen de --dry-run) y
|
|
@@ -168,7 +220,7 @@ export async function runInit(ctx, { dryRun = false, stdout = process.stdout, co
|
|
|
168
220
|
} catch (error) {
|
|
169
221
|
cal = { error };
|
|
170
222
|
}
|
|
171
|
-
const node = cal.error ? { major: null, image: null } : repoNode(ctx, cal);
|
|
223
|
+
const node = cal.error ? { major: null, image: null, npm: null, from: null } : await repoNode(ctx, cal);
|
|
172
224
|
|
|
173
225
|
say(paint(2, `npm-security-kit ${ctx.kitVersion} · init${dryRun ? ' --dry-run' : ''} · ${ctx.dir}`));
|
|
174
226
|
|
|
@@ -196,7 +248,7 @@ export async function runInit(ctx, { dryRun = false, stdout = process.stdout, co
|
|
|
196
248
|
section('Install scripts por aprobar (allowScripts en package.json)');
|
|
197
249
|
const lockfile = lockfileName(ctx);
|
|
198
250
|
if (!lockfile) {
|
|
199
|
-
say(' No hay package-lock.json: generarlo con `npm install --package-lock-only` y volver a correr init.');
|
|
251
|
+
say(' No hay package-lock.json: generarlo con `npm install --package-lock-only --ignore-scripts` y volver a correr init.');
|
|
200
252
|
} else {
|
|
201
253
|
const policy = loadAllowPolicy(JSON.parse(view.readFile('package.json')), view.readFile('.npmrc'));
|
|
202
254
|
const pending = [
|
|
@@ -210,11 +262,13 @@ export async function runInit(ctx, { dryRun = false, stdout = process.stdout, co
|
|
|
210
262
|
say(' Ninguno: todos los paquetes con install scripts están aprobados o negados.');
|
|
211
263
|
} else {
|
|
212
264
|
for (const p of pending) say(` - ${p}`);
|
|
213
|
-
say(' init no aprueba nada solo. Para revisarlos:');
|
|
214
|
-
say(' npm ci --ignore-scripts');
|
|
265
|
+
say(' init no aprueba nada solo. Para revisarlos (`npm install-scripts` lee node_modules: primero hace falta un npm ci):');
|
|
266
|
+
say(' npm ci --ignore-scripts # si da EUSAGE: npm install --package-lock-only --ignore-scripts');
|
|
215
267
|
say(' npm install-scripts ls');
|
|
216
|
-
say(' npm install-scripts approve <paquete> # lo que necesita compilar o descargar binarios');
|
|
268
|
+
say(' npm install-scripts approve <paquete> # lo que necesita compilar o descargar binarios; fija la versión');
|
|
217
269
|
say(' npm install-scripts deny <paquete> # telemetría, publicidad o lo que no haga falta');
|
|
270
|
+
say(' `approve` guarda `paquete@versión`: al actualizar el paquete hay que volver a aprobarlo (o aprobar sin');
|
|
271
|
+
say(' versión con `npm install-scripts approve --no-allow-scripts-pin <paquete>`).');
|
|
218
272
|
}
|
|
219
273
|
}
|
|
220
274
|
|
|
@@ -222,12 +276,37 @@ export async function runInit(ctx, { dryRun = false, stdout = process.stdout, co
|
|
|
222
276
|
section('Pipeline: bitbucket-pipelines.yml (init no lo edita)');
|
|
223
277
|
const pipeline = ctx.readFile('bitbucket-pipelines.yml');
|
|
224
278
|
const used = pipeline?.match(/npm-security-kit@([\w.-]+)/);
|
|
279
|
+
const image = node.image ?? '<versión LTS>';
|
|
280
|
+
if (node.from !== null && node.from !== undefined) {
|
|
281
|
+
say(
|
|
282
|
+
` El repo usa Node ${node.from}, pero ninguna release de la ${node.from} trae npm >= ${ctx.policy.minNpmVersion}, que pide la política: ` +
|
|
283
|
+
`se propone la ${node.major} (node:${image}${node.npm ? `, npm ${node.npm}` : ''}). Pasar a la ${node.major} el .nvmrc, las imágenes del pipeline y los FROM de los Dockerfiles.`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
const branches = deployBranches(pipeline);
|
|
287
|
+
const ciLines = (pipeline ?? '').split(/\r?\n/).flatMap((l, i) => (/^\s*-\s*npm ci(\s|$)/.test(l) ? [i + 1] : []));
|
|
225
288
|
if (used) {
|
|
226
289
|
say(` Ya corre el kit (${used[0]}).${used[1] !== ctx.kitVersion ? ` Para esta versión: ${PACKAGE}@${ctx.kitVersion}.` : ''}`);
|
|
290
|
+
} else if (ciLines.length) {
|
|
291
|
+
say(` Agregar al step que ya corre npm ci (bitbucket-pipelines.yml:${ciLines.join(', ')}), sin reemplazar el resto del step:`);
|
|
292
|
+
say();
|
|
293
|
+
say(` - npx --yes --min-release-age=0 ${PACKAGE}@${ctx.kitVersion} check # antes de npm ci`);
|
|
294
|
+
say(' - npm audit signatures # después de npm ci');
|
|
295
|
+
say();
|
|
296
|
+
say(` Con una imagen que traiga npm >= ${ctx.policy.minNpmVersion} (image: node:${image}).`);
|
|
297
|
+
if (!/^\s+ci:\s*(#.*)?$/m.test(pipeline)) {
|
|
298
|
+
say(' Para correrlo a mano ("Run pipeline"), un `custom: ci` con el mismo step (ver el README).');
|
|
299
|
+
}
|
|
300
|
+
if (!/condition:[\s\S]*BITBUCKET_PR_DESTINATION_BRANCH/.test(pipeline)) {
|
|
301
|
+
say(` Y correrlo solo en los PR a las ramas que despliegan${branches.length ? ` (${branches.join(', ')})` : ''}, con la condition del step:`);
|
|
302
|
+
say();
|
|
303
|
+
say(' condition:');
|
|
304
|
+
say(` state: ${prCondition(branches)}`);
|
|
305
|
+
}
|
|
227
306
|
} else {
|
|
228
307
|
say(` ${pipeline === null ? 'No hay bitbucket-pipelines.yml. ' : ''}Agregar el kit en el step de test, ANTES de npm ci, y correrlo solo en los PR a ramas que despliegan:`);
|
|
229
308
|
say();
|
|
230
|
-
for (const line of pipelineStep(ctx.kitVersion,
|
|
309
|
+
for (const line of pipelineStep(ctx.kitVersion, image, branches).split('\n')) say(` ${line}`);
|
|
231
310
|
}
|
|
232
311
|
|
|
233
312
|
// 4. Dockerfiles (no se editan)
|
|
@@ -245,7 +324,13 @@ export async function runInit(ctx, { dryRun = false, stdout = process.stdout, co
|
|
|
245
324
|
section('Pasos siguientes');
|
|
246
325
|
const steps = [
|
|
247
326
|
dryRun ? 'Correr init sin --dry-run.' : 'Revisar el diff: git diff .npmrc package.json',
|
|
248
|
-
|
|
327
|
+
...(node.from !== null && node.from !== undefined
|
|
328
|
+
? [
|
|
329
|
+
`Pasar el repo de la ${node.from} a la ${node.major}: .nvmrc, imágenes del pipeline y FROM de los Dockerfiles (node:${node.image}). ` +
|
|
330
|
+
`En local, \`nvm install ${node.major} && nvm use ${node.major}\`; si npm ci da EUSAGE, \`npm install --package-lock-only --ignore-scripts\`.`,
|
|
331
|
+
]
|
|
332
|
+
: []),
|
|
333
|
+
'Aprobar o negar los install scripts pendientes (después de `npm ci --ignore-scripts`; si el repo usa husky u otro `prepare`, correr `npm run prepare`).',
|
|
249
334
|
'Agregar el step al pipeline y corregir los Dockerfiles.',
|
|
250
335
|
`Correr el chequeo completo: npx --yes --min-release-age=0 ${PACKAGE}@${ctx.kitVersion} check`,
|
|
251
336
|
];
|
package/src/lib/audit.js
CHANGED
|
@@ -26,9 +26,22 @@ function parseReport(stdout) {
|
|
|
26
26
|
return { problem: String(detail).split('\n')[0] };
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
// `--
|
|
30
|
-
//
|
|
31
|
-
export
|
|
29
|
+
// Todo el árbol: los `--include` le ganan a un `omit` del entorno (por ejemplo NODE_ENV=production,
|
|
30
|
+
// que esconde las devDependencies).
|
|
31
|
+
export const FULL_TREE = ['--include=dev', '--include=optional', '--include=peer'];
|
|
32
|
+
|
|
33
|
+
// `malware` y `vulnerabilities` piden el mismo reporte de todo el árbol: una sola llamada por corrida.
|
|
34
|
+
const cache = new WeakMap();
|
|
35
|
+
export function runAudit(ctx, extraArgs, options) {
|
|
36
|
+
if (!cache.has(ctx)) cache.set(ctx, new Map());
|
|
37
|
+
const byArgs = cache.get(ctx);
|
|
38
|
+
const key = extraArgs.join(' ');
|
|
39
|
+
if (!byArgs.has(key)) byArgs.set(key, auditWithRetries(ctx, extraArgs, options));
|
|
40
|
+
return byArgs.get(key);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// `--package-lock-only` hace que npm audite el lockfile aunque haya node_modules.
|
|
44
|
+
async function auditWithRetries(ctx, extraArgs, { attempts = 3 } = {}) {
|
|
32
45
|
const args = ['audit', '--json', '--package-lock-only', ...extraArgs];
|
|
33
46
|
let problem;
|
|
34
47
|
for (let attempt = 1; attempt <= attempts; attempt++) {
|
package/src/lib/node-calendar.js
CHANGED
|
@@ -6,9 +6,16 @@ import { compareVersions, parseVersion } from './semver.js';
|
|
|
6
6
|
export const INDEX_URL = 'https://nodejs.org/dist/index.json';
|
|
7
7
|
export const SCHEDULE_URL = 'https://raw.githubusercontent.com/nodejs/Release/main/schedule.json';
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
// Una sola lectura por corrida: lo usan node-version, npm-policy e init.
|
|
10
|
+
const cache = new WeakMap();
|
|
11
|
+
export function loadCalendar(ctx) {
|
|
12
|
+
if (!cache.has(ctx)) {
|
|
13
|
+
cache.set(
|
|
14
|
+
ctx,
|
|
15
|
+
Promise.all([ctx.fetchJson(INDEX_URL), ctx.fetchJson(SCHEDULE_URL)]).then(([index, schedule]) => analyzeCalendar(index, schedule, ctx.today)),
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
return cache.get(ctx);
|
|
12
19
|
}
|
|
13
20
|
|
|
14
21
|
const day = (text) => new Date(`${text}T00:00:00Z`);
|
|
@@ -39,6 +46,7 @@ export function analyzeCalendar(index, schedule, today) {
|
|
|
39
46
|
const newestLts = supported.length ? Math.max(...supported) : null;
|
|
40
47
|
|
|
41
48
|
const ltsReleases = releases.filter((r) => r.lts);
|
|
49
|
+
const newestLtsOf = (major) => ltsReleases.find((r) => majorOf(r.version) === major);
|
|
42
50
|
const newestLtsRelease = newestLts !== null ? ltsReleases.find((r) => majorOf(r.version) === newestLts) : undefined;
|
|
43
51
|
const latestLtsRelease = ltsReleases[0];
|
|
44
52
|
const recommendedRelease = newestLtsRelease ?? latestLtsRelease;
|
|
@@ -52,6 +60,12 @@ export function analyzeCalendar(index, schedule, today) {
|
|
|
52
60
|
// La versión a instalar: la release LTS publicada de la LTS más nueva o, si todavía no salió,
|
|
53
61
|
// la última release LTS publicada.
|
|
54
62
|
recommended: recommendedRelease?.version.replace(/^v/, ''),
|
|
63
|
+
// La release LTS más nueva de la línea y el npm que trae (campo `npm` de index.json).
|
|
64
|
+
newestLtsOfLine: (major) => newestLtsOf(major)?.version.replace(/^v/, ''),
|
|
65
|
+
npmOfLine: (major) => newestLtsOf(major)?.npm ?? null,
|
|
66
|
+
// Las releases LTS de la línea, de la más nueva a la más vieja, y el npm que trae cada release.
|
|
67
|
+
ltsReleasesOfLine: (major) => ltsReleases.filter((r) => majorOf(r.version) === major).map((r) => r.version.replace(/^v/, '')),
|
|
68
|
+
npmOf: (version) => releases.find((r) => r.version.replace(/^v/, '') === version)?.npm ?? null,
|
|
55
69
|
// true si la LTS más nueva todavía no tiene release LTS en index.json.
|
|
56
70
|
newestLtsWithoutRelease: newestLts !== null && !newestLtsRelease,
|
|
57
71
|
latestLtsMajor: latestLtsRelease ? majorOf(latestLtsRelease.version) : null,
|
|
@@ -71,3 +85,17 @@ export function analyzeCalendar(index, schedule, today) {
|
|
|
71
85
|
[...lines.entries()].filter(([m, s]) => m > major && s.lts).map(([m]) => m).sort((a, b) => a - b)[0] ?? null,
|
|
72
86
|
};
|
|
73
87
|
}
|
|
88
|
+
|
|
89
|
+
// Las líneas de Node con soporte cuya release LTS más nueva trae un npm que cumple la política, de la
|
|
90
|
+
// más vieja a la más nueva, y las que no lo traen en ninguna release (index.json dice qué npm trae
|
|
91
|
+
// cada release; la 22 trae npm 10).
|
|
92
|
+
export function linesWithNpm(cal, minNpm) {
|
|
93
|
+
const supported = [...cal.lines.keys()]
|
|
94
|
+
.filter((m) => ['active', 'maintenance'].includes(cal.phaseOf(m)) && cal.newestLtsOfLine(m))
|
|
95
|
+
.sort((a, b) => a - b);
|
|
96
|
+
const meets = (m) => {
|
|
97
|
+
const npm = cal.npmOfLine(m);
|
|
98
|
+
return Boolean(npm && parseVersion(npm)) && compareVersions(npm, minNpm) >= 0;
|
|
99
|
+
};
|
|
100
|
+
return { ok: supported.filter(meets), without: supported.filter((m) => !meets(m)) };
|
|
101
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// A qué Node se manda a un repo que tiene que cambiar de línea (sin soporte, no LTS, o sin el npm que
|
|
2
|
+
// pide la política): un solo destino para check, init y el mensaje de npm. Es la línea LTS con soporte
|
|
3
|
+
// más baja que trae ese npm: el salto mínimo desde la línea en la que están casi todos los repos
|
|
4
|
+
// (decisión del 28-10). La versión es la release LTS más nueva de esa línea que ya tiene imagen oficial
|
|
5
|
+
// en Docker Hub: las imágenes salen días después que la release en nodejs.org.
|
|
6
|
+
import { linesWithNpm } from './node-calendar.js';
|
|
7
|
+
import { parseVersion } from './semver.js';
|
|
8
|
+
|
|
9
|
+
export const TAGS_URL = 'https://hub.docker.com/v2/repositories/library/node/tags/';
|
|
10
|
+
// Releases de la línea que se prueban, de la más nueva hacia atrás, antes de rendirse.
|
|
11
|
+
const MAX_TRIES = 4;
|
|
12
|
+
|
|
13
|
+
// ¿Existe la imagen `node:<tag>`? true, false o null si Docker Hub no responde. Una consulta por tag
|
|
14
|
+
// y corrida, sin reintentos: si Hub está lento, el kit no espera.
|
|
15
|
+
const cache = new WeakMap();
|
|
16
|
+
export function imageExists(ctx, tag) {
|
|
17
|
+
if (!cache.has(ctx)) cache.set(ctx, new Map());
|
|
18
|
+
const byTag = cache.get(ctx);
|
|
19
|
+
if (!byTag.has(tag)) {
|
|
20
|
+
byTag.set(
|
|
21
|
+
tag,
|
|
22
|
+
ctx.fetchJson(`${TAGS_URL}${tag}`, { retries: 0, timeoutMs: 5000 }).then(
|
|
23
|
+
() => true,
|
|
24
|
+
(error) => (error?.status === 404 ? false : null),
|
|
25
|
+
),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return byTag.get(tag);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// La release LTS más nueva de la línea con imagen publicada: `{ version, image, npm }`. Si Docker Hub
|
|
32
|
+
// no responde, la más nueva con `image` = `<major>.<minor>` (ese tag existe desde la primera release
|
|
33
|
+
// del minor). null si ninguna de las últimas tiene imagen.
|
|
34
|
+
export async function newestWithImage(ctx, cal, major) {
|
|
35
|
+
const versions = cal.ltsReleasesOfLine(major).slice(0, MAX_TRIES);
|
|
36
|
+
for (const version of versions) {
|
|
37
|
+
const found = await imageExists(ctx, version);
|
|
38
|
+
if (found === true) return { version, image: version, npm: cal.npmOf(version) };
|
|
39
|
+
if (found === null) {
|
|
40
|
+
const [ma, mi] = parseVersion(versions[0]);
|
|
41
|
+
return { version: versions[0], image: `${ma}.${mi}`, npm: cal.npmOf(versions[0]) };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// El destino único: `{ major, version, image, npm }`.
|
|
48
|
+
export async function upgradeTarget(ctx, cal) {
|
|
49
|
+
for (const major of linesWithNpm(cal, ctx.policy.minNpmVersion).ok) {
|
|
50
|
+
const found = await newestWithImage(ctx, cal, major);
|
|
51
|
+
if (found) return { major, ...found };
|
|
52
|
+
}
|
|
53
|
+
const rec = cal.recommended;
|
|
54
|
+
return rec ? { major: parseVersion(rec)[0], version: rec, image: rec, npm: cal.npmOf(rec) } : null;
|
|
55
|
+
}
|