@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/src/init.js ADDED
@@ -0,0 +1,236 @@
1
+ // `init`: deja un repo listo para el kit sin pisar nada. Completa .npmrc y engines, lista los
2
+ // install scripts por aprobar y muestra (sin editarlos) los cambios del pipeline y de los
3
+ // Dockerfiles. No pregunta nada: corre igual sin TTY.
4
+ import { writeFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import * as dockerfileCheck from './checks/dockerfile.js';
7
+ import { OVERRIDE_ENV, packagesWithScripts } from './checks/install-scripts.js';
8
+ import * as nodeVersionCheck from './checks/node-version.js';
9
+ import * as npmPolicyCheck from './checks/npm-policy.js';
10
+ import { loadAllowPolicy, verdict } from './lib/allow-scripts.js';
11
+ import { lockfileName } from './lib/audit.js';
12
+ import { unifiedDiff } from './lib/diff.js';
13
+ import { loadCalendar } from './lib/node-calendar.js';
14
+ import { parseNpmrc, requiredSettings, settingProblem } from './lib/npmrc.js';
15
+ import { compareVersions, minVersion, parseVersion } from './lib/semver.js';
16
+ import { formatBlocks } from './report.js';
17
+ import { runChecks } from './run.js';
18
+
19
+ const PACKAGE = '@theoptimalpartner/npm-security-kit';
20
+
21
+ // Agrega las claves de la política que faltan (con su comentario) y corrige las que tienen otro
22
+ // valor. No borra ni reordena nada.
23
+ export function mergeNpmrc(text, policy) {
24
+ const original = text ?? '';
25
+ const eol = original.includes('\r\n') ? '\r\n' : '\n';
26
+ const lines = original ? original.replace(/\r?\n$/, '').split(/\r?\n/) : [];
27
+ const npmrc = parseNpmrc(original);
28
+ const changes = [];
29
+ const additions = [];
30
+ for (const setting of requiredSettings(policy)) {
31
+ const problem = settingProblem(setting, npmrc, policy);
32
+ if (!problem) continue;
33
+ const line = `${setting.key}${setting.array ? '[]' : ''}=${setting.value}`;
34
+ if (problem === 'value') {
35
+ const entry = npmrc.get(setting.key);
36
+ // Conserva cómo estaba escrita la clave y el comentario al final de la línea.
37
+ lines[entry.line - 1] = `${entry.rawKey}=${setting.value}${entry.comment ? ` ${entry.comment}` : ''}`;
38
+ changes.push(`${line} (antes: ${setting.key}=${entry.value})`);
39
+ } else {
40
+ additions.push('', `# ${setting.comment}`, line);
41
+ changes.push(line);
42
+ }
43
+ }
44
+ if (!changes.length) return { text: original, changes };
45
+ if (!lines.length) additions.shift(); // archivo nuevo: sin línea en blanco al principio
46
+ return { text: `${[...lines, ...additions].join(eol)}${eol}`, changes };
47
+ }
48
+
49
+ function jsonFormat(text) {
50
+ return {
51
+ indent: text.match(/^[ \t]+(?=")/m)?.[0] ?? ' ',
52
+ eol: text.includes('\r\n') ? '\r\n' : '\n',
53
+ finalNewline: /\n$/.test(text),
54
+ };
55
+ }
56
+
57
+ // engines.npm (si falta o admite un npm viejo) y engines.node (solo si falta), conservando la
58
+ // indentación y el salto de línea final del archivo.
59
+ export function mergePackageJson(text, policy, nodeMajor) {
60
+ const pkg = JSON.parse(text);
61
+ const changes = [];
62
+ const minNpm = policy.minNpmVersion.replace(/\.0$/, '');
63
+ const engines = { ...(pkg.engines ?? {}) };
64
+ const currentNpm = typeof engines.npm === 'string' ? minVersion(engines.npm) : null;
65
+ if (!currentNpm || compareVersions(currentNpm, policy.minNpmVersion) < 0) {
66
+ changes.push(`engines.npm: ">=${minNpm}"${engines.npm ? ` (antes: "${engines.npm}")` : ''}`);
67
+ engines.npm = `>=${minNpm}`;
68
+ }
69
+ if (engines.node === undefined && nodeMajor) {
70
+ engines.node = `>=${nodeMajor}`;
71
+ changes.push(`engines.node: ">=${nodeMajor}"`);
72
+ }
73
+ if (!changes.length) return { text, changes };
74
+ pkg.engines = engines;
75
+ const { indent, eol, finalNewline } = jsonFormat(text);
76
+ const out = JSON.stringify(pkg, null, indent).replace(/\n/g, eol);
77
+ return { text: finalNewline ? `${out}${eol}` : out, changes };
78
+ }
79
+
80
+ export function pipelineStep(kitVersion, nodeVersion) {
81
+ return `- step: &test
82
+ name: Test (supply chain + build)
83
+ image: node:${nodeVersion}
84
+ caches:
85
+ - npm
86
+ script:
87
+ # Antes de instalar: malware, vulnerabilidades, Node, .npmrc, install scripts, Dockerfiles
88
+ - npx --yes ${PACKAGE}@${kitVersion} check
89
+ - npm ci
90
+ - npm audit signatures
91
+ - npm run build
92
+ pipelines:
93
+ pull-requests:
94
+ '**':
95
+ - step:
96
+ <<: *test
97
+ condition:
98
+ state: BITBUCKET_PR_DESTINATION_BRANCH == "develop" || BITBUCKET_PR_DESTINATION_BRANCH == "main"`;
99
+ }
100
+
101
+ // ctx que ve los archivos como quedarían después de init (para el resumen de --dry-run) y
102
+ // cachea el calendario de Node.
103
+ function overlay(ctx, files) {
104
+ const cache = new Map();
105
+ const view = {
106
+ ...ctx,
107
+ readFile: (f) => (f in files ? files[f] : ctx.readFile(f)),
108
+ readJson: (f) => (f in files ? JSON.parse(files[f]) : ctx.readJson(f)),
109
+ exists: (f) => f in files || ctx.exists(f),
110
+ fetchJson: (url, opts) => {
111
+ if (!cache.has(url)) cache.set(url, ctx.fetchJson(url, opts));
112
+ return cache.get(url);
113
+ },
114
+ runNpm: async (args) => {
115
+ // El modo efectivo de strict-allow-scripts sale del .npmrc nuevo, salvo que lo pise el entorno.
116
+ if (args.join(' ') === 'config get strict-allow-scripts' && '.npmrc' in files) {
117
+ const env = ctx.env[OVERRIDE_ENV] ?? ctx.env[OVERRIDE_ENV.toUpperCase()];
118
+ const value = env ?? parseNpmrc(files['.npmrc']).get('strict-allow-scripts')?.value ?? 'false';
119
+ return { code: 0, stdout: `${value}\n`, stderr: '' };
120
+ }
121
+ return ctx.runNpm(args);
122
+ },
123
+ };
124
+ return view;
125
+ }
126
+
127
+ export async function runInit(ctx, { dryRun = false, stdout = process.stdout, color = false } = {}) {
128
+ const paint = (code, text) => (color ? `\u001b[${code}m${text}\u001b[0m` : text);
129
+ const out = [];
130
+ const say = (line = '') => out.push(line);
131
+ const section = (title) => {
132
+ say();
133
+ say(paint(1, title));
134
+ };
135
+
136
+ const pkgText = ctx.readFile('package.json');
137
+ if (pkgText === null) {
138
+ stdout.write(`npm-security-kit: no hay package.json en ${ctx.dir}. init se corre en la raíz de un proyecto npm.\n`);
139
+ return 2;
140
+ }
141
+
142
+ const files = {};
143
+ const view = overlay(ctx, files);
144
+ let cal = null;
145
+ try {
146
+ cal = await loadCalendar(view);
147
+ } catch (error) {
148
+ cal = { error };
149
+ }
150
+ const nodeVersion = cal.recommended;
151
+ const nodeMajor = nodeVersion ? parseVersion(nodeVersion)[0] : null;
152
+
153
+ say(paint(2, `npm-security-kit ${ctx.kitVersion} · init${dryRun ? ' --dry-run' : ''} · ${ctx.dir}`));
154
+
155
+ // 1. .npmrc y package.json
156
+ const npmrcBefore = ctx.readFile('.npmrc');
157
+ const npmrc = mergeNpmrc(npmrcBefore, ctx.policy);
158
+ const pkg = mergePackageJson(pkgText, ctx.policy, nodeMajor);
159
+ if (npmrc.changes.length) files['.npmrc'] = npmrc.text;
160
+ if (pkg.changes.length) files['package.json'] = pkg.text;
161
+
162
+ section(dryRun ? 'Cambios que haría (no se escribió nada)' : 'Cambios');
163
+ if (!npmrc.changes.length && !pkg.changes.length) {
164
+ say(' Sin cambios: .npmrc y package.json ya cumplen la política.');
165
+ } else if (dryRun) {
166
+ if (npmrc.changes.length) say(unifiedDiff('.npmrc', npmrcBefore, npmrc.text).trimEnd());
167
+ if (pkg.changes.length) say(unifiedDiff('package.json', pkgText, pkg.text).trimEnd());
168
+ } else {
169
+ for (const [file, text] of Object.entries(files)) writeFileSync(join(ctx.dir, file), text);
170
+ for (const c of npmrc.changes) say(` .npmrc ${npmrcBefore === null ? '(nuevo) ' : ''}+ ${c}`);
171
+ for (const c of pkg.changes) say(` package.json + ${c}`);
172
+ }
173
+ if (cal.error) say(` No se pudo leer el calendario de Node (${cal.error.message}): engines.node no se completó.`);
174
+
175
+ // 2. Install scripts por aprobar
176
+ section('Install scripts por aprobar (allowScripts en package.json)');
177
+ const lockfile = lockfileName(ctx);
178
+ if (!lockfile) {
179
+ say(' No hay package-lock.json: generarlo con `npm install --package-lock-only` y volver a correr init.');
180
+ } else {
181
+ const policy = loadAllowPolicy(JSON.parse(view.readFile('package.json')), view.readFile('.npmrc'));
182
+ const pending = [
183
+ ...new Set(
184
+ packagesWithScripts(ctx.readJson(lockfile), ctx)
185
+ .filter((d) => verdict(policy.rules, d) === null)
186
+ .map((d) => `${d.name}@${d.version}${d.entry.optional && d.entry.os ? ` (opcional, solo ${d.entry.os.join('/')})` : ''}`),
187
+ ),
188
+ ];
189
+ if (!pending.length) {
190
+ say(' Ninguno: todos los paquetes con install scripts están aprobados o negados.');
191
+ } else {
192
+ for (const p of pending) say(` - ${p}`);
193
+ say(' init no aprueba nada solo. Para revisarlos:');
194
+ say(' npm ci --ignore-scripts');
195
+ say(' npm install-scripts ls');
196
+ say(' npm install-scripts approve <paquete> # lo que necesita compilar o descargar binarios');
197
+ say(' npm install-scripts deny <paquete> # telemetría, publicidad o lo que no haga falta');
198
+ }
199
+ }
200
+
201
+ // 3. Pipeline (no se edita)
202
+ section('Pipeline: bitbucket-pipelines.yml (init no lo edita)');
203
+ const pipeline = ctx.readFile('bitbucket-pipelines.yml');
204
+ const used = pipeline?.match(/npm-security-kit@([\w.-]+)/);
205
+ if (used) {
206
+ say(` Ya corre el kit (${used[0]}).${used[1] !== ctx.kitVersion ? ` Para esta versión: ${PACKAGE}@${ctx.kitVersion}.` : ''}`);
207
+ } else {
208
+ say(` ${pipeline === null ? 'No hay bitbucket-pipelines.yml. ' : ''}Agregar el kit en el step de test, ANTES de npm ci, y correrlo solo en los PR a ramas que despliegan:`);
209
+ say();
210
+ for (const line of pipelineStep(ctx.kitVersion, nodeVersion ?? '<versión LTS>').split('\n')) say(` ${line}`);
211
+ }
212
+
213
+ // 4. Dockerfiles (no se editan)
214
+ section('Dockerfiles (init no los edita)');
215
+ const docker = await dockerfileCheck.run(view);
216
+ if (!docker.length) say(' Sin cambios sugeridos.');
217
+ for (const f of docker) say(` ${f.level === 'error' ? '✗' : '⚠'} ${f.file}:${f.line} ${f.message}`);
218
+
219
+ // 5. Lo que queda: chequeos de configuración sobre el resultado
220
+ section(`Resumen${dryRun ? ' (como quedaría)' : ''}`);
221
+ const summaryChecks = [nodeVersionCheck, npmPolicyCheck];
222
+ const { results } = await runChecks(summaryChecks, view);
223
+ say(formatBlocks(summaryChecks, results, { color, indent: ' ' }).trimEnd());
224
+
225
+ section('Pasos siguientes');
226
+ const steps = [
227
+ dryRun ? 'Correr init sin --dry-run.' : 'Revisar el diff: git diff .npmrc package.json',
228
+ 'Aprobar o negar los install scripts pendientes.',
229
+ 'Agregar el step al pipeline y corregir los Dockerfiles.',
230
+ `Correr el chequeo completo: npx --yes ${PACKAGE}@${ctx.kitVersion} check`,
231
+ ];
232
+ steps.forEach((s, i) => say(` ${i + 1}. ${s}`));
233
+
234
+ stdout.write(`${out.join('\n')}\n`);
235
+ return 0;
236
+ }
@@ -0,0 +1,61 @@
1
+ // La política allowScripts de npm 11, con las mismas reglas de coincidencia que npm
2
+ // (@npmcli/arborist script-allowed.js) para los casos que revisa el kit.
3
+ import { parseNpmrc } from './npmrc.js';
4
+
5
+ const SOURCE_KEY = /^(git\+|git:|github:|gitlab:|bitbucket:|https?:|file:|\.{0,2}\/)/;
6
+ const EXACT = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
7
+
8
+ // `pkg`, `pkg@*`, `pkg@1.2.3`, `pkg@1.2.3 || 1.2.4`, `@scope/pkg@1.2.3`, o un origen (git, file, url).
9
+ export function parseAllowKey(key) {
10
+ if (SOURCE_KEY.test(key)) return { key, source: key };
11
+ const at = key.lastIndexOf('@');
12
+ const name = at > 0 ? key.slice(0, at) : key;
13
+ const spec = at > 0 ? key.slice(at + 1).trim() : '';
14
+ if (!spec || spec === '*') return { key, name, versions: null };
15
+ const parts = spec.split('||').map((p) => p.trim());
16
+ if (parts.every((p) => EXACT.test(p))) return { key, name, versions: new Set(parts) };
17
+ // Con `v` adelante npm la acepta pero nunca coincide (compara contra la versión de la URL).
18
+ if (parts.every((p) => EXACT.test(p.replace(/^v/, '')))) return { key, name, ignored: 'versión con "v": escribirla sin la "v"' };
19
+ // npm descarta los rangos (^, ~, >=, <) y los dist-tags: la entrada no aprueba ni niega nada.
20
+ return { key, name, ignored: /^[a-z]/i.test(spec) ? 'dist-tag' : 'rango semver' };
21
+ }
22
+
23
+ // La política efectiva: el campo allowScripts del package.json raíz o, si no está, la clave
24
+ // `allow-scripts` del .npmrc (lista de nombres). Como npm, la primera que exista gana entera.
25
+ export function loadAllowPolicy(pkg, npmrcText) {
26
+ let ignored = [];
27
+ if (pkg?.allowScripts && typeof pkg.allowScripts === 'object') {
28
+ const rules = Object.entries(pkg.allowScripts).map(([key, value]) => ({ ...parseAllowKey(key), allow: value }));
29
+ ignored = rules.filter((r) => r.ignored && !r.versions);
30
+ // Como npm: si todas las claves se descartan, el package.json no tiene política y vale el .npmrc.
31
+ if (rules.some((r) => !r.ignored)) return { source: 'package.json', rules, ignored };
32
+ }
33
+ const names = parseNpmrc(npmrcText)
34
+ .getAll('allow-scripts')
35
+ .flatMap((e) => e.value.split(','))
36
+ .map((n) => n.trim())
37
+ .filter(Boolean);
38
+ if (names.length) return { source: '.npmrc', rules: names.map((n) => ({ ...parseAllowKey(n), allow: true })), ignored };
39
+ return { source: null, rules: [], ignored };
40
+ }
41
+
42
+ // Como npm, una regla por nombre solo vale para paquetes del registro: no aprueba un tarball
43
+ // local ni un repo git que se llamen igual.
44
+ const fromRegistry = (entry) => !entry.resolved || /^https?:\/\/[^/]+\/.+\/-\/[^/]+-\d/.test(entry.resolved);
45
+
46
+ const matches = (rule, dep) => {
47
+ if (rule.ignored) return false;
48
+ if (rule.source) return dep.entry.resolved === rule.source;
49
+ return fromRegistry(dep.entry) && rule.name === dep.name && (!rule.versions || rule.versions.has(dep.version));
50
+ };
51
+
52
+ // true (aprobado), false (negado: `false` le gana a `true`) o null (pendiente).
53
+ export function verdict(rules, dep) {
54
+ let allow = false;
55
+ for (const rule of rules) {
56
+ if (!matches(rule, dep)) continue;
57
+ if (rule.allow === false) return false;
58
+ if (rule.allow === true) allow = true;
59
+ }
60
+ return allow ? true : null;
61
+ }
@@ -0,0 +1,80 @@
1
+ // `npm audit --json` sobre el lockfile, antes de `npm ci`. Distingue una falla del endpoint de
2
+ // advisories (red, 5xx) de un reporte con vulnerabilidades: npm devuelve exit 1 en los dos casos.
3
+
4
+ export class AuditUnavailableError extends Error {}
5
+
6
+ const SEVERITY_RANK = { info: 0, low: 1, moderate: 2, high: 3, critical: 4 };
7
+ export const severityAtLeast = (severity, level) => (SEVERITY_RANK[severity] ?? -1) >= (SEVERITY_RANK[level] ?? 99);
8
+
9
+ export function lockfileName(ctx) {
10
+ if (ctx.exists('npm-shrinkwrap.json')) return 'npm-shrinkwrap.json';
11
+ if (ctx.exists('package-lock.json')) return 'package-lock.json';
12
+ return null;
13
+ }
14
+
15
+ function parseReport(stdout) {
16
+ let report;
17
+ try {
18
+ report = JSON.parse(stdout);
19
+ } catch {
20
+ return { problem: 'la respuesta de npm audit no es JSON' };
21
+ }
22
+ if (report && typeof report.vulnerabilities === 'object' && report.vulnerabilities !== null && !report.error) {
23
+ return { report };
24
+ }
25
+ const detail = report?.message || report?.error?.summary || report?.error?.code || 'respuesta sin "vulnerabilities"';
26
+ return { problem: String(detail).split('\n')[0] };
27
+ }
28
+
29
+ // `--package-lock-only` hace que npm audite el lockfile aunque haya node_modules. Los `--include`
30
+ // le ganan a un `omit` del entorno (por ejemplo NODE_ENV=production, que esconde las devDependencies).
31
+ export async function runAudit(ctx, extraArgs, { attempts = 3 } = {}) {
32
+ const args = ['audit', '--json', '--package-lock-only', ...extraArgs];
33
+ let problem;
34
+ for (let attempt = 1; attempt <= attempts; attempt++) {
35
+ if (attempt > 1) await ctx.sleep(2000 * (attempt - 1));
36
+ const { stdout, stderr } = await ctx.runNpm(args);
37
+ const parsed = parseReport(stdout);
38
+ if (parsed.report) return parsed.report;
39
+ problem = parsed.problem === 'la respuesta de npm audit no es JSON' && stderr.trim() ? stderr.trim().split('\n')[0] : parsed.problem;
40
+ }
41
+ throw new AuditUnavailableError(problem);
42
+ }
43
+
44
+ export const unavailableFinding = (error) => ({
45
+ level: 'error',
46
+ message: `no se pudo consultar los advisories del registro (${error.message}); reintentar el step. No es un hallazgo de seguridad: el registro de npm no respondió.`,
47
+ });
48
+
49
+ // Cada advisory del reporte, atribuido al paquete vulnerable. Las entradas `via` que son texto
50
+ // solo apuntan a otro paquete del reporte, y ese paquete trae el advisory completo.
51
+ export function advisoriesOf(report) {
52
+ const out = [];
53
+ for (const [pkg, vuln] of Object.entries(report.vulnerabilities ?? {})) {
54
+ for (const via of vuln.via ?? []) {
55
+ if (!via || typeof via !== 'object') continue;
56
+ out.push({
57
+ name: via.name ?? pkg,
58
+ source: via.source,
59
+ title: via.title ?? '',
60
+ url: via.url,
61
+ severity: via.severity,
62
+ cwe: via.cwe ?? [],
63
+ range: via.range,
64
+ nodes: vuln.nodes ?? [],
65
+ fixAvailable: vuln.fixAvailable,
66
+ });
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+
72
+ // El título es solo un respaldo para advisories sin CWE: hay vulnerabilidades comunes cuyo título
73
+ // dice "malicious" ("bypass malicious or unwanted attributes", CWE-91), y no son malware.
74
+ const MALWARE_TITLE = /\bmalware\b|\bmalicious (code|package|version)s?\b/i;
75
+
76
+ export const isMalware = (advisory, policy) =>
77
+ advisory.cwe.some((cwe) => policy.malwareCwe.includes(cwe)) || (!advisory.cwe.length && MALWARE_TITLE.test(advisory.title));
78
+
79
+ // Clave estable de un advisory sobre un paquete (un mismo GHSA puede aparecer en varios).
80
+ export const advisoryKey = (a) => `${a.name}\u0000${a.source ?? a.url ?? a.title}`;
@@ -0,0 +1,54 @@
1
+ // Diff unificado de dos textos (por líneas, con LCS), para `init --dry-run`. Los archivos que
2
+ // toca init son chicos: no hace falta nada más eficiente.
3
+
4
+ export function unifiedDiff(file, before, after, context = 3) {
5
+ if (before === after) return '';
6
+ const a = before === null ? [] : before.replace(/\n$/, '').split('\n');
7
+ const b = after.replace(/\n$/, '').split('\n');
8
+ const n = a.length;
9
+ const m = b.length;
10
+ const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
11
+ for (let i = n - 1; i >= 0; i--) {
12
+ for (let j = m - 1; j >= 0; j--) {
13
+ lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
14
+ }
15
+ }
16
+ // Operaciones: [' ', línea] igual, ['-', línea] sale, ['+', línea] entra.
17
+ const ops = [];
18
+ let i = 0;
19
+ let j = 0;
20
+ while (i < n || j < m) {
21
+ if (i < n && j < m && a[i] === b[j]) {
22
+ ops.push([' ', a[i], i, j]);
23
+ i++;
24
+ j++;
25
+ } else if (j < m && (i >= n || lcs[i][j + 1] >= lcs[i + 1][j])) {
26
+ ops.push(['+', b[j], i, j]);
27
+ j++;
28
+ } else {
29
+ ops.push(['-', a[i], i, j]);
30
+ i++;
31
+ }
32
+ }
33
+ // Agrupa los cambios en hunks con `context` líneas alrededor.
34
+ const changed = ops.map((op, k) => (op[0] !== ' ' ? k : -1)).filter((k) => k >= 0);
35
+ const hunks = [];
36
+ for (const k of changed) {
37
+ const last = hunks.at(-1);
38
+ if (last && k - last.end <= context * 2) last.end = k;
39
+ else hunks.push({ start: k, end: k });
40
+ }
41
+ const out = [`--- a/${file}${before === null ? ' (no existe)' : ''}`, `+++ b/${file}`];
42
+ for (const h of hunks) {
43
+ const from = Math.max(0, h.start - context);
44
+ const to = Math.min(ops.length - 1, h.end + context);
45
+ const slice = ops.slice(from, to + 1);
46
+ const oldStart = slice[0][2] + 1;
47
+ const newStart = slice[0][3] + 1;
48
+ const oldLen = slice.filter((o) => o[0] !== '+').length;
49
+ const newLen = slice.filter((o) => o[0] !== '-').length;
50
+ out.push(`@@ -${oldLen ? oldStart : oldStart - 1},${oldLen} +${newLen ? newStart : newStart - 1},${newLen} @@`);
51
+ for (const [kind, line] of slice) out.push(`${kind}${line}`);
52
+ }
53
+ return `${out.join('\n')}\n`;
54
+ }
@@ -0,0 +1,74 @@
1
+ // Lectura de Dockerfiles: dónde están, instrucciones lógicas (con las líneas continuadas con `\`)
2
+ // y la imagen de cada `FROM`.
3
+
4
+ // `Dockerfile`, `Dockerfile.dev`, `dev.Dockerfile`, `Containerfile` (y `dockerfile`). No cuenta código
5
+ // ni documentos que se llaman parecido (`dockerfile.js`, `Dockerfile.md`) ni los `.dockerignore`.
6
+ const NOT_DOCKERFILE = /\.(dockerignore|[cm]?[jt]sx?|json|ya?ml|md|txt|py|rb|go|java|kt|sh|ps1|toml|lock|tpl|j2|erb|ejs|hbs|template)$/i;
7
+ export const isDockerfile = (name) =>
8
+ !NOT_DOCKERFILE.test(name) && (/^(Dockerfile|Containerfile|dockerfile)(\.[\w.-]+)?$/.test(name) || /\.(Dockerfile|Containerfile)$/.test(name));
9
+
10
+ export const findDockerfiles = (ctx) => ctx.listFiles(isDockerfile, 3);
11
+
12
+ // Instrucciones lógicas: `{ instruction, args, line }`, con `line` = la línea donde empieza.
13
+ export function parseInstructions(text) {
14
+ const out = [];
15
+ const lines = text.split(/\r?\n/);
16
+ let current = null;
17
+ for (let i = 0; i < lines.length; i++) {
18
+ const raw = lines[i];
19
+ if (current) {
20
+ // Dentro de una instrucción continuada, los comentarios y las líneas vacías se saltan.
21
+ if (/^\s*#/.test(raw) || !raw.trim()) continue;
22
+ const cont = raw.replace(/\\\s*$/, '');
23
+ current.args += ` ${cont.trim()}`;
24
+ if (cont === raw) current = null;
25
+ continue;
26
+ }
27
+ if (/^\s*#/.test(raw) || !raw.trim()) continue;
28
+ const m = raw.match(/^\s*([A-Za-z]+)\s*(.*)$/);
29
+ if (!m) continue;
30
+ const body = m[2].replace(/\\\s*$/, '');
31
+ const entry = { instruction: m[1].toUpperCase(), args: body.trim(), line: i + 1 };
32
+ out.push(entry);
33
+ if (body !== m[2]) current = entry;
34
+ }
35
+ return out;
36
+ }
37
+
38
+ // Reemplaza `${VAR}`, `${VAR:-x}` y `$VAR` con los valores de `vars`.
39
+ export function substitute(text, vars) {
40
+ return text
41
+ .replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?-([^}]*))?\}/g, (all, name, def) => vars[name] ?? def ?? all)
42
+ .replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (all, name) => vars[name] ?? all);
43
+ }
44
+
45
+ // Etapas del Dockerfile: `{ from: { image, line, raw }, instructions: [...] }`. La imagen ya
46
+ // tiene reemplazados los ARG declarados antes del primer FROM.
47
+ export function parseStages(text) {
48
+ const globalArgs = {};
49
+ const stages = [];
50
+ for (const ins of parseInstructions(text)) {
51
+ if (ins.instruction === 'FROM') {
52
+ const tokens = substitute(ins.args, globalArgs).split(/\s+/).filter((t) => !t.startsWith('--'));
53
+ const as = tokens.findIndex((t) => /^as$/i.test(t));
54
+ const name = as > 0 ? tokens[as + 1]?.toLowerCase() : undefined;
55
+ stages.push({ name, from: { image: tokens[0] ?? '', line: ins.line, raw: `FROM ${ins.args}` }, instructions: [] });
56
+ } else if (!stages.length) {
57
+ if (ins.instruction === 'ARG') {
58
+ const m = ins.args.match(/^([A-Za-z_][A-Za-z0-9_]*)(?:=(.*))?$/);
59
+ if (m && m[2] !== undefined) globalArgs[m[1]] = m[2].replace(/^["']|["']$/g, '');
60
+ }
61
+ } else {
62
+ stages.at(-1).instructions.push(ins);
63
+ }
64
+ }
65
+ return stages;
66
+ }
67
+
68
+ // Tag de una imagen oficial de Node (`node:TAG`, `library/node:TAG`, `mirror/…/node:TAG`).
69
+ // `node` sin tag es `latest`. Devuelve null si la imagen no es de Node.
70
+ export function nodeImageTag(image) {
71
+ const m = image.match(/^(?:[^@\s]*\/)?node(?::([\w.-]+))?(@sha256:[a-f0-9]+)?$/i);
72
+ if (!m) return null;
73
+ return m[1] ?? (m[2] ? null : 'latest');
74
+ }
@@ -0,0 +1,12 @@
1
+ // Línea (1-based) de una clave anidada en un JSON formateado (`['engines', 'node']`), o undefined.
2
+ export function jsonKeyLine(text, keys) {
3
+ const lines = text.split(/\r?\n/);
4
+ let from = 0;
5
+ for (const key of keys) {
6
+ const re = new RegExp(`^\\s*"${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"\\s*:`);
7
+ const found = lines.findIndex((l, i) => i >= from && re.test(l));
8
+ if (found < 0) return undefined;
9
+ from = found;
10
+ }
11
+ return from + 1;
12
+ }
@@ -0,0 +1,48 @@
1
+ // Recorrido de las entradas de `packages` de un package-lock.json v2/v3.
2
+
3
+ // `node_modules/a/node_modules/@s/b` → `@s/b`.
4
+ export const nameFromKey = (key) => key.slice(key.lastIndexOf('node_modules/') + 'node_modules/'.length);
5
+
6
+ // Nombre y versión según la URL del registro (`https://host/<nombre>/-/<archivo>-<versión>.tgz`).
7
+ // npm usa esta identidad para allowScripts, porque el package.json del tarball se puede falsear.
8
+ export function registryIdentity(resolved) {
9
+ const m = typeof resolved === 'string' && resolved.match(/^https?:\/\/[^/]+\/(.+)\/-\/([^/]+)\.tgz(?:[?#].*)?$/);
10
+ if (!m) return null;
11
+ const name = decodeURIComponent(m[1]).replace(/%2f/gi, '/');
12
+ const base = name.split('/').pop();
13
+ const version = m[2].startsWith(`${base}-`) ? m[2].slice(base.length + 1) : null;
14
+ return { name, version };
15
+ }
16
+
17
+ // lockfileVersion 1 (sin `packages`): las entradas salen del árbol `dependencies`.
18
+ function v1Packages(dependencies, prefix = '', out = {}) {
19
+ for (const [name, dep] of Object.entries(dependencies ?? {})) {
20
+ const key = `${prefix}node_modules/${name}`;
21
+ out[key] = { ...dep, inBundle: dep.bundled, link: typeof dep.version === 'string' && dep.version.startsWith('file:') };
22
+ v1Packages(dep.dependencies, `${key}/`, out);
23
+ }
24
+ return out;
25
+ }
26
+
27
+ // Entradas de dependencias: `{ key, name, version, entry }`, sin la raíz, los links ni los
28
+ // workspaces.
29
+ export function* lockEntries(lock) {
30
+ const packages = lock?.packages ?? v1Packages(lock?.dependencies);
31
+ for (const [key, entry] of Object.entries(packages)) {
32
+ if (!key || entry.link || !key.includes('node_modules/')) continue;
33
+ const id = registryIdentity(entry.resolved);
34
+ yield { key, name: id?.name ?? entry.name ?? nameFromKey(key), version: id?.version ?? entry.version, entry };
35
+ }
36
+ }
37
+
38
+ // ¿La entrada se instala en esta plataforma? Las opcionales con `os`/`cpu` de otra plataforma
39
+ // (fsevents en Linux) npm no las instala y sus scripts no corren.
40
+ const allowedOn = (list, value) => {
41
+ if (!Array.isArray(list) || !list.length) return true;
42
+ if (list.includes(`!${value}`)) return false;
43
+ const positive = list.filter((v) => !v.startsWith('!'));
44
+ return !positive.length || positive.includes(value);
45
+ };
46
+
47
+ export const installsOn = (entry, { platform, arch }) =>
48
+ !(entry.optional || entry.devOptional) || (allowedOn(entry.os, platform) && allowedOn(entry.cpu, arch));
@@ -0,0 +1,73 @@
1
+ // Calendario de Node, leído de nodejs.org en cada corrida: acá no hay fechas ni versiones fijas.
2
+ // La fase de cada línea sale de las fechas de schedule.json, no del campo `lts` de index.json,
3
+ // que recién cambia cuando sale la primera release LTS de la línea.
4
+ import { compareVersions, parseVersion } from './semver.js';
5
+
6
+ export const INDEX_URL = 'https://nodejs.org/dist/index.json';
7
+ export const SCHEDULE_URL = 'https://raw.githubusercontent.com/nodejs/Release/main/schedule.json';
8
+
9
+ export async function loadCalendar(ctx) {
10
+ const [index, schedule] = await Promise.all([ctx.fetchJson(INDEX_URL), ctx.fetchJson(SCHEDULE_URL)]);
11
+ return analyzeCalendar(index, schedule, ctx.today);
12
+ }
13
+
14
+ const day = (text) => new Date(`${text}T00:00:00Z`);
15
+ const majorOf = (version) => parseVersion(version)[0];
16
+
17
+ export function analyzeCalendar(index, schedule, today) {
18
+ const releases = index
19
+ .filter((r) => parseVersion(r.version))
20
+ .sort((a, b) => compareVersions(b.version, a.version));
21
+ const lines = new Map();
22
+ for (const [key, sch] of Object.entries(schedule)) {
23
+ const m = key.match(/^v(\d+)$/);
24
+ if (m) lines.set(Number(m[1]), sch);
25
+ }
26
+
27
+ // eol | not-lts | maintenance | active | unknown
28
+ const phaseOf = (major) => {
29
+ const sch = lines.get(major);
30
+ if (!sch) return 'unknown';
31
+ if (sch.end && today >= day(sch.end)) return 'eol';
32
+ if (!sch.lts || today < day(sch.lts)) return 'not-lts';
33
+ if (sch.maintenance && today >= day(sch.maintenance)) return 'maintenance';
34
+ return 'active';
35
+ };
36
+
37
+ // La LTS más nueva: la línea de número más alto que ya es LTS y sigue con soporte.
38
+ const supported = [...lines.keys()].filter((m) => ['active', 'maintenance'].includes(phaseOf(m)));
39
+ const newestLts = supported.length ? Math.max(...supported) : null;
40
+
41
+ const ltsReleases = releases.filter((r) => r.lts);
42
+ const newestLtsRelease = newestLts !== null ? ltsReleases.find((r) => majorOf(r.version) === newestLts) : undefined;
43
+ const latestLtsRelease = ltsReleases[0];
44
+ const recommendedRelease = newestLtsRelease ?? latestLtsRelease;
45
+
46
+ return {
47
+ today,
48
+ releases,
49
+ lines,
50
+ phaseOf,
51
+ newestLts,
52
+ // La versión a instalar: la release LTS publicada de la LTS más nueva o, si todavía no salió,
53
+ // la última release LTS publicada.
54
+ recommended: recommendedRelease?.version.replace(/^v/, ''),
55
+ // true si la LTS más nueva todavía no tiene release LTS en index.json.
56
+ newestLtsWithoutRelease: newestLts !== null && !newestLtsRelease,
57
+ latestLtsMajor: latestLtsRelease ? majorOf(latestLtsRelease.version) : null,
58
+ latestMajor: releases.length ? majorOf(releases[0].version) : null,
59
+ majorOfCodename: (codename) => {
60
+ const r = ltsReleases.find((x) => String(x.lts).toLowerCase() === codename.toLowerCase());
61
+ if (r) return majorOf(r.version);
62
+ for (const [major, sch] of lines) if (sch.codename && sch.codename.toLowerCase() === codename.toLowerCase()) return major;
63
+ return null;
64
+ },
65
+ // Releases de seguridad de la línea posteriores a `version` (de la más nueva a la más vieja).
66
+ securityAfter: (major, version) =>
67
+ releases.filter((r) => majorOf(r.version) === major && r.security && compareVersions(r.version, version) > 0),
68
+ daysUntilEnd: (major) => Math.round((day(lines.get(major).end) - today) / 86400000),
69
+ // La próxima línea que va a ser LTS después de `major`.
70
+ nextLtsAfter: (major) =>
71
+ [...lines.entries()].filter(([m, s]) => m > major && s.lts).map(([m]) => m).sort((a, b) => a - b)[0] ?? null,
72
+ };
73
+ }