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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -27,6 +27,17 @@ 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`.
30
41
 
31
42
  ### BREAKING
32
43
 
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.4`). Las publicadas, con sus dist-tags:
7
7
  `npm view @theoptimalpartner/npm-security-kit dist-tags`.
8
8
 
9
9
  ## Qué revisa
@@ -44,9 +44,11 @@ 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.
50
52
 
51
53
  ## Uso
52
54
 
@@ -66,7 +68,7 @@ definitions:
66
68
  - npm
67
69
  script:
68
70
  # 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
71
+ - npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.4 check
70
72
  - npm ci # con strict-allow-scripts, los scripts no aprobados cortan acá
71
73
  - npm audit signatures
72
74
  # … y lo que ya corría el step: build, lint, tests
@@ -86,13 +88,13 @@ pipelines:
86
88
 
87
89
  - **Antes de `npm ci`:** `npm audit` funciona solo con el lockfile, así que el malware se detecta antes de que
88
90
  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.
91
+ - **Versión fija** (`@2.0.0-beta.4`): subir la política en un repo es un cambio visible en su PR.
90
92
  - **`--min-release-age=0` solo para `npx`:** `npx` no aplica `min-release-age-exclude` (npm 11.19.1 lo aplica al
91
93
  instalar, no al resolver el paquete de `npx`). Sin el flag, dentro de un repo con `min-release-age=7` una versión
92
94
  nueva del kit no se puede usar hasta cumplir 7 días (`ETARGET … with a date before`). Afecta solo al kit: la
93
95
  versión va fija y no tiene dependencias. El `npm ci` que sigue aplica los 7 días como siempre.
94
96
  - **`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`.
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`.
96
98
 
97
99
  ### Dockerfile
98
100
 
@@ -112,11 +114,11 @@ Si el `.dockerignore` excluye el `.npmrc` (por ejemplo con `.*`), agregar `!.npm
112
114
  Una vez por repo, en una rama:
113
115
 
114
116
  ```bash
115
- npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.3 init --dry-run
117
+ npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.4 init --dry-run
116
118
  ```
117
119
 
118
120
  ```bash
119
- npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.3 init
121
+ npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.4 init
120
122
  ```
121
123
 
122
124
  `init`:
@@ -135,7 +137,7 @@ Después: revisar el diff, aprobar o negar los install scripts, aplicar el step
135
137
  ### En local
136
138
 
137
139
  ```bash
138
- npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.3 check
140
+ npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.4 check
139
141
  ```
140
142
 
141
143
  No se instala como devDependency ni usa husky: el `.npmrc` protege el `npm install` local y el pipeline es el
@@ -191,6 +193,9 @@ npm install-scripts deny core-js # solo imprime un mensaje: no hace falta
191
193
  `npm install-scripts ls` lee `node_modules`: si el `npm ci --ignore-scripts` falló (por ejemplo con EUSAGE), dice que
192
194
  no hay nada pendiente. La lista de `check` e `init` sale del lockfile y no depende de eso.
193
195
 
196
+ `--ignore-scripts` tampoco corre los scripts del propio proyecto, como el `prepare` que instala husky: si el repo los
197
+ usa, `npm run prepare` después (si no, el hook de git falla en el próximo commit).
198
+
194
199
  `approve` guarda la versión exacta (`"paquete@1.2.3": true`): cuando se actualiza el paquete, `check` vuelve a dar
195
200
  error hasta aprobar la versión nueva. Es a propósito (cada versión nueva se revisa). Para un paquete de confianza que
196
201
  se actualiza seguido, `npm install-scripts approve --no-allow-scripts-pin <paquete>` lo aprueba sin versión. `deny`
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.4",
4
4
  "description": "Revisa el lockfile antes de npm ci: malware, vulnerabilidades, versión de Node, .npmrc, install scripts y Dockerfiles",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,7 +4,8 @@
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 { imageExists, newestWithImage, upgradeTarget } from '../lib/node-target.js';
8
9
  import { compareVersions, isRange, minVersion, parseVersion } from '../lib/semver.js';
