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.
Files changed (64) hide show
  1. package/Cargo.lock +2962 -0
  2. package/Cargo.toml +66 -0
  3. package/LICENSE +21 -0
  4. package/README.md +115 -0
  5. package/bin/flareguard.js +78 -0
  6. package/package.json +50 -0
  7. package/scripts/postinstall.mjs +83 -0
  8. package/src/bindings/ast_scanner.rs +950 -0
  9. package/src/bindings/cli.rs +49 -0
  10. package/src/bindings/jsonc.rs +166 -0
  11. package/src/bindings/mod.rs +7 -0
  12. package/src/bindings/reporter.rs +328 -0
  13. package/src/bindings/types.rs +129 -0
  14. package/src/bindings/validator.rs +227 -0
  15. package/src/bindings/wrangler.rs +647 -0
  16. package/src/cli.rs +77 -0
  17. package/src/lib.rs +7 -0
  18. package/src/main.rs +451 -0
  19. package/src/origin/cli.rs +92 -0
  20. package/src/origin/cloudflare.rs +170 -0
  21. package/src/origin/confidence.rs +320 -0
  22. package/src/origin/crtsh.rs +144 -0
  23. package/src/origin/dns.rs +177 -0
  24. package/src/origin/enumerator.rs +271 -0
  25. package/src/origin/error.rs +19 -0
  26. package/src/origin/mock.rs +320 -0
  27. package/src/origin/mod.rs +19 -0
  28. package/src/origin/models.rs +167 -0
  29. package/src/origin/prober.rs +260 -0
  30. package/src/origin/remediation.rs +73 -0
  31. package/src/origin/report.rs +625 -0
  32. package/src/origin/scanner.rs +217 -0
  33. package/src/secrets/cli.rs +94 -0
  34. package/src/secrets/env_parser.rs +464 -0
  35. package/src/secrets/ignore.rs +139 -0
  36. package/src/secrets/mod.rs +14 -0
  37. package/src/secrets/report/json_format.rs +97 -0
  38. package/src/secrets/report/mod.rs +48 -0
  39. package/src/secrets/report/sarif.rs +225 -0
  40. package/src/secrets/report/text.rs +105 -0
  41. package/src/secrets/rules/builtin.rs +280 -0
  42. package/src/secrets/rules/entropy.rs +66 -0
  43. package/src/secrets/rules/mod.rs +7 -0
  44. package/src/secrets/rules/types.rs +183 -0
  45. package/src/secrets/scanner.rs +444 -0
  46. package/src/zone/cli.rs +111 -0
  47. package/src/zone/client/cf_client.rs +405 -0
  48. package/src/zone/client/mod.rs +5 -0
  49. package/src/zone/client/provider.rs +12 -0
  50. package/src/zone/mock_data.rs +481 -0
  51. package/src/zone/mod.rs +125 -0
  52. package/src/zone/models/audit.rs +194 -0
  53. package/src/zone/models/cloudflare.rs +252 -0
  54. package/src/zone/models/mod.rs +7 -0
  55. package/src/zone/models/sarif.rs +89 -0
  56. package/src/zone/reporters/html_rep.rs +345 -0
  57. package/src/zone/reporters/json_rep.rs +7 -0
  58. package/src/zone/reporters/mod.rs +9 -0
  59. package/src/zone/reporters/sarif_rep.rs +100 -0
  60. package/src/zone/reporters/terminal.rs +322 -0
  61. package/src/zone/rules/definitions.rs +201 -0
  62. package/src/zone/rules/evaluator.rs +484 -0
  63. package/src/zone/rules/mod.rs +5 -0
  64. package/src/zone/scoring.rs +104 -0
