@theoptimalpartner/npm-security-kit 2.0.0-beta.4 → 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
@@ -38,6 +38,17 @@ Sale primero como beta (`2.0.0-beta.N`), con el dist-tag `beta`.
38
38
  da EBADENGINE con el `engines.node` de `init`). Los pasos siguientes de `init` incluyen el cambio de línea, el
39
39
  lockfile y el `prepare` del proyecto; la sección del pipeline menciona `custom: ci`.
40
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.
41
52
 
42
53
  ### BREAKING
43
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.4`). 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.4`). 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 |
@@ -48,7 +48,8 @@ subir a una LTS que traiga npm 11. `check` da error en cada lugar que fija la 22
48
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
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
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.
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.
52
53
 
53
54
  ## Uso
54
55
 
@@ -68,7 +69,7 @@ definitions:
68
69
  - npm
69
70
  script:
70
71
  # Antes de instalar: malware, vulnerabilidades, Node, .npmrc, install scripts, Dockerfiles
71
- - npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.4 check
72
+ - npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.5 check
72
73
  - npm ci # con strict-allow-scripts, los scripts no aprobados cortan acá
73
74
  - npm audit signatures
74
75
  # … y lo que ya corría el step: build, lint, tests
@@ -88,13 +89,13 @@ pipelines:
88
89
 
89
90
  - **Antes de `npm ci`:** `npm audit` funciona solo con el lockfile, así que el malware se detecta antes de que
90
91
  corra su `postinstall`.
91
- - **Versión fija** (`@2.0.0-beta.4`): 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.
92
93
  - **`--min-release-age=0` solo para `npx`:** `npx` no aplica `min-release-age-exclude` (npm 11.19.1 lo aplica al
93
94
  instalar, no al resolver el paquete de `npx`). Sin el flag, dentro de un repo con `min-release-age=7` una versión
94
95
  nueva del kit no se puede usar hasta cumplir 7 días (`ETARGET … with a date before`). Afecta solo al kit: la
95
96
  versión va fija y no tiene dependencias. El `npm ci` que sigue aplica los 7 días como siempre.
96
97
  - **`npm audit signatures`** va después de `npm ci` porque necesita `node_modules`.
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`.
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`.
98
99
 
99
100
  ### Dockerfile
100
101
 
@@ -109,24 +110,28 @@ RUN npm ci
109
110
 
110
111
  Si el `.dockerignore` excluye el `.npmrc` (por ejemplo con `.*`), agregar `!.npmrc` al final.
111
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
+
112
117
  ### Adoptarlo en un repo: `init`
113
118
 
114
119
  Una vez por repo, en una rama:
115
120
 
116
121
  ```bash
117
- npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.4 init --dry-run
122
+ npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.5 init --dry-run
118
123
  ```
119
124
 
120
125
  ```bash
121
- npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.4 init
126
+ npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.5 init
122
127
  ```
123
128
 
124
129
  `init`:
125
130
 
126
131
  - agrega al `.npmrc` las claves de la política que faltan (con un comentario) y corrige las que tienen otro valor,
127
132
  sin borrar ni reordenar nada;
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);
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;
130
135
  - **no aprueba install scripts**: lista los pendientes y los comandos para revisarlos;
131
136
  - **no edita** `bitbucket-pipelines.yml` ni los Dockerfiles: dice qué líneas agregar al step que corre `npm ci`, la
132
137
  condition con las ramas que despliegan y los `COPY` corregidos;
@@ -137,7 +142,7 @@ Después: revisar el diff, aprobar o negar los install scripts, aplicar el step
137
142
  ### En local
138
143
 
139
144
  ```bash
140
- npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.4 check
145
+ npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.5 check
141
146
  ```
142
147
 
143
148
  No se instala como devDependency ni usa husky: el `.npmrc` protege el `npm install` local y el pipeline es el
@@ -221,6 +226,51 @@ Si un repo no puede adoptar todavía la aprobación de install scripts, se defin
221
226
  Con el override, npm ejecuta los scripts sin aprobar y el kit los informa como **aviso**, más un aviso de que el
222
227
  override está activo. Dentro del `docker build` la variable no llega sola: hay que pasarla como `ARG`.
223
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
+
224
274
  ### Vulnerabilidades `critical` en devDependencies
225
275
 
