@theoptimalpartner/npm-security-kit 2.0.0-beta.3 → 2.0.0-beta.5

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 CHANGED
@@ -27,6 +27,28 @@ Sale primero como beta (`2.0.0-beta.N`), con el dist-tag `beta`.
27
27
  `strict-allow-scripts=true` npm corta con `ESTRICTALLOWSCRIPTS`).
28
28
  - README: los ejemplos usan la versión del paquete (la `2.0.0` todavía no existe), `install-scripts ls` necesita
29
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`.
41
+ - **2.0.0-beta.5** (ajustes de una tercera prueba):
42
+ - `init` guía el cambio de línea también desde una línea sin soporte o no LTS (antes solo desde una sin npm 11), y
43
+ sube `engines.node` si admite una línea que hay que cambiar.
44
+ - `init` señala el `npm ci` del step que corre en los PR (inline o por su ancla) y aparte los de deploy y
45
+ publicación, que no necesitan el kit. No repite la imagen si el pipeline ya usa la que propone.
46
+ - `dockerfile`: en un monorepo, un Dockerfile cuyo contexto de build es una subcarpeta sin `.npmrc` instala sin la
47
+ política aunque la raíz lo tenga; ahora es error.
48
+ - Excepción con fecha para diferir el cambio de línea de Node cuando el salto es de más de 2 versiones mayores
49
+ (`npmSecurityKit.nodeUpgradeDeferredUntil` y `nodeUpgradeReason` en el `package.json`, hasta 183 días): mientras
50
+ está vigente, los errores de la línea de Node y de npm 10 son avisos e `init` no toca `engines`. El kit la sugiere
51
+ cuando el salto es grande.
30
52
 
31
53
  ### BREAKING
32
54
 
package/README.md CHANGED
@@ -3,7 +3,7 @@
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.3`). Las publicadas, con sus dist-tags:
6
+ Los ejemplos usan la versión de este paquete (`2.0.0-beta.5`). Las publicadas, con sus dist-tags:
7
7
  `npm view @theoptimalpartner/npm-security-kit dist-tags`.
8
8
 
9
9
  ## Qué revisa
@@ -12,7 +12,7 @@ Los ejemplos usan la versión de este paquete (`2.0.0-beta.3`). Las publicadas,
12
12
  |---|---|---|---|
13
13
  | `malware` | `npm audit` sobre **todo** el lockfile, incluidas las devDependencies | Cualquier advisory de malware (CWE-506), con cualquier severidad | — |
14
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 |
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); con un salto de más de 2 versiones, diferible con una excepción con fecha | 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 |
16
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 |
17
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 |
18
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 |
@@ -44,9 +44,12 @@ audit-level=critical
44
44
  Además, `engines.npm` en `>=11.17`: npm 10 ignora `min-release-age` sin avisar y no aplica `allowScripts`.
45
45
 
46
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 (Node 24 o más nueva: `node:24.22.1` trae npm 11.19.1). `check` da error en cada
48
- lugar que fija la 22 (`.nvmrc`, `engines.node`, `FROM`, `image:`) con la versión a la que subir, e `init` propone
49
- esa línea.
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. Si el salto
52
+ es grande (18 o 20 → 24), ver **Diferir el cambio de Node** en las recetas.
50
53
 
51
54
  ## Uso
52
55
 
@@ -66,7 +69,7 @@ definitions:
66
69
  - npm
67
70
  script:
68
71
  # Antes de instalar: malware, vulnerabilidades, Node, .npmrc, install scripts, Dockerfiles
69
- - npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.3 check
72
+ - npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.5 check
70
73
  - npm ci # con strict-allow-scripts, los scripts no aprobados cortan acá
71
74
  - npm audit signatures
72
75
  # … y lo que ya corría el step: build, lint, tests
@@ -86,13 +89,13 @@ pipelines:
86
89
 
87
90
  - **Antes de `npm ci`:** `npm audit` funciona solo con el lockfile, así que el malware se detecta antes de que
88
91
  corra su `postinstall`.
89
- - **Versión fija** (`@2.0.0-beta.3`): subir la política en un repo es un cambio visible en su PR.
92
+ - **Versión fija** (`@2.0.0-beta.5`): subir la política en un repo es un cambio visible en su PR.
90
93
  - **`--min-release-age=0` solo para `npx`:** `npx` no aplica `min-release-age-exclude` (npm 11.19.1 lo aplica al
91
94
  instalar, no al resolver el paquete de `npx`). Sin el flag, dentro de un repo con `min-release-age=7` una versión
92
95
  nueva del kit no se puede usar hasta cumplir 7 días (`ETARGET … with a date before`). Afecta solo al kit: la
93
96
  versión va fija y no tiene dependencias. El `npm ci` que sigue aplica los 7 días como siempre.
94
97
  - **`npm audit signatures`** va después de `npm ci` porque necesita `node_modules`.
95
- - Para guardar el resultado como artefacto: `npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.3 check --json > npm-security-kit.json`.
98
+ - Para guardar el resultado como artefacto: `npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.5 check --json > npm-security-kit.json`.
96
99
 
97
100
  ### Dockerfile
98
101
 
@@ -107,24 +110,28 @@ RUN npm ci
107
110
 
108
111
  Si el `.dockerignore` excluye el `.npmrc` (por ejemplo con `.*`), agregar `!.npmrc` al final.
109
112
 
113
+ En un monorepo, si el Dockerfile se construye con una subcarpeta como contexto (`cd backend && docker build .`), el
114
+ `.npmrc` de la raíz no llega a la imagen: copiarlo a esa carpeta o construir con el contexto en la raíz. `check` lo
115
+ marca como error.
116
+
110
117
  ### Adoptarlo en un repo: `init`
111
118
 
112
119
  Una vez por repo, en una rama:
113
120
 
114
121
  ```bash
115
- npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.3 init --dry-run
122
+ npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.5 init --dry-run
116
123
  ```
117
124
 
118
125
  ```bash
119
- npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.3 init
126
+ npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.5 init
120
127
  ```
121
128
 
122
129
  `init`:
123
130
 
124
131
  - agrega al `.npmrc` las claves de la política que faltan (con un comentario) y corrige las que tienen otro valor,
125
132
  sin borrar ni reordenar nada;
126
- - completa `engines.npm` (`>=11.17`) y, solo si falta, `engines.node` (con la línea de Node del repo, si trae npm 11;
127
- si no, la siguiente que lo trae);
133
+ - completa `engines.npm` (`>=11.17`) y `engines.node`: lo agrega si falta, con la línea de Node del repo si trae npm
134
+ 11, y lo sube si admite una línea que hay que cambiar (sin soporte, no LTS o sin npm 11), a la línea propuesta;
128
135
  - **no aprueba install scripts**: lista los pendientes y los comandos para revisarlos;
129
136
  - **no edita** `bitbucket-pipelines.yml` ni los Dockerfiles: dice qué líneas agregar al step que corre `npm ci`, la
