Skip to main content

layover_core/help/
redact.rs

1//! Taking credentials out of text an agent wrote.
2//!
3//! # Why this exists
4//!
5//! A help request is where an agent explains why it could not do something, and the most common
6//! reason by a wide margin is that a credential did not work. So the field most likely to contain
7//! a secret is the one whose entire purpose is to describe a failed authentication — and unlike a
8//! transcript, it is persisted as JSON Lines, served over an unauthenticated HTTP API, and
9//! rendered in a dashboard.
10//!
11//! The agent is not being careless when it pastes the token it tried. It is being helpful.
12//!
13//! # What this is not
14//!
15//! This is not a guarantee, and nothing downstream should treat it as one. Secrets have no
16//! grammar, and a redactor that claimed to catch all of them would be worse than none, because it
17//! would license writing them down. What it does is catch the shapes that are cheap to recognise
18//! and common enough to be worth the occasional false positive.
19//!
20//! The durable protection is that credentials are named in `env_from` and read from the
21//! environment, never written into config. This is the second line, not the first.
22
23use std::borrow::Cow;
24
25/// Longest a detail may be once redacted.
26///
27/// A help request is a summary for a human deciding what to do, not a log. Something arbitrarily
28/// long is both a weight on the journal and a sign that a transcript was pasted in — which is
29/// exactly where an unredacted secret would be hiding.
30pub const MAX_DETAIL: usize = 4_000;
31
32/// What replaces a redacted run.
33const MASK: &str = "[redacted]";
34
35/// Rewrites `text` with anything credential-shaped masked.
36///
37/// Borrows the original when nothing matched, which is the overwhelmingly common case.
38#[must_use]
39pub fn secrets(text: &str) -> Cow<'_, str> {
40    let mut out = String::new();
41    let mut rest = text;
42    let mut changed = false;
43
44    while let Some((start, len)) = find_secret(rest) {
45        changed = true;
46        out.push_str(&rest[..start]);
47        out.push_str(MASK);
48        rest = &rest[start + len..];
49    }
50
51    if !changed {
52        return Cow::Borrowed(text);
53    }
54
55    out.push_str(rest);
56    Cow::Owned(out)
57}
58
59/// Caps `text` at [`MAX_DETAIL`], on a character boundary, saying that it did.
60#[must_use]
61pub fn clamp(text: &str) -> Cow<'_, str> {
62    if text.len() <= MAX_DETAIL {
63        return Cow::Borrowed(text);
64    }
65
66    let mut end = MAX_DETAIL;
67    while end > 0 && !text.is_char_boundary(end) {
68        end -= 1;
69    }
70
71    Cow::Owned(format!("{}\n\n[truncated]", &text[..end]))
72}
73
74/// Redacts, then caps. The order matters: cutting first could halve a token and leave the front
75/// of it sitting in the text looking like prose.
76#[must_use]
77pub fn detail(text: &str) -> String {
78    clamp(&secrets(text)).into_owned()
79}
80
81/// Offset and length of the next credential-shaped run.
82fn find_secret(text: &str) -> Option<(usize, usize)> {
83    let bytes = text.as_bytes();
84
85    for (index, _) in text.char_indices() {
86        // Only consider the start of a run, so a token is examined once rather than per character.
87        if index > 0 && is_token_byte(bytes[index - 1]) {
88            continue;
89        }
90
91        let run = token_run(text, index);
92        if run == 0 {
93            continue;
94        }
95
96        // Assignment first. `=` is a token byte because base64 pads with it, which means
97        // `NAME=value` scans as a single run — and masking that whole run would take the name
98        // with it. Knowing *which* credential failed is the entire value of the report.
99        if let Some(found) = assignment(text, index) {
100            return Some(found);
101        }
102
103        if looks_like_secret(&text[index..index + run]) {
104            return Some((index, run));
105        }
106    }
107
108    None
109}
110
111/// Length of the run of characters that can make up a variable name.
112///
113/// Narrower than [`token_run`] on purpose: a name stops at the `=` or `:` that follows it.
114fn name_run(text: &str, start: usize) -> usize {
115    text[start..]
116        .bytes()
117        .take_while(|b| b.is_ascii_alphanumeric() || *b == b'_')
118        .count()
119}
120
121fn token_run(text: &str, start: usize) -> usize {
122    text[start..]
123        .bytes()
124        .take_while(|b| is_token_byte(*b))
125        .count()
126}
127
128fn is_token_byte(b: u8) -> bool {
129    b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b'+' | b'/' | b'=' | b'~')
130}
131
132/// Whether a bare token is credential-shaped on its own.
133fn looks_like_secret(token: &str) -> bool {
134    const PREFIXES: [&str; 10] = [
135        "sk-",
136        "pk-",
137        "ghp_",
138        "gho_",
139        "ghu_",
140        "ghs_",
141        "ghr_",
142        "github_pat_",
143        "xoxb-",
144        "xoxp-",
145    ];
146    if token.len() > 12 && PREFIXES.iter().any(|p| token.starts_with(p)) {
147        return true;
148    }
149
150    // A JWT: three base64url segments, the first announcing a JSON header.
151    if token.starts_with("eyJ") && token.matches('.').count() == 2 {
152        return true;
153    }
154
155    // A long high-entropy run. Conservative on purpose: prose does not produce thirty-two
156    // character runs mixing cases and digits, and paths are excluded by the separator check.
157    token.len() >= 32 && !token.contains('/') && mixed_enough(token)
158}
159
160/// Whether a run has the character mix of a key rather than of a word.
161fn mixed_enough(token: &str) -> bool {
162    let digits = token.bytes().filter(u8::is_ascii_digit).count();
163    let upper = token.bytes().filter(u8::is_ascii_uppercase).count();
164    let lower = token.bytes().filter(u8::is_ascii_lowercase).count();
165
166    // A hex digest has only digits and one case, so it would fail the three-class test that
167    // catches everything else. A commit hash masked in error is a fair price; the reverse
168    // mistake cannot be undone once it is on disk.
169    if digits >= 8 && token.bytes().all(|b| b.is_ascii_hexdigit()) {
170        return true;
171    }
172
173    digits > 0 && upper > 0 && lower > 0
174}
175
176/// Matches `NAME=value` / `NAME: value` where `NAME` names a credential, returning the span of
177/// the **value** alone.
178fn assignment(text: &str, start: usize) -> Option<(usize, usize)> {
179    let name_len = name_run(text, start);
180    if name_len == 0 || !names_a_secret(&text[start..start + name_len]) {
181        return None;
182    }
183
184    let bytes = text.as_bytes();
185    let mut at = start + name_len;
186
187    match bytes.get(at) {
188        Some(b'=' | b':') => at += 1,
189        _ => return None,
190    }
191
192    while bytes.get(at) == Some(&b' ') {
193        at += 1;
194    }
195
196    let len = token_run(text, at);
197    if len == 0 { None } else { Some((at, len)) }
198}
199
200/// Whether a variable name announces that it holds a credential.
201fn names_a_secret(name: &str) -> bool {
202    const MARKERS: [&str; 11] = [
203        "TOKEN",
204        "SECRET",
205        "PASSWORD",
206        "PASSWD",
207        "APIKEY",
208        "API_KEY",
209        "_PAT",
210        "CREDENTIAL",
211        "PRIVATE_KEY",
212        "ACCESS_KEY",
213        "AUTHORIZATION",
214    ];
215
216    let upper = name.to_ascii_uppercase();
217    MARKERS.iter().any(|marker| upper.contains(marker))
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn ordinary_prose_is_left_alone_and_not_reallocated() {
226        let text = "Push to refs/heads/fix/1543477 returned 401. The token expires after 30 days.";
227        assert!(matches!(secrets(text), Cow::Borrowed(_)), "{text}");
228    }
229
230    #[test]
231    fn a_github_token_is_masked_but_the_sentence_survives() {
232        let clean = secrets("tried ghp_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8 and got 401");
233
234        assert!(!clean.contains("ghp_A1b2"), "{clean}");
235        assert!(
236            clean.contains("got 401"),
237            "the useful part must survive: {clean}"
238        );
239    }
240
241    #[test]
242    fn an_openai_style_key_is_masked() {
243        let clean = secrets("key sk-proj-abcdefghijklmnopqrstuvwxyz0123456789 rejected");
244        assert!(!clean.contains("sk-proj-abcdef"), "{clean}");
245    }
246
247    #[test]
248    fn a_jwt_is_masked() {
249        let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk";
250        let text = format!("token {jwt} expired");
251        let clean = secrets(&text);
252
253        assert!(!clean.contains("eyJhbGciOi"), "{clean}");
254        assert!(clean.contains("expired"), "{clean}");
255    }
256
257    #[test]
258    fn the_variable_name_survives_because_it_is_the_useful_part() {
259        // Which credential failed *is* the report. Masking the name would leave a request saying
260        // only that something, somewhere, was wrong.
261        let clean = secrets("ADO_PAT=zq8Vv2Lm4Kp7Rt1Ns5Wx9Yb3Cd6Ef0Gh became invalid");
262
263        assert!(clean.starts_with("ADO_PAT="), "{clean}");
264        assert!(!clean.contains("zq8Vv2Lm"), "{clean}");
265        assert!(clean.contains("became invalid"), "{clean}");
266    }
267
268    #[test]
269    fn a_named_secret_is_masked_after_a_colon_too() {
270        let clean = secrets("AZURE_CLIENT_SECRET: Abcd1234Efgh5678Ijkl");
271        assert!(!clean.contains("Abcd1234"), "{clean}");
272        assert!(clean.starts_with("AZURE_CLIENT_SECRET:"), "{clean}");
273    }
274
275    #[test]
276    fn a_digest_is_masked_because_it_could_equally_be_a_key() {
277        let clean = secrets("at 5f2e9c4b8a1d3e7f0c6b2a9d8e4f1c3b5a7d9e0f");
278        assert!(clean.contains(MASK), "{clean}");
279    }
280
281    #[test]
282    fn paths_and_urls_are_not_mistaken_for_keys() {
283        for text in [
284            "could not read prompts/analyst.md",
285            "GET https://dev.azure.com/org/_apis/git/repositories returned 403",
286            "the workspace is at /home/ada/work/layover-project",
287        ] {
288            assert!(
289                matches!(secrets(text), Cow::Borrowed(_)),
290                "should not have matched: {text}"
291            );
292        }
293    }
294
295    #[test]
296    fn several_secrets_in_one_message_are_all_masked() {
297        let clean = secrets(
298            "tried ghp_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8 then sk-abcdefghijklmnopqrstuvwxyz012345",
299        );
300        assert_eq!(clean.matches(MASK).count(), 2, "{clean}");
301    }
302
303    #[test]
304    fn an_over_long_detail_is_cut_and_says_so() {
305        let long = "a".repeat(MAX_DETAIL + 500);
306        let capped = clamp(&long);
307
308        assert!(capped.len() < long.len());
309        assert!(
310            capped.ends_with("[truncated]"),
311            "a cut report has to admit it was cut"
312        );
313    }
314
315    #[test]
316    fn redaction_happens_before_the_cut() {
317        let secret = "ghp_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8";
318        let text = format!("{}{secret}", "x".repeat(MAX_DETAIL - 10));
319
320        assert!(
321            !detail(&text).contains("ghp_A1b2"),
322            "the token survived being cut in half"
323        );
324    }
325}