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,464 @@
1
+ use crate::secrets::rules::types::{Rule, Severity};
2
+ use std::collections::HashSet;
3
+ use std::fs;
4
+ use std::path::{Path, PathBuf};
5
+
6
+ /// Known non-secret variable keys to ignore when scanning.
7
+ const BENIGN_KEYS: &[&str] = &[
8
+ "port",
9
+ "host",
10
+ "hostname",
11
+ "node_env",
12
+ "env",
13
+ "environment",
14
+ "app_env",
15
+ "stage",
16
+ "debug",
17
+ "log_level",
18
+ "tz",
19
+ "lang",
20
+ "app_name",
21
+ "title",
22
+ "version",
23
+ ];
24
+
25
+ /// Known non-secret string values to ignore when scanning for leaked environment variables.
26
+ const BENIGN_VALUES: &[&str] = &[
27
+ "true",
28
+ "false",
29
+ "null",
30
+ "undefined",
31
+ "none",
32
+ "0",
33
+ "1",
34
+ "true\n",
35
+ "false\n",
36
+ "development",
37
+ "production",
38
+ "staging",
39
+ "test",
40
+ "local",
41
+ "dev",
42
+ "prod",
43
+ "localhost",
44
+ "127.0.0.1",
45
+ "0.0.0.0",
46
+ "::1",
47
+ "http://localhost",
48
+ "https://localhost",
49
+ "http://127.0.0.1",
50
+ "https://127.0.0.1",
51
+ "http://localhost:8787",
52
+ "http://localhost:3000",
53
+ "http://localhost:5173",
54
+ "http://localhost:4321",
55
+ "public",
56
+ "index.html",
57
+ "utf-8",
58
+ "application/json",
59
+ "text/html",
60
+ "text/plain",
61
+ "GET",
62
+ "POST",
63
+ "PUT",
64
+ "DELETE",
65
+ "info",
66
+ "warn",
67
+ "warning",
68
+ "error",
69
+ "trace",
70
+ "verbose",
71
+ ];
72
+
73
+ /// Represents an extracted environment variable key-value pair.
74
+ #[derive(Debug, Clone, PartialEq, Eq)]
75
+ pub struct EnvSecret {
76
+ pub key: String,
77
+ pub value: String,
78
+ pub source_file: PathBuf,
79
+ }
80
+
81
+ /// Parses an env-style file (`.env`, `.dev.vars`) into key-value pairs.
82
+ pub fn parse_env_file(path: &Path) -> Result<Vec<EnvSecret>, std::io::Error> {
83
+ let content = fs::read_to_string(path)?;
84
+ let mut secrets = Vec::new();
85
+
86
+ for line in content.lines() {
87
+ let trimmed = line.trim();
88
+ // Skip comments and empty lines
89
+ if trimmed.is_empty() || trimmed.starts_with('#') {
90
+ continue;
91
+ }
92
+
93
+ if let Some((k, v)) = trimmed.split_once('=') {
94
+ let key = k.trim().to_string();
95
+ let mut val = v.trim().to_string();
96
+
97
+ // Strip enclosing quotes if present
98
+ if ((val.starts_with('"') && val.ends_with('"'))
99
+ || (val.starts_with('\'') && val.ends_with('\'')))
100
+ && val.len() >= 2
101
+ {
102
+ val = val[1..val.len() - 1].to_string();
103
+ }
104
+
105
+ // Skip invalid or placeholder values
106
+ if is_secret_candidate(&key, &val) {
107
+ secrets.push(EnvSecret {
108
+ key,
109
+ value: val,
110
+ source_file: path.to_path_buf(),
111
+ });
112
+ }
113
+ }
114
+ }
115
+
116
+ Ok(secrets)
117
+ }
118
+
119
+ /// Parses wrangler configuration files (`wrangler.json`, `wrangler.jsonc`, `wrangler.toml`)
120
+ pub fn parse_wrangler_file(path: &Path) -> Result<Vec<EnvSecret>, std::io::Error> {
121
+ let content = fs::read_to_string(path)?;
122
+ let mut secrets = Vec::new();
123
+
124
+ let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
125
+ if ext == "toml" {
126
+ // Simple TOML [vars] block extractor
127
+ let mut in_vars = false;
128
+ for line in content.lines() {
129
+ let trimmed = line.trim();
130
+ if trimmed.starts_with('[') {
131
+ in_vars = trimmed == "[vars]"
132
+ || trimmed.starts_with("[env.") && trimmed.ends_with(".vars]");
133
+ continue;
134
+ }
135
+
136
+ if in_vars
137
+ && !trimmed.is_empty()
138
+ && !trimmed.starts_with('#')
139
+ && let Some((k, v)) = trimmed.split_once('=')
140
+ {
141
+ let key = k.trim().to_string();
142
+ let mut val = v.trim().to_string();
143
+ if ((val.starts_with('"') && val.ends_with('"'))
144
+ || (val.starts_with('\'') && val.ends_with('\'')))
145
+ && val.len() >= 2
146
+ {
147
+ val = val[1..val.len() - 1].to_string();
148
+ }
149
+ if is_secret_candidate(&key, &val) {
150
+ secrets.push(EnvSecret {
151
+ key,
152
+ value: val,
153
+ source_file: path.to_path_buf(),
154
+ });
155
+ }
156
+ }
157
+ }
158
+ } else {
159
+ // JSON or JSONC: strip comments if JSONC then parse
160
+ let cleaned = strip_jsonc_comments(&content);
161
+ if let Ok(val) = serde_json::from_str::<serde_json::Value>(&cleaned) {
162
+ extract_secrets_from_json(&val, path, &mut secrets);
163
+ }
164
+ }
165
+
166
+ Ok(secrets)
167
+ }
168
+
169
+ fn strip_jsonc_comments(jsonc: &str) -> String {
170
+ let mut result = String::with_capacity(jsonc.len());
171
+ let mut in_string = false;
172
+ let mut chars = jsonc.chars().peekable();
173
+
174
+ while let Some(c) = chars.next() {
175
+ if c == '"' && !in_string {
176
+ in_string = true;
177
+ result.push(c);
178
+ } else if c == '"' && in_string {
179
+ in_string = false;
180
+ result.push(c);
181
+ } else if in_string {
182
+ result.push(c);
183
+ if c == '\\'
184
+ && let Some(next) = chars.next()
185
+ {
186
+ result.push(next);
187
+ }
188
+ } else if c == '/' && chars.peek() == Some(&'/') {
189
+ // Line comment: skip until newline
190
+ for next in chars.by_ref() {
191
+ if next == '\n' {
192
+ result.push('\n');
193
+ break;
194
+ }
195
+ }
196
+ } else if c == '/' && chars.peek() == Some(&'*') {
197
+ // Block comment: skip until */
198
+ chars.next(); // consume '*'
199
+ let mut prev = ' ';
200
+ for next in chars.by_ref() {
201
+ if prev == '*' && next == '/' {
202
+ break;
203
+ }
204
+ prev = next;
205
+ }
206
+ } else {
207
+ result.push(c);
208
+ }
209
+ }
210
+
211
+ result
212
+ }
213
+
214
+ fn extract_secrets_from_json(value: &serde_json::Value, path: &Path, secrets: &mut Vec<EnvSecret>) {
215
+ if let Some(obj) = value.as_object() {
216
+ // Look for vars block or top level
217
+ if let Some(vars) = obj.get("vars").and_then(|v| v.as_object()) {
218
+ for (k, v) in vars {
219
+ if let Some(s) = v.as_str()
220
+ && is_secret_candidate(k, s)
221
+ {
222
+ secrets.push(EnvSecret {
223
+ key: k.clone(),
224
+ value: s.to_string(),
225
+ source_file: path.to_path_buf(),
226
+ });
227
+ }
228
+ }
229
+ }
230
+
231
+ // Look for env.*.vars blocks
232
+ if let Some(env_obj) = obj.get("env").and_then(|e| e.as_object()) {
233
+ for (_env_name, env_val) in env_obj {
234
+ if let Some(vars) = env_val.get("vars").and_then(|v| v.as_object()) {
235
+ for (k, v) in vars {
236
+ if let Some(s) = v.as_str()
237
+ && is_secret_candidate(k, s)
238
+ {
239
+ secrets.push(EnvSecret {
240
+ key: k.clone(),
241
+ value: s.to_string(),
242
+ source_file: path.to_path_buf(),
243
+ });
244
+ }
245
+ }
246
+ }
247
+ }
248
+ }
249
+ }
250
+ }
251
+
252
+ /// Decides whether a variable key/value should be monitored as a potential secret leak.
253
+ pub fn is_secret_candidate(key: &str, value: &str) -> bool {
254
+ let trimmed_val = value.trim();
255
+ let lower_key = key.to_ascii_lowercase();
256
+
257
+ // Ignore known benign non-secret variable keys
258
+ if BENIGN_KEYS.contains(&lower_key.as_str()) {
259
+ return false;
260
+ }
261
+
262
+ // Ignore short values (< 4 chars) to prevent massive false positive substring matching
263
+ if trimmed_val.len() < 4 {
264
+ return false;
265
+ }
266
+
267
+ // Ignore purely numeric values (like ports, IDs, counts)
268
+ if trimmed_val.chars().all(|c| c.is_ascii_digit()) {
269
+ return false;
270
+ }
271
+
272
+ // Ignore placeholder templates like ${FOO} or $FOO
273
+ if (trimmed_val.starts_with("${") && trimmed_val.ends_with('}'))
274
+ || trimmed_val.starts_with('$')
275
+ || trimmed_val == "<secret>"
276
+ || trimmed_val == "CHANGE_ME"
277
+ || trimmed_val == "your-secret-here"
278
+ {
279
+ return false;
280
+ }
281
+
282
+ // Ignore known benign constants
283
+ let lower_val = trimmed_val.to_ascii_lowercase();
284
+ for &benign in BENIGN_VALUES {
285
+ if lower_val == benign {
286
+ return false;
287
+ }
288
+ }
289
+
290
+ // Keys explicitly indicating public variables are safe (e.g. PUBLIC_*, NEXT_PUBLIC_*, VITE_PUBLIC_*, ASTRO_PUBLIC_*)
291
+ if lower_key.starts_with("public_")
292
+ || lower_key.starts_with("next_public_")
293
+ || lower_key.starts_with("vite_public_")
294
+ || lower_key.starts_with("astro_public_")
295
+ {
296
+ return false;
297
+ }
298
+
299
+ true
300
+ }
301
+
302
+ /// Auto-discovers environment files in search directories and parent directory trees.
303
+ pub fn discover_env_files(search_dirs: &[PathBuf]) -> Vec<PathBuf> {
304
+ let mut discovered = Vec::new();
305
+ let mut seen = HashSet::new();
306
+
307
+ let target_names = [
308
+ ".dev.vars",
309
+ ".env",
310
+ ".env.local",
311
+ ".env.production",
312
+ ".env.development",
313
+ "wrangler.jsonc",
314
+ "wrangler.json",
315
+ "wrangler.toml",
316
+ ];
317
+
318
+ let mut check_dirs = Vec::new();
319
+
320
+ // Include current working directory
321
+ if let Ok(cwd) = std::env::current_dir() {
322
+ check_dirs.push(cwd);
323
+ }
324
+
325
+ for dir in search_dirs {
326
+ let mut curr = if dir.is_file() {
327
+ dir.parent().unwrap_or(Path::new(".")).to_path_buf()
328
+ } else {
329
+ dir.to_path_buf()
330
+ };
331
+
332
+ // Walk up to 4 parent directory levels
333
+ for _ in 0..4 {
334
+ check_dirs.push(curr.clone());
335
+ if let Some(parent) = curr.parent() {
336
+ curr = parent.to_path_buf();
337
+ } else {
338
+ break;
339
+ }
340
+ }
341
+ }
342
+
343
+ for check_dir in check_dirs {
344
+ for name in &target_names {
345
+ let candidate = check_dir.join(name);
346
+ if candidate.is_file() {
347
+ let canonical = candidate.canonicalize().unwrap_or(candidate.clone());
348
+ if seen.insert(canonical) {
349
+ discovered.push(candidate);
350
+ }
351
+ }
352
+ }
353
+ }
354
+
355
+ discovered
356
+ }
357
+
358
+ /// Converts a collection of `EnvSecret` into detection `Rule`s.
359
+ pub fn env_secrets_to_rules(secrets: &[EnvSecret]) -> Vec<Rule> {
360
+ let mut rules = Vec::new();
361
+
362
+ for secret in secrets {
363
+ let rule = Rule::new_exact(
364
+ format!("ENV-{}", secret.key),
365
+ format!("Leaked Env Secret ({})", secret.key),
366
+ format!(
367
+ "Secret value for '{}' defined in {} was found in client build asset",
368
+ secret.key,
369
+ secret.source_file.display()
370
+ ),
371
+ Severity::Critical,
372
+ secret.value.clone(),
373
+ format!(
374
+ "Remove references to server variable '{}' from client-side code. Ensure environment variables accessed in client components are prefixed with PUBLIC_ or handled server-side.",
375
+ secret.key
376
+ ),
377
+ );
378
+ rules.push(rule);
379
+ }
380
+
381
+ rules
382
+ }
383
+
384
+ #[cfg(test)]
385
+ mod tests {
386
+ use super::*;
387
+ use std::io::Write;
388
+ use tempfile::NamedTempFile;
389
+
390
+ #[test]
391
+ fn test_parse_env_file() {
392
+ let mut tmp = NamedTempFile::new().unwrap();
393
+ writeln!(
394
+ tmp,
395
+ r#"
396
+ # Sample .dev.vars
397
+ DATABASE_URL="postgres://postgres:secret123@db.example.com:5432/main"
398
+ CLOUDFLARE_API_TOKEN=V48uXZ-e_92mKqT1pLwRtYuIoPsDfGhJkLxZc0vb
399
+ PUBLIC_SITE_URL=https://mycoolsite.com
400
+ IS_PROD=true
401
+ PORT=8787
402
+ "#
403
+ )
404
+ .unwrap();
405
+
406
+ let secrets = parse_env_file(tmp.path()).unwrap();
407
+ assert_eq!(secrets.len(), 2);
408
+
409
+ let keys: Vec<&str> = secrets.iter().map(|s| s.key.as_str()).collect();
410
+ assert!(keys.contains(&"DATABASE_URL"));
411
+ assert!(keys.contains(&"CLOUDFLARE_API_TOKEN"));
412
+ assert!(!keys.contains(&"PUBLIC_SITE_URL"));
413
+ assert!(!keys.contains(&"IS_PROD"));
414
+ assert!(!keys.contains(&"PORT"));
415
+ }
416
+
417
+ #[test]
418
+ fn test_parse_wrangler_jsonc() {
419
+ let mut tmp = tempfile::Builder::new()
420
+ .suffix(".jsonc")
421
+ .tempfile()
422
+ .unwrap();
423
+ writeln!(
424
+ tmp,
425
+ r#"
426
+ {{
427
+ // Cloudflare Worker configuration
428
+ "name": "my-worker",
429
+ "vars": {{
430
+ "AUTH_SECRET": "super_secret_auth_token_987654321",
431
+ "PUBLIC_API_URL": "https://api.myworker.dev"
432
+ }}
433
+ }}
434
+ "#
435
+ )
436
+ .unwrap();
437
+
438
+ let secrets = parse_wrangler_file(tmp.path()).unwrap();
439
+ assert_eq!(secrets.len(), 1);
440
+ assert_eq!(secrets[0].key, "AUTH_SECRET");
441
+ assert_eq!(secrets[0].value, "super_secret_auth_token_987654321");
442
+ }
443
+
444
+ #[test]
445
+ fn test_parse_wrangler_toml() {
446
+ let mut tmp = tempfile::Builder::new().suffix(".toml").tempfile().unwrap();
447
+ writeln!(
448
+ tmp,
449
+ r#"
450
+ name = "my-worker"
451
+ compatibility_date = "2024-01-01"
452
+
453
+ [vars]
454
+ API_SIGNING_KEY = "my_custom_hmac_signing_key_456"
455
+ PUBLIC_NAME = "My Public App"
456
+ "#
457
+ )
458
+ .unwrap();
459
+
460
+ let secrets = parse_wrangler_file(tmp.path()).unwrap();
461
+ assert_eq!(secrets.len(), 1);
462
+ assert_eq!(secrets[0].key, "API_SIGNING_KEY");
463
+ }
464
+ }
@@ -0,0 +1,139 @@
1
+ use glob::Pattern;
2
+ use std::collections::HashSet;
3
+ use std::fs;
4
+ use std::path::Path;
5
+
6
+ /// Manages ignore rules, file exclusion patterns, and secret allowlists.
7
+ #[derive(Debug, Clone, Default)]
8
+ pub struct IgnoreFilter {
9
+ pub ignored_rules: HashSet<String>,
10
+ pub ignored_secrets: HashSet<String>,
11
+ pub file_patterns: Vec<Pattern>,
12
+ }
13
+
14
+ impl IgnoreFilter {
15
+ pub fn new() -> Self {
16
+ Self::default()
17
+ }
18
+
19
+ /// Add an ignored rule ID (e.g. `CF-006` or `ENV-PORT`).
20
+ pub fn ignore_rule(&mut self, rule_id: &str) {
21
+ self.ignored_rules.insert(rule_id.to_string());
22
+ }
23
+
24
+ /// Add an exact secret string or fingerprint to ignore.
25
+ pub fn ignore_secret(&mut self, secret: &str) {
26
+ self.ignored_secrets.insert(secret.to_string());
27
+ }
28
+
29
+ /// Add a glob pattern to exclude matching files.
30
+ pub fn add_exclude_pattern(&mut self, pattern_str: &str) -> Result<(), glob::PatternError> {
31
+ let pat = Pattern::new(pattern_str)?;
32
+ self.file_patterns.push(pat);
33
+ Ok(())
34
+ }
35
+
36
+ /// Loads rules/patterns from a `.cfsecretignore` file.
37
+ pub fn load_from_file(&mut self, path: &Path) -> Result<(), std::io::Error> {
38
+ if !path.exists() {
39
+ return Ok(());
40
+ }
41
+
42
+ let content = fs::read_to_string(path)?;
43
+ for line in content.lines() {
44
+ let trimmed = line.trim();
45
+ if trimmed.is_empty() || trimmed.starts_with('#') {
46
+ continue;
47
+ }
48
+
49
+ if let Some(rule) = trimmed.strip_prefix("rule:") {
50
+ self.ignore_rule(rule.trim());
51
+ } else if let Some(secret) = trimmed.strip_prefix("secret:") {
52
+ self.ignore_secret(secret.trim());
53
+ } else {
54
+ // Treat as file glob or secret
55
+ if trimmed.contains('*') || trimmed.contains('?') || trimmed.contains('/') {
56
+ if let Ok(pat) = Pattern::new(trimmed) {
57
+ self.file_patterns.push(pat);
58
+ }
59
+ } else {
60
+ self.ignore_secret(trimmed);
61
+ }
62
+ }
63
+ }
64
+
65
+ Ok(())
66
+ }
67
+
68
+ /// Checks if a file path should be ignored.
69
+ pub fn is_file_ignored(&self, path: &Path) -> bool {
70
+ let path_str = path.to_string_lossy();
71
+ for pattern in &self.file_patterns {
72
+ if pattern.matches(&path_str) {
73
+ return true;
74
+ }
75
+ if let Some(file_name) = path.file_name().and_then(|f| f.to_str())
76
+ && pattern.matches(file_name)
77
+ {
78
+ return true;
79
+ }
80
+ }
81
+ false
82
+ }
83
+
84
+ /// Checks if a rule ID is ignored.
85
+ pub fn is_rule_ignored(&self, rule_id: &str) -> bool {
86
+ self.ignored_rules.contains(rule_id)
87
+ }
88
+
89
+ /// Checks if a matched secret value is ignored.
90
+ pub fn is_secret_ignored(&self, secret: &str) -> bool {
91
+ self.ignored_secrets.contains(secret)
92
+ }
93
+ }
94
+
95
+ #[cfg(test)]
96
+ mod tests {
97
+ use super::*;
98
+ use std::io::Write;
99
+ use tempfile::NamedTempFile;
100
+
101
+ #[test]
102
+ fn test_ignore_filter() {
103
+ let mut filter = IgnoreFilter::new();
104
+ filter.ignore_rule("CF-006");
105
+ filter.ignore_secret("0x4AAAAAA_test_safe_dummy");
106
+ filter.add_exclude_pattern("*.map").unwrap();
107
+
108
+ assert!(filter.is_rule_ignored("CF-006"));
109
+ assert!(!filter.is_rule_ignored("CF-001"));
110
+
111
+ assert!(filter.is_secret_ignored("0x4AAAAAA_test_safe_dummy"));
112
+ assert!(!filter.is_secret_ignored("0x4AAAAAA_real_secret"));
113
+
114
+ assert!(filter.is_file_ignored(Path::new("dist/client/app.js.map")));
115
+ assert!(!filter.is_file_ignored(Path::new("dist/client/app.js")));
116
+ }
117
+
118
+ #[test]
119
+ fn test_load_ignore_file() {
120
+ let mut tmp = NamedTempFile::new().unwrap();
121
+ writeln!(
122
+ tmp,
123
+ r#"
124
+ # Ignore configuration
125
+ rule: CF-006
126
+ *.test.js
127
+ secret: safe_known_dummy_token
128
+ "#
129
+ )
130
+ .unwrap();
131
+
132
+ let mut filter = IgnoreFilter::new();
133
+ filter.load_from_file(tmp.path()).unwrap();
134
+
135
+ assert!(filter.is_rule_ignored("CF-006"));
136
+ assert!(filter.is_file_ignored(Path::new("chunk.test.js")));
137
+ assert!(filter.is_secret_ignored("safe_known_dummy_token"));
138
+ }
139
+ }
@@ -0,0 +1,14 @@
1
+ pub mod cli;
2
+ pub mod env_parser;
3
+ pub mod ignore;
4
+ pub mod report;
5
+ pub mod rules;
6
+ pub mod scanner;
7
+
8
+ pub use env_parser::{
9
+ discover_env_files, env_secrets_to_rules, parse_env_file, parse_wrangler_file,
10
+ };
11
+ pub use ignore::IgnoreFilter;
12
+ pub use report::{OutputFormat, render_report};
13
+ pub use rules::{Finding, Rule, Severity, get_builtin_rules, redact_secret};
14
+ pub use scanner::{ScanResult, ScannerOptions, discover_default_targets, scan_file, scan_targets};
@@ -0,0 +1,97 @@
1
+ use crate::secrets::rules::types::Finding;
2
+ use crate::secrets::scanner::ScanStats;
3
+ use serde::{Deserialize, Serialize};
4
+
5
+ /// Top-level JSON report structure.
6
+ #[derive(Debug, Clone, Serialize, Deserialize)]
7
+ pub struct JsonReport {
8
+ pub tool_name: String,
9
+ pub tool_version: String,
10
+ pub timestamp: String,
11
+ pub stats: JsonStats,
12
+ pub findings: Vec<Finding>,
13
+ }
14
+
15
+ #[derive(Debug, Clone, Serialize, Deserialize)]
16
+ pub struct JsonStats {
17
+ pub files_scanned: usize,
18
+ pub bytes_scanned: usize,
19
+ pub total_findings: usize,
20
+ pub critical_count: usize,
21
+ pub high_count: usize,
22
+ pub medium_count: usize,
23
+ pub low_count: usize,
24
+ pub duration_ms: u128,
25
+ }
26
+
27
+ impl From<ScanStats> for JsonStats {
28
+ fn from(stats: ScanStats) -> Self {
29
+ Self {
30
+ files_scanned: stats.files_scanned,
31
+ bytes_scanned: stats.bytes_scanned,
32
+ total_findings: stats.total_findings,
33
+ critical_count: stats.critical_count,
34
+ high_count: stats.high_count,
35
+ medium_count: stats.medium_count,
36
+ low_count: stats.low_count,
37
+ duration_ms: stats.duration_ms,
38
+ }
39
+ }
40
+ }
41
+
42
+ /// Formats scan results as pretty-printed JSON.
43
+ pub fn format_json_report(
44
+ findings: &[Finding],
45
+ stats: &ScanStats,
46
+ tool_version: &str,
47
+ ) -> Result<String, serde_json::Error> {
48
+ let report = JsonReport {
49
+ tool_name: "cf-secret-leak-guard".to_string(),
50
+ tool_version: tool_version.to_string(),
51
+ timestamp: "2026-08-22T00:00:00Z".to_string(),
52
+ stats: stats.clone().into(),
53
+ findings: findings.to_vec(),
54
+ };
55
+
56
+ serde_json::to_string_pretty(&report)
57
+ }
58
+
59
+ #[cfg(test)]
60
+ mod tests {
61
+ use super::*;
62
+ use crate::secrets::rules::types::Severity;
63
+
64
+ #[test]
65
+ fn test_json_report_format() {
66
+ let stats = ScanStats {
67
+ files_scanned: 10,
68
+ bytes_scanned: 1024,
69
+ total_findings: 1,
70
+ critical_count: 1,
71
+ high_count: 0,
72
+ medium_count: 0,
73
+ low_count: 0,
74
+ duration_ms: 15,
75
+ };
76
+
77
+ let finding = Finding {
78
+ rule_id: "CF-001".into(),
79
+ rule_name: "Cloudflare API Token".into(),
80
+ severity: Severity::Critical,
81
+ file_path: "dist/client/main.js".into(),
82
+ line_number: 10,
83
+ column_number: 5,
84
+ match_start: 100,
85
+ match_end: 140,
86
+ raw_secret: "secret123".into(),
87
+ redacted_secret: "sec...123".into(),
88
+ line_content: "token = [REDACTED]".into(),
89
+ description: "Leaked token".into(),
90
+ recommendation: "Rotate token".into(),
91
+ };
92
+
93
+ let json_str = format_json_report(&[finding], &stats, "0.1.0").unwrap();
94
+ assert!(json_str.contains("cf-secret-leak-guard"));
95
+ assert!(json_str.contains("CF-001"));
96
+ }
97
+ }