Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

313 linesCodeBlameRaw
1//! Hiding credentials in text people and agents write about their work: a
2//! session's prompts, tool calls and messages, its title and what it
3//! reported. Unlike [`crate::secrets`], which must be sure before it stops
4//! a push, this errs the other way: a word too many hidden costs nothing,
5//! a key shown on a public page costs the key.
6//!
7//! [`redact`] replaces what looks like a credential with [`REDACTED`]:
8//! every format [`crate::secrets`] knows, prefixed tokens of any length,
9//! `NAME_TOKEN=value` and `password: value`, credential flags, bearer
10//! headers, a URL's user and password and its signed query parameters,
11//! JWTs, PEM private keys, and long random-looking strings. [`soften`]
12//! takes swearing out of a title, so that how someone spoke to their own
13//! agent is not what their team reads.
14
15use std::sync::LazyLock;
16
17use regex::{Captures, Regex};
18
19/// What a credential is replaced with.
20pub const REDACTED: &str = "[redacted]";
21
22fn re(pattern: &str) -> Regex {
23 Regex::new(pattern).expect("a redaction pattern")
24}
25
26/// A key or token by its issuer's prefix: `ghp_…`, `sk-ant-…`, `g1t_…`.
27static PREFIXED: LazyLock<Regex> = LazyLock::new(|| {
28 re(r"\b(g1t|ghp|gho|ghu|ghs|ghr|github_pat|glpat|gloas|glrt|glptt|gldt|xox[abeprs]|sk-ant|sk-proj|sk-svcacct|sk-admin|sk|rk|whsec|npm|hf|pypi|dop_v1|shpat|shpss|sntrys|lin_api|figd)([-_])([A-Za-z0-9_-]{12,})")
29});
30
31/// `AKIA…`, `AIza…`, `SG.….…`: formats whose prefix needs no separator.
32static BARE: LazyLock<Regex> =
33 LazyLock::new(|| re(r"\b(?:(?:AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}|AIza[A-Za-z0-9_-]{35}|SG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,})\b"));
34
35/// `Authorization: Bearer …`, `Proxy-Authorization: Basic …`.
36static AUTH_HEADER: LazyLock<Regex> =
37 LazyLock::new(|| re(r"(?i)\b((?:proxy-)?authorization\s*[:=]\s*(?:(?:bearer|basic|token|bot|digest)\s+)?)[^\s\x22'`,;]+"));
38
39/// `Bearer …` anywhere else.
40static BEARER: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\b(bearer\s+)[A-Za-z0-9._~+/=-]{8,}"));
41
42/// `G1T_TOKEN=…`, `$env:API_KEY='…'`, `"password": "…"`, `client_secret: …`.
43static ASSIGNED: LazyLock<Regex> = LazyLock::new(|| {
44 re(r#"(?i)([A-Za-z0-9_.-]*(?:token|secret|passw(?:or)?d|pwd|api[_-]?key|apikey|private[_-]?key|access[_-]?key|auth[_-]?key|signing[_-]?key|session[_-]?key|credentials?|webhook[_-]?url|dsn|connection[_-]?string)["']?\s*(?:=|:)\s*)("[^"\n]*"|'[^'\n]*'|`[^`\n]*`|[^\s,;&)}\]]+)"#)
45});
46
47/// `--token abc`, `--password=abc`, `--api-key abc`.
48static FLAG: LazyLock<Regex> = LazyLock::new(|| {
49 re(r"(?i)(--(?:token|password|passwd|secret|api-key|apikey|access-token|auth-token|client-secret|private-key)(?:=|\s+))(\x22[^\x22]*\x22|'[^']*'|\S+)")
50});
51
52/// `curl -u name:password`.
53static USER_FLAG: LazyLock<Regex> = LazyLock::new(|| re(r"((?:^|\s)(?:-u|--user)\s+[^\s:]+:)\S+"));
54
55/// `https://name:password@host`.
56static USERINFO: LazyLock<Regex> = LazyLock::new(|| re(r"(?i)\b([a-z][a-z0-9+.-]*://)[^\s/:@]+:[^\s/@]+@"));
57
58/// `?token=…`, `&X-Amz-Signature=…`.
59static QUERY: LazyLock<Regex> = LazyLock::new(|| {
60 re(r"(?i)([?&](?:access_token|refresh_token|id_token|token|key|api_key|apikey|secret|client_secret|sig|signature|password|code|x-amz-signature|x-amz-credential|x-amz-security-token)=)[^&\s#\x22'`]+")
61});
62
63/// A JSON Web Token.
64static JWT: LazyLock<Regex> = LazyLock::new(|| re(r"\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"));
65
66/// A PEM private key, to its end or to the end of the text.
67static PEM: LazyLock<Regex> =
68 LazyLock::new(|| re(r"(?s)-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?(?:-----END [A-Z0-9 ]*PRIVATE KEY-----|\z)"));
69
70/// A long run that may be random: judged by [`random_looking`]. A `/`
71/// ends a run, so a path is judged a folder at a time: joined, its names
72/// read as one random string (`AppData/Local/Temp/claude/c--Users-…`).
73static LONG: LazyLock<Regex> = LazyLock::new(|| re(r"[A-Za-z0-9+_=-]{32,}"));
74
75/// A value that names a secret rather than holding one: `$TOKEN`,
76/// `${{ secrets.X }}`, `process.env.X`, or one already hidden.
77fn reference(value: &str) -> bool {
78 let value = value.trim_matches(['"', '\'', '`']);
79 value.is_empty()
80 || value.starts_with('$')
81 || value.starts_with('%')
82 || value.starts_with('<')
83 || value.contains("secrets.")
84 || value.contains("env.")
85 || value.contains("[redacted")
86 || value == "…"
87}
88
89/// `secret: the` in a sentence: after a colon, an unquoted plain word
90/// short of a password's length is the sentence going on, not a value.
91/// `password: hunter2!` (a digit, a symbol) and a long run still count;
92/// after `=` everything does.
93fn prose(head: &str, value: &str) -> bool {
94 head.trim_end().ends_with(':')
95 && !value.starts_with(['"', '\'', '`'])
96 && value.chars().count() < 16
97 && value.chars().all(|c| c.is_alphabetic() || matches!(c, '\'' | '’' | '-'))
98}
99
100fn shannon(text: &str) -> f64 {
101 let mut counts = std::collections::HashMap::new();
102 for c in text.chars() {
103 *counts.entry(c).or_insert(0usize) += 1;
104 }
105 let length = text.chars().count() as f64;
106 counts
107 .values()
108 .map(|count| {
109 let p = *count as f64 / length;
110 -p * p.log2()
111 })
112 .sum()
113}
114
115/// Upper case, lower case and digits mixed with the spread of a random
116/// string: a key with no prefix. A commit (lowercase hex), a path, a
117/// CamelCase name or a word run together does not look like this.
118fn random_looking(run: &str) -> bool {
119 let digits = run.chars().filter(char::is_ascii_digit).count();
120 run.chars().any(|c| c.is_ascii_uppercase())
121 && run.chars().any(|c| c.is_ascii_lowercase())
122 && digits >= 3
123 && !run.contains("//")
124 && shannon(run) >= 4.3
125}
126
127fn has_letter_and_digit(body: &str) -> bool {
128 body.chars().any(|c| c.is_ascii_alphabetic()) && body.chars().any(|c| c.is_ascii_digit())
129}
130
131/// `text` with what looks like a credential replaced by [`REDACTED`].
132pub fn redact(text: &str) -> String {
133 if text.is_empty() {
134 return String::new();
135 }
136 // Exact formats first, whatever surrounds them.
137 let mut out = text.to_owned();
138 let mut values: Vec<String> = crate::secrets::scan_text(text).into_iter().map(|hit| hit.value).collect();
139 values.sort_by_key(|value| std::cmp::Reverse(value.len()));
140 for value in values {
141 // A private key's value is its header and the start of its body.
142 let value = value.split('\n').next_back().unwrap_or(&value).to_owned();
143 if value.len() >= 8 {
144 out = out.replace(&value, REDACTED);
145 }
146 }
147 let out = PEM.replace_all(&out, REDACTED);
148 let out = JWT.replace_all(&out, REDACTED);
149 let out = PREFIXED.replace_all(&out, |c: &Captures| {
150 if has_letter_and_digit(&c[3]) { format!("{}{}{REDACTED}", &c[1], &c[2]) } else { c[0].to_owned() }
151 });
152 let out = BARE.replace_all(&out, REDACTED);
153 let out = AUTH_HEADER.replace_all(&out, |c: &Captures| {
154 let value = &c[0][c[1].len()..];
155 if reference(value) { c[0].to_owned() } else { format!("{}{REDACTED}", &c[1]) }
156 });
157 let out = BEARER.replace_all(&out, |c: &Captures| {
158 let value = &c[0][c[1].len()..];
159 if reference(value) { c[0].to_owned() } else { format!("{}{REDACTED}", &c[1]) }
160 });
161 let out = USERINFO.replace_all(&out, format!("${{1}}{REDACTED}@").as_str());
162 let out = QUERY.replace_all(&out, format!("${{1}}{REDACTED}").as_str());
163 let out = FLAG.replace_all(&out, |c: &Captures| {
164 if reference(&c[2]) { c[0].to_owned() } else { format!("{}{REDACTED}", &c[1]) }
165 });
166 let out = USER_FLAG.replace_all(&out, format!("${{1}}{REDACTED}").as_str());
167 let out = ASSIGNED.replace_all(&out, |c: &Captures| {
168 let value = &c[2];
169 // `token: string` in code is a type, not a token.
170 let typed = matches!(value, "string" | "String" | "str" | "&str" | "number" | "bool" | "boolean" | "null" | "None" | "undefined" | "true" | "false" | "Option<String>");
171 if reference(value) || typed || prose(&c[1], value) { c[0].to_owned() } else { format!("{}{REDACTED}", &c[1]) }
172 });
173 let out = LONG.replace_all(&out, |c: &Captures| {
174 if random_looking(&c[0]) { REDACTED.to_owned() } else { c[0].to_owned() }
175 });
176 out.into_owned()
177}
178
179/// Words that say how someone felt, not what they asked for.
180const SWEARING: &[&str] = &[
181 "fuck", "fucking", "fucked", "fucker", "fuckin", "motherfucker", "motherfucking", "shit", "shitty", "bullshit", "horseshit",
182 "damn", "damned", "dammit", "damnit", "goddamn", "goddamned", "goddammit", "wtf", "wth", "ffs", "stfu", "omfg", "asshole",
183 "bitch", "bitching", "crap", "crappy", "idiot", "idiotic", "moron", "moronic", "dumbass", "retard", "retarded", "bastard",
184 "pissed", "jfc", "fml",
185];
186
187fn swearing(word: &str) -> bool {
188 let bare: String = word.chars().filter(|c| c.is_alphanumeric()).collect::<String>().to_lowercase();
189 !bare.is_empty() && (SWEARING.contains(&bare.as_str()) || bare.starts_with("fuck") || bare.starts_with("shit"))
190}
191
192/// A title as the team reads it: credentials hidden and swearing left out.
193/// Empty when nothing else was said.
194pub fn soften(title: &str) -> String {
195 let redacted = redact(title);
196 let mut words: Vec<&str> = Vec::new();
197 for word in redacted.split_whitespace() {
198 if swearing(word) {
199 // Its punctuation stays with the sentence: "this, fucking thing" reads "this, thing".
200 continue;
201 }
202 words.push(word);
203 }
204 let text = words.join(" ");
205 let text = text.trim_matches(|c: char| c.is_whitespace() || matches!(c, ',' | ';' | ':' | '-'));
206 let mut chars = text.chars();
207 match chars.next() {
208 // A title that lost its first word still starts as a sentence.
209 Some(first) if words.len() < redacted.split_whitespace().count() => first.to_uppercase().chain(chars).collect(),
210 Some(_) => text.to_owned(),
211 None => String::new(),
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 fn hidden(text: &str, secret: &str) {
220 let out = redact(text);
221 assert!(!out.contains(secret), "{secret} shows in {out}");
222 assert!(out.contains(REDACTED), "nothing hidden in {out}");
223 }
224
225 #[test]
226 fn tokens_by_their_prefix() {
227 hidden("source ~/.bashrc; curl -H 'x' g1t_0123456789abcdef0123456789abcdef01234567", "0123456789abcdef0123");
228 hidden("use ghp_A1b2C3d4E5f6G7h8I9j0 please", "A1b2C3d4E5f6G7h8I9j0");
229 hidden("key sk-ant-api03-abcDEF123456ghiJKL", "abcDEF123456ghiJKL");
230 hidden("whsec_1234567890abcdefXYZ", "1234567890abcdefXYZ");
231 hidden("AKIAIOSFODNN7EXAMPLE was it", "IOSFODNN7EXAMPLE");
232 // Names that only share a prefix are left alone.
233 assert_eq!(redact("npm_config_user_agent is set"), "npm_config_user_agent is set");
234 assert_eq!(redact("sk-learn-classifier"), "sk-learn-classifier");
235 }
236
237 #[test]
238 fn assignments_flags_and_headers() {
239 hidden("G1T_TOKEN=abc123def456 g1t whoami", "abc123def456");
240 hidden("$env:G1T_TOKEN='abc123def456'; g1t whoami", "abc123def456");
241 hidden(r#"{"password": "hunter2!"}"#, "hunter2");
242 hidden("client_secret: s3cr3tvalue", "s3cr3tvalue");
243 hidden("DATABASE_PASSWORD=\"correct horse\"", "correct horse");
244 hidden("g1t auth login --token abc123", "abc123");
245 hidden("x --password=pa55", "pa55");
246 hidden("curl -u admin:pa55word https://x", "pa55word");
247 hidden(r#"curl -H "Authorization: Bearer abc.def-123" https://api"#, "abc.def-123");
248 hidden("Authorization: Basic dXNlcjpwYXNz", "dXNlcjpwYXNz");
249 hidden("send it with bearer abcdefgh12345678", "abcdefgh12345678");
250 hidden("X-Api-Key: 1234abcd5678", "1234abcd5678");
251 }
252
253 #[test]
254 fn a_sentence_after_a_colon_is_not_a_value() {
255 for ordinary in [
256 "A path is not a secret: the redactor's rule read it as one",
257 "The API key: what it is and where to keep it",
258 "password: the one you set at sign-up",
259 ] {
260 assert_eq!(redact(ordinary), ordinary);
261 }
262 hidden("password: hunter2", "hunter2");
263 hidden("db_password: correcthorsebatterystaple", "correcthorsebatterystaple");
264 hidden("api_key: 'plainword'", "plainword");
265 hidden("SECRET=plainword", "plainword");
266 }
267
268 #[test]
269 fn references_and_types_are_not_secrets() {
270 let line = "TOKEN=$G1T_TOKEN; token: ${{ secrets.DEPLOY }}; password: process.env.PW; fn f(token: String)";
271 assert_eq!(redact(line), line);
272 assert_eq!(redact(r#"-H "Authorization: Bearer …""#), r#"-H "Authorization: Bearer …""#);
273 }
274
275 #[test]
276 fn urls_jwts_and_keys() {
277 hidden("git clone https://x-access-token:abc123@g1t.sh/a/b", "abc123");
278 hidden("postgres://app:pa55@db:5432/app", "pa55");
279 hidden("open https://x.com/cb?code=abc&state=1&token=t0k3n", "t0k3n");
280 hidden("GET /file?X-Amz-Signature=deadbeef1234&x=1", "deadbeef1234");
281 hidden("jwt eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", "dozjgNryP4J3");
282 let pem = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQ\n-----END OPENSSH PRIVATE KEY-----\nafter"; // g1t:allow-secret
283 let out = redact(pem);
284 assert!(!out.contains("b3BlbnNzaC1"), "{out}");
285 assert!(out.ends_with("after"));
286 hidden("-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA0Z3VS5JJcds3xfn", "MIIEpAIBAAKCAQEA"); // g1t:allow-secret
287 }
288
289 #[test]
290 fn random_strings_go_and_ordinary_ones_stay() {
291 hidden("the key is Xk9fQ2mZr7Lp4Wv8Ty3Nb6Hc1Jd5Ge0S", "Xk9fQ2mZr7Lp4Wv8Ty3Nb6Hc1Jd5Ge0S");
292 for ordinary in [
293 "git show 58def4040e93a1dcda5f388f55360f3cd46cd97e",
294 "services/work/src/coordination/sessions.rs",
295 r#"cat "C:/Users/syntaqx/AppData/Local/Temp/claude/c--Users-syntaqx-Code-syntaqx-g1t/3fcf65eb-fb45-440a-81ed-c3586c68531a/tasks/bzf08tti3.output""#,
296 "CoordinationOp::StartAgentSession and ListWorkSessionsArgs",
297 "MSYS_NO_PATHCONV=1 git show origin/40-g1t_upkeep:.g1t/upkeep.yml",
298 "Fix the merge queue: it re-tests every pull request after a push",
299 ] {
300 assert_eq!(redact(ordinary), ordinary);
301 }
302 }
303
304 #[test]
305 fn swearing_is_left_out_of_titles() {
306 assert_eq!(soften("Fix this fucking bug"), "Fix this bug");
307 assert_eq!(soften("wtf, why is the PR page slow"), "Why is the PR page slow");
308 assert_eq!(soften("Make the shitty button work"), "Make the button work");
309 assert_eq!(soften("fuck"), "");
310 assert_eq!(soften("Shell scripts and hello world"), "Shell scripts and hello world");
311 assert_eq!(soften("Rotate G1T_TOKEN=abc123def456 now"), format!("Rotate G1T_TOKEN={REDACTED} now"));
312 }
313}