Pular para conteúdo

AUR-609 — Evidência íntegra: a redação não mutila código e o catálogo não dispara em literal ou prosa

Problema

  1. A redação antes do prompt (internal/security/redaction) trocava por [REDACTED] o valor nu de toda chave sensível, fosse ele segredo ou código. API_KEY = os.environ.get("API_KEY") virava API_KEY = [REDACTED]"API_KEY"); o : de := e o primeiro = de == valiam como separador (token :[REDACTED] os.Getenv("TOKEN"), if token =[REDACTED] nil {). O modelo via linha quebrada, e o achado dele que citasse o marcador era descartado (AUR-598).
  2. O catálogo de internal/analysis casava dentro de string e em arquivo de prosa: a docstring """Wrapper seguro; nunca faça subprocess.call("rm " + name).""" gerava analysis/command-injection de severidade error e reprovava a PR com fail_on: [error]; o mesmo em README.md ou num .log.

Desenho

  • assign aceita :, = e := (este inteiro); bareValue nunca começa por = ou :. Um operador de comparação não é mais separador.
  • redactBareKeyValues troca o valor nu a menos que ele seja código (codeValue), por uma gramática lida sobre o texto inteiro, não sobre o valor cortado na aspa ou no espaço:
  • cadeia de chamada: identificadores unidos por ., -> ou ::, cada um opcionalmente chamado, terminando numa chamada (os.environ.get("API_KEY"), os.getenv("X").strip(), Config::token());
  • referência inteira: ${{ … }}, $( … ), ${NOME} ou $NOME (${NOME:-literal} carrega literal e não é referência).

A gramática é ancorada: o código precisa terminar numa fronteira de expressão (fim, espaço, ;, ,, &, quebra de linha ou fechamento) e cobrir o valor inteiro (ou deixar só os fechamentos de uma chamada externa). Parênteses e colchetes só pareiam na mesma linha e fora de aspas, então uma chamada aberta (Xk9(pL2!vQ seguido de quebra de linha, Xk9(pL2 (temp)) nunca é código. Todo o resto é trocado, por mais fraco ou curto que seja (fail-closed: segredo fraco também iria ao provedor do modelo), inclusive cadeia de membros e identificador: password=abc.def123 não se distingue de cfg.key123, e trocar a cadeia inteira não é o defeito original, porque a linha continua válida e nada fica órfão:

entrada saída
apiKey = cfg.APIKey apiKey = [REDACTED]
p := Profile{APIKey: cfg.Key} p := Profile{APIKey: [REDACTED]}
const secret = this.config.secret; const secret = [REDACTED];
connect(password=cfg.db_password) connect(password=[REDACTED])

Quando o valor fica como chamada, a busca de chaves continua dentro dela: token=build(secret=x) vira token=build(secret=[REDACTED]), em qualquer profundidade. Num valor cortado pelos fechamentos de uma chamada externa (f(password=x)) o ) fica fora da troca. Valor citado continua sempre trocado. - Tempo linear: o texto do diff é controlado pelo autor da PR. A busca acha só a chave e o separador (reKVKey); o fim do valor nu, o par de cada colchete e o fim de cada sequência de ) vêm de tabelas montadas numa passada (lexTable), então cada pergunta custa O(1) e cada busca recomeça de onde a anterior parou. 256 KB de chamadas aninhadas levam cerca de 0,23 s (antes do conserto, 147 KB levavam 3 min 17 s). - Forma conhecida é trocada em qualquer lugar pela passada global (redactCredentialShapes), inclusive dentro de chamada mantida: reCred (agora também SendGrid SG\.[\w-]{16,}\.[\w-]{16,}, Slack xox[abpors]-[\w.-]{10,} e Discord: primeiro segmento em grupos de 4 caracteres do alfabeto do base64 de um id numérico [MNO][DTjz][AEIMQUYcgk][w-z0-5], pelo menos 4 grupos, meio de 6 e cauda de 27 ou mais, o que impede uma cadeia de identificadores longos de casar), JWT e corpo DER em base64 MII[A-Za-z0-9+/]{12,}. Checar a forma em cada valor de chamada mantida releria o resto da linha a cada nível. A classificação fica em redaction.go porque o aceite do AUR-009 compila o filtro como esse arquivo único. - internal/prosefiles (pacote folha) é dono da lista de extensões de prosa (.txt .log .md .markdown .rst .adoc); a passada de segurança (internal/review) e o catálogo de análise usam a mesma lista. - As três regras de código do catálogo (command-injection, sql-injection, overly-permissive-file-perms) têm codeOnly e não rodam em prosa. A regra de segredo roda em todo arquivo. - command-injection tem outsideLiteral: matchOutsideLiteral (literalguard.go) rejeita o casamento cujo primeiro byte está dentro de literal (isInsideStringLiteral, com o estado de raw string que entra na linha) e continua procurando depois dele, para uma menção não esconder a chamada real na mesma linha. sql-injection não tem a guarda: no positivo real o SELECT está dentro da string. - Docstring Python de várias linhas (docstring.go): em .py/.pyi o Analyze acompanha a aspa tripla aberta pelas linhas do arquivo novo (adicionadas e de contexto; removida não abre nem fecha), lendo o corpo cru e ignorando """ dentro de comentário ou string de uma linha. Numa linha que começa dentro da docstring, o achado de regra com outsideLiteral só fica se a regra casar também no código depois do fechamento. A regra de segredo e o estado de stripComments não mudam.

As sete formas de AC-001 (TestAUR609KeyValueKeepsExpressionsAndOperators):

forma linha
chamada Python API_KEY = os.environ.get("API_KEY")
declaração curta Go de chamada token := os.Getenv("TOKEN")
comparação Go if token == nil {
comparação estrita JavaScript if (password === undefined) {
chamada de método em literal de struct p := Profile{APIKey: cfg.APIKey()}
chamada encadeada const secret = this.config.getSecret();
referência a secret de workflow LLM_API_KEY: ${{ secrets.LLM_API_KEY }}

ACs e testes

tests/acceptance/AUR-609.sh (cópia de go.mod go.sum cmd internal pkg; pacotes internal/security/redaction, internal/analysis, internal/prosefiles):

id testes
AC-001 TestAUR609KeyValueKeepsExpressionsAndOperators, TestAUR609AssignDoesNotEatComparisonOperators, TestAUR609BareSecretOrCode
AC-002 TestAUR609KeyValueStillMasksLiteralsAndTokens (senha citada, inclusive após :=; token tok_live_…; token gerado sem forma conhecida; JWT; AKIA, todos montados em runtime), TestAUR609BareWeakSecretStaysMasked (password=hunter2, password: supersecretvalue, token = tok), TestAUR609BareSecretOrCode (cada vazamento apontado na revisão: Tr0ub4dor.9xQ, p@ss(1), abc$def, [hunter2], {abc123xyz}, s3cr3t., .s3cr3t, abc=def.ghi, abc.def123, ab.cd, Passw0rd(1)x, chamada aberta que continua nas linhas seguintes ou com espaço dentro, segredo em argumento de chamada mantida (inclusive aninhada), ${DB_PASS:-hunter2}, SendGrid, Discord, Slack, chave AWS com / e ., corpo DER com .; e as expressões que ficam: chamada encadeada, ->, ::, $X, ${X}, $(…), chamada em argumento nomeado), TestAUR609MemberChainMaskedWhole (cadeia de membros trocada inteira, linha válida), TestAUR609RedactionIsLinear (tempo linear)
AC-003 TestAUR609DocstringMentionIsNotCommandInjection (cada corpo casa o padrão cru antes; inclui continuação de raw string), TestAUR609MultilineDocstringIsNotCommandInjection (docstring de três linhas, chamada na linha de fechamento, docstring aberta em linha de contexto; controles: chamada real depois do fechamento, # dentro da docstring, """ em comentário e em string de uma linha, abertura removida, arquivo que não é Python), TestAUR609RealCallStillCommandInjection (inclui menção antes da chamada real na mesma linha), TestAUR609SQLInsideStringStillFound
AC-004 TestAUR609ProseFilesSkipCodeRules (controle em main.go), TestAUR609SecretInProseStillFound, TestAUR609IsProsePath
AC-005 TestAUR609GateDoesNotFailOnDocstring (gate.AnalysisIssuesForGate + gate.ApplyAnalysisGate com fail_on: [error]; a chamada real reprova, controle)

