9ebc9c8868
---ci--- project: atelier phase: 6 milestone: v0.3 status: complete requirements: covered: [ATELIER-60..91] partial: [] ---/ci---
222 lines
9.7 KiB
Markdown
222 lines
9.7 KiB
Markdown
# Bad Example: Compliance Audit Log (Two Breaches)
|
|
|
|
> An audit logging implementation that violates **two** Atelier
|
|
> compliance principles in one example (per IDEATE-26, D-044):
|
|
> **P1** (Audit Logs are Append-Only) — a mutable audit log with
|
|
> routine `DELETE`/`UPDATE` "cleanup" — and **P9** (Secrets and
|
|
> Sensitive Data are Redacted in Audit) — a database password leaked
|
|
> into an audit record. Each violation is cited, then fixed.
|
|
|
|
## The Code
|
|
|
|
```python
|
|
# audit_log.py — the audit sink, stored in a mutable Postgres table
|
|
import psycopg2, datetime
|
|
|
|
# P1 VIOLATION: the audit log is a regular mutable table. There is no
|
|
# write-once protection, no immutable bucket, no hash-chaining.
|
|
# Any DB user with UPDATE/DELETE can rewrite history.
|
|
CREATE_TABLE = """
|
|
CREATE TABLE audit_log (
|
|
id BIGSERIAL PRIMARY KEY,
|
|
timestamp TIMESTAMPTZ NOT NULL,
|
|
event TEXT NOT NULL,
|
|
actor TEXT NOT NULL,
|
|
target TEXT,
|
|
payload JSONB,
|
|
request_id TEXT
|
|
);
|
|
-- No row-level immutability. No trigger preventing UPDATE/DELETE.
|
|
"""
|
|
|
|
def write_event(event, actor, target=None, payload=None, request_id=None):
|
|
conn = psycopg2.connect(os.environ["DATABASE_URL"])
|
|
conn.execute(
|
|
"INSERT INTO audit_log (timestamp, event, actor, target, payload, request_id) "
|
|
"VALUES (%s, %s, %s, %s, %s, %s)",
|
|
(datetime.datetime.utcnow(), event, actor, target,
|
|
json.dumps(payload), request_id),
|
|
)
|
|
|
|
# P1 VIOLATION (continued): "cleanup" that mutates the audit log.
|
|
# A routine job deletes records older than 30 days to "save space"
|
|
# and updates records to "fix typos in the actor field."
|
|
def cleanup_audit_log():
|
|
conn = psycopg2.connect(os.environ["DATABASE_URL"])
|
|
# DELETE — an audit record is destroyed. This is tampering,
|
|
# dressed as housekeeping.
|
|
conn.execute("DELETE FROM audit_log WHERE timestamp < NOW() - INTERVAL '30 days'")
|
|
# UPDATE — an audit record is rewritten. The "fix" is the
|
|
# violation; the original actor is lost.
|
|
conn.execute("UPDATE audit_log SET actor = 'admin' WHERE actor LIKE 'svc-%'")
|
|
```
|
|
|
|
```python
|
|
# The call site that leaks a secret into the audit log.
|
|
def read_config(key):
|
|
# ... fetches a secret from the secrets manager ...
|
|
value = secrets_manager.get(key) # e.g. the raw DB password
|
|
# P9 VIOLATION: the raw secret value is written into the audit
|
|
# payload. The append-only log is now a secret store.
|
|
write_event(
|
|
event="config.read",
|
|
actor="api-server",
|
|
target={"kind": "secret", "id": key},
|
|
payload={"value": value}, # <- the secret, in plaintext
|
|
request_id=req.id,
|
|
)
|
|
return value
|
|
```
|
|
|
|
The resulting audit record:
|
|
|
|
```json
|
|
{
|
|
"id": 48213,
|
|
"timestamp": "2026-08-05T09:12:03Z",
|
|
"event": "config.read",
|
|
"actor": "api-server",
|
|
"target": {"kind": "secret", "id": "db-password"},
|
|
"payload": {"value": "p@ssw0rd-sup3r-s3cr3t-plaintext"},
|
|
"request_id": "req_91c2"
|
|
}
|
|
```
|
|
|
|
A week later, the `cleanup_audit_log` job `DELETE`s this record (it
|
|
is older than 30 days in the team's "retention" — which is actually a
|
|
storage-economy decision, not a policy), and `UPDATE`s every
|
|
`svc-*` actor to `admin`. The secret was in the log for a week,
|
|
readable by anyone with `SELECT` on the table; now the record of it
|
|
having been there is gone.
|
|
|
|
## What Makes It Bad
|
|
|
|
### Breach 1 — Mutable Audit Log (Compliance P1 Audit Logs are Append-Only)
|
|
- The audit log is a regular mutable Postgres table. `DELETE FROM
|
|
audit_log WHERE timestamp < ...` and `UPDATE audit_log SET actor =
|
|
...` both succeed. The log is a draft, not a record.
|
|
- Routine `DELETE` as "cleanup" is the cardinal P1 violation: the
|
|
deletion of an audit record is itself an auditable incident, not a
|
|
housekeeping task. "We deleted old records to save space" is a P3
|
|
(Retention is Policy, Not Storage) violation *and* a P1 violation —
|
|
the retention decision is driven by storage cost, and the
|
|
mechanism is tampering.
|
|
- The `UPDATE` that rewrites `svc-deploy` → `admin` destroys
|
|
attribution (a P7 violation stacked on the P1 violation): the
|
|
original actor is lost, and the replacement (`admin`) is a shared
|
|
identity that could be any of ten engineers.
|
|
- **Fix:** the audit sink is append-only *by construction*, not by
|
|
policy. Write-once storage (WORM bucket, immutable log stream,
|
|
hash-chained ledger) enforces immutability at the substrate.
|
|
Retention is a declared policy with a meta-audit of deletions; a
|
|
human does not run ad-hoc `DELETE` jobs.
|
|
|
|
```python
|
|
# Fix: write to an append-only sink (illustrative — S3 Object Lock,
|
|
# WORM bucket, or a hash-chained ledger). The API has no update /
|
|
# delete path; the storage refuses mutation.
|
|
def write_event(event, actor, target=None, payload=None, request_id=None):
|
|
record = {
|
|
"timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
"event": event,
|
|
"actor": actor, # the authenticated principal, not "admin"
|
|
"target": target,
|
|
"payload": redact(payload), # see Breach 2 fix
|
|
"request_id": request_id,
|
|
"prev_hash": last_hash(), # hash-chaining: tampering breaks the chain
|
|
}
|
|
record["hash"] = sha256(canonical_json(record))
|
|
append_only_sink.write(record) # WORM storage; no update/delete API exists
|
|
|
|
# Fix: retention is a declared, reviewed policy — not an ad-hoc DELETE.
|
|
# When an audit segment ages out, the deletion is itself meta-audited
|
|
# in a higher-tier log with the rule that authorized it.
|
|
# (See domains/compliance/data-retention.md and audit-logs.md.)
|
|
```
|
|
- See `domains/compliance/audit-logs.md` (Audit Logs are Append-Only)
|
|
and `domains/compliance/first-principles.md` P1.
|
|
|
|
### Breach 2 — Secret Leaked in Audit Log (Compliance P9 Secrets and Sensitive Data are Redacted in Audit)
|
|
- `payload={"value": value}` writes the raw DB password into the
|
|
audit record. The append-only log is now a secret store: anyone
|
|
with `SELECT` on `audit_log` can read production credentials. The
|
|
log is harder to secure than the secrets manager it read from.
|
|
- Once the secret is in an append-only log, the remediation is
|
|
expensive — rotate the secret *and* rewrite the log's access scope
|
|
(you cannot edit the record; it is append-only). Redaction must
|
|
happen *at the logging boundary, before the record is written*, not
|
|
by opportunistic scrubbing after the fact.
|
|
- The redaction policy here is "nothing" — there is no rule for
|
|
which fields are redacted, by what mechanism, in which event type.
|
|
A redaction rule that lives in no one's head and no code is a P9
|
|
violation waiting to happen (and it happened).
|
|
- **Fix:** redaction is structural, applied at the logging boundary
|
|
before the record reaches the append-only sink. The policy is
|
|
itself auditable (which fields, by what rule, in which event).
|
|
|
|
```python
|
|
# Fix: redaction at the boundary. Log the FACT of the action
|
|
# (a secret was read), never the CONTENT of the secret.
|
|
REDACTED_FIELDS = {"value", "token", "password", "authorization", "secret"}
|
|
|
|
def redact(payload):
|
|
if not isinstance(payload, dict):
|
|
return "[REDACTED:non-object]"
|
|
out = {}
|
|
for k, v in payload.items():
|
|
if k.lower() in REDACTED_FIELDS or "secret" in k.lower():
|
|
out[k] = "[REDACTED:secret]"
|
|
else:
|
|
out[k] = v
|
|
out["_redaction"] = "secret-value-policy/v1" # the rule is auditable
|
|
return out
|
|
|
|
# The fixed audit record:
|
|
# {
|
|
# "event": "config.read",
|
|
# "actor": "api-server", # the authenticated principal
|
|
# "target": {"kind": "secret", "id": "db-password"},
|
|
# "payload": {"value": "[REDACTED:secret]"},
|
|
# "_redaction": "secret-value-policy/v1",
|
|
# "request_id": "req_91c2"
|
|
# }
|
|
# The fact of the read is logged; the secret never enters the log.
|
|
```
|
|
- See `domains/compliance/audit-logs.md` (Redaction at the Boundary)
|
|
and `domains/compliance/first-principles.md` P9. Cross
|
|
`domains/security/secrets.md` — the audit-side redaction is the
|
|
complement of secret management.
|
|
|
|
## The Cascade (Two Breaches Compound)
|
|
|
|
The two violations compound destructively. The secret enters the
|
|
mutable log (P9 breach), where it sits readable by any `SELECT`-holder
|
|
for a week. Then the `cleanup` job `DELETE`s the record (P1 breach) —
|
|
destroying the evidence that the secret was ever logged, while the
|
|
secret itself has already been exposed to every reader of the table.
|
|
The `UPDATE` that rewrites `svc-deploy` → `admin` (a P7 attribution
|
|
breach stacked on the P1 breach) means that even if a copy of the
|
|
record survived, the actor who triggered the secret read is no longer
|
|
identifiable. The team cannot answer "who read the DB password and
|
|
when" — the log that would answer it was mutated, and the secret it
|
|
leaked is now in the wild. This is the worst-case interaction of P1
|
|
and P9: a secret leak with no attributable actor and no surviving
|
|
record.
|
|
|
|
## Cross-Domain Links
|
|
|
|
- `domains/compliance/audit-logs.md` — the append-only guarantee and
|
|
the redaction-at-boundary rule this code violates.
|
|
- `domains/compliance/first-principles.md` — P1 (Append-Only) and P9
|
|
(Redacted) are the two breached principles; P7 (Attributable) is
|
|
breached by the `UPDATE` rewrite.
|
|
- `domains/compliance/evidence.md` — an audit log that can be
|
|
`DELETE`d is not admissible evidence; the append-only guarantee is
|
|
what makes it admissible.
|
|
- `domains/compliance/data-retention.md` — retention is a declared
|
|
policy with meta-audited deletions, not an ad-hoc `DELETE` job.
|
|
- `domains/security/secrets.md` — redaction at the logging boundary
|
|
is the audit-side complement of secret management.
|
|
- `domains/observability/logging.md` — audit logs are structured
|
|
logging with an append-only guarantee; the logging primitives
|
|
compose here. |