Skip to content

Security model

This page is the honest reading of what audit_trail does and does not protect against, so it can be deployed without overstated assumptions.

For the consolidated threat model (actor matrix, defenses, non-goals, cryptographic assumptions, operator responsibilities), see threat-model.md. The document below covers the same ground at greater depth and adds the operator-side procedures (rotation, key providers, retention policy) that the threat-model summary points back to.

Reporting a vulnerability

Use the drupal.org Security Advisory process: drupal.org/security-team/report-issue, with audit_trail selected as the affected project. The drupal.org security team coordinates the disclosure with the maintainers and announces the fix through the standard SA-CONTRIB advisory channel.

For non-security bugs, feature requests, and general questions, file an issue in the project's drupal.org issue queue.

When reporting, please include the Drupal core version, the audit_trail version (and any enabled submodules), reproduction steps as concrete as possible, and an impact assessment (chain-integrity break, secret disclosure, operator-surface XSS, unauthenticated access to audited content).

What it guarantees

Detection of retroactive tampering on any row written between the chain's genesis and its current head. Specifically:

  • Editing any column of any past row → the recomputed hash mismatches the stored value at that row.
  • Deleting a past row → the next row's previous_hash references a vanished hash.
  • Inserting a row into the middle of an existing chain → the inserted row's previous_hash cannot match both neighbors without re-signing every downstream row.
  • Forging a row under a Key the operator does not control → the recomputed hmac mismatches the stored value, even if the public hash happens to be self-consistent.

A periodic AuditTrailVerifier::verifyAll() (cron, manual, CI) turns this into an actively-monitored property: any of the above breaks shows up as a non-ok verification result.

Multi-tamper detection in a single walk. The verifier records every contiguous broken range it encounters and keeps walking after recovery rather than stopping at the first one. The verdict's broken_ranges list contains all of them in chain order, so operators auditing a chain don't have to ack-and-re-verify iteratively to find the full extent of a tamper. The summary message advertises additional ranges when there's more than one.

Schema-level chain-fork prevention. A UNIQUE index on (chain, previous_hash) makes it impossible for two rows in the same chain to share the same predecessor. Even if the application-level lock fails to serialize, a concurrent second writer's INSERT fails with a uniqueness violation rather than silently extending the chain in two places.

Signed checkpoints. The verification checkpoints in audit_trail_checkpoint are themselves signed with the chain's operator secret (HMAC-SHA-256(secret, chain || last_id || last_hash || created || secret_id)). An attacker with database write access cannot forge or modify a checkpoint to mask tampering of the chain it covers — verifyChainIncremental() validates the checkpoint's signature before trusting it, and falls back to a full walk from genesis on mismatch (with a warning surfaced on the result). Without this property, an attacker could insert a checkpoint past tampered rows so subsequent incremental walks silently skip the break.

Three-layered archive HMAC. WORM archive records carry three separate HMACs: the chain-content HMAC bound to the exported row range, the file HMAC bound to the on-disk NDJSON bytes, and a lifecycle HMAC bound to the mutable live_purged_at / file_purged_at columns. The archive-content half is immutable; the lifecycle half is mutable but bound to the immutable parent.

Silent-drop visibility. When the per-chain write lock cannot be acquired within 5 seconds the row is dropped from the chain (it still lands in dblog / syslog). Every drop bumps the audit_trail.dropped_under_contention State counter, and a non-zero counter surfaces as a REQUIREMENT_WARNING on /admin/reports/status. An attacker provoking sustained contention (slow disk, runaway concurrent writers, webdav LOCK spam) to make the chain "lose" a target row can no longer do so silently.

What it does NOT guarantee

  • Tamper-proofness. Anyone with read access to the operator HMAC secret AND write access to the database can produce a brand new chain that validates under both hash and hmac layers. The chain is tamper-EVIDENT, not tamper-PROOF. Mitigations:
  • Use a Key provider that keeps secret bytes outside the Drupal database (file outside the webroot, environment variable, AWS Secrets Manager, HashiCorp Vault, HSM-backed providers). See configuration.
  • Periodically export chain tails to WORM storage (S3 Object Lock, immutable cloud bucket, signed external archive). After export, a forged chain disagrees with the archived snapshot at the export point.
  • Apply qualified RFC 3161 timestamps to chain heads via the bundled audit_trail_tsa submodule. The timestamp is independent evidence that the chain existed in a given state at a given time; forging a chain requires forging the corresponding timestamp from a qualified TSA — much harder than tampering with the DB.

  • Completeness. If a call site forgets 'chain' => TRUE, or bypasses \Drupal::logger() entirely (raw DB writes, out-of-process work), the event never enters the chain. The chain reflects the events that were declared, not all events. Mitigations: use mode: auto on the chain entity to capture every entry on a claimed channel (the explicit flag becomes optional); use the orchestrator AuditTrailInterface::event() for business events that flow through Drupal's entity API.

  • Confidentiality. The context_permanent column is stored verbatim (JSON-encoded) and signed raw. If a chained log carries sensitive data through that bucket, anyone with database read access reads it. The chain provides integrity, not encryption. Two mitigations:

  • Use the three-tier retention model (see below) — emit sensitive payload to context_transient, hash-sign it via context_transient_hash, and let the auto-purge NULL the column at the short retention window. The chain still verifies after purge because only the hash is signed.
  • Layer at-rest encryption on the database.

  • Replay across reinstalls. Uninstalling the module deletes the secrets, so a subsequent reinstall mints fresh ones. Existing rows then carry secret_id values whose secrets no longer resolve — the verifier reports "secret #N not available" rather than silently passing. Practically: do not uninstall the module on a production audit-worthy install. For legitimate key rotation, rotate via the secrets list page (mints a new secret with a new id, leaves older rows verifying under their original ids).

