Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3112,3 +3112,15 @@ QUOTA_STORE_DRIVER=sqlite
#OMNIROUTE_SW_BUILD_ID=2026-08-28T12-00-00
#SOURCE_VERSION=abcdef0123456789
#NEXT_PUBLIC_SW_BUILD_ID=abcdef0123456789

# --- Segurança: exigir criptografia de armazenamento (fail-closed) ---
# Quando "true" (ou em NODE_ENV=production), escritas sensíveis falham sem STORAGE_ENCRYPTION_KEY
# e o startup é recusado (finding #3). Deixe vazio em dev para o passthrough.
OMNIROUTE_REQUIRE_STORAGE_ENCRYPTION=

# --- Smoke autenticado (scripts/smoke/authenticated-smoke.mjs) — apenas ops/teste ---
OMNIROUTE_SMOKE_URL=
OMNIROUTE_SMOKE_KEY=
OMNIROUTE_SMOKE_ALLOW_PAID=
OMNIROUTE_SMOKE_MODEL=
OMNIROUTE_SMOKE_TIMEOUT_MS=
39 changes: 39 additions & 0 deletions audit/AUTONOMOUS_MISSION_STATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# AUTONOMOUS MISSION STATE — Remediação OmniRoute v3.8.51

- **mission_id:** omniroute-sec-remediation-v3.8.51
- **objetivo:** corrigir os 8 findings do scan de segurança + hardening (Fase 2) + CI/supply-chain (Fase 3), com teste de regressão por correção. Sem push/deploy/publish.
- **branch:** `security/remediation-v3.8.51` (base `b345c7f6c` = SHA da missão)
- **estado:** EXECUTING (Fase 1)
- **iteração:** 1
- **início:** 2026-09-09 (sessão retomada)
- **último_heartbeat:** Fase 0 concluída
- **último_progresso_real:** baseline + branch + secret scan feitos; iniciando #1 (SSRF)

## Critérios de aceite (gate final)
working tree limpa · secret scan · lint · typecheck · unit · integração · build · testes Electron · testes MCP authz · testes SSRF · migração+rollback · smoke /v1/models · smoke autenticado /v1/messages e /v1/responses (pede credencial) · nova auditoria sem Critical/High.

## Tarefas (8 findings)
- [x] #1 SSRF teste de webhook — hardenedWebhookFetch (resolve+pin+no-redirect+no-body-privado). Commit 7488e1cbf. 18/18 testes.
- [x] #5 OpenAPI Try confused deputy — bloqueia LOCAL_ONLY/ALWAYS_PROTECTED + GET/HEAD no /api/ + sem cookie implícito. 6/6 testes.
- [x] #8 webhook secret encryption — cifra no write, decifra na leitura, backfill idempotente + integridade HMAC. 4/4 + 46/46 regressao.
- [x] #2 LOCAL_ONLY loopback-only — removida exceção LAN; LAN só via carve-out autenticado; spawn só loopback. 22/22 + 93/93.
- [x] #4 MCP scopes default-on fail-closed — isMcpScopeEnforcementEnabled (default ON, opt-out explicito). 6/6 + 70/70 sem regressao.
- [x] #7 apiKeys cifrada no repouso + validação hash-only + migração (Opção B). 3/3 + 197 subtestes de regressao. (Opção A = hardening futuro)
- [x] #3 encryptOrThrow fail-closed — contrato + gate de startup + 3 writers convertidos. 5/5 + 22/22 regressao. (varredura dos demais writers = follow-up)
- [x] #6 Electron: login:start rejeita sender remoto + valida providerId + NUNCA retorna credentials; guard puro testado. 7/7 + 25/25. (window/preload split + nav-block + sandbox = BLOCKED_BY_EXTERNAL runtime)

## Fase 2 (após Fase 1 verde)
CSP unsafe-eval · TS strict/ignoreBuildErrors · god-files · plugins sandbox · marketplace extração tar.gz · guardrails obrigatórios · browser/CDP.

## Fase 3
Actions fixadas em SHA · Dependabot · gitleaks/osv/actionlint/oasdiff fixos · npm ci · SBOM · deploy digest-pinned (sem deploy real) · remover refs fixas a diegosouzapw.

## Blockers
- Nenhum interno ainda.
- BLOCKED_BY_EXTERNAL (futuro): smoke autenticado /v1/messages e /v1/responses exige credencial do operador. push/deploy exige autorização explícita.

## Rollback
Commits pequenos e separados na branch; base `b345c7f` intocada. `git revert <commit>` por correção.

## Próxima ação
Ler `src/app/api/webhooks/[id]/test/route.ts` + `safeOutboundFetch.ts`, escrever teste que prova o SSRF por redirect/rebinding, corrigir via helper hardened, testar, commit `fix(ssrf): ...`.
83 changes: 83 additions & 0 deletions docs/evidence/remediation/00-baseline.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Remediação OmniRoute v3.8.51 — Baseline (Fase 0)

> Documento de evidência. Gerado no início da missão de remediação de segurança.
> Nenhum código foi alterado antes desta baseline.

## 1. Identidade do checkout

| Campo | Valor |
| --- | --- |
| Diretório | `C:\Users\zodyp\Downloads\OmniRoute-Unified\repos\OmniRoute-v3851-port` |
| Remote autorizado | `myfork` = https://github.com/lmpradodz23-design/OmniRoute.git |
| Remote upstream | `origin` = https://github.com/diegosouzapw/OmniRoute.git (partial clone) |
| Branch da missão | `security/remediation-v3.8.51` (criada a partir do SHA exato) |
| HEAD base | `b345c7f6cd4e1590d1177540813302375a75e332` |
| Confirma SHA remoto esperado | **SIM** — `release/v3.8.51` no myfork == `b345c7f6c` |
| Working tree | limpo |

Observação: o SHA `b345c7f` confere com o SHA remoto observado na auditoria. O ZIP
auditado (SHA-256 `9C6CC79…BBD657`) foi usado apenas como referência; o diretório de
trabalho é um checkout Git legítimo, conforme exigido.

## 2. Ferramentas

| Ferramenta | Versão |
| --- | --- |
| node | v24.16.0 |
| npm | 11.13.0 |
| git | 2.55.0 |
| runner de teste | `node --test` + `tsx` (scripts `test:unit`); `vitest` para `.tsx` |
| node_modules | junction para o repo irmão `..\OmniRoute` (mesma versão 3.8.51) |

## 3. Relatório de segurança (fonte de verdade)

- report.md: `C:\Users\zodyp\.codex\security-scans\OmniRoute-release-v3.8.51\unversioned_20260908T234305Z_f51y_o7t\report.md` (46 KB) — lido integralmente.
- SARIF: `…\exports\results.sarif` (39 KB).
- Resultado do scan: 8 findings reportáveis — 7 high + 1 medium, todos confidence high.

### Achados confirmados

| # | Sev | Categoria | Arquivos principais |
| --- | --- | --- | --- |
| 1 | high | SSRF (CWE-918) | `src/app/api/webhooks/[id]/test/route.ts`, `src/shared/network/safeOutboundFetch.ts` |
| 2 | high | missing-authorization (CWE-306/284) | `src/server/authz/policies/management.ts`, `src/server/authz/routeGuard.ts` |
| 3 | high | sensitive-data-exposure (CWE-312/636) | `src/lib/db/encryption.ts`, `src/lib/cloudAgent/credentials.ts`, `src/lib/db/commandCodeAuth.ts` |
| 4 | high | missing-authorization (CWE-862/269) | `open-sse/mcp-server/{server,scopeEnforcement}.ts`, `management.ts` |
| 5 | high | confused-deputy (CWE-441/918/284) | `src/app/api/openapi/try/route.ts` |
| 6 | high | broken-access-control (CWE-346/749/862) | `electron/main.js`, `electron/preload.js`, `electron/loginManager.js`, `electron/lib/resolveRemoteServerUrl.js` |
| 7 | high | sensitive-data-exposure (CWE-312) | `src/lib/db/apiKeys.ts` |
| 8 | medium | sensitive-data-exposure (CWE-312) | `src/lib/db/webhooks.ts`, `src/lib/webhookDispatcher.ts` |

