feat(#530): audit out-of-band config changes, group the trail by time#711
Merged
Conversation
Detects two kinds of config drift the #33 control-channel audit never saw: config.json changing without a matching dashboard commit (host-edit), and a rig applying a control-API change the dashboard never sent (rig-edit). Both append key-names-only rows to a new durable audit_events table, which also mirrors control.log so the Security panel's new hour/day/month grouping can drill deeper than the log's own trimmed tail. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… CSS class ponytail-review pass: .card-header-row has exactly one caller today (the audit trail's grouping select) — the comment claimed forward-looking genericity it hasn't earned yet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ation window Review follow-up on PR #711: - HIGH: rig-edit re-fired every ~30s poll into a permanent, never-pruned table — a rig keeps reporting its last terminal change_id, and the random row id defeated INSERT OR IGNORE. Fix: deterministic id `rig-edit-<worker>-<change_id>` (bounds the table to distinct real edits, survives restart) plus an in-memory (worker, change_id) guard that skips the redundant per-poll DB work. A rogue unauthenticated rig can no longer flood the trail with one bogus change_id. Tested across two consecutive polls and across a simulated restart. - MEDIUM: _read_host_config now re-applies the SECRET_PATHS mask (mirroring control_service.read_config's defense-in-depth pass), so a host-side masking regression can't leave a raw secret value resident in _last_host_config. The mount is already the pre-masked copy (#440) — the raw config.json is never mounted — so this is belt-and-suspenders, not a live leak; docstring corrected to state that accurately. - LOW: pinned the ≤1s "explained by a fresh commit" correlation window with a boundary test and named the ceiling in a ponytail comment. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6 tasks
…ping select Security review follow-up on PR #711: - _record_audit_event only ran actor/keys through audit_service._clean, despite the docstring's claim every field gets the same whitelist treatment. action/ status/source were code-controlled closed-enum values already, so this closes the gap defense-in-depth (no behavior change on any real input). - The hour/day/month <select> in AuditCard had no accessible name (no aria-label, no associated <label for>) — a regression against this repo's own precedent (xvb-tier-select, chart-controls role=group aria-label, etc). Added aria-label="Group audit trail by", pinned with a test assertion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The out-of-band host-config watcher (`_watch_host_config`) suppressed a detected config.json diff whenever ANY control-channel commit/applied entry landed in the correlation window, regardless of which keys that commit touched (`explained = any(...)`). A legit dashboard commit of key A concurrent with a host-side hand-edit of key B silently swallowed B — the exact out-of-band change this feature exists to catch. Now each fresh commit only explains the keys it actually changed: its audit `keys` (env-var names) are bridged to config.json paths via the new `control_service.env_key_config_paths` (reusing EDITABLE_ENV_KEY_PATHS, plus the synthetic DASHBOARD_ENERGY -> `dashboard.energy` prefix the commit gate folds in). Any changed key no recent commit covers is still recorded. The 1s time window stays as an outer bound. Tier-1 tests: a commit of key A + host-edit of key B records B (not A), and an energy-subkey commit is explained by prefix. Both fail on the old time-only logic. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
VijitSingh97
added a commit
that referenced
this pull request
Jul 21, 2026
…d branch — union CHANGELOG + dashboard.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #530
Targets the v1.11 integration branch; merges to develop after v1.10 ships.
Summary
Extends the #349 Security panel's audit trail to catch changes it never saw, and adds time-based grouping so the trail scales past a flat tail.
Ask 1 — out-of-band detection:
host-edit: the dashboard's poll loop diffsconfig.json(the same pre-masked Control channel: move the config merge host-side so the container never mounts plaintext config.json #440 copy the control channel prefills from) against its previous snapshot. A change is "explained" — and stays quiet — only if acommit/appliedentry landed in the Stack config editor: change any setting from the dashboard, applied via pithead (incl. P2Pool mode hot-swap) #33control.logsince the last check; anything else (a hand-edit, apithead applyrun from the host CLI) is recorded with the changed key names only, never values.rig-edit: the existing per-rig reconcile poll (_reconcile_worker_config, Worker-apply history: reconcile 'accepted' rows to their terminal state (slow rollbacks stay 'accepted' forever) #579) already reads each rig's terminal control-apply outcome (change_id/status/reason) off the enriched feed. Achange_idthis dashboard never spooled intoworker_config(the table only its ownworker-applywrites populate) means the rig applied it on its own — recorded naming the worker. Known limitation, not an oversight: RigForge's/statusmirror reports only the outcome of a change, not a per-key diff, so unlikehost-editthis can't name which setting moved on the rig — only that one did, and which rig.Both reuse the existing
audit_service._cleansanitizer (same whitelist-charset, length-capped treatment as every other audit field) before anything is persisted.Ask 2 — time grouping + persistence decision:
Added an hour/day/month dropdown to the
AuditCard(default "All", so existing behavior is unchanged until an operator opts in). Grouping needed more history thancontrol.log's own trimmed tail (the writers already cap it) can offer, and the out-of-band detections above have no log to live in at all — they're generated in the dashboard container, which only holds write access to the control spool'srequests/leg (results/,audit/,masked/are read-only mounts, seedocker-compose.yml). So:audit_eventsSQLite table (same pattern asworker_config/payouts/blocks/Dashboard: Worker Inspect page — read/edit each miner's XMRig config over its API, with versioned config history + per-config hashrate stats #185): permanent, no pruning — audit events are human-paced admin actions, not a hot metrics series, matching the precedent those tables already set.control.log's recent entries into this table (INSERT OR IGNOREon the log's ownid, idempotent) alongside the two out-of-band kinds./api/auditnow merges the live log tail (so a commit that just landed shows with zero lag, no regression) with the persisted table (so grouping can look back further, and out-of-band rows — which never touch the log — show up at all), deduplicated byid.Deferred
rig-editaudit row end-to-end is not its own tier-4 matrix leg — the underlying mechanism (a direct rig-side edit becoming visible in the enriched feed / masked prefill) is already proven live by the existing#516row intesting-strategy.md; this PR's detection logic on top of it is unit-tested (tier 1) with a realStateManager.rig-editrows can't name the changed keys (see limitation above) — would need a RigForge-side contract change to expose a per-key diff, out of scope here.ponytail-review
Ran a pass for over-engineering before opening this PR. One real finding:
.card-header-row's comment claimed it was "generic — reuse wherever a card needs one control next to its heading" while it has exactly one caller today. Fixed (dropped the forward-looking framing, kept the one-line class since flexbox-header-row is the natural factoring either way). Nothing else worth cutting — the remaining complexity (secret-sentinel-as-opaque-leaf handling, the whole-second/sub-second timestamp grace window, fail-open on a DB read hiccup, id-based idempotent mirroring) is load-bearing correctness/security logic, not speculative flexibility.Test plan
make lint: green (ruff, biome, yamllint, markdownlint, docs-voice, shellcheck/shfmt, taplo).lint-protocould not run — Docker daemon isn't available in this environment (unrelated to this diff; no proto files touched).make test: green — dashboard pytest (all tiers), frontendnode --test(230/230),pitheadshell suite (1541/1541), compose hardening invariants, integration harness self-test, fake-daemon contract test.diff-covervsorigin/develop-v1.11, this branch's actual base): 100% (gate: ≥90%).🤖 Generated with Claude Code