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

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,9 +6,27 @@ 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.
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).
12
30
 
13
31
  ### BREAKING
14
32
 
@@ -39,7 +57,7 @@ nueva del kit no se puede usar hasta cumplir 7 días.
39
57
  `scripts/security-check.sh`, `.husky/` (y el `"prepare": "husky install"`), los scripts `security:*` del
40
58
  `package.json` y el `SECURITY.md` generado.
41
59
  2. Si la v1 reemplazó el `bitbucket-pipelines.yml`, recuperar el del repo desde git.
42
- 3. Correr `npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0 init` y seguir los pasos que imprime (README).
60
+ 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).
43
61
 
44
62
  ## [1.0.2] y anteriores
45
63
 
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.3`). 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,18 @@ 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 (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.
50
+
43
51
  ## Uso
44
52
 
45
53
  ### En el pipeline
46
54
 
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):
55
+ En el step de test que ya existe, **sin reemplazarlo**: el kit antes de `npm ci` y `npm audit signatures` después.
56
+ El step corre solo en los PR a las ramas que despliegan (las de `pipelines.branches`). `init` imprime qué agregar y
57
+ en qué línea. Ejemplo de `bitbucket-pipelines.yml` (el pipeline que lee el chequeo de Node):
49
58
 
50
59
  ```yaml
51
60
  definitions:
@@ -57,10 +66,10 @@ definitions:
57
66
  - npm
58
67
  script:
59
68
  # 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
69
+ - npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.3 check
61
70
  - npm ci # con strict-allow-scripts, los scripts no aprobados cortan acá
62
71
  - npm audit signatures
63
- - npm run build
72
+ # … y lo que ya corría el step: build, lint, tests
64
73
 
65
74
  pipelines:
66
75
  pull-requests:
@@ -68,23 +77,28 @@ pipelines:
68
77
  - step:
69
78
  <<: *test
70
79
  condition:
71
- state: BITBUCKET_PR_DESTINATION_BRANCH == "develop" || BITBUCKET_PR_DESTINATION_BRANCH == "main"
80
+ # Las ramas de pipelines.branches (las que despliegan)
81
+ state: BITBUCKET_PR_DESTINATION_BRANCH == "main" || BITBUCKET_PR_DESTINATION_BRANCH == "develop"
82
+ custom:
83
+ ci: # para correrlo a mano ("Run pipeline")
84
+ - step: *test
72
85
  ```
73
86
 
74
87
  - **Antes de `npm ci`:** `npm audit` funciona solo con el lockfile, así que el malware se detecta antes de que
75
88
  corra su `postinstall`.
76
- - **Versión fija** (`@2.0.0`): subir la política en un repo es un cambio visible en su PR.
89
+ - **Versión fija** (`@2.0.0-beta.3`): subir la política en un repo es un cambio visible en su PR.
77
90
  - **`--min-release-age=0` solo para `npx`:** `npx` no aplica `min-release-age-exclude` (npm 11.19.1 lo aplica al
78
91
  instalar, no al resolver el paquete de `npx`). Sin el flag, dentro de un repo con `min-release-age=7` una versión
79
92
  nueva del kit no se puede usar hasta cumplir 7 días (`ETARGET … with a date before`). Afecta solo al kit: la
80
93
  versión va fija y no tiene dependencias. El `npm ci` que sigue aplica los 7 días como siempre.
81
94
  - **`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`.
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`.
83
96
 
84
97
  ### Dockerfile
85
98
 
86
99
  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`:
100
+ `strict-allow-scripts`, `min-release-age` ni `engine-strict`. El `FROM` tiene que traer npm 11 (Node 24 o más nueva;
101
+ ver **Node y npm** más arriba):
88
102
 
89
103
  ```dockerfile
90
104
  COPY package.json package-lock.json .npmrc ./
@@ -98,20 +112,22 @@ Si el `.dockerignore` excluye el `.npmrc` (por ejemplo con `.*`), agregar `!.npm
98
112
  Una vez por repo, en una rama:
99
113
 
100
114
  ```bash
101
- npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0 init --dry-run
115
+ npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.3 init --dry-run
102
116
  ```
103
117
 
104
118
  ```bash
