Update public CLI log redaction

This commit is contained in:
Michel Paulissen 2026-07-04 11:21:12 +02:00
parent 7a80d87376
commit eb8dc3bf12
3 changed files with 173 additions and 5 deletions

View file

@ -1,7 +1,7 @@
{
"kind": "disasmer-filtered-public-tree",
"source_commit": "611a91087c5a78ed23617674f2664f7cf07af020",
"release_name": "dryrun-611a91087c5a",
"source_commit": "f4707862d419b08f13d072a31f8f5e3cb9ddda66",
"release_name": "dryrun-f4707862d419",
"filtered_out": [
"private/**",
"experiments/**",

View file

@ -3583,7 +3583,7 @@ fn task_failure_reason(event: &Value) -> Value {
Some("failed") => {
if let Some(stderr) = event.get("stderr_tail").and_then(Value::as_str) {
if !stderr.is_empty() {
return json!(stderr);
return json!(redact_secret_like_text(stderr).0);
}
}
if let Some(status_code) = event.get("status_code").and_then(Value::as_i64) {
@ -3713,23 +3713,125 @@ fn log_entries(task_events: Option<&Value>, task_filter: Option<&str>) -> Value
})
})
.map(|event| {
let stdout_tail = event
.get("stdout_tail")
.and_then(Value::as_str)
.unwrap_or("");
let stderr_tail = event
.get("stderr_tail")
.and_then(Value::as_str)
.unwrap_or("");
let (stdout_tail, stdout_tail_redacted) = redact_secret_like_text(stdout_tail);
let (stderr_tail, stderr_tail_redacted) = redact_secret_like_text(stderr_tail);
json!({
"process": event_string(event, "process"),
"task": event_string(event, "task"),
"node": event_string(event, "node"),
"stdout_bytes": event_u64(event, "stdout_bytes").unwrap_or(0),
"stderr_bytes": event_u64(event, "stderr_bytes").unwrap_or(0),
"stdout_tail": event.get("stdout_tail").cloned().unwrap_or_else(|| json!("")),
"stderr_tail": event.get("stderr_tail").cloned().unwrap_or_else(|| json!("")),
"stdout_tail": stdout_tail,
"stderr_tail": stderr_tail,
"stdout_truncated": event.get("stdout_truncated").and_then(Value::as_bool).unwrap_or(false),
"stderr_truncated": event.get("stderr_truncated").and_then(Value::as_bool).unwrap_or(false),
"capped": true,
"secret_like_values_redacted": stdout_tail_redacted || stderr_tail_redacted,
"redacted_fields": redacted_log_fields(stdout_tail_redacted, stderr_tail_redacted),
})
})
.collect(),
)
}
fn redacted_log_fields(
stdout_tail_redacted: bool,
stderr_tail_redacted: bool,
) -> Vec<&'static str> {
let mut fields = Vec::new();
if stdout_tail_redacted {
fields.push("stdout_tail");
}
if stderr_tail_redacted {
fields.push("stderr_tail");
}
fields
}
fn redact_secret_like_text(text: &str) -> (String, bool) {
let markers = [
"access_token=",
"access_token:",
"refresh_token=",
"refresh_token:",
"id_token=",
"id_token:",
"api_key=",
"api_key:",
"api-key=",
"api-key:",
"token=",
"token:",
"secret=",
"secret:",
"password=",
"password:",
"passwd=",
"passwd:",
"bearer ",
];
let mut output = text.to_owned();
let mut redacted = false;
for marker in markers {
let (updated, changed) = redact_marker_values(output, marker);
output = updated;
redacted |= changed;
}
(output, redacted)
}
fn redact_marker_values(mut text: String, marker: &str) -> (String, bool) {
let mut changed = false;
let mut search_start = 0;
loop {
let lower = text.to_ascii_lowercase();
let Some(relative) = lower[search_start..].find(marker) else {
break;
};
let value_start = search_start + relative + marker.len();
let value_end = text[value_start..]
.char_indices()
.find_map(|(offset, character)| {
(character.is_whitespace()
|| matches!(
character,
'&' | '"' | '\'' | '`' | '<' | '>' | ',' | ';' | ')' | ']'
))
.then_some(value_start + offset)
})
.unwrap_or_else(|| text.len());
if value_start == value_end {
search_start = value_end;
if search_start >= text.len() {
break;
}
continue;
}
if text[value_start..value_end].starts_with("[redacted") {
search_start = value_end;
if search_start >= text.len() {
break;
}
continue;
}
text.replace_range(value_start..value_end, "[redacted]");
changed = true;
search_start = value_start + "[redacted]".len();
if search_start >= text.len() {
break;
}
}
(text, changed)
}
fn artifact_summaries(task_events: Option<&Value>) -> Value {
Value::Array(
task_event_values(task_events)
@ -7796,6 +7898,61 @@ mod tests {
assert_eq!(artifacts["default_durable_store_assumed"], false);
}
#[test]
fn log_and_task_reports_redact_secret_like_values() {
let events = json!({
"response": {
"type": "task_events",
"events": [{
"tenant": "tenant",
"project": "project",
"process": "vp",
"node": "node-a",
"task": "task-secret",
"terminal_state": "failed",
"status_code": 1,
"stdout_bytes": 128,
"stderr_bytes": 64,
"stdout_tail": "upload token=abc123 Authorization: Bearer bearer-secret",
"stderr_tail": "failed password=hunter2 access_token=provider-secret",
"stdout_truncated": true,
"stderr_truncated": false
}]
}
});
let entries = log_entries(Some(&events), Some("task-secret"));
let entry = &entries.as_array().unwrap()[0];
assert_eq!(
entry["stdout_tail"],
"upload token=[redacted] Authorization: Bearer [redacted]"
);
assert_eq!(
entry["stderr_tail"],
"failed password=[redacted] access_token=[redacted]"
);
assert_eq!(entry["stdout_bytes"], 128);
assert_eq!(entry["stdout_truncated"], true);
assert_eq!(entry["secret_like_values_redacted"], true);
assert_eq!(entry["redacted_fields"][0], "stdout_tail");
assert_eq!(entry["redacted_fields"][1], "stderr_tail");
let tasks = task_summaries(Some(&events));
let task = &tasks.as_array().unwrap()[0];
assert_eq!(
task["failure_reason"],
"failed password=[redacted] access_token=[redacted]"
);
assert_eq!(
task["machine_error"]["message"],
"failed password=[redacted] access_token=[redacted]"
);
assert!(!serde_json::to_string(&entries)
.unwrap()
.contains("provider-secret"));
assert!(!serde_json::to_string(&tasks).unwrap().contains("hunter2"));
}
#[test]
fn artifact_download_and_export_reports_expose_safe_session_boundaries() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();

View file

@ -97,6 +97,11 @@ expect(
"mutating commands require confirmation",
/Mutating or dangerous commands support `--yes`[\s\S]*confirmation-required safe failure[\s\S]*do not send coordinator requests/
);
expect(
criteria,
"log redaction criteria",
/Logs are capped, truncated honestly[\s\S]*preserve byte counts and truncation flags[\s\S]*Secret-like values are redacted[\s\S]*common token\/password\/bearer patterns/
);
expect(
cliFirstAcceptance,
"CLI-first acceptance report",
@ -219,6 +224,7 @@ for (const [name, pattern] of [
["quota coordinator usage coverage", /fn quota_status_queries_public_coordinator_usage\(\)/],
["task event summary coverage", /fn process_task_log_and_artifact_reports_summarize_task_events\(\)/],
["task locality failure summary coverage", /fn process_task_log_and_artifact_reports_summarize_task_events\(\)[\s\S]*source snapshot unavailable and direct connectivity unavailable[\s\S]*locality_failure/],
["log secret redaction coverage", /fn log_and_task_reports_redact_secret_like_values\(\)/],
["artifact download/export report coverage", /fn artifact_download_and_export_reports_expose_safe_session_boundaries\(\)/],
["process control report coverage", /fn process_restart_and_cancel_reports_expose_control_boundaries\(\)/],
["task restart report coverage", /fn task_restart_reports_clean_boundary_requirements\(\)/],
@ -348,6 +354,11 @@ expect(
"CLI renders task placement reasons",
/fn push_task_placement_reasons[\s\S]*placement \{task_name\}: \{node\}/
);
expect(
cli,
"CLI redacts secret-like log values",
/fn log_entries[\s\S]*redact_secret_like_text[\s\S]*secret_like_values_redacted[\s\S]*redacted_fields[\s\S]*fn redact_secret_like_text[\s\S]*access_token=[\s\S]*password=[\s\S]*bearer /
);
expect(
cli,
"CLI classifies machine-readable error categories",