Files
openworker/tests/test_audit_redaction.py
harryandClaude Opus 5 c8a1f7b6ce security(audit): redact nested arguments and result previews
Two independent leaks put cleartext into `audit_events` in coworker.db.

Arguments (#397): `_sanitize_args` checked top-level keys only and handed
everything else to `_summarize`, which recursed without re-applying the policy,
so a credential in a nested `headers` / `config` object was written verbatim.
The key list also missed `authorization`, `cookie`, `credential` and
`private_key`, none of which contain "token". The walk is now one recursive
pass that checks every key at every level, with a depth limit that drops what
it stops checking rather than stringifying it through.

Result previews (#525): `result_preview` was stored with `_truncate` and no
redaction at all, so an email body, a message's text, or `run_shell` stdout from
`printenv` persisted in the clear. The preview is now rebuilt in the store from
the sanitized result structure, under the argument policy plus the result-side
content keys (output, stdout, stderr, text, snippet). Envelope fields survive,
so a row still says what ran and against what.

Fixes #397
Fixes #525

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HYvy9TcuTD4CZMVcdwwH4D
2026-09-11 00:47:55 +08:00

123 lines
3.7 KiB
Python

"""Audit-log redaction — nested arguments (#397) and tool-result previews (#525).
The audit row is the record of who ran what against which resource. It is not a place
for the credential the call carried, nor for the content the call returned.
"""
from __future__ import annotations
import json
from coworker.audit import AuditStore
def _row(tmp_path, event: dict) -> dict:
store = AuditStore(tmp_path / "audit.db")
try:
store.append({"session_id": "s1", "tool": "http_request", **event})
return store.list(limit=1)[0]
finally:
store.close()
def test_nested_credentials_are_redacted(tmp_path):
row = _row(
tmp_path,
{
"arguments": {
"url": "https://example.com",
"headers": {"Authorization": "Bearer sk-live-NESTED"},
"config": {"api_key": "sk-live-NESTED2"},
}
},
)
assert row["args"]["headers"]["Authorization"] == "[redacted]"
assert row["args"]["config"]["api_key"] == "[redacted]"
assert row["args"]["url"] == "https://example.com" # the resource still reads
assert "sk-live" not in json.dumps(row["args"])
def test_credential_keys_beyond_the_token_family(tmp_path):
row = _row(
tmp_path,
{
"arguments": {
"authorization": "Bearer sk-live-AUTHZ",
"cookie": "session=sk-live-COOKIE",
"credential": "sk-live-CRED",
"private_key": "-----BEGIN...",
}
},
)
assert set(row["args"].values()) == {"[redacted]"}
def test_nested_bodies_are_redacted(tmp_path):
row = _row(
tmp_path,
{"arguments": {"draft": {"to": "a@example.com", "body": "private text"}}},
)
assert row["args"]["draft"]["body"] == "[redacted body]"
assert row["args"]["draft"]["to"] == "a@example.com"
def test_a_credential_below_the_walk_limit_is_dropped_not_copied(tmp_path):
deep: dict = {"api_key": "sk-live-DEEP"}
for _ in range(10):
deep = {"wrap": deep}
row = _row(tmp_path, {"arguments": deep})
assert "sk-live" not in json.dumps(row["args"])
def test_browser_typing_is_still_redacted_input(tmp_path):
row = _row(
tmp_path, {"tool": "browser_type", "arguments": {"text": "hunter2"}}
)
assert row["args"]["text"] == "[redacted input]"
def test_result_preview_drops_an_email_body_and_keeps_the_envelope(tmp_path):
row = _row(
tmp_path,
{
"tool": "email_read",
"arguments": {"uid": "42"},
"stage": "finished",
"result": {
"ok": True,
"subject": "Q3 numbers",
"body": "the confidential text of the message",
},
"result_preview": "unsanitized preview from the caller",
},
)
assert "confidential" not in row["result_preview"]
assert "[redacted body]" in row["result_preview"]
assert "Q3 numbers" in row["result_preview"] # triage still works
def test_result_preview_drops_shell_output_and_keeps_the_command(tmp_path):
row = _row(
tmp_path,
{
"tool": "run_shell",
"arguments": {"command": "printenv"},
"stage": "finished",
"result": {
"command": "printenv",
"exit_code": 0,
"output": "AWS_SECRET_ACCESS_KEY=sk-live-ENV",
},
},
)
assert "sk-live-ENV" not in row["result_preview"]
assert "printenv" in row["result_preview"]
def test_preview_without_a_raw_result_is_still_stored(tmp_path):
row = _row(
tmp_path,
{"stage": "finished", "result_preview": "{\"ok\": true}"},
)
assert row["result_preview"] == '{"ok": true}'