Files
vulncheck/app/services/ai_service.py
T
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

194 lines
7.7 KiB
Python

"""
AI remediation service (OpenRouter, OpenAI-compatible).
On-demand generator that turns a vulnerability + its host context into
concrete, OS-aware remediation steps. Uses OpenRouter's OpenAI-compatible
REST API directly via httpx (no extra SDK dependency).
Config (env first, then settings table, so it works headless or via UI):
OPENROUTER_API_KEY — required to enable the feature
OPENROUTER_MODEL — default "openrouter/free" (free auto-router)
OPENROUTER_FALLBACKS — optional comma list for route=fallback
Free tier: ~10 requests/day across free models — fine for on-demand use.
"""
import logging
import os
from typing import List, Optional
import httpx
from sqlalchemy.orm import Session
from app.models.setting import Setting
logger = logging.getLogger(__name__)
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
DEFAULT_MODEL = "openrouter/free"
HTTP_TIMEOUT = 60.0
SETTING_KEY = "openrouter_api_key"
SETTING_MODEL = "openrouter_model"
SETTING_ENABLED = "ai_remediation_enabled"
class AIServiceError(Exception):
"""Raised when AI remediation cannot be produced."""
def _cfg(db: Session, env_name: str, setting_key: str, default: str = "") -> str:
val = os.getenv(env_name, "").strip()
if val:
return val
try:
from app.auth.setting_crypto import read_setting_value
sv = read_setting_value(db, setting_key)
if sv:
return str(sv).strip()
except Exception:
s = db.query(Setting).filter(Setting.key == setting_key).first()
if s and s.value:
return str(s.value).strip()
return default
def is_enabled(db: Session) -> bool:
"""True when an API key is configured (env or settings)."""
return bool(_cfg(db, "OPENROUTER_API_KEY", SETTING_KEY))
def _build_messages(*, cve_id, title, description, package, installed,
fixed, os_name, scanner_remediation) -> List[dict]:
sys = (
"You are a senior security engineer. Given a vulnerability and the "
"affected host, produce concise, ACTIONABLE remediation guidance for "
"the specific operating system. Prefer concrete commands in fenced "
"code blocks (apt/dnf/zypper for Linux distros, PowerShell/winget/MSI "
"for Windows). Include: 1) the fix (upgrade/patch/config), 2) exact "
"commands for THIS OS, 3) a verification step, 4) a mitigation if no "
"patch is available. Be brief — no preamble, no marketing."
)
# EOL/EOS pseudo-findings (cve_id starts with EOL- / NESSUS-PLUGIN-) are
# not patchable CVEs — there is no fix, the product is out of support.
# Give the model EOL-specific instructions so the answer is an upgrade/
# replacement plan, not a "apply the patch" hallucination.
is_eol = bool(cve_id) and (
cve_id.upper().startswith("EOL-") or cve_id.upper().startswith("NESSUS-PLUGIN-")
)
if is_eol:
sys = (
"You are a senior IT-security engineer advising on END-OF-LIFE / "
"END-OF-SUPPORT (EOL/EOS) software. The product below no longer "
"receives security patches — there is NO CVE patch to apply, so do "
"NOT suggest 'apply the update'. Produce a concise upgrade/migration "
"plan for THIS operating system:\n"
"1) State the EOL/EOS situation and the risk of staying on it.\n"
"2) The supported target release/edition to move to (name the "
"current supported version and its support timeline if known).\n"
"3) Concrete upgrade/replace commands for THIS OS (apt/dnf/zypper "
"dist-upgrade or repo swap on Linux; winget/MSI/installer or OS "
"in-place upgrade on Windows), plus where to download the supported "
"build.\n"
"4) Interim COMPENSATING CONTROLS / containment while the upgrade is "
"pending (network isolation/segmentation, restrict exposure, disable "
"the component, WAF/firewall rules, increased monitoring).\n"
"5) A verification step. Be brief, no preamble."
)
lines = [
("EOL/EOS finding ID: " if is_eol else "CVE / ID: ") + str(cve_id),
f"Title: {title or '—'}",
f"Affected OS: {os_name or 'unknown'}",
f"Product / package: {package or '—'}",
f"Installed version: {installed or '—'}",
(f"Latest supported version: {fixed or '—'}" if is_eol
else f"Fixed version: {fixed or '—'}"),
]
if scanner_remediation:
lines.append(f"Scanner-suggested remediation: {scanner_remediation}")
if description:
lines.append(f"\nDescription:\n{description[:1500]}")
if is_eol:
lines.append(
"\nThis is an end-of-life / out-of-support product, NOT a patchable "
"CVE. Give the upgrade/migration plan + interim compensating controls."
)
else:
lines.append("\nGive the remediation now.")
return [
{"role": "system", "content": sys},
{"role": "user", "content": "\n".join(lines)},
]
def generate_remediation(
db: Session,
*,
cve_id: str,
title: Optional[str] = None,
description: Optional[str] = None,
package: Optional[str] = None,
installed: Optional[str] = None,
fixed: Optional[str] = None,
os_name: Optional[str] = None,
scanner_remediation: Optional[str] = None,
) -> dict:
"""Call OpenRouter and return {"content": str, "model": str}.
Synchronous + blocking — the caller must run it OFF the event loop
(asyncio.to_thread) so it never freezes the GUI.
"""
api_key = _cfg(db, "OPENROUTER_API_KEY", SETTING_KEY)
if not api_key:
raise AIServiceError(
"OpenRouter not configured — set OPENROUTER_API_KEY (env or Settings)."
)
model = _cfg(db, "OPENROUTER_MODEL", SETTING_MODEL, DEFAULT_MODEL)
fallbacks = _cfg(db, "OPENROUTER_FALLBACKS", "openrouter_fallbacks", "")
body = {
"model": model,
"messages": _build_messages(
cve_id=cve_id, title=title, description=description, package=package,
installed=installed, fixed=fixed, os_name=os_name,
scanner_remediation=scanner_remediation,
),
}
if fallbacks:
models = [model] + [m.strip() for m in fallbacks.split(",") if m.strip()]
body["models"] = models
body["route"] = "fallback"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
# OpenRouter attribution headers (optional but recommended).
"HTTP-Referer": "https://truevuln.local",
"X-Title": "TrueVuln",
}
try:
with httpx.Client(timeout=HTTP_TIMEOUT) as client:
resp = client.post(OPENROUTER_URL, headers=headers, json=body)
except httpx.HTTPError as e:
raise AIServiceError(f"OpenRouter request failed: {e}") from e
if resp.status_code == 401:
raise AIServiceError("OpenRouter rejected the API key (401).")
if resp.status_code == 402:
raise AIServiceError(
"OpenRouter quota/credits exhausted (402) — free-tier daily cap hit "
"or a paid model needs credits."
)
if resp.status_code >= 400:
raise AIServiceError(f"OpenRouter error {resp.status_code}: {resp.text[:300]}")
try:
data = resp.json()
content = data["choices"][0]["message"]["content"]
used_model = data.get("model", model)
except (KeyError, IndexError, ValueError) as e:
raise AIServiceError(f"Unexpected OpenRouter response shape: {e}") from e
if not content or not content.strip():
raise AIServiceError("OpenRouter returned an empty response.")
return {"content": content.strip(), "model": used_model}