431 lines
14 KiB
JavaScript
431 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
// scripts/check/check-codeql-ratchet.mjs
|
|
// Catraca de alertas CodeQL (Task 7.3 — Fase 7).
|
|
//
|
|
// Usa a GitHub API via `gh` CLI para buscar alertas de code-scanning abertos e
|
|
// não-dismissed (respeita Hard Rule #14: alertas dismissed não contam).
|
|
//
|
|
// Saída (stdout):
|
|
// codeqlAlerts=N — contagem de alertas CodeQL abertos, não-dismissed
|
|
// codeqlAlerts=SKIP reason=binary-absent — `gh` não está no PATH
|
|
// codeqlAlerts=SKIP reason=no-auth — `gh` presente mas sem autenticação
|
|
// codeqlAlerts=SKIP reason=api-error:<code> — erro da API GitHub
|
|
//
|
|
// RATCHET BLOQUEANTE (default): lê metrics.codeqlAlerts.value de
|
|
// config/quality/quality-baseline.json e SAI 1 SE — E SOMENTE SE — a contagem
|
|
// MEDIDA for MAIOR que o baseline (regressão real, mais alertas CodeQL abertos).
|
|
// Qualquer falha de MEDIÇÃO (gh ausente / sem auth / sem repo / erro de API) é um
|
|
// SKIP gracioso que SAI 0 — nunca bloqueia o build por falta de infraestrutura.
|
|
// Direction: down (a contagem só pode CAIR). Suporta --update para ratchetar.
|
|
//
|
|
// Uso:
|
|
// node scripts/check/check-codeql-ratchet.mjs
|
|
// node scripts/check/check-codeql-ratchet.mjs --json # imprime array de alertas
|
|
// node scripts/check/check-codeql-ratchet.mjs --quiet # suprime logs de diagnóstico
|
|
// node scripts/check/check-codeql-ratchet.mjs --update # ratcheta o baseline (queda)
|
|
// node scripts/check/check-codeql-ratchet.mjs --advisory # nunca falha (modo coletor)
|
|
|
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
const QUIET = process.argv.includes("--quiet");
|
|
const PRINT_JSON = process.argv.includes("--json");
|
|
const UPDATE = process.argv.includes("--update");
|
|
// --advisory: nunca falha pela contagem (modo coletor legado). Sem esta flag o
|
|
// gate é BLOQUEANTE: sai 1 numa regressão real (medida > baseline).
|
|
const ADVISORY = process.argv.includes("--advisory");
|
|
|
|
const ROOT = process.cwd();
|
|
const BASELINE_PATH = path.resolve(
|
|
process.argv.includes("--baseline")
|
|
? process.argv[process.argv.indexOf("--baseline") + 1]
|
|
: path.join(ROOT, "config/quality/quality-baseline.json")
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pure parsing function (exported for tests)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Conta alertas CodeQL abertos e não-dismissed a partir do JSON da GitHub API.
|
|
*
|
|
* A GitHub API /code-scanning/alerts retorna um array de:
|
|
* {
|
|
* number: number,
|
|
* state: "open" | "dismissed" | "fixed",
|
|
* dismissed_reason: string | null,
|
|
* dismissed_at: string | null,
|
|
* tool: { name: string, ... },
|
|
* rule: { id: string, severity: string, security_severity_level?: string, ... },
|
|
* ...
|
|
* }
|
|
*
|
|
* Hard Rule #14: alertas com `state="dismissed"` NÃO contam, independente da razão.
|
|
* Filtramos por state="open" E tool.name contendo "CodeQL" (case-insensitive).
|
|
* Alertas de outras ferramentas (ex: Semgrep) são ignorados.
|
|
*
|
|
* @param {Array|null} alerts - Array de alertas da API GitHub
|
|
* @returns {{ alertCount: number, bySeverity: Record<string, number>, byRule: Record<string, number> }}
|
|
*/
|
|
export function parseCodeQLAlerts(alerts) {
|
|
if (!Array.isArray(alerts)) {
|
|
return { alertCount: 0, bySeverity: {}, byRule: {} };
|
|
}
|
|
|
|
let alertCount = 0;
|
|
const bySeverity = {};
|
|
const byRule = {};
|
|
|
|
for (const alert of alerts) {
|
|
// Ignorar alertas não-CodeQL (outras ferramentas de code scanning)
|
|
const toolName = alert?.tool?.name ?? "";
|
|
if (!toolName.toLowerCase().includes("codeql")) continue;
|
|
|
|
// Hard Rule #14: alertas dismissed não contam
|
|
if (alert.state === "dismissed") continue;
|
|
|
|
// Só alertas abertos
|
|
if (alert.state !== "open") continue;
|
|
|
|
alertCount++;
|
|
|
|
// Coletar por severidade (security_severity_level ou severity da rule)
|
|
const severity = (
|
|
alert?.rule?.security_severity_level ??
|
|
alert?.rule?.severity ??
|
|
"unknown"
|
|
).toLowerCase();
|
|
bySeverity[severity] = (bySeverity[severity] ?? 0) + 1;
|
|
|
|
// Coletar por rule ID
|
|
const ruleId = alert?.rule?.id ?? "unknown";
|
|
byRule[ruleId] = (byRule[ruleId] ?? 0) + 1;
|
|
}
|
|
|
|
return { alertCount, bySeverity, byRule };
|
|
}
|
|
|
|
/**
|
|
* Avalia a contagem MEDIDA de alertas CodeQL contra o baseline.
|
|
* Direction: down (a contagem só pode CAIR — mais alertas = regressão).
|
|
*
|
|
* Exported for unit testing — espelha evaluateDeadCode em check-dead-code.mjs.
|
|
*
|
|
* @param {number} current - Contagem de alertas medida agora.
|
|
* @param {number} baseline - Contagem congelada em quality-baseline.json.
|
|
* @returns {{ regressed: boolean, improved: boolean }}
|
|
*/
|
|
export function evaluateCodeqlRatchet(current, baseline) {
|
|
return {
|
|
regressed: current > baseline,
|
|
improved: current < baseline,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Repository detection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Detecta o owner/repo do repositório atual usando `gh repo view`.
|
|
* Retorna null se `gh` não estiver disponível ou não autenticado.
|
|
*
|
|
* @param {string} ghBin - Caminho para o binário gh
|
|
* @returns {string|null} "owner/repo" ou null
|
|
*/
|
|
export function detectRepo(ghBin) {
|
|
try {
|
|
const stdout = execFileSync(
|
|
ghBin,
|
|
["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"],
|
|
{
|
|
encoding: "utf8",
|
|
timeout: 15_000,
|
|
}
|
|
);
|
|
return stdout.trim() || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Binary detection
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Detecta se o binário `gh` 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 findGhCli() {
|
|
try {
|
|
const result = spawnSync("which", ["gh"], {
|
|
encoding: "utf8",
|
|
timeout: 5_000,
|
|
});
|
|
if (result.status === 0) {
|
|
return result.stdout.trim();
|
|
}
|
|
} catch {
|
|
// which não disponível
|
|
}
|
|
|
|
// Fallback: tentar executar diretamente para verificar ENOENT
|
|
try {
|
|
const result = spawnSync("gh", ["--version"], {
|
|
encoding: "utf8",
|
|
timeout: 5_000,
|
|
});
|
|
if (result.error?.code === "ENOENT") return null;
|
|
if (result.status !== null) return "gh"; // found in PATH
|
|
} catch {
|
|
// noop
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// API caller
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Busca alertas CodeQL abertos via `gh api`.
|
|
* Pagina automaticamente (GitHub retorna max 100 por página).
|
|
*
|
|
* @param {string} ghBin - Caminho para o binário gh
|
|
* @param {string} repo - "owner/repo"
|
|
* @returns {Array} Array de alertas
|
|
*/
|
|
function fetchCodeQLAlerts(ghBin, repo) {
|
|
const allAlerts = [];
|
|
let page = 1;
|
|
const perPage = 100;
|
|
|
|
while (true) {
|
|
const endpoint = `/repos/${repo}/code-scanning/alerts?state=open&tool_name=CodeQL&per_page=${perPage}&page=${page}`;
|
|
|
|
if (!QUIET) {
|
|
process.stderr.write(`[codeql-ratchet] Buscando alertas: página ${page} ...\n`);
|
|
}
|
|
|
|
let stdout;
|
|
try {
|
|
stdout = execFileSync(ghBin, ["api", endpoint], {
|
|
encoding: "utf8",
|
|
timeout: 30_000,
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
});
|
|
} catch (err) {
|
|
const errMsg = String(err.stderr ?? err.message ?? "");
|
|
|
|
// Sem autenticação
|
|
if (
|
|
errMsg.includes("authentication") ||
|
|
errMsg.includes("401") ||
|
|
errMsg.includes("not logged")
|
|
) {
|
|
return { error: "no-auth", message: errMsg };
|
|
}
|
|
|
|
// Rate limit ou outro erro HTTP
|
|
const codeMatch = /HTTP (\d{3})/.exec(errMsg);
|
|
const code = codeMatch ? codeMatch[1] : "unknown";
|
|
return { error: `api-error:${code}`, message: errMsg };
|
|
}
|
|
|
|
let page_alerts;
|
|
try {
|
|
page_alerts = JSON.parse(stdout);
|
|
} catch (parseErr) {
|
|
// A malformed (but HTTP-200) API response is a MEASUREMENT failure, not a
|
|
// regression. A blocking gate must never red on it — return the same
|
|
// {error,message} shape the caller already maps to a graceful SKIP (exit 0).
|
|
return { error: "parse-error", message: String(parseErr.message ?? parseErr) };
|
|
}
|
|
|
|
// A API retorna null quando não há mais páginas (ou array vazio)
|
|
if (!Array.isArray(page_alerts) || page_alerts.length === 0) break;
|
|
|
|
allAlerts.push(...page_alerts);
|
|
|
|
// Se retornou menos que perPage, chegamos à última página
|
|
if (page_alerts.length < perPage) break;
|
|
|
|
page++;
|
|
}
|
|
|
|
return allAlerts;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Baseline
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Lê metrics.codeqlAlerts.value do quality-baseline.json.
|
|
* Retorna null se o arquivo ou a métrica estiverem ausentes (modo coletor puro:
|
|
* sem baseline não há ratchet, só emissão da contagem).
|
|
*
|
|
* @returns {number|null}
|
|
*/
|
|
function readBaselineCodeqlValue() {
|
|
if (!fs.existsSync(BASELINE_PATH)) return null;
|
|
let baselineJson;
|
|
try {
|
|
baselineJson = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
const metric = baselineJson?.metrics?.codeqlAlerts;
|
|
if (!metric || typeof metric.value !== "number") return null;
|
|
return metric.value;
|
|
}
|
|
|
|
/**
|
|
* Aplica o ratchet (direction:down) sobre a contagem medida vs o baseline.
|
|
* Define process.exitCode = 1 numa regressão real (medida > baseline) salvo
|
|
* --advisory. Ratcheta o baseline com --update quando a contagem cai.
|
|
*
|
|
* Exported for unit testing (drives o efeito em process.exitCode).
|
|
*
|
|
* @param {number} alertCount - Contagem MEDIDA (medição bem-sucedida).
|
|
*/
|
|
export function applyRatchet(alertCount) {
|
|
const baselineValue = readBaselineCodeqlValue();
|
|
|
|
// Sem baseline → modo coletor puro (emite a contagem, não falha).
|
|
if (baselineValue === null) {
|
|
if (!QUIET) {
|
|
process.stderr.write(
|
|
"[codeql-ratchet] baseline ausente (metrics.codeqlAlerts) — modo coletor, sem ratchet.\n"
|
|
);
|
|
}
|
|
process.exitCode = 0;
|
|
return;
|
|
}
|
|
|
|
const { regressed, improved } = evaluateCodeqlRatchet(alertCount, baselineValue);
|
|
|
|
if (UPDATE && improved) {
|
|
const baselineJson = JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
|
|
baselineJson.metrics.codeqlAlerts.value = alertCount;
|
|
fs.writeFileSync(BASELINE_PATH, JSON.stringify(baselineJson, null, 2) + "\n");
|
|
console.log(`[codeql-ratchet] baseline ratcheado: ${alertCount} (era ${baselineValue})`);
|
|
}
|
|
|
|
if (regressed && !ADVISORY) {
|
|
process.stderr.write(
|
|
`[codeql-ratchet] REGRESSÃO — ${alertCount} alertas CodeQL abertos > baseline ${baselineValue}\n` +
|
|
" → Corrija os novos alertas em Security → Code scanning, ou rode\n" +
|
|
" 'node scripts/check/check-codeql-ratchet.mjs --update' se a contagem caiu legitimamente.\n"
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
if (!QUIET) {
|
|
const verdict = regressed ? "ADVISORY — regressão ignorada (--advisory)" : "OK — sem regressão";
|
|
process.stderr.write(
|
|
`[codeql-ratchet] ${verdict} — ${alertCount} alertas (baseline ${baselineValue})\n`
|
|
);
|
|
}
|
|
process.exitCode = 0;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function main() {
|
|
const ghBin = findGhCli();
|
|
|
|
if (!ghBin) {
|
|
console.log("codeqlAlerts=SKIP reason=binary-absent");
|
|
if (!QUIET) {
|
|
process.stderr.write(
|
|
"[codeql-ratchet] SKIP — `gh` CLI não encontrado no PATH.\n" +
|
|
"[codeql-ratchet] Instale via: https://cli.github.com/\n" +
|
|
"[codeql-ratchet] ADVISORY — este gate sai 0 (ratchet entra no CI da Fase 7 INT).\n"
|
|
);
|
|
}
|
|
process.exitCode = 0;
|
|
return;
|
|
}
|
|
|
|
// Detectar repositório
|
|
const repo = detectRepo(ghBin);
|
|
if (!repo) {
|
|
console.log("codeqlAlerts=SKIP reason=no-repo");
|
|
if (!QUIET) {
|
|
process.stderr.write(
|
|
"[codeql-ratchet] SKIP — não foi possível detectar o repositório GitHub.\n" +
|
|
"[codeql-ratchet] Execute dentro de um repositório GitHub com `gh` autenticado.\n"
|
|
);
|
|
}
|
|
process.exitCode = 0;
|
|
return;
|
|
}
|
|
|
|
if (!QUIET) {
|
|
process.stderr.write(`[codeql-ratchet] Repositório detectado: ${repo}\n`);
|
|
}
|
|
|
|
// Buscar alertas
|
|
const result = fetchCodeQLAlerts(ghBin, repo);
|
|
|
|
// Tratar erros da API com skip gracioso
|
|
if (!Array.isArray(result)) {
|
|
const { error, message } = result;
|
|
console.log(`codeqlAlerts=SKIP reason=${error}`);
|
|
if (!QUIET) {
|
|
process.stderr.write(
|
|
`[codeql-ratchet] SKIP — erro ao consultar API GitHub: ${message.slice(0, 200)}\n`
|
|
);
|
|
}
|
|
process.exitCode = 0;
|
|
return;
|
|
}
|
|
|
|
if (PRINT_JSON) {
|
|
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
return;
|
|
}
|
|
|
|
const { alertCount, bySeverity, byRule } = parseCodeQLAlerts(result);
|
|
|
|
// Emitir em formato KEY=VALUE para o coletor de métricas (collect-metrics.mjs)
|
|
console.log(`codeqlAlerts=${alertCount}`);
|
|
|
|
if (!QUIET) {
|
|
const severitySummary =
|
|
Object.entries(bySeverity)
|
|
.map(([k, v]) => `${k}=${v}`)
|
|
.join(", ") || "nenhum";
|
|
const topRules =
|
|
Object.entries(byRule)
|
|
.sort(([, a], [, b]) => b - a)
|
|
.slice(0, 5)
|
|
.map(([r, n]) => `${r}(${n})`)
|
|
.join(", ") || "nenhum";
|
|
|
|
process.stderr.write(
|
|
`[codeql-ratchet] Alertas CodeQL abertos (não-dismissed): ${alertCount}\n`
|
|
);
|
|
if (alertCount > 0) {
|
|
process.stderr.write(`[codeql-ratchet] Por severidade: ${severitySummary}\n`);
|
|
process.stderr.write(`[codeql-ratchet] Top regras: ${topRules}\n`);
|
|
}
|
|
}
|
|
|
|
// Medição bem-sucedida → aplica o ratchet (bloqueante salvo --advisory).
|
|
// Qualquer falha de MEDIÇÃO acima já retornou com exit 0 (skip gracioso).
|
|
applyRatchet(alertCount);
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();
|