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:
@@ -31,6 +31,7 @@ NEXUS_API_TIMEOUT_SECONDS=30
|
|||||||
|
|
||||||
# LLM analysis (optional — set LLM_ENABLED=1 to activate)
|
# LLM analysis (optional — set LLM_ENABLED=1 to activate)
|
||||||
LLM_ENABLED=0
|
LLM_ENABLED=0
|
||||||
|
LLM_AUTO_ANALYZE=0
|
||||||
LLM_API_BASE=https://api.openai.com/v1
|
LLM_API_BASE=https://api.openai.com/v1
|
||||||
LLM_API_KEY=
|
LLM_API_KEY=
|
||||||
LLM_MODEL=gpt-4o-mini
|
LLM_MODEL=gpt-4o-mini
|
||||||
|
|||||||
12
AGENTS.md
12
AGENTS.md
@@ -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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ services:
|
|||||||
HOST: "0.0.0.0"
|
HOST: "0.0.0.0"
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
LLM_ENABLED: "${LLM_ENABLED:-0}"
|
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_BASE: "${LLM_API_BASE:-https://api.openai.com/v1}"
|
||||||
LLM_API_KEY: "${LLM_API_KEY:-}"
|
LLM_API_KEY: "${LLM_API_KEY:-}"
|
||||||
LLM_MODEL: "${LLM_MODEL:-gpt-4o-mini}"
|
LLM_MODEL: "${LLM_MODEL:-gpt-4o-mini}"
|
||||||
|
|||||||
@@ -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
|
@dataclass
|
||||||
class Config:
|
class Config:
|
||||||
# Nexus connection
|
# Nexus connection
|
||||||
nexus_url: str = os.getenv("NEXUS_URL", "http://localhost:8081")
|
nexus_url: str = os.getenv("NEXUS_URL", "http://localhost:8081")
|
||||||
nexus_username: str = os.getenv("NEXUS_USERNAME", "admin")
|
nexus_username: str = os.getenv("NEXUS_USERNAME", "admin")
|
||||||
nexus_password: str = os.getenv("NEXUS_PASSWORD", "admin123")
|
nexus_password: str = os.getenv("NEXUS_PASSWORD", "admin123")
|
||||||
nexus_download_timeout: int = int(
|
nexus_download_timeout: int = _env_int(
|
||||||
os.getenv("NEXUS_DOWNLOAD_TIMEOUT_SECONDS", str(HTTP_TIMEOUT_DOWNLOAD))
|
"NEXUS_DOWNLOAD_TIMEOUT_SECONDS", HTTP_TIMEOUT_DOWNLOAD
|
||||||
)
|
|
||||||
nexus_api_timeout: int = int(
|
|
||||||
os.getenv("NEXUS_API_TIMEOUT_SECONDS", str(HTTP_TIMEOUT_API))
|
|
||||||
)
|
)
|
||||||
|
nexus_api_timeout: int = _env_int("NEXUS_API_TIMEOUT_SECONDS", HTTP_TIMEOUT_API)
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
database_path: str = os.getenv("DATABASE_PATH", "data/guarddog.db")
|
database_path: str = os.getenv("DATABASE_PATH", "data/guarddog.db")
|
||||||
|
|
||||||
# Server
|
# Server
|
||||||
host: str = os.getenv("HOST", "0.0.0.0")
|
host: str = os.getenv("HOST", "0.0.0.0")
|
||||||
port: int = int(os.getenv("PORT", "8080"))
|
port: int = _env_int("PORT", 8080)
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
log_level: str = os.getenv("LOG_LEVEL", "INFO")
|
log_level: str = os.getenv("LOG_LEVEL", "INFO")
|
||||||
log_syslog_host: str = os.getenv("LOG_SYSLOG_HOST", "")
|
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", "")
|
log_syslog_facility: str = os.getenv("LOG_SYSLOG_FACILITY", "")
|
||||||
|
|
||||||
# Webhooks
|
# Webhooks
|
||||||
webhook_secret: str = os.getenv("WEBHOOK_SECRET", "")
|
webhook_secret: str = os.getenv("WEBHOOK_SECRET", "")
|
||||||
|
|
||||||
# Scanner
|
# 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")
|
temp_dir: str = os.getenv("TEMP_DIR", "/tmp/guarddog-nexus")
|
||||||
guarddog_binary: str = os.getenv("GUARDDOG_BINARY", GUARDDOG_BINARY_FALLBACK)
|
guarddog_binary: str = os.getenv("GUARDDOG_BINARY", GUARDDOG_BINARY_FALLBACK)
|
||||||
max_concurrent_scans: int = int(
|
max_concurrent_scans: int = _env_int(
|
||||||
os.getenv("MAX_CONCURRENT_SCANS", str(DEFAULT_MAX_CONCURRENT_SCANS))
|
"MAX_CONCURRENT_SCANS", DEFAULT_MAX_CONCURRENT_SCANS
|
||||||
)
|
)
|
||||||
|
|
||||||
# LLM analysis
|
# LLM analysis
|
||||||
llm_enabled: bool = os.getenv("LLM_ENABLED", "").lower() in ("1", "true", "yes")
|
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_base: str = os.getenv("LLM_API_BASE", LLM_DEFAULT_API_BASE)
|
||||||
llm_api_key: str = os.getenv("LLM_API_KEY", "")
|
llm_api_key: str = os.getenv("LLM_API_KEY", "")
|
||||||
llm_model: str = os.getenv("LLM_MODEL", LLM_DEFAULT_MODEL)
|
llm_model: str = os.getenv("LLM_MODEL", LLM_DEFAULT_MODEL)
|
||||||
llm_timeout: int = int(os.getenv("LLM_TIMEOUT_SECONDS", str(LLM_DEFAULT_TIMEOUT)))
|
llm_timeout: int = _env_int("LLM_TIMEOUT_SECONDS", LLM_DEFAULT_TIMEOUT)
|
||||||
llm_max_concurrent: int = int(
|
llm_max_concurrent: int = _env_int("LLM_MAX_CONCURRENT_ANALYSES", 2)
|
||||||
os.getenv("LLM_MAX_CONCURRENT_ANALYSES", "2")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
config = Config()
|
config = Config()
|
||||||
|
|||||||
@@ -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.
|
# harvester uses it to decide whether to download and scan.
|
||||||
PACKAGE_EXTENSIONS = (".tar.gz", ".tgz", ".whl", ".zip")
|
PACKAGE_EXTENSIONS = (".tar.gz", ".tgz", ".whl", ".zip")
|
||||||
|
|
||||||
# Prefix used in PyPI-style asset paths ("/packages/name/ver/file")
|
# Prefix used in PyPI/NPM asset paths ("/packages/name/ver/file")
|
||||||
PYPI_PATH_PREFIX = "packages"
|
PKG_PATH_PREFIX = "packages"
|
||||||
NPM_PATH_PREFIX = "packages"
|
|
||||||
|
|
||||||
# Metadata file patterns that should never be scanned
|
# Metadata file patterns that should never be scanned
|
||||||
METADATA_PATTERNS = (
|
METADATA_PATTERNS = (
|
||||||
@@ -39,7 +38,6 @@ DEFAULT_ECOSYSTEM = "pypi"
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
SEVERITY_WARNING = "WARNING"
|
SEVERITY_WARNING = "WARNING"
|
||||||
SEVERITY_ERROR = "ERROR"
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Sorting
|
# Sorting
|
||||||
@@ -81,20 +79,9 @@ WEB_PER_PAGE = 50
|
|||||||
|
|
||||||
DASHBOARD_LATEST_FLAGGED_LIMIT = 8
|
DASHBOARD_LATEST_FLAGGED_LIMIT = 8
|
||||||
DASHBOARD_LATEST_SCANS_LIMIT = 10
|
DASHBOARD_LATEST_SCANS_LIMIT = 10
|
||||||
DASHBOARD_MOST_FLAGGED_LIMIT = 8
|
|
||||||
TOP_RULES_LIMIT = 10
|
TOP_RULES_LIMIT = 10
|
||||||
|
|
||||||
RECENT_FLAGGED_DAYS = 7
|
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
|
# Scanner
|
||||||
@@ -114,8 +101,7 @@ SCAN_ERROR_DOWNLOAD_FAILED = "Download failed"
|
|||||||
ERROR_MESSAGE_MAX_LENGTH = 1000
|
ERROR_MESSAGE_MAX_LENGTH = 1000
|
||||||
SHA256_CHUNK_SIZE = 8192
|
SHA256_CHUNK_SIZE = 8192
|
||||||
|
|
||||||
# Finding data dict keys
|
# Finding severity default
|
||||||
FINDING_KEYS = ("rule", "severity", "message", "location", "code")
|
|
||||||
DEFAULT_FINDING_SEVERITY = SEVERITY_WARNING
|
DEFAULT_FINDING_SEVERITY = SEVERITY_WARNING
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -141,8 +127,6 @@ WEBHOOK_STATUS_IGNORED = "ignored"
|
|||||||
# API
|
# API
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
API_PREFIX_V1 = "/api/v1"
|
|
||||||
HEALTH_PATH = "/health"
|
|
||||||
STATIC_MOUNT_PATH = "/static"
|
STATIC_MOUNT_PATH = "/static"
|
||||||
|
|
||||||
CSV_MEDIA_TYPE = "text/csv"
|
CSV_MEDIA_TYPE = "text/csv"
|
||||||
|
|||||||
@@ -63,16 +63,20 @@ async def harvest(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async with lock:
|
async with lock:
|
||||||
# Re-check DB in case another task already created and finished a scan
|
try:
|
||||||
active = await session.scalar(
|
# Re-check DB in case another task already created and finished a scan
|
||||||
select(Scan.id).where(
|
active = await session.scalar(
|
||||||
Scan.nexus_asset_url == download_url,
|
select(Scan.id).where(
|
||||||
Scan.status.in_([ScanStatus.PENDING.value, ScanStatus.SCANNING.value]),
|
Scan.nexus_asset_url == download_url,
|
||||||
|
Scan.status.in_([ScanStatus.PENDING.value, ScanStatus.SCANNING.value]),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
if active:
|
||||||
if active:
|
log.info("Already scanning this URL, skipping")
|
||||||
log.info("Already scanning this URL, skipping")
|
return None
|
||||||
return None
|
finally:
|
||||||
|
async with _url_lock:
|
||||||
|
_url_locks.pop(download_url, None)
|
||||||
|
|
||||||
scan = Scan(
|
scan = Scan(
|
||||||
package_name=package_name,
|
package_name=package_name,
|
||||||
@@ -88,10 +92,9 @@ async def harvest(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(scan)
|
await session.refresh(scan)
|
||||||
|
|
||||||
os.makedirs(config.temp_dir, exist_ok=True)
|
|
||||||
tmpdir = tempfile.mkdtemp(dir=config.temp_dir)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
os.makedirs(config.temp_dir, exist_ok=True)
|
||||||
|
tmpdir = tempfile.mkdtemp(dir=config.temp_dir)
|
||||||
scan.status = ScanStatus.SCANNING.value
|
scan.status = ScanStatus.SCANNING.value
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
@@ -103,7 +106,7 @@ async def harvest(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
return scan
|
return scan
|
||||||
|
|
||||||
scan.sha256 = compute_sha256(downloaded)
|
scan.sha256 = await compute_sha256(downloaded)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
existing = await session.scalar(
|
existing = await session.scalar(
|
||||||
@@ -148,8 +151,12 @@ async def harvest(
|
|||||||
|
|
||||||
# Auto-trigger LLM analysis for flagged packages
|
# Auto-trigger LLM analysis for flagged packages
|
||||||
llm_reports = []
|
llm_reports = []
|
||||||
if scan.flagged and config.llm_enabled:
|
if scan.flagged and config.llm_enabled and config.llm_auto_analyze:
|
||||||
llm_reports = await _run_llm_analysis(created_findings, session)
|
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:
|
if scan.flagged:
|
||||||
extra = {
|
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."""
|
"""Run LLM analysis on findings and persist reports to the database."""
|
||||||
from .llm import analyze_finding
|
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 = []
|
reports = []
|
||||||
for finding in findings:
|
for finding in findings:
|
||||||
report = await analyze_finding(finding.data)
|
report = await analyze_finding(finding.data)
|
||||||
if report:
|
if report:
|
||||||
finding.report = report
|
finding.report = report
|
||||||
reports.append(report)
|
reports.append(report)
|
||||||
|
else:
|
||||||
|
finding.report = None
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return reports
|
return reports
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Sonatype Nexus REST API client using httpx async."""
|
"""Sonatype Nexus REST API client using httpx async."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
|
|
||||||
@@ -7,8 +8,7 @@ import httpx
|
|||||||
|
|
||||||
from ..config import config
|
from ..config import config
|
||||||
from ..constants import (
|
from ..constants import (
|
||||||
NPM_PATH_PREFIX,
|
PKG_PATH_PREFIX,
|
||||||
PYPI_PATH_PREFIX,
|
|
||||||
SHA256_CHUNK_SIZE,
|
SHA256_CHUNK_SIZE,
|
||||||
)
|
)
|
||||||
from ..logging_setup import log
|
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
|
Path format: packages/requests/2.31.0/requests-2.31.0.tar.gz
|
||||||
"""
|
"""
|
||||||
parts = asset_path.strip("/").split("/")
|
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 parts[1], parts[2]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -35,8 +35,8 @@ def extract_go_info(asset_path: str) -> tuple[str, str] | None:
|
|||||||
idx = cleaned.find("/@v/")
|
idx = cleaned.find("/@v/")
|
||||||
if idx == -1:
|
if idx == -1:
|
||||||
return None
|
return None
|
||||||
if cleaned.startswith(PYPI_PATH_PREFIX + "/"):
|
if cleaned.startswith(PKG_PATH_PREFIX + "/"):
|
||||||
module = cleaned[len(PYPI_PATH_PREFIX) + 1 : idx]
|
module = cleaned[len(PKG_PATH_PREFIX) + 1 : idx]
|
||||||
else:
|
else:
|
||||||
module = cleaned[:idx]
|
module = cleaned[:idx]
|
||||||
if not module:
|
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
|
Path format: packages/react/-/react-18.2.0.tgz
|
||||||
"""
|
"""
|
||||||
parts = asset_path.strip("/").split("/")
|
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
|
return None
|
||||||
name = parts[1]
|
name = parts[1]
|
||||||
# Last segment: <name>-<version>.tgz
|
# Last segment: <name>-<version>.tgz
|
||||||
@@ -100,14 +100,19 @@ async def download_asset(download_url: str, dest_dir: str) -> str | None:
|
|||||||
try:
|
try:
|
||||||
response = await client.get(download_url)
|
response = await client.get(download_url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
with open(dest_path, "wb") as f:
|
content = response.content
|
||||||
f.write(response.content)
|
await asyncio.to_thread(_write_file, dest_path, content)
|
||||||
return dest_path
|
return dest_path
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning("Failed to download %s: %s", download_url, e)
|
log.warning("Failed to download %s: %s", download_url, e)
|
||||||
return None
|
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:
|
async def nexus_get(path: str) -> httpx.Response:
|
||||||
"""Make an authenticated GET request to Nexus REST API."""
|
"""Make an authenticated GET request to Nexus REST API."""
|
||||||
auth = httpx.BasicAuth(config.nexus_username, config.nexus_password)
|
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}")
|
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()
|
h = hashlib.sha256()
|
||||||
with open(filepath, "rb") as f:
|
with open(filepath, "rb") as f:
|
||||||
for chunk in iter(lambda: f.read(SHA256_CHUNK_SIZE), b""):
|
for chunk in iter(lambda: f.read(SHA256_CHUNK_SIZE), b""):
|
||||||
|
|||||||
@@ -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())
|
log.error("GuardDog exited %d: %s", proc.returncode, stderr.decode())
|
||||||
return {"findings": [], "errors": [stderr.decode().strip()]}
|
return {"findings": [], "errors": [stderr.decode().strip()]}
|
||||||
|
|
||||||
|
if proc.returncode == 1 and stderr:
|
||||||
|
log.warning("GuardDog stderr (exit 1): %s", stderr.decode().strip())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(stdout.decode())
|
data = json.loads(stdout.decode())
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
@@ -96,6 +99,17 @@ def _normalize_output(data: dict) -> dict:
|
|||||||
)
|
)
|
||||||
elif isinstance(value, dict) and not value:
|
elif isinstance(value, dict) and not value:
|
||||||
continue
|
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", {})
|
errors = data.get("errors", {})
|
||||||
if isinstance(errors, dict):
|
if isinstance(errors, dict):
|
||||||
|
|||||||
@@ -68,8 +68,25 @@ async def init_db():
|
|||||||
async with _engine.begin() as conn:
|
async with _engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
await _migrate()
|
await _migrate()
|
||||||
|
await _ensure_indexes()
|
||||||
|
|
||||||
|
|
||||||
async def get_session() -> AsyncSession:
|
async def get_session() -> AsyncSession:
|
||||||
async with _async_session() as session:
|
async with _async_session() as session:
|
||||||
yield 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))
|
||||||
|
|||||||
@@ -5,16 +5,13 @@ Eliminates ~90% duplicated SQL between api/*.py and web/routes.py.
|
|||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from sqlalchemy import Integer, cast, func, select, text
|
from sqlalchemy import func, select, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from guarddog_nexus.constants import (
|
from guarddog_nexus.constants import (
|
||||||
DASHBOARD_LATEST_FLAGGED_LIMIT,
|
DASHBOARD_LATEST_FLAGGED_LIMIT,
|
||||||
DASHBOARD_LATEST_SCANS_LIMIT,
|
DASHBOARD_LATEST_SCANS_LIMIT,
|
||||||
DASHBOARD_MOST_FLAGGED_LIMIT,
|
|
||||||
HEATMAP_DAYS,
|
|
||||||
JSON_PATH_RULE,
|
JSON_PATH_RULE,
|
||||||
JSON_PATH_SEVERITY,
|
|
||||||
PACKAGE_SORT_FIELDS,
|
PACKAGE_SORT_FIELDS,
|
||||||
RECENT_FLAGGED_DAYS,
|
RECENT_FLAGGED_DAYS,
|
||||||
SCAN_SORT_FIELDS,
|
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)))
|
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(
|
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(
|
llm_pending = await session.scalar(
|
||||||
select(func.count(Finding.id)).where(
|
select(func.count(Finding.id)).where(Finding.report.is_(None))
|
||||||
func.json_extract(Finding.data, JSON_PATH_SEVERITY) == "ERROR"
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
latest_flagged = (
|
latest_flagged = (
|
||||||
@@ -191,48 +186,15 @@ async def get_dashboard_stats(session: AsyncSession) -> dict:
|
|||||||
)
|
)
|
||||||
).all()
|
).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 {
|
return {
|
||||||
"total_scans": total_scans or 0,
|
"total_scans": total_scans or 0,
|
||||||
"flagged_scans": flagged_scans or 0,
|
"flagged_scans": flagged_scans or 0,
|
||||||
"recent_flagged": recent_flagged or 0,
|
"recent_flagged": recent_flagged or 0,
|
||||||
"total_findings": total_findings or 0,
|
"total_findings": total_findings or 0,
|
||||||
"warnings_count": warnings_count or 0,
|
"llm_analyzed": llm_analyzed or 0,
|
||||||
"errors_count": errors_count or 0,
|
"llm_pending": llm_pending or 0,
|
||||||
"latest_flagged": latest_flagged,
|
"latest_flagged": latest_flagged,
|
||||||
"latest_scans": latest_scans,
|
"latest_scans": latest_scans,
|
||||||
"top_rules": [{"rule": r.rule, "count": r.cnt} for r in top_rules],
|
"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),
|
"now": datetime.datetime.now(datetime.timezone.utc),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,14 +17,12 @@ _STRINGS = {
|
|||||||
"heading_packages": {"en": "Packages", "ru": "Пакеты"},
|
"heading_packages": {"en": "Packages", "ru": "Пакеты"},
|
||||||
"heading_latest_flagged": {"en": "Latest Flagged", "ru": "Последние обнаружения"},
|
"heading_latest_flagged": {"en": "Latest Flagged", "ru": "Последние обнаружения"},
|
||||||
"heading_latest_scans": {"en": "Latest Scans", "ru": "Последние сканирования"},
|
"heading_latest_scans": {"en": "Latest Scans", "ru": "Последние сканирования"},
|
||||||
"heading_findings": {"en": "Findings", "ru": "Находки"},
|
|
||||||
"heading_findings_count": {"en": "Findings ({})", "ru": "Находки ({})"},
|
"heading_findings_count": {"en": "Findings ({})", "ru": "Находки ({})"},
|
||||||
"heading_scans_count": {"en": "Scans ({})", "ru": "Сканирований ({})"},
|
"heading_scans_count": {"en": "Scans ({})", "ru": "Сканирований ({})"},
|
||||||
"col_id": {"en": "ID", "ru": "ID"},
|
"col_id": {"en": "ID", "ru": "ID"},
|
||||||
"col_package": {"en": "Package", "ru": "Пакет"},
|
"col_package": {"en": "Package", "ru": "Пакет"},
|
||||||
"col_version": {"en": "Version", "ru": "Версия"},
|
"col_version": {"en": "Version", "ru": "Версия"},
|
||||||
"col_repo": {"en": "Repo", "ru": "Репозиторий"},
|
"col_repo": {"en": "Repo", "ru": "Репозиторий"},
|
||||||
"col_repository": {"en": "Repository", "ru": "Репозиторий"},
|
|
||||||
"col_status": {"en": "Status", "ru": "Статус"},
|
"col_status": {"en": "Status", "ru": "Статус"},
|
||||||
"col_findings": {"en": "Findings", "ru": "Находки"},
|
"col_findings": {"en": "Findings", "ru": "Находки"},
|
||||||
"col_time": {"en": "Time", "ru": "Время"},
|
"col_time": {"en": "Time", "ru": "Время"},
|
||||||
@@ -82,6 +80,11 @@ _STRINGS = {
|
|||||||
"ru": "⚠ Анализ сгенерирован AI — может содержать неточности. "
|
"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_home": {"en": "Home", "ru": "Главная"},
|
||||||
"breadcrumb_dashboard": {"en": "Dashboard", "ru": "Панель"},
|
"breadcrumb_dashboard": {"en": "Dashboard", "ru": "Панель"},
|
||||||
"breadcrumb_scans": {"en": "Scans", "ru": "Сканирования"},
|
"breadcrumb_scans": {"en": "Scans", "ru": "Сканирования"},
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..config import config
|
|
||||||
from ..constants import (
|
from ..constants import (
|
||||||
DEFAULT_OFFSET,
|
DEFAULT_OFFSET,
|
||||||
DEFAULT_PAGE_SIZE,
|
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,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ async def scan_stats(session: AsyncSession = Depends(get_session)):
|
|||||||
"total_findings": dashboard["total_findings"],
|
"total_findings": dashboard["total_findings"],
|
||||||
"top_rules": dashboard["top_rules"],
|
"top_rules": dashboard["top_rules"],
|
||||||
"latest_scan_at": dashboard["latest_flagged"][0].started_at.isoformat()
|
"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,
|
else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Prometheus-compatible metrics endpoint."""
|
"""Prometheus-compatible metrics endpoint."""
|
||||||
|
|
||||||
import time
|
import calendar
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Response
|
from fastapi import APIRouter, Depends, Response
|
||||||
from sqlalchemy import func, select
|
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}')
|
lines.append(f'guarddog_scans_by_ecosystem{{ecosystem="{eco}"}} {count}')
|
||||||
|
|
||||||
if latest:
|
if latest:
|
||||||
ts = time.mktime(latest.timetuple())
|
ts = calendar.timegm(latest.timetuple())
|
||||||
lines += [
|
lines += [
|
||||||
"",
|
"",
|
||||||
"# HELP guarddog_last_scan_timestamp_seconds Unix timestamp of most recent scan.",
|
"# HELP guarddog_last_scan_timestamp_seconds Unix timestamp of most recent scan.",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Web UI routes — Jinja2 + htmx pages."""
|
"""Web UI routes — Jinja2 + htmx pages."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request
|
||||||
@@ -8,6 +9,7 @@ from jinja2 import Environment, PackageLoader, select_autoescape
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..config import config
|
||||||
from ..constants import (
|
from ..constants import (
|
||||||
APP_PACKAGE,
|
APP_PACKAGE,
|
||||||
DEFAULT_SORT_BY_PACKAGES,
|
DEFAULT_SORT_BY_PACKAGES,
|
||||||
@@ -26,11 +28,15 @@ from ..i18n import t as _t
|
|||||||
|
|
||||||
router = APIRouter(tags=["web"])
|
router = APIRouter(tags=["web"])
|
||||||
|
|
||||||
|
_llm_locks: dict[int, asyncio.Lock] = {}
|
||||||
|
_llm_lock = asyncio.Lock()
|
||||||
|
|
||||||
_jinja_env = Environment(
|
_jinja_env = Environment(
|
||||||
loader=PackageLoader(APP_PACKAGE, "web/templates"),
|
loader=PackageLoader(APP_PACKAGE, "web/templates"),
|
||||||
autoescape=select_autoescape(),
|
autoescape=select_autoescape(),
|
||||||
)
|
)
|
||||||
_jinja_env.globals["t"] = _t
|
_jinja_env.globals["t"] = _t
|
||||||
|
_jinja_env.globals["config"] = config
|
||||||
|
|
||||||
|
|
||||||
def _render(name: str, **context) -> HTMLResponse:
|
def _render(name: str, **context) -> HTMLResponse:
|
||||||
@@ -109,7 +115,7 @@ async def scan_detail(
|
|||||||
.options(selectinload(Scan.findings))
|
.options(selectinload(Scan.findings))
|
||||||
)
|
)
|
||||||
if not scan:
|
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)
|
return _render("scan_detail.html", scan=scan, request=request)
|
||||||
|
|
||||||
@@ -186,7 +192,7 @@ async def package_detail(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not scans:
|
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 = []
|
all_findings = []
|
||||||
for s in scans:
|
for s in scans:
|
||||||
@@ -205,31 +211,72 @@ async def package_detail(
|
|||||||
@router.post("/api/v1/findings/{finding_id}/analyze", response_class=HTMLResponse)
|
@router.post("/api/v1/findings/{finding_id}/analyze", response_class=HTMLResponse)
|
||||||
async def analyze_finding_htmx(
|
async def analyze_finding_htmx(
|
||||||
finding_id: int,
|
finding_id: int,
|
||||||
|
request: Request,
|
||||||
|
retry: bool = False,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""HTMX fragment: trigger LLM analysis and return styled result HTML."""
|
"""HTMX fragment: trigger LLM analysis and return styled result HTML."""
|
||||||
from ..config import config
|
from ..config import config
|
||||||
from ..core.llm import analyze_finding
|
from ..core.llm import analyze_finding
|
||||||
|
|
||||||
|
lang = request.state.lang
|
||||||
|
|
||||||
if not config.llm_enabled:
|
if not config.llm_enabled:
|
||||||
|
msg = _t("llm_disabled", lang)
|
||||||
return HTMLResponse(
|
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))
|
finding = await session.scalar(select(Finding).where(Finding.id == finding_id))
|
||||||
if not finding:
|
if not finding:
|
||||||
|
msg = _t("llm_not_found", lang)
|
||||||
return HTMLResponse(
|
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,
|
status_code=404,
|
||||||
)
|
)
|
||||||
|
|
||||||
report = await analyze_finding(finding.data)
|
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:
|
if report is None:
|
||||||
|
finding.report = None
|
||||||
|
await session.commit()
|
||||||
|
msg = _t("llm_failed", lang)
|
||||||
return HTMLResponse(
|
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
|
finding.report = report
|
||||||
await session.commit()
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -86,10 +86,10 @@ async def nexus_webhook(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(payload.decode("utf-8"))
|
data = json.loads(payload.decode("utf-8"))
|
||||||
except json.JSONDecodeError:
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
log.warning("Webhook received invalid JSON")
|
log.warning("Webhook received invalid body")
|
||||||
raise HTTPException(
|
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()
|
action = data.get("action", "").upper()
|
||||||
@@ -112,6 +112,11 @@ async def nexus_webhook(
|
|||||||
action, initiator, source_ip)
|
action, initiator, source_ip)
|
||||||
|
|
||||||
repository = data.get("repositoryName", "")
|
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")
|
asset = data.get("asset")
|
||||||
component = data.get("component")
|
component = data.get("component")
|
||||||
|
|
||||||
|
|||||||
@@ -15,25 +15,8 @@
|
|||||||
.severity-ERROR { color: var(--pico-color-red-400); }
|
.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 {
|
.dash-block {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
@@ -57,51 +40,6 @@ table.compact { font-size: 0.82rem; }
|
|||||||
table.compact th,
|
table.compact th,
|
||||||
table.compact td { padding: 0.35rem 0.5rem; }
|
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) */
|
/* Scan info block (detail page) */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -167,17 +105,6 @@ table.compact td { padding: 0.35rem 0.5rem; }
|
|||||||
margin-bottom: 0;
|
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 */
|
/* 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 { margin-top: 0.5rem; }
|
||||||
.llm-actions button { font-size: 0.8rem; }
|
.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 {
|
.llm-disclaimer {
|
||||||
margin-top: 0.6rem;
|
margin-top: 0.6rem;
|
||||||
font-size: 0.72rem;
|
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:hover { background: var(--pico-color-gray-600); }
|
||||||
.copy-btn.copied { color: var(--pico-color-green-400); border-color: var(--pico-color-green-400); }
|
.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; }
|
.htmx-indicator { display: inline; }
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -336,9 +260,7 @@ th.sortable.active .sort-icon { opacity: 1; }
|
|||||||
/* Responsive */
|
/* Responsive */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.dashboard-grid { grid-template-columns: 1fr; }
|
|
||||||
.scan-info-grid { grid-template-columns: 1fr 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; }
|
.filter-bar { flex-direction: column; align-items: stretch; }
|
||||||
nav ul { flex-wrap: wrap; }
|
nav ul { flex-wrap: wrap; }
|
||||||
table, table.compact { font-size: 0.78rem; }
|
table, table.compact { font-size: 0.78rem; }
|
||||||
@@ -347,14 +269,13 @@ th.sortable.active .sort-icon { opacity: 1; }
|
|||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.scan-info-grid { grid-template-columns: 1fr; }
|
.scan-info-grid { grid-template-columns: 1fr; }
|
||||||
.stat-minibar { font-size: 0.8rem; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Print */
|
/* Print */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@media 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; }
|
.llm-actions, .breadcrumbs { display: none !important; }
|
||||||
body { background: white; color: black; }
|
body { background: white; color: black; }
|
||||||
.llm-report { border: 1px solid #ccc; background: none; }
|
.llm-report { border: 1px solid #ccc; background: none; }
|
||||||
|
|||||||
@@ -4,8 +4,15 @@
|
|||||||
{% if report.severity_rating %}
|
{% if report.severity_rating %}
|
||||||
<span class="llm-severity">{{ report.severity_rating }}</span>
|
<span class="llm-severity">{{ report.severity_rating }}</span>
|
||||||
{% endif %}
|
{% 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>
|
</div>
|
||||||
<p class="llm-summary">{{ report.summary }}</p>
|
<p class="llm-summary">{{ report.summary }}</p>
|
||||||
<p class="llm-analysis">{{ report.analysis }}</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>
|
</div>
|
||||||
|
|||||||
3
guarddog_nexus/web/templates/_llm_spinner.html
Normal file
3
guarddog_nexus/web/templates/_llm_spinner.html
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<div class="llm-actions">
|
||||||
|
<small><span class="spinner"></span> {{ t('llm_analyzing', request.state.lang) }}</small>
|
||||||
|
</div>
|
||||||
@@ -38,14 +38,5 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
{% set total_pages = (total // per_page) + (1 if total % per_page else 0) %}
|
{% include "_pagination.html" %}
|
||||||
{% 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 %}
|
|
||||||
<small style="opacity: 0.5;">{{ t('total_packages', request.state.lang, total) }}</small>
|
<small style="opacity: 0.5;">{{ t('total_packages', request.state.lang, total) }}</small>
|
||||||
|
|||||||
10
guarddog_nexus/web/templates/_pagination.html
Normal file
10
guarddog_nexus/web/templates/_pagination.html
Normal 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 %}
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
<td>{{ s.package_version }}</td>
|
<td>{{ s.package_version }}</td>
|
||||||
<td>{{ s.repository }}</td>
|
<td>{{ s.repository }}</td>
|
||||||
<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>
|
||||||
<td>{% if s.flagged %}<span class="flagged">{{ s.total_findings }}</span>{% else %}<span class="clean">0</span>{% endif %}</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>
|
<td>{{ s.started_at.strftime('%Y-%m-%d %H:%M') if s.started_at }}</td>
|
||||||
@@ -42,14 +42,5 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
{% set total_pages = (total // per_page) + (1 if total % per_page else 0) %}
|
{% include "_pagination.html" %}
|
||||||
{% 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 %}
|
|
||||||
<small style="opacity: 0.5;">{{ t('total_scans', request.state.lang, total) }}</small>
|
<small style="opacity: 0.5;">{{ t('total_scans', request.state.lang, total) }}</small>
|
||||||
|
|||||||
1
guarddog_nexus/web/templates/_status_badge.html
Normal file
1
guarddog_nexus/web/templates/_status_badge.html
Normal 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 %}
|
||||||
@@ -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 %}
|
{% if latest_flagged %}
|
||||||
<article class="dash-block dash-block-warn">
|
<article class="dash-block dash-block-warn">
|
||||||
<h3>{{ t('heading_latest_flagged', request.state.lang) }}</h3>
|
<h3>{{ t('heading_latest_flagged', request.state.lang) }}</h3>
|
||||||
@@ -30,7 +37,7 @@
|
|||||||
<td>{{ s.package_version }}</td>
|
<td>{{ s.package_version }}</td>
|
||||||
<td><small>{{ s.repository }}</small></td>
|
<td><small>{{ s.repository }}</small></td>
|
||||||
<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>
|
||||||
<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>{% 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>
|
<td>{{ s.started_at.strftime('%m-%d %H:%M') if s.started_at }}</td>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
<td><a href="/scans/{{ s.id }}">#{{ s.id }}</a></td>
|
<td><a href="/scans/{{ s.id }}">#{{ s.id }}</a></td>
|
||||||
<td>{{ s.repository }}</td>
|
<td>{{ s.repository }}</td>
|
||||||
<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>
|
||||||
<td>{% if s.flagged %}<span class="flagged">{{ s.total_findings }}</span>{% else %}<span class="clean">0</span>{% endif %}</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>
|
<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>
|
<pre><code id="code-{{ f.id }}">{{ f.data.code }}</code></pre>
|
||||||
{% endif %}
|
{% 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-report llm-{{ f.report.verdict }}">
|
||||||
<div class="llm-header">
|
<div class="llm-header">
|
||||||
<span class="llm-badge llm-badge-{{ f.report.verdict }}">{{ f.report.verdict }}</span>
|
<span class="llm-badge llm-badge-{{ f.report.verdict }}">{{ f.report.verdict }}</span>
|
||||||
{% if f.report.severity_rating %}
|
{% if f.report.severity_rating %}
|
||||||
<span class="llm-severity">{{ f.report.severity_rating }}</span>
|
<span class="llm-severity">{{ f.report.severity_rating }}</span>
|
||||||
{% endif %}
|
{% 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>
|
</div>
|
||||||
<p class="llm-summary">{{ f.report.summary }}</p>
|
<p class="llm-summary">{{ f.report.summary }}</p>
|
||||||
<p class="llm-analysis">{{ f.report.analysis }}</p>
|
<p class="llm-analysis">{{ f.report.analysis }}</p>
|
||||||
<p class="llm-disclaimer">{{ t('llm_disclaimer', request.state.lang) }}</p>
|
<p class="llm-disclaimer">{{ t('llm_disclaimer', request.state.lang) }}</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% elif config.llm_enabled and not config.llm_auto_analyze %}
|
||||||
<div class="llm-actions" id="llm-{{ f.id }}">
|
<div class="llm-actions" id="llm-{{ f.id }}">
|
||||||
<button class="outline"
|
<button class="outline"
|
||||||
hx-post="/api/v1/findings/{{ f.id }}/analyze"
|
hx-post="/api/v1/findings/{{ f.id }}/analyze"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<div><strong>{{ t('scan_info_ecosystem', request.state.lang) }}</strong><br>{{ scan.ecosystem }}</div>
|
<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_repository', request.state.lang) }}</strong><br>{{ scan.repository }}</div>
|
||||||
<div><strong>{{ t('scan_info_status', request.state.lang) }}</strong><br>
|
<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>
|
||||||
<div><strong>{{ t('scan_info_sha256', request.state.lang) }}</strong><br><code class="sha256">{{ scan.sha256 or '-' }}</code></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>
|
<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>
|
<pre><code id="code-{{ f.id }}">{{ f.data.code }}</code></pre>
|
||||||
{% endif %}
|
{% 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-report llm-{{ f.report.verdict }}">
|
||||||
<div class="llm-header">
|
<div class="llm-header">
|
||||||
<span class="llm-badge llm-badge-{{ f.report.verdict }}">{{ f.report.verdict }}</span>
|
<span class="llm-badge llm-badge-{{ f.report.verdict }}">{{ f.report.verdict }}</span>
|
||||||
{% if f.report.severity_rating %}
|
{% if f.report.severity_rating %}
|
||||||
<span class="llm-severity">{{ f.report.severity_rating }}</span>
|
<span class="llm-severity">{{ f.report.severity_rating }}</span>
|
||||||
{% endif %}
|
{% 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>
|
</div>
|
||||||
<p class="llm-summary">{{ f.report.summary }}</p>
|
<p class="llm-summary">{{ f.report.summary }}</p>
|
||||||
<p class="llm-analysis">{{ f.report.analysis }}</p>
|
<p class="llm-analysis">{{ f.report.analysis }}</p>
|
||||||
<p class="llm-disclaimer">{{ t('llm_disclaimer', request.state.lang) }}</p>
|
<p class="llm-disclaimer">{{ t('llm_disclaimer', request.state.lang) }}</p>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% elif config.llm_enabled and not config.llm_auto_analyze %}
|
||||||
<div class="llm-actions" id="llm-{{ f.id }}">
|
<div class="llm-actions" id="llm-{{ f.id }}">
|
||||||
<button class="outline"
|
<button class="outline"
|
||||||
hx-post="/api/v1/findings/{{ f.id }}/analyze"
|
hx-post="/api/v1/findings/{{ f.id }}/analyze"
|
||||||
|
|||||||
Reference in New Issue
Block a user