The two-tier retention model

Audit rows carry context in two separately-retained tiers:

Tier Column Signed how Purgeable? Typical content
Permanent context_permanent Raw in canonical() Never Operator-attested PII-free metadata: action codes, resource ids, structural flags.
Transient context_transient Via hash → context_transient_hash Yes, at the configured retention window — NULLed in place and the cleared range is attested by a transient-purge segment. Opt-out (empty transient_purge_after) preserves the raw bytes into the archive NDJSON instead The raw operational payload (before/after diffs, IP addresses, request URIs, full message templates).

Permanent is explicit-opt-in only: the only way data lands in that column is through a ContextContributor plugin that emits under the permanent key, or through a caller that passes an explicit _audit_trail_permanent payload. Generic caller-supplied context (['key' => 'value']) always goes to transient. This makes every "kept-forever" decision an attested code-level choice — no silent retention of unattested caller data.

When the transient column is purged at retention expiry, the cron purge worker NULLs the column in place and creates a new audit_trail_segment row attesting the transition (transient_purged_at != 0, transient_purged_event_id referencing a segment_transient_purged chain event). The row's stored context_transient_hash stays signed. Chain verification still succeeds (the canonical was computed over the hash, not the contents) AND the NULL is distinguishable from attacker tampering: the verifier accepts NULL only on rows that fall within some segment whose transient_purged_at != 0 OR archived_at != 0. An attacker who NULLs the column without a covering segment trips the verifier. Forging a covering segment requires the operator secret to sign all three segment HMACs AND the mutual segment_transient_purged chain event — a forgery that fails any layer is caught.

Transient-purge is the first lifecycle event when enabled: the cron pass runs before the archive pass on the same tick, and the per-chain settings form enforces transient_purge_after < archive_after at submit time. Once a row is archived, the NDJSON file has frozen its transient state — a later live-table NULL no longer drops PII out of long retention. When transient_purge_after is empty (opt-out), the archive captures the live row's raw transient bytes alongside the canonical, hash-bound via the signed context_transient_hash. Restoring such an archive round- trips the raw bytes back into the live row's column.

Threat model

Concrete attackers the design considers:

Attacker Capability Stopped?
Curious user with no special privileges Reads logs they're not authorized for Standard Drupal access control. Chain integrity unaffected.
Authenticated user with audit-write permission Writes legitimate entries via the logger Within scope. Cannot break the chain.
Sysadmin with database write access (no secret) Edits/deletes rows directly Detected at next verification. Forged secret_id values surface as "secret #N not available" — also a detected break. Multi-tamper walk reports every range.
Sysadmin with DB read access (no Key bytes) Reads the database to extract bytes Depends on the Key provider. The config provider keeps bytes in DB (read-accessible). File / env-var / cloud-managed providers keep bytes outside the DB; provision through drupal/key to match your threat model.
Compromised app user with PHP code execution Calls the logger with forged context Forged entries appear in the chain — but they were "logged", so verification still passes. Need application-level checks (separately verify the uid field matches an actual signed-in user, etc.).
Compromised host (root + DB-superuser + Key bytes) Forges a complete fresh chain NOT stopped by the chain alone. Mitigated only by external WORM export + qualified TSA timestamps. After rotation + retirement, the leakable window shrinks to a single currently-active secret.
Attacker provoking lock contention Drops a target row by stalling the write lock Detected: every drop bumps a State counter and surfaces a warning on /admin/reports/status. The dropped row still lands in dblog.
Outside attacker with network access only No DB write, no host access Out of scope (no impact on the chain itself).

The headline guarantee is the third row: the moment the chain becomes useful is when a sysadmin (insider or compromised) tries to clean up after themselves. They cannot do that silently. The sixth row remains a limit — application logic must be sound on its own; the chain only protects what reaches it.

Secret rotation

Operators create a new pending audit_trail_secret entity backed by a new Key, then activate it. The repository saves the new entity as active first, then retires the old one — so a crash mid-rotation leaves two actives (benign; both backed by valid Key bytes, fresh writes still succeed) rather than zero (which would halt every chained write).

