Skip to content
Merged
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
17 changes: 17 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.16.1
1.17.0
42 changes: 42 additions & 0 deletions build/dashboard/mining_dashboard/service/audit_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
50 changes: 44 additions & 6 deletions build/dashboard/mining_dashboard/web/server.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import math
import mimetypes
import os
import re
Expand Down Expand Up @@ -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)
Expand Down
41 changes: 35 additions & 6 deletions build/dashboard/mining_dashboard/web/static/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 */
Expand Down
Loading