redact: read the authority as URL parsers do
Userinfo is everything before the last @ of the authority, which ends at / ? # backslash, quotes, brackets, angle brackets or ASCII whitespace; a path @ (github.com/@user, pkg@1.2.3, a//b@c) is left alone, non-ASCII stays inside userinfo. Protocol-relative // opens an authority only at a word start. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
b19e57a6d3
commit
08afef830f
2 changed files with 58 additions and 33 deletions
|
|
@ -933,41 +933,41 @@ fn result_text(event: &serde_json::Map<String, Value>) -> String {
|
|||
/// the Self result (`result_text`: journal line and Reply) and the structured output of a Persona or Sleep result
|
||||
/// (`persona::structured_output`, string by string). It does not cover claude's stderr (inherited) or the CLI's transcript files.
|
||||
///
|
||||
/// A forge token rides in a remote URL, so every `scheme://`, and every protocol-relative `//`, opens a candidate: the run up to the next
|
||||
/// ASCII whitespace. Its userinfo is everything before the run's LAST `@`, whatever delimiters sit inside (fail closed: a path holding
|
||||
/// `@` is redacted too). The userinfo becomes `<redacted>`: `scheme://<redacted>@host`. With a colon (user:password) it is always
|
||||
/// redacted; without one it is a bare token on http, https, ftp, ftps and protocol-relative `//`, and a plain username on any other scheme
|
||||
/// (`ssh://git@host`), which stays. Scheme-less credentials are covered only in the shape `word:secret@host.tld/` at the start of a
|
||||
/// whitespace-delimited word. Syntax only; no reading of meaning.
|
||||
/// A forge token rides in a remote URL, so every `scheme://` and every protocol-relative `//` opens an authority, read as git, curl and
|
||||
/// RFC 3986 read it: it ends at the first `/`, `?`, `#`, backslash, quote, backtick, parenthesis, bracket, angle bracket or ASCII whitespace
|
||||
/// (`is_authority_end`). Its userinfo is everything before the authority's LAST `@` (a raw `@` in a password still redacts), and non-ASCII
|
||||
/// characters, NBSP and U+2028 included, stay inside it. The userinfo becomes `<redacted>`: `scheme://<redacted>@host`. With a colon it is
|
||||
/// always redacted; without one it is a bare token on http, https, ftp, ftps, protocol-relative `//` and an unparseable scheme, and a plain
|
||||
/// username on any other scheme (`ssh://git@host`), which stays. A `@` after the authority (`github.com/@user`, `pkg@1.2.3`) is path, not
|
||||
/// userinfo, and is left alone. Scheme-less credentials are covered only in the shape `word:secret@host.tld/` at the start of an
|
||||
/// ASCII-whitespace-delimited word. Not covered: a password holding `/ ? # ` a quote or ASCII whitespace (no parser reads it as userinfo),
|
||||
/// scp-style `user:secret@host:path`, tokens in query strings, claude's stderr, the kept transcripts. Syntax only; no reading of meaning.
|
||||
pub(crate) fn redact_url_userinfo(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut rest = text;
|
||||
while let Some(at) = rest.find("//") {
|
||||
let (before, after) = rest.split_at(at);
|
||||
let body = &after[2..];
|
||||
let end = body.find(is_break).unwrap_or(body.len());
|
||||
let run = &body[..end];
|
||||
out.push_str(before);
|
||||
out.push_str("//");
|
||||
// The scheme is the ASCII run before `:`. Without a colon, `//` opens an authority only at a word start (`a//b@c` is not one).
|
||||
let scheme = match before.strip_suffix(':') {
|
||||
Some(head) => {
|
||||
// Only ASCII bytes match, so the byte after the last non-match is a character boundary.
|
||||
let start = head.bytes().rposition(|c| !(c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.'))).map_or(0, |i| i + 1);
|
||||
Some(head[start..].to_ascii_lowercase())
|
||||
// Only ASCII bytes match, so the byte after the last non-match is a character boundary.
|
||||
Some(head) => head[head.bytes().rposition(|c| !(c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.'))).map_or(0, |i| i + 1)..].to_ascii_lowercase(),
|
||||
None if before.chars().next_back().is_none_or(|c| is_break(c) || matches!(c, '"' | '\'' | '`' | '(' | '[' | '{' | '<' | '=' | ',')) => String::new(),
|
||||
None => {
|
||||
rest = body;
|
||||
continue;
|
||||
}
|
||||
None => Some(String::new()),
|
||||
};
|
||||
match (scheme, run.rfind('@')) {
|
||||
(Some(scheme), Some(i)) => {
|
||||
let secret = run[..i].contains(':') || matches!(scheme.as_str(), "" | "http" | "https" | "ftp" | "ftps");
|
||||
if secret && i > 0 {
|
||||
out.push_str("<redacted>");
|
||||
out.push_str(&run[i..]);
|
||||
} else {
|
||||
out.push_str(run);
|
||||
}
|
||||
let end = body.find(is_authority_end).unwrap_or(body.len());
|
||||
let authority = &body[..end];
|
||||
match authority.rfind('@') {
|
||||
Some(i) if i > 0 && (authority[..i].contains(':') || matches!(scheme.as_str(), "" | "http" | "https" | "ftp" | "ftps")) => {
|
||||
out.push_str("<redacted>");
|
||||
out.push_str(&authority[i..]);
|
||||
}
|
||||
_ => out.push_str(run),
|
||||
_ => out.push_str(authority),
|
||||
}
|
||||
rest = &body[end..];
|
||||
}
|
||||
|
|
@ -975,11 +975,16 @@ pub(crate) fn redact_url_userinfo(text: &str) -> String {
|
|||
redact_schemeless(&out)
|
||||
}
|
||||
|
||||
/// ASCII whitespace ends a candidate; NBSP, U+2028 and other Unicode whitespace do not, so a secret holding them is redacted whole.
|
||||
/// ASCII whitespace; NBSP, U+2028 and other Unicode whitespace are not, so a secret holding them is redacted whole.
|
||||
fn is_break(c: char) -> bool {
|
||||
matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0b' | '\x0c')
|
||||
}
|
||||
|
||||
/// The end of a URL's authority.
|
||||
fn is_authority_end(c: char) -> bool {
|
||||
is_break(c) || matches!(c, '/' | '?' | '#' | '\\' | '"' | '\'' | '`' | '(' | ')' | '[' | ']' | '{' | '}' | '<' | '>')
|
||||
}
|
||||
|
||||
/// `word:secret@host.tld/` at the start of a whitespace-delimited word that holds no `//` becomes `<redacted>@host.tld/`.
|
||||
fn redact_schemeless(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
|
|
|
|||
|
|
@ -158,21 +158,41 @@ fn a_credential_split_by_the_reply_limit_is_redacted_before_the_cut() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn delimiters_inside_a_secret_leak_none_of_it_and_the_bare_and_scheme_less_forms_are_covered() {
|
||||
fn a_secret_is_redacted_whole_by_the_authority_rule_and_commonplace_urls_are_left_alone() {
|
||||
let r = redact_url_userinfo;
|
||||
for secret in ["pa/ssT", "pa?ssT", "pa#ssT", "pa\"ssT", "pa'ssT", "pa)ssT", "pa\u{a0}ssT", "pa\u{2028}ssT", "pa<ssT", "pa]ssT", "[u]"] {
|
||||
// Raw @, percent-encoding and non-ASCII (NBSP, U+2028) stay inside userinfo.
|
||||
for secret in ["pa@ssT", "pa%40ssT", "pa\u{a0}ssT", "pa\u{2028}ssT", "pa:ssT", "\u{e9}\u{65e5}ssT"] {
|
||||
let out = r(&format!("x https://u:{secret}@h/x"));
|
||||
assert!(out.starts_with("x https://<redacted>@h"), "{secret:?} -> {out:?}");
|
||||
assert!(!out.contains("ssT"), "{secret:?} -> {out:?}");
|
||||
assert_eq!(out, "x https://<redacted>@h/x", "{secret:?}");
|
||||
}
|
||||
// Only ASCII whitespace ends a candidate run.
|
||||
// The authority ends at / ? # \\ quotes, brackets and ASCII whitespace: no parser reads those as userinfo, so what follows is path.
|
||||
assert_eq!(r("https://u:pa/ssT@h/x"), "https://u:pa/ssT@h/x");
|
||||
assert_eq!(r("https://u:pa ssT@h/x"), "https://u:pa ssT@h/x");
|
||||
assert_eq!(r("https://u:pa\ttoken@h/x"), "https://u:pa\ttoken@h/x");
|
||||
assert_eq!(r("https://u:pa?ssT@h"), "https://u:pa?ssT@h");
|
||||
// Ordinary URLs with @ in the path are byte-identical.
|
||||
for same in [
|
||||
"https://github.com/@user/repo",
|
||||
"https://medium.com/@name/post-1",
|
||||
"https://registry.npmjs.org/@scope/pkg",
|
||||
"https://h/pkg@1.2.3",
|
||||
"a//b@c",
|
||||
"see //cdn.h/@x/y and https://h/?mail=a@b.c#f@g",
|
||||
"https://h:8080/p git@github.com:org/r.git ssh://git@host/r.git ssh://git@h:22/r git://git@h/x",
|
||||
] {
|
||||
assert_eq!(r(same), same);
|
||||
}
|
||||
// Compact JSON: only the credentialed URL changes.
|
||||
let json = format!(r#"{{"a":"https://u:{CANARY}@h/x","b":"https://h2/@v","c":"https://h3/p@1"}}"#);
|
||||
assert_eq!(r(&json), r#"{"a":"https://<redacted>@h/x","b":"https://h2/@v","c":"https://h3/p@1"}"#);
|
||||
assert_eq!(r("a/https://u:T@h/x,https://v:T@h2"), "a/https://<redacted>@h/x,https://<redacted>@h2");
|
||||
assert_eq!(r("HTTPS://TOKEN@h"), "HTTPS://<redacted>@h");
|
||||
assert_eq!(r("[l](https://T@h/x) <https://T@h> `https://u:T@h`"), "[l](https://<redacted>@h/x) <https://<redacted>@h> `https://<redacted>@h`");
|
||||
assert_eq!(r("a //u:T@host/x b //T@host c"), "a //<redacted>@host/x b //<redacted>@host c");
|
||||
assert_eq!(r("user:T@host.tld/path and a:b@c and mail me@x.org/ and https://h/a//b"), "<redacted>@host.tld/path and a:b@c and mail me@x.org/ and https://h/a//b");
|
||||
assert_eq!(r("ssh://git@host/r.git ssh://git@h:22/r"), "ssh://git@host/r.git ssh://git@h:22/r");
|
||||
assert_eq!(r("a //u:T@host/x b //T@host c \"//T@h\" (//T@h)"), "a //<redacted>@host/x b //<redacted>@host c \"//<redacted>@h\" (//<redacted>@h)");
|
||||
// Scheme-less: only word:secret@host.tld/ . Each guard of the shape has its own case.
|
||||
assert_eq!(r("user:T@host.tld/path"), "<redacted>@host.tld/path");
|
||||
for same in ["a:b@c", "a:b@host/x", "a:b@ho_st.tld/x", "u$er:T@host.tld/x", "a:@host.tld/x", "mail me@x.org/ and :T@host.tld/x"] {
|
||||
assert_eq!(r(same), same);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Reference in a new issue