Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions docs/RUNTIME_CONFIG_AUDIT.md
Original file line number Diff line number Diff line change
@@ -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.
124 changes: 124 additions & 0 deletions meter-simulator/src/runtime-config-auditor.js
Original file line number Diff line number Diff line change
@@ -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
};
63 changes: 63 additions & 0 deletions meter-simulator/tests/runtime-config-auditor.test.js
Original file line number Diff line number Diff line change
@@ -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 }
]);
});
});