fix(scope): don't treat an endpoint's HTTP method as its host

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 <noreply@anthropic.com>
This commit is contained in:
CyberSecurityUPandClaude Opus 4.8 committed 2026-10-15 17:56:16 -03:00
1 parent 99d91101ac
commit 2502907160
2 files changed
+50 -2

No files matched your search

@@ -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 {
+47 -1
View File
@@ -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();