#[derive(Debug)]
pub struct Finding {
pub rule: &'static str,
pub field: String,
pub sample: String,
pub advice: &'static str,
}
const PREFIXES: &[(&str, usize)] = &[
("sqa_", 20),
("squ_", 20),
("sqp_", 20),
("ghp_", 30),
("gho_", 30),
("ghu_", 30),
("ghs_", 30),
("ghr_", 30),
("github_pat_", 20),
("glpat-", 15),
("sk-ant-", 20),
("sk-", 20),
("xoxb-", 15),
("xoxp-", 15),
("xoxa-", 15),
("xoxs-", 15),
("xapp-", 15),
("AIza", 30),
("ya29.", 20),
("npm_", 30),
("dop_v1_", 20),
("doo_v1_", 20),
("hf_", 30),
("sk_live_", 20),
("pk_live_", 20),
("rk_live_", 20),
("SG.", 30),
];
const AWS: &[&str] = &["AKIA", "ASIA", "AIDA", "AROA", "AGPA", "ANPA", "ANVA"];
const ADVICE_KEY: &str = "Write down which credential it was and where it lives, never \
its value. E.g.: rotate the CI token, it is in the SONAR_TOKEN secret.";
const ADVICE_PII: &str = "Refer to the role, not to the person nor to their path. \
E.g.: the PR reviewer, the user home directory.";
const ADVICE_ENTROPY: &str = "If it is not a key, give it a name instead of pasting the \
value. If it is one, it does not get in: store where it lives, not what it is.";
const ADVICE_BODY: &str = "Name the file and what was decided about it, never its content. \
A path and a sentence survive the paste; the body of the file does not.";
pub fn check_field(field: &str, text: &str) -> Option<Finding> {
if text.contains("-----BEGIN") && text.contains("PRIVATE KEY") {
return Some(Finding {
rule: "private key in PEM format",
field: field.to_string(),
sample: "-----BEGIN ... PRIVATE KEY-----".into(),
advice: ADVICE_KEY,
});
}
if let Some(fence) = detect_fence(text) {
return Some(Finding {
rule: "fenced code block (file contents)",
field: field.to_string(),
sample: fence.sample(),
advice: ADVICE_BODY,
});
}
tokens(text).find_map(|tok| check_token(field, tok))
}
pub fn check_fields(fields: &[(&str, &str)]) -> Option<Finding> {
fields.iter().find_map(|(c, t)| check_field(c, t))
}
struct Fence {
marker: char,
tag: String,
lines: usize,
}
const TAG_LIMIT: usize = 16;
impl Fence {
fn sample(&self) -> String {
let marker: String = std::iter::repeat(self.marker).take(3).collect();
format!("{marker}{}, {} lines", self.tag, self.lines)
}
}
fn detect_fence(text: &str) -> Option<Fence> {
let lines: Vec<&str> = text.split('\n').collect();
for (i, line) in lines.iter().enumerate() {
let Some((marker, run, tag)) = open_fence(line) else {
continue;
};
let end = lines
.iter()
.skip(i + 1)
.position(|l| is_fence_close(l, marker, run))
.map(|p| i + 1 + p)
.unwrap_or(lines.len() - 1);
return Some(Fence {
marker,
tag: tag.trim().chars().take(TAG_LIMIT).collect(),
lines: end - i + 1,
});
}
None
}
fn open_fence(line: &str) -> Option<(char, usize, &str)> {
let indent = line.chars().take_while(|c| *c == ' ').count();
if indent > 3 {
return None;
}
let rest = &line[indent..];
let marker = rest.chars().next()?;
if marker != '`' && marker != '~' {
return None;
}
let run = rest.chars().take_while(|c| *c == marker).count();
(run >= 3).then(|| (marker, run, &rest[run..]))
}
fn is_fence_close(line: &str, marker: char, min_run: usize) -> bool {
let indent = line.chars().take_while(|c| *c == ' ').count();
if indent > 3 {
return false;
}
let rest = &line[indent..];
let run = rest.chars().take_while(|c| *c == marker).count();
run >= min_run && rest[run..].chars().all(char::is_whitespace)
}
fn check_token(field: &str, tok: &str) -> Option<Finding> {
let finding = |rule, advice| {
Some(Finding {
rule,
field: field.to_string(),
sample: mask(tok),
advice,
})
};
for (p, min) in PREFIXES {
if tok.starts_with(p) && tok.len() >= p.len() + min {
return finding("known credential prefix", ADVICE_KEY);
}
}
if tok.len() == 20
&& AWS.iter().any(|p| tok.starts_with(p))
&& tok
.bytes()
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
{
return finding("AWS access key", ADVICE_KEY);
}
if is_jwt(tok) {
return finding("JSON Web Token", ADVICE_KEY);
}
if is_email(tok) {
return finding("email address (personal data)", ADVICE_PII);
}
if is_home_path(tok) {
return finding("path to a user home directory (personal data)", ADVICE_PII);
}
if suspicious_entropy(tok) {
return finding("high-entropy string with no known shape", ADVICE_ENTROPY);
}
None
}
fn tokens(text: &str) -> impl Iterator<Item = &str> {
const SEPARATORS: &[char] = &[
',', ';', '"', '\'', '(', ')', '[', ']', '{', '}', '<', '>', '`',
];
const EDGES: &[char] = &['.', ':', '!', '?'];
text.split(|c: char| c.is_whitespace() || SEPARATORS.contains(&c))
.map(|t| t.trim_matches(|c: char| EDGES.contains(&c)))
.filter(|t| !t.is_empty())
}
fn is_jwt(tok: &str) -> bool {
if !tok.starts_with("eyJ") {
return false;
}
let parts: Vec<&str> = tok.split('.').collect();
parts.len() == 3 && parts.iter().all(|p| p.len() >= 8) && parts[1].starts_with("eyJ")
}
fn is_email(tok: &str) -> bool {
let Some((user, domain)) = tok.split_once('@') else {
return false;
};
if user.is_empty() {
return false;
}
let Some((host, tld)) = domain.rsplit_once('.') else {
return false;
};
!host.is_empty()
&& (2..=24).contains(&tld.len())
&& tld.bytes().all(|b| b.is_ascii_alphabetic())
&& host
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-')
}
fn is_home_path(tok: &str) -> bool {
let lower = tok.to_ascii_lowercase().replace('\\', "/");
["/users/", "/home/"].iter().any(|p| {
lower
.find(p)
.map(|i| lower[i + p.len()..].split('/').next().unwrap_or("").len() > 1)
.unwrap_or(false)
})
}
fn suspicious_entropy(tok: &str) -> bool {
if !(24..=512).contains(&tok.len()) || known_shape(tok) || !credential_alphabet(tok) {
return false;
}
let digit = tok.bytes().any(|b| b.is_ascii_digit());
let lower = tok.bytes().any(|b| b.is_ascii_lowercase());
let upper = tok.bytes().any(|b| b.is_ascii_uppercase());
digit && lower && upper && vowels(tok) < 0.26 && shannon(tok) >= 3.8
}
fn credential_alphabet(tok: &str) -> bool {
tok.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'+' | b'=' | b'~'))
}
fn vowels(tok: &str) -> f64 {
let n = tok.bytes().filter(|b| b.is_ascii_alphabetic()).count();
if n == 0 {
return 0.0;
}
let v = tok.bytes().filter(|b| b"aeiouAEIOU".contains(b)).count();
v as f64 / n as f64
}
fn known_shape(tok: &str) -> bool {
let no_dashes: String = tok.chars().filter(|c| *c != '-').collect();
if !no_dashes.is_empty() && no_dashes.bytes().all(|b| b.is_ascii_hexdigit()) {
return true; }
if tok.len() == 26
&& tok
.bytes()
.all(|b| b.is_ascii_digit() || (b.is_ascii_lowercase() && !b"ilou".contains(&b)))
{
return true; }
tok.contains('/') || tok.contains('\\')
}
fn shannon(s: &str) -> f64 {
let mut count = [0u32; 256];
for b in s.as_bytes() {
count[*b as usize] += 1;
}
let n = s.len() as f64;
-count
.iter()
.filter(|c| **c > 0)
.map(|c| {
let p = f64::from(*c) / n;
p * p.log2()
})
.sum::<f64>()
}
fn mask(tok: &str) -> String {
let visible: String = tok.chars().take(4).collect();
format!("{visible}******** ({} characters)", tok.chars().count())
}
impl std::fmt::Display for Finding {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
" Refused: {}\n\n field {}\n found {}\n\n {}",
self.rule, self.field, self.sample, self.advice
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn refuses(t: &str) -> bool {
check_field("title", t).is_some()
}
#[test]
fn known_keys() {
assert!(refuses(
"the token is sqa_9f3c1d7e5b2a48c6d0e1f2a3b4c5d6e7f8091a2b"
));
assert!(refuses("ghp_16C7e42F292c6912E7710c838347Ae178B4a"));
assert!(refuses(
"usar sk-ant-api03-abcdefghijklmnopqrstuvwxyz012345"
));
assert!(refuses("AKIAIOSFODNN7EXAMPLE"));
assert!(refuses("xoxb-2444-8172-abcdefghijkl"));
assert!(refuses("-----BEGIN RSA PRIVATE KEY-----"));
}
#[test]
fn jwt() {
assert!(refuses(
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dBjftJeZ4CVPmB92K27uhbUJU1p1r"
));
}
#[test]
fn personal_data() {
assert!(refuses("preguntarle a alguien@ejemplo.com"));
assert!(refuses("it lives in C:\\Users\\somename\\projects"));
assert!(refuses("/home/unnombre/.config/vivac"));
assert!(refuses("/Users/unnombre/Library"));
}
#[test]
fn what_has_to_get_through() {
assert!(!refuses("Port to Rust in the public vivac/ repo"));
assert!(!refuses(
"csharpsquid:S1192 literals duplicados entre archivos"
));
assert!(!refuses("PermissionServiceAdapter.cs:278 is out of scope"));
assert!(!refuses("commit e90b4832f1a4c6d8b0e2f4a6c8d0e2f4a6c8d0e2"));
assert!(!refuses("node 01j8xq2m4k7pabcdefghijklmn"));
assert!(!refuses("id 550e8400-e29b-41d4-a716-446655440000"));
assert!(!refuses(
"ver https://github.com/rust-lang/rust/issues/12345"
));
assert!(!refuses("the hook lives in ~/.claude/settings.json"));
assert!(!refuses("MeetingPolicyMaskCalculatorFactoryProvider"));
assert!(!refuses("ApplyVisibilityPolicyPerMeetingHandler"));
assert!(!refuses("ReunionV2PolicyMaskCalculatorFactory"));
}
#[test]
fn the_entropy_heuristic_still_hunts() {
assert!(refuses("Xk7fQ2mZp9RtLw4sVb8NcJ3hGd6y"));
assert!(refuses("p8KdReQvXnLYtSbGmZwHfJcT3x9Wq2Vz"));
}
#[test]
fn the_sample_does_not_carry_the_secret() {
let h = check_field("title", "sqa_9f3c1d7e5b2a48c6d0e1f2a3b4c5d6e7f8091a2b").unwrap();
assert!(!h.sample.contains("9f3c1d7e"));
assert!(h.sample.starts_with("sqa_"));
}
#[test]
fn it_returns_the_first_field_that_fails() {
let h = check_fields(&[
("title", "all fine"),
("why", "ghp_16C7e42F292c6912E7710c838347Ae178B4a"),
]);
assert_eq!(h.unwrap().field, "why");
}
#[test]
fn fenced_code_is_refused() {
assert!(refuses("```rust\nfn parse() -> bool {\n true\n}\n```"));
assert!(refuses("~~~\nsome code\n~~~"));
assert!(refuses(" ```\ncode\n```"));
}
#[test]
fn code_that_is_not_fenced_gets_through() {
assert!(!refuses("run `cargo test` before pushing"));
assert!(!refuses("it prints ```like this``` inline, not fenced"));
}
#[test]
fn the_fence_sample_does_not_carry_the_body() {
let h = check_field(
"note",
"```rust\nfn super_secret_function_name() -> u8 { 42 }\n```",
)
.unwrap();
assert!(!h.sample.contains("super"));
assert!(!h.sample.contains("secret"));
assert!(!h.sample.contains("function"));
assert_eq!(h.sample, "```rust, 3 lines");
}
#[test]
fn the_fence_is_found_past_the_first_line() {
let h = check_field("note", "Some prose here.\n```\ncode line\n```");
assert!(h.is_some());
}
}