Ir para o conteúdo

28 · Caçando bugs nos seus guardrails

O caçador de bugs lê as suas regras de guardrail -- uma política do NeMo Guardrails, a configuração de rails que a roda, o grafo de um agente -- e relata cada caminho por elas até uma ação que elas deveriam impedir, cada um como um achado com uma correção que ele já verificou de novo. Este capítulo é uma varredura do começo ao fim sobre a política da loja: do que ela precisa, como ler um achado, corrigir e aceitar, o portão, e onde a mesma varredura roda na CI e no serviço.

Idiomas

O seletor lê turnos escritos em inglês e em português do Brasil (pt-BR). O idioma é escolhido, nunca adivinhado a partir do turno: na página Settings do console (Language the selector reads), em language numa requisição de plano, ou no settings.language do seu arquivo de vocabulário; sem nenhum deles, inglês. Um turno em português pode ser digitado com os acentos ou sem eles. O português europeu não é uma gramática que o seletor tenha, e um turno em qualquer outra língua não é lido.

Uma varredura, e do que ela precisa

Uma varredura só precisa da política. A política da loja da Parte I, com dois fluxos que uma equipe acrescentou às pressas -- um reembolso só pela intenção, e um recibo chamado sem execute -- e uma configuração de rails que roda o rail Fiddler faithfulness do NeMo:

# Acrescentado às pressas: o estorno, só pela intenção.
define flow quick refund
  user ask quick refund
  bot execute_refund_card

define flow receipts after a charge
  user ask charge
  if $card_on_file
    execute send_receipt(order_id=$order_id)

# Acrescentado às pressas: o recibo, chamado sem execute.
define flow receipts
  user ask receipt
  $sent = send_receipt(order_id=$order_id)
  if $sent
    bot "O recibo está a caminho."
models: []
rails:
  output:
    flows:
      - fiddler bot faithfulness

client.hunt os envia ao modo hunt do serviço (E15):

result = client.hunt("policy.co", inputs={"rails_config": "config.yml"}, gate=True)

print(result.state, "| gate passed:", result.result["gate"]["passed"])
for finding in result.findings:
    where = finding.location
    print(f"{finding.rule} {finding.raw['title']} · {finding.severity} · {where['file']}:{where['line']}")
    print("  ", finding.raw["message"])
    print("   OWASP:", ", ".join(f"{entry.id} {entry.title}" for entry in finding.owasp) or "none")
print("nothing to read:", ", ".join(rule["rule"] for rule in result.nothing_to_read))
print("no rule for:", result.coverage["not_addressed"], "--", result.coverage["not_addressed_reasons"]["LLM05:2026"])
succeeded | gate passed: False
QG002 Escapable guard · high · policy.co:27
   The policy gates 'execute_refund_card' on 'order_paid', and there is a way past it.
   OWASP: LLM03:2026 Excessive Agency, ASI02:2026 Tool Misuse and Exploitation
QG005 Unshippable policy · high · policy.co:35
   line 35: tool action 'execute_refund_card' is registered from flow 'quick refund' as well as from 'refunds'; their guards merge as an OR, so the weaker route decides whether the side effect happens. Register a tool action from exactly one flow.
   OWASP: none
QG008 Call without execute · high · policy.co:45
   'send_receipt' is called without execute, so the action never runs
   OWASP: none
QG022 Rail with a published advisory · high · config.yml:5
   The configuration runs the library rail 'fiddler bot faithfulness' (nemoguardrails), which advisory QGA-2026-0001 describes: Fiddler faithfulness rail raises the jailbreak exception. version: unknown. The advisory affects nemoguardrails >=0.23.0,<=0.24.1, and no fixed version is published. Supply the file that pins the version you install: `stalwart verify --dependencies`, or `dependencies=` to `client.hunt`. With exceptions enabled, the faithfulness rail raises JailbreakDetectionRailException, so a host reads a detected hallucination as a jailbreak. Versions before 0.23.0 were not checked. Fix: Raise FaithfulnessRailException from the faithfulness flow, as the Colang 2 rail does.
   OWASP: LLM04:2026 Supply Chain, ASI04:2026 Agentic Supply Chain Vulnerabilities
