Commit Graph
16 Commits
Author SHA1 Message Date
vulncheck 49e7c55938 feat(nginx): harden the bundled nginx along the CIS NGINX Benchmark v3.1.0
The bundled nginx met about half of the benchmark's Level 1 items: it
answered for any Host, kept the 60-65 s default timeouts, allowed every
method and 100 MB bodies everywhere, served config files 644 and sent no
nosniff, Referrer-Policy or CSP on Next.js pages.

Now nginx answers only for the names on the certificate (TLS_SERVER_NAMES,
the DASHBOARD_URL host, localhost, 127.0.0.1, built by a new .envsh) and
closes the connection for any other Host. No ssl_reject_handshake, since
browsers send no SNI for an IP. TLS 1.3 only, with session tickets back on
(4.1.11) and HSTS at two years. Timeouts are 10 s, methods are limited to
what frontend and API use, bodies are 1 MB except 100 MB under /api/.
/etc/nginx is 750/640, there is an error page without nginx branding,
dot paths return 404 and X-Powered-By is hidden. The CSP is added only
where the upstream sent none, so backend responses keep theirs.

Clients without TLS 1.3 and Host names outside the list stop working.
Both cases are documented in README and TROUBLESHOOTING. README.DEV.md
maps every recommendation, including the Level 2 items not taken and why.
Verified on the built image with nginx -t, the benchmark's curl audits
and the login page under the CSP in a browser.
2026-10-31 16:13:12 +01:00
vulncheck d1811fd683 docs(https): a reverse proxy running as a container on the same host
The docs told an own proxy on this host to use 127.0.0.1:3003. For a proxy
in a Docker container (Nginx Proxy Manager, OpenResty, Traefik) that is
the container itself, and the host's LAN IP no longer answers on 3003
since the frontend is bound to 127.0.0.1. The proxy then returns 502 with
"Connection refused ... upstream: http://<lan-ip>:3003/".

README, .env.example, the Compose comments and TROUBLESHOOTING now cover
it: join the proxy container to <project>_vulnmanager-net, target
vulnmanager-frontend:3000 (the container port, which clashes with nothing
on the host), and make the network permanent in the proxy's Compose file
so a recreated container keeps it. Troubleshooting gains an entry for the
502 and the dead port 3003 after the update, with the fix for each setup.
2026-10-24 14:18:50 +02:00
vulncheck 0cd58a348e fix(auth): audit log and login rate limit see the client IP, not the frontend's
The backend never talks to the browser or the reverse proxy, only to the
Next.js container, and that proxy did not forward X-Forwarded-For.
FORWARDED_ALLOW_IPS defaulted to 127.0.0.1, which the frontend container
never is, so every audit row carried the container's Docker address and all
users shared one login rate-limit bucket. The limiter also parsed the header
a second time and trusted every private network, so an internal user could
pick a fresh rate-limit key per request with a forged header.

The Next.js proxy now forwards X-Forwarded-For. That was left out on purpose
while port 3003 was open, because Next.js keeps a client-supplied header.
Since the frontend is bound to 127.0.0.1, only nginx or the operator's proxy
reaches it, and those append the real peer.

The backend trusts the header from loopback and its own Docker subnet, read
from /proc/net/route, so no IP has to be configured and a recreated
container is still trusted. FORWARDED_ALLOW_IPS now adds proxies to that
(one on another host in front of nginx) instead of replacing it. uvicorn
takes the rightmost untrusted entry, so a forged entry in front never
counts. The rate limiter keys on that resolved address instead of parsing
the header itself. TRUST_PROXY_HEADERS defaults to true in Compose.
2026-10-24 14:01:55 +02: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 e0646077d6 feat(triage): Jev over a TypeSafe key directly, or over OpenRouter
Jev could only run over OpenRouter. The AI settings card now also takes a
TypeSafe API key (setting typesafe_api_key, encrypted at rest like the
OpenRouter key, or env TYPESAFE_API_KEY). When it is set, Jev goes straight
to api.typesafe.ai/v1/systemone with the pinned jev-1.13.0. Without it, Jev
uses the OpenRouter key and OpenRouter Decisions with typesafe/jev-1.13.
Both endpoints take the same body and give the same answer. The card
shows "Configured" when either key is set, and the TypeSafe key has its
own Remove button.

JEV_MODEL is gone. It overrode the model on both endpoints, but the two use
different model ids. Adding or removing a TypeSafe key switched the
endpoint and left the other endpoint's model id in place. Every CVE then
failed as a CVE-specific 4xx and was skipped, up to 500 requests with
nothing written and no error reported. The model is now fixed per
endpoint.

