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,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
|
+
}
|