Skip to content

Security: Fazmin/cve-intel

Security

SECURITY.md

Security Policy

Supported versions

Version Supported
Latest (main) Yes
Previous minor Yes (for 90 days)
Older No

Reporting a vulnerability

Please do not report security vulnerabilities via public GitHub Issues.

If you discover a security vulnerability in this project, open a GitHub Security Advisory or contact the maintainer directly via GitHub.

Include:

  • Description of the vulnerability
  • Steps to reproduce
  • Potential impact
  • Any suggested fixes (optional)

You will receive an acknowledgment within 48 hours and a resolution timeline within 7 days.

Please give us a reasonable chance to fix the issue before disclosing it publicly. We are happy to credit you in the release notes if you would like that.

In scope: this repository's code, including the desktop app, the cve-intel CLI, the MCP server and the REST API. Out of scope: vulnerabilities in the third-party services we query (NVD, OpenAI, Anthropic, Google, …) — report those to the vendor.

Never include real API keys in a report. If you think one of your keys was exposed, revoke it with the provider first.

Security design notes

By default the Rust application makes outbound HTTPS requests only to known security APIs (plus model/runtime downloads the user explicitly starts). It does not:

  • Open inbound network ports (the optional local model server and cve-intel serve bind to 127.0.0.1 by default; cve-intel serve refuses a non-loopback address unless OAuth is on or CVE_INTEL_ALLOW_UNAUTHENTICATED=1 is set)
  • Execute code from external sources
  • Store API keys in logs or cache, or expose them to the desktop frontend
  • Query private/internal IP addresses via external APIs (blocked at the validation layer, including IPv4-mapped IPv6)
  • Ever issue MCP sampling/createMessage requests (the server never asks a host LLM to generate on its behalf)

The desktop app can refresh the local watchlist in the background while it is open (every 6 hours by default; configurable or off in Settings). That task only makes outbound HTTPS requests to the public sources needed for the items you watch (NVD, OSV, EPSS, CISA KEV, ENISA EUVD, exploit indexes). Nothing runs while the app is closed. The MCP and REST interfaces can read the watchlist but cannot add, remove or refresh items.

API keys for data sources

The optional keys for sources such as NVD, GitHub, VirusTotal, Shodan and AbuseIPDB can be supplied two ways: environment variables / a .env file, or the desktop Settings → Data source API keys screen. A key saved in the app wins over the environment.

  • Keys saved in the app go into the embedded SQLite database ~/.cve-intel/settings.db (or $CVE_INTEL_HOME/settings.db), table source_keys. The file is created with owner-only permissions (0600) on Unix before SQLite writes to it, and secure_delete is on so a removed key is overwritten. They are stored in plain text: not encrypted, and not in the OS keychain. Anyone who can read your user account's files or a backup of them can read them. Prefer environment variables on shared machines, servers and in Docker, and use keys with the lowest privileges and limits the provider offers (for example, a GitHub token with no scopes).
  • The same database is read by the CLI, the MCP server and the REST API on that machine, so a key saved in the desktop app applies to them too.
  • The UI is only ever told whether a key is set and where it came from (app or environment), never its value. Keys are never logged, cached or included in any tool, REST or MCP response, and Config's Debug output is redacted. Values are validated as printable ASCII before storage so they cannot inject HTTP headers.
  • "Get a key" buttons open the system browser, but the frontend can only pass an id; the URL comes from a fixed list compiled into the app, so the UI cannot be used to open arbitrary links.
  • The settings database is separate from the lookup cache, so "Clear cache" never touches your keys. Remove next to a key deletes it.

Watchlist (watch.db)

Vendors, products, packages and CVEs you follow are stored in ~/.cve-intel/watch.db (or $CVE_INTEL_HOME/watch.db), created owner-only (0600 on Unix) before SQLite writes to it, with secure_delete on. It is not encrypted. It holds the items you added, a snapshot of each tracked CVE's signals (KEV, EPSS, CVSS, exploit evidence) and the change events those snapshots produced.

  • Separate from the lookup cache and from settings.db, so Clear cache never removes the watchlist and removing a key never removes your items.
  • Only the desktop app and cve-intel watch add|remove|refresh change it. MCP tools (list_watchlist, get_watchlist_changes) and GET /api/v1/watch are read-only.
  • The first refresh of an item records a silent baseline. Later refreshes write events (new CVE, added to KEV, EPSS jump, and so on). The feed keeps the newest 5,000 events.

