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,48 @@
|
|
|
1
|
+
pub mod json_format;
|
|
2
|
+
pub mod sarif;
|
|
3
|
+
pub mod text;
|
|
4
|
+
|
|
5
|
+
use clap::ValueEnum;
|
|
6
|
+
use serde::{Deserialize, Serialize};
|
|
7
|
+
|
|
8
|
+
pub use json_format::format_json_report;
|
|
9
|
+
pub use sarif::format_sarif_report;
|
|
10
|
+
pub use text::format_terminal_report;
|
|
11
|
+
|
|
12
|
+
use crate::secrets::rules::types::Rule;
|
|
13
|
+
use crate::secrets::scanner::ScanResult;
|
|
14
|
+
|
|
15
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize, Deserialize)]
|
|
16
|
+
#[serde(rename_all = "lowercase")]
|
|
17
|
+
pub enum OutputFormat {
|
|
18
|
+
Text,
|
|
19
|
+
Json,
|
|
20
|
+
Sarif,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
impl std::fmt::Display for OutputFormat {
|
|
24
|
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
25
|
+
match self {
|
|
26
|
+
OutputFormat::Text => write!(f, "text"),
|
|
27
|
+
OutputFormat::Json => write!(f, "json"),
|
|
28
|
+
OutputFormat::Sarif => write!(f, "sarif"),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// Renders the scan result according to the specified output format.
|
|
34
|
+
pub fn render_report(
|
|
35
|
+
format: OutputFormat,
|
|
36
|
+
result: &ScanResult,
|
|
37
|
+
rules: &[Rule],
|
|
38
|
+
tool_version: &str,
|
|
39
|
+
verbose: bool,
|
|
40
|
+
) -> Result<String, String> {
|
|
41
|
+
match format {
|
|
42
|
+
OutputFormat::Text => Ok(format_terminal_report(result, verbose)),
|
|
43
|
+
OutputFormat::Json => format_json_report(&result.findings, &result.stats, tool_version)
|
|
44
|
+
.map_err(|e| format!("Failed to serialize JSON report: {}", e)),
|
|
45
|
+
OutputFormat::Sarif => format_sarif_report(&result.findings, rules, tool_version)
|
|
46
|
+
.map_err(|e| format!("Failed to serialize SARIF report: {}", e)),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
use serde::{Deserialize, Serialize};
|
|
2
|
+
use std::collections::HashMap;
|
|
3
|
+
|
|
4
|
+
use crate::secrets::rules::types::{Finding, Rule};
|
|
5
|
+
|
|
6
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
7
|
+
pub struct SarifReport {
|
|
8
|
+
#[serde(rename = "$schema")]
|
|
9
|
+
pub schema: String,
|
|
10
|
+
pub version: String,
|
|
11
|
+
pub runs: Vec<SarifRun>,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
15
|
+
pub struct SarifRun {
|
|
16
|
+
pub tool: SarifTool,
|
|
17
|
+
pub results: Vec<SarifResult>,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
21
|
+
pub struct SarifTool {
|
|
22
|
+
pub driver: SarifDriver,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
26
|
+
pub struct SarifDriver {
|
|
27
|
+
pub name: String,
|
|
28
|
+
pub version: String,
|
|
29
|
+
#[serde(rename = "informationUri")]
|
|
30
|
+
pub information_uri: String,
|
|
31
|
+
pub rules: Vec<SarifRule>,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
35
|
+
pub struct SarifRule {
|
|
36
|
+
pub id: String,
|
|
37
|
+
pub name: String,
|
|
38
|
+
#[serde(rename = "shortDescription")]
|
|
39
|
+
pub short_description: SarifMessage,
|
|
40
|
+
#[serde(rename = "fullDescription")]
|
|
41
|
+
pub full_description: SarifMessage,
|
|
42
|
+
#[serde(rename = "defaultConfiguration")]
|
|
43
|
+
pub default_configuration: SarifConfig,
|
|
44
|
+
pub help: SarifHelp,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
48
|
+
pub struct SarifConfig {
|
|
49
|
+
pub level: String,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
53
|
+
pub struct SarifHelp {
|
|
54
|
+
pub text: String,
|
|
55
|
+
pub markdown: String,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
59
|
+
pub struct SarifResult {
|
|
60
|
+
#[serde(rename = "ruleId")]
|
|
61
|
+
pub rule_id: String,
|
|
62
|
+
pub level: String,
|
|
63
|
+
pub message: SarifMessage,
|
|
64
|
+
pub locations: Vec<SarifLocation>,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
68
|
+
pub struct SarifMessage {
|
|
69
|
+
pub text: String,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
73
|
+
pub struct SarifLocation {
|
|
74
|
+
#[serde(rename = "physicalLocation")]
|
|
75
|
+
pub physical_location: SarifPhysicalLocation,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
79
|
+
pub struct SarifPhysicalLocation {
|
|
80
|
+
#[serde(rename = "artifactLocation")]
|
|
81
|
+
pub artifact_location: SarifArtifactLocation,
|
|
82
|
+
pub region: SarifRegion,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
86
|
+
pub struct SarifArtifactLocation {
|
|
87
|
+
pub uri: String,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
91
|
+
pub struct SarifRegion {
|
|
92
|
+
#[serde(rename = "startLine")]
|
|
93
|
+
pub start_line: usize,
|
|
94
|
+
#[serde(rename = "startColumn")]
|
|
95
|
+
pub start_column: usize,
|
|
96
|
+
#[serde(rename = "endLine")]
|
|
97
|
+
pub end_line: usize,
|
|
98
|
+
#[serde(rename = "endColumn")]
|
|
99
|
+
pub end_column: usize,
|
|
100
|
+
pub snippet: SarifMessage,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/// Generates a SARIF v2.1.0 JSON report.
|
|
104
|
+
pub fn format_sarif_report(
|
|
105
|
+
findings: &[Finding],
|
|
106
|
+
rules: &[Rule],
|
|
107
|
+
tool_version: &str,
|
|
108
|
+
) -> Result<String, serde_json::Error> {
|
|
109
|
+
let mut rule_map: HashMap<String, &Rule> = HashMap::new();
|
|
110
|
+
for rule in rules {
|
|
111
|
+
rule_map.insert(rule.id.clone(), rule);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Collect rules that actually have findings or all builtin rules
|
|
115
|
+
let mut sarif_rules = Vec::new();
|
|
116
|
+
for rule in rules {
|
|
117
|
+
sarif_rules.push(SarifRule {
|
|
118
|
+
id: rule.id.clone(),
|
|
119
|
+
name: rule.name.replace(' ', ""),
|
|
120
|
+
short_description: SarifMessage {
|
|
121
|
+
text: rule.name.clone(),
|
|
122
|
+
},
|
|
123
|
+
full_description: SarifMessage {
|
|
124
|
+
text: rule.description.clone(),
|
|
125
|
+
},
|
|
126
|
+
default_configuration: SarifConfig {
|
|
127
|
+
level: rule.severity.to_sarif_level().to_string(),
|
|
128
|
+
},
|
|
129
|
+
help: SarifHelp {
|
|
130
|
+
text: rule.recommendation.clone(),
|
|
131
|
+
markdown: format!("### Remediation\n\n{}", rule.recommendation),
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let mut sarif_results = Vec::new();
|
|
137
|
+
for finding in findings {
|
|
138
|
+
let match_len = finding.raw_secret.len().max(1);
|
|
139
|
+
sarif_results.push(SarifResult {
|
|
140
|
+
rule_id: finding.rule_id.clone(),
|
|
141
|
+
level: finding.severity.to_sarif_level().to_string(),
|
|
142
|
+
message: SarifMessage {
|
|
143
|
+
text: format!(
|
|
144
|
+
"{}: {} (Secret redacted: {})",
|
|
145
|
+
finding.rule_name, finding.description, finding.redacted_secret
|
|
146
|
+
),
|
|
147
|
+
},
|
|
148
|
+
locations: vec![SarifLocation {
|
|
149
|
+
physical_location: SarifPhysicalLocation {
|
|
150
|
+
artifact_location: SarifArtifactLocation {
|
|
151
|
+
uri: finding.file_path.clone(),
|
|
152
|
+
},
|
|
153
|
+
region: SarifRegion {
|
|
154
|
+
start_line: finding.line_number,
|
|
155
|
+
start_column: finding.column_number,
|
|
156
|
+
end_line: finding.line_number,
|
|
157
|
+
end_column: finding.column_number + match_len,
|
|
158
|
+
snippet: SarifMessage {
|
|
159
|
+
text: finding.line_content.clone(),
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
}],
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let report = SarifReport {
|
|
168
|
+
schema: "https://json.schemastore.org/sarif-2.1.0.json".to_string(),
|
|
169
|
+
version: "2.1.0".to_string(),
|
|
170
|
+
runs: vec![SarifRun {
|
|
171
|
+
tool: SarifTool {
|
|
172
|
+
driver: SarifDriver {
|
|
173
|
+
name: "cf-secret-leak-guard".to_string(),
|
|
174
|
+
version: tool_version.to_string(),
|
|
175
|
+
information_uri: "https://github.com/cloudflare/cf-secret-leak-guard"
|
|
176
|
+
.to_string(),
|
|
177
|
+
rules: sarif_rules,
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
results: sarif_results,
|
|
181
|
+
}],
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
serde_json::to_string_pretty(&report)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
#[cfg(test)]
|
|
188
|
+
mod tests {
|
|
189
|
+
use super::*;
|
|
190
|
+
use crate::secrets::rules::types::Severity;
|
|
191
|
+
|
|
192
|
+
#[test]
|
|
193
|
+
fn test_sarif_generation() {
|
|
194
|
+
let rule = Rule::new_exact(
|
|
195
|
+
"CF-001",
|
|
196
|
+
"Cloudflare API Token",
|
|
197
|
+
"Detects Cloudflare API Token",
|
|
198
|
+
Severity::Critical,
|
|
199
|
+
"secret_token_1234",
|
|
200
|
+
"Move token to server environment variables",
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
let finding = Finding {
|
|
204
|
+
rule_id: "CF-001".into(),
|
|
205
|
+
rule_name: "Cloudflare API Token".into(),
|
|
206
|
+
severity: Severity::Critical,
|
|
207
|
+
file_path: "dist/client/app.js".into(),
|
|
208
|
+
line_number: 12,
|
|
209
|
+
column_number: 8,
|
|
210
|
+
match_start: 50,
|
|
211
|
+
match_end: 67,
|
|
212
|
+
raw_secret: "secret_token_1234".into(),
|
|
213
|
+
redacted_secret: "sec...1234".into(),
|
|
214
|
+
line_content: "const token = [REDACTED]".into(),
|
|
215
|
+
description: "Detects Cloudflare API Token".into(),
|
|
216
|
+
recommendation: "Move token to server environment variables".into(),
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
let sarif_json = format_sarif_report(&[finding], &[rule], "0.1.0").unwrap();
|
|
220
|
+
assert!(sarif_json.contains("https://json.schemastore.org/sarif-2.1.0.json"));
|
|
221
|
+
assert!(sarif_json.contains("2.1.0"));
|
|
222
|
+
assert!(sarif_json.contains("CF-001"));
|
|
223
|
+
assert!(sarif_json.contains("dist/client/app.js"));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
use crate::secrets::rules::types::Severity;
|
|
2
|
+
use crate::secrets::scanner::ScanResult;
|
|
3
|
+
use colored::Colorize;
|
|
4
|
+
|
|
5
|
+
/// Formats scan findings into human-readable colored terminal output.
|
|
6
|
+
pub fn format_terminal_report(result: &ScanResult, verbose: bool) -> String {
|
|
7
|
+
let mut out = String::new();
|
|
8
|
+
|
|
9
|
+
if result.findings.is_empty() {
|
|
10
|
+
out.push_str(&format!(
|
|
11
|
+
"\n{}\n",
|
|
12
|
+
"✔ cf-secret-leak-guard: No Cloudflare secrets detected in client assets!"
|
|
13
|
+
.green()
|
|
14
|
+
.bold()
|
|
15
|
+
));
|
|
16
|
+
out.push_str(&format!(
|
|
17
|
+
"Scanned {} files ({:.2} MB) in {}ms\n",
|
|
18
|
+
result.stats.files_scanned,
|
|
19
|
+
result.stats.bytes_scanned as f64 / (1024.0 * 1024.0),
|
|
20
|
+
result.stats.duration_ms
|
|
21
|
+
));
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
out.push_str(&format!(
|
|
26
|
+
"\n{}\n",
|
|
27
|
+
"🚨 Cloudflare Secret Leaks Detected in Client Bundles!"
|
|
28
|
+
.red()
|
|
29
|
+
.bold()
|
|
30
|
+
));
|
|
31
|
+
out.push_str(&format!(
|
|
32
|
+
"{}\n\n",
|
|
33
|
+
"================================================================================"
|
|
34
|
+
.bright_red()
|
|
35
|
+
));
|
|
36
|
+
|
|
37
|
+
for (idx, finding) in result.findings.iter().enumerate() {
|
|
38
|
+
let severity_badge = match finding.severity {
|
|
39
|
+
Severity::Critical => "[CRITICAL]".on_red().white().bold(),
|
|
40
|
+
Severity::High => "[HIGH]".red().bold(),
|
|
41
|
+
Severity::Medium => "[MEDIUM]".yellow().bold(),
|
|
42
|
+
Severity::Low => "[LOW]".cyan().bold(),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
out.push_str(&format!(
|
|
46
|
+
"{}. {} {} - {}\n",
|
|
47
|
+
(idx + 1).to_string().bold(),
|
|
48
|
+
severity_badge,
|
|
49
|
+
finding.rule_id.bright_yellow().bold(),
|
|
50
|
+
finding.rule_name.bold()
|
|
51
|
+
));
|
|
52
|
+
|
|
53
|
+
out.push_str(&format!(
|
|
54
|
+
" {} {}:{}:{}\n",
|
|
55
|
+
"File:".bright_blue().bold(),
|
|
56
|
+
finding.file_path.bright_white(),
|
|
57
|
+
finding.line_number.to_string().cyan(),
|
|
58
|
+
finding.column_number.to_string().cyan()
|
|
59
|
+
));
|
|
60
|
+
|
|
61
|
+
out.push_str(&format!(
|
|
62
|
+
" {} {}\n",
|
|
63
|
+
"Secret:".bright_blue().bold(),
|
|
64
|
+
finding.redacted_secret.bright_red()
|
|
65
|
+
));
|
|
66
|
+
|
|
67
|
+
out.push_str(&format!(
|
|
68
|
+
" {} {}\n",
|
|
69
|
+
"Context:".bright_blue().bold(),
|
|
70
|
+
finding.line_content.dimmed()
|
|
71
|
+
));
|
|
72
|
+
|
|
73
|
+
if verbose || !finding.recommendation.is_empty() {
|
|
74
|
+
out.push_str(&format!(
|
|
75
|
+
" {} {}\n",
|
|
76
|
+
"Remediation:".bright_green().bold(),
|
|
77
|
+
finding.recommendation
|
|
78
|
+
));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
out.push('\n');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
out.push_str(&format!(
|
|
85
|
+
"{}\n",
|
|
86
|
+
"--------------------------------------------------------------------------------"
|
|
87
|
+
.bright_black()
|
|
88
|
+
));
|
|
89
|
+
out.push_str(&format!(
|
|
90
|
+
"Scan Summary: {} findings ({} Critical, {} High, {} Medium, {} Low)\n",
|
|
91
|
+
result.stats.total_findings.to_string().red().bold(),
|
|
92
|
+
result.stats.critical_count.to_string().red(),
|
|
93
|
+
result.stats.high_count.to_string().bright_red(),
|
|
94
|
+
result.stats.medium_count.to_string().yellow(),
|
|
95
|
+
result.stats.low_count.to_string().cyan()
|
|
96
|
+
));
|
|
97
|
+
out.push_str(&format!(
|
|
98
|
+
"Files Scanned: {} ({:.2} MB) in {}ms\n\n",
|
|
99
|
+
result.stats.files_scanned,
|
|
100
|
+
result.stats.bytes_scanned as f64 / (1024.0 * 1024.0),
|
|
101
|
+
result.stats.duration_ms
|
|
102
|
+
));
|
|
103
|
+
|
|
104
|
+
out
|
|
105
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
use crate::secrets::rules::types::{Rule, Severity};
|
|
2
|
+
use regex::Regex;
|
|
3
|
+
|
|
4
|
+
/// Returns all built-in security rules for Cloudflare secret detection.
|
|
5
|
+
pub fn get_builtin_rules() -> Vec<Rule> {
|
|
6
|
+
vec![
|
|
7
|
+
// CF-001: Cloudflare API Token (Contextual)
|
|
8
|
+
Rule::new_regex(
|
|
9
|
+
"CF-001",
|
|
10
|
+
"Cloudflare API Token",
|
|
11
|
+
"Detects Cloudflare API Tokens (40-char token) referenced in client bundles or configuration",
|
|
12
|
+
Severity::Critical,
|
|
13
|
+
Regex::new(r#"(?i)(?:cloudflare|cf)[-_]?(?:api[-_]?)?token\s*[:=]\s*['"]?([a-zA-Z0-9_-]{40})['"]?"#)
|
|
14
|
+
.expect("Valid regex for CF-001"),
|
|
15
|
+
"Move Cloudflare API Token to server-side environment variables or Cloudflare Workers secrets (wrangler secret put). Never expose API tokens in client-side code.",
|
|
16
|
+
),
|
|
17
|
+
|
|
18
|
+
// CF-002: Cloudflare Global API Key
|
|
19
|
+
Rule::new_regex(
|
|
20
|
+
"CF-002",
|
|
21
|
+
"Cloudflare Global API Key",
|
|
22
|
+
"Detects Cloudflare Global API Keys (37-character hex string) in client bundles",
|
|
23
|
+
Severity::Critical,
|
|
24
|
+
Regex::new(r#"(?i)(?:(?:cloudflare|cf)[-_]?(?:api[-_]?)?key|x-auth-key)\s*[:=]\s*['"]?([a-f0-9]{37})['"]?|\b([a-f0-9]{37})\b"#)
|
|
25
|
+
.expect("Valid regex for CF-002"),
|
|
26
|
+
"Rotate your Cloudflare Global API Key immediately and use scoped, least-privilege API Tokens instead. Never embed Global API Keys in client assets.",
|
|
27
|
+
),
|
|
28
|
+
|
|
29
|
+
// CF-003: Cloudflare Origin CA Key
|
|
30
|
+
Rule::new_regex(
|
|
31
|
+
"CF-003",
|
|
32
|
+
"Cloudflare Origin CA Key",
|
|
33
|
+
"Detects Cloudflare Origin CA Key (v1.0- prefix) in client bundles",
|
|
34
|
+
Severity::Critical,
|
|
35
|
+
Regex::new(r#"\b(v1\.0-[a-zA-Z0-9_\-]{24,128})\b"#)
|
|
36
|
+
.expect("Valid regex for CF-003"),
|
|
37
|
+
"Origin CA keys allow generating certificates for your Cloudflare domains and must be kept strictly server-side.",
|
|
38
|
+
),
|
|
39
|
+
|
|
40
|
+
// CF-004: Cloudflare Turnstile Secret Key
|
|
41
|
+
Rule::new_regex(
|
|
42
|
+
"CF-004",
|
|
43
|
+
"Cloudflare Turnstile Secret Key",
|
|
44
|
+
"Detects Cloudflare Turnstile Secret Key (used for server-side siteverify)",
|
|
45
|
+
Severity::Critical,
|
|
46
|
+
Regex::new(r#"(?i)(?:(?:cf[-_]?)?turnstile[-_]?(?:secret[-_]?key|secret)|(?:turnstile|cf)[-_]?private[-_]?key)\s*[:=]\s*['"]?([0-9a-zA-Z_-]{20,65})['"]?|\b([123]x0000000000000000000000000000000AA)\b"#)
|
|
47
|
+
.expect("Valid regex for CF-004"),
|
|
48
|
+
"Cloudflare Turnstile secret keys (0x4AAAA...) are for server-side verification only (/siteverify). Only publish your public Site Key in client HTML/JS.",
|
|
49
|
+
),
|
|
50
|
+
|
|
51
|
+
// CF-005: Cloudflare Access Service Token Client Secret
|
|
52
|
+
Rule::new_regex(
|
|
53
|
+
"CF-005",
|
|
54
|
+
"Cloudflare Access Service Token Secret",
|
|
55
|
+
"Detects Cloudflare Zero Trust Access Service Token Client Secret",
|
|
56
|
+
Severity::Critical,
|
|
57
|
+
Regex::new(r#"(?i)(?:cf[-_]?access[-_]?client[-_]?secret|CF-Access-Client-Secret)\s*[:=]\s*['"]?([a-zA-Z0-9_-]{32,64})['"]?"#)
|
|
58
|
+
.expect("Valid regex for CF-005"),
|
|
59
|
+
"Access Service Token secrets grant automated access past Cloudflare Zero Trust Access policies. Keep secrets server-side.",
|
|
60
|
+
),
|
|
61
|
+
|
|
62
|
+
// CF-006: Cloudflare Account ID (in secret context)
|
|
63
|
+
Rule::new_regex(
|
|
64
|
+
"CF-006",
|
|
65
|
+
"Cloudflare Account ID in Context",
|
|
66
|
+
"Detects Cloudflare Account ID (32-hex characters) assigned in sensitive configuration contexts",
|
|
67
|
+
Severity::Medium,
|
|
68
|
+
Regex::new(r#"(?i)(?:cloudflare|cf)[-_]?(?:account[-_]?id)\s*[:=]\s*['"]?([a-f0-9]{32})['"]?"#)
|
|
69
|
+
.expect("Valid regex for CF-006"),
|
|
70
|
+
"Avoid leaking Cloudflare Account IDs in client bundles to minimize reconnaissance surface against your Cloudflare account.",
|
|
71
|
+
),
|
|
72
|
+
|
|
73
|
+
// CF-007: Cloudflare R2 / Storage Secret Access Key
|
|
74
|
+
Rule::new_regex(
|
|
75
|
+
"CF-007",
|
|
76
|
+
"Cloudflare R2 / S3 Secret Access Key",
|
|
77
|
+
"Detects Cloudflare R2 or S3-compatible secret access keys (40 characters)",
|
|
78
|
+
Severity::Critical,
|
|
79
|
+
Regex::new(r#"(?i)(?:r2|s3|aws)[-_]?(?:secret[-_]?access[-_]?key|secret[-_]?key)\s*[:=]\s*['"]?([a-zA-Z0-9/+=]{40})['"]?"#)
|
|
80
|
+
.expect("Valid regex for CF-007"),
|
|
81
|
+
"Cloudflare R2 secret access keys provide direct read/write/delete access to buckets. Use Presigned URLs or Worker bindings instead.",
|
|
82
|
+
),
|
|
83
|
+
|
|
84
|
+
// CF-008: Database Connection String / D1 Credentials
|
|
85
|
+
Rule::new_regex(
|
|
86
|
+
"CF-008",
|
|
87
|
+
"Database Connection String / D1 Secret",
|
|
88
|
+
"Detects embedded database connection strings or D1 credentials in client bundles",
|
|
89
|
+
Severity::Critical,
|
|
90
|
+
Regex::new(r#"(?i)\b(?:postgres|postgresql|mysql|redis|mongodb|couchdb|d1)://[^\s:@/]+:[^\s:@]+@[a-zA-Z0-9_.-]+(?::[0-9]+)?(?:/[^\s'"`;]*)?|\b(?:d1[-_]?(?:token|api[-_]?token|database[-_]?token))\s*[:=]\s*['"]?([a-zA-Z0-9_-]{32,64})['"]?"#)
|
|
91
|
+
.expect("Valid regex for CF-008"),
|
|
92
|
+
"Never expose database credentials or connection strings to client-side browsers. Query databases via backend Worker APIs or Cloudflare Hyperdrive / D1 bindings.",
|
|
93
|
+
),
|
|
94
|
+
|
|
95
|
+
// CF-009: Generic High-Entropy Cloudflare API Token
|
|
96
|
+
Rule::new_regex(
|
|
97
|
+
"CF-009",
|
|
98
|
+
"Standalone High-Entropy Cloudflare API Token",
|
|
99
|
+
"Detects standalone 40-character high-entropy API tokens with Cloudflare token characteristics",
|
|
100
|
+
Severity::High,
|
|
101
|
+
Regex::new(r#"\b([a-zA-Z0-9_-]{40})\b"#)
|
|
102
|
+
.expect("Valid regex for CF-009"),
|
|
103
|
+
"Review if this 40-character high-entropy token is a Cloudflare API token or private service credential.",
|
|
104
|
+
).with_min_entropy(3.8),
|
|
105
|
+
|
|
106
|
+
// CF-010: Cloudflare Hyperdrive Origin Credentials
|
|
107
|
+
Rule::new_regex(
|
|
108
|
+
"CF-010",
|
|
109
|
+
"Cloudflare Hyperdrive Secret / Connection String",
|
|
110
|
+
"Detects Hyperdrive connection strings or origin database passwords in client code",
|
|
111
|
+
Severity::Critical,
|
|
112
|
+
Regex::new(r#"(?i)(?:hyperdrive[-_]?(?:origin[-_]?key|password|secret|conn[-_]?string))\s*[:=]\s*['"]?([^\s'"`;]{8,128})['"]?"#)
|
|
113
|
+
.expect("Valid regex for CF-010"),
|
|
114
|
+
"Hyperdrive manages database pooling securely at the edge. Never embed database passwords in frontend code; bind Hyperdrive inside wrangler.jsonc instead.",
|
|
115
|
+
),
|
|
116
|
+
|
|
117
|
+
// CF-011: Cloudflare Tunnel Token (cloudflared)
|
|
118
|
+
Rule::new_regex(
|
|
119
|
+
"CF-011",
|
|
120
|
+
"Cloudflare Tunnel Token",
|
|
121
|
+
"Detects Cloudflare Tunnel (cloudflared) enrollment tokens",
|
|
122
|
+
Severity::Critical,
|
|
123
|
+
Regex::new(r#"(?i)(?:tunnel[-_]?token|TUNNEL_TOKEN)\s*[:=]\s*['"]?(eyJh[a-zA-Z0-9_-]{60,250})['"]?|\b(eyJh[a-zA-Z0-9_-]{100,250})\b"#)
|
|
124
|
+
.expect("Valid regex for CF-011"),
|
|
125
|
+
"Tunnel tokens grant direct network ingress into your private origins and infrastructure. Store tunnel credentials securely on origin servers.",
|
|
126
|
+
),
|
|
127
|
+
|
|
128
|
+
// CF-012: Cloudflare AI Gateway & LLM API Keys
|
|
129
|
+
Rule::new_regex(
|
|
130
|
+
"CF-012",
|
|
131
|
+
"Cloudflare AI Gateway Token / LLM API Key",
|
|
132
|
+
"Detects Cloudflare AI Gateway tokens, OpenAI, Anthropic, or Gemini API keys in client assets",
|
|
133
|
+
Severity::Critical,
|
|
134
|
+
Regex::new(r#"(?i)(?:cf[-_]?ai[-_]?(?:gateway[-_]?)?token|cf[-_]?ai[-_]?token)\s*[:=]\s*['"]?([a-zA-Z0-9_-]{32,64})['"]?|\b(sk-ant-[a-zA-Z0-9_-]{40,120}|sk-proj-[a-zA-Z0-9_-]{40,120}|AIzaSy[a-zA-Z0-9_-]{33})\b"#)
|
|
135
|
+
.expect("Valid regex for CF-012"),
|
|
136
|
+
"Proxy LLM calls through Cloudflare AI Gateway or backend Workers. Never expose model provider API keys in client-side JavaScript.",
|
|
137
|
+
),
|
|
138
|
+
|
|
139
|
+
// CF-013: Cloudflare Vectorize Admin / Query Secret
|
|
140
|
+
Rule::new_regex(
|
|
141
|
+
"CF-013",
|
|
142
|
+
"Cloudflare Vectorize Secret",
|
|
143
|
+
"Detects Cloudflare Vectorize index secret keys or admin tokens",
|
|
144
|
+
Severity::Critical,
|
|
145
|
+
Regex::new(r#"(?i)(?:vectorize[-_]?(?:token|secret|admin[-_]?key))\s*[:=]\s*['"]?([a-zA-Z0-9_-]{32,64})['"]?"#)
|
|
146
|
+
.expect("Valid regex for CF-013"),
|
|
147
|
+
"Query Vectorize indexes via Worker bindings (env.VECTORIZE.query). Do not expose direct API management tokens.",
|
|
148
|
+
),
|
|
149
|
+
|
|
150
|
+
// CF-014: Cloudflare Email Routing / Transactional Mail Key
|
|
151
|
+
Rule::new_regex(
|
|
152
|
+
"CF-014",
|
|
153
|
+
"Cloudflare Email / MailChannels API Secret",
|
|
154
|
+
"Detects MailChannels DKIM secrets or transactional email tokens in Worker scripts",
|
|
155
|
+
Severity::High,
|
|
156
|
+
Regex::new(r#"(?i)(?:mailchannels[-_]?(?:api[-_]?key|secret)|cf[-_]?email[-_]?(?:secret|token))\s*[:=]\s*['"]?([a-zA-Z0-9_-]{24,64})['"]?"#)
|
|
157
|
+
.expect("Valid regex for CF-014"),
|
|
158
|
+
"Keep transactional email credentials in Cloudflare Worker secrets (wrangler secret put).",
|
|
159
|
+
),
|
|
160
|
+
|
|
161
|
+
// CF-015: TLS / SSL Private Key
|
|
162
|
+
Rule::new_regex(
|
|
163
|
+
"CF-015",
|
|
164
|
+
"TLS / SSL Private Key",
|
|
165
|
+
"Detects unencrypted RSA, EC, or OpenSSH private keys in static assets or source files",
|
|
166
|
+
Severity::Critical,
|
|
167
|
+
Regex::new(r#"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"#)
|
|
168
|
+
.expect("Valid regex for CF-015"),
|
|
169
|
+
"Private keys must never be committed to git repositories or served statically. Manage certificates via Cloudflare Origin CA or Key Management Service.",
|
|
170
|
+
),
|
|
171
|
+
]
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
#[cfg(test)]
|
|
175
|
+
mod tests {
|
|
176
|
+
use super::*;
|
|
177
|
+
|
|
178
|
+
#[test]
|
|
179
|
+
fn test_cf_api_token_rule() {
|
|
180
|
+
let rules = get_builtin_rules();
|
|
181
|
+
let cf_token_rule = rules.iter().find(|r| r.id == "CF-001").unwrap();
|
|
182
|
+
let re = cf_token_rule.pattern.as_ref().unwrap();
|
|
183
|
+
|
|
184
|
+
let sample = "const CF_API_TOKEN = 'V48uXZ-e_92mKqT1pLwRtYuIoPsDfGhJkLxZc0vb';";
|
|
185
|
+
assert!(re.is_match(sample));
|
|
186
|
+
|
|
187
|
+
let sample2 = "CLOUDFLARE_API_TOKEN: \"V48uXZ-e_92mKqT1pLwRtYuIoPsDfGhJkLxZc0vb\"";
|
|
188
|
+
assert!(re.is_match(sample2));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
#[test]
|
|
192
|
+
fn test_cf_global_api_key_rule() {
|
|
193
|
+
let rules = get_builtin_rules();
|
|
194
|
+
let key_rule = rules.iter().find(|r| r.id == "CF-002").unwrap();
|
|
195
|
+
let re = key_rule.pattern.as_ref().unwrap();
|
|
196
|
+
|
|
197
|
+
let sample = "c2547eb745079dac9320b638f5e22594b678a";
|
|
198
|
+
assert_eq!(sample.len(), 37);
|
|
199
|
+
assert!(re.is_match(sample));
|
|
200
|
+
|
|
201
|
+
let sample_context = "CF_API_KEY = \"c2547eb745079dac9320b638f5e22594b678a\"";
|
|
202
|
+
assert!(re.is_match(sample_context));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
#[test]
|
|
206
|
+
fn test_origin_ca_key_rule() {
|
|
207
|
+
let rules = get_builtin_rules();
|
|
208
|
+
let origin_rule = rules.iter().find(|r| r.id == "CF-003").unwrap();
|
|
209
|
+
let re = origin_rule.pattern.as_ref().unwrap();
|
|
210
|
+
|
|
211
|
+
let sample = "v1.0-1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef";
|
|
212
|
+
assert!(re.is_match(sample));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#[test]
|
|
216
|
+
fn test_turnstile_secret_rule() {
|
|
217
|
+
let rules = get_builtin_rules();
|
|
218
|
+
let turnstile_rule = rules.iter().find(|r| r.id == "CF-004").unwrap();
|
|
219
|
+
let re = turnstile_rule.pattern.as_ref().unwrap();
|
|
220
|
+
|
|
221
|
+
let sample1 = "turnstileSecret: '0x4AAAAAAAE-xyz1234567890abcdef'";
|
|
222
|
+
assert!(re.is_match(sample1));
|
|
223
|
+
|
|
224
|
+
let sample2 = "TURNSTILE_SECRET_KEY = '0x4AAAAAA1234567890abcdef1234567890'";
|
|
225
|
+
assert!(re.is_match(sample2));
|
|
226
|
+
|
|
227
|
+
let sample3 = "1x0000000000000000000000000000000AA";
|
|
228
|
+
assert!(re.is_match(sample3));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
#[test]
|
|
232
|
+
fn test_access_secret_rule() {
|
|
233
|
+
let rules = get_builtin_rules();
|
|
234
|
+
let access_rule = rules.iter().find(|r| r.id == "CF-005").unwrap();
|
|
235
|
+
let re = access_rule.pattern.as_ref().unwrap();
|
|
236
|
+
|
|
237
|
+
let sample = "CF_ACCESS_CLIENT_SECRET = 'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2'";
|
|
238
|
+
assert!(re.is_match(sample));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#[test]
|
|
242
|
+
fn test_db_connection_string_rule() {
|
|
243
|
+
let rules = get_builtin_rules();
|
|
244
|
+
let db_rule = rules.iter().find(|r| r.id == "CF-008").unwrap();
|
|
245
|
+
let re = db_rule.pattern.as_ref().unwrap();
|
|
246
|
+
|
|
247
|
+
let sample = "const dbUri = 'postgres://admin:SuperSecretPass123!@db.cloudflare.internal:5432/production';";
|
|
248
|
+
assert!(re.is_match(sample));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
#[test]
|
|
252
|
+
fn test_tunnel_token_rule() {
|
|
253
|
+
let rules = get_builtin_rules();
|
|
254
|
+
let tunnel_rule = rules.iter().find(|r| r.id == "CF-011").unwrap();
|
|
255
|
+
let re = tunnel_rule.pattern.as_ref().unwrap();
|
|
256
|
+
|
|
257
|
+
let sample = "TUNNEL_TOKEN = 'eyJhIjoiYWJjZGVmMTIzNDU2IiwidCI6IjEyMzQtNTY3OC05MGFiIiwicyI6IlNvbWVTZWNyZXRLZXkxMjM0NTY3ODkwYWJjZGVmIn0='";
|
|
258
|
+
assert!(re.is_match(sample));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
#[test]
|
|
262
|
+
fn test_ai_gateway_token_rule() {
|
|
263
|
+
let rules = get_builtin_rules();
|
|
264
|
+
let ai_rule = rules.iter().find(|r| r.id == "CF-012").unwrap();
|
|
265
|
+
let re = ai_rule.pattern.as_ref().unwrap();
|
|
266
|
+
|
|
267
|
+
let sample = "CF_AI_GATEWAY_TOKEN = 'abcdef1234567890abcdef1234567890'";
|
|
268
|
+
assert!(re.is_match(sample));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
#[test]
|
|
272
|
+
fn test_tls_private_key_rule() {
|
|
273
|
+
let rules = get_builtin_rules();
|
|
274
|
+
let tls_rule = rules.iter().find(|r| r.id == "CF-015").unwrap();
|
|
275
|
+
let re = tls_rule.pattern.as_ref().unwrap();
|
|
276
|
+
|
|
277
|
+
let sample = "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA0";
|
|
278
|
+
assert!(re.is_match(sample));
|
|
279
|
+
}
|
|
280
|
+
}
|