zeph_common/secrets.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Canonical secret-token and path prefixes shared by redaction layers across crates.
5//!
6//! `zeph-core::redact` and `zeph-memory::store::compression_guidelines` both scrub
7//! secrets and filesystem paths before persisting or displaying untrusted text. Each
8//! crate previously carried its own hand-rolled copy of these lists, and the copies had
9//! already begun to drift from each other (see #5917). This module is the single source
10//! of truth for the raw prefixes/patterns; consumers compile their own `regex::Regex`
11//! instances from these constants — `zeph-common` does not depend on `regex` outside of
12//! tests, matching the pattern established by [`crate::patterns`].
13
14/// Prefixes of API keys, tokens, and other secret material recognized across Zeph.
15///
16/// Each entry is a literal prefix, not regex-escaped. Consumers building a regex
17/// alternation from this list must escape entries themselves (e.g. via `regex::escape`),
18/// since `ya29.` contains a literal `.` that must not be treated as "match any character".
19pub const SECRET_PREFIXES: &[&str] = &[
20 "sk-",
21 "sk_live_",
22 "sk_test_",
23 "AKIA",
24 "ghp_",
25 "gho_",
26 "-----BEGIN",
27 "xoxb-",
28 "xoxp-",
29 "AIza",
30 "ya29.",
31 "glpat-",
32 "hf_",
33 "npm_",
34 "dckr_pat_",
35];
36
37/// Absolute filesystem path prefixes redacted before persisting or displaying untrusted
38/// text, to avoid leaking local usernames or directory layout.
39pub const PATH_PREFIXES: &[&str] = &["/home/", "/Users/", "/root/", "/tmp/", "/var/"];
40
41/// Regex pattern matching `Authorization: Bearer <token>` headers.
42///
43/// Capture group 1 covers the header name up to and including the token's leading
44/// whitespace, so replacing with `"${1}[REDACTED]"` preserves the header name while
45/// redacting only the token value.
46pub const BEARER_TOKEN_PATTERN: &str = r"(?i)(Authorization:\s*Bearer\s+)\S+";
47
48/// Regex pattern matching standalone JWTs (three Base64url-encoded segments separated by
49/// dots).
50///
51/// The final segment uses `*` (not `+`) so it also matches `alg=none` JWTs, which carry an
52/// empty signature segment.
53pub const JWT_PATTERN: &str = r"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*";
54
55/// Regex pattern matching a full, properly-closed PEM/SSH2 private-key block, from the
56/// `-----BEGIN ... PRIVATE KEY-----`-style header through the matching footer, inclusive of
57/// the body between them (see #6592).
58///
59/// The root cause reported in #6592 is that no existing detector spanned a PEM key's
60/// multi-line body at all: the `-----BEGIN` entry in [`SECRET_PREFIXES`] only ever matches a
61/// literal prefix on a single line by construction, so it was never capable of covering the
62/// base64 body regardless of scrub ordering. Ordering still matters operationally, though:
63/// running the prefix-based scrub before this pattern would let it consume just the
64/// `-----BEGIN` token, preventing this pattern from ever matching the header again — which is
65/// why PEM scrubbing must run first in `zeph_sanitizer::secret_shape::scrub_secret_shapes`'s
66/// pipeline.
67///
68/// The `(?s)` flag lets `.` match newlines so the pattern spans the full multi-line body. The
69/// body is matched non-greedily and bounded to at most [`PEM_BODY_CAP`] characters
70/// (`.{0,8192}?`) — generous headroom over a real key (RSA-4096 PEM is ~3.2 KB; a bound large
71/// enough to also cover RSA-4096 was originally chosen at 65,536, but `regex` rejected that
72/// pattern with `CompiledTooBig` under its default 10 MB compiled-program size limit, so
73/// [`PEM_BODY_CAP`] is deliberately the largest power-of-two-ish bound confirmed to compile
74/// under that limit) — so that (a) consecutive PEM blocks in the same text are each redacted
75/// individually rather than swallowed into one match, and (b) a subagent cannot wrap arbitrary
76/// transcript content between forged `-----BEGIN`/`-----END` markers to make an unbounded
77/// amount of it vanish from a display surface. `regex` does not support backreferences, so the
78/// footer's label is not required to match the header's label — over-matching a mismatched
79/// pair is an acceptable tradeoff for a redaction scanner, since under-matching leaks key
80/// material. A header with no matching footer at all (truncated input, or a footer chunk
81/// dropped in a bounded channel) does not match this pattern — see
82/// [`PEM_PRIVATE_KEY_UNTERMINATED_PATTERN`], which must be applied afterward to still redact
83/// that case.
84///
85/// Keep the header/footer marker alternation (`-----BEGIN ... PRIVATE KEY(?: BLOCK)?-----` /
86/// the RFC 4716 `---- BEGIN SSH2 ENCRYPTED PRIVATE KEY ----` alternative, and the matching
87/// `END` forms) in sync with [`PEM_PRIVATE_KEY_UNTERMINATED_PATTERN`]'s header alternation —
88/// they must recognize exactly the same set of header markers.
89pub const PEM_PRIVATE_KEY_PATTERN: &str = r"(?s)(?:-----BEGIN (?:[A-Z]+ )?PRIVATE KEY(?: BLOCK)?-----|---- BEGIN SSH2 ENCRYPTED PRIVATE KEY ----).{0,8192}?(?:-----END (?:[A-Z]+ )?PRIVATE KEY(?: BLOCK)?-----|---- END SSH2 ENCRYPTED PRIVATE KEY ----)";
90
91/// Cap, in characters, on the PEM/SSH2 body matched by [`PEM_PRIVATE_KEY_PATTERN`] and
92/// [`PEM_PRIVATE_KEY_UNTERMINATED_PATTERN`]. Kept as a named constant so the value referenced
93/// in both patterns' doc comments (and in `zeph_subagent::forward`'s streaming holdback cap,
94/// which must buffer at least this many bytes past an unclosed header before force-flushing)
95/// stays traceable to one definition, even though the patterns themselves are plain `&str`
96/// literals (regex patterns can't be built from a `const usize` via string formatting at
97/// const-eval time without an extra dependency, so the literal `8192` is duplicated in both
98/// pattern strings — keep it in sync with this constant if it ever changes).
99///
100/// Known accepted tradeoff (#6592 follow-up, "M5"): a **properly terminated** block whose body
101/// exceeds this cap cannot be matched by [`PEM_PRIVATE_KEY_PATTERN`] (the footer lies past the
102/// non-greedy bound), so [`PEM_PRIVATE_KEY_UNTERMINATED_PATTERN`]'s fallback redacts only the
103/// first `PEM_BODY_CAP` characters of the body — the remaining tail, plus the now-orphaned
104/// `-----END...` footer text, is left unredacted. Real keys are comfortably under this cap
105/// (RSA-4096 PEM ≈ 3.2 KB), so the realistic risk is low, but this is a direct, deliberate
106/// tension with the cap's other purpose — bounding how much a forged/unterminated header can
107/// hide (see the primary pattern's doc comment, point (b), and the
108/// `adversarial_repeated_unterminated_pem_headers_are_bounded` test in `zeph-sanitizer`, which
109/// asserts over-cap content *must* stay visible for exactly the opposite reason). One bound
110/// cannot simultaneously guarantee "every terminated block, however large, is fully redacted"
111/// and "an unterminated/forged block can only ever hide a bounded amount of surrounding
112/// content" — this module chooses the anti-censorship guarantee and accepts the oversized-
113/// terminated-block gap as the cost, on the grounds that a real key exceeding this cap is a
114/// vanishingly rare shape to encounter versus a subagent hiding a large amount of legitimate
115/// transcript behind a forged pair of markers.
116pub const PEM_BODY_CAP: usize = 8192;
117
118/// Fallback regex matching a PEM/SSH2 private-key header with no matching footer found within
119/// [`PEM_BODY_CAP`] characters — a header that is truncated, adversarially left unterminated,
120/// or whose footer chunk was dropped by a bounded ingress channel (see #6592 follow-up).
121///
122/// Must be applied *after* [`PEM_PRIVATE_KEY_PATTERN`]'s replace pass, so that every properly
123/// closed block has already been consumed and only genuinely unterminated headers remain —
124/// otherwise this pattern's greedy, footer-agnostic match would swallow a following
125/// already-valid block's header too.
126///
127/// The body is constrained to characters that can actually occur in a PEM body — base64
128/// alphabet plus whitespace (`[A-Za-z0-9+/=\s]{0,8192}`) — rather than "any character"
129/// (`.{0,8192}`). An earlier version of this pattern used `.{0,8192}`, which meant *any* text
130/// following an unterminated header (e.g. `"...PRIVATE KEY----- in file /etc/ssl/key.pem and
131/// the deploy failed"`) was swallowed wholesale up to the cap, silently destroying up to 8 KB
132/// of unrelated legitimate content whenever a subagent merely *mentioned* a PEM header without
133/// including a body (see #6592 follow-up, "S3" — this was a real over-redaction regression,
134/// not hypothetical). Constraining the body to PEM-plausible characters makes the match stop
135/// at the first character that cannot appear in base64 (e.g. the first `.`, `,`, or other
136/// prose punctuation), which keeps genuinely truncated-key coverage while collapsing false-
137/// positive over-redaction on ordinary prose to near zero — plain English text almost always
138/// contains such a character within a few words.
139///
140/// Known narrow accepted gap (#6592 follow-up, "M7"): a *footerless* legacy encrypted PEM
141/// (`Proc-Type: 4,ENCRYPTED`) or PGP (`Version: GnuPG v2`) armor's header/comment line contains
142/// `:`, `,`, and other characters outside this class, so the match stops at that line and the
143/// base64 body after it is left unredacted in this specific truncated-input case. Terminated
144/// blocks of these same armor types are unaffected (they're covered by
145/// [`PEM_PRIVATE_KEY_PATTERN`], which has no character-class restriction on the body). Not
146/// fixed here — widening the class to cover armor-header-line punctuation would reopen most of
147/// the S3 over-redaction blast radius this pattern exists to close.
148pub const PEM_PRIVATE_KEY_UNTERMINATED_PATTERN: &str = r"(?:-----BEGIN (?:[A-Z]+ )?PRIVATE KEY(?: BLOCK)?-----|---- BEGIN SSH2 ENCRYPTED PRIVATE KEY ----)[A-Za-z0-9+/=\s]{0,8192}";
149
150/// Regex pattern matching a raw AWS secret access key or session token immediately preceded
151/// by a recognizable marker and its assignment separator (see #6592).
152///
153/// Unlike the `AKIA`-prefixed access key ID, an AWS secret access key (and session token) has
154/// no distinguishing prefix — it is just a base64-ish string, indistinguishable by shape alone
155/// from an ordinary hash or identifier. Flagging *every* base64-ish run of that length would
156/// produce excessive false positives, so this pattern only fires when the value is directly
157/// anchored to a recognizable marker name.
158///
159/// The marker alternation covers `aws_secret_access_key` / `aws_secret_key` /
160/// `secret_access_key` / `aws_session_token` / `session_token`, each tolerant of `_`, `-`,
161/// `.`, or a space as the inter-word separator (or none at all) — so the same alternation
162/// also matches the camelCase JSON key names AWS's own tooling emits verbatim
163/// (`SecretAccessKey`, `SessionToken`, e.g. from `aws configure export-credentials` or an STS
164/// `AssumeRole` response), not just underscore-joined config-file names, following the
165/// broader separator conventions gitleaks/trufflehog use for this rule class. An optional
166/// quote is tolerated both before the separator (closing a quoted JSON key) and around the
167/// value.
168///
169/// The value itself matches 40 or more base64-alphabet characters plus up to two `=` padding
170/// characters (`{40,}={0,2}`, not a fixed `{40}`) so a longer-than-standard value is redacted
171/// in full rather than leaking everything past the 40th character.
172///
173/// Capture group 1 covers the marker, separator, and optional opening quote; capture group 2
174/// covers an optional closing quote. Replacing with `"${1}[REDACTED]${2}"` preserves the
175/// marker and quoting while redacting only the secret value.
176pub const AWS_SECRET_KEY_PATTERN: &str = r#"(?i)((?:aws[_\-. ]?secret[_\-. ]?access[_\-. ]?key|aws[_\-. ]?secret[_\-. ]?key|secret[_\-. ]?access[_\-. ]?key|aws[_\-. ]?session[_\-. ]?token|session[_\-. ]?token)['"]?\s*[:=]\s*['"]?)[A-Za-z0-9+/]{40,}={0,2}(['"]?)"#;
177
178#[cfg(test)]
179mod tests {
180 use regex::Regex;
181
182 use super::*;
183
184 #[test]
185 fn bearer_pattern_compiles_and_matches() {
186 let re = Regex::new(BEARER_TOKEN_PATTERN).unwrap();
187 assert!(re.is_match("Authorization: Bearer abc.def.ghi"));
188 }
189
190 #[test]
191 fn jwt_pattern_compiles_and_matches() {
192 let re = Regex::new(JWT_PATTERN).unwrap();
193 assert!(re.is_match("eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.sig"));
194 }
195
196 #[test]
197 fn jwt_pattern_matches_alg_none_empty_signature() {
198 let re = Regex::new(JWT_PATTERN).unwrap();
199 assert!(re.is_match("eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0."));
200 }
201
202 #[test]
203 fn secret_prefixes_build_valid_escaped_regex_alternation() {
204 let pattern = SECRET_PREFIXES
205 .iter()
206 .map(|p| regex::escape(p))
207 .collect::<Vec<_>>()
208 .join("|");
209 let full = format!("(?:{pattern})[^\\s]*");
210 let re = Regex::new(&full).expect("alternation built from SECRET_PREFIXES must compile");
211 assert!(re.is_match("sk-abc123"));
212 assert!(re.is_match("ya29.a0AfH6"));
213 }
214
215 #[test]
216 fn path_prefixes_build_valid_regex_alternation() {
217 let pattern = PATH_PREFIXES.join("|");
218 let full = format!("(?:{pattern})[^\\s]*");
219 let re = Regex::new(&full).expect("alternation built from PATH_PREFIXES must compile");
220 assert!(re.is_match("/home/user/file"));
221 }
222
223 #[test]
224 fn pem_pattern_compiles_and_matches_plain_private_key() {
225 let re = Regex::new(PEM_PRIVATE_KEY_PATTERN).unwrap();
226 let pem =
227 "-----BEGIN PRIVATE KEY-----\nMIIBVQIBADANBgkqhkiG9w0B\n-----END PRIVATE KEY-----";
228 assert!(re.is_match(pem));
229 }
230
231 #[test]
232 fn pem_pattern_matches_common_label_variants() {
233 let re = Regex::new(PEM_PRIVATE_KEY_PATTERN).unwrap();
234 for label in ["RSA", "EC", "DSA", "OPENSSH", "ENCRYPTED"] {
235 let pem = format!(
236 "-----BEGIN {label} PRIVATE KEY-----\nbase64body\n-----END {label} PRIVATE KEY-----"
237 );
238 assert!(re.is_match(&pem), "failed for label: {label}");
239 }
240 }
241
242 #[test]
243 fn pem_pattern_matches_pgp_block_and_ssh2_rfc4716_variants() {
244 let re = Regex::new(PEM_PRIVATE_KEY_PATTERN).unwrap();
245 let pgp = "-----BEGIN PGP PRIVATE KEY BLOCK-----\nbase64body\n-----END PGP PRIVATE KEY BLOCK-----";
246 assert!(re.is_match(pgp), "failed for PGP PRIVATE KEY BLOCK");
247 let ssh2 = "---- BEGIN SSH2 ENCRYPTED PRIVATE KEY ----\nbase64body\n---- END SSH2 ENCRYPTED PRIVATE KEY ----";
248 assert!(re.is_match(ssh2), "failed for RFC 4716 SSH2 marker");
249 }
250
251 #[test]
252 fn pem_pattern_matches_mismatched_header_footer_labels() {
253 // Documented tradeoff: `regex` has no backreferences, so a footer whose label does
254 // not match the header's label still matches (over-matching, not under-matching).
255 let re = Regex::new(PEM_PRIVATE_KEY_PATTERN).unwrap();
256 let mismatched =
257 "-----BEGIN RSA PRIVATE KEY-----\nbase64body\n-----END EC PRIVATE KEY-----";
258 assert!(
259 re.is_match(mismatched),
260 "mismatched header/footer labels must still match (accepted over-match tradeoff)"
261 );
262 }
263
264 #[test]
265 fn pem_pattern_does_not_match_partial_markers() {
266 let re = Regex::new(PEM_PRIVATE_KEY_PATTERN).unwrap();
267 assert!(!re.is_match("this text mentions BEGIN and PRIVATE but no PEM markers"));
268 assert!(!re.is_match("-----BEGIN PRIVATE KEY----- with no matching end marker"));
269 }
270
271 #[test]
272 fn pem_unterminated_pattern_matches_footerless_header() {
273 let re = Regex::new(PEM_PRIVATE_KEY_UNTERMINATED_PATTERN).unwrap();
274 assert!(re.is_match("-----BEGIN RSA PRIVATE KEY-----\nbase64body with no end marker"));
275 assert!(!re.is_match("this text mentions BEGIN and PRIVATE but no PEM markers"));
276 }
277
278 #[test]
279 fn pem_unterminated_pattern_body_is_bounded() {
280 // Adversarial input: a header that never closes, with a body far larger than the
281 // pattern's cap. The match must not extend past the bound (M3 / censorship-vector
282 // guard) — asserted by checking the matched span length rather than just "matches".
283 let re = Regex::new(PEM_PRIVATE_KEY_UNTERMINATED_PATTERN).unwrap();
284 let huge_body = "A".repeat(200_000);
285 let text = format!("-----BEGIN RSA PRIVATE KEY-----\n{huge_body}");
286 let m = re
287 .find(&text)
288 .expect("unterminated header must still match");
289 let header_len = "-----BEGIN RSA PRIVATE KEY-----".len();
290 assert!(
291 m.len() <= header_len + PEM_BODY_CAP,
292 "match length {} exceeds header + {PEM_BODY_CAP}-char body cap",
293 m.len()
294 );
295 }
296
297 #[test]
298 fn pem_unterminated_pattern_handles_repeated_begin_with_no_end() {
299 // Adversarial input: multiple unterminated headers in sequence, none ever closed.
300 let re = Regex::new(PEM_PRIVATE_KEY_UNTERMINATED_PATTERN).unwrap();
301 let text = "-----BEGIN RSA PRIVATE KEY-----\nfirst\n-----BEGIN EC PRIVATE KEY-----\nsecond";
302 assert!(re.is_match(text));
303 }
304
305 #[test]
306 fn pem_unterminated_pattern_stops_at_first_non_pem_character() {
307 // S3 regression guard: the fallback body is constrained to PEM-plausible characters
308 // (base64 alphabet + whitespace), so it must stop matching at the first character
309 // that cannot occur in a PEM body (e.g. a period) rather than swallowing an entire
310 // sentence of ordinary prose up to the 8192-char cap.
311 let re = Regex::new(PEM_PRIVATE_KEY_UNTERMINATED_PATTERN).unwrap();
312 let text =
313 "Found -----BEGIN RSA PRIVATE KEY----- in file /etc/ssl/key.pem and the deploy failed";
314 let m = re.find(text).expect("header must still match");
315 assert!(
316 !m.as_str().contains("and the deploy failed"),
317 "match must stop well before swallowing unrelated trailing prose: {:?}",
318 m.as_str()
319 );
320 assert!(
321 text[m.end()..].contains("and the deploy failed"),
322 "trailing prose must remain outside the match, available for the caller to keep: {:?}",
323 &text[m.end()..]
324 );
325 }
326
327 #[test]
328 fn aws_secret_pattern_compiles_and_matches_marker_anchored_value() {
329 let re = Regex::new(AWS_SECRET_KEY_PATTERN).unwrap();
330 assert!(re.is_match("aws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"));
331 }
332
333 #[test]
334 fn aws_secret_pattern_matches_camelcase_json_key_form() {
335 // S2: the canonical STS/`aws configure export-credentials`/SDK JSON key form.
336 let re = Regex::new(AWS_SECRET_KEY_PATTERN).unwrap();
337 assert!(re.is_match(r#""SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY""#));
338 assert!(re.is_match(r#""SessionToken": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY""#));
339 }
340
341 #[test]
342 fn aws_secret_pattern_matches_alternate_separator_forms() {
343 // S2: gitleaks/trufflehog-style separator flexibility beyond a literal underscore.
344 let re = Regex::new(AWS_SECRET_KEY_PATTERN).unwrap();
345 assert!(re.is_match("aws-secret-key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"));
346 assert!(re.is_match("aws.secret.key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"));
347 assert!(re.is_match("aws secret key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"));
348 assert!(re.is_match("aws_session_token=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"));
349 }
350
351 #[test]
352 fn aws_secret_pattern_redacts_full_longer_than_standard_value() {
353 // M1: `{40,}` must not truncate the match at 40 chars for a longer value.
354 let re = Regex::new(AWS_SECRET_KEY_PATTERN).unwrap();
355 let long_value = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEYEXTRA1234"; // 50 chars
356 let text = format!("aws_secret_access_key={long_value}");
357 let m = re.find(&text).expect("must match");
358 assert!(
359 m.as_str().ends_with(long_value),
360 "match must cover the full value, not just the first 40 chars: {}",
361 m.as_str()
362 );
363 }
364
365 #[test]
366 fn aws_secret_pattern_ignores_unanchored_high_entropy_string() {
367 let re = Regex::new(AWS_SECRET_KEY_PATTERN).unwrap();
368 // Same shape (40-char base64-ish run) but no marker precedes it.
369 assert!(!re.is_match("commit sha or hash: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"));
370 }
371
372 #[test]
373 fn aws_secret_pattern_ignores_marker_like_identifier_suffix() {
374 // Explicitly confirmed non-finding: the marker must not fire on an identifier that
375 // merely starts with a marker name followed by more identifier characters.
376 let re = Regex::new(AWS_SECRET_KEY_PATTERN).unwrap();
377 assert!(!re.is_match("let aws_secret_access_key_length = 40"));
378 }
379}