376 lines
13 KiB
JavaScript
376 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
// scripts/check/check-vuln-ratchet.mjs
|
|
// Catraca de vulnerabilidades de dependências via osv-scanner (Task 7.2 — Fase 7).
|
|
//
|
|
// Saída (stdout):
|
|
// vulnCount=N — total de vulnerabilidades encontradas (todos os severities)
|
|
// vulnCount=SKIP reason=binary-absent — osv-scanner não está no PATH
|
|
//
|
|
// Por default é ADVISORY (sai 0 sempre). Passe --ratchet para tornar BLOQUEANTE:
|
|
// lê metrics.vulnCount.value de config/quality/quality-baseline.json, compara a
|
|
// contagem MEDIDA e SAI 1 SE — E SOMENTE SE — a medida for MAIOR que o baseline
|
|
// (regressão real, direction:down). Qualquer SKIP gracioso (binário ausente,
|
|
// osv.dev/rede inacessível, erro de parse) SAI 0 mesmo com --ratchet — uma falha
|
|
// de MEDIÇÃO nunca bloqueia, só uma regressão medida bloqueia.
|
|
//
|
|
// NB (variância de CVE): osv mede contra um banco de CVEs que cresce de forma
|
|
// contínua. Um PR que NÃO toca dependências pode subitamente medir vulnCount >
|
|
// baseline porque um novo CVE foi divulgado nas deps existentes — isso é o
|
|
// comportamento ESPERADO de um gate de CVE bloqueante, não uma regressão de
|
|
// produto. O remédio é (a) bumpar a dep afetada (preferível) ou, se não houver
|
|
// fix, (b) re-baseline metrics.vulnCount com justificativa + issue de tracking.
|
|
// Ver docs/security/SUPPLY_CHAIN.md → "Variância de CVE".
|
|
//
|
|
// Uso:
|
|
// node scripts/check/check-vuln-ratchet.mjs
|
|
// node scripts/check/check-vuln-ratchet.mjs --json # imprime JSON bruto do osv-scanner
|
|
// node scripts/check/check-vuln-ratchet.mjs --quiet # suprime logs de diagnóstico
|
|
// node scripts/check/check-vuln-ratchet.mjs --ratchet # falha (exit 1) numa regressão
|
|
|
|
import fs from "node:fs";
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
const ROOT = process.cwd();
|
|
const QUIET = process.argv.includes("--quiet");
|
|
const PRINT_JSON = process.argv.includes("--json");
|
|
const RATCHET = process.argv.includes("--ratchet");
|
|
const BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.json");
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pure parsing function (exported for tests)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Conta vulnerabilidades no JSON emitido por `osv-scanner --format json`.
|
|
*
|
|
* Formato do osv-scanner v1+:
|
|
* {
|
|
* results: [
|
|
* {
|
|
* packages: [
|
|
* {
|
|
* package: { name, version, ecosystem },
|
|
* vulnerabilities: [ { id, aliases, affected, ... }, ... ],
|
|
* groups: [ { ids: [...] }, ... ]
|
|
* },
|
|
* ...
|
|
* ]
|
|
* },
|
|
* ...
|
|
* ]
|
|
* }
|
|
*
|
|
* Contagem: cada entrada em `vulnerabilities[]` de cada package conta como 1 vuln.
|
|
* Se `groups` estiver presente e tiver menos entradas que `vulnerabilities`, usamos
|
|
* `groups.length` para deduplificar (mesma vuln em múltiplos pacotes conta 1x por
|
|
* grupo). Caso contrário, contamos `vulnerabilities.length`.
|
|
*
|
|
* @param {object|null} osvJson - Objeto JSON parseado do osv-scanner
|
|
* @returns {{ vulnCount: number, bySeverity: Record<string, number> }}
|
|
*/
|
|
export function parseOsvJson(osvJson) {
|
|
if (!osvJson || !Array.isArray(osvJson.results)) {
|
|
return { vulnCount: 0, bySeverity: {} };
|
|
}
|
|
|
|
let vulnCount = 0;
|
|
const bySeverity = {};
|
|
|
|
for (const result of osvJson.results) {
|
|
if (!Array.isArray(result.packages)) continue;
|
|
|
|
for (const pkg of result.packages) {
|
|
if (!Array.isArray(pkg.vulnerabilities)) continue;
|
|
|
|
// Use groups for deduplication when available (same vuln in multiple paths)
|
|
const pkgCount = Array.isArray(pkg.groups) && pkg.groups.length > 0
|
|
? pkg.groups.length
|
|
: pkg.vulnerabilities.length;
|
|
|
|
vulnCount += pkgCount;
|
|
|
|
// Collect severity info from the vulnerability entries
|
|
for (const vuln of pkg.vulnerabilities) {
|
|
const severity = extractSeverity(vuln);
|
|
bySeverity[severity] = (bySeverity[severity] ?? 0) + 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
return { vulnCount, bySeverity };
|
|
}
|
|
|
|
/**
|
|
* Extrai a severidade de uma entrada de vulnerabilidade do osv-scanner.
|
|
* Tenta database_specific.severity, depois severity[0].type, depois "UNKNOWN".
|
|
*
|
|
* @param {object} vuln - Entrada de vulnerabilidade do osv-scanner
|
|
* @returns {string}
|
|
*/
|
|
export function extractSeverity(vuln) {
|
|
if (!vuln) return "UNKNOWN";
|
|
|
|
// osv-scanner v2 field: database_specific.severity (common in OSV schema)
|
|
const dbSeverity = vuln.database_specific?.severity;
|
|
if (typeof dbSeverity === "string" && dbSeverity.length > 0) {
|
|
return dbSeverity.toUpperCase();
|
|
}
|
|
|
|
// CVSS severity array: [{ type: "CVSS_V3", score: "CVSS:3.1/..." }, ...]
|
|
if (Array.isArray(vuln.severity) && vuln.severity.length > 0) {
|
|
const first = vuln.severity[0];
|
|
if (typeof first?.type === "string") {
|
|
return first.type;
|
|
}
|
|
}
|
|
|
|
return "UNKNOWN";
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ratchet (direction:down) — exported for tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Avalia a contagem MEDIDA de vulnerabilidades contra o baseline.
|
|
* Direction: down (a contagem só pode CAIR — mais vulns = regressão).
|
|
*
|
|
* @param {number} current - Contagem de vulnerabilidades medida agora.
|
|
* @param {number} baseline - Contagem congelada em quality-baseline.json.
|
|
* @returns {{ regressed: boolean, improved: boolean }}
|
|
*/
|
|
export function evaluateVulnRatchet(current, baseline) {
|
|
return {
|
|
regressed: current > baseline,
|
|
improved: current < baseline,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Lê metrics.vulnCount.value do quality-baseline.json.
|
|
* Retorna null se o arquivo ou a métrica estiverem ausentes (sem baseline não há
|
|
* ratchet possível — o caller trata como SKIP gracioso, exit 0).
|
|
*
|
|
* @param {string} baselinePath
|
|
* @returns {number|null}
|
|
*/
|
|
export function readBaselineVulnValue(baselinePath = BASELINE_PATH) {
|
|
if (!fs.existsSync(baselinePath)) return null;
|
|
let baselineJson;
|
|
try {
|
|
baselineJson = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
const metric = baselineJson?.metrics?.vulnCount;
|
|
if (!metric || typeof metric.value !== "number") return null;
|
|
return metric.value;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Binary detection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Detecta se o binário `osv-scanner` está disponível no PATH.
|
|
* Usa `which` (Unix) sem interpolação de shell — Hard Rule #13.
|
|
*
|
|
* @returns {string|null} Caminho absoluto para o binário, ou null se ausente.
|
|
*/
|
|
export function findOsvScanner() {
|
|
try {
|
|
const result = spawnSync("which", ["osv-scanner"], {
|
|
encoding: "utf8",
|
|
timeout: 5_000,
|
|
});
|
|
if (result.status === 0) {
|
|
return result.stdout.trim();
|
|
}
|
|
} catch {
|
|
// which não disponível — tentar command -v via sh
|
|
}
|
|
|
|
// Fallback: tentar executar diretamente para verificar ENOENT
|
|
try {
|
|
const result = spawnSync("osv-scanner", ["--version"], {
|
|
encoding: "utf8",
|
|
timeout: 5_000,
|
|
});
|
|
if (result.error?.code === "ENOENT") return null;
|
|
if (result.status !== null) return "osv-scanner"; // found in PATH
|
|
} catch {
|
|
// noop
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Runner
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Executa o osv-scanner sobre o lockfile/diretório.
|
|
* Usa execFileSync sem shell interpolation (Hard Rule #13).
|
|
*
|
|
* Em uma falha de MEDIÇÃO (osv-scanner não produziu JSON — rede/osv.dev
|
|
* inacessível, timeout, JSON inválido) retorna { skip: true, reason } em vez de
|
|
* abortar o processo: o caller traduz isso num SKIP gracioso (exit 0 mesmo com
|
|
* --ratchet). Uma falha de medição NUNCA bloqueia; só uma regressão MEDIDA bloqueia.
|
|
*
|
|
* @param {string} osvBin - Caminho para o binário osv-scanner
|
|
* @returns {{ json: object } | { skip: true, reason: string }}
|
|
*/
|
|
function runOsvScanner(osvBin) {
|
|
const args = [
|
|
"--format", "json",
|
|
"--lockfile", path.join(ROOT, "package-lock.json"),
|
|
];
|
|
|
|
if (!QUIET) {
|
|
process.stderr.write("[vuln-ratchet] Rodando osv-scanner --format json ...\n");
|
|
}
|
|
|
|
let stdout;
|
|
try {
|
|
stdout = execFileSync(osvBin, args, {
|
|
cwd: ROOT,
|
|
encoding: "utf8",
|
|
maxBuffer: 32 * 1024 * 1024,
|
|
timeout: 120_000, // 2 min
|
|
});
|
|
} catch (err) {
|
|
// osv-scanner sai com código != 0 quando encontra vulnerabilidades;
|
|
// o JSON ainda vai no stdout.
|
|
stdout = err.stdout ? String(err.stdout) : "";
|
|
if (!stdout.trim()) {
|
|
process.stderr.write(`[vuln-ratchet] ERRO ao executar osv-scanner: ${err.message}\n`);
|
|
return { skip: true, reason: "osv-error" };
|
|
}
|
|
}
|
|
|
|
try {
|
|
return { json: JSON.parse(stdout) };
|
|
} catch (parseErr) {
|
|
process.stderr.write(`[vuln-ratchet] ERRO ao parsear JSON do osv-scanner: ${parseErr.message}\n`);
|
|
process.stderr.write(`[vuln-ratchet] stdout (primeiros 500 chars): ${stdout.slice(0, 500)}\n`);
|
|
return { skip: true, reason: "parse-error" };
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function main() {
|
|
const osvBin = findOsvScanner();
|
|
|
|
if (!osvBin) {
|
|
// Skip gracioso: binário ausente — esperado em ambientes sem osv-scanner instalado.
|
|
// SKIP sai 0 MESMO com --ratchet (binário ausente nunca bloqueia).
|
|
console.log("vulnCount=SKIP reason=binary-absent");
|
|
if (!QUIET) {
|
|
process.stderr.write(
|
|
"[vuln-ratchet] SKIP — osv-scanner não encontrado no PATH.\n" +
|
|
"[vuln-ratchet] Instale via: https://google.github.io/osv-scanner/\n" +
|
|
"[vuln-ratchet] SKIP gracioso — sai 0 mesmo com --ratchet (binário ausente nunca bloqueia).\n"
|
|
);
|
|
}
|
|
process.exitCode = 0;
|
|
return;
|
|
}
|
|
|
|
const osvResult = runOsvScanner(osvBin);
|
|
|
|
// Falha de MEDIÇÃO (rede/osv.dev inacessível, timeout, JSON inválido) → SKIP
|
|
// gracioso, sai 0 mesmo com --ratchet (uma falha de medição nunca bloqueia).
|
|
if (osvResult.skip) {
|
|
console.log(`vulnCount=SKIP reason=${osvResult.reason}`);
|
|
if (!QUIET) {
|
|
process.stderr.write(
|
|
`[vuln-ratchet] SKIP — osv-scanner não produziu uma medição (${osvResult.reason}).\n` +
|
|
"[vuln-ratchet] SKIP gracioso — sai 0 mesmo com --ratchet (falha de medição nunca bloqueia).\n"
|
|
);
|
|
}
|
|
process.exitCode = 0;
|
|
return;
|
|
}
|
|
|
|
const osvJson = osvResult.json;
|
|
|
|
if (PRINT_JSON) {
|
|
process.stdout.write(JSON.stringify(osvJson, null, 2) + "\n");
|
|
return;
|
|
}
|
|
|
|
const { vulnCount, bySeverity } = parseOsvJson(osvJson);
|
|
|
|
// Emitir em formato KEY=VALUE para o coletor de métricas (collect-metrics.mjs)
|
|
console.log(`vulnCount=${vulnCount}`);
|
|
|
|
if (!QUIET) {
|
|
const severitySummary = Object.entries(bySeverity)
|
|
.map(([k, v]) => `${k}=${v}`)
|
|
.join(", ") || "nenhuma";
|
|
process.stderr.write(
|
|
`[vuln-ratchet] Total de vulnerabilidades: ${vulnCount} (${severitySummary})\n`
|
|
);
|
|
}
|
|
|
|
// Medição bem-sucedida → aplica o ratchet (bloqueante só com --ratchet).
|
|
applyRatchet(vulnCount);
|
|
}
|
|
|
|
/**
|
|
* Aplica o ratchet (direction:down) sobre a contagem medida vs o baseline.
|
|
* Sem --ratchet: advisory (exit 0). Com --ratchet: exit 1 numa regressão real
|
|
* (medida > baseline). Baseline ausente → SKIP gracioso (exit 0).
|
|
*
|
|
* @param {number} vulnCount - Contagem MEDIDA (medição bem-sucedida).
|
|
*/
|
|
function applyRatchet(vulnCount) {
|
|
if (!RATCHET) {
|
|
if (!QUIET) {
|
|
process.stderr.write(
|
|
"[vuln-ratchet] ADVISORY — não falha pela contagem (passe --ratchet para bloquear regressão).\n"
|
|
);
|
|
}
|
|
process.exitCode = 0;
|
|
return;
|
|
}
|
|
|
|
const baselineValue = readBaselineVulnValue(BASELINE_PATH);
|
|
if (baselineValue === null) {
|
|
if (!QUIET) {
|
|
process.stderr.write(
|
|
"[vuln-ratchet] baseline ausente (metrics.vulnCount) — SKIP gracioso, sai 0.\n"
|
|
);
|
|
}
|
|
process.exitCode = 0;
|
|
return;
|
|
}
|
|
|
|
const { regressed } = evaluateVulnRatchet(vulnCount, baselineValue);
|
|
if (regressed) {
|
|
process.stderr.write(
|
|
`[vuln-ratchet] REGRESSÃO — ${vulnCount} vulnerabilidades > baseline ${baselineValue}\n` +
|
|
" → Bumpe a(s) dep(s) afetada(s) (preferível). Se não houver fix, re-baseline\n" +
|
|
" metrics.vulnCount em config/quality/quality-baseline.json com justificativa\n" +
|
|
" + issue de tracking. Ver docs/security/SUPPLY_CHAIN.md → 'Variância de CVE'.\n"
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (!QUIET) {
|
|
process.stderr.write(
|
|
`[vuln-ratchet] OK — sem regressão (${vulnCount} vulns, baseline ${baselineValue}).\n`
|
|
);
|
|
}
|
|
process.exitCode = 0;
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|