From 21b47744b9960abd8e8f3d22409c063c38fa949d Mon Sep 17 00:00:00 2001 From: shravanithouta108 Date: Sun, 17 May 2026 14:28:53 +0530 Subject: [PATCH 1/5] fix: add pytest-asyncio and anyio to dev requirements for CI Signed-off-by: shravanithouta108 --- backend/requirements-dev.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt index 66da8f774..179a00208 100644 --- a/backend/requirements-dev.txt +++ b/backend/requirements-dev.txt @@ -2,3 +2,5 @@ pytest>=8.3.5 pytest-cov>=6.0.0 httpx>=0.28.1 ruff>=0.15.12 +pytest-asyncio>=0.24.0 +anyio>=4.0.0 From bd63fa7075e5daf4d98ba56d04c7a54ee8f65576 Mon Sep 17 00:00:00 2001 From: shravanithouta108 Date: Sun, 17 May 2026 23:31:03 +0530 Subject: [PATCH 2/5] feat: add backend tests for task cancellation and cleanup endpoints Signed-off-by: shravanithouta108 --- backend/secuscan/routes.py | 17 +- .../test_task_cancellation_and_cleanup.py | 441 ++++++++++++++++++ 2 files changed, 452 insertions(+), 6 deletions(-) create mode 100644 testing/backend/integration/test_task_cancellation_and_cleanup.py diff --git a/backend/secuscan/routes.py b/backend/secuscan/routes.py index 0d8f64e56..86a2026d8 100644 --- a/backend/secuscan/routes.py +++ b/backend/secuscan/routes.py @@ -2,7 +2,7 @@ API routes for SecuScan backend """ -from fastapi import APIRouter, HTTPException, BackgroundTasks, Response +from fastapi import APIRouter, HTTPException, BackgroundTasks, Response, Query from typing import Any, Optional, List, Dict, Callable import json import logging @@ -644,25 +644,30 @@ async def delete_task(task_id: str): @router.delete("/tasks/bulk") -async def bulk_delete_tasks(task_ids: List[str]): +async def bulk_delete_tasks(task_ids: List[str] = Query(default=[])): """Delete multiple tasks at once""" + if not task_ids: + return {"deleted_count": 0, "success": True} + db = await get_db() - + # Check if any tasks are running placeholders = ",".join(["?"] * len(task_ids)) - running_tasks = await db.fetchone(f"SELECT id FROM tasks WHERE id IN ({placeholders}) AND status = 'running' LIMIT 1", tuple(task_ids)) + running_tasks = await db.fetchone( + f"SELECT id FROM tasks WHERE id IN ({placeholders}) AND status = 'running' LIMIT 1", + tuple(task_ids), + ) if running_tasks: raise HTTPException(status_code=400, detail="Cannot delete running tasks. Abort them first.") await delete_task_records(task_ids) await invalidate_view_cache() - + return { "deleted_count": len(task_ids), "success": True } - @router.delete("/tasks/clear") async def clear_all_tasks(): """Wipe all scan history and associated data (findings, reports, assets, attack surface)""" diff --git a/testing/backend/integration/test_task_cancellation_and_cleanup.py b/testing/backend/integration/test_task_cancellation_and_cleanup.py new file mode 100644 index 000000000..d5da2fb3f --- /dev/null +++ b/testing/backend/integration/test_task_cancellation_and_cleanup.py @@ -0,0 +1,441 @@ +""" +Integration tests for task cancellation and cleanup endpoints. + +Covered endpoints: + POST /api/v1/task/{task_id}/cancel + DELETE /api/v1/task/{task_id} + DELETE /api/v1/tasks/bulk + DELETE /api/v1/tasks/clear + +Each test asserts database side-effects, not only HTTP status codes. +File cleanup behaviour is covered using the temporary directories +supplied by the setup_test_environment fixture. +""" + +import asyncio +import json +import time +from pathlib import Path +from unittest.mock import patch, AsyncMock + +import pytest + +from backend.secuscan import database as database_module +from backend.secuscan.executor import executor +from backend.secuscan.config import settings + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _start_task(client): + """Start an http_inspector task and return its task_id.""" + with patch( + "backend.secuscan.executor.TaskExecutor._execute_command", + return_value=("mock output", 0), + ): + payload = { + "plugin_id": "http_inspector", + "preset": "quick", + "inputs": {"url": "http://127.0.0.1:8000"}, + "consent_granted": True, + } + resp = client.post("/api/v1/task/start", json=payload) + assert resp.status_code == 200, resp.text + return resp.json()["task_id"] + + +def _wait_for_status(client, task_id, target_statuses, *, timeout=3.0): + """Poll /status until the task reaches one of *target_statuses*.""" + deadline = time.time() + timeout + while time.time() < deadline: + resp = client.get(f"/api/v1/task/{task_id}/status") + if resp.status_code == 200 and resp.json()["status"] in target_statuses: + return resp.json() + time.sleep(0.05) + return client.get(f"/api/v1/task/{task_id}/status").json() + + +def _db_row(task_id): + """Return the raw DB row for a task (None if deleted).""" + async def _fetch(): + db = await database_module.get_db() + return await db.fetchone("SELECT * FROM tasks WHERE id = ?", (task_id,)) + return asyncio.run(_fetch()) + + +def _count_table(table, task_id=None): + """Return the row count for *table*, optionally filtered by task_id.""" + async def _fetch(): + db = await database_module.get_db() + if task_id: + return await db.fetchone( + f"SELECT COUNT(*) AS n FROM {table} WHERE task_id = ?", (task_id,) + ) + return await db.fetchone(f"SELECT COUNT(*) AS n FROM {table}") + row = asyncio.run(_fetch()) + return row["n"] if row else 0 + + +def _insert_finding(task_id, plugin_id="http_inspector"): + """Insert a synthetic finding row linked to *task_id*.""" + import uuid + async def _do(): + db = await database_module.get_db() + await db.execute( + """ + INSERT INTO findings + (id, task_id, plugin_id, title, category, severity, target, description) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (str(uuid.uuid4()), task_id, plugin_id, + "Test Finding", "web", "high", "http://127.0.0.1", "desc"), + ) + asyncio.run(_do()) + + +def _insert_report(task_id): + """Insert a synthetic report row linked to *task_id*.""" + import uuid + async def _do(): + db = await database_module.get_db() + await db.execute( + """ + INSERT INTO reports (id, task_id, name, type) + VALUES (?, ?, ?, ?) + """, + (str(uuid.uuid4()), task_id, "Test Report", "technical"), + ) + asyncio.run(_do()) + + +def _insert_audit_log(task_id): + """Insert a synthetic audit_log row linked to *task_id*.""" + async def _do(): + db = await database_module.get_db() + await db.execute( + """ + INSERT INTO audit_log (event_type, severity, message, task_id) + VALUES (?, ?, ?, ?) + """, + ("test_event", "info", "test message", task_id), + ) + asyncio.run(_do()) + + +def _set_task_status(task_id, status): + """Directly update a task's status in the database.""" + async def _do(): + db = await database_module.get_db() + await db.execute( + "UPDATE tasks SET status = ? WHERE id = ?", (status, task_id) + ) + asyncio.run(_do()) + + +def _delete_bulk(client, task_ids): + """Send a DELETE /api/v1/tasks/bulk request with task_ids as query params.""" + params = [("task_ids", tid) for tid in task_ids] + return client.delete("/api/v1/tasks/bulk", params=params) + + +# --------------------------------------------------------------------------- +# Cancel tests +# --------------------------------------------------------------------------- + +class TestCancelTask: + def test_cancel_queued_task_returns_cancelled_status(self, test_client): + """ + A task registered in executor.running_tasks should be cancellable; + the route must return status='cancelled'. + """ + task_id = _start_task(test_client) + + # Use an AsyncMock so executor.cancel_task() finds and cancels it + mock_task = AsyncMock() + executor.running_tasks[task_id] = mock_task + + try: + resp = test_client.post(f"/api/v1/task/{task_id}/cancel") + assert resp.status_code == 200 + data = resp.json() + assert data["task_id"] == task_id + assert data["status"] == "cancelled" + finally: + executor.running_tasks.pop(task_id, None) + + def test_cancel_updates_status_in_database(self, test_client): + """ + After cancellation the tasks table must reflect status='cancelled'. + """ + task_id = _start_task(test_client) + + mock_task = AsyncMock() + executor.running_tasks[task_id] = mock_task + + try: + test_client.post(f"/api/v1/task/{task_id}/cancel") + row = _db_row(task_id) + assert row is not None + assert row["status"] == "cancelled" + finally: + executor.running_tasks.pop(task_id, None) + + def test_cancel_nonexistent_task_returns_404(self, test_client): + """ + Cancelling a task_id that does not exist must return 404. + """ + resp = test_client.post("/api/v1/task/nonexistent-id-xyz/cancel") + assert resp.status_code == 404 + + def test_cancel_already_completed_task_returns_404(self, test_client): + """ + A completed task is no longer in running_tasks, so the executor + returns False and the route must return 404. + """ + task_id = _start_task(test_client) + _wait_for_status(test_client, task_id, {"completed", "failed"}) + + resp = test_client.post(f"/api/v1/task/{task_id}/cancel") + assert resp.status_code == 404 + + +# --------------------------------------------------------------------------- +# Single-task delete tests +# --------------------------------------------------------------------------- + +class TestDeleteSingleTask: + def test_delete_removes_task_row(self, test_client): + """ + Deleting a completed task must remove its row from the tasks table. + """ + task_id = _start_task(test_client) + _wait_for_status(test_client, task_id, {"completed", "failed"}) + + resp = test_client.delete(f"/api/v1/task/{task_id}") + assert resp.status_code == 200 + assert resp.json()["deleted"] is True + + assert _db_row(task_id) is None + + def test_delete_removes_associated_findings(self, test_client): + """ + Findings linked to the deleted task must be removed. + """ + task_id = _start_task(test_client) + _wait_for_status(test_client, task_id, {"completed", "failed"}) + + # Record baseline (executor may have written its own findings) + baseline = _count_table("findings", task_id) + _insert_finding(task_id) + assert _count_table("findings", task_id) == baseline + 1 + + test_client.delete(f"/api/v1/task/{task_id}") + assert _count_table("findings", task_id) == 0 + + def test_delete_removes_associated_reports(self, test_client): + """ + Report rows linked to the deleted task must be removed. + """ + task_id = _start_task(test_client) + _wait_for_status(test_client, task_id, {"completed", "failed"}) + + baseline = _count_table("reports", task_id) + _insert_report(task_id) + assert _count_table("reports", task_id) == baseline + 1 + + test_client.delete(f"/api/v1/task/{task_id}") + assert _count_table("reports", task_id) == 0 + + def test_delete_removes_associated_audit_log_entries(self, test_client): + """ + Audit log rows referencing the deleted task must be removed. + """ + task_id = _start_task(test_client) + _wait_for_status(test_client, task_id, {"completed", "failed"}) + _insert_audit_log(task_id) + assert _count_table("audit_log", task_id) >= 1 + + test_client.delete(f"/api/v1/task/{task_id}") + assert _count_table("audit_log", task_id) == 0 + + def test_delete_removes_raw_output_file(self, test_client, setup_test_environment): + """ + When a raw output file exists on disk, deleting the task must also + remove the file. + """ + task_id = _start_task(test_client) + _wait_for_status(test_client, task_id, {"completed", "failed"}) + + raw_dir = Path(setup_test_environment) / "raw" + raw_dir.mkdir(parents=True, exist_ok=True) + dummy_file = raw_dir / f"{task_id}.txt" + dummy_file.write_text("scan output") + + async def _set_path(): + db = await database_module.get_db() + await db.execute( + "UPDATE tasks SET raw_output_path = ? WHERE id = ?", + (str(dummy_file), task_id), + ) + asyncio.run(_set_path()) + + test_client.delete(f"/api/v1/task/{task_id}") + assert not dummy_file.exists() + + def test_delete_missing_task_id_still_returns_success(self, test_client): + """ + Deleting a task_id that does not exist performs a no-op delete and + returns 200 with deleted=True (idempotent behaviour). + """ + resp = test_client.delete("/api/v1/task/nonexistent-id-xyz") + assert resp.status_code == 200 + assert resp.json()["deleted"] is True + + def test_delete_running_task_returns_400(self, test_client): + """ + Attempting to delete a task that is still running must return 400. + """ + task_id = _start_task(test_client) + _wait_for_status(test_client, task_id, {"completed", "failed"}) + + # Force status to 'running' so the route guard triggers + _set_task_status(task_id, "running") + + resp = test_client.delete(f"/api/v1/task/{task_id}") + assert resp.status_code == 400 + + +# --------------------------------------------------------------------------- +# Bulk delete tests +# --------------------------------------------------------------------------- + +class TestBulkDeleteTasks: + def test_bulk_delete_removes_only_requested_tasks(self, test_client): + """ + Only the task_ids supplied in the request body should be deleted; + other tasks must remain in the database. + """ + id_a = _start_task(test_client) + id_b = _start_task(test_client) + id_c = _start_task(test_client) + for tid in (id_a, id_b, id_c): + _wait_for_status(test_client, tid, {"completed", "failed"}) + + resp = _delete_bulk(test_client, [id_a, id_b]) + assert resp.status_code == 200 + data = resp.json() + assert data["success"] is True + assert data["deleted_count"] == 2 + + assert _db_row(id_a) is None + assert _db_row(id_b) is None + assert _db_row(id_c) is not None + + def test_bulk_delete_removes_associated_findings(self, test_client): + """ + Findings for all bulk-deleted tasks must be removed. + """ + id_a = _start_task(test_client) + id_b = _start_task(test_client) + for tid in (id_a, id_b): + _wait_for_status(test_client, tid, {"completed", "failed"}) + + baseline_a = _count_table("findings", id_a) + baseline_b = _count_table("findings", id_b) + _insert_finding(id_a) + _insert_finding(id_b) + assert _count_table("findings", id_a) == baseline_a + 1 + assert _count_table("findings", id_b) == baseline_b + 1 + + _delete_bulk(test_client, [id_a, id_b]) + + assert _count_table("findings", id_a) == 0 + assert _count_table("findings", id_b) == 0 + + def test_bulk_delete_with_running_task_returns_400(self, test_client): + """ + If any task in the bulk list is currently running, the whole + request must be rejected with 400. + """ + id_a = _start_task(test_client) + id_b = _start_task(test_client) + _wait_for_status(test_client, id_a, {"completed", "failed"}) + _wait_for_status(test_client, id_b, {"completed", "failed"}) + + _set_task_status(id_b, "running") + + resp = _delete_bulk(test_client, [id_a, id_b]) + assert resp.status_code == 400 + assert _db_row(id_a) is not None + + def test_bulk_delete_empty_list_returns_success(self, test_client): + """ + An empty task_ids list is a valid no-op request. + """ + resp = _delete_bulk(test_client, []) + assert resp.status_code == 200 + assert resp.json()["deleted_count"] == 0 + + +# --------------------------------------------------------------------------- +# Clear-all tests +# --------------------------------------------------------------------------- + +class TestClearAllTasks: + def test_clear_removes_all_tasks(self, test_client): + """ + After /tasks/clear the tasks table must be empty. + """ + for _ in range(3): + tid = _start_task(test_client) + _wait_for_status(test_client, tid, {"completed", "failed"}) + + resp = test_client.delete("/api/v1/tasks/clear") + assert resp.status_code == 200 + assert resp.json()["cleared"] is True + assert _count_table("tasks") == 0 + + def test_clear_removes_all_findings(self, test_client): + """ + All findings must be purged when scan history is cleared. + """ + for _ in range(2): + tid = _start_task(test_client) + _wait_for_status(test_client, tid, {"completed", "failed"}) + _insert_finding(tid) + + assert _count_table("findings") >= 2 + + test_client.delete("/api/v1/tasks/clear") + assert _count_table("findings") == 0 + + def test_clear_removes_orphaned_raw_files(self, test_client, setup_test_environment): + """ + Raw output files that live in the data/raw directory must be deleted + even when they are not referenced by any remaining task row. + """ + raw_dir = Path(setup_test_environment) / "raw" + raw_dir.mkdir(parents=True, exist_ok=True) + orphan = raw_dir / "orphaned_output.txt" + orphan.write_text("leftover data") + + tid = _start_task(test_client) + _wait_for_status(test_client, tid, {"completed", "failed"}) + + test_client.delete("/api/v1/tasks/clear") + assert not orphan.exists() + + def test_clear_blocked_while_task_running(self, test_client): + """ + Clearing history is forbidden if any task has status='running'. + The request must return 400 and leave the database untouched. + """ + tid = _start_task(test_client) + _wait_for_status(test_client, tid, {"completed", "failed"}) + _set_task_status(tid, "running") + + resp = test_client.delete("/api/v1/tasks/clear") + assert resp.status_code == 400 + assert _count_table("tasks") >= 1 \ No newline at end of file From 2b1536bdce07c9d9fbbb2beda6b24f1ac478cb0b Mon Sep 17 00:00:00 2001 From: shravanithouta108 Date: Sun, 17 May 2026 23:43:38 +0530 Subject: [PATCH 3/5] fix: remove duplicate old test file Signed-off-by: shravanithouta108 --- .../backend/integration/test_task_cleanup.py | 344 ------------------ 1 file changed, 344 deletions(-) delete mode 100644 testing/backend/integration/test_task_cleanup.py diff --git a/testing/backend/integration/test_task_cleanup.py b/testing/backend/integration/test_task_cleanup.py deleted file mode 100644 index 6fbb88afc..000000000 --- a/testing/backend/integration/test_task_cleanup.py +++ /dev/null @@ -1,344 +0,0 @@ -""" -Integration tests for SecuScan task cancellation and cleanup endpoints. -Issue #30 — Add backend tests for task cancellation and cleanup endpoints. - -Test file location: testing/backend/integration/test_task_cleanup.py -""" - -import uuid -from unittest.mock import AsyncMock, MagicMock, patch - -import aiosqlite -import pytest -import pytest_asyncio -from httpx import ASGITransport, AsyncClient - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - -@pytest_asyncio.fixture -async def db_path(tmp_path): - """Return a path to a fresh temp SQLite file (schema created by init_db).""" - return str(tmp_path / "test_secuscan.db") - - -@pytest_asyncio.fixture -async def app_client(db_path): - """ - Yield an AsyncClient wired to the FastAPI app with: - - a real isolated temp SQLite DB (schema auto-created by init_db) - - a real in-memory cache (init_cache — no Redis needed) - - executor fully mocked (no real scans) - """ - mock_executor = MagicMock() - mock_executor.cancel_task = AsyncMock(return_value=True) - mock_executor.get_task_status = AsyncMock(return_value={"status": "queued"}) - - with patch("backend.secuscan.routes.executor", mock_executor): - - from backend.secuscan.main import app - from backend.secuscan import database as db_module - from backend.secuscan import cache as cache_module - - # Initialise a real in-memory cache (it's just a dict, no external deps) - await cache_module.init_cache() - - # Initialise a fresh DB pointing at our temp file - test_db = await db_module.init_db(db_path) - - async with AsyncClient( - transport=ASGITransport(app=app), base_url="http://test" - ) as client: - client._mock_executor = mock_executor - client._db = test_db - client._db_path = db_path - yield client - - # Teardown - await test_db.disconnect() - db_module.db = None - await cache_module.cache.disconnect() - cache_module.cache = None - - -# --------------------------------------------------------------------------- -# DB helpers — read directly from the temp DB to verify side effects -# --------------------------------------------------------------------------- - -async def db_fetchall(db_path: str, sql: str, params=()): - async with aiosqlite.connect(db_path) as conn: - conn.row_factory = aiosqlite.Row - async with conn.execute(sql, params) as cursor: - rows = await cursor.fetchall() - return [dict(r) for r in rows] - - -# --------------------------------------------------------------------------- -# Seed helpers — insert test data directly into the temp DB -# --------------------------------------------------------------------------- - -async def insert_task(db, status: str = "completed", - raw_output_path: str = None) -> str: - task_id = str(uuid.uuid4()) - await db.execute( - "INSERT INTO tasks " - "(id, plugin_id, tool_name, target, status, inputs_json, raw_output_path, consent_granted) " - "VALUES (?, 'nmap', 'nmap', '127.0.0.1', ?, '{}', ?, 1)", - (task_id, status, raw_output_path), - ) - return task_id - - -async def insert_finding(db, task_id: str) -> str: - finding_id = str(uuid.uuid4()) - await db.execute( - "INSERT INTO findings " - "(id, task_id, plugin_id, title, category, severity, target, description, remediation) " - "VALUES (?, ?, 'nmap', 'Open port', 'network', 'low', '127.0.0.1', 'desc', 'fix')", - (finding_id, task_id), - ) - return finding_id - - -async def insert_report(db, task_id: str) -> str: - report_id = str(uuid.uuid4()) - await db.execute( - "INSERT INTO reports (id, task_id, name, type, status) " - "VALUES (?, ?, 'report', 'pdf', 'ready')", - (report_id, task_id), - ) - return report_id - - -async def insert_audit_log(db, task_id: str): - await db.execute( - "INSERT INTO audit_log (event_type, severity, message, task_id) " - "VALUES ('scan_start', 'info', 'started', ?)", - (task_id,), - ) - - -# --------------------------------------------------------------------------- -# Tests: POST /api/v1/task/{task_id}/cancel -# --------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_cancel_queued_task_returns_200(app_client): - """Cancelling a queued task returns 200 with status='cancelled'.""" - db = app_client._db - task_id = await insert_task(db, status="queued") - app_client._mock_executor.cancel_task = AsyncMock(return_value=True) - - resp = await app_client.post(f"/api/v1/task/{task_id}/cancel") - - assert resp.status_code == 200, resp.text - body = resp.json() - assert body["task_id"] == task_id - assert body["status"] == "cancelled" - assert "cancelled_at" in body - - -@pytest.mark.asyncio -async def test_cancel_missing_task_returns_404(app_client): - """Cancelling a non-existent task returns 404.""" - app_client._mock_executor.cancel_task = AsyncMock(return_value=None) - - resp = await app_client.post(f"/api/v1/task/{uuid.uuid4()}/cancel") - - assert resp.status_code == 404, resp.text - - -# --------------------------------------------------------------------------- -# Tests: DELETE /api/v1/task/{task_id} -# --------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_delete_task_returns_200_and_removes_row(app_client): - """Deleting a completed task removes it from the DB and returns 200.""" - db = app_client._db - db_path = app_client._db_path - task_id = await insert_task(db, status="completed") - - resp = await app_client.delete(f"/api/v1/task/{task_id}") - - assert resp.status_code == 200, resp.text - body = resp.json() - assert body["task_id"] == task_id - assert body["deleted"] is True - - rows = await db_fetchall(db_path, "SELECT id FROM tasks WHERE id = ?", (task_id,)) - assert len(rows) == 0, "Task row should have been deleted from the DB" - - -@pytest.mark.asyncio -async def test_delete_task_also_removes_associated_records(app_client): - """Deleting a task cascades to findings, reports, and audit_log.""" - db = app_client._db - db_path = app_client._db_path - task_id = await insert_task(db, status="completed") - finding_id = await insert_finding(db, task_id) - report_id = await insert_report(db, task_id) - await insert_audit_log(db, task_id) - - resp = await app_client.delete(f"/api/v1/task/{task_id}") - assert resp.status_code == 200, resp.text - - rows = await db_fetchall(db_path, "SELECT id FROM findings WHERE id = ?", (finding_id,)) - assert len(rows) == 0, "Finding should have been deleted" - - rows = await db_fetchall(db_path, "SELECT id FROM reports WHERE id = ?", (report_id,)) - 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" - - -@pytest.mark.asyncio -async def test_delete_running_task_returns_400(app_client): - """Attempting to delete a running task must return 400. - - The route checks executor.get_task_status(), not the DB status field, - so we make the mock report the task as running. - """ - db = app_client._db - task_id = await insert_task(db, status="running") - - # Make executor report this task as actively running - app_client._mock_executor.get_task_status = AsyncMock( - return_value={"status": "running"} - ) - - resp = await app_client.delete(f"/api/v1/task/{task_id}") - - assert resp.status_code == 400, resp.text - - -@pytest.mark.asyncio -async def test_delete_missing_task_returns_200(app_client): - """Deleting a task that doesn't exist returns 200 (route is idempotent). - - The delete endpoint calls delete_task_records() which issues DELETE SQL — - deleting zero rows is not treated as an error by this implementation. - """ - resp = await app_client.delete(f"/api/v1/task/{uuid.uuid4()}") - - # The route succeeds silently when the task doesn't exist - assert resp.status_code == 200, resp.text - body = resp.json() - assert body["deleted"] is True - - -@pytest.mark.asyncio -async def test_delete_task_removes_raw_output_file(app_client, tmp_path): - """If the task has a raw_output_path, that file is deleted from disk.""" - db = app_client._db - - raw_file = tmp_path / "scan_output.txt" - raw_file.write_text("nmap output data") - assert raw_file.exists() - - task_id = await insert_task(db, status="completed", - raw_output_path=str(raw_file)) - - resp = await app_client.delete(f"/api/v1/task/{task_id}") - assert resp.status_code == 200, resp.text - - assert not raw_file.exists(), "raw_output_path file should have been deleted from disk" - - -# --------------------------------------------------------------------------- -# Tests: DELETE /api/v1/tasks/bulk -# --------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_bulk_delete_removes_only_requested_tasks(app_client): - """Bulk delete removes listed tasks but leaves others untouched.""" - db = app_client._db - db_path = app_client._db_path - - task_a = await insert_task(db, status="completed") - task_b = await insert_task(db, status="completed") - task_c = await insert_task(db, status="completed") # must survive - - resp = await app_client.request( - "DELETE", "/api/v1/tasks/bulk", json=[task_a, task_b], - ) - - assert resp.status_code == 200, resp.text - body = resp.json() - assert body["success"] is True - assert body["deleted_count"] == 2 - - for tid in (task_a, task_b): - rows = await db_fetchall(db_path, "SELECT id FROM tasks WHERE id = ?", (tid,)) - assert len(rows) == 0, f"Task {tid} should have been deleted" - - rows = await db_fetchall(db_path, "SELECT id FROM tasks WHERE id = ?", (task_c,)) - assert len(rows) == 1, "task_c should NOT have been deleted" - - -@pytest.mark.asyncio -async def test_bulk_delete_with_running_task_returns_400(app_client): - """Bulk delete containing a running task returns 400; nothing is deleted.""" - db = app_client._db - db_path = app_client._db_path - - task_ok = await insert_task(db, status="completed") - task_running = await insert_task(db, status="running") - - resp = await app_client.request( - "DELETE", "/api/v1/tasks/bulk", json=[task_ok, task_running], - ) - - assert resp.status_code == 400, resp.text - - for tid in (task_ok, task_running): - rows = await db_fetchall(db_path, "SELECT id FROM tasks WHERE id = ?", (tid,)) - assert len(rows) == 1, f"Task {tid} should NOT have been deleted after 400" - - -# --------------------------------------------------------------------------- -# Tests: DELETE /api/v1/tasks/clear -# --------------------------------------------------------------------------- - -@pytest.mark.asyncio -async def test_clear_all_tasks_removes_everything(app_client): - """Clear endpoint deletes all tasks, findings, reports, and audit_log rows.""" - db = app_client._db - db_path = app_client._db_path - - for _ in range(3): - tid = await insert_task(db, status="completed") - await insert_finding(db, tid) - await insert_report(db, tid) - await insert_audit_log(db, tid) - - resp = await app_client.delete("/api/v1/tasks/clear") - - assert resp.status_code == 200, resp.text - body = resp.json() - assert body["cleared"] is True - assert "message" in body - - for table in ("tasks", "findings", "reports", "audit_log"): - rows = await db_fetchall(db_path, f"SELECT 1 FROM {table}") - assert len(rows) == 0, f"Table '{table}' should be empty after /clear" - - -@pytest.mark.asyncio -async def test_clear_while_task_running_returns_400(app_client): - """Clear returns 400 when any task is still running; nothing is deleted.""" - db = app_client._db - db_path = app_client._db_path - - await insert_task(db, status="completed") - await insert_task(db, status="running") - - resp = await app_client.delete("/api/v1/tasks/clear") - - assert resp.status_code == 400, resp.text - - rows = await db_fetchall(db_path, "SELECT id FROM tasks") - assert len(rows) == 2, "No tasks should have been deleted after a 400 clear" From f056eb7a7f7ef429bdf0ec150adfa966f8f79471 Mon Sep 17 00:00:00 2001 From: shravanithouta108 Date: Sun, 24 May 2026 19:05:01 +0530 Subject: [PATCH 4/5] test: add backend tests for task cancellation and cleanup endpoints (fixes #30) Signed-off-by: shravanithouta108 --- backend/secuscan/routes.py | 24 +- .../backend/integration/test_task_cleanup.py | 355 ++++++++++++++++++ 2 files changed, 370 insertions(+), 9 deletions(-) create mode 100644 testing/backend/integration/test_task_cleanup.py diff --git a/backend/secuscan/routes.py b/backend/secuscan/routes.py index 86a2026d8..c6976439d 100644 --- a/backend/secuscan/routes.py +++ b/backend/secuscan/routes.py @@ -2,7 +2,7 @@ API routes for SecuScan backend """ -from fastapi import APIRouter, HTTPException, BackgroundTasks, Response, Query +from fastapi import APIRouter, HTTPException, BackgroundTasks, Response, Body from typing import Any, Optional, List, Dict, Callable import json import logging @@ -644,19 +644,25 @@ async def delete_task(task_id: str): @router.delete("/tasks/bulk") -async def bulk_delete_tasks(task_ids: List[str] = Query(default=[])): +async def bulk_delete_tasks(task_ids: List[str] = Body(...)): """Delete multiple tasks at once""" + db = await get_db() + if not task_ids: return {"deleted_count": 0, "success": True} - db = await get_db() - - # Check if any tasks are running placeholders = ",".join(["?"] * len(task_ids)) - running_tasks = await db.fetchone( - f"SELECT id FROM tasks WHERE id IN ({placeholders}) AND status = 'running' LIMIT 1", - tuple(task_ids), - ) + running_tasks = await db.fetchone(f"SELECT id FROM tasks WHERE id IN ({placeholders}) AND status = 'running' LIMIT 1", tuple(task_ids)) + if running_tasks: + raise HTTPException(status_code=400, detail="Cannot delete running tasks. Abort them first.") + + await delete_task_records(task_ids) + await invalidate_view_cache() + + return { + "deleted_count": len(task_ids), + "success": True + } if running_tasks: raise HTTPException(status_code=400, detail="Cannot delete running tasks. Abort them first.") diff --git a/testing/backend/integration/test_task_cleanup.py b/testing/backend/integration/test_task_cleanup.py new file mode 100644 index 000000000..6a8a621ca --- /dev/null +++ b/testing/backend/integration/test_task_cleanup.py @@ -0,0 +1,355 @@ +""" +Backend integration tests for task cancellation and cleanup endpoints. +Issue #30 - covers: cancel, single delete, bulk delete, clear all, missing ID errors. +""" + +import asyncio +import json +import pytest +import tempfile +import os +from pathlib import Path +from unittest.mock import AsyncMock, patch, MagicMock +from httpx import AsyncClient, ASGITransport + +from backend.secuscan.database import Database +from backend.secuscan.main import app + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +async def make_db(): + db = Database(":memory:") + await db.connect() + return db + + +def make_get_db(db): + async def _get_db(): + return db + return _get_db + + +async def insert_task(db, task_id="task-1", status="queued", raw_output_path=None): + await db.execute( + """ + INSERT INTO tasks (id, plugin_id, tool_name, target, status, inputs_json, raw_output_path) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + (task_id, "http_inspector", "http_inspector", "http://test.local", status, "{}", raw_output_path), + ) + + +async def insert_finding(db, finding_id="finding-1", task_id="task-1"): + await db.execute( + """ + INSERT INTO findings (id, task_id, plugin_id, title, category, severity, target, description) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (finding_id, task_id, "http_inspector", "Test Finding", "web", "high", "http://test.local", "desc"), + ) + + +async def insert_report(db, report_id="report-1", task_id="task-1"): + await db.execute( + """ + INSERT INTO reports (id, task_id, name, type, status) + VALUES (?, ?, ?, ?, ?) + """, + (report_id, task_id, "Test Report", "technical", "ready"), + ) + + +async def insert_audit_log(db, task_id="task-1"): + await db.execute( + """ + INSERT INTO audit_log (event_type, severity, message, task_id) + VALUES (?, ?, ?, ?) + """, + ("task_created", "info", "Task created", task_id), + ) + + +# --------------------------------------------------------------------------- +# 1. Cancel task +# --------------------------------------------------------------------------- + +class TestCancelTask: + + def test_cancel_queued_task_returns_200(self): + async def run(): + db = await make_db() + await insert_task(db, task_id="task-cancel-1", status="queued") + + mock_executor = AsyncMock() + mock_executor.cancel_task = AsyncMock(return_value=True) + + with patch("backend.secuscan.routes.executor", mock_executor), \ + patch("backend.secuscan.routes.get_db", make_get_db(db)): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + response = await ac.post("/api/v1/task/task-cancel-1/cancel") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "cancelled" + assert data["task_id"] == "task-cancel-1" + + asyncio.run(run()) + + def test_cancel_missing_task_returns_404(self): + async def run(): + mock_executor = AsyncMock() + mock_executor.cancel_task = AsyncMock(return_value=False) + + with patch("backend.secuscan.routes.executor", mock_executor): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + response = await ac.post("/api/v1/task/nonexistent-id/cancel") + + assert response.status_code == 404 + + asyncio.run(run()) + + +# --------------------------------------------------------------------------- +# 2. Delete single task +# --------------------------------------------------------------------------- + +class TestDeleteSingleTask: + + def test_delete_task_returns_200(self): + async def run(): + db = await make_db() + await insert_task(db, task_id="task-del-1", status="queued") + + mock_executor = AsyncMock() + mock_executor.get_task_status = AsyncMock(return_value={"status": "queued"}) + + with patch("backend.secuscan.routes.executor", mock_executor), \ + patch("backend.secuscan.routes.get_db", make_get_db(db)), \ + patch("backend.secuscan.routes.invalidate_view_cache", AsyncMock()): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + response = await ac.delete("/api/v1/task/task-del-1") + + assert response.status_code == 200 + assert response.json()["deleted"] is True + + asyncio.run(run()) + + def test_delete_task_removes_from_db(self): + async def run(): + db = await make_db() + await insert_task(db, task_id="task-del-2", status="queued") + + mock_executor = AsyncMock() + mock_executor.get_task_status = AsyncMock(return_value={"status": "queued"}) + + with patch("backend.secuscan.routes.executor", mock_executor), \ + patch("backend.secuscan.routes.get_db", make_get_db(db)), \ + patch("backend.secuscan.routes.invalidate_view_cache", AsyncMock()): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + await ac.delete("/api/v1/task/task-del-2") + + row = await db.fetchone("SELECT id FROM tasks WHERE id = ?", ("task-del-2",)) + assert row is None + + asyncio.run(run()) + + def test_delete_task_removes_findings_and_reports(self): + async def run(): + db = await make_db() + await insert_task(db, task_id="task-del-3", status="queued") + await insert_finding(db, finding_id="f-1", task_id="task-del-3") + await insert_report(db, report_id="r-1", task_id="task-del-3") + await insert_audit_log(db, task_id="task-del-3") + + mock_executor = AsyncMock() + mock_executor.get_task_status = AsyncMock(return_value={"status": "queued"}) + + with patch("backend.secuscan.routes.executor", mock_executor), \ + patch("backend.secuscan.routes.get_db", make_get_db(db)), \ + patch("backend.secuscan.routes.invalidate_view_cache", AsyncMock()): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + await ac.delete("/api/v1/task/task-del-3") + + findings = await db.fetchall("SELECT id FROM findings WHERE task_id = ?", ("task-del-3",)) + reports = await db.fetchall("SELECT id FROM reports WHERE task_id = ?", ("task-del-3",)) + audit = await db.fetchall("SELECT id FROM audit_log WHERE task_id = ?", ("task-del-3",)) + + assert findings == [] + assert reports == [] + assert audit == [] + + asyncio.run(run()) + + def test_delete_running_task_returns_400(self): + async def run(): + db = await make_db() + await insert_task(db, task_id="task-running-1", status="running") + + mock_executor = AsyncMock() + mock_executor.get_task_status = AsyncMock(return_value={"status": "running"}) + + with patch("backend.secuscan.routes.executor", mock_executor), \ + patch("backend.secuscan.routes.get_db", make_get_db(db)): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + response = await ac.delete("/api/v1/task/task-running-1") + + assert response.status_code == 400 + + asyncio.run(run()) + + def test_delete_task_removes_output_file(self): + async def run(): + with tempfile.NamedTemporaryFile(delete=False, suffix=".txt") as f: + tmp_path = f.name + f.write(b"scan output") + + assert os.path.exists(tmp_path) + + db = await make_db() + await insert_task(db, task_id="task-file-1", status="queued", raw_output_path=tmp_path) + + mock_executor = AsyncMock() + mock_executor.get_task_status = AsyncMock(return_value={"status": "queued"}) + + with patch("backend.secuscan.routes.executor", mock_executor), \ + patch("backend.secuscan.routes.get_db", make_get_db(db)), \ + patch("backend.secuscan.routes.invalidate_view_cache", AsyncMock()): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + await ac.delete("/api/v1/task/task-file-1") + + assert not os.path.exists(tmp_path) + + asyncio.run(run()) + + +# --------------------------------------------------------------------------- +# 3. Bulk delete +# --------------------------------------------------------------------------- + +class TestBulkDeleteTasks: + + def test_bulk_delete_removes_only_requested_tasks(self): + async def run(): + db = await make_db() + await insert_task(db, task_id="bulk-1", status="queued") + await insert_task(db, task_id="bulk-2", status="queued") + await insert_task(db, task_id="bulk-3", status="queued") + + with patch("backend.secuscan.routes.get_db", make_get_db(db)), \ + patch("backend.secuscan.routes.invalidate_view_cache", AsyncMock()): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + response = await ac.request( + "DELETE", + "/api/v1/tasks/bulk", + json=["bulk-1", "bulk-2"], + ) + + assert response.status_code == 200 + assert response.json()["deleted_count"] == 2 + + surviving = await db.fetchone("SELECT id FROM tasks WHERE id = ?", ("bulk-3",)) + assert surviving is not None + + for gone_id in ("bulk-1", "bulk-2"): + row = await db.fetchone("SELECT id FROM tasks WHERE id = ?", (gone_id,)) + assert row is None + + asyncio.run(run()) + + def test_bulk_delete_blocks_running_tasks(self): + async def run(): + db = await make_db() + await insert_task(db, task_id="bulk-run-1", status="running") + await insert_task(db, task_id="bulk-run-2", status="queued") + + with patch("backend.secuscan.routes.get_db", make_get_db(db)), \ + patch("backend.secuscan.routes.invalidate_view_cache", AsyncMock()): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + response = await ac.request( + "DELETE", + "/api/v1/tasks/bulk", + json=["bulk-run-1", "bulk-run-2"], + ) + + assert response.status_code == 400 + + asyncio.run(run()) + + +# --------------------------------------------------------------------------- +# 4. Clear all tasks +# --------------------------------------------------------------------------- + +class TestClearAllTasks: + + def test_clear_all_removes_all_tasks(self): + async def run(): + db = await make_db() + await insert_task(db, task_id="clear-1", status="queued") + await insert_task(db, task_id="clear-2", status="completed") + + mock_settings = MagicMock() + mock_settings.data_dir = tempfile.mkdtemp() + + with patch("backend.secuscan.routes.get_db", make_get_db(db)), \ + patch("backend.secuscan.routes.invalidate_view_cache", AsyncMock()), \ + patch("backend.secuscan.routes.settings", mock_settings): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + response = await ac.delete("/api/v1/tasks/clear") + + assert response.status_code == 200 + assert response.json()["cleared"] is True + + count = await db.fetchone("SELECT COUNT(*) as total FROM tasks") + assert count["total"] == 0 + + asyncio.run(run()) + + def test_clear_all_removes_findings(self): + async def run(): + db = await make_db() + await insert_task(db, task_id="clear-3", status="queued") + await insert_finding(db, finding_id="f-clear-1", task_id="clear-3") + + mock_settings = MagicMock() + mock_settings.data_dir = tempfile.mkdtemp() + + with patch("backend.secuscan.routes.get_db", make_get_db(db)), \ + patch("backend.secuscan.routes.invalidate_view_cache", AsyncMock()), \ + patch("backend.secuscan.routes.settings", mock_settings): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + await ac.delete("/api/v1/tasks/clear") + + count = await db.fetchone("SELECT COUNT(*) as total FROM findings") + assert count["total"] == 0 + + asyncio.run(run()) + + def test_clear_all_blocks_if_running(self): + async def run(): + db = await make_db() + await insert_task(db, task_id="clear-running-1", status="running") + + with patch("backend.secuscan.routes.get_db", make_get_db(db)), \ + patch("backend.secuscan.routes.invalidate_view_cache", AsyncMock()): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + response = await ac.delete("/api/v1/tasks/clear") + + assert response.status_code == 400 + + asyncio.run(run()) \ No newline at end of file From ff0d8831eefc4643cbeffb5b6cc44713566c1540 Mon Sep 17 00:00:00 2001 From: shravanithouta108 Date: Tue, 2 Jun 2026 22:20:40 +0530 Subject: [PATCH 5/5] feat: add report comparison view [closes #36] --- .github/workflows/check-artifacts.yml | 14 + .github/workflows/ci.yml | 14 +- .pre-commit-config.yaml | 14 + 0 | 0 CONTRIBUTING.md | 87 +- PLUGINS.md | 162 +++ backend/secuscan/cli.py | 167 +++ backend/secuscan/config.py | 9 +- backend/secuscan/executor.py | 96 +- backend/secuscan/plugin_validator.py | 340 ++++++ backend/secuscan/plugins.py | 60 +- backend/secuscan/redaction.py | 278 +++++ backend/secuscan/reporting.py | 139 ++- backend/secuscan/routes.py | 272 ++++- backend/secuscan/validation.py | 109 +- docs/API.md | 43 + docs/plugin-validation.md | 153 +++ frontend/Dockerfile | 13 + frontend/README.md | 247 +++- frontend/e2e/scan-workflow.spec.ts | 208 ++++ frontend/package-lock.json | 97 ++ frontend/package.json | 5 +- frontend/src/App.tsx | 4 + frontend/src/api.ts | 62 + frontend/src/components/AppShell.tsx | 2 + frontend/src/components/Pagination.tsx | 61 + frontend/src/components/Sidebar.tsx | 2 + frontend/src/components/ToastContext.tsx | 22 +- .../src/hooks/usePreferredExportFormat.ts | 16 + frontend/src/pages/Dashboard.tsx | 57 +- frontend/src/pages/Findings.tsx | 447 ++++++-- frontend/src/pages/ReportComparison.tsx | 432 +++++++ frontend/src/pages/Reports.tsx | 116 +- frontend/src/pages/Scans.tsx | 1014 ++++++++++------- frontend/src/pages/TaskDetails.tsx | 184 +-- frontend/src/pages/ToolConfig.tsx | 159 ++- frontend/src/pages/Toolkit.tsx | 192 ++-- frontend/src/pages/Workflows.tsx | 455 ++++++++ frontend/src/routes.ts | 1 + frontend/src/utils/date.ts | 10 + frontend/src/utils/validation.ts | 147 +++ .../unit/components/ToastContext.test.tsx | 49 + frontend/testing/unit/pages/Findings.test.tsx | 478 ++++++++ .../unit/pages/ReportComparison.test.tsx | 64 ++ .../pages/Reports.preferredFormat.test.tsx | 100 ++ frontend/testing/unit/pages/Reports.test.tsx | 274 +++-- .../unit/pages/ScannerCatalog.test.tsx | 13 +- .../unit/pages/ScannerEmptyState.test.tsx | 6 +- .../unit/pages/ScannerExpertMode.test.tsx | 4 +- .../unit/pages/ScannerQuickAccess.test.tsx | 4 +- .../testing/unit/pages/ScannerRetry.test.tsx | 71 ++ .../unit/pages/ToolConfigDynamic.test.tsx | 6 +- .../testing/unit/pages/Workflows.test.tsx | 189 +++ .../testing/unit/utils/validation.test.ts | 250 ++++ plugins/dns_enum/metadata.json | 2 +- plugins/dns_enum/parser.py | 117 +- pyproject.toml | 29 + scripts/check-artifacts.sh | 30 + scripts/refresh_plugin_checksum.py | 220 ++++ scripts/validate_plugin.py | 217 ++++ scripts/validate_plugins.py | 164 +++ testing/backend/conftest.py | 14 +- .../integration/test_bulk_delete_contract.py | 194 ++++ .../integration/test_dashboard_cache.py | 141 +++ .../integration/test_report_audit_log.py | 121 ++ .../integration/test_report_filenames.py | 145 +++ testing/backend/integration/test_routes.py | 37 + .../backend/integration/test_routes_sarif.py | 149 +++ testing/backend/test_cache_invalidation.py | 32 + testing/backend/test_queue_position.py | 108 ++ .../test_safe_mode_filesystem_targets.py | 226 ++++ testing/backend/test_task_pagination.py | 88 ++ .../plugins/invalid_plugin/metadata.json | 34 + .../plugins/valid_plugin/metadata.json | 42 + testing/backend/unit/test_cache_helpers.py | 97 ++ testing/backend/unit/test_cli.py | 79 ++ .../backend/unit/test_concurrent_limiter.py | 192 ++++ testing/backend/unit/test_dns_enum_parser.py | 37 + testing/backend/unit/test_plugin_validator.py | 539 +++++++++ testing/backend/unit/test_redaction.py | 274 +++++ .../unit/test_refresh_plugin_checksum.py | 279 +++++ testing/backend/unit/test_report_filename.py | 74 ++ testing/backend/unit/test_sarif.py | 124 ++ .../unit/test_task_start_validation.py | 36 + testing/backend/unit/test_validate_plugin.py | 109 ++ testing/backend/unit/test_validation.py | 33 +- .../unit/test_vault_failure_messages.py | 171 +++ 87 files changed, 10466 insertions(+), 1106 deletions(-) create mode 100644 .github/workflows/check-artifacts.yml create mode 100644 .pre-commit-config.yaml create mode 100644 0 create mode 100644 backend/secuscan/cli.py create mode 100644 backend/secuscan/plugin_validator.py create mode 100644 backend/secuscan/redaction.py create mode 100644 docs/API.md create mode 100644 docs/plugin-validation.md create mode 100644 frontend/Dockerfile create mode 100644 frontend/e2e/scan-workflow.spec.ts create mode 100644 frontend/src/components/Pagination.tsx create mode 100644 frontend/src/hooks/usePreferredExportFormat.ts create mode 100644 frontend/src/pages/ReportComparison.tsx create mode 100644 frontend/src/pages/Workflows.tsx create mode 100644 frontend/src/utils/validation.ts create mode 100644 frontend/testing/unit/components/ToastContext.test.tsx create mode 100644 frontend/testing/unit/pages/Findings.test.tsx create mode 100644 frontend/testing/unit/pages/ReportComparison.test.tsx create mode 100644 frontend/testing/unit/pages/Reports.preferredFormat.test.tsx create mode 100644 frontend/testing/unit/pages/ScannerRetry.test.tsx create mode 100644 frontend/testing/unit/pages/Workflows.test.tsx create mode 100644 frontend/testing/unit/utils/validation.test.ts create mode 100644 scripts/check-artifacts.sh create mode 100644 scripts/refresh_plugin_checksum.py create mode 100644 scripts/validate_plugin.py create mode 100644 scripts/validate_plugins.py create mode 100644 testing/backend/integration/test_bulk_delete_contract.py create mode 100644 testing/backend/integration/test_dashboard_cache.py create mode 100644 testing/backend/integration/test_report_audit_log.py create mode 100644 testing/backend/integration/test_report_filenames.py create mode 100644 testing/backend/integration/test_routes_sarif.py create mode 100644 testing/backend/test_cache_invalidation.py create mode 100644 testing/backend/test_queue_position.py create mode 100644 testing/backend/test_safe_mode_filesystem_targets.py create mode 100644 testing/backend/test_task_pagination.py create mode 100644 testing/backend/unit/fixtures/plugins/invalid_plugin/metadata.json create mode 100644 testing/backend/unit/fixtures/plugins/valid_plugin/metadata.json create mode 100644 testing/backend/unit/test_cache_helpers.py create mode 100644 testing/backend/unit/test_cli.py create mode 100644 testing/backend/unit/test_concurrent_limiter.py create mode 100644 testing/backend/unit/test_dns_enum_parser.py create mode 100644 testing/backend/unit/test_plugin_validator.py create mode 100644 testing/backend/unit/test_redaction.py create mode 100644 testing/backend/unit/test_refresh_plugin_checksum.py create mode 100644 testing/backend/unit/test_report_filename.py create mode 100644 testing/backend/unit/test_sarif.py create mode 100644 testing/backend/unit/test_task_start_validation.py create mode 100644 testing/backend/unit/test_validate_plugin.py create mode 100644 testing/backend/unit/test_vault_failure_messages.py diff --git a/.github/workflows/check-artifacts.yml b/.github/workflows/check-artifacts.yml new file mode 100644 index 000000000..6fb0108a9 --- /dev/null +++ b/.github/workflows/check-artifacts.yml @@ -0,0 +1,14 @@ +name: Check for Frontend Artifacts + +on: + pull_request: + branches: [main, dev] + +jobs: + artifact-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Run artifact check + run: bash scripts/check-artifacts.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5dc34d12d..1cd3e2513 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: "3.11" - name: Install backend system dependencies run: sudo apt-get update && sudo apt-get install -y libcairo2-dev pkg-config - name: Install backend development dependencies @@ -30,7 +30,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: "3.11" - name: Install backend system dependencies run: sudo apt-get update && sudo apt-get install -y libcairo2-dev pkg-config - name: Install backend dependencies @@ -49,13 +49,17 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: '20' - cache: 'npm' + node-version: "20" + cache: "npm" cache-dependency-path: frontend/package-lock.json - name: Install frontend dependencies run: npm ci - - name: Run frontend typecheck + - name: Run frontend TypeScript typecheck run: npm run typecheck + - name: Note TypeScript typecheck in job summary + if: always() + run: | + echo "Frontend TypeScript typecheck: npm run typecheck" >> "$GITHUB_STEP_SUMMARY" - name: Run frontend quality gate run: npm run quality - name: Run unit tests diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..82b9f1805 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,14 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + +- repo: https://github.com/psf/black + rev: 23.3.0 + hooks: + - id: black + language_version: python3 diff --git a/0 b/0 new file mode 100644 index 000000000..e69de29bb diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9fde6adc6..9bebba48f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -66,6 +66,78 @@ npm install npm run dev -- --host 127.0.0.1 --port 5173 ``` +## Backend Testing Quickstart + +This section explains how to run the backend test suite from a fresh checkout +without touching the main development environment. + +### 1. Prerequisites + +Make sure your machine has Python 3.11 or newer before running any test commands. + +```bash +python3 --version +``` + +If the version shown is older than 3.11, substitute the full path to a compatible +interpreter (e.g. `python3.11`) wherever `python3` appears below. + +### 2. Run the Full Backend Test Suite + +From the repo root, run: + +```bash +./testing/test_python.sh +``` + +This script handles everything automatically: + +- Creates an isolated virtual environment at `venv_tests/` (separate from your + dev environment) +- Installs all required dependencies from `backend/requirements.txt` and + `backend/requirements-dev.txt` +- Runs the full `testing/backend/` suite with pytest in quiet mode + +You do not need to activate any virtual environment manually for this command. + +### 3. Run a Single Test File + +When you want faster feedback on one specific file, activate the test virtual +environment and call pytest directly. Run these commands from the repo root: + +```bash +source venv_tests/bin/activate +python -m pytest testing/backend/unit/test_models.py -v +deactivate +``` + +Replace `test_models.py` with whichever file you want to target. All unit tests +live under `testing/backend/unit/` and integration tests live under +`testing/backend/integration/`. + +> **Note:** Run `./testing/test_python.sh` at least once before using this +> shortcut so that `venv_tests/` exists and dependencies are installed. + +### 4. Where Requirements Files Live + +| File | Purpose | +|---|---| +| `backend/requirements.txt` | Core runtime dependencies | +| `backend/requirements-dev.txt` | Test and development dependencies (pytest, etc.) | + +Both files must be installed for the test suite to run correctly. The +`./testing/test_python.sh` script installs both automatically. + +### 5. Common Dependency Issues + +- **`ModuleNotFoundError` on any import** — the `venv_tests/` environment may + be outdated. Delete it and re-run `./testing/test_python.sh` to rebuild from + scratch. +- **`python3` resolves to an older version** — check with `python3 --version`. + Use `python3.11` or `python3.12` explicitly if needed. +- **Permission denied on `./testing/test_python.sh`** — make it executable + first with `chmod +x testing/test_python.sh`. + ## Project Layout - `backend/secuscan`: FastAPI routes, execution logic, workflows, validation, vault, and reporting @@ -319,4 +391,17 @@ If a PR has been quiet for more than a week, a polite follow-up comment is compl - Use pull request comments for implementation-specific review discussion. - For security-sensitive reports, do not use public issues. Follow [SECURITY.md](SECURITY.md). -Thank you for helping make SecuScan more useful, safer, and more welcoming to new contributors. \ No newline at end of file +Thank you for helping make SecuScan more useful, safer, and more welcoming to new contributors. +## Frontend Generated Artifacts + +Never commit these auto-generated paths: +- `frontend/dist/` +- `frontend/playwright-report/` +- `frontend/test-results/` +- `frontend/.vite/` +- `.vite/deps/` + +If CI fails, run: +```bash +git rm --cached +echo 'frontend/dist/' >> .gitignore \ No newline at end of file diff --git a/PLUGINS.md b/PLUGINS.md index dbef0dc6a..97528fde9 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -104,7 +104,169 @@ Only run scans against systems you own or are explicitly authorized to assess. | Binary Signature Scan | `yara_scan` | `forensics` | `intrusive` | `yara` | Binary and file-system signature matching with YARA rules. | | DAST Web Proxy (ZAP) | `zap_scanner` | `vulnerability` | `exploit` | `python3` | Dynamic proxy spidering and payload injection. | +## Plugin Input Schema with Examples + +Plugins can tell us about configurable user inputs through schema fields in their +`metadata.json`. + +### Supported Field Types + +Example schema: + +```json +{ + "inputs": [ + { + "key": "target", + "label": "Target URL", + "type": "text", + "required": true, + "placeholder": "https://example.com" + }, + { + "key": "scan_type", + "label": "Scan Type", + "type": "select", + "required": true, + "options": ["quick", "full"] + }, + { + "key": "checks", + "label": "Checks", + "type": "multiselect", + "required": false, + "options": ["headers", "ssl", "cookies"] + }, + { + "key": "recursive", + "label": "Enable Recursive Scan", + "type": "checkbox", + "required": false, + "default": false + }, + { + "key": "timeout", + "label": "Timeout (seconds)", + "type": "number", + "required": false, + "default": 30 + }, + { + "key": "wordlist_path", + "label": "Wordlist Path", + "type": "path", + "required": false + } + ] +} +``` + +### Required vs Optional Fields + +- `"required": true` means that the user must provide a value before running the plugin. +- `"required": false` means that the field is optional. +- Optional fields may define a `"default"` value. + +### Preset Mapping + +Plugin presets shall map directly to schema keys. + +Example preset: + +```json +{ + "preset": { + "target": "https://example.com", + "scan_type": "quick", + "recursive": true, + "timeout": 60 + } +} +``` + +Each preset key shall exactly match a corresponding schema `"key"` value. + ## Maintenance Notes - If a plugin is added, renamed, or removed, update this file from the plugin metadata rather than editing counts by hand. - Prefer keeping `id`, category, safety level, and dependency names aligned with each plugin's `metadata.json`. +## Checksum Maintenance + +Plugin metadata files include integrity checksums. If you edit a plugin's +`metadata.json` or `parser.py`, you must refresh the checksum before committing +or the backend will reject the plugin during load and unrelated backend tests +will fail. + +Use the helper script to refresh checksums: + +```bash +# Refresh a single plugin after editing it +python scripts/refresh_plugin_checksum.py --plugin + +# Example +python scripts/refresh_plugin_checksum.py --plugin nmap + +# Refresh all plugins at once +python scripts/refresh_plugin_checksum.py --all + +# Preview what would change without writing anything +python scripts/refresh_plugin_checksum.py --all --dry-run +``` + +Run this script any time you: +- Edit a plugin's `metadata.json` fields +- Edit a plugin's `parser.py` +- Add a new plugin + +After refreshing, run the backend tests to confirm the plugin loads correctly: + +```bash +cd backend && python -m pytest +``` + +### Example 1 — Refresh a single plugin after editing its files + +Run this after editing `plugins/nmap/metadata.json` or `plugins/nmap/parser.py`: + +```bash +python scripts/refresh_plugin_checksum.py --plugin nmap +``` + +When the checksum is already up to date, the script reports the plugin as +`[OK]` and exits cleanly with no files modified. + +When the checksum is outdated, the script prints the old and new digest values, +writes the updated checksum back into `metadata.json`, and confirms the update. + +### Example 2 — Preview all plugins without writing anything (dry run) + +Run this to check which plugins are out of date before committing: + +```bash +python scripts/refresh_plugin_checksum.py --all --dry-run +``` + +In dry-run mode no files are modified. Each plugin reports either `[OK]` if +its checksum is current, or `[UPDATE]` showing what would change. A clean +state means every plugin reports `[OK]` and the final line shows zero failures. + +If any `[UPDATE]` lines appear, run the same command without `--dry-run` to +apply the changes before committing. + +## Plugin Validation + +Validate a single plugin without loading all plugins: + +```bash +# Validate by plugin id +python scripts/validate_plugin.py --plugin + +# Example +python scripts/validate_plugin.py --plugin nmap + +# Or pass a path to the plugin directory +python scripts/validate_plugin.py --plugin plugins/nmap +``` + +The validation checks metadata JSON, required fields, checksums, and custom +parser imports when applicable. diff --git a/backend/secuscan/cli.py b/backend/secuscan/cli.py new file mode 100644 index 000000000..1a41be85a --- /dev/null +++ b/backend/secuscan/cli.py @@ -0,0 +1,167 @@ +""" +SecuScan CLI - Command line interface for running security scans +""" + +import asyncio +import argparse +import json +import sys +import os +from datetime import datetime +from pathlib import Path +from typing import Optional, Dict, Any + +# Add the parent directory to sys.path to allow absolute imports +sys.path.append(str(Path(__file__).resolve().parents[2])) + +from backend.secuscan.executor import executor +from backend.secuscan.database import init_db, get_db +from backend.secuscan.cache import init_cache +from backend.secuscan.config import settings +from backend.secuscan.plugins import init_plugins, get_plugin_manager +from backend.secuscan.reporting import reporting + +async def run_scan(target: str, plugin_id: str, output_format: str, output_file: Optional[str] = None): + """Initialize components and execute a scan task.""" + + # Ensure directories exist + settings.ensure_directories() + + # Initialize backend components + await init_db(settings.database_path) + await init_cache() + await init_plugins(settings.plugins_dir) + + plugin_manager = get_plugin_manager() + + # If target is "." and no plugin specified, default to a sensible one for code + if target == "." and plugin_id == "nmap": + # Check if we should use secret_scanner or code_analyzer instead + plugin_id = "secret_scanner" if plugin_manager.get_plugin("secret_scanner") else "code_analyzer" + print(f"[*] Detected directory target '.', defaulting to plugin: {plugin_id}") + + plugin = plugin_manager.get_plugin(plugin_id) + if not plugin: + print(f"Error: Plugin '{plugin_id}' not found.") + available = ", ".join(list(plugin_manager.plugins.keys())[:10]) + print(f"Available plugins include: {available}...") + return 1 + + # Create task + inputs = {"target": target} + try: + task_id = await executor.create_task(plugin_id, inputs, consent_granted=True) + except Exception as e: + print(f"Error creating task: {e}") + return 1 + + print(f"[*] Starting scan {task_id}") + print(f"[*] Tool: {plugin.name}") + print(f"[*] Target: {target}") + print("-" * 40) + + # Execute task + # We subscribe to broadcast to show live output + queue = executor.subscribe(task_id) + + execution_task = asyncio.create_task(executor.execute_task(task_id)) + + async def monitor_output(): + try: + while not execution_task.done(): + try: + event = await asyncio.wait_for(queue.get(), timeout=0.2) + if event["type"] == "output": + print(event["data"], end="", flush=True) + elif event["type"] == "status": + if event["data"] in ["completed", "failed", "cancelled"]: + break + except asyncio.TimeoutError: + pass + except asyncio.CancelledError: + pass + + await monitor_output() + await execution_task + + # Get results + db = await get_db() + task_row = await db.fetchone( + "SELECT id, plugin_id, tool_name, target, status, created_at, preset, inputs_json, command_used, structured_json FROM tasks WHERE id = ?", + (task_id,) + ) + + if not task_row: + print("Error: Task record not found after execution.") + return 1 + + if task_row["status"] == "failed": + print(f"\n[!] Scan failed. Check logs for details.") + return 1 + + print(f"\n[*] Scan completed successfully.") + + # Generate report + structured_data = json.loads(task_row["structured_json"]) if task_row["structured_json"] else {} + result_payload = {"structured": structured_data} + + report_content: str = "" + if output_format == "sarif": + report_content = reporting.generate_sarif_report(dict(task_row), result_payload) + elif output_format == "json": + report_content = json.dumps(structured_data, indent=2) + elif output_format == "csv": + report_content = reporting.generate_csv_report(dict(task_row), result_payload) + elif output_format == "html": + report_content = reporting.generate_html_report(dict(task_row), result_payload) + else: + # Console summary + findings = structured_data.get("findings", []) + print(f"[*] Found {len(findings)} issues.") + for f in findings: + print(f" - [{f.get('severity', 'INFO').upper()}] {f.get('title')}") + return 0 + + if output_file: + output_path = Path(output_file) + output_path.write_text(report_content) + print(f"[*] Report saved to: {output_path.absolute()}") + else: + print("\n--- Report Output ---") + print(report_content) + + return 0 + +def main(): + parser = argparse.ArgumentParser(description="SecuScan CLI - Local-First Pentesting Toolkit") + subparsers = parser.add_subparsers(dest="command", help="Command to run") + + # Scan command + scan_parser = subparsers.add_parser("scan", help="Run a security scan") + scan_parser.add_argument("target", help="Target to scan (IP, Domain, or Path)") + scan_parser.add_argument("--plugin", default="nmap", help="Plugin ID to use (default: nmap)") + scan_parser.add_argument("--format", choices=["sarif", "json", "csv", "html", "console"], default="console", help="Output format") + scan_parser.add_argument("--output", "-o", help="Output file path") + + # List plugins command + subparsers.add_parser("plugins", help="List available plugins") + + args = parser.parse_args() + + if args.command == "scan": + sys.exit(asyncio.run(run_scan(args.target, args.plugin, args.format, args.output))) + elif args.command == "plugins": + # Synchronous shortcut for listing + async def list_plugins(): + await init_plugins(settings.plugins_dir) + pm = get_plugin_manager() + print(f"{'ID':<20} {'Name':<30} {'Category':<15}") + print("-" * 65) + for p_id, p in pm.plugins.items(): + print(f"{p_id:<20} {p.name:<30} {p.category:<15}") + asyncio.run(list_plugins()) + else: + parser.print_help() + +if __name__ == "__main__": + main() diff --git a/backend/secuscan/config.py b/backend/secuscan/config.py index 150f470f0..e05e573c2 100644 --- a/backend/secuscan/config.py +++ b/backend/secuscan/config.py @@ -66,7 +66,12 @@ class Settings(BaseSettings): sandbox_timeout: int = 600 # seconds sandbox_cpu_quota: float = 0.5 sandbox_memory_mb: int = 512 - + + # Task-start payload limits (tunable via env vars) + task_start_max_body_bytes: int = 64_000 # 64 KB total JSON body + task_start_max_field_length: int = 1_000 # max chars per string input value + task_start_max_array_length: int = 50 # max items in any list/multiselect input + # Logging log_level: str = "INFO" log_file: str = str(PROJECT_ROOT / "logs" / "secuscan.log") @@ -111,4 +116,4 @@ def ensure_directories(self) -> None: # Global settings instance -settings = Settings() +settings = Settings() \ No newline at end of file diff --git a/backend/secuscan/executor.py b/backend/secuscan/executor.py index 7fd178f0d..3b45fbbe1 100644 --- a/backend/secuscan/executor.py +++ b/backend/secuscan/executor.py @@ -13,11 +13,14 @@ import logging import re +from .redaction import redact from .cache import get_cache from .config import settings from .database import get_db from .plugins import get_plugin_manager from .models import TaskStatus +from .ratelimit import concurrent_limiter +from .ratelimit import concurrent_limiter # Modular Scanners from .scanners.port_scanner import PortScanner @@ -137,6 +140,41 @@ async def create_task( return task_id + async def mark_task_failed(self, task_id: str, reason: str) -> None: + """ + Mark a task as failed without running it. + Used to roll back a created-but-unscheduled task record. + + Args: + task_id: Task identifier + reason: Human-readable failure reason stored as error_message + """ + db = await get_db() + await db.execute( + """ + UPDATE tasks SET + status = ?, + completed_at = ?, + duration_seconds = ?, + error_message = ? + WHERE id = ? + """, + ( + TaskStatus.FAILED.value, + datetime.now().isoformat(), + 0, + reason, + task_id, + ) + ) + await db.log_audit( + "task_failed", + f"Task rejected before execution: {reason}", + severity="warning", + context={"task_id": task_id, "reason": reason}, + task_id=task_id, + ) + async def execute_task(self, task_id: str): """ Execute a task asynchronously. @@ -153,6 +191,7 @@ async def execute_task(self, task_id: str): "UPDATE tasks SET status = ?, started_at = ? WHERE id = ?", (TaskStatus.RUNNING.value, datetime.now().isoformat(), task_id) ) + await self._invalidate_cached_views() # Get task details task_row = await db.fetchone( @@ -260,6 +299,7 @@ async def execute_task(self, task_id: str): # Save raw output raw_path = Path(settings.raw_output_dir) / f"{task_id}.txt" + output = redact(output) with open(raw_path, 'w') as f: f.write(output) @@ -320,6 +360,33 @@ async def execute_task(self, task_id: str): logger.info(f"Task {task_id} completed in {duration:.2f}s") + except asyncio.CancelledError: + # CancelledError inherits from BaseException, not Exception — + # it bypasses the broad except below, so we handle it explicitly. + # Task.cancelled() returns False while the finally block is still + # executing, so this is the only reliable place to write the + # cancellation status to the DB. + duration = (time.time() - start_time) if 'start_time' in locals() else 0 + await db.execute( + """ + UPDATE tasks SET + status = ?, + completed_at = ?, + duration_seconds = ? + WHERE id = ? AND status = ? + """, + ( + TaskStatus.CANCELLED.value, + datetime.now().isoformat(), + duration, + task_id, + TaskStatus.RUNNING.value, + ) + ) + await self._broadcast(task_id, "status", TaskStatus.CANCELLED.value) + await self._invalidate_cached_views() + raise # let asyncio complete the cancellation + except Exception as e: logger.error(f"Task {task_id} failed: {e}", exc_info=True) @@ -354,15 +421,10 @@ async def execute_task(self, task_id: str): task_id=task_id ) finally: - # Cleanup: remove from running tasks and update DB if cancelled + # Always clean up: remove from the in-memory registry and + # release the concurrency slot regardless of how the task ended. self.running_tasks.pop(task_id, None) - - # Check if task was cancelled - if asyncio.current_task().cancelled(): - await db.execute( - "UPDATE tasks SET status = ?, completed_at = ? WHERE id = ? AND status = ?", - (TaskStatus.CANCELLED.value, datetime.now().isoformat(), task_id, TaskStatus.RUNNING.value) - ) + await concurrent_limiter.release(task_id) async def _execute_command( self, @@ -535,7 +597,19 @@ async def get_task_status(self, task_id: str) -> Optional[Dict]: ) if not task_row: return None - + + queue_position = None + pending_count = None + + if task_row["status"] == TaskStatus.QUEUED.value: + queued_rows = await db.fetchall( + "SELECT id FROM tasks WHERE status = ? ORDER BY created_at ASC", + (TaskStatus.QUEUED.value,) + ) + ids = [r["id"] for r in queued_rows] + pending_count = len(ids) + queue_position = (ids.index(task_id) + 1) if task_id in ids else None + return { "task_id": task_row["id"], "plugin_id": task_row["plugin_id"], @@ -549,7 +623,9 @@ async def get_task_status(self, task_id: str) -> Optional[Dict]: "exit_code": task_row["exit_code"], "error_message": task_row["error_message"], "preset": task_row["preset"], - "inputs": json.loads(task_row["inputs_json"] or "{}") + "inputs": json.loads(task_row["inputs_json"] or "{}"), + "queue_position": queue_position, + "pending_count": pending_count, } async def _upsert_findings_and_report(self, db, task_id: str, plugin, plugin_id: str, target: str, status: str, output: str = ""): diff --git a/backend/secuscan/plugin_validator.py b/backend/secuscan/plugin_validator.py new file mode 100644 index 000000000..7c3ac455f --- /dev/null +++ b/backend/secuscan/plugin_validator.py @@ -0,0 +1,340 @@ +""" +plugin_validator.py — SecuScan plugin metadata validator + +Shared validation logic used by: + - scripts/validate_plugins.py (CLI helper for contributors) + - backend plugin loading (via PluginManager._validate_plugin) +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +VALID_ENGINE_TYPES = {"cli", "python", "docker"} +VALID_SAFETY_LEVELS = {"safe", "intrusive", "exploit"} +VALID_FIELD_TYPES = {"string","integer","text", "number", "boolean", "select", "multiselect", "textarea"} +VALID_PARSER_TYPES = {"json", "text", "custom", "none"} + +REQUIRED_TOP_LEVEL_FIELDS = [ + "id", + "name", + "description", + "version", + "category", + "icon", + "engine", + "command_template", + "fields", + "output", + "safety", + "checksum", +] + +# --------------------------------------------------------------------------- +# Result types +# --------------------------------------------------------------------------- + + +@dataclass +class ValidationError: + plugin_id: str + path: str + message: str + + def display(self) -> str: + return f" ✗ [{self.plugin_id}] {self.path} → {self.message}" + + +@dataclass +class ValidationResult: + plugin_id: str + plugin_dir: Path + errors: list = field(default_factory=list) + + @property + def valid(self) -> bool: + return len(self.errors) == 0 + + def add(self, path: str, message: str) -> None: + self.errors.append(ValidationError(self.plugin_id, path, message)) + + +# --------------------------------------------------------------------------- +# Core validator +# --------------------------------------------------------------------------- + + +class PluginMetadataValidator: + """ + Validates a single plugin directory. + + Usage:: + + validator = PluginMetadataValidator(Path("plugins/nmap")) + result = validator.validate() + if not result.valid: + for err in result.errors: + print(err.display()) + """ + + def __init__(self, plugin_dir: Path) -> None: + self.plugin_dir = plugin_dir + self.metadata_file = plugin_dir / "metadata.json" + + def validate(self) -> ValidationResult: + plugin_id = self.plugin_dir.name # fallback before we parse id + + if not self.metadata_file.exists(): + result = ValidationResult(plugin_id=plugin_id, plugin_dir=self.plugin_dir) + result.add("metadata.json", "File not found") + return result + + try: + raw = self.metadata_file.read_text(encoding="utf-8") + data: dict[str, Any] = json.loads(raw) + except json.JSONDecodeError as exc: + result = ValidationResult(plugin_id=plugin_id, plugin_dir=self.plugin_dir) + result.add("metadata.json", f"Invalid JSON — {exc}") + return result + + plugin_id = data.get("id") or plugin_id + result = ValidationResult(plugin_id=plugin_id, plugin_dir=self.plugin_dir) + + self._check_required_fields(data, result) + self._check_engine(data, result) + self._check_command_template(data, result) + self._check_fields(data, result) + self._check_output(data, result) + self._check_safety(data, result) + self._check_validation_block(data, result) + self._check_checksum(data, result) + self._check_dependencies(data, result) + self._check_custom_parser(data, result) + + return result + + def _check_required_fields(self, data: dict, result: ValidationResult) -> None: + for key in REQUIRED_TOP_LEVEL_FIELDS: + if key not in data or data[key] in (None, "", [], {}): + result.add(key, f"Required field '{key}' is missing or empty") + + def _check_engine(self, data: dict, result: ValidationResult) -> None: + engine = data.get("engine") + if not isinstance(engine, dict): + result.add("engine", "Must be an object") + return + + engine_type = engine.get("type") + if engine_type not in VALID_ENGINE_TYPES: + result.add( + "engine.type", + f"'{engine_type}' is not a supported engine type — " + f"must be one of: {sorted(VALID_ENGINE_TYPES)}", + ) + + if engine_type == "cli" and not engine.get("binary"): + result.add("engine.binary", "CLI engine must declare a 'binary'") + + if engine_type == "docker" and not engine.get("image"): + result.add("engine.image", "Docker engine must declare an 'image'") + + def _check_command_template(self, data: dict, result: ValidationResult) -> None: + template = data.get("command_template") + if not isinstance(template, list): + result.add("command_template", "Must be a list of strings") + return + + known_field_ids: set[str] = set() + for f in data.get("fields", []): + if isinstance(f, dict) and f.get("id"): + known_field_ids.add(f["id"]) + + placeholder_re = re.compile(r"\{(\w+)(?::[^}]*)?\}") + for i, token in enumerate(template): + if not isinstance(token, str): + result.add(f"command_template[{i}]", "Each token must be a string") + continue + + if token.startswith("--if:"): + continue + + for match in placeholder_re.finditer(token): + var_name = match.group(1) + if known_field_ids and var_name not in known_field_ids: + result.add( + f"command_template[{i}]", + f"Placeholder '{{{var_name}}}' does not match any declared field id", + ) + + def _check_fields(self, data: dict, result: ValidationResult) -> None: + fields = data.get("fields") + if not isinstance(fields, list): + result.add("fields", "Must be a list") + return + + seen_ids: set[str] = set() + for i, f in enumerate(fields): + prefix = f"fields[{i}]" + if not isinstance(f, dict): + result.add(prefix, "Each field must be an object") + continue + + fid = f.get("id") + if not fid: + result.add(f"{prefix}.id", "Field is missing an 'id'") + elif fid in seen_ids: + result.add(f"{prefix}.id", f"Duplicate field id '{fid}'") + else: + seen_ids.add(fid) + + if not f.get("label"): + result.add(f"{prefix}.label", f"Field '{fid}' is missing a 'label'") + + ftype = f.get("type") + if ftype not in VALID_FIELD_TYPES: + result.add( + f"{prefix}.type", + f"'{ftype}' is not a supported field type — " + f"must be one of: {sorted(VALID_FIELD_TYPES)}", + ) + + if ftype in ("select", "multiselect"): + options = f.get("options") + if not isinstance(options, list) or len(options) == 0: + result.add( + f"{prefix}.options", + f"Field '{fid}' is type '{ftype}' and must have a non-empty 'options' list", + ) + + def _check_output(self, data: dict, result: ValidationResult) -> None: + output = data.get("output") + if not isinstance(output, dict): + result.add("output", "Must be an object") + return + + parser = output.get("parser") + if parser not in VALID_PARSER_TYPES: + result.add( + "output.parser", + f"'{parser}' is not a supported parser type — " + f"must be one of: {sorted(VALID_PARSER_TYPES)}", + ) + + def _check_safety(self, data: dict, result: ValidationResult) -> None: + safety = data.get("safety") + if not isinstance(safety, dict): + result.add("safety", "Must be an object") + return + + level = safety.get("level") + if level not in VALID_SAFETY_LEVELS: + result.add( + "safety.level", + f"'{level}' is not a supported safety level — " + f"must be one of: {sorted(VALID_SAFETY_LEVELS)}", + ) + + if safety.get("requires_consent") and not safety.get("consent_message"): + result.add( + "safety.consent_message", + "Plugin requires consent but 'consent_message' is missing or empty", + ) + + def _check_validation_block(self, data: dict, result: ValidationResult) -> None: + validation = data.get("validation") + if validation is None: + return + + if not isinstance(validation, dict): + result.add("validation", "Must be an object if present") + return + + for key, rule in validation.items(): + prefix = f"validation.{key}" + if not isinstance(rule, dict): + result.add(prefix, "Each validation rule must be an object") + continue + if "required" in rule and not isinstance(rule["required"], bool): + result.add(f"{prefix}.required", "'required' must be a boolean") + + def _check_checksum(self, data: dict, result: ValidationResult) -> None: + checksum = data.get("checksum") + if not checksum: + result.add("checksum", "Checksum is missing — run: python scripts/refresh_plugin_checksum.py --plugin ") + return + + if not isinstance(checksum, str) or len(checksum) != 64: + result.add( + "checksum", + "Checksum must be a 64-character SHA-256 hex string — " + "run: python scripts/refresh_plugin_checksum.py --plugin ", + ) + + def _check_dependencies(self, data: dict, result: ValidationResult) -> None: + deps = data.get("dependencies") + if deps is None: + return + + if not isinstance(deps, dict): + result.add("dependencies", "Must be an object if present") + return + + binaries = deps.get("binaries") + if binaries is not None and not isinstance(binaries, list): + result.add("dependencies.binaries", "Must be a list of strings") + elif isinstance(binaries, list): + for i, b in enumerate(binaries): + if not isinstance(b, str) or not b.strip(): + result.add( + f"dependencies.binaries[{i}]", + "Each binary dependency must be a non-empty string", + ) + + python_packages = deps.get("python_packages") + if python_packages is not None and not isinstance(python_packages, list): + result.add("dependencies.python_packages", "Must be a list of strings") + + def _check_custom_parser(self, data: dict, result: ValidationResult) -> None: + output = data.get("output") + if not isinstance(output, dict): + return + + if output.get("parser") == "custom": + parser_file = self.plugin_dir / "parser.py" + if not parser_file.exists(): + result.add( + "output.parser", + "Parser is 'custom' but parser.py was not found in the plugin directory", + ) + + +# --------------------------------------------------------------------------- +# Batch validation helpers +# --------------------------------------------------------------------------- + + +def validate_all_plugins(plugins_dir: Path) -> list: + """Validate every plugin directory under plugins_dir.""" + results = [] + if not plugins_dir.exists(): + raise FileNotFoundError(f"Plugins directory not found: {plugins_dir}") + + for plugin_dir in sorted(plugins_dir.iterdir()): + if not plugin_dir.is_dir(): + continue + results.append(PluginMetadataValidator(plugin_dir).validate()) + + return results + + +def validate_one_plugin(plugin_dir: Path) -> ValidationResult: + """Validate a single plugin directory.""" + return PluginMetadataValidator(plugin_dir).validate() \ No newline at end of file diff --git a/backend/secuscan/plugins.py b/backend/secuscan/plugins.py index 4af73a3cd..d224bd4ad 100644 --- a/backend/secuscan/plugins.py +++ b/backend/secuscan/plugins.py @@ -20,15 +20,15 @@ class PluginManager: """Manages plugin loading and validation""" - + def __init__(self, plugins_dir: str): self.plugins_dir = Path(plugins_dir) self.plugins: Dict[str, PluginMetadata] = {} - + async def load_plugins(self) -> int: """ Load all plugins from the plugins directory. - + Returns: Number of successfully loaded plugins """ @@ -36,22 +36,22 @@ async def load_plugins(self) -> int: logger.warning(f"Plugins directory does not exist: {self.plugins_dir}") self.plugins_dir.mkdir(parents=True, exist_ok=True) return 0 - + loaded = 0 - + # Scan for plugin directories for plugin_dir in self.plugins_dir.iterdir(): if not plugin_dir.is_dir(): continue - + metadata_file = plugin_dir / "metadata.json" if not metadata_file.exists(): logger.warning(f"No metadata.json found in {plugin_dir}") continue - + try: plugin_meta = await self._load_plugin_metadata(metadata_file) - + # Validate plugin if await self._validate_plugin(plugin_meta, plugin_dir): self.plugins[plugin_meta.id] = plugin_meta @@ -59,28 +59,28 @@ async def load_plugins(self) -> int: logger.info(f"✓ Loaded plugin: {plugin_meta.name} v{plugin_meta.version}") else: logger.error(f"✗ Failed to validate plugin: {plugin_meta.id}") - + except Exception as e: logger.error(f"Failed to load plugin from {plugin_dir}: {e}") - + logger.info(f"Loaded {loaded} plugins") return loaded - + async def _load_plugin_metadata(self, metadata_file: Path) -> PluginMetadata: """Load and parse plugin metadata JSON""" with open(metadata_file, 'r') as f: data = json.load(f) - + return PluginMetadata(**data) - + async def _validate_plugin(self, plugin: PluginMetadata, plugin_dir: Path) -> bool: """ Validate plugin metadata and dependencies. - + Args: plugin: Plugin metadata plugin_dir: Plugin directory path - + Returns: True if plugin is valid """ @@ -164,13 +164,19 @@ def compute_plugin_digest(metadata_file: Path, parser_file: Path) -> str: metadata.pop("signature", None) metadata_canonical = json.dumps(metadata, sort_keys=True, separators=(",", ":")) metadata_digest = hashlib.sha256(metadata_canonical.encode("utf-8")).hexdigest() - parser_digest = hashlib.sha256(parser_file.read_bytes()).hexdigest() if parser_file.exists() else "" + + parser_digest = "" + if parser_file.exists(): + parser_bytes = parser_file.read_bytes() + parser_bytes_normalized = parser_bytes.replace(b"\r\n", b"\n") + parser_digest = hashlib.sha256(parser_bytes_normalized).hexdigest() + return hashlib.sha256(f"{metadata_digest}:{parser_digest}".encode("utf-8")).hexdigest() - + def get_plugin(self, plugin_id: str) -> Optional[PluginMetadata]: """Get plugin by ID""" return self.plugins.get(plugin_id) - + def list_plugins(self) -> List[Dict]: """List all loaded plugins""" plugins: List[Dict] = [] @@ -221,7 +227,7 @@ def _get_missing_binaries(self, plugin: PluginMetadata) -> List[str]: # Preserve declaration order while removing duplicates. unique_required = list(dict.fromkeys(required)) return [binary for binary in unique_required if shutil.which(binary) is None] - + def get_plugin_schema(self, plugin_id: str) -> Optional[Dict]: """Get full plugin schema for UI generation""" if plugin := self.get_plugin(plugin_id): @@ -235,26 +241,26 @@ def get_plugin_schema(self, plugin_id: str) -> Optional[Dict]: } else: return None - + def _interpolate(self, token: str, inputs: Dict) -> Optional[str]: """Interpolate variables in a token string.""" if "{" not in token or "}" not in token: return token - + rendered = token matches = re.findall(r"\{(\w+)(?::([^}]+))?\}", token) - + for var_name, default_value in matches: # Handle empty default value correctly: "" from regex becomes None actual_default = default_value or None value = inputs.get(var_name, actual_default) - + if value is None or value == "": return None placeholder = "{" + var_name + (f":{default_value}" if default_value else "") + "}" rendered = rendered.replace(placeholder, str(value)) - + return rendered def _with_field_defaults(self, plugin: PluginMetadata, inputs: Dict[str, Any]) -> Dict[str, Any]: @@ -314,11 +320,11 @@ def _normalize_inputs(self, plugin: PluginMetadata, inputs: Dict[str, Any]) -> D def build_command(self, plugin_id: str, inputs: Dict) -> Optional[List[str]]: """ Build command from plugin template and user inputs. - + Args: plugin_id: Plugin identifier inputs: User input values - + Returns: Command as list of arguments """ @@ -337,7 +343,7 @@ def build_command(self, plugin_id: str, inputs: Dict) -> Optional[List[str]]: parts = token.split(":") if len(parts) >= 4 and parts[2] == "then": condition_var = parts[1] - + # Correctly identify then/else segments try: else_idx = parts.index("else") diff --git a/backend/secuscan/redaction.py b/backend/secuscan/redaction.py new file mode 100644 index 000000000..35ed1091b --- /dev/null +++ b/backend/secuscan/redaction.py @@ -0,0 +1,278 @@ +""" +Secret redaction utility. + +Provides a single ``redact()`` function that replaces common secret patterns +in scanner output, logs, and report content with a safe ``[REDACTED]`` +placeholder before any data is persisted or exported. + +Design goals +------------ +* Conservative patterns only — prefer false negatives over false positives so + legitimate finding content (URLs, headers, port strings) is never destroyed. +* Pre-compiled regexes for performance; redaction is called on every raw output + blob so speed matters. +* Replacements preserve surrounding context so analysts can still read the + finding while the secret value itself is hidden. +""" + +import re +import logging +from typing import Any + +logger = logging.getLogger(__name__) + +# ── Placeholder ─────────────────────────────────────────────────────────────── + +REDACTED = "[REDACTED]" + +# ── Secret patterns ─────────────────────────────────────────────────────────── +# Each tuple is (name, compiled_regex). +# Ordering matters: more specific patterns should come before catch-all ones. + +_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ + # Bearer / OAuth tokens in Authorization headers + ( + "bearer_token", + re.compile( + r"((?:Authorization|authorization)\s*:\s*Bearer\s+)" + r"([A-Za-z0-9\-._~+/]{16,}={0,2})", + re.IGNORECASE, + ), + ), + # Basic auth in Authorization header + ( + "basic_auth", + re.compile( + r"((?:Authorization|authorization)\s*:\s*Basic\s+)" + r"([A-Za-z0-9+/]{8,}={0,2})", + re.IGNORECASE, + ), + ), + # Generic Authorization header value (catches other schemes) + ( + "auth_header", + re.compile( + r"((?:Authorization|X-Auth-Token|X-Api-Key|X-Access-Token)\s*:\s*)" + r"(\S{8,})", + re.IGNORECASE, + ), + ), + # Inline bearer token in URLs or JSON values + ( + "bearer_inline", + re.compile( + r'((?:bearer|token)["\s:=]+)([A-Za-z0-9\-._~+/]{20,}={0,2})', + re.IGNORECASE, + ), + ), + # AWS access key id (AKIA…) + ( + "aws_access_key", + re.compile(r"(AKIA[0-9A-Z]{16})", re.IGNORECASE), + ), + # AWS secret access key (typically 40 base64 chars after label) + ( + "aws_secret_key", + re.compile( + r"(aws_secret_access_key\s*[=:]\s*)([A-Za-z0-9/+]{40})", + re.IGNORECASE, + ), + ), + # GCP / service-account private key material + ( + "gcp_private_key", + re.compile( + r"(-----BEGIN (?:RSA |EC )?PRIVATE KEY-----)" + r"(.+?)" + r"(-----END (?:RSA |EC )?PRIVATE KEY-----)", + re.DOTALL, + ), + ), + # API key / secret in common query-string or JSON shapes + # e.g. api_key=abc123 apikey: "abc" secret_key = "xyz" + ( + "api_key", + re.compile( + r"((?:api[_-]?key|apikey|api[_-]?secret|secret[_-]?key|" + r"client[_-]?secret|app[_-]?secret)\s*[=:\"'\s]{1,4})" + r"([A-Za-z0-9\-._~+/!@#%^&*]{8,})", + re.IGNORECASE, + ), + ), + # Password assignment strings + # e.g. password=hunter2 passwd: "abc" PASSWORD = 'xyz' + ( + "password", + re.compile( + r"((?:password|passwd|pass|pwd)\s*[=:\"'\s]{1,4})" + r"([^\s\"'&;,]{6,})", + re.IGNORECASE, + ), + ), + # Session / cookie values + # e.g. Set-Cookie: session=abc123 Cookie: PHPSESSID=xyz + ( + "session_cookie", + re.compile( + r"((?:Set-Cookie\s*:\s*|Cookie\s*:\s*)" + r"(?:[A-Za-z0-9_\-]+\s*=\s*)*" + r"(?:session(?:id)?|PHPSESSID|auth_token|access_token|" + r"refresh_token|csrf[_-]?token|remember[_-]?token)\s*=\s*)" + r"([A-Za-z0-9\-._~+/%]{8,})", + re.IGNORECASE, + ), + ), + # Private token patterns (GitLab glpat-, GitHub ghp_/ghs_/gho_) + ( + "vcs_token", + re.compile( + r"(glpat-[A-Za-z0-9_\-]{20,}" + r"|gh[pousr]_[A-Za-z0-9]{36,})", + re.IGNORECASE, + ), + ), + # Slack tokens (xoxb-, xoxp-, xoxa-, xoxs-) + ( + "slack_token", + re.compile(r"(xox[bpas]-[0-9A-Za-z\-]{16,})", re.IGNORECASE), + ), + # Stripe secret keys (sk_live_… sk_test_…) + ( + "stripe_key", + re.compile(r"(sk_(?:live|test)_[A-Za-z0-9]{24,})", re.IGNORECASE), + ), + # Generic long hex secrets often used as tokens (≥ 32 hex chars after label) + ( + "hex_secret", + re.compile( + r"((?:token|secret|key|hash|salt)\s*[=:\"'\s]{1,4})" + r"([0-9a-fA-F]{32,})", + re.IGNORECASE, + ), + ), +] + +# ── Public API ──────────────────────────────────────────────────────────────── + + +def redact(text: str) -> str: + """ + Scan *text* for common secret patterns and replace matched secret values + with ``[REDACTED]``. + + The function is deliberately conservative: it replaces only the secret + *value* portion of each match, preserving labels and surrounding context + so the output remains readable for analysts. + + Args: + text: Raw scanner output, log line, finding description, etc. + + Returns: + A copy of *text* with secret values replaced by ``[REDACTED]``. + If *text* is empty or ``None`` the original value is returned + unchanged. + """ + if not text: + return text + + redacted = text + for name, pattern in _PATTERNS: + try: + redacted, n = _apply_pattern(name, pattern, redacted) + if n: + logger.debug("redaction: pattern=%s replacements=%d", name, n) + except Exception as exc: # pragma: no cover + # Never let a buggy pattern break the pipeline. + logger.warning("redaction: pattern=%s raised %s — skipped", name, exc) + + return redacted + + +def redact_dict(data: dict[str, Any]) -> dict[str, Any]: + """ + Recursively redact all string values inside a dict (e.g. a finding dict). + + Non-string values are left untouched; nested dicts and lists are walked. + """ + if not isinstance(data, dict): + return data + result: dict[str, Any] = {} + for key, value in data.items(): + result[key] = _redact_value(value) + return result + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +def _redact_value(value: Any) -> Any: + if isinstance(value, str): + return redact(value) + if isinstance(value, dict): + return redact_dict(value) + if isinstance(value, list): + return [_redact_value(item) for item in value] + return value + + +def _apply_pattern( + name: str, pattern: re.Pattern[str], text: str +) -> tuple[str, int]: + """ + Apply a single compiled pattern to *text*. + + Patterns that have two capture groups replace group 2 (the secret) with + ``[REDACTED]`` while keeping group 1 (the label). + + Patterns with one or three groups (e.g. PEM key blocks) replace the entire + match or the middle group respectively. + + Returns ``(new_text, replacement_count)``. + """ + groups = pattern.groups # number of capture groups + + count = 0 + + if groups == 0: + # No groups — replace entire match + def _replace_full(m: re.Match[str]) -> str: + nonlocal count + count += 1 + return REDACTED + + return pattern.sub(_replace_full, text), count + + if groups == 1: + # Single group — replace only the captured group + def _replace_g1(m: re.Match[str]) -> str: + nonlocal count + count += 1 + return REDACTED + + return pattern.sub(_replace_g1, text), count + + if groups == 2: + # Two groups: keep group 1 (label), redact group 2 (value) + def _replace_g2(m: re.Match[str]) -> str: + nonlocal count + count += 1 + return m.group(1) + REDACTED + + return pattern.sub(_replace_g2, text), count + + if groups == 3: + # Three groups: keep groups 1 and 3, redact group 2 (e.g. PEM body) + def _replace_g3(m: re.Match[str]) -> str: + nonlocal count + count += 1 + return m.group(1) + REDACTED + m.group(3) + + return pattern.sub(_replace_g3, text), count + + # Fallback: replace whole match + def _replace_fallback(m: re.Match[str]) -> str: # pragma: no cover + nonlocal count + count += 1 + return REDACTED + + return pattern.sub(_replace_fallback, text), count \ No newline at end of file diff --git a/backend/secuscan/reporting.py b/backend/secuscan/reporting.py index 6833731c6..fb2e8987c 100644 --- a/backend/secuscan/reporting.py +++ b/backend/secuscan/reporting.py @@ -4,6 +4,7 @@ import io import json import re +from .redaction import redact, redact_dict from datetime import datetime from functools import lru_cache from typing import Any, Dict, List @@ -107,17 +108,19 @@ def _normalize_finding(cls, finding: Any) -> Dict[str, Any]: metadata = {} normalized = { + "id": cls._clean_text(finding.get("id")), "title": cls._clean_text(finding.get("title")) or "Untitled finding", "category": cls._clean_text(finding.get("category")) or "General", "severity": cls._clean_text(finding.get("severity") or "info").upper(), "target": cls._clean_text(finding.get("target")), - "description": cls._clean_text(finding.get("description")) or "No description was provided.", - "remediation": cls._clean_text(finding.get("remediation")), - "proof": cls._clean_text(finding.get("proof")), + "description": redact(cls._clean_text(finding.get("description")) or "No description was provided."), + "remediation": redact(cls._clean_text(finding.get("remediation"))), + "proof": redact(cls._clean_text(finding.get("proof"))), "cve": cls._clean_text(finding.get("cve")), + "cwe": cls._clean_text(finding.get("cwe")), "cvss": finding.get("cvss"), "discovered_at": cls._clean_text(finding.get("discovered_at")), - "metadata": {cls._clean_text(key): cls._clean_text(val) for key, val in metadata.items()}, + "metadata": redact_dict({cls._clean_text(key): cls._clean_text(val) for key, val in metadata.items()}), } if normalized["severity"] not in cls.SEVERITY_COLORS: normalized["severity"] = "INFO" @@ -973,5 +976,133 @@ def generate_csv_report(cls, task: Dict[str, Any], result: Dict[str, Any]) -> st ) return output.getvalue() + @classmethod + def generate_sarif_report(cls, task: Dict[str, Any], result: Dict[str, Any]) -> str: + """Generate a SARIF v2.1.0 report for GitHub Code Scanning.""" + payload = cls._build_report_payload(task, result) + tool_name = payload["tool_name"] + + # Define severity mapping to SARIF levels + severity_map = { + "CRITICAL": "error", + "HIGH": "error", + "MEDIUM": "warning", + "LOW": "note", + "INFO": "note" + } + + rules = [] + rule_indices = {} + results = [] + + for finding in payload["findings"]: + # Derive a stable, deterministic rule ID from finding-specific identifiers + raw_rule_id = None + + # 1. Check CVE + cve = finding.get("cve") + if cve and isinstance(cve, str) and cve.strip(): + raw_rule_id = cve.strip() + + # 2. Check CWE (direct or in metadata) + if not raw_rule_id: + cwe = finding.get("cwe") or finding.get("metadata", {}).get("cwe") + if cwe and isinstance(cwe, str) and cwe.strip(): + raw_rule_id = cwe.strip() + + # 3. Check specific check/plugin/finding identifiers + if not raw_rule_id: + for key in ["check_id", "plugin_rule_id", "rule_id", "id"]: + val = finding.get(key) or finding.get("metadata", {}).get(key) + if val and isinstance(val, str) and val.strip(): + raw_rule_id = val.strip() + break + + # 4. Fallback to sanitized title + if not raw_rule_id: + raw_rule_id = finding.get("title") or "security-finding" + + # Sanitize raw rule ID (lowercase, replace non-alphanumeric with hyphens) + rule_id = re.sub(r"[^a-zA-Z0-9\-]", "-", raw_rule_id).lower() + rule_id = re.sub(r"-+", "-", rule_id).strip("-") + if not rule_id: + rule_id = "security-finding" + + if rule_id not in rule_indices: + rule_indices[rule_id] = len(rules) + rules.append({ + "id": rule_id, + "name": finding.get("title", "Security Finding"), + "shortDescription": { + "text": finding.get("title", "Security Finding") + }, + "fullDescription": { + "text": finding.get("description", "No detailed description available.") + }, + "help": { + "text": finding.get("remediation", "No remediation provided.") + }, + "properties": { + "precision": "high" + } + }) + + sarif_result = { + "ruleId": rule_id, + "ruleIndex": rule_indices[rule_id], + "message": { + "text": finding.get("description", "Security finding detected") + }, + "level": severity_map.get(finding["severity"], "note"), + "locations": [] + } + + # Attempt to extract location if available + target = finding.get("target") or payload["target"] + # Check if target looks like a file path or URI + if target: + is_url = "://" in target or target.startswith(("http://", "https://")) + + location = { + "physicalLocation": { + "artifactLocation": { + "uri": target + } + } + } + + # If target has a line number like file.py:123 and is NOT a web URL + if not is_url and ":" in target: + parts = target.split(":") + if parts[-1].isdigit(): + location["physicalLocation"]["artifactLocation"]["uri"] = ":".join(parts[:-1]) + location["physicalLocation"]["region"] = { + "startLine": int(parts[-1]) + } + + sarif_result["locations"].append(location) + + results.append(sarif_result) + + sarif_output = { + "$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": tool_name, + "version": "1.0.0", + "informationUri": "https://github.com/utksh1/SecuScan", + "rules": rules + } + }, + "results": results + } + ] + } + + return json.dumps(sarif_output, indent=2) + reporting = ReportGenerator() diff --git a/backend/secuscan/routes.py b/backend/secuscan/routes.py index c6976439d..24281676c 100644 --- a/backend/secuscan/routes.py +++ b/backend/secuscan/routes.py @@ -3,6 +3,8 @@ """ from fastapi import APIRouter, HTTPException, BackgroundTasks, Response, Body +from fastapi import APIRouter, HTTPException, BackgroundTasks, Response, Request +from fastapi.responses import JSONResponse from typing import Any, Optional, List, Dict, Callable import json import logging @@ -71,7 +73,7 @@ def build_report_filename(task: Dict[str, Any], extension: str) -> str: from .plugins import get_plugin_manager, init_plugins from .executor import executor from .ratelimit import rate_limiter, concurrent_limiter -from .validation import validate_target +from .validation import validate_target, validate_task_start_payload from .reporting import reporting from .vault import VaultCrypto from .workflows import scheduler @@ -100,6 +102,21 @@ async def invalidate_view_cache(): await cache.delete_prefix(prefix) +def _report_generation_error_response(task_id: str, report_format: str) -> JSONResponse: + logger.exception("Report generation failed for task_id=%s format=%s", task_id, report_format) + return JSONResponse( + status_code=500, + content={ + "error": "report_generation_failed", + "message": f"Failed to generate {report_format.upper()} report", + "details": { + "task_id": task_id, + "format": report_format, + }, + }, + ) + + async def get_plugin_manager_for_request(): """ In debug mode, refresh plugin metadata from disk on demand so frontend catalog @@ -115,12 +132,44 @@ async def list_plugins(): """List all available plugins""" plugin_manager = await get_plugin_manager_for_request() plugins = plugin_manager.list_plugins() - + return PluginListResponse( plugins=plugins, total=len(plugins) ) +@router.get("/plugins/summary") +async def get_plugins_summary(): + """Return plugin summary statistics""" + + plugin_manager = await get_plugin_manager_for_request() + plugins = plugin_manager.list_plugins() + + total_plugins = len(plugins) + runnable_count = 0 + unavailable_count = 0 + category_counts: Dict[str, int] = {} + + for plugin in plugins: + category = getattr(plugin, "category", "unknown") + + category_counts[category] = ( + category_counts.get(category, 0) + 1 + ) + + availability = plugin.get("availability", {}) + runnable = availability.get("runnable", False) + + if runnable: + runnable_count += 1 + else: + unavailable_count += 1 + return { + "total_plugins": total_plugins, + "runnable_count": runnable_count, + "unavailable_count": unavailable_count, + "category_counts": dict(sorted(category_counts.items())) + } @router.get("/plugin/{plugin_id}/schema") async def get_plugin_schema(plugin_id: str): @@ -145,11 +194,18 @@ async def get_all_presets(): @router.post("/task/start") async def start_task( request: TaskCreateRequest, - background_tasks: BackgroundTasks + background_tasks: BackgroundTasks, + raw_request: Request, ): """ - Start a new scan task + Start a new scan task. """ + # ── Payload size / field-length guard ───────────────────────────────── + raw_body = await raw_request.body() + ok, status_code, error_msg = validate_task_start_payload(raw_body, request.inputs) + if not ok: + raise HTTPException(status_code=status_code, detail=error_msg) + # Validate consent if settings.require_consent and not request.consent_granted: logger.warning(f"Task start failed: Consent not granted. Request: {request}") @@ -187,13 +243,7 @@ async def start_task( if not can_execute: raise HTTPException(status_code=429, detail=error_msg) - # Check concurrent task limit - can_acquire, error_msg = await concurrent_limiter.acquire("temp") - if not can_acquire: - raise HTTPException(status_code=503, detail=error_msg) - await concurrent_limiter.release("temp") - - # Create task + # Create task record first so we have a real task_id for the limiter try: task_id = await executor.create_task( request.plugin_id, @@ -201,21 +251,29 @@ async def start_task( request.preset, request.consent_granted ) - - # Execute task in background - background_tasks.add_task(executor.execute_task, task_id) - await invalidate_view_cache() - - return { - "task_id": task_id, - "status": "queued", - "created_at": "now", - "stream_url": f"/api/v1/task/{task_id}/stream" - } - except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e + # Atomically acquire a concurrency slot using the real task_id. + # acquire() is lock-protected internally, so the check and register + # happen in a single operation — no TOCTOU window between requests. + can_acquire, error_msg = await concurrent_limiter.acquire(task_id) + if not can_acquire: + # Roll back: mark the DB row failed so it isn't left orphaned + await executor.mark_task_failed(task_id, reason="Concurrency limit reached; task was not started") + raise HTTPException(status_code=503, detail=error_msg) + + # Slot is held — schedule execution. + # execute_task releases the slot in its finally block on every exit path. + background_tasks.add_task(executor.execute_task, task_id) + await invalidate_view_cache() + + return { + "task_id": task_id, + "status": "queued", + "created_at": "now", + "stream_url": f"/api/v1/task/{task_id}/stream" + } @router.get("/task/{task_id}/status") async def get_task_status(task_id: str): @@ -299,13 +357,24 @@ async def download_csv_report(task_id: str): if task_row["status"] not in ["completed", "failed"]: raise HTTPException(status_code=400, detail="Task is not finished yet") - structured_data = json.loads(task_row["structured_json"]) if task_row["structured_json"] else {} - csv_data = reporting.generate_csv_report(dict(task_row), {"structured": structured_data}) + try: + structured_data = json.loads(task_row["structured_json"]) if task_row["structured_json"] else {} + csv_data = reporting.generate_csv_report(dict(task_row), {"structured": structured_data}) + except Exception: + return _report_generation_error_response(task_id, "csv") + + await db.log_audit( + "report_downloaded", + f"CSV report downloaded for task {task_id}", + context={"format": "csv", "task_id": task_id, "plugin_id": task_row["plugin_id"]}, + task_id=task_id, + plugin_id=task_row["plugin_id"], + ) return Response( content=csv_data, media_type="text/csv", - headers={"Content-Disposition": f"attachment; filename={build_report_filename(dict(task_row), 'csv')}"} + headers={"Content-Disposition": f'attachment; filename="{build_report_filename(dict(task_row), "csv")}"'} ) @router.get("/task/{task_id}/report/html") @@ -323,13 +392,24 @@ async def download_html_report(task_id: str): if task_row["status"] not in ["completed", "failed"]: raise HTTPException(status_code=400, detail="Task is not finished yet") - structured_data = json.loads(task_row["structured_json"]) if task_row["structured_json"] else {} - html_content = reporting.generate_html_report(dict(task_row), {"structured": structured_data}) + try: + structured_data = json.loads(task_row["structured_json"]) if task_row["structured_json"] else {} + html_content = reporting.generate_html_report(dict(task_row), {"structured": structured_data}) + except Exception: + return _report_generation_error_response(task_id, "html") + + await db.log_audit( + "report_downloaded", + f"HTML report downloaded for task {task_id}", + context={"format": "html", "task_id": task_id, "plugin_id": task_row["plugin_id"]}, + task_id=task_id, + plugin_id=task_row["plugin_id"], + ) return Response( content=html_content, media_type="text/html", - headers={"Content-Disposition": f"attachment; filename={build_report_filename(dict(task_row), 'html')}"} + headers={"Content-Disposition": f'attachment; filename="{build_report_filename(dict(task_row), "html")}"'} ) @router.get("/task/{task_id}/report/pdf") @@ -347,13 +427,60 @@ async def download_pdf_report(task_id: str): if task_row["status"] not in ["completed", "failed"]: raise HTTPException(status_code=400, detail="Task is not finished yet") - structured_data = json.loads(task_row["structured_json"]) if task_row["structured_json"] else {} - pdf_bytes = bytes(reporting.generate_pdf_report(dict(task_row), {"structured": structured_data})) + try: + structured_data = json.loads(task_row["structured_json"]) if task_row["structured_json"] else {} + pdf_bytes = bytes(reporting.generate_pdf_report(dict(task_row), {"structured": structured_data})) + except Exception: + return _report_generation_error_response(task_id, "pdf") + + await db.log_audit( + "report_downloaded", + f"PDF report downloaded for task {task_id}", + context={"format": "pdf", "task_id": task_id, "plugin_id": task_row["plugin_id"]}, + task_id=task_id, + plugin_id=task_row["plugin_id"], + ) return Response( content=pdf_bytes, media_type="application/pdf", - headers={"Content-Disposition": f"attachment; filename={build_report_filename(dict(task_row), 'pdf')}"} + headers={"Content-Disposition": f'attachment; filename="{build_report_filename(dict(task_row), "pdf")}"'} + ) + + +@router.get("/task/{task_id}/report/sarif") +async def download_sarif_report(task_id: str): + """Download task results as a SARIF report.""" + db = await get_db() + task_row = await db.fetchone( + "SELECT id, plugin_id, tool_name, target, status, created_at, preset, inputs_json, command_used, structured_json FROM tasks WHERE id = ?", + (task_id,) + ) + + if not task_row: + raise HTTPException(status_code=404, detail="Task not found") + + if task_row["status"] not in ["completed", "failed"]: + raise HTTPException(status_code=400, detail="Task is not finished yet") + + try: + structured_data = json.loads(task_row["structured_json"]) if task_row["structured_json"] else {} + sarif_data = reporting.generate_sarif_report(dict(task_row), {"structured": structured_data}) + except Exception: + return _report_generation_error_response(task_id, "sarif") + + await db.log_audit( + "report_downloaded", + f"SARIF report downloaded for task {task_id}", + context={"format": "sarif", "task_id": task_id, "plugin_id": task_row["plugin_id"]}, + task_id=task_id, + plugin_id=task_row["plugin_id"], + ) + + return Response( + content=sarif_data, + media_type="application/sarif+json", + headers={"Content-Disposition": f'attachment; filename="{build_report_filename(dict(task_row), "sarif")}"'} ) @@ -388,20 +515,24 @@ async def get_task_result(task_id: str): severity = str(finding.get("severity", "info")).lower() severity_counts[severity] = severity_counts.get(severity, 0) + 1 - summary: List[str] = [] + structured_summary = structured.get("summary") if isinstance(structured, dict) else None + summary: List[str] = [ + str(item) for item in structured_summary + if isinstance(item, (str, int, float)) and str(item).strip() + ] if isinstance(structured_summary, list) else [] total_findings = len(findings) - if total_findings > 0: + if not summary and total_findings > 0: critical_high = severity_counts.get("critical", 0) + severity_counts.get("high", 0) if critical_high > 0: summary.append(f"Assessment identified {total_findings} security risks, including {critical_high} high-priority items requiring remediation.") else: summary.append(f"Assessment identified {total_findings} minor observations; no critical or high-severity threats were found.") - else: + elif not summary: summary.append("Security analysis revealed no significant vulnerabilities or exposed risks.") if ports := structured.get("open_ports"): summary.append(f"Perimeter analysis confirmed {len(ports)} active network entry points.") - + if techs := structured.get("technologies"): summary.append(f"Fingerprinting identified {len(techs)} unique technologies powering the target infrastructure.") @@ -443,10 +574,10 @@ async def get_task_result(task_id: str): async def cancel_task(task_id: str): """Cancel a running task""" cancelled = await executor.cancel_task(task_id) - + if not cancelled: raise HTTPException(status_code=404, detail="Task not found or not running") - + return { "task_id": task_id, "status": "cancelled", @@ -460,11 +591,11 @@ async def get_dashboard_summary(): async def build(): db = await get_db() - + # Get data raw_findings = await db.fetchall("SELECT * FROM findings ORDER BY discovered_at DESC") findings = parse_json_fields(raw_findings, ["metadata_json"]) - + task_stats = await db.fetchone( """ SELECT @@ -516,7 +647,7 @@ async def build(): ) } - return await build() + return await get_or_set_cached("summary:dashboard", build) @router.get("/findings") @@ -589,13 +720,33 @@ async def list_tasks( t["inputs"] = t.pop("inputs_json", {}) total_pages = (total + per_page - 1) // per_page if per_page > 0 else 0 + + # Calculate next and previous page numbers + next_page = page + 1 if page < total_pages else None + prev_page = page - 1 if page > 1 else None + + # Function to build URL with all query parameters + def build_page_url(page_num): + if page_num is None: + return None + # Start with page and per_page + params_list = [f"page={page_num}", f"per_page={per_page}"] + # Add filters if they exist + if plugin_id: + params_list.append(f"plugin_id={plugin_id}") + if status: + params_list.append(f"status={status}") + # Join with & and return + return f"/api/v1/tasks?{'&'.join(params_list)}" return { "tasks": tasks_list, "pagination": { "page": page, "per_page": per_page, "total_pages": total_pages, - "total_items": total + "total_items": total, + "next": build_page_url(next_page), # ← NEW + "previous": build_page_url(prev_page) # ← NEW } } @@ -603,17 +754,17 @@ async def list_tasks( async def delete_task_records(task_ids: List[str]): """Helper to delete database records and files for multiple tasks.""" db = await get_db() - + # Get raw output paths for file cleanup placeholders = ",".join(["?"] * len(task_ids)) task_rows = await db.fetchall(f"SELECT raw_output_path FROM tasks WHERE id IN ({placeholders})", tuple(task_ids)) - + # Delete associated data await db.execute(f"DELETE FROM findings WHERE task_id IN ({placeholders})", tuple(task_ids)) await db.execute(f"DELETE FROM reports WHERE task_id IN ({placeholders})", tuple(task_ids)) await db.execute(f"DELETE FROM audit_log WHERE task_id IN ({placeholders})", tuple(task_ids)) await db.execute(f"DELETE FROM tasks WHERE id IN ({placeholders})", tuple(task_ids)) - + # Cleanup files on disk for row in task_rows: if row and row["raw_output_path"]: @@ -628,7 +779,7 @@ async def delete_task_records(task_ids: List[str]): async def delete_task(task_id: str): """Delete a task and its associated data (findings, reports, audit logs, and files)""" db = await get_db() - + # Check if task is running status = await executor.get_task_status(task_id) if status and status.get("status") == "running": @@ -636,7 +787,7 @@ async def delete_task(task_id: str): await delete_task_records([task_id]) await invalidate_view_cache() - + return { "task_id": task_id, "deleted": True @@ -651,6 +802,7 @@ async def bulk_delete_tasks(task_ids: List[str] = Body(...)): if not task_ids: return {"deleted_count": 0, "success": True} + # Check if any tasks are running placeholders = ",".join(["?"] * len(task_ids)) running_tasks = await db.fetchone(f"SELECT id FROM tasks WHERE id IN ({placeholders}) AND status = 'running' LIMIT 1", tuple(task_ids)) if running_tasks: @@ -678,7 +830,7 @@ async def bulk_delete_tasks(task_ids: List[str] = Body(...)): async def clear_all_tasks(): """Wipe all scan history and associated data (findings, reports, assets, attack surface)""" db = await get_db() - + # Prevent clearing if any tasks are running running_tasks = await db.fetchone("SELECT id FROM tasks WHERE status = 'running' LIMIT 1") if running_tasks: @@ -692,7 +844,7 @@ async def clear_all_tasks(): # Purge other tables await db.execute("DELETE FROM findings") - + # Fallback cleanup for any orphaned files in data directories for subdir in ["raw", "reports"]: dir_path = Path(settings.data_dir) / subdir @@ -707,7 +859,7 @@ async def clear_all_tasks(): logger.error(f"Failed to cleanup {item}: {e}") await invalidate_view_cache() - + return { "cleared": True, "message": "All scan history and associated data has been purged." @@ -896,7 +1048,7 @@ async def trigger_workflow_tick(): async def get_finding_details(finding_id: str): """Get detailed information for a specific finding""" db = await get_db() - + finding_row = await db.fetchone( """ SELECT f.*, t.tool_name, t.target as task_target @@ -906,17 +1058,17 @@ async def get_finding_details(finding_id: str): """, (finding_id,) ) - + if not finding_row: raise HTTPException(status_code=404, detail="Finding not found") - + metadata = {} if finding_row["metadata_json"]: try: metadata = json.loads(finding_row["metadata_json"]) except json.JSONDecodeError: metadata = {} - + return { "id": finding_row["id"], "task_id": finding_row["task_id"], @@ -940,14 +1092,14 @@ async def get_finding_details(finding_id: str): async def get_attack_surface(): """Return an aggregated view of the monitored attack surface.""" db = await get_db() - + # We aggregate unique targets from tasks and findings tasks = await db.fetchall("SELECT DISTINCT target, tool_name, created_at FROM tasks ORDER BY created_at DESC") findings = await db.fetchall("SELECT DISTINCT target, category, severity, discovered_at FROM findings ORDER BY discovered_at DESC") - + entries = [] seen_targets = set() - + # Add findings as high-priority surface entries for f in findings: target = f["target"] @@ -962,7 +1114,7 @@ async def get_attack_surface(): "last_seen": f["discovered_at"] }) seen_targets.add(target) - + # Add other scanned targets for t in tasks: target = t["target"] @@ -977,7 +1129,7 @@ async def get_attack_surface(): "last_seen": t["created_at"] }) seen_targets.add(target) - + return {"entries": entries} @@ -988,4 +1140,4 @@ async def get_assets(): # For now, we use unique targets as assets rows = await db.fetchall("SELECT DISTINCT target FROM tasks UNION SELECT DISTINCT target FROM findings") assets = [{"id": str(uuid.uuid4()), "name": row["target"]} for row in rows] - return {"assets": assets} + return {"assets": assets} \ No newline at end of file diff --git a/backend/secuscan/validation.py b/backend/secuscan/validation.py index cf6874aa8..495edfa39 100644 --- a/backend/secuscan/validation.py +++ b/backend/secuscan/validation.py @@ -4,7 +4,7 @@ import re import ipaddress -from typing import Tuple +from typing import Any, Dict, Tuple from fnmatch import fnmatch from .config import settings @@ -74,8 +74,13 @@ def validate_target(target: str, safe_mode: bool = True) -> Tuple[bool, str]: # Handle URLs hostname_to_validate = target if target.startswith(("http://", "https://")): - # Extract host:port or host - hostname_to_validate = target.split("://", 1)[1].split("/", 1)[0].split(":", 1)[0] + # Extract host:port or host (handle IPv6 literals in brackets) + host_part = target.split("://", 1)[1].split("/", 1)[0] + if host_part.startswith("["): + # IPv6 literal like [::1]:8080 or [::1] for ipv6 + hostname_to_validate = host_part.split("]")[0][1:] + else: + hostname_to_validate = host_part.split(":", 1)[0] # Validate hostname format (RFC 1123) if not re.match(r'^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$', hostname_to_validate): @@ -116,16 +121,23 @@ def validate_port_range(port_range: str) -> Tuple[bool, str]: Returns: Tuple of (is_valid, error_message) """ - # Handle comma-separated ports + # Handle comma-separated ports (supports mixed specs like "80,443-8080") if ',' in port_range: for port_str in port_range.split(','): - try: - port = int(port_str.strip()) - is_valid, msg = validate_port(port) + port_str = port_str.strip() + if '-' in port_str: + # Delegate sub-ranges like "443-8080" to the range parser below + is_valid, msg = validate_port_range(port_str) if not is_valid: return False, msg - except ValueError: - return False, f"Invalid port number: {port_str}" + else: + try: + port = int(port_str) + is_valid, msg = validate_port(port) + if not is_valid: + return False, msg + except ValueError: + return False, f"Invalid port number: {port_str}" return True, "" # Handle port ranges @@ -225,3 +237,82 @@ def match_pattern(value: str, pattern: str) -> bool: True if value matches pattern """ return fnmatch(value, pattern) + + +# --------------------------------------------------------------------------- +# Task-start payload size/length validation +# --------------------------------------------------------------------------- + +def validate_task_start_payload(raw_body: bytes, inputs: Dict[str, Any]) -> Tuple[bool, int, str]: + """ + Enforce size and field-length limits on POST /task/start payloads. + + Checks are run in order: + 1. Total body size → HTTP 413 + 2. inputs dict type → HTTP 400 + 3. Per-field string length and array length → HTTP 400 + + Error messages never echo back input values to avoid leaking sensitive + or oversized data into logs/responses. + + Args: + raw_body: Raw request bytes (for total-size check). + inputs: The parsed ``inputs`` dict from the request body. + + Returns: + (ok, status_code, error_message) + ok is True and status_code is 0 when all checks pass. + """ + # 1. Total body size + if len(raw_body) > settings.task_start_max_body_bytes: + return ( + False, + 413, + f"Request body exceeds the maximum allowed size of " + f"{settings.task_start_max_body_bytes} bytes.", + ) + + # 2. inputs must be a dict + if not isinstance(inputs, dict): + return False, 400, "'inputs' must be a JSON object." + + # 3. Per-field checks + for key, value in inputs.items(): + ok, status, msg = _check_field(key, value) + if not ok: + return ok, status, msg + + return True, 0, "" + + +def _check_field(key: str, value: Any) -> Tuple[bool, int, str]: + """Check a single input field value (string or list).""" + if isinstance(value, str): + if len(value) > settings.task_start_max_field_length: + # Do NOT include the value itself — it may be huge or sensitive. + return ( + False, + 400, + f"Input field '{key}' exceeds the maximum allowed length of " + f"{settings.task_start_max_field_length} characters.", + ) + + elif isinstance(value, list): + if len(value) > settings.task_start_max_array_length: + return ( + False, + 400, + f"Input field '{key}' contains too many items " + f"(max {settings.task_start_max_array_length}).", + ) + for idx, item in enumerate(value): + if isinstance(item, str) and len(item) > settings.task_start_max_field_length: + return ( + False, + 400, + f"Item at index {idx} in input field '{key}' exceeds the " + f"maximum allowed length of " + f"{settings.task_start_max_field_length} characters.", + ) + + return True, 0, "" \ No newline at end of file diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 000000000..084f33676 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,43 @@ +# SecuScan API Documentation + +## Tasks API + +### List Tasks with Pagination + +**Endpoint:** `GET /api/v1/tasks` + +**Description:** Returns a paginated list of all scan tasks with navigation metadata. + +**Query Parameters:** + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| page | integer | No | 1 | Page number (1-indexed) | +| per_page | integer | No | 25 | Items per page (1-100) | +| plugin_id | string | No | null | Filter by plugin ID | +| status | string | No | null | Filter by status | + +**Response (200 OK):** + +```json +{ + "tasks": [...], + "pagination": { + "page": 1, + "per_page": 25, + "total_pages": 4, + "total_items": 87, + "next": "/api/v1/tasks?page=2&per_page=25", + "previous": null + } +} + + + +""" +# Basic pagination +curl "http://localhost:8000/api/v1/tasks?page=2&per_page=10" + +# With filters +curl "http://localhost:8000/api/v1/tasks?status=completed&plugin_id=nmap&page=1&per_page=20" +""" \ No newline at end of file diff --git a/docs/plugin-validation.md b/docs/plugin-validation.md new file mode 100644 index 000000000..49b8de384 --- /dev/null +++ b/docs/plugin-validation.md @@ -0,0 +1,153 @@ +# Plugin Field Validation + +This document describes the validation contract for plugin field metadata in SecuScan. + +Plugin authors define fields in their plugin's schema. Each field can have an optional `validation` object that controls how the frontend form validates user input before a scan is started. + +--- + +## Supported validation keys + +| Key | Type | Description | +|-------------------|----------|--------------------------------------------------------------| +| `pattern` | `string` | A regex string the trimmed value must match | +| `message` | `string` | Custom error message shown when validation fails | +| `min` | `number` | Minimum value (integer fields only) | +| `max` | `number` | Maximum value (integer fields only) | +| `validation_type` | `string` | Named preset — see table below. Takes priority over `pattern`| + +--- + +## Named `validation_type` presets + +Use these for common cases instead of writing your own regex: + +| `validation_type` | Accepts | Example | +|-------------------|------------------------------------------|-----------------------| +| `url` | HTTP or HTTPS URLs | `https://example.com` | +| `hostname` | Hostnames with optional subdomains | `sub.example.com` | +| `domain` | Domain names without a scheme | `example.com` | +| `ipv4` | IPv4 addresses (0–255 per octet) | `192.168.1.1` | +| `port` | Integer port numbers (1–65535) | `8080` | +| `cidr` | IPv4 CIDR notation | `192.168.1.0/24` | + +If both `validation_type` and `pattern` are set, `validation_type` takes priority. + +--- + +## Examples + +### URL field + +```json +{ + "id": "target_url", + "label": "Target URL", + "type": "string", + "required": true, + "placeholder": "https://example.com", + "help": "Full URL of the target including scheme.", + "validation": { + "validation_type": "url", + "message": "Enter a valid URL starting with http:// or https://" + } +} +``` + +### Hostname field + +```json +{ + "id": "target_host", + "label": "Target Hostname", + "type": "string", + "required": true, + "placeholder": "example.com", + "help": "Hostname or subdomain to scan. Do not include http://.", + "validation": { + "validation_type": "hostname" + } +} +``` + +### IPv4 field + +```json +{ + "id": "target_ip", + "label": "Target IP", + "type": "string", + "required": true, + "placeholder": "192.168.1.1", + "validation": { + "validation_type": "ipv4", + "message": "Enter a valid IPv4 address" + } +} +``` + +### Port field (integer with range) + +```json +{ + "id": "port", + "label": "Port", + "type": "integer", + "required": false, + "placeholder": "80", + "validation": { + "min": 1, + "max": 65535, + "message": "Port must be between 1 and 65535" + } +} +``` + +### CIDR block field + +```json +{ + "id": "subnet", + "label": "Target Subnet", + "type": "string", + "required": false, + "placeholder": "192.168.1.0/24", + "validation": { + "validation_type": "cidr" + } +} +``` + +### Custom regex (backwards compatible) + +Existing plugins using a raw `pattern` continue to work without changes: + +```json +{ + "id": "api_key", + "label": "API Key", + "type": "string", + "required": true, + "validation": { + "pattern": "^[A-Za-z0-9]{32,64}$", + "message": "API key must be 32–64 alphanumeric characters" + } +} +``` + +--- + +## Frontend behaviour + +- **Required fields**: show an error if the value is empty, null, or whitespace. +- **Pattern / validation_type**: checked on non-empty string values only — an empty optional field is never flagged. +- **Integer min/max**: checked when the field has type `integer` and a value has been entered. +- **aria-invalid**: set to `true` on the input element when a validation error is present. +- **Inline error message**: shown directly below the field with `role="alert"`. +- **Scan button**: disabled while any field has a validation error. + +--- + +## Backwards compatibility + +Plugins that already define `validation.pattern` (without `validation_type`) continue to work exactly as before. No migration is required. \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 000000000..2cc4c3e27 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,13 @@ +# Build stage +FROM node:20-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +# Production stage +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/README.md b/frontend/README.md index 4c45a9599..c8d19c520 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -39,30 +39,30 @@ npm run preview frontend/ ├── src/ │ ├── components/ # Reusable UI components -│ │ ├── Layout.jsx # Main app layout with sidebar -│ │ ├── ConsentModal.jsx # Consent confirmation dialog -│ │ ├── DynamicForm.jsx # Form generator from plugin schema -│ │ └── TaskCard.jsx # Task display card +│ │ ├── AppShell.tsx # Main app layout with sidebar +│ │ ├── ConsentModal.tsx # Consent confirmation dialog +│ │ ├── DynamicForm.tsx # Form generator from plugin schema +│ │ └── TaskCard.tsx # Task display card │ │ │ ├── pages/ # Route components -│ │ ├── Scanner.jsx # Main scanning interface -│ │ ├── TaskHistory.jsx # Task history list -│ │ ├── TaskDetails.jsx # Individual task view -│ │ └── Settings.jsx # Settings page +│ │ ├── Scanner.tsx # Main scanning interface +│ │ ├── Scans.tsx # Task history list +│ │ ├── TaskDetails.tsx # Individual task view +│ │ └── Settings.tsx # Settings page │ │ │ ├── context/ # React Context for state -│ │ └── AppContext.jsx # Global app state +│ │ └── AppContext.tsx # Global app state │ │ │ ├── services/ # API integration -│ │ └── api.js # Backend API client +│ │ └── api.ts # Backend API client │ │ -│ ├── App.jsx # Main app component with routing +│ ├── App.tsx # Main app component with routing │ ├── App.css # App-specific styles -│ ├── main.jsx # React entry point +│ ├── main.tsx # React entry point │ └── index.css # Global styles │ ├── index.html # HTML template -├── vite.config.js # Vite configuration +├── vite.config.ts # Vite configuration ├── package.json # Dependencies └── README.md # This file ``` @@ -213,7 +213,7 @@ Add new endpoints in `src/services/api.js`: ```javascript export const api = { // ... existing methods - + myNewEndpoint: (param) => request(`/my-endpoint/${param}`, { method: 'POST', body: JSON.stringify({ data: 'value' }) @@ -242,7 +242,7 @@ async function handleAction() { ### `` Main app layout with sidebar navigation. -**Props:** +**Props:** - `children` - Page content **Usage:** @@ -353,9 +353,9 @@ import { useApp } from '../context/AppContext' function MyComponent() { const { plugins, settings, loading } = useApp() - + if (loading) return
Loading...
- + return
{plugins.length} plugins
} ``` @@ -401,7 +401,24 @@ function MyComponent() { ``` --- +## Available Commands + +All commands must be run from the `frontend/` directory. + +| Command | Description | +|---|---| +| `npm run dev` | Start the Vite development server | +| `npm run build` | Compile TypeScript and build for production | +| `npm run preview` | Preview the production build locally (port 8080) | +| `npm run typecheck` | Run TypeScript type checking without emitting files | +| `npm run test` | Run unit tests with Vitest | +| `npm run test:watch` | Run unit tests in watch mode (re-runs on file changes) | +| `npm run quality` | Run the quality gate checks | +| `npm run quality:full` | Run quality checks, typecheck, and tests together | +| `npm run e2e` | Run end-to-end tests with Playwright | +| `npm run e2e:ui` | Run end-to-end tests with Playwright's interactive UI | +--- ## 🧪 Testing ### Manual Testing Checklist @@ -419,6 +436,85 @@ function MyComponent() { - [ ] Navigation works between all pages - [ ] Responsive design works on mobile + +--- +## ⚡ Frontend Checks Quickstart + +Run all frontend commands from the `frontend/` directory. + +### Install Dependencies + +```bash +cd frontend +npm install +``` + +### Run Unit Tests + +```bash +npm run test +``` + +### Run Tests in Watch Mode + +```bash +npm run test:watch +``` + +### Run Type Checking + +```bash +npm run typecheck +``` + +### Run Production Build + +```bash +npm run build +``` + +### Run Quality Checks + +```bash +npm run quality +``` + +### Run Full Quality Pipeline + +```bash +npm run quality:full +``` + +### Run End-to-End Tests + +```bash +npm run e2e +``` + +### Vitest Test File Locations + +Vitest unit tests are located in: + +```bash +frontend/testing/unit +``` + +Supported naming patterns include: + +- `*.test.js` +- `*.test.jsx` +- `*.spec.js` +- `*.spec.jsx` + +> Note for Windows users: +> Some npm scripts using `NODE_OPTIONS=...` may not run directly in PowerShell. + +Run tests manually using: + +```bash +npx vitest run +``` + ### Browser Support - Chrome/Edge 90+ @@ -465,38 +561,121 @@ server: { --- -## 📦 Deployment +## 🏭 Production Deployment -### Build for Production +### 1. Build the frontend ```bash -# Install dependencies -npm install - -# Create production build +cd frontend +npm ci npm run build ``` -Output in `dist/` directory. +Output lands in `frontend/dist/`. Verify locally before deploying: + +```bash +npm run preview +# Serves dist/ at http://127.0.0.1:8080 +``` + +--- + +### 2. Set the API base URL at build time + +The frontend resolves the backend URL via `src/api.ts::resolveApiBase()` in this priority order: + +| Priority | Mechanism | When to use | +|----------|-----------|-------------| +| 1 | `VITE_API_BASE` env var | Production — always set this | +| 2 | Window location heuristic | Dev server on a non-5173 port | +| 3 | Vite proxy (`/api` → backend) | Local dev on port 5173 (default) | + +**`VITE_API_BASE` must be set at build time**, not at runtime. Vite inlines `import.meta.env` values during the +build step — changing the env var after building has no effect. + +```bash +VITE_API_BASE=http://your-backend-host:8081/api/v1 npm run build +``` + +Or create `frontend/.env.production`: +VITE_API_BASE=http://your-backend-host:8081/api/v1 + +> ⚠️ Do not confuse `VITE_API_BASE` (frontend, build-time) with `VITE_API_PROXY_TARGET` (Vite dev server only — has no effect in a production build). + +--- + +### 3. Serve the built frontend + +> ⚠️ `backend/secuscan/main.py` currently imports `StaticFiles` but does not mount the `dist/` directory. The backend cannot serve the frontend +> yet. Use one of the options below until that is wired up. -### Serve with Backend +**Option A — nginx (recommended)** -Option 1: **Static File Serving** -```python -# In backend/main.py -from fastapi.staticfiles import StaticFiles +```nginx +server { + listen 80; + root /path/to/frontend/dist; + index index.html; + + # SPA fallback — required for React Router + location / { + try_files $uri $uri/ /index.html; + } -app.mount("/", StaticFiles(directory="frontend/dist", html=True), name="frontend") + # Proxy API calls to the backend + location /api/ { + proxy_pass http://127.0.0.1:8081; + proxy_set_header Host $host; + } +} ``` -Option 2: **Separate Web Server** +**Option B — quick local verification** + ```bash -# Serve frontend with nginx/caddy/apache -# Point backend API calls to backend server +npx serve dist --single +# --single enables the SPA fallback for React Router ``` --- +### 4. SPA route fallback — why it matters + +SecuScan uses React Router for client-side navigation. If a user visits `/task/abc123` directly or refreshes the page on any route, the web +server looks for a real file at that path, finds nothing, and returns a **404**. + +The fix is always the same: serve `index.html` for any path that does not match a real static file. The nginx `try_files` directive and +`serve --single` flag above both handle this. Without it, every direct link and browser refresh on a non-root route breaks. + +--- + +### 5. Docker Compose note + +The current `docker-compose.yml` runs the frontend service with `npm run dev` (Vite development server). This is intentional for +contributor workflows and is **not production-ready**. A multi-stage frontend Dockerfile is not yet present in the repository. + +--- + +### Troubleshooting + +**404 on page refresh or direct URL** +The SPA fallback is missing. Add `try_files $uri /index.html` to your nginx config or use `npx serve dist --single`. + +**All API calls fail after deploying** +`VITE_API_BASE` was not set at build time. Rebuild with the correct value: +```bash +VITE_API_BASE=http://your-backend:8081/api/v1 npm run build +``` + +**`VITE_API_PROXY_TARGET` has no effect in production** +This variable only configures the Vite dev server proxy. It is completely ignored in the production build. + +**CORS errors in the browser console** +Add your frontend's origin to the backend config in `.env`: +SECUSCAN_CORS_ALLOWED_ORIGINS=http://your-frontend-host + +--- + ## 🔐 Security Considerations 1. **API Proxy:** Vite dev server proxies `/api` to backend @@ -536,5 +715,5 @@ For issues or questions: --- -**Last Updated:** October 29, 2025 +**Last Updated:** October 29, 2025 **Version:** 0.1.0-alpha diff --git a/frontend/e2e/scan-workflow.spec.ts b/frontend/e2e/scan-workflow.spec.ts new file mode 100644 index 000000000..a9f6c8188 --- /dev/null +++ b/frontend/e2e/scan-workflow.spec.ts @@ -0,0 +1,208 @@ +import { test, expect } from "@playwright/test"; + +const BASE = "http://127.0.0.1:5173"; + +const MOCK_PLUGINS_RESPONSE = { + plugins: [{ + id: "dns_recon", name: "DNS Recon", + description: "Perform DNS reconnaissance on a target domain.", + category: "recon", safety_level: "passive", enabled: true, icon: "dns", + requires_consent: false, consent_message: null, + availability: { runnable: true, missing_binaries: [], status: "ok", guidance: null }, + }], + total: 1, +}; + +const MOCK_PLUGIN_SCHEMA = { + id: "dns_recon", name: "DNS Recon", + description: "Perform DNS reconnaissance on a target domain.", + fields: [{ + id: "target", label: "Target Domain", type: "string", + required: true, placeholder: "example.com", help: "The domain to scan.", + }], + presets: { quick: { target: "" } }, + safety: { level: "passive" }, +}; + +const MOCK_START_TASK_RESPONSE = { + task_id: "abcd1234-0000-0000-0000-000000000001", status: "queued", + created_at: new Date().toISOString(), + stream_url: `${BASE}/api/v1/task/abcd1234-0000-0000-0000-000000000001/stream`, +}; + +const MOCK_TASK_STATUS = { + task_id: "abcd1234-0000-0000-0000-000000000001", plugin_id: "dns_recon", + tool: "DNS Recon", target: "example.com", status: "completed", + created_at: new Date().toISOString(), started_at: new Date().toISOString(), + completed_at: new Date().toISOString(), duration_seconds: 5, + exit_code: 0, error_message: null, + inputs: { target: "example.com" }, preset: "quick", +}; + +const MOCK_TASK_RESULT = { + task_id: "abcd1234-0000-0000-0000-000000000001", plugin_id: "dns_recon", + tool: "DNS Recon", target: "example.com", + timestamp: new Date().toISOString(), duration_seconds: 5, status: "completed", + summary: ["DNS scan completed for example.com.", "2 findings identified."], + severity_counts: { info: 2 }, + findings: [ + { title: "A Record Found", category: "dns", severity: "info", target: "example.com", description: "The domain resolves to 93.184.216.34." }, + { title: "MX Record Found", category: "dns", severity: "info", target: "example.com", description: "Mail exchanger record detected." }, + ], + structured: { + total_count: 2, + findings: [ + { title: "A Record Found", category: "dns", severity: "info", target: "example.com", description: "The domain resolves to 93.184.216.34." }, + { title: "MX Record Found", category: "dns", severity: "info", target: "example.com", description: "Mail exchanger record detected." }, + ], + }, + raw_output: "DNS recon complete.\nFound A record: 93.184.216.34\nFound MX record.", + command_used: "dnsrecon -d example.com", + errors: [], +}; + +async function setupMocks(page) { + await page.route(`${BASE}/api/v1/health`, (route) => + route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ status: "ok" }) }) + ); + await page.route(`${BASE}/api/v1/dashboard/summary`, (route) => + route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({}) }) + ); + await page.route(`${BASE}/api/v1/plugins`, (route) => + route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(MOCK_PLUGINS_RESPONSE) }) + ); + await page.route(`${BASE}/api/v1/plugin/dns_recon/schema`, (route) => + route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(MOCK_PLUGIN_SCHEMA) }) + ); + await page.route(`${BASE}/api/v1/task/start`, (route) => + route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(MOCK_START_TASK_RESPONSE) }) + ); + await page.route(`${BASE}/api/v1/task/abcd1234-0000-0000-0000-000000000001/status`, (route) => + route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(MOCK_TASK_STATUS) }) + ); + await page.route(`${BASE}/api/v1/task/abcd1234-0000-0000-0000-000000000001/result`, (route) => + route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(MOCK_TASK_RESULT) }) + ); + await page.route(`${BASE}/api/v1/task/abcd1234-0000-0000-0000-000000000001/stream`, (route) => + route.fulfill({ status: 200, headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" }, body: "" }) + ); +} + +test.describe("Full scan workflow", () => { + test("Step 1 - Open scanner catalog and see tool cards", async ({ page }) => { + await setupMocks(page); + await page.goto("/toolkit"); + await expect(page.getByRole("heading", { name: /tactical/i })).toBeVisible({ timeout: 10000 }); + await page.getByRole("button", { name: /recon tools/i }).click(); + await expect(page.getByRole("button", { name: /dns recon/i })).toBeVisible({ timeout: 10000 }); + }); + + test("Step 2 - Select a scanner and see its config page", async ({ page }) => { + await setupMocks(page); + await page.goto("/toolkit"); + await page.getByRole("button", { name: /recon tools/i }).click(); + await page.getByRole("button", { name: /dns recon/i }).click(); + await expect(page).toHaveURL(/\/toolkit\/dns_recon/); + await expect(page.getByRole("heading", { name: /dns recon/i })).toBeVisible(); + }); + + test("Step 3 - Fill dynamic inputs on config page", async ({ page }) => { + await setupMocks(page); + await page.goto("/toolkit/dns_recon"); + const targetInput = page.getByPlaceholder("example.com"); + await expect(targetInput).toBeVisible(); + await targetInput.fill("example.com"); + await expect(targetInput).toHaveValue("example.com"); + }); + + test("Step 4 - No consent required INITIATE_SCAN button is enabled", async ({ page }) => { + await setupMocks(page); + await page.goto("/toolkit/dns_recon"); + await expect(page.getByRole("checkbox")).not.toBeVisible(); + const startButton = page.getByRole("button", { name: /initiate_scan/i }); + await expect(startButton).toBeVisible(); + await expect(startButton).not.toBeDisabled(); + }); + + test("Step 5 - Queue the scan and navigate to task details", async ({ page }) => { + await setupMocks(page); + await page.goto("/toolkit/dns_recon"); + await page.getByPlaceholder("example.com").fill("example.com"); + await page.getByRole("button", { name: /initiate_scan/i }).click(); + await expect(page).toHaveURL(/\/task\/abcd1234/); + }); + + test("Step 6 - Task details page shows status and target", async ({ page }) => { + await setupMocks(page); + await page.goto("/task/abcd1234-0000-0000-0000-000000000001"); + await expect(page.getByText(/completed/i).first()).toBeVisible(); + await expect(page.getByText("example.com").first()).toBeVisible(); + }); + + test("Step 7 - Report export actions are available after completion", async ({ page }) => { + await setupMocks(page); + await page.goto("/task/abcd1234-0000-0000-0000-000000000001"); + await expect(page.getByRole("button", { name: /html_export/i })).toBeVisible(); + await expect(page.getByRole("button", { name: /csv_export/i })).toBeVisible(); + await expect(page.getByRole("button", { name: /pdf_report/i })).toBeVisible(); + }); + + test("Full journey - end-to-end from catalog to report export", async ({ page }) => { + await setupMocks(page); + await page.goto("/toolkit"); + await expect(page.getByRole("heading", { name: /tactical/i })).toBeVisible({ timeout: 10000 }); + await page.getByRole("button", { name: /recon tools/i }).click(); + await page.getByRole("button", { name: /dns recon/i }).click(); + await expect(page).toHaveURL(/\/toolkit\/dns_recon/); + await page.getByPlaceholder("example.com").fill("example.com"); + await page.getByRole("button", { name: /initiate_scan/i }).click(); + await expect(page).toHaveURL(/\/task\/abcd1234/); + await expect(page.getByText(/completed/i).first()).toBeVisible(); + await expect(page.getByRole("button", { name: /pdf_report/i })).toBeVisible(); + }); +}); + +test.describe("Scan workflow - consent required", () => { + test("Consent checkbox is shown and blocks scan until checked", async ({ page }) => { + await page.route(`${BASE}/api/v1/health`, (route) => + route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ status: "ok" }) }) + ); + await page.route(`${BASE}/api/v1/dashboard/summary`, (route) => + route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({}) }) + ); + await page.route(`${BASE}/api/v1/plugins`, (route) => + route.fulfill({ + status: 200, contentType: "application/json", + body: JSON.stringify({ + plugins: [{ + id: "port_scanner", name: "Port Scanner", + description: "Scan open ports.", category: "recon", + safety_level: "intrusive", enabled: true, icon: "radar", + requires_consent: true, + consent_message: "You must have explicit authorization to scan this target.", + availability: { runnable: true, missing_binaries: [], status: "ok", guidance: null }, + }], + total: 1, + }), + }) + ); + await page.route(`${BASE}/api/v1/plugin/port_scanner/schema`, (route) => + route.fulfill({ + status: 200, contentType: "application/json", + body: JSON.stringify({ + id: "port_scanner", name: "Port Scanner", description: "Scan open ports.", + fields: [{ id: "target", label: "Target Host", type: "string", required: true, placeholder: "192.168.1.1", help: "IP or hostname." }], + presets: {}, safety: { level: "intrusive" }, + }), + }) + ); + await page.goto("/toolkit/port_scanner"); + const consentCheckbox = page.getByRole("checkbox"); + await expect(consentCheckbox).toBeVisible(); + await expect(consentCheckbox).not.toBeChecked(); + await page.getByPlaceholder("192.168.1.1").fill("192.168.1.1"); + await consentCheckbox.check(); + await expect(consentCheckbox).toBeChecked(); + await expect(page.getByRole("button", { name: /initiate_scan/i })).not.toBeDisabled(); + }); +}); \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1ffe367b5..6267a517b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -32,6 +32,7 @@ "@types/react-dom": "18.3.0", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.27", + "cross-env": "^10.1.0", "jsdom": "^29.0.1", "postcss": "^8.5.8", "tailwindcss": "^3.4.19", @@ -570,6 +571,13 @@ "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", "license": "MIT" }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -2322,6 +2330,39 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/css-color-keywords": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", @@ -3032,6 +3073,13 @@ "dev": true, "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -3370,6 +3418,16 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -4048,6 +4106,29 @@ "semver": "bin/semver.js" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -5857,6 +5938,22 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 777536518..8bf47eec9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,8 +8,8 @@ "build": "vite build", "preview": "vite preview --host 127.0.0.1 --port 8080", "typecheck": "tsc --noEmit", - "test": "NODE_OPTIONS=--max-old-space-size=8192 vitest run", - "test:watch": "NODE_OPTIONS=--max-old-space-size=8192 vitest", + "test": "cross-env NODE_OPTIONS=--max-old-space-size=8192 vitest run", + "test:watch": "cross-env NODE_OPTIONS=--max-old-space-size=8192 vitest", "quality": "node quality-gate.cjs", "quality:full": "npm run quality && npm run typecheck && npm run test", "e2e": "playwright test", @@ -40,6 +40,7 @@ "@types/react-dom": "18.3.0", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.27", + "cross-env": "^10.1.0", "jsdom": "^29.0.1", "postcss": "^8.5.8", "tailwindcss": "^3.4.19", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 98fb22c51..fa2a00cee 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,11 +9,13 @@ import Reports from './pages/Reports' import Settings from './pages/Settings' import Scans from './pages/Scans' import TaskDetails from './pages/TaskDetails' +import Workflows from './pages/Workflows' import { ThemeProvider } from './components/ThemeContext' import { ToastProvider, ToastContainer } from './components/ToastContext' import { I18nProvider } from './components/I18nContext' import { routes } from './routes' +import ReportComparison from './pages/ReportComparison' export function AppRoutes() { return ( @@ -24,8 +26,10 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> } /> + } /> } /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 37942a585..7c7fc0e08 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -179,3 +179,65 @@ export function streamTask(taskId: string, onEvent: (ev: MessageEvent) => void) es.onerror = () => {} return es } +export interface WorkflowStep { + plugin_id: string + inputs: Record +} + +export interface Workflow { + id: string + name: string + schedule_interval: string + enabled: boolean + steps: WorkflowStep[] + last_run_at?: string | null + queued_task_ids?: string[] + created_at?: string +} + +export interface WorkflowCreatePayload { + name: string + schedule_interval: string + enabled: boolean + steps: WorkflowStep[] +} + +export interface WorkflowUpdatePayload { + name?: string + schedule_interval?: string + enabled?: boolean + steps?: WorkflowStep[] +} + +export function getWorkflows(): Promise { + return request('/workflows') +} + +export function createWorkflow(data: WorkflowCreatePayload): Promise { + return request('/workflows', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) +} + +export function runWorkflow(workflowId: string): Promise<{ queued_task_ids: string[] }> { + return request<{ queued_task_ids: string[] }>(`/workflows/${workflowId}/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) +} + +export function updateWorkflow(workflowId: string, data: WorkflowUpdatePayload): Promise { + return request(`/workflows/${workflowId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) +} + +export function deleteWorkflow(workflowId: string): Promise<{ deleted: boolean }> { + return request<{ deleted: boolean }>(`/workflows/${workflowId}`, { + method: 'DELETE', + }) +} \ No newline at end of file diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index f7447262b..1c73e91bb 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -44,6 +44,7 @@ export default function AppShell({ children }: AppShellProps) { { to: routes.scans, icon: 'history', label: 'Scans' }, { to: routes.findings, icon: 'emergency_home', label: 'Findings' }, { to: routes.reports, icon: 'summarize', label: 'Reports' }, + { to: routes.workflows, icon: 'account_tree', label: 'Workflows' }, { to: routes.toolkit, icon: 'add_circle', label: 'Toolkit' }, ] const mobileDrawerNav = [ @@ -51,6 +52,7 @@ export default function AppShell({ children }: AppShellProps) { { to: routes.scans, label: 'Scans' }, { to: routes.findings, label: 'Findings' }, { to: routes.reports, label: 'Reports' }, + { to: routes.workflows, label: 'Workflows' }, { to: routes.toolkit, label: 'Toolkit' }, { to: routes.settings, label: 'Settings' }, ] diff --git a/frontend/src/components/Pagination.tsx b/frontend/src/components/Pagination.tsx new file mode 100644 index 000000000..5f7728b4e --- /dev/null +++ b/frontend/src/components/Pagination.tsx @@ -0,0 +1,61 @@ +import React from "react"; + +interface PaginationProps { + page: number; + total: number; + limit: number; + loading: boolean; + onPrev: () => void; + onNext: () => void; +} + +export default function Pagination({ + page, + total, + limit, + loading, + onPrev, + onNext, +}: PaginationProps) { + const start = total === 0 ? 0 : (page - 1) * limit + 1; + const end = Math.min(page * limit, total); + const isFirst = page === 1; + const isLast = end >= total; + + return ( +
+

