Skip to main content

CodeQL Suppressions Ledger

Convenção operacional: Rust CodeQL ainda não suporta comentários inline // codeql[rule]: justification em 2026 (PR github/codeql#21638 aberto, sem merge). Este ledger é o mecanismo escolhido pelo projeto para registrar supressões — versionado em git, justificado por linha, auditável em PR review.

Owner: GAR-491 (CodeQL Triage Wave 2). Plan: personal-api-key-revogada-vectorized-matsumoto §Step 4. GAR-490 (Wave 1) extension: PR #111 (squash 613510d) added 16 entries for rust/path-injection after empirical evidence that CodeQL's Rust pack does not model validate_skill_name (crates/garraia-gateway/src/path_validation.rs) as a sanitizer. Each entry references the helper guard line, the dismissed-as-FP rationale, and the integration test that pins the rejection — see §4 alerts #67-#82. Last updated: 2026-08-30. Audit re-triage por: 2026-11-28 — 90 dias a partir da re-auditoria de 2026-08-30 (entradas com mais de 90 dias devem ser revisitadas; alertas que não existem mais no Security tab devem ser removidos do ledger).

A data agora é verificada por código. check-ledger-anchors.py compara audit_expiration com a data de hoje e sai com exit 7 se estiver no passado; o job CodeQL Ledger Anchors roda em todo PR. Até 2026-08-30 o campo não era lido por nada — nem script, nem workflow, nem teste — e ficou vencido 29 dias sem que nenhum build reclamasse. Uma regra sem leitor é um comentário.

Removida em 2026-08-30 a entrada do alerta #43 (credentials.rs:49, rust/hard-coded-cryptographic-value): o padrão que a justificava — let mut salt = vec![0u8; SALT_LEN]; seguido de rng.fill — deixou de existir. O site passou a usar garraia_security::random_bytes, que devolve o array já preenchido pelo CSPRNG e nunca materializa um buffer de zeros. É o mesmo desfecho do alerta #142 e a preferência declarada em §3: correção em código, não supressão. O ledger cai de 30 para 29 entradas. A §5 continua narrando o experimento empírico que usou o #43 como alvo — é registro histórico, e por isso a referência lá virou texto simples.

