diff --git a/.env.example b/.env.example index 49d65bf..707077e 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,10 @@ DEFAULT_ADMIN_DISPLAY_NAME=Administrator LOG_LEVEL=INFO SCHEDULER_API_BASE=http://127.0.0.1:8088 SCHEDULER_METRICS_PORT=9100 +BRAIN_HOST_PORT=8088 +FRONTEND_HOST_PORT=8080 +HOMELABSEC_UAT_FRONTEND_BIND=0.0.0.0 +HOMELABSEC_UAT_FRONTEND_PORT=18080 TARGET_SUBNET=10.0.0.0/24 DISCOVERY_INTERVAL_MINUTES=30 REPORT_HOUR_UTC=8 @@ -55,8 +59,9 @@ EDGE_OIDC_CLIENT_SECRET= EDGE_OIDC_COOKIE_SECRET= EDGE_OIDC_REDIRECT_URL= EDGE_OIDC_EMAIL_DOMAINS=* -EDGE_OIDC_SCOPE=openid email profile +EDGE_OIDC_SCOPE="openid email profile" EDGE_OIDC_WHITELIST_DOMAINS= +MONITORING_HOST_BIND=127.0.0.1 PROMETHEUS_HOST_PORT=9090 ALERTMANAGER_HOST_PORT=9093 ALERTMANAGER_DEFAULT_RECEIVER=null diff --git a/.env.uat.example b/.env.uat.example new file mode 100644 index 0000000..a46b82f --- /dev/null +++ b/.env.uat.example @@ -0,0 +1,99 @@ +# HomelabSec UAT / single-host deployment example. +# +# Copy to .env on the deployment host and change secrets before use: +# cp .env.uat.example .env +# +# This profile mirrors the pi4-style deployment: private Postgres, host-local +# API, LAN/reverse-proxy frontend, and monitoring services on non-default ports. + +POSTGRES_DB=homelabsec +POSTGRES_USER=homelabsec +POSTGRES_PASSWORD=change-me + +OLLAMA_URL=http://host.containers.internal:11434 +OLLAMA_HOST_URL=http://localhost:11434 +OLLAMA_MODEL=homelabsec-classifier +OLLAMA_TIMEOUT_SECONDS=120 + +FINGERBANK_ENABLED=true +FINGERBANK_API_KEY= +FINGERBANK_BASE_URL=https://api.fingerbank.org +FINGERBANK_TIMEOUT_SECONDS=10 +FINGERBANK_MIN_SCORE_ACCEPT=51 +FINGERBANK_MIN_SCORE_AUTO_ACCEPT=76 + +COLLECTORS_ENABLED=true +COLLECTOR_INTERFACE=any +COLLECTOR_DHCP_ENABLED=true +COLLECTOR_MDNS_ENABLED=true +COLLECTOR_SSDP_ENABLED=true + +CLASSIFICATION_FALLBACK_ROLE=unknown +CLASSIFICATION_FALLBACK_CONFIDENCE=0.10 +OBSERVATIONS_LIST_LIMIT=200 +FINGERPRINTS_LIST_LIMIT=200 +NOTABLE_ASSET_LIMIT=20 +ADMIN_STALE_SCAN_MINUTES=90 +AUTH_SESSION_DAYS=7 +DEFAULT_ADMIN_USERNAME=admin +DEFAULT_ADMIN_PASSWORD=change-me-now +DEFAULT_ADMIN_DISPLAY_NAME=Administrator +LOG_LEVEL=INFO + +# UAT port layout. These avoid common defaults so the stack can coexist with +# Nginx Proxy Manager, other monitoring systems, and local development stacks. +BRAIN_HOST_PORT=18088 +HOMELABSEC_UAT_FRONTEND_BIND=0.0.0.0 +HOMELABSEC_UAT_FRONTEND_PORT=18080 +MONITORING_HOST_BIND=0.0.0.0 +PROMETHEUS_HOST_PORT=19090 +GRAFANA_HOST_PORT=13001 +ALERTMANAGER_HOST_PORT=19093 + +SCHEDULER_METRICS_PORT=9100 +TARGET_SUBNET=10.0.0.0/24 +DISCOVERY_INTERVAL_MINUTES=30 +REPORT_HOUR_UTC=8 +TOP_PORTS=100 +API_RETRY_ATTEMPTS=5 +API_RETRY_DELAY_SECONDS=5 +STARTUP_API_TIMEOUT_SECONDS=120 +STARTUP_DISCOVERY=false + +LYNIS_POLL_INTERVAL_SECONDS=10 +LYNIS_SSH_TIMEOUT_SECONDS=30 +LYNIS_AUDIT_TIMEOUT_SECONDS=1800 +LYNIS_REPO_URL=https://github.com/CISOfy/lynis.git + +# Optional secure edge overlay values. Leave unused unless starting +# compose.exposed.yaml or compose.oidc.yaml. +EDGE_AUTH_USERNAME=admin +EDGE_AUTH_PASSWORD=change-me-now +EDGE_AUTH_MODE=basic +EDGE_SERVER_NAME=localhost +EDGE_TLS_MODE=self_signed +EDGE_HTTP_PORT=18081 +EDGE_HTTPS_PORT=18443 +EDGE_OIDC_PROVIDER=oidc +EDGE_OIDC_ISSUER_URL= +EDGE_OIDC_CLIENT_ID= +EDGE_OIDC_CLIENT_SECRET= +EDGE_OIDC_COOKIE_SECRET= +EDGE_OIDC_REDIRECT_URL= +EDGE_OIDC_EMAIL_DOMAINS=* +EDGE_OIDC_SCOPE="openid email profile" +EDGE_OIDC_WHITELIST_DOMAINS= + +# Alertmanager defaults to a null receiver so the stack is safe before real +# notification targets are configured. +ALERTMANAGER_DEFAULT_RECEIVER=null +ALERTMANAGER_WEBHOOK_URL= +ALERTMANAGER_VALIDATION_WEBHOOK_URL= +ALERTMANAGER_EMAIL_TO= +ALERTMANAGER_EMAIL_FROM= +ALERTMANAGER_SMARTHOST= +ALERTMANAGER_SMTP_AUTH_USERNAME= +ALERTMANAGER_SMTP_AUTH_PASSWORD= +ALERTMANAGER_SMTP_REQUIRE_TLS=true +GRAFANA_ADMIN_USER=admin +GRAFANA_ADMIN_PASSWORD=change-me-now diff --git a/.gitignore b/.gitignore index b07f2d4..553900b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,16 @@ discovery/parsed/ # Secrets .env *.env +secrets/runtime/ +secrets/plaintext/ +secrets/tmp/ +*.key +*.pem +*.p12 +*.pfx +*.kdbx +*.vault +*bitwarden*export* # OS .DS_Store diff --git a/.pytest-alertmanager.yml b/.pytest-alertmanager.yml new file mode 100644 index 0000000..cdd969f --- /dev/null +++ b/.pytest-alertmanager.yml @@ -0,0 +1,24 @@ + +route: + receiver: "null" + group_by: ["alertname", "job"] + group_wait: 10s + group_interval: 30s + repeat_interval: 4h + + routes: + - receiver: "delivery-validation" + matchers: + - alertname: "HomelabSecDeliveryValidation" + + +receivers: + - name: "null" + + - name: "delivery-validation" + webhook_configs: + - url: "http://host.containers.internal:19094/homelabsec-alert-validation" + send_resolved: true + + +templates: [] diff --git a/.sops.yaml b/.sops.yaml new file mode 100644 index 0000000..af116a2 --- /dev/null +++ b/.sops.yaml @@ -0,0 +1,8 @@ +# SOPS configuration for HomelabSec automation secrets. +# +# Only commit files that match the encrypted path rules below. Plaintext +# runtime files are generated locally by scripts/secrets/render_sops_env.py and +# must remain ignored. +creation_rules: + - path_regex: ^secrets/sops/.*\.enc\.(json|yaml|yml)$ + age: age16zlfacpnp7yszl9jxp8ua3t3dtvd9zed625qkt7cpmqvd7uht55qxfnmuf diff --git a/BACKLOG.md b/BACKLOG.md index 316599d..1cd0c66 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -23,6 +23,90 @@ Use it as the working queue. `TODO.md` remains the broader status and historical ## Current Prioritized Queue +### Operational P0: Secret Management Programme +Priority: `P0` +Status: `in progress` + +Goal: +- Bring API keys, SSH keys, tokens, certificates, recovery material, and service credentials under deliberate management before broad backup implementation. + +Reference: +- `docs/operations/homelab-secret-management-strategy.md` +- `docs/operations/secret-management-runbook.md` +- `docs/operations/secret-inventory-template.md` +- `secrets/README.md` + +Delivered so far: +- installed Faye-side `age`, `sops`, and Bitwarden CLI tooling with Bitwarden CLI pointed at Bitwarden EU +- generated Faye's initial SOPS age recipient and wired it into `.sops.yaml` +- added an encrypted sample secret bundle, metadata-only inventory example, guardrail validator, and local env renderer + +Deliver: +- use Robert's existing Bitwarden EU account as the primary human/recovery vault and SOPS + age as the automation secret format +- inventory high-value secrets by metadata only: owner, consumer, storage, rotation, recovery test, blast radius, and revocation path +- migrate known high-value secrets out of ad-hoc `.env` files and loose key locations into Bitwarden records, SOPS-encrypted files, or documented local materialization steps +- create and test encrypted backups for the Bitwarden/export recovery path, SOPS/age recovery material, backup repository passwords, and break-glass runbook +- rotate old, broad, or unclear-provenance credentials in staged batches +- add HomelabSec posture checks later without collecting raw secret values + +Safety rules: +- never put plaintext private keys, tokens, passwords, seeds, macaroons, backup repository passwords, rendered `.env` files, or vault exports in GitHub, backlog files, dashboards, alerts, logs, or Telegram +- report inventory coverage, freshness, rotation due dates, and recovery-test status only + +Next action: +- with Robert present, login/unlock Bitwarden EU, create recovery records for Faye's SOPS age identity, and start migrating the first real HomelabSec runtime secrets into encrypted SOPS bundles. + +### Operational P0: Self-Hosted Git Migration Programme +Priority: `P0` +Status: `planned` + +Goal: +- Move selected repositories from GitHub-only hosting to a self-hosted Git service without losing mirrors, backups, recovery options, or public-discovery benefits where they matter. + +Reference: +- `docs/operations/self-hosted-git-migration-strategy.md` + +Deliver: +- choose the self-hosted Git platform, with Forgejo as the current recommended pilot +- deploy the platform behind the homelab HTTPS/DNS/certificate chain with SSH Git access +- implement backup and restore drills before moving operational repositories +- migrate low-risk pilot repositories first, then active personal repositories, then operational/private repositories +- keep GitHub mirrors or fallback remotes until restore and rollback are proven +- defer high-impact autonomous/financial repositories until explicit go/no-go after lower-risk migration success + +Safety rules: +- do not delete or archive GitHub repositories during the pilot phase +- do not print or commit GitHub/Forgejo tokens, deploy keys, webhook secrets, Actions secrets, or private SSH keys +- do not make self-hosted Git the only copy of an important repository until backup and restore are verified + +Next action: +- confirm Forgejo as the pilot platform or choose an alternative, then write/deploy the first LAN-only Forgejo runbook and test it with a Tier 0 repository. + +### Operational P0: Thunderbluff 3-2-1 Backup Programme +Priority: `P0` +Status: `planned` + +Goal: +- Make Thunderbluff the primary encrypted backup landing zone, then complete a 3-2-1 posture with an independent/offsite or offline third copy. + +Reference: +- `docs/operations/homelab-backup-strategy.md` + +Deliver: +- confirm Thunderbluff backup share/path, access, capacity, snapshot/immutability support, and restricted backup users/keys +- inventory all important homelab apps and classify by RPO/RTO/data criticality +- implement the first three monitored jobs: Faye/Hermes runtime, HomelabSec Postgres/manifests, and proxy/DNS/certificate control-plane state +- extend to Home Assistant, Trading Team, WordPress, media/document apps, monitoring, Proxmox guests, and Lightning/Umbrel critical state +- replicate encrypted backups to a separate/offsite/offline third copy and run quarterly restore drills + +Safety rules: +- do not start implementation until Robert explicitly asks; this is currently a planning/backlog item +- application-aware database dumps before raw volume copies +- report presence, age, size, snapshot IDs, and restore-test status only; never expose backup contents or secrets + +Next action: +- secret-management choice is made; next backup step, only after Robert explicitly asks to start backup implementation, is to obtain/verify Thunderbluff access and implement the first three jobs. + ### Slice 1: Alert Routing Priority: `P0` Status: `done` @@ -176,6 +260,59 @@ Next priority: - richer alert delivery validation - backup retention policy and off-host storage +### Slice 11: Exposure Map Dashboard +Priority: `P0` +Status: `in progress` + +Goal: +- Turn HomelabSec into an operator-facing exposure map by correlating Nmap observations with HCM targets/certificates, NPM routes, Heimdall launcher links, and DNS answers. + +Reference: +- `docs/threat-exposure-map-and-dashboard-spec.md` + +Deliver: +- add route, DNS, launcher-link, and exposure-finding data models +- add read-only exposure summary/routes/DNS/launcher/findings APIs +- add secret-safe HCM and NPM collectors +- add Heimdall hygiene checks for raw-IP or legacy links where a preferred HTTPS route exists +- add DNS alignment checks that distinguish candidate records from production failures +- add dashboard cards/tables for control-plane risk, raw service exposure, TLS status, unknown services, and accepted findings + +Delivered so far: +- Added migration-backed exposure tables for routes, DNS records, launcher links, and exposure findings. +- Added authenticated read-only API skeletons for `/exposure/summary`, `/exposure/routes`, `/exposure/dns`, `/exposure/launcher-links`, and `/exposure/findings`. +- Added a dashboard exposure-map panel and frontend/API contract tests. + +Remaining: +- Add unit tests for route classifiers and finding severity rules once collectors/classifiers are introduced. +- Add fixture-based integration tests joining Nmap, HCM, NPM, Heimdall, and DNS payloads. +- Add secret-safe HCM, NPM, Heimdall, and DNS collectors. +- Add dashboard tables/cards for correlated control-plane risk, raw service exposure, TLS status, unknown services, and accepted findings. + +### Slice 12: Sandfly Security Finding Integration +Priority: `P2` +Status: `parked` + +Goal: +- Add Sandfly support as a future enrichment/source-of-findings integration for HomelabSec, after the core exposure map work is stable. + +Context: +- Public Sandfly tooling and docs appear sufficient to build the integration if a licensed Sandfly server is available. +- Expected integration points include API authentication, Sandfly-managed host inventory, check listing, scan launch, ad-hoc IP range or SSH credential scans, result retrieval, and mapping Sandfly alerts/findings into HomelabSec findings/remediation surfaces. +- This is deliberately not a current implementation item. + +Deliver later: +- document required Sandfly server/API configuration and secret handling +- add a read-only Sandfly collector for hosts, checks, scans, and findings +- map Sandfly severity/status/remediation into HomelabSec exposure findings without breaking existing finding shapes +- add opt-in scan launch controls with safe defaults and clear operator confirmation +- add fixture-backed tests using sanitized Sandfly API payloads + +Verification later: +- unit tests for Sandfly payload parsing and severity/status mapping +- integration tests with mocked Sandfly API responses +- dashboard contract tests for Sandfly-origin findings + ## Suggested Execution Order 1. Slice 1: Alert Routing @@ -188,6 +325,10 @@ Next priority: 8. Slice 8: DB Migration Discipline 9. Slice 9: Backup And Restore Drill 10. Slice 10: Admin UX Improvements +11. Operational P0: Secret Management Programme +12. Operational P0: Thunderbluff 3-2-1 Backup Programme +13. Slice 11: Exposure Map Dashboard +14. Slice 12: Sandfly Security Finding Integration, only after a Sandfly server/API path is selected ## Parking Lot @@ -199,3 +340,4 @@ These are valid ideas, but not current execution priorities: - multi-node deployment support - replacing host-network scheduler design - major frontend redesign +- Sandfly support until the core exposure map is stable and Robert decides to connect a licensed Sandfly server diff --git a/README.md b/README.md index 2cc54e4..30f44a5 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ This repo is at the stage where it can: - Docker Compose - Git - Curl +- age, SOPS, and Bitwarden CLI (`bw`) for the managed secrets workflow - Ollama running locally on the host - An installed classifier model such as `homelabsec-classifier` @@ -121,9 +122,56 @@ Still manual: - deciding whether the deployment is LAN-only or exposed behind a proxy - configuring TLS and authentication if you expose the stack beyond a trusted admin network - setting host firewall policy +- choosing a primary vault and migrating real secrets into managed storage - creating and testing a recurring backup plan - installing or updating custom Ollama models beyond the installer’s existence check +## Reproducible single-host deployment + +The repo includes a reproducible Docker Compose deployment shape for UAT or production-style single-host installs, including the same stack layout used by the live pi4 deployment. + +Source-of-truth files: + +- `compose/compose.yaml` — base application stack +- `compose/compose.uat.yaml` — single-host/UAT overlay: private Postgres, host-local API, published frontend +- `compose/compose.monitoring.yaml` — Prometheus, Grafana, and Alertmanager overlay +- `.env.uat.example` — example environment for the UAT/pi4-style port layout +- `docs/deployment/homelabsec-compose-stack.md` — full install, update, reverse-proxy, backup, and verification runbook +- `docs/runbooks/macpro-proxmox-power-recovery.md` — Mac Pro AC-restore and Proxmox guest-autostart recovery runbook +- `docs/operations/homelab-secret-management-strategy.md` — P0 strategy for vaulting, backing up, rotating, and recovering API keys, SSH keys, tokens, and other secrets without exposing raw values +- `docs/operations/secret-management-runbook.md` — operator runbook for Bitwarden EU, SOPS/age encryption, local env rendering, recovery drills, and rotation +- `secrets/` — non-secret inventory metadata plus SOPS-encrypted automation secret bundles; generated runtime files stay ignored +- `docs/operations/homelab-backup-strategy.md` — Thunderbluff-centred 3-2-1 backup strategy for homelab applications and control-plane state +- `docs/operations/self-hosted-git-migration-strategy.md` — staged plan for moving selected GitHub repositories to a self-hosted Git service while retaining mirrors, backups, and rollback paths +- `infra/proxmox/proxmox-host-access.yml` — Ansible IaC for the Proxmox `fayebot` account, key-only SSH, passwordless sudo, host access note, and removal of the former temporary direct-root key + +Quick reproduce command: + +```bash +git clone https://github.com/Twix166/homelabsec.git +cd homelabsec +cp .env.uat.example .env +$EDITOR .env +cd compose +docker compose \ + --env-file ../.env \ + -f compose.yaml \ + -f compose.uat.yaml \ + -f compose.monitoring.yaml \ + up -d --build +``` + +The UAT overlay defaults to: + +- frontend: `0.0.0.0:18080` +- brain API: `127.0.0.1:18088` +- Prometheus: `0.0.0.0:19090` +- Grafana: `0.0.0.0:13001` +- Alertmanager: `0.0.0.0:19093` +- Postgres: private to the Compose network + +Use a reverse proxy such as Nginx Proxy Manager, Caddy, Traefik, or nginx to provide TLS/authenticated routes to the published frontend and monitoring ports. Keep the brain API bound to loopback unless an authenticated edge layer is added. + ## Backup And Restore HomelabSec now includes scripted Postgres backup and restore helpers for compose-based deployments. @@ -657,6 +705,7 @@ The monitoring overlay now also includes: Relevant variables: ```bash +MONITORING_HOST_BIND=127.0.0.1 PROMETHEUS_HOST_PORT=9090 ALERTMANAGER_HOST_PORT=9093 ALERTMANAGER_DEFAULT_RECEIVER=null diff --git a/TODO.md b/TODO.md index 212ed88..a875b4c 100644 --- a/TODO.md +++ b/TODO.md @@ -72,6 +72,10 @@ Status: - An optional OIDC-based stronger-auth overlay now exists for exposed deployments. - Add real operator notification endpoints after choosing the preferred receiver channel. +- Park future Sandfly support until the core exposure map is stable and a licensed Sandfly server/API path is selected. +- Secret management implementation has started: Faye has `age`, SOPS, and Bitwarden CLI configured for Bitwarden EU, and the repo now contains `.sops.yaml`, `secrets/`, `scripts/secrets/`, and `docs/operations/secret-management-runbook.md` for SOPS/age validation and local runtime env rendering. +- Treat Thunderbluff-centred 3-2-1 backups as a P0 operational programme after secret handling is designed; see `docs/operations/homelab-backup-strategy.md`. +- Treat self-hosted Git migration as a staged P0 operational programme: prove Forgejo or the chosen alternative, backup/restore, mirrors, and rollback with low-risk repositories before moving operational/private repositories; see `docs/operations/self-hosted-git-migration-strategy.md`. ## Priority 6: Testing and Verification diff --git a/brain/app.py b/brain/app.py index e9ff585..a8e2dd6 100644 --- a/brain/app.py +++ b/brain/app.py @@ -38,6 +38,17 @@ from brainlib.config import COLLECTORS_ENABLED from brainlib.database import db from brainlib.errors import bad_gateway, bad_request, conflict, not_found +from brainlib.exposure import ( + exposure_summary, + list_exposure_dns_records, + list_exposure_findings, + list_exposure_launcher_links, + list_exposure_routes, +) +from brainlib.findings import ( + create_finding_instruction_or_404, + list_findings as list_findings_records, +) from brainlib.fingerprints import ( classification_lookup_signature, classification_lookup_signature_hash, @@ -137,6 +148,12 @@ class CompleteLynisRunRequest(BaseModel): error_text: str | None = None +class FindingInstructionRequest(BaseModel): + instruction_text: str + intent: str = "fix" + priority: str = "normal" + + @app.middleware("http") async def log_requests(request: Request, call_next): started = time.perf_counter() @@ -258,12 +275,83 @@ def list_observations(): return list_observations_records(conn) +@app.get("/findings") +def list_findings(request: Request): + with db() as conn: + from brainlib.auth import require_user + + require_user(conn, request) + return list_findings_records(conn) + + +@app.post("/findings/{finding_id}/instructions") +def create_finding_instruction(finding_id: str, payload: FindingInstructionRequest, request: Request): + with db() as conn: + user = auth_me(conn, request)["user"] + try: + return create_finding_instruction_or_404( + conn, + finding_id, + instruction_text=payload.instruction_text, + requested_by_user_id=user["user_id"], + intent=payload.intent, + priority=payload.priority, + ) + except ValueError as exc: + raise bad_request(str(exc)) from exc + + @app.get("/fingerprints") def list_fingerprints(): with db() as conn: return list_fingerprints_records(conn) +@app.get("/exposure/summary") +def get_exposure_summary(request: Request): + with db() as conn: + from brainlib.auth import require_user + + require_user(conn, request) + return exposure_summary(conn) + + +@app.get("/exposure/routes") +def get_exposure_routes(request: Request): + with db() as conn: + from brainlib.auth import require_user + + require_user(conn, request) + return list_exposure_routes(conn) + + +@app.get("/exposure/dns") +def get_exposure_dns_records(request: Request): + with db() as conn: + from brainlib.auth import require_user + + require_user(conn, request) + return list_exposure_dns_records(conn) + + +@app.get("/exposure/launcher-links") +def get_exposure_launcher_links(request: Request): + with db() as conn: + from brainlib.auth import require_user + + require_user(conn, request) + return list_exposure_launcher_links(conn) + + +@app.get("/exposure/findings") +def get_exposure_findings(request: Request): + with db() as conn: + from brainlib.auth import require_user + + require_user(conn, request) + return list_exposure_findings(conn) + + @app.get("/classification_lookup") def list_classification_lookup(): with db() as conn: diff --git a/brain/brainlib/exposure.py b/brain/brainlib/exposure.py new file mode 100644 index 0000000..4c5e94b --- /dev/null +++ b/brain/brainlib/exposure.py @@ -0,0 +1,430 @@ +from __future__ import annotations + +from typing import Any +from urllib.parse import urlparse + +import psycopg +from psycopg.types.json import Jsonb + +SEVERITY_ORDER = ("critical", "high", "medium", "low", "info") + + +def _iso(value) -> str | None: + return value.isoformat() if value is not None else None + + +def upsert_exposure_routes(conn: psycopg.Connection, routes: list[dict[str, Any]]) -> int: + with conn.cursor() as cur: + for route in routes: + cur.execute( + """ + INSERT INTO exposure_routes ( + source, domain, scheme, upstream_host, upstream_port, + tls_status, certificate_status, force_ssl, http2_support, + block_exploits, websocket_support, raw_json, observed_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, now()) + ON CONFLICT (source, domain, upstream_host, upstream_port) + DO UPDATE SET + scheme = EXCLUDED.scheme, + tls_status = EXCLUDED.tls_status, + certificate_status = EXCLUDED.certificate_status, + force_ssl = EXCLUDED.force_ssl, + http2_support = EXCLUDED.http2_support, + block_exploits = EXCLUDED.block_exploits, + websocket_support = EXCLUDED.websocket_support, + raw_json = EXCLUDED.raw_json, + observed_at = now() + """, + ( + route["source"], + route["domain"], + route.get("scheme"), + route.get("upstream_host"), + route.get("upstream_port"), + route.get("tls_status"), + route.get("certificate_status"), + route.get("force_ssl"), + route.get("http2_support"), + route.get("block_exploits"), + route.get("websocket_support"), + Jsonb(route.get("raw_json") or {}), + ), + ) + return len(routes) + + +def upsert_exposure_dns_records(conn: psycopg.Connection, records: list[dict[str, Any]]) -> int: + with conn.cursor() as cur: + for record in records: + cur.execute( + """ + INSERT INTO exposure_dns_records ( + hostname, resolver, record_type, record_value, status, raw_json, observed_at + ) + VALUES (%s, %s, %s, %s, %s, %s, now()) + ON CONFLICT (hostname, resolver, record_type, record_value) + DO UPDATE SET + status = EXCLUDED.status, + raw_json = EXCLUDED.raw_json, + observed_at = now() + """, + ( + record["hostname"], + record["resolver"], + record["record_type"], + record["record_value"], + record.get("status", "observed"), + Jsonb(record.get("raw_json") or {}), + ), + ) + return len(records) + + +def upsert_exposure_launcher_links(conn: psycopg.Connection, links: list[dict[str, Any]]) -> int: + with conn.cursor() as cur: + for link in links: + cur.execute( + """ + INSERT INTO exposure_launcher_links ( + source, title, url, normalized_host, link_kind, + preferred_route_domain, hygiene_status, raw_json, observed_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, now()) + ON CONFLICT (source, title, url) + DO UPDATE SET + normalized_host = EXCLUDED.normalized_host, + link_kind = EXCLUDED.link_kind, + preferred_route_domain = EXCLUDED.preferred_route_domain, + hygiene_status = EXCLUDED.hygiene_status, + raw_json = EXCLUDED.raw_json, + observed_at = now() + """, + ( + link["source"], + link["title"], + link["url"], + link.get("normalized_host"), + link.get("link_kind", "unknown"), + link.get("preferred_route_domain"), + link.get("hygiene_status", "observed"), + Jsonb(link.get("raw_json") or {}), + ), + ) + return len(links) + + +def _host_port_from_url(value: str | None) -> tuple[str | None, int | None]: + parsed = urlparse(value or "") + return parsed.hostname, parsed.port + + +def correlate_exposure_findings( + routes: list[dict[str, Any]], + dns_records: list[dict[str, Any]], + launcher_links: list[dict[str, Any]], +) -> list[dict[str, Any]]: + findings: list[dict[str, Any]] = [] + routes_by_upstream: dict[tuple[str, int | None], dict[str, Any]] = {} + + for route in routes: + host = route.get("upstream_host") + if host: + routes_by_upstream[(str(host), route.get("upstream_port"))] = route + + tls_status = str(route.get("tls_status") or "").lower() + force_ssl = route.get("force_ssl") + if tls_status in {"", "missing", "invalid", "expired"} or force_ssl is False: + domain = str(route.get("domain")) + findings.append( + { + "finding_type": "route_tls", + "severity": "medium", + "status": "open", + "title": f"Route TLS/HTTPS hardening gap: {domain}", + "description": "A routed service is missing a valid TLS state or is not configured to force HTTPS.", + "evidence": { + "domain": domain, + "tls_status": route.get("tls_status"), + "force_ssl": force_ssl, + }, + } + ) + + for link in launcher_links: + if link.get("link_kind") != "raw_ip": + continue + link_host, link_port = _host_port_from_url(link.get("url")) + route = routes_by_upstream.get((link_host or "", link_port)) + if not route: + continue + upstream = f"{route.get('upstream_host')}:{route.get('upstream_port')}" + findings.append( + { + "finding_type": "launcher_hygiene", + "severity": "high", + "status": "open", + "title": f"Launcher uses raw backend link for {route.get('domain')}", + "description": "A dashboard launcher link points directly at a raw backend while a preferred route exists.", + "evidence": { + "launcher_title": link.get("title"), + "launcher_url": link.get("url"), + "preferred_route_domain": route.get("domain"), + "route_upstream": upstream, + }, + } + ) + + for record in dns_records: + status = str(record.get("status") or "") + if status not in {"unresolved", "planned_unresolved"}: + continue + findings.append( + { + "finding_type": "dns_resolution", + "severity": "info" if status == "planned_unresolved" else "medium", + "status": "open", + "title": f"DNS record is unresolved: {record.get('hostname')}", + "description": "A tracked hostname does not currently resolve through the observed resolver.", + "evidence": { + "hostname": record.get("hostname"), + "resolver": record.get("resolver"), + "record_type": record.get("record_type"), + "status": status, + }, + } + ) + + return findings + + +def replace_exposure_findings(conn: psycopg.Connection, findings: list[dict[str, Any]]) -> int: + with conn.cursor() as cur: + cur.execute("DELETE FROM exposure_findings") + for finding in findings: + cur.execute( + """ + INSERT INTO exposure_findings ( + finding_type, severity, status, title, description, evidence, + created_at, updated_at + ) + VALUES (%s, %s, %s, %s, %s, %s, now(), now()) + """, + ( + finding["finding_type"], + finding["severity"], + finding.get("status", "open"), + finding["title"], + finding["description"], + Jsonb(finding.get("evidence") or {}), + ), + ) + return len(findings) + + +def exposure_summary(conn: psycopg.Connection) -> dict[str, Any]: + with conn.cursor() as cur: + cur.execute("SELECT count(*) FROM exposure_routes") + route_count = int(cur.fetchone()[0]) + cur.execute("SELECT count(*) FROM exposure_dns_records") + dns_count = int(cur.fetchone()[0]) + cur.execute("SELECT count(*) FROM exposure_launcher_links") + launcher_count = int(cur.fetchone()[0]) + cur.execute("SELECT count(*) FROM exposure_findings") + finding_count = int(cur.fetchone()[0]) + cur.execute( + """ + SELECT severity, count(*) + FROM exposure_findings + WHERE status = 'open' + GROUP BY severity + """ + ) + severity_rows = cur.fetchall() + cur.execute("SELECT now()") + generated_at = cur.fetchone()[0] + + open_by_severity = {severity: int(count) for severity, count in severity_rows} + return { + "generated_at": generated_at.isoformat(), + "routes": route_count, + "dns_records": dns_count, + "launcher_links": launcher_count, + "findings": finding_count, + "open_findings_by_severity": { + severity: open_by_severity[severity] + for severity in SEVERITY_ORDER + if severity in open_by_severity + }, + } + + +def list_exposure_routes(conn: psycopg.Connection) -> dict[str, list[dict[str, Any]]]: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + route_id, + source, + domain, + scheme, + upstream_host, + upstream_port, + tls_status, + certificate_status, + force_ssl, + http2_support, + block_exploits, + websocket_support, + raw_json, + observed_at + FROM exposure_routes + ORDER BY domain ASC, source ASC, observed_at DESC + """ + ) + rows = cur.fetchall() + + return { + "routes": [ + { + "route_id": str(row[0]), + "source": row[1], + "domain": row[2], + "scheme": row[3], + "upstream_host": row[4], + "upstream_port": row[5], + "tls_status": row[6], + "certificate_status": row[7], + "force_ssl": row[8], + "http2_support": row[9], + "block_exploits": row[10], + "websocket_support": row[11], + "raw_json": row[12], + "observed_at": _iso(row[13]), + } + for row in rows + ] + } + + +def list_exposure_dns_records(conn: psycopg.Connection) -> dict[str, list[dict[str, Any]]]: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + dns_record_id, + hostname, + resolver, + record_type, + record_value, + status, + raw_json, + observed_at + FROM exposure_dns_records + ORDER BY hostname ASC, resolver ASC, record_type ASC, record_value ASC + """ + ) + rows = cur.fetchall() + + return { + "dns_records": [ + { + "dns_record_id": str(row[0]), + "hostname": row[1], + "resolver": row[2], + "record_type": row[3], + "record_value": row[4], + "status": row[5], + "raw_json": row[6], + "observed_at": _iso(row[7]), + } + for row in rows + ] + } + + +def list_exposure_launcher_links(conn: psycopg.Connection) -> dict[str, list[dict[str, Any]]]: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + launcher_link_id, + source, + title, + url, + normalized_host, + link_kind, + preferred_route_domain, + hygiene_status, + raw_json, + observed_at + FROM exposure_launcher_links + ORDER BY hygiene_status ASC, title ASC, observed_at DESC + """ + ) + rows = cur.fetchall() + + return { + "launcher_links": [ + { + "launcher_link_id": str(row[0]), + "source": row[1], + "title": row[2], + "url": row[3], + "normalized_host": row[4], + "link_kind": row[5], + "preferred_route_domain": row[6], + "hygiene_status": row[7], + "raw_json": row[8], + "observed_at": _iso(row[9]), + } + for row in rows + ] + } + + +def list_exposure_findings(conn: psycopg.Connection) -> dict[str, list[dict[str, Any]]]: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + exposure_finding_id, + finding_type, + severity, + status, + title, + description, + evidence, + created_at, + updated_at + FROM exposure_findings + ORDER BY + CASE severity + WHEN 'critical' THEN 5 + WHEN 'high' THEN 4 + WHEN 'medium' THEN 3 + WHEN 'low' THEN 2 + ELSE 1 + END DESC, + created_at DESC, + exposure_finding_id DESC + """ + ) + rows = cur.fetchall() + + return { + "findings": [ + { + "exposure_finding_id": str(row[0]), + "finding_type": row[1], + "severity": row[2], + "status": row[3], + "title": row[4], + "description": row[5], + "evidence": row[6], + "created_at": _iso(row[7]), + "updated_at": _iso(row[8]), + } + for row in rows + ] + } diff --git a/brain/brainlib/findings.py b/brain/brainlib/findings.py new file mode 100644 index 0000000..e10562e --- /dev/null +++ b/brain/brainlib/findings.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from typing import Any + +import psycopg + +from brainlib.errors import not_found + + +VALID_INTENTS = {"fix", "investigate", "defer", "accept_risk", "note"} +VALID_PRIORITIES = {"low", "normal", "high", "urgent"} + + +def _serialize_instruction(row) -> dict[str, Any]: + return { + "instruction_id": str(row[0]), + "finding_id": str(row[1]), + "requested_by_user_id": str(row[2]) if row[2] is not None else None, + "instruction_text": row[3], + "intent": row[4], + "priority": row[5], + "status": row[6], + "created_at": row[7].isoformat(), + "updated_at": row[8].isoformat(), + } + + +def _serialize_latest_instruction(row) -> dict[str, Any] | None: + if row[11] is None: + return None + return { + "instruction_id": str(row[11]), + "instruction_text": row[12], + "intent": row[13], + "priority": row[14], + "status": row[15], + "created_at": row[16].isoformat(), + } + + +def list_findings(conn: psycopg.Connection) -> dict[str, list[dict[str, Any]]]: + with conn.cursor() as cur: + cur.execute( + """ + SELECT + f.finding_id, + f.asset_id, + a.preferred_name, + f.title, + f.description, + f.severity, + f.confidence, + f.evidence, + f.recommended_action, + f.created_at, + COALESCE(ic.instruction_count, 0) AS instruction_count, + li.instruction_id, + li.instruction_text, + li.intent, + li.priority, + li.status, + li.created_at AS latest_instruction_created_at + FROM findings f + JOIN assets a ON a.asset_id = f.asset_id + LEFT JOIN LATERAL ( + SELECT count(*) AS instruction_count + FROM finding_remediation_instructions i + WHERE i.finding_id = f.finding_id + ) ic ON TRUE + LEFT JOIN LATERAL ( + SELECT instruction_id, instruction_text, intent, priority, status, created_at + FROM finding_remediation_instructions i + WHERE i.finding_id = f.finding_id + ORDER BY created_at DESC, instruction_id DESC + LIMIT 1 + ) li ON TRUE + ORDER BY + CASE lower(f.severity) + WHEN 'critical' THEN 5 + WHEN 'high' THEN 4 + WHEN 'medium' THEN 3 + WHEN 'low' THEN 2 + ELSE 1 + END DESC, + f.created_at DESC, + f.finding_id DESC + """ + ) + rows = cur.fetchall() + + return { + "findings": [ + { + "finding_id": str(row[0]), + "asset_id": str(row[1]), + "preferred_name": row[2], + "title": row[3], + "description": row[4], + "severity": row[5], + "confidence": float(row[6]) if row[6] is not None else None, + "evidence": row[7], + "recommended_action": row[8], + "created_at": row[9].isoformat(), + "instruction_count": int(row[10]), + "latest_instruction": _serialize_latest_instruction(row), + } + for row in rows + ] + } + + +def create_finding_instruction( + conn: psycopg.Connection, + finding_id: str, + *, + instruction_text: str, + requested_by_user_id: str | None, + intent: str = "fix", + priority: str = "normal", +) -> dict[str, Any]: + instruction_text = instruction_text.strip() + if not instruction_text: + raise ValueError("Instruction text is required") + if intent not in VALID_INTENTS: + raise ValueError("Unsupported instruction intent") + if priority not in VALID_PRIORITIES: + raise ValueError("Unsupported instruction priority") + + with conn.cursor() as cur: + cur.execute("SELECT 1 FROM findings WHERE finding_id = %s", (finding_id,)) + if cur.fetchone() is None: + raise KeyError(finding_id) + + cur.execute( + """ + INSERT INTO finding_remediation_instructions ( + finding_id, + requested_by_user_id, + instruction_text, + intent, + priority + ) + VALUES (%s, %s, %s, %s, %s) + RETURNING + instruction_id, + finding_id, + requested_by_user_id, + instruction_text, + intent, + priority, + status, + created_at, + updated_at + """, + (finding_id, requested_by_user_id, instruction_text, intent, priority), + ) + row = cur.fetchone() + conn.commit() + return {"instruction": _serialize_instruction(row)} + + +def create_finding_instruction_or_404( + conn: psycopg.Connection, + finding_id: str, + *, + instruction_text: str, + requested_by_user_id: str | None, + intent: str = "fix", + priority: str = "normal", +) -> dict[str, Any]: + try: + return create_finding_instruction( + conn, + finding_id, + instruction_text=instruction_text, + requested_by_user_id=requested_by_user_id, + intent=intent, + priority=priority, + ) + except KeyError as exc: + raise not_found("Finding not found") from exc diff --git a/brain/collectors/exposure_collectors.py b/brain/collectors/exposure_collectors.py new file mode 100644 index 0000000..65b753e --- /dev/null +++ b/brain/collectors/exposure_collectors.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +from typing import Any, cast +from urllib.parse import urlparse + + +def _as_bool(value: Any) -> bool | None: + if value is None: + return None + return bool(value) + + +def _as_int(value: Any) -> int | None: + if value is None or value == "": + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _first_domain(value: Any) -> list[str]: + if isinstance(value, str): + return [value] if value else [] + if isinstance(value, list): + return [str(item) for item in value if str(item).strip()] + return [] + + +def normalize_npm_proxy_host(record: dict[str, Any]) -> list[dict[str, Any]]: + """Normalize an NPM proxy host using a strict non-secret allowlist. + + Do not copy NPM meta, advanced_config, certificate objects, access lists, + owners, tokens, or other non-allowlisted fields into raw_json. + """ + + routes: list[dict[str, Any]] = [] + proxy_host_id = record.get("id") + certificate_id = record.get("certificate_id") + upstream_port = _as_int(record.get("forward_port")) + for domain in _first_domain(record.get("domain_names")): + routes.append( + { + "source": "npm", + "domain": domain, + "scheme": record.get("forward_scheme"), + "upstream_host": record.get("forward_host"), + "upstream_port": upstream_port, + "tls_status": "configured" if certificate_id else "missing", + "certificate_status": f"certificate_id:{certificate_id}" if certificate_id else "missing", + "force_ssl": _as_bool(record.get("ssl_forced")), + "http2_support": _as_bool(record.get("http2_support")), + "block_exploits": _as_bool(record.get("block_exploits")), + "websocket_support": _as_bool(record.get("allow_websocket_upgrade")), + "raw_json": { + "proxy_host_id": proxy_host_id, + "certificate_id": certificate_id, + "caching_enabled": _as_bool(record.get("caching_enabled")), + }, + } + ) + return routes + + +def normalize_hcm_target(target: dict[str, Any]) -> dict[str, Any]: + route_url = str(target.get("route_url") or target.get("url") or "") + backend_url = str(target.get("backend_url") or target.get("target_url") or "") + route = urlparse(route_url) + backend = urlparse(backend_url) + tls_raw = target.get("tls") + health_raw = target.get("health") + tls = cast(dict[str, Any], tls_raw) if isinstance(tls_raw, dict) else {} + health = cast(dict[str, Any], health_raw) if isinstance(health_raw, dict) else {} + tls_status = tls.get("status") or target.get("tls_status") + + return { + "source": "hcm", + "domain": route.hostname or route.netloc or route_url, + "scheme": backend.scheme or None, + "upstream_host": backend.hostname, + "upstream_port": backend.port, + "tls_status": tls_status, + "certificate_status": tls_status, + "force_ssl": None, + "http2_support": None, + "block_exploits": None, + "websocket_support": None, + "raw_json": { + "name": target.get("name"), + "route_url": route_url, + "backend_url": backend_url, + "health_status": health.get("status") or target.get("health_status"), + "tls_issuer": tls.get("issuer") or target.get("tls_issuer"), + "tls_not_after": tls.get("not_after") or target.get("tls_not_after"), + }, + } + + +def normalize_heimdall_link(record: dict[str, Any]) -> dict[str, Any]: + title = str(record.get("title") or record.get("name") or "Untitled") + url = str(record.get("url") or record.get("link") or "") + parsed = urlparse(url) + host = parsed.hostname + scheme = parsed.scheme.lower() + is_ip = bool(host and all(part.isdigit() for part in host.split(".") if part) and host.count(".") == 3) + + if scheme == "https" and host and not is_ip: + link_kind = "named_https" + hygiene_status = "preferred" + preferred_route_domain = host + elif scheme == "http" and is_ip: + link_kind = "raw_ip" + hygiene_status = "weak_raw_http_ip" + preferred_route_domain = None + elif scheme == "http": + link_kind = "named_http" + hygiene_status = "weak_plain_http" + preferred_route_domain = None + elif is_ip: + link_kind = "raw_ip" + hygiene_status = "weak_raw_ip" + preferred_route_domain = None + else: + link_kind = "unknown" + hygiene_status = "observed" + preferred_route_domain = None + + return { + "source": "heimdall", + "title": title, + "url": url, + "normalized_host": host, + "link_kind": link_kind, + "preferred_route_domain": preferred_route_domain, + "hygiene_status": hygiene_status, + "raw_json": {}, + } + + +def normalize_dns_answer( + *, + hostname: str, + resolver: str, + record_type: str = "A", + values: list[str] | tuple[str, ...] | None = None, + planned: bool = False, +) -> list[dict[str, Any]]: + cleaned = [str(value).strip() for value in (values or []) if str(value).strip()] + if not cleaned: + return [ + { + "hostname": hostname, + "resolver": resolver, + "record_type": record_type, + "record_value": "", + "status": "planned_unresolved" if planned else "unresolved", + "raw_json": {"planned": planned}, + } + ] + + return [ + { + "hostname": hostname, + "resolver": resolver, + "record_type": record_type, + "record_value": value, + "status": "observed", + "raw_json": {"planned": planned}, + } + for value in cleaned + ] diff --git a/brain/init.sql b/brain/init.sql index 2632797..55c74da 100644 --- a/brain/init.sql +++ b/brain/init.sql @@ -230,3 +230,103 @@ CREATE INDEX IF NOT EXISTS idx_fingerbank_role_mappings_device_id WHERE fingerbank_device_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_fingerbank_role_mappings_priority ON fingerbank_role_mappings (priority DESC); + +CREATE TABLE IF NOT EXISTS finding_remediation_instructions ( + instruction_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + finding_id UUID NOT NULL REFERENCES findings(finding_id) ON DELETE CASCADE, + requested_by_user_id UUID REFERENCES users(user_id) ON DELETE SET NULL, + instruction_text TEXT NOT NULL, + intent TEXT NOT NULL DEFAULT 'fix', + priority TEXT NOT NULL DEFAULT 'normal', + status TEXT NOT NULL DEFAULT 'queued', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (char_length(btrim(instruction_text)) > 0), + CHECK (intent IN ('fix', 'investigate', 'defer', 'accept_risk', 'note')), + CHECK (priority IN ('low', 'normal', 'high', 'urgent')), + CHECK (status IN ('queued', 'in_progress', 'completed', 'cancelled')) +); + +CREATE INDEX IF NOT EXISTS idx_finding_remediation_instructions_finding_created + ON finding_remediation_instructions (finding_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_finding_remediation_instructions_status_priority + ON finding_remediation_instructions (status, priority, created_at DESC); + +CREATE TABLE IF NOT EXISTS exposure_routes ( + route_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source TEXT NOT NULL, + domain TEXT NOT NULL, + scheme TEXT, + upstream_host TEXT, + upstream_port INTEGER, + tls_status TEXT, + certificate_status TEXT, + force_ssl BOOLEAN, + http2_support BOOLEAN, + block_exploits BOOLEAN, + websocket_support BOOLEAN, + raw_json JSONB NOT NULL DEFAULT '{}'::jsonb, + observed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(source, domain, upstream_host, upstream_port) +); + +CREATE INDEX IF NOT EXISTS idx_exposure_routes_domain + ON exposure_routes (domain); +CREATE INDEX IF NOT EXISTS idx_exposure_routes_observed + ON exposure_routes (observed_at DESC); + +CREATE TABLE IF NOT EXISTS exposure_dns_records ( + dns_record_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hostname TEXT NOT NULL, + resolver TEXT NOT NULL, + record_type TEXT NOT NULL, + record_value TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'observed', + raw_json JSONB NOT NULL DEFAULT '{}'::jsonb, + observed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(hostname, resolver, record_type, record_value) +); + +CREATE INDEX IF NOT EXISTS idx_exposure_dns_hostname + ON exposure_dns_records (hostname); +CREATE INDEX IF NOT EXISTS idx_exposure_dns_status + ON exposure_dns_records (status); + +CREATE TABLE IF NOT EXISTS exposure_launcher_links ( + launcher_link_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source TEXT NOT NULL, + title TEXT NOT NULL, + url TEXT NOT NULL, + normalized_host TEXT, + link_kind TEXT NOT NULL DEFAULT 'unknown', + preferred_route_domain TEXT, + hygiene_status TEXT NOT NULL DEFAULT 'observed', + raw_json JSONB NOT NULL DEFAULT '{}'::jsonb, + observed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(source, title, url) +); + +CREATE INDEX IF NOT EXISTS idx_exposure_launcher_kind + ON exposure_launcher_links (link_kind); +CREATE INDEX IF NOT EXISTS idx_exposure_launcher_hygiene + ON exposure_launcher_links (hygiene_status); + +CREATE TABLE IF NOT EXISTS exposure_findings ( + exposure_finding_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + finding_type TEXT NOT NULL, + severity TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + title TEXT NOT NULL, + description TEXT NOT NULL, + evidence JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (severity IN ('critical', 'high', 'medium', 'low', 'info')), + CHECK (status IN ('open', 'accepted', 'resolved', 'dismissed')) +); + +CREATE INDEX IF NOT EXISTS idx_exposure_findings_status_severity + ON exposure_findings (status, severity, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_exposure_findings_type + ON exposure_findings (finding_type); diff --git a/brain/migrations/0007_findings_remediation_instructions.sql b/brain/migrations/0007_findings_remediation_instructions.sql new file mode 100644 index 0000000..6480c7c --- /dev/null +++ b/brain/migrations/0007_findings_remediation_instructions.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS finding_remediation_instructions ( + instruction_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + finding_id UUID NOT NULL REFERENCES findings(finding_id) ON DELETE CASCADE, + requested_by_user_id UUID REFERENCES users(user_id) ON DELETE SET NULL, + instruction_text TEXT NOT NULL, + intent TEXT NOT NULL DEFAULT 'fix', + priority TEXT NOT NULL DEFAULT 'normal', + status TEXT NOT NULL DEFAULT 'queued', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (char_length(btrim(instruction_text)) > 0), + CHECK (intent IN ('fix', 'investigate', 'defer', 'accept_risk', 'note')), + CHECK (priority IN ('low', 'normal', 'high', 'urgent')), + CHECK (status IN ('queued', 'in_progress', 'completed', 'cancelled')) +); + +CREATE INDEX IF NOT EXISTS idx_finding_remediation_instructions_finding_created + ON finding_remediation_instructions (finding_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_finding_remediation_instructions_status_priority + ON finding_remediation_instructions (status, priority, created_at DESC); diff --git a/brain/migrations/0008_exposure_map.sql b/brain/migrations/0008_exposure_map.sql new file mode 100644 index 0000000..8d56493 --- /dev/null +++ b/brain/migrations/0008_exposure_map.sql @@ -0,0 +1,77 @@ +CREATE TABLE IF NOT EXISTS exposure_routes ( + route_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source TEXT NOT NULL, + domain TEXT NOT NULL, + scheme TEXT, + upstream_host TEXT, + upstream_port INTEGER, + tls_status TEXT, + certificate_status TEXT, + force_ssl BOOLEAN, + http2_support BOOLEAN, + block_exploits BOOLEAN, + websocket_support BOOLEAN, + raw_json JSONB NOT NULL DEFAULT '{}'::jsonb, + observed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(source, domain, upstream_host, upstream_port) +); + +CREATE INDEX IF NOT EXISTS idx_exposure_routes_domain + ON exposure_routes (domain); +CREATE INDEX IF NOT EXISTS idx_exposure_routes_observed + ON exposure_routes (observed_at DESC); + +CREATE TABLE IF NOT EXISTS exposure_dns_records ( + dns_record_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + hostname TEXT NOT NULL, + resolver TEXT NOT NULL, + record_type TEXT NOT NULL, + record_value TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'observed', + raw_json JSONB NOT NULL DEFAULT '{}'::jsonb, + observed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(hostname, resolver, record_type, record_value) +); + +CREATE INDEX IF NOT EXISTS idx_exposure_dns_hostname + ON exposure_dns_records (hostname); +CREATE INDEX IF NOT EXISTS idx_exposure_dns_status + ON exposure_dns_records (status); + +CREATE TABLE IF NOT EXISTS exposure_launcher_links ( + launcher_link_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source TEXT NOT NULL, + title TEXT NOT NULL, + url TEXT NOT NULL, + normalized_host TEXT, + link_kind TEXT NOT NULL DEFAULT 'unknown', + preferred_route_domain TEXT, + hygiene_status TEXT NOT NULL DEFAULT 'observed', + raw_json JSONB NOT NULL DEFAULT '{}'::jsonb, + observed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(source, title, url) +); + +CREATE INDEX IF NOT EXISTS idx_exposure_launcher_kind + ON exposure_launcher_links (link_kind); +CREATE INDEX IF NOT EXISTS idx_exposure_launcher_hygiene + ON exposure_launcher_links (hygiene_status); + +CREATE TABLE IF NOT EXISTS exposure_findings ( + exposure_finding_id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + finding_type TEXT NOT NULL, + severity TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + title TEXT NOT NULL, + description TEXT NOT NULL, + evidence JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (severity IN ('critical', 'high', 'medium', 'low', 'info')), + CHECK (status IN ('open', 'accepted', 'resolved', 'dismissed')) +); + +CREATE INDEX IF NOT EXISTS idx_exposure_findings_status_severity + ON exposure_findings (status, severity, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_exposure_findings_type + ON exposure_findings (finding_type); diff --git a/compose/compose.monitoring.yaml b/compose/compose.monitoring.yaml index 86f2b53..f569c59 100644 --- a/compose/compose.monitoring.yaml +++ b/compose/compose.monitoring.yaml @@ -14,7 +14,7 @@ services: extra_hosts: - "host.containers.internal:host-gateway" ports: - - "127.0.0.1:${PROMETHEUS_HOST_PORT:-9090}:9090" + - "${MONITORING_HOST_BIND:-127.0.0.1}:${PROMETHEUS_HOST_PORT:-9090}:9090" depends_on: alertmanager: condition: service_started @@ -36,7 +36,7 @@ services: ALERTMANAGER_SMTP_AUTH_PASSWORD: ${ALERTMANAGER_SMTP_AUTH_PASSWORD:-} ALERTMANAGER_SMTP_REQUIRE_TLS: ${ALERTMANAGER_SMTP_REQUIRE_TLS:-true} ports: - - "127.0.0.1:${ALERTMANAGER_HOST_PORT:-9093}:9093" + - "${MONITORING_HOST_BIND:-127.0.0.1}:${ALERTMANAGER_HOST_PORT:-9093}:9093" healthcheck: test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:9093/-/healthy || exit 1"] interval: 30s @@ -56,7 +56,7 @@ services: - ../monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro - grafana-data:/var/lib/grafana ports: - - "127.0.0.1:${GRAFANA_HOST_PORT:-3001}:3000" + - "${MONITORING_HOST_BIND:-127.0.0.1}:${GRAFANA_HOST_PORT:-3001}:3000" depends_on: prometheus: condition: service_started diff --git a/compose/compose.uat.yaml b/compose/compose.uat.yaml new file mode 100644 index 0000000..6f59a80 --- /dev/null +++ b/compose/compose.uat.yaml @@ -0,0 +1,29 @@ +# UAT / reproducible single-host deployment overlay. +# +# Use this when a HomelabSec stack needs to run alongside other services on the +# same Docker host. It keeps Postgres private, binds the API to host-local +# loopback, and publishes only the operator-facing frontend plus optional +# monitoring ports. The live pi4 deployment uses this shape with host-specific +# values supplied from .env. + +services: + postgres: + # Keep the database private to the Compose network in UAT/production-style + # deployments. Backups and restores should use docker compose exec instead + # of a published database port. + ports: !reset [] + + brain: + ports: !override + - "127.0.0.1:${BRAIN_HOST_PORT:-18088}:8088" + + scheduler: + # Scheduler runs in host network mode in the base stack so it can perform + # local-network discovery. In this overlay the brain API is reachable via + # the host-local published API port above. + environment: + API_BASE: "http://127.0.0.1:${BRAIN_HOST_PORT:-18088}" + + frontend: + ports: !override + - "${HOMELABSEC_UAT_FRONTEND_BIND:-0.0.0.0}:${HOMELABSEC_UAT_FRONTEND_PORT:-18080}:80" diff --git a/docs/deployment/homelabsec-compose-stack.md b/docs/deployment/homelabsec-compose-stack.md new file mode 100644 index 0000000..0cfbe4c --- /dev/null +++ b/docs/deployment/homelabsec-compose-stack.md @@ -0,0 +1,227 @@ +# HomelabSec reproducible Compose deployment + +This document is the source-of-truth deployment recipe for the single-host HomelabSec stack. It captures the same infrastructure shape used by the live `pi4` deployment while keeping the instructions portable to any Linux Docker host. + +## Deployment shape + +HomelabSec is deployed as a Docker Compose project named `homelabsec`. + +Core services: + +- `homelabsec-postgres` — durable Postgres database; private to the Compose network in the UAT overlay. +- `homelabsec-brain` — API/backend; published to host loopback only by the UAT overlay. +- `homelabsec-scheduler` — discovery/report scheduler; runs with host networking for LAN discovery. +- `homelabsec-lynis-runner` — optional host-audit worker. +- `homelabsec-frontend` — web UI; published for LAN or reverse-proxy access. + +Monitoring services when `compose.monitoring.yaml` is included: + +- `homelabsec-prometheus` +- `homelabsec-grafana` +- `homelabsec-alertmanager` + +The live pi4-style stack uses these Compose files together: + +```bash +docker compose \ + --env-file ../.env \ + -f compose.yaml \ + -f compose.uat.yaml \ + -f compose.monitoring.yaml \ + up -d --build +``` + +## Prerequisites + +Install on the target host: + +- Linux with Docker Engine +- Docker Compose plugin that supports Compose file merge tags such as `!reset` and `!override` +- Git +- Curl +- Ollama reachable from the Docker host, unless `SKIP_OLLAMA_VALIDATION=true` is used for first boot +- The configured classifier model available in Ollama, for example `homelabsec-classifier` + +For discovery, the host must be attached to the network you want to scan. The scheduler uses host networking, and `TARGET_SUBNET` controls the subnet it scans. + +## Fresh install + +Clone the repo: + +```bash +git clone https://github.com/Twix166/homelabsec.git +cd homelabsec +``` + +Create the environment file: + +```bash +cp .env.uat.example .env +``` + +Edit `.env` before first start: + +```bash +$EDITOR .env +``` + +At minimum change: + +- `POSTGRES_PASSWORD` +- `DEFAULT_ADMIN_PASSWORD` +- `GRAFANA_ADMIN_PASSWORD` +- `TARGET_SUBNET` +- `OLLAMA_HOST_URL` if Ollama is not on the same host +- `OLLAMA_URL` if containers need a non-default Ollama address + +Start the stack: + +```bash +cd compose +docker compose \ + --env-file ../.env \ + -f compose.yaml \ + -f compose.uat.yaml \ + -f compose.monitoring.yaml \ + up -d --build +``` + +## Port layout + +The UAT example uses non-default host ports so the stack can coexist with other services. + +Default UAT ports from `.env.uat.example`: + +- Frontend: `http://:18080` +- Brain API: `http://127.0.0.1:18088` +- Prometheus: `http://:19090` +- Grafana: `http://:13001` +- Alertmanager: `http://:19093` +- Postgres: not published + +Variables that control the layout: + +```bash +BRAIN_HOST_PORT=18088 +HOMELABSEC_UAT_FRONTEND_BIND=0.0.0.0 +HOMELABSEC_UAT_FRONTEND_PORT=18080 +MONITORING_HOST_BIND=0.0.0.0 +PROMETHEUS_HOST_PORT=19090 +GRAFANA_HOST_PORT=13001 +ALERTMANAGER_HOST_PORT=19093 +``` + +For a private local-only deployment, set `HOMELABSEC_UAT_FRONTEND_BIND=127.0.0.1` and `MONITORING_HOST_BIND=127.0.0.1`, then expose only through a local tunnel or a controlled reverse proxy. + +## Reverse proxy / HTTPS + +The Compose stack does not require a specific reverse proxy. In Robert's pi4 deployment, Nginx Proxy Manager runs on the same host and proxies HTTPS routes to the UAT host ports. + +Equivalent proxy targets: + +- HomelabSec frontend: `:18080` +- Prometheus: `:19090` +- Grafana: `:13001` +- Alertmanager: `:19093` + +Keep the brain API on `127.0.0.1:18088`; do not expose it directly unless an authenticated edge layer is added. + +If publishing the service beyond a trusted admin LAN, use the secure edge/OIDC overlays or an external reverse proxy with TLS and authentication. + +## Update an existing deployment + +From the deployment directory: + +```bash +git pull --ff-only +cd compose +docker compose \ + --env-file ../.env \ + -f compose.yaml \ + -f compose.uat.yaml \ + -f compose.monitoring.yaml \ + up -d --build +``` + +The stack includes a one-shot `migrate` service. It runs before `brain` starts when the stack is brought up through Compose dependencies. + +To run migrations explicitly: + +```bash +cd compose +docker compose --env-file ../.env -f compose.yaml -f compose.uat.yaml run --rm migrate +``` + +## Verification + +Check container state: + +```bash +cd compose +docker compose \ + --env-file ../.env \ + -f compose.yaml \ + -f compose.uat.yaml \ + -f compose.monitoring.yaml \ + ps +``` + +Expected healthy services: + +- `homelabsec-postgres` +- `homelabsec-brain` +- `homelabsec-scheduler` +- `homelabsec-lynis-runner` +- `homelabsec-frontend` +- `homelabsec-alertmanager` + +Check application endpoints: + +```bash +curl -fsS http://127.0.0.1:${BRAIN_HOST_PORT:-18088}/health +curl -fsS http://127.0.0.1:${BRAIN_HOST_PORT:-18088}/version +curl -fsS http://127.0.0.1:${BRAIN_HOST_PORT:-18088}/report/summary +curl -fsS http://127.0.0.1:${PROMETHEUS_HOST_PORT:-19090}/-/ready +curl -fsS http://127.0.0.1:${ALERTMANAGER_HOST_PORT:-19093}/-/ready +curl -fsS http://127.0.0.1:${GRAFANA_HOST_PORT:-13001}/api/health +``` + +Check monitoring status: + +```bash +curl -fsS http://127.0.0.1:${PROMETHEUS_HOST_PORT:-19090}/api/v1/alertmanagers +curl -fsS http://127.0.0.1:${PROMETHEUS_HOST_PORT:-19090}/api/v1/alerts +curl -fsS http://127.0.0.1:${ALERTMANAGER_HOST_PORT:-19093}/api/v2/alerts +``` + +The `HomelabSecWatchdog` alert is intentionally present so alert routing can be validated. Treat unexpected critical/pending alerts as deployment failures. + +## Backup and restore + +Back up the Compose-managed Postgres database with: + +```bash +./scripts/backup_db.sh +``` + +Restore with: + +```bash +./scripts/restore_db.sh /path/to/backup.sql +``` + +Do not publish Postgres just to run backups. Use Compose exec/backup helpers. + +## Reproducing the pi4 stack elsewhere + +To reproduce the same shape on another host: + +1. Install Docker, Compose, Git, Curl, and Ollama. +2. Clone this repository. +3. Copy `.env.uat.example` to `.env`. +4. Change secrets, `TARGET_SUBNET`, host ports, and Ollama values. +5. Start with `compose.yaml + compose.uat.yaml + compose.monitoring.yaml`. +6. Point any reverse proxy at the documented host ports. +7. Run the verification checks above. + +No pi4-specific paths, IP addresses, hostnames, or credentials are required by the Compose files. The pi4 deployment is just one environment-specific checkout of this documented stack shape. diff --git a/docs/operations/homelab-backup-strategy.md b/docs/operations/homelab-backup-strategy.md new file mode 100644 index 0000000..1026546 --- /dev/null +++ b/docs/operations/homelab-backup-strategy.md @@ -0,0 +1,181 @@ +# Homelab Backup Strategy + +Purpose: make Thunderbluff the primary homelab backup landing zone, then extend to a practical 3-2-1 backup posture for all important homelab applications. + +Captured: 2026-06-07 + +## Target posture: 3-2-1 + +3-2-1 means: + +1. **3 copies of important data** + - Production copy on the app host. + - Primary backup copy on Thunderbluff. + - Secondary independent copy outside the production host + Thunderbluff failure domain. +2. **2 different media / storage systems** + - App host local disk, VM volume, Docker volume, or appliance storage. + - Thunderbluff backup share or encrypted backup repository. + - Prefer the third copy on a separate NAS, removable disk, or encrypted cloud/object storage rather than another share on the same Thunderbluff pool. +3. **1 offsite or offline copy** + - Best: encrypted cloud/object repository or periodically rotated USB disk stored away from the homelab. + - Minimum viable interim: replicate Thunderbluff backups to a separate storage system plus a scheduled offline/removable-disk rotation until cloud/offsite is selected. + +## Design principles + +- Thunderbluff is the primary backup hub, not the only copy. +- Use application-aware dumps for databases first; raw volume snapshots are secondary. +- Encrypt backup repositories before anything leaves the app host or Thunderbluff. +- Keep secrets out of logs, dashboards, GitHub, and Telegram reports. Report backup presence, age, size, and restore-test status only. +- Prefer small, restorable units: app config, database dump, compose/env manifests, TLS/DNS/proxy config, and critical state. +- Do not treat GitHub as the backup for runtime secrets, databases, uploaded files, or Docker volumes. +- Do not treat named Docker volumes or RAID as backup by themselves. +- Every important backup class needs a restore test, not just a successful job log. + +## Proposed repository layout on Thunderbluff + +Use a dedicated backup area, with one encrypted repository per trust class if using restic or borg: + +```text +/backups/homelab/ + faye/ + proxmox/ + proxy-edge/ + synology-apps/ + homelabsec/ + tradingteam/ + homeassistant/ + media-apps/ + documents/ + lightning/ + wordpress/ + restore-tests/ +``` + +Minimum controls: + +- Dedicated backup user/key per source host where practical. +- Append-only or restricted-write mode where the tool supports it. +- Snapshots immutable or protected on Thunderbluff if supported for the chosen share. +- Retention: daily for 14 days, weekly for 8 weeks, monthly for 12 months; tune per app. +- Monitoring: each job reports last success timestamp, bytes written, snapshot ID, and prune/check status. + +## Backup classes + +### Class A: identity, secrets, automation, control plane + +Scope: + +- Faye/Hermes config and skills/backlog, excluding volatile caches and logs. +- SSH public/private automation keys where required for recovery. +- DNS helper scripts and DNS zone exports. +- Proxy, certificate, and route configuration. +- Proxmox host access/IaC facts that are not already in Git. + +Approach: + +- Encrypted file backup from each host to Thunderbluff. +- Integrate with the secret-management programme rather than copying loose secrets into generic backups forever. +- Keep a separate emergency recovery bundle documented but not exposed in chat/logs. +- Verify by restoring to a disposable directory and checking file presence/permissions, not printing secret contents. + +### Class B: databases and app state + +Scope: + +- HomelabSec Postgres. +- Trading Team state, reports, snapshots, and any local databases. +- Paperless, Mealie, Audiobookshelf, Heimdall, Portainer, proxy manager, Grafana/Prometheus/Alertmanager, Open WebUI, and similar Docker app data. +- WordPress database/uploads if hosted inside the homelab; otherwise treat remote WordPress export separately. + +Approach: + +- Prefer logical dumps: `pg_dump`, app export, SQLite copy under service stop/read-lock, or database-native dump. +- Also capture compose files, env templates/source manifests, and volume inventory. +- Back up uploaded documents/media metadata; large replaceable media libraries can have a lower-cost policy. +- Restore-test at least one representative Postgres app, one SQLite app, and one Docker-volume app. + +### Class C: VMs/LXCs and host recovery + +Scope: + +- Proxmox VM/LXC configs and selected VM/LXC backups. +- Faye LXC, proxy/HCM host, HomelabSec UAT host, and appliance-like systems that are hard to recreate quickly. + +Approach: + +- Use Proxmox backup jobs where available, landing on Thunderbluff storage if it can be mounted safely. +- Keep IaC in Git as rebuild documentation, but still back up runtime state and secrets separately. +- Retain frequent backups for small/control-plane guests; less frequent for large/rebuildable guests. + +### Class D: special high-risk services + +Scope: + +- Home Assistant configuration, add-ons, automations, and snapshots. +- Lightning/Umbrel/LND static channel backup and wallet-critical metadata. +- Bitcoin node config/wallet-critical data. + +Approach: + +- Home Assistant: use native full/partial backups plus config backup to Thunderbluff; test restore to a disposable HA instance where possible. +- Lightning/LND: monitor static channel backup file presence/age/size after channel changes; never print backup contents. Treat missing/stale backup as critical. +- Bitcoin blockchain: usually do not back up full chain data if it can be re-synced; back up node config/wallet-critical data only, unless storage allows a low-frequency snapshot. + +### Class E: large media and replaceable data + +Scope: + +- Jellyfin media, ARR/download caches, audiobooks, books, photo/video libraries if present. + +Approach: + +- Separate irreplaceable libraries from replaceable downloads. +- Irreplaceable documents/photos/books: 3-2-1 with offsite encrypted copy. +- Replaceable media/download caches: metadata/config backup plus optional low-priority replication, not necessarily full 3-copy retention. + +## Implementation phases + +### Phase 0: discovery and access + +- Confirm Thunderbluff backup share/path, filesystem/snapshot support, capacity, and credentials. +- Confirm whether Faye has SSH/API access to Thunderbluff or whether Robert needs to authorize a key. +- Inventory all active apps from launcher links, proxy/certificate routes, Docker/Portainer, Proxmox, appliance packages, and HomelabSec. +- Classify each app by RPO/RTO and data criticality. + +### Phase 1: primary Thunderbluff landing zone + +- Create the dedicated backup area and restricted backup users/keys. +- Pick the backup engine: restic is the default recommendation for encrypted, deduplicated, scriptable backups; borg is also acceptable if preferred. +- Implement and verify the first three jobs: + 1. Faye/Hermes runtime config/backlog/skills. + 2. HomelabSec Postgres dump + compose/runtime manifests. + 3. Proxy-edge config including route/cert/DNS state. + +### Phase 2: app coverage + +- Add Docker/app backups for launcher, Portainer, document, recipe, audiobook, media, ARR/download, Open WebUI/Ollama, and monitoring stacks. +- Add Home Assistant native backups once backend/access is healthy. +- Add Trading Team state backups with strict practice/live safety separation. +- Add Lightning/Umbrel backup checks and alerts for stale static channel backups. + +### Phase 3: 3-2-1 completion + +- Choose secondary independent destination: separate NAS, rotated USB, encrypted cloud/object storage, or a mix. +- Replicate encrypted Thunderbluff repositories to the secondary target. +- Make at least one copy offsite or offline. +- Add quarterly restore drills and alerting for missed backup windows. + +## Success criteria + +- Every important app has a documented backup source, method, destination, frequency, retention, and restore command. +- Thunderbluff has recent encrypted backup snapshots for all Class A/B/C/D items. +- At least one restored sample from each backup class has been verified. +- Backup status is visible through HomelabSec/HCM/Prometheus/Telegram without exposing secrets. +- A ransomware/admin-error scenario cannot delete all copies from a single compromised app host. + +## Open decisions + +- Which third-copy target should be used: separate NAS, rotated USB, encrypted cloud/object storage, or all of the above? +- Should Thunderbluff replicate backups onward, or should each source host write independently to both destinations? +- Which datasets are irreplaceable enough for full 3-2-1: documents/photos/books, Paperless, Home Assistant, Lightning, Trading Team, WordPress, media libraries? +- Preferred backup tool: restic default, borg alternative, or appliance-native tooling where app-aware dumps are not needed? diff --git a/docs/operations/homelab-secret-management-strategy.md b/docs/operations/homelab-secret-management-strategy.md new file mode 100644 index 0000000..4a566b4 --- /dev/null +++ b/docs/operations/homelab-secret-management-strategy.md @@ -0,0 +1,221 @@ +# Homelab Secret Management Strategy + +Purpose: bring API keys, SSH keys, tokens, certificates, recovery material, and service credentials under deliberate management before they are broadly backed up. + +Captured: 2026-06-07 + +## Executive priority + +Secret management is **P0** and should be handled before broad backup implementation. Backups are only safe if the secrets inside them are encrypted, restorable, and revocable. Today, many homelab secrets are likely scattered across `.env` files, SSH directories, app databases, proxy/certificate stores, browser/operator machines, and ad-hoc notes. That creates two risks: + +- **Loss risk:** a power event, disk failure, or host rebuild can permanently lose keys/tokens needed to recover services. +- **Exposure risk:** a naive backup can copy raw secrets into too many places, making compromise or accidental Git/Telegram/log exposure more likely. + +The target is not “put every secret in Git.” The target is: every important secret is inventoried by name and owner, stored in a controlled encrypted system, backed up through a tested recovery path, rotated when needed, and never printed in normal operator output. + +## Scope + +Secret classes to manage: + +- SSH keys for Faye, Proxmox, proxy, Synology/NAS, Home Assistant, app hosts, GitHub deploy/push, and future automation accounts. +- API tokens for GitHub, Cloudflare/DNS, certificate issuance, trading/data providers, WordPress, Telegram/bots, notification endpoints, monitoring, and app integrations. +- App/runtime credentials: `.env` secrets, database passwords, OIDC/basic-auth credentials, admin bootstrap passwords, Grafana/Prometheus/Alertmanager credentials, Portainer/proxy-manager credentials, and service-specific tokens. +- TLS/certificate material where private keys are not automatically recoverable from the certificate manager. +- Recovery material for Home Assistant, Lightning/Umbrel/LND, wallet/channel backups, password-manager emergency access, and encryption keys. +- Backup repository secrets: restic/borg repository passwords, storage keys, append-only credentials, and offsite replication credentials. + +Do not store raw secret values in this repository, backlog files, issue bodies, dashboards, or Telegram reports. + +## Recommended architecture + +### Source of truth + +Use a password-manager/vault as the human-friendly source of truth, plus an automation-friendly encrypted secret distribution path. + +Decision: + +1. **Primary human/recovery vault:** Robert's existing Bitwarden EU account for human-managed records, emergency access, API keys, SSH key recovery copies, backup repository passwords, and break-glass notes. +2. **Automation secrets:** SOPS + age encrypted files for machine-consumable secrets, decryptable only by designated host or operator keys. +3. **Runtime materialization:** deploy scripts render `.env` files or service config on target hosts from encrypted sources; generated plaintext stays local, permission-restricted, and is not committed. +4. **Break-glass recovery:** a small sealed/offline recovery bundle containing the minimum information needed to regain access to Bitwarden, SOPS/age recipients, backup repositories, and core hosts. + +Vaultwarden is **not required for the initial strategy** because Robert already has Bitwarden EU. Add self-hosted Vaultwarden only if a later requirement justifies it, such as a local-only password-manager service, separate homelab vault tenancy, or avoiding hosted Bitwarden dependency. Until then, adding Vaultwarden would create another critical service to host, update, monitor, expose, and back up. + +Alternatives that can be chosen later: + +- Vaultwarden if a self-hosted Bitwarden-compatible vault becomes desirable. +- HashiCorp Vault or OpenBao if dynamic secrets and service-to-service leasing become valuable. +- KeePassXC if a simpler offline-first vault is preferred. +- Synology/C2 or cloud KMS only as an integration layer, not as the only recovery mechanism. + +### Automated secret access model + +Bitwarden EU can support automated secret lookup through the Bitwarden CLI/API, but it should not be the only automation layer and it should not become a runtime dependency for every service. + +Recommended split: + +1. **Bitwarden EU as human/recovery vault:** store high-value human-managed records, recovery copies, backup repository passwords, emergency notes, and source-of-truth metadata for API keys, SSH keys, service credentials, and break-glass material. +2. **SOPS + age as automation distribution:** store machine-consumable secrets in encrypted files that can live in Git only when encrypted, decryptable by approved operator/host keys. +3. **Local runtime materialization:** deployment scripts render `.env` files or service config on target hosts from encrypted sources; generated plaintext stays local, has restrictive permissions, and is excluded from Git/backups unless covered by the secret-backup policy. +4. **Bootstrap credentials as managed secrets:** any machine account, Bitwarden CLI API credential, Bitwarden session material, age identity, systemd credential, or local unlock file used by automation is itself a high-value secret with inventory, backup, rotation, and revocation requirements. + +Allowed automation patterns: + +- Faye or a deployment host may fetch a named secret from Bitwarden during a controlled deploy, then write a restricted local runtime file or update an encrypted SOPS file. +- Hosts may decrypt SOPS files with designated age identities during deployment or configuration rendering. +- HomelabSec may check whether secret references, encrypted files, inventories, and recovery backups exist and are fresh, but must not collect or display raw values. + +Avoid as the default: + +- Services directly querying Bitwarden on every startup or request, because a Bitwarden outage, expired CLI session, or bootstrap-credential failure could stop unrelated services from recovering after a power event. +- Giving every host broad Bitwarden access when a narrow SOPS recipient or rendered local config is enough. +- Logging `bw get`, decrypted SOPS output, rendered `.env` files, Authorization headers, private keys, cookies, session tokens, or seed/recovery material. + +SSH-key handling: + +- Day-to-day automation should use per-host/per-purpose local SSH keys with strict file permissions and narrow authorized-key or sudo/API scope. +- Bitwarden should store recovery copies or metadata for important keys, not necessarily serve private keys for every connection. +- Each SSH key should have inventory metadata for owner, target, allowed scope, backup/recovery path, rotation date, fingerprint, and revocation/removal path. + +### Git boundary + +GitHub should contain: + +- Secret inventory metadata without values. +- Encrypted SOPS files if we explicitly decide to use GitOps for some secrets. +- `.env.example` files and deployment templates. +- Runbooks for rotation, restore, and verification. + +GitHub should not contain: + +- Plaintext private keys, tokens, passwords, cookies, session files, seed phrases, macaroons, or backup repository passwords. +- Full host paths that reveal sensitive local layout in public-facing docs where avoidable. +- Debug logs that may include Authorization headers or rendered env files. + +## Inventory model + +Create a secret inventory with one row per secret class, not the raw value. + +Fields: + +- `id`: stable non-secret identifier, for example `github-token-faye-push`. +- `owner`: human or service owner. +- `system`: host/app/service that consumes it. +- `type`: SSH key, API token, DB password, TLS key, recovery phrase, backup key, webhook URL, etc. +- `storage`: vault item, SOPS file, host-local generated file, hardware token, or offline envelope. +- `consumers`: hosts/services allowed to read it. +- `rotation`: planned rotation interval or event trigger. +- `backup`: whether it is included in encrypted backups and where the recovery copy lives. +- `recovery test`: how to prove the secret can be restored without revealing it. +- `blast radius`: what an attacker can do if it leaks. +- `revocation`: where/how to revoke it. + +This inventory can live in the repo as `docs/operations/secret-inventory-template.md` or as a structured file later, but values stay elsewhere. + +## Backup strategy for secrets + +Secrets need a stricter backup plan than ordinary application data. + +Minimum posture: + +1. **Vault export backup:** encrypted password-manager export or vault database backup to Thunderbluff. +2. **SOPS/key backup:** age private keys or recipient recovery material stored in the vault and offline break-glass bundle. +3. **Host key backup:** only keys required for recovery are included; permissions and ownership are checked during restore tests. +4. **Backup repository passwords:** stored in the vault, included in break-glass, and never embedded in job logs. +5. **Offsite/offline copy:** encrypted copy of vault export and break-glass material, separate from Thunderbluff. + +Never rely on a single running password-manager instance as the only copy of secrets. Never rely on Thunderbluff alone for vault recovery. Never include raw secrets in HomelabSec findings, dashboards, alerts, or routine Telegram output. + +## Implementation phases + +### Phase 0: freeze and protect + +Implementation status: started in this repository. `.sops.yaml`, `secrets/`, and +`scripts/secrets/` now provide the initial SOPS/age workflow, validation guard, +encrypted sample bundle, and local env renderer. + +- Stop adding new plaintext secrets to Git, chat, docs, or generic backups. +- Add or verify repo-level secret scanning before every commit/push. +- Review `.gitignore`, `.dockerignore`, and examples so generated `.env`, keys, vault exports, and backup files are excluded. +- Make a list of known high-value secret locations without printing values. + +### Phase 1: inventory and choose tooling + +- Pick the primary vault: Bitwarden/Vaultwarden, 1Password, KeePassXC, or another explicit choice. +- Pick automation encryption: SOPS with age is the default recommendation. +- Inventory current secrets by class and consumer, starting with: + 1. Faye/Hermes and Telegram/GitHub/Ollama/provider credentials. + 2. SSH automation keys for Proxmox, proxy, NAS, Home Assistant, app hosts, and GitHub. + 3. Proxy/DNS/certificate tokens and private keys. + 4. HomelabSec runtime credentials and monitoring/admin credentials. + 5. Backup repository passwords and storage credentials. + 6. Trading Team, WordPress, Home Assistant, and Lightning/Umbrel recovery material. +- Record owner, storage location class, rotation path, and recovery test for each secret. Do not record raw values. + +### Phase 2: vault migration + +- Create vault records for each high-value secret, tagged by system and recovery importance. +- Generate new credentials where current provenance is unclear. +- Move machine-readable deployment secrets into SOPS-encrypted files or documented local materialization steps. +- Replace ad-hoc shared credentials with per-service/per-host credentials where possible. +- Document break-glass access and store it offline. + +### Phase 3: rotation and least privilege + +- Rotate GitHub, DNS/certificate, Telegram/bot, service admin, and database credentials that are old, overbroad, or known to have lived in plaintext. +- Replace direct root/admin automation with locked-password automation accounts and narrow sudo/API scopes where practical. +- Use separate keys/tokens per host and per purpose; avoid one universal automation key. +- Remove stale authorized keys, unused tokens, old `.env` copies, and unneeded admin sessions. + +### Phase 4: backup and restore tests + +- Back up the vault/export, SOPS encrypted files, age recipient material, backup repository passwords, and emergency runbook to Thunderbluff. +- Replicate encrypted secret backups to the independent/offsite/offline third copy. +- Run quarterly secret recovery drills: + - restore vault export to a disposable vault or offline test environment; + - decrypt one non-production SOPS sample with the documented recovery key; + - use a restored SSH key against a disposable/low-risk target or validate key fingerprint/permissions; + - prove a backup repository can be unlocked without revealing the password. + +### Phase 5: monitoring and governance + +- HomelabSec should eventually report secret-management posture without collecting raw secrets: + - stale key age; + - missing inventory entries; + - overly broad credentials; + - committed secret detections; + - missing encrypted backup freshness; + - rotation due dates. +- Alerts should describe the control failure, not the secret value. +- Reports should include counts, ages, owners, and remediation links only. + +## HomelabSec integration ideas + +Future HomelabSec slices can help manage this safely: + +- Add a secret inventory schema containing metadata only. +- Add collectors that look for risky file names and permissions without reading secret contents. +- Add Git secret-scan results as findings. +- Add SSH authorized-key inventory and stale-key checks. +- Add backup freshness checks for vault exports and encrypted secret bundles. +- Add dashboard cards for “secrets inventory coverage,” “rotation due,” and “secret backup freshness.” + +## Success criteria + +- Every high-value secret has a named inventory entry with owner, consumers, storage, rotation, backup, recovery test, and revocation path. +- No plaintext secrets are added to GitHub, docs, backlog, alerts, dashboards, or Telegram. +- Primary vault and automation-encrypted secrets are backed up to Thunderbluff and one independent/offsite/offline target. +- At least one recovery drill proves vault export restore, SOPS/age decryption, SSH-key recovery, and backup-repository unlock. +- Old broad credentials are replaced by purpose-specific credentials with limited blast radius. +- HomelabSec can monitor posture without becoming a secret store. + +## Open decisions + +- Primary vault: decided for initial rollout: Robert's existing Bitwarden EU account. +- Automation format: decided for initial rollout: SOPS + age. +- Vaultwarden: not needed initially; revisit only if a self-hosted Bitwarden-compatible vault becomes a deliberate requirement. +- Automated access: which hosts/users get Bitwarden CLI/API access, and which should use only SOPS/age or rendered local config? +- Bootstrap protection: where are Bitwarden machine credentials, CLI session material, age identities, and unlock files stored, backed up, rotated, and revoked? +- Emergency access: who/where holds the break-glass recovery material? +- Rotation policy: rotate all old/high-value secrets immediately after vault migration, or rotate in staged batches by system? +- Offsite/offline target: encrypted cloud/object storage, rotated USB, separate NAS, or a hybrid? diff --git a/docs/operations/secret-inventory-template.md b/docs/operations/secret-inventory-template.md new file mode 100644 index 0000000..0dbcc41 --- /dev/null +++ b/docs/operations/secret-inventory-template.md @@ -0,0 +1,35 @@ +# Secret Inventory Template + +Purpose: track secret metadata without storing raw secret values. + +Do not put private keys, tokens, passwords, seed phrases, macaroons, cookies, session files, repository passwords, or rendered `.env` contents in this file. + +## Template row + +```yaml +id: example-service-api-token +owner: Robert / service owner +system: app-or-host-name +secret_type: api-token | ssh-key | db-password | tls-key | recovery-material | backup-key | webhook-url | other +storage: vault-item | sops-file | host-local-generated-file | hardware-token | offline-envelope +consumers: + - host-or-service-allowed-to-use-it +rotation: quarterly | annual | on-staff-change | on-leak | manual +backup: encrypted-vault-export-and-offsite-copy +recovery_test: describe how to prove it works without exposing the value +blast_radius: what compromise enables +revocation: where and how to revoke or replace it +notes: non-secret notes only +``` + +## Initial inventory groups + +- Faye/Hermes: Telegram bot token, GitHub token, provider credentials, SSH automation keys, local model endpoint credentials if any. +- Proxmox/Mac Pro: automation SSH keys, API tokens, sudoers scope. +- Proxy/DNS/certificates: DNS API credentials, certificate account keys, reverse-proxy admin credentials, TLS private-key storage. +- NAS/Thunderbluff/backups: backup user keys, repository passwords, storage credentials, snapshot/admin credentials. +- HomelabSec: Postgres password, admin credentials, OIDC/basic-auth secrets, monitoring/admin credentials, notification webhooks. +- Home Assistant: long-lived access tokens, add-on credentials, backup encryption/recovery material. +- Trading Team: practice/live API credentials, broker/data-provider keys, notification tokens, deployment keys. +- WordPress: API/application passwords, admin credentials, deployment tokens. +- Lightning/Umbrel: static channel backup location metadata, wallet/recovery material, macaroon/token classes. diff --git a/docs/operations/secret-management-runbook.md b/docs/operations/secret-management-runbook.md new file mode 100644 index 0000000..16a3f85 --- /dev/null +++ b/docs/operations/secret-management-runbook.md @@ -0,0 +1,97 @@ +# Secret management runbook + +This runbook operationalizes the HomelabSec secret-management strategy: Bitwarden +EU for human/recovery records, SOPS + age for automation, and local rendered +runtime files for services. + +## Bootstrap state + +Faye has the following tooling installed: + +- `age` / `age-keygen` +- `sops` +- Bitwarden CLI `bw`, configured to `https://vault.bitwarden.eu` + +Faye's public SOPS recipient is recorded in `.sops.yaml`. The corresponding +private age identity is host-local and must be backed up into Bitwarden EU and +the offline break-glass bundle before this becomes the only recovery path. + +## Create or update an automation secret bundle + +1. Create or update the source item in Bitwarden EU. +2. On Faye, login/unlock Bitwarden interactively when Robert is present: + + ```bash + bw config server https://vault.bitwarden.eu + bw login + bw unlock + ``` + +3. Export/copy only the fields needed for a deployment into a temporary local + JSON file under `/tmp` or another non-repo path. +4. Encrypt it into the repo: + + ```bash + sops --encrypt --input-type json --output-type json \ + /tmp/homelabsec-runtime.json \ + > secrets/sops/homelabsec.runtime.enc.json + ``` + +5. Remove the temporary plaintext immediately. +6. Validate without printing values: + + ```bash + python3 scripts/secrets/validate_secrets_management.py + ``` + +7. Commit only encrypted SOPS files, non-secret metadata, scripts, and docs. + +## Render a runtime `.env` file + +```bash +python3 scripts/secrets/render_sops_env.py \ + secrets/sops/homelabsec.runtime.enc.json \ + secrets/runtime/homelabsec.env +``` + +The renderer writes `0600` files and prints only the output path. It does not +print decrypted values. + +## Recovery drill + +Quarterly or after key changes: + +1. Confirm Bitwarden EU can be unlocked by Robert. +2. Retrieve the recovery copy of the age identity into a disposable or controlled + host path with `0600` permissions. +3. Decrypt `secrets/sops/homelabsec.sample.enc.json` without displaying values: + + ```bash + python3 scripts/secrets/validate_secrets_management.py + ``` + +4. Render the sample to a temporary env file and confirm the file exists with + `0600` permissions. +5. Delete temporary plaintext artifacts. + +## Rotation / revocation + +When an age identity may be exposed or a host should lose access: + +1. Generate a replacement identity on the trusted host. +2. Add the new public recipient to `.sops.yaml`. +3. Re-encrypt each SOPS file with `sops updatekeys` or decrypt/re-encrypt in a + controlled local path. +4. Remove the old recipient from `.sops.yaml`. +5. Delete the old private identity from hosts that no longer need access. +6. Update Bitwarden EU, inventory metadata, and the break-glass bundle. +7. Run validation and commit the encrypted-file changes. + +## Operator safety rules + +- Never paste decrypted secrets into GitHub, docs, backlog files, dashboards, or + Telegram. +- Never attach Bitwarden exports or age private identities to issues or chats. +- Do not log `bw get`, `sops --decrypt`, rendered env files, Authorization + headers, cookies, seeds, macaroons, or private keys. +- Treat Bitwarden CLI session material and age identities as high-value secrets. diff --git a/docs/operations/self-hosted-git-migration-strategy.md b/docs/operations/self-hosted-git-migration-strategy.md new file mode 100644 index 0000000..e17a6b4 --- /dev/null +++ b/docs/operations/self-hosted-git-migration-strategy.md @@ -0,0 +1,281 @@ +# Self-Hosted Git Migration Strategy + +## Purpose + +Move selected repositories from GitHub-only hosting to a self-hosted Git service without losing GitHub's strengths for public discovery, pull requests, or offsite redundancy. + +This is a planning document. It does **not** move repositories by itself. The goal is to define a safe path for choosing the platform, deploying it in the homelab, migrating low-risk repositories first, and keeping recovery options clear. + +## Current Starting Point + +A non-secret GitHub inventory taken from the account currently shows 18 owned repositories: a mix of public utility repositories, private application repositories, homelab operations repositories, writing/content repositories, and high-impact automation repositories. + +Because this repository may be public, this strategy intentionally does **not** list private repository names. Keep exact private repo names in an operator-only tracker or the self-hosted Git migration issue queue, not in this public planning document. + +Treat the inventory as a snapshot, not the source of truth. Refresh it before migration. + +## Recommended Direction + +Use **Forgejo** as the first self-hosted Git platform unless a future requirement clearly needs GitLab's heavier project-management and CI features. + +Why Forgejo first: + +- light enough for homelab infrastructure +- compatible with normal Git workflows +- has web UI, issues, pull requests, releases, SSH/HTTPS Git, and package features +- simpler to back up and restore than a full GitLab stack +- easier to keep private/LAN-first while still mirroring selected repos to GitHub + +Avoid self-hosting as a single point of failure. For important repositories, keep at least one independent remote outside the Git host: + +- self-hosted Forgejo as the primary working remote for selected repos +- GitHub as a read-only or backup mirror where appropriate +- encrypted backup of the Forgejo application database, Git repositories, attachments, LFS objects, and configuration + +## Hosting Model + +Target shape: + +- `git.home.robertbalm.com` or equivalent home HTTPS route for browser/API access +- SSH Git access on a deliberate port, preferably via a fixed LAN route or VPN-only path +- service behind the existing homelab reverse-proxy/certificate/DNS chain +- persistent volumes for Git repositories, database, attachments, LFS, avatars, and actions data +- SOPS/age-managed runtime secrets, following `docs/operations/secret-management-runbook.md` +- monitored health, disk, backup freshness, and certificate expiry + +Keep the first deployment LAN-only unless Robert explicitly wants external access. + +## Migration Principles + +1. **Mirror before cutover.** Create bidirectional or one-way mirrors before changing developer remotes. +2. **Low-risk repos first.** Start with dormant or personal public repos before active private operational repos. +3. **Keep GitHub as fallback.** Do not delete GitHub repos during the first phase. Archive or mark read-only only after restore drills and mirror checks are proven. +4. **No secrets in migration artifacts.** Migration scripts and logs must not print tokens, deploy keys, private SSH keys, webhook secrets, or Actions secrets. +5. **One repo at a time.** Migrate, verify, and record each repository before starting the next. +6. **Restore is part of done.** The self-hosted platform is not production-ready until a backup restore drill proves the service and repositories can be recovered. + +## Repository Tiers + +### Tier 0: Pilot / Low-Risk + +Use these to prove platform operations, backup, SSH/HTTPS Git, mirrors, and restore: + +- small public shell/config repositories +- small public utility repositories +- dormant or disposable repositories with no production automation +- one newly-created test repository used only for migration drills + +Acceptance criteria: + +- repo imported into Forgejo +- clone via HTTPS works +- clone via SSH works +- push to a test branch works +- GitHub mirror remains updated or intentionally unchanged +- backup includes the repo and restore drill can recover it + +### Tier 1: Active Personal / Non-Critical + +Move after Tier 0 proves the platform: + +- public application or utility repositories that benefit from GitHub mirroring +- private personal repositories without production deployment hooks +- content/model/config repositories that can tolerate a short rollback window + +Acceptance criteria: + +- issues and releases are imported or intentionally left on GitHub +- repository default branch is protected in Forgejo +- GitHub remote is retained as `github` or configured as a mirror +- developer machines and Faye have working SSH deploy/user access + +### Tier 2: Operational / Sensitive + +Move only after backup, restore, monitoring, and access controls are proven: + +- homelab security and operations repositories +- assistant/runtime infrastructure repositories +- website/content deployment repositories +- finance or analysis repositories that are not autonomous trading systems +- any repository containing deployment workflows, private infrastructure metadata, or automation credentials + +Acceptance criteria: + +- private visibility and access control verified +- repository secrets reviewed and moved to Bitwarden/SOPS where applicable +- deploy keys and automation tokens rotated or re-scoped +- CI/deployment paths updated and tested +- GitHub mirror policy chosen per repo +- rollback path tested by pushing back to GitHub from a fresh clone + +### Tier 3: High-Impact Autonomous/Financial Work + +Move last, and only with explicit go/no-go: + +- autonomous trading or financial-control repositories +- any repository where an agent can execute real-world actions, change deployments, or affect money/assets + +Acceptance criteria: + +- all Tier 2 criteria met +- separate disaster recovery notes exist +- automated agents can authenticate without broad tokens +- CI/deploy/paper-trading workflows have been tested after remote changes +- GitHub remains available as an emergency mirror until the self-hosted platform has survived multiple backup/restore drills + +## Platform Implementation Plan + +### Phase 1: Decide and Prepare + +1. Confirm Forgejo vs Gitea vs GitLab. +2. Choose deployment host and storage location. +3. Choose database mode: Postgres preferred for operational consistency; SQLite acceptable only for a small pilot. +4. Decide network exposure: LAN-only, VPN-only, or externally reachable with strong auth. +5. Define initial admin users and groups. +6. Decide whether Forgejo Actions will be enabled immediately or deferred. + +### Phase 2: Deploy Self-Hosted Git + +1. Add a compose or Ansible deployment under HomelabSec infrastructure docs/IaC. +2. Store secrets through the SOPS/age workflow, not plaintext committed files. +3. Create reverse-proxy route and certificate. +4. Add DNS entry and verify normal resolver path. +5. Verify HTTPS route with browser/API health. +6. Verify SSH Git access. +7. Add dashboard/catalog link only after live checks pass. +8. Add monitoring checks for service health, certificate expiry, disk usage, and backup age. + +### Phase 3: Backup and Restore Baseline + +Back up at minimum: + +- Forgejo configuration +- database +- Git repositories +- LFS data +- attachments and avatars +- Actions artifacts if enabled +- SSH host keys and app secrets, via secret-management workflow + +Run a restore drill before migrating important repositories: + +1. Stop the service or restore into a disposable test instance. +2. Restore database and data volumes. +3. Start Forgejo. +4. Clone an imported test repo. +5. Push a test branch. +6. Verify web UI, issues, and releases if used. + +### Phase 4: Pilot Migration + +For each Tier 0 repo: + +1. Create/import repository in Forgejo. +2. Add GitHub as a mirror or backup remote. +3. Clone from Forgejo into a temporary directory. +4. Compare branch and tag counts with GitHub. +5. Push a test branch to Forgejo. +6. Delete the test branch. +7. Confirm backup captures the imported repo. +8. Record migration status in this document or a follow-up tracker. + +### Phase 5: Active Repo Migration + +For each Tier 1 or Tier 2 repo: + +1. Freeze direct GitHub writes for the repo during the cutover window. +2. Import repo, branches, tags, issues, releases, and wiki where required. +3. Configure branch protection. +4. Configure deploy keys, webhooks, and CI variables using least privilege. +5. Update local remotes: + + ```bash + git remote rename origin github + git remote add origin + git fetch origin + git push origin --all + git push origin --tags + ``` + +6. Run the repo's tests or deployment smoke checks. +7. Confirm GitHub mirror/fallback state. +8. Record the result and rollback notes. + +### Phase 6: GitHub Cleanup + +Only after multiple successful migrations and restore drills: + +- keep public repos mirrored to GitHub if public discoverability matters +- archive GitHub repos only after all automation points to Forgejo +- update repository descriptions to point to the canonical self-hosted remote if desired +- remove stale broad GitHub tokens and replace with scoped mirrors/deploy keys +- keep at least one off-host backup independent of Forgejo + +## Security Controls + +- Require 2FA for human users where supported. +- Use SSH keys per human/agent; avoid shared keys. +- Use deploy keys per repository for automation where possible. +- Keep personal access tokens narrow, named, and rotated. +- Disable public registration unless explicitly needed. +- Keep private repos private by default. +- Do not store plaintext GitHub or Forgejo tokens in committed files. +- Put runtime secrets in SOPS/age bundles and recovery copies in Bitwarden EU. +- Monitor failed login attempts, disk usage, queue/worker failures, and backup freshness. + +## CI and Automation + +Initial recommendation: + +- defer self-hosted CI runners until Git hosting, backups, and mirrors are stable +- keep existing GitHub Actions for repos that still need CI during transition +- later evaluate Forgejo Actions or a separate runner stack + +Before moving CI for any repo: + +- inventory current GitHub Actions secrets by name only +- move required runtime secrets to Bitwarden/SOPS or scoped Forgejo secrets +- test runners with a non-sensitive pilot repo +- avoid giving runners broad host Docker/socket access unless isolated + +## Rollback Plan + +For every migrated repo, keep a working GitHub fallback until the migration is proven. + +Rollback steps: + +1. Set GitHub remote as canonical again: + + ```bash + git remote set-url origin https://github.com//.git + ``` + +2. Push current branches/tags back to GitHub if needed: + + ```bash + git push origin --all + git push origin --tags + ``` + +3. Disable Forgejo webhooks/deploy keys for that repo. +4. Restore old CI/deploy settings. +5. Record the rollback reason. + +## Open Decisions + +- Confirm platform: Forgejo, Gitea, or GitLab. +- Confirm canonical URL and SSH access pattern. +- Choose host and storage backend. +- Decide whether public repos stay public on GitHub as mirrors. +- Decide whether private repos get GitHub private mirrors or self-hosted-only remotes. +- Decide whether to import GitHub issues/releases/wiki or keep history on GitHub. +- Decide when, if ever, to run self-hosted CI runners. + +## Next Concrete Slice + +1. Approve Forgejo as the pilot platform or choose an alternative. +2. Add a Forgejo deployment design/runbook to HomelabSec. +3. Deploy LAN-only Forgejo behind the homelab HTTPS route. +4. Prove backup/restore with one Tier 0 repo. +5. Migrate two Tier 0 repos and document results. +6. Review before moving any private or operational repos. diff --git a/docs/runbooks/macpro-proxmox-power-recovery.md b/docs/runbooks/macpro-proxmox-power-recovery.md new file mode 100644 index 0000000..e10273a --- /dev/null +++ b/docs/runbooks/macpro-proxmox-power-recovery.md @@ -0,0 +1,197 @@ +# Mac Pro / Proxmox power recovery runbook + +Purpose: verify and preserve the Mac Pro Proxmox host's recovery behaviour after a mains power outage. + +This runbook separates two different layers that are easy to confuse: + +1. **Mac Pro host AC restore**: whether the physical Mac Pro powers itself back on when mains power returns. +2. **Proxmox guest autostart**: whether VMs and LXCs start after the Proxmox host has booted. + +Both layers are required for unattended recovery after a real power outage. + +## Known host + +- Proxmox / Mac Pro host: `10.0.0.84` +- Proxmox node name: `pve` +- Important route: `https://macpro.home.robertbalm.com:8006/` + +## Current known-good state + +After the June 2026 power outage, the Mac Pro powered back on by itself and Proxmox started its configured guests. + +The host-level AC restore check returned: + +```text +setpci -s 00:1f.0 0xa4.b +08 +``` + +For this Mac Pro / C600-X79 chipset setup, bit `0` clear means auto power-on after AC return is enabled. Therefore value `08` is treated as the current known-good state. + +No persistent systemd startup item was found at that time. Because the setting was already on and the operator instruction was to leave it alone if on, no change was made. + +## Access requirements + +The Proxmox API can verify VM/LXC autostart settings and recent startup tasks, but it cannot verify the Mac Pro hardware-level AC restore register. Host shell access is required for the `setpci` checks below. + +Use the dedicated `fayebot` automation account with passwordless sudo. This account and the removal of the former temporary direct-root Faye key are managed by `infra/proxmox/proxmox-host-access.yml`. + +Examples below are host-local commands. When running remotely as `fayebot`, prefix privileged commands with `sudo -n`. + +## Verify host-level AC restore + +Run on the Proxmox host: + +```bash +setpci -s 00:1f.0 0xa4.b +``` + +Interpretation used for this host: + +```bash +v=$(setpci -s 00:1f.0 0xa4.b) +printf 'register_00_1f_0_0xa4_b=%s\n' "$v" +if (( 0x$v & 1 )); then + echo 'auto_power_after_ac_return=OFF_OR_DISABLED' +else + echo 'auto_power_after_ac_return=ON_OR_ENABLED' +fi +``` + +Known-good output: + +```text +register_00_1f_0_0xa4_b=08 +auto_power_after_ac_return=ON_OR_ENABLED +``` + +## Check for an existing persistent startup item + +Run on the Proxmox host: + +```bash +grep -Rsl 'setpci.*00:1f.0.*0xa4' \ + /etc/systemd/system \ + /usr/local/sbin \ + /usr/local/bin \ + 2>/dev/null +``` + +If this prints no paths, there is no obvious startup item re-applying the setting. + +## Set host-level AC restore if it is off + +Only do this if the check says `OFF_OR_DISABLED`, or if the operator explicitly asks for the setting to be re-applied. + +Run on the Proxmox host: + +```bash +setpci -v -s 00:1f.0 0xa4.b=0:1 +setpci -s 00:1f.0 0xa4.b +``` + +Re-run the interpretation command above and confirm it reports: + +```text +auto_power_after_ac_return=ON_OR_ENABLED +``` + +## Optional: create a persistent boot-time reapply service + +Only create this service if the setting is off, does not survive host boot, or the operator explicitly requests persistence. Do not create it merely because no service exists when the live hardware setting is already on and the instruction is to leave it alone. + +Create the helper: + +```bash +cat >/usr/local/sbin/macpro-ac-restore-enable <<'EOF' +#!/bin/sh +set -eu +setpci -v -s 00:1f.0 0xa4.b=0:1 +v=$(setpci -s 00:1f.0 0xa4.b) +if [ $((0x$v & 1)) -ne 0 ]; then + echo "Mac Pro AC restore still appears disabled: 0xa4.b=$v" >&2 + exit 1 +fi +echo "Mac Pro AC restore enabled: 0xa4.b=$v" +EOF +chmod 0755 /usr/local/sbin/macpro-ac-restore-enable +``` + +Create the systemd unit: + +```bash +cat >/etc/systemd/system/macpro-ac-restore.service <<'EOF' +[Unit] +Description=Enable Mac Pro auto power-on after AC returns +After=local-fs.target + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/macpro-ac-restore-enable +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target +EOF +``` + +Enable and run it: + +```bash +systemctl daemon-reload +systemctl enable --now macpro-ac-restore.service +systemctl status --no-pager macpro-ac-restore.service +``` + +Verify again: + +```bash +v=$(setpci -s 00:1f.0 0xa4.b) +printf 'register_00_1f_0_0xa4_b=%s\n' "$v" +if (( 0x$v & 1 )); then + echo 'auto_power_after_ac_return=OFF_OR_DISABLED' +else + echo 'auto_power_after_ac_return=ON_OR_ENABLED' +fi +``` + +## Verify Proxmox guest autostart + +Host AC restore only gets the Mac Pro powered on. Proxmox guest autostart is separate. + +From the Proxmox host: + +```bash +qm list +pct list +for id in $(qm list | awk 'NR>1 {print $1}'); do + printf 'QEMU %s ' "$id" + qm config "$id" | grep -E '^(name|onboot|startup):' || true +done +for id in $(pct list | awk 'NR>1 {print $1}'); do + printf 'LXC %s ' "$id" + pct config "$id" | grep -E '^(hostname|onboot|startup):' || true +done +``` + +Expected result for critical guests: `onboot: 1`. + +The Proxmox API can also verify this without host shell access by reading each VM/LXC config and checking `onboot=1`. + +## Post-outage verification checklist + +After a real outage: + +1. Confirm the Mac Pro host is reachable. +2. Confirm Proxmox node uptime is recent and node status is online. +3. Confirm recent `startall`, `qmstart`, and `vzstart` tasks completed successfully. +4. Confirm critical VMs/LXCs are running. +5. Confirm critical routes are healthy from the normal LAN path, not only from backend checks. +6. If the host did not auto-power-on, use this runbook to inspect and re-apply the AC restore setting. + +## Do not over-interpret + +- VM/LXC `onboot=1` proves only guest autostart after the host boots. +- Proxmox `startall` success proves only that Proxmox started guests after the host was already on. +- The `setpci` register check is the host-level evidence for Mac Pro auto power-on after AC returns. +- A successful real outage recovery is useful evidence, but still record the live register value when verifying the configuration. diff --git a/docs/runbooks/umbrel-lnd-recovery.md b/docs/runbooks/umbrel-lnd-recovery.md new file mode 100644 index 0000000..d583933 --- /dev/null +++ b/docs/runbooks/umbrel-lnd-recovery.md @@ -0,0 +1,116 @@ +# Umbrel / LND Recovery Runbook + +Purpose: safely recover the Umbrel Bitcoin + Lightning stack after a host restart, container restart, or noisy monitoring alert without mistaking normal LND startup for a real fault. + +## Scope + +This runbook covers the monitored Umbrel stack: + +- Umbrel OS/auth containers +- Bitcoin Core container +- LND / Lightning app containers +- Bitcoin and Lightning Tor containers +- The external Umbrel stack monitor, Prometheus, and Alertmanager on the monitoring host + +Do not put wallet seeds, private keys, macaroon contents, TLS keys, API tokens, or exact secret paths into tickets or chat updates. + +## First triage + +1. Check whether this is immediately after a reboot or container restart. +2. Run the monitor health probe from the monitoring host: + +```bash +cd /home/ubuntu/umbrel-stack-monitor +./scripts/post-restart-healthcheck.sh +``` + +3. Read the state fields in the output: + +- `lnd_state=rpc_starting` / `startup_wait=1`: LND RPC is not ready yet. Wait and re-probe before diagnosing deeper. +- `rpc_ready=1 wallet_unlocked=0` or `wallet_locked=1`: wallet readiness is the problem; treat as high priority. +- `wallet_unlocked=1 synced_to_chain=0`: LND is up but not caught up to the chain yet. +- `bitcoin_ok=False` or low Bitcoin peer count: diagnose Bitcoin Core before LND. + +## Normal restart expectations + +After Umbrel or host restart, LND can lag behind the containers being marked `running`. Short-lived `lncli` RPC errors are expected while LND starts and connects to Bitcoin Core. + +The monitor classifies this as `startup_wait`. The Prometheus rules intentionally wait longer before alerting: + +- Overall stack unhealthy: alerts only if not merely `startup_wait` and sustained. +- LND startup wait: warning only after a prolonged startup window. +- LND wallet locked: critical once RPC is reachable but wallet readiness fails. + +## Recovery checklist + +### 1. Confirm required containers are running + +Use the monitor `/status` or Portainer UI. Required containers include Umbrel OS/auth, Bitcoin Core/app/proxy/Tor, Lightning LND/app/proxy/Tor, and the shared Tor proxy. + +If a required container is missing or exited, restart it from Portainer/Umbrel and re-run the post-restart healthcheck. + +### 2. Confirm Bitcoin Core health + +Bitcoin must be synced and network active before LND can be considered healthy. + +Expected monitor fields: + +- `bitcoin_ok=True` +- `initialblockdownload=False` +- `networkactive=True` +- Peer count is not persistently below the alert threshold + +If Bitcoin is still syncing, wait. If it is not network active or has no peers for a sustained period, troubleshoot Bitcoin/Tor/networking first. + +### 3. Classify LND readiness + +The monitor distinguishes four common LND states: + +- `rpc_starting`: transient during startup; wait and re-probe. +- `wallet_locked`: LND RPC path is reachable but the wallet is not unlocked; unlock through the normal Umbrel UI/operator process. +- `chain_backend_unavailable`: LND cannot reach Bitcoin Core; fix Bitcoin/backend first. +- `auth_error`: monitor/CLI macaroon or TLS access problem; fix monitor access, not the Lightning node itself. + +Only escalate as a real LND fault once the same classifier persists beyond the Prometheus `for:` window or the wallet is locked. + +### 4. Check Tor last + +Tor bootstrap/heartbeat warnings can appear during restart. Re-probe after Bitcoin and LND settle. If Tor remains unhealthy, inspect the Bitcoin and Lightning Tor container logs through Portainer. + +### 5. Verify alerts cleared + +After recovery: + +```bash +curl -fsS http://127.0.0.1:9090/api/v1/alerts +curl -fsS http://127.0.0.1:9093/api/v2/alerts +``` + +Expected: no active/firing Umbrel stack alerts, or only alerts that are still within a justified startup window. + +## Backup / static channel backup checks + +LND channel backups are critical. A backup check should prove that the current static channel backup file exists, is non-empty, and has a recent modification timestamp. The monitor/backup checker should never print the backup contents. + +Minimum evidence to report: + +- backup path label only, not secret contents +- size in bytes +- modification timestamp +- age in seconds/minutes +- pass/fail status + +If the backup file is missing, empty, or stale after channel changes, treat as critical and update the backup job before making Lightning changes. + +## Reporting format + +For Telegram/status reports, include: + +- Overall: healthy / degraded / critical +- Bitcoin: synced, peers, block height +- LND: classifier state, wallet unlocked, chain synced, active/pending channels +- Tor: bootstrap/heartbeat status +- Alerts: firing/cleared +- Backup: SCB present, non-empty, recent + +Avoid raw internal secrets, tokens, keys, or wallet seed material. diff --git a/docs/threat-exposure-map-and-dashboard-spec.md b/docs/threat-exposure-map-and-dashboard-spec.md new file mode 100644 index 0000000..00d2cae --- /dev/null +++ b/docs/threat-exposure-map-and-dashboard-spec.md @@ -0,0 +1,772 @@ +# HomelabSec Threat Exposure Map And Dashboard Spec + +Generated: 2026-06-04T10:12:38+00:00 + +## Purpose + +This document turns the current live homelab inventory into a product direction for HomelabSec. It is both: + +1. a grounded snapshot of current LAN exposure, routing, DNS, dashboard, and certificate posture; and +2. a dashboard specification for turning HomelabSec from a scanner/classifier into an operator-facing security map. + +The goal is not to shame normal homelab complexity. The goal is to make the security boundary visible: +what exists, what is routed, what still uses raw IP/port links, what terminates TLS directly, +what is unknown, and what should be prioritized first. + +## Evidence Sources + +Live sources checked from the homelab control paths: + +- Homelab Certificate Manager API on the HCM host: + - target inventory + - certificate inventory + - live certificate status fields +- Nginx Proxy Manager database on the proxy/dashboard host: + - active proxy hosts + - upstream host/port/scheme + - SSL, force-SSL, HTTP/2, block-exploit, and websocket flags + - selected only non-secret columns; no certificate metadata or DNS-provider secrets were read +- Heimdall database copied from the running Heimdall container: + - active launcher items and URLs +- LAN DNS checks: + - default resolver answer + - authoritative Synology DNS answer at `10.0.0.14` +- LAN discovery from the proxy/dashboard host: + - ping discovery across `10.0.0.0/24` + - TCP connect scan for likely admin, proxy, media, monitoring, and app ports +- HomelabSec repository state: + - README, TODO, BACKLOG, TEST_PLAN + - current code/config/test surface + - security-significant code/config grep + - LOC summary with dependency/build directories excluded + +## Repository Baseline + +Current repository posture is strong enough to support this next product slice. + +Observed repo state: + +- Branch: `main` +- Recent work includes alert delivery validation, hardening regression coverage, Fingerbank integration, release `v0.2.0`, and dashboard confidence/guidance work. +- Current release in README: `0.2.0` +- Approximate code surface, excluding `.git`, `.venv`, caches, and build/dependency folders: + - 127 files + - 9,162 code lines + - 777 comment/documentation lines counted by `pygount` + - major surfaces: Python backend/scheduler/collectors/runner, FastAPI API, frontend assets, Docker Compose overlays, tests, SQL migrations, monitoring config + +Current product capabilities from README/backlog: + +- LAN discovery with Nmap +- Nmap XML ingest into Postgres +- asset fingerprinting +- local Ollama classification +- fingerprint history and change persistence +- daily reporting +- admin status dashboard +- optional auth/TLS edge overlay +- optional Prometheus/Grafana/Alertmanager monitoring overlay +- backup/restore scripts and integration coverage +- OIDC overlay validation +- alert delivery validation path + +Security-sensitive implementation areas already present: + +- Auth/session configuration with `DEFAULT_ADMIN_*`, `AUTH_SESSION_DAYS`, and `AUTH_SECURE_COOKIES`. +- Optional stronger auth/OIDC overlay. +- Scheduler host-network design retained intentionally for LAN scan semantics. +- Lynis remote runner stores SSH target configuration and supports sudo use. +- Lynis runner redacts sudo password material from captured output/error paths. +- Config validation exists for brain and scheduler startup. + +This means the next useful step is not just more scanning. It is correlating multiple live control planes and turning them into an exposure graph. + +## Live Inventory Snapshot + +### LAN Discovery + +Ping discovery observed 56 live hosts on `10.0.0.0/24`. + +Named examples from reverse DNS / host discovery: + +- `10.0.0.1`: gateway +- `10.0.0.14`: ElwynnForest Synology / DNS +- `10.0.0.17`: proxy/dashboard host +- `10.0.0.60`: Thunderbluff Synology / media and app services +- `10.0.0.82`: Umbrel +- `10.0.0.84`: Proxmox Mac Pro +- `10.0.0.86`: Brother printer + +High-signal live hosts with open scanned ports: + +- `10.0.0.1`: SSH, DNS, HTTP, HTTPS +- `10.0.0.14`: SSH, DNS, HTTP, HTTPS, SMB, DSM 5000/5001 +- `10.0.0.17`: SSH, HTTP, NPM admin 81, HTTPS, Webmin 9090, Mealie 9925, Audiobookshelf 13378 +- `10.0.0.25`: HTTP, HTTPS, 8000, 9000 +- `10.0.0.36`: SSH, HTTPS +- `10.0.0.41`: HTTPS +- `10.0.0.60`: SSH, DNS, HTTP, HTTPS, SMB, DSM 5000/5001, 8000, qBittorrent-like 8090, Jellyfin 8096, Portainer 9000, Prowlarr 9696 +- `10.0.0.84`: SSH, Proxmox 8006 +- `10.0.0.86`: HTTP, HTTPS +- `10.0.0.106`: SSH, Paperless 8000 +- `10.0.0.110`: SSH, HTTP, HTTPS, Ollama Fleet 8090, HCM 8097, Prometheus 9090, Alertmanager 9093 +- `10.0.0.153`: SSH, HTTP, HTTPS +- `10.0.0.179`: SSH, HTTPS +- `10.0.0.182`: SSH, HTTP, HTTPS +- `10.0.0.222`: 8000, 9000 + +Interpretation: + +- The LAN has a normal homelab density of direct admin surfaces. +- The highest-value exposure clusters are the proxy/dashboard host, the Synology media/app host, HCM/monitoring host, NAS/DNS host, Proxmox host, AI services, camera/device services, and torrent/ARR surfaces. +- HomelabSec should track raw service exposure separately from friendly routed URLs; both matter. + +### HCM Targets And TLS Status + +HCM tracks a mature set of named HTTPS services. Most live targets are valid and have 69-89 days remaining at time of check. + +Valid managed or tracked named services include: + +- `hcm.home.robertbalm.com` -> proxy to HCM backend +- `tradingteam.home.robertbalm.com` -> direct service-local HTTPS +- `faye.home.robertbalm.com` -> direct service-local HTTPS +- `ollamafleet.home.robertbalm.com` -> HCM/monitoring host +- `portainer.home.robertbalm.com` -> proxy host to Synology backend +- `ollama.home.robertbalm.com` -> proxy to Ollama API +- `webui.home.robertbalm.com` -> proxy to Open WebUI +- `heimdall.home.robertbalm.com` -> proxy/dashboard host +- `macpro.home.robertbalm.com` -> direct Proxmox UI certificate +- `jellyfin.home.robertbalm.com` -> proxy to media host +- `proxy.home.robertbalm.com` -> NPM admin route +- `paperless.home.robertbalm.com` -> proxy to Paperless +- `recipes.home.robertbalm.com` -> proxy to Mealie +- `books.home.robertbalm.com` -> proxy to Audiobookshelf +- `thunderbluff.home.robertbalm.com` -> Synology DSM direct cert +- `elwynnforest.home.robertbalm.com` -> Synology DSM direct cert +- `radarr.home.robertbalm.com`, `sonarr.home.robertbalm.com`, `prowlarr.home.robertbalm.com`, `qbittorrent.home.robertbalm.com` -> shared ARR proxy certificate +- `familieboten.home.robertbalm.com` -> externally managed Caddy cert, tracked by HCM live probe +- `paperclip.home.robertbalm.com` -> valid certificate but app route currently returns 502 because the VM root filesystem is full +- `alertmanager.home.robertbalm.com` -> proxy route with valid HCM-issued certificate +- `reolink.home.robertbalm.com` -> proxy route with valid HCM-issued certificate + +Candidate or unresolved HCM entries: + +- `unifi.home.robertbalm.com`: candidate, unresolved in LAN DNS +- `printer.home.robertbalm.com`: candidate, unresolved in LAN DNS +- `reolink-25.home.robertbalm.com`: candidate, unresolved in LAN DNS +- `reolink-172.home.robertbalm.com`: candidate, unresolved in LAN DNS + +Unknown/unidentified raw services tracked by HCM for investigation: + +- `10.0.0.20:3000` +- `10.0.0.41:443` +- `10.0.0.98:80` +- `10.0.0.122:80` +- `10.0.0.178:80` +- `10.0.0.211:80` +- `10.0.0.221:80` +- `10.0.0.222:8000` +- `10.0.0.244:3000` + +Interpretation: + +- TLS coverage for named services is good. +- The remaining meaningful security work is classification, exposure explanation, raw-IP cleanup, auth posture, and exception management. +- HCM already contains valuable context that HomelabSec should ingest rather than rediscover. + +### Nginx Proxy Manager Routes + +NPM contains two generations of route posture: + +1. legacy `stormwind.local` routes without SSL forcing or custom cert binding; and +2. newer `home.robertbalm.com` routes with custom certs, force-SSL, and mostly `block_exploits` enabled. + +Examples of newer routes with better posture: + +- `heimdall.home.robertbalm.com` +- `portainer.home.robertbalm.com` +- `jellyfin.home.robertbalm.com` +- `hcm.home.robertbalm.com` +- `paperless.home.robertbalm.com` +- `radarr.home.robertbalm.com` +- `sonarr.home.robertbalm.com` +- `prowlarr.home.robertbalm.com` +- `qbittorrent.home.robertbalm.com` +- `ollama.home.robertbalm.com` +- `webui.home.robertbalm.com` +- `reolink.home.robertbalm.com` +- `proxy.home.robertbalm.com` +- `recipes.home.robertbalm.com` +- `books.home.robertbalm.com` +- `grist.home.robertbalm.com` +- `alertmanager.home.robertbalm.com` + +Examples of older/local routes with weaker posture: + +- `recipes.stormwind.local` +- `portainer.stormwind.local` +- `books.stormwind.local` +- `home.stormwind.local` +- `proxy.stormwind.local` +- `pdfs.stormwind.local` +- `rancher.stormwind.local` +- `heimdall.stormwind.local` +- `ollama.stormwind.local` +- `grist.stormwind.local` +- `paperless.stormwind.local` + +Interpretation: + +- HomelabSec should show route-generation drift: old local routes may still be useful internally, but they bypass the certificate policy and can confuse users about the supported access path. +- Route risk should be computed per domain, not per backend only. One backend can have both a hardened named route and a raw/legacy route. + +### Heimdall Dashboard Links + +Heimdall currently mixes preferred named HTTPS routes with raw IP and legacy local links. + +Good named HTTPS links: + +- Alertmanager +- Audiobookshelf +- Hermes Agent / Faye +- HCM +- Jellyfin +- Mealie +- Nginx Proxy Manager +- Open WebUI +- Paperclip +- Paperless +- Portainer +- Reolink +- Synology DSM entries +- Trading Team + +Raw IP or legacy links that should be tracked as dashboard hygiene issues: + +- AMP: `10.0.0.154:8080` +- AlbyHub: `thunderbluff.stormwind.local:59000` +- Bitcoin Node: `thunderbluff.stormwind.local:2100` +- Grafana: `10.0.0.60:3340` +- HomeAssistant: `10.0.0.60:8123` +- Lightning node: `thunderbluff.stormwind.local:2101` +- NetVisor: `10.0.0.124:60072` +- Ollama Fleet Monitor: raw `10.0.0.110:8090` even though HCM has a named route +- Prowlarr: raw `10.0.0.60:9696` even though HCM/NPM has a named route +- Proxmox: raw `10.0.0.84:8006` even though HCM has a named route +- Radarr: raw `10.0.0.60:7878` even though HCM/NPM has a named route +- Sonarr: raw `10.0.0.60:8989` even though HCM/NPM has a named route +- Stirling-PDF: `pdfs.stormwind.local` +- Umbrel: `thunderbluff.stormwind.local:3253` +- Wazuh: `10.0.0.35` +- Webmin entries: raw host/IP admin ports +- qBittorrent: raw `10.0.0.60:8090` even though HCM/NPM has a named route + +Interpretation: + +- Heimdall is a strong source of operator intent. If a link exists there, the service matters. +- HomelabSec should detect when Heimdall points to a raw IP/legacy hostname while a preferred HCM/NPM route exists. +- This is a low-risk, high-clarity dashboard feature: “launcher link does not match preferred secure route.” + +### DNS Alignment + +For checked HCM domains, default resolver and Synology resolver matched. + +Expected proxy-fronted names resolve to `10.0.0.17`, including: + +- HCM +- Portainer +- Ollama API +- Open WebUI +- Heimdall +- Jellyfin +- proxy/NPM +- Paperless +- recipes +- books +- ARR apps +- Alertmanager +- Reolink + +Expected direct/service-local names resolve to their service hosts, including: + +- Trading Team -> `10.0.0.153` +- Faye/Hermes -> `10.0.0.182` +- Ollama Fleet -> `10.0.0.110` +- Proxmox Mac Pro -> `10.0.0.84` +- Thunderbluff DSM -> `10.0.0.60` +- ElwynnForest DSM -> `10.0.0.14` +- Familieboten -> `10.0.0.179` +- Paperclip -> `10.0.0.36` + +Unresolved candidate names: + +- UniFi gateway +- printer +- per-device Reolink candidate names + +Interpretation: + +- DNS is currently aligned for active managed routes. +- Candidate HCM records should be treated as planned/incomplete, not failures. +- HomelabSec should differentiate “tracked candidate with no DNS” from “production route missing DNS.” + +## Threat / Exposure Map + +### Tier 0: Boundary And Control Plane + +Assets: + +- Gateway / router +- Synology DNS/NAS +- proxy/dashboard host running NPM and Heimdall +- HCM/monitoring host +- Proxmox host + +Risks: + +- DNS and proxy misalignment can silently bypass intended TLS/auth paths. +- NPM admin route exists as a named service; useful but high-value. +- Webmin and admin surfaces exist on raw IP/port links. +- NAS/DNS hosts expose multiple admin and storage protocols. + +Recommended dashboard treatment: + +- “Control plane” label. +- Flag every open admin port. +- Show whether access is raw-only, named HTTPS, or both. +- Require a documented owner/purpose for each control-plane service. + +### Tier 1: Identity, Auth, And Remote Execution + +Assets: + +- HomelabSec auth/session system +- optional secure edge / OIDC overlay +- Lynis remote runner +- SSH targets across many hosts + +Risks: + +- Default admin credentials must never survive beyond local UAT. +- `AUTH_SECURE_COOKIES=false` is acceptable only for trusted local/basic deployments, not exposed deployments. +- Remote runner needs tight trust boundaries: credentials, sudo, command construction, timeout, and redaction. + +Recommended dashboard treatment: + +- Deployment mode card: trusted LAN, basic-auth edge, OIDC edge. +- Cookie security card: secure cookie expected when HTTPS edge is enabled. +- Remote runner card: count enabled targets, sudo targets, password-backed targets, and last audit result. +- Never display secret values. + +### Tier 2: App And Media Surfaces + +Assets: + +- Jellyfin +- ARR stack +- qBittorrent +- Paperless +- Mealie +- Audiobookshelf +- HomeAssistant +- Grafana +- Umbrel/Lightning/Bitcoin services + +Risks: + +- Mixed raw and named links create accidental bypasses. +- qBittorrent and ARR apps deserve stronger access review because they often touch external content sources. +- Media/app host `10.0.0.60` has a dense port surface. + +Recommended dashboard treatment: + +- Service group heatmap by host. +- Raw-link hygiene warnings. +- External-content/service class tag for torrent/indexer/media automation tools. +- “Preferred URL” recommendation based on HCM/NPM/Heimdall correlation. + +### Tier 3: AI And Automation Surfaces + +Assets: + +- Ollama API +- Open WebUI +- Ollama Fleet Monitor +- Faye/Hermes dashboard +- Trading Team dashboard + +Risks: + +- LLM/automation services can become high-impact if reachable beyond intended users. +- Raw API surfaces need special treatment even if LAN-only. +- Dashboard routes should advertise mode, access path, and auth posture. + +Recommended dashboard treatment: + +- AI/automation category. +- Flag unauthenticated API-style services separately from web apps. +- Show whether route is proxied through NPM, direct Caddy/service-local TLS, or raw. + +### Tier 4: Devices And Unknowns + +Assets: + +- Reolink devices +- Brother printer +- unknown HTTP/HTTPS/gSOAP services +- devices exposing 8000/9000-style web/API ports + +Risks: + +- Unknown web services are impossible to reason about. +- Device web UIs often have weak TLS/auth/update posture. +- Camera and printer routes should be intentional and documented. + +Recommended dashboard treatment: + +- Unknown service queue. +- Device class with confidence, evidence, and “needs owner confirmation.” +- Candidate DNS/cert state distinct from production failure. + +## Product Dashboard Spec + +### New Dashboard Page: Exposure Map + +Add a first-class dashboard view called `Exposure Map`. + +Core cards: + +1. `Live hosts` + - count of hosts seen in latest scan + - new/lost hosts since previous scan + - confidence: observed by ping, ARP, TCP, DNS, HCM, NPM, Heimdall + +2. `Open services` + - count of open TCP services + - high-risk admin/service ports by category + - services newly opened/closed since previous scan + +3. `Preferred route coverage` + - services with named HTTPS route + - services with raw-only access + - services with both raw and named access + - services with launcher link not matching preferred route + +4. `TLS health` + - valid certificates + - expiring certificates + - unresolved candidates + - externally managed certificates tracked by live probe + +5. `Unknown queue` + - unknown services discovered by scan/HCM + - confidence and evidence fields + - recommended next action: identify, ignore, route, block, document + +6. `Control-plane risk` + - gateway, DNS/NAS, proxy, Proxmox, monitoring, admin tools + - raw admin ports + - named admin routes + - missing or stale documentation + +### Data Model Additions + +Add normalized source tables or equivalent records for: + +#### `route_inventory` + +Fields: + +- `source`: `npm`, `hcm`, `heimdall`, `dns`, `manual` +- `service_name` +- `domain` +- `url` +- `scheme` +- `frontdoor_host` +- `backend_host` +- `backend_port` +- `certificate_status` +- `certificate_days_left` +- `proxy_flags`: force SSL, HTTP/2, websocket, block exploits +- `route_status`: active, candidate, unresolved, legacy, stale +- `last_seen_at` + +#### `dns_observations` + +Fields: + +- `domain` +- `resolver` +- `answers` +- `expected_answer` +- `status`: aligned, mismatch, missing, candidate_missing +- `last_checked_at` + +#### `launcher_links` + +Fields: + +- `source`: `heimdall` +- `title` +- `url` +- `resolved_host` +- `route_match_status`: preferred, raw_ip, legacy_host, stale, category_folder +- `preferred_url` +- `last_seen_at` + +#### `exposure_findings` + +Fields: + +- `finding_id` +- `severity`: info, low, medium, high, critical +- `asset_id` +- `service_key` +- `category` +- `title` +- `evidence` +- `recommended_action` +- `status`: open, accepted, resolved, ignored +- `first_seen_at` +- `last_seen_at` + +### Correlation Rules + +Implement these rules first because they are directly supported by the current live inventory. + +#### Rule: Heimdall raw link when preferred route exists + +If Heimdall URL is raw IP or legacy local hostname, and HCM/NPM has a valid named route for the same known app, create a medium finding. + +Examples from current inventory: + +- Ollama Fleet Monitor should prefer `https://ollamafleet.home.robertbalm.com/` +- Proxmox should prefer `https://macpro.home.robertbalm.com:8006/` +- Radarr should prefer `https://radarr.home.robertbalm.com/` +- Sonarr should prefer `https://sonarr.home.robertbalm.com/` +- Prowlarr should prefer `https://prowlarr.home.robertbalm.com/` +- qBittorrent should prefer `https://qbittorrent.home.robertbalm.com/` + +#### Rule: Legacy NPM route active + +If NPM contains an active `stormwind.local` route with no cert/force-SSL while a newer `home.robertbalm.com` route exists for the same service class, create a low/medium hygiene finding. + +Severity should be medium for admin surfaces, low for benign internal-only app redirects. + +#### Rule: Dense host exposure + +If a host has more than a threshold of open services, create an informational or medium finding depending on service classes. + +Current high-density examples: + +- `10.0.0.60`: NAS/media/app surface +- `10.0.0.17`: proxy/dashboard/admin surface +- `10.0.0.110`: HCM/monitoring surface +- `10.0.0.14`: NAS/DNS surface + +#### Rule: Unknown web service + +If scan/HCM discovers HTTP/HTTPS/gSOAP service with unknown identity, create a medium finding until identified. + +Examples include the HCM unknown queue and scan observations on device-style ports 8000/9000. + +#### Rule: Candidate DNS missing + +If HCM marks a target as candidate and DNS is missing, create an info finding, not a failure. + +If a target is marked done/managed/routed and DNS is missing or mismatched, create a high finding. + +#### Rule: Valid cert but bad backend + +If certificate is valid but app route fails, create a medium/high application availability finding. + +Current example: + +- Paperclip has a valid live certificate but returns 502 because the VM root filesystem is full. + +### UX Requirements + +The dashboard should make the operator answer these questions in one screen: + +- What changed since yesterday? +- Which hosts have the most exposed services? +- Which services are exposed by raw IP/legacy hostnames instead of preferred HTTPS routes? +- Which TLS routes are valid, expiring, missing, or externally managed? +- Which unknown services need identification? +- Which findings are accepted risk vs unresolved work? + +Recommended layout: + +1. Top summary cards. +2. Host/service heatmap. +3. Route coverage table. +4. TLS/DNS health table. +5. Heimdall hygiene table. +6. Unknown services queue. +7. Finding detail drawer with evidence and recommended action. + +### API Requirements + +Add read-only endpoints first: + +- `GET /exposure/summary` +- `GET /exposure/hosts` +- `GET /exposure/routes` +- `GET /exposure/dns` +- `GET /exposure/launcher-links` +- `GET /exposure/findings` + +Later mutation endpoints: + +- `POST /exposure/findings/{id}/accept` +- `POST /exposure/findings/{id}/resolve` +- `POST /exposure/findings/{id}/ignore` +- `POST /exposure/services/{id}/preferred-url` + +### Collector Requirements + +Add collectors in small slices: + +1. NPM collector: + - read active non-secret route fields from NPM API or database export + - never ingest cert `meta` secrets + - store proxy flags and backend mapping + +2. HCM collector: + - ingest target/cert public status from HCM API + - store plan status, live status, days left, route/backend URL + +3. Heimdall collector: + - ingest active launcher titles and URLs + - classify raw IP, legacy local hostname, category folder, named HTTPS + +4. DNS collector: + - resolve HCM/NPM domains through configured resolvers + - compare default resolver and authoritative resolver where configured + +5. Exposure correlator: + - join Nmap observations, DNS, routes, launcher links, and TLS status + - write durable findings with first/last seen timestamps + +### Security Requirements For Collectors + +- Collectors must default to read-only. +- NPM collector must explicitly avoid secret-bearing columns/fields such as certificate metadata and provider tokens. +- Heimdall collector should avoid scraping credentials or session tables; only launcher item titles/URLs are needed. +- DNS collector should not require write access. +- SSH-based collectors should support least-privilege commands and clear failure states. +- Findings must redact secrets and avoid printing private keys, passwords, cookies, tokens, or full secret-bearing environment values. + +## Recommended Next Work + +### P0: Build Exposure Map Data Model And Read-Only API + +Why: + +- The repo already discovers hosts and stores fingerprints. +- The missing layer is cross-source route/security context. + +Deliver: + +- migration for route/DNS/launcher/finding tables +- read-only API endpoints +- unit tests for serializers and correlation rules + +### P1: Add NPM + HCM Collectors + +Why: + +- These two sources give the strongest immediate insight into TLS and route posture. + +Deliver: + +- read-only NPM collector with secret-safe field allowlist +- HCM collector from public/internal API +- correlation rule for route posture and TLS status + +### P1: Add Heimdall Hygiene Collector + +Why: + +- Heimdall captures what the operator actually clicks. +- Raw/legacy links are a concrete, fixable risk. + +Deliver: + +- launcher link ingest +- preferred route matching +- findings for raw links when a named route exists + +### P2: Add DNS Alignment Collector + +Why: + +- Split-horizon DNS mistakes are a repeated homelab risk and can silently bypass the proxy. + +Deliver: + +- configured resolver checks +- candidate vs production route distinction +- mismatch findings + +### P2: Add Exposure Map UI + +Why: + +- The value of the product is operational clarity. + +Deliver: + +- summary cards +- host/service heatmap +- route/TLS/DNS/launcher tables +- findings queue + +### P3: Add Guided Remediation + +Why: + +- Once findings are reliable, the dashboard can recommend safe changes. + +Deliver: + +- “update Heimdall link” playbook text +- “retire legacy NPM route” checklist +- “identify unknown service” workflow +- “accept risk” status tracking + +## Definition Of Done For This Product Slice + +- Unit tests cover route and finding classifiers. +- Integration test ingests fixture Nmap XML plus fixture HCM/NPM/Heimdall/DNS payloads. +- Dashboard contract tests verify new exposure endpoints. +- No collector reads known secret-bearing fields. +- README documents source setup and security boundaries. +- A sample exposure report can be generated from fixtures without live homelab access. +- Live collector failures are shown as source health warnings, not whole-dashboard failures. + +## Immediate Manual Findings From This Run + +Open findings to track or convert into HomelabSec fixtures: + +1. Heimdall raw links exist for services that already have named HTTPS routes. +2. Legacy `stormwind.local` NPM routes remain active alongside newer `home.robertbalm.com` routes. +3. Multiple unknown HTTP/HTTPS/gSOAP services remain unidentified. +4. Paperclip has valid TLS but an unhealthy backend route due to a full root filesystem. +5. Dense service exposure exists on the NAS/media/app host, proxy/dashboard host, HCM/monitoring host, and DNS/NAS host. +6. Candidate DNS entries for UniFi/printer/per-device Reolink are intentionally unresolved and should be represented as planned candidates, not production failures. + +## Verification Notes + +Commands used during this assessment included: + +- `git status --short` +- `git branch --show-current` +- `git log --oneline -5` +- `pygount --format=summary --folders-to-skip='.git,.venv,__pycache__,.pytest_cache,dist,build,node_modules' .` +- HCM API reads from `/api/targets` and `/api/certs` +- NPM active route read with a non-secret SQL column allowlist +- Heimdall active item read from copied SQLite DB +- `dig +short` through default resolver and Synology DNS resolver +- `nmap -sn 10.0.0.0/24` +- `nmap -T3 --open -sT` for selected admin/app/media/monitoring ports diff --git a/frontend/admin.html b/frontend/admin.html index d35fc5b..720099a 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -4,7 +4,7 @@ HomelabSec Admin Console - +
@@ -20,8 +20,23 @@

