Preserve protected JSON fields during secret redaction

This commit is contained in:
Scott Werner 2026-10-16 08:15:34 -04:00
parent 5a3a952bed
commit 0d7dae01f0
2 changed files with 78 additions and 62 deletions

View file

@ -1325,4 +1325,17 @@ mod tests {
assert_eq!(value["kind"], "platform");
assert_eq!(value["item"]["record"]["kind"], "run.notice");
}
#[test]
fn redacted_raw_lines_keep_the_typed_run_id() {
let run_id = "01M5228EHCT0P73Z4BYGDRWJNS";
let mut event = petri(1, json!({"record": {"body": {"value": run_id}}}));
event.run_id = run_id.parse().expect("a valid run ID");
let line = raw_line(&event).expect("a redacted line");
let decoded: RunStreamItem = serde_json::from_str(&line).expect("a valid stream item");
assert_eq!(decoded.run_id, event.run_id);
assert_eq!(decoded.stream_seq, event.stream_seq);
assert_eq!(decoded.item["record"]["body"]["value"], "REDACTED");
}
}

View file

@ -31,69 +31,35 @@ fn should_skip_object(obj: &serde_json::Map<String, Value>) -> bool {
}
}
fn walk_replacements(
v: &Value,
seen: &mut std::collections::HashSet<String>,
repls: &mut Vec<(String, String)>,
) {
match v {
Value::Object(obj) => {
if should_skip_object(obj) {
return;
}
for (k, child) in obj {
if should_skip_field(k) {
continue;
}
walk_replacements(child, seen, repls);
}
}
Value::Array(arr) => {
for child in arr {
walk_replacements(child, seen, repls);
}
}
Value::String(s) => {
let redacted = super::redact_string(s);
if redacted != *s && seen.insert(s.clone()) {
repls.push((s.clone(), redacted));
}
}
_ => {}
fn redact_json_tree(value: &mut Value, skip_field: bool) -> bool {
if skip_field {
return false;
}
}
/// Walk a parsed JSON value and collect (original, redacted) string pairs.
fn collect_replacements(v: &Value) -> Vec<(String, String)> {
let mut seen = std::collections::HashSet::new();
let mut repls = Vec::new();
walk_replacements(v, &mut seen, &mut repls);
repls
}
fn redact_json_tree(value: &mut Value, skip_field: bool) {
let mut changed = false;
match value {
Value::Object(obj) => {
if should_skip_object(obj) {
return;
return false;
}
for (key, child) in obj {
redact_json_tree(child, should_skip_field(key));
changed |= redact_json_tree(child, should_skip_field(key));
}
}
Value::Array(arr) => {
for child in arr {
redact_json_tree(child, false);
changed |= redact_json_tree(child, false);
}
}
Value::String(text) if !skip_field => {
Value::String(text) => {
let redacted = super::redact_string(text);
if redacted != *text {
*text = redacted;
changed = true;
}
}
_ => {}
}
changed
}
pub fn redact_json_value(mut value: Value) -> Value {
@ -101,16 +67,11 @@ pub fn redact_json_value(mut value: Value) -> Value {
value
}
/// JSON-encode a string value (with quotes), without HTML escaping.
fn json_encode_string(s: &str) -> String {
serde_json::to_string(s).unwrap_or_else(|_| format!("\"{s}\""))
}
/// Redact secrets in a single JSONL line.
///
/// Parses the line as JSON to determine which string values need redaction,
/// then performs targeted replacements on the raw JSON bytes. Lines with no
/// secrets are returned unchanged, preserving original formatting.
/// Redacts the parsed JSON tree so skipped fields, object keys, and image
/// objects remain untouched even when they repeat a scanned string. Lines
/// with no redactions are returned unchanged, preserving original formatting.
///
/// Falls back to `redact_string` on invalid JSON.
pub fn redact_jsonl_line(line: &str) -> String {
@ -119,23 +80,16 @@ pub fn redact_jsonl_line(line: &str) -> String {
return line.to_string();
}
let parsed: Value = match serde_json::from_str(trimmed) {
let mut parsed: Value = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(_) => return super::redact_string(line),
};
let repls = collect_replacements(&parsed);
if repls.is_empty() {
if !redact_json_tree(&mut parsed, false) {
return line.to_string();
}
let mut result = line.to_string();
for (orig, redacted) in &repls {
let orig_json = json_encode_string(orig);
let redacted_json = json_encode_string(redacted);
result = result.replace(&orig_json, &redacted_json);
}
result
serde_json::to_string(&parsed).expect("a parsed JSON value should serialize")
}
#[cfg(test)]
@ -253,6 +207,55 @@ mod tests {
assert_eq!(redact_jsonl_line(input), input);
}
#[test]
fn redact_jsonl_line_preserves_ids_repeated_in_scanned_values() {
// This run ID from a failed CLI attach exceeds the entropy threshold.
// Redacting an event's context value must not also rewrite its envelope.
let run_id = "01M5228EHCT0P73Z4BYGDRWJNS";
let input = serde_json::json!({
"run_id": run_id,
"item": { "context": { "value": run_id } },
});
let result = redact_jsonl_line(&input.to_string());
let redacted: Value = serde_json::from_str(&result).unwrap();
assert_eq!(redacted["run_id"], run_id);
assert_eq!(redacted["item"]["context"]["value"], "REDACTED");
assert_eq!(redacted, redact_json_value(input));
}
#[test]
fn redact_jsonl_line_preserves_keys_and_skipped_objects_with_shared_values() {
let input = serde_json::json!({
HIGH_ENTROPY_SECRET: "unchanged key",
"content": HIGH_ENTROPY_SECRET,
"image": { "type": "image", "data": HIGH_ENTROPY_SECRET },
"path": HIGH_ENTROPY_SECRET,
"session_ids": [HIGH_ENTROPY_SECRET],
});
let redacted: Value = serde_json::from_str(&redact_jsonl_line(&input.to_string())).unwrap();
assert_eq!(redacted[HIGH_ENTROPY_SECRET], "unchanged key");
assert_eq!(redacted["content"], "REDACTED");
assert_eq!(redacted["image"]["data"], HIGH_ENTROPY_SECRET);
assert_eq!(redacted["path"], HIGH_ENTROPY_SECRET);
assert_eq!(redacted["session_ids"][0], HIGH_ENTROPY_SECRET);
assert_eq!(redacted, redact_json_value(input));
}
#[test]
fn redact_jsonl_line_scans_decoded_strings() {
let input = format!(r#"{{"content":"\u0073{}"}}"#, &HIGH_ENTROPY_SECRET[1..]);
let redacted: Value = serde_json::from_str(&redact_jsonl_line(&input)).unwrap();
assert_eq!(redacted["content"], "REDACTED");
}
#[test]
fn redact_jsonl_line_preserves_formatting_without_redactions() {
let input = " { \"content\" : \"hello\", \"number\": 1.00 }\n";
assert_eq!(redact_jsonl_line(input), input);
}
#[test]
fn redact_json_value_matches_jsonl_behavior() {
let input = serde_json::json!({