Superfícies "Needs follow-up" no relatório: plugins/marketplace, guardrails, CI/supply-chain, container/browser sidecar (endereçadas na Fase 2/3).

## 4. Secret scan de baseline

- `scripts/check/check-secrets.mjs`: **SKIP** — gitleaks ausente no PATH (sai 0 gracioso).
- Scan best-effort por padrões de alta confiança (chaves privadas, AKIA, `sk-…`, `ghp_…`, `xox…`) em `src/ open-sse/ electron/ scripts/`: **nenhum segredo hardcoded** (bate com o relatório: "secret-pattern review: No issue found").
- Pendência de Fase 3: instalar/fixar gitleaks no CI e fazer o gate falhar quando o scanner estiver ausente.

## 5. Plano de commits (ordem de execução por risco × tratabilidade)

Cada correção: teste que prova a falha → correção de causa raiz → testes +/- → lint/typecheck focados → commit separado (`fix(<escopo>): …`). **Sem push** (regra da missão).

1. `fix(ssrf)` — #1 teste de webhook usa `safeOutboundFetch` hardened (helper seguro já existe, não usado).
2. `fix(confused-deputy)` — #5 OpenAPI Try: allowlist read-only, bloquear LOCAL_ONLY/ALWAYS_PROTECTED, não encaminhar credenciais.
3. `fix(webhooks)` — #8 cifrar segredo do webhook (envelope) + migração idempotente.
4. `fix(authz)` — #2 separar LOOPBACK_ONLY de TRUSTED_LAN; host-sensitive só loopback.
5. `fix(mcp)` — #4 enforcement de escopos default-on fail-closed.
6. `fix(security)` — #7 apiKeys somente hash/prefix + migração + reveal one-time.
7. `fix(security)` — #3 contrato `encryptOrThrow`, fail-closed, migração legado.
8. `fix(electron)` — #6 janela/preload remoto sem IPC privilegiado, guarda de origem.

## 6. Rollback

- Todo trabalho na branch `security/remediation-v3.8.51`; base intocada em `b345c7f`.
- Reverter: `git checkout b345c7f -- <arquivo>` ou `git revert <commit>` por correção (commits pequenos e separados).
- Migrações: cada uma idempotente, transacional e com verificação; nenhuma destrói dados que não possam ser migrados (registra bloqueio seguro).
- Nenhum push/deploy/publish nesta missão — o estado remoto não é tocado.

## 7. Riscos conhecidos