For the same failure shape from any other cause, such as a changed schema,
five CVE-specific failures in a row now stop the run with an error. The
run no longer walks through every CVE.

A test checks the actual request: URL, bearer key, model and question.
2026-09-19 10:14:51 +02:00
vulncheck f77c7413b3 feat(kev): VulnCheck KEV as a third exploited-catalog source
The KEV panel knew CISA (~1400 CVEs) and ENISA EUVD. VulnCheck KEV carries
~5200 and typically lists them days before CISA does — every record ships
CISA's own date, so the lead time is measurable, not a claim, and each row
now shows it.

Opt-in and silent by default: without VULNCHECK_API_TOKEN the source returns
{} and nothing about the install changes. Alerting is deliberately NOT opted
in either — kev_alert_sources stays "cisa,euvd", because ~4x the CVEs is the
operator's decision, not a side effect of updating.

No SDK: two GETs against a documented JSON API. The backup endpoint hands out
the whole index as one signed ZIP; the paginated index is only the fallback
and is capped at 600 documents on the community tier, which the log says out
loud so a partial catalog is never mistaken for the full one.

Attribution is a licence term, not decoration: "VulnCheck KEV" is named in the
KEV panel header and badges every row the source contributed to. Noted in
LICENSING.md so nobody strips it later.

Per-vulnerability scoring is untouched — kev_listed still comes from CISA
alone. Feeding 5200 CVEs into the URS bonus would move every score in the
fleet and belongs in its own decision.
2026-08-29 09:42:03 +02:00
vulncheck bf07b90330 docs(env): make DASHBOARD_URL prominent — it drives all email links
Email links (dashboard button, cve/asset deep links, the CVE links in the
digest table) are built from DASHBOARD_URL; unset → they fall back to
http://localhost:3000 and break. Uncomment it with the right default port and a
clear 'set this to how users reach the frontend' note.
2026-07-13 14:05:17 +02:00
vulncheck 099c1d8af0 feat(api): rebrand OpenAPI title to TrueVuln + ENABLE_API_DOCS opt-in
Expose Swagger /docs + ReDoc /redoc on a production instance without full
development mode (ENABLE_API_DOCS=true) for ITSM/CMDB integrators. Raw
/openapi.json spec is served regardless. OpenAPI title/description rebranded
VulnManager -> TrueVuln.
2026-07-09 13:26:19 +02:00
vulncheck dbad9a365e chore(rebrand): VulnCheck → TrueVuln
Renames the product name in every user-visible surface and internal
self-reference: page title, nav/shell, login/MFA pages, email templates and
subject prefixes ([VULNCHECK] → [TRUEVULN]), TOTP issuer label, report/PDF
headers, notification previews, outbound User-Agent/HTTP-Referer headers we
set ourselves, docs (README, ARCHITECTURE, PROJECT_OVERVIEW, DATABASE_SCHEMA,
README.DEV, TROUBLESHOOTING is untouched — see below), and .env.example
placeholder config (LDAP/OIDC/SAML example domains and paths).

Also renamed the on-disk cache file paths (/tmp/vulncheck-*.zip|csv|json →
/tmp/truevuln-*), kept consistent across the two files that share the
cvelistV5 ZIP cache path — first run after deploy re-downloads that ~557 MB
cache once (harmless, disposable).

Deliberately LEFT UNCHANGED (not branding — real external references or
infra identifiers; renaming the text without renaming the underlying thing
would just break/mislead):
- The actual Gitea repo URL/path (gitea.isuit.ch/vulncheck/vulncheck) and the
  README lines derived from it (git clone target dir, tree listing) — a real
  repo rename is a manual Gitea-side step (Settings → repository name) the
  user would need to do themselves, and existing clones would need
  `git remote set-url` after.
- The real support mailbox (support-vulncheck.sq9vd@passmail.net, in both
  README and TROUBLESHOOTING) and the Buy Me A Coffee link — both point to
  accounts that still exist under the old name; renaming the text alone
  wouldn't create new ones.
- GitNexus MCP resource URIs in CLAUDE.md/AGENTS.md (gitnexus://repo/
  vulncheck/...) — tied to GitNexus's own index name for this repo, not our
  branding; those files are untracked in this repo anyway.
- docker-compose.yml container/network/Postgres user+db names
  (vulnmanager-*) — explicit user decision: infra naming carries real
  deploy/data risk on an already-running instance and isn't part of the
  product-branding ask.
- The Tailwind color token class `vulncheck-blue` (frontend/app/globals.css)
  — invisible internal CSS variable name, renaming it would touch ~270
  className occurrences for zero user-visible benefit.

