diff --git a/.github/workflows/consensus-sim.yml b/.github/workflows/consensus-sim.yml new file mode 100644 index 0000000..ec7ca3d --- /dev/null +++ b/.github/workflows/consensus-sim.yml @@ -0,0 +1,25 @@ +name: Consensus Simulation Tests + +on: + push: + pull_request: + +jobs: + consensus-sim: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '18' + - name: Install deps + run: npm ci + - name: Run consensus simulation + run: npm run test:consensus-sim + - name: Upload JUnit results + if: always() + uses: actions/upload-artifact@v4 + with: + name: consensus-sim-junit + path: tests-results/consensus-sim-junit.xml diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..87f3b5a --- /dev/null +++ b/TODO.md @@ -0,0 +1,16 @@ +# TODO - Config Runtime Auditing (Drift Detection) + +- [ ] Implement baseline loader (read committed baseline config files; start with config.json.example) +- [ ] Implement runtime snapshot collector (interval 5 minutes; uses getConfig()) +- [ ] Implement diff comparator (value changes + added/removed keys) using stable flattening +- [ ] Implement snapshot storage (in-memory + JSONL on disk optional via env) +- [ ] Implement PagerDuty notifier + alert routing policy (deployment-scoped critical drift) +- [x] Wire auditor into index.js after config init + +- [x] Add dashboard endpoints: GET /debug/config-drift and history + +- [x] Add unit tests for flatten+diff and alert policy + + +- [ ] Run tests and ensure build passes + diff --git a/index.js b/index.js index 7d1a475..68e4d7b 100644 --- a/index.js +++ b/index.js @@ -56,7 +56,43 @@ async function bootstrap() { console.warn('[index] Config module not loaded; running with env defaults'); } + // 1b. Start config drift auditor + expose debug endpoints + const driftModule = loadTsModule('config-drift/auditor'); + const driftRoutesModule = loadTsModule('config-drift/routes'); + if (driftModule && driftRoutesModule) { + try { + const { createConfigDriftAuditorFromEnv } = driftModule; + const { registerConfigDriftRoutes } = driftRoutesModule; + + const auditor = createConfigDriftAuditorFromEnv({}); + auditor.init().then(() => { + auditor.start(); + registerConfigDriftRoutes(app, auditor); + console.log('[config-drift] Auditor started'); + }); + + + // best-effort shutdown hook + const shutdownHandler = () => { + + try { + auditor.stop(); + } catch { + // noop + } + }; + process.once('SIGINT', shutdownHandler); + process.once('SIGTERM', shutdownHandler); + + } catch (err) { + console.warn('[config-drift] Failed to start auditor:', (err && err.message) ? err.message : String(err)); + } + } else { + console.warn('[config-drift] Drift modules not loaded'); + } + // 2. Initialize tracing + const tracing = loadTsModule('diagnostics/tracer'); if (tracing && typeof tracing.initTracingFromConfig === 'function') { const otelCfg = getConfigValue ? getConfigValue('telemetry.otel') : null; diff --git a/package.json b/package.json index 9109eb6..400ce58 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "main": "dist/index.js", "scripts": { "build": "tsc", - "test": "npx ts-node --project tsconfig.json tests/config.test.ts && npx ts-node --project tsconfig.json tests/slashing_sequencer.test.ts && npx ts-node --project tsconfig.json tests/notifications/slashingNotifier.test.ts && npx ts-node --project tsconfig.json tests/staking/bondPool.test.ts && npx ts-node --project tsconfig.json tests/uptime_queries.test.ts && npx ts-node --project tsconfig.json tests/tracer.test.ts && npx ts-node --project tsconfig.json tests/pool_isolation.test.ts && npx ts-node --project tsconfig.json tests/mtls.test.ts && npx ts-node --project tsconfig.json tests/tls_rotation.test.ts && npx ts-node --project tsconfig.json tests/reputation/scoreService.test.ts && npx ts-node --project tsconfig.json tests/rewards/distributor.test.ts && npx ts-node --project tsconfig.json tests/blockchain/preflight_analyzer.test.ts && npx ts-node --project tsconfig.json tests/blockchain/rpc_client.test.ts && npx ts-node --project tsconfig.json tests/blockchain/transaction_builder.test.ts && npx ts-node --project tsconfig.json tests/state_archival.test.ts && npx ts-node --project tsconfig.json tests/dead_letter_queue.test.ts && TS_NODE_TRANSPILE_ONLY=true npx ts-node --project tsconfig.json tests/core/attestation/engine_extra.test.ts", + "test": "npx ts-node --project tsconfig.json tests/config.test.ts && npx ts-node --project tsconfig.json tests/slashing_sequencer.test.ts && npx ts-node --project tsconfig.json tests/notifications/slashingNotifier.test.ts && npx ts-node --project tsconfig.json tests/staking/bondPool.test.ts && npx ts-node --project tsconfig.json tests/uptime_queries.test.ts && npx ts-node --project tsconfig.json tests/tracer.test.ts && npx ts-node --project tsconfig.json tests/pool_isolation.test.ts && npx ts-node --project tsconfig.json tests/mtls.test.ts && npx ts-node --project tsconfig.json tests/tls_rotation.test.ts && npx ts-node --project tsconfig.json tests/reputation/scoreService.test.ts && npx ts-node --project tsconfig.json tests/rewards/distributor.test.ts && npx ts-node --project tsconfig.json tests/blockchain/preflight_analyzer.test.ts && npx ts-node --project tsconfig.json tests/blockchain/rpc_client.test.ts && npx ts-node --project tsconfig.json tests/blockchain/transaction_builder.test.ts && npx ts-node --project tsconfig.json tests/state_archival.test.ts && npx ts-node --project tsconfig.json tests/dead_letter_queue.test.ts && npx ts-node --project tsconfig.json tests/config-drift.test.ts && TS_NODE_TRANSPILE_ONLY=true npx ts-node --project tsconfig.json tests/core/attestation/engine_extra.test.ts", + "test:consensus-sim": "npx ts-node --project tsconfig.json tests/consensus_sim.test.ts", "test:reputation": "npx ts-node --project tsconfig.json tests/reputation/scoreService.test.ts", "clean": "rm -rf dist" }, diff --git a/src/config-drift/auditor.ts b/src/config-drift/auditor.ts new file mode 100644 index 0000000..51317fd --- /dev/null +++ b/src/config-drift/auditor.ts @@ -0,0 +1,135 @@ +import { getConfig } from '../config'; +import { loadBaselineSnapshot, ExampleConfigBaselineSource } from './baseline'; +import { computeDriftReport, pickCriticalPrefix } from './diff'; +import { DriftStorage } from './storage'; +import { CriticalDriftPolicy, ConfigDriftAlert } from './types'; +import { HttpPagerDutyClient, buildAlertIfCritical, PagerDutyOptions, PagerDutyClient } from './pagerduty'; + +export interface ConfigDriftAuditorOptions { + intervalMs?: number; + baselineSources?: Array<{ loadBaseline(): Promise; name: string }>; + storage?: DriftStorage; + pagerDutyClient?: PagerDutyClient; + criticalPolicy: CriticalDriftPolicy; + driftCategoryFilter?: 'all'; +} + +export class ConfigDriftAuditor { + private timer: NodeJS.Timeout | null = null; + private baseline: Awaited> | null = null; + private running = false; + + constructor(private readonly options: ConfigDriftAuditorOptions) { + this.options.storage = this.options.storage ?? new DriftStorage(); + } + + async init(): Promise { + const baselineSources = this.options.baselineSources ?? [new ExampleConfigBaselineSource()]; + this.baseline = await loadBaselineSnapshot(baselineSources as any); + } + + start(): void { + const intervalMs = this.options.intervalMs ?? 5 * 60 * 1000; + // immediate run + void this.runOnce(); + this.timer = setInterval(() => void this.runOnce(), intervalMs); + this.timer.unref?.(); + } + + stop(): void { + if (this.timer) clearInterval(this.timer); + this.timer = null; + } + + history(limit = 100) { + return this.options.storage!.history(limit); + } + + latest() { + return this.options.storage!.latest(); + } + + private async runOnce(): Promise { + if (this.running) return; + this.running = true; + + try { + if (!this.baseline) return; + + const startedAt = Date.now(); + const snapshotId = `cfgdrift:${startedAt}`; + + const runtimeConfig = getConfig(); + + const report = computeDriftReport({ + snapshotId, + runtimeConfig, + baselineFlattened: this.baseline.flattened, + baselineHash: this.baseline.baselineHash, + }); + + this.options.storage!.add({ + snapshotId, + capturedAt: Date.now(), + driftReport: report, + }); + + const policyMatchedPrefix = pickCriticalPrefix( + this.options.criticalPolicy.criticalKeyPrefixes, + report.findings, + ); + + const alert = buildAlertIfCritical({ + report, + policy: this.options.criticalPolicy, + policyMatchedPrefix, + }); + + if (alert && this.options.pagerDutyClient) { + await this.options.pagerDutyClient.triggerAlert(alert as ConfigDriftAlert); + } + } catch { + // Auditor errors should not crash the server. + // (Logger module not used here to avoid adding dependencies; can be wired later.) + } finally { + this.running = false; + } + } +} + +export function createConfigDriftAuditorFromEnv(args: { + storage?: DriftStorage; +}): ConfigDriftAuditor { + const enabledPagerDuty = process.env.VERINODE_DRIFT_PAGERDUTY_ENABLED === 'true' || process.env.VERINODE_DRIFT_PAGERDUTY_ENABLED === '1'; + const routingKey = process.env.VERINODE_DRIFT_PAGERDUTY_ROUTING_KEY ?? ''; + + const criticalKeyPrefixes = (process.env.VERINODE_DRIFT_CRITICAL_PREFIXES ?? 'db,mtls,tls,app,remote') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + + const intervalMs = Number(process.env.VERINODE_DRIFT_SNAPSHOT_INTERVAL_MS ?? String(5 * 60 * 1000)); + + let pagerDutyClient: PagerDutyClient | undefined = undefined; + const criticalPolicy: CriticalDriftPolicy = { + enabled: process.env.VERINODE_DRIFT_ALERTS_ENABLED === 'true' || process.env.VERINODE_DRIFT_ALERTS_ENABLED === '1', + criticalKeyPrefixes, + }; + + if (enabledPagerDuty && routingKey) { + const pdOpts: PagerDutyOptions = { + enabled: enabledPagerDuty, + routingKey, + criticalPolicy, + }; + pagerDutyClient = new HttpPagerDutyClient(pdOpts); + } + + return new ConfigDriftAuditor({ + intervalMs, + storage: args.storage, + pagerDutyClient, + criticalPolicy, + }); +} + diff --git a/src/config-drift/baseline.ts b/src/config-drift/baseline.ts new file mode 100644 index 0000000..20c8d11 --- /dev/null +++ b/src/config-drift/baseline.ts @@ -0,0 +1,40 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { flattenConfig, computeHashFromFlattened } from './flatten'; + +export interface BaselineConfigSource { + name: string; + /** + * Load baseline config object (committed source). + */ + loadBaseline(): Promise; +} + +export class ExampleConfigBaselineSource implements BaselineConfigSource { + name = 'repo:config.json.example'; + constructor( + private readonly examplePath: string = path.resolve(__dirname, '../../../config.json.example'), + ) {} + + async loadBaseline(): Promise { + const content = fs.readFileSync(this.examplePath, 'utf8'); + return JSON.parse(content); + } +} + +export interface BaselineSnapshot { + sourceName: string; + baselineConfig: unknown; + flattened: Record; + baselineHash: string; +} + +export async function loadBaselineSnapshot(sources: BaselineConfigSource[]): Promise { + const source = sources[0]; + if (!source) throw new Error('No baseline sources configured'); + const baselineConfig = await source.loadBaseline(); + const flattened = flattenConfig(baselineConfig); + const baselineHash = computeHashFromFlattened(flattened); + return { sourceName: source.name, baselineConfig, flattened, baselineHash }; +} + diff --git a/src/config-drift/diff.ts b/src/config-drift/diff.ts new file mode 100644 index 0000000..0bffecd --- /dev/null +++ b/src/config-drift/diff.ts @@ -0,0 +1,112 @@ +import { flattenConfig, computeHashFromFlattened, keyMatchesPrefix } from './flatten'; +import { DriftFinding, DriftReport } from './types'; + +export interface DriftDiffInput { + runtimeConfig: unknown; + runtimeFlattened?: Record; + baselineFlattened: Record; + baselineHash: string; + snapshotId: string; +} + +export function diffFlattenedConfigs(params: { + runtimeFlattened: Record; + baselineFlattened: Record; +}): { findings: DriftFinding[] } { + const { runtimeFlattened, baselineFlattened } = params; + + const baselineKeys = new Set(Object.keys(baselineFlattened)); + const runtimeKeys = new Set(Object.keys(runtimeFlattened)); + + const findings: DriftFinding[] = []; + + // Added keys + for (const k of runtimeKeys) { + if (!baselineKeys.has(k)) { + findings.push({ + category: 'key_added', + key: k, + runtimeValue: runtimeFlattened[k], + }); + } + } + + // Removed keys + for (const k of baselineKeys) { + if (!runtimeKeys.has(k)) { + findings.push({ + category: 'key_removed', + key: k, + baselineValue: baselineFlattened[k], + }); + } + } + + // Value changes + for (const k of runtimeKeys) { + if (!baselineKeys.has(k)) continue; + const b = baselineFlattened[k]; + const r = runtimeFlattened[k]; + if (b !== r) { + findings.push({ + category: 'value_change', + key: k, + baselineValue: b, + runtimeValue: r, + }); + } + } + + return { findings }; +} + +export function computeDriftReport(input: DriftDiffInput): DriftReport { + const startedAt = Date.now(); + const runtimeFlattened = input.runtimeFlattened ?? flattenConfig(input.runtimeConfig); + const runtimeHash = computeHashFromFlattened(runtimeFlattened); + + const { findings } = diffFlattenedConfigs({ + runtimeFlattened, + baselineFlattened: input.baselineFlattened, + }); + + const endedAt = Date.now(); + + const summary = { + total: findings.length, + valueChanges: findings.filter((f) => f.category === 'value_change').length, + keyAdded: findings.filter((f) => f.category === 'key_added').length, + keyRemoved: findings.filter((f) => f.category === 'key_removed').length, + }; + + return { + snapshotId: input.snapshotId, + startedAt, + endedAt, + runtimeHash, + baselineHash: input.baselineHash, + findings: findings.sort((a, b) => { + // deterministic ordering + const ord: Record = { + value_change: 1, + key_added: 2, + key_removed: 3, + }; + const oa = ord[a.category] ?? 99; + const ob = ord[b.category] ?? 99; + if (oa !== ob) return oa - ob; + return a.key.localeCompare(b.key); + }), + summary, + }; +} + +export function pickCriticalPrefix(policyPrefixes: string[], findings: DriftFinding[]): string | undefined { + for (const f of findings) { + for (const prefix of policyPrefixes) { + if (keyMatchesPrefix(f.key, prefix)) return prefix; + } + } + return undefined; +} + diff --git a/src/config-drift/flatten.ts b/src/config-drift/flatten.ts new file mode 100644 index 0000000..8649ce0 --- /dev/null +++ b/src/config-drift/flatten.ts @@ -0,0 +1,138 @@ +import { getIn } from '../config/utils'; + +/** + * Flatten config object into dot-paths with deterministic serialization. + * + * - Arrays are represented with numeric indices: a.0.b + * - Objects are represented recursively. + * + * This is used for drift comparison (added/removed keys + value changes). + */ +export function flattenConfig( + obj: unknown, + options?: { maxDepth?: number; currentDepth?: number }, +): Record { + const maxDepth = options?.maxDepth ?? 64; + const currentDepth = options?.currentDepth ?? 0; + + const out: Record = {}; + + function normalizeValue(value: unknown): string { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + + // Stable JSON for objects/arrays. + try { + return stableStringify(value); + } catch { + return String(value); + } + } + + function visit(node: unknown, prefix: string, depth: number): void { + if (depth > maxDepth) { + out[prefix] = normalizeValue(node); + return; + } + + if (node === null || node === undefined) { + out[prefix] = normalizeValue(node); + return; + } + + if (Array.isArray(node)) { + if (node.length === 0) { + out[prefix] = '[]'; + return; + } + for (let i = 0; i < node.length; i++) { + const nextPrefix = prefix ? `${prefix}.${i}` : String(i); + visit(node[i], nextPrefix, depth + 1); + } + return; + } + + if (typeof node === 'object') { + const keys = Object.keys(node as Record).sort(); + if (keys.length === 0) { + out[prefix] = '{}'; + return; + } + for (const k of keys) { + const v = (node as any)[k]; + const nextPrefix = prefix ? `${prefix}.${k}` : k; + visit(v, nextPrefix, depth + 1); + } + return; + } + + // primitives + out[prefix] = normalizeValue(node); + } + + // Root + if (obj === null || obj === undefined) { + return { '': normalizeValue(obj) }; + } + + if (typeof obj !== 'object') { + return { '': normalizeValue(obj) }; + } + + const keys = Object.keys(obj as Record).sort(); + for (const k of keys) { + visit((obj as any)[k], k, currentDepth); + } + + return out; +} + +function stableStringify(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value); + if (Array.isArray(value)) { + return `[${value.map((v) => stableStringify(v)).join(',')}]`; + } + const entries = Object.keys(value as Record) + .sort() + .map((k) => `${JSON.stringify(k)}:${stableStringify((value as any)[k])}`); + return `{${entries.join(',')}}`; +} + +export function computeHashFromFlattened(flat: Record): string { + // Simple non-crypto-ish hash is enough for drift comparison. + // We use djb2 over deterministic string of key/value pairs. + const keys = Object.keys(flat).sort(); + let hash = 5381; + for (const k of keys) { + const s = `${k}=${flat[k]};`; + for (let i = 0; i < s.length; i++) { + hash = ((hash << 5) + hash) + s.charCodeAt(i); + hash = hash >>> 0; + } + } + return hash.toString(16); +} + +/** + * Helper to decide if a flattened key is within a prefix. + * Example: prefix 'db' matches 'db.host' and 'db' + */ +export function keyMatchesPrefix(flatKey: string, prefix: string): boolean { + if (!prefix) return false; + return flatKey === prefix || flatKey.startsWith(prefix + '.'); +} + +/** + * Get a value from flattened structure by prefix (debug use). + */ +export function getFlattenedPrefixValues(flat: Record, prefix: string): Record { + const out: Record = {}; + const keys = Object.keys(flat); + for (const k of keys) { + if (keyMatchesPrefix(k, prefix)) out[k] = flat[k]; + } + return out; +} + diff --git a/src/config-drift/index.ts b/src/config-drift/index.ts new file mode 100644 index 0000000..29529f1 --- /dev/null +++ b/src/config-drift/index.ts @@ -0,0 +1,8 @@ +export * from './types'; +export * from './flatten'; +export * from './diff'; +export * from './baseline'; +export * from './storage'; +export * from './pagerduty'; +export * from './auditor'; + diff --git a/src/config-drift/pagerduty.ts b/src/config-drift/pagerduty.ts new file mode 100644 index 0000000..919e15b --- /dev/null +++ b/src/config-drift/pagerduty.ts @@ -0,0 +1,94 @@ +import { createHash } from 'crypto'; +import { DriftReport, CriticalDriftPolicy, ConfigDriftAlert } from './types'; + +export interface PagerDutyOptions { + enabled: boolean; + routingKey: string; + /** + * Service integration key. In many PD setups, the routing key is enough. + */ + integrationKey?: string; + /** + * Limit alerting to deployment-scoped drift. + */ + criticalPolicy: CriticalDriftPolicy; +} + +export interface PagerDutyClient { + triggerAlert(alert: ConfigDriftAlert): Promise; +} + +export class HttpPagerDutyClient implements PagerDutyClient { + constructor(private readonly opts: PagerDutyOptions) {} + + async triggerAlert(alert: ConfigDriftAlert): Promise { + if (!this.opts.enabled) return; + + const payload = { + routing_key: this.opts.routingKey, + event_action: 'trigger', + dedup_key: alert.alertId, + payload: { + summary: `Config drift detected: ${alert.severity.toUpperCase()}`, + source: this.opts.integrationKey ? 'verinode-config-drift' : 'verinode-config-drift', + severity: alert.severity, + group: alert.policyMatchedPrefix ? `prefix:${alert.policyMatchedPrefix}` : undefined, + custom_details: { + snapshotId: alert.snapshotId, + runtimeHash: alert.driftReport.runtimeHash, + baselineHash: alert.driftReport.baselineHash, + summary: alert.driftReport.summary, + findings: alert.driftReport.findings.slice(0, 50), + }, + timestamp: new Date().toISOString(), + }, + }; + + // Prefer global fetch (Node 18+). If missing, require. + const fetchFn: typeof fetch = (global as any).fetch; + if (!fetchFn) { + throw new Error('Global fetch is not available in this Node runtime'); + } + + const res = await fetchFn('https://events.pagerduty.com/v2/enqueue', { + + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(5000), + } as any); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`PagerDuty enqueue failed: ${res.status} ${res.statusText} ${text}`); + } + } +} + +export function alertIdFor(report: DriftReport): string { + const digest = createHash('sha256') + .update(`${report.snapshotId}|${report.runtimeHash}|${report.baselineHash}|${report.summary.total}`) + .digest('hex') + .slice(0, 32); + return `config-drift:${digest}`; +} + +export function buildAlertIfCritical(args: { + report: DriftReport; + policy: CriticalDriftPolicy; + policyMatchedPrefix?: string; +}): ConfigDriftAlert | null { + const { report, policy, policyMatchedPrefix } = args; + if (!policy.enabled) return null; + if (report.findings.length === 0) return null; + if (!policyMatchedPrefix) return null; + + return { + alertId: alertIdFor(report), + snapshotId: report.snapshotId, + policyMatchedPrefix, + severity: 'critical', + driftReport: report, + }; +} + diff --git a/src/config-drift/routes.ts b/src/config-drift/routes.ts new file mode 100644 index 0000000..8f35154 --- /dev/null +++ b/src/config-drift/routes.ts @@ -0,0 +1,98 @@ +import { ConfigDriftAuditor } from './auditor'; + +// Keep types minimal to avoid coupling to express type packages in this repo. +// index.js injects an Express app instance at runtime. +export function registerConfigDriftRoutes(app: any, auditor: ConfigDriftAuditor): void { + app.get('/debug/config-drift', (_req: any, res: any) => { + res.json({ + latest: auditor.latest(), + history: auditor.history(100), + }); + }); + + app.get('/debug/config-drift/history', (req: any, res: any) => { + const limit = Math.min(Number(req.query.limit ?? 100), 1000); + res.json({ + history: auditor.history(limit), + }); + }); + + app.get('/debug/config-drift/ui', (_req: any, res: any) => { + res.type('text/html').send(` + + + + Config Drift Dashboard + + + +

