Skip to content

Post-backlog hardening + feature batch (26.18–26.30)#21

Merged
CryptoJones merged 26 commits into
mainfrom
feat/admin-auth-gate
Jun 5, 2026
Merged

Post-backlog hardening + feature batch (26.18–26.30)#21
CryptoJones merged 26 commits into
mainfrom
feat/admin-auth-gate

Conversation

@CryptoJones

Copy link
Copy Markdown
Owner

Thirteen self-contained sprints landed on one branch (one version bump each, feat+docs commit pairs). Every commit builds and passes go test ./..., go vet ./..., and golangci-lint run ./...; the scanner changes are verified for darwin/linux/windows.

The original Planning.md (42 items) and the P2 batch were already complete; this branch is the next round of hardening/features found in a fresh review.

Security

  • 26.18 — Admin console auth gate. admin.auth_token (env INVENTORY_ADMIN_TOKEN) gates every admin route; accepts Authorization: Bearer or HTTP Basic (token as password) so browsers and API clients both work; constant-time compare; required when bound off-loopback (mirrors the health-server rule). No-op on the loopback default.
  • 26.19 — Alert-sink URL validation. alerts.webhook.url / alerts.syslog.addr are scheme-validated at config load (mirrors peer_addr), blocking file:///gopher:// confusion (OWASP A10).

Reliability / ops

  • 26.20 — Scan-history retention (scanner.scan_history_ttl + ScanStore.DeleteBefore + agent prune; inventory_scans_pruned_total).
  • 26.25 — Admin /hosts + /scans pagination (?limit=/?offset=, shared pager partial).
  • 26.27 — Scanner config honesty (reverse-DNS honours the configured timeout; inventory_udp_probe_failure_total).

Features

  • 26.21 — PostgreSQL / Redis / Memcached fingerprints.
  • 26.23 — Classifier expansion: nas / hypervisor (QEMU/KVM/VirtualBox/Hyper-V/Proxmox) / kubernetes-node / container-host / camera.
  • 26.24 — UDP DNS + NTP (stratum) fingerprints.
  • 26.26 — OUI table expansion: 14 IEEE-verified camera/NAS/IoT prefixes wired into the classifier (a wrong candidate, 00:08:9b, was caught during verification and excluded).
  • 26.28 — GET /api/v1/scans paginated JSON scan history (optional subnet filter).
  • 26.29 — macOS MAC/vendor enrichment via the routing socket (golang.org/x/net/route) — no shell, preserving the "no external processes" posture; verified live against arp -n.
  • 26.30 — VNC (RFB) fingerprint on port 5900.

Quality

  • 26.22 — Test-coverage fill: internal/logging 0→100%, internal/health/client.go, and previously untested admin handlers (watchdog page, scan trigger, CSRF rejection).

