Redact secret-shaped fields by default, countable per field path
AUDIT-WP-0004-T04, closing the workplan. Decision (Bernd): default to redaction, allow rejection per sender. Losing an audit record over one field is worse than storing it masked, but a higher-assurance channel must be able to refuse rather than mask. secret_policy is set per sender identity in AUDIT_CORE_SENDERS and defaults to redact. Detection now covers the whole payload at any depth, including lists, rather than only the top level of data. Under redaction the value is masked and the key is preserved: dropping the key would hide that the sender transmitted the field at all, which is exactly what an operator needs in order to stop it. The stored record carries details.redaction with policy and affected paths, so a reader never has to infer whether what they see is what was sent. Idempotency is unaffected - the payload hash is taken over the original request body, so redaction is deterministic and a resubmission still reconciles as a duplicate. Both outcomes are counted durably by sender, source, action and field path, exposed at GET /v1/secret-findings. Per-path aggregation is the point: the actionable unit is "stop emitting data.auth.token on membership.added", not "there were 47 redactions". Counters survive restart because the fix they drive lives in another service. Contract doc updated to match. Tests 46 -> 50. WP-0004 is finished. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
0ad526c2d8
commit
576caa2665
8 changed files with 441 additions and 29 deletions
|
|
@ -46,6 +46,21 @@ CREATE TABLE IF NOT EXISTS dead_letters (
|
|||
payload_withheld INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS dead_letters_event_idx ON dead_letters (event_id);
|
||||
|
||||
-- Counted per field path, not merely per event: the point is to stop senders
|
||||
-- emitting secret-shaped fields, and that needs the offending path named.
|
||||
CREATE TABLE IF NOT EXISTS secret_findings (
|
||||
sender TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
field_path TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
persisted INTEGER NOT NULL DEFAULT 0,
|
||||
occurrences INTEGER NOT NULL DEFAULT 0,
|
||||
first_seen TEXT NOT NULL,
|
||||
last_seen TEXT NOT NULL,
|
||||
PRIMARY KEY (sender, source, action, field_path, outcome)
|
||||
);
|
||||
"""
|
||||
|
||||
# Rejection reasons whose payload must never be persisted. Storing the body of
|
||||
|
|
@ -238,6 +253,52 @@ class SQLiteAuditBackend:
|
|||
for r in rows
|
||||
]
|
||||
|
||||
def count_secret_findings(
|
||||
self, *, sender: str, source: str, action: str, outcome: str, findings
|
||||
) -> None:
|
||||
"""Increment the per-path counter for each finding.
|
||||
|
||||
Written durably rather than held in memory: these counters exist to
|
||||
drive a fix in the sending service, and that work outlives a pod
|
||||
restart.
|
||||
"""
|
||||
now = datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
||||
try:
|
||||
for finding in findings:
|
||||
self.db.execute(
|
||||
"""
|
||||
INSERT INTO secret_findings
|
||||
(sender, source, action, field_path, outcome, persisted,
|
||||
occurrences, first_seen, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)
|
||||
ON CONFLICT(sender, source, action, field_path, outcome)
|
||||
DO UPDATE SET occurrences = occurrences + 1, last_seen = excluded.last_seen
|
||||
""",
|
||||
(
|
||||
sender, source, action, finding.path, outcome,
|
||||
1 if finding.in_persisted_data else 0, now, now,
|
||||
),
|
||||
)
|
||||
except sqlite3.Error as exc:
|
||||
raise BackendUnavailableError(str(exc)) from exc
|
||||
|
||||
def secret_findings(self, limit: int = 100) -> list[dict]:
|
||||
"""Return secret-shaped field counters, most frequent first."""
|
||||
rows = self._query(
|
||||
"SELECT sender, source, action, field_path, outcome, persisted, "
|
||||
"occurrences, first_seen, last_seen FROM secret_findings "
|
||||
"ORDER BY occurrences DESC, last_seen DESC LIMIT ?",
|
||||
(int(limit),),
|
||||
)
|
||||
return [
|
||||
{
|
||||
"sender": r[0], "source": r[1], "action": r[2], "field_path": r[3],
|
||||
"outcome": r[4], "persisted": bool(r[5]), "occurrences": r[6],
|
||||
"first_seen": r[7], "last_seen": r[8],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def _query(self, sql: str, params: tuple) -> list:
|
||||
try:
|
||||
return self.db.execute(sql, params).fetchall()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue