flareguard 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +2962 -0
- package/Cargo.toml +66 -0
- package/LICENSE +21 -0
- package/README.md +115 -0
- package/bin/flareguard.js +78 -0
- package/package.json +50 -0
- package/scripts/postinstall.mjs +83 -0
- package/src/bindings/ast_scanner.rs +950 -0
- package/src/bindings/cli.rs +49 -0
- package/src/bindings/jsonc.rs +166 -0
- package/src/bindings/mod.rs +7 -0
- package/src/bindings/reporter.rs +328 -0
- package/src/bindings/types.rs +129 -0
- package/src/bindings/validator.rs +227 -0
- package/src/bindings/wrangler.rs +647 -0
- package/src/cli.rs +77 -0
- package/src/lib.rs +7 -0
- package/src/main.rs +451 -0
- package/src/origin/cli.rs +92 -0
- package/src/origin/cloudflare.rs +170 -0
- package/src/origin/confidence.rs +320 -0
- package/src/origin/crtsh.rs +144 -0
- package/src/origin/dns.rs +177 -0
- package/src/origin/enumerator.rs +271 -0
- package/src/origin/error.rs +19 -0
- package/src/origin/mock.rs +320 -0
- package/src/origin/mod.rs +19 -0
- package/src/origin/models.rs +167 -0
- package/src/origin/prober.rs +260 -0
- package/src/origin/remediation.rs +73 -0
- package/src/origin/report.rs +625 -0
- package/src/origin/scanner.rs +217 -0
- package/src/secrets/cli.rs +94 -0
- package/src/secrets/env_parser.rs +464 -0
- package/src/secrets/ignore.rs +139 -0
- package/src/secrets/mod.rs +14 -0
- package/src/secrets/report/json_format.rs +97 -0
- package/src/secrets/report/mod.rs +48 -0
- package/src/secrets/report/sarif.rs +225 -0
- package/src/secrets/report/text.rs +105 -0
- package/src/secrets/rules/builtin.rs +280 -0
- package/src/secrets/rules/entropy.rs +66 -0
- package/src/secrets/rules/mod.rs +7 -0
- package/src/secrets/rules/types.rs +183 -0
- package/src/secrets/scanner.rs +444 -0
- package/src/zone/cli.rs +111 -0
- package/src/zone/client/cf_client.rs +405 -0
- package/src/zone/client/mod.rs +5 -0
- package/src/zone/client/provider.rs +12 -0
- package/src/zone/mock_data.rs +481 -0
- package/src/zone/mod.rs +125 -0
- package/src/zone/models/audit.rs +194 -0
- package/src/zone/models/cloudflare.rs +252 -0
- package/src/zone/models/mod.rs +7 -0
- package/src/zone/models/sarif.rs +89 -0
- package/src/zone/reporters/html_rep.rs +345 -0
- package/src/zone/reporters/json_rep.rs +7 -0
- package/src/zone/reporters/mod.rs +9 -0
- package/src/zone/reporters/sarif_rep.rs +100 -0
- package/src/zone/reporters/terminal.rs +322 -0
- package/src/zone/rules/definitions.rs +201 -0
- package/src/zone/rules/evaluator.rs +484 -0
- package/src/zone/rules/mod.rs +5 -0
- package/src/zone/scoring.rs +104 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
use ipnet::{IpNet, Ipv4Net, Ipv6Net};
|
|
2
|
+
use std::net::IpAddr;
|
|
3
|
+
use std::str::FromStr;
|
|
4
|
+
use std::sync::OnceLock;
|
|
5
|
+
|
|
6
|
+
/// Official Cloudflare IPv4 IP ranges (https://www.cloudflare.com/ips-v4)
|
|
7
|
+
pub const CLOUDFLARE_IPV4_CIDRS: &[&str] = &[
|
|
8
|
+
"173.245.48.0/20",
|
|
9
|
+
"103.21.244.0/22",
|
|
10
|
+
"103.22.200.0/22",
|
|
11
|
+
"103.31.4.0/22",
|
|
12
|
+
"141.101.64.0/18",
|
|
13
|
+
"108.162.192.0/18",
|
|
14
|
+
"190.93.240.0/20",
|
|
15
|
+
"188.114.96.0/20",
|
|
16
|
+
"197.234.240.0/22",
|
|
17
|
+
"198.41.128.0/17",
|
|
18
|
+
"162.158.0.0/15",
|
|
19
|
+
"104.16.0.0/13",
|
|
20
|
+
"104.24.0.0/14",
|
|
21
|
+
"172.64.0.0/13",
|
|
22
|
+
"131.0.72.0/22",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/// Official Cloudflare IPv6 IP ranges (https://www.cloudflare.com/ips-v6)
|
|
26
|
+
pub const CLOUDFLARE_IPV6_CIDRS: &[&str] = &[
|
|
27
|
+
"2400:cb00::/32",
|
|
28
|
+
"2606:4700::/32",
|
|
29
|
+
"2803:f800::/32",
|
|
30
|
+
"2405:b500::/32",
|
|
31
|
+
"2405:8100::/32",
|
|
32
|
+
"2a06:98c0::/29",
|
|
33
|
+
"2c0f:f248::/32",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/// Cloudflare Autonomous System Numbers (ASN)
|
|
37
|
+
pub const CLOUDFLARE_ASNS: &[&str] = &["AS13335", "AS209242", "CLOUDFLARENET", "CLOUDFLARE"];
|
|
38
|
+
|
|
39
|
+
static CLOUDFLARE_NETWORKS: OnceLock<Vec<IpNet>> = OnceLock::new();
|
|
40
|
+
|
|
41
|
+
fn get_cloudflare_networks() -> &'static Vec<IpNet> {
|
|
42
|
+
CLOUDFLARE_NETWORKS.get_or_init(|| {
|
|
43
|
+
let mut nets = Vec::new();
|
|
44
|
+
for cidr in CLOUDFLARE_IPV4_CIDRS {
|
|
45
|
+
if let Ok(net) = Ipv4Net::from_str(cidr) {
|
|
46
|
+
nets.push(IpNet::V4(net));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for cidr in CLOUDFLARE_IPV6_CIDRS {
|
|
50
|
+
if let Ok(net) = Ipv6Net::from_str(cidr) {
|
|
51
|
+
nets.push(IpNet::V6(net));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
nets
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Checks if an IP address belongs to Cloudflare's public proxy edge network
|
|
59
|
+
pub fn is_cloudflare_ip(ip: &IpAddr) -> bool {
|
|
60
|
+
let networks = get_cloudflare_networks();
|
|
61
|
+
networks.iter().any(|net| net.contains(ip))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// Checks if an ASN string represents Cloudflare
|
|
65
|
+
pub fn is_cloudflare_asn(asn: &str) -> bool {
|
|
66
|
+
let upper = asn.to_uppercase();
|
|
67
|
+
CLOUDFLARE_ASNS.iter().any(|&cf_asn| upper.contains(cf_asn))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// Checks if HTTP headers contain Cloudflare edge proxy signatures
|
|
71
|
+
pub fn has_cloudflare_headers(headers: &std::collections::HashMap<String, String>) -> bool {
|
|
72
|
+
for (k, v) in headers {
|
|
73
|
+
let key = k.to_lowercase();
|
|
74
|
+
let val = v.to_lowercase();
|
|
75
|
+
if key == "server" && val.contains("cloudflare") {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
if key == "cf-ray"
|
|
79
|
+
|| key == "cf-cache-status"
|
|
80
|
+
|| key == "cf-mitigated"
|
|
81
|
+
|| key == "cf-request-id"
|
|
82
|
+
{
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
false
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// Filters a slice of IP addresses into Cloudflare edge IPs and non-Cloudflare candidate IPs
|
|
90
|
+
pub fn partition_ips(ips: &[IpAddr]) -> (Vec<IpAddr>, Vec<IpAddr>) {
|
|
91
|
+
let mut cf_ips = Vec::new();
|
|
92
|
+
let mut non_cf_ips = Vec::new();
|
|
93
|
+
|
|
94
|
+
for &ip in ips {
|
|
95
|
+
if is_cloudflare_ip(&ip) {
|
|
96
|
+
cf_ips.push(ip);
|
|
97
|
+
} else {
|
|
98
|
+
non_cf_ips.push(ip);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
(cf_ips, non_cf_ips)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
#[cfg(test)]
|
|
106
|
+
mod tests {
|
|
107
|
+
use super::*;
|
|
108
|
+
|
|
109
|
+
#[test]
|
|
110
|
+
fn test_cloudflare_ipv4_detection() {
|
|
111
|
+
// Known Cloudflare IPs
|
|
112
|
+
let cf_ip1: IpAddr = "104.21.45.12".parse().unwrap();
|
|
113
|
+
let cf_ip2: IpAddr = "172.67.180.20".parse().unwrap();
|
|
114
|
+
let cf_ip3: IpAddr = "162.158.5.99".parse().unwrap();
|
|
115
|
+
let cf_ip4: IpAddr = "198.41.130.1".parse().unwrap();
|
|
116
|
+
|
|
117
|
+
assert!(is_cloudflare_ip(&cf_ip1));
|
|
118
|
+
assert!(is_cloudflare_ip(&cf_ip2));
|
|
119
|
+
assert!(is_cloudflare_ip(&cf_ip3));
|
|
120
|
+
assert!(is_cloudflare_ip(&cf_ip4));
|
|
121
|
+
|
|
122
|
+
// Non-Cloudflare IPs
|
|
123
|
+
let non_cf_ip1: IpAddr = "198.51.100.42".parse().unwrap();
|
|
124
|
+
let non_cf_ip2: IpAddr = "8.8.8.8".parse().unwrap();
|
|
125
|
+
let non_cf_ip3: IpAddr = "1.1.1.1".parse().unwrap(); // Note: 1.1.1.1 is CF DNS, but not in proxy CIDRs
|
|
126
|
+
let non_cf_ip4: IpAddr = "140.82.121.3".parse().unwrap(); // GitHub
|
|
127
|
+
|
|
128
|
+
assert!(!is_cloudflare_ip(&non_cf_ip1));
|
|
129
|
+
assert!(!is_cloudflare_ip(&non_cf_ip2));
|
|
130
|
+
assert!(!is_cloudflare_ip(&non_cf_ip3));
|
|
131
|
+
assert!(!is_cloudflare_ip(&non_cf_ip4));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#[test]
|
|
135
|
+
fn test_cloudflare_ipv6_detection() {
|
|
136
|
+
let cf_ipv6: IpAddr = "2606:4700:3037::ac43:d236".parse().unwrap();
|
|
137
|
+
assert!(is_cloudflare_ip(&cf_ipv6));
|
|
138
|
+
|
|
139
|
+
let non_cf_ipv6: IpAddr = "2001:4860:4860::8888".parse().unwrap();
|
|
140
|
+
assert!(!is_cloudflare_ip(&non_cf_ipv6));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#[test]
|
|
144
|
+
fn test_partition_ips() {
|
|
145
|
+
let ips = vec![
|
|
146
|
+
"104.21.1.1".parse().unwrap(),
|
|
147
|
+
"198.51.100.5".parse().unwrap(),
|
|
148
|
+
"172.67.2.2".parse().unwrap(),
|
|
149
|
+
"203.0.113.10".parse().unwrap(),
|
|
150
|
+
];
|
|
151
|
+
let (cf, non_cf) = partition_ips(&ips);
|
|
152
|
+
assert_eq!(cf.len(), 2);
|
|
153
|
+
assert_eq!(non_cf.len(), 2);
|
|
154
|
+
assert!(non_cf.contains(&"198.51.100.5".parse().unwrap()));
|
|
155
|
+
assert!(non_cf.contains(&"203.0.113.10".parse().unwrap()));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
#[test]
|
|
159
|
+
fn test_has_cloudflare_headers() {
|
|
160
|
+
let mut headers = std::collections::HashMap::new();
|
|
161
|
+
headers.insert("server".to_string(), "cloudflare".to_string());
|
|
162
|
+
headers.insert("cf-ray".to_string(), "8abc1234def-ORD".to_string());
|
|
163
|
+
assert!(has_cloudflare_headers(&headers));
|
|
164
|
+
|
|
165
|
+
let mut origin_headers = std::collections::HashMap::new();
|
|
166
|
+
origin_headers.insert("server".to_string(), "nginx/1.24.0".to_string());
|
|
167
|
+
origin_headers.insert("x-powered-by".to_string(), "PHP/8.2".to_string());
|
|
168
|
+
assert!(!has_cloudflare_headers(&origin_headers));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
use crate::origin::models::{ConfidenceLevel, DiscoverySource, ProbeResult, TargetBaseline};
|
|
2
|
+
|
|
3
|
+
/// Calculates the overall confidence score (0-100) and confidence level for a candidate IP
|
|
4
|
+
pub fn calculate_confidence(
|
|
5
|
+
baseline: &TargetBaseline,
|
|
6
|
+
source: &DiscoverySource,
|
|
7
|
+
successful_probes: &[ProbeResult],
|
|
8
|
+
_failed_probes: &[ProbeResult],
|
|
9
|
+
) -> (ConfidenceLevel, u8, String) {
|
|
10
|
+
// 1. Check for exact probe match (CONFIRMED - 100%)
|
|
11
|
+
for probe in successful_probes {
|
|
12
|
+
if let Some(ref match_details) = probe.match_details
|
|
13
|
+
&& match_details.exact_body_hash_match {
|
|
14
|
+
return (
|
|
15
|
+
ConfidenceLevel::Confirmed,
|
|
16
|
+
100,
|
|
17
|
+
format!(
|
|
18
|
+
"Exact SHA-256 body hash match on direct HTTP/HTTPS probe (port {}). Backend returned identical payload bypassing Cloudflare edge.",
|
|
19
|
+
probe.port
|
|
20
|
+
),
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 2. Check for title match and header similarities (HIGH - 85%)
|
|
26
|
+
for probe in successful_probes {
|
|
27
|
+
if let Some(ref match_details) = probe.match_details
|
|
28
|
+
&& match_details.title_match && !match_details.cf_ray_present {
|
|
29
|
+
let baseline_title = baseline.html_title.as_deref().unwrap_or("N/A");
|
|
30
|
+
return (
|
|
31
|
+
ConfidenceLevel::High,
|
|
32
|
+
85,
|
|
33
|
+
format!(
|
|
34
|
+
"HTML title matches target baseline ('{}') without Cloudflare proxy headers on port {}.",
|
|
35
|
+
baseline_title, probe.port
|
|
36
|
+
),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 3. Check for direct subdomain origin bypass (HIGH - 80-85%)
|
|
42
|
+
match source {
|
|
43
|
+
DiscoverySource::Subdomain(sub) if is_high_value_subdomain(sub) => {
|
|
44
|
+
if !successful_probes.is_empty() {
|
|
45
|
+
return (
|
|
46
|
+
ConfidenceLevel::High,
|
|
47
|
+
80,
|
|
48
|
+
format!(
|
|
49
|
+
"High-value origin subdomain ('{}') points directly to unmasked IP and responds to HTTP requests.",
|
|
50
|
+
sub
|
|
51
|
+
),
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
DiscoverySource::CertificateTransparency(san) if is_high_value_subdomain(san)
|
|
56
|
+
&& !successful_probes.is_empty() => {
|
|
57
|
+
return (
|
|
58
|
+
ConfidenceLevel::High,
|
|
59
|
+
80,
|
|
60
|
+
format!(
|
|
61
|
+
"Certificate Transparency SAN ('{}') points to unmasked IP with active web service.",
|
|
62
|
+
san
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
_ => {}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// 4. Check for SPF direct IP declaration or MX server (MEDIUM - 60-70%)
|
|
70
|
+
match source {
|
|
71
|
+
DiscoverySource::SpfRecord(spf) => {
|
|
72
|
+
if !successful_probes.is_empty() {
|
|
73
|
+
return (
|
|
74
|
+
ConfidenceLevel::Medium,
|
|
75
|
+
70,
|
|
76
|
+
format!(
|
|
77
|
+
"IP explicitly authorized in target SPF record ('{}') and hosts active web service.",
|
|
78
|
+
spf
|
|
79
|
+
),
|
|
80
|
+
);
|
|
81
|
+
} else {
|
|
82
|
+
return (
|
|
83
|
+
ConfidenceLevel::Medium,
|
|
84
|
+
55,
|
|
85
|
+
format!(
|
|
86
|
+
"IP explicitly declared in SPF record ('{}'); server did not respond on standard HTTP/HTTPS ports.",
|
|
87
|
+
spf
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
DiscoverySource::MxRecord(mx) => {
|
|
93
|
+
if !successful_probes.is_empty() {
|
|
94
|
+
return (
|
|
95
|
+
ConfidenceLevel::Medium,
|
|
96
|
+
65,
|
|
97
|
+
format!(
|
|
98
|
+
"Target MX server ('{}') resolves to unmasked IP and accepts HTTP traffic.",
|
|
99
|
+
mx
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
} else {
|
|
103
|
+
return (
|
|
104
|
+
ConfidenceLevel::Medium,
|
|
105
|
+
50,
|
|
106
|
+
format!(
|
|
107
|
+
"Target MX server ('{}') resolves to non-Cloudflare IP address.",
|
|
108
|
+
mx
|
|
109
|
+
),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
DiscoverySource::Subdomain(sub) => {
|
|
114
|
+
if !successful_probes.is_empty() {
|
|
115
|
+
return (
|
|
116
|
+
ConfidenceLevel::Medium,
|
|
117
|
+
60,
|
|
118
|
+
format!(
|
|
119
|
+
"Subdomain ('{}') resolves to non-Cloudflare IP with responsive HTTP port.",
|
|
120
|
+
sub
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
DiscoverySource::CertificateTransparency(san) => {
|
|
126
|
+
if !successful_probes.is_empty() {
|
|
127
|
+
return (
|
|
128
|
+
ConfidenceLevel::Medium,
|
|
129
|
+
60,
|
|
130
|
+
format!(
|
|
131
|
+
"Historical crt.sh SAN ('{}') resolves to non-Cloudflare IP with responsive HTTP port.",
|
|
132
|
+
san
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
DiscoverySource::HistoricalDns(src)
|
|
138
|
+
if !successful_probes.is_empty() => {
|
|
139
|
+
return (
|
|
140
|
+
ConfidenceLevel::Medium,
|
|
141
|
+
65,
|
|
142
|
+
format!(
|
|
143
|
+
"Historical DNS record ('{}') resolves to active HTTP server.",
|
|
144
|
+
src
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
_ => {}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 5. Fallback for non-responsive candidate IPs (LOW - 25-35%)
|
|
152
|
+
let reason = match source {
|
|
153
|
+
DiscoverySource::Subdomain(sub) => {
|
|
154
|
+
format!(
|
|
155
|
+
"Subdomain ('{}') points to non-Cloudflare IP, but HTTP probes failed or timed out.",
|
|
156
|
+
sub
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
DiscoverySource::CertificateTransparency(san) => {
|
|
160
|
+
format!(
|
|
161
|
+
"Certificate SAN ('{}') points to non-Cloudflare IP, but HTTP probes failed or timed out.",
|
|
162
|
+
san
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
DiscoverySource::HistoricalDns(src) => {
|
|
166
|
+
format!(
|
|
167
|
+
"Historical record ('{}') points to non-Cloudflare IP, but host is currently unresponsive.",
|
|
168
|
+
src
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
_ => {
|
|
172
|
+
format!(
|
|
173
|
+
"Candidate discovered via {}, but no direct HTTP verification succeeded.",
|
|
174
|
+
source
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
(ConfidenceLevel::Low, 30, reason)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
fn is_high_value_subdomain(name: &str) -> bool {
|
|
183
|
+
let lower = name.to_lowercase();
|
|
184
|
+
let keywords = [
|
|
185
|
+
"origin",
|
|
186
|
+
"direct",
|
|
187
|
+
"cpanel",
|
|
188
|
+
"dev",
|
|
189
|
+
"staging",
|
|
190
|
+
"api-origin",
|
|
191
|
+
"backend",
|
|
192
|
+
"internal",
|
|
193
|
+
"vps",
|
|
194
|
+
"server",
|
|
195
|
+
"admin",
|
|
196
|
+
"portal",
|
|
197
|
+
"ssh",
|
|
198
|
+
];
|
|
199
|
+
keywords.iter().any(|&k| lower.contains(k))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
#[cfg(test)]
|
|
203
|
+
mod tests {
|
|
204
|
+
use super::*;
|
|
205
|
+
use crate::origin::models::ProbeMatchDetails;
|
|
206
|
+
use std::collections::HashMap;
|
|
207
|
+
|
|
208
|
+
fn mock_baseline() -> TargetBaseline {
|
|
209
|
+
TargetBaseline {
|
|
210
|
+
domain: "example.com".to_string(),
|
|
211
|
+
resolved_ips: vec!["104.21.1.1".parse().unwrap()],
|
|
212
|
+
is_behind_cloudflare: true,
|
|
213
|
+
cloudflare_ips: vec!["104.21.1.1".parse().unwrap()],
|
|
214
|
+
non_cloudflare_ips: vec![],
|
|
215
|
+
http_status: Some(200),
|
|
216
|
+
html_title: Some("Welcome to Example".to_string()),
|
|
217
|
+
body_sha256: Some("abcdef1234567890".to_string()),
|
|
218
|
+
body_length: 1200,
|
|
219
|
+
server_header: Some("cloudflare".to_string()),
|
|
220
|
+
headers: HashMap::new(),
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#[test]
|
|
225
|
+
fn test_confidence_confirmed_on_hash_match() {
|
|
226
|
+
let baseline = mock_baseline();
|
|
227
|
+
let probe = ProbeResult {
|
|
228
|
+
ip: "198.51.100.42".parse().unwrap(),
|
|
229
|
+
port: 80,
|
|
230
|
+
protocol: "http".to_string(),
|
|
231
|
+
url: "http://198.51.100.42/".to_string(),
|
|
232
|
+
success: true,
|
|
233
|
+
status_code: Some(200),
|
|
234
|
+
html_title: Some("Welcome to Example".to_string()),
|
|
235
|
+
body_sha256: Some("abcdef1234567890".to_string()),
|
|
236
|
+
body_length: 1200,
|
|
237
|
+
server_header: Some("nginx".to_string()),
|
|
238
|
+
headers: HashMap::new(),
|
|
239
|
+
response_time_ms: 45,
|
|
240
|
+
error: None,
|
|
241
|
+
match_details: Some(ProbeMatchDetails {
|
|
242
|
+
exact_body_hash_match: true,
|
|
243
|
+
title_match: true,
|
|
244
|
+
status_code_match: true,
|
|
245
|
+
body_length_delta: 0,
|
|
246
|
+
header_similarity_score: 0.9,
|
|
247
|
+
cf_ray_present: false,
|
|
248
|
+
direct_server_header: Some("nginx".to_string()),
|
|
249
|
+
baseline_server_header: Some("cloudflare".to_string()),
|
|
250
|
+
}),
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
let (level, score, reason) = calculate_confidence(
|
|
254
|
+
&baseline,
|
|
255
|
+
&DiscoverySource::Subdomain("origin.example.com".into()),
|
|
256
|
+
&[probe],
|
|
257
|
+
&[],
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
assert_eq!(level, ConfidenceLevel::Confirmed);
|
|
261
|
+
assert_eq!(score, 100);
|
|
262
|
+
assert!(reason.contains("Exact SHA-256"));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
#[test]
|
|
266
|
+
fn test_confidence_high_on_title_match() {
|
|
267
|
+
let baseline = mock_baseline();
|
|
268
|
+
let probe = ProbeResult {
|
|
269
|
+
ip: "198.51.100.42".parse().unwrap(),
|
|
270
|
+
port: 443,
|
|
271
|
+
protocol: "https".to_string(),
|
|
272
|
+
url: "https://198.51.100.42/".to_string(),
|
|
273
|
+
success: true,
|
|
274
|
+
status_code: Some(200),
|
|
275
|
+
html_title: Some("Welcome to Example".to_string()),
|
|
276
|
+
body_sha256: Some("different_hash".to_string()),
|
|
277
|
+
body_length: 1250,
|
|
278
|
+
server_header: Some("Apache/2.4".to_string()),
|
|
279
|
+
headers: HashMap::new(),
|
|
280
|
+
response_time_ms: 50,
|
|
281
|
+
error: None,
|
|
282
|
+
match_details: Some(ProbeMatchDetails {
|
|
283
|
+
exact_body_hash_match: false,
|
|
284
|
+
title_match: true,
|
|
285
|
+
status_code_match: true,
|
|
286
|
+
body_length_delta: 50,
|
|
287
|
+
header_similarity_score: 0.8,
|
|
288
|
+
cf_ray_present: false,
|
|
289
|
+
direct_server_header: Some("Apache/2.4".to_string()),
|
|
290
|
+
baseline_server_header: Some("cloudflare".to_string()),
|
|
291
|
+
}),
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
let (level, score, reason) = calculate_confidence(
|
|
295
|
+
&baseline,
|
|
296
|
+
&DiscoverySource::Subdomain("dev.example.com".into()),
|
|
297
|
+
&[probe],
|
|
298
|
+
&[],
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
assert_eq!(level, ConfidenceLevel::High);
|
|
302
|
+
assert_eq!(score, 85);
|
|
303
|
+
assert!(reason.contains("HTML title matches"));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
#[test]
|
|
307
|
+
fn test_confidence_medium_on_spf() {
|
|
308
|
+
let baseline = mock_baseline();
|
|
309
|
+
let (level, score, reason) = calculate_confidence(
|
|
310
|
+
&baseline,
|
|
311
|
+
&DiscoverySource::SpfRecord("ip4:198.51.100.42".into()),
|
|
312
|
+
&[],
|
|
313
|
+
&[],
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
assert_eq!(level, ConfidenceLevel::Medium);
|
|
317
|
+
assert_eq!(score, 55);
|
|
318
|
+
assert!(reason.contains("SPF record"));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
use crate::origin::error::Result;
|
|
2
|
+
use reqwest::Client;
|
|
3
|
+
use serde::Deserialize;
|
|
4
|
+
use std::collections::HashSet;
|
|
5
|
+
use std::time::Duration;
|
|
6
|
+
|
|
7
|
+
#[derive(Debug, Clone, Deserialize)]
|
|
8
|
+
pub struct CrtShEntry {
|
|
9
|
+
pub issuer_ca_id: Option<i64>,
|
|
10
|
+
pub issuer_name: Option<String>,
|
|
11
|
+
pub common_name: Option<String>,
|
|
12
|
+
pub name_value: Option<String>,
|
|
13
|
+
pub id: Option<i64>,
|
|
14
|
+
pub entry_timestamp: Option<String>,
|
|
15
|
+
pub not_before: Option<String>,
|
|
16
|
+
pub not_after: Option<String>,
|
|
17
|
+
pub serial_number: Option<String>,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/// Parses certificate transparency log entries to extract unique subdomains
|
|
21
|
+
pub fn extract_subdomains_from_crtsh(entries: &[CrtShEntry], root_domain: &str) -> Vec<String> {
|
|
22
|
+
let mut subdomains = HashSet::new();
|
|
23
|
+
let root_lower = root_domain
|
|
24
|
+
.to_lowercase()
|
|
25
|
+
.trim_start_matches('.')
|
|
26
|
+
.to_string();
|
|
27
|
+
|
|
28
|
+
for entry in entries {
|
|
29
|
+
if let Some(ref common_name) = entry.common_name {
|
|
30
|
+
for line in common_name.lines() {
|
|
31
|
+
clean_and_insert_domain(line, &root_lower, &mut subdomains);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if let Some(ref name_value) = entry.name_value {
|
|
35
|
+
for line in name_value.lines() {
|
|
36
|
+
clean_and_insert_domain(line, &root_lower, &mut subdomains);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let mut result: Vec<String> = subdomains.into_iter().collect();
|
|
42
|
+
result.sort();
|
|
43
|
+
result
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
fn clean_and_insert_domain(raw: &str, root_domain: &str, set: &mut HashSet<String>) {
|
|
47
|
+
let trimmed = raw
|
|
48
|
+
.trim()
|
|
49
|
+
.to_lowercase()
|
|
50
|
+
.trim_start_matches("*.")
|
|
51
|
+
.trim_start_matches('.')
|
|
52
|
+
.to_string();
|
|
53
|
+
|
|
54
|
+
if trimmed.is_empty() {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Must end with root domain or equal root domain
|
|
59
|
+
if trimmed == root_domain || trimmed.ends_with(&format!(".{}", root_domain)) {
|
|
60
|
+
set.insert(trimmed);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// Fetches Certificate Transparency logs for a domain from crt.sh
|
|
65
|
+
pub async fn query_crtsh(client: &Client, domain: &str, timeout_secs: u64) -> Result<Vec<String>> {
|
|
66
|
+
let root = domain.trim_start_matches('.').to_lowercase();
|
|
67
|
+
let url = format!("https://crt.sh/?q=%25.{}&output=json", root);
|
|
68
|
+
|
|
69
|
+
let response = client
|
|
70
|
+
.get(&url)
|
|
71
|
+
.header("User-Agent", "cf-origin-hunter/0.1.0")
|
|
72
|
+
.timeout(Duration::from_secs(timeout_secs))
|
|
73
|
+
.send()
|
|
74
|
+
.await;
|
|
75
|
+
|
|
76
|
+
match response {
|
|
77
|
+
Ok(resp) => {
|
|
78
|
+
if !resp.status().is_success() {
|
|
79
|
+
return Ok(Vec::new());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let text = resp.text().await.unwrap_or_default();
|
|
83
|
+
if text.trim().is_empty() || text.starts_with('<') {
|
|
84
|
+
return Ok(Vec::new());
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
match serde_json::from_str::<Vec<CrtShEntry>>(&text) {
|
|
88
|
+
Ok(entries) => Ok(extract_subdomains_from_crtsh(&entries, &root)),
|
|
89
|
+
Err(_) => Ok(Vec::new()),
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
Err(e) => {
|
|
93
|
+
// crt.sh can be intermittently slow or rate-limited; gracefully return empty list
|
|
94
|
+
log_or_ignore(&format!("crt.sh query failed: {}", e));
|
|
95
|
+
Ok(Vec::new())
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
fn log_or_ignore(_msg: &str) {
|
|
101
|
+
// Debug logging if enabled
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#[cfg(test)]
|
|
105
|
+
mod tests {
|
|
106
|
+
use super::*;
|
|
107
|
+
|
|
108
|
+
#[test]
|
|
109
|
+
fn test_extract_subdomains_from_crtsh() {
|
|
110
|
+
let entries = vec![
|
|
111
|
+
CrtShEntry {
|
|
112
|
+
issuer_ca_id: Some(1),
|
|
113
|
+
issuer_name: Some("Let's Encrypt".into()),
|
|
114
|
+
common_name: Some("origin.example.com".into()),
|
|
115
|
+
name_value: Some(
|
|
116
|
+
"origin.example.com\n*.dev.example.com\napi-origin.example.com".into(),
|
|
117
|
+
),
|
|
118
|
+
id: Some(100),
|
|
119
|
+
entry_timestamp: None,
|
|
120
|
+
not_before: None,
|
|
121
|
+
not_after: None,
|
|
122
|
+
serial_number: None,
|
|
123
|
+
},
|
|
124
|
+
CrtShEntry {
|
|
125
|
+
issuer_ca_id: Some(2),
|
|
126
|
+
issuer_name: Some("DigiCert".into()),
|
|
127
|
+
common_name: Some("example.com".into()),
|
|
128
|
+
name_value: Some("example.com\notherdomain.com".into()),
|
|
129
|
+
id: Some(101),
|
|
130
|
+
entry_timestamp: None,
|
|
131
|
+
not_before: None,
|
|
132
|
+
not_after: None,
|
|
133
|
+
serial_number: None,
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
|
|
137
|
+
let subs = extract_subdomains_from_crtsh(&entries, "example.com");
|
|
138
|
+
assert!(subs.contains(&"origin.example.com".to_string()));
|
|
139
|
+
assert!(subs.contains(&"dev.example.com".to_string()));
|
|
140
|
+
assert!(subs.contains(&"api-origin.example.com".to_string()));
|
|
141
|
+
assert!(subs.contains(&"example.com".to_string()));
|
|
142
|
+
assert!(!subs.contains(&"otherdomain.com".to_string()));
|
|
143
|
+
}
|
|
144
|
+
}
|