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
hashmismatches the stored value at that row. - Deleting a past row → the next row's
previous_hashreferences a vanishedhash. - Inserting a row into the middle of an existing chain → the
inserted row's
previous_hashcannot match both neighbors without re-signing every downstream row. - Forging a row under a Key the operator does not control →
the recomputed
hmacmismatches the stored value, even if the publichashhappens 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
hashandhmaclayers. 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_tsasubmodule. 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: usemode: autoon the chain entity to capture every entry on a claimed channel (the explicit flag becomes optional); use the orchestratorAuditTrailInterface::event()for business events that flow through Drupal's entity API. -
Confidentiality. The
context_permanentcolumn 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 viacontext_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_idvalues 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:
- Rotate periodically (e.g. yearly, or on personnel change).
- WORM-export the segment closed by the rotation, with a
qualified TSA timestamp on the closing entry (via the
audit_trail_tsasubmodule). - 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:
hashis recomputable by anyone with the row data. Exposing it is informational, not a leak.hmacis 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_secretkeeps its Key reference; the Key entity itself stays.getSecret()resolves cleanly even for retired secret ids, andfilePurge()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 viaanchor_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-
okresult. Theaudit_trailmodule 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-archiveagainst a WORM mount. - Apply RFC 3161 timestamps on chain heads via the
audit_trail_tsasubmodule. - Keep the secret bytes out of the database by picking a Key provider other than the config provider. See configuration.
- Restrict
'chain' => FALSEusage 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).