- Codebase grande (13.504 arquivos) não escrito por nós; mudanças em authz/crypto/Electron têm blast radius alto — mitigado por testes de regressão e ausência de push.
- Migrações de crypto (#3/#7/#8) exigem cuidado para não perder dados — abordagem: migrar e verificar, nunca apagar sem envelope.
- Build completo do Next exige ~7 GB RAM; typecheck/testes focados são a via de verificação nesta missão.
45 changes: 45 additions & 0 deletions docs/evidence/remediation/01-ssrf-webhook-test.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Finding #1 — SSRF no teste de webhook (redirect + DNS rebinding)

- **Severidade:** high · **CWE-918** · categoria ssrf
- **Arquivos:** `src/app/api/webhooks/[id]/test/route.ts`, novo `src/shared/network/hardenedWebhookFetch.ts`

## Causa raiz
`testFetch` validava só a URL inicial (string do hostname), usava `fetch` (que segue redirect
por padrão) e decidia a redação do corpo pelo hostname inicial. Logo: (a) um destino público
que faz 302 para um serviço interno tem o corpo retornado (`redactBody=false`), e (b) um
hostname público que resolve para IP privado/metadata passa a validação e é conectado.

## Correção (causa raiz)
Novo helper `hardenedWebhookFetch`:
1. **Resolve A/AAAA** e valida **todos** os IPs resolvidos — metadata/link-local bloqueado
incondicionalmente; privado só sob opt-in. A classificação usa o **IP resolvido**, não o
hostname (fecha o DNS rebinding).
2. **Fixa o IP** validado via `undici.Agent({ connect: { lookup } })` — a conexão vai ao IP
pré-validado; DNS não pode reassociar entre a checagem e o connect (TOCTOU). Host/SNI seguem
o hostname.
3. **Nunca segue redirect** (`redirect: "manual"`; qualquer 3xx é diagnóstico bloqueado, sem corpo).
4. **Nunca devolve o corpo** de um destino privado (só diagnóstico de conectividade).
5. Timer manual com `clearTimeout` no finally e `agent.close()` — sem handle/tim/ socket órfão.

A rota passa `allowPrivate: arePrivateProviderUrlsAllowed()`, preservando o comportamento
local-first (opt-in) sem acoplar o helper ao flag global.

## Testes (regressão) — `tests/unit/api/webhooks/webhook-test-ssrf-rebinding.test.ts`
- Rebinding: hostname público que resolve para 169.254.169.254 → bloqueado (mesmo com opt-in).
- Rebinding: resolve para 10.x → bloqueado sem opt-in; com opt-in → `isPrivateTarget=true`.
- Público resolvido → ok, não privado.
- Literais: metadata / loopback / credenciais embutidas / protocolo não-http → bloqueados.
- Sem registros DNS → erro.
- **Redirect nunca seguido**: 302 → `redirect blocked`; servidor recebe só 1 request (o hop NÃO é buscado).
- Corpo de destino privado é retido (`bodyText === ""`).
- Mesmo destino privado sem opt-in → bloqueado por completo.

## Resultado (evidência real)
- `node --test` (arquivo novo + `webhook-url-ssrf-guard` existente): **18/18 pass, 0 fail**.
- `tsc --noEmit` (core): 0 erros nos arquivos tocados.
- `eslint` (com suppressions do repo): exit 0.
- Sem regressão no teste SSRF pré-existente.

## Pendências honestas
- Cobrir cada hop de redirect com revalidação seria redundante aqui (redirects são bloqueados);
se no futuro se quiser permitir 1 hop, revalidar o Location pela mesma pipeline.
39 changes: 39 additions & 0 deletions docs/evidence/remediation/02-local-only-loopback-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Finding #2 — Dispositivo da LAN opera rotas locais sem login

- **Severidade:** high · **CWE-306/284** · missing-authorization
- **Arquivos:** `src/server/authz/policies/management.ts` (import + gate)

## Causa raiz
O gate Tier-1 só rejeitava LOCAL_ONLY quando o peer NÃO era loopback **e** NÃO era LAN privada
(`!isLoopbackRequest && !isPrivateLanRequest`). Um dispositivo de LAN privada **pulava o gate
inteiro** e caía no fallback anônimo (`requireLogin=false → allow anonymous`), alcançando rotas
que instalam pacotes, iniciam subprocessos e leem/escrevem configuração do host — sem credencial.
(O acesso LAN a LOCAL_ONLY fora "owner-authorized" em 2026-05-30, mas a auditoria o classifica
como vuln HIGH pelo caminho anônimo/host-sensitive.)

## Correção (causa raiz)
- LOCAL_ONLY passa a ser **loopback-only**: removida a exceção `!isPrivateLanRequest`. Qualquer
chamador não-loopback (LAN incluída) **passa pelo gate**.
- O carve-out autenticado já existente é o único caminho não-loopback: subset allow-list
(`/api/mcp/*` etc.) exige **manage/admin** (ou `mcp:connect` para `/api/mcp/`) ou **sessão de
dashboard** — este é o "TRUSTED_LAN", que exige auth real.
- Rotas host-sensitive/spawn (`/api/cli-tools/runtime/*`, NÃO bypassáveis) ficam **estritamente
loopback** — inalcançáveis da LAN mesmo autenticado.
- Anônimo (`requireLogin=false`) nunca mais alcança LOCAL_ONLY a partir da LAN.
- Proteção anti-spoofing preservada: a localidade vem do peer real do socket (peer-stamp), nunca
do header `host`.

## Testes — `tests/unit/authz/management-policy.test.ts` (novos, peer LAN 192.168.1.50)
- LAN + `/api/cli-tools/runtime/foo` + `requireLogin=false` → **403 LOCAL_ONLY** (fecha o host
control anônimo).
- LAN + `/api/mcp/stream` sem auth → 403.
- LAN + `/api/mcp/stream` + manage key → allow (TRUSTED_LAN autenticado).
- LAN + spawn route + manage key → 403 (host control só em loopback, mesmo autenticado).
- (+ hygiene) `after()` fecha o DB antes do rmSync — elimina o EPERM de teardown no Windows.

## Resultado (evidência real)
- `management-policy.test.ts`: **22/22** (antes do fix o baseline tinha o mesmo EPERM de teardown;
os subtestes de lógica sempre passaram — agora 0 EPERM).
- Regressão authz: **93/93** (pipeline, route-guard-local-prefix, discovery-routes-local-only,
classify) — os testes de bypass existentes usam peer não-loopback e continuam válidos.
- `tsc --noEmit` (core): 0 erros nos arquivos; `eslint` (com suppressions): exit 0.
54 changes: 54 additions & 0 deletions docs/evidence/remediation/03-encrypt-or-throw-fail-closed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Finding #3 — Criptografia fail-open grava credenciais em texto puro

- **Severidade:** high · **CWE-312/636** · sensitive-data-exposure
- **Arquivos:** `src/lib/db/encryption.ts` (contrato), `src/lib/cloudAgent/credentials.ts`,
`src/lib/db/commandCodeAuth.ts`, `src/lib/services/apiKey.ts` (writers), `src/lib/db/core.ts` (gate)

## Causa raiz
`encrypt()` faz **passthrough (retorna plaintext)** sem `STORAGE_ENCRYPTION_KEY` ou após erro. Os
writers checavam `if (!encrypted) throw` — mas plaintext é não-vazio, então a checagem passa e o
segredo é gravado em texto puro achando que foi cifrado.

## Correção (causa raiz) — contrato fail-closed
Novos em `encryption.ts`:
- `EncryptionUnavailableError`.
- `encryptOrThrow(plaintext)`: retorna ciphertext real `enc:v1:` **ou lança** — nunca passthrough.
- `isStorageEncryptionRequired()`: fail-closed quando o perfil é exposto/produção
(`NODE_ENV=production`) ou por opt-in explícito (`OMNIROUTE_REQUIRE_STORAGE_ENCRYPTION`); dev/test
→ passthrough (o flag explícito tem precedência, inclusive sobre o contexto de teste).
- `encryptSensitive(plaintext)`: contrato dos writers — `encryptOrThrow` no perfil exigido, senão
`encrypt` (conveniência de dev).
- `assertStorageEncryptionConfigured()`: **gate de startup** — lança em perfil exigido sem chave;
ligado no init do DB (`core.ts`) antes de `setDb`, então uma instância exposta sem chave
**recusa iniciar** em vez de gravar plaintext. No-op em dev/test.

Writers convertidos para `encryptSensitive` (rejeitam a escrita sem chave em produção):
`saveCloudAgentCredential`, `markCommandCodeAuthSessionReceived`, `services/apiKey`.

## Testes — `tests/unit/db-encrypt-or-throw.test.ts`
- `encryptOrThrow` lança sem chave; rejeita input vazio; idempotente para já-cifrado; cifra com chave.
- perfil exigido + sem chave → `encryptSensitive` e o gate de startup **lançam**.
- perfil dev + sem chave → passthrough + gate no-op.

## Resultado (evidência real)
- `node --test` (arquivo novo): **5/5**.
- Regressão: **13/13** (webhook-secret + ssrf-guard, init do DB com o novo gate) e **9/9**
(cloud-agent-credentials, db-command-code-auth, migration-071) — writers convertidos sem quebra.
- `tsc --noEmit` (core): 0 erros nos arquivos; `eslint` (com suppressions): exit 0.

## Varredura dos demais writers — CONCLUÍDA
Todos os writers com `encrypt()` passthrough foram convertidos para `encryptSensitive`
(fail-closed em perfil exposto): `db/obsidian.ts` (token + password), `db/radar.ts` (key),
`db/settings.ts` (oidcClientSecret), `logExport/secrets.ts`, `webhookDispatcher.ts` (metadata) e
`db/secrets.ts` (`persistSecret`/`getPersistedSecret`). **Achado extra:** `persistSecret` — usado
pelo `login:start` do Electron para salvar credenciais de provedor — gravava em **texto puro**;
agora cifra no repouso + decifra na leitura (legado plaintext segue lido via passthrough do
`decrypt`).

Testes: `tests/unit/db-secrets-encryption.test.ts` (ciphertext no repouso, round-trip, legado
plaintext). Regressão: **207/207** nos testes unitários dos writers (db-secrets, db-settings,
obsidian, log-export, cli-radar). tsc 0 erros; eslint exit 0.

## Follow-up remanescente
A "readiness que detecta campos sensíveis sem envelope" (varredura de todas as colunas) permanece
como follow-up — o gate de startup + todos os writers de credencial fechados já cobrem o caminho.
35 changes: 35 additions & 0 deletions docs/evidence/remediation/04-mcp-scopes-default-on.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Finding #4 — `mcp:connect` usa ferramentas de escrita sem escopos (default-off)

- **Severidade:** high · **CWE-862/269** · missing-authorization
- **Arquivos:** `open-sse/mcp-server/server.ts`, `open-sse/mcp-server/scopeEnforcement.ts`, `docs/frameworks/MCP-SERVER.md`

## Causa raiz
`evaluateToolScopes` já é fail-closed (nega ferramenta sem definição e nega escopo ausente)
**quando** `enforceScopes=true`. Mas `server.ts` ligava o enforcement apenas se a env fosse
exatamente `"true"` (`=== "true"`), i.e. **default OFF**: com a variável ausente, uma chave
`mcp:connect` (só transporte) alcançava qualquer ferramenta `write:*`. A doc canônica
(`docs/reference/ENVIRONMENT.md`) já dizia `true`, contradizendo o código.

## Correção (causa raiz)
- Novo helper testável `isMcpScopeEnforcementEnabled()` em `scopeEnforcement.ts`: **default ON**;
só desliga com valor explícito `false/0/no/off` (escape hatch de migração legado).
- `server.ts` passa a usar o helper (`const MCP_ENFORCE_SCOPES = isMcpScopeEnforcementEnabled()`).
- Doc `MCP-SERVER.md` corrigida para `true` (default on), alinhando com `ENVIRONMENT.md`.

Com isso: `mcp:connect` abre só o transporte; ferramenta sem escopo declarado → negada;
`write:*/execute:*/admin:*` exigem escopo concedido.

## Testes — `tests/unit/mcp-scope-enforcement-default.test.ts`
- default ON para unset/""/"true"/"1"/outros; opt-out só para false/0/no/off.
- `mcp:connect`-only → negado para `write:combos` (missing_scopes).
- ferramenta sem definição → negada (tool_definition_missing).
- caller com o escopo → permitido; wildcard `write:*` satisfaz.

## Resultado (evidência real)
- `node --test` (arquivo novo): **6/6**.
- Regressão MCP: **70/70** (mcp-connect-scope, tool-collections, model-catalog, local-corpus,
notion, obsidian, extra-forward, pool-tools, tool-count) — nenhuma quebra pelo flip do default.
- `tsc --noEmit` (open-sse): 0 erros nos arquivos; `eslint` (com suppressions): exit 0.

## Pendência (Fase 3)
Gate de CI proibindo ferramenta MCP sem escopos declarados + matriz chave×tool automatizada.
Loading
Loading