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, 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.

A deleted tail cannot be acknowledged. Acknowledgments record that an operator has seen a break, and the verifier honors one only while the rows it pinned are still there: it re-checks the anchors against the stored hashes, and the one at to_id is a row the deletion took. Recording a fresh acknowledgment over the gap fails earlier still, because the range sits past the now-shortened head and ChainRange::assertWithinHead() refuses it. So the status report reports the break and names the row, but offers no acknowledgment link for it. What clears the finding is restoring the rows from their archive segment; short of that the chain stays flagged, which is the intended outcome for entries that were removed rather than purged.

An acknowledgment is trusted because the chain records it, not because the row is signed. audit_trail_acknowledgment is an index of what the chain says, so its rows carry no signature of their own; what the verifier honors is the acknowledgment_recorded event, which is signed and linked like any other chain row. That is a stronger record than a signature on the index row was: an index row can be edited and re-signed by whoever holds the secret, and nothing about the row says it happened, while the same statement recorded as a chain event is linked into the sequence and cannot be altered or removed afterwards without breaking the chain. It does not stop somebody holding the secret from recording a fresh acknowledgment, and nothing can: what it stops is one being changed, or disappearing, after the fact. An index that disagrees with the chain is reported on /admin/reports/status and rebuilt by drush audit_trail:reindex-acknowledgments.

Two things follow. Replaying those events needs no secret, so a walk at any VerificationDepth honors an acknowledgment exactly as Strict does: the depths agree about acknowledged ranges, and a keyless walk is no longer the stricter reading it was while the index row carried its own HMAC. And an acknowledgment whose own recording event has aged out of the chain is not honored at all: the range is reported as a break, and the verdict names where the explanation went rather than telling the operator to record one they recorded years ago.

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), keyed by the secret named in the checkpoint's own secret_id column). 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 segment HMAC. An audit_trail_segment row carries three separate HMACs, each sealed at a different moment and each keyed by its own secret-id column, since the three may be signed years apart on a long-retention chain and the operator's active secret may have rotated between them:

  • identity, over (chain, from_id, to_id, anchor_before, anchor_after, from_created, to_created, created, secret_id). Sealed when the segment row is created and never re-signed. This is what stops a segment row being forged outright, or an existing one being re-pointed at a different range.
  • archive content, over (file_hash, anchor_before, anchor_after, row_count, from_created, to_created, archive_secret_id, version). Sealed at the archive operation and never re-signed. There is no separate signature over the archive bytes: the file is bound through file_hash, which this HMAC covers, so substituting the file means substituting a digest the operator secret signed. version is in the payload for the same reason the digest is. It pins the whole archive contract in one number, version 1 being NDJSON data lines with a signed footer digested with SHA-256, so outside the signature it would be the one archive fact that could be restated freely: point a reader at a decoder the file was never written for, or at an algorithm the digest was never taken under, and file_hash goes on matching while what comes out of the file is not what was archived. The same number is written into the file's own footer, covered by the footer's hmac, because an archive is meant to outlive the database and a reader holding only the file has no row to ask. And a reader refuses a version it does not know, which is what covers the case where the archive secret is gone and the signature is skipped rather than failed.
  • lifecycle, over (chain, from_id, to_id, transient_purged_at, transient_purged_event_id, archived_at, archived_event_id, live_purged_at, live_purged_event_id, verification_failed_at_purge, file_purged_at, file_purged_event_id, lifecycle_secret_id). Re-signed on every state change, which is why the range is inside it: a signature over the stamps alone could be pointed at rows it had never covered.

The first two are immutable once sealed; the third moves with the segment's state, which is what lets a purge advance the lifecycle without invalidating the evidence that the archive itself was not touched.

