1use std::collections::BTreeMap;
2use std::sync::OnceLock;
3
4use harn_secret_catalog::{SecretPatternSpec, DEFAULT_SECRET_PATTERN_SPECS, PRECISION_HEURISTIC};
5use regex::Regex;
6use serde::{Deserialize, Serialize};
7use sha2::Digest;
8
9const HIGH_ENTROPY_THRESHOLD: f64 = 3.5;
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct SecretFinding {
13 pub detector: String,
14 pub source: String,
15 pub title: String,
16 pub precision: String,
17 pub line: usize,
18 pub column_start: usize,
19 pub column_end: usize,
20 pub start_offset: usize,
21 pub end_offset: usize,
22 pub redacted: String,
23 pub fingerprint: String,
24}
25
26pub struct CompiledSecretPattern {
31 pub spec: &'static SecretPatternSpec,
32 pub regex: Regex,
33}
34
35static DEFAULT_PATTERNS: OnceLock<Vec<CompiledSecretPattern>> = OnceLock::new();
36static HIGH_ENTROPY_ASSIGNMENT: OnceLock<Regex> = OnceLock::new();
37
38pub fn compiled_secret_patterns() -> &'static [CompiledSecretPattern] {
40 DEFAULT_PATTERNS.get_or_init(|| {
41 DEFAULT_SECRET_PATTERN_SPECS
42 .iter()
43 .map(|spec| CompiledSecretPattern {
44 spec,
45 regex: Regex::new(spec.regex).unwrap_or_else(|error| {
46 panic!("invalid {} secret regex: {error}", spec.detector)
47 }),
48 })
49 .collect()
50 })
51}
52
53pub fn secret_patterns_compiled() -> bool {
57 DEFAULT_PATTERNS.get().is_some()
58}
59
60fn high_entropy_assignment() -> &'static Regex {
61 HIGH_ENTROPY_ASSIGNMENT.get_or_init(|| {
62 Regex::new(
63 r#"(?im)(?:secret|token|api[_-]?key|access[_-]?key|password|passwd|pwd|client[_-]?secret|private[_-]?key)[^\n:=]{0,32}(?::|=)\s*["']([A-Za-z0-9+/=_\.-]{20,})["']"#,
64 )
65 .expect("high-entropy secret pattern is valid")
66 })
67}
68
69pub fn scan_secrets(content: &str) -> Vec<SecretFinding> {
70 let line_starts = line_starts(content);
71 let mut findings = Vec::new();
72
73 for rule in compiled_secret_patterns() {
74 for matched in rule.regex.find_iter(content) {
75 findings.push(build_finding(
76 content,
77 &line_starts,
78 rule.spec.detector,
79 rule.spec.source,
80 rule.spec.title,
81 rule.spec.precision,
82 matched.start(),
83 matched.end(),
84 matched.as_str(),
85 ));
86 }
87 }
88
89 for captures in high_entropy_assignment().captures_iter(content) {
90 let Some(secret) = captures.get(1) else {
91 continue;
92 };
93 if shannon_entropy(secret.as_str()) < HIGH_ENTROPY_THRESHOLD {
94 continue;
95 }
96 findings.push(build_finding(
97 content,
98 &line_starts,
99 "high-entropy-credential-assignment",
100 "trufflehog",
101 "High-entropy secret assignment",
102 PRECISION_HEURISTIC,
103 secret.start(),
104 secret.end(),
105 secret.as_str(),
106 ));
107 }
108
109 findings.sort_by(|left, right| {
110 left.start_offset
111 .cmp(&right.start_offset)
112 .then(left.end_offset.cmp(&right.end_offset))
113 .then(left.detector.cmp(&right.detector))
114 });
115 let spans = findings
116 .iter()
117 .map(|finding| {
118 (
119 finding.start_offset,
120 finding.end_offset,
121 detector_specificity(&finding.detector),
122 )
123 })
124 .collect::<Vec<_>>();
125 findings.retain(|finding| {
126 let specificity = detector_specificity(&finding.detector);
127 !spans.iter().any(|(start, end, other_specificity)| {
128 *other_specificity > specificity
129 && finding.start_offset < *end
130 && *start < finding.end_offset
131 })
132 });
133 findings.dedup_by(|left, right| {
134 left.detector == right.detector
135 && left.start_offset == right.start_offset
136 && left.end_offset == right.end_offset
137 });
138 findings
139}
140
141fn detector_specificity(detector: &str) -> u8 {
142 match detector {
143 "sensitive-assignment" => 0,
144 "high-entropy-credential-assignment" => 1,
145 _ => 2,
146 }
147}
148
149#[allow(clippy::too_many_arguments)]
150fn build_finding(
151 content: &str,
152 line_starts: &[usize],
153 detector: &str,
154 source: &str,
155 title: &str,
156 precision: &str,
157 start_offset: usize,
158 end_offset: usize,
159 matched: &str,
160) -> SecretFinding {
161 let (line, column_start) = offset_to_line_col(content, line_starts, start_offset);
162 let (_, column_end) = offset_to_line_col(content, line_starts, end_offset);
163 SecretFinding {
164 detector: detector.to_string(),
165 source: source.to_string(),
166 title: title.to_string(),
167 precision: precision.to_string(),
168 line,
169 column_start,
170 column_end,
171 start_offset,
172 end_offset,
173 redacted: redact_match(matched),
174 fingerprint: fingerprint(matched),
175 }
176}
177
178fn line_starts(content: &str) -> Vec<usize> {
179 std::iter::once(0)
180 .chain(
181 content
182 .bytes()
183 .enumerate()
184 .filter_map(|(index, byte)| (byte == b'\n').then_some(index + 1)),
185 )
186 .collect()
187}
188
189fn offset_to_line_col(content: &str, starts: &[usize], offset: usize) -> (usize, usize) {
190 let line_index = starts
191 .partition_point(|start| *start <= offset)
192 .saturating_sub(1);
193 let line_start = starts[line_index];
194 (
195 line_index + 1,
196 content[line_start..offset].chars().count() + 1,
197 )
198}
199
200fn redact_match(matched: &str) -> String {
201 if matched.starts_with("-----BEGIN ") {
202 return format!(
203 "{} …",
204 matched
205 .lines()
206 .next()
207 .unwrap_or("-----BEGIN PRIVATE KEY-----")
208 );
209 }
210 let chars = matched.chars().collect::<Vec<_>>();
211 if chars.len() <= 8 {
212 return "*".repeat(chars.len());
213 }
214 let prefix = chars.iter().take(4).collect::<String>();
215 let suffix = chars[chars.len() - 4..].iter().collect::<String>();
216 format!("{prefix}…{suffix}")
217}
218
219fn fingerprint(matched: &str) -> String {
220 let digest = sha2::Sha256::digest(matched.as_bytes());
221 hex::encode(&digest[..8])
222}
223
224fn shannon_entropy(value: &str) -> f64 {
225 let mut counts = BTreeMap::new();
226 for character in value.chars() {
227 *counts.entry(character).or_insert(0_usize) += 1;
228 }
229 let length = value.chars().count() as f64;
230 counts
231 .values()
232 .map(|count| {
233 let probability = *count as f64 / length;
234 -(probability * probability.log2())
235 })
236 .sum()
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242
243 #[test]
244 fn scans_and_deduplicates_the_canonical_catalog() {
245 let findings = scan_secrets(r#"token = "ghp_1234567890abcdefghijklmnopqrstuvwxyzAB""#);
246 assert_eq!(findings.len(), 1);
247 assert_eq!(findings[0].detector, "github-token");
248 assert_eq!(findings[0].precision, "high");
249 }
250
251 #[test]
252 fn source_with_secretish_identifiers_remains_clean() {
253 assert!(scan_secrets("pub const Token = struct { kind: u8 };\n").is_empty());
254 }
255}