Notes for reviewers

  • golang.org/x/net promoted indirect → direct (already in the module graph; pure Go).
  • Deliberately not included: Windows neighbour enrichment (no local test path — graceful no-op remains), and pushing LIMIT/OFFSET into the store interface to bound memory (a larger interface change that the filtered hosts API can't cleanly absorb).

🤖 Generated with Claude Code

Aaron K. Clark and others added 26 commits June 5, 2026 06:09
The admin console serves the full host/port inventory, /export.{json,csv},
the /api/v1/* query API, and the POST /scan trigger, but had no auth and no
off-loopback guard — unlike the health server. Binding admin.addr off-loopback
(e.g. 0.0.0.0, or via INVENTORY_ADMIN_ADDR) exposed everything on the network.

- Add admin.auth_token (env INVENTORY_ADMIN_TOKEN): a shared secret gating
  every route. Accepts Authorization: Bearer <token> or HTTP Basic auth with
  the token as the password (browsers get a native prompt). Constant-time
  compare; no-op when unset so the loopback default stays credential-free.
- Add admin.ServerOptions{AuthToken} threaded through NewServer + runtime.
- Validation refuses off-loopback admin binds without a token, mirroring the
  existing health.addr rule; chmod-600 enforcement extended to the new token.
- Tests cover Bearer/Basic accept+reject, export & POST /scan gating (auth
  precedes CSRF), the loopback no-token regression, and config validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Document admin.auth_token / INVENTORY_ADMIN_TOKEN in the config and env
tables, revise the admin-console endpoint note, update the OWASP A01/A07
rows and operator guidance, and add the 26.18 ChangeLog entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
watchdog.peer_addr was scheme-validated at config load, but
alerts.webhook.url reached the HTTP client with no validation — a config
value like file:///etc/passwd or gopher://… was used verbatim. Syslog
addresses were only validated when the sink dialed.

- Add validateSinkURL helper; validate alerts.webhook.url (http/https) and
  alerts.syslog.addr (udp/tcp) at config load, mirroring validatePeerAddr.
- Fail-fast at boot on an invalid value instead of failing on first event.
- Private/internal hosts are intentionally not blocked (internal receivers
  are legitimate, consistent with peer_addr allowing loopback); the guard
  targets scheme confusion (OWASP A10), not egress policy.
- Tests cover webhook/syslog bad-scheme, no-host, and valid cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Note scheme validation on alerts.webhook.url / alerts.syslog.addr in the
config table, update the OWASP A10 row to cover all outbound sink targets,
and add the 26.19 ChangeLog entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The scans table grew without bound — hosts had host_ttl pruning, but scan
records accumulated forever (~105K rows/year at the 5m default), bloating
the DB and the unbounded /scans view.

- Add store.ScanStore.DeleteBefore(ctx, cutoff) + SQLite impl (single
  bounded DELETE returning the row count).
- Add scanner.scan_history_ttl config; zero (default) keeps full history.
- Agent.pruneScans runs each cycle after the host prune; the Agent retains
  the ScanStore already passed to New (no signature change).
- Add inventory_scans_pruned_total metric.
- Tests: sqlite DeleteBefore (match + no-match), agent prune on/off,
  config parse + default. Mock ScanStores gain DeleteBefore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Document scanner.scan_history_ttl in the config table and add the 26.20
ChangeLog entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ports 5432/6379/11211 were deep-probed and recorded as open, but
fingerprint() had no handler for them, so Port.Service stayed empty.

- postgresProbe: SSLRequest startup packet, read S/N reply -> "PostgreSQL"
  (reliable identification without authenticating).
- redisInfo: send INFO server, parse redis_version: -> "Redis: <ver>";
  auth-gated servers (-NOAUTH) -> "Redis (auth required)".
- memcachedVersion: text "version" command -> "Memcached: <ver>".
- Dispatch the three ports in fingerprint().
- Tests for each via a new startRequestResponse helper (client speaks first).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…26.21

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…(26.22)

Fill the largest zero-coverage gaps:
- internal/logging 0%->100%: level parsing, json/text selection, agent field.
- internal/health/client.go: Ping (200/non-200/bearer/conn-error) and
  FetchStatus (decode ok/bad json) — the file had no test.
- internal/admin: handleWatchdog (peer + no-peer) and handleScanTrigger
  (501/204/503), plus CSRF rejection on POST /scan (missing token -> 403).

No behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…6.23)

Several common asset classes fell through to the generic appliance tag.
Add five categories built only on signals already available (NIC OUI +
open ports), no dead vendor strings:

- nas: Synology/Western Digital OUI, or NFS(2049)+SMB(445); ordered before
  the Windows SMB rule so a NAS isn't mislabelled windows-host.
- hypervisor: also QEMU/KVM, VirtualBox, Hyper-V by OUI; Proxmox via 8006.
- kubernetes-node: apiserver 6443 / etcd 2379 / kubelet 10250.
- container-host: Docker daemon 2375/2376.
- camera: RTSP 554.

Tests for each plus a regression that SMB alone stays windows-host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
udpScan recorded open UDP ports but left Port.Service empty. Confirmed-open
UDP ports are now fingerprinted:

- DNS (53): standard root A query; reply with QR set + matching txn ID
  identifies it -> "DNS" (REFUSED still proves DNS).
- NTP (123): NTPv3 client request; server-mode reply -> "NTP", with stratum
  appended when valid ("NTP (stratum 2)").
- New udp_banner.go (udpFingerprint/udpExchange); helpers take the port so
  they're testable and work for DNS/NTP on non-standard probed ports.

Tests use a UDP responder for both protocols plus negative cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both pages rendered every row in one response — tens of thousands of hosts
meant a multi-megabyte HTML page. They now paginate with the same
?limit=/?offset= convention as the JSON API (default 100, cap 1000), via
the shared parsePagination.

- pager type + newPager/pageSlice helpers; a shared "pager" template
  partial renders Prev/Next + "Showing X-Y of N", hidden when it all fits.
- Host-inventory subtitle reports the full total, not the page size.
- Tests: page windowing, prev/next targets, total, invalid limit -> 400.

Bounds the rendered page; store.List still loads the full slice (pushing
LIMIT into the store interface is a larger, later change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ules (26.26)

The 26.23 camera rule only fired on RTSP (not a default probe port). Add the
vendor OUIs that make vendor-based detection actually work.

- 14 new OUI prefixes, each verified against the IEEE registry via
  maclookup.app: Hikvision(3), Dahua(2), Axis(2), QNAP(1), Ubiquiti(3),
  Espressif(3).
- Classifier: Hikvision/Dahua/Axis -> camera; QNAP -> nas; Espressif ->
  embedded.
- Verification caught a wrong candidate: 00:08:9b is ICP Electronics, not
  QNAP — excluded, with a regression test guarding it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ic (26.27)

- reverseDNS now uses the per-host timeout (scanner.timeout / per-profile
  timeout) instead of a hardcoded 500ms, falling back to 500ms only when
  unset. PTR lookups respect operator tuning like every other probe.
- Add inventory_udp_probe_failure_total, incremented on a definitive
  closed (ICMP unreachable) UDP result — the counterpart to the success
  counter. Ambiguous no-reply probes remain uncounted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The query API exposed hosts but not scans; programmatic consumers had to
scrape the HTML /scans page. Add a symmetric endpoint:

- Same {total,limit,offset,scans} envelope and ?limit=/?offset= convention
  as /api/v1/hosts, with an optional ?subnet= exact-match filter. Newest
  first.
- Tests: full list, pagination, subnet filter, invalid limit -> 400.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ARP enrichment was Linux-only (/proc/net/arp); macOS left MACAddress/Vendor
empty. Add a macOS neighbour lookup WITHOUT a shell — it reads the routing
table via golang.org/x/net/route, keeping the "no external processes"
posture. Verified on a live host against `arp -n`.

- arp.go refactored: /proc parser extracted to parseProcARP (pure, testable
  anywhere); lookupARP tries it, then platform neighbourMAC.
- arp_darwin.go: routing-socket RIB dump -> link-layer addr.
- arp_fallback.go (!darwin): no-op (Linux served by the proc path; Windows
  degrades gracefully).
- x/net promoted to a direct dependency (already in graph, pure Go).
- Tests: parseProcARP parser tests + cross-platform lookupARP happy path;
  darwin test validates neighbourMAC against the live routing table.

Builds verified for darwin/linux/windows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port 5900 was deep-probed but unfingerprinted, leaving Port.Service empty
for VNC servers.

- vncBanner reads the RFB ProtocolVersion greeting (server speaks first) ->
  "VNC: RFB 003.008". Validates the "RFB " prefix so a non-VNC service on
  5900 isn't mislabelled.
- Dispatch 5900 in fingerprint().
- Tests: RFB greeting identified; non-RFB greeting -> "".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@CryptoJones CryptoJones merged commit e47650c into main Jun 5, 2026
5 of 7 checks passed
@CryptoJones CryptoJones deleted the feat/admin-auth-gate branch June 5, 2026 12:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant