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}}" /> -
- -