Files
2026-07-13 13:39:12 +08:00

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();