226
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.4",
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({
@@ -5,6 +5,7 @@
5
5
  import { findDockerfiles, nodeImageTag, parseStages } from '../lib/dockerfile.js';
6
6
  import { jsonKeyLine } from '../lib/json-lines.js';
7
7
  import { loadCalendar } from '../lib/node-calendar.js';
8
+ import { readDeferral } from '../lib/node-deferral.js';
8
9
  import { imageExists, newestWithImage, upgradeTarget } from '../lib/node-target.js';
9
10
  import { compareVersions, isRange, minVersion, parseVersion } from '../lib/semver.js';
10
11
 
@@ -166,11 +167,11 @@ async function evaluate(ctx, pin, r, cal, { target, newestLtsWithoutImage }) {
166
167
 
167
168
  const phase = cal.phaseOf(r.major);
168
169
  if (phase === 'eol') {
169
- 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 } };
170
171
  }
171
172
  if (phase === 'not-lts') {
172
173
  const why = sch.lts ? `todavía no es LTS (lo será el ${sch.lts})` : 'línea impar, nunca va a ser LTS';
173
- 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 } };
174
175
  }
175
176
  // La política pide npm >= minNpmVersion y la imagen de Node trae el suyo: una línea que no lo trae en
176
177
  // ninguna release (la 22 trae npm 10) deja el pipeline y el docker build con npm 10.
@@ -181,7 +182,7 @@ async function evaluate(ctx, pin, r, cal, { target, newestLtsWithoutImage }) {
181
182
  ...base,
182
183
  level: 'error',
183
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}.`,
184
- data: { ...data, npm: lineNpm },
185
+ data: { ...data, npm: lineNpm, lineChange: true },
185
186
  };
186
187
  }
187
188
 
@@ -223,6 +224,40 @@ async function evaluate(ctx, pin, r, cal, { target, newestLtsWithoutImage }) {
223
224
  return { ...base, level, message: `${prefix}: ${detail}`, data: { ...data, group: `v${r.major}: ${detail}` } };
224
225
  }
225
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
+
226
261
  export async function run(ctx) {
227
262
  let cal;
228
263
  try {
@@ -239,12 +274,59 @@ export async function run(ctx) {
239
274
  const pins = findPins(ctx);
240
275
  const findings = [];
241
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;
242
281
  let nodePins = 0;
243
282
  for (const pin of pins) {
244
283
  const r = resolvePin(pin, cal);
245
284
  if (!r.otherImage) nodePins++;
246
285
  if (r.major !== undefined && r.major !== null) majors.add(r.major);
247
- findings.push(await evaluate(ctx, pin, r, cal, { target, newestLtsWithoutImage }));
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
+ });
248
330
  }
249
331
  if (!nodePins) {
250
332
  const version = target?.version ?? cal.recommended;
@@ -5,6 +5,7 @@ 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
7
  import { upgradeTarget } from '../lib/node-target.js';
8
+ import { activeDeferral } from './node-version.js';
8
9
  import { compareVersions, minVersion, parseVersion } from '../lib/semver.js';
9
10
 
10
11
  export const id = 'npm-policy';
@@ -85,6 +86,12 @@ export async function run(ctx) {
85
86
 
86
87
  // engines.npm
87
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';
88
95
  if (pkgText === null) {
89
96
  findings.push({ level: 'error', message: 'No hay package.json en el directorio revisado.' });
90
97
  } else {
@@ -93,16 +100,16 @@ export async function run(ctx) {
93
100
  const min = typeof range === 'string' ? minVersion(range) : null;
94
101
  if (typeof range !== 'string') {
95
102
  findings.push({
96
- level: 'error',
103
+ level: npmLevel,
97
104
  file: 'package.json',
98
- 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}` : ''}`,
99
106
  });
100
107
  } else if (!min || compareVersions(min, minNpm) < 0) {
101
108
  findings.push({
102
- level: 'error',
109
+ level: npmLevel,
103
110
  file: 'package.json',
104
111
  line: jsonKeyLine(pkgText, ['engines', 'npm']),
105
- 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}`,
106
113
  data: { range, min },
107
114
  });
108
115
  }
@@ -115,8 +122,8 @@ export async function run(ctx) {
115
122
  findings.push({ level: 'warn', message: `No se pudo saber la versión de npm en uso (${(npmVersion.stderr || npmVersion.stdout).trim() || 'sin salida'}).` });
116
123
  } else if (compareVersions(used, minNpm) < 0) {
117
124
  findings.push({
118
- level: 'error',
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}.`)(
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}`)(
120
127
  await npmHint(ctx, minNpm),
121
128
  ),
122
129
  data: { npm: used },
package/src/init.js CHANGED
@@ -6,6 +6,7 @@ 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';
@@ -55,9 +56,10 @@ function jsonFormat(text) {
55
56
  };
56
57
  }
57
58
 