105
- npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0 init
119
+ npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.3 init
106
120
  ```
107
121
 
108
122
  `init`:
109
123
 
110
124
  - agrega al `.npmrc` las claves de la política que faltan (con un comentario) y corrige las que tienen otro valor,
111
125
  sin borrar ni reordenar nada;
112
- - completa `engines.npm` (`>=11.17`) y, solo si falta, `engines.node`;
126
+ - completa `engines.npm` (`>=11.17`) y, solo si falta, `engines.node` (con la línea de Node del repo, si trae npm 11;
127
+ si no, la siguiente que lo trae);
113
128
  - **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;
129
+ - **no edita** `bitbucket-pipelines.yml` ni los Dockerfiles: dice qué líneas agregar al step que corre `npm ci`, la
130
+ condition con las ramas que despliegan y los `COPY` corregidos;
115
131
  - se puede correr de nuevo sin cambiar nada.
116
132
 
117
133
  Después: revisar el diff, aprobar o negar los install scripts, aplicar el step y el Dockerfile, y correr `check`.
@@ -119,7 +135,7 @@ Después: revisar el diff, aprobar o negar los install scripts, aplicar el step
119
135
  ### En local
120
136
 
121
137
  ```bash
122
- npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0 check
138
+ npx --yes --min-release-age=0 @theoptimalpartner/npm-security-kit@2.0.0-beta.3 check
123
139
  ```
124
140
 
125
141
  No se instala como devDependency ni usa husky: el `.npmrc` protege el `npm install` local y el pipeline es el
@@ -144,10 +160,12 @@ Dockerfile y pipeline) va una sola vez, con la lista de archivos.
144
160
 
145
161
  ### `npm ci` falla con EUSAGE (lockfile incompleto)
146
162
 
147
- Pasa con lockfiles generados con npm 10, a los que les faltan opcionales de plataforma. Regenerarlo con npm 11:
163
+ Pasa con lockfiles generados con npm 10, a los que les faltan opcionales de plataforma. Regenerarlo con npm 11, sin
164
+ scripts (con `strict-allow-scripts=true`, sin `--ignore-scripts` npm corta con `ESTRICTALLOWSCRIPTS` antes de
165
+ regenerarlo):
148
166
 
149
167
  ```bash
150
- npm install --package-lock-only
168
+ npm install --package-lock-only --ignore-scripts
151
169
  ```
152
170
 
153
171
  ### Parche urgente con menos de 7 días
@@ -170,6 +188,14 @@ npm install-scripts approve esbuild # baja el binario de su plataforma: lo nec
170
188
  npm install-scripts deny core-js # solo imprime un mensaje: no hace falta
171
189
  ```
172
190
 
191
+ `npm install-scripts ls` lee `node_modules`: si el `npm ci --ignore-scripts` falló (por ejemplo con EUSAGE), dice que
192
+ no hay nada pendiente. La lista de `check` e `init` sale del lockfile y no depende de eso.
193
+
194
+ `approve` guarda la versión exacta (`"paquete@1.2.3": true`): cuando se actualiza el paquete, `check` vuelve a dar
195
+ error hasta aprobar la versión nueva. Es a propósito (cada versión nueva se revisa). Para un paquete de confianza que
196
+ se actualiza seguido, `npm install-scripts approve --no-allow-scripts-pin <paquete>` lo aprueba sin versión. `deny`
197
+ siempre guarda el nombre sin versión.
198
+
173
199
  Se aprueba lo que necesita compilar o bajar binarios para funcionar, y se niega lo que es solo telemetría o
174
200
  publicidad. El kit no aprueba nada en automático. Queda en `allowScripts` del `package.json`. Reglas (las mismas de
175
201
  npm):
@@ -190,6 +216,12 @@ Si un repo no puede adoptar todavía la aprobación de install scripts, se defin
190
216
  Con el override, npm ejecuta los scripts sin aprobar y el kit los informa como **aviso**, más un aviso de que el
191
217
  override está activo. Dentro del `docker build` la variable no llega sola: hay que pasarla como `ARG`.
192
218
 
