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

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