Spaces:
Runtime error
Runtime error
| // 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(); | |