diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 259a52e5d1..83e2c77103 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -162,26 +162,35 @@ jobs: # CodeQL ratchet foi PROMOVIDO a BLOQUEANTE no job quality-gate (v3.8.26) — # não roda aqui para evitar duplo run/duplo report. # Install the advisory security scanners so the gates below actually run - # (they self-skip when the binaries are absent). Robustness over `go install`: - # `go install github.com/gitleaks/gitleaks/v8@latest` produces a binary WITHOUT - # the version ldflags gitleaks needs (and often fails) — under `bash -e` that - # aborts the whole step before the PATH export, so NO scanner lands on PATH and - # every check self-skips. Here we `set +e` (no single failure aborts the step), - # install from official RELEASE DOWNLOADS, ALWAYS export $GITHUB_PATH at the end, - # and print diagnostics so the next CI run proves exactly what installed. The job - # is continue-on-error too, so an install hiccup never blocks the build. + # (they self-skip when the binaries are absent). Robustness lessons baked in: + # • `go install …/gitleaks/v8@latest` produces a binary WITHOUT the version + # ldflags gitleaks needs (and often fails) — avoided. + # • `curl …api.github.com/…/releases/latest` is UNAUTHENTICATED and + # rate-limited to 60 req/hr/IP; when throttled it returns an empty body, + # so the asset URL resolves to nothing and the install silently no-ops — + # every gate then self-skips and the metric is never produced. We instead + # use `gh release download`, which is preinstalled on GitHub runners and + # authenticated via GITHUB_TOKEN (5000 req/hr) — robust under load. + # • actionlint keeps its official download script; zizmor stays on pipx. + # We `set +e` (no single failure aborts the step), ALWAYS export $GITHUB_PATH + # at the end, and print diagnostics so the next CI run proves exactly what + # installed. The job is continue-on-error too, so an install hiccup never + # blocks the build. - name: Install advisory security scanners (gitleaks/osv/actionlint/zizmor) continue-on-error: true + env: + GH_TOKEN: ${{ github.token }} run: | set +e mkdir -p "$HOME/.local/bin" - # gitleaks — resolve latest linux x64 tarball, extract just the binary - GL=$(curl -fsSL https://api.github.com/repos/gitleaks/gitleaks/releases/latest | grep -oE 'https://[^"]+linux_x64\.tar\.gz' | head -1) - curl -fsSL "$GL" | tar -xz -C "$HOME/.local/bin" gitleaks - # osv-scanner — resolve latest linux amd64 binary - OSV=$(curl -fsSL https://api.github.com/repos/google/osv-scanner/releases/latest | grep -oE 'https://[^"]+linux_amd64' | head -1) - curl -fsSL -o "$HOME/.local/bin/osv-scanner" "$OSV" - chmod +x "$HOME/.local/bin/osv-scanner" + # gitleaks — download latest linux x64 tarball via gh (authed), extract binary + rm -rf /tmp/gl && mkdir -p /tmp/gl + gh release download --repo gitleaks/gitleaks --pattern '*linux_x64.tar.gz' --dir /tmp/gl + tar -xzf /tmp/gl/*linux_x64.tar.gz -C "$HOME/.local/bin" gitleaks + # osv-scanner — download latest linux amd64 bare binary via gh (authed) + rm -rf /tmp/osv && mkdir -p /tmp/osv + gh release download --repo google/osv-scanner --pattern '*linux_amd64' --dir /tmp/osv + install -m 0755 /tmp/osv/*linux_amd64 "$HOME/.local/bin/osv-scanner" # actionlint — official download script bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) latest "$HOME/.local/bin" # zizmor — PyPI (pipx preferred, pip --user fallback); lands in ~/.local/bin diff --git a/.gitleaks.toml b/.gitleaks.toml index 9e221be280..0051e694b2 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -15,7 +15,15 @@ # Referência: docs/security/PUBLIC_CREDS.md (credenciais OAuth públicas conhecidas) # CLAUDE.md Hard Rule #11 (resolvePublicCred obrigatório) -# Usar as regras padrão do gitleaks (não declaramos [rules] aqui para herdar tudo) +# Herdar TODAS as regras padrão do gitleaks. ATENÇÃO: um config customizado +# SEM [extend].useDefault = true (e sem [[rules]] próprias) resulta em ZERO +# regras — o gitleaks SUBSTITUI o ruleset padrão pelo arquivo, não o estende +# automaticamente. Sem esta seção, `gitleaks --config .gitleaks.toml` nunca +# detecta nada (todo finding vira 0), tornando o gate inerte. Com useDefault, +# a allowlist abaixo é aplicada POR CIMA das ~170 regras padrão. +[extend] + useDefault = true + # Para desabilitar uma regra específica, usar: # [[rules]] # id = "rule-id" diff --git a/.zizmor.yml b/.zizmor.yml index 83eefa7d85..139183e7dc 100644 --- a/.zizmor.yml +++ b/.zizmor.yml @@ -30,22 +30,30 @@ # Uncomment to pin to a specific minimum severity level. # min-severity: low # low | medium | high | critical (default: low = all) -# ── Per-finding ignores ────────────────────────────────────────────────────── +# ── Per-rule ignores ───────────────────────────────────────────────────────── +# zizmor ≥1.0 replaced the old top-level `ignores:` list with a `rules:` map +# keyed by audit id. Per-rule config lives under `rules..ignore`, +# where each entry is `filename` or `filename:line` (line optional, column +# further-optional). See: https://docs.zizmor.sh/configuration/ +# # Format: -# ignores: -# - id: # e.g. "unpinned-uses", "script-injection" -# reason: "" -# # Optional: scope to a specific workflow or step -# # workflow: .github/workflows/ci.yml +# rules: +# : # e.g. "unpinned-uses", "template-injection" +# ignore: +# - # ignore this audit across the file +# - : # or scope to a specific line # # Example (do not uncomment without real justification): # -# ignores: -# - id: unpinned-uses -# reason: > -# actions/checkout@v6 is pinned at the major-version tag intentionally: -# GitHub-managed first-party action; SHA pinning buys little against a -# GitHub-side compromise and adds significant maintenance burden. -# workflow: .github/workflows/ci.yml +# rules: +# unpinned-uses: +# # actions/checkout@v6 is pinned at the major-version tag intentionally: +# # GitHub-managed first-party action; SHA pinning buys little against a +# # GitHub-side compromise and adds significant maintenance burden. +# ignore: +# - ci.yml -ignores: [] +# BASELINE: empty rules map = nothing ignored. Every future ignore requires an +# explicit per-rule entry above with a justification comment (same stale-review +# discipline as the other allowlists in this project). +rules: {} diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index 6f81594a19..e7873871f5 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -109,6 +109,26 @@ "value": 0, "direction": "down", "dedicatedGate": true + }, + "secretFindings": { + "value": 3, + "direction": "down", + "dedicatedGate": true + }, + "zizmorFindings": { + "value": 195, + "direction": "down", + "dedicatedGate": true + }, + "vulnCount": { + "value": 13, + "direction": "down", + "dedicatedGate": true + }, + "bundleSize": { + "value": 5601, + "direction": "down", + "dedicatedGate": true } }, "_coverage_note": "Pisos anti-flake ~2pt abaixo do real do CI mergeado MEDIDO COM os 135 testes religados (run 27247237268: statements 78.4 / lines 78.4 / functions 83.84 / branches 75.73). O religamento da 6A.1 HONESTIFICOU a regua: os ~82.5 anteriores eram inflados porque modulos nunca importados ficavam fora do denominador do c8. Apertar via --require-tighten na Fase 6A (2026-06-16).", @@ -118,6 +138,7 @@ "_eslint_rebaseline_2026_06_13_v3825": "3658 -> 3669: +11 drift consciente do ciclo v3.8.24->v3.8.25 (features #3799-#3806: free-provider-rankings, plugins menu, proxy IP-family). Medido em release/v3.8.25 (e38d22512). Crescimento de feature legitima, nao regressao; apertar via --require-tighten no fim do ciclo.", "_eslint_rebaseline_2026_06_15_release_v3826": "3669 -> 3760. Medido em origin/release/v3.8.26 e neste PR com npm run quality:collect: ambos retornam 3760 warnings, portanto este PR e neutro; o drift ja existe na base release/v3.8.26.", "_quality_rebaseline_2026_06_15_release_v3826": "deadExports 327 -> 339 e cognitiveComplexity 738 -> 753. Medido em origin/release/v3.8.26 e neste PR com os dedicated gates: ambos retornam os mesmos valores, portanto este PR e neutro; typeCoveragePct permanece acima do baseline.", + "_scanner_baselines_seeded_2026_06_15": "secretFindings (3), zizmorFindings (195), vulnCount (13) e bundleSize (5601) congelados a partir de um run LOCAL em 2026-06-15 com os binarios reais no PATH (gitleaks 8.30.1, osv-scanner 2.3.8, zizmor 1.25.2, @size-limit/file 12.1.0). Medicoes: (a) secretFindings=3 via 'gitleaks dir ' por diretorio de fonte (src/open-sse/bin/electron/scripts) APOS corrigir o .gitleaks.toml para [extend].useDefault=true (sem isso o config customizado zerava o ruleset e nunca detectava nada) e a invocacao para escopo por-dir (gitleaks dir aceita 1 path; multiplos caiam para escanear o CWD inteiro/node_modules->timeout). Os 3 sao falsos-positivos do heuristico generic-api-key (string de header beta Anthropic + nomes de coluna latencyP50Ms/latencyP95Ms), a serem allowlistados ao longo do tempo; (b) zizmorFindings=195 via 'npm run check:workflows' APOS migrar .zizmor.yml do schema antigo 'ignores: []' para 'rules: {}' (zizmor 1.25.2 rejeitava o campo 'ignores'); (c) vulnCount=13 (LOW=4/MOD=7/HIGH=2) via osv-scanner; (d) bundleSize=5601 (gzip dos 4 entrypoints bin/*.mjs) via size-limit+@size-limit/file. Todos os 4 sao dedicatedGate:true => SKIP no ratchet BLOQUEANTE (job quality-gate) e ADVISORY no job quality-extended (continue-on-error). Permanecem advisory ate um run VERDE de CI confirmar que a tooling corrigida (install via 'gh release download' em vez de api.github.com nao-autenticado) produz os valores; o flip para bloqueante (remover continue-on-error) fica para um PR de follow-up. Direction:down em todos.", "_codeql_promote_blocking_2026_06_15": "codeqlAlerts (value 0, direction down) PROMOVIDO de ADVISORY para RATCHET BLOQUEANTE como dedicated gate. CodeQL default-setup esta ON no repo e 'gh api repos/diegosouzapw/OmniRoute/code-scanning/alerts?state=open' retorna 0 alertas abertos (limpo). check-codeql-ratchet.mjs agora le metrics.codeqlAlerts.value, compara e sai 1 SOMENTE numa regressao real (medida > baseline); QUALQUER falha de medicao (gh ausente/sem auth/sem repo/erro de API) e SKIP gracioso com exit 0 — nunca bloqueia por falta de infraestrutura. O step bloqueante vive no job quality-gate (GH_TOKEN + permissions security-events:read); o step advisory duplicado foi removido do quality-extended. Substitui a nota _int_wiring_2026_06_13 que dizia 'codeqlAlerts permanece advisory'.", "_metrics_added_2026_06_13_6a11": "openapiCoverage.pct (38.3) e i18nUiCoverage.pct (80.1) promovidas de pisos manuais THRESHOLD para metricas de catraca (direction up) na Task 6A.11. eslintErrors fixado em 0 (era metrica orfa detectada pelo motor v2 da 6A.5).", "_int_wiring_2026_06_13": "Fase 7 INT: 3 metricas promovidas de ADVISORY para RATCHET BLOQUEANTE. Valores REAIS medidos em 2026-06-13 no HEAD desta branch: deadExports=327 (knip --reporter json, DEAD_TOTAL=exports+files), cognitiveComplexity=738 (eslint sonarjs/cognitive-complexity via eslint.sonarjs.config.mjs), typeCoveragePct=92.17 (type-coverage --json-output -p open-sse/tsconfig.json). Os scripts check-dead-code.mjs/check-cognitive-complexity.mjs/check-type-coverage.mjs foram convertidos de advisory (exit 0 sempre) para ratchet (exit 1 em regressao). vulnCount e codeqlAlerts permanecem advisory no job quality-extended: dependem de binarios externos (osv-scanner) e token GitHub (gh api) nao disponiveis localmente — nao podem ser medidos/congelados honestamente sem infraestrutura de CI.", diff --git a/scripts/check/check-secrets.mjs b/scripts/check/check-secrets.mjs index fe0110a324..b61c7ac478 100644 --- a/scripts/check/check-secrets.mjs +++ b/scripts/check/check-secrets.mjs @@ -28,6 +28,21 @@ 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) // --------------------------------------------------------------------------- @@ -140,66 +155,94 @@ function main() { 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" + "[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; } - // Construir args sem interpolação de variáveis no script (Hard Rule #13) - const args = [ - "detect", - "--no-git", // escanear diretório em vez de histórico git (mais rápido em CI) - "--report-format", "json", - "--report-path", "-", // output para stdout - "--source", ROOT, - "--no-banner", - ]; + // 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))); - // Adicionar config personalizada se existir - if (fs.existsSync(GITLEAKS_CONFIG)) { - args.push("--config", GITLEAKS_CONFIG); + 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 detect --no-git --report-format json ...\n"); + process.stderr.write( + `[check-secrets] Rodando gitleaks dir --report-format json para: ${scanDirs.join(", ")} ...\n` + ); } - let stdout = ""; - try { - stdout = execFileSync(gitleaksBin, args, { - cwd: ROOT, - encoding: "utf8", - maxBuffer: 32 * 1024 * 1024, - timeout: 120_000, // 2 min - }); - } 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) : ""; + // `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); + } - if (err.status === 1 && stdout.trim()) { - // Normal: gitleaks achou findings e saiu com exit 1 - } else if (!stdout.trim()) { - process.stderr.write(`[check-secrets] ERRO ao executar gitleaks: ${err.message}\n`); - if (stderr) process.stderr.write(`[check-secrets] stderr: ${stderr.slice(0, 500)}\n`); + 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); } - } - - let gitleaksJson; - if (!stdout.trim() || stdout.trim() === "null") { - gitleaksJson = []; - } else { - try { - const parsed = JSON.parse(stdout.trim()); - gitleaksJson = parsed === null ? [] : parsed; - } catch (parseErr) { - process.stderr.write(`[check-secrets] ERRO ao parsear JSON do gitleaks: ${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); } } @@ -223,7 +266,7 @@ function main() { 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" + "[check-secrets] adicione entradas em .gitleaks.toml [[allowlist]] com comentário.\n" ); } else { process.stderr.write("[check-secrets] Nenhum finding detectado.\n");