use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RejectReason {
Empty,
TooShort,
Credential,
VolatileJunk,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "verdict")]
pub enum ScreeningVerdict {
Admit,
Reject { reason: RejectReason },
}
impl ScreeningVerdict {
pub fn is_admit(&self) -> bool {
matches!(self, ScreeningVerdict::Admit)
}
}
pub const MIN_DURABLE_LEN: usize = 8;
const CREDENTIAL_KEYWORDS: &[&str] = &[
"password",
"passwd",
"pwd",
"api_key",
"apikey",
"api-key",
"secret",
"access_key",
"access-key",
"accesskey",
"private_key",
"private-key",
"privatekey",
"client_secret",
"auth_token",
"authtoken",
"bearer",
"authorization:",
"token",
"credential",
"ssn",
"passphrase",
];
const VOLATILE_JUNK_PHRASES: &[&str] = &[
"ok",
"okay",
"done",
"loading",
"loading...",
"pong",
"ping",
"yes",
"no",
"thanks",
"thank you",
"hi",
"hello",
"test",
"testing",
"n/a",
"none",
"in progress",
"working on it",
];
pub fn screen(content: &str) -> ScreeningVerdict {
let trimmed = content.trim();
if trimmed.is_empty() {
return ScreeningVerdict::Reject {
reason: RejectReason::Empty,
};
}
if trimmed.len() < MIN_DURABLE_LEN {
return ScreeningVerdict::Reject {
reason: RejectReason::TooShort,
};
}
if looks_like_credential(trimmed) {
return ScreeningVerdict::Reject {
reason: RejectReason::Credential,
};
}
if is_volatile_junk(trimmed) {
return ScreeningVerdict::Reject {
reason: RejectReason::VolatileJunk,
};
}
ScreeningVerdict::Admit
}
fn looks_like_credential(trimmed: &str) -> bool {
let lower = trimmed.to_ascii_lowercase();
if lower.contains("-----begin ") && lower.contains("private key") {
return true;
}
if CREDENTIAL_KEYWORDS
.iter()
.any(|kw| keyword_near_assignment(&lower, kw))
{
return true;
}
if looks_like_jwt(trimmed) {
return true;
}
if !trimmed.contains(char::is_whitespace)
&& trimmed.len() >= 24
&& trimmed.len() <= 512
&& looks_high_entropy(trimmed)
{
return true;
}
false
}
fn keyword_near_assignment(lower: &str, keyword: &str) -> bool {
let mut search_from = 0;
while let Some(rel) = lower[search_from..].find(keyword) {
let start = search_from + rel;
let after = &lower[start + keyword.len()..];
let after_trimmed = after.trim_start();
if let Some(rest) = after_trimmed
.strip_prefix(':')
.or_else(|| after_trimmed.strip_prefix('='))
{
let value = rest.trim();
if value.len() >= 3 {
return true;
}
}
search_from = start + keyword.len();
if search_from >= lower.len() {
break;
}
}
false
}
fn looks_like_jwt(trimmed: &str) -> bool {
let parts: Vec<&str> = trimmed.split('.').collect();
if parts.len() != 3 {
return false;
}
parts
.iter()
.all(|p| p.len() >= 8 && p.chars().all(is_base64url_char))
}
fn is_base64url_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '-' || c == '_'
}
fn looks_high_entropy(token: &str) -> bool {
let has_lower = token.chars().any(|c| c.is_ascii_lowercase());
let has_upper = token.chars().any(|c| c.is_ascii_uppercase());
let has_digit = token.chars().any(|c| c.is_ascii_digit());
let class_count = [has_lower, has_upper, has_digit]
.iter()
.filter(|b| **b)
.count();
class_count >= 2
}
fn is_volatile_junk(trimmed: &str) -> bool {
let lower = trimmed.to_ascii_lowercase();
let normalized = lower.trim_end_matches(['.', '!', '?']).trim();
if VOLATILE_JUNK_PHRASES.contains(&normalized) {
return true;
}
if normalized.len() <= 32
&& normalized.chars().enumerate().all(|(i, c)| {
c.is_ascii_digit() || matches!(c, '-' | ':' | 't' | 'z' | '.' | '+') || i == 0
})
&& normalized.chars().any(|c| c.is_ascii_digit())
&& normalized.chars().filter(|c| c.is_ascii_digit()).count() >= 8
&& !normalized
.chars()
.any(|c| c.is_ascii_alphabetic() && !matches!(c, 't' | 'z'))
{
return true;
}
false
}