From 52e5333194923c645665853aa8702c16e734901b Mon Sep 17 00:00:00 2001 From: d3vhex Date: Thu, 10 Sep 2026 19:27:12 +0300 Subject: [PATCH] Add security keys, the certificate names they need, and finish the console kit Derive the WebAuthn relying party from the request so there is nothing to configure, say why a key cannot be used where it cannot, add TLS_SAN and report a certificate that predates the configured names, move the last eight pages onto the shared kit with real loading and failure states, correct the agent module reference, and fix the event parser that dropped every PowerShell 403's first field. --- .env.example | 33 + Dockerfile | 132 +-- Sentora/build_agent.ps1 | 9 +- Sentora/build_agent.sh | 7 +- Sentora/docs/MODULES.md | 114 ++- app.py | 553 ++++++++++++- certs/generate_certs.py | 91 ++- core/self_defence.py | 6 + core/tls.py | 53 +- docs/production-deployment.md | 77 +- frontend/src/components/ui/index.tsx | 150 ++++ frontend/src/index.css | 29 + frontend/src/lib/webauthn.test.ts | 110 +++ frontend/src/lib/webauthn.ts | 127 +++ frontend/src/lib/windowsEvent.ts | 54 +- frontend/src/pages/AIAnalysis.tsx | 580 +++++++------ frontend/src/pages/AccountSecurity.tsx | 169 +++- frontend/src/pages/AdminConfig.tsx | 664 ++++++++------- frontend/src/pages/AdminUsers.tsx | 528 +++++++----- frontend/src/pages/Automations.tsx | 671 ++++++++-------- frontend/src/pages/Databases.tsx | 544 ++++++++----- frontend/src/pages/Deployment.tsx | 674 ++++++---------- frontend/src/pages/Login.tsx | 386 +++++---- frontend/src/pages/Playbooks.tsx | 1028 +++++++++++++----------- frontend/src/services/api.ts | 54 ++ requirements.lock | 19 +- requirements.txt | 11 + security/webauthn.py | 227 ++++++ tests/test_auth_wiring.py | 14 + tests/test_module_reference.py | 178 ++++ tests/test_second_factor.py | 8 +- tests/test_tls_certs.py | 128 +++ tests/test_webauthn_origin.py | 287 +++++++ 33 files changed, 5163 insertions(+), 2552 deletions(-) create mode 100644 frontend/src/lib/webauthn.test.ts create mode 100644 frontend/src/lib/webauthn.ts create mode 100644 security/webauthn.py create mode 100644 tests/test_module_reference.py create mode 100644 tests/test_webauthn_origin.py diff --git a/.env.example b/.env.example index 6de6a51..2f4115a 100644 --- a/.env.example +++ b/.env.example @@ -97,6 +97,39 @@ AGENT_SHARED_SECRET= # TLS_CERT=certs/server.crt # TLS_KEY=certs/server.key # TLS_CN=soc.example.com +# +# TLS_SAN is every *other* name the certificate should answer to, comma +# separated. Hostnames and addresses both; an entry that parses as an address +# becomes an IP SAN, because a bare address listed as a DNS name matches +# nothing. localhost, 127.0.0.1 and ::1 are always included. +# +# Put the name you actually type in the browser here. A console reached at +# https://
cannot register a security key at all: WebAuthn's RP ID +# has to be a domain and browsers reject an address outright. +# +# Generation is idempotent, so changing these on a deployment that already has +# a certificate does nothing on its own - the startup log says so and gives +# the regenerate command. +# TLS_SAN=soc.example.com,10.0.0.5 + + +# ─── Security keys (WebAuthn) ──────────────────────────────────────────── +# Nothing to set, and that is deliberate. +# +# The relying-party ID is derived from the request, so a laptop at +# https://localhost:8000 and an estate at https://soc.example.com both work +# with no setting - and no setting can disagree with the URL people type, +# which is the failure this would otherwise have. `My Security` reports +# whether a key can be registered from where the browser is standing, and why +# not when it cannot. +# +# The two things that decide it are above: TLS_ENABLED (a key needs a secure +# context) and TLS_CN/TLS_SAN (it needs a hostname - browsers reject an IP +# address as a relying-party ID). One-time codes work regardless. +# +# The `webauthn` package is imported lazily, so a deployment that pulls new +# code without reinstalling its dependencies loses the key option rather than +# the console. # Telemetry transport. 5001 is the original plaintext listener; 5011 carries # the same protocol inside TLS, and an agent chooses between them from its diff --git a/Dockerfile b/Dockerfile index 8998250..b1a636a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,66 +1,66 @@ -# --- Stage 1: Build the React Frontend --- -FROM node:20-slim AS frontend-builder -WORKDIR /build - -# Copy frontend source -COPY frontend/package*.json ./ -RUN npm install --silent -COPY frontend/ ./ - -# Build the frontend (creates 'dist' folder) -RUN npm run build - -# --- Stage 2: Final Production Container --- -FROM python:3.10-slim -WORKDIR /app - -# Install system dependencies (needed for MySQL, PostgreSQL, and LDAP) -RUN apt-get update && apt-get install -y \ - default-libmysqlclient-dev \ - libpq-dev \ - libldap2-dev \ - libsasl2-dev \ - gcc \ - && rm -rf /var/lib/apt/lists/* - -# Install Python backend dependencies -# The lock, not the loose list. requirements.txt says what we depend on; -# requirements.lock says which versions this image is built from, so the -# image a commit produces today is the image it produces next month. -COPY ./requirements.txt ./requirements.lock /app/ -RUN pip install --no-cache-dir -r /app/requirements.lock - -# Copy backend source. See .dockerignore — .env and data/ are excluded so -# secrets are not baked into the image layers. -COPY . /app - -# Copy the built frontend from the previous stage -# Note: Since app.py now expects ./frontend/dist -RUN mkdir -p /app/frontend/dist -COPY --from=frontend-builder /build/dist /app/frontend/dist - -# Environment variables -ENV PYTHONUNBUFFERED=1 -ENV VITE_API_BASE_URL="" -# Empty, not "*": the SPA is served from this same container, so requests are -# same-origin. A wildcard origin is rejected by browsers once the session -# cookie is attached. -ENV CORS_ORIGINS="" - -# Drop root. The container previously ran as uid 0, so any code execution bug -# in the API — the HTTP proxy and the playbook engine both reach out to -# attacker-influenced input — started with full privileges inside the -# container. /app/data is the one path written at runtime (the Fernet key when -# FERNET_KEY is unset), so it is the only thing the app user needs to own. -RUN useradd --system --create-home --uid 10001 sentora \ - && mkdir -p /app/data \ - && chown -R sentora:sentora /app/data \ - && chmod 700 /app/data -USER sentora - -# Expose Sanic port -EXPOSE 8000 - -# Start command -# Default to running the main app (API + UI) -CMD ["python", "app.py"] +# --- Stage 1: Build the React Frontend --- +FROM node:20-slim AS frontend-builder +WORKDIR /build + +# Copy frontend source +COPY frontend/package*.json ./ +RUN npm install --silent +COPY frontend/ ./ + +# Build the frontend (creates 'dist' folder) +RUN npm run build + +# --- Stage 2: Final Production Container --- +FROM python:3.10-slim +WORKDIR /app + +# Install system dependencies (needed for MySQL, PostgreSQL, and LDAP) +RUN apt-get update && apt-get install -y \ + default-libmysqlclient-dev \ + libpq-dev \ + libldap2-dev \ + libsasl2-dev \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Install Python backend dependencies +# The lock, not the loose list. requirements.txt says what we depend on; +# requirements.lock says which versions this image is built from, so the +# image a commit produces today is the image it produces next month. +COPY ./requirements.txt ./requirements.lock /app/ +RUN pip install --no-cache-dir -r /app/requirements.lock + +# Copy backend source. See .dockerignore — .env and data/ are excluded so +# secrets are not baked into the image layers. +COPY . /app + +# Copy the built frontend from the previous stage +# Note: Since app.py now expects ./frontend/dist +RUN mkdir -p /app/frontend/dist +COPY --from=frontend-builder /build/dist /app/frontend/dist + +# Environment variables +ENV PYTHONUNBUFFERED=1 +ENV VITE_API_BASE_URL="" +# Empty, not "*": the SPA is served from this same container, so requests are +# same-origin. A wildcard origin is rejected by browsers once the session +# cookie is attached. +ENV CORS_ORIGINS="" + +# Drop root. The container previously ran as uid 0, so any code execution bug +# in the API — the HTTP proxy and the playbook engine both reach out to +# attacker-influenced input — started with full privileges inside the +# container. /app/data is the one path written at runtime (the Fernet key when +# FERNET_KEY is unset), so it is the only thing the app user needs to own. +RUN useradd --system --create-home --uid 10001 sentora \ + && mkdir -p /app/data \ + && chown -R sentora:sentora /app/data \ + && chmod 700 /app/data +USER sentora + +# Expose Sanic port +EXPOSE 8000 + +# Start command +# Default to running the main app (API + UI) +CMD ["python", "app.py"] diff --git a/Sentora/build_agent.ps1 b/Sentora/build_agent.ps1 index 154563a..ceecb42 100644 --- a/Sentora/build_agent.ps1 +++ b/Sentora/build_agent.ps1 @@ -223,7 +223,14 @@ $sha = (Get-FileHash -Algorithm SHA256 $artifact).Hash.ToLower() Write-Ok ("Built main.exe ({0:N1} MB)" -f $sizeMb) Write-Host " sha256: $sha" -Write-Host " Ship it via /api/agent/download/windows (restart the server container to pick it up)." +# `docker compose restart app` and not `up -d --build app` was what this line +# used to say, and a restart does nothing: Sentora/ is copied into the image +# by the Dockerfile, not bind-mounted, so the container keeps serving the +# binary it was built with. A rebuild-and-reinstall cycle was spent on that - +# the agent came back with the previous build's hash and every fix looked like +# it had failed. +Write-Host " Serve it: docker compose up -d --build app (a plain restart keeps the old binary)" +Write-Host " Then reinstall the agent; /api/agent/download/windows reads it from the image." # ─────────────────────── post-build cleanup ─────────────────────── # Drop the intermediate PyInstaller artifacts so the working tree diff --git a/Sentora/build_agent.sh b/Sentora/build_agent.sh index 5b8539f..ea305c8 100644 --- a/Sentora/build_agent.sh +++ b/Sentora/build_agent.sh @@ -265,7 +265,12 @@ fi ok "Built main ($SIZE)" printf " sha256: %s\n" "$SHA" -printf " Ship it via /api/agent/download/linux (restart the server container to pick it up).\n" +# A restart does nothing here: Sentora/ is copied into the image by the +# Dockerfile rather than bind-mounted, so the container keeps serving the +# binary it was built with. See the note in build_agent.ps1 - a whole +# rebuild-and-reinstall cycle was spent discovering it. +printf " Serve it: docker compose up -d --build app (a plain restart keeps the old binary)\n" +printf " Then reinstall the agent; /api/agent/download/linux reads it from the image.\n" # ─────────────────────── post-build cleanup ─────────────────────── # Drop the intermediate PyInstaller artifacts so the working tree diff --git a/Sentora/docs/MODULES.md b/Sentora/docs/MODULES.md index 2f85aa3..5bd1a9b 100644 --- a/Sentora/docs/MODULES.md +++ b/Sentora/docs/MODULES.md @@ -2,7 +2,16 @@ Every module is launched from `main.py` via `periodic_wrapped(...)` (or a dedicated thread for continuous watchers). Findings are written to -the agent's local DB and shipped to the server by a background flusher. +the agent's local DB, and shipped to the server by a background flusher +**only if the table is named in `TABLES` in `main.py`**. + +That last part is not a formality. `software_inventory` and +`network_inventory` were written every cycle by `inventory` and were not +on that list, so nothing was ever offered to the server: 368,902 rows and +78,224 rows sat unsent in the agent's own database while both tables read +as empty in the console. Adding a module means adding its table there, and +`tests/test_ingest_schema_coverage.py` now fails if a table in the agent +schema is neither shipped nor listed as deliberately local. Convention: tables marked (enc) have sensitive fields encrypted at rest by `modules/enc_db.py` using the per-tenant Fernet key. See the @@ -49,13 +58,25 @@ map. ### `edr_enforcer` (periodic baseline) - Path: `modules/edr_enforcer.py` -- Behavior: Scheduled hash-baseline scan of a small high-value file set - (`/etc/passwd`, `/etc/shadow`, SAM hive path, `hosts`, `sshd_config`). - First run establishes the baseline; subsequent runs flag any mismatch - as a `security_audit` finding. Complements the realtime `fim` watcher, - since the baseline scan catches changes that occurred while the agent - was offline. -- Output table: `security_audit` (enc) +- Behavior: The agent's periodic endpoint sweep. Five passes per cycle: + - `check_fim()` — hash-baseline scan of a small high-value file set + (`/etc/passwd`, `/etc/shadow`, SAM hive path, `hosts`, `sshd_config`). + Complements the realtime `fim` watcher, which cannot see changes made + while the agent was offline. + - `track_network()` — current sockets and the process behind each. + - `monitor_processes()` — new and exiting processes. + - `get_hardware_inventory()` — CPU, memory and disk facts. + - `monitor_registry()` — Windows autorun and policy keys. +- Output tables: `fim_data` (enc), `network_connections` (enc), + `process_events` (enc), `hardware_inventory` (enc), `registry_logs` (enc) +- This entry described a module writing `security_audit` and nothing else. + It has never written that table — nothing in the agent has — and the four + tables it does write beside `fim_data` were undocumented. `security_audit` + is the clearest case in the codebase of a claim outliving its + implementation: the document promised it, the schema created it, `TABLES` + shipped it empty every cycle, and the console showed it as NOT COLLECTED + for the life of every install. It is off the shipping list now; the table + stays so a collector can be added without a schema change. --- @@ -63,22 +84,28 @@ map. ### `inventory` - Path: `modules/inventory.py` -- Behavior: Hardware snapshot (CPU model, RAM, disk topology) plus +- Behavior: Hardware snapshot (CPU model, RAM, disk topology), installed-software enumeration (`dpkg-query`, then `rpm -qa`, then a - Windows registry walk under `Uninstall`). Writes one - `hardware_inventory` row and N `installed_software` rows per sweep. + Windows registry walk under `Uninstall`), and the set of listening + sockets with the process owning each. Writes one `hardware_inventory` + row, N `software_inventory` rows and N `network_inventory` rows per + sweep. - Output tables: `hardware_inventory` (enc) for `name` and - `serial_number`; `installed_software` + `serial_number`; `software_inventory`; `network_inventory` +- The doc said `installed_software`, which is not a table and never was. + The two inventory tables were also the pair that reached the server for + the first time only after `TABLES` was corrected — see the note at the + top. ### `find_vulns` - Path: `modules/find_vulns/` (`info_collector.py` and `find_vuln.py`) - Behavior: - `info_collector` produces the package list (same logic as - `inventory` but emits to the OSV-input format). + `inventory` but emitting the OSV input format) into `packages`. - `find_vuln` queries `https://api.osv.dev` (or the server-proxied air-gap mirror) for known vulnerabilities and writes `vulnerabilities_report` rows. -- Output table: `vulnerabilities_report` +- Output tables: `packages`, `vulnerabilities_report` --- @@ -92,7 +119,12 @@ map. 2. Walks `psutil.process_iter()` for known dual-use tooling: `psexec`, `winrs`, `wsmprovhost`, `nmap`, `masscan`, `nc` / `ncat` / `netcat`, `socat`, `chisel`, `mimikatz`, `rdesktop`. -- Output table: `security_audit` (enc) with `type=LATERAL_MOVEMENT` +- Output table: `events_alert` (enc), source `LateralMovement` +- Findings go through `send_alert()` in `main.py`, which deduplicates on a + content fingerprint before writing. These detectors report *state*, not + events — an established loopback SMB connection is re-found every 300s — + and without the fingerprint one normal connection produced the same alert + roughly 288 times a day. ### `persistence_hunter` - Path: `modules/persistence_hunter.py` @@ -102,7 +134,17 @@ map. running `.vbs` / `.ps1`. Inspects the per-user Startup folder. - Linux: `/etc/crontab`, `/etc/cron.d/`, user crontabs and suspicious systemd unit files. -- Output table: `security_audit` (enc) with `type=PERSISTENCE` +- Output table: `events_alert` (enc), source `PersistenceHunter` +- Same `send_alert()` path and the same deduplication as + `lateral_movement` above. + +> All three modules in this section, plus `edr_enforcer` above, were +> documented as writing `security_audit`. None of them ever did — nothing in +> this agent has written a row to that table. The schema created it, `TABLES` +> shipped it empty every cycle, and the console showed it as NOT COLLECTED for +> the life of every install, which reads as a sensor that broke rather than one +> that was never built. It is off the shipping list; the table stays so a +> collector can be added later without a schema change. ### `check_permissions` - Path: `modules/check_permissions/check_permissions.py` @@ -119,17 +161,28 @@ map. ### `portscanner` - Path: `modules/portscanner/portscanner.py` -- Behavior: Scans local TCP ports, grabs service banners, classifies - the service when a fingerprint matches. Used both for surface mapping - and to detect new listeners that appear between sweeps. -- Output table: `portscan_result` +- Behavior: Scans `127.0.0.1` hourly, grabs a banner from each open + port, and classifies the service when a fingerprint matches. Used for + surface mapping and to spot listeners that appear between sweeps. +- Output table: `portscan_result` — `target_ip`, `port`, `protocol`, + `state`, `service`, `product`, `version`, `banner` +- `target_ip`, `state` and `banner` were columns on the *server's* table + that the agent's own table did not have and the insert never mentioned, + so all 108 rows on the server carried NULL for each. A port list that + does not say which host, or whether the port answered, is a list of + numbers. +- The banner is remote input and is truncated to 2 KB. It is also where + the NUL bytes come from that used to kill the whole scan — see + `db.py` below. ### `resource_checker` - Path: `modules/resource_checker/resource_checker.py` (plus `disks.py`) -- Behavior: Per-tick CPU and memory snapshot into `resource_log`. The - `disks.py` companion writes per-partition usage to `disk_info` and +- Behavior: Per-tick CPU and memory snapshot into `resource_usage`. The + `disks.py` companion writes per-partition usage to `disk_usage` and flags any partition over the soft or hard threshold. -- Output tables: `resource_log`, `disk_info` +- Output tables: `resource_usage`, `disk_usage` +- Documented as `resource_log` and `disk_info` until now. Neither name + exists in the schema. ### `docker_monitor` - Path: `modules/docker_monitor/docker_monitor.py` @@ -180,6 +233,21 @@ an equivalent SQLite schema is shipped at [`db/init_sqlite.sql`](../db/init_sqlite.sql) for offline or minimal deployments. +Two behaviours a module author needs to know about: + +- **`scrub_nuls` strips `\x00` from every bound parameter.** PostgreSQL + text cannot hold a NUL and psycopg2 refuses the whole statement with + `ValueError: A string literal cannot contain NUL (0x00) characters` — + note the type, an `except psycopg2.Error` walks straight past it. Any + module that stores bytes off a socket or a file will hit this; the port + scanner did, every hour, and lost the entire scan each time because the + exception left the loop that was writing the other ports. +- **`prune_unsent` bounds the backlog of rows that never shipped.** + `prune_sent` deliberately refuses to delete an unsent row, which is + right for a table being delivered and wrong for one that is not. A table + missing from `TABLES` would otherwise grow until the disk did, on a + machine this product is meant to be watching rather than filling. + ### `enc_db.py` Fernet-backed transparent encryption layer over `db.py`. Pulls the active key from `/api/agents/bootstrap` and refreshes it every diff --git a/app.py b/app.py index f1e874e..28587a2 100644 --- a/app.py +++ b/app.py @@ -112,6 +112,7 @@ def password_complexity(cls, v: str): from security import session as session_store from security import ssrf from security import totp as totp_store +from security import webauthn as webauthn_store from core import agent_link from core import attack from core import config_validation @@ -7526,14 +7527,45 @@ async def _totp_secret_for(cur, user_id: int) -> tuple[str | None, bool]: async def _totp_required(cur, user_id: int) -> bool: - """Whether this user has a *confirmed* second factor. + """Whether this user has a *confirmed* second factor of any kind. Confirmed, not merely enrolled. A secret written when the QR code was displayed and never proved would lock out everybody who scanned it into an app they then deleted. + + A registered security key counts. Reading only the TOTP table would let an + operator who enrolled a key and never set up an authenticator sign in with + a password alone - the console would show a second factor on the account + and not ask for one, which is the worst of both: the appearance of the + control without the control. """ _, confirmed = await _totp_secret_for(cur, user_id) - return confirmed + if confirmed: + return True + return await _has_security_key(cur, user_id) + + +async def _has_security_key(cur, user_id: int) -> bool: + """Any key, for any origin. + + Deliberately not scoped to the current relying party, unlike the list the + console shows. Whether a login needs a second factor is a property of the + account; whether *this* browser can offer a key is a property of the + origin. Conflating them would mean an operator with a key registered at a + hostname could skip the second factor entirely by opening the console at + an address - which turns the origin binding, the whole point of WebAuthn, + into a bypass. + """ + try: + await _webauthn_tables(cur) + await cur.execute( + "SELECT 1 FROM user_webauthn WHERE user_id = %s LIMIT 1", (user_id,)) + return (await cur.fetchone()) is not None + except Exception: + # A missing table on an older database is "no keys", not a failed + # login. This runs on the password path, and raising here would lock + # everybody out of a console whose 2FA they had never enabled. + return False async def _begin_second_factor(request, *, user_id: int, username: str, @@ -7682,6 +7714,24 @@ async def complete_second_factor(request): finally: await cur.close() + return await _finish_second_factor_login( + request, user_id=user_id, username=username, auth_type=auth_type, + role_row=role_row, ip=ip, + factor="recovery code" if used_recovery else "second factor", + used_recovery=used_recovery) + + +async def _finish_second_factor_login(request, *, user_id: int, username: str, + auth_type: str, role_row, ip: str, + factor: str, used_recovery: bool = False): + """Issue the session, once both factors are in. + + Shared with the security-key route rather than copied into it. Session + issuance is the step where a mistake is a bypass, and two of them drift: + the code that files a login and the code that scores one had already come + apart once in this codebase, and nobody could see it because each side had + its own idea of what counted. + """ role = role_row[0] if role_row else None must_change = bool(role_row[1]) if role_row else False @@ -7700,9 +7750,7 @@ async def complete_second_factor(request): "credential one.", }, status=503) - await log_login_attempt( - username, auth_type, "success", - "recovery code" if used_recovery else "second factor", ip) + await log_login_attempt(username, auth_type, "success", factor, ip) if used_recovery: # Worth its own line and its own event: a recovery code means the # normal factor was unavailable, which is either an honest lost phone @@ -7915,6 +7963,501 @@ async def second_factor_disable(request): "message": "Two-factor is off for this account."}) +# --------------------------------------------------------------------------- +# Security keys +# +# The second factor TOTP cannot be: a code typed into a convincing copy of this +# login page works on the real one, and the operator has no way to tell. A +# security key signs over the origin it is actually talking to, so it produces +# nothing usable on a copy. +# +# There is no configuration. The relying-party ID is derived from the request, +# which is why this works on a laptop at `localhost` and on an estate with a +# real hostname without either being told anything - and why some origins +# cannot host a key at all. `security.webauthn.usability` answers that, and the +# console shows the reason instead of a button that fails. +# --------------------------------------------------------------------------- + +def _webauthn_lib(): + """The library, or None. + + Imported here rather than at module scope on purpose. This is an optional + factor, and a deployment that pulls new code without reinstalling its + dependencies should lose the security-key option, not the whole console. + """ + try: + import webauthn as _lib + from webauthn.helpers import base64url_to_bytes, bytes_to_base64url + from webauthn.helpers.structs import ( + AuthenticatorSelectionCriteria, PublicKeyCredentialDescriptor, + ResidentKeyRequirement, UserVerificationRequirement, + ) + except ImportError: + return None + return { + "lib": _lib, "to_bytes": base64url_to_bytes, "to_b64": bytes_to_base64url, + "Selection": AuthenticatorSelectionCriteria, + "Descriptor": PublicKeyCredentialDescriptor, + "ResidentKey": ResidentKeyRequirement, + "UserVerification": UserVerificationRequirement, + } + + +async def _webauthn_tables(cur) -> None: + for statement in (webauthn_store.DDL_CREDENTIALS, webauthn_store.DDL_CHALLENGES): + await cur.execute(statement) + + +def _webauthn_origin(request): + """(rp_id, origin, reason) for the request in hand.""" + scheme = (request.scheme or "http").lower() + try: + rp_id, origin = webauthn_store.relying_party(request.host, scheme) + except webauthn_store.Unusable as e: + return None, None, str(e) + return rp_id, origin, None + + +async def _webauthn_credentials(cur, user_id: int, rp_id: str) -> list: + """This user's keys *for this origin*. + + Scoped to the relying party deliberately. A key registered while the + console was at `localhost` is one the browser will not offer at a hostname, + so listing it there shows an operator a factor they cannot use and then + times out at the prompt with no explanation. + """ + await cur.execute( + "SELECT id, credential_id, public_key, sign_count, transports, " + " nickname, created_at, last_used_at " + " FROM user_webauthn WHERE user_id = %s AND rp_id = %s " + " ORDER BY created_at", + (user_id, rp_id)) + rows = await cur.fetchall() + return [{"id": r[0], "credential_id": r[1], "public_key": r[2], + "sign_count": int(r[3] or 0), "transports": r[4], + "nickname": r[5], "created_at": r[6], "last_used_at": r[7]} + for r in rows] + + +@app.get("/api/2fa/webauthn/capability") +async def webauthn_capability(request): + """Whether a key can be used from where this browser is standing. + + The console asks before it offers anything, because the alternative is a + button that produces a browser error naming neither the cause nor the fix. + """ + user_id = current_user_id(request) + if not user_id: + return sanic_json({"status": "error", "message": "Unauthorized"}, status=401) + + if _webauthn_lib() is None: + return sanic_json({ + "status": "success", "available": False, + "reason": "This server was installed without the `webauthn` " + "package, so security keys are unavailable. One-time " + "codes are unaffected.", + "credentials": [], + }) + + rp_id, _origin, reason = _webauthn_origin(request) + if reason: + return sanic_json({"status": "success", "available": False, + "reason": reason, "credentials": []}) + + async with userdb_conn() as cnx: + cur = await cnx.cursor() + try: + await _webauthn_tables(cur) + creds = await _webauthn_credentials(cur, user_id, rp_id) + finally: + await cur.close() + + return sanic_json({ + "status": "success", "available": True, "reason": "", "rp_id": rp_id, + "credentials": [{ + "id": c["id"], + "label": webauthn_store.describe(c), + "created_at": str(c["created_at"] or ""), + "last_used_at": str(c["last_used_at"] or "") or None, + } for c in creds], + }) + + +@app.post("/api/2fa/webauthn/register/begin") +async def webauthn_register_begin(request): + """Options for creating a credential. Nothing becomes a factor yet.""" + user_id = current_user_id(request) + if not user_id: + return sanic_json({"status": "error", "message": "Unauthorized"}, status=401) + + lib = _webauthn_lib() + if lib is None: + return sanic_json({"status": "error", + "message": "This server has no WebAuthn support " + "installed."}, status=501) + + rp_id, origin, reason = _webauthn_origin(request) + if reason: + return sanic_json({"status": "error", "message": reason}, status=409) + + challenge = webauthn_store.new_challenge() + async with userdb_conn() as cnx: + cur = await cnx.cursor() + try: + await _webauthn_tables(cur) + await cur.execute("SELECT username FROM users WHERE id = %s", (user_id,)) + row = await cur.fetchone() + username = row[0] if row else str(user_id) + existing = await _webauthn_credentials(cur, user_id, rp_id) + await cur.execute( + "INSERT INTO webauthn_challenges (challenge, user_id, purpose, " + "rp_id, origin, expires_at) VALUES (%s, %s, 'register', %s, %s, " + "DATE_ADD(NOW(), INTERVAL %s SECOND))", + (challenge, user_id, rp_id, origin, + webauthn_store.CHALLENGE_TTL_SECONDS)) + # Cheap, and this table only grows otherwise: an abandoned ceremony + # is exactly the row nobody comes back for. + await cur.execute("DELETE FROM webauthn_challenges WHERE expires_at < NOW()") + await cnx.commit() + finally: + await cur.close() + + options = lib["lib"].generate_registration_options( + rp_id=rp_id, + rp_name=webauthn_store.RP_NAME, + user_id=str(user_id).encode(), + user_name=username, + challenge=lib["to_bytes"](challenge), + # Already-registered keys are excluded so the authenticator says "you + # have one of these already" rather than quietly making a second + # credential the operator cannot tell apart from the first. + exclude_credentials=[ + lib["Descriptor"](id=lib["to_bytes"](c["credential_id"])) + for c in existing + ], + authenticator_selection=lib["Selection"]( + resident_key=lib["ResidentKey"].PREFERRED, + user_verification=lib["UserVerification"].PREFERRED, + ), + ) + return sanic_json({"status": "success", + "options": json.loads(lib["lib"].options_to_json(options))}) + + +@app.post("/api/2fa/webauthn/register/finish") +async def webauthn_register_finish(request): + """Verify the attestation and store the credential.""" + user_id = current_user_id(request) + if not user_id: + return sanic_json({"status": "error", "message": "Unauthorized"}, status=401) + + lib = _webauthn_lib() + if lib is None: + return sanic_json({"status": "error", + "message": "This server has no WebAuthn support " + "installed."}, status=501) + + data = request.json or {} + credential = data.get("credential") + nickname = (data.get("nickname") or "").strip()[:64] or None + if not credential: + return sanic_json({"status": "error", + "message": "No credential was sent."}, status=400) + + async with userdb_conn() as cnx: + cur = await cnx.cursor() + try: + await _webauthn_tables(cur) + # The challenge, the RP ID and the origin all come from the row we + # wrote, never from the response. Verifying a response against the + # origin the response itself claims is not a check. + await cur.execute( + "SELECT challenge, rp_id, origin FROM webauthn_challenges " + " WHERE user_id = %s AND purpose = 'register' " + " AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1", + (user_id,)) + row = await cur.fetchone() + if not row: + return sanic_json({ + "status": "error", + "message": "That registration expired. Start again.", + }, status=400) + challenge, rp_id, origin = row + + try: + verified = lib["lib"].verify_registration_response( + credential=credential, + expected_challenge=lib["to_bytes"](challenge), + expected_rp_id=rp_id, + expected_origin=origin, + ) + except Exception as e: + await cur.execute( + "DELETE FROM webauthn_challenges WHERE challenge = %s", + (challenge,)) + await cnx.commit() + return sanic_json({ + "status": "error", + "message": f"That key could not be verified: {e}", + }, status=400) + + # Single use, verified or not. + await cur.execute("DELETE FROM webauthn_challenges WHERE challenge = %s", + (challenge,)) + transports = "" + if isinstance(credential, dict): + transports = ",".join( + str(t) for t in (credential.get("transports") or []) + )[:128] + await cur.execute( + "INSERT INTO user_webauthn (user_id, credential_id, public_key, " + "rp_id, sign_count, transports, nickname) " + "VALUES (%s, %s, %s, %s, %s, %s, %s) " + "ON DUPLICATE KEY UPDATE sign_count = VALUES(sign_count)", + (user_id, lib["to_b64"](verified.credential_id), + lib["to_b64"](verified.credential_public_key), rp_id, + int(verified.sign_count or 0), transports, nickname)) + await cnx.commit() + finally: + await cur.close() + + await audit_log(request, "WEBAUTHN_KEY_ADDED", str(user_id), + f"security key registered for {rp_id}") + return sanic_json({"status": "success", + "message": "Security key registered."}) + + +@app.delete("/api/2fa/webauthn/") +async def webauthn_remove(request, credential_row_id: int): + """Remove one key. Scoped to the caller, so an id from elsewhere does + nothing rather than removing somebody else's factor.""" + user_id = current_user_id(request) + if not user_id: + return sanic_json({"status": "error", "message": "Unauthorized"}, status=401) + + async with userdb_conn() as cnx: + cur = await cnx.cursor() + try: + await _webauthn_tables(cur) + await cur.execute( + "DELETE FROM user_webauthn WHERE id = %s AND user_id = %s", + (credential_row_id, user_id)) + removed = cur.rowcount + await cnx.commit() + finally: + await cur.close() + + if not removed: + return sanic_json({"status": "error", + "message": "No such key on this account."}, status=404) + await audit_log(request, "WEBAUTHN_KEY_REMOVED", str(user_id), + "security key removed by the account holder") + return sanic_json({"status": "success", "message": "Security key removed."}) + + +@app.post("/login/2fa/webauthn/begin") +async def webauthn_login_begin(request): + """Options for asserting a key against a pending login. + + The pending token is the same one the code path uses: passing the password + gets a short-lived credential that can do exactly one thing, and which + factor finishes it is the operator's choice. + """ + lib = _webauthn_lib() + if lib is None: + return sanic_json({"status": "error", + "message": "This server has no WebAuthn support " + "installed."}, status=501) + + raw_token = ((request.json or {}).get("token") or "").strip() + if not raw_token: + return sanic_json({"status": "error", + "message": "The login token is required."}, status=400) + + rp_id, origin, reason = _webauthn_origin(request) + if reason: + return sanic_json({"status": "error", "message": reason}, status=409) + + challenge = webauthn_store.new_challenge() + async with userdb_conn() as cnx: + cur = await cnx.cursor() + try: + await _totp_tables(cur) + await _webauthn_tables(cur) + await cur.execute( + "SELECT user_id FROM totp_pending WHERE token_hash = %s " + " AND expires_at > NOW()", + (totp_store.hash_pending(raw_token),)) + row = await cur.fetchone() + if not row: + return sanic_json({ + "status": "error", + "message": "This login has expired. Sign in again.", + }, status=401) + user_id = row[0] + + creds = await _webauthn_credentials(cur, user_id, rp_id) + if not creds: + # Said plainly rather than offered as an empty prompt. A key + # registered at another origin is invisible to the browser + # here, and "nothing happened" is the least useful answer. + return sanic_json({ + "status": "error", + "message": "No security key is registered for this account " + "at this address. Use a one-time code.", + }, status=404) + + await cur.execute( + "INSERT INTO webauthn_challenges (challenge, user_id, purpose, " + "rp_id, origin, expires_at) VALUES (%s, %s, 'login', %s, %s, " + "DATE_ADD(NOW(), INTERVAL %s SECOND))", + (challenge, user_id, rp_id, origin, + webauthn_store.CHALLENGE_TTL_SECONDS)) + await cur.execute("DELETE FROM webauthn_challenges WHERE expires_at < NOW()") + await cnx.commit() + finally: + await cur.close() + + options = lib["lib"].generate_authentication_options( + rp_id=rp_id, + challenge=lib["to_bytes"](challenge), + allow_credentials=[ + lib["Descriptor"](id=lib["to_bytes"](c["credential_id"])) + for c in creds + ], + user_verification=lib["UserVerification"].PREFERRED, + ) + return sanic_json({"status": "success", + "options": json.loads(lib["lib"].options_to_json(options))}) + + +@app.post("/login/2fa/webauthn/finish") +async def webauthn_login_finish(request): + """Verify the assertion and issue the session.""" + lib = _webauthn_lib() + if lib is None: + return sanic_json({"status": "error", + "message": "This server has no WebAuthn support " + "installed."}, status=501) + + data = request.json or {} + raw_token = (data.get("token") or "").strip() + credential = data.get("credential") + ip = _client_ip(request) + if not raw_token or not credential: + return sanic_json({"status": "error", + "message": "Both the login token and a credential " + "are required."}, status=400) + + async with userdb_conn() as cnx: + cur = await cnx.cursor() + try: + await _totp_tables(cur) + await _webauthn_tables(cur) + await cur.execute( + "SELECT user_id, username, auth_type FROM totp_pending " + " WHERE token_hash = %s AND expires_at > NOW()", + (totp_store.hash_pending(raw_token),)) + pending = await cur.fetchone() + if not pending: + return sanic_json({ + "status": "error", + "message": "This login has expired. Sign in again.", + }, status=401) + user_id, username, auth_type = pending + + await cur.execute( + "SELECT challenge, rp_id, origin FROM webauthn_challenges " + " WHERE user_id = %s AND purpose = 'login' " + " AND expires_at > NOW() ORDER BY created_at DESC LIMIT 1", + (user_id,)) + row = await cur.fetchone() + if not row: + return sanic_json({ + "status": "error", + "message": "That challenge expired. Try again.", + }, status=400) + challenge, rp_id, origin = row + + raw_id = credential.get("id") if isinstance(credential, dict) else None + await cur.execute( + "SELECT id, public_key, sign_count FROM user_webauthn " + " WHERE user_id = %s AND rp_id = %s AND credential_id = %s", + (user_id, rp_id, raw_id)) + stored = await cur.fetchone() + if not stored: + return sanic_json({ + "status": "error", + "message": "That key is not registered for this account " + "at this address.", + }, status=401) + row_id, public_key, stored_count = stored + + try: + verified = lib["lib"].verify_authentication_response( + credential=credential, + expected_challenge=lib["to_bytes"](challenge), + expected_rp_id=rp_id, + expected_origin=origin, + credential_public_key=lib["to_bytes"](public_key), + credential_current_sign_count=int(stored_count or 0), + ) + except Exception as e: + await cur.execute( + "DELETE FROM webauthn_challenges WHERE challenge = %s", + (challenge,)) + await cnx.commit() + await log_login_attempt(username, auth_type, "failure", + "security key rejected", ip) + return sanic_json({ + "status": "error", + "message": f"That key could not be verified: {e}", + }, status=401) + + # Single use. + await cur.execute("DELETE FROM webauthn_challenges WHERE challenge = %s", + (challenge,)) + + presented = int(verified.new_sign_count or 0) + if webauthn_store.counter_regressed(int(stored_count or 0), presented): + # The one thing the counter is for. A key that has gone + # backwards is a copy of one that has been used more times than + # the copy knows about. + await cur.execute("DELETE FROM totp_pending WHERE token_hash = %s", + (totp_store.hash_pending(raw_token),)) + await cnx.commit() + await log_login_attempt(username, auth_type, "failure", + "security key counter went backwards", ip) + await _platform_event( + "WEBAUTHN_COUNTER_REGRESSED", subject=username, source_ip=ip, + detail=f"a security key presented signature counter " + f"{presented} after {stored_count}; the key has " + f"either been cloned or the response replayed") + return sanic_json({ + "status": "error", + "message": "That key reported a signature counter lower " + "than the last one seen, which means it has " + "been copied. It has not been accepted.", + }, status=401) + + await cur.execute( + "UPDATE user_webauthn SET sign_count = %s, last_used_at = NOW() " + " WHERE id = %s", (presented, row_id)) + await cur.execute("DELETE FROM totp_pending WHERE token_hash = %s", + (totp_store.hash_pending(raw_token),)) + await cur.execute( + "SELECT role, must_change_password, created_at FROM users " + " WHERE id = %s", (user_id,)) + role_row = await cur.fetchone() + await cnx.commit() + finally: + await cur.close() + + return await _finish_second_factor_login( + request, user_id=user_id, username=username, auth_type=auth_type, + role_row=role_row, ip=ip, factor="security key") + + @app.route("/api/platform/events") @require_permission("manage_system") async def get_platform_events(request): diff --git a/certs/generate_certs.py b/certs/generate_certs.py index 524984a..7ec2c49 100644 --- a/certs/generate_certs.py +++ b/certs/generate_certs.py @@ -1,5 +1,6 @@ import argparse +import ipaddress import os from datetime import datetime, timedelta, timezone @@ -9,6 +10,73 @@ from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID +def subject_alt_names(cn: str, extra=None) -> x509.SubjectAlternativeName: + """Every name this certificate is allowed to answer to. + + The list used to be `[DNSName(cn), DNSName("localhost")]` and nothing else, + which has two consequences. + + With the default CN the certificate came out carrying `DNS:localhost` + twice - harmless, and a sign that nothing was deduplicating. + + The real one is that there was no way to add a name at all. A console + reached at `https://192.168.1.26:8000` fails hostname verification in every + client, because an IP address in the CN is not an IP SAN and clients have + not honoured the CN for this since 2000. And WebAuthn cannot be added at + all without a DNS name: its RP ID must be a domain, and browsers reject an + IP address outright, so `https://
` is not a place a security key + can ever be registered. + + Entries that parse as an IP address become `IPAddress` SANs and the rest + become `DNSName`; putting an address in the DNS list produces a + certificate that silently matches nothing. + """ + dns: list[str] = [] + ips: list = [] + + def add(value: str) -> None: + value = (value or "").strip() + if not value: + return + try: + parsed = ipaddress.ip_address(value) + except ValueError: + if value not in dns: + dns.append(value) + else: + if parsed not in ips: + ips.append(parsed) + + add(cn) + for name in (extra or []): + add(name) + # Always reachable from the machine itself, which is where the first + # login and every health check happen. + add("localhost") + add("127.0.0.1") + add("::1") + + return x509.SubjectAlternativeName( + [x509.DNSName(d) for d in dns] + [x509.IPAddress(i) for i in ips] + ) + + +def certificate_names(cert) -> set[str]: + """The names a certificate actually covers, as strings. + + Used to tell an operator that the certificate on disk predates the names + they have configured — see `core.tls`. Without it, changing `TLS_CN` does + nothing at all and nothing says why. + """ + try: + san = cert.extensions.get_extension_for_class( + x509.SubjectAlternativeName).value + except x509.ExtensionNotFound: + return set() + return (set(san.get_values_for_type(x509.DNSName)) + | {str(i) for i in san.get_values_for_type(x509.IPAddress)}) + + def write_pem(path: str, data: bytes): os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "wb") as f: @@ -55,7 +123,7 @@ def generate_root_ca(common_name: str, days: int): return key, cert -def generate_server_cert(cn: str, days: int, ca_key, ca_cert): +def generate_server_cert(cn: str, days: int, ca_key, ca_cert, extra_names=None): key = rsa.generate_private_key(public_exponent=65537, key_size=2048) subject = x509.Name([ @@ -69,10 +137,7 @@ def generate_server_cert(cn: str, days: int, ca_key, ca_cert): # utcnow() produced — just without the call Python deprecated in 3.12. now = datetime.now(timezone.utc).replace(tzinfo=None) - san = x509.SubjectAlternativeName([ - x509.DNSName(cn), - x509.DNSName("localhost"), - ]) + san = subject_alt_names(cn, extra_names) cert = ( x509.CertificateBuilder() @@ -105,7 +170,7 @@ def generate_server_cert(cn: str, days: int, ca_key, ca_cert): def ensure_certs(outdir: str = None, cn: str = "localhost", days: int = 365, - force: bool = False, log=print) -> dict: + force: bool = False, log=print, extra_names=None) -> dict: """Create the CA and server certificate if they are not already there. Returns the paths. Idempotent unless `force`. @@ -148,7 +213,7 @@ def ensure_certs(outdir: str = None, cn: str = "localhost", days: int = 365, )) write_pem(paths["root_crt"], ca_cert.public_bytes(serialization.Encoding.PEM)) - srv_key, srv_cert = generate_server_cert(cn, days, ca_key, ca_cert) + srv_key, srv_cert = generate_server_cert(cn, days, ca_key, ca_cert, extra_names) write_pem(paths["key"], srv_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.TraditionalOpenSSL, @@ -162,7 +227,8 @@ def ensure_certs(outdir: str = None, cn: str = "localhost", days: int = 365, _restrict(paths["root_key"]) _restrict(paths["key"]) - log(f"[+] Generated {paths['crt']} (CN={cn}, {days} days)") + covered = ", ".join(sorted(certificate_names(srv_cert))) + log(f"[+] Generated {paths['crt']} ({days} days), valid for: {covered}") return paths @@ -181,6 +247,11 @@ def _restrict(path: str) -> None: def main(): parser = argparse.ArgumentParser(description="Generate a Root CA and a server certificate (beginner style)") parser.add_argument("--cn", default="localhost", help="Server certificate Common Name (default: localhost)") + parser.add_argument("--san", default="", + help="Extra names the certificate answers to, comma " + "separated. Hostnames and IP addresses both; a " + "browser needs a hostname here before a security " + "key can be registered against this server.") parser.add_argument("--days", type=int, default=365, help="Validity in days (default: 365)") parser.add_argument("--out", default=os.path.dirname(__file__) or ".", help="Output directory (default: certs folder)") parser.add_argument("--force", action="store_true", help="Overwrite existing files if present") @@ -214,7 +285,8 @@ def main(): write_pem(root_crt_path, ca_cert.public_bytes(serialization.Encoding.PEM)) print("[i] Generating server certificate …") - srv_key, srv_cert = generate_server_cert(args.cn, args.days, ca_key, ca_cert) + extra = [n for n in args.san.split(',') if n.strip()] + srv_key, srv_cert = generate_server_cert(args.cn, args.days, ca_key, ca_cert, extra) write_pem( srv_key_path, srv_key.private_bytes( @@ -231,6 +303,7 @@ def main(): ) print("[+] Done.") + print(f" Valid for: {', '.join(sorted(certificate_names(srv_cert)))}") print(f" Root CA: {root_crt_path} (key: {root_key_path})") print(f" Server: {srv_crt_path} (key: {srv_key_path})") print(f" Fullchain: {fullchain_path}") diff --git a/core/self_defence.py b/core/self_defence.py index 6c5b7bc..e6769b2 100644 --- a/core/self_defence.py +++ b/core/self_defence.py @@ -84,6 +84,12 @@ "A wrong password was given when turning off two-factor. A hijacked " "session trying to remove the control that would have stopped it " "looks exactly like this."), + "WEBAUTHN_COUNTER_REGRESSED": ( + "CRITICAL", + "A security key presented a signature counter lower than the last one " + "this server saw. That is what a copy of the key looks like: the copy " + "does not know how many times the original has been used. The only " + "other reading is a replayed response, and neither is benign."), } # `TLS_DOWNGRADE_REFUSED` was defined here and is deliberately gone. diff --git a/core/tls.py b/core/tls.py index 851090b..5598ffb 100644 --- a/core/tls.py +++ b/core/tls.py @@ -152,10 +152,11 @@ def _generate(env, log) -> tuple[str, str]: ) from e cn = (env.get("TLS_CN") or "").strip() or "localhost" + extra = [n.strip() for n in (env.get("TLS_SAN") or "").split(",") if n.strip()] outdir = material_dir(env) try: os.makedirs(outdir, exist_ok=True) - paths = ensure_certs(outdir=outdir, cn=cn, log=log) + paths = ensure_certs(outdir=outdir, cn=cn, log=log, extra_names=extra) except OSError as e: # Named, because the bare errno gives no clue which of the two # plausible causes it is - and they need opposite fixes. @@ -173,9 +174,59 @@ def _generate(env, log) -> tuple[str, str]: "beyond your own network.") log(f"[tls] Agents need {paths['root_crt']} to verify this server; the " f"installer fetches it from /api/agent/ca, or set server_ca by hand.") + _warn_if_names_are_stale(paths["crt"], cn, extra, log) return paths["crt"], paths["key"] +def _warn_if_names_are_stale(cert_path: str, cn: str, extra: list[str], log) -> None: + """Say so when the certificate on disk predates the configured names. + + `ensure_certs` is idempotent, which is right - regenerating on every boot + would hand every restart a new identity and break every agent that had + pinned the CA. The cost is that changing `TLS_CN` or `TLS_SAN` on a + deployment that already has a certificate does *nothing*, and nothing said + so. The operator sets the hostname they intend to use, restarts, and gets + the same certificate for `localhost` with no error anywhere - then spends + the afternoon on the browser warning instead of on the two files that + would have fixed it. + + This is a warning rather than a failure. The server still works on the + names the certificate does carry, and refusing to start because a name was + added would be worse than saying it out loud. + """ + try: + from cryptography import x509 + + from certs.generate_certs import certificate_names + + with open(cert_path, "rb") as fh: + covered = certificate_names(x509.load_pem_x509_certificate(fh.read())) + except Exception as e: + # Not fatal - a certificate that cannot be read back is still being + # served, and refusing to start over a diagnostic would be worse than + # the diagnostic. But say it: a check that silently does nothing is + # indistinguishable from a check that passed, which is the exact + # failure this function exists to report. + log(f"[tls] Could not read {cert_path} back to confirm which names it " + f"covers ({type(e).__name__}: {e}). If the browser reports a name " + f"mismatch, that is why nothing warned about it.") + return + + wanted = {n for n in [cn, *extra] if n} + missing = sorted(wanted - covered) + if not missing: + return + + log(f"[tls] The certificate at {cert_path} does not cover " + f"{', '.join(missing)} - it was generated before those names were " + f"configured, and TLS material is never regenerated on top of itself. " + f"Browsers will report a name mismatch, and WebAuthn cannot be " + f"registered against a name the certificate does not carry. Delete " + f"the files in that directory and restart, or run " + f"`python certs/generate_certs.py --force --cn {cn}" + + (f" --san {','.join(extra)}" if extra else "") + "`.") + + def existing(env=None) -> dict | None: """The certificate pair that is already on disk, without creating one. diff --git a/docs/production-deployment.md b/docs/production-deployment.md index 6d79293..0e71b75 100644 --- a/docs/production-deployment.md +++ b/docs/production-deployment.md @@ -227,7 +227,32 @@ shows it reporting, and nothing reaches it. ```ini # .env TLS_ENABLED=1 -TLS_CN=soc.example.com # the name agents and browsers will use +TLS_CN=soc.example.com # the name agents and browsers use +TLS_SAN=soc.example.com,10.0.0.5 # every other name it answers to +``` + +`TLS_SAN` is comma separated and takes hostnames and addresses alike; anything +that parses as an address becomes an IP SAN rather than a DNS name, because a +bare address in the DNS list matches nothing. `localhost`, `127.0.0.1` and +`::1` are always included. + +Put in it every name anyone will actually type. The old certificate carried +`DNS:localhost` and nothing else, so a console opened at `https://10.0.0.5` +failed hostname verification in every browser on top of the untrusted-CA +warning - two errors that look like one. + +It also decides whether a security key can ever be used here. WebAuthn's RP ID +must be a domain, and browsers reject an IP address outright, so `https://` at +an address is not somewhere a passkey can be registered. That needs a name in +this list, resolvable from the operator's machine. + +Generation is idempotent - a new identity on every restart would break every +agent that pinned the CA - so **changing these values on a deployment that +already has a certificate does nothing by itself**. The startup log says so and +prints the command to regenerate: + +``` +python certs/generate_certs.py --force --cn soc.example.com --san 10.0.0.5 ``` That is enough. On first boot `app` generates a CA and a server certificate @@ -869,6 +894,53 @@ stay valid for their whole step, and without single use anyone who sees one entered has the rest of that step to use it themselves. A second attempt raises `TOTP_CODE_REPLAYED`. +#### Security keys + +A code typed into a convincing copy of this login page works on the real one, +and the operator has no way to tell. That is the case a one-time code cannot +cover and a security key can: the browser signs over the origin it is actually +talking to, so a key registered against `soc.example.com` produces nothing +usable on a lookalike. + +**There is nothing to configure.** The relying-party ID is derived from the +request, so a single-machine install at `https://localhost:8000` and an estate +at `https://soc.example.com` both work without being told anything, and no +setting can disagree with the URL people type. + +The cost of that is that some origins cannot host a key at all, and the console +says which. Open **My Security** and it reports one of: + +| Where the console is open | Security keys | +| :--- | :--- | +| `https://soc.example.com` | available | +| `http://localhost:8000` or `https://localhost:8000` | available — localhost is a secure context by definition | +| `https://10.0.0.5:8000` | **unavailable.** A relying-party ID must be a domain and browsers reject an address outright. Reach the console by a name — see `TLS_SAN` in §3.2. | +| `http://soc.example.com` | **unavailable.** WebAuthn needs a secure context; set `TLS_ENABLED=1`. | +| any, with the `webauthn` package absent | **unavailable.** The import is lazy, so a deployment that pulls new code without reinstalling loses this option rather than the console. | + +One-time codes are unaffected in every one of those rows. A key is an addition, +not a replacement, and recovery codes remain the way back into an account whose +authenticator is gone. + +Two consequences worth knowing before you roll it out: + +- **A credential is bound to the origin it was made at.** A key registered + while the console was at `localhost` is one the browser will not offer at a + hostname. `user_webauthn.rp_id` records it, and the login step offers only + the keys that can work where you are, rather than showing a prompt that times + out. Register keys at the address operators actually use. +- **Whether a login needs a second factor is a property of the account, not of + the origin.** An operator with a key and no authenticator is still challenged + when they open the console at an address — otherwise the origin binding, the + entire point of WebAuthn, would become a way around it. + +A key that presents a signature counter lower than the last one seen is +refused and raises `WEBAUTHN_COUNTER_REGRESSED` (CRITICAL): that is what a +cloned key looks like, since the copy does not know how many times the original +has been used. A counter of zero means the authenticator does not implement one +— true of most platform passkeys and everything that syncs between devices — +and is accepted. + #### What is stored | | | @@ -876,6 +948,8 @@ raises `TOTP_CODE_REPLAYED`. | `user_totp.secret` | The seed, **Fernet-encrypted** with the server key. A dump of `userdb` alone does not hand over everybody's second factor. | | `user_recovery_codes.code_hash` | SHA-256. Never the code. | | `totp_pending.token_hash` | SHA-256 of the half-authenticated token, like a session. | +| `user_webauthn.public_key` | The credential's public key, base64url. Public by construction — it verifies signatures and cannot make them. | +| `webauthn_challenges` | Server-side and single use, expiring in two minutes. A challenge the browser chooses is not a challenge. | The seed column is `VARCHAR(512)` for ciphertext, not for the ~32 characters a base32 seed reads as - see §4.1 for what sizing a column to the human-readable @@ -931,6 +1005,7 @@ every host in the fleet and was the one machine nobody watched. | `ENROLMENT_TOKEN_REUSED` | HIGH | A one-time token presented twice. Usually an installer re-run; the other reading is that it leaked. | | `TOTP_CODE_REPLAYED` | HIGH | A second-factor code already used was presented again. | | `TOTP_DISABLE_REFUSED` | HIGH | A wrong password when turning off two-factor. A hijacked session trying to remove the control that would have stopped it looks exactly like this. | +| `WEBAUTHN_COUNTER_REGRESSED` | CRITICAL | A security key presented a signature counter lower than the last one seen. That is what a copy of the key looks like — the copy does not know how many times the original has been used. The only other reading is a replayed response, and neither is benign. | | `PERMISSION_DENIED` | MEDIUM | An operator reached past their role. One is a mis-click; a run of them is somebody mapping what they can touch. | | `RECOVERY_CODE_USED` | MEDIUM | Signed in with a recovery code rather than an authenticator. | diff --git a/frontend/src/components/ui/index.tsx b/frontend/src/components/ui/index.tsx index d2a8a66..320b5c7 100644 --- a/frontend/src/components/ui/index.tsx +++ b/frontend/src/components/ui/index.tsx @@ -17,6 +17,7 @@ * out cannot be made responsive twenty times. */ import React from 'react'; +import { createPortal } from 'react-dom'; import { AlertTriangle, Inbox, Loader2 } from 'lucide-react'; /* ── Page shell ─────────────────────────────────────────────────────────── */ @@ -320,3 +321,152 @@ export function Cell({ ); } + +/* ── Dialogs ────────────────────────────────────────────────────────────── */ + +/** + * One dialog, not eight. + * + * The overlay-plus-panel markup was written out eight separate times across + * six pages, and had drifted the way independently made decisions do: three + * different corner radii, two different overlay opacities, two different + * widths, and `zIndex: 100` everywhere except where it was 1000. AdminUsers + * alone carried three copies of it, differing only in the fields inside. + * + * Escape closes it and a click on the backdrop closes it. Neither was true + * anywhere before, so the only way out of a dialog was to find the Cancel + * button - and the delete confirmations did not have one. + * + * Rendered through a portal, which is not decoration. `position: fixed` is + * resolved against the nearest ancestor that establishes a containing block - + * any `transform`, `filter`, `backdrop-filter` or `contain` will do it - so a + * dialog written inside a card is positioned against the card rather than the + * viewport and comes out half off-screen. AgentDetail hit exactly that and + * carries a comment about it; putting the portal here means no page has to + * know. + */ +export function Modal({ + title, subtitle, onClose, children, footer, width = 400, +}: { + title: React.ReactNode; + subtitle?: React.ReactNode; + onClose: () => void; + children: React.ReactNode; + footer?: React.ReactNode; + width?: number; +}) { + React.useEffect(() => { + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + return createPortal( +
+
e.stopPropagation()} + className="card" + style={{ + width: '100%', maxWidth: width, + maxHeight: 'calc(100vh - var(--space-7))', + overflowY: 'auto', + }} + > +

