diff --git a/backend/secuscan/routes.py b/backend/secuscan/routes.py index 1fba3ee2..94ff7aaf 100644 --- a/backend/secuscan/routes.py +++ b/backend/secuscan/routes.py @@ -11,6 +11,8 @@ import os import shutil import uuid +import csv +import io import asyncio from pathlib import Path from urllib.parse import urlparse @@ -949,14 +951,22 @@ async def delete_task_records(task_ids: List[str]): ) all_task_rows.extend(rows) - # Delete associated records in chunks + # Log deletion before removing records + for tid in task_ids: + await db.log_audit( + event_type="task_deleted", + message=f"Task {tid} deleted with all associated findings and reports", + severity="info", + task_id=tid, + ) + + # Delete associated records in chunks (preserve audit_log for accountability) for i in range(0, len(task_ids), SQLITE_CHUNK_SIZE): chunk = task_ids[i : i + SQLITE_CHUNK_SIZE] placeholders = ",".join(["?"] * len(chunk)) - await db.execute(f"DELETE FROM findings WHERE task_id IN ({placeholders})", tuple(chunk)) - await db.execute(f"DELETE FROM reports WHERE task_id IN ({placeholders})", tuple(chunk)) - await db.execute(f"DELETE FROM audit_log WHERE task_id IN ({placeholders})", tuple(chunk)) - await db.execute(f"DELETE FROM tasks WHERE id IN ({placeholders})", tuple(chunk)) + await db.execute(f"DELETE FROM findings WHERE task_id IN ({placeholders})", tuple(chunk)) + await db.execute(f"DELETE FROM reports WHERE task_id IN ({placeholders})", tuple(chunk)) + await db.execute(f"DELETE FROM tasks WHERE id IN ({placeholders})", tuple(chunk)) # Cleanup files on disk for row in all_task_rows: @@ -1058,6 +1068,115 @@ async def clear_all_tasks(): } +@router.get("/audit") +async def get_audit_logs( + event_type: Optional[str] = None, + plugin_id: Optional[str] = None, + date_from: Optional[str] = None, + date_to: Optional[str] = None, + page: int = 1, + per_page: int = 50, +): + """Return paginated audit log entries, filterable by event_type, plugin_id, and date range.""" + db = await get_db() + where_clauses: List[str] = [] + params: List[Any] = [] + + if event_type: + where_clauses.append("event_type = ?") + params.append(event_type) + if plugin_id: + where_clauses.append("plugin_id = ?") + params.append(plugin_id) + if date_from: + where_clauses.append("timestamp >= ?") + params.append(date_from) + if date_to: + where_clauses.append("timestamp <= ?") + params.append(date_to) + + where_sql = (" WHERE " + " AND ".join(where_clauses)) if where_clauses else "" + + # Total count + count_row = await db.fetchone(f"SELECT COUNT(*) as cnt FROM audit_log{where_sql}", tuple(params)) + total = count_row["cnt"] if count_row else 0 + + # Paginated results + offset = (page - 1) * per_page + rows = await db.fetchall( + f"SELECT * FROM audit_log{where_sql} ORDER BY timestamp DESC LIMIT ? OFFSET ?", + tuple(params) + (per_page, offset), + ) + + entries = parse_json_fields(rows, ["context_json"]) + + return { + "entries": entries, + "total": total, + "page": page, + "per_page": per_page, + "total_pages": max(1, (total + per_page - 1) // per_page), + } + + +@router.get("/audit/export") +async def export_audit_logs( + format: str = "json", + event_type: Optional[str] = None, + plugin_id: Optional[str] = None, + date_from: Optional[str] = None, + date_to: Optional[str] = None, +): + """Export audit log as CSV or JSON.""" + db = await get_db() + where_clauses: List[str] = [] + params: List[Any] = [] + + if event_type: + where_clauses.append("event_type = ?") + params.append(event_type) + if plugin_id: + where_clauses.append("plugin_id = ?") + params.append(plugin_id) + if date_from: + where_clauses.append("timestamp >= ?") + params.append(date_from) + if date_to: + where_clauses.append("timestamp <= ?") + params.append(date_to) + + where_sql = (" WHERE " + " AND ".join(where_clauses)) if where_clauses else "" + rows = await db.fetchall( + f"SELECT id, timestamp, event_type, severity, message, task_id, plugin_id, context_json FROM audit_log{where_sql} ORDER BY timestamp DESC", + tuple(params), + ) + + if format == "csv": + output = io.StringIO() + writer = csv.writer(output) + writer.writerow(["id", "timestamp", "event_type", "severity", "message", "task_id", "plugin_id", "context"]) + for row in rows: + writer.writerow([ + row["id"], row["timestamp"], row["event_type"], + row["severity"], row["message"], row["task_id"], + row["plugin_id"], row.get("context_json", "{}"), + ]) + csv_bytes = output.getvalue().encode("utf-8") + return Response( + content=csv_bytes, + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=secuscan-audit-log.csv"}, + ) + + # Default JSON + entries = parse_json_fields(rows, ["context_json"]) + return Response( + content=json.dumps(entries, indent=2), + media_type="application/json", + headers={"Content-Disposition": "attachment; filename=secuscan-audit-log.json"}, + ) + + @router.get("/settings") async def get_settings(): """Get current settings""" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2d327482..9988ab61 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import Settings from './pages/Settings' import Scans from './pages/Scans' import TaskDetails from './pages/TaskDetails' import Workflows from './pages/Workflows' +import AuditLog from './pages/AuditLog' import ApiKeySetupScreen from './components/ApiKeySetupScreen' import { ThemeProvider } from './components/ThemeContext' @@ -28,6 +29,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index b3cb9fcf..3c346e9f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -338,3 +338,57 @@ export function deleteWorkflow(workflowId: string): Promise<{ deleted: boolean } method: 'DELETE', }) } +/* ─── Audit Log ─────────────────────────────────────────── */ + +export interface AuditEntry { + id: number + timestamp: string + event_type: string + severity: string + user_id: string | null + ip_address: string | null + message: string + context_json: Record + task_id: string | null + plugin_id: string | null +} + +export interface AuditListResponse { + entries: AuditEntry[] + total: number + page: number + per_page: number + total_pages: number +} + +export interface AuditQueryParams { + event_type?: string + plugin_id?: string + date_from?: string + date_to?: string + page?: number + per_page?: number +} + +export function getAuditLogs(params?: AuditQueryParams): Promise { + const qs = new URLSearchParams() + if (params?.event_type) qs.set('event_type', params.event_type) + if (params?.plugin_id) qs.set('plugin_id', params.plugin_id) + if (params?.date_from) qs.set('date_from', params.date_from) + if (params?.date_to) qs.set('date_to', params.date_to) + if (params?.page) qs.set('page', String(params.page)) + if (params?.per_page) qs.set('per_page', String(params.per_page)) + return request(`/audit?${qs.toString()}`) +} + +export async function exportAuditLogs(params?: AuditQueryParams, format: 'csv' | 'json' = 'csv'): Promise { + const qs = new URLSearchParams() + qs.set('format', format) + if (params?.event_type) qs.set('event_type', params.event_type) + if (params?.plugin_id) qs.set('plugin_id', params.plugin_id) + if (params?.date_from) qs.set('date_from', params.date_from) + if (params?.date_to) qs.set('date_to', params.date_to) + const response = await fetch(`${API_BASE}/audit/export?${qs.toString()}`) + if (!response.ok) throw new Error(`Export failed: ${response.status}`) + return response.blob() +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 99bf5e2b..af6d6abc 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -168,6 +168,7 @@ export default function Sidebar() { + diff --git a/frontend/src/pages/AuditLog.tsx b/frontend/src/pages/AuditLog.tsx new file mode 100644 index 00000000..8b12ef0c --- /dev/null +++ b/frontend/src/pages/AuditLog.tsx @@ -0,0 +1,258 @@ +import React, { useEffect, useState, useCallback } from 'react' +import { motion } from 'framer-motion' +import { + getAuditLogs, + exportAuditLogs, + type AuditEntry, + type AuditQueryParams, +} from '../api' +import { formatDateLong } from '../utils/date' + +const EVENT_TYPES = [ + 'task_created', 'task_started', 'task_completed', 'task_failed', + 'task_cancelled', 'task_deleted', 'report_downloaded', +] + +const SEVERITY_COLORS: Record = { + info: 'text-cyan', + warning: 'text-amber', + error: 'text-rag-red', +} + +const containerVariants = { + hidden: { opacity: 0 }, + visible: { opacity: 1, transition: { staggerChildren: 0.03 } }, +} + +const itemVariants = { + hidden: { opacity: 0, y: 12 }, + visible: { opacity: 1, y: 0, transition: { duration: 0.25 } }, +} + +export default function AuditLog() { + const [entries, setEntries] = useState([]) + const [loading, setLoading] = useState(true) + const [page, setPage] = useState(1) + const [totalPages, setTotalPages] = useState(1) + const [total, setTotal] = useState(0) + const [expandedId, setExpandedId] = useState(null) + const [filters, setFilters] = useState({ per_page: 50 }) + const [exporting, setExporting] = useState(false) + + const fetchLogs = useCallback(async () => { + setLoading(true) + try { + const data = await getAuditLogs({ ...filters, page }) + setEntries(data.entries) + setTotalPages(data.total_pages) + setTotal(data.total) + } catch (err) { + console.error('Failed to fetch audit logs:', err) + } finally { + setLoading(false) + } + }, [filters, page]) + + useEffect(() => { + fetchLogs() + }, [fetchLogs]) + + const handleExport = async (format: 'csv' | 'json') => { + setExporting(true) + try { + const blob = await exportAuditLogs(filters, format) + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `secuscan-audit-log.${format}` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + window.URL.revokeObjectURL(url) + } catch (err) { + console.error('Export failed:', err) + } finally { + setExporting(false) + } + } + + return ( + + {/* Header */} +
+
+

