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,260 @@
|
|
|
1
|
+
use crate::origin::cloudflare::has_cloudflare_headers;
|
|
2
|
+
use crate::origin::error::{HunterError, Result};
|
|
3
|
+
use crate::origin::models::{ProbeMatchDetails, ProbeResult, TargetBaseline};
|
|
4
|
+
use regex::Regex;
|
|
5
|
+
use reqwest::Client;
|
|
6
|
+
use reqwest::header::{HOST, USER_AGENT};
|
|
7
|
+
use sha2::{Digest, Sha256};
|
|
8
|
+
use std::collections::HashMap;
|
|
9
|
+
use std::net::IpAddr;
|
|
10
|
+
use std::sync::OnceLock;
|
|
11
|
+
use std::time::{Duration, Instant};
|
|
12
|
+
|
|
13
|
+
static TITLE_REGEX: OnceLock<Regex> = OnceLock::new();
|
|
14
|
+
|
|
15
|
+
fn get_title_regex() -> &'static Regex {
|
|
16
|
+
TITLE_REGEX.get_or_init(|| Regex::new(r"(?is)<title[^>]*>(.*?)</title>").unwrap())
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/// Computes hex-encoded SHA-256 checksum of a byte slice
|
|
20
|
+
pub fn compute_sha256(bytes: &[u8]) -> String {
|
|
21
|
+
let mut hasher = Sha256::new();
|
|
22
|
+
hasher.update(bytes);
|
|
23
|
+
hex::encode(hasher.finalize())
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// Extracts HTML title from text
|
|
27
|
+
pub fn extract_html_title(html: &str) -> Option<String> {
|
|
28
|
+
let re = get_title_regex();
|
|
29
|
+
re.captures(html).and_then(|cap| {
|
|
30
|
+
cap.get(1).map(|m| {
|
|
31
|
+
m.as_str()
|
|
32
|
+
.trim()
|
|
33
|
+
.replace(['\n', '\r'], " ")
|
|
34
|
+
.split_whitespace()
|
|
35
|
+
.collect::<Vec<_>>()
|
|
36
|
+
.join(" ")
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/// Builds an HTTP client configured for origin probing
|
|
42
|
+
pub fn create_probe_client(timeout_secs: u64) -> Result<Client> {
|
|
43
|
+
Client::builder()
|
|
44
|
+
.danger_accept_invalid_certs(true)
|
|
45
|
+
.danger_accept_invalid_hostnames(true)
|
|
46
|
+
.timeout(Duration::from_secs(timeout_secs))
|
|
47
|
+
.redirect(reqwest::redirect::Policy::limited(3))
|
|
48
|
+
.build()
|
|
49
|
+
.map_err(HunterError::from)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Fetches the target domain's HTTP/HTTPS response to establish comparison baseline
|
|
53
|
+
pub async fn fetch_target_baseline(
|
|
54
|
+
client: &Client,
|
|
55
|
+
domain: &str,
|
|
56
|
+
resolved_ips: &[IpAddr],
|
|
57
|
+
is_behind_cf: bool,
|
|
58
|
+
cf_ips: &[IpAddr],
|
|
59
|
+
non_cf_ips: &[IpAddr],
|
|
60
|
+
) -> TargetBaseline {
|
|
61
|
+
let mut baseline = TargetBaseline {
|
|
62
|
+
domain: domain.to_string(),
|
|
63
|
+
resolved_ips: resolved_ips.to_vec(),
|
|
64
|
+
is_behind_cloudflare: is_behind_cf,
|
|
65
|
+
cloudflare_ips: cf_ips.to_vec(),
|
|
66
|
+
non_cloudflare_ips: non_cf_ips.to_vec(),
|
|
67
|
+
http_status: None,
|
|
68
|
+
html_title: None,
|
|
69
|
+
body_sha256: None,
|
|
70
|
+
body_length: 0,
|
|
71
|
+
server_header: None,
|
|
72
|
+
headers: HashMap::new(),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
let https_url = format!("https://{}/", domain);
|
|
76
|
+
let http_url = format!("http://{}/", domain);
|
|
77
|
+
|
|
78
|
+
let urls = [https_url, http_url];
|
|
79
|
+
for url in &urls {
|
|
80
|
+
let resp = client
|
|
81
|
+
.get(url)
|
|
82
|
+
.header(USER_AGENT, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
|
83
|
+
.send()
|
|
84
|
+
.await;
|
|
85
|
+
|
|
86
|
+
if let Ok(response) = resp {
|
|
87
|
+
baseline.http_status = Some(response.status().as_u16());
|
|
88
|
+
|
|
89
|
+
let mut hdrs = HashMap::new();
|
|
90
|
+
for (k, v) in response.headers() {
|
|
91
|
+
if let Ok(val_str) = v.to_str() {
|
|
92
|
+
hdrs.insert(k.as_str().to_string(), val_str.to_string());
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if let Some(srv) = hdrs.get("server") {
|
|
97
|
+
baseline.server_header = Some(srv.clone());
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
baseline.headers = hdrs;
|
|
101
|
+
|
|
102
|
+
if let Ok(body_bytes) = response.bytes().await {
|
|
103
|
+
baseline.body_length = body_bytes.len();
|
|
104
|
+
baseline.body_sha256 = Some(compute_sha256(&body_bytes));
|
|
105
|
+
let body_text = String::from_utf8_lossy(&body_bytes);
|
|
106
|
+
baseline.html_title = extract_html_title(&body_text);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
baseline
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/// Probes a single IP on a given port and protocol with the Host header set to the target domain
|
|
117
|
+
pub async fn probe_candidate_ip(
|
|
118
|
+
client: &Client,
|
|
119
|
+
ip: IpAddr,
|
|
120
|
+
port: u16,
|
|
121
|
+
target_domain: &str,
|
|
122
|
+
baseline: &TargetBaseline,
|
|
123
|
+
) -> ProbeResult {
|
|
124
|
+
let protocol = if port == 443 || port == 8443 {
|
|
125
|
+
"https"
|
|
126
|
+
} else {
|
|
127
|
+
"http"
|
|
128
|
+
};
|
|
129
|
+
let formatted_ip = match ip {
|
|
130
|
+
IpAddr::V4(v4) => v4.to_string(),
|
|
131
|
+
IpAddr::V6(v6) => format!("[{}]", v6),
|
|
132
|
+
};
|
|
133
|
+
let url = format!("{}://{}:{}/", protocol, formatted_ip, port);
|
|
134
|
+
|
|
135
|
+
let start = Instant::now();
|
|
136
|
+
|
|
137
|
+
let req = client
|
|
138
|
+
.get(&url)
|
|
139
|
+
.header(HOST, target_domain)
|
|
140
|
+
.header(USER_AGENT, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 cf-origin-hunter/0.1.0");
|
|
141
|
+
|
|
142
|
+
match req.send().await {
|
|
143
|
+
Ok(response) => {
|
|
144
|
+
let elapsed = start.elapsed().as_millis() as u64;
|
|
145
|
+
let status = response.status().as_u16();
|
|
146
|
+
|
|
147
|
+
let mut hdrs = HashMap::new();
|
|
148
|
+
for (k, v) in response.headers() {
|
|
149
|
+
if let Ok(val_str) = v.to_str() {
|
|
150
|
+
hdrs.insert(k.as_str().to_string(), val_str.to_string());
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let srv = hdrs.get("server").cloned();
|
|
155
|
+
let cf_ray_present = has_cloudflare_headers(&hdrs);
|
|
156
|
+
|
|
157
|
+
let (body_len, sha256_hash, title) = match response.bytes().await {
|
|
158
|
+
Ok(bytes) => {
|
|
159
|
+
let len = bytes.len();
|
|
160
|
+
let hash = compute_sha256(&bytes);
|
|
161
|
+
let body_text = String::from_utf8_lossy(&bytes);
|
|
162
|
+
let t = extract_html_title(&body_text);
|
|
163
|
+
(len, Some(hash), t)
|
|
164
|
+
}
|
|
165
|
+
Err(_) => (0, None, None),
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Compare match details against baseline
|
|
169
|
+
let exact_body_hash = match (&sha256_hash, &baseline.body_sha256) {
|
|
170
|
+
(Some(probe_hash), Some(base_hash)) => probe_hash == base_hash,
|
|
171
|
+
_ => false,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
let title_match = match (&title, &baseline.html_title) {
|
|
175
|
+
(Some(pt), Some(bt)) => !pt.is_empty() && pt.to_lowercase() == bt.to_lowercase(),
|
|
176
|
+
_ => false,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
let status_match = match baseline.http_status {
|
|
180
|
+
Some(base_status) => base_status == status,
|
|
181
|
+
None => false,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
let body_length_delta = (body_len as i64) - (baseline.body_length as i64);
|
|
185
|
+
|
|
186
|
+
let match_details = ProbeMatchDetails {
|
|
187
|
+
exact_body_hash_match: exact_body_hash,
|
|
188
|
+
title_match,
|
|
189
|
+
status_code_match: status_match,
|
|
190
|
+
body_length_delta,
|
|
191
|
+
header_similarity_score: if cf_ray_present { 0.1 } else { 0.8 },
|
|
192
|
+
cf_ray_present,
|
|
193
|
+
direct_server_header: srv.clone(),
|
|
194
|
+
baseline_server_header: baseline.server_header.clone(),
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
ProbeResult {
|
|
198
|
+
ip,
|
|
199
|
+
port,
|
|
200
|
+
protocol: protocol.to_string(),
|
|
201
|
+
url,
|
|
202
|
+
success: true,
|
|
203
|
+
status_code: Some(status),
|
|
204
|
+
html_title: title,
|
|
205
|
+
body_sha256: sha256_hash,
|
|
206
|
+
body_length: body_len,
|
|
207
|
+
server_header: srv,
|
|
208
|
+
headers: hdrs,
|
|
209
|
+
response_time_ms: elapsed,
|
|
210
|
+
error: None,
|
|
211
|
+
match_details: Some(match_details),
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
Err(err) => {
|
|
215
|
+
let elapsed = start.elapsed().as_millis() as u64;
|
|
216
|
+
ProbeResult {
|
|
217
|
+
ip,
|
|
218
|
+
port,
|
|
219
|
+
protocol: protocol.to_string(),
|
|
220
|
+
url,
|
|
221
|
+
success: false,
|
|
222
|
+
status_code: None,
|
|
223
|
+
html_title: None,
|
|
224
|
+
body_sha256: None,
|
|
225
|
+
body_length: 0,
|
|
226
|
+
server_header: None,
|
|
227
|
+
headers: HashMap::new(),
|
|
228
|
+
response_time_ms: elapsed,
|
|
229
|
+
error: Some(err.to_string()),
|
|
230
|
+
match_details: None,
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
#[cfg(test)]
|
|
237
|
+
mod tests {
|
|
238
|
+
use super::*;
|
|
239
|
+
|
|
240
|
+
#[test]
|
|
241
|
+
fn test_compute_sha256() {
|
|
242
|
+
let text = b"Hello Cloudflare Hunter";
|
|
243
|
+
let hash = compute_sha256(text);
|
|
244
|
+
assert_eq!(hash.len(), 64);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
#[test]
|
|
248
|
+
fn test_extract_html_title() {
|
|
249
|
+
let html = "<html><head><title>Acme Corp | Security Portal</title></head><body><h1>Hi</h1></body></html>";
|
|
250
|
+
let title = extract_html_title(html);
|
|
251
|
+
assert_eq!(title, Some("Acme Corp | Security Portal".to_string()));
|
|
252
|
+
|
|
253
|
+
let multiline = "<html><head><title>\n Dashboard • Login \n</title></head></html>";
|
|
254
|
+
let title2 = extract_html_title(multiline);
|
|
255
|
+
assert_eq!(title2, Some("Dashboard • Login".to_string()));
|
|
256
|
+
|
|
257
|
+
let no_title = "<html><body><h1>No title</h1></body></html>";
|
|
258
|
+
assert_eq!(extract_html_title(no_title), None);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
use crate::origin::models::RemediationStep;
|
|
2
|
+
|
|
3
|
+
/// Generates actionable remediation steps based on detected origin leak exposures
|
|
4
|
+
pub fn generate_remediation_plan() -> Vec<RemediationStep> {
|
|
5
|
+
vec![
|
|
6
|
+
RemediationStep {
|
|
7
|
+
id: "REM-001".to_string(),
|
|
8
|
+
title: "Configure Cloudflare Authenticated Origin Pulls (AOP)".to_string(),
|
|
9
|
+
priority: "CRITICAL".to_string(),
|
|
10
|
+
description: "Enforce TLS client certificate validation on origin web servers (Nginx/Apache/Envoy). Direct requests from attackers to origin IP will be rejected at TLS handshake unless routed through Cloudflare edge.".to_string(),
|
|
11
|
+
commands: vec![
|
|
12
|
+
"# Download Cloudflare Origin CA root certificate".to_string(),
|
|
13
|
+
"curl -s https://developers.cloudflare.com/ssl/static/authenticated_origin_pull_ca.pem -o /etc/ssl/certs/cloudflare_aop_ca.pem".to_string(),
|
|
14
|
+
"# In Nginx server block:".to_string(),
|
|
15
|
+
"ssl_client_certificate /etc/ssl/certs/cloudflare_aop_ca.pem;".to_string(),
|
|
16
|
+
"ssl_verify_client on;".to_string(),
|
|
17
|
+
],
|
|
18
|
+
doc_url: "https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/".to_string(),
|
|
19
|
+
},
|
|
20
|
+
RemediationStep {
|
|
21
|
+
id: "REM-002".to_string(),
|
|
22
|
+
title: "Restrict Origin Firewall / Security Groups to Cloudflare IPs Only".to_string(),
|
|
23
|
+
priority: "CRITICAL".to_string(),
|
|
24
|
+
description: "Block all direct ingress traffic on HTTP (80) and HTTPS (443) ports except from Cloudflare's published IP ranges. Ensure no unauthorized hosts can reach the backend.".to_string(),
|
|
25
|
+
commands: vec![
|
|
26
|
+
"# Cloudflare IPv4 ranges: 173.245.48.0/20, 103.21.244.0/22, 103.22.200.0/22, 103.31.4.0/22, 141.101.64.0/18, 108.162.192.0/18, 190.93.240.0/20, 188.114.96.0/20, 197.234.240.0/22, 198.41.128.0/17, 162.158.0.0/15, 104.16.0.0/13, 104.24.0.0/14, 172.64.0.0/13, 131.0.72.0/22".to_string(),
|
|
27
|
+
"# Example UFW setup:".to_string(),
|
|
28
|
+
"for ip in $(curl -s https://www.cloudflare.com/ips-v4); do ufw allow from $ip to any port 443 proto tcp; done".to_string(),
|
|
29
|
+
"ufw default deny incoming".to_string(),
|
|
30
|
+
],
|
|
31
|
+
doc_url: "https://www.cloudflare.com/ips/".to_string(),
|
|
32
|
+
},
|
|
33
|
+
RemediationStep {
|
|
34
|
+
id: "REM-003".to_string(),
|
|
35
|
+
title: "Deploy Cloudflare Tunnel (cloudflared) for Zero Public Ingress".to_string(),
|
|
36
|
+
priority: "HIGH".to_string(),
|
|
37
|
+
description: "Replace public DNS A/AAAA records with Cloudflare Tunnel (CNAME to *.cfargotunnel.com). The origin creates outbound-only connections to Cloudflare edge with no open inbound firewall ports.".to_string(),
|
|
38
|
+
commands: vec![
|
|
39
|
+
"# Install cloudflared daemon".to_string(),
|
|
40
|
+
"cloudflared tunnel create prod-origin-tunnel".to_string(),
|
|
41
|
+
"cloudflared tunnel route dns prod-origin-tunnel app.example.com".to_string(),
|
|
42
|
+
"cloudflared tunnel run --token <TOKEN>".to_string(),
|
|
43
|
+
],
|
|
44
|
+
doc_url: "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/".to_string(),
|
|
45
|
+
},
|
|
46
|
+
RemediationStep {
|
|
47
|
+
id: "REM-004".to_string(),
|
|
48
|
+
title: "Rotate Exposed Origin IP & Separate Mail/DNS Infrastructure".to_string(),
|
|
49
|
+
priority: "HIGH".to_string(),
|
|
50
|
+
description: "Once an origin IP is discovered in historical DNS, certificate transparency logs, or SPF records, rotate the server's public IP address. Separate MX mail servers onto dedicated external relays (e.g. Fastmail, Google Workspace, AWS SES).".to_string(),
|
|
51
|
+
commands: vec![
|
|
52
|
+
"# Disassociate leaked Elastic/Cloud IP and allocate new origin IP address".to_string(),
|
|
53
|
+
"# Verify MX records point to third-party hosted mail servers rather than web origin".to_string(),
|
|
54
|
+
"# Delete unused DNS records (e.g., direct.domain.com, cpanel.domain.com, origin.domain.com)".to_string(),
|
|
55
|
+
],
|
|
56
|
+
doc_url: "https://developers.cloudflare.com/fundamentals/setup/protect-your-origin-server/".to_string(),
|
|
57
|
+
},
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#[cfg(test)]
|
|
62
|
+
mod tests {
|
|
63
|
+
use super::*;
|
|
64
|
+
|
|
65
|
+
#[test]
|
|
66
|
+
fn test_remediation_steps_generation() {
|
|
67
|
+
let plan = generate_remediation_plan();
|
|
68
|
+
assert_eq!(plan.len(), 4);
|
|
69
|
+
assert_eq!(plan[0].id, "REM-001");
|
|
70
|
+
assert!(plan[0].title.contains("Authenticated Origin Pulls"));
|
|
71
|
+
assert!(plan[1].title.contains("Firewall"));
|
|
72
|
+
}
|
|
73
|
+
}
|