feat: guarddog-nexus — webhook-based PyPI scanner with web UI

This commit is contained in:
Marker689
2026-05-09 04:48:10 +03:00
parent bdcc82807d
commit 4ce99d3c85
32 changed files with 1865 additions and 0 deletions

View File

View File

@@ -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("<h1>Not found</h1>", status_code=404)
return _render("scan_detail.html", scan=scan, request=request)
@router.get("/packages", response_class=HTMLResponse)
async def packages_list(
request: Request,
page: int = 1,
flagged: str = "",
session: AsyncSession = Depends(get_session),
):
per_page = 50
offset = (page - 1) * per_page
subq = select(
Scan.package_name.label("pkg_name"),
Scan.package_version.label("pkg_ver"),
Scan.ecosystem,
Scan.repository,
func.max(Scan.started_at).label("last_scan"),
func.max(Scan.flagged).label("is_flagged"),
func.sum(Scan.total_findings).label("findings_sum"),
func.max(Scan.id).label("sid"),
).group_by(Scan.package_name, Scan.package_version)
if flagged == "1":
subq = subq.having(func.max(Scan.flagged) == True)
subq = subq.subquery()
total = await session.scalar(select(func.count()).select_from(subq))
rows = (
await session.execute(
select(subq)
.order_by(subq.c.last_scan.desc())
.offset(offset)
.limit(per_page)
)
).all()
return _render(
"packages_list.html",
packages=rows,
page=page,
per_page=per_page,
total=total,
flagged_filter=flagged,
request=request,
)
@router.get("/packages/{name}/{version}", response_class=HTMLResponse)
async def package_detail(
name: str,
version: str,
request: Request,
session: AsyncSession = Depends(get_session),
):
from sqlalchemy.orm import selectinload
scans = (
await session.execute(
select(Scan)
.where(Scan.package_name == name, Scan.package_version == version)
.options(selectinload(Scan.findings))
.order_by(Scan.started_at.desc())
)
).scalars().all()
if not scans:
return HTMLResponse("<h1>Not found</h1>", status_code=404)
all_findings = []
for s in scans:
all_findings.extend(s.findings)
return _render(
"package_detail.html",
pkg_name=name,
pkg_version=version,
scans=scans,
findings=all_findings,
request=request,
)

View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GuardDog Nexus</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<style>
.flagged { color: var(--pico-color-red-400); font-weight: bold; }
.clean { color: var(--pico-color-green-400); }
.status-pending { color: var(--pico-color-yellow-400); }
.status-scanning { color: var(--pico-color-blue-400); }
.status-completed { color: var(--pico-color-green-400); }
.status-failed { color: var(--pico-color-red-400); }
.severity-WARNING { color: var(--pico-color-yellow-400); }
.severity-ERROR { color: var(--pico-color-red-400); }
.finding-card { margin-bottom: 0.5rem; padding: 0.5rem; border-left: 3px solid; }
.finding-card.WARNING { border-left-color: var(--pico-color-yellow-400); }
.finding-card.ERROR { border-left-color: var(--pico-color-red-400); }
.finding-card.INFO { border-left-color: var(--pico-color-blue-400); }
table { font-size: 0.9rem; }
nav { margin-bottom: 1rem; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
.stat-card { text-align: center; padding: 1rem; }
</style>
</head>
<body>
<main class="container">
<nav>
<ul><li><strong><a href="/">GuardDog Nexus</a></strong></li></ul>
<ul>
<li><a href="/">Dashboard</a></li>
<li><a href="/scans">Scans</a></li>
<li><a href="/packages">Packages</a></li>
</ul>
</nav>
{% block content %}{% endblock %}
</main>
</body>
</html>

View File

@@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block content %}
<h1>Dashboard</h1>
<div hx-get="/api/v1/scans/stats" hx-trigger="every 30s" hx-swap="innerHTML">
{% include "dashboard_stats.html" %}
</div>
{% endblock %}

View File