58
- // engines.npm (si falta o admite un npm viejo) y engines.node (solo si falta), conservando la
59
- // indentación y el salto de línea final del archivo.
60
- 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 } = {}) {
61
63
  const pkg = JSON.parse(text);
62
64
  const changes = [];
63
65
  const minNpm = policy.minNpmVersion.replace(/\.0$/, '');
@@ -67,9 +69,10 @@ export function mergePackageJson(text, policy, nodeMajor) {
67
69
  changes.push(`engines.npm: ">=${minNpm}"${engines.npm ? ` (antes: "${engines.npm}")` : ''}`);
68
70
  engines.npm = `>=${minNpm}`;
69
71
  }
70
- if (engines.node === undefined && nodeMajor) {
72
+ if (nodeMajor && (engines.node === undefined || raiseNode)) {
73
+ const before = engines.node;
71
74
  engines.node = `>=${nodeMajor}`;
72
- changes.push(`engines.node: ">=${nodeMajor}"`);
75
+ changes.push(`engines.node: ">=${nodeMajor}"${before ? ` (antes: "${before}")` : ''}`);
73
76
  }
74
77
  if (!changes.length) return { text, changes };
75
78
  pkg.engines = engines;
@@ -88,18 +91,22 @@ export function mergePackageJson(text, policy, nodeMajor) {
88
91
  // soporte), el destino único de lib/node-target.js.
89
92
  export async function repoNode(ctx, cal) {
90
93
  const target = await upgradeTarget(ctx, cal);
91
- let inUse = null;
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é
92
97
  let pipelinePin = null;
93
98
  for (const pin of nodeVersionCheck.findPins(ctx)) {
94
99
  const r = nodeVersionCheck.resolvePin(pin, cal);
95
100
  if (r.major === undefined || r.major === null || r.latest) continue;
96
- if (!['active', 'maintenance'].includes(cal.phaseOf(r.major))) continue;
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;
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
+ }
99
107
  }
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;
108
+ if (!target) return { major: null, image: null, npm: null, from };
109
+ const major = inUse ?? target.major;
103
110
  if (pipelinePin?.major === major) {
104
111
  let patched = false;
105
112
  for (const s of cal.securityAfter(major, pipelinePin.exact)) {
@@ -112,6 +119,43 @@ export async function repoNode(ctx, cal) {
112
119
  return { major, image: found?.image ?? String(major), npm: found?.npm ?? cal.npmOfLine(major), from };
113
120
  }
114
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)) };
157
+ }
158
+
115
159
  // Las ramas que despliegan: las claves de `pipelines.branches` (sin globs), en orden.
116
160
  export function deployBranches(text) {
117
161
  const out = [];
@@ -227,7 +271,13 @@ export async function runInit(ctx, { dryRun = false, stdout = process.stdout, co
227
271
  // 1. .npmrc y package.json
228
272
  const npmrcBefore = ctx.readFile('.npmrc');
229
273
  const npmrc = mergeNpmrc(npmrcBefore, ctx.policy);
230
- 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 });
231
281
  if (npmrc.changes.length) files['.npmrc'] = npmrc.text;
232
282
  if (pkg.changes.length) files['package.json'] = pkg.text;
233
283
 
@@ -277,23 +327,35 @@ export async function runInit(ctx, { dryRun = false, stdout = process.stdout, co
277
327
  const pipeline = ctx.readFile('bitbucket-pipelines.yml');
278
328
  const used = pipeline?.match(/npm-security-kit@([\w.-]+)/);
279
329
  const image = node.image ?? '<versión LTS>';
280
- 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) {
281
337
  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.`,
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").' : ''),
284
341
  );
285
342
  }
286
343
  const branches = deployBranches(pipeline);
287
- const ciLines = (pipeline ?? '').split(/\r?\n/).flatMap((l, i) => (/^\s*-\s*npm ci(\s|$)/.test(l) ? [i + 1] : []));
344
+ const ci = npmCiLines(pipeline);
288
345
  if (used) {
289
346
  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:`);
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:`);
292
349
  say();
293
350
  say(` - npx --yes --min-release-age=0 ${PACKAGE}@${ctx.kitVersion} check # antes de npm ci`);
294
351
  say(' - npm audit signatures # después de npm ci');
295
352
  say();
296
- 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
+ }
297
359
  if (!/^\s+ci:\s*(#.*)?$/m.test(pipeline)) {
298
360
  say(' Para correrlo a mano ("Run pipeline"), un `custom: ci` con el mismo step (ver el README).');
299
361
  }
@@ -324,9 +386,11 @@ export async function runInit(ctx, { dryRun = false, stdout = process.stdout, co
324
386
  section('Pasos siguientes');
325
387
  const steps = [
326
388
  dryRun ? 'Correr init sin --dry-run.' : 'Revisar el diff: git diff .npmrc package.json',
327
- ...(node.from !== null && node.from !== undefined
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
328
392
  ? [
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}). ` +
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}). ` +
330
394
  `En local, \`nvm install ${node.major} && nvm use ${node.major}\`; si npm ci da EUSAGE, \`npm install --package-lock-only --ignore-scripts\`.`,
331
395
  ]
332
396
  : []),
@@ -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
+ }
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: {