mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 12:22:14 +03:00
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).
284 lines
9.7 KiB
JavaScript
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();
|