Monitoring and alerting 

The audit chain is evidence. Evidence nobody looks at is not a control. This page wires the chain to something that pages a human.

Two things have to be running, and they are different: anchoring publishes the chain tip so a table reset becomes detectable, and verification checks the chain and compares it against the last anchor. Anchoring without verification produces evidence nobody checks; verification without anchoring cannot detect a reset at all.

Scheduler tasks 

Both tasks are registered as native TCA task types and appear in Scheduler > Add task. They are the scheduler counterparts of the CLI commands and do exactly the same work.

Task Equivalent Fails when
AuditAnchorTask vault:audit-anchor No sink accepted the anchor. An anchoring run that reached nothing outside the database provides no reset protection, and a green scheduler entry would misreport that as working tamper evidence.
AuditVerifyTask vault:audit-verify Findings were raised — subject to the nr_vault_tamper_only switch below.

Alternatively, run the commands from cron:

Example crontab
# Publish the chain tip hourly.
17 * * * *  cd /var/www/site && vendor/bin/typo3 vault:audit-anchor --format=json
# Verify chain + anchor every 15 minutes.
*/15 * * * * cd /var/www/site && vendor/bin/typo3 vault:audit-verify --format=json
Copied!

nr_vault_tamper_only 

A field on the AuditVerifyTask record. When set, the task fails only on tamper evidence — HASH_MISMATCH, UID_GAP, TABLE_RESET, EPOCH_DOWNGRADE — and treats NO_EXTERNAL_SINK and SINK_FAILURE as warnings.

Use it while sinks are still being rolled out, so a pending SIEM integration does not leave the task permanently red and mask a real tamper alarm behind alert fatigue. Turn it off once the sinks work: a persistently failing sink is a genuine gap in your evidence.

The CLI equivalent is vault:audit-verify --tamper-only.

AuditIntegrityAlertEvent 

Findings are dispatched as \Netresearch\NrVault\Event\AuditIntegrityAlertEvent , so listeners fire whether the finding came from a CLI run, a scheduled run, or a live vault operation. Nobody has to be watching the scheduler log.

A listener that pages on tamper evidence only
use Netresearch\NrVault\Event\AuditIntegrityAlertEvent;
use TYPO3\CMS\Core\Attribute\AsEventListener;

final readonly class VaultIntegrityPager
{
    #[AsEventListener(identifier: 'my-ext/vault-integrity-pager')]
    public function __invoke(AuditIntegrityAlertEvent $event): void
    {
        $alert = $event->getAlert();

        if ($event->isTamperEvidence()) {
            $this->pager->page($alert->reason->value, $alert->message);

            return;
        }

        $this->logger->warning($alert->message, ['reason' => $alert->reason->value]);
    }
}
Copied!

Contract, and it matters:

  • The event is informational, not vetoable. The finding has already happened; there is nothing to cancel.
  • Listeners must be fast and must tolerate both contexts. The SINK_FAILURE path fires inside the audit write path of a live vault operation, not only from a CLI verification run.
  • A throwing listener is caught and logged at the dispatch site and never propagates into the audited operation — and never costs the remaining findings.
  • isTamperEvidence() is the intended discriminator between paging and logging.

Two dispatch sites exist: AuditSinkRegistry raises SINK_FAILURE when a sink refuses a record, and ChainTipAnchorService raises the tamper-evidence codes and NO_EXTERNAL_SINK from verification.

The bundled AuditIntegrityAlertSinkListener already forwards every alert to the enabled external sinks, so an alert reaches your SIEM by the same route as the entries — no extra wiring needed for that part.

Break-glass has its own events — BreakGlassActivatedEvent and BreakGlassDeactivatedEvent . Alert on activation; see Break-glass usage policy and review.

Wiring the sinks 

See Sinks for what each sink does. Configuration notes that only matter operationally:

Syslog 

Extension configuration
auditSinkSyslogEnabled = 1
auditSinkSyslogIdent = nr-vault-prod
Copied!

The cheapest useful sink. Facility is fixed at LOG_LOCAL0; only the ident is configurable, which is the field you actually need to vary when several TYPO3 instances share a host — set it per instance.

Example rsyslog rule
# /etc/rsyslog.d/30-nr-vault.conf
local0.*  action(type="omfwd" target="siem.example.internal" port="514" protocol="tcp")
& stop
Copied!

Route on the ident to separate instances, and on severity to separate signal from noise: LOG_CRIT is tamper evidence, LOG_ERR is a delivery failure, LOG_NOTICE is an anchor, LOG_WARNING a failed audit entry, LOG_INFO a successful one.

NDJSON file 

Extension configuration
auditSinkFileEnabled = 1
auditSinkFilePath = /var/log/typo3/nr-vault-audit.ndjson
auditSinkAnchorPath = /var/log/typo3/nr-vault-anchors.ndjson
Copied!

Both paths must be outside any public root — a path under one makes the sink report itself disabled rather than writing anyway. Files are created 0600 and directories 0700, and each line is written under an exclusive flock().

auditSinkAnchorPath is what AnchorFileReader reads back, so it is the file whose integrity carries the reset-detection property. Ship it somewhere append-only or off-host; an anchor file the attacker can truncate is not a baseline. Note that the anchor path also receives alert records — that is normal traffic, and the reader skips non-anchor lines.