+ Showing_Records:{" "} + + {start}–{end} + {" "} + // Total: {total} +

+
+ +
+ + {page} + +
+ +
+
+ ); +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index e796d5a8b..8853ca4f9 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -15,6 +15,7 @@ const NavItem = ({ to, icon, label, isExpanded, highlight = false }: NavItemProp return ( e.stopPropagation()} className={({ isActive }) => ` relative flex items-center transition-all duration-300 group @@ -165,6 +166,7 @@ export default function Sidebar() { + diff --git a/frontend/src/components/ToastContext.tsx b/frontend/src/components/ToastContext.tsx index e6bec900a..dee47c0f4 100644 --- a/frontend/src/components/ToastContext.tsx +++ b/frontend/src/components/ToastContext.tsx @@ -50,22 +50,27 @@ export function ToastContainer() { const { toasts, removeToast } = useToast() return ( -
+
{toasts.map((toast) => ( removeToast(toast.id)} > - + {toast.message}
- ))} diff --git a/frontend/src/hooks/usePreferredExportFormat.ts b/frontend/src/hooks/usePreferredExportFormat.ts new file mode 100644 index 000000000..e4584ead7 --- /dev/null +++ b/frontend/src/hooks/usePreferredExportFormat.ts @@ -0,0 +1,16 @@ +import { useState } from 'react' + +const STORAGE_KEY = 'secuscan:preferred-export-format' + +export function usePreferredExportFormat() { + const [preferred, setPreferred] = useState( + () => localStorage.getItem(STORAGE_KEY) + ) + + function savePreference(format: string) { + localStorage.setItem(STORAGE_KEY, format) + setPreferred(format) + } + + return { preferred, savePreference } +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index b6b01a10f..52a26fd65 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -4,7 +4,7 @@ import { motion, AnimatePresence } from 'framer-motion' import { getDashboardSummary, getHealth, cancelTask } from '../api' import { ExecutiveStatsBar } from '../components/ExecutiveStatsBar' import { routePath, routes } from '../routes' -import { parseDateSafe, formatBriefingDate, formatTaskInit, formatLocaleDate } from '../utils/date' +import { formatBriefingDate, formatTaskInit, formatLocaleDate, formatLocaleTime } from '../utils/date' type Finding = { id: string @@ -180,9 +180,15 @@ export default function Dashboard() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [backendConnected, setBackendConnected] = useState(null) - const [lastSync, setLastSync] = useState(() => new Date().toISOString()) + const [lastSync, setLastSync] = useState(null) const navigate = useNavigate() + const applySummary = (data: Partial) => { + setSummary(normalizeSummary(data)) + setLastSync(new Date().toISOString()) + setError(null) + } + useEffect(() => { let cancelled = false @@ -202,9 +208,7 @@ export default function Dashboard() { getDashboardSummary() .then((data) => { if (cancelled) return - setSummary(normalizeSummary(data as Partial)) - setLastSync(new Date().toISOString()) - setError(null) + applySummary(data as Partial) }) .catch((err) => { if (cancelled) return @@ -229,7 +233,7 @@ export default function Dashboard() { await cancelTask(taskId) // Refresh summary immediately const data = await getDashboardSummary() as Summary - setSummary(normalizeSummary(data)) + applySummary(data) } catch (err) { console.error('Failed to abort task:', err) } @@ -237,11 +241,11 @@ export default function Dashboard() { const risk = getRiskProfile(summary) const criticalHigh = summary.critical_findings + summary.high_findings - + const progressWidth = summary.scan_activity.total > 0 ? Math.max(8, Math.min(100, (summary.scan_activity.completed / summary.scan_activity.total) * 100)) : 0 - + const statusBadgeClasses = backendConnected === null ? 'border-accent-silver/10 bg-silver/5 text-silver-bright' : backendConnected @@ -279,29 +283,38 @@ export default function Dashboard() { transition={{ delay: 0.2, duration: 0.8, ease: [0.19, 1, 0.22, 1] }} className="flex flex-col md:flex-row items-start md:items-center gap-8 md:gap-10" > - {/* Integrity Metric - High Visibility */} -
-
- + {/* Integrity Metric - Live Status Panel */} +
+ {/* Subtle top glow */} +
+ +
+ SYSTEM_STATUS_SYNC -
-
+
+
- {(formatBriefingDate(lastSync).split(',')[0]?.trim().toUpperCase()) || 'INITIALIZING'} + {lastSync ? (formatBriefingDate(lastSync).split(',')[0]?.trim().toUpperCase()) : 'INITIALIZING'} - - {(formatBriefingDate(lastSync).split(',')[1]?.trim().toUpperCase()) || '---'} + + {lastSync ? (formatBriefingDate(lastSync).split(',')[1]?.trim().toUpperCase()) : '---'}
-
+
- {(formatBriefingDate(lastSync).split(',')[2]?.trim().toUpperCase()) || '00:00'} + {lastSync ? (formatBriefingDate(lastSync).split(',')[2]?.trim().toUpperCase()) : '00:00'}
+ {lastSync ? ( +

+ Last updated: +

+ ) : null}
-
- terminal + +
+ terminal
@@ -578,7 +591,7 @@ export default function Dashboard() { Recent Audit Findings - +
{summary.recent_findings.length === 0 ? (
diff --git a/frontend/src/pages/Findings.tsx b/frontend/src/pages/Findings.tsx index 74e7e8112..cfc788245 100644 --- a/frontend/src/pages/Findings.tsx +++ b/frontend/src/pages/Findings.tsx @@ -1,8 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react' import { motion } from 'framer-motion' import { getFindings } from '../api' -import { formatLocaleDate } from '../utils/date' - +import { formatLocaleDate, parseDateSafe, getCurrentTimeZone } from '../utils/date' type Finding = { id: string severity: string @@ -14,6 +13,7 @@ type Finding = { discovered_at: string cvss?: number cve?: string + plugin_id?: string } type FindingStatus = 'new' | 'reviewed' | 'suppressed' @@ -84,11 +84,22 @@ function filterPillClasses(isActive: boolean) { : 'border-silver-bright/10 bg-charcoal-dark text-silver/65 hover:border-silver-bright/30 hover:text-silver-bright' } +const filterLabelClass = 'text-[10px] font-black uppercase tracking-[0.2em] text-silver-bright' +const filterControlClass = + 'h-11 w-full border-2 border-silver-bright/10 bg-charcoal-dark px-3 text-xs font-mono text-silver-bright focus:border-rag-red focus:outline-none' + +type SortMode = 'severity' | 'newest' | 'oldest' | 'target' + export default function Findings() { const [findings, setFindings] = useState([]) const [loading, setLoading] = useState(true) const [searchQuery, setSearchQuery] = useState('') const [filterSeverity, setFilterSeverity] = useState('all') + const [filterTarget, setFilterTarget] = useState('all') + const [filterScanner, setFilterScanner] = useState('all') + const [sortMode, setSortMode] = useState('severity') + const [dateFrom, setDateFrom] = useState('') + const [dateTo, setDateTo] = useState('') const [selectedFindingId, setSelectedFindingId] = useState(null) const [reviewState, setReviewState] = useState({}) const [copiedFindingId, setCopiedFindingId] = useState(null) @@ -129,11 +140,48 @@ export default function Findings() { [findings, reviewState], ) + // Collect unique targets and categories so we can build filter dropdowns. + const uniqueTargets = useMemo(() => { + const seen = new Set() + for (const f of enrichedFindings) { + if (f.target) seen.add(f.target) + } + return Array.from(seen).sort() + }, [enrichedFindings]) + + // plugin_id values serve as the "scanner/tool" filter per issue #43 + const uniqueScanners = useMemo(() => { + const seen = new Set() + for (const f of enrichedFindings) { + if (f.plugin_id) seen.add(f.plugin_id) + } + return Array.from(seen).sort() + }, [enrichedFindings]) + const filteredFindings = useMemo(() => { const query = searchQuery.trim().toLowerCase() + // Compare dates using the *displayed* calendar day in the user's configured + // timezone, not raw UTC timestamps. This way a finding at 2026-05-13T20:00:00Z + // that shows as May 14 in IST correctly matches a From Date of 2026-05-14. + const tz = getCurrentTimeZone() + const dateFormatter = new Intl.DateTimeFormat('en-CA', { timeZone: tz }) + return enrichedFindings.filter((finding) => { const matchesSeverity = filterSeverity === 'all' || finding.severity === filterSeverity + const matchesTarget = filterTarget === 'all' || finding.target === filterTarget + const matchesScanner = filterScanner === 'all' || finding.plugin_id === filterScanner + + // Date range check — derive the calendar day in the display timezone + if (dateFrom || dateTo) { + const parsed = parseDateSafe(finding.discovered_at) + if (!parsed) return false + // en-CA locale gives us YYYY-MM-DD which matches the value + const displayDay = dateFormatter.format(parsed) + if (dateFrom && displayDay < dateFrom) return false + if (dateTo && displayDay > dateTo) return false + } + const haystack = [ finding.title, finding.target, @@ -146,17 +194,43 @@ export default function Findings() { .join(' ') .toLowerCase() - return matchesSeverity && haystack.includes(query) + return matchesSeverity && matchesTarget && matchesScanner && haystack.includes(query) }) - }, [enrichedFindings, filterSeverity, searchQuery]) + }, [enrichedFindings, filterSeverity, filterTarget, filterScanner, searchQuery, dateFrom, dateTo]) + + const sortedFindings = useMemo(() => { + const items = [...filteredFindings] + switch (sortMode) { + case 'newest': + return items.sort((a, b) => { + const da = parseDateSafe(a.discovered_at)?.getTime() ?? 0 + const db = parseDateSafe(b.discovered_at)?.getTime() ?? 0 + return db - da + }) + case 'oldest': + return items.sort((a, b) => { + const da = parseDateSafe(a.discovered_at)?.getTime() ?? 0 + const db = parseDateSafe(b.discovered_at)?.getTime() ?? 0 + return da - db + }) + case 'target': + return items.sort((a, b) => + (a.target || '').localeCompare(b.target || '') + ) + case 'severity': + default: + // Keep the original severity-group ordering; groupedFindings handles it. + return items + } + }, [filteredFindings, sortMode]) const groupedFindings = useMemo( () => severityOrder.map((severity) => ({ severity, - items: filteredFindings.filter((finding) => finding.severity === severity), + items: sortedFindings.filter((finding) => finding.severity === severity), })), - [filteredFindings], + [sortedFindings], ) const selectedFinding = @@ -192,6 +266,29 @@ export default function Findings() { [enrichedFindings, filteredFindings, countsBySeverity], ) + // Derives a flat list of active filter chips from non-default filter state. + const activeFilters = useMemo(() => { + const chips: { key: string; label: string }[] = [] + if (searchQuery.trim()) chips.push({ key: 'search', label: `Search: "${searchQuery.trim()}"` }) + if (filterTarget !== 'all') chips.push({ key: 'target', label: `Target: ${filterTarget}` }) + if (filterScanner !== 'all') chips.push({ key: 'scanner', label: `Scanner: ${filterScanner}` }) + if (sortMode !== 'severity') chips.push({ key: 'sort', label: `Sort: ${sortMode}` }) + if (dateFrom) chips.push({ key: 'from', label: `From: ${dateFrom}` }) + if (dateTo) chips.push({ key: 'to', label: `To: ${dateTo}` }) + return chips + }, [searchQuery, filterTarget, filterScanner, sortMode, dateFrom, dateTo]) + + + function resetAllFilters() { + setFilterSeverity('all') + setFilterTarget('all') + setFilterScanner('all') + setSortMode('severity') + setDateFrom('') + setDateTo('') + setSearchQuery('') + } + function updateFindingStatus(id: string, status: FindingStatus) { setReviewState((current) => ({ ...current, [id]: status })) } @@ -219,6 +316,70 @@ export default function Findings() { } } + function renderFindingRow(finding: Finding & { severity: string; status: FindingStatus }) { + const isSelected = selectedFinding?.id === finding.id + const cfg = severityConfig[finding.severity] + + return ( + + ) + } + return (
@@ -255,63 +416,162 @@ export default function Findings() {
-
-
-
-
- - setSearchQuery(event.target.value)} - placeholder="Title, target, CVE, remediation..." - className="w-full border-2 border-silver-bright/10 bg-charcoal-dark px-4 py-3 text-xs font-mono text-silver-bright placeholder:text-silver/20 focus:border-rag-red focus:outline-none" - /> -
- -
- -
+
+
+
+
+ + +
+ setSearchQuery(event.target.value)} + placeholder="Title, target, CVE, remediation..." + className={`${filterControlClass} px-4 pr-12 placeholder:text-silver/20`} + /> + + {searchQuery.trim() && ( + + )} +
+
+ +
+ + {severityOrder.map((severity) => ( - {['critical', 'high', 'medium'].map((severity) => ( - - ))} -
+ ))}
-
- {severityOrder.map((severity) => ( - - ))} +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + setDateFrom(e.target.value)} + className={`${filterControlClass} [color-scheme:dark]`} + /> +
+ +
+ + setDateTo(e.target.value)} + className={`${filterControlClass} [color-scheme:dark]`} + /> +
+
+ +
+ {/* ── Active filter summary strip ──────────────────────────────────────── + Hidden when all filters are at their default values. */} + {activeFilters.length > 0 && ( +
+ + Active Filters + + {activeFilters.map(({ key, label }) => ( + + {label} + + ))} +
+ )} +
{loading ? ( @@ -323,7 +583,7 @@ export default function Findings() {

No Findings Match

Adjust filters to reopen the queue.

- ) : ( + ) : sortMode === 'severity' ? ( groupedFindings.map(({ severity, items }) => { if (items.length === 0) return null @@ -342,73 +602,28 @@ export default function Findings() {
- {items.map((finding) => { - const isSelected = selectedFinding?.id === finding.id - const config = severityConfig[finding.severity] - - return ( - - ) - })} + {items.map((finding) => renderFindingRow(finding))}
) }) + ) : ( +
+
+
+ +
+

+ {sortMode === 'newest' ? 'Newest First' : sortMode === 'oldest' ? 'Oldest First' : 'By Target'} +

+

{sortedFindings.length} visible in queue

+
+
+
+
+ {sortedFindings.map((finding) => renderFindingRow(finding))} +
+
)} diff --git a/frontend/src/pages/ReportComparison.tsx b/frontend/src/pages/ReportComparison.tsx new file mode 100644 index 000000000..180c1b3ee --- /dev/null +++ b/frontend/src/pages/ReportComparison.tsx @@ -0,0 +1,432 @@ +import { useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { HugeiconsIcon } from '@hugeicons/react' +import { + Analytics02Icon, + ArrowRight01Icon, + Cancel01Icon, + CheckmarkCircle01Icon, + GitCompareIcon, + Radar02Icon, + Refresh01Icon, + ShieldCheckIcon, + WarningDiamondIcon, +} from '@hugeicons/core-free-icons' + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface Finding { + id: string + title: string + severity?: 'critical' | 'high' | 'medium' | 'low' | 'info' + description?: string +} + +export interface ScanReport { + id: string + name: string + generated_at: string + findings: Finding[] +} + +export interface ComparisonResult { + newFindings: Finding[] + fixedFindings: Finding[] + unchangedFindings: Finding[] + severityChanges: Array<{ + finding: Finding + oldSeverity?: string + newSeverity?: string + }> +} + +// ─── Comparison logic ───────────────────────────────────────────────────────── +/** + * Compares two scan reports deterministically using finding `id` as the key. + * - New → in reportB but not reportA + * - Fixed → in reportA but not reportB + * - Unchanged → in both with the same severity + * - Severity change → in both but severity differs + */ +export function compareReports( + reportA: ScanReport, + reportB: ScanReport, +): ComparisonResult { + const mapA = new Map(reportA.findings.map((f) => [f.id, f])) + const mapB = new Map(reportB.findings.map((f) => [f.id, f])) + + const newFindings: Finding[] = [] + const fixedFindings: Finding[] = [] + const unchangedFindings: Finding[] = [] + const severityChanges: ComparisonResult['severityChanges'] = [] + + for (const [id, finding] of mapB) { + if (!mapA.has(id)) { + newFindings.push(finding) + } else { + const old = mapA.get(id)! + if (old.severity !== finding.severity) { + severityChanges.push({ finding, oldSeverity: old.severity, newSeverity: finding.severity }) + } else { + unchangedFindings.push(finding) + } + } + } + + for (const [id, finding] of mapA) { + if (!mapB.has(id)) fixedFindings.push(finding) + } + + return { newFindings, fixedFindings, unchangedFindings, severityChanges } +} + +// ─── Mock data (swap for real API call later) ───────────────────────────────── + +const MOCK_REPORTS: ScanReport[] = [ + { + id: 'r1', + name: 'Scan_Alpha — Jan 2025', + generated_at: '2025-01-15T10:00:00Z', + findings: [ + { id: 'f1', title: 'SQL Injection in /login', severity: 'critical', description: 'Unsanitised user input passed directly to query.' }, + { id: 'f2', title: 'Outdated TLS 1.0 Accepted', severity: 'medium', description: 'Server still accepts TLS 1.0 connections.' }, + { id: 'f3', title: 'Missing CSP Header', severity: 'low', description: 'No Content-Security-Policy header returned.' }, + ], + }, + { + id: 'r2', + name: 'Scan_Beta — Feb 2025', + generated_at: '2025-02-20T10:00:00Z', + findings: [ + { id: 'f2', title: 'Outdated TLS 1.0 Accepted', severity: 'high', description: 'Severity escalated after re-assessment.' }, + { id: 'f3', title: 'Missing CSP Header', severity: 'low', description: 'Still not remediated.' }, + { id: 'f4', title: 'Open Redirect on /logout', severity: 'medium', description: 'Redirect destination unvalidated.' }, + ], + }, + { + id: 'r3', + name: 'Scan_Gamma — Mar 2025', + generated_at: '2025-03-10T10:00:00Z', + findings: [ + { id: 'f3', title: 'Missing CSP Header', severity: 'low', description: 'Still present.' }, + { id: 'f5', title: 'Reflected XSS in Search', severity: 'high', description: 'New reflected XSS detected in search input.' }, + ], + }, +] + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function Icon({ icon, size = 20, className = '' }: { icon: any; size?: number; className?: string }) { + return +} + +const severityStyles: Record = { + critical: 'bg-rag-red text-black', + high: 'bg-orange-500 text-black', + medium: 'bg-rag-amber text-black', + low: 'bg-rag-green text-black', + info: 'bg-rag-blue text-black', + unknown: 'bg-silver/20 text-black', +} + +function SeverityBadge({ severity }: { severity?: string }) { + const key = severity ?? 'unknown' + return ( + + {key} + + ) +} + +const containerVariants = { + hidden: { opacity: 0 }, + visible: { opacity: 1, transition: { staggerChildren: 0.06 } }, +} + +const itemVariants = { + hidden: { opacity: 0, y: 16 }, + visible: { opacity: 1, y: 0, transition: { duration: 0.35 } }, +} + +// ─── Finding row ────────────────────────────────────────────────────────────── + +function FindingRow({ finding }: { finding: Finding }) { + return ( + +
+ + {finding.title} + + +
+ {finding.description && ( +

+ {finding.description} +

+ )} +
+ ) +} + +// ─── Comparison section ─────────────────────────────────────────────────────── + +function ComparisonSection({ + title, + icon, + findings, + accentClass, + emptyMsg, +}: { + title: string + icon: any + findings: Finding[] + accentClass: string + emptyMsg: string +}) { + return ( +
+
+ +

+ {title} +

+ + {findings.length} + +
+ {findings.length === 0 ? ( +

+ {emptyMsg} +

+ ) : ( + + {findings.map((f) => )} + + )} +
+ ) +} + +// ─── Main page ──────────────────────────────────────────────────────────────── + +export default function ReportComparison() { + const reports = MOCK_REPORTS // TODO: replace with getReports() API call + + const [baseId, setBaseId] = useState('') + const [newerId, setNewerId] = useState('') + const [result, setResult] = useState(null) + const [error, setError] = useState('') + + function handleCompare() { + setError('') + setResult(null) + if (!baseId || !newerId) { setError('Select both reports before comparing.'); return } + if (baseId === newerId) { setError('Select two different reports.'); return } + const rA = reports.find((r) => r.id === baseId) + const rB = reports.find((r) => r.id === newerId) + if (!rA || !rB) { setError('Could not load one or both reports.'); return } + setResult(compareReports(rA, rB)) + } + + const selectedA = reports.find((r) => r.id === baseId) + const selectedB = reports.find((r) => r.id === newerId) + + const selectClass = + 'w-full bg-charcoal-dark border-4 border-black px-4 py-3 text-[11px] font-black text-silver-bright uppercase tracking-widest italic shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] focus:outline-none focus:border-rag-blue' + + return ( +
+ + {/* Header */} +
+
+
+ Delta_Engine v1.0 +
+

+ Report{' '} + + Delta + +

+

+ COMPARE_SCAN_RUNS // TRACK_REMEDIATION // DETECT_REGRESSION +

+
+
+ + {/* Selector Panel */} +
+

+ Select_Reports_For_Comparison +

+ +
+ {/* Baseline selector */} +
+ + +
+ + {/* Newer selector */} +
+ + +
+
+ + + + {/* Error */} + {error && ( +
+ +

{error}

+
+ )} +
+ + {/* Results */} + + {result && selectedA && selectedB && ( + + {/* Summary bar */} +
+ {[ + { label: 'New', val: result.newFindings.length, color: 'bg-rag-red' }, + { label: 'Fixed', val: result.fixedFindings.length, color: 'bg-rag-green' }, + { label: 'Unchanged', val: result.unchangedFindings.length, color: 'bg-silver/20' }, + { label: 'Severity_Changed', val: result.severityChanges.length, color: 'bg-rag-amber' }, + ].map((m) => ( +
+ {m.label} + {m.val} +
+ ))} +
+ + {/* Comparing label */} +
+ {selectedA.name} + + {selectedB.name} +
+ + {/* Four sections */} +
+ + + + + {/* Severity changes — special layout */} +
+
+ +

+ Severity Changes +

+ + {result.severityChanges.length} + +
+ {result.severityChanges.length === 0 ? ( +

+ No severity changes detected. +

+ ) : ( + + {result.severityChanges.map(({ finding, oldSeverity, newSeverity }) => ( + + + {finding.title} + +
+ + + +
+
+ ))} +
+ )} +
+
+
+ )} +
+ + {/* Footer */} +
+
+
+ DELTA_ANALYSIS_DAEMON // REPORT_DIFF_ENGINE // {new Date().getFullYear()} +
+
+ {[1, 2, 3, 4, 5, 6, 7, 8].map((i) => ( +
+ ))} +
+
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/pages/Reports.tsx b/frontend/src/pages/Reports.tsx index 61b2172cf..777e9071a 100644 --- a/frontend/src/pages/Reports.tsx +++ b/frontend/src/pages/Reports.tsx @@ -15,7 +15,8 @@ import { UserShield02Icon, } from '@hugeicons/core-free-icons' import { getDashboardSummary, getReports, API_BASE } from '../api' -import { formatDateLong } from '../utils/date' +import { formatDateLong, isWithinDateRange, type DateRange } from '../utils/date' +import { usePreferredExportFormat } from '../hooks/usePreferredExportFormat' type Report = { id: string @@ -29,6 +30,8 @@ type Report = { pages: number } +type ReportStatus = 'all' | 'ready' | 'generating' | 'failed' + const containerVariants = { hidden: { opacity: 0 }, visible: { @@ -47,7 +50,7 @@ const itemVariants = { }, } -const exportFormats = ['pdf', 'html', 'csv'] as const +const exportFormats = ['pdf', 'html', 'csv' , 'sarif'] as const function ReportIcon({ icon, @@ -66,8 +69,11 @@ export default function Reports() { const [reports, setReports] = useState([]) const [summary, setSummary] = useState({ total_findings: 0, total_assets: 0, critical_findings: 0, high_findings: 0, total_attack_surface: 0 }) const [selectedType, setSelectedType] = useState('all') + const [selectedStatus, setSelectedStatus] = useState('all') + const [selectedDateRange, setSelectedDateRange] = useState('all') const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const { preferred, savePreference } = usePreferredExportFormat() const fetchReports = () => { setLoading(true) @@ -89,7 +95,11 @@ export default function Reports() { fetchReports() }, []) - const filteredReports = reports.filter((report) => selectedType === 'all' || report.type === selectedType) + const filteredReports = reports.filter((report) => + (selectedType === 'all' || report.type === selectedType) && + (selectedStatus === 'all' || report.status === selectedStatus) && + isWithinDateRange(report.generated_at, selectedDateRange) + ) return (
@@ -108,12 +118,19 @@ export default function Reports() {
+ +
@@ -122,7 +139,7 @@ export default function Reports() { {loading && (
- +

Retrieving Archive Data... @@ -159,7 +176,7 @@ export default function Reports() {

{m.label} - +
{m.val} @@ -173,6 +190,8 @@ export default function Reports() { {/* Filtration Sidebar */}
-
@@ -297,6 +322,19 @@ export default function TaskDetails() { } } + const copyTaskId = async () => { + if (!taskId) { + addToast('No Task ID available', 'warning') + return + } + try { + await navigator.clipboard.writeText(taskId || '') + addToast('Task ID copied successfully', 'success') + } catch (err) { + console.error('Failed to copy task ID:', err) + addToast('Unable to copy Task ID', 'error') + } + } const handleRescan = async () => { if (!task) return try { @@ -360,7 +398,7 @@ export default function TaskDetails() { : '--:--' const isTerminal = ['completed', 'failed', 'cancelled'].includes(task.status) const durationLabel = isTerminal - ? (task.duration_seconds + ? (task.duration_seconds ? `${Math.floor(task.duration_seconds / 60)}M ${Math.floor(task.duration_seconds % 60)}S` : (task.status === 'completed' ? '0M 0S' : 'TERMINATED')) : 'ACTIVE' @@ -527,6 +565,7 @@ export default function TaskDetails() { } } + const DetailCard = ({ label, value, subValue }: { label: string, value: string, subValue?: string }) => (
@@ -549,17 +588,31 @@ export default function TaskDetails() {
- + > + +
Mission_Dossier_SIG#{taskId?.split('-')[0].toUpperCase()} + {task.status} @@ -601,16 +654,16 @@ export default function TaskDetails() { Csv_Export - - - )} -
+ + + )} +
@@ -624,8 +677,10 @@ export default function TaskDetails() { /> 1 ? 'S' : ''} PENDING` + : task.started_at ? formatDateLong(task.started_at) : 'PENDING'} /> {task.status === 'failed' && task.error_message && ( -
- Diagnostic_Code::EXEC_FAIL_{task.exit_code || 'ERR'} + Diagnostic_Code::EXEC_FAIL_{task.exit_code || 'ERR'}
)} @@ -666,11 +721,10 @@ export default function TaskDetails() { @@ -698,32 +752,32 @@ export default function TaskDetails() {
- ({ - name: s.toUpperCase(), + ({ + name: s.toUpperCase(), count: severityCounts[s] || 0, color: s === 'critical' ? '#ff3e3e' : s === 'high' ? '#ff9500' : s === 'medium' ? '#0070f3' : s === 'low' ? '#00d1b2' : '#888888' }))} margin={{ top: 20, right: 30, left: 0, bottom: 0 }} > - - {orderedSeverities.map((s, index) => ( - ))} @@ -751,13 +805,13 @@ export default function TaskDetails() { dataKey="value" > {orderedSeverities.map((s, index) => ( - ))} - +
@@ -773,14 +827,13 @@ export default function TaskDetails() { {previewFindings.length > 0 ? (
{previewFindings.map((f: any, idx: number) => ( -
setSelectedFinding(f)} className="border border-white/6 bg-black/20 p-5 hover:bg-white/[0.04] cursor-pointer transition-all group relative overflow-hidden" > -
+
{f.severity} @@ -872,7 +925,7 @@ export default function TaskDetails() { {findings.map((f: Finding, idx: number) => { const description = stripAnsi(f.description) || 'No description provided.'; - + return ( {entry.label}

- + {entry.source}
-

+

{entry.value}

{entry.help && ( @@ -1096,18 +1147,19 @@ export default function TaskDetails() { CLASSIFIED_EXECUTIVE_SUMMARY // CORE_DAEMON_LOG_ID::{taskId?.split('-')[0].toUpperCase()}
- {[1,2,3,4].map(i =>
)} + {[1, 2, 3, 4].map(i =>
)}
{selectedFinding && ( <> - setSelectedFinding(null)} + aria-hidden="true" className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[90]" /> setSelectedFinding(null)} /> diff --git a/frontend/src/pages/ToolConfig.tsx b/frontend/src/pages/ToolConfig.tsx index 2ac40f7a5..81bf096c9 100644 --- a/frontend/src/pages/ToolConfig.tsx +++ b/frontend/src/pages/ToolConfig.tsx @@ -11,6 +11,7 @@ import { } from '../api' import { useToast } from '../components/ToastContext' import { routePath, routes } from '../routes' +import { getValidationError } from '../utils/validation' type InputState = Record @@ -29,59 +30,6 @@ function buildDefaultInputs(fields: PluginFieldSchema[]): InputState { return defaults } -function isRequiredFieldValid(field: PluginFieldSchema, value: unknown): boolean { - if (!field.required) return true - if (value === undefined || value === null) return false - if (typeof value === 'string') return value.trim().length > 0 - if (Array.isArray(value)) return value.length > 0 - return true -} - -function asFiniteNumber(value: unknown): number | null { - if (typeof value === 'number' && Number.isFinite(value)) return value - if (typeof value === 'string' && value.trim()) { - const parsed = Number(value) - return Number.isFinite(parsed) ? parsed : null - } - return null -} - -function getFieldValidationError(field: PluginFieldSchema, value: unknown): string | null { - if (!isRequiredFieldValid(field, value)) { - return `${field.label} is required` - } - - const validation = field.validation || {} - const message = typeof validation.message === 'string' ? validation.message : null - - if (typeof value === 'string' && value.trim()) { - const pattern = typeof validation.pattern === 'string' ? validation.pattern : null - if (pattern) { - try { - if (!new RegExp(pattern).test(value.trim())) { - return message || `${field.label} is not valid` - } - } catch { - return null - } - } - } - - if (field.type === 'integer' && value !== '' && value !== undefined && value !== null) { - const numericValue = asFiniteNumber(value) - if (numericValue === null || !Number.isInteger(numericValue)) { - return message || `${field.label} must be a whole number` - } - - const min = asFiniteNumber(validation.min) - const max = asFiniteNumber(validation.max) - if (min !== null && numericValue < min) return message || `${field.label} must be at least ${min}` - if (max !== null && numericValue > max) return message || `${field.label} must be no more than ${max}` - } - - return null -} - function resolvePresetInputs( fields: PluginFieldSchema[], presets: Record>, @@ -162,15 +110,18 @@ export default function ToolConfig() { }, [toolId, navigate, addToast]) const presetNames = useMemo(() => Object.keys(schema?.presets || {}), [schema]) + const validationErrors = useMemo>(() => { if (!schema) return {} return schema.fields.reduce>((errors, field) => { - const error = getFieldValidationError(field, inputs[field.id]) + const error = getValidationError(field, inputs[field.id]) if (error) errors[field.id] = error return errors }, {}) }, [schema, inputs]) + const invalidFieldCount = Object.keys(validationErrors).length + const hasValidationErrors = invalidFieldCount > 0 const safetyLevel = String(schema?.safety?.level || 'safe') const handleFieldChange = (field: PluginFieldSchema, value: unknown) => { @@ -185,7 +136,7 @@ export default function ToolConfig() { const handleStartScan = async () => { if (!plugin || !schema || submitting) return - if (invalidFieldCount > 0) { + if (hasValidationErrors) { addToast('Fix highlighted scan parameters before starting the scan.', 'error') return } @@ -279,7 +230,8 @@ export default function ToolConfig() { Task launch remains available, but execution may fail until dependencies are installed.

- )} + )} +
{presetNames.length > 0 && ( @@ -310,41 +262,56 @@ export default function ToolConfig() { {schema.fields.map((field) => { const value = inputs[field.id] const validationError = validationErrors[field.id] + const isInvalid = Boolean(validationError) + const inputBorderClass = isInvalid + ? 'border-rag-red focus:border-rag-red' + : 'border-black focus:border-rag-blue' + const fieldId = `field-${field.id}` + const errorId = `error-${field.id}` return (
-
{field.type === 'text' ? (