Audit Log

+

+ {total} event{total !== 1 ? 's' : ''} recorded +

+
+
+ + +
+
+ + {/* Filters */} +
+
+ + +
+
+ + { setFilters(f => ({ ...f, date_from: e.target.value || undefined })); setPage(1) }} + className="bg-bg-tertiary border-4 border-black px-3 py-2 text-[11px] font-bold text-primary outline-none" + /> +
+
+ + { setFilters(f => ({ ...f, date_to: e.target.value || undefined })); setPage(1) }} + className="bg-bg-tertiary border-4 border-black px-3 py-2 text-[11px] font-bold text-primary outline-none" + /> +
+ {(filters.event_type || filters.date_from || filters.date_to) && ( + + )} +
+ + {/* Table */} +
+ + + + + + + + + + + + + + {loading ? ( + + + + ) : entries.length === 0 ? ( + + + + ) : ( + entries.map((entry) => ( + + setExpandedId(expandedId === entry.id ? null : entry.id)} + > + + + + + + + + + {expandedId === entry.id && ( + + + + )} + + )) + )} + +
TimestampEventSeverityMessageTaskPlugin
+ Loading... +
+ No audit events found +
+ {formatDateLong(entry.timestamp)} + + + {entry.event_type.replace(/_/g, ' ')} + + + + {entry.severity} + + + {entry.message} + + {entry.task_id ? entry.task_id.slice(0, 8) + '...' : '-'} + + {entry.plugin_id || '-'} + + + {expandedId === entry.id ? 'expand_less' : 'expand_more'} + +
+
+                          {JSON.stringify(entry.context_json, null, 2)}
+                        
+
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} +
+ ) +} diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 38caa98f..7da1213c 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -6,6 +6,7 @@ export const routes = { scans: '/scans', reports: '/reports', workflows: '/workflows', + audit: '/audit', settings: '/settings', task: '/task/:taskId', } as const diff --git a/testing/backend/integration/test_audit_routes.py b/testing/backend/integration/test_audit_routes.py new file mode 100644 index 00000000..c9b8f2ba --- /dev/null +++ b/testing/backend/integration/test_audit_routes.py @@ -0,0 +1,206 @@ +""" +Tests for audit log API routes — /api/v1/audit and /api/v1/audit/export, +plus verification that delete_task_records preserves audit_log entries. +""" + +import uuid + +import aiosqlite +import pytest + + +@pytest.fixture +def db_path(test_client): + from backend.secuscan.config import settings + return settings.database_path + + +def test_get_audit_logs_empty(test_client): + response = test_client.get("/api/v1/audit") + assert response.status_code == 200 + data = response.json() + assert data["entries"] == [] + assert data["total"] == 0 + assert data["page"] == 1 + assert data["per_page"] == 50 + assert data["total_pages"] == 1 + + +def test_get_audit_logs_with_data(test_client, db_path): + import asyncio + async def seed(): + async with aiosqlite.connect(db_path) as conn: + conn.row_factory = aiosqlite.Row + for i in range(5): + await conn.execute( + "INSERT INTO audit_log (id, event_type, severity, message, timestamp) " + "VALUES (?, 'scan_completed', 'info', ?, datetime('now'))", + (str(uuid.uuid4()), f"Scan {i} completed"), + ) + await conn.commit() + asyncio.run(seed()) + + response = test_client.get("/api/v1/audit") + assert response.status_code == 200 + data = response.json() + assert data["total"] >= 5 + assert len(data["entries"]) >= 5 + assert data["page"] == 1 + assert data["per_page"] == 50 + + +def test_audit_pagination_bounds(test_client, db_path): + import asyncio + async def seed(): + async with aiosqlite.connect(db_path) as conn: + conn.row_factory = aiosqlite.Row + for i in range(10): + await conn.execute( + "INSERT INTO audit_log (id, event_type, severity, message, timestamp) " + "VALUES (?, 'scan_completed', 'info', ?, datetime('now'))", + (str(uuid.uuid4()), f"Batch {i}"), + ) + await conn.commit() + asyncio.run(seed()) + + resp_page_1 = test_client.get("/api/v1/audit?page=1&per_page=3") + assert resp_page_1.status_code == 200 + d1 = resp_page_1.json() + assert len(d1["entries"]) == 3 + assert d1["total_pages"] == 4 + + resp_page_2 = test_client.get("/api/v1/audit?page=2&per_page=3") + assert resp_page_2.status_code == 200 + d2 = resp_page_2.json() + assert len(d2["entries"]) == 3 + + resp_page_4 = test_client.get("/api/v1/audit?page=4&per_page=3") + assert resp_page_4.status_code == 200 + d4 = resp_page_4.json() + assert len(d4["entries"]) == 1 + + resp_page_5 = test_client.get("/api/v1/audit?page=5&per_page=3") + assert resp_page_5.status_code == 200 + d5 = resp_page_5.json() + assert d5["entries"] == [] + + +def test_audit_filter_by_event_type(test_client, db_path): + import asyncio + async def seed(): + async with aiosqlite.connect(db_path) as conn: + conn.row_factory = aiosqlite.Row + for evt in [("scan_start", "started", "info"), + ("scan_completed", "done", "info"), + ("task_deleted", "removed", "warning")]: + await conn.execute( + "INSERT INTO audit_log (id, event_type, severity, message, timestamp) " + "VALUES (?, ?, ?, ?, datetime('now'))", + (str(uuid.uuid4()), evt[0], evt[2], evt[1]), + ) + await conn.commit() + asyncio.run(seed()) + + resp = test_client.get("/api/v1/audit?event_type=scan_completed") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] >= 1 + for entry in data["entries"]: + assert entry["event_type"] == "scan_completed" + + +def test_audit_export_json(test_client, db_path): + import asyncio + async def seed(): + async with aiosqlite.connect(db_path) as conn: + conn.row_factory = aiosqlite.Row + await conn.execute( + "INSERT INTO audit_log (id, event_type, severity, message, timestamp) " + "VALUES (?, 'scan_start', 'info', ?, datetime('now'))", + (str(uuid.uuid4()), "export test"), + ) + await conn.commit() + asyncio.run(seed()) + + response = test_client.get("/api/v1/audit/export?format=json") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + assert "filename=secuscan-audit-log.json" in response.headers["content-disposition"] + data = response.json() + assert isinstance(data, list) + assert len(data) >= 1 + + +def test_audit_export_csv(test_client, db_path): + import asyncio + async def seed(): + async with aiosqlite.connect(db_path) as conn: + conn.row_factory = aiosqlite.Row + await conn.execute( + "INSERT INTO audit_log (id, event_type, severity, message, timestamp) " + "VALUES (?, 'scan_start', 'info', ?, datetime('now'))", + (str(uuid.uuid4()), "csv export"), + ) + await conn.commit() + asyncio.run(seed()) + + response = test_client.get("/api/v1/audit/export?format=csv") + assert response.status_code == 200 + assert response.headers["content-type"] == "text/csv" + assert "filename=secuscan-audit-log.csv" in response.headers["content-disposition"] + body = response.text + assert body.startswith("id,timestamp,event_type,severity,message,task_id,plugin_id,context") + assert "csv export" in body + + +def test_audit_export_default_format_json(test_client): + response = test_client.get("/api/v1/audit/export") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + + +def test_audit_export_invalid_format(test_client): + response = test_client.get("/api/v1/audit/export?format=xml") + assert response.status_code == 200 + assert response.headers["content-type"] == "application/json" + + +def test_audit_log_preserved_on_task_deletion(test_client, db_path): + import asyncio + from unittest.mock import patch, AsyncMock + + async def seed(): + task_id = str(uuid.uuid4()) + async with aiosqlite.connect(db_path) as conn: + conn.row_factory = aiosqlite.Row + await conn.execute( + "INSERT INTO tasks (id, plugin_id, tool_name, target, status, inputs_json, consent_granted) " + "VALUES (?, 'nmap', 'nmap', '127.0.0.1', 'completed', '{}', 1)", + (task_id,), + ) + await conn.execute( + "INSERT INTO audit_log (id, event_type, severity, message, task_id, timestamp) " + "VALUES (?, 'scan_completed', 'info', ?, ?, datetime('now'))", + (str(uuid.uuid4()), f"Task {task_id} completed", task_id), + ) + await conn.commit() + return task_id + + task_id = asyncio.run(seed()) + + with patch("backend.secuscan.routes.executor") as mock_exec: + mock_exec.get_task_status = AsyncMock(return_value={"status": "completed"}) + delete_resp = test_client.delete(f"/api/v1/task/{task_id}") + assert delete_resp.status_code == 200 + + async def verify(): + async with aiosqlite.connect(db_path) as conn: + conn.row_factory = aiosqlite.Row + rows = await conn.execute_fetchall( + "SELECT id, event_type, message FROM audit_log WHERE task_id = ?", + (task_id,), + ) + assert len(rows) == 1 + assert rows[0][1] == "scan_completed" + + asyncio.run(verify()) diff --git a/testing/backend/integration/test_task_cleanup.py b/testing/backend/integration/test_task_cleanup.py index 797bfe29..f11ff983 100644 --- a/testing/backend/integration/test_task_cleanup.py +++ b/testing/backend/integration/test_task_cleanup.py @@ -183,7 +183,7 @@ async def test_delete_task_returns_200_and_removes_row(app_client): @pytest.mark.asyncio async def test_delete_task_also_removes_associated_records(app_client): - """Deleting a task cascades to findings, reports, and audit_log.""" + """Deleting a task cascades to findings and reports but preserves audit_log.""" db = app_client._db db_path = app_client._db_path task_id = await insert_task(db, status="completed") @@ -201,7 +201,7 @@ async def test_delete_task_also_removes_associated_records(app_client): assert len(rows) == 0, "Report should have been deleted" rows = await db_fetchall(db_path, "SELECT id FROM audit_log WHERE task_id = ?", (task_id,)) - assert len(rows) == 0, "Audit log rows should have been deleted" + assert len(rows) == 1, "Audit log rows should be preserved for accountability" @pytest.mark.asyncio