nothing to read: QG017, QG018, QG019, QG020, QG021, QG023, QG106, QG107
no rule for: ['LLM05:2026'] -- a property of training data and model weights, which no guardrail policy decides
root cause: permissions | reproduction: not_yet_replayed
fix: Patch: allow 'execute_refund_card' only when: $order_paid in ['refunds', 'quick refund']
re-scanned: verified | over-blocking: proved_free
OWASP's mitigation: OWASP LLM03:2026, "Complete mediation": enforce the check in the rail, which decides from trusted state, rather than relying on the model to decide whether the action is allowed.
taxonomies: OWASP Top 10 for LLM Applications, OWASP Top 10 for Agentic Applications, Guardrail categories (Vassilev 2026), NIST AI RMF functions (NIST AI 100-1)
QG002's tags: security, guardrail, guardrail-category:E, ai-rmf:GOVERN, ai-rmf:MANAGE, OWASP LLM03:2026, OWASP ASI02:2026
results: [('QG002', 'policy.co'), ('QG005', 'policy.co'), ('QG008', 'policy.co'), ('QG022', 'config.yml')]

Quatro achados, e um portão reprovado:

  • QG002 Escapable guard no reembolso: o fluxo refunds condiciona execute_refund_card a $order_paid, e o fluxo rápido chega à mesma ação sem isso. A localização é a linha protegida que a equipe lê como segura; a correção é a condição que fecha o caminho de passagem, em cada fluxo que ela nomeia.
  • QG005 Unshippable policy no reembolso rápido: o reembolso é registrado por dois fluxos, refunds e quick refund, então as guardas deles se combinam num OU e a mais fraca decide. Uma política que não pode ser entregue é o que reprova o portão aqui; os outros achados são avisos.
  • QG008 Call without execute no recibo: a linha é uma expressão que o runtime avalia, não uma ação que ele roda, então o recibo nunca é enviado e o fluxo segue como se fosse. Ele não tem entrada OWASP: é um defeito no que a política faz, não um risco de segurança.
  • QG022 Rail with a published advisory na configuração (abaixo).

A varredura também diz o que não conseguiu ler. Sem catálogo ao lado da política, as regras que leem as declarações de uma ferramenta não tiveram nada para ler, e cada uma é listada em vez de relatada como limpa. Toda ação é tratada como High, porque nenhum catálogo diz quais ações escrevem (abaixo). Uma varredura roda o perfil fast a não ser que você nomeie outro (capítulo 10).

As regras

Todo achado está sob uma regra do catálogo, e uma regra nunca muda de significado. QG0xx são encontradas numa política ou num grafo, QG1xx são itens de revisão (security hotspots: uma pessoa decide), QG2xx dizem o que uma varredura não conseguiu decidir, e QG3xx são encontradas no roteador de um host. O catálogo de regras tem uma página para cada uma: quando ela é levantada, por que importa, como corrigi-la e as suas entradas OWASP. QG201 Undecided é a que um relatório limpo ainda pede para ser lida: uma pergunta que a varredura não conseguiu decidir dentro do seu orçamento é relatada, nunca contada como aprovada.

Lendo um achado

escapable = next(finding for finding in result.findings if finding.rule == "QG002")
print("root cause:", escapable.owasp_root_cause, "| reproduction:", escapable.reproduction)
print("fix:", escapable.fix["patch"], "in", escapable.fix["flows"])
print("re-scanned:", escapable.fix["verified"], "| over-blocking:", escapable.fix["over_blocking"]["outcome"])
print("OWASP's mitigation:", escapable.raw["owasp_remediation"][0])

Cada achado traz a sua regra e o seu título, a sua severidade, a localização no arquivo que você edita, o fluxo, a ação e o controle de que trata, uma impressão digital, a sua reprodução e, onde a varredura consegue fazer uma, uma correção: as linhas depois de nothing to read na saída acima.

A severidade vem do que a ação faz: Critical para uma escrita destrutiva ou irreversível, ou que exige privilégio alto; High para outra escrita; Medium para uma leitura. Uma ação que nenhum catálogo classifica é High, e é por isso que todo achado aqui é: envie o seu catálogo ou modelo extraído com a varredura (catalogue, model) e cada ação é classificada pelo que as suas ferramentas declaram.

A reprodução diz se o caminho de passagem foi visto funcionando: a conversa do achado é reproduzida pelo NeMo Guardrails, e o achado fica reproduced ou not_reproduced. Um achado não reproduzido é uma discordância entre o caçador e o runtime, e é mantido e mostrado primeiro, nunca escondido. A máquina deste exemplo não tem ambiente NeMo, então os seus achados ficam not_yet_replayed; um achado sem nada a reproduzir, como um aviso, é not_applicable.

A correção é verificada antes de você vê-la. O caçador aplica o próprio patch, varre de novo e informa se a política corrigida ainda tem um caminho de passagem (verified onde não tem nenhum) e se o patch remove algo que o controle permite (over_blocking: proved_free onde não remove nada).