+ {title} +

+ {subtitle && ( +

+ {subtitle} +

+ )} +
{children}
+ {footer && ( +
+ {footer} +
+ )} +
+
, + document.body, + ); +} + +/** A label above its control, which is the only arrangement used anywhere. */ +export function Field({ + label, hint, children, +}: { label: string; hint?: string; children: React.ReactNode }) { + return ( + + ); +} + +/** The dialog's own buttons. `tone` picks the confirm colour, so a page never + * has to decide which red means delete. */ +export function DialogButton({ + children, onClick, type = 'button', variant = 'ghost', tone, +}: { + children: React.ReactNode; + onClick?: () => void; + type?: 'button' | 'submit'; + variant?: 'ghost' | 'solid'; + tone?: Tone; +}) { + const accent = tone ? toneColor(tone) : 'var(--accent-secondary)'; + return ( + + ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 87d3342..b80f49f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -275,6 +275,35 @@ button:active { border-color: var(--border-neon); } +/* An icon on its own, as a row action. + * + * Written inline on every table that has one, and each got a different pad and + * a different colour. The disabled state is the part that kept being left out: + * the delete button on the `admin` account is disabled and looked exactly like + * the enabled ones, so it read as broken rather than as protected. */ +.icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px; + border: 1px solid transparent; + border-radius: var(--radius-md); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + line-height: 0; +} + +.icon-btn:hover:not(:disabled) { + border-color: var(--border-color); + color: var(--text-primary); +} + +.icon-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + /* A surface, not an object. * * This used to lift two pixels and glow on hover, over a 12px backdrop blur. diff --git a/frontend/src/lib/webauthn.test.ts b/frontend/src/lib/webauthn.test.ts new file mode 100644 index 0000000..705c468 --- /dev/null +++ b/frontend/src/lib/webauthn.test.ts @@ -0,0 +1,110 @@ +/** + * The base64url conversions, which are the part that fails intermittently. + * + * WebAuthn moves binary through JSON, so every challenge, credential id and + * signature crosses this boundary twice. base64url is not base64 — `-` and `_` + * stand in for `+` and `/`, and the padding is dropped — and `atob` accepts + * neither substitution. + * + * What makes that dangerous rather than merely wrong is the failure rate. The + * two alphabets agree until a byte lands on one of the four differing + * characters, so a naive implementation works on most challenges and throws + * `InvalidCharacterError` on the rest. That is a bug which passes a demo, + * passes review, and then fails for one operator in three with a message + * naming nothing. + * + * Run: + * cd frontend + * npm test + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; + +import { base64urlToBuffer, bufferToBase64url, explain } from './webauthn.ts'; + +const bytes = (buf: ArrayBuffer) => Array.from(new Uint8Array(buf)); + +describe('base64url', () => { + test('a round trip returns the same bytes', () => { + const original = new Uint8Array([0, 1, 2, 250, 251, 252, 253, 254, 255]); + const encoded = bufferToBase64url(original.buffer); + assert.deepEqual(bytes(base64urlToBuffer(encoded)), Array.from(original)); + }); + + test('the output never contains the characters base64url replaces', () => { + // 0xFB 0xFF 0xBF encodes to `+/+/` in standard base64 — the exact bytes + // that separate the two alphabets. + const awkward = new Uint8Array([0xfb, 0xff, 0xbf, 0xfe, 0xff]); + const encoded = bufferToBase64url(awkward.buffer); + assert.ok(!encoded.includes('+'), `+ leaked into ${encoded}`); + assert.ok(!encoded.includes('/'), `/ leaked into ${encoded}`); + assert.ok(!encoded.includes('='), `padding leaked into ${encoded}`); + }); + + test('input using - and _ decodes rather than throwing', () => { + // What the server actually sends. `atob` refuses both characters, so a + // decoder that forgets to translate throws InvalidCharacterError here. + // Four base64 characters carry 24 bits, so this is three bytes. + assert.deepEqual(bytes(base64urlToBuffer('-_-_')), [251, 255, 191]); + }); + + test('unpadded input of every length decodes', () => { + // py_webauthn strips padding, so the decoder has to put it back. One byte + // and two bytes need two and one `=` respectively; getting this wrong + // fails on exactly two thirds of inputs. + for (const [encoded, expected] of [ + ['AQ', [1]], + ['AQI', [1, 2]], + ['AQID', [1, 2, 3]], + ['AQIDBA', [1, 2, 3, 4]], + ] as [string, number[]][]) { + assert.deepEqual(bytes(base64urlToBuffer(encoded)), expected, encoded); + } + }); + + test('a 32-byte challenge survives, which is what every ceremony sends', () => { + const challenge = new Uint8Array(32); + for (let i = 0; i < 32; i += 1) challenge[i] = (i * 37) % 256; + const encoded = bufferToBase64url(challenge.buffer); + assert.equal(encoded.length, 43, 'unpadded base64url of 32 bytes is 43 chars'); + assert.deepEqual(bytes(base64urlToBuffer(encoded)), Array.from(challenge)); + }); + + test('a large attestation does not overflow the stack', () => { + // Not `String.fromCharCode(...bytes)`. Spreading a big attestation into an + // argument list throws RangeError on some browsers, and an attestation is + // comfortably big enough to do it. + const big = new Uint8Array(200_000).fill(65); + const encoded = bufferToBase64url(big.buffer); + assert.equal(base64urlToBuffer(encoded).byteLength, big.length); + }); + + test('empty input is empty output, not a throw', () => { + assert.equal(bufferToBase64url(new Uint8Array([]).buffer), ''); + assert.equal(base64urlToBuffer('').byteLength, 0); + }); +}); + +describe('explain', () => { + test('a cancelled prompt says what to do', () => { + const said = explain({ name: 'NotAllowedError' }); + assert.match(said, /cancelled|timed out/i); + }); + + test('a duplicate registration is named as one', () => { + // Otherwise "NotAllowedError" and "you already registered this key" are + // the same blank to the operator. + assert.match(explain({ name: 'InvalidStateError' }), /already registered/i); + }); + + test('a refused origin points at the actual cause', () => { + const said = explain({ name: 'SecurityError' }); + assert.match(said, /hostname|IP address/i); + }); + + test('an unknown error still returns something sayable', () => { + assert.ok(explain({}).length > 0); + assert.ok(explain(null).length > 0); + }); +}); diff --git a/frontend/src/lib/webauthn.ts b/frontend/src/lib/webauthn.ts new file mode 100644 index 0000000..3eecc04 --- /dev/null +++ b/frontend/src/lib/webauthn.ts @@ -0,0 +1,127 @@ +/** + * The browser half of a WebAuthn ceremony. + * + * All of this is type conversion, and it is here rather than in a page because + * getting it wrong fails in a way that names nothing. `navigator.credentials` + * wants `ArrayBuffer` for the challenge and every credential id; the server + * speaks JSON and therefore base64url. Hand a string where a buffer is + * expected and Chrome throws `TypeError: Failed to execute 'create' on + * 'CredentialsContainer'`, which says neither which field nor why. + * + * base64url is not base64. `-` and `_` stand in for `+` and `/`, and the + * padding is dropped. `atob` accepts neither substitution, so decoding without + * translating first produces `InvalidCharacterError` on roughly one challenge + * in three - the two alphabets agree until a byte happens to land on one of + * those characters, which is exactly the kind of bug that passes a demo and + * fails in the field. + */ + +export function base64urlToBuffer(value: string): ArrayBuffer { + const padded = value.replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(padded + '='.repeat((4 - (padded.length % 4)) % 4)); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i); + return bytes.buffer; +} + +export function bufferToBase64url(value: ArrayBuffer): string { + const bytes = new Uint8Array(value); + let binary = ''; + // Not `String.fromCharCode(...bytes)`. Spreading a large attestation object + // into an argument list overflows the call stack on some browsers, and an + // attestation is comfortably big enough to do it. + for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i]); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** Is the API even present? False on plain http and in older browsers. */ +export function supported(): boolean { + return typeof window !== 'undefined' + && !!window.PublicKeyCredential + && !!navigator.credentials; +} + +/** Register a new credential from the server's options. */ +export async function createCredential(options: any) { + const publicKey: any = { + ...options, + challenge: base64urlToBuffer(options.challenge), + user: { ...options.user, id: base64urlToBuffer(options.user.id) }, + excludeCredentials: (options.excludeCredentials || []).map((c: any) => ({ + ...c, id: base64urlToBuffer(c.id), + })), + }; + + const credential = await navigator.credentials.create({ publicKey }) as PublicKeyCredential; + if (!credential) throw new Error('The browser returned no credential.'); + const response = credential.response as AuthenticatorAttestationResponse; + + return { + id: credential.id, + rawId: bufferToBase64url(credential.rawId), + type: credential.type, + // Reported by the authenticator and stored, so the console can say "USB + // security key" rather than "Security key" in the list. + transports: typeof response.getTransports === 'function' ? response.getTransports() : [], + response: { + clientDataJSON: bufferToBase64url(response.clientDataJSON), + attestationObject: bufferToBase64url(response.attestationObject), + }, + }; +} + +/** Assert an existing credential from the server's options. */ +export async function getAssertion(options: any) { + const publicKey: any = { + ...options, + challenge: base64urlToBuffer(options.challenge), + allowCredentials: (options.allowCredentials || []).map((c: any) => ({ + ...c, id: base64urlToBuffer(c.id), + })), + }; + + const credential = await navigator.credentials.get({ publicKey }) as PublicKeyCredential; + if (!credential) throw new Error('The browser returned no assertion.'); + const response = credential.response as AuthenticatorAssertionResponse; + + return { + id: credential.id, + rawId: bufferToBase64url(credential.rawId), + type: credential.type, + response: { + clientDataJSON: bufferToBase64url(response.clientDataJSON), + authenticatorData: bufferToBase64url(response.authenticatorData), + signature: bufferToBase64url(response.signature), + userHandle: response.userHandle ? bufferToBase64url(response.userHandle) : null, + }, + }; +} + +/** + * What went wrong, in words an operator can act on. + * + * The browser's own messages are deliberately vague — telling a page exactly + * why a ceremony failed is a fingerprinting surface — so the useful signal is + * the error *name*, and the raw `message` is usually empty or generic. + */ +export function explain(err: any): string { + const name = err?.name || ''; + if (name === 'NotAllowedError') { + return 'The request was cancelled or timed out. Touch the key when it ' + + 'lights up, or try again.'; + } + if (name === 'InvalidStateError') { + return 'That key is already registered on this account.'; + } + if (name === 'SecurityError') { + return 'The browser refused this origin. A security key needs https and a ' + + 'hostname — an IP address cannot be used.'; + } + if (name === 'NotSupportedError') { + return 'This authenticator does not support what the server asked for.'; + } + if (name === 'AbortError') { + return 'The request was aborted.'; + } + return err?.message || 'The security key could not be used.'; +} diff --git a/frontend/src/lib/windowsEvent.ts b/frontend/src/lib/windowsEvent.ts index 248afc7..c89bf89 100644 --- a/frontend/src/lib/windowsEvent.ts +++ b/frontend/src/lib/windowsEvent.ts @@ -70,26 +70,50 @@ export function parseEventMessage(raw: string): ParsedEvent { let seenField = false; for (const line of lines) { - const match = line.match(FIELD_LINE); - if (match) { - seenField = true; - const key = match[1]; - const value = match[2].trim(); - // Windows emits a run of empty keys on most events - PipelineId, - // CommandName, CommandType, CommandPath. They are noise in every one. - if (value) fields.push({ key, value }); - continue; + // Split on tabs before matching. `FIELD_LINE` is anchored at the start of + // a line, and the agent builds its message as `summary | inserts` on one + // line - so the first field arrives *after* the headline on the same line, + // separated by the tab Windows puts in front of it: + // + // [PowerShell] EID=403, Cat=4 | Stopped | Available | \tNewEngineState=Stopped + // + // Matching per line dropped that field on every event of this shape, and + // it is the field that says what actually happened. The rest of the + // inserts are one per line and are unaffected. + for (const segment of line.split('\t')) { + const match = segment.match(FIELD_LINE); + if (match) { + seenField = true; + const key = match[1]; + const value = match[2].trim(); + // Windows emits a run of empty keys on most events - PipelineId, + // CommandName, CommandType, CommandPath. They are noise in every one. + if (value) fields.push({ key, value }); + continue; + } + if (!seenField) headlineParts.push(segment); } - if (!seenField) headlineParts.push(line); } // The first line often ends with the same values repeated as ` | a | b | ` // before the fields begin. Trim the trailing separators, keep the text. - const headline = headlineParts - .join(' ') - .replace(/\s+/g, ' ') - .replace(/(\s*\|\s*)+$/, '') - .trim(); + // + // Trimmed with a loop rather than a regex, and deliberately. + // + // This was `(\s*\|\s*)+$`: a quantifier inside a quantifier with the + // whitespace ambiguous between adjacent repetitions, which backtracks + // exponentially on a line ending in a long run of spaces. `[\s|]+$` fixes + // that and is still super-linear, because an anchored trailing match makes + // the engine retry from every position when it fails. JavaScript has no + // possessive quantifiers to say "do not backtrack", so the honest answer is + // not to ask a regex for something a scan does in one pass - over text an + // attacker writes into a log, running in the operator's browser. + const collapsed = headlineParts.join(' ').replace(/\s+/g, ' '); + let end = collapsed.length; + while (end > 0 && (collapsed[end - 1] === '|' || collapsed[end - 1] === ' ')) { + end -= 1; + } + const headline = collapsed.slice(0, end).trim(); return { headline, fields, structured: seenField }; } diff --git a/frontend/src/pages/AIAnalysis.tsx b/frontend/src/pages/AIAnalysis.tsx index e6846ec..7706951 100644 --- a/frontend/src/pages/AIAnalysis.tsx +++ b/frontend/src/pages/AIAnalysis.tsx @@ -1,8 +1,39 @@ -import React, { useState, useEffect } from 'react'; -import { BrainCircuit, Play, Clock, Search, ShieldAlert, Cpu, RefreshCw, Layers } from 'lucide-react'; +/** + * What the triage model has said about this estate, and what it could not say. + * + * Two things were wrong here beyond the styling. + * + * The header carried a green pill reading "RabbitMQ Worker Active". It was + * hardcoded - a `
` with a green dot, rendered unconditionally, with no + * request behind it. It said the worker was up while the worker was down, + * which on a security console is worse than saying nothing: a health light + * that is always green is a health light nobody can use. It is gone. The + * defensive scan below reports what actually happened, including the case + * where the worker never answers. + * + * And `fetchGlobalInsights` caught its own failure, logged it, and set the + * list to empty - so a failed request rendered "No insights found. System is + * monitoring." That is a claim, and it was false exactly when it mattered. + * + * The filtering logic is unchanged. It was already careful about the + * difference between "no critical findings" and "no verdict recorded", and + * that distinction is the point of the page. + */ +import React, { useCallback, useEffect, useState } from 'react'; +import { + BrainCircuit, Play, Clock, Search, ShieldAlert, Cpu, RefreshCw, Layers, +} from 'lucide-react'; import api, { agentService } from '../services/api'; import { InsightCard } from './AgentDetail'; import { isUnanswered, countUnanswered } from '../lib/insightTriage'; +import { + PageHeader, Card, Field, EmptyState, ErrorState, LoadingState, +} from '../components/ui'; + +const VERDICTS = [ + 'CRITICAL', 'SUSPICIOUS', 'ACT', 'MONITOR', + 'NOT_CRITICAL', 'IGNORE', 'INSUFFICIENT_DATA', +]; const AIAnalysis: React.FC = () => { const [agents, setAgents] = useState([]); @@ -10,68 +41,72 @@ const AIAnalysis: React.FC = () => { const [agentFilter, setAgentFilter] = useState('all'); const [insights, setInsights] = useState([]); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); const [analyzing, setAnalyzing] = useState(false); const [filter, setFilter] = useState(''); const [verdictFilter, setVerdictFilter] = useState('all'); const [minConfidence, setMinConfidence] = useState(0); const [refreshTick, setRefreshTick] = useState(0); + const [scanning, setScanning] = useState(false); + const [scanStatus, setScanStatus] = useState(''); - useEffect(() => { - fetchGlobalInsights(); - agentService.getAgents().then(list => { - setAgents(list); - if (list.length > 0) { - const first = typeof list[0] === 'string' ? list[0] : list[0].name; - setSelectedAgent(first); - } - }); - - const interval = setInterval(() => setRefreshTick(t => t + 1), 15000); - return () => clearInterval(interval); - }, []); - - useEffect(() => { - fetchGlobalInsights(); - }, [refreshTick]); + const say = (err: any, fallback: string) => + err?.response?.data?.error || err?.response?.data?.message || err?.message || fallback; - const fetchGlobalInsights = async () => { + const fetchGlobalInsights = useCallback(async () => { try { const res = await api.get('/api/ai-insights/all'); if (res.data?.success) { setInsights(Array.isArray(res.data.results) ? res.data.results : []); + setError(null); } else { - setInsights([]); + // A successful HTTP call that reports failure is still a failure, and + // it used to land in the same empty list as "there is nothing". + setError(res.data?.error || 'The insights endpoint reported failure'); } } catch (err) { - console.error('AI Insights fetch error', err); - setInsights([]); + setError(say(err, 'Could not load insights')); } finally { setLoading(false); } - }; + }, []); + + useEffect(() => { + fetchGlobalInsights(); + agentService.getAgents() + .then((list: any[]) => { + setAgents(list); + const first = list.length + ? (typeof list[0] === 'string' ? list[0] : list[0].name) + : ''; + setSelectedAgent((current) => current || first); + }) + .catch(() => { /* the insight list carries its own error; agents are secondary */ }); + + const interval = setInterval(() => setRefreshTick((t) => t + 1), 15000); + return () => clearInterval(interval); + }, [fetchGlobalInsights]); + + useEffect(() => { + if (refreshTick) fetchGlobalInsights(); + }, [refreshTick, fetchGlobalInsights]); const runManualAnalysis = () => { if (!selectedAgent) return; setAnalyzing(true); agentService.runManualAnalysis(selectedAgent, 100) .then((res) => { - alert(res.message || 'Manual analysis queued in background.'); + setScanStatus(res.message || 'Manual analysis queued.'); fetchGlobalInsights(); }) - .catch(err => { - console.error('Manual analysis error', err); - alert('Failed to start manual analysis.'); - }) + .catch((err) => setScanStatus(say(err, 'Failed to start manual analysis'))) .finally(() => setAnalyzing(false)); }; - const [scanning, setScanning] = useState(false); - const [scanStatus, setScanStatus] = useState(''); - const triggerDefensiveScan = async () => { if (!selectedAgent) return; setScanning(true); - setScanStatus('Pushing alerts to defensive AI queue...'); + setScanStatus('Pushing alerts to the defensive queue…'); try { // Small batch by default — local Ollama models take 20-60s per event; // pushing 25 at once means the user waits 10+ minutes for the worker @@ -82,21 +117,20 @@ const AIAnalysis: React.FC = () => { const reason = res.data?.reason || ''; if (!queued) { setScanStatus(reason - ? `No alerts queued (${reason}). Nothing to analyze for ${selectedAgent}.` - : `No alerts found for ${selectedAgent}. The agent must produce events_alert rows first.`); + ? `Nothing queued (${reason}).` + : `No alerts for ${selectedAgent}. The agent has to produce events_alert rows first.`); setScanning(false); return; } const baselineSnap = (await api.get('/api/ai-insights/all')).data?.results || []; const baselineForAgent = baselineSnap.filter((x: any) => x.agent === selectedAgent).length; - setScanStatus(`Queued ${queued}/${total} alerts. Local model processes ~1 every 20-60s — polling...`); + setScanStatus(`Queued ${queued}/${total}. The local model answers roughly one every 20-60s — polling.`); const t0 = Date.now(); - // Poll for up to 8 minutes — each event can take a full minute on CPU. - const maxIters = 48; + const maxIters = 48; // 8 minutes; each event can take a full minute on CPU let producedTotal = 0; for (let i = 0; i < maxIters; i++) { - await new Promise(r => setTimeout(r, 10000)); + await new Promise((r) => setTimeout(r, 10000)); const fresh = (await api.get('/api/ai-insights/all')).data?.results || []; setInsights(fresh); const agentFresh = fresh.filter((x: any) => x.agent === selectedAgent); @@ -104,32 +138,33 @@ const AIAnalysis: React.FC = () => { producedTotal = producedNow; const elapsed = Math.round((Date.now() - t0) / 1000); if (producedNow >= queued) { - setScanStatus(`✓ Done. AI produced ${producedNow} new insight(s) for ${selectedAgent} in ${elapsed}s.`); + setScanStatus(`Done — ${producedNow} new insight(s) for ${selectedAgent} in ${elapsed}s.`); break; } setScanStatus( - `Progress: ${producedNow}/${queued} insights produced · ${elapsed}s elapsed` + - (producedNow > 0 ? ` · last at ${agentFresh[0]?.created_at || 'just now'}` : ' · still waiting for first response') + `${producedNow}/${queued} produced · ${elapsed}s elapsed` + + (producedNow > 0 + ? ` · last at ${agentFresh[0]?.created_at || 'just now'}` + : ' · still waiting for the first answer'), ); } if (producedTotal === 0) { setScanStatus( - `Timed out after 8 minutes with 0 new insights. ` + - `Check: docker logs sentora-ai-worker-defensive — the worker may be stuck on Ollama, ` + - `or the model isn't installed (docker exec sentora-ollama ollama list).` + 'Timed out after 8 minutes with nothing produced. The worker is not answering: ' + + 'check `docker logs sentora-ai-worker-defensive`, and that the model is installed ' + + '(`docker exec sentora-ollama ollama list`).', ); } setScanning(false); - } catch (err: any) { - console.error('Defensive scan failed', err); - setScanStatus(`Failed: ${err?.response?.data?.error || err?.message || 'unknown error'}`); + } catch (err) { + setScanStatus(say(err, 'Defensive scan failed')); setScanning(false); } }; - // Build the dropdown list from BOTH the live agents list AND every agent - // that has ever produced an insight — so the dropdown isn't empty just - // because no insight has been emitted yet for a newly-connected agent. + // Both the live agent list AND every agent that has produced an insight, so + // the dropdown is not empty just because a newly connected agent has not + // been analysed yet. const agentList = React.useMemo(() => { const set = new Set(); for (const i of insights) if (i.agent) set.add(String(i.agent)); @@ -142,16 +177,15 @@ const AIAnalysis: React.FC = () => { return Array.from(set).sort((a, b) => a.localeCompare(b)); }, [insights, agents]); - // Verdict and confidence are columns now, so this is a real filter rather - // than a substring search over a rendered line. Rows written before those - // columns exist carry NULL and are excluded once a verdict filter is on — - // showing them would imply a verdict nobody recorded. - const filteredInsights = insights.filter(i => { + // Verdict and confidence are columns, so these are real filters rather than + // a substring search over a rendered line. Rows written before those columns + // existed carry NULL and are excluded once a verdict filter is on — showing + // them would imply a verdict nobody recorded. + const filteredInsights = insights.filter((i) => { // Rows the model could not answer on are not findings about a host: it // contradicted itself, returned nothing parseable, or the event never // reached it because it would not decrypt. They stay recorded and stay // counted below, but they do not sit in the feed burying real ones. - // Asking for them by verdict still shows them. if (verdictFilter !== 'INSUFFICIENT_DATA' && isUnanswered(i)) return false; if (agentFilter !== 'all' && i.agent !== agentFilter) return false; if (verdictFilter !== 'all' && (i.verdict || '') !== verdictFilter) return false; @@ -163,308 +197,236 @@ const AIAnalysis: React.FC = () => { || (i.source_file || '').toLowerCase().includes(needle); }); - // How many rows the verdict filter simply cannot speak for. Stating this is - // the difference between "no CRITICAL findings" and "no CRITICAL findings - // among the rows that carry a verdict". - const legacyRows = insights.filter(i => i.verdict == null).length; + // How many rows the verdict filter cannot speak for. Stating this is the + // difference between "no CRITICAL findings" and "no CRITICAL findings among + // the rows that carry a verdict". + const legacyRows = insights.filter((i) => i.verdict == null).length; const unansweredRows = countUnanswered(insights); - return ( -
-
-
-

- - AI Security Pulse -

-

- Continuous real-time log triage powered by Ollama Local AI. -

-
-
- -
-
- RabbitMQ Worker Active + const feed = () => { + if (loading) return ; + if (error) { + return ( + + ); + } + if (filteredInsights.length === 0) { + return ( + } + /> + ); + } + return ( +
+ {filteredInsights.map((insight, idx) => ( +
+
+ + {insight.agent || 'unknown'} + + + {insight.created_at ? new Date(insight.created_at).toLocaleString() : ''} + +
+
-
+ ))}
+ ); + }; -
+ return ( +
+ } + actions={ + + } + /> - {/* Main Insights Feed */} -
-
-
- +
+
+
+
+ setFilter(e.target.value)} - style={{ - width: '100%', - backgroundColor: 'var(--card-bg)', - border: '1px solid var(--border-color)', - borderRadius: '12px', - padding: '14px 14px 14px 48px', - color: 'var(--text-primary)', - fontSize: '0.9375rem', - outline: 'none', - }} + style={{ width: '100%', paddingLeft: 34 }} />
- setAgentFilter(e.target.value)}> + + {agentList.map((a) => ( ))} - - {/* Verdict and confidence are stored columns, so these filter on - what the model actually decided rather than on text that - happens to appear in the rendered summary. */} - setVerdictFilter(e.target.value)}> - {['CRITICAL', 'SUSPICIOUS', 'ACT', 'MONITOR', 'NOT_CRITICAL', 'IGNORE', 'INSUFFICIENT_DATA'].map(v => { - const n = insights.filter(i => i.verdict === v).length; - return ; - })} + {VERDICTS.map((v) => ( + + ))} -
{/* A filter that silently drops rows it cannot evaluate would let "no CRITICAL findings" stand in for "no verdict recorded". */} {(verdictFilter !== 'all' || minConfidence > 0) && legacyRows > 0 && ( -

- {legacyRows} older insight{legacyRows === 1 ? '' : 's'} carry no verdict column and - are not included in this filter. +

+ {legacyRows} older insight{legacyRows === 1 ? '' : 's'} carry no verdict column + and are not included in this filter.

)} {/* Hidden as rows, still visible as a number — and reachable, so nobody has to guess whether the model is answering at all. */} {verdictFilter !== 'INSUFFICIENT_DATA' && unansweredRows > 0 && ( -

- {unansweredRows} event{unansweredRows === 1 ? '' : 's'} produced no usable - verdict and {unansweredRows === 1 ? 'is' : 'are'} not shown —{' '} +

+ {unansweredRows} event{unansweredRows === 1 ? '' : 's'} produced no usable verdict + and {unansweredRows === 1 ? 'is' : 'are'} not shown —{' '} .

)} -
- {loading ? ( -
- -

Loading AI Intelligence...

-
- ) : filteredInsights.length > 0 ? ( - filteredInsights.map((insight, idx) => ( -
-
- - {insight.agent || 'unknown'} - - {insight.created_at ? new Date(insight.created_at).toLocaleString() : ''} - -
- -
- )) - ) : ( -
- -

No Insights Found

-

System is monitoring. Automatic analysis will appear here.

-

- Use "Force Defensive Scan" on the right to push existing alerts through the AI. -

-
- )} -
+ {feed()}
- {/* Sidebar Controls */} -
-
-

- Manual Scanner -

-

- Force a manual scan on a specific agent to backfill missing analysis. -

- - - - - +
+ Run analysis now}> +
+ + + - + - {scanStatus && ( -
- {scanStatus} -
- )} -
+ {scanning + ? + : } + {scanning ? 'Scanning…' : 'Force defensive scan'} + -
-

System Stats

-
-
- Total Insights - {insights.length} -
-
- After Filter - {filteredInsights.length} -
-
- Agents Reporting - {agentList.length} -
-
- Connected Agents - {agents.length} -
+ {scanStatus && ( +

+ {scanStatus} +

+ )}
-
+
+ + +
+ {[ + ['Insights held', insights.length], + ['Shown by these filters', filteredInsights.length], + ['No usable verdict', unansweredRows], + ['Agents with insights', agentList.length], + ['Agents connected', agents.length], + ].map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+
diff --git a/frontend/src/pages/AccountSecurity.tsx b/frontend/src/pages/AccountSecurity.tsx index 78c29f1..ffb4e9c 100644 --- a/frontend/src/pages/AccountSecurity.tsx +++ b/frontend/src/pages/AccountSecurity.tsx @@ -1,8 +1,12 @@ -import React, { useEffect, useState } from 'react'; -import { ShieldCheck, ShieldOff, KeyRound, Copy, Check, AlertTriangle } from 'lucide-react'; +import React, { useCallback, useEffect, useState } from 'react'; +import { + ShieldCheck, ShieldOff, KeyRound, Copy, Check, AlertTriangle, + Usb, Trash2, Info, +} from 'lucide-react'; import { QRCodeSVG } from 'qrcode.react'; import { authService } from '../services/api'; -import { Card, PageHeader } from '../components/ui'; +import { Card, PageHeader, Badge, EmptyState, LoadingState } from '../components/ui'; +import * as webauthn from '../lib/webauthn'; /** Every operator can reach this, whatever their role. * @@ -38,8 +42,58 @@ const AccountSecurity: React.FC = () => { const [password, setPassword] = useState(''); + // Security keys. `capability` is the server's answer to "can a key be used + // from where this browser is standing", which is not a question the page can + // answer for itself — see security/webauthn.py. + const [capability, setCapability] = useState(null); + const [keyName, setKeyName] = useState(''); + const [keyBusy, setKeyBusy] = useState(false); + const [keyNotice, setKeyNotice] = useState(''); + const refresh = () => authService.twoFactorStatus().then(setStatus).catch(() => setStatus(null)); - useEffect(() => { refresh(); }, []); + + const refreshKeys = useCallback(() => { + authService.webauthnCapability() + .then(setCapability) + .catch(() => setCapability({ + available: false, + reason: 'The server did not answer when asked about security keys.', + credentials: [], + })); + }, []); + + useEffect(() => { refresh(); refreshKeys(); }, [refreshKeys]); + + const addKey = async () => { + setError(''); setKeyNotice(''); setKeyBusy(true); + try { + const options = await authService.webauthnRegisterBegin(); + const credential = await webauthn.createCredential(options); + await authService.webauthnRegisterFinish(credential, keyName); + setKeyName(''); + setKeyNotice('Security key registered.'); + refreshKeys(); + refresh(); + } catch (e: any) { + // `explain` covers the browser's side: its own message is usually empty, + // because saying exactly why a ceremony failed is a fingerprinting + // surface, so the useful signal is the error name. + setError(e?.response?.data?.message || webauthn.explain(e)); + } finally { setKeyBusy(false); } + }; + + const removeKey = async (id: number, label: string) => { + if (!window.confirm(`Remove ${label}? You will not be able to sign in with it.`)) return; + setError(''); setKeyNotice(''); setKeyBusy(true); + try { + await authService.webauthnRemove(id); + setKeyNotice('Security key removed.'); + refreshKeys(); + refresh(); + } catch (e: any) { + setError(e?.response?.data?.message || 'Could not remove that key.'); + } finally { setKeyBusy(false); } + }; const begin = async () => { setError(''); setBusy(true); @@ -282,6 +336,113 @@ const AccountSecurity: React.FC = () => { )} + + +

+ A one-time code typed into a convincing copy of this login page works + on the real one. A security key does not: the browser signs over the + address it is actually talking to, so a key registered here produces + nothing usable anywhere else. +

+ + {capability === null && } + + {capability && !capability.available && ( +
+ +
+ {/* The reason, not a disabled button. Every one of these is a + dead end the operator would otherwise meet as a browser error + naming neither the cause nor the fix. */} +
{capability.reason}
+
+ One-time codes above are unaffected. +
+
+
+ )} + + {capability?.available && ( + <> + {!!keyNotice && ( +

{keyNotice}

+ )} + + {capability.credentials.length === 0 ? ( + } + /> + ) : ( +
+ {capability.credentials.map((c: any) => ( +
+
+
{c.label}
+
+ Added {c.created_at || 'recently'} + {c.last_used_at ? ` · last used ${c.last_used_at}` : ' · never used'} +
+
+ +
+ ))} +
+ )} + +
+ setKeyName(e.target.value)} + placeholder="Name this key (optional)" + maxLength={64} + style={{ width: 240 }} + /> + + {capability.rp_id} +
+ +

+ A key is an addition, not a replacement. Keep your recovery codes: + they are what gets you back in if the key is lost, and turning + two-factor off still needs your password. +

+ + )} +
); }; diff --git a/frontend/src/pages/AdminConfig.tsx b/frontend/src/pages/AdminConfig.tsx index e341020..7358798 100644 --- a/frontend/src/pages/AdminConfig.tsx +++ b/frontend/src/pages/AdminConfig.tsx @@ -1,73 +1,54 @@ -import React, { useState, useEffect } from 'react'; -import { Mail, BrainCircuit, Save, ShieldCheck, Share2, Globe, RefreshCw } from 'lucide-react'; +/** + * The settings the platform runs on: mail, the model, and the directory. + * + * `fetchConfigs` logged its failure to the browser console and returned, so a + * load that failed left every field at its blank default. On this page that is + * not merely confusing - the forms are editable and they save. An operator who + * reads blank fields as "nothing is configured", fills them in and presses + * Save has just overwritten a working SMTP or LDAP configuration with whatever + * they guessed. The page now says it could not read the settings and does not + * pretend they are empty. + * + * `InputGroup` was this page's own copy of a label-above-a-control, complete + * with hand-rolled focus and blur handlers that duplicated the `input:focus` + * rule in index.css. It is the shared `Field` now. + */ +import React, { useCallback, useEffect, useState } from 'react'; +import { Mail, BrainCircuit, Save, ShieldCheck, Share2, Globe, Trash2 } from 'lucide-react'; import { adminService } from '../services/api'; +import { + PageHeader, Card, Badge, Field, ErrorState, LoadingState, EmptyState, +} from '../components/ui'; + +type EmailTemplate = { + id?: number; + template_name: string; + subject_template: string; + body_template: string; +}; const AdminConfig: React.FC = () => { const [emailConfig, setEmailConfig] = useState({ - smtp_server: '', - smtp_port: 587, - smtp_user: '', - smtp_password: '', - smtp_use_tls: true, - email_from: '', - email_to: '', - enabled: false + smtp_server: '', smtp_port: 587, smtp_user: '', smtp_password: '', + smtp_use_tls: true, email_from: '', email_to: '', enabled: false, }); const [aiConfig, setAiConfig] = useState({ - model_name: 'llama3.2:3b', - api_key: 'ollama', - endpoint: 'http://ollama:11434/api' + model_name: 'llama3.2:3b', api_key: 'ollama', endpoint: 'http://ollama:11434/api', }); const [ldapConfig, setLdapConfig] = useState({ - ldap_host: '', - ldap_port: 389, - users_base: '', - group_base: '', - bind_dn: '', - bind_password: '', - login_filter: '(uid=%s)' + ldap_host: '', ldap_port: 389, users_base: '', group_base: '', + bind_dn: '', bind_password: '', login_filter: '(uid=%s)', }); - const [loading, setLoading] = useState(false); - - type EmailTemplate = { - id?: number; - template_name: string; - subject_template: string; - body_template: string; - }; const [templates, setTemplates] = useState([]); const [editingTemplate, setEditingTemplate] = useState(null); const [templateDefaultName, setTemplateDefaultName] = useState('Critical Alerts (default)'); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); - useEffect(() => { - fetchConfigs(); - }, []); - - const handleSaveTemplate = async () => { - if (!editingTemplate) return; - try { - const res = await adminService.saveEmailTemplate(editingTemplate); - alert(res.message || 'Template saved.'); - setEditingTemplate(null); - fetchConfigs(); - } catch (err: any) { - alert(err?.response?.data?.message || 'Failed to save template.'); - } - }; - - const handleDeleteTemplate = async (t: EmailTemplate) => { - if (!t.id) return; - if (!window.confirm(`Delete template "${t.template_name}"?`)) return; - try { - const res = await adminService.deleteEmailTemplate(t.id); - alert(res.message || 'Template deleted.'); - fetchConfigs(); - } catch (err: any) { - alert(err?.response?.data?.message || 'Failed to delete template.'); - } - }; + const say = (err: any, fallback: string) => + err?.response?.data?.message || err?.response?.data?.error || err?.message || fallback; - const fetchConfigs = async () => { + const fetchConfigs = useCallback(async () => { setLoading(true); try { const [email, ai, ldap, tmpl] = await Promise.all([ @@ -80,317 +61,428 @@ const AdminConfig: React.FC = () => { setTemplates(tmpl.templates || []); if (tmpl.default_name) setTemplateDefaultName(tmpl.default_name); } - if (email) { setEmailConfig({ ...email, email_from: email.email_from || email.from_addr || '', - email_to: email.email_to || email.to_addr || '' + email_to: email.email_to || email.to_addr || '', }); } if (ai) setAiConfig(ai); - if (ldap && ldap.config) setLdapConfig(ldap.config); + if (ldap?.config) setLdapConfig(ldap.config); + setError(null); } catch (err) { - console.error("Failed to fetch configs", err); + setError(say(err, 'Could not read the current configuration')); } finally { setLoading(false); } - }; + }, []); + + useEffect(() => { fetchConfigs(); }, [fetchConfigs]); const handleSaveEmail = async () => { try { - // Backend expects email_from, email_to - const payload = { - ...emailConfig, - email_from: emailConfig.email_from, - email_to: emailConfig.email_to - }; - await adminService.saveEmailConfig(payload); - alert("Email configuration saved!"); + await adminService.saveEmailConfig(emailConfig); + alert('Email configuration saved.'); fetchConfigs(); - } catch (err: any) { - alert("Failed to save email configuration: " + (err.response?.data?.message || err.message)); + } catch (err) { + alert(say(err, 'Failed to save email configuration')); } }; const handleSaveAi = async () => { try { await adminService.updateAiConfig('server', aiConfig); - alert("AI configuration saved!"); + alert('AI configuration saved.'); fetchConfigs(); - } catch (err: any) { - alert("Failed to save AI configuration: " + (err.response?.data?.error || err.message)); + } catch (err) { + alert(say(err, 'Failed to save AI configuration')); } }; const handleSaveLdap = async () => { try { await adminService.saveLdapConfig(ldapConfig); - alert("LDAP configuration saved!"); + alert('LDAP configuration saved.'); fetchConfigs(); } catch (err) { - alert("Failed to save LDAP configuration"); + alert(say(err, 'Failed to save LDAP configuration')); } }; const handleTestLdap = async () => { try { const res = await adminService.testLdap(ldapConfig); - alert(res.message || "LDAP connection successful!"); - } catch (err: any) { - alert(err.response?.data?.message || "LDAP connection failed"); + alert(res.message || 'LDAP connection succeeded.'); + } catch (err) { + alert(say(err, 'LDAP connection failed')); + } + }; + + const handleSaveTemplate = async () => { + if (!editingTemplate) return; + try { + const res = await adminService.saveEmailTemplate(editingTemplate); + alert(res.message || 'Template saved.'); + setEditingTemplate(null); + fetchConfigs(); + } catch (err) { + alert(say(err, 'Failed to save template')); } }; - if (loading && !emailConfig.smtp_server) { + const handleDeleteTemplate = async (t: EmailTemplate) => { + if (!t.id) return; + if (!window.confirm(`Delete template "${t.template_name}"?`)) return; + try { + const res = await adminService.deleteEmailTemplate(t.id); + alert(res.message || 'Template deleted.'); + fetchConfigs(); + } catch (err) { + alert(say(err, 'Failed to delete template')); + } + }; + + const templateIncomplete = !editingTemplate?.template_name.trim() + || !editingTemplate?.subject_template.trim() + || !editingTemplate?.body_template.trim(); + + if (loading) { return ( -
- +
+ } /> +
); } return (
-
-

System Configuration

-

Manage global settings, AI integration, and notification channels.

-
+ } + /> -
- {/* Email Config */} -
-
- -

Email Notifications

-
- -
- setEmailConfig({...emailConfig, smtp_server: val})} placeholder="smtp.gmail.com" /> - setEmailConfig({...emailConfig, smtp_port: parseInt(val) || 587})} placeholder="587" /> - setEmailConfig({...emailConfig, smtp_user: val})} placeholder="user@gmail.com" /> - setEmailConfig({...emailConfig, smtp_password: val})} placeholder="••••••••" /> - setEmailConfig({...emailConfig, email_from: val})} placeholder="alerts@sentora.com" /> - setEmailConfig({...emailConfig, email_to: val})} placeholder="admin@company.com" /> - -
- setEmailConfig({...emailConfig, enabled: e.target.checked})} id="email-enabled" /> - + {error && ( +
+ +
+ )} + +
+
+ Email notifications}> +
+ + setEmailConfig({ ...emailConfig, smtp_server: e.target.value })} + placeholder="smtp.example.com" + /> + + + setEmailConfig({ ...emailConfig, smtp_port: parseInt(e.target.value, 10) || 587 })} + placeholder="587" + /> + + + setEmailConfig({ ...emailConfig, smtp_user: e.target.value })} + placeholder="alerts@example.com" + /> + + + setEmailConfig({ ...emailConfig, smtp_password: e.target.value })} + placeholder="••••••••" + /> + + + setEmailConfig({ ...emailConfig, email_from: e.target.value })} + placeholder="alerts@example.com" + /> + + + setEmailConfig({ ...emailConfig, email_to: e.target.value })} + placeholder="soc@example.com" + /> + + +
+
- -
+ Analysis model}> +
+ + setAiConfig({ ...aiConfig, model_name: e.target.value })} + /> + + + setAiConfig({ ...aiConfig, endpoint: e.target.value })} + placeholder="http://ollama:11434/api" + /> + + + setAiConfig({ ...aiConfig, api_key: e.target.value })} + placeholder={aiConfig.has_api_key ? '•••••••• (set)' : 'Enter API key'} + /> + +

+ The model proposes; the criteria in ai/criteria.py decide. A + verdict whose stated criterion is not in the log is refused regardless of + which model produced it. +

+ +
+
- {/* Email Templates — the bodies the alert mails actually use. There - was no way to see or edit these outside the database, so the - per-agent override the dispatcher looks for was unusable. */} -
-
-
- -

Alert Mail Templates

-
+ Alert mail templates} + actions={ -
-

- Critical-alert mail looks for a template named Critical Alerts - Agent: <name> and - falls back to {templateDefaultName} when the agent has none of its own. - Placeholders {'{{agent}}'} and {'{{body}}'} are substituted at send time. + } + > +

+ Critical-alert mail looks for Critical Alerts - Agent: <name> and + falls back to {templateDefaultName} when the agent has none of its + own. {'{{agent}}'} and {'{{body}}'} are substituted at + send time.

-
- {templates.map(t => ( -
-
-
- {t.template_name} - {t.template_name === templateDefaultName && ( - - Fallback - - )} -
-
- {t.subject_template} -
-
-
- - -
-
- ))} - {templates.length === 0 && ( -

- No templates yet. Alert mail cannot be sent without one. -

- )} -
+
+
+ {t.template_name} + {isFallback && fallback} +
+
+ {t.subject_template} +
+
+
+ + +
+
+ ); + })} +
+ )} {editingTemplate && ( -
-
- + + setEditingTemplate({ ...editingTemplate, template_name: val })} + onChange={(e) => setEditingTemplate({ ...editingTemplate, template_name: e.target.value })} placeholder="Critical Alerts - Agent: WIN-01" /> - + + setEditingTemplate({ ...editingTemplate, subject_template: val })} + onChange={(e) => setEditingTemplate({ ...editingTemplate, subject_template: e.target.value })} placeholder="[Sentora] Critical alerts on {{agent}}" /> -
- -