Skip to content

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"; the audit_trail_segment schema in audit_trail.install, signed by src/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_permanent is 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_hash must equal the predecessor's hash. Catches inserted, removed, or reordered rows (src/AuditTrailVerifier.php ~ verifyRow(), verification.md).
  • Public hash integrity. row.hash is recomputed via SHA-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.hmac is recomputed via HMAC-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_checkpoint rows are themselves HMAC-signed under the row's signing secret. Forged checkpoints fall back to a full walk + raise checkpoint_forged: TRUE (security.md, verification.md).
  • Segment-event lifecycle cross-reference. Every lifecycle transition writes a chained segment_* event back into the chain; the matching audit_trail_segment row records the event's audit_trail.id in its *_event_id column, signed into lifecycle_hmac. The verifier cross-checks both directions (docs/configuration.md, docs/architecture.md).
  • Three-layered segment HMAC. An audit_trail_segment row 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 through file_hash and version, which the archive-content HMAC covers, and it repeats version in its footer under the footer's own hmac (security.md).
  • NULL-transient legitimization. The verifier accepts context_transient = NULL only when a covering segment attests the transition (transient_purged_at != 0 OR archived_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_contention in 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_purged event's permanent bucket, and flagged on the segment row under lifecycle_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_hmac at 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_archived event that folded it in, or into segment_compacted for 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 every VerificationDepth, None included (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_tsa submodule): 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 short archive_after so 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_supplied rather than winning. 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 'audit_trail' => TRUE (on a chain in mode: flag) or bypasses \Drupal::logger() entirely (raw DB writes, out-of-process work), the event never enters the chain. Mitigation: prefer mode: auto for chains where every channel entry must chain; enforce 'audit_trail' => FALSE opt-outs with a code-review rule (docs/security.md). - Bytes of a file-purged segment. Once file_purge_after elapses, the NDJSON file is unlinked from disk. The bookkeeping row stays (so the verifier can bridge across the now-empty range via anchor_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.

  1. Pick a drupal/key provider that keeps bytes outside the DB. The default config provider 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).
  2. Run verification on cron (15-min to hourly) and alert on any non-ok result. Treat audit_trail.dropped_under_contention > 0 the same way. Cron-driven incremental verification is built in, auto_verify_enabled: TRUE (security.md).
  3. 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).
  4. 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).
  5. 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).
  6. Honor the granularity-vs-threshold rule. Configure segment_granularity to be smaller than any retention threshold that reflects a compliance commitment. Larger granularity dominates the threshold: transient_purge_after = P7D paired with segment_granularity = month means 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").
  7. Keep archive_directory private. The settings form refuses public:// 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).
  8. 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 by AuditTrail::record() before the row hits disk, so routing metadata can't leak into long-term retention (src/AuditTrail.php: the seed-transient loop in doRecord()). 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 through record() (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 the audit_trail channel 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 by AuditTrailEventSafetyTest).
  • XSS escape in the entries controller. AuditTrailEntriesController::buildEntityRefFromResource() applies htmlspecialchars() 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: TRUE on 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() and AuditTrailTsaProvider::calculateDependencies() declare their referenced key.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 through onDependencyRemoval(), and it holds for the UI, for drush and for any other caller. Config sync and module uninstall are exempt, because ConfigEntityBase::preDelete() skips dependency resolution for both; the entity then survives still naming the Key, so restoring that Key from backup restores verification. Covered in security.md § Deleting a Key (src/Entity/AuditTrailSecret.php: calculateDependencies(), onDependencyRemoval(), preSave()).
  • Per-row secret_id dispatch 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.