Como ler um achado nos termos da OWASP

Todo achado traz as entradas do OWASP Top 10 for LLM Applications 2026 e do OWASP Top 10 for Agentic Applications 2026 de que é uma instância, cada uma com a sua edição (LLM01:2026, ASI04:2026), para que uma equipe de segurança o registre onde ele pertence. Uma regra mapeada para LLM03:2026 Excessive Agency traz também a causa raiz entre colchetes -- funcionalidade, permissões ou autonomia -- como owasp_root_cause. Uma regra sem entrada OWASP diz por quê, no mesmo lugar. O SARIF traz o mesmo mapeamento como taxonomies e relationships, e cada regra recebe a etiqueta OWASP <id>, para que o code scanning possa ser filtrado por ela:

saved = result.artifact("sarif").save("findings.sarif")        # o que o seu envio de code scanning lê
run = json.loads(saved.read_text(encoding="utf-8"))["runs"][0]
rule = next(rule for rule in run["tool"]["driver"]["rules"] if rule["id"] == "QG002")
print("taxonomies:", ", ".join(taxonomy["name"] for taxonomy in run["taxonomies"]))
print("QG002's tags:", ", ".join(rule["properties"]["tags"]))
print("results:", [(r["ruleId"], r["locations"][0]["physicalLocation"]["artifactLocation"]["uri"]) for r in run["results"]])

As três últimas linhas da saída. O SARIF nomeia cada arquivo como você o enviou, então o code scanning põe cada resultado no seu arquivo.

A linha de correção de cada achado traz primeiro a instrução do próprio caçador e depois a mitigação que a OWASP lista para a entrada, nas palavras deste guia: a linha OWASP's mitigation de QG002 acima.

A declaração de cobertura, no nível da política

O resumo de cada varredura traz uma declaração de cobertura: para quais entradas da OWASP o caçador tem regras, quais não tiveram achado nesta varredura e para quais ele não tem regra. As regras do caçador decidem a parte de cada risco que a política e o seu catálogo controlam -- no nível da política -- e nada além disso.

Com as nove regras descritas no capítulo 31 e abaixo, o caçador tem regras para nove das dez entradas LLM e para dez das dez entradas agênticas. A única entrada que nenhuma regra cobre é LLM05:2026 Data and Model Poisoning: uma propriedade dos dados de treinamento e dos pesos do modelo, que nenhuma política de guardrail decide. A declaração a nomeia com esse motivo em toda varredura.

Uma varredura limpa sob uma entrada da OWASP

Uma varredura limpa sob uma entrada da OWASP significa nenhum achado no nível da política, não que o risco esteja ausente. Uma entrada sem achado nesta varredura é listada como tal, o que não diz nada além disso.

O resumo lista também toda regra que não teve nada para ler -- uma regra cujas declarações o catálogo não faz não levanta nada, e o resumo diz isso -- e toda afirmação que a varredura usou como declarada, como uma ferramenta que a equipe declara sandboxed, em assumptions.

Rails com um aviso publicado

Um defeito encontrado num rail de biblioteca viaja com toda cópia do arquivo e com toda configuração que nomeia o rail. O caçador traz um registro de avisos publicados, e QG022 levanta um achado quando um fluxo varrido corresponde a um deles:

  • pelo texto: um fluxo cujo texto normalizado é o fluxo de um aviso -- reindentado, com comentários acrescentados ou removidos, com as continuações de linha unidas -- corresponde, e um fluxo com uma condição alterada não corresponde;
  • por referência: um config.yml que nomeia um rail de biblioteca descrito por um aviso corresponde na linha que o nomeia, como o da loja.

O registro vem com dois avisos, QGA-2026-0001 (o rail de saída fiddler bot faithfulness do NeMo) e QGA-2026-0002 (um fluxo de carrinho publicado com os exemplos do ACE). Toda varredura registra advisories_version e advisories_digest nos seus metadados e nas propriedades da execução do SARIF, para que um achado possa sempre ser lido diante do registro sob o qual foi levantado.

A versão que você instala: dependencies

Uma referência nomeia um rail, não uma versão. Sem um arquivo de dependências, o achado diz version: unknown e nomeia a faixa afetada do aviso. Dê ao caçador o arquivo que fixa o que você instala -- requirements.txt, pyproject.toml, uv.lock ou poetry.lock -- e a referência é conferida com a versão instalada: dentro da faixa, ela é levantada com essa versão; fora dela, nada é levantado. O uv.lock da loja fixa o nemoguardrails 0.24.1:

with Client() as client:
    result = client.hunt("policy.co", inputs={"rails_config": "config.yml"}, dependencies="uv.lock")

