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.
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.
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.
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.
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.
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.
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.
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.
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.
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.)
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.
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).
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.
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.