Files
OmniRoute/scripts/check/check-secrets.mjs
Diego Rodrigues de Sa e Souza 8981b322d7 ci(quality): make zizmor/gitleaks/osv scanners functional + freeze advisory baselines (#3947)
Three CI security gates in the quality-extended job never produced a value;
diagnose + fix each, then freeze the real measured numbers as advisory ratchet
baselines (dedicatedGate => SKIP in the blocking quality-gate ratchet).

FIX 1 — .zizmor.yml: migrate the config from the pre-1.0 'ignores: []' schema to
the 'rules: {}' schema. zizmor 1.25.2 rejected the old field ('unknown field
`ignores`, expected `rules`') and performed NO audit. Now check:workflows emits
zizmorFindings=195.

FIX 2 — scripts/check/check-secrets.mjs: the gate ran 'gitleaks detect --no-git
--source .', which walks the WHOLE tree including a real node_modules/ (90k+ files
under npm ci) and times out (ETIMEDOUT) — gitleaks has no traversal-exclude flag
(.gitleaks.toml paths filter findings AFTER reading). Scope the scan to the source
dirs (src/open-sse/bin/electron/scripts), one 'gitleaks dir <dir>' invocation each
('gitleaks dir' takes a single path; multiple args fall back to scanning the CWD).
Also fix .gitleaks.toml: it lacked [extend].useDefault=true, so the custom config
REPLACED the default ruleset with zero rules and detected nothing — the gate always
reported 0 regardless of real secrets. Now: ~10s (was 120s timeout), secretFindings=3
(generic-api-key false positives in beta-header strings / column names).

FIX 3 — .github/workflows/ci.yml: the scanner install resolved release URLs via
unauthenticated api.github.com (60 req/hr/IP; returns empty when throttled -> silent
no-op install -> every gate self-skips). Switch gitleaks + osv-scanner to 'gh release
download' (preinstalled + GITHUB_TOKEN-authed, 5000 req/hr); add GH_TOKEN to the step
env. actionlint/zizmor install paths unchanged.

MEASURE + FREEZE (advisory, dedicatedGate:true, direction down) in
config/quality/quality-baseline.json: secretFindings=3, zizmorFindings=195,
vulnCount=13 (LOW=4/MOD=7/HIGH=2), bundleSize=5601. Seeded from a local run with the
real binaries on PATH (2026-06-15). They stay advisory (SKIP in the blocking ratchet;
quality-extended is continue-on-error) until a green CI run confirms the fixed tooling
produces values; the flip to blocking is a follow-up PR. continue-on-error untouched.

Validated locally: zizmor --config parses; check:secrets <60s + real count;
check:workflows/check:vuln-ratchet emit real numbers; ci.yml actionlint-clean; baseline
JSON valid; 103 build-scanner unit tests + 19 check-secrets + 18 quality-ratchet pass;
the 4 keys SKIP in the ratchet. FIX 3 logic is sound but CI-only (cannot run gh release
download against the runner locally).
2026-06-15 22:45:48 -03:00

284 lines
9.7 KiB
JavaScript

#!/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
//
// Esta versão é ADVISORY (sai 0 sempre). O ratchet (direction:down) é gerenciado
// pelo motor quality-baseline.json no CI (Task 7.18 INT).
//
// 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
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 GITLEAKS_CONFIG = path.join(ROOT, ".gitleaks.toml");
// 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<string, number>, byFile: Record<string, number> }}
*/
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 };
}
// ---------------------------------------------------------------------------
// 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] ADVISORY — este gate sai 0 (ratchet entra no CI da Fase 7 INT).\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 <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");
}
process.stderr.write(
"[check-secrets] ADVISORY — esta versão não falha pela contagem (ratchet entra no CI).\n"
);
}
// Sai 0 sempre nesta versão (advisory)
process.exitCode = 0;
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();