use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::OnceLock;
use harn_secret_catalog::{
SecretPatternSpec, DEFAULT_SECRET_PATTERN_SPECS, PRECISION_HEURISTIC, PRECISION_HIGH,
};
use regex::Regex;
use serde::{Deserialize, Serialize};
use sha2::Digest;
const HIGH_ENTROPY_THRESHOLD: f64 = 3.5;
#[derive(Debug, Clone, Copy, Serialize)]
pub struct SecretPattern {
pub redaction_name: &'static str,
pub detector: &'static str,
pub source: &'static str,
pub title: &'static str,
pub regex: &'static str,
pub precision: &'static str,
}
pub fn secret_pattern_catalog() -> Vec<SecretPattern> {
DEFAULT_SECRET_PATTERN_SPECS
.iter()
.map(|spec| SecretPattern {
redaction_name: spec.redaction_name,
detector: spec.detector,
source: spec.source,
title: spec.title,
regex: spec.regex,
precision: spec.precision,
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecretFinding {
pub detector: String,
pub source: String,
pub title: String,
pub precision: String,
pub line: usize,
pub column_start: usize,
pub column_end: usize,
pub start_offset: usize,
pub end_offset: usize,
pub redacted: String,
pub fingerprint: String,
}
pub struct CompiledSecretPattern {
pub spec: &'static SecretPatternSpec,
pub regex: Regex,
}
impl CompiledSecretPattern {
pub fn accepts_match(&self, input: &str, start: usize, end: usize) -> bool {
self.spec.accepts_match(input, start, end)
}
}
static DEFAULT_PATTERNS: OnceLock<Vec<CompiledSecretPattern>> = OnceLock::new();
static HIGH_ENTROPY_ASSIGNMENT: OnceLock<Regex> = OnceLock::new();
static HASHED_TOKEN: OnceLock<Regex> = OnceLock::new();
pub fn scan_secret_hashes(content: &str, hashes: &[String]) -> Result<Vec<SecretFinding>, String> {
if hashes.is_empty()
|| hashes.iter().any(|hash| {
hash.len() != 64
|| !hash
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
})
{
return Err(
"secret_scan: hashes must be a nonempty list of lowercase SHA-256 digests".into(),
);
}
let denied: BTreeSet<&str> = hashes.iter().map(String::as_str).collect();
let pattern = HASHED_TOKEN
.get_or_init(|| Regex::new(r"[A-Za-z0-9._-]+").expect("hashed token pattern is valid"));
let mut verdicts = HashMap::<String, Option<String>>::new();
let mut findings = Vec::new();
let mut scanned = 0;
let mut line = 1;
let mut column = 1;
for token in pattern.find_iter(content) {
let digest = verdicts
.entry(token.as_str().to_ascii_lowercase())
.or_insert_with_key(|normalized| {
let digest = super::sha256_hex(normalized.as_bytes());
denied.contains(digest.as_str()).then_some(digest)
});
let Some(digest) = digest else {
continue;
};
for character in content
.get(scanned..token.start())
.expect("regex boundaries are UTF-8 boundaries")
.chars()
{
if character == '\n' {
line += 1;
column = 1;
} else {
column += 1;
}
}
let column_start = column;
column += token.len();
scanned = token.end();
findings.push(SecretFinding {
detector: "sha256-denylist".into(),
source: "custom".into(),
title: "Denied token".into(),
precision: PRECISION_HIGH.into(),
line,
column_start,
column_end: column,
start_offset: token.start(),
end_offset: token.end(),
redacted: "[hash match]".into(),
fingerprint: digest.clone(),
});
}
Ok(findings)
}
pub fn compiled_secret_patterns() -> &'static [CompiledSecretPattern] {
DEFAULT_PATTERNS.get_or_init(|| {
DEFAULT_SECRET_PATTERN_SPECS
.iter()
.map(|spec| CompiledSecretPattern {
spec,
regex: Regex::new(spec.regex).unwrap_or_else(|error| {
panic!("invalid {} secret regex: {error}", spec.detector)
}),
})
.collect()
})
}
pub fn secret_patterns_compiled() -> bool {
DEFAULT_PATTERNS.get().is_some()
}
fn high_entropy_assignment() -> &'static Regex {
HIGH_ENTROPY_ASSIGNMENT.get_or_init(|| {
Regex::new(
r#"(?im)(?:secret|token|api[_-]?key|access[_-]?key|password|passwd|pwd|client[_-]?secret|private[_-]?key)[^\n:=]{0,32}(?::|=)\s*["']([A-Za-z0-9+/=_\.-]{20,})["']"#,
)
.expect("high-entropy secret pattern is valid")
})
}
pub fn scan_secrets(content: &str) -> Vec<SecretFinding> {
let line_starts = line_starts(content);
let mut findings = Vec::new();
for rule in compiled_secret_patterns() {
for matched in rule.regex.find_iter(content) {
if !rule.accepts_match(content, matched.start(), matched.end()) {
continue;
}
findings.push(build_finding(
content,
&line_starts,
rule.spec.detector,
rule.spec.source,
rule.spec.title,
rule.spec.precision,
matched.start(),
matched.end(),
matched.as_str(),
));
}
}
for captures in high_entropy_assignment().captures_iter(content) {
let Some(secret) = captures.get(1) else {
continue;
};
if shannon_entropy(secret.as_str()) < HIGH_ENTROPY_THRESHOLD {
continue;
}
findings.push(build_finding(
content,
&line_starts,
"high-entropy-credential-assignment",
"trufflehog",
"High-entropy secret assignment",
PRECISION_HEURISTIC,
secret.start(),
secret.end(),
secret.as_str(),
));
}
findings.sort_by(|left, right| {
left.start_offset
.cmp(&right.start_offset)
.then(left.end_offset.cmp(&right.end_offset))
.then(left.detector.cmp(&right.detector))
});
let spans = findings
.iter()
.map(|finding| {
(
finding.start_offset,
finding.end_offset,
detector_specificity(&finding.detector),
)
})
.collect::<Vec<_>>();
findings.retain(|finding| {
let specificity = detector_specificity(&finding.detector);
!spans.iter().any(|(start, end, other_specificity)| {
*other_specificity > specificity
&& finding.start_offset < *end
&& *start < finding.end_offset
})
});
findings.dedup_by(|left, right| {
left.detector == right.detector
&& left.start_offset == right.start_offset
&& left.end_offset == right.end_offset
});
findings
}
fn detector_specificity(detector: &str) -> u8 {
match detector {
"sensitive-assignment" => 0,
"high-entropy-credential-assignment" => 1,
_ => 2,
}
}
#[allow(clippy::too_many_arguments)]
fn build_finding(
content: &str,
line_starts: &[usize],
detector: &str,
source: &str,
title: &str,
precision: &str,
start_offset: usize,
end_offset: usize,
matched: &str,
) -> SecretFinding {
let (line, column_start) = offset_to_line_col(content, line_starts, start_offset);
let (_, column_end) = offset_to_line_col(content, line_starts, end_offset);
SecretFinding {
detector: detector.to_string(),
source: source.to_string(),
title: title.to_string(),
precision: precision.to_string(),
line,
column_start,
column_end,
start_offset,
end_offset,
redacted: redact_match(matched),
fingerprint: fingerprint(matched),
}
}
fn line_starts(content: &str) -> Vec<usize> {
std::iter::once(0)
.chain(
content
.bytes()
.enumerate()
.filter_map(|(index, byte)| (byte == b'\n').then_some(index + 1)),
)
.collect()
}
#[expect(
clippy::string_slice,
reason = "line starts and regex match offsets are char boundaries"
)]
fn offset_to_line_col(content: &str, starts: &[usize], offset: usize) -> (usize, usize) {
let line_index = starts
.partition_point(|start| *start <= offset)
.saturating_sub(1);
let line_start = starts[line_index];
(
line_index + 1,
content[line_start..offset].chars().count() + 1,
)
}
fn redact_match(matched: &str) -> String {
if matched.starts_with("-----BEGIN ") {
return format!(
"{} …",
matched
.lines()
.next()
.unwrap_or("-----BEGIN PRIVATE KEY-----")
);
}
let chars = matched.chars().collect::<Vec<_>>();
if chars.len() <= 8 {
return "*".repeat(chars.len());
}
let prefix = chars.iter().take(4).collect::<String>();
let suffix = chars[chars.len() - 4..].iter().collect::<String>();
format!("{prefix}…{suffix}")
}
fn fingerprint(matched: &str) -> String {
let digest = sha2::Sha256::digest(matched.as_bytes());
hex::encode(&digest[..8])
}
fn shannon_entropy(value: &str) -> f64 {
let mut counts = BTreeMap::new();
for character in value.chars() {
*counts.entry(character).or_insert(0_usize) += 1;
}
let length = value.chars().count() as f64;
counts
.values()
.map(|count| {
let probability = *count as f64 / length;
-(probability * probability.log2())
})
.sum()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scans_and_deduplicates_the_canonical_catalog() {
let findings = scan_secrets(r#"token = "ghp_1234567890abcdefghijklmnopqrstuvwxyzAB""#);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].detector, "github-token");
assert_eq!(findings[0].precision, "high");
}
#[test]
fn source_with_secretish_identifiers_remains_clean() {
assert!(scan_secrets("pub const Token = struct { kind: u8 };\n").is_empty());
}
#[test]
fn source_expressions_do_not_become_secret_findings() {
let source = "const token = process.env.GITHUB_TOKEN ?? \"\";\nlet apiKey = readKey(path);\nlet accessToken = readKey2(path);\nconst password = credentials.current;";
assert!(scan_secrets(source).is_empty());
}
}