@theoptimalpartner/npm-security-kit 1.0.1 → 2.0.0-beta.0

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/package.json CHANGED
@@ -1,51 +1,35 @@
1
1
  {
2
2
  "name": "@theoptimalpartner/npm-security-kit",
3
- "version": "1.0.1",
4
- "description": "Portable security kit to protect Node.js/npm projects against malicious packages and vulnerabilities",
5
- "main": "src/index.js",
3
+ "version": "2.0.0-beta.0",
4
+ "description": "Revisa el lockfile antes de npm ci: malware, vulnerabilidades, versión de Node, .npmrc, install scripts y Dockerfiles",
5
+ "type": "module",
6
6
  "bin": {
7
- "npm-security-install": "bin/install.js",
8
- "npm-security-check": "bin/check.js"
7
+ "npm-security-kit": "bin/npm-security-kit.js"
9
8
  },
9
+ "files": [
10
+ "bin/",
11
+ "src/",
12
+ "README.md",
13
+ "CHANGELOG.md",
14
+ "LICENSE"
15
+ ],
10
16
  "scripts": {
11
- "test": "echo \"No tests specified - package verified with npm pack\"",
12
- "prepare": "chmod +x bin/*.js"
17
+ "test": "node --test"
13
18
  },
14
19
  "keywords": [
15
20
  "security",
16
21
  "npm",
17
- "malicious-packages",
18
22
  "supply-chain",
19
- "vulnerability",
23
+ "malware",
20
24
  "audit",
21
- "phishing",
22
- "typosquatting",
23
- "socket.dev",
24
- "dependabot",
25
- "github-actions"
25
+ "allowScripts",
26
+ "min-release-age"
26
27
  ],
27
- "author": "The Optimal",
28
+ "author": "The Optimal Partner",
28
29
  "license": "MIT",
29
- "repository": {
30
- "type": "git",
31
- "url": "git+https://bitbucket.org/RoutingUC/npm-security-kit.git"
32
- },
33
30
  "engines": {
34
- "node": ">=14.0.0",
35
- "npm": ">=6.0.0"
31
+ "node": ">=18.3"
36
32
  },
37
- "files": [
38
- "bin/",
39
- "src/",
40
- "template/",
41
- "README.md",
42
- "LICENSE",
43
- "QUICK_START.md",
44
- "EXAMPLES.md"
45
- ],
46
- "dependencies": {},
47
- "devDependencies": {},
48
- "peerDependencies": {},
49
33
  "publishConfig": {
50
34
  "access": "public"
51
35
  }
@@ -0,0 +1,217 @@
1
+ // La política npm también tiene que aplicar dentro del `docker build`: el .npmrc se copia antes de
2
+ // `npm ci`, y se usa `npm ci` (reproducible) en vez de `npm install`.
3
+ import { findDockerfiles, parseStages } from '../lib/dockerfile.js';
4
+
5
+ export const id = 'dockerfile';
6
+ export const title = 'Dockerfiles';
7
+
8
+ // Subcomandos de npm que instalan desde el lockfile (o lo actualizan). `npm run`, `npm prune`,
9
+ // `npm rebuild` no cuentan.
10
+ const CI_SUBS = new Set(['ci', 'clean-install', 'ic', 'install-clean', 'isntall-clean', 'cit', 'install-ci-test', 'clean-install-test']);
11
+ const INSTALL_SUBS = new Set([...CI_SUBS, 'install', 'i', 'in', 'ins', 'inst', 'insta', 'instal', 'isnt', 'isnta', 'isntal', 'isntall', 'add', 'it', 'install-test']);
12
+ // Opciones de npm que llevan valor aparte (`npm --prefix app ci`).
13
+ const VALUE_OPTS = new Set(['--prefix', '-C', '--workspace', '-w', '--cache', '--registry', '--userconfig', '--globalconfig', '--loglevel', '--location', '--tag']);
14
+
15
+ // Los `npm <subcomando que instala>` de un RUN, en forma shell o exec (`["npm","ci"]`), con
16
+ // `{ sub, global }`. Un `npm install -g …` instala una herramienta, no las dependencias del proyecto.
17
+ export function npmInstalls(args) {
18
+ let command = args.replace(/^(--[\w-]+(=\S+)?\s+)+/, ''); // --mount, --network, …
19
+ if (command.trim().startsWith('[')) {
20
+ try {
21
+ command = JSON.parse(command).join(' ');
22
+ } catch {
23
+ // queda como texto
24
+ }
25
+ }
26
+ const tokens = command.replace(/["'`]/g, ' ').replace(/&&|\|\||[;|()]/g, ' ; ').split(/\s+/).filter(Boolean);
27
+ const found = [];
28
+ for (let i = 0; i < tokens.length; i++) {
29
+ if (tokens[i] !== 'npm' && !tokens[i].endsWith('/npm')) continue;
30
+ let j = i + 1;
31
+ let global = false;
32
+ while (j < tokens.length && tokens[j].startsWith('-')) {
33
+ if (/^(-g|--global|--location=global)$/.test(tokens[j])) global = true;
34
+ if (VALUE_OPTS.has(tokens[j])) j++;
35
+ j++;
36
+ }
37
+ const sub = tokens[j];
38
+ if (!INSTALL_SUBS.has(sub)) continue;
39
+ for (let k = j + 1; k < tokens.length && tokens[k] !== ';'; k++) {
40
+ if (/^(-g|--global|--location=global)$/.test(tokens[k])) global = true;
41
+ }
42
+ found.push({ sub, global, ci: CI_SUBS.has(sub) });
43
+ }
44
+ return found;
45
+ }
46
+
47
+ // `RUN --mount=type=bind,source=.npmrc,…` o un secret montado en la carpeta del proyecto.
48
+ function mountsNpmrc(args) {
49
+ for (const m of args.matchAll(/--mount=(\S+)/g)) {
50
+ const opts = Object.fromEntries(m[1].split(',').map((kv) => kv.split('=')));
51
+ const type = opts.type ?? 'bind';
52
+ if (type === 'bind' && /(^|\/)\.npmrc$/.test(opts.source ?? opts.src ?? '')) return true;
53
+ const target = opts.target ?? opts.dst ?? opts.destination ?? '';
54
+ if (type === 'secret' && /(^|\/)\.npmrc$/.test(target) && !/^(\/root\/|~|\$HOME)/.test(target)) return true;
55
+ }
56
+ return false;
57
+ }
58
+
59
+ const FLAG = /^--[\w-]+(=\S*)?$/;
60
+
61
+ const dirOf = (file) => (file.includes('/') ? file.slice(0, file.lastIndexOf('/')) : '');
62
+ const join = (dir, name) => (dir ? `${dir}/${name}` : name);
63
+
64
+ // Glob de Docker (filepath.Match más `**`) a regex sobre rutas relativas al contexto.
65
+ function globToRegex(pattern) {
66
+ let re = '';
67
+ for (let i = 0; i < pattern.length; i++) {
68
+ const c = pattern[i];
69
+ if (c === '*' && pattern[i + 1] === '*') {
70
+ i++;
71
+ if (pattern[i + 1] === '/') {
72
+ i++;
73
+ re += '(?:.*/)?';
74
+ } else {
75
+ re += '.*';
76
+ }
77
+ } else if (c === '*') re += '[^/]*';
78
+ else if (c === '?') re += '[^/]';
79
+ else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
80
+ }
81
+ return new RegExp(`^${re}(?:/.*)?$`);
82
+ }
83
+
84
+ // ¿El .dockerignore excluye `path`? Devuelve la regla que lo excluye (la última que coincide gana).
85
+ export function dockerignoreExcludes(text, path) {
86
+ let hit = null;
87
+ (text ?? '').split(/\r?\n/).forEach((raw, i) => {
88
+ let pattern = raw.trim();
89
+ if (!pattern || pattern.startsWith('#')) return;
90
+ const negate = pattern.startsWith('!');
91
+ if (negate) pattern = pattern.slice(1).trim();
92
+ pattern = pattern.replace(/^\/+/, '').replace(/^\.\//, '');
93
+ if (!pattern || !globToRegex(pattern).test(path)) return;
94
+ hit = negate ? null : { pattern: raw.trim(), line: i + 1 };
95
+ });
96
+ return hit;
97
+ }
98
+
99
+ // Fuentes y destino de un COPY/ADD, sin flags. `null` si copia de otra etapa (`--from`).
100
+ export function copyParts(args) {
101
+ let tokens;
102
+ const json = args.match(/^((?:--\S+\s+)*)(\[.*\])\s*$/);
103
+ if (json) {
104
+ try {
105
+ tokens = [...json[1].split(/\s+/).filter(Boolean), ...JSON.parse(json[2])];
106
+ } catch {
107
+ tokens = args.split(/\s+/);
108
+ }
109
+ } else {
110
+ tokens = args.split(/\s+/).filter(Boolean);
111
+ }
112
+ const from = tokens.find((t) => /^--from=/.test(t))?.slice('--from='.length).toLowerCase();
113
+ const paths = tokens.filter((t) => !FLAG.test(t));
114
+ if (paths.length < 2) return null;
115
+ return { from, flags: tokens.filter((t) => FLAG.test(t)), sources: paths.slice(0, -1), dest: paths.at(-1) };
116
+ }
117
+
118
+ const basename = (p) => p.replace(/\/+$/, '').split('/').pop();
119
+ const WHOLE_CONTEXT = new Set(['.', './', '*']);
120
+ const bringsNpmrc = (src) => WHOLE_CONTEXT.has(src) || globToRegex(basename(src)).test('.npmrc');
121
+ const isManifest = (src) => /^(\.\/)?(package(-lock)?\.json|package\*\.json|npm-shrinkwrap\.json)$/.test(src);
122
+
123
+ // El COPY que conviene corregir: el último anterior al RUN que trae el lockfile o package*.json.
124
+ function suggestion(copies, runLine) {
125
+ const target = [...copies].reverse().find((c) => !c.parts.from && c.parts.sources.some(isManifest));
126
+ if (!target) return `agregar \`COPY .npmrc ./\` antes de la línea ${runLine}`;
127
+ const { flags, sources, dest } = target.parts;
128
+ const fixed = ['COPY', ...flags, ...sources, '.npmrc', dest].join(' ');
129
+ return `agregar .npmrc al COPY de la línea ${target.line} (\`${fixed}\`)`;
130
+ }
131
+
132
+ export async function run(ctx) {
133
+ const findings = [];
134
+ const reportedIgnores = new Set();
135
+ for (const file of findDockerfiles(ctx)) {
136
+ // Contexto de build: la carpeta del Dockerfile si tiene su propio .dockerignore o package.json;
137
+ // si no, la raíz del repo (como `docker build -f pipeline/Dockerfile .`).
138
+ const own = dirOf(file);
139
+ const context = own && (ctx.exists(join(own, '.dockerignore')) || ctx.exists(join(own, 'package.json'))) ? own : '';
140
+ const hasNpmrc = ctx.exists(join(context, '.npmrc'));
141
+ const ignoreFile = ctx.exists(`${file}.dockerignore`) ? `${file}.dockerignore` : join(context, '.dockerignore');
142
+ const ignoreText = ctx.readFile(ignoreFile);
143
+ const npmrcIgnored = hasNpmrc ? dockerignoreExcludes(ignoreText, '.npmrc') : null;
144
+ const lockIgnored = dockerignoreExcludes(ignoreText, 'package-lock.json');
145
+
146
+ let installs = false;
147
+ // Qué etapas ya tienen el .npmrc (por nombre e índice), para `FROM <etapa>` y `COPY --from`.
148
+ const withNpmrc = new Map();
149
+ const stages = parseStages(ctx.readFile(file) ?? '');
150
+ stages.forEach((stage, index) => {
151
+ const parent = stage.from.image.toLowerCase();
152
+ let brought = withNpmrc.get(parent) ?? false;
153
+ const copies = [];
154
+ let reported = false;
155
+ for (const ins of stage.instructions) {
156
+ if (ins.instruction === 'COPY' || ins.instruction === 'ADD') {
157
+ const parts = copyParts(ins.args);
158
+ if (!parts) continue;
159
+ copies.push({ line: ins.line, parts });
160
+ if (parts.from === undefined) {
161
+ if (parts.sources.some(bringsNpmrc)) brought = true;
162
+ } else if (parts.sources.some((src) => globToRegex(basename(src)).test('.npmrc'))) {
163
+ brought = true;
164
+ } else if (withNpmrc.get(parts.from) && parts.sources.some((src) => !/\.[\w-]+$/.test(basename(src)) || WHOLE_CONTEXT.has(src))) {
165
+ // Copia una carpeta entera de una etapa que tenía el .npmrc.
166
+ brought = true;
167
+ }
168
+ continue;
169
+ }
170
+ if (ins.instruction !== 'RUN') continue;
171
+ const local = npmInstalls(ins.args).filter((n) => !n.global);
172
+ if (!local.length) continue;
173
+ installs = true;
174
+ if (hasNpmrc && !brought && !mountsNpmrc(ins.args) && !reported) {
175
+ reported = true;
176
+ findings.push({
177
+ level: 'error',
178
+ file,
179
+ line: ins.line,
180
+ message: `Instala sin el .npmrc (strict-allow-scripts, min-release-age y engine-strict no aplican dentro de la imagen): ${suggestion(copies, ins.line)}.`,
181
+ });
182
+ }
183
+ for (const n of local.filter((x) => !x.ci)) {
184
+ findings.push({
185
+ level: 'warn',
186
+ file,
187
+ line: ins.line,
188
+ message: `Usa \`npm ${n.sub}\` en vez de \`npm ci\`: no es reproducible y puede cambiar versiones respecto del lockfile. Usar \`npm ci\`.`,
189
+ });
190
+ }
191
+ }
192
+ withNpmrc.set(String(index), brought);
193
+ if (stage.name) withNpmrc.set(stage.name, brought);
194
+ });
195
+
196
+ if (!installs || reportedIgnores.has(ignoreFile)) continue;
197
+ if (npmrcIgnored) {
198
+ reportedIgnores.add(ignoreFile);
199
+ findings.push({
200
+ level: 'error',
201
+ file: ignoreFile,
202
+ line: npmrcIgnored.line,
203
+ message: `\`${npmrcIgnored.pattern}\` excluye el .npmrc del contexto de build: el npm de la imagen no recibe la política. Agregar \`!.npmrc\` al final del .dockerignore.`,
204
+ });
205
+ }
206
+ if (lockIgnored) {
207
+ reportedIgnores.add(ignoreFile);
208
+ findings.push({
209
+ level: 'warn',
210
+ file: ignoreFile,
211
+ line: lockIgnored.line,
212
+ message: `\`${lockIgnored.pattern}\` excluye el package-lock.json del contexto de build: la imagen instala versiones que no son las del lockfile revisado. Quitar esa línea y usar \`npm ci\`.`,
213
+ });
214
+ }
215
+ }
216
+ return findings;
217
+ }
@@ -0,0 +1,10 @@
1
+ // Lista ordenada de chequeos de `check`. Cada uno exporta `{ id, title, run(ctx) }` y `run`
2
+ // devuelve hallazgos `{ level: 'error'|'warn'|'info', message, file?, line?, data? }`.
3
+ import * as dockerfile from './dockerfile.js';
4
+ import * as installScripts from './install-scripts.js';
5
+ import * as malware from './malware.js';
6
+ import * as nodeVersion from './node-version.js';
7
+ import * as npmPolicy from './npm-policy.js';
8
+ import * as vulnerabilities from './vulnerabilities.js';
9
+
10
+ export const checks = [malware, vulnerabilities, nodeVersion, npmPolicy, installScripts, dockerfile];
@@ -0,0 +1,77 @@
1
+ // Install scripts sin aprobar, antes de `npm ci`: los paquetes del lockfile con
2
+ // `hasInstallScript` contra allowScripts. Con strict-allow-scripts es error (npm ci va a fallar);
3
+ // con el override `npm_config_strict_allow_scripts=false` es aviso y se deja constancia.
4
+ import { loadAllowPolicy, verdict } from '../lib/allow-scripts.js';
5
+ import { lockfileName } from '../lib/audit.js';
6
+ import { jsonKeyLine } from '../lib/json-lines.js';
7
+ import { installsOn, lockEntries } from '../lib/lockfile.js';
8
+ import { parseNpmrc } from '../lib/npmrc.js';
9
+
10
+ export const id = 'install-scripts';
11
+ export const title = 'Install scripts (allowScripts)';
12
+
13
+ export const OVERRIDE_ENV = 'npm_config_strict_allow_scripts';
14
+
15
+ // Paquetes del lockfile con install scripts que npm ejecutaría en esta plataforma.
16
+ export function packagesWithScripts(lock, platform) {
17
+ return [...lockEntries(lock)].filter(({ entry }) => entry.hasInstallScript && !entry.inBundle && installsOn(entry, platform));
18
+ }
19
+
20
+ export async function run(ctx) {
21
+ const lockfile = lockfileName(ctx);
22
+ if (!lockfile) return []; // lo informa npm-policy
23
+ const lock = ctx.readJson(lockfile);
24
+ const pkgText = ctx.readFile('package.json');
25
+ const pkg = pkgText === null ? {} : JSON.parse(pkgText);
26
+ const npmrcText = ctx.readFile('.npmrc');
27
+ const findings = [];
28
+
29
+ const policy = loadAllowPolicy(pkg, npmrcText);
30
+ for (const rule of policy.ignored) {
31
+ findings.push({
32
+ level: 'warn',
33
+ file: 'package.json',
34
+ line: jsonKeyLine(pkgText, ['allowScripts', rule.key]),
35
+ message: `allowScripts: npm ignora "${rule.key}" (${rule.ignored}): usar versiones exactas unidas por "||", o solo el nombre. Mientras tanto, ese paquete queda sin aprobar.`,
36
+ });
37
+ }
38
+
39
+ // Modo efectivo (incluye el override por variable de entorno) contra lo que dice el repo.
40
+ const effective = await ctx.runNpm(['config', 'get', 'strict-allow-scripts']);
41
+ const repoStrict = parseNpmrc(npmrcText).get('strict-allow-scripts')?.value === 'true';
42
+ const strict = effective.code === 0 ? effective.stdout.trim() === 'true' : repoStrict;
43
+ const overridden = repoStrict && !strict;
44
+
45
+ const pending = [];
46
+ const seen = new Set();
47
+ for (const dep of packagesWithScripts(lock, ctx)) {
48
+ if (verdict(policy.rules, dep) !== null) continue;
49
+ const label = `${dep.name}@${dep.version}`;
50
+ if (seen.has(label)) continue;
51
+ seen.add(label);
52
+ pending.push({ ...dep, label });
53
+ }
54
+
55
+ const effect = strict ? 'npm ci va a fallar' : 'se van a ejecutar en npm ci';
56
+ const group = `Install scripts sin aprobar en allowScripts: ${effect}. Revisarlos con \`npm ci --ignore-scripts\` y \`npm install-scripts ls\`, y después \`npm install-scripts approve <paquete>\` o \`npm install-scripts deny <paquete>\`.`;
57
+ for (const dep of pending) {
58
+ const os = dep.entry.optional && dep.entry.os ? ` (opcional, solo ${dep.entry.os.join('/')})` : '';
59
+ const how = `Revisarlo con \`npm ci --ignore-scripts\` y \`npm install-scripts ls\`, y después \`npm install-scripts approve ${dep.name}\` o \`npm install-scripts deny ${dep.name}\`.`;
60
+ findings.push({
61
+ level: strict ? 'error' : 'warn',
62
+ file: lockfile,
63
+ message: strict
64
+ ? `${dep.label}${os} tiene install scripts y no está aprobado en allowScripts: npm ci va a fallar. ${how}`
65
+ : `${dep.label}${os} tiene install scripts y no está aprobado en allowScripts: se va a ejecutar en npm ci. ${how}`,
66
+ data: { name: dep.name, version: dep.version, key: dep.key, pin: `${dep.label}${os}`, group },
67
+ });
68
+ }
69
+ if (overridden) {
70
+ findings.push({
71
+ level: 'warn',
72
+ message: `strict-allow-scripts desactivado por la variable de entorno \`${OVERRIDE_ENV}\`; los install scripts no aprobados se van a ejecutar.`,
73
+ data: { override: OVERRIDE_ENV },
74
+ });
75
+ }
76
+ return findings;
77
+ }
@@ -0,0 +1,61 @@
1
+ // Malware en todo el árbol (incluidas las devDependencies), con cualquier severidad.
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';
4
+
5
+ export const id = 'malware';
6
+ export const title = 'Malware (todo el árbol, cualquier severidad)';
7
+
8
+ const INCIDENT =
9
+ 'Si llegó a instalarse en alguna máquina o pipeline, tratarlo como incidente: rotar los tokens y secretos de ese entorno (npm, CI, nube).';
10
+
11
+ function installed(lock, nodes) {
12
+ const found = [];
13
+ for (const node of nodes) {
14
+ const entry = lock?.packages?.[node];
15
+ if (!entry?.version) continue;
16
+ found.push({ node, version: entry.version, dev: Boolean(entry.dev || entry.devOptional) });
17
+ }
18
+ return found;
19
+ }
20
+
21
+ export async function run(ctx) {
22
+ const lockfile = lockfileName(ctx);
23
+ if (!lockfile) return []; // sin lockfile no hay audit; lo informa npm-policy
24
+ let report;
25
+ try {
26
+ report = await runAudit(ctx, ['--include=dev', '--include=optional', '--include=peer']);
27
+ } catch (error) {
28
+ if (error instanceof AuditUnavailableError) return [unavailableFinding(error)];
29
+ throw error;
30
+ }
31
+ const lock = ctx.readJson(lockfile);
32
+ const seen = new Set();
33
+ const findings = [];
34
+ for (const advisory of advisoriesOf(report)) {
35
+ if (!isMalware(advisory, ctx.policy) || seen.has(advisoryKey(advisory))) continue;
36
+ seen.add(advisoryKey(advisory));
37
+ const copies = installed(lock, advisory.nodes);
38
+ const versions = [...new Set(copies.map((c) => c.version))];
39
+ const label = versions.length ? versions.map((v) => `${advisory.name}@${v}`).join(', ') : `${advisory.name} (${advisory.range})`;
40
+ const dev = copies.length && copies.every((c) => c.dev) ? ' (devDependency)' : '';
41
+ findings.push({
42
+ level: 'error',
43
+ file: lockfile,
44
+ message:
45
+ `${label}${dev}: ${advisory.title}. Es malware aunque la severidad del advisory diga "${advisory.severity}". ` +
46
+ `Quitarlo o cambiar a una versión fuera del rango ${advisory.range}. ${INCIDENT}` +
47
+ (advisory.url ? ` ${advisory.url}` : ''),
48
+ data: {
49
+ name: advisory.name,
50
+ range: advisory.range,
51
+ title: advisory.title,
52
+ url: advisory.url,
53
+ severity: advisory.severity,
54
+ cwe: advisory.cwe,
55
+ nodes: advisory.nodes,
56
+ versions,
57
+ },
58
+ });
59
+ }
60
+ return findings;
61
+ }
@@ -0,0 +1,234 @@
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.
4
+ import { findDockerfiles, nodeImageTag, parseStages } from '../lib/dockerfile.js';
5
+ import { jsonKeyLine } from '../lib/json-lines.js';
6
+ import { loadCalendar } from '../lib/node-calendar.js';
7
+ import { isRange, minVersion, parseVersion } from '../lib/semver.js';
8
+
9
+ export const id = 'node-version';
10
+ export const title = 'Versión de Node';
11
+
12
+ const PIPELINE = 'bitbucket-pipelines.yml';
13
+ // Tags de la imagen oficial que siguen a la última release de Node (la Current).
14
+ const LATEST_TAGS = /^(latest|current|alpine|slim|bookworm|bullseye|buster|trixie|stretch|jessie|wheezy|windowsservercore|nanoserver)$/;
15
+
16
+ // Dónde fija el proyecto la versión de Node: `{ file, line?, pin, spec, kind }`.
17
+ export function findPins(ctx) {
18
+ const pins = [];
19
+ for (const file of ['.nvmrc', '.node-version']) {
20
+ const text = ctx.readFile(file);
21
+ if (text === null) continue;
22
+ const lines = text.split(/\r?\n/);
23
+ const idx = lines.findIndex((l) => l.replace(/#.*/, '').trim());
24
+ if (idx >= 0) {
25
+ const spec = lines[idx].replace(/#.*/, '').trim();
26
+ pins.push({ file, line: idx + 1, pin: spec, spec, kind: 'version-file' });
27
+ }
28
+ }
29
+ const tool = ctx.readFile('.tool-versions');
30
+ tool?.split(/\r?\n/).forEach((l, i) => {
31
+ const m = l.match(/^\s*(?:nodejs|node)\s+(\S+)/);
32
+ if (m) pins.push({ file: '.tool-versions', line: i + 1, pin: l.trim(), spec: m[1], kind: 'version-file' });
33
+ });
34
+
35
+ const pkgText = ctx.readFile('package.json');
36
+ if (pkgText !== null) {
37
+ const pkg = JSON.parse(pkgText);
38
+ if (typeof pkg.engines?.node === 'string') {
39
+ const line = jsonKeyLine(pkgText, ['engines', 'node']);
40
+ pins.push({ file: 'package.json', line, pin: `engines.node: ${pkg.engines.node}`, spec: pkg.engines.node, kind: 'engines' });
41
+ }
42
+ if (typeof pkg.volta?.node === 'string') {
43
+ const line = jsonKeyLine(pkgText, ['volta', 'node']);
44
+ pins.push({ file: 'package.json', line, pin: `volta.node: ${pkg.volta.node}`, spec: pkg.volta.node, kind: 'version-file' });
45
+ }
46
+ }
47
+
48
+ for (const file of findDockerfiles(ctx)) {
49
+ for (const stage of parseStages(ctx.readFile(file) ?? '')) {
50
+ const tag = nodeImageTag(stage.from.image);
51
+ if (tag !== null) pins.push({ file, line: stage.from.line, pin: stage.from.raw, spec: tag, kind: 'docker' });
52
+ }
53
+ }
54
+
55
+ const pipeline = ctx.readFile(PIPELINE);
56
+ if (pipeline !== null) {
57
+ for (const { image, line, raw } of pipelineImages(pipeline)) {
58
+ const tag = nodeImageTag(image);
59
+ pins.push({ file: PIPELINE, line, pin: raw, spec: tag ?? image, kind: tag === null ? 'other-image' : 'docker' });
60
+ }
61
+ }
62
+ return pins;
63
+ }
64
+
65
+ // `image: x`, `- image: x`, `image: &ancla x`, `image: *ancla`, `image: { name: x }` e `image:`
66
+ // seguido de `name: x` en las líneas siguientes (con o sin comillas).
67
+ export function pipelineImages(text) {
68
+ const out = [];
69
+ const lines = text.split(/\r?\n/);
70
+ const clean = (v) => v.replace(/\s+#.*$/, '').trim().replace(/^["']|["']$/g, '');
71
+ // Anclas YAML definidas en cualquier lugar: `&nombre valor`.
72
+ const anchors = new Map();
73
+ for (const l of lines) {
74
+ const m = l.match(/&([\w-]+)\s+([^\s{][^#]*?)\s*(?:#.*)?$/);
75
+ if (m) anchors.set(m[1], clean(m[2]));
76
+ }
77
+ const resolve = (value) => {
78
+ let v = value;
79
+ const alias = v.match(/^\*([\w-]+)$/);
80
+ if (alias) return anchors.get(alias[1]) ?? null;
81
+ v = v.replace(/^&[\w-]+\s*/, '');
82
+ const flow = v.match(/^\{(.*)\}$/);
83
+ if (flow) {
84
+ const name = flow[1].match(/(?:^|,)\s*name\s*:\s*([^,}]+)/);
85
+ return name ? clean(name[1]) : null;
86
+ }
87
+ return v ? clean(v) : null;
88
+ };
89
+ for (let i = 0; i < lines.length; i++) {
90
+ const m = lines[i].match(/^(\s*)(?:-\s+)?image:\s*(.*)$/);
91
+ if (!m) continue;
92
+ const value = clean(m[2]);
93
+ if (value && value.replace(/^&[\w-]+\s*$/, '')) {
94
+ const image = resolve(value);
95
+ if (image) out.push({ image, line: i + 1, raw: lines[i].trim() });
96
+ continue;
97
+ }
98
+ // Forma `image:` con `name:` en las líneas siguientes, más indentadas.
99
+ const indent = m[1].length;
100
+ for (let j = i + 1; j < lines.length; j++) {
101
+ if (!lines[j].trim() || /^\s*#/.test(lines[j])) continue;
102
+ const ind = lines[j].match(/^(\s*)/)[1].length;
103
+ if (ind <= indent) break;
104
+ const n = lines[j].match(/^\s*name:\s*(.+)$/);
105
+ if (n) {
106
+ const image = resolve(clean(n[1]));
107
+ if (image) out.push({ image, line: j + 1, raw: lines[j].trim() });
108
+ break;
109
+ }
110
+ }
111
+ }
112
+ return out;
113
+ }
114
+
115
+ // Qué línea de Node fija un pin: `{ major, exact?, range?, floating?, latest? }` o `{ note }`.
116
+ export function resolvePin(pin, cal) {
117
+ if (pin.kind === 'other-image') return { otherImage: true };
118
+ let spec = pin.spec.trim();
119
+ if (pin.kind === 'docker') {
120
+ if (/^\d/.test(spec)) {
121
+ spec = spec.split('-')[0];
122
+ } else {
123
+ const head = spec.split('-')[0].toLowerCase();
124
+ if (head === 'lts') return { major: cal.latestLtsMajor, floating: `tag "${pin.spec}"` };
125
+ if (LATEST_TAGS.test(head)) return { major: cal.latestMajor, floating: `tag "${pin.spec}"`, latest: true };
126
+ const byCodename = cal.majorOfCodename(head);
127
+ if (byCodename !== null) return { major: byCodename, floating: `tag "${pin.spec}"` };
128
+ return { note: `no se entiende el tag "${pin.spec}"` };
129
+ }
130
+ }
131
+ const lts = spec.match(/^lts\/(\*|[a-z]+)$/i);
132
+ if (lts) {
133
+ if (lts[1] === '*') return { major: cal.latestLtsMajor, floating: `"${spec}"` };
134
+ const major = cal.majorOfCodename(lts[1]);
135
+ return major !== null ? { major, floating: `"${spec}"` } : { note: `codename desconocido: ${spec}` };
136
+ }
137
+ if (/^(node|stable|latest|current)$/i.test(spec)) return { major: cal.latestMajor, floating: `"${spec}"`, latest: true };
138
+ if (isRange(spec)) {
139
+ const min = minVersion(spec);
140
+ return min ? { major: parseVersion(min)[0], range: true } : { note: `no se entiende el rango "${spec}"` };
141
+ }
142
+ const v = parseVersion(spec);
143
+ if (!v) return { note: `no se entiende "${spec}"` };
144
+ const parts = spec.replace(/^v/, '').split('.').length;
145
+ return { major: v[0], exact: parts === 3 ? v.join('.') : undefined };
146
+ }
147
+
148
+ function evaluate(pin, r, cal, policy) {
149
+ const base = { file: pin.file, line: pin.line };
150
+ if (r.otherImage) {
151
+ return { ...base, level: 'info', message: `${pin.spec}: imagen sin Node fijado (la versión de Node la decide la imagen).`, data: { pin: pin.pin } };
152
+ }
153
+ if (r.note) return { ...base, level: 'warn', message: `${r.note}: no se puede saber qué versión de Node corre.`, data: { pin: pin.pin } };
154
+
155
+ const sch = cal.lines.get(r.major);
156
+ const prefix = `${r.range ? 'el rango admite desde ' : ''}v${r.major}${r.floating ? ` (${r.floating})` : ''}`;
157
+ const rec = cal.recommended;
158
+ const engineHint = pin.kind === 'engines' ? ` (en engines: ">=${parseVersion(rec)[0]}")` : '';
159
+ const data = { pin: pin.pin, major: r.major, phase: cal.phaseOf(r.major), recommended: rec };
160
+ if (!sch) return { ...base, level: 'warn', message: `${prefix}: no aparece en el calendario de Node.`, data };
161
+
162
+ const phase = cal.phaseOf(r.major);
163
+ if (phase === 'eol') {
164
+ return { ...base, level: 'error', message: `${prefix}: sin soporte desde ${sch.end}. Actualizar a ${rec}${engineHint}.`, data };
165
+ }
166
+ if (phase === 'not-lts') {
167
+ const why = sch.lts ? `todavía no es LTS (lo será el ${sch.lts})` : 'línea impar, nunca va a ser LTS';
168
+ return { ...base, level: 'error', message: `${prefix}: no es LTS, ${why}. Usar ${rec}${engineHint}.`, data };
169
+ }
170
+
171
+ const notes = [];
172
+ // Mientras la LTS nueva no tiene su primera release LTS nadie puede migrar: se informa sin avisar.
173
+ const infoNotes = [];
174
+ if (r.latest) notes.push(`El tag sigue a la última release de Node aunque no sea LTS: fijar una versión LTS (${rec}).`);
175
+ if (cal.newestLts > r.major) {
176
+ const n = cal.lines.get(cal.newestLts);
177
+ if (cal.newestLtsWithoutRelease) {
178
+ infoNotes.push(`Hay una LTS nueva (v${cal.newestLts}, desde ${n.lts}), aún sin release LTS: planificar la migración cuando salga.`);
179
+ } else {
180
+ notes.push(`Hay una LTS nueva (v${cal.newestLts}, desde ${n.lts}): planificar la migración.`);
181
+ }
182
+ }
183
+ const days = cal.daysUntilEnd(r.major);
184
+ if (days < policy.nodeEolWarnDays && !(cal.newestLts > r.major)) {
185
+ const next = cal.nextLtsAfter(r.major);
186
+ if (next) notes.push(`Planificar el paso a v${next}.`);
187
+ }
188
+ if (r.exact) {
189
+ const sec = cal.securityAfter(r.major, r.exact);
190
+ if (sec.length) notes.push(`Hay releases de seguridad posteriores a ${r.exact}: como mínimo ${sec[0].version.replace(/^v/, '')}.`);
191
+ }
192
+ const support = `con soporte hasta ${sch.end}${days < policy.nodeEolWarnDays ? ` (quedan ${days} días)` : ''}`;
193
+ if (!notes.length && !infoNotes.length && days >= policy.nodeEolWarnDays) {
194
+ return { ...base, level: 'info', message: `${prefix}: LTS más nueva, ${support}.`, data: { ...data, ok: true } };
195
+ }
196
+ const all = [...notes, ...infoNotes];
197
+ const detail = `LTS ${support}.${all.length ? ` ${all.join(' ')}` : ''}`;
198
+ const level = notes.length || days < policy.nodeEolWarnDays ? 'warn' : 'info';
199
+ // En texto, los pines de la misma línea con las mismas observaciones van juntos (rango o no).
200
+ return { ...base, level, message: `${prefix}: ${detail}`, data: { ...data, group: `v${r.major}: ${detail}` } };
201
+ }
202
+
203
+ export async function run(ctx) {
204
+ let cal;
205
+ try {
206
+ cal = await loadCalendar(ctx);
207
+ } catch (error) {
208
+ return [{ level: 'warn', message: `no se pudo leer el calendario de Node (${error.message}); se omite el chequeo.` }];
209
+ }
210
+ if (!cal.recommended) return [{ level: 'warn', message: 'el calendario de Node no trae releases LTS; se omite el chequeo.' }];
211
+
212
+ const pins = findPins(ctx);
213
+ const findings = [];
214
+ const majors = new Set();
215
+ let nodePins = 0;
216
+ for (const pin of pins) {
217
+ const r = resolvePin(pin, cal);
218
+ if (!r.otherImage) nodePins++;
219
+ if (r.major !== undefined && r.major !== null) majors.add(r.major);
220
+ findings.push(evaluate(pin, r, cal, ctx.policy));
221
+ }
222
+ if (!nodePins) {
223
+ const major = parseVersion(cal.recommended)[0];
224
+ findings.push({
225
+ level: 'warn',
226
+ message: `No se fija la versión de Node en ningún lado (.nvmrc, engines.node, Dockerfile, pipeline). Fijarla, por ejemplo: .nvmrc con ${cal.recommended} y engines.node ">=${major}".`,
227
+ });
228
+ }
229
+ if (majors.size > 1) {
230
+ const list = [...majors].sort((a, b) => a - b).map((m) => `v${m}`);
231
+ findings.push({ level: 'warn', message: `Versiones mayores distintas entre archivos: ${list.join(', ')}. Alinearlas en la misma LTS.`, data: { majors: [...majors].sort((a, b) => a - b) } });
232
+ }
233
+ return findings;
234
+ }