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