130
137
  condition con las ramas que despliegan y los `COPY` corregidos;
@@ -135,7 +142,7 @@ Después: revisar el diff, aprobar o negar los install scripts, aplicar el step
135
142
  ### En local
136
143
 
137
144
  ```bash
138
- npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.3 check
145
+ npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.5 check
139
146
  ```
140
147
 
141
148
  No se instala como devDependency ni usa husky: el `.npmrc` protege el `npm install` local y el pipeline es el
@@ -191,6 +198,9 @@ npm install-scripts deny core-js # solo imprime un mensaje: no hace falta
191
198
  `npm install-scripts ls` lee `node_modules`: si el `npm ci --ignore-scripts` falló (por ejemplo con EUSAGE), dice que
192
199
  no hay nada pendiente. La lista de `check` e `init` sale del lockfile y no depende de eso.
193
200
 
201
+ `--ignore-scripts` tampoco corre los scripts del propio proyecto, como el `prepare` que instala husky: si el repo los
202
+ usa, `npm run prepare` después (si no, el hook de git falla en el próximo commit).
203
+
194
204
  `approve` guarda la versión exacta (`"paquete@1.2.3": true`): cuando se actualiza el paquete, `check` vuelve a dar
195
205
  error hasta aprobar la versión nueva. Es a propósito (cada versión nueva se revisa). Para un paquete de confianza que
196
206
  se actualiza seguido, `npm install-scripts approve --no-allow-scripts-pin <paquete>` lo aprueba sin versión. `deny`
@@ -216,6 +226,51 @@ Si un repo no puede adoptar todavía la aprobación de install scripts, se defin
216
226
  Con el override, npm ejecuta los scripts sin aprobar y el kit los informa como **aviso**, más un aviso de que el
217
227
  override está activo. Dentro del `docker build` la variable no llega sola: hay que pasarla como `ARG`.
218
228
 
229
+ ### Diferir el cambio de Node (salto grande)
230
+
231
+ Pasar de Node 18 o 20 a la 24 suele arrastrar dependencias y refactors. Para no frenar el repo mientras se planifica,
232
+ el cambio de línea se puede diferir con una excepción con fecha.
233
+
234
+ **Cuándo se puede usar**
235
+
236
+ - Solo si el salto de la línea del repo al destino (hoy la 24) es de **más de 2 versiones mayores**: 18 → 24 o
237
+ 20 → 24 sí; 22 → 24 no (ese cambio es chico y no se difiere). `check` lo sugiere ("Salto grande (Node 18 → 24)…")
238
+ cuando aplica.
239
+ - Cuando subir ahora no es viable (dependencias que no soportan la línea nueva, un refactor que hay que planificar), y
240
+ con el OK de quien aprueba los cambios de seguridad del repo. No es para "lo veo después".
241
+
242
+ **Cómo**
243
+
244
+ En la raíz del `package.json` (al lado de `dependencies`), en un PR:
245
+
246
+ ```json
247
+ "npmSecurityKit": {
248
+ "nodeUpgradeDeferredUntil": "2027-03-31",
249
+ "nodeUpgradeReason": "Express 4 → 5 y los tipos de Node 24"
250
+ }
251
+ ```
252
+
253
+ - `nodeUpgradeDeferredUntil`: fecha `AAAA-MM-DD`, como mucho 183 días desde hoy.
254
+ - `nodeUpgradeReason`: por qué no se puede subir ahora. Es obligatorio.
255
+ - En la descripción del PR: el motivo, quién lo aprobó y el plan para subir antes de la fecha (tarjeta o PR).
256
+
257
+ **Qué cambia mientras está vigente**
258
+
259
+ - Los errores de la línea de Node y los de npm 10 (`npm` en uso, `engines.npm`) pasan a aviso, con la fecha y el motivo;
260
+ `check` dice cuántos días quedan. `init` no toca `engines`: los de la línea nueva, con `engine-strict`, romperían el
261
+ `npm ci` de la actual.
262
+ - Lo demás sigue bloqueando: malware, vulnerabilidades críticas en producción, install scripts sin aprobar, `.npmrc`
263
+ y Dockerfiles.
264
+ - **Ojo:** con npm 10 (la 18 no corre npm 11), `min-release-age` y `allowScripts` no hacen nada aunque estén en el
265
+ `.npmrc`. El repo queda sin la espera de 7 días ni el control de install scripts: el kit sigue revisando malware y
266
+ vulnerabilidades antes de `npm ci`, pero no reemplaza eso. Por eso la excepción vence.
267
+
268
+ **Al vencer**
269
+
270
+ - Los errores vuelven a bloquear el pipeline y `check` pide quitarla o renovarla.
271
+ - Renovarla es otro PR con fecha nueva (como mucho 183 días desde ese día), motivo y OK. Si la fecha es inválida,
272
+ falta el motivo o pasa de 183 días, el kit la ignora y lo avisa.
273
+
219
274
  ### Vulnerabilidades `critical` en devDependencies
220
275
 
