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