219
+ ### Vulnerabilidades `critical` en devDependencies
220
+
221
+ No bloquean (no llegan a producción), pero corren en el pipeline y en las máquinas de los devs: `check` las informa
222
+ como aviso. Casi siempre las arregla `npm audit fix` (sin `--force`, que puede subir de major), o subir la
223
+ dependencia que las trae.
224
+
193
225
  ### El registro de npm no responde
194
226
 
195
227
  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.1",
3
+ "version": "2.0.0-beta.3",
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,11 @@
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
- import { loadCalendar } from '../lib/node-calendar.js';
7
- import { isRange, minVersion, parseVersion } from '../lib/semver.js';
7
+ import { linesWithNpm, loadCalendar } from '../lib/node-calendar.js';
8
+ import { compareVersions, isRange, minVersion, parseVersion } from '../lib/semver.js';
8
9
 
9
10
  export const id = 'node-version';
10
11
  export const title = 'Versión de Node';
@@ -167,6 +168,20 @@ function evaluate(pin, r, cal, policy) {
167
168
  const why = sch.lts ? `todavía no es LTS (lo será el ${sch.lts})` : 'línea impar, nunca va a ser LTS';
168
169
  return { ...base, level: 'error', message: `${prefix}: no es LTS, ${why}. Usar ${rec}${engineHint}.`, data };
169
170
  }
171
+ // La política pide npm >= minNpmVersion y la imagen de Node trae el suyo: una línea que no lo trae en
172
+ // ninguna release (la 22 trae npm 10) deja el pipeline y el docker build con npm 10.
173
+ const lineNpm = cal.npmOfLine(r.major);
174
+ 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}")` : '';
178
+ return {
179
+ ...base,
180
+ level: 'error',
181
+ message: `${prefix}: trae npm ${lineNpm}, y ninguna release de la ${r.major} trae npm >= ${policy.minNpmVersion}, que pide la política. Subir a una LTS con npm 11: ${to} o más nueva${hint}.`,
182
+ data: { ...data, npm: lineNpm },
183
+ };
184
+ }
170
185
 
171
186
  const notes = [];
172
187
  // Mientras la LTS nueva no tiene su primera release LTS nadie puede migrar: se informa sin avisar.
@@ -3,6 +3,7 @@
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';
6
7
  import { compareVersions, minVersion, parseVersion } from '../lib/semver.js';
7
8
 
8
9
  export const id = 'npm-policy';
@@ -10,6 +11,21 @@ export const title = 'npm y .npmrc';
10
11
 
11
12
  const NPM10_WHY = 'npm 10 ignora min-release-age sin avisar y no aplica allowScripts';
12
13
 
14
+ // Qué imagen usar para tener ese npm. Sin calendario, el consejo genérico.
15
+ async function npmImageHint(ctx, minNpm) {
16
+ let cal;
17
+ try {
18
+ cal = await loadCalendar(ctx);
19
+ } catch {
20
+ return 'usar la imagen de Node LTS recomendada';
21
+ }
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}`;
27
+ }
28
+
13
29
  export async function run(ctx) {
14
30
  const { policy } = ctx;
15
31
  const findings = [];
@@ -88,7 +104,7 @@ export async function run(ctx) {
88
104
  } else if (compareVersions(used, minNpm) < 0) {
89
105
  findings.push({
90
106
  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\`.`,
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\`.`,
92
108
  data: { npm: used },
93
109
  });
94
110
  }
@@ -99,7 +115,7 @@ export async function run(ctx) {
99
115
  const other = ['yarn.lock', 'pnpm-lock.yaml'].find((f) => ctx.exists(f));
100
116
  findings.push({
101
117
  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`.'}`,
118
+ 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
119
  });
104
120
  } else {
105
121
  const lock = ctx.readJson(lockfile);
@@ -107,13 +123,13 @@ export async function run(ctx) {
107
123
  findings.push({
108
124
  level: 'error',
109
125
  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\`).`,
126
+ 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
127
  });
112
128
  } else if (!(lock.lockfileVersion >= 3)) {
113
129
  findings.push({
114
130
  level: 'warn',
115
131
  file: lockfile,
116
- message: `lockfileVersion ${lock.lockfileVersion ?? '?'}: regenerar con npm 11 (\`npm install --package-lock-only\`).`,
132
+ message: `lockfileVersion ${lock.lockfileVersion ?? '?'}: regenerar con npm 11 (\`npm install --package-lock-only --ignore-scripts\`).`,
117
133
  });
118
134
  }
119
135
  }
