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
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.
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 throughfile_hash, which this HMAC covers, so substituting the file means substituting a digest the operator secret signed.versionis 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, andfile_hashgoes 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'shmac, 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
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
'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: usemode: autoon the chain entity to capture every entry on a claimed channel (the explicit flag becomes optional); use the orchestratorAuditTrailInterface::record()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 two-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, 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 toerror_log(). The status report raises an error either way, because no secret entity exists at all. Existing rows meanwhile carrysecret_idvalues 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_tsahas nothing of the kind to keep, and uninstalling it drops its per-chain cron throttle and no more. A storedtsa_timestamprow references its provider by the entity'suuid(), andChainTimestamper::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:
- 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 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:
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.
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:
- 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.
- Delete the secret only if it has never signed anything. Its own delete form refuses otherwise, and says so.
- 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_secretkeeps its Key reference; the Key entity itself stays.getSecret()resolves cleanly even for retired secret ids, andpurgeSegmentArchiveFile()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 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.
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_hashsigns 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_uriis 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 theaudit_trail_entitysubmodule: 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: theaudit_trail_file,audit_trail_user_authandaudit_trail_tsasettings forms are all gated onadminister audit trail, and the TSA bridge's verification routes onrun audit trail verification.
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
'audit_trail' => 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).