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