diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index c5484ae4..f7576f0e 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -47,3 +47,20 @@ updates:
# migrations, not security updates — keep Dependabot to digest + patch within the pinned tag.
- dependency-name: "*"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
+
+ # Third-party image digests pinned directly in docker-compose.yml (Tari node + wallet,
+ # docker-socket-proxy, caddy) — outside build/*, so the `docker` entry above never sees them,
+ # and they feed both channels: pulled on the DIY stack, baked into the appliance image (#833).
+ # The pithead-* image lines interpolate env vars and carry no digest; Dependabot skips them.
+ - package-ecosystem: "docker-compose"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ groups:
+ compose:
+ patterns: ["*"]
+ ignore:
+ # Same policy as the docker entry: digest + patch within the pinned tag. A Tari or Caddy
+ # major/minor is a deliberate migration (compose flags, config compatibility), not a CVE fix.
+ - dependency-name: "*"
+ update-types: ["version-update:semver-major", "version-update:semver-minor"]
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7049f0d4..329d90d8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,6 +4,14 @@ on:
push:
branches: [main, develop]
pull_request:
+ # Weekly CVE sweep (#833): rebuild + Trivy-scan every image even when no PR is open, so a CVE
+ # disclosed during a quiet week — or fixed in the apt archive without a new base digest, which
+ # Dependabot never sees — surfaces here instead of reddening the next unrelated PR. Only
+ # `build-images` runs on the schedule (every other job gates on the event); the run going red
+ # in the Actions tab is the alert, the lychee.yml posture. Scheduled runs use the default
+ # branch (develop).
+ schedule:
+ - cron: "0 5 * * 1" # Mondays 05:00 UTC
# Least privilege (#282): every job here only reads the repo — none push commits, comment, or
# publish packages. Narrowing the default GITHUB_TOKEN limits the blast radius of a compromised step.
@@ -19,6 +27,7 @@ concurrency:
jobs:
dashboard:
name: Dashboard tests (pytest + coverage)
+ if: github.event_name != 'schedule' # scheduled runs are the CVE sweep — build-images only (#833)
runs-on: ubuntu-latest
timeout-minutes: 15
env:
@@ -50,6 +59,7 @@ jobs:
frontend:
name: Frontend logic tests (node --test)
+ if: github.event_name != 'schedule' # scheduled runs are the CVE sweep — build-images only (#833)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
@@ -67,6 +77,7 @@ jobs:
dashboard-image:
name: Dashboard image (Docker test stage)
+ if: github.event_name != 'schedule' # scheduled runs are the CVE sweep — build-images only (#833)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
@@ -109,6 +120,7 @@ jobs:
hadolint:
name: Dockerfile lint (hadolint)
+ if: github.event_name != 'schedule' # scheduled runs are the CVE sweep — build-images only (#833)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
@@ -126,6 +138,7 @@ jobs:
python-lint:
name: Python lint + format (ruff)
+ if: github.event_name != 'schedule' # scheduled runs are the CVE sweep — build-images only (#833)
runs-on: ubuntu-latest
timeout-minutes: 15
env:
@@ -149,6 +162,7 @@ jobs:
gitleaks:
name: Secret scan (gitleaks)
+ if: github.event_name != 'schedule' # scheduled runs are the CVE sweep — build-images only (#833)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
@@ -166,6 +180,7 @@ jobs:
zizmor:
name: Workflow audit (zizmor)
+ if: github.event_name != 'schedule' # scheduled runs are the CVE sweep — build-images only (#833)
runs-on: ubuntu-latest
timeout-minutes: 15
env:
@@ -187,6 +202,7 @@ jobs:
shell:
name: Shell tests (shellcheck + pithead suite)
+ if: github.event_name != 'schedule' # scheduled runs are the CVE sweep — build-images only (#833)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
@@ -224,6 +240,7 @@ jobs:
compose:
name: Compose config + security hardening
+ if: github.event_name != 'schedule' # scheduled runs are the CVE sweep — build-images only (#833)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
@@ -235,6 +252,7 @@ jobs:
lint-surfaces:
name: Lint per-surface (biome, yaml, markdown, proto, toml)
+ if: github.event_name != 'schedule' # scheduled runs are the CVE sweep — build-images only (#833)
runs-on: ubuntu-latest
timeout-minutes: 15
env:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a5086d87..d4d66beb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,25 @@ Pithead ships as **one product, one version** — the version lives in the top-l
[`VERSION`](VERSION) file and every released image is tagged with it. Releases are cut
per the process in [`docs/dev/releasing.md`](docs/dev/releasing.md).
+## [1.17.0] - 2026-08-02
+
+### Added
+
+- **Log navigation for the Security panels (#823).** The access log and the config-change audit
+ trail share one control row: range presets (24 Hr / 1 Wk / 1 Mo / All, the chart's idiom) for
+ following a live log, two date fields for jumping to a specific day or span, and a search box
+ that matches any field and narrows as you type. Filters compose, and filtering happens on the
+ server, so a match deeper than the on-screen tail is still found — the access log's read stays
+ size-bounded either way. Below the row, a pager reports how many entries matched and walks
+ them a page at a time — 5 to 100 rows per page with Prev/Next — replacing the audit trail's
+ hour/day/month grouping dropdown, whose job the date controls now do better. A filter with no
+ matches says so; the failed-login counter always describes the whole log.
+
+### Dependencies
+
+- Compose-pinned third-party images, now under Dependabot's watch: refreshed digests for the
+ Tari console wallet (within v5.3.1-mainnet) and Caddy (within 2.11.4).
+
## [1.16.1] - 2026-08-01
### Fixed
diff --git a/VERSION b/VERSION
index 41c11ffb..092afa15 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.16.1
+1.17.0
diff --git a/build/dashboard/mining_dashboard/service/audit_service.py b/build/dashboard/mining_dashboard/service/audit_service.py
index 98dcfef8..56e33b01 100644
--- a/build/dashboard/mining_dashboard/service/audit_service.py
+++ b/build/dashboard/mining_dashboard/service/audit_service.py
@@ -14,6 +14,7 @@
``control_audit`` and Caddy rolls its own access log — this module only ever reads a tail.
"""
+import calendar
import json
import os
import re
@@ -93,6 +94,47 @@ def recent_changes(limit=50):
return entries[::-1][:limit]
+# Log-navigation filters (#823). One helper serves BOTH log surfaces even though their timestamps
+# differ — access entries carry epoch seconds, audit entries the canonical "YYYY-MM-DDTHH:MM:SSZ"
+# string — by normalizing each entry's ts to epoch at the comparison. The window is half-open
+# [frm, to) so a "to" built from a date input's next midnight includes that whole day exactly
+# once; an entry whose ts cannot be read matches NO window (filtering means placing entries in
+# time — an undatable row has no place) but still matches a pure text search.
+
+
+def _entry_epoch(ts):
+ """``ts`` as epoch seconds, or None when unreadable. Accepts the two shapes the log surfaces
+ actually emit: a number (access log) or the canonical UTC ISO string (audit trail)."""
+ if isinstance(ts, (int, float)):
+ return float(ts)
+ if isinstance(ts, str):
+ try:
+ return float(calendar.timegm(time.strptime(ts, "%Y-%m-%dT%H:%M:%SZ")))
+ except ValueError:
+ return None
+ return None
+
+
+def filter_log_entries(entries, frm=None, to=None, q=None):
+ """``entries`` narrowed to the [frm, to) epoch window and/or a case-insensitive substring
+ ``q`` across every field value. Filters compose; None means "don't filter on this axis"."""
+ ql = (q or "").lower()
+ out = []
+ for e in entries:
+ if frm is not None or to is not None:
+ ep = _entry_epoch(e.get("ts"))
+ if ep is None:
+ continue
+ if frm is not None and ep < frm:
+ continue
+ if to is not None and ep >= to:
+ continue
+ if ql and not any(ql in str(v).lower() for v in e.values()):
+ continue
+ out.append(e)
+ return out
+
+
def access_summary(limit=50, now=None):
"""Recent dashboard accesses plus the rotate-signal: 401s in the last 24 h.
diff --git a/build/dashboard/mining_dashboard/web/server.py b/build/dashboard/mining_dashboard/web/server.py
index 04a18559..a4b31d1d 100644
--- a/build/dashboard/mining_dashboard/web/server.py
+++ b/build/dashboard/mining_dashboard/web/server.py
@@ -1,4 +1,5 @@
import logging
+import math
import mimetypes
import os
import re
@@ -359,24 +360,61 @@ def _merged_audit_entries(state_mgr):
return sorted(merged.values(), key=lambda e: e.get("ts", ""), reverse=True)
+def _log_filters(request):
+ """The #823 navigation params, parsed defensively: ``from``/``to`` as epoch seconds (anything
+ non-numeric reads as absent — a malformed bound must never 500 a log view) and ``q`` trimmed
+ and length-capped (it's compared, never stored or echoed unsanitized)."""
+
+ def _num(name):
+ v = request.query.get(name)
+ if v in (None, ""):
+ return None
+ try:
+ f = float(v)
+ except ValueError:
+ return None
+ # float() happily parses "inf"/"nan", which would silently warp the window comparisons
+ # (nan compares False with everything) — a non-finite bound is malformed, so it's absent.
+ return f if math.isfinite(f) else None
+
+ q = (request.query.get("q") or "").strip()[:200]
+ return _num("from"), _num("to"), q or None
+
+
async def handle_audit_log(request):
"""Config-change audit entries — the #33 control-channel log plus the out-of-band host-edit /
- rig-edit detections (#530), merged and persisted so the Security panel can group by hour/day/
- month deeper than the log's own trimmed tail. Registered only alongside the control channel —
- the log is a #33 artifact and the out-of-band watchers only run when it's on."""
+ rig-edit detections (#530), merged and persisted so the Security panel can filter and page
+ deeper than the log's own trimmed tail. Registered only alongside the control channel —
+ the log is a #33 artifact and the out-of-band watchers only run when it's on.
+ Accepts the #823 navigation params (``from``/``to`` epoch seconds, ``q`` substring)."""
try:
state_mgr = request.app["state_manager"]
- return web.json_response({"entries": _merged_audit_entries(state_mgr)})
+ frm, to, q = _log_filters(request)
+ entries = audit_service.filter_log_entries(_merged_audit_entries(state_mgr), frm, to, q)
+ return web.json_response({"entries": entries})
except Exception:
logger.exception("Error reading the control audit log")
return web.json_response({"error": "Failed to read the audit log."}, status=500)
+# How deep the access log is read when the operator is NAVIGATING it (#823) vs the default
+# glance. The tail read is byte-bounded either way (audit_service._TAIL_BYTES) — this only stops
+# a filtered view from being quietly truncated to the glance depth before the filter even runs.
+_ACCESS_NAV_LIMIT = 1000
+
+
async def handle_access_log(request):
"""Recent dashboard accesses + failed-login count, from Caddy's JSON access log. Always
- registered (Caddy always writes the log); behind the same Caddy basic_auth as every route."""
+ registered (Caddy always writes the log); behind the same Caddy basic_auth as every route.
+ Accepts the #823 navigation params; the failure counters always describe the whole tail,
+ never the filtered slice."""
try:
- return web.json_response(audit_service.access_summary())
+ frm, to, q = _log_filters(request)
+ filtering = frm is not None or to is not None or q is not None
+ summary = audit_service.access_summary(limit=_ACCESS_NAV_LIMIT if filtering else 50)
+ if filtering:
+ summary["entries"] = audit_service.filter_log_entries(summary["entries"], frm, to, q)
+ return web.json_response(summary)
except Exception:
logger.exception("Error reading the access log")
return web.json_response({"error": "Failed to read the access log."}, status=500)
diff --git a/build/dashboard/mining_dashboard/web/static/dashboard.css b/build/dashboard/mining_dashboard/web/static/dashboard.css
index e6b03348..a86ea51a 100644
--- a/build/dashboard/mining_dashboard/web/static/dashboard.css
+++ b/build/dashboard/mining_dashboard/web/static/dashboard.css
@@ -491,6 +491,26 @@ tr:last-child td {
.est-scroll {
overflow-x: auto;
}
+/* Log navigation (#823): the Security cards' shared filter row — the chart-controls preset
+ * idiom plus native date inputs and a search box, left-aligned to read as part of its card. */
+.log-controls {
+ justify-content: flex-start;
+ margin-bottom: 10px;
+}
+.log-controls input[type="date"],
+.log-controls input[type="search"] {
+ background: var(--bg);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 2px 6px;
+ font-size: 0.8rem;
+}
+.log-controls input[type="search"] {
+ flex: 1;
+ min-width: 110px;
+}
+
/* Expected-vs-actual card (#808/#817): three labelled rows, not a shared-precision numeric
* grid — values wrap instead of panning (this card must never scroll in either view) and the
* table fills the card. table-layout fixed, because in auto layout a cell's min-content width
@@ -590,12 +610,21 @@ tr:last-child td {
margin: 0;
}
-/* Audit-trail time-bucket header row (#530), between groups when the operator picks
- * hour/day/month grouping. A raised, muted divider — readable, not another data row. */
-.audit-group-header td {
- background: var(--elevated);
- color: var(--text-muted);
- font-weight: 600;
+/* Log pager (#823 follow-up): match count, rows-per-page select, prev/next — the grouping
+ * dropdown's replacement now that presets/dates/search own the time navigation. */
+.log-pager {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+.log-pager select {
+ background: var(--bg);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 2px 6px;
+ font-size: 0.8rem;
}
/* Components */
diff --git a/build/dashboard/mining_dashboard/web/static/securityview.mjs b/build/dashboard/mining_dashboard/web/static/securityview.mjs
index b79cad92..ec298373 100644
--- a/build/dashboard/mining_dashboard/web/static/securityview.mjs
+++ b/build/dashboard/mining_dashboard/web/static/securityview.mjs
@@ -11,45 +11,101 @@
import { Component, html } from "./preact.mjs";
-const ACCESS_LIMIT_SHOWN = 20;
-
-// Audit entries share one ts format across every source (control.log's own writer and the #530
-// watchers both emit "YYYY-MM-DDTHH:MM:SSZ", see data_service._iso_now), so a bucket key is a
-// plain string slice — no date parsing, no timezone math.
-export function bucketKey(ts, granularity) {
- if (typeof ts !== "string") return "";
- if (granularity === "hour") return ts.slice(0, 13);
- if (granularity === "month") return ts.slice(0, 7);
- return ts.slice(0, 10); // "day"
-}
+// --- Log navigation (#823) -------------------------------------------------------------
+//
+// Both cards share one control row: the chart's preset idiom (24 Hr / 1 Wk / 1 Mo / All) for
+// "following" a live log, two native date inputs for jumping to a specific time, and a search
+// box. Filtering is SERVER-side (?from&to&q on /api/access and /api/audit) so a match outside
+// the glance tail is still found; the server owns sanitation, this file only builds the query.
-// Group already newest-first ``entries`` into contiguous {bucket, entries} runs for
-// ``granularity`` ("hour"|"day"|"month"), or one ungrouped run for "flat"/anything else — the
-// drill: pick "month" to scan a year at a glance, "hour" to pin down one incident.
-export function groupAuditEntries(entries, granularity) {
- if (granularity !== "hour" && granularity !== "day" && granularity !== "month") {
- return [{ bucket: null, entries }];
- }
- const groups = [];
- let current = null;
- for (const e of entries) {
- const key = bucketKey(e.ts, granularity);
- if (!current || current.bucket !== key) {
- current = { bucket: key, entries: [] };
- groups.push(current);
- }
- current.entries.push(e);
+export const LOG_PRESETS = [
+ { id: "24h", label: "24 Hr", secs: 86_400 },
+ { id: "7d", label: "1 Wk", secs: 7 * 86_400 },
+ { id: "30d", label: "1 Mo", secs: 30 * 86_400 },
+ { id: "all", label: "All", secs: null },
+];
+
+// UI filter state -> the endpoint query string. Preset and explicit dates are mutually
+// exclusive (the controls clear one when the other is picked); an explicit "to" date means
+// "through that whole day", so it maps to the NEXT midnight as the half-open upper bound.
+// Date inputs parse as UTC midnight (the audit trail is displayed in UTC) — close enough for
+// day-granularity jumps either way, and stable across viewer timezones.
+export function buildLogQuery({ preset = "all", fromDate = "", toDate = "", q = "" } = {}, now) {
+ const p = new URLSearchParams();
+ const nowSec = now !== undefined ? now : Date.now() / 1000;
+ const chosen = LOG_PRESETS.find((x) => x.id === preset);
+ if (fromDate || toDate) {
+ const from = Date.parse(fromDate) / 1000;
+ const to = Date.parse(toDate) / 1000;
+ if (Number.isFinite(from)) p.set("from", String(from));
+ if (Number.isFinite(to)) p.set("to", String(to + 86_400));
+ } else if (chosen && chosen.secs !== null) {
+ p.set("from", String(nowSec - chosen.secs));
}
- return groups;
+ const qq = q.trim();
+ if (qq) p.set("q", qq);
+ const s = p.toString();
+ return s ? `?${s}` : "";
+}
+
+const LogControls = ({ label, filters, onChange }) => {
+ const set = (patch) => onChange({ ...filters, ...patch });
+ return html`
`;
+};
+
+const EMPTY_FILTERS = { preset: "all", fromDate: "", toDate: "", q: "" };
+const isFiltering = (f) => f.preset !== "all" || !!f.fromDate || !!f.toDate || !!f.q.trim();
+
+// Pagination (#823 follow-up): the grouping dropdown became a page-size control — with range
+// presets, date jumps and search doing the time navigation, bucketed grouping answered a
+// strictly weaker question. Paging is CLIENT-side over the (server-filtered, bounded) result
+// set; pageFor clamps so a shrinking result set never strands the view on a page past the end.
+export const PAGE_SIZES = [5, 10, 20, 50, 100];
+
+export function pageFor(entries, page, size) {
+ // Guarded, not trusted: size comes from a fixed select in practice, but a 0/NaN reaching the
+ // division would render "page NaN of Infinity" — fall back to the default page size instead.
+ const s = Number.isFinite(size) && size >= 1 ? Math.floor(size) : 20;
+ const total = entries.length;
+ const pages = Math.max(1, Math.ceil(total / s));
+ const p = Math.min(Math.max(0, Number.isFinite(page) ? page : 0), pages - 1);
+ return { slice: entries.slice(p * s, (p + 1) * s), page: p, pages, total };
}
+const Pager = ({ label, total, page, pages, size, onPage, onSize }) => html`