Scan history (scans.db)

Scans saved by the desktop app (every scan made there) and by cve-intel scan … --save are stored in ~/.cve-intel/scans.db (or $CVE_INTEL_HOME/scans.db), created owner-only (0600 on Unix) before SQLite writes to it, with secure_delete on. On Windows the file inherits your profile directory's permissions. It is not encrypted. It holds, per target, the folder and file paths that were scanned, and per scan the findings: package names and versions, advisory IDs and their KEV, EPSS, exploit and risk signals.

  • Pasted text is never stored. A pasted list is identified by a hash of the packages in it, so scanning the same list again shows what changed.
  • Scan again re-reads only the folders and files of a saved target, the ones you dropped or chose before; nothing else on disk is read.
  • The MCP tools list_scans and get_scan_findings, and GET /api/v1/scans…, are read-only and serve local callers only: MCP over stdio, and the server the desktop app starts (which refuses requests from web pages). cve-intel serve allows any origin, so it does not serve saved scans at all. No tool can start, save or forget a scan.
  • Forget in the app, or cve-intel scans forget <key>, deletes a target and every scan of it. Each target keeps its newest 30 scans, and the newest 200 targets are kept.
  • Monitor on a target is your standing consent to scan it again in the background, once a day, while the desktop app is open; nothing runs while it is closed. A monitored exposure check spends your Shodan quota each time. At most 20 targets can be monitored; untick Monitor or Forget the target to stop. What a background scan finds new or riskier is written to the watchlist feed (watch.db).

What each kind of scan sends where:

Scan Read on your machine Sent to
Dependencies The lockfiles, manifests and SBOMs you drop, choose or paste OSV.dev: package names and versions (Arch Linux pkg:alpm packages: none, see below). FIRST EPSS and CISA KEV: CVE IDs. Exploit-DB and Metasploit indexes are downloaded whole, so nothing is sent
Public exposure Nothing on your machine crt.sh: your domain. Your DNS resolver: the names found. Shodan or Shodan InternetDB: the public addresses you leave ticked (private and reserved addresses are never sent). NVD: product and version of the software seen there. Nothing connects to the targets
Container image The image file or OCI folder you drop or choose, read as a stream and never unpacked to disk. Only os-release, the package databases and dependency files are kept, in memory, with size limits OSV.dev: package names and versions. Arch Linux packages: nothing; the Arch Linux Security Tracker's list is downloaded whole and matched locally
This computer Windows: the Uninstall registry keys and the Windows build. macOS: Info.plist of apps in /Applications and ~/Applications, the Homebrew Cellar, the macOS version. Linux: os-release and the package database (dpkg, apk, rpm or pacman). Never Desktop, Documents, Downloads or Library Linux: OSV.dev gets the distribution's package names and versions, except on Arch Linux and its derivatives, where the Arch Linux Security Tracker's list is downloaded whole and matched locally. NVD: vendor, product and version of recognised apps and the OS. Only with look up unrecognised apps ticked: the names of other apps, to NVD's product dictionary. Nothing is sent by List installed software

Activity log (desktop app)

The desktop app records what is done in it so you can look back later: lookups and their inputs, Ask questions and answers, scans (including the pasted dependency list), comparisons, model and settings changes, and the tool calls, REST calls and refused web-page requests on the MCP server it hosts. Each entry also lists the upstream hosts its work contacted.

  • Stored in ~/.cve-intel/activity.db (or $CVE_INTEL_HOME/activity.db), created owner-only (0600 on Unix) before SQLite writes to it, with secure_delete on. It is not encrypted, and it contains the indicators you investigated, so treat it as you would your shell history. On Windows the file inherits your profile directory's permissions.
  • API key values are never passed to the log. Saving or removing a key records only its name, and changing a cloud provider records only whether a new key was saved. Upstream requests are stored as host and path only, because some sources (Shodan) take their key as a query parameter. Error messages have URL query strings stripped and key-like tokens redacted, since a rejected cloud key is echoed back partly masked.
  • Activity → Record activity pauses recording, and Clear log deletes every entry and compacts the file. Pausing, resuming and clearing are each noted in the log, so gaps are explained. Only the newest 20,000 entries are kept.
  • Only the desktop app records anything. cve-intel serve, cve-intel mcp and the other CLI commands write no activity log.