221
276
  No bloquean (no llegan a producción), pero corren en el pipeline y en las máquinas de los devs: `check` las informa
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theoptimalpartner/npm-security-kit",
3
- "version": "2.0.0-beta.3",
3
+ "version": "2.0.0-beta.5",
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": {
@@ -138,6 +138,10 @@ export async function run(ctx) {
138
138
  const own = dirOf(file);
139
139
  const context = own && (ctx.exists(join(own, '.dockerignore')) || ctx.exists(join(own, 'package.json'))) ? own : '';
140
140
  const hasNpmrc = ctx.exists(join(context, '.npmrc'));
141
+ // Monorepo: el contexto es una subcarpeta sin .npmrc y la política está en el de la raíz, que
142
+ // `docker build` no ve desde ahí.
143
+ const npmrcOutside = Boolean(context) && !hasNpmrc && ctx.exists('.npmrc');
144
+ let reportedOutside = false;
141
145
  const ignoreFile = ctx.exists(`${file}.dockerignore`) ? `${file}.dockerignore` : join(context, '.dockerignore');
142
146
  const ignoreText = ctx.readFile(ignoreFile);
143
147
  const npmrcIgnored = hasNpmrc ? dockerignoreExcludes(ignoreText, '.npmrc') : null;
@@ -171,6 +175,15 @@ export async function run(ctx) {
171
175
  const local = npmInstalls(ins.args).filter((n) => !n.global);
172
176
  if (!local.length) continue;
173
177
  installs = true;
178
+ if (npmrcOutside && !mountsNpmrc(ins.args) && !reportedOutside) {
179
+ reportedOutside = true;
180
+ findings.push({
181
+ level: 'error',
182
+ file,
183
+ line: ins.line,
184
+ message: `Instala sin el .npmrc: el contexto de build es ${context}/ (tiene su propio package.json o .dockerignore) y ahí no hay .npmrc; el de la raíz no llega a la imagen. Copiar el .npmrc a ${context}/ y agregarlo al COPY, o construir con el contexto en la raíz.`,
185
+ });
186
+ }
174
187
  if (hasNpmrc && !brought && !mountsNpmrc(ins.args) && !reported) {
175
188
  reported = true;
176
189
  findings.push({
@@ -4,7 +4,9 @@
4
4
  // trae npm 10) es error. Todo sale del calendario de nodejs.org.
5
5
  import { findDockerfiles, nodeImageTag, parseStages } from '../lib/dockerfile.js';
6
6
  import { jsonKeyLine } from '../lib/json-lines.js';
7
- import { linesWithNpm, loadCalendar } from '../lib/node-calendar.js';
7
+ import { loadCalendar } from '../lib/node-calendar.js';
8
+ import { readDeferral } from '../lib/node-deferral.js';
9
+ import { imageExists, newestWithImage, upgradeTarget } from '../lib/node-target.js';
8
10
  import { compareVersions, isRange, minVersion, parseVersion } from '../lib/semver.js';
9
11
 
10
12
  export const id = 'node-version';
@@ -146,7 +148,10 @@ export function resolvePin(pin, cal) {
146
148
  return { major: v[0], exact: parts === 3 ? v.join('.') : undefined };
147
149
  }
148
150
 
149
- function evaluate(pin, r, cal, policy) {
151
+ // `target` es el destino único cuando hay que cambiar de línea (lib/node-target.js). En los pines de
152
+ // Docker se propone su imagen, que ya existe en Docker Hub.
153
+ async function evaluate(ctx, pin, r, cal, { target, newestLtsWithoutImage }) {
154
+ const { policy } = ctx;
150
155
  const base = { file: pin.file, line: pin.line };
151
156
  if (r.otherImage) {
152
157
  return { ...base, level: 'info', message: `${pin.spec}: imagen sin Node fijado (la versión de Node la decide la imagen).`, data: { pin: pin.pin } };
@@ -155,31 +160,29 @@ function evaluate(pin, r, cal, policy) {
155
160
 
156
161
  const sch = cal.lines.get(r.major);
157
162
  const prefix = `${r.range ? 'el rango admite desde ' : ''}v${r.major}${r.floating ? ` (${r.floating})` : ''}`;
158
- const rec = cal.recommended;
159
- const engineHint = pin.kind === 'engines' ? ` (en engines: ">=${parseVersion(rec)[0]}")` : '';
160
- const data = { pin: pin.pin, major: r.major, phase: cal.phaseOf(r.major), recommended: rec };
163
+ const rec = target ? (pin.kind === 'docker' ? target.image : target.version) : cal.recommended;
164
+ const engineHint = pin.kind === 'engines' ? ` (en engines: ">=${target ? target.major : parseVersion(rec)[0]}")` : '';
165
+ const data = { pin: pin.pin, major: r.major, phase: cal.phaseOf(r.major), recommended: target?.version ?? cal.recommended };
161
166
  if (!sch) return { ...base, level: 'warn', message: `${prefix}: no aparece en el calendario de Node.`, data };
162
167
 
163
168
  const phase = cal.phaseOf(r.major);
164
169
  if (phase === 'eol') {
165
- return { ...base, level: 'error', message: `${prefix}: sin soporte desde ${sch.end}. Actualizar a ${rec}${engineHint}.`, data };
170
+ return { ...base, level: 'error', message: `${prefix}: sin soporte desde ${sch.end}. Actualizar a ${rec}${engineHint}.`, data: { ...data, lineChange: true } };
166
171
  }
167
172
  if (phase === 'not-lts') {
168
173
  const why = sch.lts ? `todavía no es LTS (lo será el ${sch.lts})` : 'línea impar, nunca va a ser LTS';
169
- return { ...base, level: 'error', message: `${prefix}: no es LTS, ${why}. Usar ${rec}${engineHint}.`, data };
174
+ return { ...base, level: 'error', message: `${prefix}: no es LTS, ${why}. Usar ${rec}${engineHint}.`, data: { ...data, lineChange: true } };
170
175
  }
171
176
  // La política pide npm >= minNpmVersion y la imagen de Node trae el suyo: una línea que no lo trae en
172
177
  // ninguna release (la 22 trae npm 10) deja el pipeline y el docker build con npm 10.
173
178
  const lineNpm = cal.npmOfLine(r.major);
174
179
  if (lineNpm && parseVersion(lineNpm) && compareVersions(lineNpm, policy.minNpmVersion) < 0) {
175
- const target = linesWithNpm(cal, policy.minNpmVersion).ok.find((m) => m > r.major);
176
- const to = target ? `${cal.newestLtsOfLine(target)} (npm ${cal.npmOfLine(target)})` : rec;
177
- const hint = pin.kind === 'engines' && target ? ` (en engines: ">=${target}")` : '';
180
+ const to = target?.npm ? `${rec} (npm ${target.npm})` : rec;
178
181
  return {
179
182
  ...base,
180
183
  level: 'error',
181
- 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${hint}.`,
182
- data: { ...data, npm: lineNpm },
184
+ 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}.`,
185
+ data: { ...data, npm: lineNpm, lineChange: true },
183
186
  };
184
187
  }
185
188
 
@@ -191,6 +194,8 @@ function evaluate(pin, r, cal, policy) {
191
194
  const n = cal.lines.get(cal.newestLts);
192
195
  if (cal.newestLtsWithoutRelease) {
193
196
  infoNotes.push(`Hay una LTS nueva (v${cal.newestLts}, desde ${n.lts}), aún sin release LTS: planificar la migración cuando salga.`);
197
+ } else if (newestLtsWithoutImage) {
198
+ 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.`);
194
199
  } else {
195
200
  notes.push(`Hay una LTS nueva (v${cal.newestLts}, desde ${n.lts}): planificar la migración.`);
196
201
  }
@@ -202,7 +207,11 @@ function evaluate(pin, r, cal, policy) {
202
207
  }
203
208
  if (r.exact) {
204
209
  const sec = cal.securityAfter(r.major, r.exact);
205
- if (sec.length) notes.push(`Hay releases de seguridad posteriores a ${r.exact}: como mínimo ${sec[0].version.replace(/^v/, '')}.`);
210
+ if (sec.length) {
211
+ const first = sec[0].version.replace(/^v/, '');
212
+ const noImage = pin.kind === 'docker' && (await imageExists(ctx, first)) === false ? ' (todavía sin imagen en Docker Hub)' : '';
213
+ notes.push(`Hay releases de seguridad posteriores a ${r.exact}: como mínimo ${first}${noImage}.`);
214
+ }
206
215
  }
207
216
  const support = `con soporte hasta ${sch.end}${days < policy.nodeEolWarnDays ? ` (quedan ${days} días)` : ''}`;
208
217
  if (!notes.length && !infoNotes.length && days >= policy.nodeEolWarnDays) {
@@ -215,6 +224,40 @@ function evaluate(pin, r, cal, policy) {
215
224
  return { ...base, level, message: `${prefix}: ${detail}`, data: { ...data, group: `v${r.major}: ${detail}` } };
216
225
  }
217
226
 
227
+ // ¿Hay una excepción vigente que aplique a este repo? Aplica si alguna línea fijada que hay que cambiar
228
+ // (sin soporte, no LTS o sin npm 11) está a más de policy.nodeDeferMinJump versiones mayores del destino.
229
+ // La usan npm-policy (npm 10 y engines.npm) e init. Una vez por corrida.
230
+ const deferrals = new WeakMap();
231
+ export function activeDeferral(ctx) {
232
+ if (!deferrals.has(ctx)) deferrals.set(ctx, computeDeferral(ctx).catch(() => null));
233
+ return deferrals.get(ctx);
234
+ }
235
+
236
+ async function computeDeferral(ctx) {
237
+ const deferral = readDeferral(ctx);
238
+ if (deferral.status !== 'active') return null;
239
+ const cal = await loadCalendar(ctx);
240
+ const target = await upgradeTarget(ctx, cal);
241
+ if (!target) return null;
242
+ const majors = bigJumpLines(ctx, cal, target);
243
+ return majors.length ? { ...deferral, majors, target } : null;
244
+ }
245
+
246
+ // Las líneas fijadas que hay que cambiar y están a más de nodeDeferMinJump versiones del destino.
247
+ function bigJumpLines(ctx, cal, target) {
248
+ const out = new Set();
249
+ for (const pin of findPins(ctx)) {
250
+ const r = resolvePin(pin, cal);
251
+ if (r.major === undefined || r.major === null || r.otherImage) continue;
252
+ if (target.major - r.major <= ctx.policy.nodeDeferMinJump) continue;
253
+ const phase = cal.phaseOf(r.major);
254
+ const npm = cal.npmOfLine(r.major);
255
+ const noNpm = npm && parseVersion(npm) && compareVersions(npm, ctx.policy.minNpmVersion) < 0;
256
+ if (phase === 'eol' || phase === 'not-lts' || noNpm) out.add(r.major);
257
+ }
258
+ return [...out].sort((a, b) => a - b);
259
+ }
260
+
218
261
  export async function run(ctx) {
219
262
  let cal;
220
263
  try {
@@ -224,21 +267,72 @@ export async function run(ctx) {
224
267
  }
225
268
  if (!cal.recommended) return [{ level: 'warn', message: 'el calendario de Node no trae releases LTS; se omite el chequeo.' }];
226
269
 
270
+ const target = await upgradeTarget(ctx, cal);
271
+ // Mientras la LTS más nueva no tiene imagen oficial nadie la puede usar en el pipeline: se informa.
272
+ const newestLtsWithoutImage =
273
+ cal.newestLts !== null && !cal.newestLtsWithoutRelease && (await newestWithImage(ctx, cal, cal.newestLts)) === null;
227
274
  const pins = findPins(ctx);
228
275
  const findings = [];
229
276
  const majors = new Set();
277
+ const deferral = readDeferral(ctx);
278
+ const max = ctx.policy.nodeDeferMaxDays;
279
+ const big = target ? new Set(bigJumpLines(ctx, cal, target)) : new Set();
280
+ const deferring = deferral.status === 'active' && big.size > 0;
230
281
  let nodePins = 0;
231
282
  for (const pin of pins) {
232
283
  const r = resolvePin(pin, cal);
233
284
  if (!r.otherImage) nodePins++;
234
285
  if (r.major !== undefined && r.major !== null) majors.add(r.major);
235
- findings.push(evaluate(pin, r, cal, ctx.policy));
286
+ const finding = await evaluate(ctx, pin, r, cal, { target, newestLtsWithoutImage });
287
+ // Con la excepción vigente, el cambio de una línea con salto grande pasa a aviso.
288
+ if (deferring && finding.level === 'error' && finding.data?.lineChange && big.has(r.major)) {
289
+ findings.push({
290
+ ...finding,
291
+ level: 'warn',
292
+ message: `${finding.message} Diferido hasta ${deferral.until} (${deferral.reason}).`,
293
+ data: { ...finding.data, deferredUntil: deferral.until },
294
+ });
295
+ } else {
296
+ findings.push(finding);
297
+ }
298
+ }
299
+ // El estado de la excepción, o la sugerencia de declararla cuando el salto es grande.
300
+ const deferralAt = { file: 'package.json', line: deferral.line };
301
+ const lines = [...big].map((m) => `Node ${m}`).join(' y ');
302
+ if (deferral.status === 'invalid') {
303
+ findings.push({ ...deferralAt, level: 'warn', message: `La excepción para diferir el cambio de Node no vale: ${deferral.problem}. Se ignora.` });
304
+ } else if (deferral.status === 'expired') {
305
+ findings.push({
306
+ ...deferralAt,
307
+ level: 'warn',
308
+ message: `La excepción para diferir el cambio de Node venció el ${deferral.until}: los errores vuelven a bloquear. Quitarla del package.json, o renovarla con otra fecha y motivo (máximo ${max} días).`,
309
+ });
310
+ } else if (deferral.status === 'active' && !deferring) {
311
+ findings.push({
312
+ ...deferralAt,
313
+ level: 'warn',
314
+ message: `La excepción para diferir el cambio de Node no hace falta: ninguna línea fijada está a más de ${ctx.policy.nodeDeferMinJump} versiones mayores del destino. Quitarla del package.json.`,
315
+ });
316
+ } else if (deferring) {
317
+ findings.push({
318
+ ...deferralAt,
319
+ level: 'warn',
320
+ message: `Cambio de línea de Node diferido hasta ${deferral.until} (${deferral.reason}): quedan ${deferral.daysLeft} días. Mientras tanto, los errores de ${lines} y de npm 10 son avisos; al vencer, vuelven a bloquear. Destino: ${target.version}.`,
321
+ data: { deferredUntil: deferral.until, majors: [...big] },
322
+ });
323
+ } else if (big.size && findings.some((f) => f.level === 'error' && f.data?.lineChange && big.has(f.data.major))) {
324
+ findings.push({
325
+ level: 'info',
326
+ message:
327
+ `Salto grande (${lines} → ${target.major}): si no se puede subir ahora, se puede diferir con una excepción con fecha en el package.json, ` +
328
+ `"npmSecurityKit": { "nodeUpgradeDeferredUntil": "AAAA-MM-DD", "nodeUpgradeReason": "…" } (máximo ${max} días). Mientras esté vigente, estos errores son avisos.`,
329
+ });
236
330
  }
237
331
  if (!nodePins) {
238
- const major = parseVersion(cal.recommended)[0];
332
+ const version = target?.version ?? cal.recommended;
239
333
  findings.push({
240
334
  level: 'warn',
241
- message: `No se fija la versión de Node en ningún lado (.nvmrc, engines.node, Dockerfile, pipeline). Fijarla, por ejemplo: .nvmrc con ${cal.recommended} y engines.node ">=${major}".`,
335
+ 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]}".`,
242
336
  });
243
337
  }
244
338
  if (majors.size > 1) {
@@ -4,6 +4,8 @@ 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
6
  import { linesWithNpm, loadCalendar } from '../lib/node-calendar.js';
7
+ import { upgradeTarget } from '../lib/node-target.js';
8
+ import { activeDeferral } from './node-version.js';
7
9
  import { compareVersions, minVersion, parseVersion } from '../lib/semver.js';
8
10
 
9
11
  export const id = 'npm-policy';
@@ -11,19 +13,30 @@ export const title = 'npm y .npmrc';
11
13
 
12
14
  const NPM10_WHY = 'npm 10 ignora min-release-age sin avisar y no aplica allowScripts';
13
15
 
14
- // Qué imagen usar para tener ese npm. Sin calendario, el consejo genérico.
15
- async function npmImageHint(ctx, minNpm) {
16
+ // Qué hacer para tener ese npm: en el pipeline, la imagen del destino único (lib/node-target.js); en
17
+ // local, cambiar de línea si la del Node que corre no lo trae (con engine-strict y el engines.node de
18
+ // init, npm 11 sobre la 22 da EBADENGINE) o, si la trae, actualizar npm. Sin calendario, lo genérico.
19
+ async function npmHint(ctx, minNpm) {
20
+ const generic = { pipeline: 'usar la imagen de Node LTS recomendada', local: '`npm install -g npm@11`' };
16
21
  let cal;
22
+ let target;
17
23
  try {
18
24
  cal = await loadCalendar(ctx);
25
+ target = await upgradeTarget(ctx, cal);
19
26
  } catch {
20
- return 'usar la imagen de Node LTS recomendada';
27
+ return generic;
21
28
  }
22
- const { ok, without } = linesWithNpm(cal, minNpm);
23
- if (!ok.length) return 'usar la imagen de Node LTS recomendada';
24
- const first = ok[0];
25
- const none = without.length ? `; ninguna release de Node ${without.join(' ni ')} trae npm ${parseVersion(minNpm)[0]}` : '';
26
- return `usar una imagen de Node que lo traiga: node:${cal.newestLtsOfLine(first)} (npm ${cal.npmOfLine(first)}) o más nueva${none}`;
29
+ if (!target) return generic;
30
+ const major = parseVersion(minNpm)[0];
31
+ const { without } = linesWithNpm(cal, minNpm);
32
+ const none = without.length ? `; ninguna release de Node ${without.join(' ni ')} trae npm ${major}` : '';
33
+ const running = parseVersion(ctx.nodeVersion ?? '')?.[0];
34
+ return {
35
+ pipeline: `usar una imagen de Node que lo traiga: node:${target.image}${target.npm ? ` (npm ${target.npm})` : ''} o más nueva${none}`,
36
+ local: without.includes(running)
37
+ ? `pasar a la ${target.major}: \`nvm install ${target.major} && nvm use ${target.major}\` (la ${running} no trae npm ${major})`
38
+ : '`npm install -g npm@11`',
39
+ };
27
40
  }
28
41
 
29
42
  export async function run(ctx) {
@@ -73,6 +86,12 @@ export async function run(ctx) {
73
86
 
74
87
  // engines.npm
75
88
  const pkgText = ctx.readFile('package.json');
89
+ // Con el cambio de Node diferido (salto grande), la línea del repo puede no correr npm 11: engines.npm
90
+ // y el npm en uso son avisos hasta que venza la excepción. engines.npm >=11.17 con engine-strict
91
+ // rompería el npm ci de esa línea.
92
+ const deferral = await activeDeferral(ctx);
93
+ const deferredNote = deferral ? ` Diferido hasta ${deferral.until} junto con el cambio de Node (${deferral.reason}).` : '';
94
+ const npmLevel = deferral ? 'warn' : 'error';
76
95
  if (pkgText === null) {
77
96
  findings.push({ level: 'error', message: 'No hay package.json en el directorio revisado.' });
78
97
  } else {
@@ -81,16 +100,16 @@ export async function run(ctx) {
81
100
  const min = typeof range === 'string' ? minVersion(range) : null;
82
101
  if (typeof range !== 'string') {
83
102
  findings.push({
84
- level: 'error',
103
+ level: npmLevel,
85
104
  file: 'package.json',
86
- message: `Falta engines.npm: agregar "npm": ">=${minNpmShort}" en engines (${NPM10_WHY}).`,
105
+ message: `Falta engines.npm: agregar "npm": ">=${minNpmShort}" en engines (${NPM10_WHY}).${deferral ? ` Agregarlo al subir de Node: con engine-strict, en la línea actual rompe npm ci.${deferredNote}` : ''}`,
87
106
  });
88
107
  } else if (!min || compareVersions(min, minNpm) < 0) {
89
108
  findings.push({
90
- level: 'error',
109
+ level: npmLevel,
91
110
  file: 'package.json',
92
111
  line: jsonKeyLine(pkgText, ['engines', 'npm']),
93
- message: `engines.npm "${range}" admite npm anteriores a ${minNpm} (${NPM10_WHY}). Cambiar a ">=${minNpmShort}".`,
112
+ message: `engines.npm "${range}" admite npm anteriores a ${minNpm} (${NPM10_WHY}). Cambiar a ">=${minNpmShort}".${deferredNote}`,
94
113
  data: { range, min },
95
114
  });
96
115
  }
@@ -103,8 +122,10 @@ export async function run(ctx) {
103
122
  findings.push({ level: 'warn', message: `No se pudo saber la versión de npm en uso (${(npmVersion.stderr || npmVersion.stdout).trim() || 'sin salida'}).` });
104
123
  } else if (compareVersions(used, minNpm) < 0) {
105
124
  findings.push({
106
- level: 'error',
107
- message: `npm ${used} en uso: hace falta npm >= ${minNpm} (${NPM10_WHY}). En el pipeline y en el Dockerfile, ${await npmImageHint(ctx, minNpm)}. En local, \`npm install -g npm@11\`.`,
125
+ level: npmLevel,
126
+ message: (({ pipeline, local }) => `npm ${used} en uso: hace falta npm >= ${minNpm} (${NPM10_WHY}). En el pipeline y en el Dockerfile, ${pipeline}. En local, ${local}.${deferredNote}`)(
127
+ await npmHint(ctx, minNpm),
128
+ ),
108
129
  data: { npm: used },
109
130
  });
110
131
  }
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
@@ -6,11 +6,13 @@ import { join } from 'node:path';
6
6
  import * as dockerfileCheck from './checks/dockerfile.js';
7
7
  import { OVERRIDE_ENV, packagesWithScripts } from './checks/install-scripts.js';
8
8
  import * as nodeVersionCheck from './checks/node-version.js';
9
+ import { activeDeferral } from './checks/node-version.js';
9
10
  import * as npmPolicyCheck from './checks/npm-policy.js';
10
11
  import { loadAllowPolicy, verdict } from './lib/allow-scripts.js';
11
12
  import { lockfileName } from './lib/audit.js';
12
13
  import { unifiedDiff } from './lib/diff.js';
13
14
  import { linesWithNpm, loadCalendar } from './lib/node-calendar.js';
15
+ import { imageExists, newestWithImage, upgradeTarget } from './lib/node-target.js';
14
16
  import { parseNpmrc, requiredSettings, settingProblem } from './lib/npmrc.js';
15
17
  import { compareVersions, minVersion, parseVersion } from './lib/semver.js';
16
18
  import { formatBlocks } from './report.js';
@@ -54,9 +56,10 @@ function jsonFormat(text) {
54
56
  };
55
57
  }
56
58
 
57
- // engines.npm (si falta o admite un npm viejo) y engines.node (solo si falta), conservando la
58
- // indentación y el salto de línea final del archivo.
59
- export function mergePackageJson(text, policy, nodeMajor) {
59
+ // engines.npm (si falta o admite un npm viejo) y engines.node (si falta, o con `raiseNode` si admite
60
+ // una línea que hay que cambiar: sin soporte, no LTS o sin npm 11), conservando la indentación y el
61
+ // salto de línea final del archivo.
62
+ export function mergePackageJson(text, policy, nodeMajor, { raiseNode = false } = {}) {
60
63
  const pkg = JSON.parse(text);
61
64
  const changes = [];
62
65
  const minNpm = policy.minNpmVersion.replace(/\.0$/, '');
@@ -66,9 +69,10 @@ export function mergePackageJson(text, policy, nodeMajor) {
66
69
  changes.push(`engines.npm: ">=${minNpm}"${engines.npm ? ` (antes: "${engines.npm}")` : ''}`);
67
70
  engines.npm = `>=${minNpm}`;
68
71
  }
69
- if (engines.node === undefined && nodeMajor) {
72
+ if (nodeMajor && (engines.node === undefined || raiseNode)) {
73
+ const before = engines.node;
70
74
  engines.node = `>=${nodeMajor}`;
71
- changes.push(`engines.node: ">=${nodeMajor}"`);
75
+ changes.push(`engines.node: ">=${nodeMajor}"${before ? ` (antes: "${before}")` : ''}`);
72
76
  }
73
77
  if (!changes.length) return { text, changes };
74
78
  pkg.engines = engines;
@@ -80,21 +84,76 @@ export function mergePackageJson(text, policy, nodeMajor) {
80
84
  // El Node que init propone (engines.node, si falta, y la imagen del pipeline): el que ya usa el repo
81
85
  // si es de una LTS con soporte, para que adoptar el kit no obligue a cambiar de Node. Tiene que traer
82
86
  // el npm que pide la política: si la línea del repo no lo trae en ninguna release (la 22 trae npm 10),
83
- // la siguiente línea que sí, y `from` dice de cuál viene. engines.node toma la línea más baja en uso
84
- // (con engine-strict, un ">=" más alto rompe el npm ci de esa línea) y la imagen es la release LTS más
85
- // nueva de la línea (sin parches de seguridad pendientes). Sin Node fijado, la release recomendada.
86
- export function repoNode(ctx, cal) {
87
- const recommended = cal.recommended ?? null;
88
- let inUse = null;
87
+ // la siguiente que sí, y `from` dice de cuál viene. engines.node toma la línea más baja en uso (con
88
+ // engine-strict, un ">=" más alto rompe el npm ci de esa línea). La imagen es la exacta que ya usa el
89
+ // pipeline si es de esa línea y no hay un parche de seguridad posterior con imagen publicada; si no,
90
+ // la release más nueva de la línea con imagen en Docker Hub. Sin Node fijado (o solo líneas sin
91
+ // soporte), el destino único de lib/node-target.js.
92
+ export async function repoNode(ctx, cal) {
93
+ const target = await upgradeTarget(ctx, cal);
94
+ const { ok } = linesWithNpm(cal, ctx.policy.minNpmVersion);
95
+ let inUse = null; // la línea más baja en uso que cumple (LTS con soporte y npm 11)
96
+ let from = null; // la línea más baja en uso que hay que cambiar, y por qué
97
+ let pipelinePin = null;
89
98
  for (const pin of nodeVersionCheck.findPins(ctx)) {
90
99
  const r = nodeVersionCheck.resolvePin(pin, cal);
91
100
  if (r.major === undefined || r.major === null || r.latest) continue;
92
- if (!['active', 'maintenance'].includes(cal.phaseOf(r.major))) continue;
93
- if (inUse === null || r.major < inUse) inUse = r.major;
101
+ if (ok.includes(r.major)) {
102
+ if (inUse === null || r.major < inUse) inUse = r.major;
103
+ if (!pipelinePin && pin.file === 'bitbucket-pipelines.yml' && pin.kind === 'docker' && r.exact) pipelinePin = r;
104
+ } else if (from === null || r.major < from.major) {
105
+ from = { major: r.major, why: lineProblem(cal, r.major, ctx.policy) };
106
+ }
107
+ }
108
+ if (!target) return { major: null, image: null, npm: null, from };
109
+ const major = inUse ?? target.major;
110
+ if (pipelinePin?.major === major) {
111
+ let patched = false;
112
+ for (const s of cal.securityAfter(major, pipelinePin.exact)) {
113
+ if ((await imageExists(ctx, s.version.replace(/^v/, ''))) === true) patched = true;
114
+ }
115
+ if (!patched) return { major, image: pipelinePin.exact, npm: cal.npmOf(pipelinePin.exact), from };
94
116
  }
95
- const major = inUse === null ? null : (linesWithNpm(cal, ctx.policy.minNpmVersion).ok.find((m) => m >= inUse) ?? null);
96
- if (major === null) return { major: recommended ? parseVersion(recommended)[0] : null, image: recommended, from: null };
97
- return { major, image: cal.newestLtsOfLine(major), from: major !== inUse ? inUse : null };
117
+ if (major === target.major) return { major, image: target.image, npm: target.npm, from };
118
+ const found = await newestWithImage(ctx, cal, major);
119
+ return { major, image: found?.image ?? String(major), npm: found?.npm ?? cal.npmOfLine(major), from };
120
+ }
121
+
122
+ // Por qué una línea de Node no sirve, para el mensaje de init.
123
+ function lineProblem(cal, major, policy) {
124
+ const phase = cal.phaseOf(major);
125
+ if (phase === 'eol') return `sin soporte desde ${cal.lines.get(major).end}`;
126
+ if (phase === 'not-lts') return 'que no es LTS';
127
+ if (phase === 'unknown') return 'que no aparece en el calendario de Node';
128
+ return `pero ninguna release de la ${major} trae npm >= ${policy.minNpmVersion}, que pide la política`;
129
+ }
130
+
131
+ // Las líneas `- npm ci` del pipeline: las del step que corre en los PR (inline o por ancla) y el resto
132
+ // (deploys, publicación). El kit va en el de los PR. Sin sección pull-requests, todas cuentan como PR.
133
+ export function npmCiLines(text) {
134
+ const lines = (text ?? '').split(/\r?\n/);
135
+ const indent = (l) => l.match(/^ */)[0].length;
136
+ const isCi = (l) => /^\s*-\s*npm ci(\s|$)/.test(l);
137
+ const all = lines.flatMap((l, i) => (isCi(l) ? [i + 1] : []));
138
+ const blockAfter = (start) => {
139
+ const out = [];
140
+ for (let i = start + 1; i < lines.length; i++) {
141
+ if (!lines[i].trim() || lines[i].trim().startsWith('#')) continue;
142
+ if (indent(lines[i]) <= indent(lines[start])) break;
143
+ out.push(i);
144
+ }
145
+ return out;
146
+ };
147
+ const prStart = lines.findIndex((l) => /^\s+pull-requests:\s*(#.*)?$/.test(l));
148
+ if (prStart < 0) return { pr: all, others: [] };
149
+ const prBlock = blockAfter(prStart);
150
+ const pr = new Set(prBlock.filter((i) => isCi(lines[i])).map((i) => i + 1));
151
+ for (const anchor of new Set(prBlock.flatMap((i) => [...lines[i].matchAll(/\*([\w-]+)/g)].map((m) => m[1])))) {
152
+ const def = lines.findIndex((l) => new RegExp(`&${anchor}(\\s|$)`).test(l));
153
+ if (def >= 0) for (const i of blockAfter(def)) if (isCi(lines[i])) pr.add(i + 1);
154
+ }
155
+ if (!pr.size) return { pr: all, others: [] };
156
+ return { pr: [...pr].sort((a, b) => a - b), others: all.filter((n) => !pr.has(n)) };
98
157
  }
99
158
 
100
159
  // Las ramas que despliegan: las claves de `pipelines.branches` (sin globs), en orden.
@@ -205,14 +264,20 @@ export async function runInit(ctx, { dryRun = false, stdout = process.stdout, co
205
264
  } catch (error) {
206
265
  cal = { error };
207
266
  }
208
- const node = cal.error ? { major: null, image: null } : repoNode(ctx, cal);
267
+ const node = cal.error ? { major: null, image: null, npm: null, from: null } : await repoNode(ctx, cal);
209
268
 
210
269
  say(paint(2, `npm-security-kit ${ctx.kitVersion} · init${dryRun ? ' --dry-run' : ''} · ${ctx.dir}`));
211
270
 
212
271
  // 1. .npmrc y package.json
213
272
  const npmrcBefore = ctx.readFile('.npmrc');
214
273
  const npmrc = mergeNpmrc(npmrcBefore, ctx.policy);
215
- const pkg = mergePackageJson(pkgText, ctx.policy, node.major);
274
+ // Con el cambio de Node diferido (salto grande, excepción vigente), engines no se toca: los de la línea
275
+ // nueva, con engine-strict, romperían el npm ci de la actual.
276
+ const deferral = cal.error ? null : await activeDeferral(ctx);
277
+ const engNode = JSON.parse(pkgText).engines?.node;
278
+ const engMin = typeof engNode === 'string' ? minVersion(engNode) : null;
279
+ const raiseNode = Boolean(engMin && !cal.error && node.major && !linesWithNpm(cal, ctx.policy.minNpmVersion).ok.includes(parseVersion(engMin)[0]));
280
+ const pkg = deferral ? { text: pkgText, changes: [] } : mergePackageJson(pkgText, ctx.policy, node.major, { raiseNode });
216
281
  if (npmrc.changes.length) files['.npmrc'] = npmrc.text;
217
282
  if (pkg.changes.length) files['package.json'] = pkg.text;
218
283
 
@@ -262,23 +327,38 @@ export async function runInit(ctx, { dryRun = false, stdout = process.stdout, co
262
327
  const pipeline = ctx.readFile('bitbucket-pipelines.yml');
263
328
  const used = pipeline?.match(/npm-security-kit@([\w.-]+)/);
264
329
  const image = node.image ?? '<versión LTS>';
265
- if (node.from !== null && node.from !== undefined) {
330
+ const bigJump = node.from && node.major - node.from.major > ctx.policy.nodeDeferMinJump;
331
+ if (deferral) {
332
+ say(
333
+ ` Cambio de Node diferido hasta ${deferral.until} (${deferral.reason}): el step sigue con la imagen actual y engines no se toca. ` +
334
+ `Al vencer, pasar a la ${node.major} (node:${image}).`,
335
+ );
336
+ } else if (node.from) {
266
337
  say(
267
- ` El repo usa Node ${node.from}, pero ninguna release de la ${node.from} trae npm >= ${ctx.policy.minNpmVersion}, que pide la política: ` +
268
- `se propone la ${node.major} (node:${image}, npm ${cal.npmOfLine(node.major)}). Pasar a la ${node.major} el .nvmrc, las imágenes del pipeline y los FROM de los Dockerfiles.`,
338
+ ` El repo usa Node ${node.from.major}, ${node.from.why}: ` +
339
+ `se propone la ${node.major} (node:${image}${node.npm ? `, npm ${node.npm}` : ''}). Pasar a la ${node.major} el .nvmrc, las imágenes del pipeline, los FROM de los Dockerfiles y engines.node.` +
340
+ (bigJump ? ' Si el salto no se puede hacer ahora, se puede diferir con una excepción con fecha (README: "Diferir el cambio de Node").' : ''),
269
341
  );
270
342
  }
271
343
  const branches = deployBranches(pipeline);
272
- const ciLines = (pipeline ?? '').split(/\r?\n/).flatMap((l, i) => (/^\s*-\s*npm ci(\s|$)/.test(l) ? [i + 1] : []));
344
+ const ci = npmCiLines(pipeline);
273
345
  if (used) {
274
346
  say(` Ya corre el kit (${used[0]}).${used[1] !== ctx.kitVersion ? ` Para esta versión: ${PACKAGE}@${ctx.kitVersion}.` : ''}`);
275
- } else if (ciLines.length) {
276
- say(` Agregar al step que ya corre npm ci (bitbucket-pipelines.yml:${ciLines.join(', ')}), sin reemplazar el resto del step:`);
347
+ } else if (ci.pr.length) {
348
+ say(` Agregar al step que corre en los PR (bitbucket-pipelines.yml:${ci.pr.join(', ')}), antes de su npm ci, sin reemplazar el resto del step:`);
277
349
  say();
278
350
  say(` - npx --yes --min-release-age=0 ${PACKAGE}@${ctx.kitVersion} check # antes de npm ci`);
279
351
  say(' - npm audit signatures # después de npm ci');
280
352
  say();
281
- say(` Con una imagen que traiga npm >= ${ctx.policy.minNpmVersion} (image: node:${image}).`);
353
+ if (ci.others.length) {
354
+ say(` Los otros npm ci (líneas ${ci.others.join(', ')}: deploys, publicación) no necesitan el kit: ya pasó en el PR.`);
355
+ }
356
+ if (!deferral && !new RegExp(`node:${image.replace(/\./g, '\\.')}(\\s|$|-)`).test(pipeline)) {
357
+ say(` Con una imagen que traiga npm >= ${ctx.policy.minNpmVersion} (image: node:${image}).`);
358
+ }
359
+ if (!/^\s+ci:\s*(#.*)?$/m.test(pipeline)) {
360
+ say(' Para correrlo a mano ("Run pipeline"), un `custom: ci` con el mismo step (ver el README).');
361
+ }
282
362
  if (!/condition:[\s\S]*BITBUCKET_PR_DESTINATION_BRANCH/.test(pipeline)) {
283
363
  say(` Y correrlo solo en los PR a las ramas que despliegan${branches.length ? ` (${branches.join(', ')})` : ''}, con la condition del step:`);
284
364
  say();
@@ -306,7 +386,15 @@ export async function runInit(ctx, { dryRun = false, stdout = process.stdout, co
306
386
  section('Pasos siguientes');
307
387
  const steps = [
308
388
  dryRun ? 'Correr init sin --dry-run.' : 'Revisar el diff: git diff .npmrc package.json',
309
- 'Aprobar o negar los install scripts pendientes.',
389
+ ...(deferral
390
+ ? [`Antes del ${deferral.until}, cuando vence la excepción: pasar de la ${node.from?.major ?? deferral.majors[0]} a la ${node.major} (.nvmrc, imágenes, FROM, engines.node y engines.npm).`]
391
+ : node.from
392
+ ? [
393
+ `Pasar el repo de la ${node.from.major} a la ${node.major}: .nvmrc, imágenes del pipeline y FROM de los Dockerfiles (node:${node.image}). ` +
394
+ `En local, \`nvm install ${node.major} && nvm use ${node.major}\`; si npm ci da EUSAGE, \`npm install --package-lock-only --ignore-scripts\`.`,
395
+ ]
396
+ : []),
397
+ '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`).',
310
398
  'Agregar el step al pipeline y corregir los Dockerfiles.',
311
399
  `Correr el chequeo completo: npx --yes --min-release-age=0 ${PACKAGE}@${ctx.kitVersion} check`,
312
400
  ];
@@ -63,6 +63,9 @@ export function analyzeCalendar(index, schedule, today) {
63
63
  // La release LTS más nueva de la línea y el npm que trae (campo `npm` de index.json).
64
64
  newestLtsOfLine: (major) => newestLtsOf(major)?.version.replace(/^v/, ''),
65
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,
66
69
  // true si la LTS más nueva todavía no tiene release LTS en index.json.
67
70
  newestLtsWithoutRelease: newestLts !== null && !newestLtsRelease,
68
71
  latestLtsMajor: latestLtsRelease ? majorOf(latestLtsRelease.version) : null,
@@ -0,0 +1,39 @@
1
+ // Excepción para diferir el cambio de línea de Node cuando el salto es grande (decisión del 28-10):
2
+ // subir varias versiones mayores suele arrastrar dependencias y refactors. Se declara en el
3
+ // package.json, así queda en el diff del PR, y vence:
4
+ //
5
+ // "npmSecurityKit": { "nodeUpgradeDeferredUntil": "AAAA-MM-DD", "nodeUpgradeReason": "por qué" }
6
+ //
7
+ // Vale por hasta policy.nodeDeferMaxDays desde hoy y solo para las líneas cuyo salto al destino es de
8
+ // más de policy.nodeDeferMinJump versiones mayores (eso lo decide quien la usa).
9
+ import { parseDay } from '../context.js';
10
+ import { jsonKeyLine } from './json-lines.js';
11
+
12
+ const DAY = 86400000;
13
+ const iso = (date) => date.toISOString().slice(0, 10);
14
+
15
+ // `{ status: 'none' | 'invalid' | 'expired' | 'active', until?, reason?, line?, daysLeft?, problem? }`.
16
+ export function readDeferral(ctx) {
17
+ const text = ctx.readFile('package.json');
18
+ let pkg;
19
+ try {
20
+ pkg = JSON.parse(text ?? 'null');
21
+ } catch {
22
+ return { status: 'none' };
23
+ }
24
+ const config = pkg?.npmSecurityKit;
25
+ if (!config || config.nodeUpgradeDeferredUntil === undefined) return { status: 'none' };
26
+ const until = config.nodeUpgradeDeferredUntil;
27
+ const line = jsonKeyLine(text, ['npmSecurityKit', 'nodeUpgradeDeferredUntil']);
28
+ const reason = typeof config.nodeUpgradeReason === 'string' ? config.nodeUpgradeReason.trim() : '';
29
+ const date = typeof until === 'string' ? parseDay(until) : null;
30
+ if (!date) return { status: 'invalid', until, line, problem: 'la fecha tiene que ser "AAAA-MM-DD"' };
31
+ if (!reason) return { status: 'invalid', until, line, problem: 'falta "nodeUpgradeReason" (por qué no se puede subir ahora)' };
32
+ const daysLeft = Math.round((date - ctx.today) / DAY);
33
+ const max = ctx.policy.nodeDeferMaxDays;
34
+ if (daysLeft > max) {
35
+ return { status: 'invalid', until, reason, line, problem: `pasa del máximo de ${max} días (como mucho hasta ${iso(new Date(ctx.today.getTime() + max * DAY))})` };
36
+ }
37
+ if (daysLeft < 0) return { status: 'expired', until, reason, line };
38
+ return { status: 'active', until, reason, line, daysLeft };
39
+ }
@@ -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
+ }
package/src/policy.js CHANGED
@@ -27,6 +27,10 @@ export const policy = freeze({
27
27
  allowedRegistries: ['https://registry.npmjs.org/'],
28
28
  // Días de soporte restantes de una línea de Node por debajo de los cuales se avisa.
29
29
  nodeEolWarnDays: 180,
30
+ // El cambio de línea de Node se puede diferir con una excepción con fecha en el package.json solo si
31
+ // el salto al destino es de más de estas versiones mayores, y por hasta estos días.
32
+ nodeDeferMinJump: 2,
33
+ nodeDeferMaxDays: 183,
30
34
  // Claves del .npmrc del proyecto. `error`: sin ellas la política no se cumple.
31
35
  // `warn`: recomendadas.
32
36
  requiredNpmrc: {