Silent-drop visibility. When the per-chain write lock cannot be acquired within 5 seconds the write fails. Every failure bumps the audit_trail.dropped_under_contention State counter, and a non-zero counter surfaces as a RequirementSeverity::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 the caller sees depends on the path. A \Drupal::logger() entry keeps its dblog row and loses only the chain row, because PSR-3 forbids a logger from throwing back at its caller. An AuditTrail::record() call is told: the failure propagates, so the bridge can retry or abort the operation it was recording.

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 'audit_trail' => 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::record() 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 two-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, and nothing replaces them: the module ships no secret and mints none on install, so a reinstalled site has no active secret and cannot chain a row until an operator provisions a Key and activates a secret against it. How that surfaces differs by ingress, which is worth knowing if you are watching for it: AuditTrailInterface::record() raises "secret repository unreachable for chain X", while the PSR-3 path cannot throw and falls back to error_log(). The status report raises an error either way, because no secret entity exists at all. Existing rows meanwhile carry secret_id values whose secrets no longer resolve: the verifier reports the secret as not available rather than silently passing. A newly provisioned secret cannot take an old id either, because the id is computed from the bytes: a record kept elsewhere, in an archive file or a backup, resolves only to the bytes that signed it, and a secret recreated from the original Key gets the original id back. Practically: do not uninstall the module on a production audit-worthy install. For legitimate key rotation, add a secret from the secrets list page and activate it, which leaves older rows verifying under their original ids. audit_trail_tsa has nothing of the kind to keep, and uninstalling it drops its per-chain cron throttle and no more. A stored tsa_timestamp row references its provider by the entity's uuid(), and ChainTimestamper::verifyRow() resolves the provider by that uuid to get the CA bundle the RFC 3161 response is checked against. A uuid is not allocated from a counter and cannot be reissued: recreate a provider under the very same machine name and it carries a new uuid, rows naming the old one resolve to nothing and say so, and nothing resolves to the wrong authority.

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 the canonical payload Never Operator-attested PII-free metadata: action codes, resource ids, structural flags. Plus the writer's own statements about the row, including what a value too long for its column was cut from.
Transient context_transient Via hash to 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).

Caller payload reaches permanent by explicit opt-in only: the only way it 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 about payload an attested code-level choice.

The writer adds statements of its own to that column, which are the module's rather than a caller's, and a caller can neither add one nor overwrite one: the names are claimed, and stripped from the permanent bucket write() is handed. Two are flags (_audit_trail_write_mode, _audit_trail_rolled_back). The third, _audit_trail_shortened, is caller data, and the only one worth a decision: it holds what channel, action or resource was cut from when the value was too long for its column, so that the signature stops vouching for something the caller never sent. Those three columns are already kept forever, so nothing crosses a tier that was not there before, but the part that used to be discarded is now retained, and on a path-shaped resource that tail can carry a name. Masking does not reach it either, and never did: the two lists under URL-borne credentials cover the URLs the module observes for itself, request_uri and referer, and have never applied to resource, which is the caller's own identifier for what the event was about. shortened_original_length is the operator's control over what is kept, per site and per chain, and 0 switches it off; see configuration.md.

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. What it checks on the covering segment is the lifecycle HMAC, and that is the layer to reason about: it binds the chain, the range and the stamps together, so a forged covering segment needs the operator secret. A segment whose lifecycle secret cannot be resolved is not accepted either, because withholding the attestation turns an unverifiable claim into a reported break rather than a silent pass.

Transient-purge is the first of the retention stages when enabled: it runs before archive 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 a secret that is not available: also a detected break. Multi-tamper walk reports every range. Deleting the most recent rows is caught by the checkpoint that names them, and by the absence of a segment attesting a live-purge; deleting those checkpoints as well is not caught, because what remains is a genuinely consistent shorter chain. See the truncation non-goal in threat-model.md.
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. The actor, client IP, request URI and message template are stamped by the module and outrank whatever the call site passed, so a forged entry cannot claim to be someone else's action; a displaced caller value is recorded under _audit_trail_caller_supplied. What the caller still controls is the action, the resource and the rest of the context, so an application that cares needs its own checks on those.
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 pinned per record, not per release

The hash column carries a raw 64-character hex string with no algorithm prefix (e.g. it's 5ad… rather than sha256:5ad…), and it needs none: the record carries a version instead, and that one number pins the algorithm along with everything else that decides the bytes.

Version 1 is the eleven columns Chain\ChainPayload signs, the canonical bytes Chain\CanonicalJson produces from them, and SHA-256 over those bytes. One number rather than an algorithm name, because a row hash depends on all three: change the column set, the canonical form or the digest and it is a new version, while naming only the algorithm would describe one of the three.

So moving to SHA-3 / BLAKE3 / a post-quantum primitive is a version 2 rather than a schema migration. The writer stamps new records with it, every existing record goes on being checked under the version it names, and one chain spans both the way it already spans rotated secrets through secret_id. Nothing is rewritten, which matters here more than it would elsewhere: rewriting a row to re-hash it is the operation this module exists to make evident.

A reader that meets a version it does not have reports the record as undetermined rather than mismatched. Computing SHA-256 over a row hashed with something else and calling the result a tamper is the one answer that is certainly wrong, so the chain walk, the segment list, the spine replay, the index rebuild, the checkpoint and the staged-write buffer each say what they could not check and carry on with the records behind it.

version sits inside every signature it governs. That is what makes per-record dispatch safe rather than an opening: outside the signature, it would be the one field telling a verifier which primitive to check with, and anyone who could write the row could restate it.

For 1.0: SHA-256 is currently considered cryptographically sound. The version column is what keeps that a choice the module can revisit rather than a commitment deployers have to accept for the life of their data.

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.

An archive file is not readable by the host

An archive NDJSON carries what the rows carried: actor uids, client IP addresses, request URIs and full before/after entity snapshots. On disk that is one file, and the default umask would leave it at 0644, readable by every local account on the machine.

Every archive this module puts in the archive directory is set to 0640 before any payload reaches it: owner writes, the group reads so the file can be moved to WORM storage, nobody else. That covers both routes in. ArchiveEnvelope::writeNdjson() narrows the temp file after opening it and before the first row is written, and SegmentRestorer::importFromFile() creates the destination empty, narrows it, and only then copies the operator's file into it. Neither leaves a window in which the file is complete and world-readable.

A mode that cannot be set is refused rather than warned about. An archive the module could not restrict is not written and not recorded, because the alternative is one that is signed, recorded, and readable by anyone with a shell. A mount or a stream wrapper that cannot set a mode is a place this module should not be writing archives to.

The directory the files sit in is a separate control: ArchiveLocation::resolve() refuses a web-accessible one outright, so public:// and anything under the docroot are rejected before a file is created. The shipped default is private://audit_trail, which the web server does not serve; Drupal does, and only when a module says so.

Fetching one over HTTP

AuditTrailArchiveDownloadHooks says so, for the archive directory only, and only to a user holding administer audit trail, the permission the segments page itself requires. That is what makes the path on that page a download link. Anyone else is refused outright rather than passed over, so no other module can grant a file in this directory.

Two things the claim is careful about, because hook_file_download() is asked about every private file on the site:

  • A file outside the archive directory gets no answer at all, granting nor denying, so other modules' files are theirs to rule on.
  • Membership is decided on resolved paths, not on the URI as requested. Core already refuses a target carrying . or .. (SA-CORE-2023-005); a symlink inside the archive directory pointing outside it carries neither, and is caught here.

An archive directory configured as an absolute host path is served by nothing: it sits under no stream wrapper, so no route reaches it and the segments page renders the path as inert text. Fetching it is then a filesystem operation, which is what the WORM-export recommendation below assumes anyway.

Deleting a Key

A Key an audit trail secret signs with cannot be deleted. The secret is the bytes in that Key, and every row signed under its secret_id is verified with them and with nothing else, so removing the Key would leave those rows permanently unverifiable. The refusal reaches the admin UI, drush, update functions and any other caller.

It used to be the opposite. AuditTrailSecret declares the Key as a config dependency, and a config dependency is not a protection: it declares that the dependent is affected when that config goes, and Drupal resolves "affected" as deletion unless the dependent says otherwise. So deleting a Key deleted the secret naming it, walking straight past the in-use guard on the secret's own delete form, and taking with it the only record of which Key signed which secret_id.

The declaration stays, because it is what the relationship is and config export ordering, config diffs and the Key's own delete form all read it. AuditTrailSecret::preSave() is what refuses, reached through onDependencyRemoval(), and audit_trail_tsa_provider refuses the same way for the Keys its auth mode references.

On the Key's delete form the refusal arrives before the confirmation rather than as an error afterwards: the form states that the Key cannot be deleted, lists every secret and every timestamp provider that holds it with the reason for each, links to the listing where you can act on them, and offers no submit button.

If you do mean to remove it

Remove the secret first, in this order, where each step says what it is doing:

  1. Retire the secret if it is active. Retirement keeps the entity resolvable for historical verification, which is the posture this module recommends and the one the rest of this page assumes.
  2. Delete the secret only if it has never signed anything. Its own delete form refuses otherwise, and says so.
  3. Then the Key can go.

A retired secret keeps its Key, and that is the point: the rows it signed stay verifiable. A deleted secret's secret_id never goes to other bytes either, because it is computed from the bytes, so rows signed under an old secret can never resolve to another secret's bytes and read as tampered. A TSA provider is referenced by uuid(), which is never reissued.

The one route that still removes a Key

ConfigEntityBase::preDelete() skips dependency resolution entirely for an entity that is syncing or being uninstalled, so a config import that does not carry the Key still removes it, and uninstalling key still uninstalls. Blocking those would break deployment, so they are left alone.

In that state the secret entity survives and still names the Key, which is what keeps the loss recoverable:

  • The verdict names the Key (references Key entity "x", but no such Key exists), so an operator knows which one to restore.
  • Restoring that Key from backup restores verification outright. Nothing has to be reconstructed.

A row whose secret_id matches no entity at all reports differently: "Either the secret is retired and was deleted, or the row carries a forged secret_id." Because the cascade is gone, that sentence now means a genuinely forged id rather than the aftermath of routine key hygiene.

If the mapping is lost some other way, recreating it is supported rather than surgery: add a secret on the Key that holds the original bytes, and it gets the original id back, because the id is computed from the bytes. A Key holding other bytes gives another id, so a wrong guess cannot turn those entries into forgeries.

Changing a Key's value

A secret's id is computed from the bytes its Key holds, and every row the secret signed is verified with them. The Key's own form, Drush or the entity API can change those bytes without the secret being saved, and every row the secret signed then verifies as tampered. The module warns rather than refuses, because the same form edits the Key's label, and moving the same bytes to another provider is safe:

  • The Key's edit form says, before anything is changed, which secrets that have signed use its bytes.
  • A save that leaves the Key holding other bytes says so at once. Restore the previous value then: every entry written until it is back is signed with the new bytes and needs an acknowledgment.
  • Bytes that change outside Drupal (a file replaced, an environment variable, a secrets manager) save nothing, so the status report checks every secret instead and names the Key.
  • A pending secret whose Key got other bytes is refused when it is put in the signing role. It has signed nothing: delete it and add it again on the Key, and it takes the id of the new bytes.

To sign with new bytes on purpose, create a new Key and a new secret, and rotate to it.

File-purge after secret retirement

The auto-archive lifecycle's purgeSegmentArchiveFile() 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 purgeSegmentArchiveFile() 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 purgeSegmentArchiveFile() succeeds. This is the recommended posture: the audit doc on rotation walks operators through retire-without-delete. The section above, on deleting a Key, is the larger reason for it.
  • 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.

File-purge has no --allow-missing-secret escape of its own, and needs none: it refuses only on a digest that does not match, never on a secret it cannot resolve, so the second path above is a manual unlink and nothing is waiting on a flag to make it official.

URL-borne credentials are masked before they are recorded

Every chained row records the request URI, and the entry-detail page renders it. A one-time login link, a password-reset URL or a signed callback would therefore land in the trail and stay there, readable by anyone holding view audit trail reports rather than only by someone with database access. The trail is meant to record that a request happened, not to keep a copy of the credential that authorized it.

Two fields carry a URL and both are masked: the request URI the stamp observes, and the referer core copies out of the request header. The referer matters as much as the URI: a browser sends the full same-origin URL, so the page a user lands on after clicking a one-time login link records that link in full.

The rules are config, and the site owns them. Two lists in audit_trail.settings, shipped with defaults and meant to be edited:

redact_query_keys:
  - token
  - access_token
  # …nine more, the shipped default
  - tkn            # a name this site's own URLs use
redact_path_prefixes:
  - /user/reset/*
  - /user/*/cancel/confirm
  - /webdav        # a path whose tail is a credential

redact_query_keys names parameters, matched case-insensitively and including nested ones such as ?filter[token]=. redact_path_prefixes covers what a parameter name cannot reach, because core puts the one-time login hash in the path itself: a pattern keeps its own segments and masks whatever follows, so /user/reset/* records /user/reset/7/REDACTED while /user/reset/7, the harmless "send me a link" form, is left alone. A star matches any one segment. Patterns match whole segments, case-insensitively, and tolerate one leading segment so a language prefix cannot hide a path.

Both lists are the whole rule. Add the names and paths your own URLs use; remove any that are not credentials on your site, and code is the obvious candidate, since it is here for OAuth callbacks and is a country code elsewhere. Removing one means its value is recorded in full from then on, and rows already written cannot be scrubbed, so it is a decision to make deliberately.

Both fields are on the settings form, under Credentials in recorded URLs, one entry per line.

An empty list masks nothing and is honored as written. Empty both and the feature switches off entirely: the URL is recorded exactly as the request carried it and the referer is not even parsed, which is what this field cost before any of this existed. A list that is absent, which is what an install predating these settings has, falls back to the shipped defaults instead, because nothing is not the same as a decision to mask nothing. The form fills its two fields from that same reading, so a site that has never saved these settings sees the shipped lists rather than two empty boxes, and saving the form as it stands changes nothing about what is masked.

A module that serves credential-bearing URLs of its own adds its pattern to the site's config in its hook_install(), and takes it out again on uninstall, the way modules extend one another's configuration. Append to the list the writer is using, which ForensicStamp::getPathPrefixesToRedact() returns, rather than to whatever the key holds: on a site where the key is absent the shipped patterns are in force without being stored, so a hook that appends to what it reads there stores a one-entry list and takes password-reset masking down with it. audit_trail_webdav is the case in point: its URL-token is a path segment, so it adds /webdav. There is no hook and no alter over these lists: the config is the single place to look, it exports and diffs with the rest of the site, and anyone reading it can see exactly what is masked.

A referer that parse_url() cannot parse is not recorded at all, only the placeholder: the header is whatever the client sent, and a string the module cannot examine is one it cannot call safe.

The masking runs once per request, not once per row: one request writes as many rows as it has bridges and entities, and the answer is the same for all of them. Per additional row it costs 0.12 microseconds, less than the unmasked field cost before this.

What is masked is what the framework observes. Payload a caller passes is recorded as given: a message placeholder, a link, a contributor's own data. A module that puts a credential in its own log message is the module that has to stop doing it.

Two consequences to plan around:

  • It is write-time only. context_transient_hash signs the transient bytes, so a retroactive scrub of stored rows is not available and should not be attempted: the verifier would report every scrubbed row as tampered. Values captured before the module started masking them age out through the transient purge, which NULLs the column, and the verifier accepts that state. If a leaked credential is still live, rotate it rather than trying to edit the trail.
  • A contributor is not a way to change this. The stamp outranks the caller, so a contributor's request_uri is displaced rather than recorded. The two config lists are the only lever, which is why they are a site's to edit.

Permission split

Four operator permissions gate the audit_trail surfaces, three from the parent module and one from a submodule:

  • 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 six verify routes (per-chain, all-chains incremental, all-chains full, TSA-row verify, TSA-chain verify, all-chains TSA 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, the retention settings, and every operation that destroys audit data.
  • administer audit trail entity bridge, declared by the audit_trail_entity submodule: which entity types and operations that bridge records. It is the one permission that lets a site delegate bridge configuration without handing over the signing secrets and the destructive operations above, and it lets its holder switch entity auditing off, so grant it with that in mind. No other submodule declares one: the audit_trail_file, audit_trail_user_auth and audit_trail_tsa settings forms are all gated on administer audit trail, and the TSA bridge's verification routes on run audit trail verification.

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