use gossan_core::Target;
use secfinding::{Evidence, Finding, FindingKind, Severity};
use crate::utils::normalize_host;
use crate::CorrelationRule;
pub struct AdminExposedRule;
impl CorrelationRule for AdminExposedRule {
fn name(&self) -> &'static str {
"admin-no-auth-chain"
}
fn check(&self, findings: &[Finding], _targets: &[Target]) -> Vec<Finding> {
let mut chains = Vec::new();
let admin_hosts: std::collections::HashSet<String> = findings
.iter()
.filter(|f| {
f.scanner() == "hidden"
&& (f.title().to_lowercase().contains("admin")
|| f.title().to_lowercase().contains("dashboard")
|| f.title().to_lowercase().contains("console"))
})
.filter_map(|f| Some(f.target()).map(|d| normalize_host(d)))
.collect();
if admin_hosts.is_empty() {
return chains;
}
let is_unauth_finding = |f: &Finding| -> bool {
let scanner_ok = f.scanner() == "techstack" || f.scanner() == "hidden";
if !scanner_ok {
return false;
}
let t = f.title().to_lowercase();
t.contains("no authentication")
|| t.contains("missing authentication")
|| t.contains("authentication missing")
|| t.contains("authentication required")
|| t.contains("auth bypass")
|| t.contains("auth-bypass")
|| t.contains("www-authenticate")
|| t.contains("unauthenticated")
|| f.tags().iter().any(|tag| {
let ts = tag.as_ref();
ts == "auth-bypass" || ts == "no-auth" || ts == "unauthenticated"
})
};
let unauth_hosts: std::collections::HashSet<String> = findings
.iter()
.filter(|f| is_unauth_finding(f))
.filter_map(|f| Some(f.target()).map(|d| normalize_host(d)))
.collect();
for host in admin_hosts.intersection(&unauth_hosts) {
let admin_findings: Vec<&Finding> = findings
.iter()
.filter(|f| {
f.scanner() == "hidden"
&& normalize_host(f.target()) == *host
&& (f.title().to_lowercase().contains("admin")
|| f.title().to_lowercase().contains("dashboard")
|| f.title().to_lowercase().contains("console"))
})
.collect();
let auth_findings: Vec<&Finding> = findings
.iter()
.filter(|f| normalize_host(f.target()) == *host && is_unauth_finding(f))
.collect();
let mut evidence_ids = Vec::new();
for finding in admin_findings.iter().chain(auth_findings.iter()) {
evidence_ids.push(finding.id().to_string());
}
let evidence_string = evidence_ids.join(", ");
let display_target = admin_findings
.first()
.map(|f| f.target().to_string())
.unwrap_or_else(|| host.clone());
if let Some(finding) =
Finding::builder("correlation", display_target, Severity::Critical)
.title(format!(
"Admin panel exposed without authentication: {}",
host
))
.detail(format!(
"An admin/dashboard panel is accessible on {} and no authentication \
mechanism (WWW-Authenticate, login redirect, auth headers) was detected. \
Immediate privilege escalation and full application compromise likely.",
host
))
.kind(FindingKind::Vulnerability)
.tag("chain")
.tag("admin")
.tag("auth-bypass")
.evidence(Evidence::Raw(
format!("Finding IDs: {}", evidence_string).into(),
))
.build_or_log()
{
chains.push(finding);
}
}
chains
}
}
#[cfg(test)]
mod tests {
use super::*;
fn finding(scanner: &str, target: &str, title: &str) -> Finding {
Finding::builder(scanner, target, Severity::High)
.title(title)
.build()
.expect("test finding")
}
#[test]
fn admin_exposed_rule_requires_matching_targets() {
let findings = vec![
finding("hidden", "admin.example.com", "Admin dashboard exposed"),
finding("hidden", "api.example.com", "No authentication required"),
];
assert!(AdminExposedRule.check(&findings, &[]).is_empty());
}
#[test]
fn admin_exposed_rule_emits_critical_chain_for_matching_host() {
let findings = vec![
finding("hidden", "admin.example.com", "Admin dashboard exposed"),
finding("hidden", "admin.example.com", "No authentication required"),
];
let chains = AdminExposedRule.check(&findings, &[]);
assert_eq!(chains.len(), 1);
assert_eq!(chains[0].severity(), Severity::Critical);
assert!(chains[0].title().contains("without authentication"));
}
#[test]
fn admin_exposed_rule_does_not_chain_on_unrelated_missing_header() {
let findings = vec![
finding("hidden", "admin.example.com", "Admin dashboard exposed"),
finding("hidden", "admin.example.com", "Missing HSTS header"),
finding(
"hidden",
"admin.example.com",
"Missing X-Frame-Options header",
),
finding(
"hidden",
"admin.example.com",
"Missing Content-Security-Policy header",
),
];
let chains = AdminExposedRule.check(&findings, &[]);
assert!(
chains.is_empty(),
"missing-header findings produced a false-positive admin-no-auth chain: {:?}",
chains.iter().map(Finding::title).collect::<Vec<_>>()
);
}
#[test]
fn admin_exposed_rule_chains_on_explicit_auth_bypass_signal() {
let findings = vec![
finding("hidden", "admin.example.com", "Admin dashboard exposed"),
finding(
"hidden",
"admin.example.com",
"Authentication missing on /admin",
),
];
let chains = AdminExposedRule.check(&findings, &[]);
assert_eq!(chains.len(), 1);
}
}