Skip to main content

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; softDeleteEvent requires 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-seq header 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 AuditEvent with previousHash + currentHash + chainSeq.
  • verifyChain(fromSeq, take) walks the chain + detects hash mismatch + sequence gaps.
  • AuditRedactor masks PII fields before SIEM egress.
  • SiemExportService.push() with mTLS + host allowlist + x-highest-received-seq echo confirmation.
  • softDeleteEvent(eventId, reason) with deletedAt + deletedBy + deletionReason audit-of-audit shape.
  • compliance-sars/ + compliance-breaches/ with SAR / breach / postmortem lifecycles.

All 3 phases shipped — 2026-07-20 (as-20260720-1154)

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 — RetentionPurgeJob already soft-deletes audit events past DataSharingAuditRetentionDays (default 2555 d = 7 y) + emits audit.retention.purged aggregate audit. Verified in place.
  • gap 2 — verifyChain() walks every row including deletedAt != null (soft-delete keeps previousHash/currentHash/chainSeq intact); new spec covers the semantics. Read-side listEvents() filters out deleted rows for human review but the tamper-evidence layer keeps them.
  • gap 10 — RetentionPurgeJob.writeAuditColdManifest() writes a per-sweep manifest to AUDIT_COLD_ARCHIVE_PATH/audit-<stamp>.json (mode 0600) with chainSeq range + per-row {seq, id, action}; ops can rsync to S3 Glacier / tape. Emits audit.retention.cold_archive.written audit; 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 hatch SIEM_ALLOW_MISSING_SEQ_HEADER=true restores the legacy behaviour for narrow migration windows.
  • gap 9 — pushOnce() pre-flights the mTLS client cert expiry using X509Certificate.validTo on 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 through redactAuditEvent() for callers without the new compliance:audit:read_pii permission (SystemPermissions.AuditReadPii). PII fields become <redacted> in the audit-read API for everyone except case-authorised compliance officers.

Acceptance criteria

#AC
1New 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.
2verifyChain treats soft-deleted rows as chain-participating (their currentHash continues to hash into the next row). Spec test asserts.
4Internal audit-read API pipes results through AuditRedactor.redact() for callers below compliance:audit:read_pii.
6Missing x-highest-received-seq from SIEM → cursor stays put + emits siem.push.unconfirmed audit event; retry same batch.
9SiemExportService reads mTLS cert expiry via crypto.X509Certificate on module init + emits siem.mtls.cert_expiring when < 30 d + refuses boot when expired.
10RetentionPurgeJob 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.