advisory = next(finding for finding in result.findings if finding.rule == "QG022")
print(advisory.raw["message"])
print("fingerprint:", advisory.raw["fingerprint"][:12])
The configuration runs the library rail 'fiddler bot faithfulness' (nemoguardrails), which advisory QGA-2026-0001 describes: Fiddler faithfulness rail raises the jailbreak exception. nemoguardrails 0.24.1 is inside the advisory's range >=0.23.0,<=0.24.1. With exceptions enabled, the faithfulness rail raises JailbreakDetectionRailException, so a host reads a detected hallucination as a jailbreak. Versions before 0.23.0 were not checked. Fix: Raise FaithfulnessRailException from the faithfulness flow, as the Colang 2 rail does.
fingerprint: 1b37d969ee3b

Fornecer o arquivo estreita o achado; não cria um novo, e por isso a sua impressão digital é a da primeira varredura. O stalwart verify recebe o mesmo arquivo como a sua opção --dependencies.

Corrigindo, aceitando, e a linha de base

Os dois fluxos apressados, corrigidos:

# O estorno rápido é um estorno: as suas frases entram em `ask refund`, e o único fluxo protegido decide.
define user ask refund
  "estorne o pedido 4411"
  "devolva o dinheiro do cliente"
  "só estorna"
  "me devolve agora, sem perguntas"

define flow refunds
  user ask refund
  if $order_paid
    bot execute_refund_card
  else
    bot refuse

# O recibo, de um fluxo só, chamado com execute e protegido como a cobrança.
define flow receipts
  user ask receipt
  if $card_on_file
    $sent = execute send_receipt(order_id=$order_id)
    if $sent
      bot "O recibo está a caminho."

O reembolso rápido é um reembolso: as suas frases entram em ask refund, então o único fluxo protegido o decide, e o reembolso é registrado por um fluxo só. O recibo é chamado com execute -- e agora é o único fluxo que registra send_receipt, protegido como era o recibo depois de uma cobrança: uma ação de ferramenta registrada por dois fluxos tem as guardas deles combinadas, e a mais fraca decide (QG005), então uma correção que só acrescentasse execute ao segundo fluxo também teria sido encontrada.

Não há nemoguardrails corrigido publicado, então a equipe aceita o achado do aviso como um risco, com uma justificativa e uma data de expiração, no documento de achados da última varredura, e o envia de volta como linha de base:

baseline = dict(first.result)                                 # o documento de achados da última varredura
for entry in baseline["findings"]:
    if entry["rule"] == "QG022":
        entry.update(lifecycle="accepted_risk", expires="2027-01-31",
                     justification="no fixed nemoguardrails is published; exceptions are off here")
with open("baseline.json", "w", encoding="utf-8") as handle:
    json.dump(baseline, handle)

with Client() as client:
    result = client.hunt("policy.fixed.co", inputs={"rails_config": "config.yml"}, dependencies="uv.lock",
                         baseline="baseline.json", gate=True)

print("gate passed:", result.result["gate"]["passed"])
for finding in result.findings:
    print(finding.rule, finding.raw["status"], finding.raw["lifecycle"], "until", finding.raw["expires"])
print("fixed:", sorted(entry["rule"] for entry in result.result["fixed"]))
gate passed: True
QG022 existing accepted_risk until 2027-01-31
fixed: ['QG002', 'QG005', 'QG008']

A linha de base é o documento de achados da varredura anterior. Cada achado é casado com o anterior pela sua impressão digital, que sobrevive a edições em outras partes do arquivo, e é new, existing, reintroduced (corrigido uma vez, de volta) ou relatado em fixed. O seu ciclo de vida é a decisão da equipe, levada pela linha de base: open, confirmed, false_positive ou accepted_risk. Um risco aceito precisa de uma justificativa e de uma data de expiração; no dia em que expira, o achado volta a ficar aberto, com uma nota dizendo isso. Um achado marcado como falso positivo que se reproduz também volta a ficar aberto: um bug reproduzido não é um falso positivo. A linha de base traz também a tabela de regressão: cada achado corrigido é verificado de novo por toda varredura seguinte, como uma das obrigações da própria equipe.

O portão

gate=True pede a decisão do portão junto com os achados: passed, cada condição e os achados que reprovam cada uma. O stalwart verify e a GitHub Action reprovam o build por ela. As condições padrão:

