Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 88 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from typing import List

import httpx
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi import FastAPI, File, Form, HTTPException, Request, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from pydantic import BaseModel
Expand Down Expand Up @@ -56,6 +56,7 @@
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["Content-Disposition"],
)

WORK_ROOT = Path(
Expand Down Expand Up @@ -568,6 +569,92 @@ async def get_findings(job_id: str):
return {"job_id": job_id, "finding_count": len(findings), "findings": findings}


@app.get("/jobs/{job_id}/findings/csv")
async def get_findings_csv(job_id: str):
import csv
import io

db = await get_db()
try:
cur = await db.execute("SELECT job_id FROM jobs WHERE job_id = ?", (job_id,))
job_row = await cur.fetchone()

if job_row is None:
raise HTTPException(
status_code=404, detail=f"No job found with id '{job_id}'"
)

cur = await db.execute(
"""
SELECT id, rule_id, severity, category, file_path,
line_number, cwe, scanner, message, package_name, package_version, created_at
FROM findings
WHERE job_id = ?
ORDER BY created_at
""",
(job_id,),
)
columns = [col[0] for col in cur.description]
rows = await cur.fetchall()
finally:
await db.close()

output = io.StringIO(newline="")
# Write UTF-8 BOM for Microsoft Excel compatibility
output.write("\ufeff")
writer = csv.writer(output, lineterminator="\r\n")

# Write CSV header row
writer.writerow([
"finding_id",
"scanner",
"severity",
"file_path",
"line_number",
"title",
"description",
"status"
])

# Write findings
for row in rows:
row_dict = dict(zip(columns, row))

finding_id = row_dict.get("id") or ""
scanner = row_dict.get("scanner") or ""
severity = row_dict.get("severity") or ""
file_path = row_dict.get("file_path") or ""
line_number = row_dict.get("line_number")
line_number_str = str(line_number) if line_number is not None else ""

# rule_id acts as title/name
title = row_dict.get("rule_id") or ""
description = row_dict.get("message") or ""

# findings are currently hardcoded to "open" on the UI as there's no status storage
status = "open"

writer.writerow([
finding_id,
scanner,
severity,
file_path,
line_number_str,
title,
description,
status
])

csv_content = output.getvalue()
output.close()

headers = {
"Content-Disposition": f'attachment; filename="findings-{job_id}.csv"',
"Content-Type": "text/csv"
}
return Response(content=csv_content, media_type="text/csv", headers=headers)


@app.get("/jobs/{job_id}/verify")
async def get_verify(job_id: str):
db = await get_db()
Expand Down
2 changes: 1 addition & 1 deletion backend/app/reports/evidence_pack.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def build_evidence_pack(
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as z:
for p in pack_root.rglob("*"):
if p.is_file():
z.write(p, arcname=str(p.relative_to(pack_root)))
z.write(p, arcname=p.relative_to(pack_root).as_posix())

return zip_path

Expand Down
5 changes: 5 additions & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import pytest

@pytest.fixture(scope="session")
def anyio_backend():
return "asyncio"
52 changes: 52 additions & 0 deletions backend/tests/test_job_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,55 @@ def test_verify_not_run_yet(self):
res = client.get(f"/jobs/{JOB_ID}/verify")
assert res.status_code == 404
assert "No verify outcome" in res.json()["detail"]


class TestGetFindingsCsv:
def test_happy_path(self):
with patch(
"app.main.get_db", AsyncMock(return_value=db_mock(True, findings=FINDINGS))
):
res = client.get(f"/jobs/{JOB_ID}/findings/csv")
assert res.status_code == 200
assert "text/csv" in res.headers["content-type"]
assert f'attachment; filename="findings-{JOB_ID}.csv"' in res.headers["content-disposition"]

csv_text = res.text.lstrip('\ufeff')
lines = csv_text.splitlines()
assert len(lines) == 4 # header + 3 findings

# Verify headers
headers = lines[0].split(",")
expected_headers = ["finding_id", "scanner", "severity", "file_path", "line_number", "title", "description", "status"]
assert headers == expected_headers

# Verify one of the rows
row1 = lines[1].split(",")
assert row1[1] == "semgrep"
assert row1[2] == "HIGH"
assert row1[3] == "app/config.py"
assert row1[4] == "42"
assert row1[5] == "semgrep.hardcoded-secret"
assert row1[6] == "Hardcoded secret detected"
assert row1[7] == "open"

def test_job_with_no_findings(self):
with patch(
"app.main.get_db", AsyncMock(return_value=db_mock(True, findings=[]))
):
res = client.get(f"/jobs/{JOB_ID}/findings/csv")
assert res.status_code == 200
csv_text = res.text.lstrip('\ufeff')
lines = csv_text.splitlines()
assert len(lines) == 1 # only header row
headers = lines[0].split(",")
expected_headers = ["finding_id", "scanner", "severity", "file_path", "line_number", "title", "description", "status"]
assert headers == expected_headers

def test_unknown_job(self):
with patch(
"app.main.get_db", AsyncMock(return_value=db_mock(False, findings=[]))
):
res = client.get("/jobs/does-not-exist/findings/csv")
assert res.status_code == 404
assert "does-not-exist" in res.json()["detail"]

39 changes: 39 additions & 0 deletions frontend/src/app/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,45 @@ export async function downloadEvidencePack(
return { blob, filename };
}

export async function downloadFindingsCsv(jobId: string) {
const res = await fetch(`${API_BASE}/jobs/${jobId}/findings/csv`);

const contentType = res.headers.get("content-type") || "";

if (!res.ok) {
let errMsg = `Failed to export CSV report (Status ${res.status}).`;
if (contentType.includes("application/json")) {
try {
const errJson = await res.json();
errMsg = errJson.detail || errMsg;
} catch {}
} else {
try {
const errText = await res.text();
if (errText) errMsg = errText;
} catch {}
}
throw new Error(errMsg);
}

if (contentType.includes("application/json")) {
const errJson = await res.json().catch(() => null);
throw new Error(errJson?.detail || "Received JSON response instead of CSV file.");
}

const blob = await res.blob();

const cd = res.headers.get("content-disposition") || "";
const match = cd.match(/filename="?([^"]+)"?/i);
let filename = match?.[1] || `findings-${jobId}.csv`;

if (!filename.toLowerCase().endsWith(".csv")) {
filename += ".csv";
}

return { blob, filename };
}

export type TrendData = {
date: string;
findings: number;
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/app/lib/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,9 @@ export function saveBlob(blob: Blob, filename: string) {
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
// Defer revocation to give the browser time to initiate the download
setTimeout(() => {
URL.revokeObjectURL(url);
}, 100);
}

52 changes: 47 additions & 5 deletions frontend/src/app/pages/findings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,32 @@ import type { Finding } from "../data/sample-data";
import { loadLastScan } from "../lib/scan-store";
import { mapBackendFindingToUi } from "../lib/mappers";
import { cn } from "../components/ui/utils";
import { downloadFindingsCsv } from "../lib/api";
import { saveBlob } from "../lib/download";

export function Findings() {
const navigate = useNavigate();

const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);

const onExportCsv = async () => {
if (!scan?.job_id) {
setExportError("No active scan job found to export.");
return;
}
setExporting(true);
setExportError(null);
try {
const { blob, filename } = await downloadFindingsCsv(scan.job_id);
saveBlob(blob, filename);
} catch (e: any) {
setExportError(e?.message || "Failed to export CSV report.");
} finally {
setExporting(false);
}
};

const scan = useMemo(() => loadLastScan(), []);
const findings: Finding[] = useMemo(
() => (scan ? scan.findings.map(mapBackendFindingToUi) : []),
Expand Down Expand Up @@ -129,13 +151,33 @@ export function Findings() {

return (
<div className="container mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8 max-w-7xl pb-20 md:pb-8">
<div className="mb-6">
<h1 className="mb-2">Findings</h1>
<p className="text-muted-foreground">
{findings.length} vulnerabilities detected in {scan.project_name}
</p>
<div className="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="mb-2">Findings</h1>
<p className="text-muted-foreground">
{findings.length} vulnerabilities detected in {scan.project_name}
</p>
</div>
<div className="flex items-center gap-2">
<Button
onClick={onExportCsv}
disabled={exporting}
variant="outline"
size="sm"
className="w-full sm:w-auto"
>
<Download className="h-4 w-4 mr-2" />
{exporting ? "Exporting CSV..." : "Export CSV"}
</Button>
</div>
</div>

{exportError && (
<div className="p-3 mb-6 rounded-lg bg-destructive/10 text-destructive text-sm border border-destructive/20 font-medium">
{exportError}
</div>
)}

<Card className="mb-6">
<CardContent className="p-4">
<div className="flex flex-col md:flex-row gap-4">
Expand Down
12 changes: 1 addition & 11 deletions frontend/src/app/pages/verify.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { cn } from "../components/ui/utils";

import { downloadEvidencePack } from "../lib/api";
import { loadLastScan } from "../lib/scan-store";
import { saveBlob } from "../lib/download";

interface VerificationCheck {
id: string;
Expand All @@ -29,17 +30,6 @@ interface TimelineEvent {
status: "completed" | "current";
}

function saveBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}

export function Verify() {
const scan = loadLastScan();

Expand Down
Loading