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
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
use crate::correlation::scope;
use crate::correlation::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();
let is_version =
|f: &Finding| f.tags().iter().any(|t| t.as_ref() == "api-version");
// `host_scope` strips the port from `f.target()`, so a portscan
// finding on `example.com:27017` ("MongoDB responds - likely
// unauthenticated", tag `no-auth`) buckets with an api-version
// finding from `https://example.com/api/v1` and a pure title-
// substring match for "unauthenticated" was producing a false
// Critical "Unauthenticated legacy API version" chain whose
// no-auth signal was actually a database on a different,
// non-HTTP port. Restrict the no-auth partner to (a) a finding
// tagged `auth-bypass` (the canonical web-no-auth marker,
// emitted by hidden/swagger + hidden/tech_probes), OR (b) a
// title-substring match whose target is explicitly an HTTP URL.
let is_no_auth = |f: &Finding| {
if f.tags().iter().any(|t| t.as_ref() == "auth-bypass") {
return true;
}
let target = f.target();
let looks_http =
target.starts_with("http://") || target.starts_with("https://");
if !looks_http {
return false;
}
let t = f.title().to_lowercase();
t.contains("no authentication") || t.contains("unauthenticated")
};
// Group by NORMALIZED host (http://app vs https://app vs
// app:443 cluster together; otherwise the version finding and
// the missing-auth finding land in separate buckets and never
// chain) then require an api-version finding AND a *distinct*
// missing-auth finding. The grouping + distinct-pair guard are
// the audited `scope` primitive (was a hand-rolled by_target
// HashMap + `ptr::eq` self-chain loop). A single
// api-version-tagged finding whose title says "unauthenticated"
// (e.g. "Legacy /api/v1 reachable unauthenticated") matches
// both predicates but is one finding already reported by its
// own scanner - the distinct-object requirement suppresses that
// self-chain.
for (target, group) in scope::group_by(findings, scope::host_scope) {
if scope::distinct_pair(&group, &is_version, &is_no_auth).is_none() {
continue;
}
let versions: Vec<&Finding> =
group.iter().copied().filter(|&f| is_version(f)).collect();
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::*;
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`, as hidden/swagger emits) 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"],
),
// `auth-bypass` is the canonical web-no-auth tag (hidden/
// swagger.rs, hidden/tech_probes.rs).
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);
}
/// PROVING (regression twin): the dual-signal finding, joined by a
/// distinct api-version finding, must still chain - distinctness
/// guard suppresses only the self-chain.
#[test]
fn api_auth_chains_combined_finding_with_distinct_version_partner() {
let findings = vec![
finding(
"https://api.example.com/v1",
"Legacy /api/v1 reachable unauthenticated",
&["api-version"],
),
finding(
"https://api.example.com/v2",
"API v2 version banner disclosed",
&["api-version"],
),
];
let chains = ApiAuthRule.check(&findings, &[]);
assert_eq!(
chains.len(),
1,
"combined finding + distinct api-version finding must still chain"
);
}
/// PRECISION (the real defect - pre-2026-05-22). `scope::host_scope`
/// strips the port, so a portscan finding on `example.com:27017`
/// ("MongoDB responds - likely unauthenticated", tag `no-auth`)
/// shared a bucket with an api-version finding on
/// `https://example.com/api/v1`. The old title-substring matcher
/// hit "unauthenticated" in the MongoDB title and chained to a
/// false Critical "Unauthenticated legacy API version" whose
/// no-auth signal was actually a database on a different,
/// non-HTTP port.
#[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"],
),
// Exact portscan emission for MongoDB on a host:port target.
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"
);
}
/// PRECISION: same class - Elasticsearch on a non-HTTP port.
#[test]
fn api_auth_does_not_use_portscan_elasticsearch_no_auth_as_partner() {
let findings = vec![
finding(
"https://example.com/api/v1",
"API version enumeration",
&["api-version"],
),
finding(
"example.com:9200",
"Elasticsearch responds - likely unauthenticated",
&["banner", "elasticsearch", "no-auth"],
),
];
assert!(
ApiAuthRule.check(&findings, &[]).is_empty(),
"portscan Elasticsearch `unauthenticated` finding wrongly partnered an api-version chain"
);
}
/// PROVING: a real web auth-bypass tag (not a no-scheme target)
/// still chains - the precision fix did not over-correct.
#[test]
fn api_auth_still_chains_when_no_auth_carries_auth_bypass_tag_only() {
let findings = vec![
finding(
"https://example.com/api/v1",
"API version enumeration",
&["api-version"],
),
// No "unauthenticated" in title - relies solely on the
// auth-bypass tag path (e.g. hidden/tech_probes Strapi).
finding(
"https://example.com/admin",
"Strapi admin panel exposed without registration lock",
&["strapi", "auth-bypass", "exposure"],
),
];
let chains = ApiAuthRule.check(&findings, &[]);
assert_eq!(chains.len(), 1);
}
}