Admin console

-
-
+
+
+
+
+

Admin sections

+

Switch between status, enrichment modules, raw data sources, and user access without leaving the console.

+
+
+
+ + + + +
+
+ +

Admin status

API health, scheduler freshness, and quick access links.

@@ -29,7 +44,7 @@

Admin status

-
+ -
+ -
+
- - + + diff --git a/frontend/admin.js b/frontend/admin.js index f8cef2a..02b9518 100644 --- a/frontend/admin.js +++ b/frontend/admin.js @@ -4,6 +4,9 @@ const userList = document.getElementById("user-list"); const createUserForm = document.getElementById("create-user-form"); const userMessage = document.getElementById("user-message"); const adminStatus = document.getElementById("admin-status"); +const adminTabButtons = Array.from(document.querySelectorAll("[data-admin-tab-target]")); +const adminTabPanels = Array.from(document.querySelectorAll("[data-admin-tab-panel]")); +const adminTabNames = adminTabButtons.map((button) => button.dataset.adminTabTarget); function escapeHtml(value) { return window.HomelabSecAuth.escapeHtml(value); @@ -67,13 +70,13 @@ function formatDate(value) { } function buildQuickLinks() { - const { protocol, hostname, origin } = window.location; + const { hostname, origin } = window.location; const links = [ { label: "Dashboard", href: origin.replace("/admin.html", "/") }, - { label: "API health", href: `${protocol}//${hostname}:8088/health` }, - { label: "Prometheus", href: "http://127.0.0.1:9090" }, - { label: "Grafana", href: "http://127.0.0.1:3001" }, - { label: "Alertmanager", href: "http://127.0.0.1:9093" }, + { label: "API health", href: `${origin}/api/health` }, + { label: "Prometheus", href: "https://prometheus.home.robertbalm.com/" }, + { label: "Grafana", href: "https://grafana.home.robertbalm.com/" }, + { label: "Alertmanager", href: "https://alertmanager.home.robertbalm.com/" }, ]; if (hostname === "localhost" || hostname === "127.0.0.1") { links.push({ label: "Secure edge", href: "https://localhost:18443" }); @@ -152,6 +155,53 @@ function renderAdminStatus(status) { `; } +function activateAdminTab(tabName, updateHash = true) { + const selected = adminTabNames.includes(tabName) ? tabName : "status"; + for (const button of adminTabButtons) { + const isSelected = button.dataset.adminTabTarget === selected; + button.classList.toggle("is-active", isSelected); + button.setAttribute("aria-selected", isSelected ? "true" : "false"); + button.tabIndex = isSelected ? 0 : -1; + } + for (const panel of adminTabPanels) { + const isSelected = panel.dataset.adminTabPanel === selected; + panel.classList.toggle("is-active", isSelected); + panel.hidden = !isSelected; + } + if (updateHash) { + history.replaceState(null, "", `#${selected}`); + } +} + +function focusRelativeAdminTab(currentIndex, offset) { + const nextIndex = (currentIndex + offset + adminTabButtons.length) % adminTabButtons.length; + const nextButton = adminTabButtons[nextIndex]; + nextButton.focus(); + activateAdminTab(nextButton.dataset.adminTabTarget); +} + +function initializeAdminTabs() { + for (const [index, button] of adminTabButtons.entries()) { + button.addEventListener("click", () => activateAdminTab(button.dataset.adminTabTarget)); + button.addEventListener("keydown", (event) => { + if (event.key === "ArrowRight") { + event.preventDefault(); + focusRelativeAdminTab(index, 1); + } else if (event.key === "ArrowLeft") { + event.preventDefault(); + focusRelativeAdminTab(index, -1); + } else if (event.key === "Home") { + event.preventDefault(); + focusRelativeAdminTab(0, 0); + } else if (event.key === "End") { + event.preventDefault(); + focusRelativeAdminTab(adminTabButtons.length - 1, 0); + } + }); + } + activateAdminTab((location.hash || "#status").slice(1), false); +} + async function loadAdminConsole() { const user = await window.HomelabSecAuth.requireUser({ admin: true }); window.HomelabSecAuth.mountHeaderNav(document.getElementById("page-nav"), user, "admin"); @@ -222,6 +272,8 @@ createUserForm.addEventListener("submit", async (event) => { } }); +initializeAdminTabs(); + loadAdminConsole().catch((error) => { adminStatus.innerHTML = `
Failed to load admin status: ${escapeHtml(error.message)}
`; moduleList.innerHTML = `
Failed to load modules: ${escapeHtml(error.message)}
`; diff --git a/frontend/app.js b/frontend/app.js index f9d05b1..711a153 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -5,6 +5,12 @@ const endpoints = { assets: "/api/assets", observations: "/api/observations", fingerprints: "/api/fingerprints", + findings: "/api/findings", + exposureSummary: "/api/exposure/summary", + exposureRoutes: "/api/exposure/routes", + exposureDns: "/api/exposure/dns", + exposureLauncherLinks: "/api/exposure/launcher-links", + exposureFindings: "/api/exposure/findings", adminStatus: "/api/admin/status", }; @@ -22,6 +28,26 @@ const elements = { detailTitle: document.getElementById("detail-title"), detailDescription: document.getElementById("detail-description"), detailList: document.getElementById("detail-list"), + findingsBoard: document.getElementById("findings-board"), + exposureSummary: document.getElementById("exposure-summary"), + exposureReadiness: document.getElementById("exposure-readiness"), + exposureReadinessDetail: document.getElementById("exposure-readiness-detail"), + exposureLastGenerated: document.getElementById("exposure-last-generated"), + exposureCoverageGrid: document.getElementById("exposure-coverage-grid"), + exposureSeveritySummary: document.getElementById("exposure-severity-summary"), + exposureRoutes: document.getElementById("exposure-routes"), + exposureDns: document.getElementById("exposure-dns"), + exposureLauncherLinks: document.getElementById("exposure-launcher-links"), + exposureFindings: document.getElementById("exposure-findings"), + findingCount: document.getElementById("finding-count"), + remediationCount: document.getElementById("remediation-count"), + instructionModal: document.getElementById("instruction-modal"), + instructionForm: document.getElementById("instruction-form"), + instructionFindingTitle: document.getElementById("instruction-finding-title"), + instructionIntent: document.getElementById("instruction-intent"), + instructionPriority: document.getElementById("instruction-priority"), + instructionText: document.getElementById("instruction-text"), + instructionError: document.getElementById("instruction-error"), assetCount: document.getElementById("asset-count"), assetsTable: document.getElementById("assets-table"), filterAssetsAll: document.getElementById("filter-assets-all"), @@ -39,6 +65,15 @@ const dashboardState = { observations: [], fingerprints: [], changes: [], + findings: [], + exposure: { + summary: null, + routes: [], + dnsRecords: [], + launcherLinks: [], + findings: [], + }, + selectedFindingId: null, notableAssetIds: new Set(), notableReasonsByAssetId: new Map(), recentChangeByAssetId: new Map(), @@ -168,6 +203,310 @@ function severityClass(severity) { return String(severity).toLowerCase(); } +function findingById(findingId) { + return dashboardState.findings.find((finding) => finding.finding_id === findingId) || null; +} + +function evidenceSummary(evidence) { + if (!evidence) { + return "No structured evidence captured yet."; + } + if (typeof evidence === "string") { + return evidence; + } + return Object.entries(evidence) + .map(([key, value]) => `${key}: ${typeof value === "object" ? JSON.stringify(value) : value}`) + .join(" · "); +} + +function renderFindingsBoard() { + elements.findingCount.textContent = dashboardState.findings.length; + const queuedCount = dashboardState.findings.reduce((total, finding) => total + (finding.instruction_count || 0), 0); + elements.remediationCount.textContent = queuedCount; + + if (!dashboardState.findings.length) { + setEmptyState(elements.findingsBoard, "No active findings are available yet."); + return; + } + + elements.findingsBoard.innerHTML = dashboardState.findings + .map((finding) => { + const latest = finding.latest_instruction; + return ` +
+
+
+
${escapeHtml(finding.title)}
+
+ ${escapeHtml(finding.preferred_name || "Unnamed asset")} + ${escapeHtml(formatDate(finding.created_at))} +
+
+ ${escapeHtml(finding.severity || "unknown")} +
+

${escapeHtml(finding.description || "No description captured.")}

+
${escapeHtml(evidenceSummary(finding.evidence))}
+
+ Recommended action +

${escapeHtml(finding.recommended_action || "No remediation recommendation captured yet.")}

+
+
+ ${escapeHtml(formatConfidence(finding.confidence))} confidence + ${escapeHtml(finding.instruction_count || 0)} instruction(s) + ${latest ? `Latest: ${escapeHtml(latest.intent)} / ${escapeHtml(latest.priority)}` : "No instructions queued"} +
+ ${latest ? `
${escapeHtml(latest.instruction_text)}
` : ""} + +
+ `; + }) + .join(""); + + for (const button of elements.findingsBoard.querySelectorAll(".finding-instruction-button")) { + button.addEventListener("click", () => openInstructionModal(button.dataset.findingId)); + } +} + +function renderCompactCards(container, items, renderItem, emptyMessage) { + if (!items.length) { + setEmptyState(container, emptyMessage); + return; + } + container.innerHTML = items.map(renderItem).join(""); +} + +function exposureSummaryCount(summary, key) { + const value = summary?.[key]; + return typeof value === "number" ? value : 0; +} + +function exposureReadiness(summary) { + const routes = exposureSummaryCount(summary, "routes"); + const dnsRecords = exposureSummaryCount(summary, "dns_records"); + const launcherLinks = exposureSummaryCount(summary, "launcher_links"); + const findings = exposureSummaryCount(summary, "findings"); + + if (!summary || !summary.generated_at) { + return { + label: "collector coverage unavailable", + detail: "Exposure contracts loaded, but no generated timestamp is available yet. Run the collectors/correlation job and redeploy before UAT sign-off.", + className: "exposure-status-warning", + }; + } + + if (routes && dnsRecords && launcherLinks) { + return { + label: findings ? "open exposure findings" : "collector-backed exposure map ready", + detail: findings + ? "Collectors are populated and correlation found open items to review before treating routes as clean." + : "Routes, DNS, and launcher links are populated; no open exposure findings are reported by the current correlation pass.", + className: findings ? "exposure-status-warning" : "exposure-status-ok", + }; + } + + return { + label: "collector coverage incomplete", + detail: "At least one source has not produced data yet. UAT can verify the panel, but route hygiene is not complete until routes, DNS, and launcher links are all populated.", + className: "exposure-status-warning", + }; +} + +function renderExposureStatus(summary) { + const readiness = exposureReadiness(summary); + elements.exposureReadiness.textContent = readiness.label; + elements.exposureReadinessDetail.textContent = readiness.detail; + elements.exposureLastGenerated.textContent = formatDate(summary?.generated_at); + elements.exposureReadiness.className = readiness.className; +} + +function renderExposureCoverage(summary) { + const coverageItems = [ + ["Routes", "routes", "Preferred hostnames and NPM/HCM backend targets"], + ["DNS", "dns_records", "Resolved or planned records for exposed services"], + ["Launcher", "launcher_links", "Heimdall links checked for raw IP / raw HTTP hygiene"], + ["Findings", "findings", "Generated exposure issues from the latest correlation pass"], + ]; + + elements.exposureCoverageGrid.innerHTML = coverageItems + .map(([label, key, detail]) => { + const count = exposureSummaryCount(summary, key); + const state = count > 0 ? "available" : "missing"; + return ` +
+ ${escapeHtml(label)} + ${escapeHtml(count)} +

${escapeHtml(detail)}

+
+ `; + }) + .join(""); +} + +function renderExposureSeveritySummary(summary) { + const severities = summary?.open_findings_by_severity || {}; + const ordered = ["critical", "high", "medium", "low", "info"]; + const rendered = ordered + .filter((severity) => severities[severity]) + .map((severity) => `${escapeHtml(severity)}: ${escapeHtml(severities[severity])}`) + .join(""); + + elements.exposureSeveritySummary.innerHTML = rendered || 'No open exposure findings in the latest correlation pass.'; +} + +function renderExposureMap() { + const summary = dashboardState.exposure.summary || {}; + const severitySummary = Object.entries(summary.open_findings_by_severity || {}) + .map(([severity, count]) => `${severity}: ${count}`) + .join(" · ") || "no open findings"; + + elements.exposureSummary.innerHTML = ` +
+ ${escapeHtml(summary.routes ?? 0)} routes + ${escapeHtml(summary.dns_records ?? 0)} DNS records + ${escapeHtml(summary.launcher_links ?? 0)} launcher links + ${escapeHtml(summary.findings ?? 0)} findings +
+
${escapeHtml(severitySummary)}
+ `; + renderExposureStatus(summary); + renderExposureCoverage(summary); + renderExposureSeveritySummary(summary); + + renderCompactCards( + elements.exposureRoutes, + dashboardState.exposure.routes, + (route) => ` +
+
+
${escapeHtml(route.domain)}
+ ${escapeHtml(route.tls_status || route.certificate_status || "route")} +
+
+ ${escapeHtml(route.scheme || "-")} + ${escapeHtml(route.upstream_host || "-")}${route.upstream_port ? `:${escapeHtml(route.upstream_port)}` : ""} + ${route.force_ssl ? "force SSL" : "no force SSL"} +
+
+ `, + "No exposure routes collected yet." + ); + + renderCompactCards( + elements.exposureDns, + dashboardState.exposure.dnsRecords, + (record) => ` +
+
+
${escapeHtml(record.hostname)}
+ ${escapeHtml(record.status)} +
+
+ ${escapeHtml(record.record_type)} + ${escapeHtml(record.record_value)} + ${escapeHtml(record.resolver)} +
+
+ `, + "No DNS exposure records collected yet." + ); + + renderCompactCards( + elements.exposureLauncherLinks, + dashboardState.exposure.launcherLinks, + (link) => ` +
+
+
${escapeHtml(link.title)}
+ ${escapeHtml(link.link_kind)} +
+
+ ${escapeHtml(link.hygiene_status)} + ${escapeHtml(link.preferred_route_domain || link.normalized_host || "-")} +
+
${escapeHtml(link.url)}
+
+ `, + "No launcher-link exposure records collected yet." + ); + + renderCompactCards( + elements.exposureFindings, + dashboardState.exposure.findings, + (finding) => ` +
+
+
${escapeHtml(finding.title)}
+ ${escapeHtml(finding.severity)} +
+

${escapeHtml(finding.description)}

+
+ ${escapeHtml(finding.finding_type)} + ${escapeHtml(finding.status)} +
+
+ `, + "No exposure findings collected yet." + ); +} + +function openInstructionModal(findingId) { + const finding = findingById(findingId); + if (!finding) { + return; + } + dashboardState.selectedFindingId = findingId; + elements.instructionFindingTitle.textContent = finding.title; + elements.instructionIntent.value = "fix"; + elements.instructionPriority.value = finding.severity === "critical" || finding.severity === "high" ? "high" : "normal"; + elements.instructionText.value = finding.recommended_action || ""; + elements.instructionError.hidden = true; + elements.instructionError.textContent = ""; + elements.instructionModal.hidden = false; + elements.instructionText.focus(); +} + +function closeInstructionModal() { + dashboardState.selectedFindingId = null; + elements.instructionModal.hidden = true; +} + +async function submitFindingInstruction(event) { + event.preventDefault(); + const findingId = dashboardState.selectedFindingId; + if (!findingId) { + return; + } + + const instructionText = elements.instructionText.value.trim(); + if (!instructionText) { + elements.instructionError.textContent = "Instruction text is required."; + elements.instructionError.hidden = false; + return; + } + + try { + const response = await fetch(`${endpoints.findings}/${encodeURIComponent(findingId)}/instructions`, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + instruction_text: instructionText, + intent: elements.instructionIntent.value, + priority: elements.instructionPriority.value, + }), + }); + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + throw new Error(payload.detail || `${response.status} ${response.statusText}`); + } + closeInstructionModal(); + await loadDashboard(); + } catch (error) { + elements.instructionError.textContent = error.message; + elements.instructionError.hidden = false; + } +} + function filterAssets(assets) { if (dashboardState.assetFilter === "notable") { return assets.filter((asset) => dashboardState.notableAssetIds.has(asset.asset_id)); @@ -399,18 +738,45 @@ async function loadDashboard() { elements.refreshButton.textContent = "Refreshing"; try { - const [health, summary, daily, assets, observations, fingerprints] = await Promise.all([ + const [ + health, + summary, + daily, + assets, + observations, + fingerprints, + findings, + exposureSummary, + exposureRoutes, + exposureDns, + exposureLauncherLinks, + exposureFindings, + ] = await Promise.all([ fetchJson(endpoints.health), fetchJson(endpoints.summary), fetchJson(endpoints.daily), fetchJson(endpoints.assets), fetchJson(endpoints.observations), fetchJson(endpoints.fingerprints), + fetchJson(endpoints.findings), + fetchJson(endpoints.exposureSummary), + fetchJson(endpoints.exposureRoutes), + fetchJson(endpoints.exposureDns), + fetchJson(endpoints.exposureLauncherLinks), + fetchJson(endpoints.exposureFindings), ]); dashboardState.assets = assets.assets || []; dashboardState.observations = observations.observations || []; dashboardState.fingerprints = fingerprints.fingerprints || []; + dashboardState.findings = findings.findings || []; + dashboardState.exposure = { + summary: exposureSummary, + routes: exposureRoutes.routes || [], + dnsRecords: exposureDns.dns_records || [], + launcherLinks: exposureLauncherLinks.launcher_links || [], + findings: exposureFindings.findings || [], + }; dashboardState.changes = daily.recent_changes || []; dashboardState.notableAssetIds = new Set((daily.notable_assets || []).map((asset) => asset.asset_id)); dashboardState.notableReasonsByAssetId = new Map( @@ -428,6 +794,8 @@ async function loadDashboard() { updateAssetFilterButtons(); updateSortButtons(); + renderFindingsBoard(); + renderExposureMap(); renderAssetsTable(); renderSummaryDetail(dashboardState.activeSummary); } catch (error) { @@ -436,8 +804,14 @@ async function loadDashboard() { elements.detailTitle.textContent = "Detail view"; elements.detailDescription.textContent = "Click a summary card to list the underlying items."; setEmptyState(elements.detailList, `Failed to load detail data: ${error.message}`); - setEmptyState(elements.adminStatus, `Failed to load admin status: ${error.message}`); - setEmptyState(elements.recentChanges, `Failed to load dashboard: ${error.message}`); + setEmptyState(elements.findingsBoard, `Failed to load findings: ${error.message}`); + setEmptyState(elements.exposureRoutes, `Failed to load exposure data: ${error.message}`); + setEmptyState(elements.exposureDns, "Exposure data unavailable."); + setEmptyState(elements.exposureLauncherLinks, "Exposure data unavailable."); + setEmptyState(elements.exposureFindings, "Exposure data unavailable."); + renderExposureStatus(null); + renderExposureCoverage(null); + renderExposureSeveritySummary(null); renderAssetsTable(); } finally { elements.refreshButton.disabled = false; @@ -495,6 +869,12 @@ elements.filterConfidenceBlue.addEventListener("click", () => { renderAssetsTable(); }); +elements.instructionForm.addEventListener("submit", submitFindingInstruction); + +for (const closeButton of document.querySelectorAll("[data-close-instruction-modal]")) { + closeButton.addEventListener("click", closeInstructionModal); +} + for (const button of elements.sortButtons) { button.addEventListener("click", () => { const { sortKey } = button.dataset; @@ -525,6 +905,12 @@ initDashboard().catch((error) => { elements.healthStatus.textContent = "error"; elements.reportGenerated.textContent = "-"; setEmptyState(elements.detailList, `Failed to load dashboard: ${error.message}`); - setEmptyState(elements.adminStatus, `Failed to load admin status: ${error.message}`); - setEmptyState(elements.recentChanges, `Failed to load dashboard: ${error.message}`); + setEmptyState(elements.findingsBoard, `Failed to load findings: ${error.message}`); + setEmptyState(elements.exposureRoutes, `Failed to load exposure data: ${error.message}`); + setEmptyState(elements.exposureDns, "Exposure data unavailable."); + setEmptyState(elements.exposureLauncherLinks, "Exposure data unavailable."); + setEmptyState(elements.exposureFindings, "Exposure data unavailable."); + renderExposureStatus(null); + renderExposureCoverage(null); + renderExposureSeveritySummary(null); }); diff --git a/frontend/index.html b/frontend/index.html index 0a70b45..a699d05 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -58,6 +58,61 @@

System summary

+
+
+
+

Exposure map

+

UAT-visible collector-backed view of preferred routes, DNS, launcher hygiene, and generated exposure findings.

+
+
+
+
+
+

Exposure readiness

+ Loading collector status +

Waiting for route, DNS, launcher, and finding contracts.

+
+
+ Generated + - +
+
+
+
+
+
+

Routes

+
+
+
+

DNS

+
+
+
+

Launcher links

+ +
+
+

Exposure findings

+
+
+
+
+ +
+
+
+

SecOps remediation board

+

Security findings paired with operator instructions, so Faye can move from evidence to DevOps remediation.

+
+
+
0 findings
+
0 queued instructions
+
+
+
+
+

Detail view

@@ -108,6 +163,47 @@

Asset inventory

No data available.
+ + diff --git a/frontend/styles.css b/frontend/styles.css index 3a9a3c6..8771d57 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -177,7 +177,109 @@ th { .summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 14px; + gap: 16px; +} + +.exposure-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.exposure-summary { + text-align: right; +} + +.exposure-status-panel { + display: flex; + justify-content: space-between; + gap: 18px; + align-items: center; + margin-bottom: 16px; + border: 1px solid rgba(141, 226, 198, 0.2); + border-radius: 20px; + padding: 16px; + background: + radial-gradient(circle at top left, rgba(141, 226, 198, 0.12), transparent 38%), + rgba(255, 255, 255, 0.035); +} + +.exposure-status-panel p { + margin: 6px 0 0; + color: var(--muted); +} + +.exposure-status-ok { + color: #bdf2df; +} + +.exposure-status-warning { + color: var(--high); +} + +.exposure-status-meta { + min-width: 180px; + border-left: 1px solid var(--line); + padding-left: 18px; + text-align: right; +} + +.exposure-status-meta span, +.exposure-coverage-card span { + display: block; + margin-bottom: 6px; + color: var(--muted); + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.exposure-coverage-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 14px; +} + +.exposure-coverage-card { + border: 1px solid var(--line); + border-radius: 16px; + padding: 13px 14px; + background: rgba(255, 255, 255, 0.035); +} + +.exposure-coverage-card.available { + border-color: rgba(141, 226, 198, 0.22); +} + +.exposure-coverage-card.missing { + border-color: rgba(255, 192, 120, 0.2); +} + +.exposure-coverage-card strong { + font-size: 1.5rem; +} + +.exposure-coverage-card p { + margin: 7px 0 0; + color: var(--muted); + font-size: 0.82rem; +} + +.severity-summary { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; +} + +.compact-list { + max-height: 360px; + overflow: auto; +} + +.compact-card { + padding: 14px; } .stat-card { @@ -310,6 +412,71 @@ th { linear-gradient(180deg, rgba(19, 34, 49, 0.98), rgba(10, 20, 31, 0.98)); } +.admin-tab-layout { + align-items: start; +} + +.admin-tabs-shell { + padding-bottom: 18px; +} + +.admin-tab-list { + display: flex; + flex-wrap: wrap; + gap: 10px; + border-bottom: 1px solid var(--line); + padding-bottom: 12px; +} + +.admin-tab-button { + border: 1px solid rgba(184, 204, 219, 0.14); + border-radius: 999px; + padding: 10px 15px; + background: rgba(255, 255, 255, 0.045); + color: var(--muted); + cursor: pointer; + font: inherit; + font-weight: 700; + transition: + border-color 0.18s ease, + background 0.18s ease, + color 0.18s ease, + transform 0.18s ease; +} + +.admin-tab-button:hover, +.admin-tab-button:focus-visible { + border-color: rgba(141, 226, 198, 0.42); + color: var(--text); + outline: none; + transform: translateY(-1px); +} + +.admin-tab-button.is-active { + border-color: rgba(141, 226, 198, 0.7); + background: linear-gradient(135deg, rgba(141, 226, 198, 0.2), rgba(73, 180, 217, 0.16)); + color: var(--text); +} + +.admin-tab-panel[hidden] { + display: none; +} + +.admin-tab-panel.is-active { + animation: admin-tab-fade 0.16s ease-out; +} + +@keyframes admin-tab-fade { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + .admin-card { background: radial-gradient(circle at top left, rgba(141, 226, 198, 0.1), transparent 34%), @@ -415,26 +582,35 @@ th { color: var(--text); } -.pill.critical { +.pill.critical, +.pill.severity-critical { background: rgba(255, 122, 122, 0.16); color: var(--critical); } -.pill.high { +.pill.high, +.pill.severity-high { background: rgba(255, 179, 107, 0.16); color: var(--high); } -.pill.medium { +.pill.medium, +.pill.severity-medium { background: rgba(240, 216, 121, 0.16); color: var(--medium); } -.pill.low { +.pill.low, +.pill.severity-low { background: rgba(131, 198, 255, 0.16); color: var(--low); } +.pill.severity-info { + background: rgba(169, 186, 200, 0.14); + color: #d4dee7; +} + .pill.notable { background: rgba(255, 179, 107, 0.16); color: var(--high); @@ -582,7 +758,8 @@ tbody tr:hover { } .auth-form input, -.auth-form select { +.auth-form select, +.auth-form textarea { width: 100%; border: 1px solid var(--line); border-radius: 14px; @@ -727,6 +904,72 @@ tbody tr:hover { text-align: center; } +.findings-board { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; +} + +.finding-card { + display: grid; + gap: 12px; + border: 1px solid var(--line); + border-radius: 18px; + padding: 16px; + background: rgba(255, 255, 255, 0.035); +} + +.finding-card p { + margin: 0; + color: var(--muted); +} + +.finding-evidence, +.remediation-block, +.latest-instruction { + border: 1px solid var(--line); + border-radius: 14px; + padding: 12px; + background: rgba(0, 0, 0, 0.14); +} + +.remediation-block strong { + display: block; + margin-bottom: 6px; + color: var(--text); +} + +.latest-instruction { + margin: 0; + color: #d9f8ee; + border-color: rgba(124, 224, 195, 0.22); +} + +.severity-critical { + background: rgba(255, 122, 122, 0.16); + color: #ffb0b0; +} + +.severity-high { + background: rgba(255, 179, 107, 0.16); + color: var(--high); +} + +.severity-medium { + background: rgba(255, 211, 128, 0.16); + color: #ffd380; +} + +.severity-low { + background: rgba(124, 224, 195, 0.12); + color: #bdf2df; +} + +.auth-form textarea { + min-height: 130px; + resize: vertical; +} + @media (max-width: 980px) { .hero, .section-heading { @@ -739,10 +982,16 @@ tbody tr:hover { } .summary-grid, - .hero-meta { + .hero-meta, + .exposure-coverage-grid, + .exposure-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .exposure-status-panel { + align-items: flex-start; + } + .admin-grid, .admin-grid-compact { grid-template-columns: 1fr; diff --git a/infra/proxmox/README.md b/infra/proxmox/README.md new file mode 100644 index 0000000..28d3ab7 --- /dev/null +++ b/infra/proxmox/README.md @@ -0,0 +1,42 @@ +# Proxmox host IaC + +This directory captures host-level Proxmox/Mac Pro configuration that sits outside the HomelabSec Docker Compose application stack. + +## Managed state + +`proxmox-host-access.yml` enforces the Faye/Hermes automation access model on the Proxmox host: + +- install `sudo` if missing +- create the dedicated `fayebot` automation account +- lock the `fayebot` password so password login is unavailable +- install the dedicated `fayebot` SSH public key +- grant `fayebot` passwordless sudo through `/etc/sudoers.d/90-fayebot` +- write an operator note at `/etc/fayebot-proxmox-access.README` +- remove the former temporary direct-root Faye SSH public key from root's `authorized_keys` +- validate sudoers syntax and prove `fayebot` can run non-interactive sudo + +The matching private SSH keys and Proxmox API token secrets are intentionally not stored in this repo. + +## Apply + +From this directory, with root SSH still available for initial bootstrap or with another privileged account: + +```bash +ansible-playbook -i inventory.example.yml proxmox-host-access.yml +``` + +After the first successful run, update your private inventory to connect as `fayebot` instead of `root` if desired: + +```yaml +ansible_user: fayebot +ansible_ssh_private_key_file: ~/.ssh/id_ed25519_proxmox_fayebot +``` + +## Verify manually + +```bash +ssh -i ~/.ssh/id_ed25519_proxmox_fayebot fayebot@10.0.0.84 'id && sudo -n true && sudo -n visudo -cf /etc/sudoers.d/90-fayebot' +ssh -i ~/.ssh/id_ed25519_proxmox_faye root@10.0.0.84 'true' +``` + +Expected result: the `fayebot` command succeeds and the old direct-root key is denied. diff --git a/infra/proxmox/ansible.cfg b/infra/proxmox/ansible.cfg new file mode 100644 index 0000000..9d45a68 --- /dev/null +++ b/infra/proxmox/ansible.cfg @@ -0,0 +1,8 @@ +[defaults] +inventory = inventory.example.yml +stdout_callback = default +retry_files_enabled = False +host_key_checking = True + +[ssh_connection] +pipelining = True diff --git a/infra/proxmox/group_vars/proxmox_hosts.yml b/infra/proxmox/group_vars/proxmox_hosts.yml new file mode 100644 index 0000000..6da451d --- /dev/null +++ b/infra/proxmox/group_vars/proxmox_hosts.yml @@ -0,0 +1,20 @@ +--- +# Public key used by Faye/Hermes for Proxmox host automation via the locked +# fayebot account. The matching private key is intentionally not stored here. +proxmox_fayebot_authorized_key: >- + ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINhEy+K81tkjMbHWPW+0tsHHQWpBJLd9g9BwfoVi3b2f fayebot-to-proxmox-macpro-10.0.0.84 + +# Temporary direct-root public key used during the transition. The playbook +# ensures this key is absent from root's authorized_keys after fayebot access is +# in place and sudo has been validated. +proxmox_remove_root_authorized_keys: + - >- + ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICl4Wf0y8Tn3FoOCI4DhqOpsyuyqXhvXlHcbHxbdxjGt faye-to-proxmox-macpro-10.0.0.84 + +proxmox_fayebot_user: fayebot +proxmox_fayebot_groups: + - sudo + - users + +proxmox_fayebot_sudoers_path: /etc/sudoers.d/90-fayebot +proxmox_fayebot_readme_path: /etc/fayebot-proxmox-access.README diff --git a/infra/proxmox/inventory.example.yml b/infra/proxmox/inventory.example.yml new file mode 100644 index 0000000..3af0dff --- /dev/null +++ b/infra/proxmox/inventory.example.yml @@ -0,0 +1,9 @@ +--- +all: + children: + proxmox_hosts: + hosts: + macpro: + ansible_host: 10.0.0.84 + ansible_user: root + ansible_python_interpreter: /usr/bin/python3 diff --git a/infra/proxmox/proxmox-host-access.yml b/infra/proxmox/proxmox-host-access.yml new file mode 100644 index 0000000..a171081 --- /dev/null +++ b/infra/proxmox/proxmox-host-access.yml @@ -0,0 +1,107 @@ +--- +- name: Harden Proxmox host automation access for Faye + hosts: proxmox_hosts + become: true + gather_facts: true + + vars: + sudoers_content: | + # Managed by homelabsec infra/proxmox/proxmox-host-access.yml. + # Dedicated key-only automation account for Faye/Hermes Proxmox host work. + Defaults:{{ proxmox_fayebot_user }} !authenticate + Defaults:{{ proxmox_fayebot_user }} env_reset + Defaults:{{ proxmox_fayebot_user }} secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + {{ proxmox_fayebot_user }} ALL=(root) NOPASSWD: ALL + + pre_tasks: + - name: Install sudo for Proxmox host automation + ansible.builtin.apt: + name: sudo + state: present + update_cache: true + when: ansible_facts['os_family'] == 'Debian' + + tasks: + - name: Create dedicated locked-password automation account + ansible.builtin.user: + name: "{{ proxmox_fayebot_user }}" + comment: Faye/Hermes Proxmox automation account + shell: /bin/bash + create_home: true + groups: "{{ proxmox_fayebot_groups }}" + append: true + password_lock: true + + - name: Ensure fayebot SSH directory is private + ansible.builtin.file: + path: "/home/{{ proxmox_fayebot_user }}/.ssh" + state: directory + owner: "{{ proxmox_fayebot_user }}" + group: "{{ proxmox_fayebot_user }}" + mode: "0700" + + - name: Install dedicated fayebot public SSH key + ansible.builtin.lineinfile: + path: "/home/{{ proxmox_fayebot_user }}/.ssh/authorized_keys" + line: "{{ proxmox_fayebot_authorized_key }}" + state: present + create: true + owner: "{{ proxmox_fayebot_user }}" + group: "{{ proxmox_fayebot_user }}" + mode: "0600" + + - name: Configure passwordless sudo for fayebot + ansible.builtin.copy: + dest: "{{ proxmox_fayebot_sudoers_path }}" + content: "{{ sudoers_content }}" + owner: root + group: root + mode: "0440" + validate: /usr/sbin/visudo -cf %s + + - name: Write local Proxmox host operator note + ansible.builtin.copy: + dest: "{{ proxmox_fayebot_readme_path }}" + owner: root + group: root + mode: "0644" + content: | + This Proxmox host has a dedicated fayebot automation account for Faye/Hermes operations. + + Security model: + - Account: fayebot + - Password login: locked + - Remote login: public-key authentication for the dedicated Faye Proxmox key + - Privilege: passwordless sudo via /etc/sudoers.d/90-fayebot + - Purpose: replace temporary direct root login for automated Proxmox/Mac Pro maintenance + + Operational note: + - Direct root login for the temporary Faye Proxmox key is removed after fayebot access is verified. + + - name: Validate fayebot sudoers file + ansible.builtin.command: "/usr/sbin/visudo -cf {{ proxmox_fayebot_sudoers_path }}" + changed_when: false + + - name: Prove fayebot can run non-interactive sudo + ansible.builtin.command: "su - {{ proxmox_fayebot_user }} -c 'sudo -n true'" + changed_when: false + + - name: Remove former temporary direct-root Faye SSH keys + ansible.builtin.lineinfile: + path: /root/.ssh/authorized_keys + line: "{{ item }}" + state: absent + create: false + loop: "{{ proxmox_remove_root_authorized_keys }}" + + - name: Report managed automation account state + ansible.builtin.command: "id {{ proxmox_fayebot_user }}" + changed_when: false + register: fayebot_id + + - name: Show managed state summary + ansible.builtin.debug: + msg: + - "{{ fayebot_id.stdout }}" + - "{{ proxmox_fayebot_sudoers_path }} validated" + - "Temporary direct-root Faye SSH key absent" diff --git a/requirements-dev.txt b/requirements-dev.txt index 3a784a8..52d8d00 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,3 +1,5 @@ pytest fastapi +httpx psycopg[binary] +requests diff --git a/scripts/secrets/render_sops_env.py b/scripts/secrets/render_sops_env.py new file mode 100755 index 0000000..43850fb --- /dev/null +++ b/scripts/secrets/render_sops_env.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Render a SOPS-encrypted JSON object to a restrictive .env file. + +The script intentionally does not print decrypted values. It expects the +encrypted file to decrypt to a JSON object whose keys are environment variable +names and whose values are scalars. +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import stat +import subprocess +import sys +from pathlib import Path +from typing import NoReturn, cast + +ENV_KEY_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$") + + +def die(message: str, code: int = 1) -> NoReturn: + print(f"error: {message}", file=sys.stderr) + raise SystemExit(code) + + +def decrypt_json(path: Path) -> dict[str, object]: + result = subprocess.run( + ["sops", "--decrypt", "--output-type", "json", str(path)], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if result.returncode != 0: + die(f"sops decrypt failed for {path}: {result.stderr.strip()}") + data: object + try: + data = json.loads(result.stdout) + except json.JSONDecodeError as exc: + die(f"decrypted payload is not JSON: {exc}") + if not isinstance(data, dict): + die("decrypted payload must be a JSON object") + return cast(dict[str, object], data) + + +def env_quote(value: object) -> str: + if value is None: + text = "" + elif isinstance(value, bool): + text = "true" if value else "false" + elif isinstance(value, (int, float)): + text = str(value) + elif isinstance(value, str): + text = value + else: + die(f"unsupported value type for env rendering: {type(value).__name__}") + return "'" + text.replace("'", "'\\''") + "'" + + +def render(data: dict[str, object]) -> str: + lines: list[str] = [] + for key in sorted(data): + if key == "sops": + continue + if not ENV_KEY_RE.match(key): + die(f"invalid env key {key!r}; use uppercase letters, numbers, and underscores") + lines.append(f"{key}={env_quote(data[key])}") + if not lines: + die("no renderable env keys found") + return "\n".join(lines) + "\n" + + +def write_restrictive(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + fd = os.open(path, flags, 0o600) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(content) + finally: + os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("encrypted_json", type=Path) + parser.add_argument("output_env", type=Path) + args = parser.parse_args() + + if not args.encrypted_json.name.endswith(".enc.json"): + die("input must be a .enc.json SOPS file") + data = decrypt_json(args.encrypted_json) + write_restrictive(args.output_env, render(data)) + print(f"rendered {args.output_env} with 0600 permissions") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/secrets/validate_secrets_management.py b/scripts/secrets/validate_secrets_management.py new file mode 100755 index 0000000..48183db --- /dev/null +++ b/scripts/secrets/validate_secrets_management.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Validate HomelabSec secret-management guardrails. + +This script is intentionally secret-safe: it validates paths, tool presence, and +SOPS decryptability, but never prints decrypted values. +""" +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path +from typing import NoReturn, cast + +REPO = Path(__file__).resolve().parents[2] +FORBIDDEN_TRACKED_PATH_PATTERNS = [ + re.compile(r"(^|/)secrets/runtime/"), + re.compile(r"(^|/)\.env$"), + re.compile(r"\.env$"), + re.compile(r"(^|/)data\.json$"), + re.compile(r"(^|/)keys\.txt$"), +] +PRIVATE_SECRET_MARKERS = [ + "AGE-SECRET" + "-KEY-", + "-----BEGIN OPENSSH PRIVATE" + " KEY-----", + "-----BEGIN RSA PRIVATE" + " KEY-----", + "-----BEGIN EC PRIVATE" + " KEY-----", + "-----BEGIN PRIVATE" + " KEY-----", +] +ASSIGNMENT_RE = re.compile(r"\b(BW_SESSION|GITHUB_TOKEN|CF_API_TOKEN|TELEGRAM_TOKEN|PASSWORD|SECRET|TOKEN)=([^\s'\"]+)") +SAFE_PLACEHOLDERS = {"change-me", "replace-me", "redacted", "example", "placeholder", "***", ""} + + +def fail(message: str) -> NoReturn: + print(f"FAIL: {message}", file=sys.stderr) + raise SystemExit(1) + + +def run(args: list[str], *, input_text: str | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run( + args, + cwd=REPO, + input=input_text, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + check=False, + ) + + +def require_tool(name: str) -> None: + if not shutil.which(name): + fail(f"required tool not found on PATH: {name}") + + +def tracked_files() -> list[str]: + result = run(["git", "ls-files"]) + if result.returncode != 0: + fail(result.stderr.strip() or "git ls-files failed") + return [line for line in result.stdout.splitlines() if line] + + +def validate_tracked_paths(paths: list[str]) -> None: + for rel in paths: + if rel.endswith(".env.example"): + continue + if rel.startswith("docs/"): + continue + if rel.startswith("secrets/sops/") and ".enc." in rel: + continue + for pattern in FORBIDDEN_TRACKED_PATH_PATTERNS: + if pattern.search(rel): + fail(f"forbidden secret-like path is tracked: {rel}") + + +def validate_no_private_markers(paths: list[str]) -> None: + for rel in paths: + path = REPO / rel + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + for marker in PRIVATE_SECRET_MARKERS: + if marker in text: + fail(f"private secret marker found in tracked file: {rel}") + for match in ASSIGNMENT_RE.finditer(text): + value = match.group(2).strip().strip('"').strip("'").lower() + if value not in SAFE_PLACEHOLDERS and not value.startswith("<"): + fail(f"real-looking secret assignment found in tracked file: {rel}: {match.group(1)}") + + +def validate_sops_config() -> None: + config = REPO / ".sops.yaml" + if not config.exists(): + fail(".sops.yaml is missing") + text = config.read_text(encoding="utf-8") + age_private_marker = "AGE-SECRET" + "-KEY-" + if age_private_marker in text: + fail(".sops.yaml contains an age private key") + if "age1" not in text: + fail(".sops.yaml does not contain an age recipient") + + +def validate_encrypted_files() -> None: + encrypted = sorted((REPO / "secrets" / "sops").glob("*.enc.json")) + if not encrypted: + fail("no encrypted SOPS JSON files found under secrets/sops") + for path in encrypted: + result = run(["sops", "--decrypt", "--output-type", "json", str(path.relative_to(REPO))]) + if result.returncode != 0: + fail(f"cannot decrypt {path.relative_to(REPO)}: {result.stderr.strip()}") + data: object + try: + data = json.loads(result.stdout) + except json.JSONDecodeError as exc: + fail(f"decrypted {path.relative_to(REPO)} is not JSON: {exc}") + if not isinstance(data, dict): + fail(f"decrypted {path.relative_to(REPO)} is not a JSON object") + payload = cast(dict[str, object], data) + keys = sorted(k for k in payload if k != "sops") + if not keys: + fail(f"decrypted {path.relative_to(REPO)} has no usable keys") + print(f"OK decryptable: {path.relative_to(REPO)} ({len(keys)} keys)") + + +def main() -> int: + require_tool("git") + require_tool("sops") + require_tool("age") + paths = tracked_files() + validate_tracked_paths(paths) + validate_no_private_markers(paths) + validate_sops_config() + validate_encrypted_files() + print("OK secret-management guardrails validated") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/show_access_urls.sh b/scripts/show_access_urls.sh index c8f5e3a..e5cd2a2 100755 --- a/scripts/show_access_urls.sh +++ b/scripts/show_access_urls.sh @@ -11,7 +11,9 @@ if [[ -f "$ENV_FILE" ]]; then set +a fi +EDGE_RUNTIME_ENV_PRESENT=false if [[ -f "$ROOT_DIR/.edge-runtime.env" ]]; then + EDGE_RUNTIME_ENV_PRESENT=true set -a # shellcheck disable=SC1091 source "$ROOT_DIR/.edge-runtime.env" @@ -23,11 +25,11 @@ EDGE_HTTPS_PORT="${EDGE_HTTPS_PORT:-8443}" PROMETHEUS_HOST_PORT="${PROMETHEUS_HOST_PORT:-9090}" ALERTMANAGER_HOST_PORT="${ALERTMANAGER_HOST_PORT:-9093}" GRAFANA_HOST_PORT="${GRAFANA_HOST_PORT:-3001}" -API_HOST_PORT="${API_HOST_PORT:-8088}" -FRONTEND_HOST_PORT="${FRONTEND_HOST_PORT:-8080}" +API_HOST_PORT="${API_HOST_PORT:-${BRAIN_HOST_PORT:-8088}}" +FRONTEND_HOST_PORT="${FRONTEND_HOST_PORT:-${HOMELABSEC_UAT_FRONTEND_PORT:-8080}}" EDGE_AUTH_MODE="${EDGE_AUTH_MODE:-}" -if [[ -n "$EDGE_AUTH_MODE" ]]; then +if [[ "$EDGE_RUNTIME_ENV_PRESENT" == "true" && -n "$EDGE_AUTH_MODE" ]]; then DASHBOARD_URL="not published when secure edge overlay is active" else DASHBOARD_URL="http://localhost:${FRONTEND_HOST_PORT}" diff --git a/secrets/README.md b/secrets/README.md new file mode 100644 index 0000000..9ba2d40 --- /dev/null +++ b/secrets/README.md @@ -0,0 +1,49 @@ +# HomelabSec secrets workflow + +This directory implements the current secret-management strategy: + +- **Bitwarden EU** is the human/recovery vault. +- **SOPS + age** is the automation secret distribution layer. +- **Rendered runtime files** are local-only and ignored by Git. + +## Layout + +- `inventory.example.json` — non-secret inventory metadata example. +- `sops/*.enc.json` — encrypted machine-consumable secret bundles. +- `runtime/` — local generated files; ignored and never committed. + +## First-time operator setup + +Faye now has local SOPS/age tooling installed and an age identity at the +standard SOPS path. The private identity is **not** in this repo. Store a +recovery copy in Bitwarden EU and the offline break-glass bundle before relying +on it for disaster recovery. + +Useful commands: + +```bash +# Confirm Bitwarden CLI points at Bitwarden EU. +bw config server + +# Login/unlock interactively when Robert is present. +bw login +bw unlock + +# Verify encrypted sample can decrypt on Faye. +python3 scripts/secrets/validate_secrets_management.py + +# Render a local env file from an encrypted bundle. +python3 scripts/secrets/render_sops_env.py \ + secrets/sops/homelabsec.sample.enc.json \ + secrets/runtime/homelabsec.sample.env +``` + +## Rules + +- Never commit `.env`, `*.env`, age private identities, Bitwarden session files, + vault exports, private keys, API tokens, cookies, seeds, macaroons, or backup + repository passwords. +- Commit only metadata or SOPS-encrypted files. +- Avoid logging decrypted SOPS output. Render to files with `0600` permissions. +- Store recovery copies and emergency notes in Bitwarden EU/offline break-glass, + not in GitHub or Telegram. diff --git a/secrets/inventory.example.json b/secrets/inventory.example.json new file mode 100644 index 0000000..894768a --- /dev/null +++ b/secrets/inventory.example.json @@ -0,0 +1,28 @@ +[ + { + "id": "homelabsec-runtime-example", + "owner": "Robert", + "system": "HomelabSec", + "type": "runtime environment secret bundle", + "storage": "Bitwarden EU record plus secrets/sops/homelabsec.sample.enc.json pattern", + "consumers": ["Faye deployment workflow", "HomelabSec service host"], + "rotation": "on credential change or after suspected exposure", + "backup": "Bitwarden EU recovery copy and offline break-glass bundle; encrypted SOPS file in Git", + "recovery_test": "decrypt non-production sample and render a local env file without printing values", + "blast_radius": "limited to the consuming service and explicit scopes in the vault record", + "revocation": "revoke or rotate at the source service, then update Bitwarden EU and re-encrypt SOPS bundle" + }, + { + "id": "faye-age-sops-identity", + "owner": "Robert/Faye", + "system": "SOPS automation", + "type": "age identity private key", + "storage": "host-local SOPS age key file, Bitwarden EU recovery item, offline break-glass copy", + "consumers": ["Faye"], + "rotation": "when host trust changes, after exposure, or as part of key rollover drills", + "backup": "Bitwarden EU and sealed/offline recovery bundle", + "recovery_test": "decrypt the encrypted sample from a documented recovery environment", + "blast_radius": "decrypts SOPS files encrypted to the corresponding public recipient", + "revocation": "remove recipient from .sops.yaml, re-encrypt files to replacement recipients, and delete old identity from hosts" + } +] diff --git a/secrets/sops/homelabsec.sample.enc.json b/secrets/sops/homelabsec.sample.enc.json new file mode 100644 index 0000000..6e2086e --- /dev/null +++ b/secrets/sops/homelabsec.sample.enc.json @@ -0,0 +1,17 @@ +{ + "HOMELABSEC_EXAMPLE_API_TOKEN": "ENC[AES256_GCM,data:mVDS1zDyZUETjA==,iv:472zUkG7I+EHUZiO84lUVJSwyz9ibcCzXzxH0/ueMkY=,tag:3+9qfnErTtEC/yjh7ULwnw==,type:str]", + "HOMELABSEC_EXAMPLE_DB_PASSWORD": "ENC[AES256_GCM,data:+ImTi2SsMYTR,iv:p/shCoWv8HL0Hm8u/ra6znbpNbApA/yeijCFYS70+J4=,tag:Jg0RTEeGoTR1MUV4AUwPUg==,type:str]", + "HOMELABSEC_EXAMPLE_WEBHOOK_SECRET": "ENC[AES256_GCM,data:3KxZfVG9IYQ=,iv:iGN+DB6fbdN1TAcBShAZYME4L1NlhhyzxffT6//HAzE=,tag:MwwOdteHHjaDpjgao0zCfg==,type:str]", + "sops": { + "age": [ + { + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBzZERkK0Q3VjRkbCs2UXVW\nYkJPSnpNWFBsemtuZXVVSFpFMDF3eENzOXh3Ci8vN05la3VidFhpVmgwY3pXbkph\ndVBEZXJjRHF6TGE1TU1hVEgweE5XU1kKLS0tIDRRbEkvcGt4bTF4UnFlY2E0dXBZ\nQk5SOVpSOFVGMDdZNjRmM3YzMTdmNjQKftTKTArlAy813CrTdeiuzI9YkpqTXUos\nyoHq+tReqyut4Mih19ert/9zdNHXr8JdBGmYURxdCJripa4rlwQ8sw==\n-----END AGE ENCRYPTED FILE-----\n", + "recipient": "age16zlfacpnp7yszl9jxp8ua3t3dtvd9zed625qkt7cpmqvd7uht55qxfnmuf" + } + ], + "lastmodified": "2026-06-07T07:43:27Z", + "mac": "ENC[AES256_GCM,data:+sYa9nzcs53+hKPZ9v672C0Y1zNmD6sTkZL5EVBY4p0YzwamRTZYDlQ8Je+r9gsNlDrVG74a4bI60SUHZpM4g/dvmEQqlme3AG+pOkhjhP7aWeK52iq9q121p9sNA4fNmWfRP7i+GREY4NksLLoHAO+Bj7g9D1b9YZxtnKyk9po=,iv:QzwAWAfpfp+SYiWUFpGgteI/lXTwgjIAWJCaiQbwBm4=,tag:pcwkH6WdzPknb2cR7xStuA==,type:str]", + "unencrypted_suffix": "_unencrypted", + "version": "3.13.1" + } +} diff --git a/secrets/sops/nextcloud-cv-pack.enc.json b/secrets/sops/nextcloud-cv-pack.enc.json new file mode 100644 index 0000000..6364391 --- /dev/null +++ b/secrets/sops/nextcloud-cv-pack.enc.json @@ -0,0 +1,16 @@ +{ + "NEXTCLOUD_USERNAME": "ENC[AES256_GCM,data:b6FFzdVmRfwRRDmWwRK3SBBcdY++,iv:KmoQpdWpdLexJSbHi/QK7sGHxFkhcZrihOJ07RfI6Sg=,tag:7l5mVhL7ovAFW7jc1b6CVg==,type:str]", + "NEXTCLOUD_APP_PASSWORD": "ENC[AES256_GCM,data:xR2FP1qX/Xb2PkDt,iv:e5ME37zzH/khI/f87muEMNyzFIh+kTRmquazIHreWnE=,tag:mSlT4d/SlBnY9WV+Bcc4Rg==,type:str]", + "sops": { + "age": [ + { + "enc": "-----BEGIN AGE ENCRYPTED FILE-----\nYWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB0SlE3Z0dvUmUrS3ZjV05I\nQ2RXWEMvdWZUbk9BM2VydDkrZ1hmMldnaDJjCkQ0SFRWamp0d1BTWW1OWWhrOEJ4\ndEcxSUgxOXZVd1JCL2I2R3VjZGZwU3MKLS0tIHdaRjgyNktQOFZzcU1uMm53dmIy\nT3hlY3JxTHppZ096ZGdaK3o0MmEvWTgK6wb/faTN9XX1883kKuVm2WRQHofC/Z0s\nM76qrP7wwf43GLiiKhNc3l+pDuaMQLKAemumxIlUgKAP/AsSoPjr+g==\n-----END AGE ENCRYPTED FILE-----\n", + "recipient": "age16zlfacpnp7yszl9jxp8ua3t3dtvd9zed625qkt7cpmqvd7uht55qxfnmuf" + } + ], + "lastmodified": "2026-06-14T15:48:55Z", + "mac": "ENC[AES256_GCM,data:SMhmiSeN30fDCcgM3d1wGC0lEwuaSlpsDja9XUeIRFu/rHBnPg1s5zMQeQQOQ5A2g7yQeyfbV9s3IHxgky32xfsr9NSFv52WHQvvmA+7X5YY0aDZg9YvTWaw2uVLLsQcrv3XAaCJkpKrxhCxKdWToCixlv5mN7jYfom57qyE5+c=,iv:pLB1ALSeB1MDyltPRqKkADCIjuS6orgc23u3Y2oWbYs=,tag:k3daVc8lCFOqNoOT5SU6qA==,type:str]", + "unencrypted_suffix": "_unencrypted", + "version": "3.13.1" + } +} diff --git a/tests/regression/test_dashboard_contracts.py b/tests/regression/test_dashboard_contracts.py index 235bc44..a4f9b54 100644 --- a/tests/regression/test_dashboard_contracts.py +++ b/tests/regression/test_dashboard_contracts.py @@ -292,4 +292,31 @@ def test_auth_and_admin_pages_exist(): assert 'id="user-list"' in admin_html assert 'id="admin-status"' in admin_html assert 'id="page-nav"' in admin_html + assert 'href="/styles.css?v=admin-links-20260604"' in admin_html + assert 'src="/auth.js?v=admin-links-20260604"' in admin_html + assert 'src="/admin.js?v=admin-links-20260604"' in admin_html + assert 'role="tablist"' in admin_html + assert 'id="admin-tab-status"' in admin_html + assert 'id="admin-tab-enrichment"' in admin_html + assert 'id="admin-tab-sources"' in admin_html + assert 'id="admin-tab-users"' in admin_html + assert 'id="admin-panel-status"' in admin_html + assert 'id="admin-panel-enrichment"' in admin_html + assert 'id="admin-panel-sources"' in admin_html + assert 'id="admin-panel-users"' in admin_html + assert 'aria-controls="admin-panel-status"' in admin_html + assert 'aria-labelledby="admin-tab-users"' in admin_html + assert 'data-admin-tab-target="users"' in admin_html + assert 'function activateAdminTab(tabName, updateHash = true)' in admin_script + assert 'function initializeAdminTabs()' in admin_script + assert 'event.key === "ArrowRight"' in admin_script + assert 'event.key === "ArrowLeft"' in admin_script assert 'apiJson("/api/admin/status")' in admin_script + assert 'href: `${origin}/api/health`' in admin_script + assert 'href: "https://prometheus.home.robertbalm.com/"' in admin_script + assert 'href: "https://grafana.home.robertbalm.com/"' in admin_script + assert 'href: "https://alertmanager.home.robertbalm.com/"' in admin_script + assert 'http://${monitoringHost}' not in admin_script + assert '127.0.0.1:9090' not in admin_script + assert '127.0.0.1:3001' not in admin_script + assert '127.0.0.1:9093' not in admin_script diff --git a/tests/regression/test_exposure_contracts.py b/tests/regression/test_exposure_contracts.py new file mode 100644 index 0000000..153bc99 --- /dev/null +++ b/tests/regression/test_exposure_contracts.py @@ -0,0 +1,319 @@ +import os +import sys +from pathlib import Path + +import psycopg +import pytest +from fastapi.testclient import TestClient + +REPO_ROOT = Path(__file__).resolve().parents[2] +BRAIN_DIR = REPO_ROOT / "brain" +if str(BRAIN_DIR) not in sys.path: + sys.path.insert(0, str(BRAIN_DIR)) +os.environ.setdefault("DATABASE_URL", "postgresql://test:***@localhost:5432/test") +os.environ.setdefault("OLLAMA_URL", "http://ollama.test") + +from brainlib.exposure import ( + correlate_exposure_findings, + replace_exposure_findings, + upsert_exposure_dns_records, + upsert_exposure_launcher_links, + upsert_exposure_routes, +) +from collectors.exposure_collectors import ( + normalize_dns_answer, + normalize_heimdall_link, + normalize_npm_proxy_host, +) + + +FRONTEND_INDEX_PATH = Path(__file__).resolve().parents[2] / "frontend" / "index.html" +FRONTEND_APP_PATH = Path(__file__).resolve().parents[2] / "frontend" / "app.js" +FRONTEND_STYLES_PATH = Path(__file__).resolve().parents[2] / "frontend" / "styles.css" + + +@pytest.fixture +def regression_client(integration_brain_module): + client = TestClient(integration_brain_module.app) + response = client.post("/auth/login", json={"username": "admin", "password": "change-me-now"}) + assert response.status_code == 200 + return client + + +def _seed_exposure_rows(integration_db_url): + with psycopg.connect(integration_db_url) as conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO exposure_routes ( + source, domain, scheme, upstream_host, upstream_port, + tls_status, force_ssl, block_exploits, websocket_support + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + ( + "fixture", + "service.home.example", + "https", + "10.0.0.10", + 8443, + "valid", + True, + True, + False, + ), + ) + cur.execute( + """ + INSERT INTO exposure_dns_records ( + hostname, resolver, record_type, record_value, status + ) + VALUES (%s, %s, %s, %s, %s) + """, + ("service.home.example", "fixture-resolver", "A", "10.0.0.10", "aligned"), + ) + cur.execute( + """ + INSERT INTO exposure_launcher_links ( + source, title, url, normalized_host, link_kind, + preferred_route_domain, hygiene_status + ) + VALUES (%s, %s, %s, %s, %s, %s, %s) + """, + ( + "fixture", + "Service", + "http://10.0.0.10:8080", + "10.0.0.10", + "raw_ip", + "service.home.example", + "raw_link_has_preferred_route", + ), + ) + cur.execute( + """ + INSERT INTO exposure_findings ( + finding_type, severity, status, title, description, evidence + ) + VALUES (%s, %s, %s, %s, %s, %s::jsonb) + """, + ( + "launcher_hygiene", + "high", + "open", + "Raw launcher link has preferred route", + "A dashboard link still points to a raw service while a preferred HTTPS route exists.", + '{"domain": "service.home.example"}', + ), + ) + conn.commit() + + +def test_exposure_endpoints_return_empty_contracts(regression_client): + summary = regression_client.get("/exposure/summary") + routes = regression_client.get("/exposure/routes") + dns = regression_client.get("/exposure/dns") + launcher_links = regression_client.get("/exposure/launcher-links") + findings = regression_client.get("/exposure/findings") + + assert summary.status_code == 200 + assert routes.status_code == 200 + assert dns.status_code == 200 + assert launcher_links.status_code == 200 + assert findings.status_code == 200 + + assert set(summary.json()) == { + "generated_at", + "routes", + "dns_records", + "launcher_links", + "findings", + "open_findings_by_severity", + } + assert routes.json() == {"routes": []} + assert dns.json() == {"dns_records": []} + assert launcher_links.json() == {"launcher_links": []} + assert findings.json() == {"findings": []} + + +def test_exposure_endpoints_return_seeded_records(regression_client, integration_db_url): + _seed_exposure_rows(integration_db_url) + + summary = regression_client.get("/exposure/summary").json() + routes = regression_client.get("/exposure/routes").json() + dns = regression_client.get("/exposure/dns").json() + launcher_links = regression_client.get("/exposure/launcher-links").json() + findings = regression_client.get("/exposure/findings").json() + + assert summary["routes"] == 1 + assert summary["dns_records"] == 1 + assert summary["launcher_links"] == 1 + assert summary["findings"] == 1 + assert summary["open_findings_by_severity"] == {"high": 1} + + route = routes["routes"][0] + assert { + "route_id", + "source", + "domain", + "scheme", + "upstream_host", + "upstream_port", + "tls_status", + "force_ssl", + "block_exploits", + "websocket_support", + "observed_at", + }.issubset(route) + assert route["domain"] == "service.home.example" + assert route["force_ssl"] is True + + record = dns["dns_records"][0] + assert record["hostname"] == "service.home.example" + assert record["record_value"] == "10.0.0.10" + assert record["status"] == "aligned" + + link = launcher_links["launcher_links"][0] + assert link["link_kind"] == "raw_ip" + assert link["hygiene_status"] == "raw_link_has_preferred_route" + assert link["preferred_route_domain"] == "service.home.example" + + finding = findings["findings"][0] + assert finding["finding_type"] == "launcher_hygiene" + assert finding["severity"] == "high" + assert finding["status"] == "open" + assert finding["evidence"] == {"domain": "service.home.example"} + + +def test_exposure_collectors_persist_secret_safe_records(regression_client, integration_db_url): + npm_routes = normalize_npm_proxy_host( + { + "id": 42, + "domain_names": ["service.home.example"], + "forward_scheme": "http", + "forward_host": "10.0.0.10", + "forward_port": 8080, + "certificate_id": 7, + "ssl_forced": True, + "http2_support": True, + "block_exploits": True, + "allow_websocket_upgrade": False, + "access_list": {"secret": "must-not-leak"}, + "advanced_config": "proxy_set_header Authorization bearer-secret;", + "meta": {"letsencrypt_agree": True, "dns_challenge_token": "secret"}, + } + ) + dns_records = normalize_dns_answer( + hostname="service.home.example", + resolver="synology-lan", + values=["10.0.0.17"], + ) + launcher_links = [ + normalize_heimdall_link( + { + "title": "Service", + "url": "http://10.0.0.10:8080", + "password": "must-not-leak", + } + ) + ] + + with psycopg.connect(integration_db_url) as conn: + upsert_exposure_routes(conn, npm_routes) + upsert_exposure_dns_records(conn, dns_records) + upsert_exposure_launcher_links(conn, launcher_links) + conn.commit() + + routes = regression_client.get("/exposure/routes").json()["routes"] + dns_records = regression_client.get("/exposure/dns").json()["dns_records"] + links = regression_client.get("/exposure/launcher-links").json()["launcher_links"] + route = next(item for item in routes if item["source"] == "npm" and item["domain"] == "service.home.example") + dns = next( + item + for item in dns_records + if item["resolver"] == "synology-lan" and item["hostname"] == "service.home.example" + ) + link = next(item for item in links if item["source"] == "heimdall" and item["title"] == "Service") + combined = str({"route": route, "dns": dns, "link": link}) + + assert route["domain"] == "service.home.example" + assert route["upstream_host"] == "10.0.0.10" + assert route["raw_json"] == { + "proxy_host_id": 42, + "certificate_id": 7, + "caching_enabled": None, + } + assert dns["record_value"] == "10.0.0.17" + assert link["link_kind"] == "raw_ip" + assert "must-not-leak" not in combined + assert "Authorization" not in combined + assert "dns_challenge_token" not in combined + + +def test_exposure_correlation_persists_generated_findings(regression_client, integration_db_url): + routes = normalize_npm_proxy_host( + { + "id": 99, + "domain_names": ["rawsvc.home.example"], + "forward_scheme": "http", + "forward_host": "10.0.0.25", + "forward_port": 9000, + "certificate_id": None, + "ssl_forced": False, + } + ) + dns_records = normalize_dns_answer( + hostname="rawsvc.home.example", + resolver="synology-lan", + values=[], + planned=True, + ) + launcher_links = [ + normalize_heimdall_link({"title": "Raw Service", "url": "http://10.0.0.25:9000"}) + ] + findings = correlate_exposure_findings(routes, dns_records, launcher_links) + + with psycopg.connect(integration_db_url) as conn: + upsert_exposure_routes(conn, routes) + upsert_exposure_dns_records(conn, dns_records) + upsert_exposure_launcher_links(conn, launcher_links) + replace_exposure_findings(conn, findings) + conn.commit() + + api_findings = regression_client.get("/exposure/findings").json()["findings"] + by_type = {finding["finding_type"]: finding for finding in api_findings} + + assert set(by_type) == {"launcher_hygiene", "route_tls", "dns_resolution"} + assert by_type["launcher_hygiene"]["severity"] == "high" + assert by_type["route_tls"]["status"] == "open" + assert by_type["dns_resolution"]["evidence"]["hostname"] == "rawsvc.home.example" + + +def test_exposure_dashboard_markup_and_script_contracts(): + html = FRONTEND_INDEX_PATH.read_text(encoding="utf-8") + script = FRONTEND_APP_PATH.read_text(encoding="utf-8") + styles = FRONTEND_STYLES_PATH.read_text(encoding="utf-8") + + assert 'id="exposure-summary"' in html + assert 'id="exposure-routes"' in html + assert 'id="exposure-dns"' in html + assert 'id="exposure-launcher-links"' in html + assert 'id="exposure-findings"' in html + assert 'id="exposure-status-panel"' in html + assert 'id="exposure-last-generated"' in html + assert 'id="exposure-coverage-grid"' in html + assert 'id="exposure-severity-summary"' in html + assert "collector-backed view" in html + assert "/exposure/summary" in script + assert "/exposure/routes" in script + assert "/exposure/dns" in script + assert "/exposure/launcher-links" in script + assert "/exposure/findings" in script + assert "function exposureReadiness(summary)" in script + assert "function renderExposureStatus(summary)" in script + assert "function renderExposureCoverage(summary)" in script + assert "function renderExposureSeveritySummary(summary)" in script + assert "collector coverage incomplete" in script + assert ".exposure-status-panel" in styles + assert ".exposure-coverage-grid" in styles + assert ".severity-summary" in styles diff --git a/tests/regression/test_secops_devops_frontend.py b/tests/regression/test_secops_devops_frontend.py new file mode 100644 index 0000000..67ff01b --- /dev/null +++ b/tests/regression/test_secops_devops_frontend.py @@ -0,0 +1,123 @@ +from pathlib import Path + +import psycopg +import pytest +from fastapi.testclient import TestClient + + +FIXTURE_PATH = Path(__file__).resolve().parents[1] / "fixtures" / "nmap_single_host.xml" +FRONTEND_INDEX_PATH = Path(__file__).resolve().parents[2] / "frontend" / "index.html" +FRONTEND_APP_PATH = Path(__file__).resolve().parents[2] / "frontend" / "app.js" + + +@pytest.fixture +def secops_client(integration_brain_module): + client = TestClient(integration_brain_module.app) + response = client.post("/auth/login", json={"username": "admin", "password": "change-me-now"}) + assert response.status_code == 200 + return client + + +@pytest.fixture +def asset_with_finding(secops_client, integration_db_url): + ingest_response = secops_client.post("/ingest/nmap_xml", json={"xml_path": str(FIXTURE_PATH)}) + assert ingest_response.status_code == 200 + asset_id = secops_client.get("/assets").json()["assets"][0]["asset_id"] + + with psycopg.connect(integration_db_url) as conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO findings ( + asset_id, + title, + description, + severity, + confidence, + evidence, + recommended_action + ) + VALUES ( + %s, + 'Raw IP dashboard link bypasses HTTPS route', + 'The launcher still points operators at a raw IP and port instead of the managed proxy route.', + 'high', + 0.910, + '{"source":"heimdall","url":"http://10.0.0.17:8080"}'::jsonb, + 'Replace the Heimdall URL with the preferred HTTPS hostname and verify the proxy route.' + ) + RETURNING finding_id + """, + (asset_id,), + ) + finding_id = str(cur.fetchone()[0]) + conn.commit() + + return {"asset_id": asset_id, "finding_id": finding_id} + + +def test_findings_contract_combines_security_and_remediation(secops_client, asset_with_finding): + payload = secops_client.get("/findings").json() + + assert set(payload.keys()) == {"findings"} + assert payload["findings"] + finding = payload["findings"][0] + assert { + "finding_id", + "asset_id", + "preferred_name", + "title", + "description", + "severity", + "confidence", + "evidence", + "recommended_action", + "created_at", + "instruction_count", + "latest_instruction", + }.issubset(finding.keys()) + assert finding["title"] == "Raw IP dashboard link bypasses HTTPS route" + assert finding["recommended_action"].startswith("Replace the Heimdall URL") + assert finding["instruction_count"] == 0 + assert finding["latest_instruction"] is None + + +def test_operator_can_attach_fix_instructions_to_a_finding(secops_client, asset_with_finding): + response = secops_client.post( + f"/findings/{asset_with_finding['finding_id']}/instructions", + json={ + "instruction_text": "Faye, fix the Heimdall link first and then verify the HTTPS route from the dashboard host.", + "intent": "fix", + "priority": "high", + }, + ) + + assert response.status_code == 200 + instruction = response.json()["instruction"] + assert instruction["finding_id"] == asset_with_finding["finding_id"] + assert instruction["instruction_text"].startswith("Faye, fix the Heimdall link") + assert instruction["intent"] == "fix" + assert instruction["priority"] == "high" + assert instruction["status"] == "queued" + + findings = secops_client.get("/findings").json()["findings"] + updated = next(item for item in findings if item["finding_id"] == asset_with_finding["finding_id"]) + assert updated["instruction_count"] == 1 + assert updated["latest_instruction"]["instruction_text"] == instruction["instruction_text"] + + +def test_secops_frontend_exposes_findings_remediation_and_instruction_workflow(): + html = FRONTEND_INDEX_PATH.read_text(encoding="utf-8") + script = FRONTEND_APP_PATH.read_text(encoding="utf-8") + + assert "SecOps remediation board" in html + assert 'id="findings-board"' in html + assert 'id="finding-count"' in html + assert 'id="remediation-count"' in html + assert 'id="instruction-modal"' in html + assert 'id="instruction-form"' in html + assert 'findings: "/api/findings"' in script + assert 'function renderFindingsBoard()' in script + assert 'function openInstructionModal(findingId)' in script + assert 'function submitFindingInstruction(event)' in script + assert 'fetch(`${endpoints.findings}/${encodeURIComponent(findingId)}/instructions`' in script diff --git a/tests/smoke/test_compose_smoke.py b/tests/smoke/test_compose_smoke.py index 0aa5ba1..2c9ae06 100644 --- a/tests/smoke/test_compose_smoke.py +++ b/tests/smoke/test_compose_smoke.py @@ -24,6 +24,14 @@ FIXTURE_PATH = REPO_ROOT / "tests" / "fixtures" / "nmap_single_host.xml" +def _ensure_discovery_raw_dir() -> Path: + """Pre-create bind-mounted discovery dirs before Docker can create them as root.""" + + smoke_fixture_dir = REPO_ROOT / "discovery" / "raw" + smoke_fixture_dir.mkdir(parents=True, exist_ok=True) + return smoke_fixture_dir + + def _find_free_port() -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.bind(("127.0.0.1", 0)) @@ -103,6 +111,7 @@ def service_ready(service): def test_compose_stack_reaches_healthy_state(): project_name = f"homelabsec-smoke-{uuid.uuid4().hex[:8]}" + _ensure_discovery_raw_dir() api_port = _find_free_port() frontend_port = _find_free_port() postgres_port = _find_free_port() @@ -146,6 +155,7 @@ def test_compose_stack_reaches_healthy_state(): def test_monitoring_and_secure_edge_overlays_reach_healthy_state(): project_name = f"homelabsec-smoke-obs-{uuid.uuid4().hex[:8]}" + _ensure_discovery_raw_dir() api_port = _find_free_port() postgres_port = _find_free_port() prometheus_port = _find_free_port() @@ -227,8 +237,7 @@ def test_api_workflow_smoke(): frontend_port = _find_free_port() postgres_port = _find_free_port() compose_files = [SMOKE_COMPOSE, SMOKE_WORKFLOW_COMPOSE] - smoke_fixture_dir = REPO_ROOT / "discovery" / "raw" - smoke_fixture_dir.mkdir(parents=True, exist_ok=True) + smoke_fixture_dir = _ensure_discovery_raw_dir() smoke_fixture_path = smoke_fixture_dir / f"smoke-{uuid.uuid4().hex}.xml" smoke_fixture_path.write_text(FIXTURE_PATH.read_text()) compose_env = { diff --git a/tests/unit/test_exposure_collectors.py b/tests/unit/test_exposure_collectors.py new file mode 100644 index 0000000..90edc90 --- /dev/null +++ b/tests/unit/test_exposure_collectors.py @@ -0,0 +1,165 @@ +import os +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +BRAIN_DIR = REPO_ROOT / "brain" +if str(BRAIN_DIR) not in sys.path: + sys.path.insert(0, str(BRAIN_DIR)) +os.environ.setdefault("DATABASE_URL", "postgresql://test:***@localhost:5432/test") +os.environ.setdefault("OLLAMA_URL", "http://ollama.test") + +from collectors.exposure_collectors import ( + normalize_dns_answer, + normalize_hcm_target, + normalize_heimdall_link, + normalize_npm_proxy_host, +) + + +def test_npm_proxy_host_normalization_uses_strict_non_secret_allowlist(): + record = { + "id": 7, + "domain_names": ["grafana.home.robertbalm.com"], + "forward_scheme": "http", + "forward_host": "10.0.0.17", + "forward_port": 3000, + "certificate_id": 12, + "ssl_forced": True, + "http2_support": True, + "block_exploits": True, + "caching_enabled": False, + "allow_websocket_upgrade": True, + "advanced_config": "proxy_set_header Authorization super-secret;", + "meta": {"dns_challenge": {"token": "secret-token"}}, + "access_list_id": 1, + "owner_user_id": 2, + "certificate": {"provider": "letsencrypt", "meta": {"credentials": "secret"}}, + "password": "do-not-copy", + } + + routes = normalize_npm_proxy_host(record) + + assert routes == [ + { + "source": "npm", + "domain": "grafana.home.robertbalm.com", + "scheme": "http", + "upstream_host": "10.0.0.17", + "upstream_port": 3000, + "tls_status": "configured", + "certificate_status": "certificate_id:12", + "force_ssl": True, + "http2_support": True, + "block_exploits": True, + "websocket_support": True, + "raw_json": { + "proxy_host_id": 7, + "certificate_id": 12, + "caching_enabled": False, + }, + } + ] + assert "secret" not in repr(routes).lower() + assert "advanced_config" not in repr(routes) + assert "meta" not in repr(routes) + assert "password" not in repr(routes) + + +def test_hcm_target_normalization_keeps_route_and_certificate_health_only(): + target = { + "name": "Grafana", + "route_url": "https://grafana.home.robertbalm.com", + "backend_url": "http://10.0.0.17:3000", + "tls": { + "status": "valid", + "issuer": "Home CA", + "not_after": "2026-09-01T00:00:00Z", + }, + "health": {"status": "ok"}, + "token": "secret-token", + "private_key_path": "/secret/path", + } + + route = normalize_hcm_target(target) + + assert route == { + "source": "hcm", + "domain": "grafana.home.robertbalm.com", + "scheme": "http", + "upstream_host": "10.0.0.17", + "upstream_port": 3000, + "tls_status": "valid", + "certificate_status": "valid", + "force_ssl": None, + "http2_support": None, + "block_exploits": None, + "websocket_support": None, + "raw_json": { + "name": "Grafana", + "route_url": "https://grafana.home.robertbalm.com", + "backend_url": "http://10.0.0.17:3000", + "health_status": "ok", + "tls_issuer": "Home CA", + "tls_not_after": "2026-09-01T00:00:00Z", + }, + } + assert "secret" not in repr(route).lower() + assert "private_key" not in repr(route) + + +def test_heimdall_link_normalization_classifies_raw_http_ip_and_named_https(): + raw_link = normalize_heimdall_link( + { + "title": "Grafana raw", + "url": "http://10.0.0.17:3000/d/abc", + "apikey": "secret", + "password": "secret", + } + ) + named_link = normalize_heimdall_link( + {"title": "Grafana", "url": "https://grafana.home.robertbalm.com/"} + ) + + assert raw_link == { + "source": "heimdall", + "title": "Grafana raw", + "url": "http://10.0.0.17:3000/d/abc", + "normalized_host": "10.0.0.17", + "link_kind": "raw_ip", + "preferred_route_domain": None, + "hygiene_status": "weak_raw_http_ip", + "raw_json": {}, + } + assert named_link["link_kind"] == "named_https" + assert named_link["hygiene_status"] == "preferred" + assert "secret" not in repr(raw_link).lower() + + +def test_dns_answer_normalization_marks_candidate_unresolved_as_info(): + unresolved = normalize_dns_answer( + hostname="future.home.robertbalm.com", + resolver="10.0.0.14", + record_type="A", + values=[], + planned=True, + ) + aligned = normalize_dns_answer( + hostname="grafana.home.robertbalm.com", + resolver="10.0.0.14", + record_type="A", + values=["10.0.0.17"], + ) + + assert unresolved == [ + { + "hostname": "future.home.robertbalm.com", + "resolver": "10.0.0.14", + "record_type": "A", + "record_value": "", + "status": "planned_unresolved", + "raw_json": {"planned": True}, + } + ] + assert aligned[0]["status"] == "observed" + assert aligned[0]["record_value"] == "10.0.0.17" diff --git a/tests/unit/test_exposure_correlation.py b/tests/unit/test_exposure_correlation.py new file mode 100644 index 0000000..f19da05 --- /dev/null +++ b/tests/unit/test_exposure_correlation.py @@ -0,0 +1,70 @@ +import os +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +BRAIN_DIR = REPO_ROOT / "brain" +if str(BRAIN_DIR) not in sys.path: + sys.path.insert(0, str(BRAIN_DIR)) +os.environ.setdefault("DATABASE_URL", "postgresql://test:***@localhost:5432/test") +os.environ.setdefault("OLLAMA_URL", "http://ollama.test") + +from brainlib.exposure import correlate_exposure_findings + + +def test_correlate_exposure_findings_flags_launcher_tls_and_dns_gaps(): + routes = [ + { + "source": "npm", + "domain": "service.home.example", + "scheme": "http", + "upstream_host": "10.0.0.10", + "upstream_port": 8080, + "tls_status": "missing", + "force_ssl": False, + "raw_json": {}, + } + ] + dns_records = [ + { + "hostname": "service.home.example", + "resolver": "synology-lan", + "record_type": "A", + "record_value": "", + "status": "planned_unresolved", + "raw_json": {"planned": True}, + } + ] + launcher_links = [ + { + "source": "heimdall", + "title": "Service", + "url": "http://10.0.0.10:8080", + "normalized_host": "10.0.0.10", + "link_kind": "raw_ip", + "preferred_route_domain": None, + "hygiene_status": "weak_raw_http_ip", + "raw_json": {}, + } + ] + + findings = correlate_exposure_findings(routes, dns_records, launcher_links) + + by_type = {finding["finding_type"]: finding for finding in findings} + assert set(by_type) == {"launcher_hygiene", "route_tls", "dns_resolution"} + assert by_type["launcher_hygiene"]["severity"] == "high" + assert by_type["launcher_hygiene"]["evidence"] == { + "launcher_title": "Service", + "launcher_url": "http://10.0.0.10:8080", + "preferred_route_domain": "service.home.example", + "route_upstream": "10.0.0.10:8080", + } + assert by_type["route_tls"]["severity"] == "medium" + assert by_type["route_tls"]["evidence"]["domain"] == "service.home.example" + assert by_type["dns_resolution"]["severity"] == "info" + assert by_type["dns_resolution"]["evidence"] == { + "hostname": "service.home.example", + "resolver": "synology-lan", + "record_type": "A", + "status": "planned_unresolved", + }