From 2502907160d3fc41785b1fbed88dee798690ce0e Mon Sep 17 00:00:00 2001 From: CyberSecurityUP Date: Thu, 15 Oct 2026 17:56:16 -0300 Subject: [PATCH] fix(scope): don't treat an endpoint's HTTP method as its host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents report endpoints as "METHOD /path" (e.g. "GET /api/search"). host_of split on whitespace/slash and returned the method ("get"/"post") as the host, so host-relative findings were wrongly quarantined as out-of-scope and the attack graph grew bogus HOST "get"/HOST "post" nodes — real findings vanished from the report. Add scope::strip_leading_method and apply it in host_of and in the graph finding projection. A host-relative endpoint now resolves to an empty host (kept in scope, relative to the authorized target) and a method+URL endpoint resolves to the real host. Covered by new scope tests. Co-Authored-By: Claude Opus 4.8 --- .../crates/harness/src/graph_memory.rs | 4 +- neurosploit-rs/crates/harness/src/scope.rs | 48 ++++++++++++++++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/neurosploit-rs/crates/harness/src/graph_memory.rs b/neurosploit-rs/crates/harness/src/graph_memory.rs index 4a55090..213704d 100644 --- a/neurosploit-rs/crates/harness/src/graph_memory.rs +++ b/neurosploit-rs/crates/harness/src/graph_memory.rs @@ -626,7 +626,9 @@ pub fn project_findings(b: &mut GraphBatch, target: &str, run: &str, findings: & let conf = f.confidence.clamp(0.0, 1.0).max(if st == Status::Confirmed { 0.8 } else { 0.3 }); let attack = f.evidence_data.as_ref().and_then(|e| e.attack.as_ref().or(e.identity_b.as_ref())); let url = if !f.endpoint.trim().is_empty() { - f.endpoint.trim().to_string() + // Endpoints are often "METHOD /path" — drop the method so it isn't + // mistaken for the host (which created bogus "get"/"post" HOST nodes). + crate::scope::strip_leading_method(&f.endpoint).to_string() } else if let Some(a) = attack { a.url.clone() } else { diff --git a/neurosploit-rs/crates/harness/src/scope.rs b/neurosploit-rs/crates/harness/src/scope.rs index df8cc99..69e3408 100644 --- a/neurosploit-rs/crates/harness/src/scope.rs +++ b/neurosploit-rs/crates/harness/src/scope.rs @@ -179,9 +179,28 @@ fn u32_to_ipv4(v: u32) -> String { format!("{}.{}.{}.{}", v >> 24, (v >> 16) & 255, (v >> 8) & 255, v & 255) } +/// Strip a leading HTTP method token from an endpoint string. +/// +/// Agent endpoints are frequently written as "METHOD /path" or +/// "METHOD https://host/path" (e.g. "GET /api/search"). Without stripping it, +/// the method is mistaken for the host — a host-relative finding like +/// "GET /api/search" resolves to host "get" and gets wrongly quarantined as +/// out-of-scope or graphed as a bogus HOST node. Returns the input unchanged +/// when it does not begin with a known method. +pub fn strip_leading_method(endpoint: &str) -> &str { + let s = endpoint.trim_start(); + if let Some((first, rest)) = s.split_once(char::is_whitespace) { + const METHODS: [&str; 9] = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE", "CONNECT"]; + if METHODS.contains(&first.to_ascii_uppercase().as_str()) { + return rest.trim_start(); + } + } + s +} + /// Host of a URL or bare authority, lowercased, without port or userinfo. pub fn host_of(url: &str) -> String { - let s = url.trim().to_lowercase(); + let s = strip_leading_method(url).to_lowercase(); let s = s.split_once("://").map(|(_, r)| r).unwrap_or(&s); let s = s.split(['/', '?', '#']).next().unwrap_or(s); let s = s.rsplit_once('@').map(|(_, h)| h).unwrap_or(s); @@ -753,6 +772,33 @@ mod tests { p } + #[test] + fn host_of_strips_a_leading_http_method() { + // "METHOD /path" endpoints (how agents usually report them) must not + // resolve the method as the host — the path is host-relative, so the + // host is empty and the finding stays in scope. + assert_eq!(host_of("GET /api/search?q=x"), ""); + assert_eq!(host_of("POST /api/auth/login"), ""); + assert_eq!(host_of("get /admin"), ""); + // Method + absolute URL still yields the real host. + assert_eq!(host_of("GET https://www.evidelo.com.br/api/x"), "evidelo.com.br"); + // A bare host or URL is unchanged. + assert_eq!(host_of("https://app.example.com/a"), "app.example.com"); + assert_eq!(host_of("app.example.com:8443"), "app.example.com"); + } + + #[test] + fn host_relative_findings_are_kept_in_scope() { + let p = policy(); + let f = |ep: &str| Finding { endpoint: ep.to_string(), ..Default::default() }; + let (kept, out) = p.audit_findings(vec![ + f("GET /api/search?q=x"), + f("POST /api/auth/login"), + ]); + assert_eq!(kept.len(), 2, "host-relative findings must not be quarantined"); + assert!(out.is_empty()); + } + #[test] fn the_default_scope_is_the_target_and_nothing_else() { let p = policy();