Skip to content

fix(audit,query): redact secrets in audit log fields and @query errors (#137)#160

Open
tcconnally wants to merge 1 commit into
mainfrom
fix/137-audit-log-secret-leak
Open

fix(audit,query): redact secrets in audit log fields and @query errors (#137)#160
tcconnally wants to merge 1 commit into
mainfrom
fix/137-audit-log-secret-leak

Conversation

@tcconnally
Copy link
Copy Markdown
Owner

Summary

Closes #137 — second item in the v1.0.6 hotfix bundle (after #136 / PR #159).

Pre-1.0.6, @query "curl -H 'Authorization: Bearer ghp_…'" produced correctly-redacted rendered output BUT:

  1. Persisted the raw bearer token in ~/.perseus/audit_log.jsonl (via the command field of the shell_exec audit event)
  2. Leaked the same secret in @query error/timeout/no-output messages back into render output

Render-time redaction only runs on the final assembled output — not on audit fields, and not on error strings constructed before the redaction pass.

Fix

src/perseus/audit.py

  • audit_event() now passes every user-supplied field value through redact_text() before serializing to JSONL
  • Structural fields (directive, exit_code, duration_ms, pid, etc.) are exempt via explicit allowlist _AUDIT_NEVER_REDACT_KEYS — they are never user-supplied secrets
  • New helper _audit_redact_value() walks nested dicts and lists recursively
  • New config knob audit.redact_fields (default true); operators can opt out for forensic mode where the audit log is itself the secured artifact
  • On redact_text failure, fall back to raw value rather than dropping the audit entry — observability beats perfect redaction

src/perseus/directives/query.py

  • Exit-nonzero header: cmd + stderr passed through redact_text before interpolation
  • No-output message: cmd redacted
  • TimeoutExpired branch: cmd redacted
  • Generic exception branch: str(exc) redacted (exc.args often includes full cmd via shell argv)

Tests

5 new regression tests in tests/test_audit_log.py:

  • test_audit_event_redacts_aws_key_in_command_field
  • test_audit_event_redacts_bearer_token_in_command_field
  • test_audit_event_does_not_redact_structural_fields
  • test_audit_event_redact_fields_can_be_disabled (forensic opt-out)
  • test_audit_event_walks_nested_dict_fields

Test results

  • All 5 new regression tests pass
  • tests/test_audit_log.py parity vs main confirmed: 6 pre-existing failures on both branches (tracked separately; unrelated to this change)
  • tests/test_redaction.py: 21/21 pass unchanged

CHANGELOG

New [1.0.6] — UNRELEASED section added. Note: PR #159 (for #136) also adds a 1.0.6 section. Whichever PR merges second should reconcile the two entries.

Migration

No config breaking changes. The new audit.redact_fields default of true is strictly more secure than pre-1.0.6 behavior.

Forensic Mode

Operators who specifically need the audit log to contain raw secrets (for incident forensics where the log is itself secured and access-controlled) can opt out:

audit:
  redact_fields: false

Second of 12 PRs in the v1.0.6 milestone. Suggested next: #129 (trust profile override) or #138 (query timeout leak).

#137)

Pre-1.0.6, calls like `@query "curl -H 'Authorization: Bearer ghp_…'"` produced
correctly-redacted render output BUT persisted the raw bearer token in
`~/.perseus/audit_log.jsonl` (via the `command` field), and leaked the same
secret in `@query` error/timeout/no-output messages back into render output.

Render-time redaction only applies to the final assembled output, not to
audit fields or to error strings constructed before the redaction pass runs.

Two fixes:

src/perseus/audit.py:
- audit_event() now passes every user-supplied field value through
  redact_text() before serializing to JSONL. Structural fields (directive,
  exit_code, duration_ms, pid, etc.) are exempt via an explicit allowlist
  (_AUDIT_NEVER_REDACT_KEYS) — they are never user-supplied secrets.
- New _audit_redact_value() helper walks nested dicts and lists recursively.
- New config knob audit.redact_fields (default true). Operators can set
  false for forensic mode where the audit log is itself the secured artifact.
- On redact_text failure, fall back to raw value rather than dropping the
  audit entry — observability beats perfect redaction, and rendered output
  is the primary defense.

src/perseus/directives/query.py:
- Exit-nonzero header: cmd + stderr now passed through redact_text before
  interpolation.
- No-output message: cmd redacted.
- TimeoutExpired branch: cmd redacted.
- Generic exception branch: str(exc) redacted (exc.args often includes
  the full cmd via shell argv).

Tests (tests/test_audit_log.py):
- test_audit_event_redacts_aws_key_in_command_field
- test_audit_event_redacts_bearer_token_in_command_field
- test_audit_event_does_not_redact_structural_fields
- test_audit_event_redact_fields_can_be_disabled (forensic opt-out)
- test_audit_event_walks_nested_dict_fields

Test results:
- All 5 new regression tests pass.
- tests/test_audit_log.py parity vs main confirmed (6 pre-existing failures
  on both branches — unrelated to this change; tracked separately).
- tests/test_redaction.py: 21/21 pass (unchanged).

Closes #137
Refs milestone v1.0.6
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

audit bug Something isn't working P1-high security

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: @query audit log leaks secrets in command text and stderr

2 participants