Rotation restores forward integrity (a leaked old secret cannot forge new rows), but does not retroactively re-secure rows already signed under the leaked id. For rows in that window, only an independent record of the chain state at the time of writing — typically a WORM-archived snapshot plus a qualified RFC 3161 timestamp — proves they have not been re-signed by an attacker holding the leaked secret. This is the case the chain itself cannot solve; external evidence is the final answer.

Operational pattern for installs that need full long-term integrity:

  1. Rotate periodically (e.g. yearly, or on personnel change).
  2. WORM-export the segment closed by the rotation, with a qualified TSA timestamp on the closing entry (via the audit_trail_tsa submodule).
  3. Retire the old secret once the segment is externally anchored. The verifier then walks that segment structurally (linking constraint) and defers cryptographic re-verification to the WORM archive — strictly reducing the in-database leak surface to a single currently-active secret.

Hash algorithm is a fixed point at 1.0

The hash column carries a raw 64-character hex string with no algorithm prefix (e.g. it's 5ad… rather than sha256:5ad…). The module is committed to SHA-256 for the public hash layer; migrating to SHA-3 / BLAKE3 / a post-quantum primitive is not possible without an explicit algorithm-tagged schema migration that does not exist in 1.0.

If SHA-256 ever becomes the wrong choice, the migration path will be: add a hash_algorithm column (defaulting to sha-256 for existing rows), let the writer stamp new rows with a fresher algorithm, let the verifier dispatch per-row. Existing rows keep verifying under SHA-256; new rows verify under the new algorithm. The chain spans both, similar to how it spans multiple rotated secrets today via the secret_id column.

For 1.0: SHA-256 is currently considered cryptographically sound; the lock-in is documented here so deployers go in with eyes open.

Hash and HMAC are displayed in full on the entry detail page

The /admin/reports/audit-trail/entries/<id> detail page renders the row's hash and hmac columns as full 64-character hex strings. Both are public-verifiability anchors; neither is sensitive in isolation:

  • hash is recomputable by anyone with the row data. Exposing it is informational, not a leak.
  • hmac is the operator's signature over the hash. Knowing the hmac doesn't help an attacker forge new rows (they'd need the secret to do that), nor does it help them rewrite this row (since the row's stored fields plus hash give the hmac for free).

Operators who'd rather hide them on a shared screen can restrict the view audit_trail reports permission to a narrower audience.

File-purge after secret retirement

The auto-archive lifecycle's filePurge() step needs the row's signing secret to recompute the lifecycle_hmac that binds the now-mutated file_purged_at stamp to the immutable archive record. If that secret was retired AND the underlying Key entity deleted, getSecret() throws SecretNotAvailableException and filePurge() aborts — the NDJSON file lingers on disk indefinitely.

Operationally this is benign (the file is still WORM-valid; the chain still verifies); it's just a janitorial gap. Two recovery paths:

  • Don't delete the Key entity when retiring a secret. A retired audit_trail_secret keeps its Key reference; the Key entity itself stays. getSecret() resolves cleanly even for retired secret ids, and filePurge() succeeds. This is the recommended posture — the audit doc on rotation walks operators through retire-without-delete.
  • Delete the file manually if the Key is already gone. The audit_trail bookkeeping row (audit_trail_segment) stays as-is, the verifier continues to bridge the purged range via anchor_before / anchor_after. There's no data-integrity concern; only the lifecycle stamp is unverifiable post-hoc, which is a journaling issue not a chain-integrity issue.

A future audit_trail:file-purge --allow-missing-secret flag (mirroring restore --allow-missing-secret) would formalize the second path — see roadmap.

Permission split

Two operator permissions gate the audit_trail surfaces:

  • view audit_trail reports — read-only access to the entries listing and entry-detail pages. Does NOT include the ability to trigger verification.
  • run audit_trail verification — gates the verify routes (per-chain, all-chains incremental, all-chains full, TSA-row verify, TSA-chain verify). A full walk is minutes-to-hours of CPU on a multi-million-row chain, so the trigger is gated separately from read-only viewing.
  • administer audit_trail — secret entities, chain entities, acknowledgments, archives.

Defensive deployment recommendations

For an audit-worthy production install:

  • Run verification on cron (15-min or hourly), alerting on any non-ok result. The audit_trail module ships an auto-verify hook that minted checkpoints feed.
  • Export tail batches to WORM storage daily. The auto- archive lifecycle handles this if the destination is WORM-backed; otherwise call drush audit_trail:auto-archive against a WORM mount.
  • Apply RFC 3161 timestamps on chain heads via the audit_trail_tsa submodule.
  • Keep the secret bytes out of the database by picking a Key provider other than the config provider. See configuration.
  • Restrict 'chain' => FALSE usage in your codebase via a PHPCS rule or a code review checklist, so opting out is conscious.
  • Document an incident response procedure for "chain break" and "lock contention" alerts: who investigates, who restores, how the authority of the affected rows is re-established (likely: marked as unverified via audit_trail_acknowledgment, the corresponding domain action is reverified by external means).