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:
CyberSecurityUP
2026-09-20 12:34:34 -03:00
co-authored by Claude Opus 5
parent 088d133c80
commit c9e1f74e23
5 changed files with 368 additions and 8 deletions
@@ -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");
}
}
+18 -3
View File
@@ -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;
}
+28 -4
View File
@@ -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());
}