Re-auditoria de mérito concluída em 2026-08-30 (issue #886), sobre as 29 entradas. A metade mecânica virou contínua com o CodeQL Ledger Anchors (PR #882), então esta passagem cobriu o que ferramenta não faz — se cada justificativa ainda procede:

  • Os 16 rust/path-injection se apoiam em validate_skill_name (path_validation.rs:76) ser allow-list. Conferido: continua is_ascii_alphanumeric() || c == b'-' com MAX_NAME_LEN = 128, exatamente como o ledger alega. E é chamado antes de todos os 16 sinks, com return imediato no erro — verificado função a função, não por amostragem.
  • #111 / #112 (config_cmd.rs) imprimem "set"/"not set" e uma lista de nomes de provider (.map(|(name, _)| name.clone()) em check.rs:212), nunca a chave.
  • #113 (wizard/mod.rs:769; era :673 até o #1180 mover o wizard): o key vai só para vault.set(entry, key); o println! interpola apenas entry. A correção de 2026-08-30 procede.
  • #115 (session_store.rs:1326): o único valor ligado à query é params![hash_session_token(token)], duas linhas abaixo.
  • #144 (signal/mod.rs): reforçado desde a triagem — o POST de resposta do mesmo tokio::spawn passou a chamar api::send_text, que roda o guard.
  • #145 / #146 (whatsapp/api.rs): GRAPH_API_BASE é uma constante https://; não há caminho para tráfego em claro.
  • #40, #41, #42, #44, #45: continuam dentro de #[cfg(test)].

Nenhuma entrada mudou de categoria e nenhuma virou achado real. A #43 saiu por correção em código (acima), não por vencimento.

#165 — a condição de saída não é verificável neste repositório. A entrada diz que o LEGACY_KDF_SALT "só sai quando não houver mais instalações por migrar". Confirmei que o código que o consome segue vivo: resolve_admin_encryption_key é chamado no boot (server.rs:657 e :820) e dispara migrate_admin_secrets_kdf. Mas se ainda existem instalações anteriores a 2026-08-29 sem ter bootado é fato de campo, não de código — o repositório não tem como responder. A entrada fica, e a condição de saída precisa de um critério observável (por exemplo: nenhuma instalação suportada em versão anterior à que introduziu o salt por instalação) antes de poder ser avaliada por quem for renovar em 2026-11-28.

§1. Background​

GarraRUST adotou CodeQL advanced setup em 2026-04-30 (PR #106, runbook em docs/security/codeql-setup.md). O Security tab inicial mostrou 90 alertas abertos. Esta sub-issue (GAR-491) endereça 6 alertas do rule rust/hard-coded-cryptographic-value que estão estruturalmente em test fixtures ou em buffer initializers — não são vulnerabilidades reais.

Tres alternativas avaliadas para suprimi-los:

MecanismoPor que NÃOPor que NÃO
Inline // codeql[...]❌ não suportado em RustPR github/codeql#21638 aberto
paths-ignore em codeql-config.yml❌ silencia arquivo inteirotestes do GarraRUST são INLINE (#[cfg(test)] mod tests {}) dentro de produção; ignorar mobile_auth.rs esconde alertas reais
query-filters: exclude por rule-id❌ silencia regra inteiraperde sinal de alertas reais futuros

A solução adotada: REST API dismissal + este ledger versionado + script de reaplicação (scripts/security/codeql-reapply-dismissals.sh).

Amendment 2026-08-29 — fixture de teste sai por escopo, não por ledger​

A tabela acima descartou paths-ignore porque "testes do GarraRUST são INLINE (#[cfg(test)] mod tests {}) dentro de produção; ignorar mobile_auth.rs esconde alertas reais". Isso continua verdade para paths-ignore — mas existe um mecanismo que resolve o problema pela raiz e que não estava em uso em 2026-05: o extractor Rust do CodeQL liga cfg(test) incondicionalmente (rust/extractor/src/config.rs::to_cfg_overrides) e expõe a opção oficial cargo_cfg_overrides para desligá-lo.

Desde 2026-08-29, .github/workflows/codeql.yml passa CODEQL_EXTRACTOR_RUST_OPTION_CARGO_CFG_OVERRIDES: "-test" no passo Initialize CodeQL. Consequência para este ledger:

  • Fixture de teste não entra mais aqui. Se um alerta está dentro de um #[cfg(test)] ou em crates/*/tests/, ele deixa de existir por escopo de análise — não se dismissa, não se justifica linha a linha.
  • O ledger fica só para falso-positivo em código de produção, que é onde a justificativa por linha realmente agrega (ex.: credentials.rs:49, um vec![0u8; SALT_LEN] sobrescrito por SystemRandom::fill na linha seguinte).
  • As 5 entradas de rust/hard-coded-cryptographic-value que são fixture (mobile_auth.rs, validation.rs) continuam válidas — ao contrário do que esta seção previa antes de 2026-08-29. A previsão era que ficariam stale (exit 3) assim que o -test chegasse à main; a medição mostrou o contrário, e a razão está em §5.2. credentials.rs:49 é produção e permanece de qualquer forma.
  • O -test continua valendo para alertas novos: fixture que ainda não virou alerta não vira mais. O que ele não faz é apagar alerta já dispensado.
  • As 16 entradas de rust/path-injection são High e não são afetadas.

Contexto completo da onda que motivou a mudança (bundle 2.26.3 → 2.26.4, cobertura de extração de 118 para 422 arquivos): codeql-setup.md §"A onda de 2026-08-28".

A entrada nova desta leva já está registrada: alerta #165, o salt PBKDF2 legado em crates/garraia-gateway/src/admin/shared.rs:67 (LEGACY_KDF_SALT). O salt constante deixou de ser usado para derivar qualquer chave nova — agora é aleatório por instalação, com 600k iterações — mas a constante permanece no fonte porque a migração forward-only precisa dela para decifrar os segredos já gravados uma última vez antes de re-cifrá-los.

Ela estreia a disposição dismissed-wont-fix neste ledger, e a distinção importa: as 22 entradas anteriores são false_positive (o CodeQL errou) ou used_in_tests (não é superfície de produção). Esta é a primeira em que o CodeQL está certo e mesmo assim mantemos o código — o valor é de fato um salt constante, e a alternativa seria perder os segredos de toda instalação existente. Registrar isso como falso-positivo seria mentir para o ledger.

Efeito medido do -test (PR #869, head 7a9b5fc): os alertas Critical novos do PR caíram de 3 para 1, e o que apontava para um assert! dentro de #[cfg(test)] passou a outdated. Sobrou exatamente o #165, que é produção.

§2. Mechanism​

Cada alerta dismissed via:

gh api -X PATCH repos/michelbr84/GarraRUST/code-scanning/alerts/<N> \
-f state=dismissed \
-f dismissed_reason="<used_in_tests|false_positive|won't_fix>" \
-f dismissed_comment="GAR-491 — <justificativa>. See docs/security/codeql-suppressions.md row #<N>."

A fonte de verdade machine-readable é docs/security/codeql-suppressions.json (schema version 1.2.0). O script consome o JSON; este .md é a versão humana auditável. Manter ambos sincronizados — o script tem flag --check-md que valida que os números de alerta listados em §4 batem com entries[].alert_number do JSON.

Amendment 2026-09-19 — a âncora é o conteúdo, não a linha (issue #1263)​

Até aqui a âncora de cada entrada era (path, line) e o check-ledger-anchors.py conferia o sink_snippet naquela linha. Qualquer commit que inserisse ou removesse linhas acima do sink derrubava o gate sem encostar no statement suprimido. A entrada #113 andou duas vezes no mesmo PR (#1252, :769 → :826 → :867) sem o snippet mudar um byte — dois ciclos completos de uma fila de ~110 jobs, para nada. Pior: o caminho mais rápido para ficar verde era editar o sink_snippet até casar com o que estivesse na linha, ou seja, fraudar o registro. Um guard cuja saída mais cômoda é a fraude depende de disciplina, não de mecanismo.

Invertido: o sink_snippet é o identificador e o checker procura por ele no arquivo, derivando a linha.

  • 1 ocorrência → é ela. A linha derivada é escrita de volta no .json e na coluna File:line deste .md, e a saída diz linha derivada X (antes Y). Mover line não é fraude: é posição derivada, não afirmação de auditoria — é justamente por isso que o script faz isso sozinho, e é por isso que editar sink_snippet à mão continua sendo.

  • 0 ocorrências → exit 6, pedindo reauditoria. É o único caso em que o gate deve ficar vermelho: o statement suprimido mudou ou sumiu.

  • 2+ ocorrências → exit 5. O snippet não identifica um statement só, e escolher pela posição antiga seria voltar ao problema. A entrada precisa de âncora auxiliar, também de conteúdo:

    "sink_snippet": "if !file_path.is_file() {",
    "disambiguator": { "kind": "function", "value": "delete_skin" }

    kind hoje só tem um valor, function — o nome da fn Rust envolvente, varrida para cima a partir do candidato. Deliberadamente não existe um kind "n-ésima ocorrência": ordinal reaponta em silêncio no dia em que alguém acrescenta um statement idêntico acima, que é a classe de bug que esta mudança fecha. Se duas ocorrências caírem na mesma fn, o gate acusa (exit 5) e pede âncora mais específica em vez de chutar.

    Na migração, 13 das 30 entradas eram ambíguas (skills_handler.rs tem quatro if !skill_path.exists() { idênticos; skins_handler.rs e whatsapp/api.rs, dois cada). A fn envolvente da posição auditada foi única em todas as 13. A posição commitada foi confiada uma vez, e só aqui: o gate anterior exigia que o snippet casasse byte a byte com aquela linha, então o line daquele commit era um ponteiro verificado. Daí em diante manda o conteúdo.

  • Citações arquivo:linha dentro da justification passaram a ser conferidas. Foi só por falta disso que um drift sobreviveu escondido num documento de auditoria de segurança: a justificativa do #113 apontava cloud_secret em :630 (estava em :824) e o eprintln! do vault em :643 (estava em :837), e nenhum gate teria pego. Caminho ancorado no repo (crates/...) que não existe mais, ou linha fora de faixa em arquivo que existe, é exit 6; nome de arquivo solto que não resolve sai como aviso, porque prosa não é caminho e o script não adivinha. Citação :NNN sem arquivo fica fora de escopo de propósito — quase toda ocorrência é histórica ("o sink desceu de :1380 para :1653") e validar história por faixa é tripwire sem valor.

O job do CI roda com --no-rewrite: reporta a posição derivada e não escreve nada (a árvore do runner é descartada, e "reescrito" ali seria mentira). Quem consertar roda o checker sem a flag, localmente, e commita o line atualizado junto com a mudança que o moveu.

Wiring em workflow (2026-08-29) — fecha o GAR-491.2​

O comando acima era executado à mão, de uma máquina com PAT de escopo security_events — foi assim que os 22 alertas de GAR-490/GAR-491 foram dispensados (§5). O header do script registrava a lacuna:

"Per amendment A8: there is intentionally NO schedule wiring here. (…) A future sub-issue (GAR-491.2) decides if/when to wire it into a workflow."

Agora existe .github/workflows/codeql-apply-dismissals.yml: workflow_dispatch, permissions: security-events: write, rodando o mesmo script com o GITHUB_TOKEN do job — sem PAT.

Três decisões que importam:

  • Dry-run é o default. O input apply é false até alguém marcar. Rodar o workflow sem pensar não escreve nada.
  • Workflow separado do de triagem. codeql-triage.yml continua security-events: read. Um relatório não carrega permissão de escrita sobre alertas de segurança.
  • Sem schedule. Dispensar um alerta é ato deliberado, não rotina. E sem continue-on-error: exit 2 (ledger divergente) e exit 3 (alerta sumiu) são o resultado útil quando acontecem — significam "reaudite à mão".

O input alert restringe a um número só, que foi como a prova empírica do #43 foi conduzida. Isso também contorna uma armadilha real: o loop percorre as entradas em ordem e aborta na primeira stale, então uma entrada obsoleta no topo impediria as de baixo de serem aplicadas.

§3. Operational rules​

  1. No bulk suppression. Cada entrada precisa justificativa por linha.
  2. No silencing real alerts as FPs. Se em dúvida, NÃO suprime — abre sub-issue de investigação.
  3. Audit trail. Cada dismissal emite dismissed_comment referenciando GAR-# e linha do ledger.
  4. Re-audit obrigatório a cada 90 dias. Audit expiration: 2026-08-01. Entradas vencidas devem ser revistas; se ainda válidas, renovar com nova justificativa + commit hash; se não, abrir fix real.
  5. Fail-closed reaplicação. O script verifica rule_id/path/line do alerta atual contra o ledger antes de reaplicar. Se divergir → exit 2, manual re-audit obrigatório (alerta pode ter sido renumerado, código pode ter mudado, regra pode ter sido renomeada).
  6. Sem fallback global. Se a empirical proof (§5) falhar, NÃO recorrer a query-filters: exclude global — abrir nova sub-issue para decidir entre custom query suite, path-specific approach, ou manual UI dismissal mantendo este ledger.
  7. alert_number não é chave estável — reconciliar antes de reaplicar. Ver "Amendment 2026-08-30" abaixo.

Amendment 2026-08-30 — renumeração silenciosa e o --rekey​

Em 2026-08-29 o Security tab voltou a acusar 16 alertas rust/path-injection que já estavam dispensados desde o PR #111. Não foi regressão de código, nem o dismissal ter sido revertido: o dry-run do codeql-reapply-dismissals.sh no run 33261527354 reportou skipped: 23 (already-dismissed) — as entradas #67–#82 continuavam vivas e dispensadas.

O que aconteceu foi duplicação. Quando o escopo da análise mudou no mesmo dia (paths-ignore: crates/*/tests/** mais o CODEQL_EXTRACTOR_RUST_OPTION_CARGO_CFG_OVERRIDES movido para o nível do job), os partialFingerprints do SARIF mudaram junto, o GitHub deixou de casar os achados com os alertas existentes e criou alertas novos — #147–#162 — para o mesmo código, nas mesmas linhas. Os dismissals ficaram grudados nas duplicatas aposentadas.

A lição: alert_number é um identificador de apresentação, não uma identidade. A identidade estável é (rule_id, path, line) — que é, aliás, exatamente o que o codeql-reapply-dismissals.sh já validava fail-closed antes de reaplicar.

Por isso existe scripts/security/codeql-rekey-ledger.py: ele lista os alertas abertos, casa por (rule_id, path, line) e reaponta o alert_number das entradas, reescrevendo .json e .md em sincronia para o --check-md continuar passando. Ele não emite PATCH, não inventa entrada para alerta sem justificativa, e não apaga entrada — nada nele afrouxa a regra 1 (no bulk suppression): a justificativa de cada linha já foi revisada uma vez e continua valendo, só o ponteiro muda, e o diff vai a review em PR como qualquer outro.

Duas chaves iguais casando com dois alertas abertos é exit 2: o script não adivinha. Entrada sem alerta aberto correspondente não é erro — é o estado saudável de uma entrada dispensada e quieta —, só entra no resumo para alimentar o re-audit de 90 dias da regra 4.

O codeql-triage.yml roda esse plano em --dry-run a cada execução e publica no job summary, então a divergência aparece sozinha na próxima triagem em vez de esperar alguém reparar no Security tab.

§4. Ledger​

#RuleFile:lineDispositionReasonJustificativaGAR
#40rust/hard-coded-cryptographic-valuecrates/garraia-gateway/src/mobile_auth.rs:738dismissed-used-in-testsused_in_testsTest fixture em #[tokio::test] argon2id_register_and_login_roundtrip. Literal salt "" é placeholder do path PHC Argon2id (que embute seu próprio salt); coluna legacy não-usada.GAR-491
#41rust/hard-coded-cryptographic-valuecrates/garraia-gateway/src/mobile_auth.rs:749dismissed-used-in-testsused_in_testsTest fixture em #[tokio::test] argon2id_register_and_login_roundtrip — branch negativo, password "nope" deve retornar false. Input intencionalmente inválido para coverage.GAR-491
#42rust/hard-coded-cryptographic-valuecrates/garraia-gateway/src/mobile_auth.rs:870dismissed-used-in-testsused_in_testsTest fixture em #[tokio::test] second_login_after_upgrade_still_works. "seq-password-xyz" exercita o PBKDF2 → Argon2id lazy-upgrade transactional path; nunca persistido.GAR-491
#44rust/hard-coded-cryptographic-valuecrates/garraia-security/src/validation.rs:233dismissed-used-in-testsused_in_testsTest fixture em #[test] validate_password_length. Literal "short" intencionalmente abaixo do mínimo para asserir Err. Negative-path coverage.GAR-491
#45rust/hard-coded-cryptographic-valuecrates/garraia-security/src/validation.rs:234dismissed-used-in-testsused_in_testsTest fixture em #[test] validate_password_length. Literal "validpass123" intencionalmente acima do mínimo para asserir Ok. Positive-path coverage.GAR-491
#149rust/path-injectioncrates/garraia-gateway/src/skins_handler.rs:84dismissed-false-positivefalse_positiveGAR-490 PR A (PR #111, squash 613510d): create_skin guards body.name via validate_skill_name at line 60 before tokio::fs::create_dir_all / tokio::fs::write. Charset [A-Za-z0-9-]{1,128} ASCII-only. CodeQL Rust pack does not model the helper as a sanitizer. Regression: tests/skins_test.rs::create_skin_with_path_traversal_returns_400 + create_skin_with_dot_in_name_returns_400 + create_skin_rejects_underscore_per_project_convention.GAR-490
#147rust/path-injectioncrates/garraia-gateway/src/skins_handler.rs:111dismissed-false-positivefalse_positiveGAR-490 PR A (PR #111, squash 613510d): get_skin guards Path(name) via validate_skill_name at line 99 before format!("{name}.json"). Charset [A-Za-z0-9-]{1,128} ASCII-only. CodeQL Rust pack does not model the helper as a sanitizer. Regression: tests/skins_test.rs::get_skin_with_dot_in_name_returns_400.GAR-490
#148rust/path-injectioncrates/garraia-gateway/src/skins_handler.rs:141dismissed-false-positivefalse_positiveGAR-490 PR A (PR #111, squash 613510d): delete_skin guards Path(name) via validate_skill_name at line 129 before tokio::fs::remove_file. Charset [A-Za-z0-9-]{1,128} ASCII-only. CodeQL Rust pack does not model the helper as a sanitizer. Regression: tests/skins_test.rs::delete_skin_with_backslash_returns_400.GAR-490
#150rust/path-injectioncrates/garraia-gateway/src/skills_handler.rs:177dismissed-false-positivefalse_positiveGAR-490 PR A (PR #111, squash 613510d): get_skill guards Path(name) via validate_skill_name at line 162 before std::fs::read_to_string. Charset [A-Za-z0-9-]{1,128} ASCII-only. CodeQL Rust pack does not model the helper as a sanitizer. Regression: tests/skills_test.rs::get_skill_rejects_path_traversal.GAR-490
#153rust/path-injectioncrates/garraia-gateway/src/skills_handler.rs:269dismissed-false-positivefalse_positiveGAR-490 PR A (PR #111, squash 613510d): create_skill guards body.name via validate_skill_name at line 220 before std::fs::write. Charset [A-Za-z0-9-]{1,128} ASCII-only. CodeQL Rust pack does not model the helper as a sanitizer. Regression: tests/skills_test.rs::create_skill_rejects_{path_traversal,empty_name,nul_byte,windows_drive}.GAR-490
#154rust/path-injectioncrates/garraia-gateway/src/skills_handler.rs:344dismissed-false-positivefalse_positiveGAR-490 PR A (PR #111, squash 613510d): update_skill double-guards URL name + body.name via validate_skill_name at lines 300 and 307 before std::fs::write. Charset [A-Za-z0-9-]{1,128} ASCII-only. CodeQL Rust pack does not model the helper as a sanitizer. Regression: tests/skills_test.rs::update_skill_rejects_dot_in_name.GAR-490
#151rust/path-injectioncrates/garraia-gateway/src/skills_handler.rs:533dismissed-false-positivefalse_positiveGAR-490 PR A (PR #111, squash 613510d): export_skill guards Path(name) via validate_skill_name at line 519 before std::fs::read_to_string. Charset [A-Za-z0-9-]{1,128} ASCII-only. CodeQL Rust pack does not model the helper as a sanitizer. Regression: tests/skills_test.rs::export_skill_rejects_dot_in_name.GAR-490
#152rust/path-injectioncrates/garraia-gateway/src/skills_handler.rs:590dismissed-false-positivefalse_positiveGAR-490 PR A (PR #111, squash 613510d): set_skill_triggers guards Path(name) via validate_skill_name at line 574 before std::fs::read_to_string. Charset [A-Za-z0-9-]{1,128} ASCII-only. CodeQL Rust pack does not model the helper as a sanitizer. Regression: tests/skills_test.rs::set_skill_triggers_rejects_dot_in_name.GAR-490
#155rust/path-injectioncrates/garraia-gateway/src/skills_handler.rs:632dismissed-false-positivefalse_positiveGAR-490 PR A (PR #111, squash 613510d): set_skill_triggers guards Path(name) via validate_skill_name at line 574 before std::fs::write of updated trigger content. Charset [A-Za-z0-9-]{1,128} ASCII-only. CodeQL Rust pack does not model the helper as a sanitizer. Regression: tests/skills_test.rs::set_skill_triggers_rejects_dot_in_name.GAR-490
#161rust/path-injectioncrates/garraia-gateway/src/skins_handler.rs:104dismissed-false-positivefalse_positiveGAR-490 PR A (PR #111, squash 613510d): get_skin guards Path(name) via validate_skill_name at line 99 before tokio::fs::read_to_string. Charset [A-Za-z0-9-]{1,128} ASCII-only. CodeQL Rust pack does not model the helper as a sanitizer. Regression: tests/skins_test.rs::get_skin_with_dot_in_name_returns_400.GAR-490
#162rust/path-injectioncrates/garraia-gateway/src/skins_handler.rs:134dismissed-false-positivefalse_positiveGAR-490 PR A (PR #111, squash 613510d): delete_skin guards Path(name) via validate_skill_name at line 129 before file_path.is_file() check. Charset [A-Za-z0-9-]{1,128} ASCII-only. CodeQL Rust pack does not model the helper as a sanitizer. Regression: tests/skins_test.rs::delete_skin_with_backslash_returns_400.GAR-490
#156rust/path-injectioncrates/garraia-gateway/src/skills_handler.rs:167dismissed-false-positivefalse_positiveGAR-490 PR A (PR #111, squash 613510d): get_skill guards Path(name) via validate_skill_name at line 162 before skill_path.exists() check. Charset [A-Za-z0-9-]{1,128} ASCII-only. CodeQL Rust pack does not model the helper as a sanitizer. Regression: tests/skills_test.rs::get_skill_rejects_path_traversal.GAR-490
#157rust/path-injectioncrates/garraia-gateway/src/skills_handler.rs:227dismissed-false-positivefalse_positiveGAR-490 PR A (PR #111, squash 613510d): create_skill guards body.name via validate_skill_name at line 220 before skill_path.exists() check. Charset [A-Za-z0-9-]{1,128} ASCII-only. CodeQL Rust pack does not model the helper as a sanitizer. Regression: tests/skills_test.rs::create_skill_rejects_{path_traversal,empty_name,nul_byte,windows_drive,underscore_per_project_convention}.GAR-490
#158rust/path-injectioncrates/garraia-gateway/src/skills_handler.rs:312dismissed-false-positivefalse_positiveGAR-490 PR A (PR #111, squash 613510d): update_skill double-guards URL name + body.name via validate_skill_name at lines 300 and 307 before skill_path.exists() check. Charset [A-Za-z0-9-]{1,128} ASCII-only. CodeQL Rust pack does not model the helper as a sanitizer. Regression: tests/skills_test.rs::update_skill_rejects_dot_in_name.GAR-490
#159rust/path-injectioncrates/garraia-gateway/src/skills_handler.rs:523dismissed-false-positivefalse_positiveGAR-490 PR A (PR #111, squash 613510d): export_skill guards Path(name) via validate_skill_name at line 519 before skill_path.exists() check. Charset [A-Za-z0-9-]{1,128} ASCII-only. CodeQL Rust pack does not model the helper as a sanitizer. Regression: tests/skills_test.rs::export_skill_rejects_dot_in_name.GAR-490
#160rust/path-injectioncrates/garraia-gateway/src/skills_handler.rs:579dismissed-false-positivefalse_positiveGAR-490 PR A (PR #111, squash 613510d): set_skill_triggers guards Path(name) via validate_skill_name at line 574 before skill_path.exists() check. Charset [A-Za-z0-9-]{1,128} ASCII-only. CodeQL Rust pack does not model the helper as a sanitizer. Regression: tests/skills_test.rs::set_skill_triggers_rejects_dot_in_name.GAR-490
#111rust/cleartext-loggingcrates/garraia-cli/src/config_cmd.rs:222dismissed-false-positivefalse_positiveWave 3 — cleartext. garraia config check imprime presenca, nunca valor: o campo summary.gateway_api_key_set e bool e as unicas strings possiveis sao os literais "set" / "not set". A secao sai sob o cabecalho literal "Summary (redacted)" (config_cmd.rs:206). CodeQL rastreia o identificador chamado api_key, nao o booleano que ele carrega.GAR-491
#112rust/cleartext-loggingcrates/garraia-cli/src/config_cmd.rs:245dismissed-false-positivefalse_positiveWave 3 — cleartext. Mesma superficie do #111: imprime summary.llm_providers_api_key_set.join(", "), que e a lista de nomes de provider com chave configurada (ex.: [openai, anthropic]), nao as chaves. Invariante de redaction documentado em CLAUDE.md (garraia-config, plan 0046).GAR-491
#113rust/cleartext-loggingcrates/garraia-cli/src/wizard/mod.rs:1005dismissed-false-positivefalse_positiveWave 3 — cleartext. println!(" Cloud provider API key encrypted in vault (entry {entry}).") imprime o nome da entrada do cofre, nunca o segredo. cloud_secret e Option<(&str, &str)> (wizard/mod.rs:828); o destructuring (entry, key) manda o key exclusivamente para vault.set(entry, key) na linha anterior, e o println! interpola apenas entry. CodeQL contamina entry por ele vir do mesmo tuple que key. Re-audit 2026-08-30: esta entrada apontava para :640 e descrevia outro statement — o eprintln! do vault open failed, hoje em :841. O alerta vivo sempre esteve no println! de :673, que e o que §5.3 ja dizia; era o .json que estava errado. Ver a nota de re-audit apos esta tabela. Reancorada 2026-09-16 (PR #1252): o sink desceu de :769 para :826 porque a geracao de credencial em bind exposto acrescentou linhas acima deste ponto. Mesmo statement, mesmo sink, sink_snippet intocado. Reancorada de novo 2026-09-16 (mesmo PR): o sink desceu de :826 para :867 quando a credencial deixou de ser impressa e a escrita com permissao restrita entrou acima deste ponto. As duas citacoes de linha da propria justificativa tambem estavam velhas e foram corrigidas junto: a declaracao de cloud_secret esta em :828 (era :630) e o eprintln! do vault em :841 (era :643). Mesmo statement, mesmo sink, sink_snippet intocado. Reancorada uma terceira vez 2026-09-16 (merge do #1256 com a main): o #1256 e o #1252 reancoraram esta MESMA entrada em paralelo — aquele por 4 linhas de roteamento do garra whatsapp, este pela geracao de credencial. Depois do merge as duas somam, entao a linha nao e a de nenhum dos dois lados: foi RECALCULADA na arvore mesclada para :871. As duas citacoes internas subiram os mesmos 4: cloud_secret em :828 e o eprintln! do vault em :841. Mesmo statement, mesmo sink, sink_snippet intocado.GAR-491
#115rust/cleartext-storage-databasecrates/garraia-db/src/session_store.rs:1711dismissed-false-positivefalse_positiveTriagem 2026-08-30. O que chega ao banco e um hash, nunca o token: validate_session_token liga params![hash_session_token(token)], e hash_session_token (session_store.rs:1612) e SHA-256 hex de mao unica. O lado da escrita declara o invariante em session_store.rs:1634 — "Only the hash is persisted; the raw token goes to the caller and is never written to disk" — e ha migracao que reescreve tokens legados em claro, detectados por length(token) <> 64 OR token GLOB '*[^0-9a-f]*' (session_store.rs:347). CodeQL trata token: &str como credencial fluindo para uma query, sem modelar o hash como transformacao irreversivel. Reancorada 2026-09-06 (PR #1007): o sink desceu de :1380 para :1653 porque set_agent_mode ganhou distincao entre modo escolhido e deduzido (#988), o upsert passou a mesclar metadado (#1008) e o modo customizado ganhou escopo de usuario (#986) — 273 linhas acima no total. Mesmo statement, mesma funcao, mesmo sink. Reancorada 2026-09-09 (PR #1114): o sink desceu de :1653 para :1674 porque o patch de metadado nao-objeto na criacao de sessao (#1102) inseriu 21 linhas em SessionStore, todas acima deste ponto. Reancorada 2026-09-15 (PR #1224): o sink desceu de :1674 para :1677 porque a migration da tabela agent_runs (ledger duravel de runs, P1 do gap analysis) inseriu 3 linhas no inicio de run_migrations. Mesmo statement, mesma funcao, mesmo sink_snippet. Reancorada 2026-09-20 (PR #1309): o sink subiu de :1677 para :1669 e o sink_snippet mudou de .prepare(&sql) para .prepare( porque a nota lateral da #1247 reescreveu a query: o SQL montado por format! virou literal estatico, com o timeout idle entrando por bind ?2. Mesmo statement, mesma funcao validate_session_token, mesmo invariante — o que chega ao banco segue sendo so o hash, agora em session_store.rs:1681. Nao ha mais SQL dinamico neste caminho.GAR-491
#144rust/cleartext-transmissioncrates/garraia-channels/src/signal/mod.rs:228dismissed-false-positivefalse_positiveTriagem 2026-08-30, reancorada neste PR. O sink (:228 aqui; o alerta vivo aponta :279, emitido contra a main antes de este PR encolher os sends para wrappers) esta dentro de connect(), depois do guard: connect() chama ensure_url_vetted()? antes de qualquer IO, e o ? aborta se a URL for recusada. O config e clonado antes do tokio::spawn e SignalConfig nao tem mutabilidade interior, entao a URL do loop e o mesmo valor imutavel ja validado. CodeQL nao liga o clone a validacao anterior. Auditado por security-auditor. Este PR reforca a justificativa: o POST de resposta dentro do mesmo spawn passou a chamar api::send_text, que roda o guard — antes ele dependia so da ordem de execucao. Contraste com o #143, mesma regra e mesmo arquivo, que nao entrou neste ledger: era codigo morto (receive_messages, #[allow(dead_code)], zero call sites) e foi removido, fechando como Fixed.GAR-491
#145rust/cleartext-transmissioncrates/garraia-channels/src/whatsapp/api.rs:48dismissed-false-positivefalse_positiveWave 3 — cleartext. O destino e HTTPS: const GRAPH_API_BASE: &str = "https://graph.facebook.com/v21.0" (whatsapp/api.rs:5), entao .bearer_auth(token) sai sempre sobre TLS. O esquema e constante de compilacao e nenhum caminho de config o altera. CodeQL nao dobra a const dentro do format! e trata o esquema como desconhecido.GAR-491
#146rust/cleartext-transmissioncrates/garraia-channels/src/whatsapp/api.rs:79dismissed-false-positivefalse_positiveWave 3 — cleartext. Identico ao #145, em mark_as_read. Contraste deliberado com os alertas 143/144 do canal Signal: mesmo rule, mas nao eram falso-positivo — la a URL base vinha da config do operador e admitia http:// remoto, e foi corrigida em codigo (guard vet_signal_cli_url).GAR-491
#165rust/hard-coded-cryptographic-valuecrates/garraia-gateway/src/admin/shared.rs:67dismissed-wont-fixwont_fixLEGACY_KDF_SALT. Não é falso-positivo nem fixture — o CodeQL está certo, é um salt PBKDF2 constante. Permanece deliberadamente porque a migração forward-only do PR #869 precisa dele para decifrar uma última vez os segredos de instalações anteriores a 2026-08-29, antes de re-cifrá-los sob a chave derivada do salt aleatório por instalação. Nenhuma chave nova é derivada dele: derive_with_passphrase só o usa no arm de migração pendente e no fallback de parâmetros ilegíveis. Os parâmetros novos vivem na tabela kdf_params do admin.db, gravados na mesma transação que re-cifra os segredos. Só sai quando não houver mais instalações por migrar. ✅ Número reconferido na main em 2026-08-29 (squash a47d3c3): o dry-run casou rule_id/path/line sem exit 2, então 165 sobreviveu ao squash. Dispensado no mesmo dia — ver §5.2.PR-869

| #176 | rust/cleartext-logging | crates/garraia-cli/src/whatsapp.rs:1367 | dismissed-false-positive | false_positive | PR #1256 (#1238). Mensagem de sucesso do garra whatsapp link: o que chega ao stdout é um caminho de diretório, não credencial e não PII — <data_dir>/whatsapp/<account>/, com <data_dir> saindo de AppConfig::resolved_data_dir() (a máquina do próprio usuário). A entrada faz duas afirmações, e cada uma tem prova própria. (1) O caminho não sai do data dir, porque for_data_dir devolve Result e recusa (SessionError::InvalidAccount) qualquer conta que não seja um segmento [A-Za-z0-9_-]{1,64} — allowlist que fecha .., /, \, ., vazio, NUL e espaço de uma vez. E for_data_dir é o único construtor que existe fora da crate: a irmã SessionStore::new, que aceita qualquer PathBuf e não valida nada, é pub(crate) desde a revisão R4 — quem impede a CLI de contorná-la é o rustc, não a disciplina de quem escreve o call site. Regressão da declaração: the_store_exposes_a_single_validating_constructor (source_scan.rs), que fica vermelho no dia em que new voltar a ser pub. Regressão: an_account_that_escapes_the_data_dir_is_refused_before_any_write (session.rs), que no ramo em que a validação aceitasse grava a sessão cifrada e afirma a contenção no data dir. (2) O segmento impresso é a constante DEFAULT_ACCOUNT — um número de telefone casaria a regra de (1) e seria PII no stdout, então validar não basta aqui. Regressão: every_session_store_in_the_cli_uses_the_constant_account (whatsapp/tests.rs), que varre a árvore inteira de crates/garraia-cli/src lida do disco e exige que o segundo argumento de cada for_data_dir seja literalmente DEFAULT_ACCOUNT, lendo o argumento até o parêntese que fecha a chamada — account.unwrap_or(DEFAULT_ACCOUNT) é reprovado, e o parser tem teste próprio. ⚠️ Reauditada 2026-09-16 (revisão R4): a versão anterior desta linha afirmava que o invariante estava pinado por um scan de janela de 160 caracteres num arquivo só; ele aprovava conta dinâmica com a constante como fallback e não enxergava call site em outro arquivo, e o for_data_dir não validava nada — o path traversal estava fechado por acidente. Guarda textual → validação estrutural. Por que supressão e não correção: o mesmo segmento já aparece em outros dois println! do comando — store.blob_path() no status e store.archive_path() no aviso de arquivado — e nenhum dos dois foi sinalizado; apagá-lo só neste sink calaria o scanner sem mudar nada do que ele teme, e dizer onde a sessão foi gravada é o motivo de a mensagem existir. Deixa de valer se a CLI passar a escolher conta em runtime: é o teste (2) que fica vermelho nesse dia. Reancorada 2026-09-16 (PR #1256): o sink desceu de :631 para :807 porque as revisões R3/R4 acrescentaram acima deste ponto o tratamento de erro do for_data_dir, o ArchiveGuard e a extração de link_paired — mesmo statement, sink_snippet intocado. Reancorada de novo 2026-09-17 (PR #1256, rodada 4): desceu de :807 para :994 — a rodada acrescentou acima dele o prazo do handshake, a saída injetável do ArchiveGuard e o comando garra whatsapp restore. Mesmo statement, sink_snippet intocado. Reancorada de novo 2026-09-17 (PR #1256, rodada 5): desceu de :994 para :1017 — a rodada acrescentou acima dele a prova de que o blob restaurado abre antes de restore ligar o canal. Mesmo statement, sink_snippet intocado. Reancorada de novo 2026-09-17 (PR #1256, rodada 6): desceu de :1017 para :1018 — a rodada inverteu a ordem daquela prova, que agora roda ANTES de mover o arquivado. Mesmo statement, sink_snippet intocado. Reancorada de novo 2026-09-17 (PR #1256, rodada 9): desceu de :1018 para :1067 — entrou acima dele o needs_a_terminal, a mensagem que link e cloud passaram a imprimir sem terminal, no lugar de repetirem o texto do menu e sairem 0. Linha recalculada nesta arvore, nao copiada. Mesmo statement, sink_snippet intocado. Reancorada de novo 2026-09-17 (PR #1256, rodada 10): desceu de :1067 para :1070 — entrou acima dele o pulso PairUi::connecting, que da sinal de vida enquanto a ponte esta muda antes do primeiro QR. Linha recalculada, nao copiada. Mesmo statement, sink_snippet intocado. Reancorada de novo 2026-09-17 (PR #1265, fatia D): desceu de :1070 para :1107 — o merge da fatia D entrou acima dele a integração de saúde do garra whatsapp status: o use do módulo health, DiskFacts::read + classify, o bloco de MissingDependencies e a função print_next_step. Linha recalculada nesta árvore com grep, não copiada. Mesmo statement, sink_snippet intocado. ⚠️ Número a reconferir após o merge — 173 foi atribuído na análise da branch. Reapontada 2026-09-25 (#173 → #176): confirmado depois do merge da v0.4.5 — o GitHub abriu o alerta #176 para o mesmo sink (span 1356-1358, sink em :1358; o println! virou multi-linha com o rustfmt) e o #173 ficou dispensado numa duplicata aposentada. Reapontamento feito pelo codeql-rekey-ledger.py, que passou a casar a linha do sink dentro do span do alerta, como o codeql-reapply-dismissals.sh já fazia. Mesmo statement, sink_snippet intocado. | PR-1256 | | #178 | rust/path-injection | crates/garraia-channels/src/whatsapp_linked/bridge.rs:629 | dismissed-false-positive | false_positive | PR #1373 (v0.4.5). Mesma classe e mesmo diretório do #174: std::fs::metadata(&stamp) em deps_pending lê o carimbo de dependências (DEPS_STAMP_FILE, nome constante) dentro do diretório do bridge Node que o prepare recebe — PreparoDaPonte::embutido(paths.bridge_dir) no gateway e ctx.bridge_dir() na CLI, ambos derivados de AppConfig::resolved_data_dir() do config.yml do operador. Nenhum byte de request, canal ou LLM alcança o caminho; a leitura é só stat e no máximo os 7 bytes do marcador pending. Deixa de valer, junto com o #174, se o diretório do bridge passar a vir de request, canal ou LLM. | PR-1373 | | #177 | rust/path-injection | crates/garraia-channels/src/whatsapp_linked/bridge.rs:631 | dismissed-false-positive | false_positive | PR #1373 (v0.4.5). Mesma classe e mesmo diretório do #174: std::fs::read(&stamp) em deps_pending lê o carimbo de dependências (DEPS_STAMP_FILE, nome constante) dentro do diretório do bridge Node que o prepare recebe — PreparoDaPonte::embutido(paths.bridge_dir) no gateway e ctx.bridge_dir() na CLI, ambos derivados de AppConfig::resolved_data_dir() do config.yml do operador. Nenhum byte de request, canal ou LLM alcança o caminho; a leitura é só stat e no máximo os 7 bytes do marcador pending. Deixa de valer, junto com o #174, se o diretório do bridge passar a vir de request, canal ou LLM. | PR-1373 | | #181 | rust/path-injection | crates/garraia-gateway/src/rest_v1/uploads.rs:1081 | dismissed-false-positive | false_positive | Falso positivo em finalize_upload. Falso positivo: staging_path junta staging_dir (canonicalizado no bootstrap) com format!("{upload_id}.staging") e upload_id: Uuid tipado (crates/garraia-gateway/src/rest_v1/uploads.rs:197) — os handlers recebem Path(upload_id): Path<Uuid> (crates/garraia-gateway/src/rest_v1/uploads.rs:656 em patch_upload, crates/garraia-gateway/src/rest_v1/uploads.rs:875 em delete_upload) e o extractor do axum recusa valor que nao parseie como UUID (400 antes do handler). Forma canonica de 36 chars [0-9a-f-], sem traversal. CodeQL nao modela Path<Uuid> como sanitizer. Regressao: uploads.rs::staging_path_is_traversal_safe (crates/garraia-gateway/src/rest_v1/uploads.rs:1538). | GAR-491 | | #182 | rust/path-injection | crates/garraia-gateway/src/rest_v1/uploads.rs:1093 | dismissed-false-positive | false_positive | Falso positivo em finalize_upload. Falso positivo: staging_path junta staging_dir (canonicalizado no bootstrap) com format!("{upload_id}.staging") e upload_id: Uuid tipado (crates/garraia-gateway/src/rest_v1/uploads.rs:197) — os handlers recebem Path(upload_id): Path<Uuid> (crates/garraia-gateway/src/rest_v1/uploads.rs:656 em patch_upload, crates/garraia-gateway/src/rest_v1/uploads.rs:875 em delete_upload) e o extractor do axum recusa valor que nao parseie como UUID (400 antes do handler). Forma canonica de 36 chars [0-9a-f-], sem traversal. CodeQL nao modela Path<Uuid> como sanitizer. Regressao: uploads.rs::staging_path_is_traversal_safe (crates/garraia-gateway/src/rest_v1/uploads.rs:1538). | GAR-491 | | #183 | rust/path-injection | crates/garraia-gateway/src/rest_v1/uploads.rs:1248 | dismissed-false-positive | false_positive | Falso positivo em append_to_staging (crates/garraia-gateway/src/rest_v1/uploads.rs:1244): o parametro vem do chamador via staging.staging_path(upload_id) (crates/garraia-gateway/src/rest_v1/uploads.rs:755, crates/garraia-gateway/src/rest_v1/uploads.rs:979, crates/garraia-gateway/src/rest_v1/uploads.rs:1075), nunca de dado cru. Falso positivo: staging_path junta staging_dir (canonicalizado no bootstrap) com format!("{upload_id}.staging") e upload_id: Uuid tipado (crates/garraia-gateway/src/rest_v1/uploads.rs:197) — os handlers recebem Path(upload_id): Path<Uuid> (crates/garraia-gateway/src/rest_v1/uploads.rs:656 em patch_upload, crates/garraia-gateway/src/rest_v1/uploads.rs:875 em delete_upload) e o extractor do axum recusa valor que nao parseie como UUID (400 antes do handler). Forma canonica de 36 chars [0-9a-f-], sem traversal. CodeQL nao modela Path<Uuid> como sanitizer. Regressao: uploads.rs::staging_path_is_traversal_safe (crates/garraia-gateway/src/rest_v1/uploads.rs:1538). | GAR-491 | | #184 | rust/path-injection | crates/garraia-gateway/src/rest_v1/uploads.rs:811 | dismissed-false-positive | false_positive | Falso positivo em patch_upload. Falso positivo: staging_path junta staging_dir (canonicalizado no bootstrap) com format!("{upload_id}.staging") e upload_id: Uuid tipado (crates/garraia-gateway/src/rest_v1/uploads.rs:197) — os handlers recebem Path(upload_id): Path<Uuid> (crates/garraia-gateway/src/rest_v1/uploads.rs:656 em patch_upload, crates/garraia-gateway/src/rest_v1/uploads.rs:875 em delete_upload) e o extractor do axum recusa valor que nao parseie como UUID (400 antes do handler). Forma canonica de 36 chars [0-9a-f-], sem traversal. CodeQL nao modela Path<Uuid> como sanitizer. Regressao: uploads.rs::staging_path_is_traversal_safe (crates/garraia-gateway/src/rest_v1/uploads.rs:1538). | GAR-491 | | #185 | rust/path-injection | crates/garraia-gateway/src/rest_v1/uploads.rs:980 | dismissed-false-positive | false_positive | Falso positivo em delete_upload. Falso positivo: staging_path junta staging_dir (canonicalizado no bootstrap) com format!("{upload_id}.staging") e upload_id: Uuid tipado (crates/garraia-gateway/src/rest_v1/uploads.rs:197) — os handlers recebem Path(upload_id): Path<Uuid> (crates/garraia-gateway/src/rest_v1/uploads.rs:656 em patch_upload, crates/garraia-gateway/src/rest_v1/uploads.rs:875 em delete_upload) e o extractor do axum recusa valor que nao parseie como UUID (400 antes do handler). Forma canonica de 36 chars [0-9a-f-], sem traversal. CodeQL nao modela Path<Uuid> como sanitizer. Regressao: uploads.rs::staging_path_is_traversal_safe (crates/garraia-gateway/src/rest_v1/uploads.rs:1538). | GAR-491 | | #186 | rust/path-injection | crates/garraia-agents/src/tools/session_workspace.rs:313 | dismissed-false-positive | false_positive | Falso positivo em diretorio_de_verdade (disambiguator diretorio_de_verdade): chamador quente passa self.raiz, o workspace do operador verificado no boot; a linha e checagem read-only com symlink_metadata que nao segue o link — a defesa, nao a vulnerabilidade. Falso positivo: o caminho e raiz.join(nome_do_subdiretorio(session_id)) — 32 hex do digest SHA-256 do id (crates/garraia-agents/src/tools/session_workspace.rs:220), charset sem .., sem separador, sem absoluto; o outro chamador e o boot do gateway sobre o data_dir canonicalizado do operador. CodeQL nao modela SHA-256-hex como sanitizer. Regressao: session_workspace.rs::o_caminho_fica_sempre_debaixo_da_raiz e session_workspace.rs::o_nome_do_subdiretorio_e_sempre_hex_de_32_nunca_escapa_charset (crates/garraia-agents/src/tools/session_workspace.rs:453). | GAR-491 | | #187 | rust/path-injection | crates/garraia-agents/src/tools/session_workspace.rs:335 | dismissed-false-positive | false_positive | Falso positivo em garantir_raiz (disambiguator garantir_raiz): mesma origem do #188. A linha e a checagem read-only com symlink_metadata (recusa Symlink sem seguir) — a defesa contra traversal; o mkdir de componente so, 0700, vem logo abaixo. Falso positivo: o caminho e raiz.join(nome_do_subdiretorio(session_id)) — 32 hex do digest SHA-256 do id (crates/garraia-agents/src/tools/session_workspace.rs:220), charset sem .., sem separador, sem absoluto; o outro chamador e o boot do gateway sobre o data_dir canonicalizado do operador. CodeQL nao modela SHA-256-hex como sanitizer. Regressao: session_workspace.rs::o_caminho_fica_sempre_debaixo_da_raiz e session_workspace.rs::o_nome_do_subdiretorio_e_sempre_hex_de_32_nunca_escapa_charset (crates/garraia-agents/src/tools/session_workspace.rs:453). | GAR-491 | | #188 | rust/path-injection | crates/garraia-agents/src/tools/session_workspace.rs:352 | dismissed-false-positive | false_positive | Falso positivo em criar_fechado: mkdir de um componente so, sem recursive, nasce 0700; link plantado faz mkdir falhar com EEXIST em vez de ser seguido. Falso positivo: o caminho e raiz.join(nome_do_subdiretorio(session_id)) — 32 hex do digest SHA-256 do id (crates/garraia-agents/src/tools/session_workspace.rs:220), charset sem .., sem separador, sem absoluto; o outro chamador e o boot do gateway sobre o data_dir canonicalizado do operador. CodeQL nao modela SHA-256-hex como sanitizer. Regressao: session_workspace.rs::o_caminho_fica_sempre_debaixo_da_raiz e session_workspace.rs::o_nome_do_subdiretorio_e_sempre_hex_de_32_nunca_escapa_charset (crates/garraia-agents/src/tools/session_workspace.rs:453). | GAR-491 | Total: 30 entries (5 from GAR-491 Wave 2 + 16 from GAR-490 Wave 1 PR A + 1 from PR #869 + 5 from Wave 3 cleartext + 2 da triagem de 2026-08-30, #115 e #144 + 1 do PR #1256).

+8 na auditoria CodeQL de 2026-10-10 (alertas #181–#188). Três em crates/garraia-agents/src/tools/session_workspace.rs (#186–#188) e cinco em crates/garraia-gateway/src/rest_v1/uploads.rs (#181–#185), todos rust/path-injection severity=high. Veredito por entrada: falso-positivo — o dado nao-confiavel (session_id, upload-id de URL) so alcanca o filesystem como SHA-256-hex de 32 chars ou UUID canonico, e nenhum dos dois charset expressa traversal; o CodeQL Rust pack nao modela nenhuma das duas transformacoes como sanitizer. Regressao nova: o_nome_do_subdiretorio_e_sempre_hex_de_32_nunca_escapa_charset. Removida em 2026-10-10 (mesma missão): a entrada #179 saiu do ledger porque o alerta vivo está state=fixed — o --apply trata entrada de alerta resolvido como exit 3 (stale) e aborta fail-closed; remoção é o caminho (precedente: #143). Total hoje: 40 entries (33 + 8 − 1).

Correção de contagem, 2026-09-16 (PR #1256). A main declarava 31 entries com 29 linhas na tabela, e a parcela "GAR-491 Wave 2" dizia 6 onde há 5 — a soma nunca fechou. Este PR acrescentou uma entrada (#173) e corrigiu a declaração para 30, que é o número que a tabela sustenta (5 + 16 + 1 + 5 + 2 + 1 = 30). O 31 → 30 no diff, portanto, não é uma entrada removida: é a soma passando a bater. Registrado porque num ledger de alertas de segurança dispensados uma contagem que muda calada é exatamente a espécie de número que ninguém reconfere. Sink novo a esperar depois do merge, 2026-09-17 (PR #1256, rodada 6). crates/garraia-channels/src/whatsapp_linked/session.rs:694 ganhou um tracing::warn!(caminho = %tmp.display(), …) — a rodada 5 o acrescentou para que a falha ao triturar o temporário da sessão deixasse rastro em vez de sumir em silêncio. Ele tem a mesma forma do #173: o que vai para o log é um caminho, e o conteúdo (o blob cifrado) nunca. O ledger foi reancorado só para o #173, porque é o único alerta que a análise da branch emitiu até aqui; a análise pós-merge roda sobre outra base e pode abrir um alerta rust/cleartext-logging novo neste segundo sink. Fica registrado aqui para que ele chegue como coisa prevista, com a justificativa já pronta, e não como surpresa a ser despachada às pressas. Quem o triar: o argumento é o mesmo do #173 — caminho sob o data dir da própria máquina, conta fixa em DEFAULT_ACCOUNT — e a diferença é que aqui nem o segmento de conta é escolhido em runtime, porque tmp é derivado de blob_path().

Bulk-dismissal proibido — cada linha foi revisada individualmente, com referência ao helper guard, ao handler afetado, e à regressão de teste correspondente.

Triagem 2026-08-30 — os 3 alertas que sobraram sem entrada​

Depois da renumeração e do apply dos 21 dismissals, o --dry-run do codeql-rekey-ledger.py deixou exatamente 3 alertas abertos sem entrada: #115, #143 e #144. Os três foram auditados, e o desfecho não foi o mesmo para os três — que é o ponto de auditar um a um.

#143 saiu por remoção de código, não por supressão. O sink era receive_messages() em signal/mod.rs, marcada #[allow(dead_code)], com zero call sites em todo o workspace, duplicando a construção de URL e o GET que o loop dentro de connect() já faz. Ela também era uma armadilha: não passava pelo guard, ao contrário do loop. Foi removida, e o alerta fecha como Fixed. Nenhuma entrada foi criada — o ledger é último recurso (§3), e código morto não precisa de justificativa, precisa de git rm.

#144 e #115 são falso-positivo de verdade e entraram na tabela acima, cada um com a evidência de linha.

O que a auditoria destapou de brinde. O doc comment do próprio vet_signal_cli_url afirmava cobrir "every request this channel makes", nomeando send_text / send_to_group porque carregam o corpo da mensagem. Mas o guard tinha um único call site: connect(). E Channel::send_message alcança os dois sends sem connect() jamais rodar — nada no tipo nem em runtime ordena os dois. Era vazamento real de número de telefone e corpo de mensagem sobre http:// remoto, e o CodeQL não flagou (só apontou :143 e :272). Corrigido no mesmo PR: os sends passam por ensure_url_vetted, que memoiza o veredito num OnceLock — o guard resolve DNS com syscall bloqueante, e SignalConfig é imutável depois de construída, então pagar isso a cada envio seria caro e inútil.

Mitigante que a auditoria também estabeleceu: SignalChannel hoje não é instanciado em lugar nenhum — bootstrap/channels.rs não tem arm "signal". Risco imediato baixo, risco latente alto, que é exatamente o caso em que se fecha a porta antes de alguém abrir.

Re-audit 2026-08-30 — a renumeração aplicada, e o que ela destapou​

As 16 entradas rust/path-injection foram reapontadas dos números antigos (#67-#82) para as duplicatas vivas (#147-#162), fechando o que a Amendment 2026-08-30 previa. A condição que §5.3 impunha para adiar — "as linhas vão mudar de novo, por causa do autofix do #874" — deixou de valer: o dry-run do run 33299044785, sobre c7d7b08, casou as 16 por (rule_id, path, line) sem ambiguidade. Nenhuma justificativa foi tocada: só o ponteiro andou, que é o que mantém a regra §3.1 de pé.

O #113 não era renumeração — era entrada errada. O .json apontava para wizard/mod.rs:640 e descrevia o eprintln! do vault open failed. Esse eprintln! está hoje em :643 (deriva de +3), mas o alerta vivo está em :673, que é outro statement, em outro bloco: o println!(" Cloud provider API key encrypted in vault (entry {entry})."). Fosse o eprintln!, o alerta teria acompanhado a deriva de +3. A §5.3 acima já descrevia o #113 corretamente ("imprime o nome da entrada do cofre") — ou seja, .json e .md discordavam sobre o que a entrada era. Ambos foram corrigidos para o sink real, e a conclusão de falso-positivo continua de pé, agora pela razão certa.

Por que passou despercebido por três meses. plan_rekey pulava toda entrada cujo alert_number seguisse aberto, sem nunca comparar a chave — o comentário dizia "ja aponta para um alerta aberto nesta chave", mas o código só comparava o número. Uma entrada com número vivo e linha podre era invisível ao relatório. O script agora reporta esses casos como drifted e sai com exit 2 (mesma semântica de "stale, reaudite à mão" que o codeql-reapply-dismissals.sh já usa), com cobertura em scripts/security/tests/test_rekey_ledger.py.

§5. Empirical validation​

§5.1 — Persistência do dismissal entre re-análises (2026-05-01)​

Hypothesis: dismissals via REST API persistem entre re-análises CodeQL no mesmo repositório; o state=dismissed não é resetado quando o workflow codeql.yml re-roda na mesma branch (ou após merge para main).

Procedure:

  1. Aplicar dismissal apenas no alerta #43 (credentials.rs:49, dismissed_reason=false_positive) na branch security/gar-491-codeql-suppressions-2026-05-01.
  2. Imediato: gh api repos/michelbr84/GarraRUST/code-scanning/alerts/43 --jq '{state,dismissed_reason}' deve retornar {"state":"dismissed", "dismissed_reason":"false_positive"}.
  3. Push commit no-op (esta seção §5 atualizada com run IDs) na branch para acionar codeql.yml re-run.
  4. Aguardar CodeQL run completar (~16 min, baseline conhecido do PR #106).
  5. Re-query o alerta — state deve continuar dismissed.

Result (preenchido após CodeQL re-run completar em security/gar-491-codeql-suppressions-2026-05-01 — última atualização 2026-05-01):

StepRun IDTimestamp (UTC)Status
Pre-dismissal CodeQL on main (baseline)252025022972026-05-01T04:39:43Zsuccess
Initial dismissal of #43 (PATCH)n/a2026-05-01T12:33:36Zsuccess — state=dismissed, reason="false positive", by michelbr84
Verify state immediate (gh api re-query)n/a2026-05-01T12:33:38Zsuccess — confirmed dismissed
Push commit 34b155b, trigger CodeQL re-run on branch252144647192026-05-01T12:35:44Z (start)success
Re-query #43 post-rerunn/a2026-05-01T~12:43Zstate=dismissed PERSISTED — dismissed_reason="false positive", dismissed_at=2026-05-01T12:33:36Z (unchanged)
Apply remaining 5 dismissals via --applyn/a2026-05-01T~12:44Zsuccess — 5 applied, 0 errors
Final verification: all 6 dismissedn/a2026-05-01T~12:45Zsuccess — all 6 {state:"dismissed"}

Verdict: ✅ Empirical proof PASSED. The REST-dismissal mechanism preserves state=dismissed across CodeQL re-analysis of the same branch. Mechanism approved for the batch.

Final state of all 6 alerts:

{"n":40,"reason":"used in tests","state":"dismissed"}
{"n":41,"reason":"used in tests","state":"dismissed"}
{"n":42,"reason":"used in tests","state":"dismissed"}
{"n":43,"reason":"false positive","state":"dismissed"}
{"n":44,"reason":"used in tests","state":"dismissed"}
{"n":45,"reason":"used in tests","state":"dismissed"}

Idempotency: confirmed empirically — a second --apply run on the same ledger reports 6 skipped, 0 applied, 0 errors. The script's fail-closed validation (rule_id + path + start_line) re-passes for each entry, and the API-form-aware skip check correctly identifies already-dismissed alerts.

§5.2 — O -test não apaga alerta já dispensado (2026-08-29)​

Previsão que foi falsificada. §1 afirmava que as 5 entradas de fixture (mobile_auth.rs, validation.rs) ficariam stale — exit 3 — assim que o CODEQL_EXTRACTOR_RUST_OPTION_CARGO_CFG_OVERRIDES: "-test" chegasse à main. O raciocínio era: sem extração, sem alerta.

Medição. Com o -test já em main desde c3e7521 (PR #869, 13:29Z), o workflow codeql-apply-dismissals.yml rodou em --dry-run sem --alert, percorrendo as 23 entradas:

RunInputsResultado
33261274173apply:false, alert:165DRY-RUN: would PATCH alert #165 state=dismissed reason='won't fix' — applied: 0, dry-run: 1, errors: 0
33261314173apply:true, alert:165applied: 1, skipped: 0, errors: 0
33261350845apply:true, alert:165skip: already dismissed (reason='won't fix') — idempotência confirmada
33261527354apply:false (sem escopo)skipped: 23 (already-dismissed), applied: 0, errors: 0

Verdict: ❌ previsão falsa. Nenhuma das 5 entradas ficou stale. A transição para state=fixed acontece para alertas que estavam open e deixaram de aparecer na análise; um alerta já dismissed permanece dismissed independentemente de o código continuar sendo extraído. O -test reduz alertas novos, não retroage sobre dispensados.

Consequência prática. A armadilha descrita em §2 — "o loop aborta na primeira entrada stale, então uma obsoleta no topo impediria as de baixo" — é uma propriedade real do script, mas não se materializou aqui: a run sem escopo chegou até o #165. O input alert continua útil como bisturi, não como contorno obrigatório.

Ganho colateral, que vale registrar. A validação fail-closed (rule_id + path + start_line) roda antes do skip de já-dispensado (codeql-reapply-dismissals.sh:184-200 vs :229). Como a run fechou com errors: 0 e sem exit 2, ela provou mecanicamente que as 23 entradas ainda apontam para o mesmo rule/path/linha que o ledger registra. Isso cobre a metade mecânica do re-audit de 90 dias exigido por §3 regra 4 — não a metade de julgamento (a justificativa continua fazendo sentido?), que segue pendente. Ver o aviso de expiração no topo deste arquivo.

§5.3 — Os 16 path-injection foram RENUMERADOS, não reabertos (2026-08-29)​

Medido pelo codeql-triage.yml em ba6d86b (run 33263640687), severity=high, state=open: 24 alertas, e 16 deles são rust/path-injection nos mesmos arquivos e linhas que este ledger já dispensa.

Não é regressão nem o dismissal falhando. §5.2 provou que #67–#82 continuam dismissed. O que aconteceu é que a re-análise pós-upgrade do bundle (2.26.3 → 2.26.4) emitiu um segundo conjunto de alertas para os mesmos achados, com números novos. O mapeamento é um-para-um por rule_id + path + linha:

Ledger (dismissed)Live (open)Arquivo:linha
#67#149skins_handler.rs:84
#68#147skins_handler.rs:111
#69#148skins_handler.rs:141
#76#161skins_handler.rs:104
#77#162skins_handler.rs:134
#70#150skills_handler.rs:177
#71#153skills_handler.rs:269
#72#154skills_handler.rs:344
#73#151skills_handler.rs:533
#74#152skills_handler.rs:590
#75#155skills_handler.rs:632
#78#156skills_handler.rs:167
#79#157skills_handler.rs:227
#80#158skills_handler.rs:312
#81#159skills_handler.rs:523
#82#160skills_handler.rs:579

Por que o alert_number NÃO foi atualizado agora. Duas razões, nenhuma delas burocrática:

  1. As linhas vão mudar de novo. O merge de #874 (Copilot Autofix para o #161) inseriu ~25 linhas em skins_handler.rs::get_skin, e as linhas dos sinks pinados já se moveram no fonte: :104 → :136, :134 → :159, :141 → :166, :111 → :117. Só :84 (#67/#149) não mexeu. Renumerar antes da próxima análise do CodeQL sobre ba6d86b produziria um ledger que nasce stale.
  2. O autofix pode ter resolvido #161 de verdade — ele adicionou canonicalize + checagem de prefixo em get_skin, que é exatamente o que o CodeQL queria. Se resolveu, a entrada correspondente sai do ledger em vez de ser renumerada.

Próximo passo, na ordem certa: esperar a análise CodeQL de ba6d86b concluir → rodar codeql-apply-dismissals.yml com apply:false sem escopo → ler o exit 2/exit 3 que ele der → reconciliar .json e .md num PR revisável. §3 regra 5 é explícita: divergência não se auto-corrige.

Os outros 8 High, todos verificados no fonte e todos falso-positivo ou material de ledger, nenhum exigindo código:

Regra#LocalLeitura
rust/cleartext-logging#111, #112config_cmd.rs:213,236Imprime "set"/"not set" e a lista de nomes de provider com chave — nunca o valor. É o invariante de redaction do plan 0046.
rust/cleartext-logging#113wizard/mod.rs:640Imprime o nome da entrada do cofre; o key vai só para vault.set(entry, key).
rust/cleartext-transmission#143–#146signal/mod.rs:143,203, whatsapp/api.rs:48,79Ainda não auditados linha a linha.
rust/cleartext-storage-database#115session_store.rs:1261Ainda não auditado.

Correção de escopo: o plano anterior falava em "33 sites de non-https-url". Essa regra não aparece entre os alertas abertos. A onda High real é 16 + 4 + 3 + 1 = 24.

§6. Failure handling (no global filter fallback)​

Se a empirical proof §5 falhar (state reverte para open após CodeQL re-run):

  1. PARAR — não aplicar os 5 dismissals restantes.
  2. DOCUMENTAR aqui em §5 com run IDs e timestamps do failure.
  3. ABRIR sub-issue Linear GAR-491.X com o problema empírico observado.
  4. NÃO silenciar globalmente via query-filters: exclude (proibido por §3 rule 6).
  5. PR #1 fica em draft permanente até nova decisão.

Decisões aceitáveis para nova sub-issue:

  • Custom query suite .qls com predicates Rust customizados (alta granularidade, alto custo de manutenção).
  • Path-specific approach a definir caso a caso.
  • Manual UI dismissal mantendo este ledger versionado (admite que o script de reaplicação não é confiável; revogação via UI vira fonte de verdade).

§7. Reapply automation​

Script: scripts/security/codeql-reapply-dismissals.sh

Funcionalidades:

  • --dry-run (default em CI; mostra o que seria reaplicado sem PATCH).
  • --apply (oposto explícito; faz PATCH).
  • --check-md (valida que .md ↔ .json listam os mesmos alert_number).
  • --alert <N> (escopo a um único alerta — usado pela empirical proof).

Fail-closed: para cada entry, antes de PATCH, o script confirma que o alerta atual em GitHub tem mesmo rule_id, path, line que o ledger. Se divergir → exit 2 + diagnóstico, manual re-audit obrigatório.

Sem schedule automático nesta PR (per amendment A8). Decisão de agendamento fica em sub-issue follow-up GAR-491.2 quando o mecanismo estiver provado e estável.

§8. See also​