rightkit_memory_client/
screening.rs1use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum RejectReason {
16 Empty,
18 TooShort,
21 Credential,
24 VolatileJunk,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case", tag = "verdict")]
31pub enum ScreeningVerdict {
32 Admit,
33 Reject { reason: RejectReason },
34}
35
36impl ScreeningVerdict {
37 pub fn is_admit(&self) -> bool {
38 matches!(self, ScreeningVerdict::Admit)
39 }
40}
41
42pub const MIN_DURABLE_LEN: usize = 8;
46
47const CREDENTIAL_KEYWORDS: &[&str] = &[
53 "password",
54 "passwd",
55 "pwd",
56 "api_key",
57 "apikey",
58 "api-key",
59 "secret",
60 "access_key",
61 "access-key",
62 "accesskey",
63 "private_key",
64 "private-key",
65 "privatekey",
66 "client_secret",
67 "auth_token",
68 "authtoken",
69 "bearer",
70 "authorization:",
71 "token",
72 "credential",
73 "ssn",
74 "passphrase",
75];
76
77const VOLATILE_JUNK_PHRASES: &[&str] = &[
78 "ok",
79 "okay",
80 "done",
81 "loading",
82 "loading...",
83 "pong",
84 "ping",
85 "yes",
86 "no",
87 "thanks",
88 "thank you",
89 "hi",
90 "hello",
91 "test",
92 "testing",
93 "n/a",
94 "none",
95 "in progress",
96 "working on it",
97];
98
99pub fn screen(content: &str) -> ScreeningVerdict {
103 let trimmed = content.trim();
104 if trimmed.is_empty() {
105 return ScreeningVerdict::Reject {
106 reason: RejectReason::Empty,
107 };
108 }
109 if trimmed.len() < MIN_DURABLE_LEN {
110 return ScreeningVerdict::Reject {
111 reason: RejectReason::TooShort,
112 };
113 }
114 if looks_like_credential(trimmed) {
115 return ScreeningVerdict::Reject {
116 reason: RejectReason::Credential,
117 };
118 }
119 if is_volatile_junk(trimmed) {
120 return ScreeningVerdict::Reject {
121 reason: RejectReason::VolatileJunk,
122 };
123 }
124 ScreeningVerdict::Admit
125}
126
127fn looks_like_credential(trimmed: &str) -> bool {
128 let lower = trimmed.to_ascii_lowercase();
129
130 if lower.contains("-----begin ") && lower.contains("private key") {
131 return true;
132 }
133
134 if CREDENTIAL_KEYWORDS
135 .iter()
136 .any(|kw| keyword_near_assignment(&lower, kw))
137 {
138 return true;
139 }
140
141 if looks_like_jwt(trimmed) {
142 return true;
143 }
144
145 if !trimmed.contains(char::is_whitespace)
149 && trimmed.len() >= 24
150 && trimmed.len() <= 512
151 && looks_high_entropy(trimmed)
152 {
153 return true;
154 }
155
156 false
157}
158
159fn keyword_near_assignment(lower: &str, keyword: &str) -> bool {
165 let mut search_from = 0;
166 while let Some(rel) = lower[search_from..].find(keyword) {
167 let start = search_from + rel;
168 let after = &lower[start + keyword.len()..];
169 let after_trimmed = after.trim_start();
170 if let Some(rest) = after_trimmed
171 .strip_prefix(':')
172 .or_else(|| after_trimmed.strip_prefix('='))
173 {
174 let value = rest.trim();
175 if value.len() >= 3 {
176 return true;
177 }
178 }
179 search_from = start + keyword.len();
180 if search_from >= lower.len() {
181 break;
182 }
183 }
184 false
185}
186
187fn looks_like_jwt(trimmed: &str) -> bool {
190 let parts: Vec<&str> = trimmed.split('.').collect();
191 if parts.len() != 3 {
192 return false;
193 }
194 parts
195 .iter()
196 .all(|p| p.len() >= 8 && p.chars().all(is_base64url_char))
197}
198
199fn is_base64url_char(c: char) -> bool {
200 c.is_ascii_alphanumeric() || c == '-' || c == '_'
201}
202
203fn looks_high_entropy(token: &str) -> bool {
208 let has_lower = token.chars().any(|c| c.is_ascii_lowercase());
209 let has_upper = token.chars().any(|c| c.is_ascii_uppercase());
210 let has_digit = token.chars().any(|c| c.is_ascii_digit());
211 let class_count = [has_lower, has_upper, has_digit]
212 .iter()
213 .filter(|b| **b)
214 .count();
215 class_count >= 2
216}
217
218fn is_volatile_junk(trimmed: &str) -> bool {
219 let lower = trimmed.to_ascii_lowercase();
220 let normalized = lower.trim_end_matches(['.', '!', '?']).trim();
221 if VOLATILE_JUNK_PHRASES.contains(&normalized) {
222 return true;
223 }
224 if normalized.len() <= 32
227 && normalized.chars().enumerate().all(|(i, c)| {
228 c.is_ascii_digit() || matches!(c, '-' | ':' | 't' | 'z' | '.' | '+') || i == 0
229 })
230 && normalized.chars().any(|c| c.is_ascii_digit())
231 && normalized.chars().filter(|c| c.is_ascii_digit()).count() >= 8
232 && !normalized
233 .chars()
234 .any(|c| c.is_ascii_alphabetic() && !matches!(c, 't' | 'z'))
235 {
236 return true;
237 }
238 false
239}