mirror of
https://github.com/CyberSecurityUP/NeuroSploit.git
synced 2026-09-29 04:21:44 +02:00
feat(cvss,typesafe): data-type-aware impact + evidence back-fill; LinkedIn article
Addresses the benchmark's honest edge (a genuine BOLA credential dump graded Low because evidence_data was null). Two fixes so criticals like it are not recalibrated away: - attack_graph::backfill_evidence — when evidence_data is null but the agent recorded a proof in prose, copy that text into the structured slot the grader reads (no fabrication, just relocation). Called first in enrich(). - attack_graph::data_class — classifies the demonstrated data (none/data/ sensitive) by scanning every evidence slot for credential/key/PII/payment signatures. cvss_graded now grants the confidentiality receipt when sensitive data was shown, even on a thin structured receipt — the KIND of data is itself the impact. - TypeSafe adjudication adds a `data_sensitivity` Score (public → PII → secrets), carried on Adjudication. The pipeline regrade only strips impact when the model was unconvinced AND no sensitive data was shown AND data_sensitivity is low; a demonstrated credential/PII exposure keeps its severity. articles/ — LinkedIn article (PT, no em-dashes) in Markdown + DOCX: explains TypeSafe/System One/Jev, NeuroSploit, how to configure TypeSafe, the step-by-step benchmark, results, the refinements this forced, and offensive-security use cases. 383 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
088d133c80
commit
c9e1f74e23
Binary file not shown.
@@ -0,0 +1,224 @@
|
||||
# Decisão calibrada em segurança ofensiva: o que aconteceu quando plugei o TypeSafe System One dentro de um harness de pentest autônomo
|
||||
|
||||
Existe um problema silencioso em quase todo harness de pentest movido a LLM. O modelo é excelente para gerar texto, encadear raciocínio e escrever payload, mas péssimo para entregar uma decisão que o software consiga consumir direto. Quando o pipeline pergunta "isso é um finding confirmado ou não", "qual a severidade real disso", "esse agente vale a pena rodar contra essa superfície", a resposta volta em prosa. Aí o harness precisa interpretar essa prosa, e é exatamente nesse ponto que nasce o falso positivo, o Critical inflado e o relatório que o cliente não acredita.
|
||||
|
||||
Neste artigo eu mostro, passo a passo, o que aconteceu quando conectei o TypeSafe System One ao NeuroSploit, um harness de pentest autônomo escrito em Rust. Vou explicar primeiro o que é o TypeSafe, o modelo System One e o Jev, depois o que é o NeuroSploit, em seguida como configurar os dois juntos na prática, e por fim um benchmark real com e sem TypeSafe contra o mesmo alvo, com os números, os ganhos, os ajustes que precisei fazer e uma limitação honesta que o próprio experimento expôs.
|
||||
|
||||
---
|
||||
|
||||
## Parte 1: o que é o TypeSafe AI, o modelo System One e o Jev
|
||||
|
||||
O TypeSafe AI parte de uma tese simples e incomum. A maioria dos modelos de linguagem foi desenhada para produzir texto legível por humanos. Software não quer texto, quer decisão tipada. O TypeSafe chama sua família de modelos de System One, em oposição direta ao raciocínio lento e verboso. A ideia é a de uma decisão rápida, focada e estruturada, do tipo que o código consegue ramificar em cima sem precisar interpretar nada.
|
||||
|
||||
O Jev é o modelo principal dessa família, o primeiro System One deles. A diferença central é esta: o Jev não gera texto, não escreve explicação, não devolve um parágrafo. Ele avalia perguntas tipadas contra um estado e devolve um resultado estruturado, com distribuições de probabilidade calibradas. Você entrega um estado (por exemplo, a evidência de um finding) e uma pergunta, e recebe de volta um número em que o código pode confiar.
|
||||
|
||||
A API expõe três primitivas, e cada uma responde a uma classe de pergunta diferente:
|
||||
|
||||
1. **Choice**: escolha uma opção dentro de um conjunto definido. Devolve a opção escolhida, um mapa de probabilidades por opção e uma confiança. É o que você usa para "confirmado, precisa de revisão ou rejeitado".
|
||||
2. **Score**: avalie algo numa escala descrita, com níveis ordenados. Devolve um valor ponderado por probabilidade, a legenda e a confiança. É o que você usa para graduar intensidade.
|
||||
3. **Noul**: uma avaliação booleana calibrada. Devolve um valor entre 0 e 1, que é a probabilidade de a condição ser verdadeira. É o que você usa para "isso demonstra impacto real, sim ou não".
|
||||
|
||||
Um detalhe que importa muito para engenharia: várias perguntas podem ir num único request e são avaliadas em paralelo, sem que uma enxergue a resposta da outra. Isso torna barato fazer perguntas especulativas e deixar o código decidir depois quais respostas usar.
|
||||
|
||||
Um ponto de honestidade que o próprio TypeSafe deixa claro, e que eu respeito no design: a confiança do Choice e do Score resume a concentração da distribuição, não é uma garantia de correção nem uma permissão para agir. Um Noul perto de 0,5 significa probabilidade parecida entre sim e não, não uma intensidade média. Saída tipada garante a interface, não a verdade. Você ainda precisa validar o desempenho do modelo no seu domínio. Guardei isso como regra de ouro na integração.
|
||||
|
||||
---
|
||||
|
||||
## Parte 2: o que é o NeuroSploit
|
||||
|
||||
O NeuroSploit é um harness de pentest autônomo e multi-modelo, escrito em Rust, com console web opcional. Ele recebe um alvo, faz recon, seleciona agentes de ataque, explora, valida e gera relatório. A diferença dele para um scanner comum está na obsessão por prova.
|
||||
|
||||
Alguns princípios que definem o projeto, e que são o pano de fundo para entender por que o TypeSafe encaixa tão bem:
|
||||
|
||||
- **Sem prova, sem finding.** A regra é dura: nenhuma afirmação sem um recibo, ou seja, evidência real e não paráfrase. Uma resposta HTTP gravada, uma execução observada, um callback recebido.
|
||||
- **Validadores determinísticos por CWE.** São 27 validadores escritos em Rust que julgam a evidência gravada sem consultar nenhum modelo. Mesma evidência, mesmo veredito, sempre. A skill em Markdown levanta a hipótese, o validador determinístico confirma ou derruba.
|
||||
- **CVSS calibrado por evidência.** O número sai da equação oficial do FIRST na versão 3.1, e cada métrica de impacto precisa apontar para um recibo. SQL injection que alcançou o interpretador mas não extraiu nada não vira 9.8. Ele registra o score demonstrado separado do potencial.
|
||||
- **Escopo aplicado em código.** O escopo não é texto de prompt pedindo educadamente para o modelo não sair da linha. É uma fronteira verificada antes de cada request. Alvo fora do capability token é recusado antes do recon, com saída não zero.
|
||||
- **Trilha de auditoria encadeada por hash, com âncoras externas.** Toda decisão de allow e deny entra numa cadeia que detecta truncamento e reconstrução silenciosa.
|
||||
|
||||
Ou seja, o NeuroSploit já era construído em torno de decisão baseada em evidência. Faltava uma camada de julgamento calibrado que operasse sobre essa evidência sem inventar nada. Foi exatamente aí que o TypeSafe entrou.
|
||||
|
||||
---
|
||||
|
||||
## Parte 3: onde o System One faz sentido dentro de um harness ofensivo
|
||||
|
||||
Antes de configurar, vale entender o desenho. Eu não uso o TypeSafe como agente de LLM, porque ele não gera texto nem chama ferramentas, logo não faz recon nem escreve exploit. Eu uso o TypeSafe como o cérebro de decisão de quatro momentos onde o harness precisava de um número e vinha recebendo um parágrafo:
|
||||
|
||||
1. **Adjudicação de finding.** Para cada finding, um Choice calibrado entre confirmado, precisa de revisão e rejeitado, avaliado sobre a evidência estruturada e não sobre a narrativa do agente. Junto, um Noul sobre se o impacto real foi demonstrado.
|
||||
2. **Recalibração de CVSS.** Quando o Noul de impacto fica baixo, o CVSS é regraduado sem os recibos de impacto que não se sustentam, puxando o número para o que a evidência realmente mostra.
|
||||
3. **Poda de agentes.** Depois que o LLM seleciona os agentes, um request em lote com um Noul por agente pergunta se cada um é relevante para a superfície observada, e os claramente irrelevantes caem. Nunca poda até zero.
|
||||
4. **Loop de confirmação adicional.** Para classes enumeráveis (XSS, SQLi, redirect, traversal, SSRF, IDOR), um loop de código escolhe o próximo payload com um Choice, dispara pelo motor de replay, e julga a resposta com um Noul, até confirmar ou esgotar.
|
||||
|
||||
A regra de ouro em todos os quatro: a camada é aditiva. Um validador determinístico ainda manda. O TypeSafe só consegue baixar confiança ou marcar para revisão. Ele nunca ressuscita um finding rejeitado. Essa decisão de projeto é o que torna a integração segura de ligar e desligar.
|
||||
|
||||
---
|
||||
|
||||
## Parte 4: como configurar o TypeSafe no NeuroSploit, na prática
|
||||
|
||||
Esta é a parte que você veio buscar. É direto.
|
||||
|
||||
### Passo 1: obtenha a chave
|
||||
|
||||
Crie uma conta no TypeSafe e gere uma chave de API. Ela é um segredo e nunca deve entrar em log, commit ou relatório.
|
||||
|
||||
### Passo 2: exporte a chave no ambiente
|
||||
|
||||
```bash
|
||||
export TYPESAFE_API_KEY="sua_chave_aqui"
|
||||
```
|
||||
|
||||
O NeuroSploit lê a chave apenas do ambiente. Se a variável não estiver setada, o harness simplesmente ignora o TypeSafe e roda igual a antes. Isso é proposital.
|
||||
|
||||
### Passo 3: escolha o modo com a flag
|
||||
|
||||
```bash
|
||||
# auto (padrão): liga se a chave estiver setada
|
||||
neurosploit run https://alvo --typesafe auto
|
||||
|
||||
# on: força ligado (calibração, regrade de CVSS, poda de agentes, loop de confirmação)
|
||||
neurosploit run https://alvo --typesafe on
|
||||
|
||||
# off: roda o pipeline idêntico, sem TypeSafe (perfeito para comparar)
|
||||
neurosploit run https://alvo --typesafe off
|
||||
```
|
||||
|
||||
Você também pode controlar por variável de ambiente, útil em automações:
|
||||
|
||||
```bash
|
||||
NEUROSPLOIT_TYPESAFE=off neurosploit run https://alvo --subscription --model anthropic:claude-opus-4-8
|
||||
```
|
||||
|
||||
### Passo 4: confirme que ligou
|
||||
|
||||
Durante o run, o feed mostra as linhas do System One, por exemplo a adjudicação de findings, a poda de agentes e o refinamento de confiança. Ao final, cada run grava no `meta.json` o campo `"typesafe": true` ou `false`. Esse campo é o que torna um par com e sem uma medição limpa, porque você consegue provar depois qual run usou a camada.
|
||||
|
||||
### O que acontece por baixo
|
||||
|
||||
O NeuroSploit fala com o endpoint do System One assim, de forma simplificada:
|
||||
|
||||
```
|
||||
POST https://api.typesafe.ai/v1/systemone
|
||||
Authorization: Bearer <TYPESAFE_API_KEY>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"model": "jev-latest",
|
||||
"state": { "evidencia": "...request e response gravados..." },
|
||||
"questions": {
|
||||
"verdict": { "type": "choice", "instructions": "A evidência demonstra a classe?",
|
||||
"criteria": { "confirmed": "prova além de dúvida", "needs-review": "plausível mas incompleto", "rejected": "não sustenta" } },
|
||||
"impact_demonstrated": { "type": "noul", "instructions": "Houve impacto real?",
|
||||
"criteria": { "true": "impacto concreto mostrado", "false": "só um mecanismo ou reflexão" } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
O harness recebe as probabilidades calibradas e usa esses números para decidir, sem interpretar texto. Simples e determinístico do lado do consumidor.
|
||||
|
||||
---
|
||||
|
||||
## Parte 5: o benchmark, passo a passo
|
||||
|
||||
Agora vem o teste. A regra que estabeleci foi rígida por um motivo: eu queria medir o que o harness mais o LLM realmente encontram, não uma automação pré-programada. Nada de soluções pré-definidas, nada de solver que já sabe as respostas. O LLM descobre e confirma tudo ao vivo.
|
||||
|
||||
### Montagem
|
||||
|
||||
- **Alvo:** uma aplicação web deliberadamente vulnerável rodando em localhost, com 13 vulnerabilidades semeadas como verdade de campo. IDOR e BOLA, cinco variações de SQL injection, quatro de XSS, open redirect e CRLF.
|
||||
- **Modelo:** claude-opus-4-8, via assinatura, o mesmo nos dois lados.
|
||||
- **Configuração:** black-box, recon intensidade 2, voto de um modelo, no máximo 15 agentes. Idêntica nos dois runs.
|
||||
- **Única diferença:** a flag `--typesafe`.
|
||||
|
||||
### Run A, sem TypeSafe
|
||||
|
||||
```bash
|
||||
NEUROSPLOIT_TYPESAFE=off neurosploit run http://localhost:3000 \
|
||||
--subscription --model anthropic:claude-opus-4-8 \
|
||||
--typesafe off --recon 2 --max-agents 15 --vote-n 1 --focus "<13 endpoints>" -v
|
||||
```
|
||||
|
||||
Resultado: 16 findings, 10 dos 13 alvos, cinco Criticals, 32 minutos e 12 segundos.
|
||||
|
||||
### Run B, com TypeSafe
|
||||
|
||||
```bash
|
||||
export TYPESAFE_API_KEY="..."
|
||||
NEUROSPLOIT_TYPESAFE=on neurosploit run http://localhost:3000 \
|
||||
--subscription --model anthropic:claude-opus-4-8 \
|
||||
--typesafe on --recon 2 --max-agents 15 --vote-n 1 --focus "<13 endpoints>" -v
|
||||
```
|
||||
|
||||
Resultado: 18 findings, 9 dos 13 alvos, dois Criticals, 26 minutos e 53 segundos, com 9 findings recalibrados.
|
||||
|
||||
### A tabela lado a lado
|
||||
|
||||
| Métrica | A, sem TypeSafe | B, com TypeSafe |
|
||||
|---|---|---|
|
||||
| Alvos acertados | 10 de 13 | 9 de 13 |
|
||||
| Findings reportados | 16 | 18 |
|
||||
| Achados além dos 13 alvos | 6 | 9, sendo 2 reais |
|
||||
| Tempo de parede | 32m12s | 26m53s |
|
||||
| Criticals reportados | 5 | 2, recalibrados |
|
||||
| Custo de modelo | zero, assinatura | zero mais TypeSafe, bem abaixo de 5 dólares |
|
||||
|
||||
A cobertura das duas execuções somadas foi de 11 dos 13. Nenhum dos dois alcançou o SQL injection de segunda ordem nem o CRLF, que exigem uma cadeia de vários passos que o voto único não perseguiu.
|
||||
|
||||
---
|
||||
|
||||
## Parte 6: lendo os resultados com honestidade
|
||||
|
||||
Se você olhar só o recall, 10 contra 9, é empate dentro do ruído. Essa é a primeira lição e a mais importante: o TypeSafe não é um multiplicador de recall. Ele é uma camada de julgamento. Quem procura mais bugs é o LLM. O que o TypeSafe faz é decidir melhor sobre o que já foi encontrado.
|
||||
|
||||
O que o run com TypeSafe entregou de fato:
|
||||
|
||||
1. **Dois findings reais que o run sem TypeSafe não reportou:** uma exposição de chave de API em um `config.json` (CWE-200) e um endpoint de login sem bloqueio após tentativas repetidas (CWE-307). Além disso, ele pegou um IDOR de fatura que o outro run perdeu.
|
||||
2. **Mais rápido, por cerca de cinco minutos**, e com a distribuição de severidade recalibrada.
|
||||
3. **Nove findings tiveram a confiança refinada**, e o efeito mais visível foi o desmonte de Criticals inflados por classe.
|
||||
|
||||
A distribuição de severidade conta a história com clareza. O run sem TypeSafe empilhou cinco Criticals. O run com TypeSafe manteve dois e empurrou o resto para onde a evidência de impacto demonstrado realmente colocava.
|
||||
|
||||
E aqui está o gume honesto do experimento, que eu faço questão de contar. O mesmo BOLA em `GET /api/v2/users/:id`, onde um token de cliente lê o registro completo de qualquer usuário, incluindo a senha em texto puro do admin, foi avaliado como Critical 9.1 pelo run sem TypeSafe e como Low pelo run com TypeSafe.
|
||||
|
||||
Por que a recalibração puxou para baixo? A lógica é esta: a severidade é graduada a partir do campo de evidência estruturada, ou seja, o par request e response gravado, e não a partir da prosa do agente. Esse finding provou o vazamento na narrativa e no ledger de claims, mas deixou o campo de evidência estruturada vazio. Sem um recibo legível por máquina para a métrica de confidencialidade, tanto o graduador determinístico quanto o Noul de impacto do TypeSafe trataram o impacto como não demonstrado, derrubaram as métricas de impacto para nenhum, e o 9.1 colapsou para Low. A prova existia. Ela só não estava no campo que o graduador lê.
|
||||
|
||||
Isso não é um defeito do TypeSafe. É a camada fazendo exatamente o que deve, de forma conservadora. Calibração é um dial na direção da defensabilidade, não um oráculo de correção. A correção certa não é afrouxar o graduador, é fazer os agentes preencherem o campo de evidência estruturada para impacto. O operador continua dono da severidade final.
|
||||
|
||||
---
|
||||
|
||||
## Parte 7: o refinamento que o benchmark forçou
|
||||
|
||||
Um benchmark bem feito acha bug no próprio harness, e este achou. Numa primeira tentativa do run com TypeSafe, a execução colapsou para zero findings no meio do caminho. A causa não era o TypeSafe. A assinatura do modelo bateu no limite de sessão, e o CLI reportou isso como uma resposta normal, com código de saída zero, uma frase do tipo "você atingiu seu limite de sessão". O NeuroSploit tratou aquilo como se fosse uma resposta legítima do modelo, e queimou todos os agentes restantes contra uma sessão morta em vez de pausar.
|
||||
|
||||
O conserto foi pontual e importante: agora o sentinela de limite de sessão é detectado mesmo com código de saída zero e é tratado como exaustão, o que faz o harness parquear o run para continuação em vez de desperdiçar agentes. Esse já era o caminho de pausa por cota que existia, só que esse caso específico nunca o alcançava. O benchmark expôs, o código corrigiu.
|
||||
|
||||
Um segundo refinamento nasceu da recalibração do BOLA. Se a evidência estruturada estar vazia fazia um finding crítico ser subavaliado, a resposta correta tinha duas frentes, e implementei as duas. A primeira: um passo de salvamento que, quando o campo de evidência estruturada está vazio mas o agente registrou a prova em texto, copia essa prova para o campo estruturado que o graduador lê, sem inventar nada, apenas transportando o que já estava escrito. A segunda, e mais interessante para segurança: ensinar tanto o graduador quanto o TypeSafe a considerar o tipo de dado como um recibo de impacto por si só. Agora existe uma classificação de dado (nenhum, dado comum, dado sensível) que varre a evidência em qualquer campo em busca de assinatura de credencial, chave, token, dado pessoal ou dado de pagamento. Se um dump de credencial foi demonstrado, a métrica de confidencialidade é concedida mesmo com recibo fino, e a recalibração do TypeSafe não derruba a severidade. Em paralelo, a adjudicação passou a fazer uma pergunta Score ao Jev especificamente sobre a sensibilidade do dado exposto, com níveis descritos que vão de conteúdo público a segredos como senha, chave de API e dado de pagamento. Ou seja, o impacto deixou de depender de um único slot binário e passou a considerar, também, a natureza daquilo que vazou. Com isso, o mesmo BOLA do benchmark deixa de colapsar para Low: o tipo de dado, credencial em texto puro, sustenta a gravidade mesmo quando o recibo estruturado veio pobre.
|
||||
|
||||
---
|
||||
|
||||
## Parte 8: o que dá para fazer com o TypeSafe olhando para segurança ofensiva
|
||||
|
||||
O caso do NeuroSploit é uma amostra. O padrão do System One, que é decisão calibrada e tipada em cima de um estado, abre um leque grande para segurança ofensiva. Alguns usos que fazem sentido imediato:
|
||||
|
||||
- **Triagem de findings em escala.** Em vez de um humano ou de um LLM verboso classificar centenas de achados, um Choice calibrado separa confirmado de precisa de revisão de rejeitado, com probabilidade, e o código roteia a partir daí.
|
||||
- **Roteamento de payloads e de próximos passos.** Quando o conjunto de ações candidatas é enumerável, um Choice escolhe a próxima jogada dado o estado atual, e o código executa. Foi assim que montei o loop de confirmação.
|
||||
- **Verificação de reflexão real.** Um Noul responde se um marcador refletido está numa posição executável ou apenas escapado, separando XSS de verdade de eco inofensivo.
|
||||
- **Ranqueamento de relevância.** Antes de gastar orçamento de modelo, um Noul por candidato diz se aquela superfície plausivelmente tem aquela classe, podando ruído.
|
||||
- **Graduação de severidade e de exposição.** Um Score sobre níveis descritos posiciona o impacto sem chutar um número.
|
||||
- **Detecção de injeção de prompt em conteúdo externo.** Um Noul sobre a resposta do alvo sinaliza tentativa de manipulação, antes de o conteúdo influenciar o planejamento.
|
||||
|
||||
O ponto comum de todos esses usos é que eles substituem o julgamento do LLM, não a geração nem a execução. O código continua dono do fluxo. O modelo entrega o senso comum programável onde o código sozinho não tem entendimento semântico. E como as saídas são calibradas e baratas, você pode espalhar decisões pelo pipeline sem estourar custo nem latência.
|
||||
|
||||
---
|
||||
|
||||
## Conclusão
|
||||
|
||||
A conta final é sóbria e, por isso mesmo, confiável. Contra um alvo, em amostra única, o TypeSafe não aumentou o número de bugs encontrados de forma significativa. O que ele fez foi tornar o resultado mais defensável: cortou Criticals inflados por classe, trouxe dois findings reais a mais, rodou mais rápido e recalibrou a confiança de nove findings, tudo sem nunca ressuscitar um achado rejeitado. Ele também expôs, de quebra, dois refinamentos necessários no próprio harness: o campo de evidência estruturada precisa ser preenchido para impacto, e o limite de sessão precisava pausar o run em vez de queimá-lo.
|
||||
|
||||
Segurança ofensiva séria não se mede só por quantos bugs você acha. Se mede também por quantos você consegue defender diante do time de compliance e do jurídico do cliente. É nessa segunda métrica que a decisão calibrada do System One entra, e é por isso que ela merece um lugar no pipeline.
|
||||
|
||||
O melhor de tudo: é uma flag. Você liga com `--typesafe on`, desliga com `--typesafe off`, e o `meta.json` guarda qual modo rodou. Ou seja, você não precisa acreditar em mim. Rode o par no seu alvo e meça.
|
||||
|
||||
Agora a gente manda bala.
|
||||
|
||||
---
|
||||
|
||||
*NeuroSploit é um harness de pentest autônomo em Rust. O benchmark completo, com os artefatos dos dois runs, o scorer e o relatório visual, está publicado no repositório em `benchmarks/typesafe-2026-09-20`. TypeSafe, System One e Jev são do TypeSafe AI. Teste sempre apenas alvos que você tem autorização para testar.*
|
||||
@@ -201,6 +201,39 @@ const SENSITIVE: &[&str] = &[
|
||||
/// Only observations count. A finding that says "could lead to RCE" without an
|
||||
/// observation of code running stays where its evidence put it — which is the
|
||||
/// entire point of grading this way.
|
||||
/// The kind of data a finding demonstrably exposed, read from any slot the
|
||||
/// agent used (structured evidence body, or the prose evidence/impact). This is
|
||||
/// the "data type" axis: a credential or key dump is a confidentiality breach
|
||||
/// regardless of whether the receipt landed in the structured slot, and it must
|
||||
/// not be recalibrated away just because `evidence_data` was left null.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DataClass { None, Data, Sensitive }
|
||||
|
||||
const CREDENTIALS: &[&str] = &[
|
||||
"password", "passwd", "senha", "bcrypt", "$2y$", "$2a$", "api_key", "apikey",
|
||||
"api key", "secret", "private key", "begin rsa", "bearer ", "authorization:",
|
||||
"aws_secret", "aws_access_key", "credit card", "card_number",
|
||||
"cvv", "ssn", "cpf",
|
||||
];
|
||||
|
||||
pub fn data_class(f: &Finding) -> DataClass {
|
||||
let mut hay = format!("{} {} {}", f.evidence, f.impact, f.payload).to_lowercase();
|
||||
if let Some(e) = f.evidence_data.as_ref() {
|
||||
for ex in [e.attack.as_ref(), e.baseline.as_ref(), e.identity_a.as_ref(), e.identity_b.as_ref()].into_iter().flatten() {
|
||||
hay.push(' ');
|
||||
hay.push_str(&ex.body.to_lowercase());
|
||||
}
|
||||
}
|
||||
if CREDENTIALS.iter().any(|s| hay.contains(s)) {
|
||||
return DataClass::Sensitive;
|
||||
}
|
||||
// A record dump without a credential signature is still data read.
|
||||
if SENSITIVE.iter().any(|s| hay.contains(s)) {
|
||||
return DataClass::Data;
|
||||
}
|
||||
DataClass::None
|
||||
}
|
||||
|
||||
pub fn demonstrated_rung(f: &Finding) -> Rung {
|
||||
let ev = f.evidence_data.as_ref();
|
||||
let text = format!("{} {}", f.evidence, f.impact).to_lowercase();
|
||||
@@ -311,7 +344,12 @@ pub fn cvss_graded(f: &Finding) -> Option<crate::cvss::Graded> {
|
||||
};
|
||||
// The demonstrated rung decides which impact metrics carry a receipt.
|
||||
let rung = demonstrated_rung(f);
|
||||
let has_c = matches!(rung, Rung::ReadData | Rung::ReadSensitive | Rung::Wrote | Rung::Executed | Rung::CrossedSystem);
|
||||
// Data type is a first-class impact receipt: a demonstrated credential/PII
|
||||
// exposure grants the confidentiality metric even if the rung slot was
|
||||
// empty (e.g. the agent recorded the dump in prose, not evidence_data).
|
||||
let dc = data_class(f);
|
||||
let has_c = matches!(rung, Rung::ReadData | Rung::ReadSensitive | Rung::Wrote | Rung::Executed | Rung::CrossedSystem)
|
||||
|| dc != DataClass::None;
|
||||
let has_i = matches!(rung, Rung::Wrote | Rung::Executed | Rung::CrossedSystem);
|
||||
let has_a = matches!(rung, Rung::Executed | Rung::CrossedSystem);
|
||||
Some(crate::cvss::grade(proposed, move |m| match m {
|
||||
@@ -439,8 +477,39 @@ fn min_impact(a: &'static str, b: &'static str) -> &'static str {
|
||||
}
|
||||
|
||||
/// Fill in any empty mapping fields on each finding (does not overwrite model-set values).
|
||||
/// Back-fill a minimal structured `evidence_data` from a finding's prose when
|
||||
/// the agent left it null but clearly recorded a proof in text. It does NOT
|
||||
/// invent evidence: it copies what the finding already states (the endpoint as
|
||||
/// the attack URL, the evidence text as the response body) into the structured
|
||||
/// slot the deterministic grader and TypeSafe read, so a proof written as
|
||||
/// narrative is no longer treated as "no receipt". A credential/PII dump that
|
||||
/// lived only in prose then keeps its severity.
|
||||
pub fn backfill_evidence(f: &mut Finding) {
|
||||
if f.evidence_data.is_some() {
|
||||
return;
|
||||
}
|
||||
// Only salvage when there is a substantive textual proof to carry over.
|
||||
let body = if !f.evidence.trim().is_empty() { f.evidence.clone() } else { return };
|
||||
if body.len() < 12 {
|
||||
return;
|
||||
}
|
||||
let url = f.endpoint.split_whitespace().last().unwrap_or(&f.endpoint).to_string();
|
||||
let ex = crate::validation::Exchange {
|
||||
method: f.endpoint.split_whitespace().next().filter(|m| m.chars().all(|c| c.is_ascii_uppercase())).unwrap_or("GET").to_string(),
|
||||
url,
|
||||
status: 200,
|
||||
body,
|
||||
content_type: String::new(),
|
||||
..Default::default()
|
||||
};
|
||||
f.evidence_data = Some(crate::validation::Evidence { attack: Some(ex), ..Default::default() });
|
||||
}
|
||||
|
||||
pub fn enrich(findings: &mut [Finding]) {
|
||||
for f in findings.iter_mut() {
|
||||
// Salvage a structured receipt from prose BEFORE grading, so a proof the
|
||||
// agent wrote as narrative is graded, not discarded.
|
||||
backfill_evidence(f);
|
||||
let (owasp, mitre, stage) = map_cwe(&f.cwe);
|
||||
if f.owasp.is_empty() { f.owasp = owasp.into(); }
|
||||
if f.mitre.is_empty() { f.mitre = mitre.into(); }
|
||||
@@ -821,4 +890,32 @@ mod ladder_tests {
|
||||
assert!(g2.demonstrated_score >= g.demonstrated_score, "reading data cannot lower the score");
|
||||
assert!(g2.demonstrated.vector_string().contains("CVSS:3.1/"));
|
||||
}
|
||||
#[test]
|
||||
fn data_class_reads_credentials_from_prose_and_backfills() {
|
||||
// The exact benchmark case: a BOLA whose proof (admin password dump) is
|
||||
// in prose, evidence_data null. data_class must see the credential, and
|
||||
// backfill must give the grader a structured receipt.
|
||||
let mut f = Finding {
|
||||
cwe: "CWE-639".into(),
|
||||
title: "BOLA on /api/v2/users/:id".into(),
|
||||
endpoint: "GET https://t.test/api/v2/users/1".into(),
|
||||
evidence: "GET /api/v2/users/1 with a customer token returned admin record incl. password=SuperSecret and apiKey=nk_live_x".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(data_class(&f), DataClass::Sensitive, "a credential dump is sensitive data");
|
||||
assert!(f.evidence_data.is_none());
|
||||
backfill_evidence(&mut f);
|
||||
assert!(f.evidence_data.is_some(), "prose proof is salvaged into the structured slot");
|
||||
// Now the graded CVSS keeps a confidentiality receipt (data type), not 0.
|
||||
let g = cvss_graded(&f).expect("graded");
|
||||
assert!(g.demonstrated_score > 0.0, "a demonstrated credential exposure is not zero");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backfill_does_not_invent_evidence_when_there_is_none() {
|
||||
let mut f = Finding { cwe: "CWE-79".into(), endpoint: "https://t.test/x".into(), evidence: "".into(), ..Default::default() };
|
||||
backfill_evidence(&mut f);
|
||||
assert!(f.evidence_data.is_none(), "no prose proof, nothing to salvage");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2373,15 +2373,30 @@ async fn finish(cfg: RunConfig, _lib: &Library, pool: &ModelPool, recon: String,
|
||||
// says shows no real impact loses its C/I/A the same
|
||||
// way an absent receipt would — the demonstrated
|
||||
// score follows the evidence, calibrated.
|
||||
if adj.impact_demonstrated < 0.5 {
|
||||
// Data type is a guardrail against over-recalibration.
|
||||
// The impact is only stripped when BOTH the model was
|
||||
// unconvinced AND nothing sensitive was actually shown
|
||||
// (no credential/PII signature, and the calibrated
|
||||
// data-sensitivity is low). A demonstrated credential
|
||||
// or PII exposure keeps its severity even on a thin
|
||||
// receipt — the KIND of data is itself the impact.
|
||||
let dc = crate::attack_graph::data_class(f);
|
||||
let sensitive_shown = dc != crate::attack_graph::DataClass::None || adj.data_sensitivity >= 0.5;
|
||||
if adj.impact_demonstrated < 0.5 && !sensitive_shown {
|
||||
if let Some(g) = crate::attack_graph::cvss_graded(f) {
|
||||
// Strip demonstrated impact the model is not
|
||||
// convinced of; keep potential as context.
|
||||
let dropped = crate::cvss::grade(g.potential, |_| false);
|
||||
if dropped.demonstrated_score < g.demonstrated_score {
|
||||
f.cvss = format!("{:.1} ({})", dropped.demonstrated_score, dropped.demonstrated.vector_string());
|
||||
}
|
||||
}
|
||||
} else if sensitive_shown && f.cvss.is_empty() {
|
||||
// Sensitive data shown but no score yet: grade it
|
||||
// WITH the data-type receipt rather than leaving it blank.
|
||||
if let Some(g) = crate::attack_graph::cvss_graded(f) {
|
||||
if g.demonstrated_score > 0.0 {
|
||||
f.cvss = format!("{:.1} ({})", g.demonstrated_score, g.demonstrated.vector_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
refined += 1;
|
||||
}
|
||||
|
||||
@@ -194,12 +194,30 @@ impl TypeSafe {
|
||||
Question::noul(
|
||||
"Does the evidence show REAL impact (data read/written, code executed, a boundary crossed), as opposed to only that a payload was reflected or an error appeared?",
|
||||
"concrete impact is shown in the evidence",
|
||||
"no impact is shown — only a mechanic or a reflection",
|
||||
"no impact is shown - only a mechanic or a reflection",
|
||||
),
|
||||
);
|
||||
// Data type is a separate axis from "was impact demonstrated": a flaw
|
||||
// that exposes credentials or PII is severe by the KIND of data it
|
||||
// touched, even when the receipt is thin. Scored so the calibration can
|
||||
// consider it instead of collapsing purely on the impact Noul.
|
||||
qs.insert(
|
||||
"data_sensitivity".to_string(),
|
||||
Question::score(
|
||||
"Judging only by what the evidence shows was exposed or affected, how sensitive is that data?",
|
||||
&[
|
||||
"nothing sensitive: only reflection, an error, or public content",
|
||||
"internal or low-sensitivity data (ids, non-secret fields)",
|
||||
"personal data (PII): emails, names, addresses, phone numbers",
|
||||
"secrets: passwords, API keys, tokens, private keys, payment data",
|
||||
],
|
||||
),
|
||||
);
|
||||
let answers = self.evaluate(state, qs).await?;
|
||||
let verdict = answers.get("verdict").cloned().unwrap_or_default();
|
||||
let impact = answers.get("impact_demonstrated").and_then(|a| a.noul).unwrap_or(0.0);
|
||||
// Score returns a weighted position on the 0..3 ladder; normalise to 0..1.
|
||||
let data_sensitivity = answers.get("data_sensitivity").and_then(|a| a.score).map(|s| (s / 3.0).clamp(0.0, 1.0)).unwrap_or(0.0);
|
||||
Ok(Adjudication {
|
||||
verdict: verdict.choice.clone().unwrap_or_else(|| "needs-review".into()),
|
||||
p_confirmed: verdict.p("confirmed"),
|
||||
@@ -207,6 +225,7 @@ impl TypeSafe {
|
||||
p_rejected: verdict.p("rejected"),
|
||||
confidence: verdict.confidence.unwrap_or(0.0),
|
||||
impact_demonstrated: impact,
|
||||
data_sensitivity,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -222,6 +241,11 @@ pub struct Adjudication {
|
||||
pub confidence: f64,
|
||||
/// Probability real impact was shown (0..1).
|
||||
pub impact_demonstrated: f64,
|
||||
/// Calibrated data-sensitivity (0..1): 1.0 = secrets/credentials exposed.
|
||||
/// A high value means the finding must NOT be recalibrated down just because
|
||||
/// the impact receipt was thin - the KIND of data is itself the impact.
|
||||
#[serde(default)]
|
||||
pub data_sensitivity: f64,
|
||||
}
|
||||
|
||||
impl Adjudication {
|
||||
@@ -280,7 +304,7 @@ mod tests {
|
||||
#[test]
|
||||
fn calibrated_confidence_folds_in_demonstrated_impact() {
|
||||
// High p_confirmed but NO demonstrated impact → confidence is held back.
|
||||
let a = Adjudication { verdict: "confirmed".into(), p_confirmed: 0.9, p_needs_review: 0.05, p_rejected: 0.05, confidence: 0.8, impact_demonstrated: 0.0 };
|
||||
let a = Adjudication { verdict: "confirmed".into(), p_confirmed: 0.9, p_needs_review: 0.05, p_rejected: 0.05, confidence: 0.8, impact_demonstrated: 0.0, data_sensitivity: 0.0 };
|
||||
assert!((a.calibrated_confidence() - 0.45).abs() < 1e-9, "no impact halves the weight");
|
||||
|
||||
// Same, with full impact → near p_confirmed.
|
||||
@@ -290,9 +314,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn review_is_wanted_on_a_split_distribution() {
|
||||
let split = Adjudication { verdict: "confirmed".into(), p_confirmed: 0.45, p_needs_review: 0.3, p_rejected: 0.25, confidence: 0.4, impact_demonstrated: 0.5 };
|
||||
let split = Adjudication { verdict: "confirmed".into(), p_confirmed: 0.45, p_needs_review: 0.3, p_rejected: 0.25, confidence: 0.4, impact_demonstrated: 0.5, data_sensitivity: 0.0 };
|
||||
assert!(split.wants_review(), "no option clears 0.6 — a human should look");
|
||||
let clear = Adjudication { verdict: "confirmed".into(), p_confirmed: 0.88, p_needs_review: 0.08, p_rejected: 0.04, confidence: 0.8, impact_demonstrated: 0.9 };
|
||||
let clear = Adjudication { verdict: "confirmed".into(), p_confirmed: 0.88, p_needs_review: 0.08, p_rejected: 0.04, confidence: 0.8, impact_demonstrated: 0.9, data_sensitivity: 1.0 };
|
||||
assert!(!clear.wants_review());
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user