Condição Reprova com
no_new_critical um achado novo ou reintroduzido de severidade Critical, de qualquer tipo; abaixo de Critical um achado é um aviso, que não bloqueia
no_broken_team_row um caminho novo ou reintroduzido que passa por uma linha das suas próprias tabelas, qualquer que seja a severidade: uma propriedade que você enviou com a varredura, ou um achado que você corrigiu, na tabela de regressão (acima). A linha é uma promessa sua
no_new_unshippable uma política nova ou reintroduzida que não pode ir para produção (QG005, QG311)
no_finding_not_reproduced um achado não reproduzido, a não ser que a equipe o tenha marcado como falso positivo ou risco aceito
no_engine_disagreement um ataque realizado num caminho que o caçador disse fechado (QG312, QG016), a não ser que resolvido do mesmo jeito

O portão lê o que é novo, então um achado existente não o reprova: a primeira varredura de um projeto é a sua linha de base. O perfil de qualidade de um projeto troca as condições pelas suas -- um piso de severidade (no_new_severity_at_least:Medium), uma regra (no_new_rule:QG101) -- para um projeto que quer barrar mais (abaixo). count_ways=True conta os caminhos distintos de passagem de cada achado, além de relatar um.

Projetos, CI e SonarQube

client.hunt devolve um HuntResult: findings, cada um um Finding com a sua rule, severity, location, entradas owasp (OwaspEntry), fix, witness e reproduction; o summary; a declaração de coverage; o que teve nothing_to_read; a busca de frases, wordings, um Wordings com um AgentWordings por agente, dizendo quantas frases ela montou, quantas leu e se leu todas; e os artefatos -- o sarif, as conversas que as reproduções leem (hunt_transcript) e o que a etapa do roteador escreveu (hunt_artifact).

Projetos guardam as varreduras de uma política ao longo do tempo. POST /v1/projects cria um; POST /v1/projects/{id}/scans envia uma requisição hunt como POST /v1/solves envia, e o serviço lê a linha de base e o perfil de qualidade do projeto (PUT /v1/projects/{id}/profile) para a varredura e guarda os seus achados no projeto. GET /v1/projects, GET /v1/projects/{id} (a última varredura e a tendência), GET /v1/projects/{id}/scans, GET /v1/projects/{id}/scans/{scan_id}, GET /v1/projects/{id}/findings e GET /v1/projects/{id}/findings/{fingerprint} (um achado com o seu histórico) os leem de volta, e PATCH /v1/projects/{id}/findings/{fingerprint} registra a decisão de uma pessoa -- confirmed, false_positive, accepted_risk com a sua justificativa e data de expiração, ou open --, que a próxima varredura lê pela sua linha de base. As páginas Findings do console mostram os mesmos projetos, varreduras e achados, e registram as mesmas decisões. O projeto de outra organização é um 404. O que você tentou contra um achado no seu próprio ambiente -- uma reprodução da sua testemunha, uma campanha de ataque (capítulo 33) -- é registrado com POST /v1/hunt-attempts como o seu resultado e digests, nunca o seu texto, e listado por GET /v1/hunt-attempts.

A GitHub Action envia os arquivos que um workflow nomeia, submete uma varredura do projeto, envia o SARIF ao code scanning com cada localização movida para o seu caminho no seu repositório, comenta os achados novos, corrigidos e existentes no pull request e reprova a verificação quando o portão reprova. Uma varredura no branch padrão é a linha de base. O SonarQube lê o mesmo SARIF como issues externas (sonar.sarifReportPaths), com as mesmas regras, severidades, localizações e impressões digitais; mantenha o ciclo de vida na linha de base do caçador, que é o que o portão dele lê.

O que cada plano inclui. Todo plano faz varreduras (hunt) e lê os achados de um projeto contados, com o mais grave por inteiro. hunt_details -- todo achado por inteiro, o histórico e a tendência de um projeto, o perfil de qualidade e o ciclo de vida de cada achado -- vem com os planos pagos (capítulo 40).

Os limites

  • O caçador trabalha sobre as regras em torno das ações, não sobre os prompts.
  • Um achado que a reprodução não confirmou é mantido, não descartado: ele reprova o portão até a equipe marcá-lo como falso positivo ou risco aceito.
  • Uma varredura limpa cobre as regras que leu e nenhuma outra. Uma regra cuja declaração falta não levanta nada, e o resumo a lista como não tendo tido nada para ler.
  • Um achado diz que há um caminho pelas suas regras; uma correção fecha esse caminho. Nenhum dos dois diz que a ação é a certa para o turno, o que cabe ao roteador decidir (capítulo 29).
  • O registro de avisos traz os avisos publicados na versão que a varredura nomeia; um rail com um defeito que ninguém publicou não é encontrado por QG022.