OAuth for cve-intel serve

With CVE_INTEL_OAUTH_ISSUER and CVE_INTEL_OAUTH_RESOURCE set, cve-intel serve is an OAuth 2.1 resource server (README, "Securing the HTTP server"). Every request needs a bearer token except /health and the RFC 9728 metadata document. That is the rule, not a list of protected prefixes, so a route added later (or a path nothing serves) is protected too. Under OAuth the public /health answers only {"status":"ok"}: the full report calls NVD on every request and says which API keys are set, so it is served at /api/v1/health, behind the token check.

  • A token is accepted only if it is a JWT signed with an allow-listed asymmetric algorithm (RS256/384/512, PS256/384/512, ES256/384, EdDSA) by a key in the configured issuer's JWKS, and iss is a single string equal to the configured issuer byte for byte, aud contains the configured audience, exp is present and not past, nbf/iat are not in the future (each with a 60 s leeway by default, at most 600 s), sub is not blank, and every required scope is granted. An exp or nbf that is not a number in range (a string, 1e30) rejects the token rather than counting as absent (GHSA-h395-gr6q-cpjc, fixed in jsonwebtoken 10.3; this server uses 11.x). ID tokens are refused, so a client's OpenID Connect ID token can't stand in for an access token when the audience is that client's ID: a token is one if it has a Keycloak typ claim other than Bearer, a Cognito token_use other than access, or, without those, a nonce, at_hash, c_hash or s_hash claim. An ID token with none of these markers can't be told apart and is accepted if its aud matches. An Entra ID token from the code exchange without a nonce is one, so with Entra, use separate app registrations for the API and its clients, or require a scope (README, "Entra ID and ID tokens"). alg: none and HS* tokens are refused, so the public key can't be used as an HMAC secret. A token with a crit header is refused: this server supports no critical extensions (RFC 7515 section 4.1.11). A key that names its algorithm is only used with that algorithm, and keys marked "use": "enc", symmetric keys and keys naming any other algorithm are never used to verify. Signatures are checked by ring, the library rustls uses, through a verify-only jsonwebtoken provider (jwt_crypto.rs) that refuses to sign; a test checks all nine algorithms against tampered messages, wrong keys and wrong key types.
  • Keys come only from the configured issuer: the JWKS URL is read from <issuer>/.well-known/openid-configuration (or CVE_INTEL_OAUTH_JWKS_URI), never from a token header (jku, x5u, jwk are ignored). Discovery must name the configured issuer exactly. Configured URLs must be https:// (plain http:// only for loopback testing) and redirects are not followed.
  • The JWKS is cached for an hour and refetched on an unknown kid (key rotation), at most every 30 seconds however many requests ask at once (they wait for one fetch), so forged tokens with random key IDs can't flood the issuer. The fetch runs in a task of its own, so a client that hangs up mid-fetch can't cancel it and leave the 30-second limit blocking a key the issuer just rotated in. Keys of a type or algorithm the server doesn't understand are skipped (RFC 7517 section 5); a set with no usable signing key is an error. If a refetch fails, the keys already held keep working for up to four hours, after which requests get 503 instead of honouring keys the issuer may have withdrawn; if no keys were ever loaded, requests get 503, never a pass. A failed fetch from a discovered JWKS URL runs discovery again, so a moved key set is found without a restart. Once the cached keys are an hour old, a request with a known key uses them at once while a single request (at most one every 30 seconds) refreshes them, so a slow or unreachable issuer never holds up everyone. A discovery document or key set over 1 MiB is refused. The HTTP client that fetches keys is built with a timeout, no redirects and the platform TLS settings, and serve refuses to start if it can't be built rather than falling back to a default client.
  • The URL in the WWW-Authenticate challenge and the metadata document is built from CVE_INTEL_OAUTH_RESOURCE, never from the request's Host header, so a forged header can't send a client to log in elsewhere.
  • Tokens are never logged or echoed; a rejection logs only its reason (token expired, wrong audience, …). With --debug, the log shows each token's header and standard claims (decoded without trusting them, so control characters and bidi overrides in them are escaped) and a fingerprint (first 12 hex digits of its SHA-256), never the token, its signature or the Authorization header (a malformed header, such as a bare token or Bearer followed by a tab, is logged only as its scheme word, if it has one); custom claims are left out because they may hold personal data. A test signs real tokens, sends them well-formed and malformed, and fails if any part of one beyond the header reaches the log.
  • Each accepted request is logged at info with the token's display name, sub and client, and each MCP tool call with the tool's name, so cve-intel serve's log is an access log. Claim values are logged quoted and escaped, so a newline in a username can't forge a log line. Scope names are checked against RFC 6749's character set at startup, because they are quoted into the WWW-Authenticate header.
  • Authorization. With CVE_INTEL_OAUTH_REQUIRED_ROLES set, a token is accepted only if it grants at least one listed role or group, matched exactly against realm_access.roles, roles, groups and, for entries written client:role, that client's resource_access roles (never another client's). A claim of the wrong shape grants nothing. A refused user gets 403 access_denied without a bearer challenge and without the list of allowed roles; the server logs who was refused. Unset, any valid token for this audience may use every tool cve-intel serve offers (it never serves saved scans or decisions). CVE_INTEL_OAUTH_REQUIRED_SCOPES limits which clients can connect, not which users.
  • Without OAuth, cve-intel serve refuses to start on a non-loopback address unless CVE_INTEL_ALLOW_UNAUTHENTICATED=1 is set, which it reports with a warning. Use that only behind your own authenticating proxy.

