feat(net): fail-closed egress, self-hosted OOB channel, inbound SMS

transport.rs — internal engagements happen through a VPN, a bastion or a
tunnel, and the dangerous failure is silent: with the VPN down, 10.20.0.15
is a machine on the operator's own network and the scan succeeds against
the wrong host. So an internal target with no transport is refused before
any traffic leaves, and a transport that is up must prove it (the apparent
source address has to change) rather than be assumed. Supports SOCKS, HTTP
proxy, OpenVPN, SSH bastion (dynamic or single-host forward) and cloudflared.

oob.rs — our own Collaborator, self-hosted by default because callbacks are
engagement data (internal hostnames, resolver addresses, sometimes the
exfiltrated value). HTTP and DNS listeners written on tokio directly, no new
dependency. The two levels of proof are separated in code: an HTTP callback
proves egress, a DNS query proves only that a resolver saw the name — the
overclaim this channel otherwise invites.

inbox.rs — mail.tm and inbound SMS (Twilio or webhook). extract_code() scores
candidates by surrounding text and returns nothing rather than a guess, so a
copyright year never gets submitted as an OTP. A throttling claim requires
delivered messages carrying DISTINCT codes, not HTTP 200s.

Wired through RunConfig, the CLI (global flags, so a session cannot re-route
itself mid-engagement), the REPL and the web console's Authorization tab.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
CyberSecurityUPandClaude Opus 5 committed 2026-09-14 01:35:36 -03:00
1 parent 90b4614d94
commit 8aa776665a
12 files changed
+1901 -2

No files matched your search

