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,190 @@
/**
* Unit tests for EncryptionService — AES-256-CBC column-level encryption.
*
* Tests:
* 1. Encrypt/decrypt round-trip returns original plaintext
* 2. isEncrypted: true for base64:base64 format, false for plaintext strings
* 3. encryptColumn produces different ciphertext on each call (IV randomness)
* 4. Singleton reset utility works for test isolation
*/
import {
EncryptionService,
getEncryptionService,
_resetEncryptionServiceSingleton,
} from '../../../src/services/EncryptionService';
import { VaultClient } from '../../../src/vault/VaultClient';
// ============================================================================
// Mock VaultClient
// ============================================================================
/** A 32-byte (64-char hex) test encryption key. */
const TEST_KEY = 'a'.repeat(64); // 64 x 'a' = valid 32-byte hex key
/**
* Creates a mock VaultClient that returns TEST_KEY from readArbitrarySecret.
*/
function makeMockVaultClient(): VaultClient {
const mock = {
readArbitrarySecret: jest.fn().mockResolvedValue({ encryptionKey: TEST_KEY }),
writeArbitrarySecret: jest.fn().mockResolvedValue(undefined),
writeSecret: jest.fn(),
readSecret: jest.fn(),
verifySecret: jest.fn(),
deleteSecret: jest.fn(),
};
return mock as unknown as VaultClient;
}
// ============================================================================
// Tests
// ============================================================================
describe('EncryptionService', () => {
let service: EncryptionService;
let mockVaultClient: VaultClient;
beforeEach(() => {
_resetEncryptionServiceSingleton();
mockVaultClient = makeMockVaultClient();
service = new EncryptionService(mockVaultClient);
});
afterEach(() => {
_resetEncryptionServiceSingleton();
});
// ── Round-trip ────────────────────────────────────────────────────────────
it('should encrypt and then decrypt back to the original plaintext', async () => {
const plaintext = 'super-secret-credential-hash-value';
const encrypted = await service.encryptColumn(plaintext);
expect(encrypted).not.toBe(plaintext);
expect(encrypted).toContain(':');
const decrypted = await service.decryptColumn(encrypted);
expect(decrypted).toBe(plaintext);
});
it('should handle empty string round-trip', async () => {
const plaintext = '';
const encrypted = await service.encryptColumn(plaintext);
const decrypted = await service.decryptColumn(encrypted);
expect(decrypted).toBe(plaintext);
});
it('should handle unicode strings in round-trip', async () => {
const plaintext = 'secret/data/agentidp/agents/über-agent/credentials/cred-123';
const encrypted = await service.encryptColumn(plaintext);
const decrypted = await service.decryptColumn(encrypted);
expect(decrypted).toBe(plaintext);
});
// ── IV randomness ─────────────────────────────────────────────────────────
it('should produce different ciphertext on each call (random IV)', async () => {
const plaintext = 'same-plaintext-value';
const encrypted1 = await service.encryptColumn(plaintext);
const encrypted2 = await service.encryptColumn(plaintext);
// Same plaintext but different IV → different ciphertext
expect(encrypted1).not.toBe(encrypted2);
// Both must still decrypt to the same plaintext
expect(await service.decryptColumn(encrypted1)).toBe(plaintext);
expect(await service.decryptColumn(encrypted2)).toBe(plaintext);
});
// ── isEncrypted ──────────────────────────────────────────────────────────
it('should return true for a value in base64:base64 format', async () => {
const encrypted = await service.encryptColumn('test-value');
expect(service.isEncrypted(encrypted)).toBe(true);
});
it('should return false for a plaintext bcrypt hash', () => {
const bcryptHash = '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy';
expect(service.isEncrypted(bcryptHash)).toBe(false);
});
it('should return false for a Vault path string', () => {
expect(service.isEncrypted('secret/data/agentidp/agents/abc/credentials/xyz')).toBe(false);
});
it('should return false for an empty string', () => {
expect(service.isEncrypted('')).toBe(false);
});
it('should return false for a plain UUID', () => {
expect(service.isEncrypted('550e8400-e29b-41d4-a716-446655440000')).toBe(false);
});
it('should return true for a manually constructed base64:base64 string', () => {
const iv = Buffer.from('deadbeef12345678', 'hex').toString('base64');
const ct = Buffer.from('cafebabe00112233', 'hex').toString('base64');
expect(service.isEncrypted(`${iv}:${ct}`)).toBe(true);
});
// ── Vault key fetching ────────────────────────────────────────────────────
it('should call Vault readArbitrarySecret once and cache the key', async () => {
const plaintext = 'value1';
await service.encryptColumn(plaintext);
await service.encryptColumn(plaintext);
await service.encryptColumn(plaintext);
// Key should be fetched only once
expect(
(mockVaultClient.readArbitrarySecret as jest.Mock).mock.calls.length,
).toBe(1);
});
it('should use the ENCRYPTION_KEY_VAULT_PATH env var for the Vault path', async () => {
const originalPath = process.env['ENCRYPTION_KEY_VAULT_PATH'];
process.env['ENCRYPTION_KEY_VAULT_PATH'] = 'secret/data/custom/path';
const freshService = new EncryptionService(mockVaultClient);
await freshService.encryptColumn('test');
expect(
(mockVaultClient.readArbitrarySecret as jest.Mock).mock.calls[0][0],
).toBe('secret/data/custom/path');
// Restore env
if (originalPath === undefined) {
delete process.env['ENCRYPTION_KEY_VAULT_PATH'];
} else {
process.env['ENCRYPTION_KEY_VAULT_PATH'] = originalPath;
}
});
// ── Error handling ────────────────────────────────────────────────────────
it('should throw when ciphertext has no colon separator', async () => {
await expect(service.decryptColumn('invalidformat')).rejects.toThrow(
'Invalid encrypted column format',
);
});
it('should throw when Vault returns an invalid key', async () => {
const badVault = {
readArbitrarySecret: jest.fn().mockResolvedValue({ encryptionKey: 'tooshort' }),
} as unknown as VaultClient;
const badService = new EncryptionService(badVault);
await expect(badService.encryptColumn('test')).rejects.toThrow(
'expected a 64-character hex string',
);
});
// ── Singleton ─────────────────────────────────────────────────────────────
it('getEncryptionService should return the same instance on repeated calls', () => {
const instance1 = getEncryptionService(mockVaultClient);
const instance2 = getEncryptionService(mockVaultClient);
expect(instance1).toBe(instance2);
});
});