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"; theaudit_trail_segmentschema inaudit_trail.install, signed bysrc/Segment/SegmentSignature.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§ "Stage 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 for edits and for deletions a checkpoint or a segment still accounts for; forged secret_id surfaces as a secret that is not available (docs/security.md). No if they delete the checkpoints naming the removed rows as well: see the truncation non-goal below |
Run verifyAll() on cron; timestamp chain heads and keep the TSR off-box |
| 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 | Partly: the entry appears in the chain because it was logged, but who acted, from where, on what path and under which template are the framework's own observations and outrank whatever the call site passed, with any displaced value kept under _audit_trail_caller_supplied. The action, the resource and the rest of the context are the caller's (security.md) |
Application authentication + access control; an application that cares about the action and resource needs its own checks on those |
| 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(CanonicalJson::encode(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 segment HMAC. An
audit_trail_segmentrow carries three independent HMACs: identity (the chain, the range and the four boundary facts, sealed at creation), archive content (the file digest, the anchors and the counts, sealed at the archive op) and lifecycle (the transition stamps and the range they apply to, re-signed on every state change). The on-disk file has no signature of its own: it is bound throughfile_hashandversion, which the archive-content HMAC covers, and it repeatsversionin its footer under the footer's ownhmac(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). - Verify before retention moves evidence. Both stages that
move a range look at whether its rows still link before they
act. The archive stage reports and proceeds, because the
archive window is a legal deadline and the rows are still
live. The live-purge stage is where the evidence goes, so a
range that still does not verify and that no acknowledgment
explains leaves a permanent mark: recorded in the
segment_live_purgedevent's permanent bucket, and flagged on the segment row underlifecycle_hmac. The walk then reports the range as purged-and-unexplained instead of bridging it on the segment's anchors. Without it a chain that reported BROKEN reported VERIFIED once the range aged out, since the anchors are separate columns from the row content and an edit between them leaves both ends matching (src/Archive/ChainArchiver.php,verification.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). - Segment spine. Each archived segment's immutable facts
fold into a hash chain over segments, mirroring the one over
rows, whose head is written into the
segment_archivedevent that folded it in, or intosegment_compactedfor a consolidated row. A segment used to be attested only by its own two events, which retention purges; now every later archive re-attests it, so altering an old segment means rewriting every head above it and the chain rows recording them. It needs no secret, so it is reported at everyVerificationDepth,Noneincluded (src/Segment/SpineReplay.php,verification.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).The segment spine does not change this. It is a plain hash chain in the same database, so a holder of the secret recomputes it along with everything else. What it changes is the size of the forgery: altering one archived segment means rewriting every head above it and every chain row recording one, which is a whole-chain rewrite rather than a single
UPDATE, and diverges from any replica, backup or checkpoint. That is cost and detection surface, not a barrier (architecture.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). - Truncation by an attacker who deletes consistently. Deleting the most recent rows leaves a shorter chain that is genuinely self-consistent, so verification of the rows alone cannot notice. The checkpoint is what catches it, because it names a row that is now missing, and a segment with a signed live-purge is what distinguishes a deletion from retention. Both of those live in the same database, so an attacker who removes the rows AND every checkpoint naming them, and leaves no covering segment behind, leaves nothing inconsistent to find. Verification reports clean because what remains genuinely is a clean chain. The only record outside that database is the archive NDJSON, and archives cover ranges old enough to have been archived, which the tail by definition is not. Mitigation: a shortarchive_afterso the unarchived window is small, an archive directory the database user cannot reach, and TSA timestamps on chain heads whose TSR is retained off-box, since the stored response is itself a chain row (security.md). - Application-level forgery via code execution. An attacker with PHP code execution can call the logger with whatever action, resource and context they like, and the chain faithfully records the entry. What they cannot choose is who the row says acted, from where, or on what path: those come from what the framework observed, and a caller value that differed is kept under_audit_trail_caller_suppliedrather than winning. Mitigation: sound application authentication + access control upstream of the logger (security.md). - Confidentiality ofcontext_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'audit_trail' => 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'audit_trail' => FALSEopt-outs with a code-review rule (docs/security.md). - Bytes of a file-purged segment. Oncefile_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 | Chain\CanonicalJson::encode() to row.hash; archive file content; anchor_before / anchor_after; checkpoint last_hash; context_transient_hash (docs/architecture.md, security.md) |
Canonical row payload (version, 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: each row records its own version inside the signed payload, so a successor is stamped on new rows and every existing row goes on being checked under the one it names |
| HMAC-SHA-256 | audit_trail.hmac, audit_trail_checkpoint.hmac, and the segment's three: audit_trail_segment.hmac (identity), archive_hmac, lifecycle_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 | Chain\CanonicalJson::encode() (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/CanonicalJsonTest.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 |
| Segment spine | Segment\SegmentSpine, replayed by Segment\SpineReplay (verification.md) |
Per segment: chain, range, anchors, file_hash and row count, folded as S_k = H(k, S_k-1, D_k). NOT keyed, deliberately |
Archived segments go back to being attested only by their own two chain events, which retention purges. Pinned by tests/src/Unit/SegmentSpineTest.php |
| 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
to contributor private channels (
_audit_trail_*) are stripped byAuditTrail::record()before the row hits disk, so routing metadata can't leak into long-term retention (src/AuditTrail.php: the seed-transient loop indoRecord()). The strip belongs to that path alone: an event logged straight through a PSR-3 channel keeps every key its caller passed, prefix included, so a bridge with private metadata to thread has to write it throughrecord()(consumers.md, "Private routing keys"). - Plugin isolation on
AuditTrail::record(). A filter or a context contributor that throws is caught at its own call site, reported on theaudit_trailchannel and stepped over, so the row still lands and a buggy third-party plugin cannot cascade into the entity-save hook that triggered the event (src/AuditTrail.php: the try/catch around each plugin call). A failure of the chain write itself is the opposite case and is deliberately not caught: the caller has to learn that the entry did not land, or an attacker who can provoke a transient write failure can drop the entries that incriminate them (src/AuditTrailInterface.php§ record() docblock, pinned byAuditTrailEventSafetyTest). - XSS escape in the entries controller.
AuditTrailEntriesController::buildEntityRefFromResource()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).- Refusal to delete a Key the trail signs with.
AuditTrailSecret::calculateDependencies()andAuditTrailTsaProvider::calculateDependencies()declare their referencedkey.key.*entities as config dependencies, which makes the relationship visible in config exports, in config diffs and on the Key's own delete form. The declaration is not itself the protection: a config dependency deletes its dependents by default, which is how deleting a Key used to delete the secret naming it.preSave()on both entities refuses the deletion instead, reached throughonDependencyRemoval(), and it holds for the UI, fordrushand for any other caller. Config sync and module uninstall are exempt, becauseConfigEntityBase::preDelete()skips dependency resolution for both; the entity then survives still naming the Key, so restoring that Key from backup restores verification. Covered insecurity.md§ Deleting a Key (src/Entity/AuditTrailSecret.php:calculateDependencies(),onDependencyRemoval(),preSave()). - 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 deleted secret reports that secret as 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.