Verified: backend py_compile clean on every touched .py file; frontend tsc
clean (two pre-existing, unrelated errors remain: assets/page.tsx SVG title
prop, mfa-setup missing qrcode.react types). All diffs are exact-string
renames — no other changes riding along.
2026-07-07 16:34:40 +02:00
vulncheck 4d6ccb71d2 fix: heavy manual endpoints run off the event loop (no more GUI freeze)
Tester: clicking "M365 CVEs" froze the whole web GUI until the cvelistV5
comparison finished — same class of bug as the earlier date-backfill case.

Cause: these endpoints were `async def` but call fully-synchronous,
blocking services inline (httpx + DB), starving the single asyncio event
loop. Fix: declare them as plain `def` — FastAPI/Starlette then runs them
in a worker threadpool, off the event loop, so the GUI stays responsive
(responses/counts unchanged). Converted: m365-check, eol-check,
exploit-intel/refresh, bulk enrich (Refresh Threat Intel),
override/vulnrichment (Correct CVSS), and the Nessus /sync trigger.

Also:
- Intune detectedApps: v1.0 `$expand=detectedApps` returns HTTP 400 on the
  managedDevice; now falls back to the beta /detectedApps navigation
  collection (paginated). Still best-effort.
- Defender 403 ("Missing application roles"): /api/machines needs
  Machine.Read.All in addition to Vulnerability.Read.All — documented in
  the Intune settings tooltip + .env.example. (Sync stays non-fatal.)
2026-06-15 15:04:00 +02:00
vulncheck 5418d6a9d3 feat(intune): Microsoft Intune/Graph inventory source — Phase 1 (devices + OS-EOL)
Third inventory source next to Wazuh/Nessus: pulls Intune managed devices
via Microsoft Graph (app-only client-credentials) → assets + OS-level EOL.

- Migration 033: INTUNE assetsource label + assets.intune_device_id /
  defender_machine_id (+ indexes). Model updated.
- app/integrations/graph_client.py: client-credentials token cache (mirrors
  wazuh_client), paginated managedDevices, get_detected_apps (phase 2),
  test_connection. Plain httpx, no msal dependency.
- app/services/intune_service.py: run_intune_sync — find-or-create asset
  (intune_device_id → hostname → auto-create) mirroring nessus_sync,
  source=INTUNE, refresh OS/version, OS-EOL via eol_service.check_os_eol,
  id-keyed lifecycle reconcile.
- asset_lifecycle.reconcile_intune_by_seen_ids (mirrors the Nessus one).
- app/routers/intune.py: POST /api/v1/integrations/intune/test (admin),
  /sync (editor, fire-and-forget 202). Registered in main.py.
- scheduler: intune_sync_nightly (sync def) at 02:10.
- Settings: encrypted intune_config (PROTECTED_SETTING_KEYS); settings PUT
  now MERGES secret subfields (blank/"***set***" keeps the stored secret)
  so JSON configs can be edited without re-typing secrets; client_secret
  added to redaction subfields.
- Frontend: "Microsoft Intune (Graph API)" settings card (tenant/client/
  secret + Test/Sync), intune/defender source badges, Asset type field.
- .env.example documents the Entra app registration + permissions.

Migration 033 required: alembic upgrade head. detectedApps→EOL/M365 is
phase 2 (toggle present, off by default until the per-package helpers land).
2026-06-13 10:34:51 +02:00
vulncheck 9021943f38 feat(ai): on-demand OpenRouter AI remediation on the CVE detail page
Tester feature request: generate OS-aware fix guidance per CVE via
OpenRouter (OpenAI-compatible).

- app/services/ai_service.py: calls OpenRouter /chat/completions via httpx
  (no new SDK dep). Config from env first, then settings table:
  OPENROUTER_API_KEY, OPENROUTER_MODEL (default openrouter/free),
  OPENROUTER_FALLBACKS (route=fallback). Builds an OS-aware prompt from the
  CVE + host (package, installed/fixed version, OS, scanner remediation)
  and asks for concrete commands + verification + mitigation. Maps 401/402
  to clear errors.
- POST /vulnerabilities/{id}/ai-remediation runs it via asyncio.to_thread
  (off the event loop). GET /ai-remediation/status reports whether a key
  is set so the UI hides the button when unconfigured.
- CVE detail: "🤖 AI Remediation" section with Generate/Regenerate button,
  shown only when configured.
- .env.example documents the OpenRouter keys.

Keyless by default = feature hidden; no behaviour change unless a key is set.
2026-06-06 09:48:51 +02:00
vulncheck 57e457e92f perf(enrich): confine rate-limited NVD date backfill to the nightly job
Follow-up to the published_date backfill. The NVD CVE API sleeps up to
6.5s per request without an API key, so running the backfill inline on
interactive / sync paths could hang them for minutes.

