feat: LLM-анализ — индикатор прогресса, кнопка рескана, статистика на дашборде

- Добавлен статус {"status": "analyzing"} в finding.report на время LLM-анализа
- Кнопка рескана (Retry) под LLM-отчётом в ручном режиме
- LLM-статистика на дашборде: analysed / pending
- Защита от двойного анализа через per-finding asyncio.Lock
- _llm_spinner.html — фрагмент спиннера для состояния analysing
- Удалён мёртвый код: constants, i18n, CSS, queries
- Фиксы: _env_int, индексы БД, UnicodeDecodeError, time.mktime и др.
- Шаблоны: shared includes (_status_badge, _pagination)
- AGENTS.md: workflow (lint, test, commit, rebuild)
This commit is contained in:
Marker689
2026-05-10 09:54:04 +03:00
parent c99a7bf67c
commit 6984844161
26 changed files with 261 additions and 266 deletions

View File

@@ -31,6 +31,7 @@ NEXUS_API_TIMEOUT_SECONDS=30
# LLM analysis (optional — set LLM_ENABLED=1 to activate)
LLM_ENABLED=0
LLM_AUTO_ANALYZE=0
LLM_API_BASE=https://api.openai.com/v1
LLM_API_KEY=
LLM_MODEL=gpt-4o-mini

View File

@@ -197,3 +197,15 @@ curl -X POST http://localhost:8080/webhooks/nexus \
- **No Nexus Pro required:** the system works with Nexus OSS. Webhooks can be triggered manually or via community plugins.
- **GuardDog deadlocks:** GuardDog is CPU-intensive. Use `MAX_CONCURRENT_SCANS` to avoid resource exhaustion.
- **LLM may be slow:** increase `LLM_TIMEOUT_SECONDS` for large models. Set `LLM_MAX_CONCURRENT_ANALYSES` to limit parallel requests.
---
## Workflow
**After every change** — follow these steps in order:
1. **Document** — update `AGENTS.md` if the change introduces a new concept, env var, endpoint, or workflow.
2. **Lint**`ruff check guarddog_nexus && ruff format guarddog_nexus`
3. **Test**`python3 -m pytest -v` (must pass 100%)
4. **Commit** — use the existing commit prefix convention (`feat:`, `fix:`, `refactor:`, `docs:`, `ui:`).
5. **Rebuild**`docker compose up -d --build` to deploy changes.

View File

@@ -12,6 +12,7 @@ services:
HOST: "0.0.0.0"
PORT: "8080"
LLM_ENABLED: "${LLM_ENABLED:-0}"
LLM_AUTO_ANALYZE: "${LLM_AUTO_ANALYZE:-0}"
LLM_API_BASE: "${LLM_API_BASE:-https://api.openai.com/v1}"
LLM_API_KEY: "${LLM_API_KEY:-}"
LLM_MODEL: "${LLM_MODEL:-gpt-4o-mini}"

View File