@@ -0,0 +1,56 @@
<div class="stats-grid">
<article class="stat-card">
<h5>{{ total_scans }}</h5>
<small>Total Scans</small>
</article>
<article class="stat-card">
<h5 class="flagged">{{ flagged_scans }}</h5>
<small>Flagged</small>
</article>
<article class="stat-card">
<h5 class="flagged">{{ recent_flagged }}</h5>
<small>Flagged (7 days)</small>
</article>
<article class="stat-card">
<h5>{{ total_findings }}</h5>
<small>Total Findings</small>
</article>
</div>
<h2>Latest Scans</h2>
<table>
<thead>
<tr>
<th>Package</th>
<th>Version</th>
<th>Ecosystem</th>
<th>Status</th>
<th>Findings</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{% for s in latest_scans %}
<tr>
<td><a href="/packages/{{ s.package_name }}/{{ s.package_version }}">{{ s.package_name }}</a></td>
<td>{{ s.package_version }}</td>
<td>{{ s.ecosystem }}</td>
<td><span class="status-{{ s.status }}">{{ s.status }}</span></td>
<td>{% if s.flagged %}<span class="flagged">{{ s.total_findings }}</span>{% else %}<span class="clean">{{ s.total_findings }}</span>{% endif %}</td>
<td>{{ s.started_at.strftime('%Y-%m-%d %H:%M') if s.started_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if top_rules %}
<h2>Top Rules Triggered</h2>
<table>
<thead><tr><th>Rule</th><th>Count</th></tr></thead>
<tbody>
{% for rule, cnt in top_rules %}
<tr><td><code>{{ rule }}</code></td><td>{{ cnt }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}

View File

@@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block content %}
<h1>{{ pkg_name }} <small>v{{ pkg_version }}</small></h1>
<h2>Scans ({{ scans|length }})</h2>
<table>
<thead>
<tr><th>ID</th><th>Repo</th><th>Status</th><th>Findings</th><th>Time</th></tr>
</thead>
<tbody>
{% for s in scans %}
<tr>
<td><a href="/scans/{{ s.id }}">#{{ s.id }}</a></td>
<td>{{ s.repository }}</td>
<td><span class="status-{{ s.status }}">{{ s.status }}</span></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>
</tr>
{% endfor %}
</tbody>
</table>
<h2>Findings ({{ findings|length }})</h2>
{% if findings %}
{% for f in findings|sort(attribute='severity', reverse=true) %}
<article class="finding-card {{ f.severity }}">
<strong class="severity-{{ f.severity }}">[{{ f.severity }}]</strong>
<strong>{{ f.rule }}</strong>
{% if f.location %}<small> @ {{ f.location }}</small>{% endif %}
<p>{{ f.message }}</p>
</article>
{% endfor %}
{% else %}
<p class="clean">No findings — package looks clean.</p>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block content %}
<h1>Packages</h1>
<p>
<a href="?flagged={% if flagged_filter == '1' %}0{% else %}1{% endif %}" role="button" class="outline">
{% if flagged_filter == '1' %}Show all{% else %}Flagged only{% endif %}
</a>
</p>
<table>
<thead>
<tr>
<th>Name</th>
<th>Version</th>
<th>Ecosystem</th>
<th>Repo</th>
<th>Flagged</th>
<th>Findings</th>
<th>Last Scan</th>
</tr>
</thead>
<tbody>
{% for p in packages %}
<tr>
<td><a href="/packages/{{ p.pkg_name }}/{{ p.pkg_ver }}">{{ p.pkg_name }}</a></td>
<td>{{ p.pkg_ver }}</td>
<td>{{ p.ecosystem }}</td>
<td>{{ p.repository }}</td>
<td>{% if p.is_flagged %}<span class="flagged">YES</span>{% else %}<span class="clean">No</span>{% endif %}</td>
<td>{{ p.findings_sum }}</td>
<td>{{ p.last_scan.strftime('%Y-%m-%d %H:%M') if p.last_scan }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% set total_pages = (total // per_page) + (1 if total % per_page else 0) %}
{% if total_pages > 1 %}
<nav>
<ul>
<li>{% if page > 1 %}<a href="?page={{ page - 1 }}&flagged={{ flagged_filter }}">Prev</a>{% else %}<span>Prev</span>{% endif %}</li>
<li><small>Page {{ page }} of {{ total_pages }}</small></li>
<li>{% if page < total_pages %}<a href="?page={{ page + 1 }}&flagged={{ flagged_filter }}">Next</a>{% else %}<span>Next</span>{% endif %}</li>
</ul>
</nav>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block content %}
<h1>Scan #{{ scan.id }}</h1>
<table>
<tr><td><strong>Package</strong></td><td><a href="/packages/{{ scan.package_name }}/{{ scan.package_version }}">{{ scan.package_name }}</a></td></tr>
<tr><td><strong>Version</strong></td><td>{{ scan.package_version }}</td></tr>
<tr><td><strong>Ecosystem</strong></td><td>{{ scan.ecosystem }}</td></tr>
<tr><td><strong>Repository</strong></td><td>{{ scan.repository }}</td></tr>
<tr><td><strong>Status</strong></td><td><span class="status-{{ scan.status }}">{{ scan.status }}</span></td></tr>
<tr><td><strong>SHA256</strong></td><td><code>{{ scan.sha256 or '-' }}</code></td></tr>
<tr><td><strong>Started</strong></td><td>{{ scan.started_at.isoformat() if scan.started_at }}</td></tr>
<tr><td><strong>Finished</strong></td><td>{{ scan.finished_at.isoformat() if scan.finished_at }}</td></tr>
{% if scan.error_message %}<tr><td><strong>Error</strong></td><td><span class="flagged">{{ scan.error_message }}</span></td></tr>{% endif %}
</table>
<h2>Findings ({{ scan.findings|length }})</h2>
{% if scan.findings %}
{% for f in scan.findings|sort(attribute='severity', reverse=true) %}
<article class="finding-card {{ f.severity }}">
<strong class="severity-{{ f.severity }}">[{{ f.severity }}]</strong>
<strong>{{ f.rule }}</strong>
{% if f.location %}<small> @ {{ f.location }}</small>{% endif %}
<p>{{ f.message }}</p>
</article>
{% endfor %}
{% else %}
<p class="clean">No findings — package looks clean.</p>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block content %}
<h1>Scans</h1>
<p>
<a href="?flagged={% if flagged_filter == '1' %}0{% else %}1{% endif %}" role="button" class="outline">
{% if flagged_filter == '1' %}Show all{% else %}Flagged only{% endif %}
</a>
</p>
<table>
<thead>
<tr>
<th>ID</th>
<th>Package</th>
<th>Version</th>
<th>Repo</th>
<th>Status</th>
<th>Findings</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{% for s in scans %}
<tr>
<td><a href="/scans/{{ s.id }}">#{{ s.id }}</a></td>
<td>{{ s.package_name }}</td>
<td>{{ s.package_version }}</td>
<td>{{ s.repository }}</td>
<td><span class="status-{{ s.status }}">{{ s.status }}</span></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>
</tr>
{% endfor %}
</tbody>
</table>
{% set total_pages = (total // per_page) + (1 if total % per_page else 0) %}
{% if total_pages > 1 %}
<nav>
<ul>
<li>{% if page > 1 %}<a href="?page={{ page - 1 }}&flagged={{ flagged_filter }}">Prev</a>{% else %}<span>Prev</span>{% endif %}</li>
<li><small>Page {{ page }} of {{ total_pages }}</small></li>
<li>{% if page < total_pages %}<a href="?page={{ page + 1 }}&flagged={{ flagged_filter }}">Next</a>{% else %}<span>Next</span>{% endif %}</li>
</ul>
</nav>
{% endif %}
{% endblock %}