- NVD backfill now only runs in the nightly enrich_all_open_vulnerabilities
  job (and single-vuln enrich, <=1 lookup). Disabled on the "Refresh
  Threat Intel" button, the Wazuh full + per-agent syncs, and the Nessus
  sync — all pass use_nvd_dates=False to stay fast.
- Per-run lookup cap is now key-aware: 60/run without a key (~6.5 min),
  1500/run with a key (~18 min) — avoids a no-key install stalling the
  nightly job for the better part of an hour.
- Document NVD_API_KEY in .env.example (free key → ~10x faster fill,
  picked up automatically via env_file).
2026-06-03 12:28:51 +02:00
vulncheck b8e8870b29 feat(auth): LDAPS + OIDC + SAML 2.0 strategies and SSO routers
Phase 2-4 of multi-provider authentication. All three providers slot
into the AuthOrchestrator from phase 1; no further changes to
/auth/login are needed.

LDAPS (app/auth/strategies/ldap_strategy.py):
- ldap3 with strict TLS cert validation (CERT_REQUIRED + CA bundle)
- Service-account search-then-bind flow (DN never exposed to caller)
- Filter chars escaped via ldap3.utils.conv.escape_filter_chars
- AD-flavored defaults: sAMAccountName / memberOf / objectGUID
- Refuses if filter returns >1 entry (anti-impersonation safety)
- Bind password encrypted at rest (Fernet), bootstrapped from env on
  first start then stored in settings table

OIDC (app/auth/strategies/oidc_strategy.py + app/routers/auth_oidc.py):
- Authorization Code + PKCE (S256)
- Strict ID-Token validation via Authlib: signature (JWKS w/ auto-refresh
  on rotation), iss (essential), aud (essential, must == client_id),
  exp (essential), nonce (replay protection)
- State/PKCE-verifier/nonce stored in signed itsdangerous cookie (no
  server-side session store needed)
- Discovery + JWKS cached in-process; JWKS auto-refetched on key miss
- Groups merged from both id_token claims and userinfo endpoint
- Hardened: prompt=select_account to defeat silent IdP reuse

SAML 2.0 (app/auth/strategies/saml_strategy.py + app/routers/auth_saml.py):
- python3-saml (OneLogin) with strict=true; xmlsec1 handles signature
  validation. XSW attacks mitigated via strict assertion/response
  signature position checks plus wantAssertionsSigned=true
- SP-initiated (/auth/saml/login) + IdP-initiated (POST /auth/saml/acs)
- /auth/saml/metadata serves signed SP descriptor
- RelayState same-origin check to prevent open redirect
- IdP metadata loaded from URL or file at startup

Wiring:
- app/main.py imports auth_oidc/auth_saml routers behind try/except so
  the app still starts when authlib or python3-saml aren't installed
- Frontend login page fetches /auth/providers and renders matching
  redirect buttons (Sign in with Entra ID / SAML SSO) plus the local
  credentials form. Adds MFA second-step screen with 6-digit OTP input
  when /auth/login returns mfa_required=true.

.env.example: full provider config blocks with worked examples for
Entra ID, Okta, Keycloak, Google. Each block is commented with the
exact format the corresponding admin needs.
2026-05-12 19:11:32 +02:00
vulncheck 61e63e3d56 fix(setup): default admin not created because 'changeme' fails strength check
The bootstrap routine in app/db_init.py validates DEFAULT_ADMIN_PASSWORD
against the password-strength policy (upper, lower, digit, special) and
silently refuses to create the admin if it fails. The shipped default
'changeme' violates the policy, so a fresh install ended up with no
admin user and login was impossible.

- Set .env.example default to 'ChangeMe123!' (passes strength check)
- README: spell out the strength requirements, add a log-grep to verify
  the admin was created, and list DEFAULT_ADMIN_PASSWORD as a required
  step in the install instructions.
2026-05-11 13:58:38 +02:00
vulncheck 6969d0c62e Initial release v1.0.0
VulnCheck - Open Source Vulnerability Management for Wazuh

Features:
- Vulnerability management with Wazuh integration
- AI-powered CVE analysis (OpenAI, Anthropic, Google, DeepSeek, Ollama, Infomaniak)
- SLA policy enforcement with automated email alerts
- Automated patch verification via Wazuh Syscollector
- Role-based access control (Admin, Editor, Readonly)
- PDF/CSV reporting for compliance workflows
- Full audit trail

https://gitea.isuit.ch/vulncheck/vulncheck
2026-02-08 10:15:20 +01:00