@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.
@@ -0,0 +1,121 @@
1
+ // Que el repo tenga la configuración que hace cumplir la política: claves del .npmrc,
2
+ // engines.npm, un npm que las entienda y un lockfile.
3
+ import { lockfileName } from '../lib/audit.js';
4
+ import { parseNpmrc, requiredSettings, settingProblem } from '../lib/npmrc.js';
5
+ import { jsonKeyLine } from '../lib/json-lines.js';
6
+ import { compareVersions, minVersion, parseVersion } from '../lib/semver.js';
7
+
8
+ export const id = 'npm-policy';
9
+ export const title = 'npm y .npmrc';
10
+
11
+ const NPM10_WHY = 'npm 10 ignora min-release-age sin avisar y no aplica allowScripts';
12
+
13
+ export async function run(ctx) {
14
+ const { policy } = ctx;
15
+ const findings = [];
16
+ const minNpm = policy.minNpmVersion;
17
+ const minNpmShort = minNpm.replace(/\.0$/, '');
18
+
19
+ // .npmrc del proyecto (el archivo, no la configuración efectiva)
20
+ const npmrcText = ctx.readFile('.npmrc');
21
+ const npmrc = parseNpmrc(npmrcText);
22
+ const errorKeys = new Set(policy.requiredNpmrc.error);
23
+ for (const setting of requiredSettings(policy)) {
24
+ const name = setting.array ? `${setting.key}[]` : setting.key;
25
+ const problem = settingProblem(setting, npmrc, policy);
26
+ if (!problem) continue;
27
+ const level = errorKeys.has(name) ? 'error' : 'warn';
28
+ const line = `${name}=${setting.value}`;
29
+ const entry = setting.array ? undefined : npmrc.get(setting.key);
30
+ const where = npmrcText === null ? ' (no hay .npmrc: crearlo o correr `npm-security-kit init`)' : '';
31
+ const why = {
32
+ 'min-release-age': ' Sin esto, npm instala versiones recién publicadas.',
33
+ 'min-release-age-exclude[]': ` Sin esto, los paquetes ${setting.value} también esperan ${policy.minReleaseAgeDays} días.`,
34
+ 'engine-strict': ' Es lo que hace cumplir engines.npm.',
35
+ 'strict-allow-scripts': ' Sin esto, npm ejecuta los install scripts no aprobados.',
36
+ }[name] ?? '';
37
+ findings.push({
38
+ level,
39
+ file: '.npmrc',
40
+ line: entry?.line,
41
+ message:
42
+ problem === 'missing'
43
+ ? `Falta \`${line}\` en el .npmrc${where}.${why}`
44
+ : `\`${setting.key}=${entry.value}\` no cumple la política: usar \`${line}\`.${why}`,
45
+ data: {
46
+ key: name,
47
+ expected: setting.value,
48
+ actual: entry?.value ?? null,
49
+ // Sin .npmrc, en texto van todas juntas.
50
+ ...(npmrcText === null && {
51
+ pin: line,
52
+ group: `No hay .npmrc (\`npm-security-kit init\` lo crea). Faltan las claves ${level === 'error' ? 'que exige la política' : 'recomendadas'}:`,
53
+ }),
54
+ },
55
+ });
56
+ }
57
+
58
+ // engines.npm
59
+ const pkgText = ctx.readFile('package.json');
60
+ if (pkgText === null) {
61
+ findings.push({ level: 'error', message: 'No hay package.json en el directorio revisado.' });
62
+ } else {
63
+ const pkg = JSON.parse(pkgText);
64
+ const range = pkg.engines?.npm;
65
+ const min = typeof range === 'string' ? minVersion(range) : null;
66
+ if (typeof range !== 'string') {
67
+ findings.push({
68
+ level: 'error',
69
+ file: 'package.json',
70
+ message: `Falta engines.npm: agregar "npm": ">=${minNpmShort}" en engines (${NPM10_WHY}).`,
71
+ });
72
+ } else if (!min || compareVersions(min, minNpm) < 0) {
73
+ findings.push({
74
+ level: 'error',
75
+ file: 'package.json',
76
+ line: jsonKeyLine(pkgText, ['engines', 'npm']),
77
+ message: `engines.npm "${range}" admite npm anteriores a ${minNpm} (${NPM10_WHY}). Cambiar a ">=${minNpmShort}".`,
78
+ data: { range, min },
79
+ });
80
+ }
81
+ }
82
+
83
+ // npm en uso
84
+ const npmVersion = await ctx.runNpm(['--version']);
85
+ const used = npmVersion.code === 0 ? npmVersion.stdout.trim() : null;
86
+ if (!used || !parseVersion(used)) {
87
+ findings.push({ level: 'warn', message: `No se pudo saber la versión de npm en uso (${(npmVersion.stderr || npmVersion.stdout).trim() || 'sin salida'}).` });
88
+ } else if (compareVersions(used, minNpm) < 0) {
89
+ findings.push({
90
+ level: 'error',
91
+ message: `npm ${used} en uso: hace falta npm >= ${minNpm} (${NPM10_WHY}). En el pipeline, usar la imagen de Node LTS recomendada; en local, \`npm install -g npm@11\`.`,
92
+ data: { npm: used },
93
+ });
94
+ }
95
+
96
+ // Lockfile
97
+ const lockfile = lockfileName(ctx);
98
+ if (!lockfile) {
99
+ const other = ['yarn.lock', 'pnpm-lock.yaml'].find((f) => ctx.exists(f));
100
+ findings.push({
101
+ level: 'error',
102
+ message: `No hay package-lock.json: sin lockfile no hay \`npm ci\` ni audit.${other ? ` (${other} no sirve: el kit revisa proyectos npm.)` : ' Generarlo con `npm install --package-lock-only`.'}`,
103
+ });
104
+ } else {
105
+ const lock = ctx.readJson(lockfile);
106
+ if (!lock.packages) {
107
+ findings.push({
108
+ level: 'error',
109
+ file: lockfile,
110
+ message: `lockfileVersion ${lock.lockfileVersion ?? '?'}: no trae \`packages\` (ni \`hasInstallScript\`), así que no se pueden revisar los install scripts. Regenerar con npm 11 (\`npm install --package-lock-only\`).`,
111
+ });
112
+ } else if (!(lock.lockfileVersion >= 3)) {
113
+ findings.push({
114
+ level: 'warn',
115
+ file: lockfile,
116
+ message: `lockfileVersion ${lock.lockfileVersion ?? '?'}: regenerar con npm 11 (\`npm install --package-lock-only\`).`,
117
+ });
118
+ }
119
+ }
120
+ return findings;
121
+ }
@@ -0,0 +1,79 @@
1
+ // Vulnerabilidades (que no son malware) en dependencias de producción. Bloquea desde
2
+ // `policy.vulnerabilityLevel`; las `high` van en un solo aviso de resumen.
3
+ import {
4
+ AuditUnavailableError,
5
+ advisoriesOf,
6
+ advisoryKey,
7
+ isMalware,
8
+ lockfileName,
9
+ runAudit,
10
+ severityAtLeast,
11
+ unavailableFinding,
12
+ } from '../lib/audit.js';
13
+
14
+ export const id = 'vulnerabilities';
15
+ export const title = 'Vulnerabilidades en producción';
16
+
17
+ function fixText(fixAvailable) {
18
+ if (fixAvailable === true) return 'hay arreglo (npm audit fix)';
19
+ if (fixAvailable && typeof fixAvailable === 'object') {
20
+ const target = fixAvailable.name ? `${fixAvailable.name}@${fixAvailable.version}` : 'otra versión';
21
+ return fixAvailable.isSemVerMajor ? `hay arreglo con ${target}, pero requiere major` : `hay arreglo con ${target}`;
22
+ }
23
+ return 'sin arreglo publicado todavía';
24
+ }
25
+
26
+ export async function run(ctx) {
27
+ const lockfile = lockfileName(ctx);
28
+ if (!lockfile) return [];
29
+ let report;
30
+ try {
31
+ report = await runAudit(ctx, ['--omit=dev']);
32
+ } catch (error) {
33
+ if (error instanceof AuditUnavailableError) return [unavailableFinding(error)];
34
+ throw error;
35
+ }
36
+ const level = ctx.policy.vulnerabilityLevel;
37
+ // Advisories por paquete, sin el malware (lo cuenta el chequeo `malware`, sobre todo el árbol).
38
+ const byPackage = new Map();
39
+ const seen = new Set();
40
+ for (const advisory of advisoriesOf(report)) {
41
+ if (isMalware(advisory, ctx.policy) || seen.has(advisoryKey(advisory))) continue;
42
+ seen.add(advisoryKey(advisory));
43
+ if (!byPackage.has(advisory.name)) byPackage.set(advisory.name, []);
44
+ byPackage.get(advisory.name).push(advisory);
45
+ }
46
+ const findings = [];
47
+ const high = []; // paquetes cuya vulnerabilidad más grave es high (como cuenta npm audit)
48
+ for (const [name, advisories] of byPackage) {
49
+ const blocking = advisories.filter((a) => severityAtLeast(a.severity, level));
50
+ for (const advisory of blocking) {
51
+ findings.push({
52
+ level: 'error',
53
+ file: lockfile,
54
+ message:
55
+ `${advisory.name} (${advisory.range}): ${advisory.title} [${advisory.severity}]. ${fixText(advisory.fixAvailable)}.` +
56
+ (advisory.url ? ` ${advisory.url}` : ''),
57
+ data: {
58
+ name: advisory.name,
59
+ range: advisory.range,
60
+ title: advisory.title,
61
+ url: advisory.url,
62
+ severity: advisory.severity,
63
+ fixAvailable: advisory.fixAvailable,
64
+ },
65
+ });
66
+ }
67
+ if (!blocking.length && advisories.some((a) => a.severity === 'high')) high.push(name);
68
+ }
69
+ if (high.length) {
70
+ high.sort();
71
+ findings.push({
72
+ level: 'warn',
73
+ file: lockfile,
74
+ message: `${high.length} paquete(s) con vulnerabilidades high en producción (${high.join(', ')}). No bloquean; ver \`npm audit --omit=dev\` y planificar el arreglo.`,
75
+ data: { severity: 'high', count: high.length, packages: high },
76
+ });
77
+ }
78
+ return findings;
79
+ }
package/src/cli.js ADDED
@@ -0,0 +1,117 @@
1
+ // Parseo de argumentos y despacho de comandos. Devuelve el exit code:
2
+ // 0 sin errores (puede haber avisos), 1 con al menos un error, 2 por uso incorrecto o fallo interno.
3
+ import { existsSync, statSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+ import { parseArgs } from 'node:util';
6
+ import { checks } from './checks/index.js';
7
+ import { createContext, parseDay } from './context.js';
8
+ import { formatJson, formatText, useColor } from './report.js';
9
+ import { runChecks } from './run.js';
10
+ import { kitVersion } from './version.js';
11
+
12
+ export const HELP = `npm-security-kit ${kitVersion}
13
+ Reglas de seguridad de dependencias npm, revisadas antes de npm ci.
14
+
15
+ Uso:
16
+ npm-security-kit check [dir] [--json] [--base <ref>]
17
+ npm-security-kit init [dir] [--dry-run]
18
+ npm-security-kit --version | --help
19
+
20
+ Comandos:
21
+ check Revisa el proyecto sin modificar nada. Va en el pipeline ANTES de npm ci.
22
+ init Completa .npmrc y engines, lista los install scripts por aprobar y muestra
23
+ los cambios sugeridos para el pipeline y los Dockerfiles (no los edita).
24
+
25
+ Opciones:
26
+ --json Salida en JSON (solo check).
27
+ --base <ref> Rama base para comparar el lockfile (por defecto, la rama destino del PR
28
+ en Bitbucket: origin/$BITBUCKET_PR_DESTINATION_BRANCH).
29
+ --dry-run init: muestra el diff sin escribir.
30
+ -h, --help Esta ayuda.
31
+ -v, --version Versión del kit.
32
+
33
+ Exit codes: 0 sin errores (puede haber avisos), 1 hay errores, 2 uso incorrecto o fallo del kit.
34
+ `;
35
+
36
+ const OPTIONS = {
37
+ json: { type: 'boolean' },
38
+ base: { type: 'string' },
39
+ today: { type: 'string' }, // solo para tests; no aparece en --help
40
+ 'dry-run': { type: 'boolean' },
41
+ help: { type: 'boolean', short: 'h' },
42
+ version: { type: 'boolean', short: 'v' },
43
+ };
44
+
45
+ class UsageError extends Error {}
46
+
47
+ function parse(argv) {
48
+ let parsed;
49
+ try {
50
+ parsed = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: true });
51
+ } catch (error) {
52
+ throw new UsageError(error.message);
53
+ }
54
+ const { values, positionals } = parsed;
55
+ if (values.help || values.version) return { values, command: null };
56
+ const [command, dir, ...rest] = positionals;
57
+ if (!command) throw new UsageError('falta el comando');
58
+ if (!['check', 'init'].includes(command)) throw new UsageError(`comando desconocido: ${command}`);
59
+ if (rest.length) throw new UsageError(`argumento de más: ${rest.join(' ')}`);
60
+ if (command !== 'check' && (values.json || values.base)) throw new UsageError(`--json y --base son opciones de check`);
61
+ if (command !== 'init' && values['dry-run']) throw new UsageError('--dry-run es una opción de init');
62
+ if (values.today !== undefined && !parseDay(values.today)) throw new UsageError(`--today espera YYYY-MM-DD: ${values.today}`);
63
+ return { values, command, dir };
64
+ }
65
+
66
+ export async function main(argv, io = {}) {
67
+ const {
68
+ stdout = process.stdout,
69
+ stderr = process.stderr,
70
+ env = process.env,
71
+ cwd = process.cwd(),
72
+ contextOptions = {},
73
+ checkList = checks,
74
+ } = io;
75
+ let args;
76
+ try {
77
+ args = parse(argv);
78
+ } catch (error) {
79
+ if (!(error instanceof UsageError)) throw error;
80
+ stderr.write(`npm-security-kit: ${error.message}\n\n${HELP}`);
81
+ return 2;
82
+ }
83
+ if (args.values.help) {
84
+ stdout.write(HELP);
85
+ return 0;
86
+ }
87
+ if (args.values.version) {
88
+ stdout.write(`${kitVersion}\n`);
89
+ return 0;
90
+ }
91
+
92
+ const dir = resolve(cwd, args.dir ?? '.');
93
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) {
94
+ stderr.write(`npm-security-kit: no existe el directorio ${dir}\n`);
95
+ return 2;
96
+ }
97
+ const ctx = createContext({
98
+ dir,
99
+ env,
100
+ today: parseDay(args.values.today),
101
+ base: args.values.base,
102
+ ...contextOptions,
103
+ });
104
+
105
+ if (args.command === 'init') {
106
+ const { runInit } = await import('./init.js');
107
+ return runInit(ctx, { dryRun: Boolean(args.values['dry-run']), stdout, color: useColor(stdout, env) });
108
+ }
109
+
110
+ const { results, summary } = await runChecks(checkList, ctx);
111
+ if (args.values.json) {
112
+ stdout.write(formatJson({ kitVersion, policy: ctx.policy, results, summary }));
113
+ } else {
114
+ stdout.write(formatText({ kitVersion, dir, checks: checkList, results, summary }, { color: useColor(stdout, env) }));
115
+ }
116
+ return summary.errors ? 1 : 0;
117
+ }
package/src/context.js ADDED
@@ -0,0 +1,147 @@
1
+ // Arma el `ctx` que reciben los chequeos. Todo acceso a archivos, a npm, a git y a la red pasa
2
+ // por acá, para que los tests puedan reemplazarlo sin red ni procesos.
3
+ import { execFile } from 'node:child_process';
4
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
5
+ import { join, relative, sep } from 'node:path';
6
+ import { policy as defaultPolicy } from './policy.js';
7
+ import { kitVersion } from './version.js';
8
+
9
+ export class NetworkError extends Error {
10
+ constructor(message, { url, status } = {}) {
11
+ super(message);
12
+ this.name = 'NetworkError';
13
+ this.url = url;
14
+ this.status = status;
15
+ }
16
+ }
17
+
18
+ // Directorios que no se recorren al buscar Dockerfiles.
19
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'coverage']);
20
+
21
+ export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
22
+
23
+ // Fecha `YYYY-MM-DD` a medianoche UTC. Sin argumento, hoy.
24
+ export function parseDay(value) {
25
+ const text = value ?? new Date().toISOString().slice(0, 10);
26
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) return null;
27
+ const date = new Date(`${text}T00:00:00Z`);
28
+ return Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== text ? null : date;
29
+ }
30
+
31
+ function runProcess(cmd, args, options) {
32
+ return new Promise((resolve) => {
33
+ execFile(cmd, args, { maxBuffer: 256 * 1024 * 1024, windowsHide: true, ...options }, (error, stdout, stderr) => {
34
+ if (error && typeof error.code !== 'number') {
35
+ // No se pudo ejecutar (por ejemplo, ENOENT): no hay exit code.
36
+ resolve({ code: -1, stdout: stdout ?? '', stderr: stderr || error.message });
37
+ return;
38
+ }
39
+ resolve({ code: error ? error.code : 0, stdout: stdout ?? '', stderr: stderr ?? '' });
40
+ });
41
+ });
42
+ }
43
+
44
+ export function createContext({
45
+ dir,
46
+ env = process.env,
47
+ today = parseDay(),
48
+ policy = defaultPolicy,
49
+ platform = process.platform,
50
+ arch = process.arch,
51
+ base,
52
+ fetchImpl = globalThis.fetch,
53
+ wait = sleep,
54
+ } = {}) {
55
+ const abs = (file) => join(dir, file);
56
+
57
+ const readFile = (file) => {
58
+ try {
59
+ // Sin BOM: npm lo ignora y JSON.parse no.
60
+ return readFileSync(abs(file), 'utf8').replace(/^\uFEFF/, '');
61
+ } catch (error) {
62
+ if (error.code === 'ENOENT' || error.code === 'EISDIR') return null;
63
+ throw error;
64
+ }
65
+ };
66
+
67
+ const readJson = (file) => {
68
+ const text = readFile(file);
69
+ if (text === null) return null;
70
+ try {
71
+ return JSON.parse(text);
72
+ } catch (error) {
73
+ throw new Error(`${file} no es JSON válido (${error.message})`);
74
+ }
75
+ };
76
+
77
+ // Lista archivos (rutas relativas con `/`) hasta `maxDepth` niveles bajo `dir`.
78
+ const listFiles = (match, maxDepth = 3) => {
79
+ const out = [];
80
+ const walk = (current, depth) => {
81
+ let entries;
82
+ try {
83
+ entries = readdirSync(current);
84
+ } catch {
85
+ return;
86
+ }
87
+ for (const name of entries.sort()) {
88
+ const full = join(current, name);
89
+ let stats;
90
+ try {
91
+ stats = statSync(full);
92
+ } catch {
93
+ continue;
94
+ }
95
+ if (stats.isDirectory()) {
96
+ if (depth < maxDepth && !SKIP_DIRS.has(name)) walk(full, depth + 1);
97
+ } else if (match(name)) {
98
+ out.push(relative(dir, full).split(sep).join('/'));
99
+ }
100
+ }
101
+ };
102
+ walk(dir, 0);
103
+ return out;
104
+ };
105
+
106
+ const fetchJson = async (url, { retries = 2, timeoutMs = 15000 } = {}) => {
107
+ let lastError;
108
+ for (let attempt = 0; attempt <= retries; attempt++) {
109
+ if (attempt > 0) await wait(1000 * 2 ** (attempt - 1));
110
+ try {
111
+ const res = await fetchImpl(url, {
112
+ signal: AbortSignal.timeout(timeoutMs),
113
+ headers: { accept: 'application/json' },
114
+ });
115
+ if (res.status === 404) throw new NetworkError(`${url}: HTTP 404`, { url, status: 404 });
116
+ if (!res.ok) {
117
+ lastError = new NetworkError(`${url}: HTTP ${res.status}`, { url, status: res.status });
118
+ continue;
119
+ }
120
+ return await res.json();
121
+ } catch (error) {
122
+ if (error instanceof NetworkError && error.status === 404) throw error;
123
+ lastError = error instanceof NetworkError ? error : new NetworkError(`${url}: ${error.message}`, { url });
124
+ }
125
+ }
126
+ throw lastError;
127
+ };
128
+
129
+ return {
130
+ dir,
131
+ env,
132
+ today,
133
+ policy,
134
+ platform,
135
+ arch,
136
+ base,
137
+ kitVersion,
138
+ readFile,
139
+ readJson,
140
+ exists: (file) => existsSync(abs(file)),
141
+ listFiles,
142
+ runNpm: (args) => runProcess('npm', args, { cwd: dir, env, shell: process.platform === 'win32' }),
143
+ git: (args) => runProcess('git', args, { cwd: dir, env }),
144
+ fetchJson,
145
+ sleep: wait,
146
+ };
147
+ }