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