Threat model¶
A single-page reference for security reviewers and compliance
assessors evaluating audit_trail for an audit-worthy
deployment. Every claim below cites the code path or doc
section that establishes it; this document is a synthesis,
not the canonical source.
For operator-side deployment guidance (rotation, key-provider
selection, retention policy), see security.md.
For the verifier's algorithm, see
verification.md. For the cryptographic
construction in detail, see architecture.md.
For the formal vulnerability-reporting process, see
security.md § "Reporting a vulnerability".
1. Scope and purpose¶
audit_trail is a tamper-evidence module: it makes
post-write modifications to audit rows provably detectable.
It is not a tamper-prevention system. An attacker with
sufficient privilege can still destroy or rewrite the chain;
the chain guarantees that doing so leaves evidence.
What the chain protects:
- Integrity of every chained row written between a chain's
genesis and its current head (
security.md§ "What it guarantees"). - Lifecycle attestation for every retention action
(archive, live-purge, file-purge, transient-purge), so the
chain itself records its own retention history
(
architecture.md§ "Segment lifecycle";src/Entity/AuditTrailSegment.php). - GDPR-respectful retention of operator-identifying
context via the permanent / transient bucket split + the
transient-purge mechanism (
security.md§ "Two-tier retention";docs/configuration.md§ "Pass ordering").
What the chain is NOT:
- Not an access-control layer. Anyone with Drupal-level
write access can write legitimate chained entries; access
control is upstream (
AccessCheck/ route permissions). - Not at-rest encryption.
context_permanentis stored as raw JSON; anyone with DB read access reads it (security.md). - Not a completeness guarantee. The chain protects what
reaches it; if a call site bypasses the orchestrator the
event never enters the chain (
docs/security.md).
2. Trust model — actor matrix¶
Synthesized from security.md § "Threat model". Each row: what the actor can
do, what the chain detects, where the mitigation lives.
| Actor | Capability | Chain detects? | Mitigation |
|---|---|---|---|
| Anonymous reader | Reads logs without authorization | N/A (chain integrity unaffected) | Standard Drupal access control |
| Authenticated audit-writer | Writes legitimate entries via the logger | Within scope — cannot break the chain | None needed |
| Sysadmin with DB write access, no secret | Edits / deletes rows directly | Yes — detected at next verification; forged secret_id surfaces as "secret #N not available" (docs/security.md) |
Run verifyAll() on cron |
| Sysadmin with DB read access only | Reads the DB to extract secret bytes | Depends on Key provider — providers other than config keep bytes outside the DB (security.md) |
Pick a non-config Key provider |
| Compromised app user with code execution | Calls the logger with forged context | No — entries appear in the chain because they were logged (security.md) |
Application authentication + access control |
| Compromised host (root + DB-superuser + Key bytes) | Forges a complete fresh chain | No — not stopped by the chain alone (docs/security.md) |
External WORM export + qualified RFC-3161 TSA timestamps (audit_trail_tsa submodule) |
| Lock-contention attacker | Stalls the write lock to drop a target row | Yes — every drop bumps audit_trail.dropped_under_contention in State; status report flags non-zero (security.md, docs/architecture.md) |
Monitor the status report |
3. Defenses — what the chain detects¶
Each defense names the mechanism + the source file that establishes it. All operate on every chained row by default.
- Public hash chain link.
row.previous_hashmust equal the predecessor'shash. Catches inserted, removed, or reordered rows (src/AuditTrailVerifier.php~verifyRow(),verification.md). - Public hash integrity.
row.hashis recomputed viaSHA-256(canonicalize(payload))and compared in constant time. Catches any column edit on a past row. Publicly verifiable — no operator secret required (security.md). - Operator HMAC layer.
row.hmacis recomputed viaHMAC-SHA-256(row.hash, secret). Catches rows inserted directly into the DB by an attacker who has DB-write access but lacks the signing secret (src/AuditTrailVerifier.php). - Schema-level chain-fork prevention. A
UNIQUE (chain, previous_hash)index makes it impossible for two rows in the same chain to share the same predecessor — concurrent writers can't silently extend the chain in two places (security.md). - Multi-tamper detection in a single walk. The verifier
records every contiguous broken range and keeps walking
after recovery, surfacing them all in one verdict via
broken_ranges(security.md). - Signed checkpoints.
audit_trail_checkpointrows are themselves HMAC-signed under the row's signing secret. Forged checkpoints fall back to a full walk + raisecheckpoint_forged: TRUE(security.md,verification.md). - Segment-event lifecycle cross-reference. Every
lifecycle transition writes a chained
segment_*event back into the chain; the matchingaudit_trail_segmentrow records the event'saudit_trail.idin its*_event_idcolumn, signed intolifecycle_hmac. The verifier cross-checks both directions (docs/configuration.md,docs/architecture.md). - Three-layered archive HMAC. WORM archive records carry
three independent HMACs: chain-content (over exported
rows), file (over the on-disk NDJSON bytes), and lifecycle
(over mutable state columns)
(
security.md). - NULL-transient legitimization. The verifier accepts
context_transient = NULLonly when a covering segment attests the transition (transient_purged_at != 0ORarchived_at != 0). An attacker who NULLs the column without a covering segment trips the verifier (security.md). - Lock-contention drop counter. If the chain-write lock
can't be acquired within 5 seconds, the entry is dropped
rather than blocking the user's request. Every drop
increments
audit_trail.dropped_under_contentionin State and surfaces a warning on/admin/reports/status(docs/architecture.md). - WORM file tampering detection. The whole NDJSON file's
SHA-256 is signed into
audit_trail_segment.archive_hmacat archive time — byte-level edits anywhere in the file are detectable independent of the per-row HMAC layer (docs/architecture.md).
4. Non-goals — what the chain does NOT defend against¶
The honest list. A regulated buyer who finds an undocumented attack later loses trust faster than one who saw it called out upfront.
- Forgery by an attacker who holds the signing secret.
The secret IS the trust root; once leaked the operator
HMAC layer can be replayed at will. Mitigation: WORM
export + RFC-3161 TSA timestamps on chain heads
(
audit_trail_tsasubmodule) — the timestamp is independent evidence the chain existed in a given state at a given time (security.md). - Third-party-verifiable provenance without an external
trust anchor. The public hash chain proves integrity
relative to the row data; without an external anchor it
cannot prove the chain hasn't been rebuilt wholesale.
Mitigation: qualified TSA on chain heads + WORM
snapshot (
security.md). - Application-level forgery via code execution. An
attacker with PHP code execution can call the logger with
whatever context they like; the chain faithfully records
the forged entry. Mitigation: sound application
authentication + access control upstream of the logger
(
security.md). - Confidentiality of
context_permanent. Anyone with DB read access reads the permanent bucket verbatim. The chain provides integrity, not encryption. Mitigation: at-rest DB encryption if confidentiality matters (security.md). - Completeness. If a call site forgets
'chain' => TRUE(on a chain inmode: flag) or bypasses\Drupal::logger()entirely (raw DB writes, out-of-process work), the event never enters the chain. Mitigation: prefermode: autofor chains where every channel entry must chain; enforce'chain' => FALSEopt-outs with a code-review rule (docs/security.md). - Bytes of a file-purged segment. Once
file_purge_afterelapses, the NDJSON file is unlinked from disk. The bookkeeping row stays (so the verifier can bridge across the now-empty range viaanchor_before/anchor_after), but the row content itself is gone. This is intentional retention — not an attack surface, but a property a reviewer should know (docs/architecture.md).
5. Cryptographic primitives and assumptions¶
| Primitive | Where used | What's signed | Breakage implication |
|---|---|---|---|
| SHA-256 | AuditTrailVerifier::canonicalize() → row.hash; archive file content; anchor_before / anchor_after; checkpoint last_hash; context_transient_hash (docs/architecture.md, security.md) |
Canonical row payload (channel, chain, severity, action, resource, context_permanent, context_transient_hash, created, secret_id, previous_hash) | The public-verifiability layer breaks. Operator HMAC layer still holds. Migration path: per-row secret_id dispatch lets a stronger algorithm be introduced on a forward-only basis |
| HMAC-SHA-256 | row.hmac, archive_hmac, lifecycle_hmac, audit_trail_checkpoint.hmac, audit_trail_secret.identity_hmac (security.md) |
The output of the SHA-256 layer above, keyed by the operator's signing secret bytes | The operator-verifiability layer breaks. Public hash chain still holds — anyone can re-verify structural integrity. Same forward-only migration path |
| Canonical JSON | AuditTrailVerifier::canonicalize() (docs/architecture.md) |
The row payload columns, with recursive ksort(SORT_STRING) + JSON_UNESCAPED_SLASHES \| JSON_UNESCAPED_UNICODE \| JSON_THROW_ON_ERROR |
Byte output is pinned by tests/src/Unit/CanonicalizeBytesTest.php. Any change to the encoding fails CI; load-bearing for every signed row |
| Key-module-backed secret repository | KeyBackedSecretRepository::getSecret() (docs/architecture.md) |
The secret bytes never live on audit_trail entities; they live in whatever drupal/key provider the operator chose (file, env, Vault, Secrets Manager, HSM) |
Provider choice determines the attack surface for byte extraction. See operator responsibilities § 6 |
| RFC-3161 TSA timestamping | audit_trail_tsa submodule (security.md) |
Chain heads / archive batch boundaries, signed by a qualified third party | External trust anchor — the timestamp is the evidence the chain existed in a given state at a given time. The mitigation for the "secret-leaked-then-chain-rewritten" non-goal |
6. Operator responsibilities¶
Preconditions for the defenses above to hold. Ordered by criticality.
- Pick a
drupal/keyprovider that keeps bytes outside the DB. The defaultconfigprovider stores bytes in the Drupal database — fine for dev, NOT fine for a deploy where DB-read compromise is in scope. Use file / env-var / AWS Secrets Manager / GCP Secret Manager / Azure Key Vault / HashiCorp Vault / HSM-backed providers (security.md). - Run verification on cron (15-min to hourly) and alert
on any non-
okresult. Treataudit_trail.dropped_under_contention > 0the same way. Cron-driven incremental verification is built in —auto_verify_enabled: TRUE(security.md). - WORM-export archive files off-host. Move NDJSON files
to S3 Object Lock / Vault / equivalent write-once storage
after archive. Without this, an attacker with host
compromise can rewrite the archive AND its chained
attestation
(
docs/security.md). - For long-term integrity, enable
audit_trail_tsa. A qualified RFC-3161 TSA on chain heads / archive batches is the mitigation for the secret-compromise non-goal (§ 4, item 1). Without it, "the chain was rebuilt wholesale" is unprovable (security.md). - Rotate the operator HMAC secret periodically. Yearly,
or on personnel change. The activate-then-retire
rotation order is safe to schedule unattended; a crash
mid-rotation leaves two active entities (benign — fresh
writes still succeed) rather than zero (which would halt
chained writes)
(
security.md,docs/configuration.md). - Honor the granularity-vs-threshold rule. Configure
segment_granularityto be smaller than any retention threshold that reflects a compliance commitment. Larger granularity dominates the threshold —transient_purge_after = P7Dpaired withsegment_granularity = monthmeans the transient column actually lives in DB for up to ~37 days, not 7. The chain forms warn on this at save time (docs/configuration.md§ "Granularity must not exceed the smallest applicable threshold"). - Keep
archive_directoryprivate. The settings form refusespublic://paths; an absolute host path outside the web root is also acceptable. Audit rows may carry PII; serving them from the web tree by accident is a disclosure incident (src/Form/AuditTrailSettingsForm.php— archive directory validation). - Document incident response before the first incident.
Chain-break alerts and lock-contention warnings need a
pre-defined response procedure (who is paged, what's
the first triage step, when is the master secret
considered compromised). Improvising under pressure is
how secrets leak (
security.md).
7. Defense-in-depth layers beyond the chain¶
Secondary protections shipped alongside the chain itself. Each closes a different attack surface than the core HMAC construction does.
- Private routing keys stripped at the producer. Bridge
→ contributor private channels (
_audit_trail_*) are stripped byAuditTrailLogger::bucketContext()before the row hits disk, so routing metadata can't leak into long-term retention (src/Logger/AuditTrailLogger.php—bucketContext()). - Producer-side safety on
AuditTrail::event(). All internal failures (contributor crashes, secret missing, DB error) are caught and logged via watchdog; the caller's primary operation always succeeds even when the audit row can't persist. Prevents a buggy bridge from cascading into entity-save hook failures (src/AuditTrailInterface.php§ event() docblock;src/AuditTrail.php—event()outer try/catch). - XSS escape in the entries controller.
AuditTrailEntriesController::renderEntityRefFromResource()applieshtmlspecialchars()to the parsed machine name before concatenating into#markup— Twig autoescape is the normal shield, this is the belt-and-braces extra layer for rarely-rendered fields (src/Controller/AuditTrailEntriesController.php). _admin_route: TRUEon every audit-trail route. Restricts dynamic_page_cache, ensures the admin toolbar / context applies to operator views, and unifies the cacheability metadata across the surface (audit_trail.routing.yml).- Config dependency declarations protecting Keys.
AuditTrailSecret::calculateDependencies()andAuditTrailTsaProvider::calculateDependencies()declare their referencedkey.key.*entities as config dependencies. Drupal's config-dependency handler then refuses to delete a Key while any signing-secret entity references it (cascade-deletes the secret entity if the Key is force-removed — preferable to a dangling secret pointer that silently fails verification later) (src/Entity/AuditTrailSecret.php—calculateDependencies()). - Per-row
secret_iddispatch for chain continuity across rotation. A single chain can span any number of rotated secrets without breaking verification; older rows verify under their original signing secret, newer rows under the current one. A row referencing a retired secret reports "secret #N not available" — the operator investigates as either a legitimate retirement or a forgery attempt (verification.md). - Acknowledgment ranges for legitimate gaps. Operator
attestations that a specific row range is known to be
unverifiable (deleted in an incident, restored from
pre-chain backup). The verifier silently skips
acknowledged ranges and reports them in the verdict,
rather than treating them as breaks
(
security.md).
8. Reporting¶
For vulnerability reports, use the drupal.org Security
Advisory process. The formal disclosure procedure is in
security.md § "Reporting a vulnerability".
For general security questions (deployment, hardening guidance, threat-model questions for a specific use case), the project's drupal.org issue queue is the right place.