Commit Graph
8 Commits
Author SHA1 Message Date
vulncheck 6fdb800cef feat(auth): passwordless sign-in with passkeys for local accounts
"Sign in with a passkey" on the login page replaces password and TOTP.
User verification is required, so the passkey is two factors on its own;
password login stays alongside. Users add, list and remove passkeys under
Settings -> Profile after confirming their password. WebAuthn checks
(challenge, origin, RP ID, signature, sign count) come from py_webauthn;
the browser side uses the native PublicKeyCredential JSON helpers.

Off unless WEBAUTHN_ORIGIN is set; its hostname is the RP ID. Local
accounts only: a passkey sign-in never asks the directory, so an LDAP user
disabled or demoted in AD would keep access and role. Deactivated and
permanently locked users are refused. Challenges are 5-minute signed
tokens burnt on first use (token_revocation.claim, check and revoke under
one lock). The admin "Reset MFA" also removes the user's passkeys.

New table passkeys (migration 067). Rules live in app/auth/passkey.py,
endpoints under /auth/passkeys in app/routers/auth_passkey.py.
2026-11-03 15:40:31 +01:00
vulncheck f7c7812fdd feat(vulnerabilities): group findings by package
The findings list shows one row per CVE, so a host with 40 log4j CVEs reads
as 40 problems when it is one update. A "Group by package" chip on the
Vulnerabilities page now shows one row per product with its findings, CVEs,
hosts, severities, exploited CVEs and installed/fixed versions, under the
list's own filters, so with one asset selected it is that asset's affected
products at a glance.

GET /vulnerabilities/by-package aggregates in one GROUP BY with the patch
plan's key (package rows, else package_name, else the CVE id). The list's
filter block moves into _apply_list_filters so both views count the same
rows. A row click opens the flat list with ?package=<key>, which matches the
group's findings by their package rows; the search's ILIKE on the summary
column missed a finding's second product.
2026-10-26 09:58:27 +01:00
vulncheck de6d41cdd3 feat(https): serve the UI over TLS with a CA per installation
TrueVuln spoke plain HTTP only. HTTPS needed a foreign reverse proxy, the
hop behind it stayed unencrypted, and the frontend port 3003 was open on
every interface, so anyone on the network could read the vulnerability
data around the proxy.

A bundled nginx (Compose profile "tls", on by default via
COMPOSE_PROFILES) now terminates TLS 1.2/1.3 on 443: forward secrecy and
AEAD only, HSTS, port 80 only redirects. Frontend and backend are bound to
127.0.0.1. A reverse proxy on another host connects to nginx via HTTPS; one
on the same host may leave the profile out and use 127.0.0.1:3003.

On first start the backend creates a CA for the installation and issues
the server certificate from it for TLS_SERVER_NAMES, the DASHBOARD_URL host
and localhost, renewed in lifespan and nightly at 03:45. The CA carries
name constraints on exactly those names, because its key sits on the
vulnerability server and an unconstrained root in every browser would let
whoever steals it impersonate any site. A name it does not cover creates a
new CA, with an audit row. The CA lives in a backend-only volume; nginx
mounts only the certificate and key, and reloads itself when they change,
so nothing needs the Docker socket.

Admins can download the CA, or upload a certificate from the internal PKI
(PEM with chain, DER or PFX) under Settings > Security > HTTPS Certificate.
It is checked (key matches, chain complete, covers the DASHBOARD_URL host,
valid, not self-signed, RSA >= 2048 or ECDSA P-256/P-384, SHA-256+) and
served without a restart; renewal never overwrites it, a reset goes back to
the CA. The key stays in the volume (0600), never in the database or the
CONFIG_CHANGE audit rows.

The Next.js proxy now forwards request bodies as bytes: request.text()
mangled binary DER and PFX uploads.

Existing proxies on http://<host>:3003 from another machine must be
switched; README has the upgrade steps.
2026-10-24 13:29:10 +02:00
vulncheck 540f1f4cc9 feat(settings): trust internal-PKI CAs uploaded in the web UI
Integrations behind a certificate from an internal PKI only worked with
"Verify SSL" off. httpx verifies against certifi, not the OS store, so
update-ca-certificates in the container changed nothing. httpx, pyVmomi
and ldap3 do honour SSL_CERT_FILE.

Admins can now upload root/intermediate CAs under Settings > Security >
Trusted CA Certificates (PEM, chain or DER). ca_trust_service writes
certifi (or the SSL_CERT_FILE the process started with) plus the
uploaded CAs into one bundle and points SSL_CERT_FILE at it. The next
connection picks it up without a restart, and the public roots stay
trusted. Leaf certificates, expired CAs and files containing a private
key are rejected.

