Skip to main content

magi/
scrub.rs

1//! Last-line scrub for text that lands on GitHub.
2//!
3//! The prompts tell agents not to put machine- or operator-identifying data
4//! (hostnames, account names, IPs, home paths, emails, tokens) into pull
5//! requests and issues, but a prompt is advisory. [`scrub`] is the enforced
6//! half: shared rules used by the GitHub text posting gate.
7//!
8//! It scans the input left to right exactly once and pushes into a *separate*
9//! output string, so a replacement is never scanned again (compare the
10//! `blind::redact` loop, whose `[REDACTED]` contains the letters of the words it
11//! hunted). It prefers missing something to mangling ordinary prose: repo-
12//! relative paths, URLs, `std::io::Error`, `v1.2.3` and `@handle` all pass.
13
14/// The local facts worth hiding. `Default` is "nothing known", which leaves only
15/// the pattern-based rules.
16#[derive(Debug, Clone, Default)]
17pub struct Identity {
18    /// Local account name.
19    pub user: String,
20    /// Machine hostname.
21    pub host: String,
22    /// Home directory path.
23    pub home: String,
24}
25
26impl Identity {
27    /// Read the current account, host and home directory. The only I/O here.
28    pub fn current() -> Self {
29        let env = |k: &str| std::env::var(k).ok().filter(|v| !v.trim().is_empty());
30        let host = env("HOSTNAME")
31            .or_else(|| env("COMPUTERNAME"))
32            .or_else(|| {
33                std::fs::read_to_string("/etc/hostname")
34                    .ok()
35                    .map(|s| s.trim().to_owned())
36                    .filter(|s| !s.is_empty())
37            })
38            .unwrap_or_default();
39        Self {
40            user: env("USER").or_else(|| env("USERNAME")).unwrap_or_default(),
41            host,
42            home: dirs::home_dir()
43                .map(|p| p.to_string_lossy().into_owned())
44                .unwrap_or_default(),
45        }
46    }
47}
48
49const TOKEN_PREFIXES: [&str; 9] = [
50    "AKIA",
51    "github_pat_",
52    "ghp_",
53    "gho_",
54    "ghs_",
55    "ghu_",
56    "sk-",
57    "xoxb-",
58    "xoxp-",
59];
60const TOKEN_MIN_TAIL: usize = 16;
61/// Identity words shorter than this are too likely to be ordinary prose.
62const MIN_IDENTITY_WORD: usize = 3;
63
64fn is_word(c: char) -> bool {
65    c.is_ascii_alphanumeric() || c == '_' || c == '-'
66}
67
68fn is_name(c: char) -> bool {
69    c.is_alphanumeric() || matches!(c, '_' | '-' | '.')
70}
71
72fn is_email_local(c: char) -> bool {
73    c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '%' | '+' | '-')
74}
75
76/// A path component: the name run minus trailing dots, which are punctuation.
77fn name_len(s: &str) -> usize {
78    s[..run(s, is_name)].trim_end_matches('.').len()
79}
80
81/// Length in bytes of the leading run of `s` whose chars satisfy `f`.
82fn run(s: &str, f: impl Fn(char) -> bool) -> usize {
83    s.char_indices()
84        .find(|&(_, c)| !f(c))
85        .map_or(s.len(), |(i, _)| i)
86}
87
88/// Replace machine- and operator-identifying data in `text`. Pure.
89pub fn scrub(text: &str, id: &Identity) -> String {
90    let mut out = String::with_capacity(text.len());
91    let mut i = 0;
92    while i < text.len() {
93        let prev = text[..i].chars().next_back();
94        if let Some((len, rep)) = match_at(&text[i..], prev, id) {
95            out.push_str(rep);
96            i += len;
97        } else {
98            let c = text[i..].chars().next().expect("i is on a char boundary");
99            out.push(c);
100            i += c.len_utf8();
101        }
102    }
103    out
104}
105
106/// Where [`scrub`] would replace something: `(line, kind)` per match, lines
107/// counted from 1. Never carries the matched text. Pure.
108pub fn locate(text: &str, id: &Identity) -> Vec<(usize, &'static str)> {
109    let mut hits = Vec::new();
110    let mut i = 0;
111    let mut line = 1;
112    while i < text.len() {
113        let prev = text[..i].chars().next_back();
114        if let Some((len, rep)) = match_at(&text[i..], prev, id) {
115            let kind = match rep {
116                "~" => "home-path",
117                r => r
118                    .trim_start_matches('[')
119                    .trim_end_matches(']')
120                    .trim_start_matches("redacted-"),
121            };
122            hits.push((line, kind));
123            line += text[i..i + len].matches('\n').count();
124            i += len;
125        } else {
126            let c = text[i..].chars().next().expect("i is on a char boundary");
127            if c == '\n' {
128                line += 1;
129            }
130            i += c.len_utf8();
131        }
132    }
133    hits
134}
135
136fn match_at(rest: &str, prev: Option<char>, id: &Identity) -> Option<(usize, &'static str)> {
137    let starts_word = prev.is_none_or(|p| !is_word(p));
138    home_path(rest, prev, id)
139        .or_else(|| {
140            starts_word
141                .then(|| {
142                    token(rest).or_else(|| credential(rest)).or_else(|| {
143                        prev.is_none_or(|c| !is_name(c))
144                            .then(|| named_identity(rest))
145                            .flatten()
146                    })
147                })
148                .flatten()
149        })
150        .or_else(|| absolute_path(rest, prev))
151        .or_else(|| {
152            prev.is_none_or(|p| !is_email_local(p))
153                .then(|| email(rest))
154                .flatten()
155        })
156        .or_else(|| {
157            prev.is_none_or(|p| !p.is_ascii_alphanumeric() && p != '.' && p != ':')
158                .then(|| ipv4(rest).or_else(|| ipv6(rest)))
159                .flatten()
160        })
161        .or_else(|| starts_word.then(|| identity_word(rest, id)).flatten())
162}
163
164/// `/Users/x`, `/home/x`, `/root`, `C:\Users\x`, `C:/Users/x` and the literal
165/// home directory, each collapsed to `~` so the repo-relative tail survives.
166fn home_path(rest: &str, prev: Option<char>, id: &Identity) -> Option<(usize, &'static str)> {
167    if prev.is_some_and(|p| p.is_ascii_alphanumeric() || matches!(p, '.' | '_' | '-' | '~')) {
168        return None;
169    }
170    if id.home.len() > 1 && rest.starts_with(id.home.as_str()) {
171        let tail = &rest[id.home.len()..];
172        if tail.chars().next().is_none_or(|c| !is_name(c)) {
173            return Some((id.home.len(), "~"));
174        }
175    }
176    for base in ["/Users/", "/home/"] {
177        if let Some(tail) = rest.strip_prefix(base) {
178            let n = name_len(tail);
179            if n > 0 {
180                return Some((base.len() + n, "~"));
181            }
182        }
183    }
184    if let Some(tail) = rest.strip_prefix("/root")
185        && tail.chars().next().is_none_or(|c| !is_word(c))
186    {
187        return Some(("/root".len(), "~"));
188    }
189    let b = rest.as_bytes();
190    if b.len() > 9
191        && b[0].is_ascii_alphabetic()
192        && b[1] == b':'
193        && matches!(b[2], b'\\' | b'/')
194        && b[3..8].eq_ignore_ascii_case(b"users")
195        && matches!(b[8], b'\\' | b'/')
196    {
197        let n = name_len(&rest[9..]);
198        if n > 0 {
199            return Some((9 + n, "~"));
200        }
201    }
202    None
203}
204
205fn token(rest: &str) -> Option<(usize, &'static str)> {
206    let prefix = TOKEN_PREFIXES.iter().find(|p| rest.starts_with(**p))?;
207    let tail = run(&rest[prefix.len()..], is_word);
208    (tail >= TOKEN_MIN_TAIL).then_some((prefix.len() + tail, "[redacted-token]"))
209}
210
211/// Explicit assignments avoid guessing whether an ordinary word is an account.
212fn named_identity(rest: &str) -> Option<(usize, &'static str)> {
213    for key in [
214        "hostname=",
215        "hostname: ",
216        "username=",
217        "username: ",
218        "user=",
219        "host=",
220    ] {
221        if rest
222            .get(..key.len())
223            .is_some_and(|prefix| prefix.eq_ignore_ascii_case(key))
224        {
225            let n = run(&rest[key.len()..], is_name);
226            if n > 0 {
227                return Some((key.len() + n, "[redacted-identity]"));
228            }
229        }
230    }
231    let n = name_len(rest);
232    if n > 0
233        && [".local", ".internal", ".lan"].iter().any(|suffix| {
234            n.checked_sub(suffix.len())
235                .and_then(|start| rest.get(start..n))
236                .is_some_and(|tail| tail.eq_ignore_ascii_case(suffix))
237        })
238    {
239        return Some((n, "[redacted-host]"));
240    }
241    None
242}
243
244/// `"password": "value"` and `'token':'value'`: the key is quoted, so the
245/// unquoted `key=` / `key: ` forms below never match.
246fn quoted_credential(rest: &str) -> Option<(usize, &'static str)> {
247    for key in [
248        "password", "token", "api_key", "api-key", "apikey", "secret",
249    ] {
250        let Some(after) = rest
251            .get(..key.len())
252            .filter(|p| p.eq_ignore_ascii_case(key))
253            .map(|_| &rest[key.len()..])
254        else {
255            continue;
256        };
257        let Some(after_quote) = after.strip_prefix(['"', '\'']) else {
258            continue;
259        };
260        let t = after_quote.trim_start();
261        let Some(t) = t.strip_prefix([':', '=']) else {
262            continue;
263        };
264        let t = t.trim_start();
265        // A quoted value runs to its closing quote and may hold spaces or commas.
266        let n = match t.chars().next() {
267            Some(q @ ('"' | '\'')) => {
268                let inner = &t[1..];
269                let end = quoted_end(inner, q).unwrap_or(inner.len());
270                1 + end + usize::from(end < inner.len())
271            }
272            _ => run(t, |c| {
273                !c.is_whitespace() && !matches!(c, '`' | '<' | '>' | ',' | '}')
274            }),
275        };
276        let value = t;
277        if n > 0 {
278            return Some((rest.len() - value.len() + n, "[redacted-token]"));
279        }
280    }
281    None
282}
283
284/// Byte offset of the first `q` in `inner` that no backslash escapes.
285fn quoted_end(inner: &str, q: char) -> Option<usize> {
286    let mut chars = inner.char_indices();
287    while let Some((i, c)) = chars.next() {
288        if c == '\\' {
289            chars.next();
290        } else if c == q {
291            return Some(i);
292        }
293    }
294    None
295}
296
297fn credential(rest: &str) -> Option<(usize, &'static str)> {
298    if let Some(hit) = quoted_credential(rest) {
299        return Some(hit);
300    }
301    for key in [
302        "password=",
303        "token=",
304        "api_key=",
305        "api-key=",
306        "password: ",
307        "token: ",
308        "api_key: ",
309        "bearer ",
310        "password ",
311        "token ",
312        "api key ",
313    ] {
314        if rest
315            .get(..key.len())
316            .is_some_and(|prefix| prefix.eq_ignore_ascii_case(key))
317        {
318            let tail = &rest[key.len()..];
319            let padding = tail.len() - tail.trim_start_matches([' ', '\'', '"']).len();
320            let value = &tail[padding..];
321            let n = run(value, |c| {
322                !c.is_whitespace() && !matches!(c, '`' | '<' | '>' | '"' | '\'')
323            });
324            let contextual = matches!(key, "password " | "token " | "api key ");
325            let entropy = n >= 20
326                && value[..n].bytes().any(|b| b.is_ascii_digit())
327                && value[..n].bytes().any(|b| b.is_ascii_alphabetic());
328            if n > 0 && (!contextual || entropy) {
329                return Some((key.len() + padding + n, "[redacted-token]"));
330            }
331        }
332    }
333    None
334}
335
336fn absolute_path(rest: &str, prev: Option<char>) -> Option<(usize, &'static str)> {
337    if prev.is_some_and(|c| c.is_alphanumeric() || matches!(c, '/' | ':' | '.' | '~')) {
338        return None;
339    }
340    let b = rest.as_bytes();
341    let windows =
342        b.len() > 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && matches!(b[2], b'/' | b'\\');
343    // Known filesystem roots, rather than arbitrary slash-prefixed API routes.
344    let unix = [
345        "/tmp/",
346        "/private/",
347        "/var/",
348        "/etc/",
349        "/opt/",
350        "/srv/",
351        "/usr/",
352        "/mnt/",
353        "/Volumes/",
354    ]
355    .iter()
356    .any(|root| rest.starts_with(root));
357    if windows || unix {
358        let n = run(rest, |c| {
359            !c.is_whitespace() && !matches!(c, '`' | '"' | '\'' | '<' | '>')
360        });
361        return Some((n, "[redacted-path]"));
362    }
363    None
364}
365
366fn email(rest: &str) -> Option<(usize, &'static str)> {
367    let local = run(rest, is_email_local);
368    if local == 0 || !rest[local..].starts_with('@') {
369        return None;
370    }
371    let domain = &rest[local + 1..];
372    let mut end = 0;
373    let mut labels = 0;
374    loop {
375        let n = run(&domain[end..], |c| c.is_ascii_alphanumeric() || c == '-');
376        if n == 0 {
377            break;
378        }
379        end += n;
380        labels += 1;
381        if domain[end..].starts_with('.')
382            && run(&domain[end + 1..], |c| c.is_ascii_alphanumeric()) > 0
383        {
384            end += 1;
385        } else {
386            break;
387        }
388    }
389    (labels >= 2).then_some((local + 1 + end, "[redacted-email]"))
390}
391
392fn ipv4(rest: &str) -> Option<(usize, &'static str)> {
393    let mut len = 0;
394    for part in 0..4 {
395        let s = &rest[len..];
396        let n = run(s, |c| c.is_ascii_digit());
397        if n == 0 || n > 3 || s[..n].parse::<u16>().ok()? > 255 {
398            return None;
399        }
400        len += n;
401        if part < 3 {
402            if !rest[len..].starts_with('.') {
403                return None;
404            }
405            len += 1;
406        }
407    }
408    let after = &rest[len..];
409    let mut chars = after.chars();
410    match chars.next() {
411        Some(c) if c.is_ascii_alphanumeric() => return None,
412        Some('.') if chars.next().is_some_and(|c| c.is_ascii_digit()) => return None,
413        _ => {}
414    }
415    Some((len, "[redacted-ip]"))
416}
417
418fn ipv6(rest: &str) -> Option<(usize, &'static str)> {
419    let mut n = run(rest, |c| c.is_ascii_hexdigit() || c == ':');
420    // A trailing single colon is punctuation, not part of the address.
421    while n > 0 && rest[..n].ends_with(':') && !rest[..n].ends_with("::") {
422        n -= 1;
423    }
424    let cand = &rest[..n];
425    let colons = cand.matches(':').count();
426    let shaped = (cand.contains("::") && colons >= 2) || colons == 7;
427    if !shaped
428        || !cand.bytes().any(|b| b.is_ascii_digit())
429        || cand.split(':').any(|g| g.len() > 4)
430        || rest[n..]
431            .chars()
432            .next()
433            .is_some_and(|c| c.is_ascii_alphanumeric())
434    {
435        return None;
436    }
437    Some((n, "[redacted-ip]"))
438}
439
440fn identity_word(rest: &str, id: &Identity) -> Option<(usize, &'static str)> {
441    for (word, rep) in [(&id.user, "[redacted-user]"), (&id.host, "[redacted-host]")] {
442        let w = word.trim();
443        if w.len() < MIN_IDENTITY_WORD || rest.len() < w.len() || !rest.is_char_boundary(w.len()) {
444            continue;
445        }
446        if rest[..w.len()].eq_ignore_ascii_case(w)
447            && rest[w.len()..].chars().next().is_none_or(|c| !is_word(c))
448        {
449            return Some((w.len(), rep));
450        }
451    }
452    None
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    fn id() -> Identity {
460        Identity {
461            user: "alice".into(),
462            host: "buildbox".into(),
463            home: "/srv/people/alice".into(),
464        }
465    }
466
467    fn s(t: &str) -> String {
468        scrub(t, &id())
469    }
470
471    #[test]
472    fn locate_names_kind_and_line_without_the_text() {
473        let id = Identity::default();
474        let text = "fine\ntoken=abcdefghijklmnop1234 here\nmail a@b.example";
475        assert_eq!(locate(text, &id), vec![(2, "token"), (3, "email")]);
476    }
477
478    #[test]
479    fn quoted_credentials_honor_escaped_quotes() {
480        let out = s(r#"{"password":"prefix\"hunter2"} tail"#);
481        assert!(!out.contains("hunter2") && !out.contains("prefix"), "{out}");
482        assert!(out.ends_with("} tail"), "{out}");
483        // An even run of backslashes leaves the quote as the closer.
484        let out = s(r#"{"password":"a\\\\"} tail"#);
485        assert!(out.ends_with("} tail") && !out.contains("a\\"), "{out}");
486        // An odd run escapes it.
487        let out = s(r#"{"password":"a\\\"hunter2"} tail"#);
488        assert!(!out.contains("hunter2"), "{out}");
489        let out = s(r#"{'token':'x\'hunter2'} tail"#);
490        assert!(!out.contains("hunter2"), "{out}");
491        let out = s(r#"{"password":"日本\"語hunter2"} tail"#);
492        assert!(!out.contains("hunter2") && !out.contains('語'), "{out}");
493        // No closing quote: everything after is removed.
494        let out = s(r#"{"password":"abc\"hunter2"#);
495        assert!(!out.contains("hunter2"), "{out}");
496    }
497
498    #[test]
499    fn home_paths_collapse_and_keep_the_tail() {
500        assert_eq!(s("see /Users/bob/src/x.rs now"), "see ~/src/x.rs now");
501        assert_eq!(s("in /home/bob-1/.config"), "in ~/.config");
502        assert_eq!(s("at C:\\Users\\Bob\\proj\\a.rs"), "at ~\\proj\\a.rs");
503        assert_eq!(s("at C:/Users/Bob/proj"), "at ~/proj");
504        assert_eq!(s("/root/x and /root."), "~/x and ~.");
505        assert_eq!(s("/srv/people/alice/wt/a"), "~/wt/a");
506    }
507
508    #[test]
509    fn emails_ips_and_tokens() {
510        assert_eq!(s("mail dev.x+y@example.co.uk!"), "mail [redacted-email]!");
511        assert_eq!(s("host 192.168.0.12:8080"), "host [redacted-ip]:8080");
512        assert_eq!(
513            s("v6 fe80::1 and ::1"),
514            "v6 [redacted-ip] and [redacted-ip]"
515        );
516        assert_eq!(s("full 2001:db8:0:0:0:0:0:1."), "full [redacted-ip].");
517        assert_eq!(
518            s("tok ghp_abcdefghijklmnopqrstuv end"),
519            "tok [redacted-token] end"
520        );
521    }
522
523    #[test]
524    fn identity_words_match_whole_words_only() {
525        assert_eq!(
526            s("by Alice on buildbox"),
527            "by [redacted-user] on [redacted-host]"
528        );
529        assert_eq!(
530            s("alicein wonderland, xbuildbox"),
531            "alicein wonderland, xbuildbox"
532        );
533    }
534
535    #[test]
536    fn ordinary_text_is_untouched() {
537        for t in [
538            "Change src/graph.rs and tests/common/mod.rs.",
539            "See https://github.com/o/r/pull/12 for #12",
540            "returns std::io::Error, or a::b, Vec::new()",
541            "released v1.2.3, version 0.41.1, 300.1.1.1",
542            "thanks @coderabbitai; at 12:34:56 it ran",
543            "/api/v1/runs; docs/home/x and ./Users/y",
544            "A plain sentence about background and motivation.",
545            "日本語のテキスト 🎉 with émoji",
546        ] {
547            assert_eq!(s(t), t);
548        }
549    }
550
551    #[test]
552    fn multibyte_input_does_not_panic_and_replacement_is_not_rescanned() {
553        assert_eq!(
554            s("C:\\日本語 and C:/日本"),
555            "[redacted-path] and [redacted-path]"
556        );
557        assert_eq!(s("é/Users/bob/é 日本 alice"), "é~/é 日本 [redacted-user]");
558        let once = s("/Users/bob 10.0.0.1 a@b.io alice");
559        assert_eq!(scrub(&once, &Identity::default()), once);
560        // A user named like the replacement text cannot loop or re-match.
561        let odd = Identity {
562            user: "redacted".into(),
563            ..Identity::default()
564        };
565        assert_eq!(scrub("redacted x", &odd), "[redacted-user] x");
566    }
567}