feat(phase-3): workstream 6 — SOC 2 Type II Preparation

Implements all 22 WS6 tasks completing Phase 3 Enterprise.

Column-level encryption (AES-256-CBC, Vault-backed key) via EncryptionService
applied to credentials.secret_hash, credentials.vault_path,
webhook_subscriptions.vault_secret_path, and agent_did_keys.vault_key_path.
Backward-compatible: isEncrypted() guard skips decryption for existing
plaintext rows until next read-write cycle.

Audit chain integrity (CC7.2): AuditRepository computes SHA-256 Merkle hash
on every INSERT (hash = SHA-256(eventId+timestamp+action+outcome+agentId+orgId+prevHash)).
AuditVerificationService walks the full chain verifying hash continuity.
AuditChainVerificationJob runs hourly; sets agentidp_audit_chain_integrity
Prometheus gauge to 1 (pass) or 0 (fail).

TLS enforcement (CC6.7): TLSEnforcementMiddleware registered as first
middleware in Express stack; 301 redirect on non-https X-Forwarded-Proto
in production.

SecretsRotationJob (CC9.2): hourly scan for credentials expiring within 7
days; increments agentidp_credentials_expiring_soon_total.

ComplianceController + routes: GET /audit/verify (auth+audit:read scope,
30/min rate-limit); GET /compliance/controls (public, Cache-Control 60s).
ComplianceStatusStore: module-level map updated by jobs, consumed by controller.

Prometheus: 2 new metrics (agentidp_credentials_expiring_soon_total,
agentidp_audit_chain_integrity); 6 alerting rules in alerts.yml.

Compliance docs: soc2-controls-matrix.md, encryption-runbook.md,
audit-log-runbook.md, incident-response.md, secrets-rotation.md.

Tests: 557 unit tests passing (35 suites); 26 new tests (EncryptionService,
AuditVerificationService); 19 compliance integration tests. TypeScript clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
SentryAgent.ai Developer
2026-03-31 00:41:53 +00:00
parent 272b69f18d
commit fd90b2acd1
35 changed files with 3715 additions and 26 deletions

View File