9
10
 
10
11
  export const id = 'node-version';
@@ -146,7 +147,10 @@ export function resolvePin(pin, cal) {
146
147
  return { major: v[0], exact: parts === 3 ? v.join('.') : undefined };
147
148
  }
148
149
 
149
- function evaluate(pin, r, cal, policy) {
150
+ // `target` es el destino único cuando hay que cambiar de línea (lib/node-target.js). En los pines de
151
+ // Docker se propone su imagen, que ya existe en Docker Hub.
152
+ async function evaluate(ctx, pin, r, cal, { target, newestLtsWithoutImage }) {
153
+ const { policy } = ctx;
150
154
  const base = { file: pin.file, line: pin.line };
151
155
  if (r.otherImage) {
152
156
  return { ...base, level: 'info', message: `${pin.spec}: imagen sin Node fijado (la versión de Node la decide la imagen).`, data: { pin: pin.pin } };
@@ -155,9 +159,9 @@ function evaluate(pin, r, cal, policy) {
155
159
 
156
160
  const sch = cal.lines.get(r.major);
157
161
  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 };
162
+ const rec = target ? (pin.kind === 'docker' ? target.image : target.version) : cal.recommended;
163
+ const engineHint = pin.kind === 'engines' ? ` (en engines: ">=${target ? target.major : parseVersion(rec)[0]}")` : '';
164
+ const data = { pin: pin.pin, major: r.major, phase: cal.phaseOf(r.major), recommended: target?.version ?? cal.recommended };
161
165
  if (!sch) return { ...base, level: 'warn', message: `${prefix}: no aparece en el calendario de Node.`, data };
162
166
 
163
167
  const phase = cal.phaseOf(r.major);
@@ -172,13 +176,11 @@ function evaluate(pin, r, cal, policy) {
172
176
  // ninguna release (la 22 trae npm 10) deja el pipeline y el docker build con npm 10.
173
177
  const lineNpm = cal.npmOfLine(r.major);
174
178
  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}")` : '';
179
+ const to = target?.npm ? `${rec} (npm ${target.npm})` : rec;
178
180
  return {
179
181
  ...base,
180
182
  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}.`,
183
+ message: `${prefix}: trae npm ${lineNpm}, y ninguna release de la ${r.major} trae npm >= ${policy.minNpmVersion}, que pide la política. Subir a una LTS con npm 11: ${to} o más nueva${engineHint}.`,
182
184
  data: { ...data, npm: lineNpm },
183
185
  };
184
186
  }
@@ -191,6 +193,8 @@ function evaluate(pin, r, cal, policy) {
191
193
  const n = cal.lines.get(cal.newestLts);
192
194
  if (cal.newestLtsWithoutRelease) {
193
195
  infoNotes.push(`Hay una LTS nueva (v${cal.newestLts}, desde ${n.lts}), aún sin release LTS: planificar la migración cuando salga.`);
196
+ } else if (newestLtsWithoutImage) {
197
+ infoNotes.push(`Hay una LTS nueva (v${cal.newestLts}, desde ${n.lts}), aún sin imagen oficial en Docker Hub: planificar la migración cuando salga.`);
194
198
  } else {
195
199
  notes.push(`Hay una LTS nueva (v${cal.newestLts}, desde ${n.lts}): planificar la migración.`);
196
200
  }
@@ -202,7 +206,11 @@ function evaluate(pin, r, cal, policy) {
202
206
  }
203
207
  if (r.exact) {
204
208
  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/, '')}.`);
209
+ if (sec.length) {
210
+ const first = sec[0].version.replace(/^v/, '');
211
+ const noImage = pin.kind === 'docker' && (await imageExists(ctx, first)) === false ? ' (todavía sin imagen en Docker Hub)' : '';
212
+ notes.push(`Hay releases de seguridad posteriores a ${r.exact}: como mínimo ${first}${noImage}.`);
213
+ }
206
214
  }
207
215
  const support = `con soporte hasta ${sch.end}${days < policy.nodeEolWarnDays ? ` (quedan ${days} días)` : ''}`;
208
216
  if (!notes.length && !infoNotes.length && days >= policy.nodeEolWarnDays) {
@@ -224,6 +232,10 @@ export async function run(ctx) {
224
232
  }
225
233
  if (!cal.recommended) return [{ level: 'warn', message: 'el calendario de Node no trae releases LTS; se omite el chequeo.' }];
226
234
 
235
+ const target = await upgradeTarget(ctx, cal);
236
+ // Mientras la LTS más nueva no tiene imagen oficial nadie la puede usar en el pipeline: se informa.
237
+ const newestLtsWithoutImage =
238
+ cal.newestLts !== null && !cal.newestLtsWithoutRelease && (await newestWithImage(ctx, cal, cal.newestLts)) === null;
227
239
  const pins = findPins(ctx);
228
240
  const findings = [];
229
241
  const majors = new Set();
@@ -232,13 +244,13 @@ export async function run(ctx) {
232
244
  const r = resolvePin(pin, cal);
233
245
  if (!r.otherImage) nodePins++;
234
246
  if (r.major !== undefined && r.major !== null) majors.add(r.major);
235
- findings.push(evaluate(pin, r, cal, ctx.policy));
247
+ findings.push(await evaluate(ctx, pin, r, cal, { target, newestLtsWithoutImage }));
236
248
  }
237
249
  if (!nodePins) {
238
- const major = parseVersion(cal.recommended)[0];
250
+ const version = target?.version ?? cal.recommended;
239
251
  findings.push({
240
252
  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}".`,
253
+ message: `No se fija la versión de Node en ningún lado (.nvmrc, engines.node, Dockerfile, pipeline). Fijarla, por ejemplo: .nvmrc con ${version} y engines.node ">=${parseVersion(version)[0]}".`,
242
254
  });
243
255
  }
244
256
  if (majors.size > 1) {
@@ -4,6 +4,7 @@ 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';
7
8
  import { compareVersions, minVersion, parseVersion } from '../lib/semver.js';
8
9
 
9
10
  export const id = 'npm-policy';
@@ -11,19 +12,30 @@ export const title = 'npm y .npmrc';
11
12
 
12
13
  const NPM10_WHY = 'npm 10 ignora min-release-age sin avisar y no aplica allowScripts';
13
14
 
14
- // Qué imagen usar para tener ese npm. Sin calendario, el consejo genérico.
15
- async function npmImageHint(ctx, minNpm) {
15
+ // Qué hacer para tener ese npm: en el pipeline, la imagen del destino único (lib/node-target.js); en
16
+ // local, cambiar de línea si la del Node que corre no lo trae (con engine-strict y el engines.node de
17
+ // init, npm 11 sobre la 22 da EBADENGINE) o, si la trae, actualizar npm. Sin calendario, lo genérico.
18
+ async function npmHint(ctx, minNpm) {
19
+ const generic = { pipeline: 'usar la imagen de Node LTS recomendada', local: '`npm install -g npm@11`' };
16
20
  let cal;
21
+ let target;
17
22
  try {
18
23
  cal = await loadCalendar(ctx);
24
+ target = await upgradeTarget(ctx, cal);
19
25
  } catch {
20
- return 'usar la imagen de Node LTS recomendada';
26
+ return generic;
21
27
  }
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}`;
28
+ if (!target) return generic;
29
+ const major = parseVersion(minNpm)[0];
30
+ const { without } = linesWithNpm(cal, minNpm);
31
+ const none = without.length ? `; ninguna release de Node ${without.join(' ni ')} trae npm ${major}` : '';
32
+ const running = parseVersion(ctx.nodeVersion ?? '')?.[0];
33
+ return {
34
+ pipeline: `usar una imagen de Node que lo traiga: node:${target.image}${target.npm ? ` (npm ${target.npm})` : ''} o más nueva${none}`,
35
+ local: without.includes(running)
36
+ ? `pasar a la ${target.major}: \`nvm install ${target.major} && nvm use ${target.major}\` (la ${running} no trae npm ${major})`
37
+ : '`npm install -g npm@11`',
38
+ };
27
39
  }
28
40
 
29
41
  export async function run(ctx) {
@@ -104,7 +116,9 @@ export async function run(ctx) {
104
116
  } else if (compareVersions(used, minNpm) < 0) {
105
117
  findings.push({
106
118
  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\`.`,
119
+ message: (({ pipeline, local }) => `npm ${used} en uso: hace falta npm >= ${minNpm} (${NPM10_WHY}). En el pipeline y en el Dockerfile, ${pipeline}. En local, ${local}.`)(
120
+ await npmHint(ctx, minNpm),
121
+ ),
108
122
  data: { npm: used },
109
123
  });
110
124
  }
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
@@ -11,6 +11,7 @@ import { loadAllowPolicy, verdict } from './lib/allow-scripts.js';
11
11
  import { lockfileName } from './lib/audit.js';
12
12
  import { unifiedDiff } from './lib/diff.js';
13
13
  import { linesWithNpm, loadCalendar } from './lib/node-calendar.js';
14
+ import { imageExists, newestWithImage, upgradeTarget } from './lib/node-target.js';
14
15
  import { parseNpmrc, requiredSettings, settingProblem } from './lib/npmrc.js';
15
16
  import { compareVersions, minVersion, parseVersion } from './lib/semver.js';
16
17
  import { formatBlocks } from './report.js';
@@ -80,21 +81,35 @@ export function mergePackageJson(text, policy, nodeMajor) {
80
81
  // El Node que init propone (engines.node, si falta, y la imagen del pipeline): el que ya usa el repo
81
82
  // si es de una LTS con soporte, para que adoptar el kit no obligue a cambiar de Node. Tiene que traer
82
83
  // el npm que pide la política: si la línea del repo no lo trae en ninguna release (la 22 trae npm 10),
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;
84
+ // la siguiente que sí, y `from` dice de cuál viene. engines.node toma la línea más baja en uso (con
85
+ // engine-strict, un ">=" más alto rompe el npm ci de esa línea). La imagen es la exacta que ya usa el
86
+ // pipeline si es de esa línea y no hay un parche de seguridad posterior con imagen publicada; si no,
87
+ // la release más nueva de la línea con imagen en Docker Hub. Sin Node fijado (o solo líneas sin
88
+ // soporte), el destino único de lib/node-target.js.
89
+ export async function repoNode(ctx, cal) {
90
+ const target = await upgradeTarget(ctx, cal);
88
91
  let inUse = null;
92
+ let pipelinePin = null;
89
93
  for (const pin of nodeVersionCheck.findPins(ctx)) {
90
94
  const r = nodeVersionCheck.resolvePin(pin, cal);
91
95
  if (r.major === undefined || r.major === null || r.latest) continue;
92
96
  if (!['active', 'maintenance'].includes(cal.phaseOf(r.major))) continue;
93
97
  if (inUse === null || r.major < inUse) inUse = r.major;
98
+ if (!pipelinePin && pin.file === 'bitbucket-pipelines.yml' && pin.kind === 'docker' && r.exact) pipelinePin = r;
94
99
  }
95
- 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 };
100
+ if (!target) return { major: null, image: null, npm: null, from: null };
101
+ const major = inUse === null ? target.major : (linesWithNpm(cal, ctx.policy.minNpmVersion).ok.find((m) => m >= inUse) ?? target.major);
102
+ const from = inUse !== null && major !== inUse ? inUse : null;
103
+ if (pipelinePin?.major === major) {
104
+ let patched = false;
105
+ for (const s of cal.securityAfter(major, pipelinePin.exact)) {
106
+ if ((await imageExists(ctx, s.version.replace(/^v/, ''))) === true) patched = true;
107
+ }
108
+ if (!patched) return { major, image: pipelinePin.exact, npm: cal.npmOf(pipelinePin.exact), from };
109
+ }
110
+ if (major === target.major) return { major, image: target.image, npm: target.npm, from };
111
+ const found = await newestWithImage(ctx, cal, major);
112
+ return { major, image: found?.image ?? String(major), npm: found?.npm ?? cal.npmOfLine(major), from };
98
113
  }
99
114
 
100
115
  // Las ramas que despliegan: las claves de `pipelines.branches` (sin globs), en orden.
@@ -205,7 +220,7 @@ export async function runInit(ctx, { dryRun = false, stdout = process.stdout, co
205
220
  } catch (error) {
206
221
  cal = { error };
207
222
  }
208
- const node = cal.error ? { major: null, image: null } : repoNode(ctx, cal);
223
+ const node = cal.error ? { major: null, image: null, npm: null, from: null } : await repoNode(ctx, cal);
209
224
 
210
225
  say(paint(2, `npm-security-kit ${ctx.kitVersion} · init${dryRun ? ' --dry-run' : ''} · ${ctx.dir}`));
211
226
 
@@ -265,7 +280,7 @@ export async function runInit(ctx, { dryRun = false, stdout = process.stdout, co
265
280
  if (node.from !== null && node.from !== undefined) {
266
281
  say(
267
282
  ` 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.`,
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.`,
269
284
  );
270
285
  }
271
286
  const branches = deployBranches(pipeline);
@@ -279,6 +294,9 @@ export async function runInit(ctx, { dryRun = false, stdout = process.stdout, co
279
294
  say(' - npm audit signatures # después de npm ci');
280
295
  say();
281
296
  say(` Con una imagen que traiga npm >= ${ctx.policy.minNpmVersion} (image: node:${image}).`);
297
+ if (!/^\s+ci:\s*(#.*)?$/m.test(pipeline)) {
298
+ say(' Para correrlo a mano ("Run pipeline"), un `custom: ci` con el mismo step (ver el README).');
299
+ }
282
300
  if (!/condition:[\s\S]*BITBUCKET_PR_DESTINATION_BRANCH/.test(pipeline)) {
283
301
  say(` Y correrlo solo en los PR a las ramas que despliegan${branches.length ? ` (${branches.join(', ')})` : ''}, con la condition del step:`);
284
302
  say();
@@ -306,7 +324,13 @@ export async function runInit(ctx, { dryRun = false, stdout = process.stdout, co
306
324
  section('Pasos siguientes');
307
325
  const steps = [
308
326
  dryRun ? 'Correr init sin --dry-run.' : 'Revisar el diff: git diff .npmrc package.json',
309
- 'Aprobar o negar los install scripts pendientes.',
327
+ ...(node.from !== null && node.from !== undefined
328
+ ? [
329
+ `Pasar el repo de la ${node.from} a la ${node.major}: .nvmrc, imágenes del pipeline y FROM de los Dockerfiles (node:${node.image}). ` +
330
+ `En local, \`nvm install ${node.major} && nvm use ${node.major}\`; si npm ci da EUSAGE, \`npm install --package-lock-only --ignore-scripts\`.`,
331
+ ]
332
+ : []),
333
+ 'Aprobar o negar los install scripts pendientes (después de `npm ci --ignore-scripts`; si el repo usa husky u otro `prepare`, correr `npm run prepare`).',
310
334
  'Agregar el step al pipeline y corregir los Dockerfiles.',
311
335
  `Correr el chequeo completo: npx --yes --min-release-age=0 ${PACKAGE}@${ctx.kitVersion} check`,
312
336
  ];
@@ -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,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
+ }