From e9006cfb2cbc990620e575dd72ea0257b6767e93 Mon Sep 17 00:00:00 2001 From: shreyagupta2006 Date: Fri, 12 Jun 2026 02:00:17 +0530 Subject: [PATCH] feat: add CSV export for findings --- backend/app/main.py | 89 +++++++++++++++++++++++++++- backend/app/reports/evidence_pack.py | 2 +- backend/tests/conftest.py | 5 ++ backend/tests/test_job_endpoints.py | 52 ++++++++++++++++ frontend/src/app/lib/api.ts | 39 ++++++++++++ frontend/src/app/lib/download.ts | 6 +- frontend/src/app/pages/findings.tsx | 52 ++++++++++++++-- frontend/src/app/pages/verify.tsx | 12 +--- 8 files changed, 238 insertions(+), 19 deletions(-) create mode 100644 backend/tests/conftest.py diff --git a/backend/app/main.py b/backend/app/main.py index f5e6170..f655d74 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 @@ -56,6 +56,7 @@ allow_credentials=True, allow_methods=["*"], allow_headers=["*"], + expose_headers=["Content-Disposition"], ) WORK_ROOT = Path( @@ -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() diff --git a/backend/app/reports/evidence_pack.py b/backend/app/reports/evidence_pack.py index f83b699..8bf47b1 100644 --- a/backend/app/reports/evidence_pack.py +++ b/backend/app/reports/evidence_pack.py @@ -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 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..f08359b --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,5 @@ +import pytest + +@pytest.fixture(scope="session") +def anyio_backend(): + return "asyncio" diff --git a/backend/tests/test_job_endpoints.py b/backend/tests/test_job_endpoints.py index 3b07fd8..e55406f 100644 --- a/backend/tests/test_job_endpoints.py +++ b/backend/tests/test_job_endpoints.py @@ -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"] + diff --git a/frontend/src/app/lib/api.ts b/frontend/src/app/lib/api.ts index 3e51a74..00bb61d 100644 --- a/frontend/src/app/lib/api.ts +++ b/frontend/src/app/lib/api.ts @@ -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; diff --git a/frontend/src/app/lib/download.ts b/frontend/src/app/lib/download.ts index 075adda..f1856e5 100644 --- a/frontend/src/app/lib/download.ts +++ b/frontend/src/app/lib/download.ts @@ -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); } + diff --git a/frontend/src/app/pages/findings.tsx b/frontend/src/app/pages/findings.tsx index 1a7535a..de41cd1 100644 --- a/frontend/src/app/pages/findings.tsx +++ b/frontend/src/app/pages/findings.tsx @@ -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(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) : []), @@ -129,13 +151,33 @@ export function Findings() { return (
-
-

Findings

-

- {findings.length} vulnerabilities detected in {scan.project_name} -

+
+
+

Findings

+

+ {findings.length} vulnerabilities detected in {scan.project_name} +

+
+
+ +
+ {exportError && ( +
+ {exportError} +
+ )} +
diff --git a/frontend/src/app/pages/verify.tsx b/frontend/src/app/pages/verify.tsx index eadd8cc..ff1b5e1 100644 --- a/frontend/src/app/pages/verify.tsx +++ b/frontend/src/app/pages/verify.tsx @@ -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; @@ -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();