use gossan_core::Target;
use secfinding::{Finding, FindingKind, Severity};
use crate::utils::normalize_host;
fn parent_domain(host: &str) -> String {
let labels: Vec<&str> = host.split('.').filter(|s| !s.is_empty()).collect();
if labels.len() < 2 {
return host.to_string();
}
labels[labels.len() - 2..].join(".")
}
const SSRF_SIGNALS: &[&str] = &[
"ssrf",
"open redirect",
"server-side request forgery",
"proxy",
"host header injection",
];
const INTERNAL_SIGNALS: &[&str] = &[
"redis",
"elasticsearch",
"mongodb",
"docker",
"kubernetes",
"etcd",
"consul",
"memcached",
"couchdb",
"rabbitmq",
"unauthenticated",
];
pub struct SsrfInternalRule;
impl super::super::CorrelationRule for SsrfInternalRule {
fn name(&self) -> &'static str {
"ssrf_internal"
}
fn check(&self, findings: &[Finding], _targets: &[Target]) -> Vec<Finding> {
let ssrf_parents: std::collections::HashSet<String> = findings
.iter()
.filter(|f| {
let lower = f.title().to_lowercase();
SSRF_SIGNALS.iter().any(|sig| lower.contains(sig))
})
.map(|f| parent_domain(&normalize_host(f.target())))
.collect();
if ssrf_parents.is_empty() {
return vec![];
}
let internal_services: Vec<&Finding> = findings
.iter()
.filter(|f| {
let lower = f.title().to_lowercase();
if !INTERNAL_SIGNALS.iter().any(|sig| lower.contains(sig)) {
return false;
}
let parent = parent_domain(&normalize_host(f.target()));
ssrf_parents.contains(&parent)
})
.collect();
if internal_services.is_empty() {
return vec![];
}
let service_names: Vec<String> = internal_services
.iter()
.map(|f| f.title().to_string())
.take(5)
.collect();
let chain = Finding::builder(
"correlation",
internal_services[0].target(),
Severity::Critical,
)
.title("SSRF → Internal Service Access Chain")
.detail(format!(
"An SSRF-capable endpoint was found alongside {} exposed internal service(s) \
under the same parent domain. An attacker can chain the SSRF to reach internal \
services that are not exposed to the internet, potentially leading to data \
exfiltration, command execution, or full infrastructure compromise. Services \
at risk: {}",
internal_services.len(),
service_names.join(", ")
))
.kind(FindingKind::Vulnerability)
.tag("chain")
.tag("ssrf")
.tag("internal")
.build_or_log();
chain.into_iter().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::CorrelationRule;
fn finding(scanner: &str, target: &str, title: &str) -> Finding {
Finding::builder(scanner, target, Severity::High)
.title(title)
.build()
.expect("test finding")
}
#[test]
fn fires_when_ssrf_and_internal_service_present() {
let rule = SsrfInternalRule;
let findings = vec![
finding("hidden", "example.com", "Open redirect detected"),
finding(
"portscan",
"example.com",
"Redis exposed without authentication",
),
];
let chains = rule.check(&findings, &[]);
assert_eq!(chains.len(), 1);
assert!(chains[0].title().contains("SSRF"));
}
#[test]
fn does_not_fire_without_ssrf() {
let rule = SsrfInternalRule;
let findings = vec![finding(
"portscan",
"example.com",
"Redis exposed without authentication",
)];
assert!(rule.check(&findings, &[]).is_empty());
}
#[test]
fn does_not_fire_without_internal_service() {
let rule = SsrfInternalRule;
let findings = vec![finding("hidden", "example.com", "Open redirect detected")];
assert!(rule.check(&findings, &[]).is_empty());
}
#[test]
fn ssrf_internal_does_not_fire_across_unrelated_parent_domains() {
let rule = SsrfInternalRule;
let findings = vec![
finding("hidden", "app.example.com", "Open redirect detected"),
finding(
"portscan",
"redis.unrelated-target.com",
"Redis exposed without authentication",
),
];
assert!(
rule.check(&findings, &[]).is_empty(),
"cross-parent ssrf+internal chain emitted as false positive"
);
}
#[test]
fn ssrf_internal_fires_when_parent_domain_matches() {
let rule = SsrfInternalRule;
let findings = vec![
finding("hidden", "app.example.com", "Open redirect detected"),
finding(
"portscan",
"redis.example.com",
"Redis exposed without authentication",
),
];
let chains = rule.check(&findings, &[]);
assert_eq!(chains.len(), 1);
}
}