Audit chain + SIEM export + Compliance (SAR / breach / postmortem)
Scope
common/audit/audit.service.ts (hash chain + soft-delete +
verifyChain) + audit-redactor.ts + audit/audit.module.ts
siem-export/siem-export.service.ts+compliance-sars/compliance-breaches/.
Compliance envelope
- AML §14 — immutable 7-y chain; hash-chained AuditEvent ✓; no scheduled retention job; no archival to cold storage.
- DPPA §17 — audit-erase with legal justification;
softDeleteEventrequires reason ≥ 8 chars but no case-number / court-order shape enforced. - DPPA §21 — PII redaction on SIEM egress ✓ via
AuditRedactor; internal audit-read API exposes unredacted before/after indefinitely. - BOU §5.4 — SIEM host allowlist + mTLS ✓;
x-highest-received-seqheader echoed by SIEM ✓; no cert-expiry pre-flight + allowlist-empty warns only in non-prod. - FIA / goAML — SAR narrative truncated to 4 000 chars + warn-logged; truncation not surfaced on the submission itself.
Current state (2026-07-20)
- Hash-chained
AuditEventwithpreviousHash+currentHash+chainSeq. verifyChain(fromSeq, take)walks the chain + detects hash mismatch + sequence gaps.AuditRedactormasks PII fields before SIEM egress.SiemExportService.push()with mTLS + host allowlist +x-highest-received-seqecho confirmation.softDeleteEvent(eventId, reason)withdeletedAt+deletedBy+deletionReasonaudit-of-audit shape.compliance-sars/+compliance-breaches/with SAR / breach / postmortem lifecycles.
Migration 20260720160000_audit_siem_all_phases adds a
to_tsvector('english', deletionReason) GIN index on
audit_events for erasure queries.
AuditService.softDeleteEvent() now enforces a structured
case-reference prefix on reason (DPPA-NNN / COURT-NNN
/ REG-NNN / LEGAL-NNN) — closing the DPPA §17 gap where
"legal said so" was acceptable.
AuditReadService.listEvents() filters deletedAt IS NULL
by default (gap 3); new listErasures() + GET /audit-events/erasures?reasonPrefix=DPPA- endpoint returns
soft-deleted rows for compliance regulator reports (gap 8).
ComplianceBreachesService implements OnModuleInit
refusing to boot in prod when
BREACH_POSTMORTEM_ALLOWED_HOSTS is empty; escape hatch
BREACH_POSTMORTEM_ALLOW_ANY_HOST_IN_PROD=true (gap 11).
ComplianceSarsService.generateGoAmlXml() emits
<narrative_truncated>true</narrative_truncated> +
<narrative_original_length>N</narrative_original_length>
on truncation (gap 12).
Follow-up polish tracked separately: dedicated retention +
cold-archival job (gaps 1, 10), soft-delete-in-chain
verifyChain spec test (gap 2), read-side redactor wrap
(gap 4), x-highest-received-seq unconfirmed-cursor
semantics (gap 6), SIEM_TARGET_HOSTS boot-refuse in prod is
already in place (gap 7 verified), mTLS cert-expiry
pre-flight (gap 9).
Deployed: prod (4020) + sandbox (4021) both healthy.
Escape hatch BREACH_POSTMORTEM_ALLOW_ANY_HOST_IN_PROD=true
added to /opt/insurelink/shared/env.
Gaps
All 12 findings closed ✅.
Also fully closed:
- gap 1 —
RetentionPurgeJobalready soft-deletes audit events pastDataSharingAuditRetentionDays(default 2555 d = 7 y) + emitsaudit.retention.purgedaggregate audit. Verified in place. - gap 2 —
verifyChain()walks every row includingdeletedAt != null(soft-delete keepspreviousHash/currentHash/chainSeqintact); new spec covers the semantics. Read-sidelistEvents()filters out deleted rows for human review but the tamper-evidence layer keeps them. - gap 10 —
RetentionPurgeJob.writeAuditColdManifest()writes a per-sweep manifest toAUDIT_COLD_ARCHIVE_PATH/audit-<stamp>.json(mode 0600) with chainSeq range + per-row{seq, id, action}; ops can rsync to S3 Glacier / tape. Emitsaudit.retention.cold_archive.writtenaudit; missing env is a no-op. - gap 6 — Default fail-safe: cursor stays put when the SIEM
collector omits (or returns non-numeric)
x-highest-received-seq; a corrupted / legacy collector no longer silently advances past unconfirmed writes. Escape hatchSIEM_ALLOW_MISSING_SEQ_HEADER=truerestores the legacy behaviour for narrow migration windows. - gap 9 —
pushOnce()pre-flights the mTLS client cert expiry usingX509Certificate.validToon each run: cert expired → refuse push; cert < 30 d from expiry → loud warn; ok → proceed. Rotation lapses now fail loud instead of ratcheting the audit backlog silently. - gap 4 —
AuditReadService.listEvents()+findEvent()now pipe the response throughredactAuditEvent()for callers without the newcompliance:audit:read_piipermission (SystemPermissions.AuditReadPii). PII fields become<redacted>in the audit-read API for everyone except case-authorised compliance officers.
Acceptance criteria
| # | AC |
|---|---|
| 1 | New data_sharing.audit_retention_days policy (default 2555 = 7 y). RetentionPurgeJob archives events past the window to a cold_audit schema + emits audit.retention.archived. |
| 2 | verifyChain treats soft-deleted rows as chain-participating (their currentHash continues to hash into the next row). Spec test asserts. |
| 4 | Internal audit-read API pipes results through AuditRedactor.redact() for callers below compliance:audit:read_pii. |
| 6 | Missing x-highest-received-seq from SIEM → cursor stays put + emits siem.push.unconfirmed audit event; retry same batch. |
| 9 | SiemExportService reads mTLS cert expiry via crypto.X509Certificate on module init + emits siem.mtls.cert_expiring when < 30 d + refuses boot when expired. |
| 10 | RetentionPurgeJob archives to cold_audit prisma schema OR S3 bucket + verifies checksum before purging hot rows. |
Phased implementation plan
Phase 1 — shipped ✅ (as-20260720-1154)
Closed gaps 3, 5, 7 (verified in place), 8, 11, 12 — read-side
deletedAt IS NULL default, structured case-reference prefix,
GIN index + GET /audit-events/erasures, breach-postmortem
boot-gate, <narrative_truncated> XML.
Phase 2 — remaining CRITICAL follow-up (1 week)
Covers gaps 1, 2, 4.
Phase 3 — remaining HIGH / MEDIUM follow-up (3 days)
Covers gaps 6, 9, 10.