Admin only, reads included, at /api/v1/trust-store/ca-certificates.
Every add and remove writes a CONFIG_CHANGE audit row. The CAs are
stored as JSON in the custom_ca_certificates setting, and the generic
settings PUT is denied for that key so nothing bypasses validation or
the audit trail. The bundle is rebuilt in lifespan before the scheduler
starts.
2026-10-23 18:32:34 +02:00
vulncheck ec34e307d3 feat(dockhand): link container images to their Docker host asset
Every image was its own asset in the flat asset list, between the servers,
and the Docker host's own asset (docker-01 from Wazuh/Nessus/vCenter) said
nothing about what ran on it; the host was only text in the image's
description.

The Dockhand sync now matches each environment's Docker hostname (docker info
Name from GET /api/host) against non-container, non-decommissioned assets:
exact name first, then FQDN vs short form. Several candidates and the
environment's public IP / host does not decide, or none: no link, the
description stays the only trace. A wrong host is worse than none. Links go
to the new container_placements table (060), rewritten by every sync, so an
image gone from a host loses its link with the next run. Findings stay on the
image asset; the cross-source retraction is unchanged.

The asset page hides container images by default (include_containers=false,
"Show container images"); a host row shows an "N images" badge that opens
GET /assets/{id}/containers, an image row names its hosts. The list reads
both directions in one query, so its statement count stays flat. The new
route is readonly-visible like /software.

Hawser needs nothing on our side: it is Dockhand's agent for remote hosts,
and every Hawser host is a Dockhand environment like any other.
2026-09-18 10:55:00 +02:00
vulncheckandClaude Opus 5 394a957899 fix(assets): /assets/names in die Readonly-Allowlist
Die Gate-Inventur listet, was ein Readonly-Nutzer erreichen darf, und der neue
Endpoint fehlte darin. Er gehoert hinein: ein Readonly-Nutzer filtert nach Host,
also muss der Picker fuer ihn genauso offen sein wie die Liste, die er ersetzt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 17:10:39 +02:00
vulncheck ba57975ce0 fix(msrc): a scan started by hand still answered from the previous night's index
Follow-up to 10c4b86. That fix put the MSRC index build before the scans that
read it (01:30 → 03:20 → 03:50), which was the nightly half of the problem.
The GUI half stayed: the operator ran MSRC enrichment AND an app scan by hand
and still saw only Google Chrome on CVE-2026-84324 — no Edge product, no
fixed build.

Two reasons, both invisible from the outside:

  * "MSRC Enrich" (POST /msrc/refresh → msrc_service) pulls per-CVE
    remediation TEXT. It does not touch msrc_product_index_v2 — the
    product→(CVE, FixedBuild) index the Edge findings are created from.
  * The app scan read that index with load_index(), which serves the stored
    blob at ANY age, and rebuilt only when it was missing. So the pass
    decided from whatever the last nightly build left behind, however old,
    and pressing the button again changed nothing.

And there was no button that forced a rebuild: /msrc-scan exists as API only,
with rebuild_index defaulting to False.

Now every scan reads the index through ensure_index(SCAN_INDEX_MAX_AGE = 6h)
instead of load_index — the nightly chain still shares the single 01:30 build,
a run started at 14:00 rebuilds first. A failed rebuild keeps serving the
cache and logs a warning, so an offline MSRC costs no more than before. The
GUI gets "MSRC Scan" next to "MSRC Enrich": rebuild + OS pass as a background
job with status polling, because the CVRF pull outlives the browser's HTTP
patience the same way the app scan did. Readonly may poll the status, not
start it.

Regression: tests/test_msrc_edge_chromium_cve.py section 6 — a 30h-old index
is rebuilt by both run_app_cve_scan and run_msrc_scan, a 1h-old one is not,
and a failed rebuild leaves the run intact with the cached index.
2026-09-06 15:40:42 +02:00
vulncheck 35c7a858c2 fix(rbac): Groups, Policies and Auth Providers were hidden from readonly only in the sidebar
Same gap as Scan Jobs (6a1e6f5), found by walking every GET route as a
readonly user: the sidebar hid the pages, the server did not.

- /groups: GET /api/v1/groups returned every group's member list
  (usernames) to any role, while /auth/users is admin-only. The list
  stays open — Assets and Vulnerabilities need it for their group
  filter — but membership is now only in the admin's response.
  GET /groups/default is admin-only; it is the Groups page's own
  request, so its 403 is what bounces a non-admin back to the
  dashboard.
- /policies: GET /api/v1/policies is editor+ now, matching the sidebar.
  The Assets page fetched it for everyone for its editor-only
  dropdowns; readonly no longer asks (READONLY_DENIED). The page
  bounces on 403.
- /admin/auth: the API was already admin-only, but the page swallowed
  the 403 and rendered an empty shell. It bounces now.
- api.ts: the client-side READONLY_DENIED rejection carries
  response.status 403, so pages that bounce on 403 do not need to know
  the request never left.

Regression test mounts every router as readonly and asserts the exact
allow-list of reachable GET routes, so the next page that is only hidden
in the sidebar fails the suite. README role table updated.
2026-09-03 18:48:22 +02:00