HTTPS for cve-intel serve

With CVE_INTEL_TLS_CERT and CVE_INTEL_TLS_KEY set, cve-intel serve speaks TLS itself (README, "Serving HTTPS"). TLS protects the connection only; the rules above for who may connect apply unchanged.

  • rustls with the ring provider, the same one the outbound clients use: TLS 1.2 and 1.3 only, rustls' default cipher suites (all AEAD, forward secret). ALPN offers only http/1.1. Client certificates are not requested; clients authenticate with OAuth.
  • No silent fallback to plain HTTP. Only one of the two variables set, an unreadable or oversized (over 1 MiB) file, a file with no certificate or no unencrypted key, or a key that doesn't match the certificate's public key all stop serve at startup. With TLS on, the port accepts nothing but TLS.
  • Each handshake runs in its own task and is dropped after 10 seconds, so clients that connect and stall can't block the accept loop or other clients.
  • With or without TLS, a connection that hasn't sent a complete request header 10 seconds after connecting (or after its previous response) is closed, since the token check can only run once the headers have arrived. CVE_INTEL_HEADER_TIMEOUT_SECS changes the 10 seconds (1–3600), for load balancers that keep idle connections open longer; a longer value gives a stalled client longer to hold a connection. At most 512 connections are open at once, counted from accept, so ones still in the TLS handshake count. At the limit serve stops accepting until one closes and logs a warning at most once a minute, rather than running out of file descriptors. There is no per-client limit; put a proxy in front if one client must not be able to hold every slot.
  • Without --debug, serve logs only its own records; rustls, hyper and reqwest messages (which scanners can trigger, such as an illegal SNI) appear only with --debug.
  • The files are read again every minute. A changed pair replaces the served one only if it loads and matches; otherwise the error is logged once and the previous certificate stays in use, so a half-finished renewal never takes the server down.
  • Private key material is never logged or put in an error message. Errors name the file and the problem only; the PEM parser's own error text is dropped for the key file because it can quote a line of it. A test feeds malformed key files and fails if any of their content, as text or as a byte list, appears in the error.

Optional remote AI (OpenAI, Anthropic, Google Gemini, OpenAI-compatible endpoints)

The Ask feature can use a remote model instead of the on-device one. This is off by default and only active after the user selects a provider in Remote AI (or with cve-intel ai use, or --provider on the CLI). Be aware of what that changes:

Data leaves the machine. While a remote provider is selected, each question and the intelligence-tool results gathered for it (CVE details, plus any IP address, file hash or domain the user asked about) are sent to that provider and are subject to its operator's terms and data-retention policy. The desktop app shows a banner in Ask AI whenever this is on, naming the host for an OpenAI-compatible endpoint. If you analyse sensitive indicators, keep the private local model selected.

How keys are handled:

  • Keys come from the OPENAI_API_KEY / ANTHROPIC_API_KEY / GEMINI_API_KEY / CUSTOM_LLM_API_KEY environment variables, from the Remote AI screen, or from cve-intel ai set --key (hidden prompt or stdin; never a command-line argument). The environment variable is the safer option because nothing is written to disk. An environment value that does not look like a key (8–512 printable characters, no spaces) is ignored, like a saved one would be refused.
  • A key saved in Remote AI is stored in plain text in the same embedded SQLite database described above (settings.db, table providers), with the same owner-only permissions and secure_delete. It is not encrypted and not in the OS keychain: anyone who can read your user account's files, or a backup of them, can read the key. On Windows the file inherits your profile directory's permissions.
  • The key is never returned to the UI (the frontend only learns whether a key exists and where it came from), never written to logs, the lookup cache or MCP/REST responses, and is redacted from debug output. A provider's error message is shown with the key replaced by [API key], since some providers and gateways repeat it. Keys are validated (printable ASCII, no whitespace) before use so they cannot inject HTTP headers.
  • Settings are kept in a separate database from the lookup cache, so "Clear cache" does not touch them. Remove saved key in Remote AI (or cve-intel ai remove-key) deletes the key.
  • Use API keys with a spending limit, and rotate any key you suspect was exposed.

Where requests go:

  • OpenAI, Anthropic and Gemini use compiled-in HTTPS base URLs. A user can choose the model name but cannot point those keys at another host.
  • The OpenAI-compatible endpoint (a LiteLLM proxy, vLLM, …) uses a base URL the user supplies (Remote AI, cve-intel ai set --url, or CUSTOM_LLM_BASE_URL). It must be https://; plain http:// is accepted only to this machine's loopback address (localhost, 127.0.0.0/8, ::1), so a question, its gathered intelligence and the key never cross a network unencrypted. URLs with embedded credentials, a query string or a fragment are rejected, and an invalid URL from the environment is ignored rather than used. Private and internal HTTPS hosts are allowed on purpose, since an organisation's LiteLLM proxy usually lives on one. Certificates are checked against the OS trust store.
  • A saved key is bound to its URL. Changing the endpoint's URL forgets the saved key unless a new one is entered in the same change, so a key is never silently sent to a different host. The desktop app asks for confirmation before doing this. A key saved while the URL comes from CUSTOM_LLM_BASE_URL is saved together with that URL, and a saved key is never paired with a URL from the environment, so changing the variable cannot redirect it. The reverse also holds: CUSTOM_LLM_API_KEY is sent only while requests go to CUSTOM_LLM_BASE_URL, never to a URL saved in the app.
  • Requests to a remote provider do not follow HTTP redirects (a redirect would carry the question to another host), and requests to a loopback address bypass HTTP(S)_PROXY so a local proxy's key is never handed to a network proxy.
  • Remote requests have a 120-second timeout.
  • Model lists. Once a key is saved, the Remote AI view (and cve-intel ai models) fetches GET <base URL>/models to list the models the key can use. That request carries only the key, never a question or lookup data, and follows the same URL, TLS, proxy and redirect rules as chat requests. It goes only to the selected provider's configured address.

The REST API does not use remote AI. POST /api/v1/ask only ever uses the local / CVE_LLM_BASE_URL model, so a web page or another local process cannot make the server spend your cloud key. The MCP server never calls a model at all.

Model output is untrusted. Whatever model answers, treat its text as advice to verify, not as a verdict. The structured evidence shown next to each answer (risk score, KEV, EPSS, PoC) comes from the data sources, not the model.

For full details see the Architecture section of the README.

There aren't any published security advisories