#!/usr/bin/env node // scripts/check/check-secrets.mjs // Catraca de secret scanning via gitleaks (Task 7.18 — Fase 7). // // Complementa `check-public-creds.mjs` (Fase 6, cobre credenciais OAuth públicas // conhecidas em 2 arquivos específicos): este gate pega a classe geral de secrets — // `const API_KEY = "sk-…"`, tokens em config/teste/docs, secrets em histórico. // // Saída (stdout): // secretFindings=N — número de findings do gitleaks // secretFindings=SKIP reason=binary-absent — gitleaks não está no PATH // // Por default é ADVISORY (sai 0 sempre). Passe --ratchet para tornar BLOQUEANTE: // lê metrics.secretFindings.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, nenhum // dir de fonte) SAI 0 mesmo com --ratchet — falta de infraestrutura nunca bloqueia, // só uma regressão medida bloqueia. // // Uso: // node scripts/check/check-secrets.mjs // node scripts/check/check-secrets.mjs --json # imprime JSON bruto do gitleaks // node scripts/check/check-secrets.mjs --quiet # suprime logs de diagnóstico // node scripts/check/check-secrets.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 GITLEAKS_CONFIG = path.join(ROOT, ".gitleaks.toml"); const BASELINE_PATH = path.join(ROOT, "config/quality/quality-baseline.json"); // Source directories to scan for secrets. We deliberately scope to the // production/source trees instead of scanning the whole working dir: // • `gitleaks dir .` (and `detect --no-git --source .`) WALKS the entire tree // and READS every file — including a real `node_modules/` (90k+ files) when // present (CI runs `npm ci`). gitleaks has no traversal-exclude flag: the // `.gitleaks.toml [allowlist].paths` list filters FINDINGS *after* each file // is read, so it does NOT speed up the walk. The full walk blows past the // timeout in CI (confirmed: ETIMEDOUT) → the gate silently never produces a // value. Scoping the scan to the source dirs keeps it fast (~6s) while still // covering every place an embedded secret would actually be a risk (the same // dirs Hard Rule #8 governs: src/open-sse/electron/bin, plus scripts/). // • We also drop git-history mode (scanning 4500+ commits is slow and grows // unbounded); the current working tree is what ships. const SECRET_SCAN_DIRS = ["src", "open-sse", "bin", "electron", "scripts"]; // --------------------------------------------------------------------------- // Pure parsing function (exported for tests) // --------------------------------------------------------------------------- /** * Conta findings no JSON emitido por `gitleaks detect --report-format json`. * * O gitleaks emite um array de findings (ou array vazio / null quando limpo): * [ * { * Description: string, * StartLine: number, * EndLine: number, * Match: string, // valor mascarado ou trecho * Secret: string, // valor mascarado * File: string, // caminho relativo * Commit: string, * Entropy: number, * Author: string, * Email: string, * Date: string, * Tags: string[], * RuleID: string, * Fingerprint: string * }, * ... * ] * * @param {Array|null} gitleaksJson - Array de findings do gitleaks (ou null) * @returns {{ findingCount: number, byRule: Record, byFile: Record }} */ export function parseGitleaksJson(gitleaksJson) { // null ou array vazio = nenhum finding if (gitleaksJson === null || (Array.isArray(gitleaksJson) && gitleaksJson.length === 0)) { return { findingCount: 0, byRule: {}, byFile: {} }; } if (!Array.isArray(gitleaksJson)) { return { findingCount: 0, byRule: {}, byFile: {} }; } let findingCount = 0; const byRule = {}; const byFile = {}; for (const finding of gitleaksJson) { if (!finding || typeof finding !== "object") continue; findingCount++; // Agrupar por RuleID (gitleaks usa PascalCase) const ruleId = finding.RuleID ?? finding.ruleId ?? "unknown"; byRule[ruleId] = (byRule[ruleId] ?? 0) + 1; // Agrupar por arquivo const file = finding.File ?? finding.file ?? "unknown"; byFile[file] = (byFile[file] ?? 0) + 1; } return { findingCount, byRule, byFile }; } // --------------------------------------------------------------------------- // Ratchet (direction:down) — exported for tests // --------------------------------------------------------------------------- /** * Avalia a contagem MEDIDA de secrets contra o baseline. * Direction: down (a contagem só pode CAIR — mais secrets = regressão). * * @param {number} current - Contagem de findings medida agora. * @param {number} baseline - Contagem congelada em quality-baseline.json. * @returns {{ regressed: boolean, improved: boolean }} */ export function evaluateSecretsRatchet(current, baseline) { return { regressed: current > baseline, improved: current < baseline, }; } /** * Lê metrics.secretFindings.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 readBaselineSecretsValue(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?.secretFindings; if (!metric || typeof metric.value !== "number") return null; return metric.value; } // --------------------------------------------------------------------------- // Binary detection // --------------------------------------------------------------------------- /** * Detecta se o binário `gitleaks` está disponível no PATH. * Usa `which` (Unix) sem interpolação de shell — Hard Rule #13. * * @returns {string|null} Caminho para o binário, ou null se ausente. */ export function findGitleaks() { try { const result = spawnSync("which", ["gitleaks"], { 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("gitleaks", ["version"], { encoding: "utf8", timeout: 5_000, }); if (result.error?.code === "ENOENT") return null; if (result.status !== null) return "gitleaks"; // encontrado no PATH } catch { // noop } return null; } // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- function main() { const gitleaksBin = findGitleaks(); if (!gitleaksBin) { console.log("secretFindings=SKIP reason=binary-absent"); if (!QUIET) { process.stderr.write( "[check-secrets] SKIP — gitleaks não encontrado no PATH.\n" + "[check-secrets] Instale via: https://github.com/gitleaks/gitleaks\n" + "[check-secrets] SKIP gracioso — sai 0 mesmo com --ratchet (binário ausente nunca bloqueia).\n" ); } process.exitCode = 0; return; } // Resolver os diretórios de fonte que realmente existem (robusto se um sumir). const scanDirs = SECRET_SCAN_DIRS.filter((d) => fs.existsSync(path.join(ROOT, d))); if (scanDirs.length === 0) { // Nenhum dir de fonte encontrado — nada a escanear (advisory, sai 0). console.log("secretFindings=0"); if (!QUIET) { process.stderr.write("[check-secrets] Nenhum diretório de fonte encontrado para escanear.\n"); } process.exitCode = 0; return; } if (!QUIET) { process.stderr.write( `[check-secrets] Rodando gitleaks dir --report-format json para: ${scanDirs.join(", ")} ...\n` ); } // `gitleaks dir` aceita UM ÚNICO path posicional (uso: `gitleaks dir [flags] // [path]`). Passar múltiplos paths faz o gitleaks ignorar os extras e cair para // escanear o CWD inteiro (`.`) — o que re-traz node_modules/docs/tests e o // timeout original. Por isso escaneamos CADA diretório de fonte em uma invocação // separada e concatenamos os findings. const gitleaksJson = []; for (const dir of scanDirs) { const args = [ "dir", dir, "--report-format", "json", "--report-path", "-", // output para stdout "--no-banner", ]; if (fs.existsSync(GITLEAKS_CONFIG)) { args.push("--config", GITLEAKS_CONFIG); } let stdout = ""; try { stdout = execFileSync(gitleaksBin, args, { cwd: ROOT, encoding: "utf8", maxBuffer: 32 * 1024 * 1024, timeout: 90_000, // 90s por dir — o scan escopado completa em ~10s; folga ampla }); } catch (err) { // exit 1 com stdout = findings encontrados (comportamento esperado do gitleaks) stdout = err.stdout ? String(err.stdout) : ""; const stderr = err.stderr ? String(err.stderr) : ""; if (err.status === 1 && stdout.trim()) { // Normal: gitleaks achou findings neste dir e saiu com exit 1 } else if (!stdout.trim()) { process.stderr.write( `[check-secrets] ERRO ao executar gitleaks em '${dir}': ${err.message}\n` ); if (stderr) process.stderr.write(`[check-secrets] stderr: ${stderr.slice(0, 500)}\n`); process.exit(2); } } if (!stdout.trim() || stdout.trim() === "null") { continue; // sem findings neste dir } let parsed; try { parsed = JSON.parse(stdout.trim()); } catch (parseErr) { process.stderr.write( `[check-secrets] ERRO ao parsear JSON do gitleaks em '${dir}': ${parseErr.message}\n` ); process.stderr.write( `[check-secrets] stdout (primeiros 500 chars): ${stdout.slice(0, 500)}\n` ); process.exit(2); } if (Array.isArray(parsed)) { gitleaksJson.push(...parsed); } } if (PRINT_JSON) { process.stdout.write(JSON.stringify(gitleaksJson, null, 2) + "\n"); return; } const { findingCount, byRule, byFile } = parseGitleaksJson(gitleaksJson); // Emitir em formato KEY=VALUE para o coletor de métricas (collect-metrics.mjs) console.log(`secretFindings=${findingCount}`); if (!QUIET) { if (findingCount > 0) { const topRules = Object.entries(byRule) .sort(([, a], [, b]) => b - a) .slice(0, 5) .map(([r, n]) => `${r}(${n})`) .join(", "); process.stderr.write(`[check-secrets] Findings: ${findingCount} (top rules: ${topRules})\n`); process.stderr.write( "[check-secrets] Para allowlistar findings legítimos (fixtures de teste, creds públicas),\n" + "[check-secrets] adicione entradas em .gitleaks.toml [[allowlist]] com comentário.\n" ); } else { process.stderr.write("[check-secrets] Nenhum finding detectado.\n"); } } // Medição bem-sucedida → aplica o ratchet (bloqueante só com --ratchet). applyRatchet(findingCount); } /** * 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} findingCount - Contagem MEDIDA (medição bem-sucedida). */ function applyRatchet(findingCount) { if (!RATCHET) { if (!QUIET) { process.stderr.write( "[check-secrets] ADVISORY — não falha pela contagem (passe --ratchet para bloquear regressão).\n" ); } process.exitCode = 0; return; } const baselineValue = readBaselineSecretsValue(BASELINE_PATH); if (baselineValue === null) { if (!QUIET) { process.stderr.write( "[check-secrets] baseline ausente (metrics.secretFindings) — SKIP gracioso, sai 0.\n" ); } process.exitCode = 0; return; } const { regressed } = evaluateSecretsRatchet(findingCount, baselineValue); if (regressed) { process.stderr.write( `[check-secrets] REGRESSÃO — ${findingCount} secret findings > baseline ${baselineValue}\n` + " → Remova o novo secret (ou allowliste em .gitleaks.toml se for falso-positivo legítimo),\n" + " depois re-baseline metrics.secretFindings em config/quality/quality-baseline.json.\n" ); process.exitCode = 1; return; } if (!QUIET) { process.stderr.write( `[check-secrets] OK — sem regressão (${findingCount} findings, baseline ${baselineValue}).\n` ); } process.exitCode = 0; } if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();