@@ -0,0 +1,66 @@
1
+ use std::collections::HashMap;
2
+
3
+ /// Calculates the Shannon entropy of a string (bits per symbol).
4
+ pub fn shannon_entropy(s: &str) -> f64 {
5
+ if s.is_empty() {
6
+ return 0.0;
7
+ }
8
+
9
+ let mut char_counts = HashMap::new();
10
+ let mut total_chars = 0;
11
+
12
+ for c in s.chars() {
13
+ *char_counts.entry(c).or_insert(0) += 1;
14
+ total_chars += 1;
15
+ }
16
+
17
+ let total = total_chars as f64;
18
+ let mut entropy = 0.0;
19
+
20
+ for &count in char_counts.values() {
21
+ let prob = count as f64 / total;
22
+ entropy -= prob * prob.log2();
23
+ }
24
+
25
+ entropy
26
+ }
27
+
28
+ /// Checks if a string has sufficient character diversity to likely be a cryptographic secret/token.
29
+ pub fn is_high_entropy_token(s: &str, min_entropy: f64) -> bool {
30
+ if s.len() < 16 {
31
+ return false;
32
+ }
33
+
34
+ // Check if it's just all lowercase hex (like git commit hash)
35
+ let is_all_hex = s.chars().all(|c| c.is_ascii_hexdigit());
36
+ let is_all_lower_hex = is_all_hex && s.chars().all(|c| !c.is_ascii_uppercase());
37
+
38
+ // If it's a 40-char string that is purely lowercase hex, it's very often a git sha or build hash
39
+ if s.len() == 40 && is_all_lower_hex {
40
+ return false;
41
+ }
42
+
43
+ let entropy = shannon_entropy(s);
44
+ entropy >= min_entropy
45
+ }
46
+
47
+ #[cfg(test)]
48
+ mod tests {
49
+ use super::*;
50
+
51
+ #[test]
52
+ fn test_shannon_entropy() {
53
+ assert_eq!(shannon_entropy(""), 0.0);
54
+ assert_eq!(shannon_entropy("aaaa"), 0.0);
55
+ // Random 40-char token should have high entropy (> 3.5)
56
+ let token = "Z8-Jb3X9vQ2pL7mK1wR4tY6uI0oP5sD8fG2hJ4kL";
57
+ assert!(shannon_entropy(token) > 3.8);
58
+ }
59
+
60
+ #[test]
61
+ fn test_git_sha_filter() {
62
+ // 40 char lowercase hex (git sha)
63
+ let git_sha = "e5ac35da6b107e3240e4f20bf8061266e746e163";
64
+ assert!(!is_high_entropy_token(git_sha, 3.0));
65
+ }
66
+ }
@@ -0,0 +1,7 @@
1
+ pub mod builtin;
2
+ pub mod entropy;
3
+ pub mod types;
4
+
5
+ pub use builtin::get_builtin_rules;
6
+ pub use entropy::{is_high_entropy_token, shannon_entropy};
7
+ pub use types::{Finding, Rule, Severity, redact_secret};
@@ -0,0 +1,183 @@
1
+ use serde::{Deserialize, Serialize};
2
+ use std::fmt;
3
+
4
+ /// Severity level of a secret leak finding.
5
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
6
+ #[serde(rename_all = "lowercase")]
7
+ pub enum Severity {
8
+ Low = 1,
9
+ Medium = 2,
10
+ High = 3,
11
+ Critical = 4,
12
+ }
13
+
14
+ impl Severity {
15
+ pub fn as_str(&self) -> &'static str {
16
+ match self {
17
+ Severity::Low => "low",
18
+ Severity::Medium => "medium",
19
+ Severity::High => "high",
20
+ Severity::Critical => "critical",
21
+ }
22
+ }
23
+
24
+ pub fn to_sarif_level(&self) -> &'static str {
25
+ match self {
26
+ Severity::Critical | Severity::High => "error",
27
+ Severity::Medium => "warning",
28
+ Severity::Low => "note",
29
+ }
30
+ }
31
+ }
32
+
33
+ impl fmt::Display for Severity {
34
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35
+ match self {
36
+ Severity::Low => write!(f, "LOW"),
37
+ Severity::Medium => write!(f, "MEDIUM"),
38
+ Severity::High => write!(f, "HIGH"),
39
+ Severity::Critical => write!(f, "CRITICAL"),
40
+ }
41
+ }
42
+ }
43
+
44
+ impl std::str::FromStr for Severity {
45
+ type Err = String;
46
+
47
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
48
+ match s.to_ascii_lowercase().as_str() {
49
+ "low" | "note" => Ok(Severity::Low),
50
+ "medium" | "med" | "warning" | "warn" => Ok(Severity::Medium),
51
+ "high" | "error" => Ok(Severity::High),
52
+ "critical" | "crit" => Ok(Severity::Critical),
53
+ _ => Err(format!(
54
+ "Invalid severity '{}'. Valid values: low, medium, high, critical",
55
+ s
56
+ )),
57
+ }
58
+ }
59
+ }
60
+
61
+ /// A secret detection rule.
62
+ #[derive(Debug, Clone, Serialize, Deserialize)]
63
+ pub struct Rule {
64
+ pub id: String,
65
+ pub name: String,
66
+ pub description: String,
67
+ pub severity: Severity,
68
+ #[serde(skip)]
69
+ pub pattern: Option<regex::Regex>,
70
+ #[serde(skip)]
71
+ pub exact_match: Option<String>,
72
+ pub recommendation: String,
73
+ #[serde(default)]
74
+ pub min_entropy: Option<f64>,
75
+ }
76
+
77
+ impl Rule {
78
+ pub fn new_regex(
79
+ id: impl Into<String>,
80
+ name: impl Into<String>,
81
+ description: impl Into<String>,
82
+ severity: Severity,
83
+ pattern: regex::Regex,
84
+ recommendation: impl Into<String>,
85
+ ) -> Self {
86
+ Self {
87
+ id: id.into(),
88
+ name: name.into(),
89
+ description: description.into(),
90
+ severity,
91
+ pattern: Some(pattern),
92
+ exact_match: None,
93
+ recommendation: recommendation.into(),
94
+ min_entropy: None,
95
+ }
96
+ }
97
+
98
+ pub fn new_exact(
99
+ id: impl Into<String>,
100
+ name: impl Into<String>,
101
+ description: impl Into<String>,
102
+ severity: Severity,
103
+ secret_value: impl Into<String>,
104
+ recommendation: impl Into<String>,
105
+ ) -> Self {
106
+ Self {
107
+ id: id.into(),
108
+ name: name.into(),
109
+ description: description.into(),
110
+ severity,
111
+ pattern: None,
112
+ exact_match: Some(secret_value.into()),
113
+ recommendation: recommendation.into(),
114
+ min_entropy: None,
115
+ }
116
+ }
117
+
118
+ pub fn with_min_entropy(mut self, min_entropy: f64) -> Self {
119
+ self.min_entropy = Some(min_entropy);
120
+ self
121
+ }
122
+ }
123
+
124
+ /// A detected secret leak match.
125
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126
+ pub struct Finding {
127
+ pub rule_id: String,
128
+ pub rule_name: String,
129
+ pub severity: Severity,
130
+ pub file_path: String,
131
+ pub line_number: usize,
132
+ pub column_number: usize,
133
+ pub match_start: usize,
134
+ pub match_end: usize,
135
+ pub raw_secret: String,
136
+ pub redacted_secret: String,
137
+ pub line_content: String,
138
+ pub description: String,
139
+ pub recommendation: String,
140
+ }
141
+
142
+ /// Redact a secret string for safe logging and reporting.
143
+ pub fn redact_secret(secret: &str) -> String {
144
+ let len = secret.chars().count();
145
+ if len <= 6 {
146
+ "******".to_string()
147
+ } else if len <= 12 {
148
+ let prefix: String = secret.chars().take(2).collect();
149
+ let suffix: String = secret.chars().skip(len - 2).collect();
150
+ format!("{}...{}", prefix, suffix)
151
+ } else if len <= 24 {
152
+ let prefix: String = secret.chars().take(4).collect();
153
+ let suffix: String = secret.chars().skip(len - 4).collect();
154
+ format!("{}...{}", prefix, suffix)
155
+ } else {
156
+ let prefix: String = secret.chars().take(6).collect();
157
+ let suffix: String = secret.chars().skip(len - 4).collect();
158
+ format!("{}...{}", prefix, suffix)
159
+ }
160
+ }
161
+
162
+ #[cfg(test)]
163
+ mod tests {
164
+ use super::*;
165
+
166
+ #[test]
167
+ fn test_redact_secret() {
168
+ assert_eq!(redact_secret("short"), "******");
169
+ assert_eq!(redact_secret("12345678"), "12...78");
170
+ assert_eq!(redact_secret("0x4AAAAAAAE-xyz1234567890"), "0x4AAA...7890");
171
+ assert_eq!(
172
+ redact_secret("c2547eb745079dac9320b638f5e22594b678a"),
173
+ "c2547e...678a"
174
+ );
175
+ }
176
+
177
+ #[test]
178
+ fn test_severity_ordering() {
179
+ assert!(Severity::Critical > Severity::High);
180
+ assert!(Severity::High > Severity::Medium);
181
+ assert!(Severity::Medium > Severity::Low);
182
+ }
183
+ }
@@ -0,0 +1,444 @@
1
+ use memmap2::Mmap;
2
+ use rayon::prelude::*;
3
+ use std::fs::File;
4
+ use std::path::{Path, PathBuf};
5
+ use std::sync::atomic::{AtomicUsize, Ordering};
6
+ use std::time::Instant;
7
+ use walkdir::WalkDir;
8
+
9
+ use crate::secrets::ignore::IgnoreFilter;
10
+ use crate::secrets::rules::entropy::is_high_entropy_token;
11
+ use crate::secrets::rules::types::{Finding, Rule, Severity, redact_secret};
12
+
13
+ /// Known binary extensions that should not be scanned as text bundles.
14
+ const BINARY_EXTENSIONS: &[&str] = &[
15
+ "png", "jpg", "jpeg", "gif", "webp", "avif", "ico", "svgz", "mp4", "webm", "mp3", "wav",
16
+ "woff", "woff2", "ttf", "eot", "otf", "wasm", "zip", "tar", "gz", "br", "zst", "7z", "pdf",
17
+ "exe", "dll", "so", "dylib",
18
+ ];
19
+
20
+ /// Options for configuring the directory and bundle scanner.
21
+ #[derive(Debug, Clone)]
22
+ pub struct ScannerOptions {
23
+ pub max_file_size_bytes: u64,
24
+ pub min_severity: Severity,
25
+ pub follow_symlinks: bool,
26
+ }
27
+
28
+ impl Default for ScannerOptions {
29
+ fn default() -> Self {
30
+ Self {
31
+ max_file_size_bytes: 50 * 1024 * 1024, // 50 MB
32
+ min_severity: Severity::Low,
33
+ follow_symlinks: false,
34
+ }
35
+ }
36
+ }
37
+
38
+ /// Statistics from a completed scan.
39
+ #[derive(Debug, Clone, Default)]
40
+ pub struct ScanStats {
41
+ pub files_scanned: usize,
42
+ pub bytes_scanned: usize,
43
+ pub total_findings: usize,
44
+ pub critical_count: usize,
45
+ pub high_count: usize,
46
+ pub medium_count: usize,
47
+ pub low_count: usize,
48
+ pub duration_ms: u128,
49
+ }
50
+
51
+ /// Results of a scan.
52
+ #[derive(Debug, Clone)]
53
+ pub struct ScanResult {
54
+ pub findings: Vec<Finding>,
55
+ pub stats: ScanStats,
56
+ }
57
+
58
+ /// Discovers candidate client asset directories if none are specified.
59
+ pub fn discover_default_targets(base: &Path) -> Vec<PathBuf> {
60
+ let specific_dirs = [
61
+ "dist/client",
62
+ "dist/_astro",
63
+ "build/client",
64
+ "public",
65
+ ".svelte-kit/output/client",
66
+ ".next/static",
67
+ "out",
68
+ ];
69
+
70
+ let mut found = Vec::new();
71
+ for candidate in &specific_dirs {
72
+ let p = base.join(candidate);
73
+ if p.exists() && p.is_dir() {
74
+ found.push(p);
75
+ }
76
+ }
77
+
78
+ if found.is_empty() {
79
+ let fallback_dirs = ["dist", "build"];
80
+ for candidate in &fallback_dirs {
81
+ let p = base.join(candidate);
82
+ if p.exists() && p.is_dir() {
83
+ found.push(p);
84
+ }
85
+ }
86
+ }
87
+
88
+ if found.is_empty() {
89
+ // Fall back to base directory itself
90
+ found.push(base.to_path_buf());
91
+ }
92
+
93
+ found
94
+ }
95
+
96
+ /// Scans the target directories or files for secret leaks.
97
+ pub fn scan_targets(
98
+ targets: &[PathBuf],
99
+ rules: &[Rule],
100
+ ignore_filter: &IgnoreFilter,
101
+ options: &ScannerOptions,
102
+ ) -> ScanResult {
103
+ let start_time = Instant::now();
104
+
105
+ // 1. Collect all candidate file paths (deduplicated by canonical path)
106
+ let mut files_to_scan = Vec::new();
107
+ let mut seen_paths = std::collections::HashSet::new();
108
+
109
+ for target in targets {
110
+ if target.is_file() {
111
+ if !should_skip_file(target, ignore_filter) {
112
+ let canonical = target.canonicalize().unwrap_or_else(|_| target.clone());
113
+ if seen_paths.insert(canonical) {
114
+ files_to_scan.push(target.clone());
115
+ }
116
+ }
117
+ } else if target.is_dir() {
118
+ let walker = WalkDir::new(target).follow_links(options.follow_symlinks);
119
+ for entry in walker.into_iter().filter_map(|e| e.ok()) {
120
+ let path = entry.path();
121
+ if path.is_file() && !should_skip_file(path, ignore_filter) {
122
+ let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
123
+ if seen_paths.insert(canonical) {
124
+ files_to_scan.push(path.to_path_buf());
125
+ }
126
+ }
127
+ }
128
+ }
129
+ }
130
+
131
+ let files_count = AtomicUsize::new(0);
132
+ let bytes_count = AtomicUsize::new(0);
133
+
134
+ // 2. Multi-threaded scanning with rayon
135
+ let findings: Vec<Finding> = files_to_scan
136
+ .par_iter()
137
+ .flat_map(|path| {
138
+ let result = scan_file(path, rules, ignore_filter, options);
139
+ if let Ok((scanned_bytes, file_findings)) = result {
140
+ files_count.fetch_add(1, Ordering::Relaxed);
141
+ bytes_count.fetch_add(scanned_bytes, Ordering::Relaxed);
142
+ file_findings
143
+ } else {
144
+ Vec::new()
145
+ }
146
+ })
147
+ .filter(|f| f.severity >= options.min_severity)
148
+ .collect();
149
+
150
+ let duration = start_time.elapsed().as_millis();
151
+
152
+ let mut stats = ScanStats {
153
+ files_scanned: files_count.load(Ordering::Relaxed),
154
+ bytes_scanned: bytes_count.load(Ordering::Relaxed),
155
+ total_findings: findings.len(),
156
+ critical_count: 0,
157
+ high_count: 0,
158
+ medium_count: 0,
159
+ low_count: 0,
160
+ duration_ms: duration,
161
+ };
162
+
163
+ for finding in &findings {
164
+ match finding.severity {
165
+ Severity::Critical => stats.critical_count += 1,
166
+ Severity::High => stats.high_count += 1,
167
+ Severity::Medium => stats.medium_count += 1,
168
+ Severity::Low => stats.low_count += 1,
169
+ }
170
+ }
171
+
172
+ ScanResult { findings, stats }
173
+ }
174
+
175
+ /// Scans a single file against the provided rules.
176
+ pub fn scan_file(
177
+ path: &Path,
178
+ rules: &[Rule],
179
+ ignore_filter: &IgnoreFilter,
180
+ options: &ScannerOptions,
181
+ ) -> Result<(usize, Vec<Finding>), std::io::Error> {
182
+ let file = File::open(path)?;
183
+ let metadata = file.metadata()?;
184
+ let file_len = metadata.len();
185
+
186
+ if file_len == 0 || file_len > options.max_file_size_bytes {
187
+ return Ok((0, Vec::new()));
188
+ }
189
+
190
+ // Use memory mapping for files >= 16KB, std::fs::read for smaller files
191
+ let content_str = if file_len >= 16384 {
192
+ let mmap = unsafe { Mmap::map(&file)? };
193
+ if is_binary_content(&mmap) {
194
+ return Ok((file_len as usize, Vec::new()));
195
+ }
196
+ match std::str::from_utf8(&mmap) {
197
+ Ok(s) => s.to_string(),
198
+ Err(_) => String::from_utf8_lossy(&mmap).into_owned(),
199
+ }
200
+ } else {
201
+ let bytes = std::fs::read(path)?;
202
+ if is_binary_content(&bytes) {
203
+ return Ok((bytes.len(), Vec::new()));
204
+ }
205
+ match String::from_utf8(bytes) {
206
+ Ok(s) => s,
207
+ Err(e) => String::from_utf8_lossy(&e.into_bytes()).into_owned(),
208
+ }
209
+ };
210
+
211
+ let findings = scan_content(
212
+ &content_str,
213
+ path.to_string_lossy().as_ref(),
214
+ rules,
215
+ ignore_filter,
216
+ );
217
+ Ok((file_len as usize, findings))
218
+ }
219
+
220
+ /// Scans raw string content and returns findings.
221
+ pub fn scan_content(
222
+ content: &str,
223
+ file_path: &str,
224
+ rules: &[Rule],
225
+ ignore_filter: &IgnoreFilter,
226
+ ) -> Vec<Finding> {
227
+ let mut findings = Vec::new();
228
+
229
+ for rule in rules {
230
+ if ignore_filter.is_rule_ignored(&rule.id) {
231
+ continue;
232
+ }
233
+
234
+ // Exact match rule (e.g. from .dev.vars or .env)
235
+ if let Some(ref secret_val) = rule.exact_match {
236
+ if secret_val.is_empty() || ignore_filter.is_secret_ignored(secret_val) {
237
+ continue;
238
+ }
239
+
240
+ let mut start_idx = 0;
241
+ while let Some(pos) = content[start_idx..].find(secret_val) {
242
+ let match_start = start_idx + pos;
243
+ let match_end = match_start + secret_val.len();
244
+ start_idx = match_end;
245
+
246
+ let (line_num, col_num, snippet) =
247
+ extract_location_and_snippet(content, match_start, match_end, &rule.name);
248
+ findings.push(Finding {
249
+ rule_id: rule.id.clone(),
250
+ rule_name: rule.name.clone(),
251
+ severity: rule.severity,
252
+ file_path: file_path.to_string(),
253
+ line_number: line_num,
254
+ column_number: col_num,
255
+ match_start,
256
+ match_end,
257
+ raw_secret: secret_val.clone(),
258
+ redacted_secret: redact_secret(secret_val),
259
+ line_content: snippet,
260
+ description: rule.description.clone(),
261
+ recommendation: rule.recommendation.clone(),
262
+ });
263
+ }
264
+ }
265
+
266
+ // Regex pattern rule
267
+ if let Some(ref regex) = rule.pattern {
268
+ for cap in regex.captures_iter(content) {
269
+ // If regex has capture group 1, use that as the raw secret; otherwise use full match
270
+ let matched_group = if let Some(m) = cap.get(1) {
271
+ m
272
+ } else if let Some(m) = cap.get(2) {
273
+ m
274
+ } else if let Some(m) = cap.get(0) {
275
+ m
276
+ } else {
277
+ continue;
278
+ };
279
+
280
+ let raw_secret = matched_group.as_str();
281
+
282
+ if ignore_filter.is_secret_ignored(raw_secret) {
283
+ continue;
284
+ }
285
+
286
+ // Check min entropy if configured
287
+ if let Some(min_entropy) = rule.min_entropy
288
+ && !is_high_entropy_token(raw_secret, min_entropy)
289
+ {
290
+ continue;
291
+ }
292
+
293
+ let match_start = matched_group.start();
294
+ let match_end = matched_group.end();
295
+ let (line_num, col_num, snippet) =
296
+ extract_location_and_snippet(content, match_start, match_end, &rule.name);
297
+
298
+ findings.push(Finding {
299
+ rule_id: rule.id.clone(),
300
+ rule_name: rule.name.clone(),
301
+ severity: rule.severity,
302
+ file_path: file_path.to_string(),
303
+ line_number: line_num,
304
+ column_number: col_num,
305
+ match_start,
306
+ match_end,
307
+ raw_secret: raw_secret.to_string(),
308
+ redacted_secret: redact_secret(raw_secret),
309
+ line_content: snippet,
310
+ description: rule.description.clone(),
311
+ recommendation: rule.recommendation.clone(),
312
+ });
313
+ }
314
+ }
315
+ }
316
+
317
+ findings
318
+ }
319
+
320
+ /// Checks if a file path should be skipped based on extension or ignore filter.
321
+ fn should_skip_file(path: &Path, ignore_filter: &IgnoreFilter) -> bool {
322
+ if ignore_filter.is_file_ignored(path) {
323
+ return true;
324
+ }
325
+
326
+ if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
327
+ let lower = ext.to_ascii_lowercase();
328
+ if BINARY_EXTENSIONS.contains(&lower.as_str()) {
329
+ return true;
330
+ }
331
+ }
332
+
333
+ false
334
+ }
335
+
336
+ /// Checks if the first 1KB of content contains null bytes (indicative of binary data).
337
+ fn is_binary_content(bytes: &[u8]) -> bool {
338
+ let check_len = bytes.len().min(1024);
339
+ bytes[..check_len].contains(&0)
340
+ }
341
+
342
+ /// Calculates 1-based line & column numbers and creates a sanitized/bounded snippet.
343
+ fn extract_location_and_snippet(
344
+ content: &str,
345
+ match_start: usize,
346
+ match_end: usize,
347
+ rule_name: &str,
348
+ ) -> (usize, usize, String) {
349
+ let prefix = &content[..match_start];
350
+ let line_number = prefix.chars().filter(|&c| c == '\n').count() + 1;
351
+
352
+ let line_start = prefix.rfind('\n').map(|idx| idx + 1).unwrap_or(0);
353
+ let line_end = content[match_end..]
354
+ .find('\n')
355
+ .map(|idx| match_end + idx)
356
+ .unwrap_or(content.len());
357
+
358
+ let column_number = match_start.saturating_sub(line_start) + 1;
359
+
360
+ let full_line = &content[line_start..line_end];
361
+ let matched_slice = &content[match_start..match_end];
362
+ let redacted = redact_secret(matched_slice);
363
+
364
+ // If the line is very long (e.g. minified JS bundle), create a window around the match
365
+ let snippet = if full_line.len() > 200 {
366
+ let rel_start = match_start.saturating_sub(line_start);
367
+ let rel_end = match_end.saturating_sub(line_start);
368
+
369
+ let window_start = rel_start.saturating_sub(60);
370
+ let window_end = (rel_end + 60).min(full_line.len());
371
+
372
+ let mut window = String::new();
373
+ if window_start > 0 {
374
+ window.push_str("...");
375
+ }
376
+ window.push_str(&full_line[window_start..rel_start]);
377
+ window.push_str(&format!("[REDACTED: {} ({})]", rule_name, redacted));
378
+ window.push_str(&full_line[rel_end..window_end]);
379
+ if window_end < full_line.len() {
380
+ window.push_str("...");
381
+ }
382
+ window
383
+ } else {
384
+ let rel_start = match_start.saturating_sub(line_start);
385
+ let rel_end = match_end.saturating_sub(line_start);
386
+ let mut line_res = String::new();
387
+ line_res.push_str(&full_line[..rel_start]);
388
+ line_res.push_str(&format!("[REDACTED: {} ({})]", rule_name, redacted));
389
+ line_res.push_str(&full_line[rel_end..]);
390
+ line_res
391
+ };
392
+
393
+ (line_number, column_number, snippet.trim().to_string())
394
+ }
395
+
396
+ #[cfg(test)]
397
+ mod tests {
398
+ use super::*;
399
+ use crate::secrets::rules::builtin::get_builtin_rules;
400
+
401
+ #[test]
402
+ fn test_scan_content_turnstile() {
403
+ let content = r#"
404
+ // Client bundle
405
+ const siteKey = "0x4AAAAAAAE-xyz1234567890abcdef";
406
+ const config = {
407
+ turnstileSecret: "0x4AAAAAAAE-xyz1234567890abcdef",
408
+ };
409
+ "#;
410
+
411
+ let rules = get_builtin_rules();
412
+ let ignore = IgnoreFilter::new();
413
+ let findings = scan_content(content, "dist/client/app.js", &rules, &ignore);
414
+
415
+ assert!(!findings.is_empty());
416
+ assert_eq!(findings[0].rule_id, "CF-004");
417
+ assert_eq!(findings[0].file_path, "dist/client/app.js");
418
+ assert!(findings[0].line_content.contains("[REDACTED:"));
419
+ }
420
+
421
+ #[test]
422
+ fn test_scan_content_env_leak() {
423
+ let content = r#"
424
+ const dbUri = "postgres://admin:topsecret1234@db.cloudflare.internal:5432/main";
425
+ "#;
426
+
427
+ let secret_rule = Rule::new_exact(
428
+ "ENV-DB",
429
+ "Database Secret",
430
+ "Database secret leaked",
431
+ Severity::Critical,
432
+ "postgres://admin:topsecret1234@db.cloudflare.internal:5432/main",
433
+ "Keep secret server side",
434
+ );
435
+
436
+ let rules = vec![secret_rule];
437
+ let ignore = IgnoreFilter::new();
438
+ let findings = scan_content(content, "dist/client/bundle.js", &rules, &ignore);
439
+
440
+ assert_eq!(findings.len(), 1);
441
+ assert_eq!(findings[0].rule_id, "ENV-DB");
442
+ assert_eq!(findings[0].line_number, 2);
443
+ }
444
+ }