@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/CHANGELOG.md +44 -0
- package/LICENSE +1 -1
- package/README.md +124 -426
- package/bin/npm-security-kit.js +12 -0
- package/package.json +17 -33
- package/src/checks/dockerfile.js +217 -0
- package/src/checks/index.js +10 -0
- package/src/checks/install-scripts.js +77 -0
- package/src/checks/malware.js +61 -0
- package/src/checks/node-version.js +234 -0
- package/src/checks/npm-policy.js +121 -0
- package/src/checks/vulnerabilities.js +79 -0
- package/src/cli.js +117 -0
- package/src/context.js +147 -0
- package/src/init.js +236 -0
- package/src/lib/allow-scripts.js +61 -0
- package/src/lib/audit.js +80 -0
- package/src/lib/diff.js +54 -0
- package/src/lib/dockerfile.js +74 -0
- package/src/lib/json-lines.js +12 -0
- package/src/lib/lockfile.js +48 -0
- package/src/lib/node-calendar.js +73 -0
- package/src/lib/npmrc.js +93 -0
- package/src/lib/semver.js +74 -0
- package/src/policy.js +36 -0
- package/src/report.js +68 -0
- package/src/run.js +35 -0
- package/src/version.js +3 -0
- package/EXAMPLES.md +0 -528
- package/QUICK_START.md +0 -291
- package/bin/check.js +0 -30
- package/bin/install.js +0 -36
- package/bin/install.sh +0 -318
- package/src/index.js +0 -71
- package/template/.github/dependabot.yml +0 -63
- package/template/.github/workflows/security-audit.yml +0 -218
- package/template/.security/config.json +0 -94
- package/template/.security/malicious-packages-blacklist.json +0 -259
- package/template/scripts/check-malicious-packages.sh +0 -118
- package/template/scripts/security-check.sh +0 -166
package/src/lib/npmrc.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Lectura del .npmrc del proyecto (formato ini de npm): `clave=valor`, `clave[]=valor` repetible
|
|
2
|
+
// y comentarios con `#` o `;`.
|
|
3
|
+
|
|
4
|
+
// Como `unsafe()` del paquete `ini` que usa npm: un valor sin comillas se corta en el primer `;` o
|
|
5
|
+
// `#` sin escapar (comentario al final de la línea); uno entre comillas se toma entero.
|
|
6
|
+
function unsafe(raw) {
|
|
7
|
+
const val = raw.trim();
|
|
8
|
+
if (/^(["']).*\1$/.test(val)) {
|
|
9
|
+
let value = val.charAt(0) === "'" ? val.slice(1, -1) : val;
|
|
10
|
+
try {
|
|
11
|
+
value = JSON.parse(value);
|
|
12
|
+
} catch {
|
|
13
|
+
// como ini: si no es JSON, queda como está
|
|
14
|
+
}
|
|
15
|
+
return { value: String(value), comment: '' };
|
|
16
|
+
}
|
|
17
|
+
let out = '';
|
|
18
|
+
for (let i = 0; i < val.length; i++) {
|
|
19
|
+
const c = val[i];
|
|
20
|
+
if (c === '\\' && i + 1 < val.length && '\\;#'.includes(val[i + 1])) {
|
|
21
|
+
out += val[++i];
|
|
22
|
+
} else if (c === ';' || c === '#') {
|
|
23
|
+
return { value: out.trim(), comment: val.slice(i).trim() };
|
|
24
|
+
} else {
|
|
25
|
+
out += c;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { value: out.trim(), comment: '' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function parseNpmrc(text) {
|
|
32
|
+
const entries = [];
|
|
33
|
+
(text ?? '').split(/\r?\n/).forEach((raw, i) => {
|
|
34
|
+
const line = raw.trim();
|
|
35
|
+
if (!line || line.startsWith('#') || line.startsWith(';') || line.startsWith('[')) return;
|
|
36
|
+
const eq = line.indexOf('=');
|
|
37
|
+
if (eq < 0) return;
|
|
38
|
+
const rawKey = line.slice(0, eq).trim();
|
|
39
|
+
let key = unsafe(rawKey).value;
|
|
40
|
+
const array = key.endsWith('[]');
|
|
41
|
+
if (array) key = key.slice(0, -2).trim();
|
|
42
|
+
const { value, comment } = unsafe(line.slice(eq + 1));
|
|
43
|
+
entries.push({ key, rawKey, value, comment, line: i + 1, array });
|
|
44
|
+
});
|
|
45
|
+
const find = (key) => entries.filter((e) => e.key === key || e.key === key.replace(/-/g, '_'));
|
|
46
|
+
return {
|
|
47
|
+
entries,
|
|
48
|
+
// La última asignación de la clave, como npm.
|
|
49
|
+
get: (key) => find(key).at(-1),
|
|
50
|
+
getAll: (key) => find(key),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Las claves y valores que pide la política, en el orden en que init las escribe, con el
|
|
55
|
+
// comentario que las acompaña.
|
|
56
|
+
export function requiredSettings(policy) {
|
|
57
|
+
return [
|
|
58
|
+
{ key: 'min-release-age', value: String(policy.minReleaseAgeDays), comment: `Dependencias externas: mínimo ${policy.minReleaseAgeDays} días desde su publicación` },
|
|
59
|
+
...policy.releaseAgeExclude.map((pattern) => ({
|
|
60
|
+
key: 'min-release-age-exclude',
|
|
61
|
+
array: true,
|
|
62
|
+
value: pattern,
|
|
63
|
+
comment: 'Paquetes internos: se pueden instalar inmediatamente',
|
|
64
|
+
})),
|
|
65
|
+
{ key: 'save-exact', value: 'true', comment: 'Guardar versiones exactas' },
|
|
66
|
+
{ key: 'engine-strict', value: 'true', comment: 'Validar engines (npm >= la versión mínima de la política)' },
|
|
67
|
+
{ key: 'strict-allow-scripts', value: 'true', comment: 'Install scripts: solo corren los aprobados en allowScripts (package.json)' },
|
|
68
|
+
{ key: 'audit-level', value: policy.vulnerabilityLevel, comment: `npm audit falla solo por vulnerabilidades ${policy.vulnerabilityLevel === 'critical' ? 'críticas' : policy.vulnerabilityLevel}` },
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const AUDIT_LEVELS = ['info', 'low', 'moderate', 'high', 'critical'];
|
|
73
|
+
|
|
74
|
+
// ¿El valor del .npmrc cumple la política? Devuelve el problema o null.
|
|
75
|
+
export function settingProblem(setting, npmrc, policy) {
|
|
76
|
+
if (setting.array) {
|
|
77
|
+
const values = npmrc.getAll(setting.key).map((e) => e.value);
|
|
78
|
+
return values.includes(setting.value) ? null : 'missing';
|
|
79
|
+
}
|
|
80
|
+
const entry = npmrc.get(setting.key);
|
|
81
|
+
if (!entry) return 'missing';
|
|
82
|
+
switch (setting.key) {
|
|
83
|
+
case 'min-release-age': {
|
|
84
|
+
const days = Number(entry.value);
|
|
85
|
+
return Number.isFinite(days) && days >= policy.minReleaseAgeDays ? null : 'value';
|
|
86
|
+
}
|
|
87
|
+
case 'audit-level':
|
|
88
|
+
// Un nivel más estricto que el de la política también sirve.
|
|
89
|
+
return AUDIT_LEVELS.includes(entry.value) ? null : 'value';
|
|
90
|
+
default:
|
|
91
|
+
return entry.value === setting.value ? null : 'value';
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Lo mínimo de semver que necesita el kit, sin dependencias: comparar versiones y obtener la
|
|
2
|
+
// versión más baja que admite un rango (`engines`).
|
|
3
|
+
|
|
4
|
+
const PART = /^(\d+|x|X|\*)$/;
|
|
5
|
+
|
|
6
|
+
// `v9.8.7` → [9, 8, 7]. Las partes que faltan o son comodín valen 0. Devuelve null si no es
|
|
7
|
+
// una versión.
|
|
8
|
+
export function parseVersion(text) {
|
|
9
|
+
const clean = String(text).trim().replace(/^[=v]+/, '').split(/[-+]/)[0];
|
|
10
|
+
const parts = clean.split('.');
|
|
11
|
+
if (!parts[0] || parts.length > 3 || !parts.every((p) => PART.test(p))) return null;
|
|
12
|
+
const nums = parts.map((p) => (/^\d+$/.test(p) ? Number(p) : 0));
|
|
13
|
+
while (nums.length < 3) nums.push(0);
|
|
14
|
+
return nums;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function compareVersions(a, b) {
|
|
18
|
+
const x = Array.isArray(a) ? a : parseVersion(a);
|
|
19
|
+
const y = Array.isArray(b) ? b : parseVersion(b);
|
|
20
|
+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] - y[i];
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const formatVersion = (v) => v.join('.');
|
|
25
|
+
|
|
26
|
+
// Cuántas partes numéricas trae la versión escrita (`9` → 1, `9.8` → 2, `9.x` → 1).
|
|
27
|
+
const precision = (text) => String(text).replace(/^[=v]+/, '').split(/[-+]/)[0].split('.').filter((p) => /^\d+$/.test(p)).length;
|
|
28
|
+
|
|
29
|
+
function lowerBound(comparator) {
|
|
30
|
+
const m = comparator.match(/^(>=|<=|>|<|=|\^|~)?\s*(.*)$/);
|
|
31
|
+
const [, op = '', rest] = m;
|
|
32
|
+
if (rest === '' || /^[*xX]$/.test(rest)) return op === '<' ? null : [0, 0, 0];
|
|
33
|
+
const v = parseVersion(rest);
|
|
34
|
+
if (!v) return undefined; // no se entiende
|
|
35
|
+
if (op === '<' || op === '<=') return [0, 0, 0];
|
|
36
|
+
if (op === '>') {
|
|
37
|
+
const p = precision(rest);
|
|
38
|
+
if (p >= 3) return [v[0], v[1], v[2] + 1];
|
|
39
|
+
if (p === 2) return [v[0], v[1] + 1, 0];
|
|
40
|
+
return [v[0] + 1, 0, 0];
|
|
41
|
+
}
|
|
42
|
+
return v;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Versión más baja que admite un rango semver (`>=9.1.0` → 9.1.0, `^7 || ^9` → 7.0.0,
|
|
46
|
+
// `11.x` → 11.0.0). Devuelve null si el rango no se entiende o no admite ninguna versión.
|
|
47
|
+
export function minVersion(range) {
|
|
48
|
+
let best = null;
|
|
49
|
+
for (const alternative of String(range).split('||')) {
|
|
50
|
+
const text = alternative.trim();
|
|
51
|
+
let bound;
|
|
52
|
+
const hyphen = text.match(/^(\S+)\s+-\s+\S+$/);
|
|
53
|
+
if (hyphen) {
|
|
54
|
+
bound = parseVersion(hyphen[1]);
|
|
55
|
+
} else {
|
|
56
|
+
// `>= 18` → `>=18`, para separar los comparadores por espacios.
|
|
57
|
+
const comparators = text.replace(/(>=|<=|>|<|=|\^|~)\s+/g, '$1').split(/\s+/).filter(Boolean);
|
|
58
|
+
if (!comparators.length) comparators.push('*');
|
|
59
|
+
bound = [0, 0, 0];
|
|
60
|
+
for (const c of comparators) {
|
|
61
|
+
const lb = lowerBound(c);
|
|
62
|
+
if (lb === undefined) return null;
|
|
63
|
+
if (lb === null) continue;
|
|
64
|
+
if (compareVersions(lb, bound) > 0) bound = lb;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (!bound) return null;
|
|
68
|
+
if (!best || compareVersions(bound, best) < 0) best = bound;
|
|
69
|
+
}
|
|
70
|
+
return best && formatVersion(best);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ¿El texto es un rango (y no una versión puntual)?
|
|
74
|
+
export const isRange = (text) => /[<>^~|*xX]|\s-\s/.test(String(text).replace(/^v/, '')) || /\s/.test(String(text).trim());
|
package/src/policy.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// La política de seguridad de dependencias npm. Es el único lugar con umbrales, días y scopes:
|
|
2
|
+
// los chequeos leen de acá y nunca fijan valores propios. Las versiones y fechas de Node no van
|
|
3
|
+
// acá: se leen de nodejs.org en cada corrida.
|
|
4
|
+
//
|
|
5
|
+
// Cambiar un valor es cambiar la política: va con su decisión fechada y con una versión nueva
|
|
6
|
+
// del kit.
|
|
7
|
+
|
|
8
|
+
const freeze = (obj) => {
|
|
9
|
+
for (const value of Object.values(obj)) {
|
|
10
|
+
if (value && typeof value === 'object') freeze(value);
|
|
11
|
+
}
|
|
12
|
+
return Object.freeze(obj);
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const policy = freeze({
|
|
16
|
+
// Días mínimos desde la publicación de una versión antes de instalarla.
|
|
17
|
+
minReleaseAgeDays: 7,
|
|
18
|
+
// Scopes propios que se instalan sin esperar (glob simple `@scope/*`).
|
|
19
|
+
releaseAgeExclude: ['@theoptimalpartner/*'],
|
|
20
|
+
// `min-release-age-exclude` existe desde npm 11.17; npm 10 ignora `min-release-age` sin avisar.
|
|
21
|
+
minNpmVersion: '11.17.0',
|
|
22
|
+
// Vulnerabilidades (que no son malware) que bloquean, solo en dependencias de producción.
|
|
23
|
+
vulnerabilityLevel: 'critical',
|
|
24
|
+
// CWE que identifica un advisory de malware. Bloquea con cualquier severidad y en todo el árbol.
|
|
25
|
+
malwareCwe: ['CWE-506'],
|
|
26
|
+
// Únicos orígenes aceptados para el `resolved` de las entradas del lockfile.
|
|
27
|
+
allowedRegistries: ['https://registry.npmjs.org/'],
|
|
28
|
+
// Días de soporte restantes de una línea de Node por debajo de los cuales se avisa.
|
|
29
|
+
nodeEolWarnDays: 180,
|
|
30
|
+
// Claves del .npmrc del proyecto. `error`: sin ellas la política no se cumple.
|
|
31
|
+
// `warn`: recomendadas.
|
|
32
|
+
requiredNpmrc: {
|
|
33
|
+
error: ['min-release-age', 'engine-strict', 'strict-allow-scripts'],
|
|
34
|
+
warn: ['min-release-age-exclude[]', 'save-exact', 'audit-level'],
|
|
35
|
+
},
|
|
36
|
+
});
|
package/src/report.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Salida en texto y en JSON del comando `check`.
|
|
2
|
+
|
|
3
|
+
const ICON = { error: '✗', warn: '⚠', info: 'ℹ', ok: '✓' };
|
|
4
|
+
const COLOR = { error: 31, warn: 33, info: 36, ok: 32, dim: 2, bold: 1 };
|
|
5
|
+
|
|
6
|
+
export function useColor(stream = process.stdout, env = process.env) {
|
|
7
|
+
return Boolean(stream.isTTY) && !('NO_COLOR' in env);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const painter = (color) => (code, text) => (color ? `\u001b[${code}m${text}\u001b[0m` : text);
|
|
11
|
+
const location = (r) => (r.file ? `${r.file}${r.line ? `:${r.line}` : ''}` : '');
|
|
12
|
+
const withPin = (r) => [location(r), r.data?.pin].filter(Boolean).join(' ');
|
|
13
|
+
|
|
14
|
+
// Junta los hallazgos de un chequeo que dicen lo mismo en distintos archivos, o que comparten
|
|
15
|
+
// `data.group` (un encabezado común; cada uno se lista con su `data.pin`).
|
|
16
|
+
function group(findings) {
|
|
17
|
+
const groups = new Map();
|
|
18
|
+
for (const f of findings) {
|
|
19
|
+
const headline = f.data?.group ?? f.message;
|
|
20
|
+
const key = `${f.level}\u0000${headline}`;
|
|
21
|
+
if (!groups.has(key)) groups.set(key, { level: f.level, message: headline, items: [] });
|
|
22
|
+
groups.get(key).items.push(f);
|
|
23
|
+
}
|
|
24
|
+
return [...groups.values()];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Un bloque por chequeo, con el título y una línea por hallazgo.
|
|
28
|
+
export function formatBlocks(checks, results, { color = false, indent = '' } = {}) {
|
|
29
|
+
const paint = painter(color);
|
|
30
|
+
const lines = [];
|
|
31
|
+
for (const check of checks) {
|
|
32
|
+
const own = results.filter((r) => r.check === check.id);
|
|
33
|
+
lines.push(`${indent}${paint(COLOR.bold, check.title)}`);
|
|
34
|
+
// Los `info` con `data.ok` solo van en --json: en texto, el chequeo sin observaciones.
|
|
35
|
+
const visible = own.filter((r) => !(r.level === 'info' && r.data?.ok));
|
|
36
|
+
if (!visible.length) {
|
|
37
|
+
lines.push(`${indent} ${paint(COLOR.ok, ICON.ok)} sin observaciones`);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
for (const g of group(visible)) {
|
|
41
|
+
const icon = paint(COLOR[g.level], ICON[g.level]);
|
|
42
|
+
if (g.items.length === 1) {
|
|
43
|
+
const [item] = g.items;
|
|
44
|
+
// Con `data.group`, el mensaje propio ya nombra lo que dice el pin.
|
|
45
|
+
const where = item.data?.group ? location(item) : withPin(item);
|
|
46
|
+
lines.push(`${indent} ${icon} ${where ? `${paint(COLOR.dim, where)} ` : ''}${item.message}`);
|
|
47
|
+
} else {
|
|
48
|
+
lines.push(`${indent} ${icon} ${g.message}`);
|
|
49
|
+
for (const item of g.items) {
|
|
50
|
+
const where = withPin(item);
|
|
51
|
+
if (where) lines.push(`${indent} ${paint(COLOR.dim, where)}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return `${lines.join('\n')}\n`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function formatText({ kitVersion, dir, checks, results, summary }, { color = false } = {}) {
|
|
60
|
+
const paint = painter(color);
|
|
61
|
+
const total = `${summary.errors} error(es), ${summary.warnings} aviso(s)`;
|
|
62
|
+
const colored = summary.errors ? paint(COLOR.error, total) : summary.warnings ? paint(COLOR.warn, total) : paint(COLOR.ok, total);
|
|
63
|
+
return `${paint(COLOR.dim, `npm-security-kit ${kitVersion} · ${dir}`)}\n\n${formatBlocks(checks, results, { color })}\n${colored}\n`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function formatJson({ kitVersion, policy, results, summary }) {
|
|
67
|
+
return `${JSON.stringify({ kitVersion, policy, results, summary }, null, 2)}\n`;
|
|
68
|
+
}
|
package/src/run.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Corre los chequeos en paralelo y junta los hallazgos en el orden de la lista. Si un chequeo
|
|
2
|
+
// lanza una excepción, queda como un error de ese chequeo y los demás siguen.
|
|
3
|
+
|
|
4
|
+
const LEVELS = new Set(['error', 'warn', 'info']);
|
|
5
|
+
|
|
6
|
+
function normalize(check, finding) {
|
|
7
|
+
const level = LEVELS.has(finding.level) ? finding.level : 'error';
|
|
8
|
+
const out = { check: check.id, level, message: String(finding.message) };
|
|
9
|
+
if (finding.file) out.file = finding.file;
|
|
10
|
+
if (finding.line) out.line = finding.line;
|
|
11
|
+
if (finding.data !== undefined) out.data = finding.data;
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function summarize(results) {
|
|
16
|
+
return {
|
|
17
|
+
errors: results.filter((r) => r.level === 'error').length,
|
|
18
|
+
warnings: results.filter((r) => r.level === 'warn').length,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function runChecks(checks, ctx) {
|
|
23
|
+
const perCheck = await Promise.all(
|
|
24
|
+
checks.map(async (check) => {
|
|
25
|
+
try {
|
|
26
|
+
const findings = (await check.run(ctx)) ?? [];
|
|
27
|
+
return findings.map((f) => normalize(check, f));
|
|
28
|
+
} catch (error) {
|
|
29
|
+
return [normalize(check, { level: 'error', message: `falló el chequeo ${check.id}: ${error.message}` })];
|
|
30
|
+
}
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
const results = perCheck.flat();
|
|
34
|
+
return { results, summary: summarize(results) };
|
|
35
|
+
}
|
package/src/version.js
ADDED