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,177 @@
|
|
|
1
|
+
use crate::origin::error::{HunterError, Result};
|
|
2
|
+
use hickory_resolver::TokioResolver;
|
|
3
|
+
use hickory_resolver::proto::rr::{RData, RecordType};
|
|
4
|
+
use ipnet::Ipv4Net;
|
|
5
|
+
use regex::Regex;
|
|
6
|
+
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
|
7
|
+
use std::str::FromStr;
|
|
8
|
+
use std::sync::OnceLock;
|
|
9
|
+
|
|
10
|
+
/// Creates a new asynchronous DNS resolver configured with system resolver and fallback
|
|
11
|
+
pub fn create_resolver() -> Result<TokioResolver> {
|
|
12
|
+
TokioResolver::builder_tokio()
|
|
13
|
+
.map_err(|e| HunterError::Dns(e.to_string()))?
|
|
14
|
+
.build()
|
|
15
|
+
.map_err(|e| HunterError::Dns(e.to_string()))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/// Resolves A (IPv4) and AAAA (IPv6) records for a domain
|
|
19
|
+
pub async fn resolve_ips(resolver: &TokioResolver, domain: &str) -> Result<Vec<IpAddr>> {
|
|
20
|
+
let mut results = Vec::new();
|
|
21
|
+
|
|
22
|
+
if let Ok(response) = resolver.lookup_ip(domain).await {
|
|
23
|
+
for ip in response.iter() {
|
|
24
|
+
if !results.contains(&ip) {
|
|
25
|
+
results.push(ip);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
Ok(results)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// Resolves MX records for a domain and looks up the IP addresses of the mail exchangers
|
|
34
|
+
pub async fn resolve_mx_servers(
|
|
35
|
+
resolver: &TokioResolver,
|
|
36
|
+
domain: &str,
|
|
37
|
+
) -> Result<Vec<(String, Vec<IpAddr>)>> {
|
|
38
|
+
let mut servers = Vec::new();
|
|
39
|
+
|
|
40
|
+
if let Ok(lookup) = resolver.lookup(domain, RecordType::MX).await {
|
|
41
|
+
for record in lookup.answers() {
|
|
42
|
+
if let RData::MX(ref mx) = record.data {
|
|
43
|
+
let exchange = mx.exchange.to_utf8().trim_end_matches('.').to_string();
|
|
44
|
+
let ips = resolve_ips(resolver, &exchange).await.unwrap_or_default();
|
|
45
|
+
servers.push((exchange, ips));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
Ok(servers)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// Resolves TXT records for a domain
|
|
54
|
+
pub async fn resolve_txt_records(resolver: &TokioResolver, domain: &str) -> Result<Vec<String>> {
|
|
55
|
+
let mut records = Vec::new();
|
|
56
|
+
|
|
57
|
+
if let Ok(lookup) = resolver.lookup(domain, RecordType::TXT).await {
|
|
58
|
+
for record in lookup.answers() {
|
|
59
|
+
if let RData::TXT(ref txt) = record.data {
|
|
60
|
+
let text = txt
|
|
61
|
+
.txt_data
|
|
62
|
+
.iter()
|
|
63
|
+
.map(|b| String::from_utf8_lossy(b).into_owned())
|
|
64
|
+
.collect::<Vec<_>>()
|
|
65
|
+
.join("");
|
|
66
|
+
records.push(text);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
Ok(records)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/// Resolves NS records for a domain
|
|
75
|
+
pub async fn resolve_ns_records(resolver: &TokioResolver, domain: &str) -> Result<Vec<String>> {
|
|
76
|
+
let mut records = Vec::new();
|
|
77
|
+
|
|
78
|
+
if let Ok(lookup) = resolver.lookup(domain, RecordType::NS).await {
|
|
79
|
+
for record in lookup.answers() {
|
|
80
|
+
if let RData::NS(ref ns) = record.data {
|
|
81
|
+
let ns_str = ns.0.to_utf8().trim_end_matches('.').to_string();
|
|
82
|
+
records.push(ns_str);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
Ok(records)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
static SPF_IP4_REGEX: OnceLock<Regex> = OnceLock::new();
|
|
91
|
+
static SPF_IP6_REGEX: OnceLock<Regex> = OnceLock::new();
|
|
92
|
+
|
|
93
|
+
fn get_spf_ip4_regex() -> &'static Regex {
|
|
94
|
+
SPF_IP4_REGEX.get_or_init(|| Regex::new(r"(?i)ip4:([0-9\.\/]+)").unwrap())
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fn get_spf_ip6_regex() -> &'static Regex {
|
|
98
|
+
SPF_IP6_REGEX.get_or_init(|| Regex::new(r"(?i)ip6:([0-9a-fA-F:\/]+)").unwrap())
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/// Extracts IPv4 and IPv6 addresses specified directly in an SPF record
|
|
102
|
+
pub fn extract_ips_from_spf(spf_text: &str) -> Vec<IpAddr> {
|
|
103
|
+
let mut ips = Vec::new();
|
|
104
|
+
|
|
105
|
+
if !spf_text.to_lowercase().contains("v=spf1") {
|
|
106
|
+
return ips;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Match ip4:
|
|
110
|
+
let ip4_re = get_spf_ip4_regex();
|
|
111
|
+
for cap in ip4_re.captures_iter(spf_text) {
|
|
112
|
+
if let Some(matched) = cap.get(1) {
|
|
113
|
+
let val = matched.as_str();
|
|
114
|
+
if val.contains('/') {
|
|
115
|
+
if let Ok(net) = Ipv4Net::from_str(val) {
|
|
116
|
+
let addr = IpAddr::V4(net.addr());
|
|
117
|
+
if !ips.contains(&addr) {
|
|
118
|
+
ips.push(addr);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} else if let Ok(ip) = Ipv4Addr::from_str(val) {
|
|
122
|
+
let addr = IpAddr::V4(ip);
|
|
123
|
+
if !ips.contains(&addr) {
|
|
124
|
+
ips.push(addr);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Match ip6:
|
|
131
|
+
let ip6_re = get_spf_ip6_regex();
|
|
132
|
+
for cap in ip6_re.captures_iter(spf_text) {
|
|
133
|
+
if let Some(matched) = cap.get(1) {
|
|
134
|
+
let val = matched.as_str();
|
|
135
|
+
let cleaned = val.split('/').next().unwrap_or(val);
|
|
136
|
+
if let Ok(ip) = Ipv6Addr::from_str(cleaned) {
|
|
137
|
+
let addr = IpAddr::V6(ip);
|
|
138
|
+
if !ips.contains(&addr) {
|
|
139
|
+
ips.push(addr);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
ips
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
#[cfg(test)]
|
|
149
|
+
mod tests {
|
|
150
|
+
use super::*;
|
|
151
|
+
|
|
152
|
+
#[test]
|
|
153
|
+
fn test_extract_ips_from_spf() {
|
|
154
|
+
let spf = "v=spf1 ip4:198.51.100.42 ip4:203.0.113.0/24 ip6:2001:db8::1 include:_spf.google.com ~all";
|
|
155
|
+
let ips = extract_ips_from_spf(spf);
|
|
156
|
+
|
|
157
|
+
assert_eq!(ips.len(), 3);
|
|
158
|
+
assert!(ips.contains(&"198.51.100.42".parse().unwrap()));
|
|
159
|
+
assert!(ips.contains(&"203.0.113.0".parse().unwrap()));
|
|
160
|
+
assert!(ips.contains(&"2001:db8::1".parse().unwrap()));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
#[test]
|
|
164
|
+
fn test_extract_ips_from_non_spf() {
|
|
165
|
+
let txt = "google-site-verification=abcdef123456";
|
|
166
|
+
let ips = extract_ips_from_spf(txt);
|
|
167
|
+
assert!(ips.is_empty());
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
#[test]
|
|
171
|
+
fn test_extract_ips_single_ip4() {
|
|
172
|
+
let spf = "v=spf1 a mx ip4:45.33.32.156 -all";
|
|
173
|
+
let ips = extract_ips_from_spf(spf);
|
|
174
|
+
assert_eq!(ips.len(), 1);
|
|
175
|
+
assert_eq!(ips[0], "45.33.32.156".parse::<IpAddr>().unwrap());
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
use crate::origin::cloudflare::{is_cloudflare_ip, partition_ips};
|
|
2
|
+
use crate::origin::crtsh::query_crtsh;
|
|
3
|
+
use crate::origin::dns::{
|
|
4
|
+
extract_ips_from_spf, resolve_ips, resolve_mx_servers, resolve_ns_records, resolve_txt_records,
|
|
5
|
+
};
|
|
6
|
+
use crate::origin::error::Result;
|
|
7
|
+
use crate::origin::models::{CandidateIp, DiscoverySource};
|
|
8
|
+
use futures::stream::{self, StreamExt};
|
|
9
|
+
use hickory_resolver::TokioResolver;
|
|
10
|
+
use reqwest::Client;
|
|
11
|
+
use std::collections::HashMap;
|
|
12
|
+
use std::net::IpAddr;
|
|
13
|
+
use std::path::Path;
|
|
14
|
+
|
|
15
|
+
pub const DEFAULT_SUBDOMAINS: &[&str] = &[
|
|
16
|
+
"direct",
|
|
17
|
+
"origin",
|
|
18
|
+
"mail",
|
|
19
|
+
"cpanel",
|
|
20
|
+
"ftp",
|
|
21
|
+
"dev",
|
|
22
|
+
"staging",
|
|
23
|
+
"portal",
|
|
24
|
+
"admin",
|
|
25
|
+
"api-origin",
|
|
26
|
+
"backend",
|
|
27
|
+
"internal",
|
|
28
|
+
"vps",
|
|
29
|
+
"server",
|
|
30
|
+
"ssh",
|
|
31
|
+
"mx",
|
|
32
|
+
"autodiscover",
|
|
33
|
+
"webmail",
|
|
34
|
+
"test",
|
|
35
|
+
"beta",
|
|
36
|
+
"stage",
|
|
37
|
+
"corp",
|
|
38
|
+
"vpn",
|
|
39
|
+
"ns1",
|
|
40
|
+
"ns2",
|
|
41
|
+
"smtp",
|
|
42
|
+
"imap",
|
|
43
|
+
"pop3",
|
|
44
|
+
"git",
|
|
45
|
+
"jenkins",
|
|
46
|
+
"status",
|
|
47
|
+
"monitor",
|
|
48
|
+
"app-origin",
|
|
49
|
+
"gateway",
|
|
50
|
+
"remote",
|
|
51
|
+
"intranet",
|
|
52
|
+
"whm",
|
|
53
|
+
"webdisk",
|
|
54
|
+
"secure",
|
|
55
|
+
"preview",
|
|
56
|
+
"old",
|
|
57
|
+
"legacy",
|
|
58
|
+
"demo",
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/// Options for configuring candidate origin enumeration
|
|
62
|
+
#[derive(Debug, Clone)]
|
|
63
|
+
pub struct EnumeratorOptions {
|
|
64
|
+
pub concurrency: usize,
|
|
65
|
+
pub enable_crtsh: bool,
|
|
66
|
+
pub enable_subdomains: bool,
|
|
67
|
+
pub enable_dns: bool,
|
|
68
|
+
pub custom_wordlist: Option<Vec<String>>,
|
|
69
|
+
pub timeout_secs: u64,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
impl Default for EnumeratorOptions {
|
|
73
|
+
fn default() -> Self {
|
|
74
|
+
Self {
|
|
75
|
+
concurrency: 10,
|
|
76
|
+
enable_crtsh: true,
|
|
77
|
+
enable_subdomains: true,
|
|
78
|
+
enable_dns: true,
|
|
79
|
+
custom_wordlist: None,
|
|
80
|
+
timeout_secs: 5,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// Reads a custom wordlist from file
|
|
86
|
+
pub fn load_wordlist_file(path: &Path) -> Result<Vec<String>> {
|
|
87
|
+
let content = std::fs::read_to_string(path)?;
|
|
88
|
+
let list: Vec<String> = content
|
|
89
|
+
.lines()
|
|
90
|
+
.map(|l| l.trim().to_lowercase())
|
|
91
|
+
.filter(|l| !l.is_empty() && !l.starts_with('#'))
|
|
92
|
+
.collect();
|
|
93
|
+
Ok(list)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// Discovers candidate origin IPs for a target domain across subdomains, DNS, and Certificate Transparency
|
|
97
|
+
pub async fn enumerate_candidate_ips(
|
|
98
|
+
domain: &str,
|
|
99
|
+
resolver: &TokioResolver,
|
|
100
|
+
http_client: &Client,
|
|
101
|
+
options: &EnumeratorOptions,
|
|
102
|
+
) -> Result<Vec<CandidateIp>> {
|
|
103
|
+
let mut candidate_map: HashMap<IpAddr, CandidateIp> = HashMap::new();
|
|
104
|
+
let root = domain.trim().to_lowercase();
|
|
105
|
+
|
|
106
|
+
// 1. Direct Domain Baseline Partitioning
|
|
107
|
+
if let Ok(root_ips) = resolve_ips(resolver, &root).await {
|
|
108
|
+
let (_cf_ips, non_cf) = partition_ips(&root_ips);
|
|
109
|
+
for ip in non_cf {
|
|
110
|
+
candidate_map.insert(
|
|
111
|
+
ip,
|
|
112
|
+
CandidateIp {
|
|
113
|
+
ip,
|
|
114
|
+
source: DiscoverySource::DirectDns(root.clone()),
|
|
115
|
+
hostname: Some(root.clone()),
|
|
116
|
+
is_cloudflare: false,
|
|
117
|
+
notes: vec![
|
|
118
|
+
"Apex / Root domain A/AAAA record points directly to non-Cloudflare IP"
|
|
119
|
+
.into(),
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 2. DNS Records Check (MX, SPF, TXT, NS)
|
|
127
|
+
if options.enable_dns {
|
|
128
|
+
// MX Records
|
|
129
|
+
if let Ok(mx_list) = resolve_mx_servers(resolver, &root).await {
|
|
130
|
+
for (exchange, ips) in mx_list {
|
|
131
|
+
for ip in ips {
|
|
132
|
+
if !is_cloudflare_ip(&ip) {
|
|
133
|
+
candidate_map.entry(ip).or_insert_with(|| CandidateIp {
|
|
134
|
+
ip,
|
|
135
|
+
source: DiscoverySource::MxRecord(exchange.clone()),
|
|
136
|
+
hostname: Some(exchange.clone()),
|
|
137
|
+
is_cloudflare: false,
|
|
138
|
+
notes: vec![format!("Discovered via MX exchange {}", exchange)],
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// TXT and SPF Records
|
|
146
|
+
if let Ok(txt_records) = resolve_txt_records(resolver, &root).await {
|
|
147
|
+
for txt in &txt_records {
|
|
148
|
+
let spf_ips = extract_ips_from_spf(txt);
|
|
149
|
+
for ip in spf_ips {
|
|
150
|
+
if !is_cloudflare_ip(&ip) {
|
|
151
|
+
candidate_map.entry(ip).or_insert_with(|| CandidateIp {
|
|
152
|
+
ip,
|
|
153
|
+
source: DiscoverySource::SpfRecord(txt.clone()),
|
|
154
|
+
hostname: None,
|
|
155
|
+
is_cloudflare: false,
|
|
156
|
+
notes: vec!["Extracted from SPF policy declaration".into()],
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// NS Records
|
|
164
|
+
if let Ok(ns_records) = resolve_ns_records(resolver, &root).await {
|
|
165
|
+
for ns in ns_records {
|
|
166
|
+
if let Ok(ips) = resolve_ips(resolver, &ns).await {
|
|
167
|
+
for ip in ips {
|
|
168
|
+
if !is_cloudflare_ip(&ip) {
|
|
169
|
+
candidate_map.entry(ip).or_insert_with(|| CandidateIp {
|
|
170
|
+
ip,
|
|
171
|
+
source: DiscoverySource::DirectDns(format!("NS: {}", ns)),
|
|
172
|
+
hostname: Some(ns.clone()),
|
|
173
|
+
is_cloudflare: false,
|
|
174
|
+
notes: vec![format!("Discovered via Nameserver {}", ns)],
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 3. Subdomain Wordlist Enumeration
|
|
184
|
+
if options.enable_subdomains {
|
|
185
|
+
let subdomains_to_check: Vec<String> = match &options.custom_wordlist {
|
|
186
|
+
Some(custom) => custom.clone(),
|
|
187
|
+
None => DEFAULT_SUBDOMAINS.iter().map(|&s| s.to_string()).collect(),
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
let fqdns: Vec<String> = subdomains_to_check
|
|
191
|
+
.into_iter()
|
|
192
|
+
.map(|sub| {
|
|
193
|
+
if sub.contains('.') {
|
|
194
|
+
sub
|
|
195
|
+
} else {
|
|
196
|
+
format!("{}.{}", sub, root)
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
.collect();
|
|
200
|
+
|
|
201
|
+
let concurrency = options.concurrency.max(1);
|
|
202
|
+
let stream = stream::iter(fqdns).map(|fqdn| {
|
|
203
|
+
let res = resolver.clone();
|
|
204
|
+
async move {
|
|
205
|
+
let ips = resolve_ips(&res, &fqdn).await.unwrap_or_default();
|
|
206
|
+
(fqdn, ips)
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
let mut buffered = stream.buffer_unordered(concurrency);
|
|
211
|
+
while let Some((fqdn, ips)) = buffered.next().await {
|
|
212
|
+
for ip in ips {
|
|
213
|
+
if !is_cloudflare_ip(&ip) {
|
|
214
|
+
candidate_map.entry(ip).or_insert_with(|| CandidateIp {
|
|
215
|
+
ip,
|
|
216
|
+
source: DiscoverySource::Subdomain(fqdn.clone()),
|
|
217
|
+
hostname: Some(fqdn.clone()),
|
|
218
|
+
is_cloudflare: false,
|
|
219
|
+
notes: vec![format!("Subdomain {} resolved to unmasked IP", fqdn)],
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 4. Certificate Transparency Logs (crt.sh)
|
|
227
|
+
if options.enable_crtsh
|
|
228
|
+
&& let Ok(sans) = query_crtsh(http_client, &root, options.timeout_secs).await {
|
|
229
|
+
let stream = stream::iter(sans).map(|san| {
|
|
230
|
+
let res = resolver.clone();
|
|
231
|
+
async move {
|
|
232
|
+
let ips = resolve_ips(&res, &san).await.unwrap_or_default();
|
|
233
|
+
(san, ips)
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
let mut buffered = stream.buffer_unordered(options.concurrency.max(1));
|
|
238
|
+
while let Some((san, ips)) = buffered.next().await {
|
|
239
|
+
for ip in ips {
|
|
240
|
+
if !is_cloudflare_ip(&ip) {
|
|
241
|
+
candidate_map.entry(ip).or_insert_with(|| CandidateIp {
|
|
242
|
+
ip,
|
|
243
|
+
source: DiscoverySource::CertificateTransparency(san.clone()),
|
|
244
|
+
hostname: Some(san.clone()),
|
|
245
|
+
is_cloudflare: false,
|
|
246
|
+
notes: vec![format!("crt.sh SAN {} resolved to unmasked IP", san)],
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let mut result: Vec<CandidateIp> = candidate_map.into_values().collect();
|
|
254
|
+
result.sort_by_key(|c| c.ip);
|
|
255
|
+
Ok(result)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
#[cfg(test)]
|
|
259
|
+
mod tests {
|
|
260
|
+
use super::*;
|
|
261
|
+
|
|
262
|
+
#[test]
|
|
263
|
+
fn test_default_subdomains_list() {
|
|
264
|
+
assert!(DEFAULT_SUBDOMAINS.contains(&"origin"));
|
|
265
|
+
assert!(DEFAULT_SUBDOMAINS.contains(&"direct"));
|
|
266
|
+
assert!(DEFAULT_SUBDOMAINS.contains(&"cpanel"));
|
|
267
|
+
assert!(DEFAULT_SUBDOMAINS.contains(&"mail"));
|
|
268
|
+
assert!(DEFAULT_SUBDOMAINS.contains(&"dev"));
|
|
269
|
+
assert!(DEFAULT_SUBDOMAINS.contains(&"staging"));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
use thiserror::Error;
|
|
2
|
+
|
|
3
|
+
#[derive(Error, Debug)]
|
|
4
|
+
pub enum HunterError {
|
|
5
|
+
#[error("DNS error: {0}")]
|
|
6
|
+
Dns(String),
|
|
7
|
+
#[error("HTTP error: {0}")]
|
|
8
|
+
Http(#[from] reqwest::Error),
|
|
9
|
+
#[error("IO error: {0}")]
|
|
10
|
+
Io(#[from] std::io::Error),
|
|
11
|
+
#[error("JSON error: {0}")]
|
|
12
|
+
Json(#[from] serde_json::Error),
|
|
13
|
+
#[error("Invalid target domain: {0}")]
|
|
14
|
+
InvalidTarget(String),
|
|
15
|
+
#[error("Other error: {0}")]
|
|
16
|
+
Other(String),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
pub type Result<T> = std::result::Result<T, HunterError>;
|