@@ -0,0 +1,280 @@
/**
* Unit tests for AuditVerificationService — audit chain integrity verification.
*
* Tests:
* 1. Intact chain: correct hashes → { verified: true, checkedCount: N, brokenAtEventId: null }
* 2. Tampered chain: one wrong hash → { verified: false, brokenAtEventId: <event_id> }
* 3. Empty log: no rows → { verified: true, checkedCount: 0, brokenAtEventId: null }
* 4. Date range params are propagated to SQL query
* 5. previous_hash mismatch is detected
*/
import crypto from 'crypto';
import { Pool } from 'pg';
import {
AuditVerificationService,
IChainVerificationResult,
_resetAuditVerificationServiceSingleton,
getAuditVerificationService,
} from '../../../src/services/AuditVerificationService';
// ============================================================================
// Helpers
// ============================================================================
/**
* Computes the SHA-256 hash of an audit event — must match the algorithm in
* AuditVerificationService and AuditRepository.
*/
function computeHash(
eventId: string,
timestamp: Date,
action: string,
outcome: string,
agentId: string,
organizationId: string,
previousHash: string,
): string {
return crypto
.createHash('sha256')
.update(
eventId +
timestamp.toISOString() +
action +
outcome +
agentId +
organizationId +
previousHash,
)
.digest('hex');
}
/** Generates a minimal audit chain row with correct hash linkage. */
function makeRow(
eventId: string,
timestamp: Date,
action: string,
outcome: string,
agentId: string,
organizationId: string,
previousHash: string,
) {
const hash = computeHash(eventId, timestamp, action, outcome, agentId, organizationId, previousHash);
return {
event_id: eventId,
timestamp,
action,
outcome,
agent_id: agentId,
organization_id: organizationId,
hash,
previous_hash: previousHash,
};
}
/** Creates a mock pg.Pool whose query() returns the given rows. */
function mockPool(rows: unknown[]): Pool {
return {
query: jest.fn().mockResolvedValue({ rows }),
} as unknown as Pool;
}
// ============================================================================
// Test data
// ============================================================================
const ORG = 'org_test';
const AGENT = 'agent-abc-123';
const T1 = new Date('2026-03-01T10:00:00.000Z');
const T2 = new Date('2026-03-01T10:01:00.000Z');
const T3 = new Date('2026-03-01T10:02:00.000Z');
// ============================================================================
// Tests
// ============================================================================
describe('AuditVerificationService', () => {
afterEach(() => {
_resetAuditVerificationServiceSingleton();
});
// ── Intact chain ──────────────────────────────────────────────────────────
it('should return verified: true for an intact 3-event chain', async () => {
const row1 = makeRow('evt-001', T1, 'agent.created', 'success', AGENT, ORG, '');
const row2 = makeRow('evt-002', T2, 'credential.generated', 'success', AGENT, ORG, row1.hash);
const row3 = makeRow('evt-003', T3, 'token.issued', 'success', AGENT, ORG, row2.hash);
const pool = mockPool([row1, row2, row3]);
const service = new AuditVerificationService(pool);
const result: IChainVerificationResult = await service.verifyChain();
expect(result.verified).toBe(true);
expect(result.checkedCount).toBe(3);
expect(result.brokenAtEventId).toBeNull();
});
it('should return verified: true for a single-event chain', async () => {
const row1 = makeRow('evt-001', T1, 'agent.created', 'success', AGENT, ORG, '');
const pool = mockPool([row1]);
const service = new AuditVerificationService(pool);
const result = await service.verifyChain();
expect(result.verified).toBe(true);
expect(result.checkedCount).toBe(1);
expect(result.brokenAtEventId).toBeNull();
});
// ── Empty log ─────────────────────────────────────────────────────────────
it('should return verified: true with checkedCount 0 for an empty log', async () => {
const pool = mockPool([]);
const service = new AuditVerificationService(pool);
const result = await service.verifyChain();
expect(result.verified).toBe(true);
expect(result.checkedCount).toBe(0);
expect(result.brokenAtEventId).toBeNull();
});
// ── Tampered hash ─────────────────────────────────────────────────────────
it('should detect a tampered hash on the second event', async () => {
const row1 = makeRow('evt-001', T1, 'agent.created', 'success', AGENT, ORG, '');
const row2 = makeRow('evt-002', T2, 'credential.generated', 'success', AGENT, ORG, row1.hash);
// Tamper: replace hash on row2 with garbage
const tamperedRow2 = { ...row2, hash: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' };
const pool = mockPool([row1, tamperedRow2]);
const service = new AuditVerificationService(pool);
const result = await service.verifyChain();
expect(result.verified).toBe(false);
expect(result.brokenAtEventId).toBe('evt-002');
expect(result.checkedCount).toBe(1); // row1 was checked before break detected
});
it('should detect a previous_hash mismatch', async () => {
const row1 = makeRow('evt-001', T1, 'agent.created', 'success', AGENT, ORG, '');
// row2 references wrong previous_hash
const row2 = makeRow('evt-002', T2, 'credential.generated', 'success', AGENT, ORG, 'wrongprevhash');
const pool = mockPool([row1, row2]);
const service = new AuditVerificationService(pool);
const result = await service.verifyChain();
expect(result.verified).toBe(false);
expect(result.brokenAtEventId).toBe('evt-002');
});
it('should stop at the first break and not report subsequent events', async () => {
const row1 = makeRow('evt-001', T1, 'agent.created', 'success', AGENT, ORG, '');
const row2 = makeRow('evt-002', T2, 'credential.generated', 'success', AGENT, ORG, row1.hash);
const row3 = makeRow('evt-003', T3, 'token.issued', 'success', AGENT, ORG, row2.hash);
// Tamper row2 hash
const tamperedRow2 = { ...row2, hash: 'aaaa' + row2.hash.slice(4) };
const pool = mockPool([row1, tamperedRow2, row3]);
const service = new AuditVerificationService(pool);
const result = await service.verifyChain();
expect(result.verified).toBe(false);
expect(result.brokenAtEventId).toBe('evt-002');
// row3 was never checked
});
// ── Pre-migration rows (empty hashes) ─────────────────────────────────────
it('should skip pre-migration rows with empty hashes', async () => {
// Simulate rows written before migration 020 (hash = '', previous_hash = '')
const legacyRow = {
event_id: 'evt-legacy',
timestamp: T1,
action: 'agent.created',
outcome: 'success',
agent_id: AGENT,
organization_id: ORG,
hash: '',
previous_hash: '',
};
const pool = mockPool([legacyRow]);
const service = new AuditVerificationService(pool);
const result = await service.verifyChain();
expect(result.verified).toBe(true);
expect(result.checkedCount).toBe(1);
expect(result.brokenAtEventId).toBeNull();
});
// ── Date range params ─────────────────────────────────────────────────────
it('should propagate fromDate and toDate to the SQL query', async () => {
const pool = mockPool([]);
const service = new AuditVerificationService(pool);
const fromDate = '2026-03-01T00:00:00.000Z';
const toDate = '2026-03-31T23:59:59.999Z';
const result = await service.verifyChain(fromDate, toDate);
// Verify the query was called with date params
const queryMock = pool.query as jest.Mock;
expect(queryMock).toHaveBeenCalledTimes(1);
const callArgs = queryMock.mock.calls[0] as [string, unknown[]];
expect(callArgs[0]).toContain('timestamp >=');
expect(callArgs[0]).toContain('timestamp <=');
expect(callArgs[1]).toEqual([new Date(fromDate), new Date(toDate)]);
// fromDate/toDate are echoed back in result
expect(result.fromDate).toBe(fromDate);
expect(result.toDate).toBe(toDate);
});
it('should include only fromDate in query when toDate is omitted', async () => {
const pool = mockPool([]);
const service = new AuditVerificationService(pool);
const fromDate = '2026-03-01T00:00:00.000Z';
const result = await service.verifyChain(fromDate, undefined);
const queryMock = pool.query as jest.Mock;
const callArgs = queryMock.mock.calls[0] as [string, unknown[]];
expect(callArgs[0]).toContain('timestamp >=');
expect(callArgs[0]).not.toContain('timestamp <=');
expect(result.fromDate).toBe(fromDate);
expect(result.toDate).toBeUndefined();
});
it('should include no WHERE clause when no date range is provided', async () => {
const pool = mockPool([]);
const service = new AuditVerificationService(pool);
await service.verifyChain();
const queryMock = pool.query as jest.Mock;
const callArgs = queryMock.mock.calls[0] as [string, unknown[]];
expect(callArgs[0]).not.toContain('WHERE');
expect(callArgs[1]).toEqual([]);
});
// ── Singleton ─────────────────────────────────────────────────────────────
it('getAuditVerificationService should return the same instance on repeated calls', () => {
const pool = mockPool([]);
const instance1 = getAuditVerificationService(pool);
const instance2 = getAuditVerificationService(pool);
expect(instance1).toBe(instance2);
});
});