+62
View File
@@ -49,6 +49,26 @@ struct Cli {
/// Policy profile for an interactive session: web · ot.
#[arg(long = "session-policy", global = true)]
session_policy: Option<String>,
/// Egress: direct · socks5://host:port · http://host:port ·
/// openvpn:/path.ovpn · ssh://user@bastion[?forward=host:port] ·
/// cloudflared://host:port. An internal target with no transport is refused
/// rather than tested against whatever network this host is on. Global, so
/// an interactive session cannot change its own route mid-engagement.
#[arg(long = "transport", global = true)]
transport: Option<String>,
/// Out-of-band domain (a wildcard pointed at this host). Without it the
/// blind classes — SSRF, XXE, blind RCE — can only be reported as leads.
#[arg(long = "oob-domain", global = true)]
oob_domain: Option<String>,
/// Where the OOB HTTP listener binds (default 0.0.0.0:8080).
#[arg(long = "oob-http", global = true)]
oob_http: Option<String>,
/// Where the OOB DNS listener binds, when the zone is delegated to us.
#[arg(long = "oob-dns", global = true)]
oob_dns: Option<String>,
/// Inbound SMS: twilio:<sid>:<token>:<number> or webhook:<url>:<number>.
#[arg(long = "sms", global = true)]
sms: Option<String>,
}
#[derive(Subcommand)]
@@ -464,6 +484,11 @@ async fn main() -> anyhow::Result<()> {
in_scope: cli.session_in_scope.clone(),
environment: cli.session_environment.clone(),
policy: cli.session_policy.clone(),
transport: cli.transport.clone(),
oob_domain: cli.oob_domain.clone(),
oob_http: cli.oob_http.clone(),
oob_dns: cli.oob_dns.clone(),
sms: cli.sms.clone(),
};
repl::repl(&base, auth).await?;
return Ok(());
@@ -520,6 +545,7 @@ async fn main() -> anyhow::Result<()> {
cfg.pinned = parse_only(&only);
apply_authorization(&mut cfg, &in_scope, cli.capability_token.clone(), &environment, &policy)?;
apply_budget(&mut cfg, budget.as_deref(), token_limit, deep_test_limit, coverage_first, depth_first, sample_per_route)?;
apply_network(&mut cfg, &cli)?;
if !models.is_empty() {
cfg.models = models;
}
@@ -1370,6 +1396,42 @@ fn apply_budget(
Ok(())
}
/// Egress route, out-of-band channel and inbound SMS.
///
/// The transport spec is parsed here rather than at run time so a typo fails
/// on the command line instead of three minutes into an engagement.
fn apply_network(cfg: &mut RunConfig, cli: &Cli) -> anyhow::Result<()> {
let (transport, oob_domain, oob_http, oob_dns, sms) = (
cli.transport.clone(),
cli.oob_domain.clone(),
cli.oob_http.clone(),
cli.oob_dns.clone(),
cli.sms.clone(),
);
if let Some(spec) = transport {
let egress = harness::transport::Egress::parse(&spec).map_err(|e| anyhow::anyhow!(e))?;
println!(" \x1b[2megress: {}\x1b[0m", egress.label());
cfg.transport = Some(spec);
}
if let Some(d) = oob_domain {
if !d.contains('.') {
anyhow::bail!("--oob-domain needs a real domain whose wildcard points at this host");
}
println!(" \x1b[2mout-of-band: *.{d}\x1b[0m");
cfg.oob_domain = Some(d);
}
for (val, label) in [(&oob_http, "--oob-http"), (&oob_dns, "--oob-dns")] {
if let Some(v) = val {
v.parse::<std::net::SocketAddr>()
.map_err(|_| anyhow::anyhow!("{label} must be host:port, got `{v}`"))?;
}
}
cfg.oob_http = oob_http;
cfg.oob_dns = oob_dns;
cfg.sms = sms;
Ok(())
}
fn parse_only(vals: &[String]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for v in vals {
+41
View File
@@ -254,6 +254,13 @@ struct RunRecord {
}
struct Session {
/// Egress and out-of-band configuration, handed down from the launcher —
/// not settable from inside the session (see [`SessionAuth`]).
transport: Option<String>,
oob_domain: Option<String>,
oob_http: Option<String>,
oob_dns: Option<String>,
sms: Option<String>,
models: Vec<String>,
subscription: bool,
mcp: bool,
@@ -301,6 +308,11 @@ struct Session {
impl Default for Session {
fn default() -> Self {
Session {
transport: None,
oob_domain: None,
oob_http: None,
oob_dns: None,
sms: None,
models: vec!["anthropic:claude-opus-4-8".into()],
subscription: harness::installed_cli_backends().contains(&"claude"),
mcp: false,
@@ -413,6 +425,14 @@ pub struct SessionAuth {
pub in_scope: Vec<String>,
pub environment: Option<String>,
pub policy: Option<String>,
/// Egress route. Passed in like the grant, and for the same reason: a
/// session that can re-route its own traffic mid-engagement can leave the
/// network it was authorized on.
pub transport: Option<String>,
pub oob_domain: Option<String>,
pub oob_http: Option<String>,
pub oob_dns: Option<String>,
pub sms: Option<String>,
}
pub async fn repl(base: &Path, auth: SessionAuth) -> anyhow::Result<()> {
@@ -454,6 +474,17 @@ pub async fn repl(base: &Path, auth: SessionAuth) -> anyhow::Result<()> {
for entry in &auth.in_scope {
s.policy.allow(entry);
}
s.transport = auth.transport.clone();
s.oob_domain = auth.oob_domain.clone();
s.oob_http = auth.oob_http.clone();
s.oob_dns = auth.oob_dns.clone();
s.sms = auth.sms.clone();
if let Some(t) = &s.transport {
println!(" \x1b[2m🔌 egress: {t}\x1b[0m");
}
if let Some(d) = &s.oob_domain {
println!(" \x1b[2m📡 out-of-band: *.{d}\x1b[0m");
}
if let Some(token) = auth.capability.as_deref() {
match harness::capability::key_from_env() {
None => println!(" \x1b[31m⛔ a capability token was supplied but no verification key is configured\x1b[0m — set NEUROSPLOIT_CAPABILITY_KEY. Not applied."),
@@ -1447,6 +1478,11 @@ async fn run(base: &Path, s: &Session, history: &mut Vec<RunRecord>) {
cfg.scope = s.policy.clone();
cfg.capability = s.capability.clone();
cfg.policy = s.engagement.clone();
cfg.transport = s.transport.clone();
cfg.oob_domain = s.oob_domain.clone();
cfg.oob_http = s.oob_http.clone();
cfg.oob_dns = s.oob_dns.clone();
cfg.sms = s.sms.clone();
cfg.auth = s.auth.clone();
cfg.pinned = s.pinned.clone();
// Multiple /auth identities → prepend the access-control (IDOR/BOLA/BFLA) directive.
@@ -1525,6 +1561,11 @@ async fn start_background(base: &Path, s: &Session, reader: &mut Reader,
cfg.scope = s.policy.clone();
cfg.capability = s.capability.clone();
cfg.policy = s.engagement.clone();
cfg.transport = s.transport.clone();
cfg.oob_domain = s.oob_domain.clone();
cfg.oob_http = s.oob_http.clone();
cfg.oob_dns = s.oob_dns.clone();
cfg.sms = s.sms.clone();
cfg.auth = s.auth.clone();
cfg.pinned = s.pinned.clone();
if matches!(mode_e, crate::Mode::Grey) { cfg.repo = s.repo.clone(); }