diff --git a/docs/RUNTIME_CONFIG_AUDIT.md b/docs/RUNTIME_CONFIG_AUDIT.md new file mode 100644 index 0000000..0f2424f --- /dev/null +++ b/docs/RUNTIME_CONFIG_AUDIT.md @@ -0,0 +1,43 @@ +# Runtime Configuration Auditing and Drift Detection + +This design introduces a system-wide runtime configuration audit layer for Utility Protocol services. The first implementation ships with the meter simulator and defines the rollout pattern for contracts, dashboards, relayers, and operational services. + +## Goals + +- Capture deterministic runtime configuration snapshots for each service. +- Hash redacted configuration state so operators can compare services without exposing secrets. +- Detect path-level drift between an approved baseline and current runtime state. +- Keep critical-path audits below the 100 ms P99 target by using in-process canonicalization and SHA-256 hashing only. +- Emit data that can be wired into monitoring, alerting, dashboards, blue-green deploys, and canary analysis. + +## Architecture + +1. **Baseline creation**: each service creates a redacted snapshot after configuration loading and stores the approved hash with deployment metadata. +2. **Runtime audit**: services periodically compare their current effective configuration against the baseline. +3. **Drift report**: reports include the service name, baseline hash, current hash, drift flag, path-level changes, runtime duration, and budget status. +4. **Monitoring**: drift reports should be exported as metrics and structured logs. Alert on `driftDetected=true` or `withinBudget=false`. +5. **Deployment safety**: blue-green and canary flows should compare baseline hashes before shifting traffic and fail the rollout if unexpected drift appears. + +## Security Model + +- Secret-like paths containing `password`, `private`, `secret`, `token`, or `key` are redacted before hashing and reporting. +- Drift reports expose paths and redacted values, not raw credentials. +- Baseline updates are treated as privileged operational changes and should go through the same security review as contract configuration changes. + +## Runbook + +1. Create or refresh the baseline for the target service during deployment. +2. Verify the baseline hash is recorded in deployment metadata. +3. Enable periodic audits and export reports to monitoring. +4. If drift is detected, compare changed paths against the approved change record. +5. If drift is unapproved, freeze rollout, page the service owner, and restore the approved configuration. +6. For canaries, block promotion until drift reports remain clean for the canary analysis window. + +## Meter Simulator Implementation + +The meter simulator exposes `RuntimeConfigAuditor` from `src/runtime-config-auditor.js`. It provides: + +- `setBaseline(config)` to create the approved snapshot. +- `audit(config)` to produce a drift report. +- Deterministic hashing that is stable across object key ordering. +- Secret redaction before hashing or reporting. diff --git a/meter-simulator/src/runtime-config-auditor.js b/meter-simulator/src/runtime-config-auditor.js new file mode 100644 index 0000000..d04b9d4 --- /dev/null +++ b/meter-simulator/src/runtime-config-auditor.js @@ -0,0 +1,124 @@ +const crypto = require('crypto'); + +const DEFAULT_CRITICAL_PATH_BUDGET_MS = 100; + +function canonicalize(value) { + if (Array.isArray(value)) { + return value.map(canonicalize); + } + if (value && typeof value === 'object') { + return Object.keys(value) + .sort() + .reduce((acc, key) => { + acc[key] = canonicalize(value[key]); + return acc; + }, {}); + } + return value; +} + +function stableStringify(value) { + return JSON.stringify(canonicalize(value)); +} + +function hashConfig(config) { + return crypto.createHash('sha256').update(stableStringify(config)).digest('hex'); +} + +function redactValue(key, value) { + if (/password|private|secret|token|key/i.test(key)) { + return '[REDACTED]'; + } + return value; +} + +function flattenConfig(config, prefix = '') { + if (!config || typeof config !== 'object' || Array.isArray(config)) { + return { [prefix || 'value']: config }; + } + + return Object.keys(config).reduce((acc, key) => { + const path = prefix ? `${prefix}.${key}` : key; + const value = config[key]; + + if (value && typeof value === 'object' && !Array.isArray(value)) { + Object.assign(acc, flattenConfig(value, path)); + } else { + acc[path] = redactValue(path, value); + } + + return acc; + }, {}); +} + +class RuntimeConfigAuditor { + constructor(options = {}) { + this.serviceName = options.serviceName || 'unknown-service'; + this.criticalPathBudgetMs = options.criticalPathBudgetMs || DEFAULT_CRITICAL_PATH_BUDGET_MS; + this.baseline = null; + } + + createSnapshot(config, observedAt = new Date()) { + const started = process.hrtime.bigint(); + const redacted = flattenConfig(config); + const snapshot = { + service: this.serviceName, + observedAt: observedAt.toISOString(), + hash: hashConfig(redacted), + values: redacted + }; + const durationMs = Number(process.hrtime.bigint() - started) / 1_000_000; + + return { + ...snapshot, + durationMs, + withinBudget: durationMs < this.criticalPathBudgetMs + }; + } + + setBaseline(config, observedAt = new Date()) { + this.baseline = this.createSnapshot(config, observedAt); + return this.baseline; + } + + audit(config, observedAt = new Date()) { + if (!this.baseline) { + throw new Error('Runtime configuration baseline has not been set'); + } + + const current = this.createSnapshot(config, observedAt); + const changes = diffValues(this.baseline.values, current.values); + + return { + service: this.serviceName, + baselineHash: this.baseline.hash, + currentHash: current.hash, + driftDetected: changes.length > 0, + changes, + durationMs: current.durationMs, + withinBudget: current.withinBudget, + observedAt: current.observedAt + }; + } +} + +function diffValues(expected, actual) { + const keys = new Set([...Object.keys(expected), ...Object.keys(actual)]); + return Array.from(keys) + .sort() + .filter((key) => expected[key] !== actual[key]) + .map((key) => ({ + path: key, + expected: expected[key], + actual: actual[key] + })); +} + +module.exports = { + RuntimeConfigAuditor, + DEFAULT_CRITICAL_PATH_BUDGET_MS, + flattenConfig, + hashConfig, + stableStringify, + diffValues +}; diff --git a/meter-simulator/tests/runtime-config-auditor.test.js b/meter-simulator/tests/runtime-config-auditor.test.js new file mode 100644 index 0000000..7f2a9ed --- /dev/null +++ b/meter-simulator/tests/runtime-config-auditor.test.js @@ -0,0 +1,63 @@ +const { + RuntimeConfigAuditor, + diffValues, + flattenConfig, + hashConfig +} = require('../src/runtime-config-auditor'); + +const baseConfig = { + contract: { + network: 'testnet', + rpcUrl: 'https://soroban-testnet.stellar.org' + }, + mqtt: { + host: 'localhost', + password: 'super-secret', + qos: 1 + } +}; + +describe('RuntimeConfigAuditor', () => { + test('creates deterministic redacted hashes independent of key order', () => { + const reordered = { + mqtt: { qos: 1, password: 'super-secret', host: 'localhost' }, + contract: { rpcUrl: 'https://soroban-testnet.stellar.org', network: 'testnet' } + }; + + expect(hashConfig(flattenConfig(baseConfig))).toBe(hashConfig(flattenConfig(reordered))); + expect(flattenConfig(baseConfig)['mqtt.password']).toBe('[REDACTED]'); + }); + + test('detects runtime configuration drift with path-level changes', () => { + const auditor = new RuntimeConfigAuditor({ serviceName: 'meter-simulator' }); + auditor.setBaseline(baseConfig, new Date('2026-07-17T00:00:00.000Z')); + + const report = auditor.audit({ + ...baseConfig, + mqtt: { ...baseConfig.mqtt, qos: 2 } + }, new Date('2026-07-17T00:01:00.000Z')); + + expect(report.driftDetected).toBe(true); + expect(report.changes).toEqual([ + { path: 'mqtt.qos', expected: 1, actual: 2 } + ]); + expect(report.withinBudget).toBe(true); + }); + + test('reports no drift for unchanged effective configuration', () => { + const auditor = new RuntimeConfigAuditor({ serviceName: 'meter-simulator' }); + auditor.setBaseline(baseConfig); + + const report = auditor.audit({ ...baseConfig }); + + expect(report.driftDetected).toBe(false); + expect(report.changes).toEqual([]); + }); + + test('diffValues reports additions and removals', () => { + expect(diffValues({ a: 1, b: 2 }, { b: 2, c: 3 })).toEqual([ + { path: 'a', expected: 1, actual: undefined }, + { path: 'c', expected: undefined, actual: 3 } + ]); + }); +});