mind-self-run: redact URL userinfo from a result's text before journal and reply
One function, redact_url_userinfo, owned by result_text: the journal line and the admitted PipelineReply both read the result through it, and redaction runs before the reply is chunked. scheme://user:tok@h and http(s)/ftp(s)://tok@h become scheme://<redacted>@h; ssh://git@h stays. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ceb1dbdb86
commit
480f94875b
2 changed files with 74 additions and 2 deletions
|
|
@ -825,6 +825,7 @@ impl<'a> SelfInbox<'a> {
|
|||
fn reply_to(&mut self, line: &str, ok: bool) {
|
||||
let Ok(Value::Object(event)) = serde_json::from_str::<Value>(line) else { return };
|
||||
let text = result_text(&event);
|
||||
let text = text.as_str();
|
||||
let flush = std::mem::take(&mut self.flushing);
|
||||
// The flush result is the session's note to its successor, in the journal: it is no reply.
|
||||
if ok && !flush && !text.trim().is_empty() {
|
||||
|
|
@ -923,8 +924,39 @@ fn result_head(event: &serde_json::Map<String, Value>) -> String {
|
|||
head
|
||||
}
|
||||
|
||||
fn result_text(event: &serde_json::Map<String, Value>) -> &str {
|
||||
event.get("result").and_then(Value::as_str).unwrap_or("")
|
||||
/// A result's words as they leave the actuator, for the journal and for her mind: the one place URL credentials are redacted.
|
||||
fn result_text(event: &serde_json::Map<String, Value>) -> String {
|
||||
redact_url_userinfo(event.get("result").and_then(Value::as_str).unwrap_or(""))
|
||||
}
|
||||
|
||||
/// `text` with the userinfo of every `scheme://userinfo@host` replaced: `scheme://<redacted>@host`. A forge token rides in a remote URL
|
||||
/// (`https://user:token@host/...`, `https://token@host/...`) and Self prints remotes. Userinfo with a colon is user and password, always
|
||||
/// redacted. Without a colon it is a bare token on http, https, ftp and ftps, and a plain username on every other scheme (`ssh://git@host`),
|
||||
/// which stays. The userinfo ends at the last `@` before the authority ends (`/`, `?`, `#`, whitespace, a quote or a bracket), so a password
|
||||
/// holding `@` leaks no tail. A tiny syntactic guardrail on URL shape, not a reading of meaning; the CLI's own transcript files are not covered.
|
||||
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 scheme_start = before.rfind(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))).map_or(0, |i| i + 1);
|
||||
let scheme = before[scheme_start..].to_ascii_lowercase();
|
||||
let body = &after[3..];
|
||||
let end = body.find(|c: char| c.is_whitespace() || matches!(c, '/' | '?' | '#' | '"' | '\'' | '`' | '<' | '>' | '(' | ')' | '[' | ']' | '{' | '}' | '\\')).unwrap_or(body.len());
|
||||
let authority = &body[..end];
|
||||
out.push_str(before);
|
||||
out.push_str("://");
|
||||
match authority.rfind('@') {
|
||||
Some(i) if !scheme.is_empty() && (authority[..i].contains(':') || matches!(scheme.as_str(), "http" | "https" | "ftp" | "ftps")) => {
|
||||
out.push_str("<redacted>@");
|
||||
out.push_str(&authority[i + 1..]);
|
||||
}
|
||||
_ => out.push_str(authority),
|
||||
}
|
||||
rest = &body[end..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// `text` in consecutive pieces of at most `limit` bytes, each cut at a character boundary; their concatenation is `text`.
|
||||
|
|
|
|||
|
|
@ -118,3 +118,43 @@ fn a_refused_piece_of_a_long_result_holds_back_the_pieces_behind_it() {
|
|||
let ordinals: Vec<u64> = (1..=3).map(|n| mind.view(K::Reply, &format!("{INSTANCE}:reply:selfrun-a-r{n}")).unwrap_or_else(|| panic!("piece {n} lost")).admission.ordinal).collect();
|
||||
assert!(ordinals.windows(2).all(|w| w[0] < w[1]), "the pieces reached the mind out of order: {ordinals:?}");
|
||||
}
|
||||
|
||||
// A canary stands where a forge token would: no byte of it may reach the journal or her mind.
|
||||
const CANARY: &str = "CANARY-NOT-A-REAL-TOKEN-0000";
|
||||
|
||||
#[test]
|
||||
fn url_userinfo_is_redacted_where_it_is_a_secret_and_kept_where_it_is_a_username() {
|
||||
let said = format!(
|
||||
"a https://user:{CANARY}@host.example/r.git b https://{CANARY}@host.example/r.git c ssh://u:{CANARY}@h/x d http://{CANARY}@h e HTTPS://x:{CANARY}@h \
|
||||
f https://u:p@ss{CANARY}@h/x g ssh://git@host.example/r.git h git://git@h/x i https://host.example/no-userinfo j (https://u:{CANARY}@h) k ://@x"
|
||||
);
|
||||
let want = "a https://<redacted>@host.example/r.git b https://<redacted>@host.example/r.git c ssh://<redacted>@h/x d http://<redacted>@h e HTTPS://<redacted>@h \
|
||||
f https://<redacted>@h/x g ssh://git@host.example/r.git h git://git@h/x i https://host.example/no-userinfo j (https://<redacted>@h) k ://@x";
|
||||
assert_eq!(redact_url_userinfo(&said), want);
|
||||
assert!(!redact_url_userinfo(&said).contains(CANARY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_credential_in_a_url_reaches_neither_the_journal_nor_the_admitted_reply() {
|
||||
let mind = live_mind();
|
||||
let said = format!("origin https://user:{CANARY}@host.example/r.git and https://{CANARY}@host.example/s.git");
|
||||
let ran = mind_run(&mind, vec![Out(echo()), Out(saying(&said))]);
|
||||
assert!(ran.outcome.is_ok());
|
||||
assert!(!ran.out.contains(CANARY), "{}", ran.out);
|
||||
assert!(ran.out.contains("https://<redacted>@host.example/r.git"), "{}", ran.out);
|
||||
let text = text_of(&mind, 1);
|
||||
assert!(!text.contains(CANARY), "{text}");
|
||||
assert_eq!(text, "origin https://<redacted>@host.example/r.git and https://<redacted>@host.example/s.git");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_credential_split_by_the_reply_limit_is_redacted_before_the_cut() {
|
||||
let mind = live_mind();
|
||||
let said = format!("{}https://u:{CANARY}@h/x", "a".repeat(REPLY_TEXT_LIMIT - 25));
|
||||
mind_run(&mind, vec![Out(echo()), Out(saying(&said))]).outcome.unwrap();
|
||||
for n in 1..=2 {
|
||||
if let Some(reply) = reply_of(&mind, n) {
|
||||
assert!(!reply.text.0.contains("CANARY"), "{}", reply.text.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue