diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..432bff7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.12-slim-bookworm + +RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/ + +WORKDIR /app +COPY pyproject.toml ./ +COPY guarddog_nexus/ guarddog_nexus/ + +RUN uv pip install --system guarddog +RUN uv pip install --system -e . + +RUN mkdir -p /data /tmp/guarddog-nexus + +ENV DATABASE_PATH=/data/guarddog.db +ENV TEMP_DIR=/tmp/guarddog-nexus + +EXPOSE 8080 + +CMD ["python", "-m", "guarddog_nexus.main"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c8a6914 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,44 @@ +services: + guarddog-nexus: + build: . + ports: + - "8080:8080" + environment: + NEXUS_URL: http://nexus:8081 + NEXUS_USERNAME: admin + NEXUS_PASSWORD: "${NEXUS_PASSWORD:-admin123}" + NEXUS_REPOSITORIES: pypi-proxy + LOG_LEVEL: INFO + LOG_SYSLOG_HOST: "" + HOST: "0.0.0.0" + PORT: "8080" + volumes: + - ./data:/data + depends_on: + nexus-setup: + condition: service_completed_successfully + restart: unless-stopped + + nexus: + image: sonatype/nexus3:3.79.0 + ports: + - "8081:8081" + volumes: + - nexus-data:/nexus-data + restart: unless-stopped + + nexus-setup: + image: alpine:3.21 + volumes: + - ./scripts/setup-nexus.sh:/setup.sh:ro + - nexus-data:/nexus-data:ro + environment: + NEXUS_URL: http://nexus:8081 + ADMIN_PASSWORD: "${NEXUS_PASSWORD:-admin123}" + WEBHOOK_URL: http://guarddog-nexus:8080/webhooks/nexus + entrypoint: ["/bin/sh", "/setup.sh"] + depends_on: + - nexus + +volumes: + nexus-data: diff --git a/guarddog_nexus/__init__.py b/guarddog_nexus/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/guarddog_nexus/api/__init__.py b/guarddog_nexus/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/guarddog_nexus/api/findings.py b/guarddog_nexus/api/findings.py new file mode 100644 index 0000000..255e3d1 --- /dev/null +++ b/guarddog_nexus/api/findings.py @@ -0,0 +1,49 @@ +"""REST API for findings (across all scans).""" + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from guarddog_nexus.database import get_session +from guarddog_nexus.models import Finding + +router = APIRouter(prefix="/api/v1/findings", tags=["findings"]) + + +@router.get("") +async def list_findings( + limit: int = Query(50, le=200), + offset: int = Query(0, ge=0), + rule: str | None = Query(None), + severity: str | None = Query(None), + scan_id: int | None = Query(None), + session: AsyncSession = Depends(get_session), +): + q = select(Finding) + if rule: + q = q.where(Finding.rule == rule) + if severity: + q = q.where(Finding.severity == severity) + if scan_id: + q = q.where(Finding.scan_id == scan_id) + + total = await session.scalar(select(func.count()).select_from(q.subquery())) + findings = (await session.execute(q.offset(offset).limit(limit))).scalars().all() + + return { + "total": total, + "limit": limit, + "offset": offset, + "findings": [ + { + "id": f.id, + "scan_id": f.scan_id, + "rule": f.rule, + "severity": f.severity, + "message": f.message, + "location": f.location, + "created_at": f.created_at.isoformat() if f.created_at else None, + } + for f in findings + ], + } diff --git a/guarddog_nexus/api/packages.py b/guarddog_nexus/api/packages.py new file mode 100644 index 0000000..88d4839 --- /dev/null +++ b/guarddog_nexus/api/packages.py @@ -0,0 +1,122 @@ +"""REST API for packages (distinct packages across scans).""" + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from guarddog_nexus.database import get_session +from guarddog_nexus.models import Finding, Scan + +router = APIRouter(prefix="/api/v1/packages", tags=["packages"]) + + +@router.get("") +async def list_packages( + limit: int = Query(50, le=200), + offset: int = Query(0, ge=0), + ecosystem: str | None = Query(None), + flagged: bool | None = Query(None), + session: AsyncSession = Depends(get_session), +): + subq = ( + select( + Scan.package_name, + Scan.package_version, + Scan.ecosystem, + Scan.repository, + func.max(Scan.started_at).label("last_scanned_at"), + func.max(Scan.flagged).label("is_flagged"), + func.sum(Scan.total_findings).label("total_findings"), + func.max(Scan.id).label("latest_scan_id"), + ) + .group_by(Scan.package_name, Scan.package_version) + ) + + if ecosystem: + subq = subq.where(Scan.ecosystem == ecosystem) + if flagged is not None: + subq = subq.having(func.max(Scan.flagged) == flagged) + + total_q = select(func.count()).select_from(subq.subquery()) + total = await session.scalar(total_q) + + rows = ( + (await session.execute( + subq.order_by(func.max(Scan.started_at).desc()).offset(offset).limit(limit) + )) + .all() + ) + + return { + "total": total, + "limit": limit, + "offset": offset, + "packages": [ + { + "name": r.package_name, + "version": r.package_version, + "ecosystem": r.ecosystem, + "repository": r.repository, + "last_scanned_at": r.last_scanned_at.isoformat() if r.last_scanned_at else None, + "flagged": bool(r.is_flagged), + "total_findings": r.total_findings, + "latest_scan_id": r.latest_scan_id, + } + for r in rows + ], + } + + +@router.get("/{name}/{version}") +async def get_package( + name: str, + version: str, + session: AsyncSession = Depends(get_session), +): + scans = ( + await session.execute( + select(Scan) + .where(Scan.package_name == name, Scan.package_version == version) + .order_by(Scan.started_at.desc()) + ) + ).scalars().all() + + if not scans: + return {"detail": "Not found"} + + all_findings = [] + for s in scans: + findings = ( + await session.execute( + select(Finding).where(Finding.scan_id == s.id) + ) + ).scalars().all() + all_findings.extend(f.__dict__ for f in findings) + + return { + "name": scans[0].package_name, + "version": scans[0].package_version, + "ecosystem": scans[0].ecosystem, + "repository": scans[0].repository, + "flagged": any(s.flagged for s in scans), + "scans": [ + { + "id": s.id, + "status": s.status, + "total_findings": s.total_findings, + "flagged": s.flagged, + "started_at": s.started_at.isoformat() if s.started_at else None, + } + for s in scans + ], + "findings": [ + { + "id": f["id"], + "rule": f.get("rule"), + "severity": f.get("severity"), + "message": f.get("message"), + "location": f.get("location"), + } + for f in all_findings + ], + } diff --git a/guarddog_nexus/api/scans.py b/guarddog_nexus/api/scans.py new file mode 100644 index 0000000..a0a9fcb --- /dev/null +++ b/guarddog_nexus/api/scans.py @@ -0,0 +1,120 @@ +"""REST API for scans.""" + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from guarddog_nexus.database import get_session +from guarddog_nexus.models import Finding, Scan + +router = APIRouter(prefix="/api/v1/scans", tags=["scans"]) + + +@router.get("") +async def list_scans( + limit: int = Query(50, le=200), + offset: int = Query(0, ge=0), + flagged: bool | None = Query(None), + session: AsyncSession = Depends(get_session), +): + q = select(Scan) + if flagged is not None: + q = q.where(Scan.flagged == flagged) + q = q.order_by(Scan.started_at.desc()).offset(offset).limit(limit) + + total = await session.scalar(select(func.count(Scan.id))) + + scans = (await session.execute(q)).scalars().all() + return { + "total": total, + "limit": limit, + "offset": offset, + "scans": [ + { + "id": s.id, + "package_name": s.package_name, + "package_version": s.package_version, + "ecosystem": s.ecosystem, + "repository": s.repository, + "status": s.status, + "total_findings": s.total_findings, + "flagged": s.flagged, + "started_at": s.started_at.isoformat() if s.started_at else None, + "finished_at": s.finished_at.isoformat() if s.finished_at else None, + "error_message": s.error_message, + } + for s in scans + ], + } + + +@router.get("/stats") +async def scan_stats(session: AsyncSession = Depends(get_session)): + total_scans = await session.scalar(select(func.count(Scan.id))) + flagged_scans = await session.scalar( + select(func.count(Scan.id)).where(Scan.flagged == True) + ) + recent_flagged = await session.scalar( + select(func.count(Scan.id)).where( + Scan.flagged == True, + Scan.started_at >= func.datetime("now", "-7 days"), + ) + ) + total_findings = await session.scalar(select(func.count(Finding.id))) + + top_rules = ( + await session.execute( + select(Finding.rule, func.count(Finding.id).label("cnt")) + .group_by(Finding.rule) + .order_by(func.count(Finding.id).desc()) + .limit(10) + ) + ).all() + + latest_scan = await session.scalar( + select(Scan).order_by(Scan.started_at.desc()).limit(1) + ) + + return { + "total_scans": total_scans, + "flagged_scans": flagged_scans, + "recent_flagged": recent_flagged, + "total_findings": total_findings, + "top_rules": [{"rule": r.rule, "count": r.cnt} for r in top_rules], + "latest_scan_at": latest_scan.started_at.isoformat() if latest_scan else None, + } + + +@router.get("/{scan_id}") +async def get_scan(scan_id: int, session: AsyncSession = Depends(get_session)): + scan = await session.scalar( + select(Scan).where(Scan.id == scan_id).options(selectinload(Scan.findings)) + ) + if not scan: + return {"detail": "Not found"} + return { + "id": scan.id, + "package_name": scan.package_name, + "package_version": scan.package_version, + "ecosystem": scan.ecosystem, + "repository": scan.repository, + "nexus_asset_url": scan.nexus_asset_url, + "sha256": scan.sha256, + "status": scan.status, + "total_findings": scan.total_findings, + "flagged": scan.flagged, + "started_at": scan.started_at.isoformat() if scan.started_at else None, + "finished_at": scan.finished_at.isoformat() if scan.finished_at else None, + "error_message": scan.error_message, + "findings": [ + { + "id": f.id, + "rule": f.rule, + "severity": f.severity, + "message": f.message, + "location": f.location, + } + for f in scan.findings + ], + } diff --git a/guarddog_nexus/harvester.py b/guarddog_nexus/harvester.py new file mode 100644 index 0000000..f3ad9f5 --- /dev/null +++ b/guarddog_nexus/harvester.py @@ -0,0 +1,129 @@ +"""Harvester: download a package from Nexus, scan it, store results.""" + +import datetime +import os +import tempfile + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from guarddog_nexus.config import config +from guarddog_nexus.logging_setup import log +from guarddog_nexus.models import Finding, Scan, ScanStatus +from guarddog_nexus.nexus_client import ( + SUPPORTED_EXTENSIONS, + compute_sha256, + download_asset, + extract_pypi_info, +) +from guarddog_nexus.scanner import scan_package + + +async def harvest( + download_url: str, + repository: str, + format_: str, + asset_path: str, + session: AsyncSession, +) -> Scan | None: + """Download, scan, and store results for a single package asset.""" + ecosystem = "pypi" if format_ in ("pypi",) else format_ + + filename = os.path.basename(download_url.split("?")[0]) + if not filename.endswith(SUPPORTED_EXTENSIONS): + log.info("Skipping non-package asset: %s", filename) + return None + + info = extract_pypi_info(asset_path) + if info is None: + log.warning("Could not parse package info from path: %s", asset_path) + return None + + package_name, package_version = info + + existing = await session.scalar( + select(Scan.id).where( + Scan.package_name == package_name, + Scan.package_version == package_version, + Scan.repository == repository, + ) + ) + if existing: + log.info("Already scanned %s==%s, skipping", package_name, package_version) + return None + + scan = Scan( + package_name=package_name, + package_version=package_version, + ecosystem=ecosystem, + repository=repository, + nexus_asset_url=download_url, + status=ScanStatus.PENDING.value, + ) + session.add(scan) + await session.commit() + await session.refresh(scan) + + os.makedirs(config.temp_dir, exist_ok=True) + tmpdir = tempfile.mkdtemp(dir=config.temp_dir) + + try: + scan.status = ScanStatus.SCANNING.value + await session.commit() + + downloaded = download_asset(download_url, tmpdir) + if not downloaded: + scan.status = ScanStatus.FAILED.value + scan.error_message = "Download failed" + scan.finished_at = datetime.datetime.now(datetime.timezone.utc) + await session.commit() + return scan + + scan.sha256 = compute_sha256(downloaded) + await session.commit() + + log.info("Scanning %s==%s", package_name, package_version) + result = scan_package(downloaded, ecosystem) + + findings_list = result.get("findings", []) + + for fdata in findings_list: + finding = Finding( + scan_id=scan.id, + rule=fdata["rule"], + severity=fdata["severity"], + message=fdata["message"], + location=fdata.get("location"), + ) + session.add(finding) + + scan.total_findings = len(findings_list) + scan.flagged = len(findings_list) > 0 + scan.status = ScanStatus.COMPLETED.value + scan.finished_at = datetime.datetime.now(datetime.timezone.utc) + await session.commit() + + if scan.flagged: + log.warning( + "FLAGGED %s==%s: %d findings in repo %s", + package_name, + package_version, + scan.total_findings, + repository, + ) + + log.info( + "Scan complete: %s==%s (%d findings)", + package_name, + package_version, + scan.total_findings, + ) + return scan + + except Exception as e: + log.error("Scan failed for %s==%s: %s", package_name, package_version, e) + scan.status = ScanStatus.FAILED.value + scan.error_message = str(e)[:1000] + scan.finished_at = datetime.datetime.now(datetime.timezone.utc) + await session.commit() + return scan diff --git a/guarddog_nexus/logging_setup.py b/guarddog_nexus/logging_setup.py new file mode 100644 index 0000000..68f5bbc --- /dev/null +++ b/guarddog_nexus/logging_setup.py @@ -0,0 +1,43 @@ +"""Structured logging with syslog support.""" + +import json +import logging +import sys +from logging.handlers import SysLogHandler + +from guarddog_nexus.config import config + + +class JsonFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + payload = { + "timestamp": self.formatTime(record, self.datefmt), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + if record.exc_info and record.exc_info[1]: + payload["exception"] = str(record.exc_info[1]) + return json.dumps(payload, ensure_ascii=False) + + +def setup_logging() -> logging.Logger: + logger = logging.getLogger("guarddog_nexus") + logger.setLevel(config.log_level.upper()) + + stdout_handler = logging.StreamHandler(sys.stdout) + stdout_handler.setFormatter(JsonFormatter()) + logger.addHandler(stdout_handler) + + if config.log_syslog_host: + syslog_handler = SysLogHandler( + address=(config.log_syslog_host, config.log_syslog_port), + facility=SysLogHandler.LOG_LOCAL0, + ) + syslog_handler.setFormatter(JsonFormatter()) + logger.addHandler(syslog_handler) + + return logger + + +log = setup_logging() diff --git a/guarddog_nexus/main.py b/guarddog_nexus/main.py new file mode 100644 index 0000000..7bbe78a --- /dev/null +++ b/guarddog_nexus/main.py @@ -0,0 +1,61 @@ +"""GuardDog Nexus — FastAPI application entry point.""" + +import os +from contextlib import asynccontextmanager + +import uvicorn +from fastapi import FastAPI +from fastapi.staticfiles import StaticFiles + +from guarddog_nexus.api import findings, packages, scans +from guarddog_nexus.config import config +from guarddog_nexus.database import init_db +from guarddog_nexus.logging_setup import log +from guarddog_nexus.web.routes import router as web_router +from guarddog_nexus.webhooks import router as webhook_router + +STATIC_DIR = os.path.join(os.path.dirname(__file__), "static") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_db() + log.info("GuardDog Nexus started on %s:%s", config.host, config.port) + yield + log.info("GuardDog Nexus shutting down") + + +app = FastAPI( + title="GuardDog Nexus", + version="0.1.0", + description="Scan PyPI packages from Sonatype Nexus webhooks using GuardDog", + lifespan=lifespan, +) + +app.include_router(webhook_router) +app.include_router(scans.router) +app.include_router(packages.router) +app.include_router(findings.router) +app.include_router(web_router) + +if os.path.isdir(STATIC_DIR): + app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + + +@app.get("/health") +async def health(): + return {"status": "ok", "version": "0.1.0"} + + +def main(): + uvicorn.run( + "guarddog_nexus.main:app", + host=config.host, + port=config.port, + log_level=config.log_level.lower(), + reload=False, + ) + + +if __name__ == "__main__": + main() diff --git a/guarddog_nexus/models.py b/guarddog_nexus/models.py new file mode 100644 index 0000000..8a3830d --- /dev/null +++ b/guarddog_nexus/models.py @@ -0,0 +1,58 @@ +"""SQLAlchemy ORM models.""" + +import datetime +from enum import Enum + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from guarddog_nexus.database import Base + + +class ScanStatus(str, Enum): + PENDING = "pending" + SCANNING = "scanning" + COMPLETED = "completed" + FAILED = "failed" + + +class Scan(Base): + __tablename__ = "scans" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + package_name: Mapped[str] = mapped_column(String(255), nullable=False) + package_version: Mapped[str] = mapped_column(String(255), nullable=False) + ecosystem: Mapped[str] = mapped_column(String(50), nullable=False, default="pypi") + repository: Mapped[str] = mapped_column(String(255), nullable=False) + nexus_asset_url: Mapped[str] = mapped_column(Text, nullable=False) + sha256: Mapped[str | None] = mapped_column(String(64), nullable=True) + status: Mapped[str] = mapped_column( + String(20), nullable=False, default=ScanStatus.PENDING.value + ) + total_findings: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + flagged: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + started_at: Mapped[datetime.datetime] = mapped_column( + DateTime, nullable=False, default=func.now() + ) + finished_at: Mapped[datetime.datetime | None] = mapped_column(DateTime, nullable=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + + findings: Mapped[list["Finding"]] = relationship( + "Finding", back_populates="scan", cascade="all, delete-orphan" + ) + + +class Finding(Base): + __tablename__ = "findings" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + scan_id: Mapped[int] = mapped_column(Integer, ForeignKey("scans.id"), nullable=False) + rule: Mapped[str] = mapped_column(String(255), nullable=False) + severity: Mapped[str] = mapped_column(String(50), nullable=False) + message: Mapped[str] = mapped_column(Text, nullable=False) + location: Mapped[str | None] = mapped_column(String(512), nullable=True) + created_at: Mapped[datetime.datetime] = mapped_column( + DateTime, nullable=False, default=func.now() + ) + + scan: Mapped["Scan"] = relationship("Scan", back_populates="findings") diff --git a/guarddog_nexus/nexus_client.py b/guarddog_nexus/nexus_client.py new file mode 100644 index 0000000..05bdf9e --- /dev/null +++ b/guarddog_nexus/nexus_client.py @@ -0,0 +1,65 @@ +"""Sonatype Nexus REST API client.""" + +import hashlib +import os +import subprocess + +from guarddog_nexus.config import config +from guarddog_nexus.logging_setup import log + +SUPPORTED_EXTENSIONS = (".tar.gz", ".tgz", ".whl", ".zip") +PACKAGE_FILE_PATTERNS = ("packages/",) + + +def get_ecosystem_from_format(fmt: str) -> str | None: + mapping = { + "pypi": "pypi", + "npm": "npm", + "rubygems": "rubygems", + "go": "go", + "raw": None, + } + return mapping.get(fmt.lower() if fmt else "") + + +def extract_pypi_info(asset_path: str) -> tuple[str, str] | None: + """Extract package name and version from a PyPI asset path. + + 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] == "packages": + return parts[1], parts[2] + return None + + +def download_asset(download_url: str, dest_dir: str) -> str | None: + """Download an asset from Nexus using curl (available in Docker).""" + dest_path = os.path.join(dest_dir, os.path.basename(download_url.split("?")[0])) + try: + result = subprocess.run( + [ + "curl", "-sfSL", + "-u", f"{config.nexus_username}:{config.nexus_password}", + "-o", dest_path, + download_url, + ], + capture_output=True, + text=True, + timeout=120, + ) + if result.returncode != 0: + log.warning("Failed to download %s: %s", download_url, result.stderr) + return None + return dest_path + except Exception as e: + log.error("Download error for %s: %s", download_url, e) + return None + + +def compute_sha256(filepath: str) -> str: + h = hashlib.sha256() + with open(filepath, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + h.update(chunk) + return h.hexdigest() diff --git a/guarddog_nexus/scanner.py b/guarddog_nexus/scanner.py new file mode 100644 index 0000000..1cce125 --- /dev/null +++ b/guarddog_nexus/scanner.py @@ -0,0 +1,74 @@ +"""GuardDog CLI integration via subprocess.""" + +import json +import shutil +import subprocess + +from guarddog_nexus.config import config +from guarddog_nexus.logging_setup import log + +GUARDDOG_BIN = shutil.which("guarddog") or "guarddog" + + +def scan_package(filepath: str, ecosystem: str = "pypi") -> dict: + """Run guarddog scan on a downloaded package file. Returns parsed JSON output.""" + cmd = [ + GUARDDOG_BIN, ecosystem, "scan", filepath, + "--output-format", "json", + ] + + log.info("Running: %s", " ".join(cmd)) + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=config.scan_timeout_seconds, + ) + except subprocess.TimeoutExpired: + log.error("GuardDog scan timed out for %s", filepath) + return {"issues": [], "errors": ["timeout"]} + except FileNotFoundError: + log.error("GuardDog binary not found at %s", GUARDDOG_BIN) + return {"issues": [], "errors": ["guarddog_not_found"]} + + if result.returncode not in (0, 1): + log.error("GuardDog exited %d: %s", result.returncode, result.stderr) + return {"issues": [], "errors": [result.stderr.strip()]} + + try: + data = json.loads(result.stdout) + except json.JSONDecodeError: + log.error("GuardDog returned invalid JSON for %s", filepath) + return {"issues": [], "errors": ["json_parse_error"]} + + return _normalize_output(data) + + +def _normalize_output(data: dict) -> dict: + """Normalize guarddog JSON output across versions into a consistent format. + + GuardDog JSON format (varies by version): + { + "results": [{"rule": "...", "severity": "...", "message": "...", "location": "..."}], + "errors": [...] + } + Or simpler: + {"issues": [...], "errors": [...]} + """ + findings = [] + + for entry in data.get("results", data.get("issues", [])): + if isinstance(entry, dict): + findings.append({ + "rule": entry.get("rule", entry.get("id", "unknown")), + "severity": entry.get("severity", "WARNING"), + "message": entry.get("message", entry.get("description", "")), + "location": entry.get("location", entry.get("path", "")), + }) + + return { + "findings": findings, + "errors": data.get("errors", []), + } diff --git a/guarddog_nexus/static/style.css b/guarddog_nexus/static/style.css new file mode 100644 index 0000000..28213ba --- /dev/null +++ b/guarddog_nexus/static/style.css @@ -0,0 +1 @@ +/* static/style.css - minimal overrides for Pico.css dark theme */ diff --git a/guarddog_nexus/web/__init__.py b/guarddog_nexus/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/guarddog_nexus/web/routes.py b/guarddog_nexus/web/routes.py new file mode 100644 index 0000000..faa744b --- /dev/null +++ b/guarddog_nexus/web/routes.py @@ -0,0 +1,191 @@ +"""Web UI routes — Jinja2 + htmx pages.""" + +import datetime + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from guarddog_nexus.database import get_session +from guarddog_nexus.models import Finding, Scan + +router = APIRouter(tags=["web"]) +TEMPLATES: dict[str, str] = {} + + +def _render(name: str, **context) -> HTMLResponse: + from jinja2 import Environment, PackageLoader, select_autoescape + + env = Environment( + loader=PackageLoader("guarddog_nexus", "web/templates"), + autoescape=select_autoescape(), + ) + template = env.get_template(name) + return HTMLResponse(template.render(**context)) + + +@router.get("/", response_class=HTMLResponse) +async def dashboard(request: Request, session: AsyncSession = Depends(get_session)): + total_scans = await session.scalar(select(func.count(Scan.id))) + flagged_scans = await session.scalar( + select(func.count(Scan.id)).where(Scan.flagged == True) + ) + recent_flagged = await session.scalar( + select(func.count(Scan.id)).where( + Scan.flagged == True, + Scan.started_at >= func.datetime("now", "-7 days"), + ) + ) + total_findings = await session.scalar(select(func.count(Finding.id))) + latest_scans = ( + (await session.execute( + select(Scan).order_by(Scan.started_at.desc()).limit(10) + )) + .scalars() + .all() + ) + + top_rules = ( + await session.execute( + select(Finding.rule, func.count(Finding.id).label("cnt")) + .group_by(Finding.rule) + .order_by(func.count(Finding.id).desc()) + .limit(10) + ) + ).all() + + return _render( + "dashboard.html", + total_scans=total_scans, + flagged_scans=flagged_scans, + recent_flagged=recent_flagged, + total_findings=total_findings, + latest_scans=latest_scans, + top_rules=[(r.rule, r.cnt) for r in top_rules], + now=datetime.datetime.now(datetime.timezone.utc), + request=request, + ) + + +@router.get("/scans", response_class=HTMLResponse) +async def scans_list( + request: Request, + page: int = 1, + flagged: str = "", + session: AsyncSession = Depends(get_session), +): + per_page = 50 + offset = (page - 1) * per_page + + q = select(Scan) + if flagged == "1": + q = q.where(Scan.flagged == True) + q = q.order_by(Scan.started_at.desc()).offset(offset).limit(per_page) + + scans = (await session.execute(q)).scalars().all() + total = await session.scalar(select(func.count(Scan.id))) + + return _render( + "scans_list.html", + scans=scans, + page=page, + per_page=per_page, + total=total, + flagged_filter=flagged, + request=request, + ) + + +@router.get("/scans/{scan_id}", response_class=HTMLResponse) +async def scan_detail(scan_id: int, request: Request, session: AsyncSession = Depends(get_session)): + from sqlalchemy.orm import selectinload + + scan = await session.scalar( + select(Scan).where(Scan.id == scan_id).options(selectinload(Scan.findings)) + ) + if not scan: + return HTMLResponse("
| Package | +Version | +Ecosystem | +Status | +Findings | +Time | +
|---|---|---|---|---|---|
| {{ s.package_name }} | +{{ s.package_version }} | +{{ s.ecosystem }} | +{{ s.status }} | +{% if s.flagged %}{{ s.total_findings }}{% else %}{{ s.total_findings }}{% endif %} | +{{ s.started_at.strftime('%Y-%m-%d %H:%M') if s.started_at }} | +
| Rule | Count |
|---|---|
{{ rule }} | {{ cnt }} |
| ID | Repo | Status | Findings | Time |
|---|---|---|---|---|
| #{{ s.id }} | +{{ s.repository }} | +{{ s.status }} | +{% if s.flagged %}{{ s.total_findings }}{% else %}0{% endif %} | +{{ s.started_at.strftime('%Y-%m-%d %H:%M') if s.started_at }} | +
{{ f.message }}
+No findings — package looks clean.
+{% endif %} +{% endblock %} diff --git a/guarddog_nexus/web/templates/packages_list.html b/guarddog_nexus/web/templates/packages_list.html new file mode 100644 index 0000000..629f7b5 --- /dev/null +++ b/guarddog_nexus/web/templates/packages_list.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block content %} ++ + {% if flagged_filter == '1' %}Show all{% else %}Flagged only{% endif %} + +
+ +| Name | +Version | +Ecosystem | +Repo | +Flagged | +Findings | +Last Scan | +
|---|---|---|---|---|---|---|
| {{ p.pkg_name }} | +{{ p.pkg_ver }} | +{{ p.ecosystem }} | +{{ p.repository }} | +{% if p.is_flagged %}YES{% else %}No{% endif %} | +{{ p.findings_sum }} | +{{ p.last_scan.strftime('%Y-%m-%d %H:%M') if p.last_scan }} | +
| Package | {{ scan.package_name }} |
| Version | {{ scan.package_version }} |
| Ecosystem | {{ scan.ecosystem }} |
| Repository | {{ scan.repository }} |
| Status | {{ scan.status }} |
| SHA256 | {{ scan.sha256 or '-' }} |
| Started | {{ scan.started_at.isoformat() if scan.started_at }} |
| Finished | {{ scan.finished_at.isoformat() if scan.finished_at }} |
| Error | {{ scan.error_message }} |
{{ f.message }}
+No findings — package looks clean.
+{% endif %} +{% endblock %} diff --git a/guarddog_nexus/web/templates/scans_list.html b/guarddog_nexus/web/templates/scans_list.html new file mode 100644 index 0000000..8f405cb --- /dev/null +++ b/guarddog_nexus/web/templates/scans_list.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block content %} ++ + {% if flagged_filter == '1' %}Show all{% else %}Flagged only{% endif %} + +
+ +| ID | +Package | +Version | +Repo | +Status | +Findings | +Time | +
|---|---|---|---|---|---|---|
| #{{ s.id }} | +{{ s.package_name }} | +{{ s.package_version }} | +{{ s.repository }} | +{{ s.status }} | +{% if s.flagged %}{{ s.total_findings }}{% else %}0{% endif %} | +{{ s.started_at.strftime('%Y-%m-%d %H:%M') if s.started_at }} | +