mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-22 00:31:12 +00:00
Two-layer detection: Shannon entropy on high-entropy alphanumeric tokens (threshold 4.5) and gitleaks v8.22.1 pattern matching (202 rules) with Aho-Corasick keyword pre-filtering. JSONL-aware redaction skips exempt fields (IDs, paths) and image objects. 43 tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Entire-Checkpoint: 9e8476d16412
113 lines
3.2 KiB
Rust
113 lines
3.2 KiB
Rust
mod entropy;
|
|
mod gitleaks;
|
|
mod jsonl;
|
|
|
|
pub use jsonl::redact_jsonl_line;
|
|
|
|
/// A byte range within a string that should be redacted.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Region {
|
|
pub start: usize,
|
|
pub end: usize,
|
|
}
|
|
|
|
/// Replace all detected secrets in `s` with "REDACTED".
|
|
///
|
|
/// Uses two layers of detection:
|
|
/// 1. Shannon entropy on high-entropy alphanumeric tokens
|
|
/// 2. Gitleaks regex pattern matching (200+ known secret formats)
|
|
pub fn redact_string(s: &str) -> String {
|
|
let mut regions = entropy::find_entropy_regions(s);
|
|
regions.extend(gitleaks::find_gitleaks_regions(s));
|
|
|
|
if regions.is_empty() {
|
|
return s.to_string();
|
|
}
|
|
|
|
regions.sort_by_key(|r| r.start);
|
|
|
|
// Merge overlapping regions
|
|
let mut merged = vec![regions[0].clone()];
|
|
for r in ®ions[1..] {
|
|
let last = merged.last_mut().unwrap();
|
|
if r.start <= last.end {
|
|
last.end = last.end.max(r.end);
|
|
} else {
|
|
merged.push(r.clone());
|
|
}
|
|
}
|
|
|
|
let mut result = String::with_capacity(s.len());
|
|
let mut prev = 0;
|
|
for r in &merged {
|
|
result.push_str(&s[prev..r.start]);
|
|
result.push_str("REDACTED");
|
|
prev = r.end;
|
|
}
|
|
result.push_str(&s[prev..]);
|
|
result
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const HIGH_ENTROPY_SECRET: &str = "sk-ant-api03-xK9mZ2vL8nQ5rT1wY4bC7dF0gH3jE6pA";
|
|
|
|
#[test]
|
|
fn redact_string_no_secrets() {
|
|
assert_eq!(redact_string("hello world"), "hello world");
|
|
}
|
|
|
|
#[test]
|
|
fn redact_string_with_aws_key() {
|
|
let result = redact_string("key=AKIAYRWQG5EJLPZLBYNP");
|
|
assert_eq!(result, "key=REDACTED");
|
|
}
|
|
|
|
#[test]
|
|
fn redact_string_overlapping_detections_produce_single_redacted() {
|
|
// A high-entropy string that also matches a gitleaks pattern
|
|
// should produce one REDACTED, not two
|
|
let input = format!("key={HIGH_ENTROPY_SECRET}");
|
|
let result = redact_string(&input);
|
|
assert_eq!(
|
|
result.matches("REDACTED").count(),
|
|
1,
|
|
"expected single REDACTED, got: {result}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn redact_string_two_secrets_separated_by_space() {
|
|
let input = "key=AKIAYRWQG5EJLPZLBYNP AKIAYRWQG5EJLPZLBYNP";
|
|
let result = redact_string(input);
|
|
assert_eq!(result, "key=REDACTED REDACTED");
|
|
}
|
|
|
|
#[test]
|
|
fn redact_string_file_path_preserved() {
|
|
let input = "/tmp/test/controller.go";
|
|
assert_eq!(redact_string(input), input);
|
|
}
|
|
|
|
#[test]
|
|
fn redact_string_json_escape_preserved() {
|
|
let input = r"controller.go\nmodel.go";
|
|
assert_eq!(redact_string(input), input);
|
|
}
|
|
|
|
#[test]
|
|
fn redact_string_github_pat() {
|
|
let input = "token=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef0123";
|
|
let result = redact_string(input);
|
|
assert!(result.contains("REDACTED"), "expected REDACTED in: {result}");
|
|
}
|
|
|
|
#[test]
|
|
fn redact_string_private_key() {
|
|
let input = "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA\n-----END RSA PRIVATE KEY-----";
|
|
let result = redact_string(input);
|
|
assert!(result.contains("REDACTED"), "expected REDACTED in: {result}");
|
|
}
|
|
}
|