Post-backlog hardening + feature batch (26.18–26.30)#21
Merged
Conversation
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>
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.
Thirteen self-contained sprints landed on one branch (one version bump each,
feat+docscommit pairs). Every commit builds and passesgo test ./...,go vet ./..., andgolangci-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
admin.auth_token(envINVENTORY_ADMIN_TOKEN) gates every admin route; acceptsAuthorization: Beareror 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.alerts.webhook.url/alerts.syslog.addrare scheme-validated at config load (mirrorspeer_addr), blockingfile:///gopher://confusion (OWASP A10).Reliability / ops
scanner.scan_history_ttl+ScanStore.DeleteBefore+ agent prune;inventory_scans_pruned_total)./hosts+/scanspagination (?limit=/?offset=, shared pager partial).inventory_udp_probe_failure_total).Features
00:08:9b, was caught during verification and excluded).GET /api/v1/scanspaginated JSON scan history (optionalsubnetfilter).golang.org/x/net/route) — no shell, preserving the "no external processes" posture; verified live againstarp -n.Quality
internal/logging0→100%,internal/health/client.go, and previously untested admin handlers (watchdog page, scan trigger, CSRF rejection).Notes for reviewers
golang.org/x/netpromoted indirect → direct (already in the module graph; pure Go).LIMIT/OFFSETinto thestoreinterface to bound memory (a larger interface change that the filtered hosts API can't cleanly absorb).🤖 Generated with Claude Code