@@ -14,52 +14,59 @@ from guarddog_nexus.constants import (
)
def _env_int(name: str, default: int) -> int:
val = os.getenv(name)
if val is None:
return default
try:
return int(val)
except ValueError:
return default
@dataclass
class Config:
# Nexus connection
nexus_url: str = os.getenv("NEXUS_URL", "http://localhost:8081")
nexus_username: str = os.getenv("NEXUS_USERNAME", "admin")
nexus_password: str = os.getenv("NEXUS_PASSWORD", "admin123")
nexus_download_timeout: int = int(
os.getenv("NEXUS_DOWNLOAD_TIMEOUT_SECONDS", str(HTTP_TIMEOUT_DOWNLOAD))
)
nexus_api_timeout: int = int(
os.getenv("NEXUS_API_TIMEOUT_SECONDS", str(HTTP_TIMEOUT_API))
nexus_download_timeout: int = _env_int(
"NEXUS_DOWNLOAD_TIMEOUT_SECONDS", HTTP_TIMEOUT_DOWNLOAD
)
nexus_api_timeout: int = _env_int("NEXUS_API_TIMEOUT_SECONDS", HTTP_TIMEOUT_API)
# Database
database_path: str = os.getenv("DATABASE_PATH", "data/guarddog.db")
# Server
host: str = os.getenv("HOST", "0.0.0.0")
port: int = int(os.getenv("PORT", "8080"))
port: int = _env_int("PORT", 8080)
# Logging
log_level: str = os.getenv("LOG_LEVEL", "INFO")
log_syslog_host: str = os.getenv("LOG_SYSLOG_HOST", "")
log_syslog_port: int = int(os.getenv("LOG_SYSLOG_PORT", "514"))
log_syslog_port: int = _env_int("LOG_SYSLOG_PORT", 514)
log_syslog_facility: str = os.getenv("LOG_SYSLOG_FACILITY", "")
# Webhooks
webhook_secret: str = os.getenv("WEBHOOK_SECRET", "")
# Scanner
scan_timeout_seconds: int = int(os.getenv("SCAN_TIMEOUT_SECONDS", "300"))
scan_timeout_seconds: int = _env_int("SCAN_TIMEOUT_SECONDS", 300)
temp_dir: str = os.getenv("TEMP_DIR", "/tmp/guarddog-nexus")
guarddog_binary: str = os.getenv("GUARDDOG_BINARY", GUARDDOG_BINARY_FALLBACK)
max_concurrent_scans: int = int(
os.getenv("MAX_CONCURRENT_SCANS", str(DEFAULT_MAX_CONCURRENT_SCANS))
max_concurrent_scans: int = _env_int(
"MAX_CONCURRENT_SCANS", DEFAULT_MAX_CONCURRENT_SCANS
)
# LLM analysis
llm_enabled: bool = os.getenv("LLM_ENABLED", "").lower() in ("1", "true", "yes")
llm_auto_analyze: bool = os.getenv("LLM_AUTO_ANALYZE", "").lower() in ("1", "true", "yes")
llm_api_base: str = os.getenv("LLM_API_BASE", LLM_DEFAULT_API_BASE)
llm_api_key: str = os.getenv("LLM_API_KEY", "")
llm_model: str = os.getenv("LLM_MODEL", LLM_DEFAULT_MODEL)
llm_timeout: int = int(os.getenv("LLM_TIMEOUT_SECONDS", str(LLM_DEFAULT_TIMEOUT)))
llm_max_concurrent: int = int(
os.getenv("LLM_MAX_CONCURRENT_ANALYSES", "2")
)
llm_timeout: int = _env_int("LLM_TIMEOUT_SECONDS", LLM_DEFAULT_TIMEOUT)
llm_max_concurrent: int = _env_int("LLM_MAX_CONCURRENT_ANALYSES", 2)
config = Config()

View File

@@ -13,9 +13,8 @@ used across the codebase live here to avoid duplication and drift.
# harvester uses it to decide whether to download and scan.
PACKAGE_EXTENSIONS = (".tar.gz", ".tgz", ".whl", ".zip")
# Prefix used in PyPI-style asset paths ("/packages/name/ver/file")
PYPI_PATH_PREFIX = "packages"
NPM_PATH_PREFIX = "packages"
# Prefix used in PyPI/NPM asset paths ("/packages/name/ver/file")
PKG_PATH_PREFIX = "packages"
# Metadata file patterns that should never be scanned
METADATA_PATTERNS = (
@@ -39,7 +38,6 @@ DEFAULT_ECOSYSTEM = "pypi"
# ---------------------------------------------------------------------------
SEVERITY_WARNING = "WARNING"
SEVERITY_ERROR = "ERROR"
# ---------------------------------------------------------------------------
# Sorting
@@ -81,20 +79,9 @@ WEB_PER_PAGE = 50
DASHBOARD_LATEST_FLAGGED_LIMIT = 8
DASHBOARD_LATEST_SCANS_LIMIT = 10
DASHBOARD_MOST_FLAGGED_LIMIT = 8
TOP_RULES_LIMIT = 10
RECENT_FLAGGED_DAYS = 7
HEATMAP_DAYS = 14
# ---------------------------------------------------------------------------
# Database fields
# ---------------------------------------------------------------------------
MAX_PACKAGE_NAME_LENGTH = 255
MAX_PACKAGE_VERSION_LENGTH = 255
MAX_ECOSYSTEM_LENGTH = 50
SHA256_HEX_LENGTH = 64
# ---------------------------------------------------------------------------
# Scanner
@@ -114,8 +101,7 @@ SCAN_ERROR_DOWNLOAD_FAILED = "Download failed"
ERROR_MESSAGE_MAX_LENGTH = 1000
SHA256_CHUNK_SIZE = 8192
# Finding data dict keys
FINDING_KEYS = ("rule", "severity", "message", "location", "code")
# Finding severity default
DEFAULT_FINDING_SEVERITY = SEVERITY_WARNING
# ---------------------------------------------------------------------------
@@ -141,8 +127,6 @@ WEBHOOK_STATUS_IGNORED = "ignored"
# API
# ---------------------------------------------------------------------------
API_PREFIX_V1 = "/api/v1"
HEALTH_PATH = "/health"
STATIC_MOUNT_PATH = "/static"
CSV_MEDIA_TYPE = "text/csv"

View File

@@ -63,6 +63,7 @@ async def harvest(
return None
async with lock:
try:
# Re-check DB in case another task already created and finished a scan
active = await session.scalar(
select(Scan.id).where(
@@ -73,6 +74,9 @@ async def harvest(
if active:
log.info("Already scanning this URL, skipping")
return None
finally:
async with _url_lock:
_url_locks.pop(download_url, None)
scan = Scan(
package_name=package_name,
@@ -88,10 +92,9 @@ async def harvest(
await session.commit()
await session.refresh(scan)
try:
os.makedirs(config.temp_dir, exist_ok=True)
tmpdir = tempfile.mkdtemp(dir=config.temp_dir)
try:
scan.status = ScanStatus.SCANNING.value
await session.commit()
@@ -103,7 +106,7 @@ async def harvest(
await session.commit()
return scan
scan.sha256 = compute_sha256(downloaded)
scan.sha256 = await compute_sha256(downloaded)
await session.commit()
existing = await session.scalar(
@@ -148,8 +151,12 @@ async def harvest(
# Auto-trigger LLM analysis for flagged packages
llm_reports = []
if scan.flagged and config.llm_enabled:
if scan.flagged and config.llm_enabled and config.llm_auto_analyze:
try:
llm_reports = await _run_llm_analysis(created_findings, session)
except Exception as e:
log.error("LLM analysis failed for %s==%s: %s", package_name, package_version, e)
llm_reports = []
if scan.flagged:
extra = {
@@ -199,11 +206,18 @@ async def _run_llm_analysis(findings: list[Finding], session: AsyncSession) -> l
"""Run LLM analysis on findings and persist reports to the database."""
from .llm import analyze_finding
# Mark all as analyzing so the UI shows a spinner
for finding in findings:
finding.report = {"status": "analyzing"}
await session.commit()
reports = []
for finding in findings:
report = await analyze_finding(finding.data)
if report:
finding.report = report
reports.append(report)
else:
finding.report = None
await session.commit()
return reports

View File

@@ -1,5 +1,6 @@
"""Sonatype Nexus REST API client using httpx async."""
import asyncio
import hashlib
import os
@@ -7,8 +8,7 @@ import httpx
from ..config import config
from ..constants import (
NPM_PATH_PREFIX,
PYPI_PATH_PREFIX,
PKG_PATH_PREFIX,
SHA256_CHUNK_SIZE,
)
from ..logging_setup import log
@@ -20,7 +20,7 @@ def extract_pypi_info(asset_path: str) -> tuple[str, str] | None:
Path format: packages/requests/2.31.0/requests-2.31.0.tar.gz
"""
parts = asset_path.strip("/").split("/")
if len(parts) >= 3 and parts[0] == PYPI_PATH_PREFIX:
if len(parts) >= 3 and parts[0] == PKG_PATH_PREFIX:
return parts[1], parts[2]
return None
@@ -35,8 +35,8 @@ def extract_go_info(asset_path: str) -> tuple[str, str] | None:
idx = cleaned.find("/@v/")
if idx == -1:
return None
if cleaned.startswith(PYPI_PATH_PREFIX + "/"):
module = cleaned[len(PYPI_PATH_PREFIX) + 1 : idx]
if cleaned.startswith(PKG_PATH_PREFIX + "/"):
module = cleaned[len(PKG_PATH_PREFIX) + 1 : idx]
else:
module = cleaned[:idx]
if not module:
@@ -56,7 +56,7 @@ def extract_npm_info(asset_path: str) -> tuple[str, str] | None:
Path format: packages/react/-/react-18.2.0.tgz
"""
parts = asset_path.strip("/").split("/")
if len(parts) < 4 or parts[0] != NPM_PATH_PREFIX:
if len(parts) < 4 or parts[0] != PKG_PATH_PREFIX:
return None
name = parts[1]
# Last segment: <name>-<version>.tgz
@@ -100,14 +100,19 @@ async def download_asset(download_url: str, dest_dir: str) -> str | None:
try:
response = await client.get(download_url)
response.raise_for_status()
with open(dest_path, "wb") as f:
f.write(response.content)
content = response.content
await asyncio.to_thread(_write_file, dest_path, content)
return dest_path
except Exception as e:
log.warning("Failed to download %s: %s", download_url, e)
return None
def _write_file(path: str, content: bytes) -> None:
with open(path, "wb") as f:
f.write(content)
async def nexus_get(path: str) -> httpx.Response:
"""Make an authenticated GET request to Nexus REST API."""
auth = httpx.BasicAuth(config.nexus_username, config.nexus_password)
@@ -117,7 +122,11 @@ async def nexus_get(path: str) -> httpx.Response:
return await client.get(f"{config.nexus_url.rstrip('/')}{path}")
def compute_sha256(filepath: str) -> str:
async def compute_sha256(filepath: str) -> str:
return await asyncio.to_thread(_compute_sha256_sync, filepath)
def _compute_sha256_sync(filepath: str) -> str:
h = hashlib.sha256()
with open(filepath, "rb") as f:
for chunk in iter(lambda: f.read(SHA256_CHUNK_SIZE), b""):

View File

@@ -43,6 +43,9 @@ async def scan_package(filepath: str, ecosystem: str = DEFAULT_ECOSYSTEM) -> dic
log.error("GuardDog exited %d: %s", proc.returncode, stderr.decode())
return {"findings": [], "errors": [stderr.decode().strip()]}
if proc.returncode == 1 and stderr:
log.warning("GuardDog stderr (exit 1): %s", stderr.decode().strip())
try:
data = json.loads(stdout.decode())
except json.JSONDecodeError:
@@ -96,6 +99,17 @@ def _normalize_output(data: dict) -> dict:
)
elif isinstance(value, dict) and not value:
continue
elif isinstance(value, dict):
# Non-empty dict — treat as a single finding
findings.append(
{
"rule": rule_name,
"severity": value.get("severity", DEFAULT_FINDING_SEVERITY),
"message": value.get("message", ""),
"location": value.get("location", ""),
"code": value.get("code", ""),
}
)
errors = data.get("errors", {})
if isinstance(errors, dict):

View File

@@ -68,8 +68,25 @@ async def init_db():
async with _engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await _migrate()
await _ensure_indexes()
async def get_session() -> AsyncSession:
async with _async_session() as session:
yield session
async def _ensure_indexes():
"""Create indexes that are not covered by ORM model definitions."""
indexes = [
"CREATE INDEX IF NOT EXISTS idx_scans_status ON scans(status)",
"CREATE INDEX IF NOT EXISTS idx_scans_sha256 ON scans(sha256)",
"CREATE INDEX IF NOT EXISTS idx_scans_package_name ON scans(package_name)",
"CREATE INDEX IF NOT EXISTS idx_scans_package_version ON scans(package_version)",
"CREATE INDEX IF NOT EXISTS idx_scans_flagged ON scans(flagged)",
"CREATE INDEX IF NOT EXISTS idx_scans_nexus_asset_url ON scans(nexus_asset_url)",
"CREATE INDEX IF NOT EXISTS idx_findings_scan_id ON findings(scan_id)",
]
async with _engine.begin() as conn:
for sql in indexes:
await conn.execute(text(sql))

View File

@@ -5,16 +5,13 @@ Eliminates ~90% duplicated SQL between api/*.py and web/routes.py.
import datetime
from sqlalchemy import Integer, cast, func, select, text
from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from guarddog_nexus.constants import (
DASHBOARD_LATEST_FLAGGED_LIMIT,
DASHBOARD_LATEST_SCANS_LIMIT,
DASHBOARD_MOST_FLAGGED_LIMIT,
HEATMAP_DAYS,
JSON_PATH_RULE,
JSON_PATH_SEVERITY,
PACKAGE_SORT_FIELDS,
RECENT_FLAGGED_DAYS,
SCAN_SORT_FIELDS,
@@ -143,15 +140,13 @@ async def get_dashboard_stats(session: AsyncSession) -> dict:
)
total_findings = await session.scalar(select(func.count(Finding.id)))
warnings_count = await session.scalar(
llm_analyzed = await session.scalar(
select(func.count(Finding.id)).where(
func.json_extract(Finding.data, JSON_PATH_SEVERITY) == "WARNING"
func.json_extract(Finding.report, "$.verdict").isnot(None)
)
)
errors_count = await session.scalar(
select(func.count(Finding.id)).where(
func.json_extract(Finding.data, JSON_PATH_SEVERITY) == "ERROR"
)
llm_pending = await session.scalar(
select(func.count(Finding.id)).where(Finding.report.is_(None))
)
latest_flagged = (
@@ -191,48 +186,15 @@ async def get_dashboard_stats(session: AsyncSession) -> dict:
)
).all()
most_flagged = (
await session.execute(
select(
Scan.package_name,
Scan.package_version,
func.sum(Scan.total_findings).label("total"),
func.max(Scan.started_at).label("last_scan"),
)
.where(Scan.flagged == True)
.group_by(Scan.package_name, Scan.package_version)
.order_by(func.sum(Scan.total_findings).desc())
.limit(DASHBOARD_MOST_FLAGGED_LIMIT)
)
).all()
max_findings = max((r.total for r in most_flagged), default=1)
days_raw = (
await session.execute(
select(
func.date(Scan.started_at).label("day"),
func.count(Scan.id).label("cnt"),
func.sum(cast(Scan.flagged, Integer)).label("flagged_cnt"),
)
.where(Scan.started_at >= func.datetime("now", f"-{HEATMAP_DAYS} days"))
.group_by("day")
.order_by("day")
)
).all()
return {
"total_scans": total_scans or 0,
"flagged_scans": flagged_scans or 0,
"recent_flagged": recent_flagged or 0,
"total_findings": total_findings or 0,
"warnings_count": warnings_count or 0,
"errors_count": errors_count or 0,
"llm_analyzed": llm_analyzed or 0,
"llm_pending": llm_pending or 0,
"latest_flagged": latest_flagged,
"latest_scans": latest_scans,
"top_rules": [{"rule": r.rule, "count": r.cnt} for r in top_rules],
"most_flagged": most_flagged,
"max_findings": max_findings,
"days": [(d.day, d.cnt, d.flagged_cnt) for d in days_raw],
"now": datetime.datetime.now(datetime.timezone.utc),
}

View File

@@ -17,14 +17,12 @@ _STRINGS = {
"heading_packages": {"en": "Packages", "ru": "Пакеты"},
"heading_latest_flagged": {"en": "Latest Flagged", "ru": "Последние обнаружения"},
"heading_latest_scans": {"en": "Latest Scans", "ru": "Последние сканирования"},
"heading_findings": {"en": "Findings", "ru": "Находки"},
"heading_findings_count": {"en": "Findings ({})", "ru": "Находки ({})"},
"heading_scans_count": {"en": "Scans ({})", "ru": "Сканирований ({})"},
"col_id": {"en": "ID", "ru": "ID"},
"col_package": {"en": "Package", "ru": "Пакет"},
"col_version": {"en": "Version", "ru": "Версия"},
"col_repo": {"en": "Repo", "ru": "Репозиторий"},
"col_repository": {"en": "Repository", "ru": "Репозиторий"},
"col_status": {"en": "Status", "ru": "Статус"},
"col_findings": {"en": "Findings", "ru": "Находки"},
"col_time": {"en": "Time", "ru": "Время"},
@@ -82,6 +80,11 @@ _STRINGS = {
"ru": "⚠ Анализ сгенерирован AI — может содержать неточности. "
"Всегда проверяйте находки перед принятием мер.",
},
"llm_analyzing": {"en": "Analyzing...", "ru": "Анализирую..."},
"llm_retry": {"en": "Retry", "ru": "Повторить"},
"llm_analyzed": {"en": "LLM analyzed", "ru": "LLM проанализ."},
"llm_pending": {"en": "Pending", "ru": "Ожидают"},
"not_found": {"en": "Not found", "ru": "Не найдено"},
"breadcrumb_home": {"en": "Home", "ru": "Главная"},
"breadcrumb_dashboard": {"en": "Dashboard", "ru": "Панель"},
"breadcrumb_scans": {"en": "Scans", "ru": "Сканирования"},

View File

@@ -4,7 +4,6 @@ from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import config
from ..constants import (
DEFAULT_OFFSET,
DEFAULT_PAGE_SIZE,
@@ -55,32 +54,3 @@ async def list_findings(
}
@router.post("/{finding_id}/analyze")
async def analyze_finding_endpoint(
finding_id: int,
session: AsyncSession = Depends(get_session),
):
"""Manually trigger LLM analysis for a single finding."""
if not config.llm_enabled:
return {"detail": "LLM analysis is disabled"}
finding = await session.scalar(
select(Finding).where(Finding.id == finding_id)
)
if not finding:
return {"detail": "Not found"}
from ..core.llm import analyze_finding
report = await analyze_finding(finding.data)
if report is None:
return {"detail": "LLM analysis failed"}
finding.report = report
await session.commit()
return {
"id": finding.id,
**finding.data,
"report": report,
}

View File

@@ -127,7 +127,7 @@ async def scan_stats(session: AsyncSession = Depends(get_session)):
"total_findings": dashboard["total_findings"],
"top_rules": dashboard["top_rules"],
"latest_scan_at": dashboard["latest_flagged"][0].started_at.isoformat()
if dashboard["latest_flagged"]
if dashboard["latest_flagged"] and dashboard["latest_flagged"][0].started_at
else None,
}

View File

@@ -1,6 +1,6 @@
"""Prometheus-compatible metrics endpoint."""
import time
import calendar
from fastapi import APIRouter, Depends, Response
from sqlalchemy import func, select
@@ -69,7 +69,7 @@ async def metrics(session: AsyncSession = Depends(get_session)):
lines.append(f'guarddog_scans_by_ecosystem{{ecosystem="{eco}"}} {count}')
if latest:
ts = time.mktime(latest.timetuple())
ts = calendar.timegm(latest.timetuple())
lines += [
"",
"# HELP guarddog_last_scan_timestamp_seconds Unix timestamp of most recent scan.",

View File

@@ -1,5 +1,6 @@
"""Web UI routes — Jinja2 + htmx pages."""
import asyncio
from urllib.parse import unquote
from fastapi import APIRouter, Depends, Request
@@ -8,6 +9,7 @@ from jinja2 import Environment, PackageLoader, select_autoescape
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import config
from ..constants import (
APP_PACKAGE,
DEFAULT_SORT_BY_PACKAGES,
@@ -26,11 +28,15 @@ from ..i18n import t as _t
router = APIRouter(tags=["web"])
_llm_locks: dict[int, asyncio.Lock] = {}
_llm_lock = asyncio.Lock()
_jinja_env = Environment(
loader=PackageLoader(APP_PACKAGE, "web/templates"),
autoescape=select_autoescape(),
)
_jinja_env.globals["t"] = _t
_jinja_env.globals["config"] = config
def _render(name: str, **context) -> HTMLResponse:
@@ -109,7 +115,7 @@ async def scan_detail(
.options(selectinload(Scan.findings))
)
if not scan:
return HTMLResponse("<h1>Not found</h1>", status_code=404)
return HTMLResponse(f"<h1>{_t('not_found', request.state.lang)}</h1>", status_code=404)
return _render("scan_detail.html", scan=scan, request=request)
@@ -186,7 +192,7 @@ async def package_detail(
)
if not scans:
return HTMLResponse("<h1>Not found</h1>", status_code=404)
return HTMLResponse(f"<h1>{_t('not_found', request.state.lang)}</h1>", status_code=404)
all_findings = []
for s in scans:
@@ -205,31 +211,72 @@ async def package_detail(
@router.post("/api/v1/findings/{finding_id}/analyze", response_class=HTMLResponse)
async def analyze_finding_htmx(
finding_id: int,
request: Request,
retry: bool = False,
session: AsyncSession = Depends(get_session),
):
"""HTMX fragment: trigger LLM analysis and return styled result HTML."""
from ..config import config
from ..core.llm import analyze_finding
lang = request.state.lang
if not config.llm_enabled:
msg = _t("llm_disabled", lang)
return HTMLResponse(
'<div class="llm-actions"><small class="flagged">LLM analysis is disabled</small></div>'
f'<div class="llm-actions"><small class="flagged">{msg}</small></div>'
)
finding = await session.scalar(select(Finding).where(Finding.id == finding_id))
if not finding:
msg = _t("llm_not_found", lang)
return HTMLResponse(
'<div class="llm-actions"><small class="flagged">Finding not found</small></div>',
f'<div class="llm-actions"><small class="flagged">{msg}</small></div>',
status_code=404,
)
if not retry and finding.report and finding.report.get("verdict"):
return _render(
"_llm_report_fragment.html",
report=finding.report,
finding_id=finding_id,
request=request,
)
if not retry and finding.report and finding.report.get("status") == "analyzing":
return _render("_llm_spinner.html", request=request)
async with _llm_lock:
if finding_id not in _llm_locks:
_llm_locks[finding_id] = asyncio.Lock()
lock = _llm_locks[finding_id]
if lock.locked():
return _render("_llm_spinner.html", request=request)
async with lock:
try:
finding.report = {"status": "analyzing"}
await session.commit()
report = await analyze_finding(finding.data)
finally:
async with _llm_lock:
_llm_locks.pop(finding_id, None)
if report is None:
finding.report = None
await session.commit()
msg = _t("llm_failed", lang)
return HTMLResponse(
'<div class="llm-actions"><small class="flagged">LLM analysis failed</small></div>'
f'<div class="llm-actions"><small class="flagged">{msg}</small></div>'
)
finding.report = report
await session.commit()
return _render("_llm_report_fragment.html", report=report)
return _render(
"_llm_report_fragment.html",
report=report,
finding_id=finding_id,
request=request,
)

View File

@@ -86,10 +86,10 @@ async def nexus_webhook(
try:
data = json.loads(payload.decode("utf-8"))
except json.JSONDecodeError:
log.warning("Webhook received invalid JSON")
except (json.JSONDecodeError, UnicodeDecodeError):
log.warning("Webhook received invalid body")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid JSON"
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid request body"
)
action = data.get("action", "").upper()
@@ -112,6 +112,11 @@ async def nexus_webhook(
action, initiator, source_ip)
repository = data.get("repositoryName", "")
if not repository:
log.warning("Webhook rejected: missing repositoryName")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Missing repository name"
)
asset = data.get("asset")
component = data.get("component")

View File

@@ -15,25 +15,8 @@
.severity-ERROR { color: var(--pico-color-red-400); }
/* ------------------------------------------------------------------ */
/* Dashboard mini-bar */
/* Dashboard blocks */
/* ------------------------------------------------------------------ */
.stat-minibar {
display: flex;
gap: 1.5rem;
padding: 0.6rem 0;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--pico-color-gray-500);
font-size: 0.9rem;
opacity: 0.9;
}
/* Dashboard block grid (2 cols → 1 on mobile) */
.dashboard-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
margin-bottom: 1rem;
}
.dash-block {
padding: 1rem;
@@ -57,51 +40,6 @@ table.compact { font-size: 0.82rem; }
table.compact th,
table.compact td { padding: 0.35rem 0.5rem; }
/* ------------------------------------------------------------------ */
/* Heatmap */
/* ------------------------------------------------------------------ */
.heatmap {
display: flex;
align-items: flex-end;
gap: 2px;
height: 40px;
margin: 0.4rem 0 0 0;
}
.heatmap-day {
flex: 1;
display: flex;
flex-direction: column;
justify-content: flex-end;
position: relative;
}
.heatmap-day .bar {
border-radius: 2px 2px 0 0;
opacity: 0.8;
transition: height 0.3s ease, opacity 0.2s;
}
.heatmap-day:hover .bar { opacity: 1; }
.heatmap-day .tooltip {
display: none;
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: var(--pico-color-gray-700);
color: var(--pico-color-white);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.7rem;
white-space: nowrap;
z-index: 10;
margin-bottom: 4px;
}
.heatmap-day:hover .tooltip { display: block; }
/* ------------------------------------------------------------------ */
/* Scan info block (detail page) */
/* ------------------------------------------------------------------ */
@@ -167,17 +105,6 @@ table.compact td { padding: 0.35rem 0.5rem; }
margin-bottom: 0;
}
.finding-header-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.finding-header-row h2 {
margin-bottom: 0;
}
/* ------------------------------------------------------------------ */
/* LLM report — verdict-based colour scheme */
/* ------------------------------------------------------------------ */
@@ -237,6 +164,15 @@ table.compact td { padding: 0.35rem 0.5rem; }
.llm-actions { margin-top: 0.5rem; }
.llm-actions button { font-size: 0.8rem; }
.llm-retry {
margin-left: auto;
font-size: 0.7rem;
opacity: 0.5;
cursor: pointer;
border-bottom: 1px dashed;
}
.llm-retry:hover { opacity: 0.8; }
.llm-disclaimer {
margin-top: 0.6rem;
font-size: 0.72rem;
@@ -269,18 +205,6 @@ table.compact td { padding: 0.35rem 0.5rem; }
.copy-btn:hover { background: var(--pico-color-gray-600); }
.copy-btn.copied { color: var(--pico-color-green-400); border-color: var(--pico-color-green-400); }
.toggle-all-btn {
font-size: 0.8rem;
cursor: pointer;
background: none;
border: 1px solid var(--pico-color-gray-500);
padding: 0.2rem 0.6rem;
border-radius: 3px;
color: var(--pico-color-gray-300);
}
.toggle-all-btn:hover { background: var(--pico-color-gray-600); }
.htmx-indicator { display: inline; }
/* ------------------------------------------------------------------ */
@@ -336,9 +260,7 @@ th.sortable.active .sort-icon { opacity: 1; }
/* Responsive */
/* ------------------------------------------------------------------ */
@media (max-width: 768px) {
.dashboard-grid { grid-template-columns: 1fr; }
.scan-info-grid { grid-template-columns: 1fr 1fr; }
.stat-minibar { flex-wrap: wrap; gap: 0.75rem; }
.filter-bar { flex-direction: column; align-items: stretch; }
nav ul { flex-wrap: wrap; }
table, table.compact { font-size: 0.78rem; }
@@ -347,14 +269,13 @@ th.sortable.active .sort-icon { opacity: 1; }
@media (max-width: 480px) {
.scan-info-grid { grid-template-columns: 1fr; }
.stat-minibar { font-size: 0.8rem; }
}
/* ------------------------------------------------------------------ */
/* Print */
/* ------------------------------------------------------------------ */
@media print {
nav, .filter-bar, .copy-btn, .toggle-all-btn, nav.sticky,
nav, .filter-bar, .copy-btn, nav.sticky,
.llm-actions, .breadcrumbs { display: none !important; }
body { background: white; color: black; }
.llm-report { border: 1px solid #ccc; background: none; }

View File

@@ -4,8 +4,15 @@
{% if report.severity_rating %}
<span class="llm-severity">{{ report.severity_rating }}</span>
{% endif %}
{% if config.llm_enabled and not config.llm_auto_analyze %}
<span class="llm-retry"
hx-post="/api/v1/findings/{{ finding_id }}/analyze?retry=1"
hx-target="closest .llm-report"
hx-swap="outerHTML"
hx-indicator="closest .llm-report">{{ t('llm_retry', request.state.lang) }}</span>
{% endif %}
</div>
<p class="llm-summary">{{ report.summary }}</p>
<p class="llm-analysis">{{ report.analysis }}</p>
<p class="llm-disclaimer">⚠ AI-generated analysis — may contain inaccuracies. Always verify findings before taking action.</p>
<p class="llm-disclaimer">{{ t('llm_disclaimer', request.state.lang) }}</p>
</div>

View File

@@ -0,0 +1,3 @@
<div class="llm-actions">
<small><span class="spinner"></span> {{ t('llm_analyzing', request.state.lang) }}</small>
</div>

View File

@@ -38,14 +38,5 @@
</tbody>
</table>
{% set total_pages = (total // per_page) + (1 if total % per_page else 0) %}
{% if total_pages > 1 %}
<nav>
<ul>
<li>{% if page > 1 %}<a href="?page={{ page - 1 }}&flagged={{ flagged_filter }}&search={{ search }}&sort_by={{ sort_by }}&sort_dir={{ sort_dir }}">{{ t('btn_prev', request.state.lang) }}</a>{% else %}<span>{{ t('btn_prev', request.state.lang) }}</span>{% endif %}</li>
<li><small>{{ t('page_label', request.state.lang) }} {{ page }} {{ t('page_of', request.state.lang) }} {{ total_pages }}</small></li>
<li>{% if page < total_pages %}<a href="?page={{ page + 1 }}&flagged={{ flagged_filter }}&search={{ search }}&sort_by={{ sort_by }}&sort_dir={{ sort_dir }}">{{ t('btn_next', request.state.lang) }}</a>{% else %}<span>{{ t('btn_next', request.state.lang) }}</span>{% endif %}</li>
</ul>
</nav>
{% endif %}
{% include "_pagination.html" %}
<small style="opacity: 0.5;">{{ t('total_packages', request.state.lang, total) }}</small>

View File

@@ -0,0 +1,10 @@
{% set total_pages = (total // per_page) + (1 if total % per_page else 0) %}
{% if total_pages > 1 %}
<nav>
<ul>
<li>{% if page > 1 %}<a href="?page={{ page - 1 }}{% if flagged_filter %}&flagged={{ flagged_filter }}{% endif %}{% if search %}&search={{ search }}{% endif %}{% if status_filter %}&status={{ status_filter }}{% endif %}&sort_by={{ sort_by }}&sort_dir={{ sort_dir }}">{{ t('btn_prev', request.state.lang) }}</a>{% else %}<span>{{ t('btn_prev', request.state.lang) }}</span>{% endif %}</li>
<li><small>{{ t('page_label', request.state.lang) }} {{ page }} {{ t('page_of', request.state.lang) }} {{ total_pages }}</small></li>
<li>{% if page < total_pages %}<a href="?page={{ page + 1 }}{% if flagged_filter %}&flagged={{ flagged_filter }}{% endif %}{% if search %}&search={{ search }}{% endif %}{% if status_filter %}&status={{ status_filter }}{% endif %}&sort_by={{ sort_by }}&sort_dir={{ sort_dir }}">{{ t('btn_next', request.state.lang) }}</a>{% else %}<span>{{ t('btn_next', request.state.lang) }}</span>{% endif %}</li>
</ul>
</nav>
{% endif %}

View File

@@ -28,7 +28,7 @@
<td>{{ s.package_version }}</td>
<td>{{ s.repository }}</td>
<td>
{% if s.status == 'scanning' %}<span class="status-scanning"><span class="spinner"></span>scanning</span>{% else %}<span class="status-{{ s.status }}">{{ s.status }}</span>{% endif %}
{% with status=s.status %}{% include "_status_badge.html" %}{% endwith %}
</td>
<td>{% if s.flagged %}<span class="flagged">{{ s.total_findings }}</span>{% else %}<span class="clean">0</span>{% endif %}</td>
<td>{{ s.started_at.strftime('%Y-%m-%d %H:%M') if s.started_at }}</td>
@@ -42,14 +42,5 @@
</tbody>
</table>
{% set total_pages = (total // per_page) + (1 if total % per_page else 0) %}
{% if total_pages > 1 %}
<nav>
<ul>
<li>{% if page > 1 %}<a href="?page={{ page - 1 }}&flagged={{ flagged_filter }}&search={{ search }}&status={{ status_filter }}&sort_by={{ sort_by }}&sort_dir={{ sort_dir }}">{{ t('btn_prev', request.state.lang) }}</a>{% else %}<span>{{ t('btn_prev', request.state.lang) }}</span>{% endif %}</li>
<li><small>{{ t('page_label', request.state.lang) }} {{ page }} {{ t('page_of', request.state.lang) }} {{ total_pages }}</small></li>
<li>{% if page < total_pages %}<a href="?page={{ page + 1 }}&flagged={{ flagged_filter }}&search={{ search }}&status={{ status_filter }}&sort_by={{ sort_by }}&sort_dir={{ sort_dir }}">{{ t('btn_next', request.state.lang) }}</a>{% else %}<span>{{ t('btn_next', request.state.lang) }}</span>{% endif %}</li>
</ul>
</nav>
{% endif %}
{% include "_pagination.html" %}
<small style="opacity: 0.5;">{{ t('total_scans', request.state.lang, total) }}</small>

View File

@@ -0,0 +1 @@
{% if status == 'scanning' %}<span class="status-scanning"><span class="spinner"></span>scanning</span>{% else %}<span class="status-{{ status }}">{{ status }}</span>{% endif %}

View File

@@ -1,3 +1,10 @@
{% if total_findings %}
<div style="display:flex; gap:1.5rem; padding:0.3rem 0; margin-bottom:1rem; border-bottom:1px solid var(--pico-color-gray-500); font-size:0.82rem; opacity:0.8;">
<span>{{ t('col_findings', request.state.lang) }}: <strong>{{ total_findings }}</strong></span>
<span>{{ t('llm_analyzed', request.state.lang) }}: <strong>{{ llm_analyzed }}</strong></span>
<span>{{ t('llm_pending', request.state.lang) }}: <strong>{{ llm_pending }}</strong></span>
</div>
{% endif %}
{% if latest_flagged %}
<article class="dash-block dash-block-warn">
<h3>{{ t('heading_latest_flagged', request.state.lang) }}</h3>
@@ -30,7 +37,7 @@
<td>{{ s.package_version }}</td>
<td><small>{{ s.repository }}</small></td>
<td>
{% if s.status == 'scanning' %}<span class="status-scanning"><span class="spinner"></span>scanning</span>{% else %}<span class="status-{{ s.status }}">{{ s.status }}</span>{% endif %}
{% with status=s.status %}{% include "_status_badge.html" %}{% endwith %}
</td>
<td>{% if s.flagged %}<span class="flagged">⚠ {{ s.total_findings }}</span>{% elif s.status == 'completed' %}<span class="clean"></span>{% else %}<span>-</span>{% endif %}</td>
<td>{{ s.started_at.strftime('%m-%d %H:%M') if s.started_at }}</td>

View File

@@ -24,7 +24,7 @@
<td><a href="/scans/{{ s.id }}">#{{ s.id }}</a></td>
<td>{{ s.repository }}</td>
<td>
{% if s.status == 'scanning' %}<span class="status-scanning"><span class="spinner"></span>scanning</span>{% else %}<span class="status-{{ s.status }}">{{ s.status }}</span>{% endif %}
{% with status=s.status %}{% include "_status_badge.html" %}{% endwith %}
</td>
<td>{% if s.flagged %}<span class="flagged">{{ s.total_findings }}</span>{% else %}<span class="clean">0</span>{% endif %}</td>
<td>{{ s.started_at.strftime('%Y-%m-%d %H:%M') if s.started_at }}</td>
@@ -54,19 +54,28 @@
<pre><code id="code-{{ f.id }}">{{ f.data.code }}</code></pre>
{% endif %}
{% if f.report %}
{% if f.report and f.report.status == "analyzing" %}
{% include "_llm_spinner.html" %}
{% elif f.report and f.report.verdict %}
<div class="llm-report llm-{{ f.report.verdict }}">
<div class="llm-header">
<span class="llm-badge llm-badge-{{ f.report.verdict }}">{{ f.report.verdict }}</span>
{% if f.report.severity_rating %}
<span class="llm-severity">{{ f.report.severity_rating }}</span>
{% endif %}
{% if config.llm_enabled and not config.llm_auto_analyze %}
<span class="llm-retry"
hx-post="/api/v1/findings/{{ f.id }}/analyze?retry=1"
hx-target="closest .llm-report"
hx-swap="outerHTML"
hx-indicator="closest .llm-report">{{ t('llm_retry', request.state.lang) }}</span>
{% endif %}
</div>
<p class="llm-summary">{{ f.report.summary }}</p>
<p class="llm-analysis">{{ f.report.analysis }}</p>
<p class="llm-disclaimer">{{ t('llm_disclaimer', request.state.lang) }}</p>
</div>
{% else %}
{% elif config.llm_enabled and not config.llm_auto_analyze %}
<div class="llm-actions" id="llm-{{ f.id }}">
<button class="outline"
hx-post="/api/v1/findings/{{ f.id }}/analyze"

View File

@@ -19,7 +19,7 @@
<div><strong>{{ t('scan_info_ecosystem', request.state.lang) }}</strong><br>{{ scan.ecosystem }}</div>
<div><strong>{{ t('scan_info_repository', request.state.lang) }}</strong><br>{{ scan.repository }}</div>
<div><strong>{{ t('scan_info_status', request.state.lang) }}</strong><br>
{% if scan.status == 'scanning' %}<span class="status-scanning"><span class="spinner"></span>scanning</span>{% else %}<span class="status-{{ scan.status }}">{{ scan.status }}</span>{% endif %}
{% with status=scan.status %}{% include "_status_badge.html" %}{% endwith %}
</div>
<div><strong>{{ t('scan_info_sha256', request.state.lang) }}</strong><br><code class="sha256">{{ scan.sha256 or '-' }}</code></div>
<div><strong>{{ t('scan_info_started', request.state.lang) }}</strong><br>{{ scan.started_at.strftime('%Y-%m-%d %H:%M') if scan.started_at }}</div>
@@ -50,19 +50,28 @@
<pre><code id="code-{{ f.id }}">{{ f.data.code }}</code></pre>
{% endif %}
{% if f.report %}
{% if f.report and f.report.status == "analyzing" %}
{% include "_llm_spinner.html" %}
{% elif f.report and f.report.verdict %}
<div class="llm-report llm-{{ f.report.verdict }}">
<div class="llm-header">
<span class="llm-badge llm-badge-{{ f.report.verdict }}">{{ f.report.verdict }}</span>
{% if f.report.severity_rating %}
<span class="llm-severity">{{ f.report.severity_rating }}</span>
{% endif %}
{% if config.llm_enabled and not config.llm_auto_analyze %}
<span class="llm-retry"
hx-post="/api/v1/findings/{{ f.id }}/analyze?retry=1"
hx-target="closest .llm-report"
hx-swap="outerHTML"
hx-indicator="closest .llm-report">{{ t('llm_retry', request.state.lang) }}</span>
{% endif %}
</div>
<p class="llm-summary">{{ f.report.summary }}</p>
<p class="llm-analysis">{{ f.report.analysis }}</p>
<p class="llm-disclaimer">{{ t('llm_disclaimer', request.state.lang) }}</p>
</div>
{% else %}
{% elif config.llm_enabled and not config.llm_auto_analyze %}
<div class="llm-actions" id="llm-{{ f.id }}">
<button class="outline"
hx-post="/api/v1/findings/{{ f.id }}/analyze"