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"; 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_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 — 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_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(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.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 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 = 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).
  • 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).

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).
  • 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 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 'chain' => 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 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.

  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 → contributor private channels (_audit_trail_*) are stripped by AuditTrailLogger::bucketContext() before the row hits disk, so routing metadata can't leak into long-term retention (src/Logger/AuditTrailLogger.phpbucketContext()).
  • 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.phpevent() outer try/catch).
  • XSS escape in the entries controller. AuditTrailEntriesController::renderEntityRefFromResource() 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).
  • Config dependency declarations protecting Keys. AuditTrailSecret::calculateDependencies() and AuditTrailTsaProvider::calculateDependencies() declare their referenced key.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.phpcalculateDependencies()).
  • 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 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.