188 lines
5.6 KiB
Python
188 lines
5.6 KiB
Python
"""REST API for packages (distinct packages across scans)."""
|
|
|
|
import csv
|
|
import io
|
|
|
|
from fastapi import APIRouter, Depends, Query, Response
|
|
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"])
|
|
|
|
VALID_SORT_FIELDS = {
|
|
"name": Scan.package_name,
|
|
"version": Scan.package_version,
|
|
"last_scanned_at": Scan.started_at,
|
|
"total_findings": Scan.total_findings,
|
|
"flagged": Scan.flagged,
|
|
}
|
|
|
|
|
|
@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),
|
|
search: str | None = Query(None),
|
|
repository: str | None = Query(None),
|
|
sort_by: str = Query("last_scanned_at"),
|
|
sort_dir: str = Query("desc"),
|
|
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)
|
|
if repository:
|
|
subq = subq.where(Scan.repository == repository)
|
|
if search:
|
|
pattern = f"%{search}%"
|
|
subq = subq.where(
|
|
Scan.package_name.ilike(pattern) | Scan.package_version.ilike(pattern)
|
|
)
|
|
|
|
sort_field = VALID_SORT_FIELDS.get(sort_by, Scan.started_at)
|
|
sort_dir = "asc" if sort_dir.lower() == "asc" else "desc"
|
|
sort_col = func.max(sort_field)
|
|
subq = subq.order_by(
|
|
sort_col.desc() if sort_dir == "desc" else sort_col.asc()
|
|
)
|
|
|
|
total_q = select(func.count()).select_from(subq.subquery())
|
|
total = await session.scalar(total_q)
|
|
|
|
rows = (
|
|
await session.execute(subq.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("/export")
|
|
async def export_packages_csv(
|
|
flagged: bool | None = Query(None),
|
|
search: str | 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"),
|
|
).group_by(Scan.package_name, Scan.package_version)
|
|
|
|
if flagged is not None:
|
|
subq = subq.having(func.max(Scan.flagged) == flagged)
|
|
if search:
|
|
pattern = f"%{search}%"
|
|
subq = subq.where(
|
|
Scan.package_name.ilike(pattern) | Scan.package_version.ilike(pattern)
|
|
)
|
|
|
|
subq = subq.order_by(func.max(Scan.started_at).desc())
|
|
rows = (await session.execute(subq)).all()
|
|
|
|
output = io.StringIO()
|
|
writer = csv.writer(output)
|
|
writer.writerow([
|
|
"name", "version", "ecosystem", "repository",
|
|
"last_scanned_at", "flagged", "total_findings"
|
|
])
|
|
for r in rows:
|
|
writer.writerow([
|
|
r.package_name, r.package_version, r.ecosystem, r.repository,
|
|
r.last_scanned_at.isoformat() if r.last_scanned_at else "",
|
|
bool(r.is_flagged),
|
|
r.total_findings,
|
|
])
|
|
|
|
return Response(
|
|
content=output.getvalue(),
|
|
media_type="text/csv",
|
|
headers={"Content-Disposition": "attachment; filename=packages_export.csv"},
|
|
)
|
|
|
|
|
|
@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: list[dict] = []
|
|
for s in scans:
|
|
findings = (
|
|
(await session.execute(select(Finding).where(Finding.scan_id == s.id))).scalars().all()
|
|
)
|
|
for f in findings:
|
|
all_findings.append({"id": f.id, **f.data})
|
|
|
|
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": all_findings,
|
|
}
|