use regex::Regex;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::borrow::Cow;
use std::collections::HashMap;
use std::ops::Range;
use std::path::Path;
use std::sync::OnceLock;
const TRUNCATION_MARKER: &str = "… [truncated]";
const REDACTED: &str = "[REDACTED]";
pub const SECRET_ALLOWLIST_PATH: &str = ".kranz/secret-allowlist";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SecretFinding {
pub rule_id: String,
pub fingerprint: String,
pub location: String,
pub start: usize,
pub end: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SecretScan {
pub redacted: String,
pub findings: Vec<SecretFinding>,
}
struct Rule {
id: &'static str,
re: Regex,
replacement: &'static str,
secret_group: Option<usize>,
}
fn rule(id: &'static str, pattern: &str, replacement: &'static str) -> Rule {
Rule {
id,
re: Regex::new(pattern).expect("static scrub regex must compile"),
replacement,
secret_group: None,
}
}
fn grouped_rule(
id: &'static str,
pattern: &str,
replacement: &'static str,
secret_group: usize,
) -> Rule {
Rule {
id,
re: Regex::new(pattern).expect("static scrub regex must compile"),
replacement,
secret_group: Some(secret_group),
}
}
fn rules() -> &'static [Rule] {
static RULES: OnceLock<Vec<Rule>> = OnceLock::new();
RULES.get_or_init(|| {
vec![
rule(
"pem-private-key",
r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----|-----BEGIN [A-Z ]*PRIVATE KEY-----[^\r\n]*",
REDACTED,
),
grouped_rule(
"gcp-private-key-json",
r#"(?i)("private_key"\s*:\s*")(-----BEGIN[^"]*)"#,
"${1}[REDACTED]",
2,
),
rule("anthropic-api-key", r"\bsk-ant-[A-Za-z0-9_-]{8,}", REDACTED),
rule(
"openai-project-key",
r"\bsk-proj-[A-Za-z0-9_-]{20,}",
REDACTED,
),
rule("openai-api-key", r"\bsk-[A-Za-z0-9]{20,}", REDACTED),
rule("google-api-key", r"\bAIza[0-9A-Za-z_-]{35}\b", REDACTED),
rule(
"stripe-live-key",
r"\b(?:sk|rk|pk)_live_[0-9A-Za-z]{16,}",
REDACTED,
),
rule("npm-token", r"\bnpm_[0-9A-Za-z]{36}\b", REDACTED),
rule("github-token", r"\bgh[pos]_[A-Za-z0-9]{20,}", REDACTED),
rule(
"github-fine-grained-token",
r"\bgithub_pat_[A-Za-z0-9_]{20,}",
REDACTED,
),
rule("aws-access-key-id", r"\bAKIA[0-9A-Z]{16}\b", REDACTED),
grouped_rule(
"aws-secret-access-key",
r"(?i)\b(aws_secret_access_key\s*[=:]\s*)(\S+)",
"${1}[REDACTED]",
2,
),
rule("slack-token", r"\bxox[baprs]-[A-Za-z0-9-]{10,}", REDACTED),
rule(
"jwt",
r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}",
REDACTED,
),
grouped_rule(
"authorization-bearer",
r"(?i)\b(bearer\s+)([a-z0-9._~+/=-]{16,})",
"${1}[REDACTED]",
2,
),
grouped_rule(
"authorization-basic",
r"(?i)\b(basic\s+)([a-z0-9+/]{16,}={0,2})",
"${1}[REDACTED]",
2,
),
grouped_rule(
"connection-string-password",
r"([a-zA-Z][a-zA-Z0-9+.-]*://[^\s:/@]+:)([^\s:/@]+)(@)",
"${1}[REDACTED]${3}",
2,
),
]
})
}
fn generic_assignment_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r#"(?i)((?:api[_-]?key|secret|token|password|passwd|credential)["']?\s*[:=]\s*["']?)([^\s"']{8,})"#,
)
.expect("generic assignment regex must compile")
})
}
fn entropy_assignment_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(
r#"(?i)((?:access[_-]?token|auth[_-]?token|auth|client[_-]?secret|private[_-]?key)["']?\s*[:=]\s*["']?)([A-Za-z0-9+/_=-]{24,})"#,
)
.expect("entropy assignment regex must compile")
})
}
pub(crate) fn shannon_entropy(s: &str) -> f64 {
if s.is_empty() {
return 0.0;
}
let mut counts: HashMap<char, usize> = HashMap::new();
for c in s.chars() {
*counts.entry(c).or_insert(0) += 1;
}
let len = s.chars().count() as f64;
counts
.values()
.map(|&count| {
let p = count as f64 / len;
-p * p.log2()
})
.sum()
}
fn looks_like_secret_charset(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '_' | '-' | '='))
}
fn is_allowlisted(value: &str) -> bool {
let lower = value.to_ascii_lowercase();
const PLACEHOLDERS: &[&str] = &[
"xxxx",
"replace",
"example",
"changeme",
"your",
"dummy",
"placeholder",
"todo",
"none",
"redacted",
];
if PLACEHOLDERS.iter().any(|p| lower.contains(p)) {
return true;
}
if let Some(first) = value.chars().next() {
if value.chars().all(|c| c == first) {
return true;
}
}
if is_uuid(value) {
return true;
}
if value.len() == 40 && value.chars().all(|c| c.is_ascii_hexdigit()) && lower == value {
return true;
}
false
}
fn is_uuid(s: &str) -> bool {
let groups: Vec<&str> = s.split('-').collect();
if groups.len() != 5 {
return false;
}
let widths = [8usize, 4, 4, 4, 12];
groups
.iter()
.zip(widths)
.all(|(g, w)| g.len() == w && g.chars().all(|c| c.is_ascii_hexdigit()))
}
fn is_high_entropy_secret(value: &str) -> bool {
value.len() >= 24
&& looks_like_secret_charset(value)
&& !is_allowlisted(value)
&& shannon_entropy(value) >= 4.0
}
fn secret_fingerprint(rule_id: &str, value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(rule_id.as_bytes());
hasher.update([0]);
hasher.update(value.as_bytes());
let digest = hasher.finalize();
digest[..12].iter().map(|b| format!("{b:02x}")).collect()
}
fn push_finding(
out: &mut Vec<SecretFinding>,
occupied: &mut Vec<Range<usize>>,
rule_id: &str,
location: &str,
range: Range<usize>,
value: &str,
) {
if is_allowlisted(value) {
return;
}
if occupied
.iter()
.any(|existing| existing.start < range.end && range.start < existing.end)
{
return;
}
occupied.push(range.clone());
out.push(SecretFinding {
rule_id: rule_id.to_string(),
fingerprint: secret_fingerprint(rule_id, value),
location: location.to_string(),
start: range.start,
end: range.end,
});
}
pub fn scan_text_at(text: &str, location: &str) -> Vec<SecretFinding> {
scan_text_with_assignments(text, text, location)
}
fn scan_text_with_assignments(
text: &str,
assignment_text: &str,
location: &str,
) -> Vec<SecretFinding> {
let mut out = Vec::new();
let mut occupied: Vec<Range<usize>> = Vec::new();
for rule in rules() {
for caps in rule.re.captures_iter(text) {
let m = rule
.secret_group
.and_then(|idx| caps.get(idx))
.or_else(|| caps.get(0));
if let Some(m) = m {
push_finding(
&mut out,
&mut occupied,
rule.id,
location,
m.start()..m.end(),
m.as_str(),
);
}
}
}
for caps in generic_assignment_re().captures_iter(assignment_text) {
if let Some(value) = caps.get(2) {
push_finding(
&mut out,
&mut occupied,
"generic-secret-assignment",
location,
value.start()..value.end(),
value.as_str(),
);
}
}
for caps in entropy_assignment_re().captures_iter(assignment_text) {
if let Some(value) = caps.get(2) {
if is_high_entropy_secret(value.as_str()) {
push_finding(
&mut out,
&mut occupied,
"high-entropy-secret-assignment",
location,
value.start()..value.end(),
value.as_str(),
);
}
}
}
out
}
pub fn scan_text(text: &str) -> Vec<SecretFinding> {
scan_text_at(text, "text")
}
fn scrub_assignments(text: &str) -> Cow<'_, str> {
generic_assignment_re().replace_all(text, |caps: ®ex::Captures<'_>| {
let prefix = &caps[1];
let value = &caps[2];
if is_allowlisted(value) {
caps[0].to_owned()
} else {
format!("{prefix}{REDACTED}")
}
})
}
fn scrub_entropy(text: &str) -> Cow<'_, str> {
entropy_assignment_re().replace_all(text, |caps: ®ex::Captures<'_>| {
let prefix = &caps[1];
let value = &caps[2];
if is_high_entropy_secret(value) {
format!("{prefix}{REDACTED}")
} else {
caps[0].to_owned()
}
})
}
fn scrub_plain(text: &str) -> String {
let mut out = text.to_owned();
for rule in rules() {
if let Cow::Owned(replaced) = rule.re.replace_all(&out, rule.replacement) {
out = replaced;
}
}
if let Cow::Owned(replaced) = scrub_assignments(&out) {
out = replaced;
}
if let Cow::Owned(replaced) = scrub_entropy(&out) {
out = replaced;
}
out
}
fn scrub_json_text(text: &str) -> Option<String> {
serde_json::from_str::<serde_json::Value>(text).ok()?;
let bytes = text.as_bytes();
let mut cursor = 0;
let mut copied = 0;
let mut out = String::new();
let mut key: Option<String> = None;
while cursor < bytes.len() {
let start = cursor;
if bytes[cursor] == b'"' {
cursor += 1;
while cursor < bytes.len() {
match bytes[cursor] {
b'\\' => cursor += 2,
b'"' => {
cursor += 1;
break;
}
_ => cursor += 1,
}
}
let decoded: String = serde_json::from_str(&text[start..cursor]).ok()?;
let is_key = text[cursor..].trim_start().starts_with(':');
let redacted = if is_key {
scrub_plain(&decoded)
} else {
scrub_json_assignment(scrub_impl(&decoded), key.as_deref())
};
if redacted != decoded {
out.push_str(&text[copied..start]);
out.push_str(
&serde_json::to_string(&redacted)
.ok()?
.replace('<', "\\u003c")
.replace('>', "\\u003e"),
);
copied = cursor;
}
key = is_key.then_some(decoded);
} else if bytes[cursor].is_ascii_whitespace() || bytes[cursor] == b':' {
cursor += 1;
} else {
if key.is_some() && matches!(bytes[cursor], b'-' | b'0'..=b'9') {
while cursor < bytes.len()
&& matches!(
bytes[cursor],
b'-' | b'+' | b'.' | b'e' | b'E' | b'0'..=b'9'
)
{
cursor += 1;
}
let value = &text[start..cursor];
let redacted = scrub_json_assignment(value.to_owned(), key.as_deref());
if redacted != value {
out.push_str(&text[copied..start]);
out.push_str(&serde_json::to_string(&redacted).ok()?);
copied = cursor;
}
} else {
cursor += 1;
}
key = None;
}
}
out.push_str(&text[copied..]);
Some(out)
}
fn scrub_json_assignment(mut value: String, key: Option<&str>) -> String {
if let Some(key) = key {
let prefix = format!("{key}=\"");
let contextual = format!("{prefix}{value}");
for (regex, entropy_only) in [
(generic_assignment_re(), false),
(entropy_assignment_re(), true),
] {
let Some(caps) = regex.captures(&contextual) else {
continue;
};
let candidate = caps.get(2).expect("assignment value capture");
if candidate.start() == prefix.len()
&& if entropy_only {
is_high_entropy_secret(candidate.as_str())
} else {
!is_allowlisted(candidate.as_str())
}
{
value.replace_range(..candidate.len(), REDACTED);
break;
}
}
}
value
}
fn scrub_impl(text: &str) -> String {
if let Some(redacted) = scrub_json_text(text) {
return redacted;
}
let mut out = String::new();
let mut plain_start = 0;
let mut body_start = None;
let mut cursor = 0;
for line in text.split_inclusive('\n') {
let start = cursor;
cursor += line.len();
let trimmed = line.trim();
if body_start.is_none() && matches!(trimmed, "```" | "```json" | "```JSON") {
body_start = Some(cursor);
} else if trimmed == "```" {
if let Some(body) = body_start.take() {
if let Some(redacted) = scrub_json_text(&text[body..start]) {
out.push_str(&scrub_plain(&text[plain_start..body]));
out.push_str(&redacted);
if !redacted.ends_with('\n') {
out.push('\n');
}
plain_start = start;
}
}
}
}
out.push_str(&scrub_plain(&text[plain_start..]));
out
}
pub fn scrub_with_findings(text: &str, location: &str) -> SecretScan {
SecretScan {
redacted: scrub_impl(text),
findings: scan_text_at(text, location),
}
}
pub fn scrub(text: &str) -> String {
scrub_impl(text)
}
pub fn scrub_json_value(value: &mut serde_json::Value, location: &str) -> Vec<SecretFinding> {
fn walk(value: &mut serde_json::Value, path: String, findings: &mut Vec<SecretFinding>) {
match value {
serde_json::Value::String(s) => {
let scan = scrub_with_findings(s, &path);
*s = scan.redacted;
findings.extend(scan.findings);
}
serde_json::Value::Array(items) => {
for (idx, item) in items.iter_mut().enumerate() {
walk(item, format!("{path}/{idx}"), findings);
}
}
serde_json::Value::Object(map) => {
for (key, item) in map.iter_mut() {
walk(item, format!("{path}/{key}"), findings);
}
}
serde_json::Value::Null | serde_json::Value::Bool(_) | serde_json::Value::Number(_) => {
}
}
}
let mut findings = Vec::new();
walk(value, location.to_string(), &mut findings);
findings
}
const GENERATED_DIFF_PATH_PREFIXES: &[&str] =
&["apps/dashboard/dist/", "crates/cli/assets/dashboard/dist/"];
pub fn scan_unified_diff(diff: &str) -> Vec<SecretFinding> {
let mut findings = Vec::new();
let mut path = "<diff>".to_string();
let mut generated_dashboard_bundle = false;
let mut new_line: Option<usize> = None;
for line in diff.lines() {
if let Some(rest) = line.strip_prefix("+++ b/") {
path = rest.to_string();
generated_dashboard_bundle = GENERATED_DIFF_PATH_PREFIXES
.iter()
.any(|prefix| path.starts_with(prefix));
continue;
}
if line.starts_with("@@ ") {
new_line = parse_new_hunk_start(line);
continue;
}
if line.starts_with("+++") {
continue;
}
if let Some(added) = line.strip_prefix('+') {
let line_no = new_line.unwrap_or(0);
let location = if line_no == 0 {
path.clone()
} else {
format!("{path}:{line_no}")
};
let mut line_findings = scan_text_at(added, &location);
if generated_dashboard_bundle {
line_findings.retain(|finding| finding.rule_id != "generic-secret-assignment");
}
findings.extend(line_findings);
if let Some(n) = &mut new_line {
*n += 1;
}
} else if !line.starts_with('-') {
if let Some(n) = &mut new_line {
*n += 1;
}
}
}
findings
}
fn parse_new_hunk_start(line: &str) -> Option<usize> {
let plus = line.split_whitespace().find(|part| part.starts_with('+'))?;
let number = plus
.trim_start_matches('+')
.split(',')
.next()
.filter(|s| !s.is_empty())?;
number.parse().ok()
}
pub fn read_allowlist_text(text: &str) -> std::collections::BTreeSet<String> {
text.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.filter_map(|line| line.split_whitespace().next())
.map(str::to_string)
.collect()
}
pub fn filter_allowed(
findings: Vec<SecretFinding>,
allowed: &std::collections::BTreeSet<String>,
) -> Vec<SecretFinding> {
findings
.into_iter()
.filter(|f| !allowed.contains(&f.fingerprint))
.collect()
}
pub fn format_findings(findings: &[SecretFinding]) -> String {
findings
.iter()
.map(|finding| {
format!(
"{} [{}] {} bytes {}..{}",
finding.fingerprint, finding.rule_id, finding.location, finding.start, finding.end
)
})
.collect::<Vec<_>>()
.join("\n")
}
const SCAN_PATH_MAX_FILE_BYTES: u64 = 8 * 1024 * 1024;
fn read_scan_candidate(path: &Path) -> Option<Vec<u8>> {
use std::io::Read as _;
let (parent, name) = crate::paths::open_parent_nofollow(path).ok()?;
let mut options = cap_std::fs::OpenOptions::new();
{
use cap_fs_ext::OpenOptionsFollowExt as _;
use cap_primitives::fs::FollowSymlinks;
options.read(true).follow(FollowSymlinks::No);
}
#[cfg(unix)]
{
use cap_fs_ext::OpenOptionsExt as _;
options.custom_flags(libc::O_NONBLOCK);
}
let file = parent.open_with(name, &options).ok()?.into_std();
let metadata = file.metadata().ok()?;
if !metadata.file_type().is_file() || metadata.len() > SCAN_PATH_MAX_FILE_BYTES {
return None;
}
let mut buf = Vec::new();
(&mut &file)
.take(SCAN_PATH_MAX_FILE_BYTES + 1)
.read_to_end(&mut buf)
.ok()?;
if buf.len() as u64 > SCAN_PATH_MAX_FILE_BYTES {
return None;
}
Some(buf)
}
pub fn scan_paths(repo_root: &Path, paths: &[&Path]) -> Vec<SecretFinding> {
let mut findings = Vec::new();
for path in paths {
let full = if path.is_absolute() {
path.to_path_buf()
} else {
repo_root.join(path)
};
let Some(bytes) = read_scan_candidate(&full) else {
continue;
};
let text = String::from_utf8_lossy(&bytes);
let location = full
.strip_prefix(repo_root)
.ok()
.and_then(|p| p.to_str())
.unwrap_or_else(|| full.to_str().unwrap_or("<path>"));
let assignments = if full.extension().is_some_and(|ext| ext == "py") {
python_assignment_text(&text)
} else {
Cow::Borrowed(text.as_ref())
};
findings.extend(scan_text_with_assignments(&text, &assignments, location));
}
findings
}
fn python_assignment_text(text: &str) -> Cow<'_, str> {
let mut out = Cow::Borrowed(text);
let mut offset = 0;
for line in text.split_inclusive('\n') {
let trimmed = line.trim_end();
let keyword = trimmed.split_whitespace().next().unwrap_or("");
if trimmed.ends_with(':')
&& matches!(
keyword,
"if" | "elif" | "while" | "for" | "with" | "except" | "class" | "match" | "case"
)
{
let colon = offset + trimmed.len() - 1;
out.to_mut().replace_range(colon..colon + 1, " ");
}
offset += line.len();
}
out
}
pub fn truncate_chars(text: &str, max: usize) -> String {
match text.char_indices().nth(max) {
None => text.to_owned(),
Some((cut_at, _)) => {
let mut out = String::with_capacity(cut_at + TRUNCATION_MARKER.len());
out.push_str(&text[..cut_at]);
out.push_str(TRUNCATION_MARKER);
out
}
}
}
pub fn scrub_and_truncate(text: &str, max: usize) -> String {
truncate_chars(&scrub(text), max)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn entropy_of_empty_is_zero() {
assert_eq!(shannon_entropy(""), 0.0);
}
#[test]
fn entropy_of_uniform_string_is_zero() {
assert_eq!(shannon_entropy("aaaaaaaa"), 0.0);
}
#[test]
fn entropy_of_random_base64_is_high() {
let e = shannon_entropy("aB3xQ9zK7mP2wR5tY8uV1nJ4kL6dF0sG");
assert!(e >= 4.0, "entropy too low: {e}");
}
#[test]
fn entropy_of_english_word_is_low() {
let e = shannon_entropy("bureaucracy");
assert!(e < 4.0, "prose entropy unexpectedly high: {e}");
}
#[test]
fn uuid_recognized() {
assert!(is_uuid("550e8400-e29b-41d4-a716-446655440000"));
assert!(!is_uuid("not-a-uuid"));
assert!(!is_uuid("550e8400e29b41d4a716446655440000"));
}
#[test]
fn allowlist_covers_placeholders_and_shas() {
assert!(is_allowlisted("REPLACE_ME_WITH_REAL_KEY_1234567890"));
assert!(is_allowlisted("xxxxxxxxxxxxxxxxxxxxxxxx"));
assert!(is_allowlisted("aaaaaaaaaaaaaaaaaaaaaaaa"));
assert!(is_allowlisted("550e8400-e29b-41d4-a716-446655440000"));
assert!(is_allowlisted("da39a3ee5e6b4b0d3255bfef95601890afd80709"));
}
const SCRUB_NOFOLLOW_SECRET: &str = "sk-ant-api03-ScrubNofollowTestValue1";
fn scan_with_timeout(root: &Path, paths: &[&Path], secs: u64) -> Vec<SecretFinding> {
let root = root.to_path_buf();
let paths: Vec<std::path::PathBuf> = paths.iter().map(|p| p.to_path_buf()).collect();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let refs: Vec<&Path> = paths.iter().map(std::path::PathBuf::as_path).collect();
let _ = tx.send(scan_paths(&root, &refs));
});
rx.recv_timeout(std::time::Duration::from_secs(secs))
.expect("scan_paths must not block")
}
#[cfg(unix)]
#[test]
fn scrub_nofollow_fifo_does_not_block_checkpoint_scan() {
let dir = tempfile::tempdir().unwrap();
let fifo = dir.path().join("planted.fifo");
let c_path = std::ffi::CString::new(fifo.to_str().expect("utf-8 temp path")).unwrap();
let rc = unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) };
assert_eq!(rc, 0, "mkfifo failed: {}", std::io::Error::last_os_error());
let findings = scan_with_timeout(dir.path(), &[Path::new("planted.fifo")], 10);
assert!(
findings.is_empty(),
"a FIFO is skipped, never scanned: {findings:?}"
);
}
#[cfg(unix)]
#[test]
fn scrub_nofollow_symlink_to_dev_zero_is_skipped() {
let dir = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink("/dev/zero", dir.path().join("zero")).unwrap();
let findings = scan_with_timeout(dir.path(), &[Path::new("zero")], 10);
assert!(
findings.is_empty(),
"a symlink to an unbounded source is skipped, never read through: {findings:?}"
);
}
#[cfg(unix)]
#[test]
fn scrub_nofollow_symlinked_file_is_not_read_through() {
let dir = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let real = outside.path().join("real.txt");
std::fs::write(&real, SCRUB_NOFOLLOW_SECRET).unwrap();
std::os::unix::fs::symlink(&real, dir.path().join("linked.txt")).unwrap();
let findings = scan_paths(dir.path(), &[Path::new("linked.txt")]);
assert!(
findings.is_empty(),
"a symlink is never read through: {findings:?}"
);
let findings = scan_paths(dir.path(), &[real.as_path()]);
assert!(
findings.iter().any(|f| f.rule_id == "anthropic-api-key"),
"the direct scan must flag the secret: {findings:?}"
);
}
#[test]
fn scrub_nofollow_oversized_file_is_skipped_and_under_cap_scans() {
let dir = tempfile::tempdir().unwrap();
let mut content = SCRUB_NOFOLLOW_SECRET.as_bytes().to_vec();
content.resize(SCAN_PATH_MAX_FILE_BYTES as usize + 1, b'x');
std::fs::write(dir.path().join("big.txt"), &content).unwrap();
let findings = scan_with_timeout(dir.path(), &[Path::new("big.txt")], 10);
assert!(
findings.is_empty(),
"an oversized file is skipped whole, never partially scanned: {findings:?}"
);
std::fs::write(dir.path().join("small.txt"), SCRUB_NOFOLLOW_SECRET).unwrap();
let findings = scan_paths(dir.path(), &[Path::new("small.txt")]);
assert!(
findings.iter().any(|f| f.rule_id == "anthropic-api-key"),
"under-cap content still scans: {findings:?}"
);
}
#[test]
fn composition_audit_secret_allowlist_waives_one_fingerprint_never_a_rule() {
let text_a = "sk-ant-api03-CompositionAuditValueA1";
let text_b = "sk-ant-api03-CompositionAuditValueB2";
let findings = scan_text(&format!("{text_a} {text_b}"));
assert_eq!(findings.len(), 2, "both keys must be found: {findings:?}");
let waived: std::collections::BTreeSet<String> =
[findings[0].fingerprint.clone()].into_iter().collect();
let remaining = filter_allowed(findings, &waived);
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].rule_id, "anthropic-api-key");
let findings = scan_text(text_a);
assert_eq!(
filter_allowed(findings.clone(), &Default::default()),
findings
);
let garbage = read_allowlist_text("# reviewed\nnot-a-fingerprint\n");
assert_eq!(filter_allowed(findings.clone(), &garbage), findings);
}
}