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:
QG002Escapable guard no reembolso: o fluxorefundscondicionaexecute_refund_carda$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.QG005Unshippable policy no reembolso rápido: o reembolso é registrado por dois fluxos,refundsequick 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.QG008Call 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.QG022Rail 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.ymlque 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.