If you rotate these files, do not rotate the anchor file with a policy that truncates or discards old lines unless the copies are archived. The reader takes the highest anchored sequence it can find; losing history shortens your detection reach.

Webhook 

Extension configuration
auditSinkWebhookEnabled = 1
auditSinkWebhookUrl = https://siem.example.internal/collector/nr-vault
Copied!

One JSON POST per record, with a type discriminator (entry / anchor / alert) and a source marker, so one endpoint routes all three kinds.

The scheme is restricted to http and https. An enabled-but-unconfigured webhook reports itself disabled rather than claiming to be external evidence while delivering nothing.

What to page on 

Reason code Response Why
TABLE_RESET Page The chain no longer contains the anchored tip. Either the audit table was wiped, or someone restored a backup without telling you. Both need a human now — Suspected audit tampering.
HASH_MISMATCH Page Rows were altered, or the master key and the table are from different points in time. Do not wait for the pattern to repeat.
EPOCH_DOWNGRADE Page An attempt to move rows onto a weaker or keyless algorithm. Benign causes exist (a lowered auditHmacEpoch), but the malicious one is deliberate and targeted.
UID_GAP Page Rows were deleted from the chain.
NO_EXTERNAL_SINK Ticket, escalate if it persists Hardened only. Not an attack — but while it holds, a full reset would be undetectable. Treat a gap that survives one business day as an incident.
SINK_FAILURE Alert on rate, not on a single event A single transient failure is noise. Sustained failure means your evidence is only in the database it is meant to protect. Alert on n failures in a window, and on the same sink failing continuously.
BREAK_GLASS Page, and review afterwards Reserved code; also alert on BreakGlassActivatedEvent directly. An activation is an incident by definition — Break-glass usage policy and review.

Also worth alerting on, from outside the reason-code set:

  • The anchor task failing — it fails precisely when no sink accepted the anchor, which is exactly the state that silently removes your reset protection.
  • The verify task not running — a stalled scheduler is indistinguishable from a clean chain if you only watch for failures. Alert on absence, not just on failure.
  • Anchor staleness. Compare the newest anchor's timestamp against your expected interval. A stale anchor means the baseline is old even though nothing reported an error.
  • ``access_denied`` audit rows. A burst of them is reconnaissance or a broken integration; either way somebody should know.
  • ``master_key_rotate_start`` without a successful ``master_key_rotate_end`` — a half-finished rotation (If it fails).

Sink failure counters 

AuditSinkRegistry counts failures for the lifetime of the request: getFailureCount() in total, and getFailureCountsBySink() keyed by sink identifier (syslog, file, webhook). These are what let a health surface say "the audit pipeline stopped flowing" rather than only "something logged an error".

Two behaviours to know about when reading them:

  • A sink whose own enablement probe throws is counted as failed and treated as disabled — under the record kind enablement-probe. Without that, a misconfigured sink could throw outside the per-call handling and take the audited operation down.
  • Alert delivery is non-reentrant. A SINK_FAILURE alert is itself delivered through the sinks; a failure observed while delivering an alert is logged and counted but raises no further alert. Otherwise one broken sink would recurse until the stack ran out. So the counters can exceed the number of alerts you receive — by design.

Because the counters are per-request, they are a signal for a health check or a custom listener, not a long-term metric. For trends, count SINK_FAILURE alerts in the SIEM.

Persisted delivery state 

The per-request counters are not the whole picture. The sink registry also persists each sink's delivery state — last success, last failure, the error text, and the consecutive-failure count — in sys_registry. A freshly started process therefore still knows that a collector has been unreachable for days, which no request-scoped counter can tell you.

vault:doctor surfaces it as one audit.sink_state.<sink> finding per enabled sink, warning under the standard profile and critical under hardened. A sink counts as stale once its last successful delivery is older than auditSinkStaleDeliveryHours (default 24). A sink that is enabled but has never delivered successfully is reported as such rather than as healthy.

This is the state to monitor for "the audit pipeline is quietly broken", because it survives process boundaries and does not depend on anyone having been watching when the failure happened.

Periodic vault:doctor 

Run it on a schedule, not only at deploy time. Configuration drifts: someone unticks a setting in Admin Tools > Settings, a key file's permissions change, a sink URL is edited.

vendor/bin/typo3 vault:doctor --profile=hardened --format=json
Copied!

The exit code is the contract: 0 pass, 1 warnings, 2 critical. Alert on 2, ticket on 1, and — as with the verify task — alert on the check not having run at all.

The scheduled run above is passive: it reads state, it does not test delivery. To prove end-to-end that every enabled sink still accepts evidence, add a less frequent run with active probes:

vendor/bin/typo3 vault:doctor --active-probes --format=json
Copied!

This pushes the current chain-tip anchor through every enabled sink — a webhook collector must answer 2xx — and emits one audit.sink_probe.<sink> finding each. It talks to external systems and writes delivery state, so it is never run implicitly, neither by the passive checks nor by the backend status panel. Schedule it daily rather than every few minutes, and keep the passive run for the frequent one.