Config Drift Dashboard

+
Displaying latest snapshot and history.
+
Loading...
+ + + + `); + }); +} + + + diff --git a/src/config-drift/storage.ts b/src/config-drift/storage.ts new file mode 100644 index 0000000..6ddf805 --- /dev/null +++ b/src/config-drift/storage.ts @@ -0,0 +1,67 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { DriftReport } from './types'; + +export interface DriftSnapshotRecord { + snapshotId: string; + capturedAt: number; + driftReport: DriftReport; +} + +export interface DriftStorageOptions { + maxInMemory?: number; + jsonlPath?: string; +} + +export class DriftStorage { + private readonly maxInMemory: number; + private readonly jsonlPath?: string; + private readonly inMemory: DriftSnapshotRecord[] = []; + + constructor(options: DriftStorageOptions = {}) { + this.maxInMemory = options.maxInMemory ?? 240; // 20 hours at 5 min interval + this.jsonlPath = options.jsonlPath; + if (this.jsonlPath) { + const dir = path.dirname(this.jsonlPath); + fs.mkdirSync(dir, { recursive: true }); + if (fs.existsSync(this.jsonlPath)) { + const content = fs.readFileSync(this.jsonlPath, 'utf8'); + for (const line of content.split(/\r?\n/)) { + if (!line.trim()) continue; + try { + const record = JSON.parse(line) as DriftSnapshotRecord; + if (record && typeof record.snapshotId === 'string') { + this.inMemory.push(record); + } + } catch { + continue; + } + } + while (this.inMemory.length > this.maxInMemory) { + this.inMemory.shift(); + } + } + } + } + + add(record: DriftSnapshotRecord): void { + this.inMemory.push(record); + while (this.inMemory.length > this.maxInMemory) { + this.inMemory.shift(); + } + + if (this.jsonlPath) { + fs.appendFileSync(this.jsonlPath, JSON.stringify(record) + '\n', 'utf8'); + } + } + + history(limit = 100): DriftSnapshotRecord[] { + const slice = this.inMemory.slice(-limit); + return slice; + } + + latest(): DriftSnapshotRecord | null { + return this.inMemory.length ? this.inMemory[this.inMemory.length - 1] : null; + } +} + diff --git a/src/config-drift/tests/drift.test.cjs b/src/config-drift/tests/drift.test.cjs new file mode 100644 index 0000000..4bc01dd --- /dev/null +++ b/src/config-drift/tests/drift.test.cjs @@ -0,0 +1,6 @@ +// NOTE: this is a lightweight smoke test intended for quick local validation. +// It does not run in Jest/ts-node due to environment constraints; it is kept +// as CommonJS and avoids importing TypeScript directly. + +console.log('config-drift smoke-test: skipped (TypeScript import not available in CJS without ts-node registration)'); + diff --git a/src/config-drift/tests/drift.test.mjs b/src/config-drift/tests/drift.test.mjs new file mode 100644 index 0000000..ffdbd92 --- /dev/null +++ b/src/config-drift/tests/drift.test.mjs @@ -0,0 +1,61 @@ +import flattenPkg from '../flatten.ts'; +import diffPkg from '../diff.ts'; + +const { flattenConfig, computeHashFromFlattened } = flattenPkg; +const { diffFlattenedConfigs, computeDriftReport } = diffPkg; + + +function assert(cond, msg) { + if (!cond) throw new Error(msg || 'assertion failed'); +} + +// Test 1: flatten stability +{ + const cfg1 = { b: 2, a: { c: 3 } }; + const cfg2 = { a: { c: 3 }, b: 2 }; + const f1 = flattenConfig(cfg1); + const f2 = flattenConfig(cfg2); + const k1 = Object.keys(f1).sort().join('|'); + const k2 = Object.keys(f2).sort().join('|'); + assert(k1 === k2, 'flatten keys mismatch'); +} + +// Test 2: value changes +{ + const baseline = flattenConfig({ db: { host: 'x', port: 1 } }); + const runtime = flattenConfig({ db: { host: 'y', port: 1 } }); + const { findings } = diffFlattenedConfigs({ runtimeFlattened: runtime, baselineFlattened: baseline }); + assert(findings.some((f) => f.category === 'value_change' && f.key === 'db.host'), 'value change not detected'); +} + +// Test 3: added/removed keys +{ + const baseline = flattenConfig({ app: { environment: 'production' } }); + const runtime = flattenConfig({ app: { logLevel: 'info' } }); + const { findings } = diffFlattenedConfigs({ runtimeFlattened: runtime, baselineFlattened: baseline }); + assert(findings.some((f) => f.category === 'key_added' && f.key === 'app.logLevel'), 'key added not detected'); + assert(findings.some((f) => f.category === 'key_removed' && f.key === 'app.environment'), 'key removed not detected'); +} + +// Test 4: computeDriftReport summary +{ + const baselineConfig = { app: { environment: 'production' } }; + const runtimeConfig = { app: { environment: 'staging', logLevel: 'info' } }; + + const baselineFlat = flattenConfig(baselineConfig); + const runtimeFlat = flattenConfig(runtimeConfig); + const baselineHash = computeHashFromFlattened(baselineFlat); + + const report = computeDriftReport({ + snapshotId: 'snap:1', + runtimeConfig, + runtimeFlattened: runtimeFlat, + baselineFlattened: baselineFlat, + baselineHash, + }); + + assert(report.summary.total >= 2, 'summary seems too small'); +} + +console.log('config-drift tests passed'); + diff --git a/src/config-drift/tests/drift.test.ts b/src/config-drift/tests/drift.test.ts new file mode 100644 index 0000000..8fb74ad --- /dev/null +++ b/src/config-drift/tests/drift.test.ts @@ -0,0 +1,63 @@ +import { flattenConfig, computeHashFromFlattened } from '../flatten'; +import { diffFlattenedConfigs, computeDriftReport } from '../diff'; +import { CriticalDriftPolicy } from '../types'; + +// Jest-compatible tests. +// If your test runner does not define describe/test/expect, run via the project's +// test setup (Jest) or skip this file. + +describe('config-drift flatten + diff', () => { + + test('flattenConfig produces stable keys', () => { + const cfg1 = { b: 2, a: { c: 3 } }; + const cfg2 = { a: { c: 3 }, b: 2 }; + const f1 = flattenConfig(cfg1); + const f2 = flattenConfig(cfg2); + expect(f1).toEqual(f2); + }); + + test('value changes detected', () => { + const baseline = flattenConfig({ db: { host: 'x', port: 1 } }); + const runtime = flattenConfig({ db: { host: 'y', port: 1 } }); + const { findings } = diffFlattenedConfigs({ runtimeFlattened: runtime, baselineFlattened: baseline }); + expect(findings.some((f) => f.category === 'value_change' && f.key === 'db.host')).toBe(true); + }); + + test('key added/removed detected', () => { + const baseline = flattenConfig({ app: { environment: 'production' } }); + const runtime = flattenConfig({ app: { logLevel: 'info' } }); + const { findings } = diffFlattenedConfigs({ runtimeFlattened: runtime, baselineFlattened: baseline }); + expect(findings.some((f) => f.category === 'key_added' && f.key === 'app.logLevel')).toBe(true); + expect(findings.some((f) => f.category === 'key_removed' && f.key === 'app.environment')).toBe(true); + }); + + test('computeDriftReport summary consistent', () => { + const baselineConfig = { app: { environment: 'production' } }; + const runtimeConfig = { app: { environment: 'staging', logLevel: 'info' } }; + + const baselineFlat = flattenConfig(baselineConfig); + const runtimeFlat = flattenConfig(runtimeConfig); + const baselineHash = computeHashFromFlattened(baselineFlat); + + const report = computeDriftReport({ + snapshotId: 'snap:1', + runtimeConfig, + runtimeFlattened: runtimeFlat, + baselineFlattened: baselineFlat, + baselineHash, + }); + + expect(report.summary.total).toBeGreaterThanOrEqual(2); + }); +}); + +describe('config-drift critical policy', () => { + test('default critical prefixes used by policy', () => { + const policy: CriticalDriftPolicy = { + enabled: true, + criticalKeyPrefixes: ['db', 'mtls', 'tls', 'app', 'remote'], + }; + expect(policy.criticalKeyPrefixes).toContain('db'); + }); +}); + diff --git a/src/config-drift/types.ts b/src/config-drift/types.ts new file mode 100644 index 0000000..7c60c24 --- /dev/null +++ b/src/config-drift/types.ts @@ -0,0 +1,44 @@ +export type DriftCategory = + | 'value_change' + | 'key_added' + | 'key_removed'; + +export interface DriftFinding { + category: DriftCategory; + key: string; + baselineValue?: unknown; + runtimeValue?: unknown; +} + +export interface DriftReport { + snapshotId: string; + startedAt: number; + endedAt: number; + runtimeHash: string; + baselineHash: string; + findings: DriftFinding[]; + summary: { + total: number; + valueChanges: number; + keyAdded: number; + keyRemoved: number; + }; +} + +export interface CriticalDriftPolicy { + enabled: boolean; + /** + * If any finding touches one of these dot-prefix paths, the drift is treated as + * deployment-scoped critical. + */ + criticalKeyPrefixes: string[]; +} + +export interface ConfigDriftAlert { + alertId: string; + snapshotId: string; + policyMatchedPrefix?: string; + severity: 'critical' | 'warning'; + driftReport: DriftReport; +} + diff --git a/src/consensus-sim/faults.ts b/src/consensus-sim/faults.ts new file mode 100644 index 0000000..d21cae3 --- /dev/null +++ b/src/consensus-sim/faults.ts @@ -0,0 +1,42 @@ +export type ValidatorId = string; + +export type Message = { + from: ValidatorId; + to: ValidatorId; + round: number; + payload: any; +}; + +export type Partition = { + groups: ValidatorId[][]; // messages between groups are dropped + durationRounds?: number; +}; + +export type Delay = { + ms: number; // base delay in ms + jitter?: number; // added random jitter + probability?: number; // probability to delay each message +}; + +export type Equivocation = { + by: ValidatorId[]; // validators that equivocate + roundSpan?: number; // rounds over which equivocation occurs +}; + +export type TimeoutFault = { + by: ValidatorId[]; // validators that timeout (stop sending) + durationRounds?: number; +}; + +export type FaultSpec = { + partition?: Partition | null; + delay?: Delay | null; + equivocation?: Equivocation | null; + timeout?: TimeoutFault | null; +}; + +export type SimulationConfig = { + validators: ValidatorId[]; + maxRounds?: number; + faultSpec?: FaultSpec; +}; diff --git a/src/consensus-sim/index.ts b/src/consensus-sim/index.ts new file mode 100644 index 0000000..bd0c877 --- /dev/null +++ b/src/consensus-sim/index.ts @@ -0,0 +1,2 @@ +export * from './faults'; +export * from './simulation'; diff --git a/src/consensus-sim/simulation.ts b/src/consensus-sim/simulation.ts new file mode 100644 index 0000000..4610806 --- /dev/null +++ b/src/consensus-sim/simulation.ts @@ -0,0 +1,126 @@ +import { SimulationConfig, ValidatorId, Message, FaultSpec } from './faults'; + +type ValidatorState = { + id: ValidatorId; + active: boolean; // not timed out + decided: Map; +}; + +export class Simulation { + validators: ValidatorState[]; + maxRounds: number; + faultSpec?: FaultSpec | null; + + constructor(cfg: SimulationConfig) { + this.validators = cfg.validators.map((id) => ({ id, active: true, decided: new Map() })); + this.maxRounds = cfg.maxRounds ?? 20; + this.faultSpec = cfg.faultSpec ?? null; + } + + private getValidator(id: ValidatorId) { + return this.validators.find((v) => v.id === id)!; + } + + // decides whether message between a->b in a given round should be delivered + private shouldDeliver(from: ValidatorId, to: ValidatorId, round: number): boolean { + const p = this.faultSpec?.partition; + if (p && p.groups && p.groups.length > 1) { + const groupIndex = (id: ValidatorId) => p.groups.findIndex((g) => g.includes(id)); + const gi = groupIndex(from); + const gj = groupIndex(to); + if (gi !== gj && gi >= 0 && gj >= 0) { + // if durationRounds is set, only drop for those rounds + if (!p.durationRounds || round <= p.durationRounds) return false; + } + } + // timeout faults: if sender is timed out during this round, don't send + const toff = this.faultSpec?.timeout; + if (toff && toff.by && toff.by.includes(from)) { + if (!toff.durationRounds || round <= toff.durationRounds) return false; + } + // otherwise deliver + return true; + } + + // simulate a single round; returns true if consensus achieved + private async runRound(round: number): Promise { + // Each validator generates a proposal. For simplicity, their proposal is `${id}-v${round}` + const proposals: Map = new Map(); + for (const v of this.validators) { + // if timeout and in timeout duration, do not propose + const toff = this.faultSpec?.timeout; + if (toff && toff.by && toff.by.includes(v.id) && (!toff.durationRounds || round <= toff.durationRounds)) { + continue; + } + proposals.set(v.id, `${v.id}-v${round}`); + } + + // Build message deliveries respecting partition/delay/equivocation + const deliveries: Promise[] = []; + for (const [from, payload] of proposals.entries()) { + for (const dest of this.validators.map((x) => x.id)) { + if (dest === from) continue; + if (!this.shouldDeliver(from, dest, round)) { + deliveries.push(Promise.resolve(null)); + continue; + } + + // equivocation: if configured and this sender is in equivocation list, send different payloads to different peers + const eq = this.faultSpec?.equivocation; + let pl = payload; + if (eq && eq.by && eq.by.includes(from)) { + // craft an alternate value per dest + pl = `${payload}-alt-${dest}`; + } + + // delay injection + const delay = this.faultSpec?.delay; + if (delay && Math.random() < (delay.probability ?? 1)) { + const jitter = delay.jitter ? (Math.random() - 0.5) * delay.jitter : 0; + const ms = Math.max(0, delay.ms + jitter); + deliveries.push(new Promise((res) => setTimeout(() => res({ from, to: dest, round, payload: pl }), ms))); + } else { + deliveries.push(Promise.resolve({ from, to: dest, round, payload: pl })); + } + } + } + + const msgs = (await Promise.all(deliveries)).filter((m): m is Message => m !== null); + + // Tally per-recipient + const perRecipient = new Map>(); + for (const v of this.validators) perRecipient.set(v.id, new Map()); + for (const m of msgs) { + const map = perRecipient.get(m.to)!; + map.set(m.payload, (map.get(m.payload) ?? 0) + 1); + } + + // For each validator, see if they observe a 2/3+ majority on a value + const n = this.validators.length; + let anyDecided = false; + for (const [vid, tally] of perRecipient.entries()) { + for (const [val, count] of tally.entries()) { + if (count >= Math.floor((2 * n) / 3) + 1) { + this.getValidator(vid).decided.set(round, val); + anyDecided = true; + break; + } + } + } + + return anyDecided; + } + + // run the simulation and return an object summarizing rounds and whether recovery achieved + async run(): Promise<{ rounds: number; recovered: boolean }> { + for (let r = 1; r <= this.maxRounds; r++) { + const ok = await this.runRound(r); + if (ok) { + return { rounds: r, recovered: true }; + } + } + return { rounds: this.maxRounds, recovered: false }; + } +} + +export default Simulation; diff --git a/tests/config-drift.test.ts b/tests/config-drift.test.ts new file mode 100644 index 0000000..03d9cde --- /dev/null +++ b/tests/config-drift.test.ts @@ -0,0 +1,171 @@ +/** + * Config drift runtime auditing tests + */ +declare global { + function describe(name: string, fn: () => void): void; + function it(name: string, fn: () => void | Promise): void; + function before(fn: () => void | Promise): void; + function after(fn: () => void | Promise): void; +} + +if (typeof (global as any).describe === 'undefined') { + const tests: Array<{ name: string; fn: () => any }> = []; + let beforeFn: (() => any) | null = null; + let afterFn: (() => any) | null = null; + + (global as any).describe = (name: string, fn: () => void) => { + console.log(`Running Suite: ${name}`); + fn(); + setTimeout(async () => { + try { + if (beforeFn) await beforeFn(); + for (const test of tests) { + console.log(` Running Test: ${test.name}`); + await test.fn(); + console.log(` ✓ ${test.name}`); + } + if (afterFn) await afterFn(); + console.log('\nAll config drift tests passed!'); + process.exit(0); + } catch (err: any) { + console.error(`\nTest failed: ${err.message}`); + console.error(err.stack); + if (afterFn) { + try { + await afterFn(); + } catch {} + } + process.exit(1); + } + }, 0); + }; + (global as any).before = (fn: () => any) => { + beforeFn = fn; + }; + (global as any).after = (fn: () => any) => { + afterFn = fn; + }; + (global as any).it = (name: string, fn: () => any) => { + tests.push({ name, fn }); + }; +} + +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { flattenConfig, computeHashFromFlattened } from '../src/config-drift/flatten'; +import { diffFlattenedConfigs, computeDriftReport } from '../src/config-drift/diff'; +import { buildAlertIfCritical } from '../src/config-drift/pagerduty'; +import { DriftStorage } from '../src/config-drift/storage'; +import { registerConfigDriftRoutes } from '../src/config-drift/routes'; + +const tmpDir = path.join(__dirname, 'tmp'); +const jsonlFile = path.join(tmpDir, 'drift-history.jsonl'); + +describe('Config drift module', () => { + before(() => { + if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true }); + }); + + after(() => { + try { + if (fs.existsSync(jsonlFile)) fs.unlinkSync(jsonlFile); + if (fs.existsSync(tmpDir)) fs.rmdirSync(tmpDir); + } catch { + // noop + } + }); + + it('flattens config consistently across object order and nesting', () => { + const a = { b: 2, a: { c: 3 } }; + const b = { a: { c: 3 }, b: 2 }; + assert.deepStrictEqual(flattenConfig(a), flattenConfig(b)); + + const complex = { app: { list: ['one', { nested: true }] } }; + const flat = flattenConfig(complex); + assert.strictEqual(flat['app.list.0'], 'one'); + assert.strictEqual(flat['app.list.1.nested'], 'true'); + }); + + it('detects added, removed, and changed keys in runtime drift', () => { + const baseline = flattenConfig({ app: { environment: 'production' } }); + const runtime = flattenConfig({ app: { environment: 'staging', logLevel: 'info' } }); + const { findings } = diffFlattenedConfigs({ runtimeFlattened: runtime, baselineFlattened: baseline }); + assert.ok(findings.some((f) => f.category === 'value_change' && f.key === 'app.environment')); + assert.ok(findings.some((f) => f.category === 'key_added' && f.key === 'app.logLevel')); + }); + + it('generates drift reports with a consistent summary and hash', () => { + const baselineConfig = { app: { environment: 'production' } }; + const runtimeConfig = { app: { environment: 'staging', logLevel: 'info' } }; + const baselineFlat = flattenConfig(baselineConfig); + const runtimeFlat = flattenConfig(runtimeConfig); + const baselineHash = computeHashFromFlattened(baselineFlat); + const report = computeDriftReport({ + snapshotId: 'snap:1', + runtimeConfig, + runtimeFlattened: runtimeFlat, + baselineFlattened: baselineFlat, + baselineHash, + }); + assert.strictEqual(report.baselineHash, baselineHash); + assert.strictEqual(report.summary.total, report.summary.valueChanges + report.summary.keyAdded + report.summary.keyRemoved); + assert.ok(report.runtimeHash.length > 0); + }); + + it('only creates a PagerDuty alert when a critical prefix is matched', () => { + const baselineFlat = flattenConfig({ db: { host: 'x' } }); + const runtimeFlat = flattenConfig({ db: { host: 'y' } }); + const report = computeDriftReport({ + snapshotId: 'snap:2', + runtimeConfig: {}, + runtimeFlattened: runtimeFlat, + baselineFlattened: baselineFlat, + baselineHash: computeHashFromFlattened(baselineFlat), + }); + + const policy = { enabled: true, criticalKeyPrefixes: ['db'] }; + const alert = buildAlertIfCritical({ report, policy, policyMatchedPrefix: 'db' }); + assert.ok(alert && alert.severity === 'critical'); + + const noAlert = buildAlertIfCritical({ report, policy, policyMatchedPrefix: undefined }); + assert.strictEqual(noAlert, null); + }); + + it('persists and restores drift history from JSONL storage', () => { + const first = { + snapshotId: 's1', + capturedAt: Date.now() - 1000, + driftReport: { + snapshotId: 's1', startedAt: 1, endedAt: 2, + runtimeHash: 'abc', baselineHash: 'def', findings: [], + summary: { total: 0, valueChanges: 0, keyAdded: 0, keyRemoved: 0 }, + }, + }; + const second = { + snapshotId: 's2', + capturedAt: Date.now(), + driftReport: { + snapshotId: 's2', startedAt: 3, endedAt: 4, + runtimeHash: 'ghi', baselineHash: 'jkl', findings: [], + summary: { total: 0, valueChanges: 0, keyAdded: 0, keyRemoved: 0 }, + }, + }; + + fs.writeFileSync(jsonlFile, JSON.stringify(first) + '\n' + JSON.stringify(second) + '\n', 'utf8'); + const storage = new DriftStorage({ jsonlPath: jsonlFile, maxInMemory: 10 }); + assert.strictEqual(storage.history(2).length, 2); + assert.strictEqual(storage.latest()?.snapshotId, 's2'); + }); + + it('registers drift debug and dashboard routes', () => { + const routes: Record = {}; + const app = { get: (path: string, handler: Function) => { routes[path] = handler; } }; + const auditorStub = { latest: () => null, history: (_limit: number) => [] }; + registerConfigDriftRoutes(app, auditorStub as any); + assert.ok(routes['/debug/config-drift']); + assert.ok(routes['/debug/config-drift/history']); + assert.ok(routes['/debug/config-drift/ui']); + }); +}); diff --git a/tests/consensus_sim.test.ts b/tests/consensus_sim.test.ts new file mode 100644 index 0000000..e0bf3e0 --- /dev/null +++ b/tests/consensus_sim.test.ts @@ -0,0 +1,77 @@ +import { Simulation } from '../src/consensus-sim/simulation'; +import { SimulationConfig } from '../src/consensus-sim/faults'; +import * as fs from 'fs'; + +type Scenario = { name: string; cfg: SimulationConfig; maxAllowedRounds?: number }; + +const validators = Array.from({ length: 8 }, (_, i) => `V${i + 1}`); + +const scenarios: Scenario[] = [ + { name: 'baseline-no-faults', cfg: { validators, maxRounds: 10 } }, + { + name: 'network-partition-then-heal', + cfg: { + validators, + maxRounds: 10, + faultSpec: { partition: { groups: [validators.slice(0, 4), validators.slice(4)], durationRounds: 2 } }, + }, + maxAllowedRounds: 6, + }, + { + name: 'equivocation-from-one-node', + cfg: { validators, maxRounds: 10, faultSpec: { equivocation: { by: [validators[0]] } } }, + }, + { + name: 'message-delay-high', + cfg: { validators, maxRounds: 12, faultSpec: { delay: { ms: 20, jitter: 10, probability: 0.9 } } }, + }, + { + name: 'validator-timeout-partial', + cfg: { validators, maxRounds: 10, faultSpec: { timeout: { by: [validators[1], validators[2]], durationRounds: 3 } } }, + }, +]; + +async function runAll() { + const results: { name: string; ok: boolean; rounds: number; durationMs: number }[] = []; + for (const s of scenarios) { + const sim = new Simulation(s.cfg); + const start = Date.now(); + const res = await sim.run(); + const duration = Date.now() - start; + const ok = res.recovered && (s.maxAllowedRounds ? res.rounds <= s.maxAllowedRounds : true); + results.push({ name: s.name, ok, rounds: res.rounds, durationMs: duration }); + console.log(`${s.name}: recovered=${res.recovered} rounds=${res.rounds} duration=${duration}ms`); + } + + // write JUnit XML + const xmlParts: string[] = []; + xmlParts.push(''); + xmlParts.push(``); + for (const r of results) { + xmlParts.push(``); + if (!r.ok) { + xmlParts.push(`rounds=${r.rounds}`); + } + xmlParts.push(''); + } + xmlParts.push(''); + + const out = xmlParts.join('\n'); + const dir = 'tests-results'; + if (!fs.existsSync(dir)) fs.mkdirSync(dir); + fs.writeFileSync(`${dir}/consensus-sim-junit.xml`, out, 'utf8'); + + // final exit code + const allOk = results.every((r) => r.ok); + if (!allOk) { + console.error('Some consensus simulation scenarios failed. See tests-results/consensus-sim-junit.xml'); + process.exitCode = 2; + } else { + console.log('All consensus simulation scenarios passed. JUnit written to tests-results/consensus-sim-junit.xml'); + } +} + +runAll().catch((e) => { + console.error('Simulation runner error', e); + process.exitCode = 3; +});