feat: guarddog-nexus — webhook-based PyPI scanner with web UI
This commit is contained in:
0
guarddog_nexus/web/__init__.py
Normal file
0
guarddog_nexus/web/__init__.py
Normal file
191
guarddog_nexus/web/routes.py
Normal file
191
guarddog_nexus/web/routes.py
Normal 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,
|
||||
)
|
||||
41
guarddog_nexus/web/templates/base.html
Normal file
41
guarddog_nexus/web/templates/base.html
Normal 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>
|
||||
8
guarddog_nexus/web/templates/dashboard.html
Normal file
8
guarddog_nexus/web/templates/dashboard.html
Normal 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 %}
|
||||
56
guarddog_nexus/web/templates/dashboard_stats.html
Normal file
56
guarddog_nexus/web/templates/dashboard_stats.html
Normal 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 %}
|
||||
36
guarddog_nexus/web/templates/package_detail.html
Normal file
36
guarddog_nexus/web/templates/package_detail.html
Normal 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 %}
|
||||
48
guarddog_nexus/web/templates/packages_list.html
Normal file
48
guarddog_nexus/web/templates/packages_list.html
Normal 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 %}
|
||||
30
guarddog_nexus/web/templates/scan_detail.html
Normal file
30
guarddog_nexus/web/templates/scan_detail.html
Normal 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 %}
|
||||
48
guarddog_nexus/web/templates/scans_list.html
Normal file
48
guarddog_nexus/web/templates/scans_list.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user