Skip to content
Open
129 changes: 124 additions & 5 deletions backend/secuscan/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"""
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -28,6 +29,7 @@ export function AppRoutes() {
<Route path={routes.scans} element={<Scans />} />
<Route path={routes.reports} element={<Reports />} />
<Route path={routes.workflows} element={<Workflows />} />
<Route path={routes.audit} element={<AuditLog />} />
<Route path={routes.settings} element={<Settings />} />
<Route path={routes.task} element={<TaskDetails />} />

Expand Down
54 changes: 54 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
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<AuditListResponse> {
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<AuditListResponse>(`/audit?${qs.toString()}`)
}

export async function exportAuditLogs(params?: AuditQueryParams, format: 'csv' | 'json' = 'csv'): Promise<Blob> {
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()
}
1 change: 1 addition & 0 deletions frontend/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export default function Sidebar() {

<NavItem to={routes.reports} icon="summarize" label="Reports" isExpanded={isExpanded} />
<NavItem to={routes.workflows} icon="account_tree" label="Workflows" isExpanded={isExpanded} />
<NavItem to={routes.audit} icon="history" label="Audit Log" isExpanded={isExpanded} />

</div>

Expand Down
Loading
Loading