mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
OmniRoute v3.8.26 — see CHANGELOG.md [3.8.26] for the full notes. Highlights: Vertex AI media generation (#3929), GLM-5.2 effort-tier routing (#3885), sticky round-robin combos (#3846), OpenRouter connection presets (#3878), compression prompt-cache fix (#3936/#3890), and a security pass (form-data/vite + workflow hardening, #3949). Co-authored-by: artickc <artickc@users.noreply.github.com> Co-authored-by: rdself <rdself@users.noreply.github.com> Co-authored-by: herjarsa <herjarsa@users.noreply.github.com> Co-authored-by: Jack Smith <16862258+YunyunZhai@users.noreply.github.com> Co-authored-by: dhaern <dhaern@users.noreply.github.com> Co-authored-by: adivekar-utexas <adivekar-utexas@users.noreply.github.com> Co-authored-by: megamen32 <megamen32@users.noreply.github.com> Co-authored-by: zhiru <zhiru@users.noreply.github.com> Co-authored-by: insoln <insoln@users.noreply.github.com> Co-authored-by: diego-anselmo <diego-anselmo@users.noreply.github.com>
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();
|