feat: guarddog-nexus — webhook-based PyPI scanner with web UI
This commit is contained in:
0
guarddog_nexus/api/__init__.py
Normal file
0
guarddog_nexus/api/__init__.py
Normal file
49
guarddog_nexus/api/findings.py
Normal file
49
guarddog_nexus/api/findings.py
Normal file
@@ -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
|
||||
],
|
||||
}
|
||||
122
guarddog_nexus/api/packages.py
Normal file
122
guarddog_nexus/api/packages.py
Normal file
@@ -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
|
||||
],
|
||||
}
|
||||
120
guarddog_nexus/api/scans.py
Normal file
120
guarddog_nexus/api/scans.py
Normal file
@@ -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
|
||||
],
|
||||
}
|
||||
Reference in New Issue
Block a user