@@ -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/init.js CHANGED
@@ -10,7 +10,7 @@ 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
14
  import { parseNpmrc, requiredSettings, settingProblem } from './lib/npmrc.js';
15
15
  import { compareVersions, minVersion, parseVersion } from './lib/semver.js';
16
16
  import { formatBlocks } from './report.js';
@@ -77,7 +77,65 @@ export function mergePackageJson(text, policy, nodeMajor) {
77
77
  return { text: finalNewline ? `${out}${eol}` : out, changes };
78
78
  }
79
79
 
80
- export function pipelineStep(kitVersion, nodeVersion) {
80
+ // El Node que init propone (engines.node, si falta, y la imagen del pipeline): el que ya usa el repo
81
+ // si es de una LTS con soporte, para que adoptar el kit no obligue a cambiar de Node. Tiene que traer
82
+ // el npm que pide la política: si la línea del repo no lo trae en ninguna release (la 22 trae npm 10),
83
+ // la siguiente línea que sí, y `from` dice de cuál viene. engines.node toma la línea más baja en uso
84
+ // (con engine-strict, un ">=" más alto rompe el npm ci de esa línea) y la imagen es la release LTS más
85
+ // nueva de la línea (sin parches de seguridad pendientes). Sin Node fijado, la release recomendada.
86
+ export function repoNode(ctx, cal) {
87
+ const recommended = cal.recommended ?? null;
88
+ let inUse = null;
89
+ for (const pin of nodeVersionCheck.findPins(ctx)) {
90
+ const r = nodeVersionCheck.resolvePin(pin, cal);
91
+ if (r.major === undefined || r.major === null || r.latest) continue;
92
+ if (!['active', 'maintenance'].includes(cal.phaseOf(r.major))) continue;
93
+ if (inUse === null || r.major < inUse) inUse = r.major;
94
+ }
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 };
98
+ }
99
+
100
+ // Las ramas que despliegan: las claves de `pipelines.branches` (sin globs), en orden.
101
+ export function deployBranches(text) {
102
+ const out = [];
103
+ let inPipelines = false;
104
+ let branchesIndent = null;
105
+ let keyIndent = null;
106
+ for (const raw of (text ?? '').split(/\r?\n/)) {
107
+ if (!raw.trim() || raw.trim().startsWith('#')) continue;
108
+ if (/^\S/.test(raw)) {
109
+ inPipelines = /^pipelines:\s*(#.*)?$/.test(raw);
110
+ branchesIndent = null;
111
+ continue;
112
+ }
113
+ if (!inPipelines) continue;
114
+ const indent = raw.match(/^ */)[0].length;
115
+ const branches = raw.match(/^( *)branches:\s*(#.*)?$/);
116
+ if (branches) {
117
+ branchesIndent = branches[1].length;
118
+ keyIndent = null;
119
+ continue;
120
+ }
121
+ if (branchesIndent === null) continue;
122
+ if (indent <= branchesIndent) {
123
+ branchesIndent = null;
124
+ continue;
125
+ }
126
+ keyIndent ??= indent;
127
+ const key = raw.match(/^ *(['"]?)([^'"#:\s][^'"#:]*)\1:\s*(#.*)?$/);
128
+ if (indent === keyIndent && key && !/[*?{}[\]]/.test(key[2])) out.push(key[2].trim());
129
+ }
130
+ return out;
131
+ }
132
+
133
+ export function prCondition(branches) {
134
+ const names = branches.length ? branches : ['develop', 'main'];
135
+ return names.map((b) => `BITBUCKET_PR_DESTINATION_BRANCH == "${b}"`).join(' || ');
136
+ }
137
+
138
+ export function pipelineStep(kitVersion, nodeVersion, branches = []) {
81
139
  return `- step: &test
82
140
  name: Test (supply chain + build)
83
141
  image: node:${nodeVersion}
@@ -88,14 +146,14 @@ export function pipelineStep(kitVersion, nodeVersion) {
88
146
  - npx --yes --min-release-age=0 ${PACKAGE}@${kitVersion} check
89
147
  - npm ci
90
148
  - npm audit signatures
91
- - npm run build
149
+ # … y lo que ya corre el step: build, lint, tests
92
150
  pipelines:
93
151
  pull-requests:
94
152
  '**':
95
153
  - step:
96
154
  <<: *test
97
155
  condition:
98
- state: BITBUCKET_PR_DESTINATION_BRANCH == "develop" || BITBUCKET_PR_DESTINATION_BRANCH == "main"`;
156
+ state: ${prCondition(branches)}`;
99
157
  }
100
158
 
101
159
  // ctx que ve los archivos como quedarían después de init (para el resumen de --dry-run) y
@@ -147,15 +205,14 @@ export async function runInit(ctx, { dryRun = false, stdout = process.stdout, co
147
205
  } catch (error) {
148
206
  cal = { error };
149
207
  }
150
- const nodeVersion = cal.recommended;
151
- const nodeMajor = nodeVersion ? parseVersion(nodeVersion)[0] : null;
208
+ const node = cal.error ? { major: null, image: null } : repoNode(ctx, cal);
152
209
 
153
210
  say(paint(2, `npm-security-kit ${ctx.kitVersion} · init${dryRun ? ' --dry-run' : ''} · ${ctx.dir}`));
154
211
 
155
212
  // 1. .npmrc y package.json
156
213
  const npmrcBefore = ctx.readFile('.npmrc');
157
214
  const npmrc = mergeNpmrc(npmrcBefore, ctx.policy);
158
- const pkg = mergePackageJson(pkgText, ctx.policy, nodeMajor);
215
+ const pkg = mergePackageJson(pkgText, ctx.policy, node.major);
159
216
  if (npmrc.changes.length) files['.npmrc'] = npmrc.text;
160
217
  if (pkg.changes.length) files['package.json'] = pkg.text;
161
218
 
@@ -176,7 +233,7 @@ export async function runInit(ctx, { dryRun = false, stdout = process.stdout, co
176
233
  section('Install scripts por aprobar (allowScripts en package.json)');
177
234
  const lockfile = lockfileName(ctx);
178
235
  if (!lockfile) {
179
- say(' No hay package-lock.json: generarlo con `npm install --package-lock-only` y volver a correr init.');
236
+ say(' No hay package-lock.json: generarlo con `npm install --package-lock-only --ignore-scripts` y volver a correr init.');
180
237
  } else {
181
238
  const policy = loadAllowPolicy(JSON.parse(view.readFile('package.json')), view.readFile('.npmrc'));
182
239
  const pending = [
@@ -190,11 +247,13 @@ export async function runInit(ctx, { dryRun = false, stdout = process.stdout, co
190
247
  say(' Ninguno: todos los paquetes con install scripts están aprobados o negados.');
191
248
  } else {
192
249
  for (const p of pending) say(` - ${p}`);
193
- say(' init no aprueba nada solo. Para revisarlos:');
194
- say(' npm ci --ignore-scripts');
250
+ say(' init no aprueba nada solo. Para revisarlos (`npm install-scripts` lee node_modules: primero hace falta un npm ci):');
251
+ say(' npm ci --ignore-scripts # si da EUSAGE: npm install --package-lock-only --ignore-scripts');
195
252
  say(' npm install-scripts ls');
196
- say(' npm install-scripts approve <paquete> # lo que necesita compilar o descargar binarios');
253
+ say(' npm install-scripts approve <paquete> # lo que necesita compilar o descargar binarios; fija la versión');
197
254
  say(' npm install-scripts deny <paquete> # telemetría, publicidad o lo que no haga falta');
255
+ say(' `approve` guarda `paquete@versión`: al actualizar el paquete hay que volver a aprobarlo (o aprobar sin');
256
+ say(' versión con `npm install-scripts approve --no-allow-scripts-pin <paquete>`).');
198
257
  }
199
258
  }
200
259
 
@@ -202,12 +261,34 @@ export async function runInit(ctx, { dryRun = false, stdout = process.stdout, co
202
261
  section('Pipeline: bitbucket-pipelines.yml (init no lo edita)');
203
262
  const pipeline = ctx.readFile('bitbucket-pipelines.yml');
204
263
  const used = pipeline?.match(/npm-security-kit@([\w.-]+)/);
264
+ const image = node.image ?? '<versión LTS>';
265
+ if (node.from !== null && node.from !== undefined) {
266
+ say(
267
+ ` El repo usa Node ${node.from}, pero ninguna release de la ${node.from} trae npm >= ${ctx.policy.minNpmVersion}, que pide la política: ` +
268
+ `se propone la ${node.major} (node:${image}, npm ${cal.npmOfLine(node.major)}). Pasar a la ${node.major} el .nvmrc, las imágenes del pipeline y los FROM de los Dockerfiles.`,
269
+ );
270
+ }
271
+ const branches = deployBranches(pipeline);
272
+ const ciLines = (pipeline ?? '').split(/\r?\n/).flatMap((l, i) => (/^\s*-\s*npm ci(\s|$)/.test(l) ? [i + 1] : []));
205
273
  if (used) {
206
274
  say(` Ya corre el kit (${used[0]}).${used[1] !== ctx.kitVersion ? ` Para esta versión: ${PACKAGE}@${ctx.kitVersion}.` : ''}`);
275
+ } else if (ciLines.length) {
276
+ say(` Agregar al step que ya corre npm ci (bitbucket-pipelines.yml:${ciLines.join(', ')}), sin reemplazar el resto del step:`);
277
+ say();
278
+ say(` - npx --yes --min-release-age=0 ${PACKAGE}@${ctx.kitVersion} check # antes de npm ci`);
279
+ say(' - npm audit signatures # después de npm ci');
280
+ say();
281
+ say(` Con una imagen que traiga npm >= ${ctx.policy.minNpmVersion} (image: node:${image}).`);
282
+ if (!/condition:[\s\S]*BITBUCKET_PR_DESTINATION_BRANCH/.test(pipeline)) {
283
+ say(` Y correrlo solo en los PR a las ramas que despliegan${branches.length ? ` (${branches.join(', ')})` : ''}, con la condition del step:`);
284
+ say();
285
+ say(' condition:');
286
+ say(` state: ${prCondition(branches)}`);
287
+ }
207
288
  } else {
208
289
  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:`);
209
290
  say();
210
- for (const line of pipelineStep(ctx.kitVersion, nodeVersion ?? '<versión LTS>').split('\n')) say(` ${line}`);
291
+ for (const line of pipelineStep(ctx.kitVersion, image, branches).split('\n')) say(` ${line}`);
211
292
  }
212
293
 
213
294
  // 4. Dockerfiles (no se editan)
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,9 @@ 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,
55
66
  // true si la LTS más nueva todavía no tiene release LTS en index.json.
56
67
  newestLtsWithoutRelease: newestLts !== null && !newestLtsRelease,
57
68
  latestLtsMajor: latestLtsRelease ? majorOf(latestLtsRelease.version) : null,
@@ -71,3 +82,17 @@ export function analyzeCalendar(index, schedule, today) {
71
82
  [...lines.entries()].filter(([m, s]) => m > major && s.lts).map(([m]) => m).sort((a, b) => a - b)[0] ?? null,
72
83
  };
73
84
  }
85
+
86
+ // Las líneas de Node con soporte cuya release LTS más nueva trae un npm que cumple la política, de la
87
+ // más vieja a la más nueva, y las que no lo traen en ninguna release (index.json dice qué npm trae
88
+ // cada release; la 22 trae npm 10).
89
+ export function linesWithNpm(cal, minNpm) {
90
+ const supported = [...cal.lines.keys()]
91
+ .filter((m) => ['active', 'maintenance'].includes(cal.phaseOf(m)) && cal.newestLtsOfLine(m))
92
+ .sort((a, b) => a - b);
93
+ const meets = (m) => {
94
+ const npm = cal.npmOfLine(m);
95
+ return Boolean(npm && parseVersion(npm)) && compareVersions(npm, minNpm) >= 0;
96
+ };
97
+ return { ok: supported.filter(meets), without: supported.filter((m) => !meets(m)) };
98
+ }