Mutações

id mutação teste que cai
MUT-001 if !bareSecret(value, expr) { → if false && … (sem a checagem de forma de código, os.environ.get( volta a ser mascarado) TestAUR609KeyValueKeepsExpressionsAndOperators
MUT-002 if !isInsideStringLiteral(body, pos, inRaw) { → if true { TestAUR609DocstringMentionIsNotCommandInjection
MUT-003 if prose && rule.codeOnly { → … && false { TestAUR609ProseFilesSkipCodeRules
MUT-004 return ok && expressionStop(s, end) && (…cobertura…) → return ok && end >= 0 (gramática sem âncora no fim: Passw0rd(1)x volta a vazar) TestAUR609BareSecretOrCode
MUT-005 if c == '\r' \|\| c == '\n' { → if false { (colchete pareia através da quebra de linha: DB_PASSWORD=Xk9(pL2!vQ + \nOTHER=1) volta a vazar) TestAUR609BareSecretOrCode
MUT-006 reKVKey volta a consumir o valor (… + assign +)+ bareValue): cada busca retomada dentro de uma chamada mantida relê o resto da linha e a redação fica quadrática TestAUR609RedactionIsLinear (256 KB aninhado ou em sequência abaixo de 2 s, e dobrar a entrada no máximo triplica o tempo)

Compatibilidade

  • Literal citado, forma conhecida e valor nu sem forma de código continuam mascarados, inclusive o fraco (password=hunter2); password := "…" passa a ser mascarado inteiro (antes o = de := virava valor e o literal vazava). Identificador puro depois de chave de segredo (token = tok) continua mascarado: o custo é inofensivo e a regra não abre exceção que um segredo fraco pudesse usar.
  • Risco residual explícito: só um valor nu que, inteiro, tem forma de chamada (Passw0rd(1)) ou de referência ($ecret1) passa como código. O scanner de segredos, que lê o diff bruto, continua vendo esses valores.
  • review.IsProsePath saiu; o chamador único (securitypass.go) usa prosefiles.IsProsePath. review.RuleAppliesToCode continua.
  • Nenhuma saída gravada de tutorial muda: DEMO_API_TOKEN=tok_live_… não tem forma de código e segue mascarado; o canário e a chave do provedor são segredos registrados; nenhum fixture de tutorial tem chamada de comando ou SQL dentro de literal ou em prosa.
  • Limite: a aspa tripla só é acompanhada em Python; em outra linguagem """ não é delimitador e tratá-lo como tal poderia esconder uma chamada real.