1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
use crate::utils::normalize_host;
use crate::CorrelationRule;
use gossan_core::Target;
use secfinding::{Finding, FindingKind, Severity};
/// Rule: Old API version exposed without authentication.
pub struct ApiAuthRule;
impl CorrelationRule for ApiAuthRule {
fn name(&self) -> &'static str {
"api-auth"
}
fn check(&self, findings: &[Finding], _targets: &[Target]) -> Vec<Finding> {
let mut chains = Vec::new();
// Group by NORMALIZED target so http://app vs https://app vs
// app:443 cluster together, otherwise an api-version finding
// on `https://app.example.com/v1` and an unauthenticated
// finding on `http://app.example.com/v1` would land in
// separate buckets and never chain.
let mut by_target: std::collections::HashMap<String, Vec<&Finding>> =
std::collections::HashMap::new();
for f in findings {
by_target
.entry(normalize_host(f.target()))
.or_default()
.push(f);
}
for (target, fs) in by_target {
let versions: Vec<&Finding> = fs
.iter()
.copied()
.filter(|f| f.tags().iter().any(|t| t.as_ref() == "api-version"))
.collect();
// Restrict no-auth partner to the canonical web tag OR an
// HTTP-URL target, prevents portscan DB banners ("MongoDB
// responds, likely unauthenticated", no-scheme target) from
// driving a false Critical "Unauthenticated legacy API" chain.
let no_auth_findings: Vec<&Finding> = fs
.iter()
.copied()
.filter(|f| {
if f.tags().iter().any(|t| t.as_ref() == "auth-bypass") {
return true;
}
let tgt = f.target();
let tgt_l = tgt.to_ascii_lowercase();
// Reject non-HTTP schemes (mongodb://, ftp://, etc.)
if tgt_l.contains("://")
&& !tgt_l.starts_with("http://")
&& !tgt_l.starts_with("https://")
{
return false;
}
// Reject bare host:port / IP:port banners, but keep
// http(s) URLs that legitimately include non-default ports.
let is_http = tgt_l.starts_with("http://") || tgt_l.starts_with("https://");
if !is_http
&& tgt.chars().rev().take_while(|c| c.is_ascii_digit()).count() > 0
&& tgt.chars().rev().skip_while(|c| c.is_ascii_digit()).next() == Some(':')
{
return false;
}
let t = f.title().to_lowercase();
t.contains("no authentication") || t.contains("unauthenticated")
})
.collect();
// Self-chain guard: require a *distinct* no-auth finding. A
// single api-version-tagged finding whose title says
// "unauthenticated" is one finding already reported by its
// own scanner, not a two-signal correlation.
let has_distinct_pair = versions
.iter()
.any(|v| no_auth_findings.iter().any(|n| !std::ptr::eq(*v, *n)));
if !versions.is_empty() && has_distinct_pair {
gossan_core::try_push_finding(
Finding::builder("correlation", &target, Severity::Critical)
.title("Unauthenticated legacy API version")
.detail(format!(
"Target {} exposes legacy API versions ({}) that appear to lack authentication. \
Attackers can use these endpoints to bypass security controls on newer API versions.",
target,
versions.iter().map(|f| f.title()).collect::<Vec<_>>().join(", ")
))
.tag("correlation")
.tag("attack-chain")
.tag("api-security")
.kind(FindingKind::Vulnerability),
&mut chains,
);
}
}
chains
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::CorrelationRule;
use secfinding::Severity;
fn finding(target: &str, title: &str, tags: &[&str]) -> Finding {
let mut b = Finding::builder("hidden", target, Severity::High).title(title);
for t in tags {
b = b.tag(*t);
}
b.build().expect("test finding")
}
/// ADVERSARIAL: one api-version-tagged finding whose title already
/// says "unauthenticated" must NOT self-chain, it is a single
/// finding already reported by its own scanner.
#[test]
fn api_auth_does_not_self_chain_single_finding() {
for title in [
"Legacy /api/v1 reachable unauthenticated",
"API version v1 exposed with no authentication",
] {
let f = finding("https://api.example.com", title, &["api-version"]);
let chains = ApiAuthRule.check(&[f], &[]);
assert!(
chains.is_empty(),
"single combined finding {title:?} self-chained: {:?}",
chains.iter().map(|c| c.title().to_string()).collect::<Vec<_>>()
);
}
}
/// PROVING: an api-version finding plus a *distinct* missing-auth
/// finding (tagged `auth-bypass`) on the same host still chains.
#[test]
fn api_auth_chains_two_distinct_findings() {
let findings = vec![
finding(
"https://api.example.com/v1",
"API version enumeration",
&["api-version"],
),
finding(
"https://api.example.com",
"5 API endpoint(s) with no authentication requirement",
&["auth-bypass", "swagger"],
),
];
let chains = ApiAuthRule.check(&findings, &[]);
assert_eq!(chains.len(), 1);
assert_eq!(chains[0].severity(), Severity::Critical);
}
/// PRECISION: a portscan DB finding ("MongoDB responds, likely
/// unauthenticated", no-scheme target) must NOT partner an
/// api-version finding in the chain.
#[test]
fn api_auth_does_not_use_portscan_db_no_auth_as_chain_partner() {
let findings = vec![
finding(
"https://example.com/api/v1",
"API version enumeration",
&["api-version"],
),
finding(
"example.com:27017",
"MongoDB responds, likely unauthenticated",
&["banner", "mongodb", "no-auth"],
),
];
assert!(
ApiAuthRule.check(&findings, &[]).is_empty(),
"portscan DB `unauthenticated` finding wrongly partnered an api-version chain"
);
}
}