From 729657e8e5c680c042532b25e118388c3411a5b6 Mon Sep 17 00:00:00 2001 From: Somil450 Date: Thu, 28 May 2026 23:21:47 +0530 Subject: [PATCH 1/5] feat: Export findings directly to Jira and GitHub Issues (#377) --- backend/requirements.txt | 1 + backend/secuscan/integrations.py | 115 ++ backend/secuscan/models.py | 13 + backend/secuscan/routes.py | 2316 +++++++++++++++--------------- frontend/src/api.ts | 8 + frontend/src/pages/Findings.tsx | 34 +- frontend/src/pages/Settings.tsx | 63 + 7 files changed, 1400 insertions(+), 1150 deletions(-) create mode 100644 backend/secuscan/integrations.py diff --git a/backend/requirements.txt b/backend/requirements.txt index b7d7a851..29cf983a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -10,3 +10,4 @@ python-multipart>=0.0.9 xhtml2pdf>=0.2.17 aiosqlite>=0.20.0 python-whois>=0.9.4 +httpx>=0.27.0 diff --git a/backend/secuscan/integrations.py b/backend/secuscan/integrations.py new file mode 100644 index 00000000..6d1e8351 --- /dev/null +++ b/backend/secuscan/integrations.py @@ -0,0 +1,115 @@ +import httpx +import logging +from typing import Dict, Any +from .models import Finding + +logger = logging.getLogger(__name__) + +async def create_jira_ticket(finding: Finding, config: Dict[str, str]) -> Dict[str, Any]: + url = config.get("jiraUrl", "").rstrip("/") + email = config.get("jiraEmail") + token = config.get("jiraToken") + project_key = config.get("jiraProject") + + if not all([url, email, token, project_key]): + raise ValueError("Missing Jira configuration parameters") + + api_url = f"{url}/rest/api/2/issue" + + description = f""" +*Target:* {finding.target} +*Severity:* {finding.severity} +*Category:* {finding.category} +*Discovered At:* {finding.discovered_at} + +*Description:* +{finding.description} + +*Remediation:* +{finding.remediation} +""" + if finding.cve: + description += f"\n*CVE:* {finding.cve}" + + payload = { + "fields": { + "project": { + "key": project_key + }, + "summary": f"[{finding.severity.upper()}] {finding.title}", + "description": description, + "issuetype": { + "name": "Bug" + } + } + } + + async with httpx.AsyncClient() as client: + response = await client.post( + api_url, + json=payload, + auth=(email, token), + headers={"Content-Type": "application/json"} + ) + + if response.status_code >= 400: + logger.error(f"Jira API error: {response.text}") + raise Exception(f"Failed to create Jira ticket: {response.status_code} {response.text}") + + data = response.json() + return { + "ticket_id": data.get("key"), + "ticket_url": f"{url}/browse/{data.get('key')}" + } + + +async def create_github_issue(finding: Finding, config: Dict[str, str]) -> Dict[str, Any]: + token = config.get("githubToken") + repo = config.get("githubRepo") + + if not all([token, repo]): + raise ValueError("Missing GitHub configuration parameters") + + api_url = f"https://api.github.com/repos/{repo}/issues" + + body = f""" +**Target:** `{finding.target}` +**Severity:** {finding.severity.upper()} +**Category:** {finding.category} +**Discovered At:** {finding.discovered_at} + +### Description +{finding.description} + +### Remediation +{finding.remediation} +""" + if finding.cve: + body += f"\n**CVE:** {finding.cve}" + + payload = { + "title": f"[{finding.severity.upper()}] {finding.title}", + "body": body, + "labels": ["bug", "security"] + } + + async with httpx.AsyncClient() as client: + response = await client.post( + api_url, + json=payload, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github.v3+json", + "X-GitHub-Api-Version": "2022-11-28" + } + ) + + if response.status_code >= 400: + logger.error(f"GitHub API error: {response.text}") + raise Exception(f"Failed to create GitHub issue: {response.status_code} {response.text}") + + data = response.json() + return { + "ticket_id": str(data.get("number")), + "ticket_url": data.get("html_url") + } diff --git a/backend/secuscan/models.py b/backend/secuscan/models.py index 264363e5..77b161d3 100644 --- a/backend/secuscan/models.py +++ b/backend/secuscan/models.py @@ -161,3 +161,16 @@ class ErrorResponse(BaseModel): message: str field: Optional[str] = None details: Optional[Dict[str, Any]] = None + + +class TicketCreateRequest(BaseModel): + """Request to create a ticket in an external integration""" + provider: str # "jira" or "github" + finding: Finding + config: Dict[str, str] # credentials and config for the integration + + +class TicketResponse(BaseModel): + """Response after creating a ticket""" + ticket_id: str + ticket_url: str diff --git a/backend/secuscan/routes.py b/backend/secuscan/routes.py index 278559df..15520176 100644 --- a/backend/secuscan/routes.py +++ b/backend/secuscan/routes.py @@ -1,1149 +1,1167 @@ -""" -API routes for SecuScan backend -""" - -from fastapi import APIRouter, HTTPException, BackgroundTasks, Response, Request, Depends -from fastapi.responses import JSONResponse -from typing import Any, Optional, List, Dict, Callable -import json -import logging -import re -import os -import shutil -import uuid -import asyncio -from pathlib import Path -from urllib.parse import urlparse - -def parse_json_fields(rows: List[Dict], fields: List[str]) -> List[Dict]: - """Helper to parse stringified JSON fields from SQLite.""" - parsed = [] - for row in rows: - item = dict(row) - for field in fields: - if item.get(field) and isinstance(item[field], str): - try: - item[field] = json.loads(item[field]) - except json.JSONDecodeError: - pass - parsed.append(item) - return parsed - - -def is_filesystem_target(target: str) -> bool: - """Best-effort detection for path-based targets that should bypass host validation.""" - if target.startswith(("/", "./", "../", "~")): - return True - if re.match(r"^[A-Za-z]:[\\/]", target): - return True - if "/" in target and not target.startswith(("http://", "https://")): - return True - return False - - -def _slugify_filename_part(value: str, fallback: str) -> str: - cleaned = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") - return cleaned or fallback - - -def build_report_filename(task: Dict[str, Any], extension: str) -> str: - tool = _slugify_filename_part(str(task.get("tool_name") or task.get("plugin_id") or "scan"), "scan") - - raw_target = str(task.get("target") or "") - parsed = urlparse(raw_target if "://" in raw_target else f"//{raw_target}") - target_source = parsed.netloc or parsed.path or raw_target - target = _slugify_filename_part(target_source, "target") - - created_at = str(task.get("created_at") or "") - date_match = re.search(r"\d{4}-\d{2}-\d{2}", created_at) - date_part = date_match.group(0) if date_match else "report" - - return f"secuscan_{tool}_{target}_{date_part}.{extension}" - -logger = logging.getLogger(__name__) - -from .cache import get_cache -from .models import ( - TaskCreateRequest, TaskResponse, TaskResult, - PluginListResponse, ErrorResponse -) -from .config import settings -from .database import get_db -from .plugins import get_plugin_manager, init_plugins -from .executor import executor -from .ratelimit import ( - rate_limiter, concurrent_limiter, - task_start_limiter, vault_limiter, - report_download_limiter, read_heavy_limiter -) -from .validation import validate_target, validate_task_start_payload -from .reporting import reporting -from .vault import VaultCrypto -from .workflows import scheduler - -from sse_starlette.sse import EventSourceResponse - -router = APIRouter(prefix="/api/v1") - - -async def get_or_set_cached(key: str, builder): - """Read from cache, or build and cache a JSON response.""" - cache = await get_cache() - cached = await cache.get_json(key) - if cached is not None: - return cached - - value = await builder() - await cache.set_json(key, value) - return value - - -async def invalidate_view_cache(): - """Clear aggregate caches after writes.""" - cache = await get_cache() - for prefix in ["summary:", "findings:", "reports:", "tasks:"]: - 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 - changes reflect parser/metadata edits without requiring a backend restart. - """ - if settings.debug: - return await init_plugins(settings.plugins_dir) - return get_plugin_manager() - - -@router.get("/plugins", response_model=PluginListResponse) -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): - """Get plugin schema for UI generation""" - plugin_manager = await get_plugin_manager_for_request() - if schema := plugin_manager.get_plugin_schema(plugin_id): - return schema - else: - raise HTTPException(status_code=404, detail=f"Plugin not found: {plugin_id}") - - -@router.get("/presets") -async def get_all_presets(): - """Get all plugin presets""" - plugin_manager = await get_plugin_manager_for_request() - return { - plugin_id: plugin.presets - for plugin_id, plugin in plugin_manager.plugins.items() - } - - -@router.post("/task/start", dependencies=[Depends(task_start_limiter)]) -async def start_task( - request: TaskCreateRequest, - background_tasks: BackgroundTasks, - raw_request: Request, -): - """ - 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}") - raise HTTPException( - status_code=400, - detail="Consent required. You must acknowledge the legal notice." - ) - - # Get plugin - plugin_manager = await get_plugin_manager_for_request() - plugin = plugin_manager.get_plugin(request.plugin_id) - - if not plugin: - logger.warning(f"Task start failed: Plugin not found: {request.plugin_id}") - raise HTTPException(status_code=404, detail=f"Plugin not found: {request.plugin_id}") - - if target := request.inputs.get("target"): - safe_mode = request.inputs.get("safe_mode", settings.safe_mode_default) - target_str = str(target) - should_validate_target = plugin.category != "code" and not is_filesystem_target(target_str) - - if should_validate_target: - is_valid, error_msg = validate_target(target_str, safe_mode) - - if not is_valid: - logger.warning(f"Task start failed: Target validation failed for '{target}': {error_msg}") - raise HTTPException(status_code=400, detail=error_msg) - - # Check rate limits - can_execute, error_msg = await rate_limiter.can_execute( - request.plugin_id, - plugin.safety.get("rate_limit", {}).get("max_per_hour", settings.max_tasks_per_hour) - ) - - if not can_execute: - raise HTTPException(status_code=429, detail=error_msg) - - # Create task record first so we have a real task_id for the limiter - try: - task_id = await executor.create_task( - request.plugin_id, - request.inputs, - request.preset, - request.consent_granted - ) - 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): - """Get task status""" - status = await executor.get_task_status(task_id) - - if not status: - raise HTTPException(status_code=404, detail="Task not found") - - return status - -@router.get("/task/{task_id}/stream") -async def stream_task_output(task_id: str): - """Stream task output via Server-Sent Events (SSE)""" - import asyncio - - status = await executor.get_task_status(task_id) - if not status: - raise HTTPException(status_code=404, detail="Task not found") - - async def event_generator(): - # First, send the initial status - yield { - "event": "status", - "data": json.dumps({"status": status["status"]}) - } - - # If it's already completed/failed, we just return the raw output if any and close - if status["status"] in ["completed", "failed", "cancelled"]: - try: - db = await get_db() - task_row = await db.fetchone("SELECT raw_output_path FROM tasks WHERE id = ?", (task_id,)) - if task_row and task_row["raw_output_path"]: - with open(task_row["raw_output_path"], "r") as f: - yield { - "event": "output", - "data": json.dumps({"chunk": f.read()}) - } - except Exception: - pass - return - - # Otherwise, subscribe to the live task events - queue = executor.subscribe(task_id) - try: - while True: - # Wait for the next event from the executor - event = await queue.get() - - if event["type"] == "status": - yield { - "event": "status", - "data": json.dumps({"status": event["data"]}) - } - if event["data"] in ["completed", "failed", "cancelled"]: - break - elif event["type"] == "output": - yield { - "event": "output", - "data": json.dumps({"chunk": event["data"]}) - } - except asyncio.CancelledError: - pass - finally: - executor.unsubscribe(task_id, queue) - - return EventSourceResponse(event_generator()) - -@router.get("/task/{task_id}/report/csv", dependencies=[Depends(report_download_limiter)]) -async def download_csv_report(task_id: str): - """Download task results as a CSV 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 {} - 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")}"'} - ) - -@router.get("/task/{task_id}/report/html", dependencies=[Depends(report_download_limiter)]) -async def download_html_report(task_id: str): - """Download task results as an HTML 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 {} - 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")}"'} - ) - -@router.get("/task/{task_id}/report/pdf", dependencies=[Depends(report_download_limiter)]) -async def download_pdf_report(task_id: str): - """Download task results as a PDF 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 {} - 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")}"'} - ) - - -@router.get("/task/{task_id}/report/sarif", dependencies=[Depends(report_download_limiter)]) -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")}"'} - ) - - -@router.get("/task/{task_id}/result") -async def get_task_result(task_id: str): - """Get task execution result""" - db = await get_db() - - task_row = await db.fetchone( - """ - SELECT id, plugin_id, tool_name, target, status, - created_at, duration_seconds, structured_json, preset, inputs_json, - raw_output_path, command_used, error_message, exit_code - FROM tasks WHERE id = ? - """, - (task_id,) - ) - - if not task_row: - raise HTTPException(status_code=404, detail="Task not found") - - structured = {} - if task_row["structured_json"]: - try: - structured = json.loads(task_row["structured_json"]) - except json.JSONDecodeError: - structured = {} - - findings = structured.get("findings", []) if isinstance(structured, dict) else [] - severity_counts: Dict[str, int] = {} - for finding in findings: - severity = str(finding.get("severity", "info")).lower() - severity_counts[severity] = severity_counts.get(severity, 0) + 1 - - 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 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.") - 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.") - - # Read raw output (limit to 100k for performance, but usually enough) - raw_output = None - if task_row["raw_output_path"]: - try: - with open(task_row["raw_output_path"], 'r') as f: - raw_output = f.read(100000) - except Exception: - pass - - return { - "task_id": task_row["id"], - "plugin_id": task_row["plugin_id"], - "tool": task_row["tool_name"], - "target": task_row["target"], - "timestamp": task_row["created_at"], - "duration_seconds": task_row["duration_seconds"], - "status": task_row["status"], - "preset": task_row["preset"], - "inputs": json.loads(task_row["inputs_json"] or "{}"), - "summary": summary, - "severity_counts": severity_counts, - "findings": findings, - "structured": structured, - "raw_output_path": task_row["raw_output_path"], - "raw_output_excerpt": raw_output, - "raw_output": raw_output, - "command_used": task_row["command_used"], - "errors": [{"message": task_row["error_message"]}] if task_row["error_message"] else [], - "error_message": task_row["error_message"], - "exit_code": task_row["exit_code"], - "metadata": {} - } - - -@router.post("/task/{task_id}/cancel") -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", - "cancelled_at": "now" - } - - -@router.get("/dashboard/summary", dependencies=[Depends(read_heavy_limiter)]) -async def get_dashboard_summary(): - """Return aggregate dashboard data from the primary store, cached in Redis.""" - - async def build(): - db = await get_db() - - # Get data - # Push severity aggregation to DB — avoids full table scan in Python - severity_rows = await db.fetchall( - """ - SELECT severity, COUNT(*) AS cnt - FROM findings - GROUP BY severity - """ - ) - severity_counts = {row["severity"]: row["cnt"] for row in severity_rows} - - task_stats = await db.fetchone( - """ - SELECT - COUNT(*) AS total, - COUNT(*) FILTER (WHERE status = 'completed') AS completed, - COUNT(*) FILTER (WHERE status = 'running') AS running - FROM tasks - """ - ) - - total_findings_row = await db.fetchone("SELECT COUNT(*) AS total FROM findings") - total_findings = total_findings_row["total"] if total_findings_row else 0 - - critical_findings: int = severity_counts.get("critical", 0) - high_findings: int = severity_counts.get("high", 0) - medium_findings: int = severity_counts.get("medium", 0) - low_findings: int = severity_counts.get("low", 0) - info_findings: int = severity_counts.get("info", 0) - - # Fetch only the 5 most recent findings — not the entire table - recent_rows = await db.fetchall( - """ - SELECT id, title, category, severity, target, description, - remediation, proof, cvss, cve, discovered_at, metadata_json - FROM findings - ORDER BY discovered_at DESC - LIMIT 5 - """ - ) - recent_findings: List[Dict] = parse_json_fields(recent_rows, ["metadata_json"]) - - return { - "total_findings": total_findings, - "critical_findings": critical_findings, - "high_findings": high_findings, - "medium_findings": medium_findings, - "low_findings": low_findings, - "info_findings": info_findings, - "last_scan_time": recent_findings[0].get("discovered_at") if recent_findings else None, - "recent_findings": recent_findings, - "scan_activity": { - "total": int(task_stats["total"]) if task_stats and task_stats.get("total") is not None else 0, - "completed": int(task_stats["completed"]) if task_stats and task_stats.get("completed") is not None else 0, - "running": int(task_stats["running"]) if task_stats and task_stats.get("running") is not None else 0, - }, - "running_tasks": parse_json_fields( - await db.fetchall( - "SELECT id, plugin_id, tool_name, target, status, created_at FROM tasks WHERE status = 'running' ORDER BY created_at DESC LIMIT 5" - ), - [] - ), - "recent_tasks": parse_json_fields( - await db.fetchall( - "SELECT id, plugin_id, tool_name, target, status, created_at, duration_seconds FROM tasks ORDER BY created_at DESC LIMIT 5" - ), - [] - ) - } - - return await get_or_set_cached("summary:dashboard", build) - - -@router.get("/findings", dependencies=[Depends(read_heavy_limiter)]) -async def get_findings(): - """Return vulnerability findings.""" - - async def build(): - db = await get_db() - rows = await db.fetchall("SELECT * FROM findings ORDER BY discovered_at DESC") - return {"findings": parse_json_fields(rows, ["metadata_json"])} - - return await get_or_set_cached("findings:list", build) - - -@router.get("/reports", dependencies=[Depends(read_heavy_limiter)]) -async def get_reports(): - """Return generated reports.""" - - async def build(): - db = await get_db() - rows = await db.fetchall("SELECT * FROM reports ORDER BY generated_at DESC") - return {"reports": parse_json_fields(rows, ["metadata_json"])} - - return await get_or_set_cached("reports:list", build) - - -@router.get("/tasks", dependencies=[Depends(read_heavy_limiter)]) -async def list_tasks( - page: int = 1, - per_page: int = 25, - plugin_id: Optional[str] = None, - status: Optional[str] = None -): - """List all tasks with pagination""" - db = await get_db() - - # Build query - query = "SELECT id, plugin_id, tool_name, target, status, created_at, duration_seconds, inputs_json, preset, error_message, exit_code FROM tasks" - params = [] - - where_clauses = [] - if plugin_id: - where_clauses.append("plugin_id = ?") - params.append(plugin_id) - if status: - where_clauses.append("status = ?") - params.append(status) - - if where_clauses: - query += " WHERE " + " AND ".join(where_clauses) - - query += " ORDER BY created_at DESC LIMIT ? OFFSET ?" - params.extend([per_page, (page - 1) * per_page]) - - tasks = await db.fetchall(query, tuple(params)) - - # Get total count - count_query = "SELECT COUNT(*) as total FROM tasks" - if where_clauses: - count_query += " WHERE " + " AND ".join(where_clauses) - - count_result = await db.fetchone(count_query, tuple(params[:-2]) if where_clauses else ()) - total: int = int(count_result["total"]) if count_result and count_result.get("total") is not None else 0 - - # Parse JSON fields and format for frontend - tasks_list = parse_json_fields(tasks, ["structured_json", "config_json", "metadata_json", "inputs_json"]) - for t in tasks_list: - if "id" in t: - t["task_id"] = t.pop("id") - 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, - "next": build_page_url(next_page), # ← NEW - "previous": build_page_url(prev_page) # ← NEW - } - } - - -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"]: - try: - path = Path(row["raw_output_path"]) - if path.exists(): - path.unlink() - except Exception as e: - logger.error(f"Failed to delete raw output file {row['raw_output_path']}: {e}") - -@router.delete("/task/{task_id}") -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": - raise HTTPException(status_code=400, detail="Cannot delete a running task. Abort it first.") - - await delete_task_records([task_id]) - await invalidate_view_cache() - - return { - "task_id": task_id, - "deleted": True - } - - -@router.delete("/tasks/bulk") -async def bulk_delete_tasks(task_ids: List[str]): - """Delete multiple tasks at once""" - 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)) - 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)""" - 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: - raise HTTPException(status_code=400, detail="Cannot clear history while tasks are running.") - - # Get all task IDs to cleanup files - all_tasks = await db.fetchall("SELECT id FROM tasks") - task_ids = [t["id"] for t in all_tasks] - if task_ids: - await delete_task_records(task_ids) - - # 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 - if dir_path.exists(): - for item in dir_path.iterdir(): - try: - if item.is_file(): - item.unlink() - elif item.is_dir(): - shutil.rmtree(item) - except Exception as e: - 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." - } - - -@router.get("/settings") -async def get_settings(): - """Get current settings""" - return { - "network": { - "bind_address": settings.bind_address, - "port": settings.bind_port, - "allow_remote": False - }, - "sandbox": { - "engine": "docker" if settings.docker_enabled else "subprocess", - "default_timeout": settings.sandbox_timeout, - "resource_limits": { - "cpu_quota": settings.sandbox_cpu_quota, - "memory_mb": settings.sandbox_memory_mb - } - }, - "safety": { - "require_consent": settings.require_consent, - "safe_mode_default": settings.safe_mode_default, - "allowed_networks": settings.allowed_networks - } - } - - -@router.get("/vault", dependencies=[Depends(vault_limiter)]) -async def list_vault_secrets(): - db = await get_db() - rows = await db.fetchall( - "SELECT id, name, created_at, updated_at FROM credential_vault ORDER BY name ASC" - ) - return {"items": rows, "total": len(rows)} - - -@router.put("/vault/{name}", dependencies=[Depends(vault_limiter)]) -async def upsert_vault_secret(name: str, payload: Dict[str, str]): - value = str(payload.get("value", "")) - if not value: - raise HTTPException(status_code=400, detail="Secret value is required") - - db = await get_db() - crypto = VaultCrypto(settings.resolved_vault_key) - encrypted = crypto.encrypt(value) - secret_id = str(uuid.uuid4()) - - existing = await db.fetchone("SELECT id FROM credential_vault WHERE name = ?", (name,)) - if existing: - await db.execute( - "UPDATE credential_vault SET encrypted_value = ?, updated_at = datetime('now') WHERE name = ?", - (encrypted, name), - ) - else: - await db.execute( - "INSERT INTO credential_vault (id, name, encrypted_value) VALUES (?, ?, ?)", - (secret_id, name, encrypted), - ) - return {"name": name, "stored": True} - - -@router.get("/vault/{name}", dependencies=[Depends(vault_limiter)]) -async def get_vault_secret(name: str): - db = await get_db() - row = await db.fetchone("SELECT encrypted_value FROM credential_vault WHERE name = ?", (name,)) - if not row: - raise HTTPException(status_code=404, detail="Secret not found") - crypto = VaultCrypto(settings.resolved_vault_key) - return {"name": name, "value": crypto.decrypt(row["encrypted_value"])} - - -@router.delete("/vault/{name}", dependencies=[Depends(vault_limiter)]) -async def delete_vault_secret(name: str): - db = await get_db() - await db.execute("DELETE FROM credential_vault WHERE name = ?", (name,)) - return {"name": name, "deleted": True} - - -@router.get("/workflows") -async def list_workflows(): - db = await get_db() - rows = await db.fetchall("SELECT * FROM workflows ORDER BY created_at DESC") - return {"workflows": parse_json_fields(rows, ["steps_json"]), "total": len(rows)} - - -@router.post("/workflows") -async def create_workflow(payload: Dict[str, Any]): - name = str(payload.get("name", "")).strip() - if not name: - raise HTTPException(status_code=400, detail="Workflow name is required") - - steps = payload.get("steps", []) - if not isinstance(steps, list) or not steps: - raise HTTPException(status_code=400, detail="Workflow requires at least one step") - - workflow_id = str(uuid.uuid4()) - schedule_seconds = payload.get("schedule_seconds") - enabled = bool(payload.get("enabled", True)) - db = await get_db() - await db.execute( - """ - INSERT INTO workflows (id, name, schedule_seconds, enabled, steps_json) - VALUES (?, ?, ?, ?, ?) - """, - ( - workflow_id, - name, - int(schedule_seconds) if schedule_seconds else None, - 1 if enabled else 0, - json.dumps(steps), - ), - ) - return {"id": workflow_id, "created": True} - - -@router.post("/workflows/{workflow_id}/run") -async def run_workflow_once(workflow_id: str): - db = await get_db() - row = await db.fetchone("SELECT steps_json FROM workflows WHERE id = ?", (workflow_id,)) - if not row: - raise HTTPException(status_code=404, detail="Workflow not found") - steps = json.loads(row["steps_json"] or "[]") - created_task_ids: List[str] = [] - for step in steps: - task_id = await executor.create_task( - step.get("plugin_id"), - step.get("inputs", {}), - step.get("preset"), - consent_granted=True, - ) - asyncio.create_task(executor.execute_task(task_id)) - created_task_ids.append(task_id) - await db.execute("UPDATE workflows SET last_run_at = datetime('now') WHERE id = ?", (workflow_id,)) - return {"workflow_id": workflow_id, "queued_tasks": created_task_ids} - - -@router.patch("/workflows/{workflow_id}") -async def update_workflow(workflow_id: str, payload: Dict[str, Any]): - db = await get_db() - row = await db.fetchone("SELECT id FROM workflows WHERE id = ?", (workflow_id,)) - if not row: - raise HTTPException(status_code=404, detail="Workflow not found") - - updates = [] - params: List[Any] = [] - if "name" in payload: - updates.append("name = ?") - params.append(str(payload["name"]).strip()) - if "steps" in payload: - updates.append("steps_json = ?") - params.append(json.dumps(payload["steps"])) - if "schedule_seconds" in payload: - val = payload["schedule_seconds"] - updates.append("schedule_seconds = ?") - params.append(int(val) if val else None) - if "enabled" in payload: - updates.append("enabled = ?") - params.append(1 if payload["enabled"] else 0) - - if not updates: - raise HTTPException(status_code=400, detail="No update fields provided") - - params.append(workflow_id) - await db.execute(f"UPDATE workflows SET {', '.join(updates)} WHERE id = ?", tuple(params)) - return {"workflow_id": workflow_id, "updated": True} - - -@router.delete("/workflows/{workflow_id}") -async def delete_workflow(workflow_id: str): - db = await get_db() - await db.execute("DELETE FROM workflows WHERE id = ?", (workflow_id,)) - return {"workflow_id": workflow_id, "deleted": True} - - -@router.post("/workflows/scheduler/tick") -async def trigger_workflow_tick(): - await scheduler.tick() - return {"tick": "ok"} - - -@router.get("/finding/{finding_id}") -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 - FROM findings f - JOIN tasks t ON f.task_id = t.id - WHERE f.id = ? - """, - (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"], - "plugin_id": finding_row["plugin_id"], - "tool": finding_row["tool_name"], - "title": finding_row["title"], - "category": finding_row["category"], - "severity": finding_row["severity"], - "target": finding_row["target"], - "description": finding_row["description"], - "remediation": finding_row["remediation"], - "proof": finding_row["proof"], - "cvss": finding_row["cvss"], - "cve": finding_row["cve"], - "discovered_at": finding_row["discovered_at"], - "metadata": metadata - } - - -@router.get("/attack-surface") -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"] - if target not in seen_targets: - entries.append({ - "id": str(uuid.uuid4()), - "category": f["category"], - "item": target, - "details": f"Active exposure identified in {f['category']}", - "risk": f["severity"], - "source": "Audit Scan", - "last_seen": f["discovered_at"] - }) - seen_targets.add(target) - - # Add other scanned targets - for t in tasks: - target = t["target"] - if target not in seen_targets: - entries.append({ - "id": str(uuid.uuid4()), - "category": "Infrastructure", - "item": target, - "details": f"Monitored via {t['tool_name']}", - "risk": "info", - "source": "Recon", - "last_seen": t["created_at"] - }) - seen_targets.add(target) - - return {"entries": entries} - - -@router.get("/assets") -async def get_assets(): - """Return a list of tracked assets.""" - db = await get_db() - # 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} \ No newline at end of file +""" +API routes for SecuScan backend +""" + +from fastapi import APIRouter, HTTPException, BackgroundTasks, Response, Request, Depends +from fastapi.responses import JSONResponse +from typing import Any, Optional, List, Dict, Callable +import json +import logging +import re +import os +import shutil +import uuid +import asyncio +from pathlib import Path +from urllib.parse import urlparse + +def parse_json_fields(rows: List[Dict], fields: List[str]) -> List[Dict]: + """Helper to parse stringified JSON fields from SQLite.""" + parsed = [] + for row in rows: + item = dict(row) + for field in fields: + if item.get(field) and isinstance(item[field], str): + try: + item[field] = json.loads(item[field]) + except json.JSONDecodeError: + pass + parsed.append(item) + return parsed + + +def is_filesystem_target(target: str) -> bool: + """Best-effort detection for path-based targets that should bypass host validation.""" + if target.startswith(("/", "./", "../", "~")): + return True + if re.match(r"^[A-Za-z]:[\\/]", target): + return True + if "/" in target and not target.startswith(("http://", "https://")): + return True + return False + + +def _slugify_filename_part(value: str, fallback: str) -> str: + cleaned = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + return cleaned or fallback + + +def build_report_filename(task: Dict[str, Any], extension: str) -> str: + tool = _slugify_filename_part(str(task.get("tool_name") or task.get("plugin_id") or "scan"), "scan") + + raw_target = str(task.get("target") or "") + parsed = urlparse(raw_target if "://" in raw_target else f"//{raw_target}") + target_source = parsed.netloc or parsed.path or raw_target + target = _slugify_filename_part(target_source, "target") + + created_at = str(task.get("created_at") or "") + date_match = re.search(r"\d{4}-\d{2}-\d{2}", created_at) + date_part = date_match.group(0) if date_match else "report" + + return f"secuscan_{tool}_{target}_{date_part}.{extension}" + +logger = logging.getLogger(__name__) + +from .cache import get_cache +from .models import ( + TaskCreateRequest, TaskResponse, TaskResult, + PluginListResponse, ErrorResponse, TicketCreateRequest, TicketResponse +) +from .config import settings +from .database import get_db +from .plugins import get_plugin_manager, init_plugins +from .executor import executor +from .ratelimit import ( + rate_limiter, concurrent_limiter, + task_start_limiter, vault_limiter, + report_download_limiter, read_heavy_limiter +) +from .validation import validate_target, validate_task_start_payload +from .reporting import reporting +from .vault import VaultCrypto +from .workflows import scheduler +from .integrations import create_jira_ticket, create_github_issue + +from sse_starlette.sse import EventSourceResponse + +router = APIRouter(prefix="/api/v1") + + +async def get_or_set_cached(key: str, builder): + """Read from cache, or build and cache a JSON response.""" + cache = await get_cache() + cached = await cache.get_json(key) + if cached is not None: + return cached + + value = await builder() + await cache.set_json(key, value) + return value + + +async def invalidate_view_cache(): + """Clear aggregate caches after writes.""" + cache = await get_cache() + for prefix in ["summary:", "findings:", "reports:", "tasks:"]: + 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 + changes reflect parser/metadata edits without requiring a backend restart. + """ + if settings.debug: + return await init_plugins(settings.plugins_dir) + return get_plugin_manager() + + +@router.get("/plugins", response_model=PluginListResponse) +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): + """Get plugin schema for UI generation""" + plugin_manager = await get_plugin_manager_for_request() + if schema := plugin_manager.get_plugin_schema(plugin_id): + return schema + else: + raise HTTPException(status_code=404, detail=f"Plugin not found: {plugin_id}") + + +@router.get("/presets") +async def get_all_presets(): + """Get all plugin presets""" + plugin_manager = await get_plugin_manager_for_request() + return { + plugin_id: plugin.presets + for plugin_id, plugin in plugin_manager.plugins.items() + } + + +@router.post("/task/start", dependencies=[Depends(task_start_limiter)]) +async def start_task( + request: TaskCreateRequest, + background_tasks: BackgroundTasks, + raw_request: Request, +): + """ + 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}") + raise HTTPException( + status_code=400, + detail="Consent required. You must acknowledge the legal notice." + ) + + # Get plugin + plugin_manager = await get_plugin_manager_for_request() + plugin = plugin_manager.get_plugin(request.plugin_id) + + if not plugin: + logger.warning(f"Task start failed: Plugin not found: {request.plugin_id}") + raise HTTPException(status_code=404, detail=f"Plugin not found: {request.plugin_id}") + + if target := request.inputs.get("target"): + safe_mode = request.inputs.get("safe_mode", settings.safe_mode_default) + target_str = str(target) + should_validate_target = plugin.category != "code" and not is_filesystem_target(target_str) + + if should_validate_target: + is_valid, error_msg = validate_target(target_str, safe_mode) + + if not is_valid: + logger.warning(f"Task start failed: Target validation failed for '{target}': {error_msg}") + raise HTTPException(status_code=400, detail=error_msg) + + # Check rate limits + can_execute, error_msg = await rate_limiter.can_execute( + request.plugin_id, + plugin.safety.get("rate_limit", {}).get("max_per_hour", settings.max_tasks_per_hour) + ) + + if not can_execute: + raise HTTPException(status_code=429, detail=error_msg) + + # Create task record first so we have a real task_id for the limiter + try: + task_id = await executor.create_task( + request.plugin_id, + request.inputs, + request.preset, + request.consent_granted + ) + 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): + """Get task status""" + status = await executor.get_task_status(task_id) + + if not status: + raise HTTPException(status_code=404, detail="Task not found") + + return status + +@router.get("/task/{task_id}/stream") +async def stream_task_output(task_id: str): + """Stream task output via Server-Sent Events (SSE)""" + import asyncio + + status = await executor.get_task_status(task_id) + if not status: + raise HTTPException(status_code=404, detail="Task not found") + + async def event_generator(): + # First, send the initial status + yield { + "event": "status", + "data": json.dumps({"status": status["status"]}) + } + + # If it's already completed/failed, we just return the raw output if any and close + if status["status"] in ["completed", "failed", "cancelled"]: + try: + db = await get_db() + task_row = await db.fetchone("SELECT raw_output_path FROM tasks WHERE id = ?", (task_id,)) + if task_row and task_row["raw_output_path"]: + with open(task_row["raw_output_path"], "r") as f: + yield { + "event": "output", + "data": json.dumps({"chunk": f.read()}) + } + except Exception: + pass + return + + # Otherwise, subscribe to the live task events + queue = executor.subscribe(task_id) + try: + while True: + # Wait for the next event from the executor + event = await queue.get() + + if event["type"] == "status": + yield { + "event": "status", + "data": json.dumps({"status": event["data"]}) + } + if event["data"] in ["completed", "failed", "cancelled"]: + break + elif event["type"] == "output": + yield { + "event": "output", + "data": json.dumps({"chunk": event["data"]}) + } + except asyncio.CancelledError: + pass + finally: + executor.unsubscribe(task_id, queue) + + return EventSourceResponse(event_generator()) + +@router.get("/task/{task_id}/report/csv", dependencies=[Depends(report_download_limiter)]) +async def download_csv_report(task_id: str): + """Download task results as a CSV 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 {} + 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")}"'} + ) + +@router.get("/task/{task_id}/report/html", dependencies=[Depends(report_download_limiter)]) +async def download_html_report(task_id: str): + """Download task results as an HTML 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 {} + 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")}"'} + ) + +@router.get("/task/{task_id}/report/pdf", dependencies=[Depends(report_download_limiter)]) +async def download_pdf_report(task_id: str): + """Download task results as a PDF 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 {} + 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")}"'} + ) + + +@router.get("/task/{task_id}/report/sarif", dependencies=[Depends(report_download_limiter)]) +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")}"'} + ) + + +@router.get("/task/{task_id}/result") +async def get_task_result(task_id: str): + """Get task execution result""" + db = await get_db() + + task_row = await db.fetchone( + """ + SELECT id, plugin_id, tool_name, target, status, + created_at, duration_seconds, structured_json, preset, inputs_json, + raw_output_path, command_used, error_message, exit_code + FROM tasks WHERE id = ? + """, + (task_id,) + ) + + if not task_row: + raise HTTPException(status_code=404, detail="Task not found") + + structured = {} + if task_row["structured_json"]: + try: + structured = json.loads(task_row["structured_json"]) + except json.JSONDecodeError: + structured = {} + + findings = structured.get("findings", []) if isinstance(structured, dict) else [] + severity_counts: Dict[str, int] = {} + for finding in findings: + severity = str(finding.get("severity", "info")).lower() + severity_counts[severity] = severity_counts.get(severity, 0) + 1 + + 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 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.") + 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.") + + # Read raw output (limit to 100k for performance, but usually enough) + raw_output = None + if task_row["raw_output_path"]: + try: + with open(task_row["raw_output_path"], 'r') as f: + raw_output = f.read(100000) + except Exception: + pass + + return { + "task_id": task_row["id"], + "plugin_id": task_row["plugin_id"], + "tool": task_row["tool_name"], + "target": task_row["target"], + "timestamp": task_row["created_at"], + "duration_seconds": task_row["duration_seconds"], + "status": task_row["status"], + "preset": task_row["preset"], + "inputs": json.loads(task_row["inputs_json"] or "{}"), + "summary": summary, + "severity_counts": severity_counts, + "findings": findings, + "structured": structured, + "raw_output_path": task_row["raw_output_path"], + "raw_output_excerpt": raw_output, + "raw_output": raw_output, + "command_used": task_row["command_used"], + "errors": [{"message": task_row["error_message"]}] if task_row["error_message"] else [], + "error_message": task_row["error_message"], + "exit_code": task_row["exit_code"], + "metadata": {} + } + + +@router.post("/task/{task_id}/cancel") +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", + "cancelled_at": "now" + } + + +@router.get("/dashboard/summary", dependencies=[Depends(read_heavy_limiter)]) +async def get_dashboard_summary(): + """Return aggregate dashboard data from the primary store, cached in Redis.""" + + async def build(): + db = await get_db() + + # Get data + # Push severity aggregation to DB — avoids full table scan in Python + severity_rows = await db.fetchall( + """ + SELECT severity, COUNT(*) AS cnt + FROM findings + GROUP BY severity + """ + ) + severity_counts = {row["severity"]: row["cnt"] for row in severity_rows} + + task_stats = await db.fetchone( + """ + SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE status = 'completed') AS completed, + COUNT(*) FILTER (WHERE status = 'running') AS running + FROM tasks + """ + ) + + total_findings_row = await db.fetchone("SELECT COUNT(*) AS total FROM findings") + total_findings = total_findings_row["total"] if total_findings_row else 0 + + critical_findings: int = severity_counts.get("critical", 0) + high_findings: int = severity_counts.get("high", 0) + medium_findings: int = severity_counts.get("medium", 0) + low_findings: int = severity_counts.get("low", 0) + info_findings: int = severity_counts.get("info", 0) + + # Fetch only the 5 most recent findings — not the entire table + recent_rows = await db.fetchall( + """ + SELECT id, title, category, severity, target, description, + remediation, proof, cvss, cve, discovered_at, metadata_json + FROM findings + ORDER BY discovered_at DESC + LIMIT 5 + """ + ) + recent_findings: List[Dict] = parse_json_fields(recent_rows, ["metadata_json"]) + + return { + "total_findings": total_findings, + "critical_findings": critical_findings, + "high_findings": high_findings, + "medium_findings": medium_findings, + "low_findings": low_findings, + "info_findings": info_findings, + "last_scan_time": recent_findings[0].get("discovered_at") if recent_findings else None, + "recent_findings": recent_findings, + "scan_activity": { + "total": int(task_stats["total"]) if task_stats and task_stats.get("total") is not None else 0, + "completed": int(task_stats["completed"]) if task_stats and task_stats.get("completed") is not None else 0, + "running": int(task_stats["running"]) if task_stats and task_stats.get("running") is not None else 0, + }, + "running_tasks": parse_json_fields( + await db.fetchall( + "SELECT id, plugin_id, tool_name, target, status, created_at FROM tasks WHERE status = 'running' ORDER BY created_at DESC LIMIT 5" + ), + [] + ), + "recent_tasks": parse_json_fields( + await db.fetchall( + "SELECT id, plugin_id, tool_name, target, status, created_at, duration_seconds FROM tasks ORDER BY created_at DESC LIMIT 5" + ), + [] + ) + } + + return await get_or_set_cached("summary:dashboard", build) + + +@router.get("/findings", dependencies=[Depends(read_heavy_limiter)]) +async def get_findings(): + """Return vulnerability findings.""" + + async def build(): + db = await get_db() + rows = await db.fetchall("SELECT * FROM findings ORDER BY discovered_at DESC") + return {"findings": parse_json_fields(rows, ["metadata_json"])} + + return await get_or_set_cached("findings:list", build) + + +@router.get("/reports", dependencies=[Depends(read_heavy_limiter)]) +async def get_reports(): + """Return generated reports.""" + + async def build(): + db = await get_db() + rows = await db.fetchall("SELECT * FROM reports ORDER BY generated_at DESC") + return {"reports": parse_json_fields(rows, ["metadata_json"])} + + return await get_or_set_cached("reports:list", build) + + +@router.get("/tasks", dependencies=[Depends(read_heavy_limiter)]) +async def list_tasks( + page: int = 1, + per_page: int = 25, + plugin_id: Optional[str] = None, + status: Optional[str] = None +): + """List all tasks with pagination""" + db = await get_db() + + # Build query + query = "SELECT id, plugin_id, tool_name, target, status, created_at, duration_seconds, inputs_json, preset, error_message, exit_code FROM tasks" + params = [] + + where_clauses = [] + if plugin_id: + where_clauses.append("plugin_id = ?") + params.append(plugin_id) + if status: + where_clauses.append("status = ?") + params.append(status) + + if where_clauses: + query += " WHERE " + " AND ".join(where_clauses) + + query += " ORDER BY created_at DESC LIMIT ? OFFSET ?" + params.extend([per_page, (page - 1) * per_page]) + + tasks = await db.fetchall(query, tuple(params)) + + # Get total count + count_query = "SELECT COUNT(*) as total FROM tasks" + if where_clauses: + count_query += " WHERE " + " AND ".join(where_clauses) + + count_result = await db.fetchone(count_query, tuple(params[:-2]) if where_clauses else ()) + total: int = int(count_result["total"]) if count_result and count_result.get("total") is not None else 0 + + # Parse JSON fields and format for frontend + tasks_list = parse_json_fields(tasks, ["structured_json", "config_json", "metadata_json", "inputs_json"]) + for t in tasks_list: + if "id" in t: + t["task_id"] = t.pop("id") + 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, + "next": build_page_url(next_page), # ← NEW + "previous": build_page_url(prev_page) # ← NEW + } + } + + +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"]: + try: + path = Path(row["raw_output_path"]) + if path.exists(): + path.unlink() + except Exception as e: + logger.error(f"Failed to delete raw output file {row['raw_output_path']}: {e}") + +@router.delete("/task/{task_id}") +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": + raise HTTPException(status_code=400, detail="Cannot delete a running task. Abort it first.") + + await delete_task_records([task_id]) + await invalidate_view_cache() + + return { + "task_id": task_id, + "deleted": True + } + + +@router.delete("/tasks/bulk") +async def bulk_delete_tasks(task_ids: List[str]): + """Delete multiple tasks at once""" + 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)) + 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)""" + 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: + raise HTTPException(status_code=400, detail="Cannot clear history while tasks are running.") + + # Get all task IDs to cleanup files + all_tasks = await db.fetchall("SELECT id FROM tasks") + task_ids = [t["id"] for t in all_tasks] + if task_ids: + await delete_task_records(task_ids) + + # 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 + if dir_path.exists(): + for item in dir_path.iterdir(): + try: + if item.is_file(): + item.unlink() + elif item.is_dir(): + shutil.rmtree(item) + except Exception as e: + 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." + } + + +@router.get("/settings") +async def get_settings(): + """Get current settings""" + return { + "network": { + "bind_address": settings.bind_address, + "port": settings.bind_port, + "allow_remote": False + }, + "sandbox": { + "engine": "docker" if settings.docker_enabled else "subprocess", + "default_timeout": settings.sandbox_timeout, + "resource_limits": { + "cpu_quota": settings.sandbox_cpu_quota, + "memory_mb": settings.sandbox_memory_mb + } + }, + "safety": { + "require_consent": settings.require_consent, + "safe_mode_default": settings.safe_mode_default, + "allowed_networks": settings.allowed_networks + } + } + + +@router.get("/vault", dependencies=[Depends(vault_limiter)]) +async def list_vault_secrets(): + db = await get_db() + rows = await db.fetchall( + "SELECT id, name, created_at, updated_at FROM credential_vault ORDER BY name ASC" + ) + return {"items": rows, "total": len(rows)} + + +@router.put("/vault/{name}", dependencies=[Depends(vault_limiter)]) +async def upsert_vault_secret(name: str, payload: Dict[str, str]): + value = str(payload.get("value", "")) + if not value: + raise HTTPException(status_code=400, detail="Secret value is required") + + db = await get_db() + crypto = VaultCrypto(settings.resolved_vault_key) + encrypted = crypto.encrypt(value) + secret_id = str(uuid.uuid4()) + + existing = await db.fetchone("SELECT id FROM credential_vault WHERE name = ?", (name,)) + if existing: + await db.execute( + "UPDATE credential_vault SET encrypted_value = ?, updated_at = datetime('now') WHERE name = ?", + (encrypted, name), + ) + else: + await db.execute( + "INSERT INTO credential_vault (id, name, encrypted_value) VALUES (?, ?, ?)", + (secret_id, name, encrypted), + ) + return {"name": name, "stored": True} + + +@router.get("/vault/{name}", dependencies=[Depends(vault_limiter)]) +async def get_vault_secret(name: str): + db = await get_db() + row = await db.fetchone("SELECT encrypted_value FROM credential_vault WHERE name = ?", (name,)) + if not row: + raise HTTPException(status_code=404, detail="Secret not found") + crypto = VaultCrypto(settings.resolved_vault_key) + return {"name": name, "value": crypto.decrypt(row["encrypted_value"])} + + +@router.delete("/vault/{name}", dependencies=[Depends(vault_limiter)]) +async def delete_vault_secret(name: str): + db = await get_db() + await db.execute("DELETE FROM credential_vault WHERE name = ?", (name,)) + return {"name": name, "deleted": True} + + +@router.get("/workflows") +async def list_workflows(): + db = await get_db() + rows = await db.fetchall("SELECT * FROM workflows ORDER BY created_at DESC") + return {"workflows": parse_json_fields(rows, ["steps_json"]), "total": len(rows)} + + +@router.post("/workflows") +async def create_workflow(payload: Dict[str, Any]): + name = str(payload.get("name", "")).strip() + if not name: + raise HTTPException(status_code=400, detail="Workflow name is required") + + steps = payload.get("steps", []) + if not isinstance(steps, list) or not steps: + raise HTTPException(status_code=400, detail="Workflow requires at least one step") + + workflow_id = str(uuid.uuid4()) + schedule_seconds = payload.get("schedule_seconds") + enabled = bool(payload.get("enabled", True)) + db = await get_db() + await db.execute( + """ + INSERT INTO workflows (id, name, schedule_seconds, enabled, steps_json) + VALUES (?, ?, ?, ?, ?) + """, + ( + workflow_id, + name, + int(schedule_seconds) if schedule_seconds else None, + 1 if enabled else 0, + json.dumps(steps), + ), + ) + return {"id": workflow_id, "created": True} + + +@router.post("/workflows/{workflow_id}/run") +async def run_workflow_once(workflow_id: str): + db = await get_db() + row = await db.fetchone("SELECT steps_json FROM workflows WHERE id = ?", (workflow_id,)) + if not row: + raise HTTPException(status_code=404, detail="Workflow not found") + steps = json.loads(row["steps_json"] or "[]") + created_task_ids: List[str] = [] + for step in steps: + task_id = await executor.create_task( + step.get("plugin_id"), + step.get("inputs", {}), + step.get("preset"), + consent_granted=True, + ) + asyncio.create_task(executor.execute_task(task_id)) + created_task_ids.append(task_id) + await db.execute("UPDATE workflows SET last_run_at = datetime('now') WHERE id = ?", (workflow_id,)) + return {"workflow_id": workflow_id, "queued_tasks": created_task_ids} + + +@router.patch("/workflows/{workflow_id}") +async def update_workflow(workflow_id: str, payload: Dict[str, Any]): + db = await get_db() + row = await db.fetchone("SELECT id FROM workflows WHERE id = ?", (workflow_id,)) + if not row: + raise HTTPException(status_code=404, detail="Workflow not found") + + updates = [] + params: List[Any] = [] + if "name" in payload: + updates.append("name = ?") + params.append(str(payload["name"]).strip()) + if "steps" in payload: + updates.append("steps_json = ?") + params.append(json.dumps(payload["steps"])) + if "schedule_seconds" in payload: + val = payload["schedule_seconds"] + updates.append("schedule_seconds = ?") + params.append(int(val) if val else None) + if "enabled" in payload: + updates.append("enabled = ?") + params.append(1 if payload["enabled"] else 0) + + if not updates: + raise HTTPException(status_code=400, detail="No update fields provided") + + params.append(workflow_id) + await db.execute(f"UPDATE workflows SET {', '.join(updates)} WHERE id = ?", tuple(params)) + return {"workflow_id": workflow_id, "updated": True} + + +@router.delete("/workflows/{workflow_id}") +async def delete_workflow(workflow_id: str): + db = await get_db() + await db.execute("DELETE FROM workflows WHERE id = ?", (workflow_id,)) + return {"workflow_id": workflow_id, "deleted": True} + + +@router.post("/workflows/scheduler/tick") +async def trigger_workflow_tick(): + await scheduler.tick() + return {"tick": "ok"} + + +@router.get("/finding/{finding_id}") +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 + FROM findings f + JOIN tasks t ON f.task_id = t.id + WHERE f.id = ? + """, + (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"], + "plugin_id": finding_row["plugin_id"], + "tool": finding_row["tool_name"], + "title": finding_row["title"], + "category": finding_row["category"], + "severity": finding_row["severity"], + "target": finding_row["target"], + "description": finding_row["description"], + "remediation": finding_row["remediation"], + "proof": finding_row["proof"], + "cvss": finding_row["cvss"], + "cve": finding_row["cve"], + "discovered_at": finding_row["discovered_at"], + "metadata": metadata + } + + +@router.get("/attack-surface") +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"] + if target not in seen_targets: + entries.append({ + "id": str(uuid.uuid4()), + "category": f["category"], + "item": target, + "details": f"Active exposure identified in {f['category']}", + "risk": f["severity"], + "source": "Audit Scan", + "last_seen": f["discovered_at"] + }) + seen_targets.add(target) + + # Add other scanned targets + for t in tasks: + target = t["target"] + if target not in seen_targets: + entries.append({ + "id": str(uuid.uuid4()), + "category": "Infrastructure", + "item": target, + "details": f"Monitored via {t['tool_name']}", + "risk": "info", + "source": "Recon", + "last_seen": t["created_at"] + }) + seen_targets.add(target) + + return {"entries": entries} + + +@router.get("/assets") +async def get_assets(): + """Return a list of tracked assets.""" + db = await get_db() + # 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} + + +@router.post("/integrations/ticket", response_model=TicketResponse) +async def create_ticket(request: TicketCreateRequest): + """Create a ticket in an external issue tracker""" + try: + if request.provider == "jira": + result = await create_jira_ticket(request.finding, request.config) + return TicketResponse(**result) + elif request.provider == "github": + result = await create_github_issue(request.finding, request.config) + return TicketResponse(**result) + else: + raise HTTPException(status_code=400, detail="Unsupported provider") + except Exception as e: + logger.exception("Ticket creation failed") + raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 7c7fc0e0..4fa6e506 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -240,4 +240,12 @@ export function deleteWorkflow(workflowId: string): Promise<{ deleted: boolean } return request<{ deleted: boolean }>(`/workflows/${workflowId}`, { method: 'DELETE', }) +} + +export function createTicket(provider: string, finding: any, config: Record): Promise<{ ticket_id: string; ticket_url: string }> { + return request<{ ticket_id: string; ticket_url: string }>('/integrations/ticket', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider, finding, config }), + }) } \ No newline at end of file diff --git a/frontend/src/pages/Findings.tsx b/frontend/src/pages/Findings.tsx index cfc78824..5dd2a12d 100644 --- a/frontend/src/pages/Findings.tsx +++ b/frontend/src/pages/Findings.tsx @@ -1,7 +1,8 @@ import React, { useEffect, useMemo, useState } from 'react' import { motion } from 'framer-motion' -import { getFindings } from '../api' +import { getFindings, createTicket } from '../api' import { formatLocaleDate, parseDateSafe, getCurrentTimeZone } from '../utils/date' +import { useToast } from '../components/ToastContext' type Finding = { id: string severity: string @@ -91,6 +92,7 @@ const filterControlClass = type SortMode = 'severity' | 'newest' | 'oldest' | 'target' export default function Findings() { + const { addToast } = useToast() const [findings, setFindings] = useState([]) const [loading, setLoading] = useState(true) const [searchQuery, setSearchQuery] = useState('') @@ -316,6 +318,20 @@ export default function Findings() { } } + async function exportToTracker(finding: Finding & { status: FindingStatus }, provider: 'jira' | 'github') { + try { + const savedConfig = localStorage.getItem('secuscan-config') + const config = savedConfig ? JSON.parse(savedConfig) : {} + + addToast(`Exporting to ${provider.toUpperCase()}...`, 'info') + const result = await createTicket(provider, finding, config) + addToast(`Successfully created ${provider.toUpperCase()} ticket: ${result.ticket_id}`, 'success') + window.open(result.ticket_url, '_blank', 'noopener,noreferrer') + } catch (error: any) { + addToast(`Failed to create ${provider.toUpperCase()} ticket: ${error.message}`, 'error') + } + } + function renderFindingRow(finding: Finding & { severity: string; status: FindingStatus }) { const isSelected = selectedFinding?.id === finding.id const cfg = severityConfig[finding.severity] @@ -725,6 +741,22 @@ export default function Findings() { {copiedFindingId === selectedFinding.id ? 'Copied' : 'Copy Brief'} +
+ + +
) : ( diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 14061158..eed75390 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -19,6 +19,12 @@ const DEFAULT_CONFIG = { dataRetention: 30, // days shodanKey: '', virustotalKey: '', + jiraUrl: '', + jiraEmail: '', + jiraToken: '', + jiraProject: '', + githubToken: '', + githubRepo: '', ipWhitelist: '127.0.0.1\n10.0.0.0/8', autoPurgeFailed: false, autoRescanCritical: true, @@ -263,6 +269,63 @@ export default function Settings() { +
+
+

External_Integrations

+
+
+
+ setConfig({...config, jiraUrl: val})} + /> + setConfig({...config, jiraEmail: val})} + /> + setConfig({...config, jiraToken: val})} + /> + setConfig({...config, jiraProject: val})} + /> + setConfig({...config, githubToken: val})} + /> + setConfig({...config, githubRepo: val})} + /> +
+
+

Access_Perimeters

From 2270cc389bea42fc5adde45543d47d6f41859517 Mon Sep 17 00:00:00 2001 From: Somil450 Date: Fri, 29 May 2026 00:12:31 +0530 Subject: [PATCH 2/5] Fix PR review comments: secure credentials in backend, fix formatting, add tests --- backend/secuscan/models.py | 6 +- backend/secuscan/routes.py | 2433 +++++++++-------- frontend/src/__tests__/Findings.test.tsx | 31 + frontend/src/api.ts | 12 +- frontend/src/pages/Findings.tsx | 5 +- frontend/src/pages/Settings.tsx | 30 +- frontend/testing/unit/AppRoutes.test.tsx | 10 +- frontend/testing/unit/pages/Findings.test.tsx | 10 +- testing/backend/unit/test_integrations.py | 46 + 9 files changed, 1399 insertions(+), 1184 deletions(-) create mode 100644 frontend/src/__tests__/Findings.test.tsx create mode 100644 testing/backend/unit/test_integrations.py diff --git a/backend/secuscan/models.py b/backend/secuscan/models.py index c040de38..e15bb132 100644 --- a/backend/secuscan/models.py +++ b/backend/secuscan/models.py @@ -170,11 +170,15 @@ class ErrorResponse(BaseModel): details: Optional[Dict[str, Any]] = None +class BulkDeleteRequest(RootModel[Annotated[List[str], Field(max_length=MAX_BULK_DELETE)]]): + """Accepts a JSON array of task IDs directly. Max 500 per request.""" + pass + + class TicketCreateRequest(BaseModel): """Request to create a ticket in an external integration""" provider: str # "jira" or "github" finding: Finding - config: Dict[str, str] # credentials and config for the integration class TicketResponse(BaseModel): diff --git a/backend/secuscan/routes.py b/backend/secuscan/routes.py index 49d47843..1b745746 100644 --- a/backend/secuscan/routes.py +++ b/backend/secuscan/routes.py @@ -1,1167 +1,1266 @@ -""" -API routes for SecuScan backend -""" - -from fastapi import APIRouter, HTTPException, BackgroundTasks, Response, Request, Depends -from fastapi.responses import JSONResponse -from typing import Any, Optional, List, Dict, Callable -import json -import logging -import re -import os -import shutil -import uuid -import asyncio -from pathlib import Path -from urllib.parse import urlparse - -def parse_json_fields(rows: List[Dict], fields: List[str]) -> List[Dict]: - """Helper to parse stringified JSON fields from SQLite.""" - parsed = [] - for row in rows: - item = dict(row) - for field in fields: - if item.get(field) and isinstance(item[field], str): - try: - item[field] = json.loads(item[field]) - except json.JSONDecodeError: - pass - parsed.append(item) - return parsed - - -def is_filesystem_target(target: str) -> bool: - """Best-effort detection for path-based targets that should bypass host validation.""" - if target.startswith(("/", "./", "../", "~")): - return True - if re.match(r"^[A-Za-z]:[\\/]", target): - return True - if "/" in target and not target.startswith(("http://", "https://")): - return True - return False - - -def _slugify_filename_part(value: str, fallback: str) -> str: - cleaned = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") - return cleaned or fallback - - -def build_report_filename(task: Dict[str, Any], extension: str) -> str: - tool = _slugify_filename_part(str(task.get("tool_name") or task.get("plugin_id") or "scan"), "scan") - - raw_target = str(task.get("target") or "") - parsed = urlparse(raw_target if "://" in raw_target else f"//{raw_target}") - target_source = parsed.netloc or parsed.path or raw_target - target = _slugify_filename_part(target_source, "target") - - created_at = str(task.get("created_at") or "") - date_match = re.search(r"\d{4}-\d{2}-\d{2}", created_at) - date_part = date_match.group(0) if date_match else "report" - - return f"secuscan_{tool}_{target}_{date_part}.{extension}" - -logger = logging.getLogger(__name__) - -from .cache import get_cache -from .models import ( - TaskCreateRequest, TaskResponse, TaskResult, - PluginListResponse, ErrorResponse, TicketCreateRequest, TicketResponse -) -from .config import settings -from .database import get_db -from .plugins import get_plugin_manager, init_plugins -from .executor import executor -from .ratelimit import ( - rate_limiter, concurrent_limiter, - task_start_limiter, vault_limiter, - report_download_limiter, read_heavy_limiter -) -from .validation import validate_target, validate_task_start_payload -from .reporting import reporting -from .vault import VaultCrypto -from .workflows import scheduler -from .integrations import create_jira_ticket, create_github_issue - -from sse_starlette.sse import EventSourceResponse - -router = APIRouter(prefix="/api/v1") - - -async def get_or_set_cached(key: str, builder): - """Read from cache, or build and cache a JSON response.""" - cache = await get_cache() - cached = await cache.get_json(key) - if cached is not None: - return cached - - value = await builder() - await cache.set_json(key, value) - return value - - -async def invalidate_view_cache(): - """Clear aggregate caches after writes.""" - cache = await get_cache() - for prefix in ["summary:", "findings:", "reports:", "tasks:"]: - 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 - changes reflect parser/metadata edits without requiring a backend restart. - """ - if settings.debug: - return await init_plugins(settings.plugins_dir) - return get_plugin_manager() - - -@router.get("/plugins", response_model=PluginListResponse) -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): - """Get plugin schema for UI generation""" - plugin_manager = await get_plugin_manager_for_request() - if schema := plugin_manager.get_plugin_schema(plugin_id): - return schema - else: - raise HTTPException(status_code=404, detail=f"Plugin not found: {plugin_id}") - - -@router.get("/presets") -async def get_all_presets(): - """Get all plugin presets""" - plugin_manager = await get_plugin_manager_for_request() - return { - plugin_id: plugin.presets - for plugin_id, plugin in plugin_manager.plugins.items() - } - - -@router.post("/task/start", dependencies=[Depends(task_start_limiter)]) -async def start_task( - request: TaskCreateRequest, - background_tasks: BackgroundTasks, - raw_request: Request, -): - """ - 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}") - raise HTTPException( - status_code=400, - detail="Consent required. You must acknowledge the legal notice." - ) - - # Get plugin - plugin_manager = await get_plugin_manager_for_request() - plugin = plugin_manager.get_plugin(request.plugin_id) - - if not plugin: - logger.warning(f"Task start failed: Plugin not found: {request.plugin_id}") - raise HTTPException(status_code=404, detail=f"Plugin not found: {request.plugin_id}") - - if target := request.inputs.get("target"): - safe_mode = request.inputs.get("safe_mode", settings.safe_mode_default) - target_str = str(target) - should_validate_target = plugin.category != "code" and not is_filesystem_target(target_str) - - if should_validate_target: - is_valid, error_msg = validate_target(target_str, safe_mode) - - if not is_valid: - logger.warning(f"Task start failed: Target validation failed for '{target}': {error_msg}") - raise HTTPException(status_code=400, detail=error_msg) - - # Check rate limits - can_execute, error_msg = await rate_limiter.can_execute( - request.plugin_id, - plugin.safety.get("rate_limit", {}).get("max_per_hour", settings.max_tasks_per_hour) - ) - - if not can_execute: - raise HTTPException(status_code=429, detail=error_msg) - - # Create task record first so we have a real task_id for the limiter - try: - task_id = await executor.create_task( - request.plugin_id, - request.inputs, - request.preset, - request.consent_granted - ) - 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): - """Get task status""" - status = await executor.get_task_status(task_id) - - if not status: - raise HTTPException(status_code=404, detail="Task not found") - - return status - -@router.get("/task/{task_id}/stream") -async def stream_task_output(task_id: str): - """Stream task output via Server-Sent Events (SSE)""" - import asyncio - - status = await executor.get_task_status(task_id) - if not status: - raise HTTPException(status_code=404, detail="Task not found") - - async def event_generator(): - # First, send the initial status - yield { - "event": "status", - "data": json.dumps({"status": status["status"]}) - } - - # If it's already completed/failed, we just return the raw output if any and close - if status["status"] in ["completed", "failed", "cancelled"]: - try: - db = await get_db() - task_row = await db.fetchone("SELECT raw_output_path FROM tasks WHERE id = ?", (task_id,)) - if task_row and task_row["raw_output_path"]: - with open(task_row["raw_output_path"], "r") as f: - yield { - "event": "output", - "data": json.dumps({"chunk": f.read()}) - } - except Exception: - pass - return - - # Otherwise, subscribe to the live task events - queue = executor.subscribe(task_id) - try: - while True: - # Wait for the next event from the executor - event = await queue.get() - - if event["type"] == "status": - yield { - "event": "status", - "data": json.dumps({"status": event["data"]}) - } - if event["data"] in ["completed", "failed", "cancelled"]: - break - elif event["type"] == "output": - yield { - "event": "output", - "data": json.dumps({"chunk": event["data"]}) - } - except asyncio.CancelledError: - pass - finally: - executor.unsubscribe(task_id, queue) - - return EventSourceResponse(event_generator()) - -@router.get("/task/{task_id}/report/csv", dependencies=[Depends(report_download_limiter)]) -async def download_csv_report(task_id: str): - """Download task results as a CSV 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 {} - 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")}"'} - ) - -@router.get("/task/{task_id}/report/html", dependencies=[Depends(report_download_limiter)]) -async def download_html_report(task_id: str): - """Download task results as an HTML 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 {} - 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")}"'} - ) - -@router.get("/task/{task_id}/report/pdf", dependencies=[Depends(report_download_limiter)]) -async def download_pdf_report(task_id: str): - """Download task results as a PDF 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 {} - 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")}"'} - ) - - -@router.get("/task/{task_id}/report/sarif", dependencies=[Depends(report_download_limiter)]) -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")}"'} - ) - - -@router.get("/task/{task_id}/result") -async def get_task_result(task_id: str): - """Get task execution result""" - db = await get_db() - - task_row = await db.fetchone( - """ - SELECT id, plugin_id, tool_name, target, status, - created_at, duration_seconds, structured_json, preset, inputs_json, - raw_output_path, command_used, error_message, exit_code - FROM tasks WHERE id = ? - """, - (task_id,) - ) - - if not task_row: - raise HTTPException(status_code=404, detail="Task not found") - - structured = {} - if task_row["structured_json"]: - try: - structured = json.loads(task_row["structured_json"]) - except json.JSONDecodeError: - structured = {} - - findings = structured.get("findings", []) if isinstance(structured, dict) else [] - severity_counts: Dict[str, int] = {} - for finding in findings: - severity = str(finding.get("severity", "info")).lower() - severity_counts[severity] = severity_counts.get(severity, 0) + 1 - - 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 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.") - 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.") - - # Read raw output (limit to 100k for performance, but usually enough) - raw_output = None - if task_row["raw_output_path"]: - try: - with open(task_row["raw_output_path"], 'r') as f: - raw_output = f.read(100000) - except Exception: - pass - - return { - "task_id": task_row["id"], - "plugin_id": task_row["plugin_id"], - "tool": task_row["tool_name"], - "target": task_row["target"], - "timestamp": task_row["created_at"], - "duration_seconds": task_row["duration_seconds"], - "status": task_row["status"], - "preset": task_row["preset"], - "inputs": json.loads(task_row["inputs_json"] or "{}"), - "summary": summary, - "severity_counts": severity_counts, - "findings": findings, - "structured": structured, - "raw_output_path": task_row["raw_output_path"], - "raw_output_excerpt": raw_output, - "raw_output": raw_output, - "command_used": task_row["command_used"], - "errors": [{"message": task_row["error_message"]}] if task_row["error_message"] else [], - "error_message": task_row["error_message"], - "exit_code": task_row["exit_code"], - "metadata": {} - } - - -@router.post("/task/{task_id}/cancel") -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", - "cancelled_at": "now" - } - - -@router.get("/dashboard/summary", dependencies=[Depends(read_heavy_limiter)]) -async def get_dashboard_summary(): - """Return aggregate dashboard data from the primary store, cached in Redis.""" - - async def build(): - db = await get_db() - - # Get data - # Push severity aggregation to DB — avoids full table scan in Python - severity_rows = await db.fetchall( - """ - SELECT severity, COUNT(*) AS cnt - FROM findings - GROUP BY severity - """ - ) - severity_counts = {row["severity"]: row["cnt"] for row in severity_rows} - - task_stats = await db.fetchone( - """ - SELECT - COUNT(*) AS total, - COUNT(*) FILTER (WHERE status = 'completed') AS completed, - COUNT(*) FILTER (WHERE status = 'running') AS running - FROM tasks - """ - ) - - total_findings_row = await db.fetchone("SELECT COUNT(*) AS total FROM findings") - total_findings = total_findings_row["total"] if total_findings_row else 0 - - critical_findings: int = severity_counts.get("critical", 0) - high_findings: int = severity_counts.get("high", 0) - medium_findings: int = severity_counts.get("medium", 0) - low_findings: int = severity_counts.get("low", 0) - info_findings: int = severity_counts.get("info", 0) - - # Fetch only the 5 most recent findings — not the entire table - recent_rows = await db.fetchall( - """ - SELECT id, title, category, severity, target, description, - remediation, proof, cvss, cve, discovered_at, metadata_json - FROM findings - ORDER BY discovered_at DESC - LIMIT 5 - """ - ) - recent_findings: List[Dict] = parse_json_fields(recent_rows, ["metadata_json"]) - - return { - "total_findings": total_findings, - "critical_findings": critical_findings, - "high_findings": high_findings, - "medium_findings": medium_findings, - "low_findings": low_findings, - "info_findings": info_findings, - "last_scan_time": recent_findings[0].get("discovered_at") if recent_findings else None, - "recent_findings": recent_findings, - "scan_activity": { - "total": int(task_stats["total"]) if task_stats and task_stats.get("total") is not None else 0, - "completed": int(task_stats["completed"]) if task_stats and task_stats.get("completed") is not None else 0, - "running": int(task_stats["running"]) if task_stats and task_stats.get("running") is not None else 0, - }, - "running_tasks": parse_json_fields( - await db.fetchall( - "SELECT id, plugin_id, tool_name, target, status, created_at FROM tasks WHERE status = 'running' ORDER BY created_at DESC LIMIT 5" - ), - [] - ), - "recent_tasks": parse_json_fields( - await db.fetchall( - "SELECT id, plugin_id, tool_name, target, status, created_at, duration_seconds FROM tasks ORDER BY created_at DESC LIMIT 5" - ), - [] - ) - } - - return await get_or_set_cached("summary:dashboard", build) - - -@router.get("/findings", dependencies=[Depends(read_heavy_limiter)]) -async def get_findings(): - """Return vulnerability findings.""" - - async def build(): - db = await get_db() - rows = await db.fetchall("SELECT * FROM findings ORDER BY discovered_at DESC") - return {"findings": parse_json_fields(rows, ["metadata_json"])} - - return await get_or_set_cached("findings:list", build) - - -@router.get("/reports", dependencies=[Depends(read_heavy_limiter)]) -async def get_reports(): - """Return generated reports.""" - - async def build(): - db = await get_db() - rows = await db.fetchall("SELECT * FROM reports ORDER BY generated_at DESC") - return {"reports": parse_json_fields(rows, ["metadata_json"])} - - return await get_or_set_cached("reports:list", build) - - -@router.get("/tasks", dependencies=[Depends(read_heavy_limiter)]) -async def list_tasks( - page: int = 1, - per_page: int = 25, - plugin_id: Optional[str] = None, - status: Optional[str] = None -): - """List all tasks with pagination""" - db = await get_db() - - # Build query - query = "SELECT id, plugin_id, tool_name, target, status, created_at, duration_seconds, inputs_json, preset, error_message, exit_code FROM tasks" - params = [] - - where_clauses = [] - if plugin_id: - where_clauses.append("plugin_id = ?") - params.append(plugin_id) - if status: - where_clauses.append("status = ?") - params.append(status) - - if where_clauses: - query += " WHERE " + " AND ".join(where_clauses) - - query += " ORDER BY created_at DESC LIMIT ? OFFSET ?" - params.extend([per_page, (page - 1) * per_page]) - - tasks = await db.fetchall(query, tuple(params)) - - # Get total count - count_query = "SELECT COUNT(*) as total FROM tasks" - if where_clauses: - count_query += " WHERE " + " AND ".join(where_clauses) - - count_result = await db.fetchone(count_query, tuple(params[:-2]) if where_clauses else ()) - total: int = int(count_result["total"]) if count_result and count_result.get("total") is not None else 0 - - # Parse JSON fields and format for frontend - tasks_list = parse_json_fields(tasks, ["structured_json", "config_json", "metadata_json", "inputs_json"]) - for t in tasks_list: - if "id" in t: - t["task_id"] = t.pop("id") - 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, - "next": build_page_url(next_page), # ← NEW - "previous": build_page_url(prev_page) # ← NEW - } - } - - -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"]: - try: - path = Path(row["raw_output_path"]) - if path.exists(): - path.unlink() - except Exception as e: - logger.error(f"Failed to delete raw output file {row['raw_output_path']}: {e}") - -@router.delete("/task/{task_id}") -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": - raise HTTPException(status_code=400, detail="Cannot delete a running task. Abort it first.") - - await delete_task_records([task_id]) - await invalidate_view_cache() - - return { - "task_id": task_id, - "deleted": True - } - - -@router.delete("/tasks/bulk") -async def bulk_delete_tasks(task_ids: List[str]): - """Delete multiple tasks at once""" - 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)) - 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)""" - 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: - raise HTTPException(status_code=400, detail="Cannot clear history while tasks are running.") - - # Get all task IDs to cleanup files - all_tasks = await db.fetchall("SELECT id FROM tasks") - task_ids = [t["id"] for t in all_tasks] - if task_ids: - await delete_task_records(task_ids) - - # 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 - if dir_path.exists(): - for item in dir_path.iterdir(): - try: - if item.is_file(): - item.unlink() - elif item.is_dir(): - shutil.rmtree(item) - except Exception as e: - 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." - } - - -@router.get("/settings") -async def get_settings(): - """Get current settings""" - return { - "network": { - "bind_address": settings.bind_address, - "port": settings.bind_port, - "allow_remote": False - }, - "sandbox": { - "engine": "docker" if settings.docker_enabled else "subprocess", - "default_timeout": settings.sandbox_timeout, - "resource_limits": { - "cpu_quota": settings.sandbox_cpu_quota, - "memory_mb": settings.sandbox_memory_mb - } - }, - "safety": { - "require_consent": settings.require_consent, - "safe_mode_default": settings.safe_mode_default, - "allowed_networks": settings.allowed_networks - } - } - - -@router.get("/vault", dependencies=[Depends(vault_limiter)]) -async def list_vault_secrets(): - db = await get_db() - rows = await db.fetchall( - "SELECT id, name, created_at, updated_at FROM credential_vault ORDER BY name ASC" - ) - return {"items": rows, "total": len(rows)} - - -@router.put("/vault/{name}", dependencies=[Depends(vault_limiter)]) -async def upsert_vault_secret(name: str, payload: Dict[str, str]): - value = str(payload.get("value", "")) - if not value: - raise HTTPException(status_code=400, detail="Secret value is required") - - db = await get_db() - crypto = VaultCrypto(settings.resolved_vault_key) - encrypted = crypto.encrypt(value) - secret_id = str(uuid.uuid4()) - - existing = await db.fetchone("SELECT id FROM credential_vault WHERE name = ?", (name,)) - if existing: - await db.execute( - "UPDATE credential_vault SET encrypted_value = ?, updated_at = datetime('now') WHERE name = ?", - (encrypted, name), - ) - else: - await db.execute( - "INSERT INTO credential_vault (id, name, encrypted_value) VALUES (?, ?, ?)", - (secret_id, name, encrypted), - ) - return {"name": name, "stored": True} - - -@router.get("/vault/{name}", dependencies=[Depends(vault_limiter)]) -async def get_vault_secret(name: str): - db = await get_db() - row = await db.fetchone("SELECT encrypted_value FROM credential_vault WHERE name = ?", (name,)) - if not row: - raise HTTPException(status_code=404, detail="Secret not found") - crypto = VaultCrypto(settings.resolved_vault_key) - return {"name": name, "value": crypto.decrypt(row["encrypted_value"])} - - -@router.delete("/vault/{name}", dependencies=[Depends(vault_limiter)]) -async def delete_vault_secret(name: str): - db = await get_db() - await db.execute("DELETE FROM credential_vault WHERE name = ?", (name,)) - return {"name": name, "deleted": True} - - -@router.get("/workflows") -async def list_workflows(): - db = await get_db() - rows = await db.fetchall("SELECT * FROM workflows ORDER BY created_at DESC") - return {"workflows": parse_json_fields(rows, ["steps_json"]), "total": len(rows)} - - -@router.post("/workflows") -async def create_workflow(payload: Dict[str, Any]): - name = str(payload.get("name", "")).strip() - if not name: - raise HTTPException(status_code=400, detail="Workflow name is required") - - steps = payload.get("steps", []) - if not isinstance(steps, list) or not steps: - raise HTTPException(status_code=400, detail="Workflow requires at least one step") - - workflow_id = str(uuid.uuid4()) - schedule_seconds = payload.get("schedule_seconds") - enabled = bool(payload.get("enabled", True)) - db = await get_db() - await db.execute( - """ - INSERT INTO workflows (id, name, schedule_seconds, enabled, steps_json) - VALUES (?, ?, ?, ?, ?) - """, - ( - workflow_id, - name, - int(schedule_seconds) if schedule_seconds else None, - 1 if enabled else 0, - json.dumps(steps), - ), - ) - return {"id": workflow_id, "created": True} - - -@router.post("/workflows/{workflow_id}/run") -async def run_workflow_once(workflow_id: str): - db = await get_db() - row = await db.fetchone("SELECT steps_json FROM workflows WHERE id = ?", (workflow_id,)) - if not row: - raise HTTPException(status_code=404, detail="Workflow not found") - steps = json.loads(row["steps_json"] or "[]") - created_task_ids: List[str] = [] - for step in steps: - task_id = await executor.create_task( - step.get("plugin_id"), - step.get("inputs", {}), - step.get("preset"), - consent_granted=True, - ) - asyncio.create_task(executor.execute_task(task_id)) - created_task_ids.append(task_id) - await db.execute("UPDATE workflows SET last_run_at = datetime('now') WHERE id = ?", (workflow_id,)) - return {"workflow_id": workflow_id, "queued_tasks": created_task_ids} - - -@router.patch("/workflows/{workflow_id}") -async def update_workflow(workflow_id: str, payload: Dict[str, Any]): - db = await get_db() - row = await db.fetchone("SELECT id FROM workflows WHERE id = ?", (workflow_id,)) - if not row: - raise HTTPException(status_code=404, detail="Workflow not found") - - updates = [] - params: List[Any] = [] - if "name" in payload: - updates.append("name = ?") - params.append(str(payload["name"]).strip()) - if "steps" in payload: - updates.append("steps_json = ?") - params.append(json.dumps(payload["steps"])) - if "schedule_seconds" in payload: - val = payload["schedule_seconds"] - updates.append("schedule_seconds = ?") - params.append(int(val) if val else None) - if "enabled" in payload: - updates.append("enabled = ?") - params.append(1 if payload["enabled"] else 0) - - if not updates: - raise HTTPException(status_code=400, detail="No update fields provided") - - params.append(workflow_id) - await db.execute(f"UPDATE workflows SET {', '.join(updates)} WHERE id = ?", tuple(params)) - return {"workflow_id": workflow_id, "updated": True} - - -@router.delete("/workflows/{workflow_id}") -async def delete_workflow(workflow_id: str): - db = await get_db() - await db.execute("DELETE FROM workflows WHERE id = ?", (workflow_id,)) - return {"workflow_id": workflow_id, "deleted": True} - - -@router.post("/workflows/scheduler/tick") -async def trigger_workflow_tick(): - await scheduler.tick() - return {"tick": "ok"} - - -@router.get("/finding/{finding_id}") -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 - FROM findings f - JOIN tasks t ON f.task_id = t.id - WHERE f.id = ? - """, - (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"], - "plugin_id": finding_row["plugin_id"], - "tool": finding_row["tool_name"], - "title": finding_row["title"], - "category": finding_row["category"], - "severity": finding_row["severity"], - "target": finding_row["target"], - "description": finding_row["description"], - "remediation": finding_row["remediation"], - "proof": finding_row["proof"], - "cvss": finding_row["cvss"], - "cve": finding_row["cve"], - "discovered_at": finding_row["discovered_at"], - "metadata": metadata - } - - -@router.get("/attack-surface") -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"] - if target not in seen_targets: - entries.append({ - "id": str(uuid.uuid4()), - "category": f["category"], - "item": target, - "details": f"Active exposure identified in {f['category']}", - "risk": f["severity"], - "source": "Audit Scan", - "last_seen": f["discovered_at"] - }) - seen_targets.add(target) - - # Add other scanned targets - for t in tasks: - target = t["target"] - if target not in seen_targets: - entries.append({ - "id": str(uuid.uuid4()), - "category": "Infrastructure", - "item": target, - "details": f"Monitored via {t['tool_name']}", - "risk": "info", - "source": "Recon", - "last_seen": t["created_at"] - }) - seen_targets.add(target) - - return {"entries": entries} - - -@router.get("/assets") -async def get_assets(): - """Return a list of tracked assets.""" - db = await get_db() - # 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} - - -@router.post("/integrations/ticket", response_model=TicketResponse) -async def create_ticket(request: TicketCreateRequest): - """Create a ticket in an external issue tracker""" - try: - if request.provider == "jira": - result = await create_jira_ticket(request.finding, request.config) - return TicketResponse(**result) - elif request.provider == "github": - result = await create_github_issue(request.finding, request.config) - return TicketResponse(**result) - else: - raise HTTPException(status_code=400, detail="Unsupported provider") - except Exception as e: - logger.exception("Ticket creation failed") - raise HTTPException(status_code=500, detail=str(e)) +""" +API routes for SecuScan backend +""" + +from fastapi import APIRouter, HTTPException, BackgroundTasks, Response, Request, Depends +from fastapi.responses import JSONResponse +from typing import Any, Optional, List, Dict, Callable +import json +import logging +import re +import os +import shutil +import uuid +import asyncio +from pathlib import Path +from urllib.parse import urlparse + +def parse_json_fields(rows: List[Dict], fields: List[str]) -> List[Dict]: + """Helper to parse stringified JSON fields from SQLite.""" + parsed = [] + for row in rows: + item = dict(row) + for field in fields: + if item.get(field) and isinstance(item[field], str): + try: + item[field] = json.loads(item[field]) + except json.JSONDecodeError: + pass + parsed.append(item) + return parsed + + +def _parse_workflow_steps(raw_steps: Any) -> List[Dict[str, Any]]: + if isinstance(raw_steps, list): + return raw_steps + if not raw_steps: + return [] + try: + parsed = json.loads(raw_steps) + except (TypeError, json.JSONDecodeError): + return [] + return parsed if isinstance(parsed, list) else [] + + +def _serialize_workflow(row: Dict[str, Any], queued_task_ids: Optional[List[str]] = None) -> Dict[str, Any]: + """Return the workflow shape consumed by the frontend.""" + return { + "id": row["id"], + "name": row["name"], + "schedule_seconds": row.get("schedule_seconds"), + "enabled": bool(row.get("enabled")), + "steps": _parse_workflow_steps(row.get("steps_json")), + "created_at": row.get("created_at"), + "last_run_at": row.get("last_run_at"), + "queued_task_ids": queued_task_ids or [], + } + + +def is_filesystem_target(target: str) -> bool: + """Best-effort detection for path-based targets that should bypass host validation.""" + if target.startswith(("/", "./", "../", "~")): + return True + if re.match(r"^[A-Za-z]:[\\/]", target): + return True + if "/" in target and not target.startswith(("http://", "https://")): + return True + return False + + +def _slugify_filename_part(value: str, fallback: str) -> str: + cleaned = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + return cleaned or fallback + + +def build_report_filename(task: Dict[str, Any], extension: str) -> str: + tool = _slugify_filename_part(str(task.get("tool_name") or task.get("plugin_id") or "scan"), "scan") + + raw_target = str(task.get("target") or "") + parsed = urlparse(raw_target if "://" in raw_target else f"//{raw_target}") + target_source = parsed.netloc or parsed.path or raw_target + target = _slugify_filename_part(target_source, "target") + + created_at = str(task.get("created_at") or "") + date_match = re.search(r"\d{4}-\d{2}-\d{2}", created_at) + date_part = date_match.group(0) if date_match else "report" + + return f"secuscan_{tool}_{target}_{date_part}.{extension}" + +logger = logging.getLogger(__name__) + +from .cache import get_cache +from .models import ( + TaskCreateRequest, TaskResponse, TaskResult, + PluginListResponse, ErrorResponse, BulkDeleteRequest, + TicketCreateRequest, TicketResponse +) +from .integrations import create_jira_ticket, create_github_issue +from .config import settings +from .database import get_db +from .plugins import get_plugin_manager, init_plugins +from .executor import executor +from .ratelimit import ( + rate_limiter, concurrent_limiter, + task_start_limiter, vault_limiter, + report_download_limiter, read_heavy_limiter +) +from .validation import validate_target, validate_task_start_payload +from .reporting import reporting +from .vault import VaultCrypto +from .workflows import scheduler + +from sse_starlette.sse import EventSourceResponse + +router = APIRouter(prefix="/api/v1") + + +async def get_or_set_cached(key: str, builder): + """Read from cache, or build and cache a JSON response.""" + cache = await get_cache() + cached = await cache.get_json(key) + if cached is not None: + return cached + + value = await builder() + await cache.set_json(key, value) + return value + + +async def invalidate_view_cache(): + """Clear aggregate caches after writes.""" + cache = await get_cache() + for prefix in ["summary:", "findings:", "reports:", "tasks:"]: + 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 + changes reflect parser/metadata edits without requiring a backend restart. + """ + if settings.debug: + return await init_plugins(settings.plugins_dir) + return get_plugin_manager() + + +@router.get("/plugins", response_model=PluginListResponse) +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): + """Get plugin schema for UI generation""" + plugin_manager = await get_plugin_manager_for_request() + if schema := plugin_manager.get_plugin_schema(plugin_id): + return schema + else: + raise HTTPException(status_code=404, detail=f"Plugin not found: {plugin_id}") + + +@router.get("/presets") +async def get_all_presets(): + """Get all plugin presets""" + plugin_manager = await get_plugin_manager_for_request() + return { + plugin_id: plugin.presets + for plugin_id, plugin in plugin_manager.plugins.items() + } + + +@router.post("/task/start", dependencies=[Depends(task_start_limiter)]) +async def start_task( + request: TaskCreateRequest, + background_tasks: BackgroundTasks, + raw_request: Request, +): + """ + 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}") + raise HTTPException( + status_code=400, + detail="Consent required. You must acknowledge the legal notice." + ) + + # Get plugin + plugin_manager = await get_plugin_manager_for_request() + plugin = plugin_manager.get_plugin(request.plugin_id) + + if not plugin: + logger.warning(f"Task start failed: Plugin not found: {request.plugin_id}") + raise HTTPException(status_code=404, detail=f"Plugin not found: {request.plugin_id}") + + if target := request.inputs.get("target"): + safe_mode = request.inputs.get("safe_mode", settings.safe_mode_default) + target_str = str(target) + should_validate_target = plugin.category != "code" and not is_filesystem_target(target_str) + + if should_validate_target: + is_valid, error_msg = validate_target(target_str, safe_mode) + + if not is_valid: + logger.warning(f"Task start failed: Target validation failed for '{target}': {error_msg}") + raise HTTPException(status_code=400, detail=error_msg) + + # Check rate limits + can_execute, error_msg = await rate_limiter.can_execute( + request.plugin_id, + plugin.safety.get("rate_limit", {}).get("max_per_hour", settings.max_tasks_per_hour) + ) + + if not can_execute: + raise HTTPException(status_code=429, detail=error_msg) + + # Create task record first so we have a real task_id for the limiter + try: + task_id = await executor.create_task( + request.plugin_id, + request.inputs, + request.preset, + request.consent_granted + ) + 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): + """Get task status""" + status = await executor.get_task_status(task_id) + + if not status: + raise HTTPException(status_code=404, detail="Task not found") + + return status + +@router.get("/task/{task_id}/stream") +async def stream_task_output(task_id: str): + """Stream task output via Server-Sent Events (SSE)""" + import asyncio + + status = await executor.get_task_status(task_id) + if not status: + raise HTTPException(status_code=404, detail="Task not found") + + async def event_generator(): + # First, send the initial status + yield { + "event": "status", + "data": json.dumps({"status": status["status"]}) + } + + # If it's already completed/failed, we just return the raw output if any and close + if status["status"] in ["completed", "failed", "cancelled"]: + try: + db = await get_db() + task_row = await db.fetchone("SELECT raw_output_path FROM tasks WHERE id = ?", (task_id,)) + if task_row and task_row["raw_output_path"]: + with open(task_row["raw_output_path"], "r") as f: + yield { + "event": "output", + "data": json.dumps({"chunk": f.read()}) + } + except Exception: + pass + return + + # Otherwise, subscribe to the live task events + queue = executor.subscribe(task_id) + try: + while True: + # Wait for the next event from the executor + event = await queue.get() + + if event["type"] == "status": + yield { + "event": "status", + "data": json.dumps({"status": event["data"]}) + } + if event["data"] in ["completed", "failed", "cancelled"]: + break + elif event["type"] == "output": + yield { + "event": "output", + "data": json.dumps({"chunk": event["data"]}) + } + except asyncio.CancelledError: + pass + finally: + executor.unsubscribe(task_id, queue) + + return EventSourceResponse(event_generator()) + +@router.get("/task/{task_id}/report/csv", dependencies=[Depends(report_download_limiter)]) +async def download_csv_report(task_id: str): + """Download task results as a CSV 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 {} + 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")}"'} + ) + +@router.get("/task/{task_id}/report/html", dependencies=[Depends(report_download_limiter)]) +async def download_html_report(task_id: str): + """Download task results as an HTML 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 {} + 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")}"'} + ) + +@router.get("/task/{task_id}/report/pdf", dependencies=[Depends(report_download_limiter)]) +async def download_pdf_report(task_id: str): + """Download task results as a PDF 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 {} + 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")}"'} + ) + + +@router.get("/task/{task_id}/report/sarif", dependencies=[Depends(report_download_limiter)]) +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")}"'} + ) + + +@router.get("/task/{task_id}/result") +async def get_task_result(task_id: str): + """Get task execution result""" + db = await get_db() + + task_row = await db.fetchone( + """ + SELECT id, plugin_id, tool_name, target, status, + created_at, duration_seconds, structured_json, preset, inputs_json, + raw_output_path, command_used, error_message, exit_code + FROM tasks WHERE id = ? + """, + (task_id,) + ) + + if not task_row: + raise HTTPException(status_code=404, detail="Task not found") + + structured = {} + if task_row["structured_json"]: + try: + structured = json.loads(task_row["structured_json"]) + except json.JSONDecodeError: + structured = {} + + findings = structured.get("findings", []) if isinstance(structured, dict) else [] + severity_counts: Dict[str, int] = {} + for finding in findings: + severity = str(finding.get("severity", "info")).lower() + severity_counts[severity] = severity_counts.get(severity, 0) + 1 + + 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 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.") + 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.") + + # Read raw output (limit to 100k for performance, but usually enough) + raw_output = None + if task_row["raw_output_path"]: + try: + with open(task_row["raw_output_path"], 'r') as f: + raw_output = f.read(100000) + except Exception: + pass + + return { + "task_id": task_row["id"], + "plugin_id": task_row["plugin_id"], + "tool": task_row["tool_name"], + "target": task_row["target"], + "timestamp": task_row["created_at"], + "duration_seconds": task_row["duration_seconds"], + "status": task_row["status"], + "preset": task_row["preset"], + "inputs": json.loads(task_row["inputs_json"] or "{}"), + "summary": summary, + "severity_counts": severity_counts, + "findings": findings, + "structured": structured, + "raw_output_path": task_row["raw_output_path"], + "raw_output_excerpt": raw_output, + "raw_output": raw_output, + "command_used": task_row["command_used"], + "errors": [{"message": task_row["error_message"]}] if task_row["error_message"] else [], + "error_message": task_row["error_message"], + "exit_code": task_row["exit_code"], + "metadata": {} + } + + +@router.post("/task/{task_id}/cancel") +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", + "cancelled_at": "now" + } + + +@router.get("/dashboard/summary", dependencies=[Depends(read_heavy_limiter)]) +async def get_dashboard_summary(): + """Return aggregate dashboard data from the primary store, cached in Redis.""" + + async def build(): + db = await get_db() + + # Get data + # Push severity aggregation to DB — avoids full table scan in Python + severity_rows = await db.fetchall( + """ + SELECT severity, COUNT(*) AS cnt + FROM findings + GROUP BY severity + """ + ) + severity_counts = {row["severity"]: row["cnt"] for row in severity_rows} + + task_stats = await db.fetchone( + """ + SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE status = 'completed') AS completed, + COUNT(*) FILTER (WHERE status = 'running') AS running + FROM tasks + """ + ) + + total_findings_row = await db.fetchone("SELECT COUNT(*) AS total FROM findings") + total_findings = total_findings_row["total"] if total_findings_row else 0 + + critical_findings: int = severity_counts.get("critical", 0) + high_findings: int = severity_counts.get("high", 0) + medium_findings: int = severity_counts.get("medium", 0) + low_findings: int = severity_counts.get("low", 0) + info_findings: int = severity_counts.get("info", 0) + + # Fetch only the 5 most recent findings — not the entire table + recent_rows = await db.fetchall( + """ + SELECT id, title, category, severity, target, description, + remediation, proof, cvss, cve, discovered_at, + risk_score, risk_factors_json, metadata_json + FROM findings + ORDER BY discovered_at DESC + LIMIT 5 + """ + ) + recent_findings: List[Dict] = parse_json_fields(recent_rows, ["metadata_json"]) + + risk_scores = [ + f.get("risk_score") for f in recent_findings + if isinstance(f.get("risk_score"), (int, float)) + ] + avg_risk_score = round(sum(risk_scores) / len(risk_scores), 1) if risk_scores else None + + return { + "total_findings": total_findings, + "critical_findings": critical_findings, + "high_findings": high_findings, + "medium_findings": medium_findings, + "low_findings": low_findings, + "info_findings": info_findings, + "avg_risk_score": avg_risk_score, + "last_scan_time": recent_findings[0].get("discovered_at") if recent_findings else None, + "recent_findings": recent_findings, + "scan_activity": { + "total": int(task_stats["total"]) if task_stats and task_stats.get("total") is not None else 0, + "completed": int(task_stats["completed"]) if task_stats and task_stats.get("completed") is not None else 0, + "running": int(task_stats["running"]) if task_stats and task_stats.get("running") is not None else 0, + }, + "running_tasks": parse_json_fields( + await db.fetchall( + "SELECT id, plugin_id, tool_name, target, status, created_at FROM tasks WHERE status = 'running' ORDER BY created_at DESC LIMIT 5" + ), + [] + ), + "recent_tasks": parse_json_fields( + await db.fetchall( + "SELECT id, plugin_id, tool_name, target, status, created_at, duration_seconds FROM tasks ORDER BY created_at DESC LIMIT 5" + ), + [] + ) + } + + return await get_or_set_cached("summary:dashboard", build) + + +@router.get("/findings", dependencies=[Depends(read_heavy_limiter)]) +async def get_findings(): + """Return vulnerability findings.""" + + async def build(): + db = await get_db() + rows = await db.fetchall("SELECT * FROM findings ORDER BY discovered_at DESC") + findings = parse_json_fields(rows, ["metadata_json", "risk_factors_json"]) + for f in findings: + if "risk_factors_json" in f: + f["risk_factors"] = f.pop("risk_factors_json") + return {"findings": findings} + + return await get_or_set_cached("findings:list", build) + + +@router.get("/reports", dependencies=[Depends(read_heavy_limiter)]) +async def get_reports(): + """Return generated reports.""" + + async def build(): + db = await get_db() + rows = await db.fetchall("SELECT * FROM reports ORDER BY generated_at DESC") + return {"reports": parse_json_fields(rows, ["metadata_json"])} + + return await get_or_set_cached("reports:list", build) + + +@router.get("/tasks", dependencies=[Depends(read_heavy_limiter)]) +async def list_tasks( + page: int = 1, + per_page: int = 25, + plugin_id: Optional[str] = None, + status: Optional[str] = None +): + """List all tasks with pagination""" + db = await get_db() + + # Build query + query = "SELECT id, plugin_id, tool_name, target, status, created_at, duration_seconds, inputs_json, preset, error_message, exit_code FROM tasks" + params = [] + + where_clauses = [] + if plugin_id: + where_clauses.append("plugin_id = ?") + params.append(plugin_id) + if status: + where_clauses.append("status = ?") + params.append(status) + + if where_clauses: + query += " WHERE " + " AND ".join(where_clauses) + + query += " ORDER BY created_at DESC LIMIT ? OFFSET ?" + params.extend([per_page, (page - 1) * per_page]) + + tasks = await db.fetchall(query, tuple(params)) + + # Get total count + count_query = "SELECT COUNT(*) as total FROM tasks" + if where_clauses: + count_query += " WHERE " + " AND ".join(where_clauses) + + count_result = await db.fetchone(count_query, tuple(params[:-2]) if where_clauses else ()) + total: int = int(count_result["total"]) if count_result and count_result.get("total") is not None else 0 + + # Parse JSON fields and format for frontend + tasks_list = parse_json_fields(tasks, ["structured_json", "config_json", "metadata_json", "inputs_json"]) + for t in tasks_list: + if "id" in t: + t["task_id"] = t.pop("id") + 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, + "next": build_page_url(next_page), + "previous": build_page_url(prev_page) + } + } + + +SQLITE_CHUNK_SIZE = 500 # safely under SQLITE_LIMIT_VARIABLE_NUMBER = 999 + +async def delete_task_records(task_ids: List[str]): + """Helper to delete database records and files for multiple tasks. + + Processes IDs in chunks of SQLITE_CHUNK_SIZE to stay under + SQLite's SQLITE_LIMIT_VARIABLE_NUMBER = 999 limit. + """ + if not task_ids: + return + + db = await get_db() + + # Collect all raw_output_paths across chunks for file cleanup + all_task_rows = [] + for i in range(0, len(task_ids), SQLITE_CHUNK_SIZE): + chunk = task_ids[i : i + SQLITE_CHUNK_SIZE] + placeholders = ",".join(["?"] * len(chunk)) + rows = await db.fetchall( + f"SELECT raw_output_path FROM tasks WHERE id IN ({placeholders})", + tuple(chunk) + ) + all_task_rows.extend(rows) + + # Delete associated records in chunks + for i in range(0, len(task_ids), SQLITE_CHUNK_SIZE): + chunk = task_ids[i : i + SQLITE_CHUNK_SIZE] + placeholders = ",".join(["?"] * len(chunk)) + await db.execute(f"DELETE FROM findings WHERE task_id IN ({placeholders})", tuple(chunk)) + await db.execute(f"DELETE FROM reports WHERE task_id IN ({placeholders})", tuple(chunk)) + await db.execute(f"DELETE FROM audit_log WHERE task_id IN ({placeholders})", tuple(chunk)) + await db.execute(f"DELETE FROM tasks WHERE id IN ({placeholders})", tuple(chunk)) + + # Cleanup files on disk + for row in all_task_rows: + if row and row["raw_output_path"]: + try: + path = Path(row["raw_output_path"]) + if path.exists(): + path.unlink() + except Exception as e: + logger.error(f"Failed to delete raw output file {row['raw_output_path']}: {e}") + +@router.delete("/task/{task_id}") +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": + raise HTTPException(status_code=400, detail="Cannot delete a running task. Abort it first.") + + await delete_task_records([task_id]) + await invalidate_view_cache() + + return { + "task_id": task_id, + "deleted": True + } + + +@router.delete("/tasks/bulk") +async def bulk_delete_tasks(request: BulkDeleteRequest): + """Delete multiple tasks at once (max 500 IDs per request)""" + task_ids = request.root # RootModel exposes data via .root + db = await get_db() + + # Empty list — return early cleanly (test requires 200, not 422) + if not task_ids: + return {"deleted_count": 0, "success": True} + + # Check running tasks — safe: len(task_ids) <= 500 guaranteed by Pydantic + 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: + 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)""" + 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: + raise HTTPException(status_code=400, detail="Cannot clear history while tasks are running.") + + # Get all task IDs to cleanup files + all_tasks = await db.fetchall("SELECT id FROM tasks") + task_ids = [t["id"] for t in all_tasks] + if task_ids: + await delete_task_records(task_ids) + + # 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 + if dir_path.exists(): + for item in dir_path.iterdir(): + try: + if item.is_file(): + item.unlink() + elif item.is_dir(): + shutil.rmtree(item) + except Exception as e: + 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." + } + + +@router.get("/settings") +async def get_settings(): + """Get current settings""" + return { + "network": { + "bind_address": settings.bind_address, + "port": settings.bind_port, + "allow_remote": False + }, + "sandbox": { + "engine": "docker" if settings.docker_enabled else "subprocess", + "default_timeout": settings.sandbox_timeout, + "resource_limits": { + "cpu_quota": settings.sandbox_cpu_quota, + "memory_mb": settings.sandbox_memory_mb + } + }, + "safety": { + "require_consent": settings.require_consent, + "safe_mode_default": settings.safe_mode_default, + "allowed_networks": settings.allowed_networks + } + } + + +@router.get("/vault", dependencies=[Depends(vault_limiter)]) +async def list_vault_secrets(): + db = await get_db() + rows = await db.fetchall( + "SELECT id, name, created_at, updated_at FROM credential_vault ORDER BY name ASC" + ) + return {"items": rows, "total": len(rows)} + + +@router.put("/vault/{name}", dependencies=[Depends(vault_limiter)]) +async def upsert_vault_secret(name: str, payload: Dict[str, str]): + value = str(payload.get("value", "")) + if not value: + raise HTTPException(status_code=400, detail="Secret value is required") + + db = await get_db() + crypto = VaultCrypto(settings.resolved_vault_key) + encrypted = crypto.encrypt(value) + secret_id = str(uuid.uuid4()) + + existing = await db.fetchone("SELECT id FROM credential_vault WHERE name = ?", (name,)) + if existing: + await db.execute( + "UPDATE credential_vault SET encrypted_value = ?, updated_at = datetime('now') WHERE name = ?", + (encrypted, name), + ) + else: + await db.execute( + "INSERT INTO credential_vault (id, name, encrypted_value) VALUES (?, ?, ?)", + (secret_id, name, encrypted), + ) + return {"name": name, "stored": True} + + +@router.get("/vault/{name}", dependencies=[Depends(vault_limiter)]) +async def get_vault_secret(name: str): + db = await get_db() + row = await db.fetchone("SELECT encrypted_value FROM credential_vault WHERE name = ?", (name,)) + if not row: + raise HTTPException(status_code=404, detail="Secret not found") + crypto = VaultCrypto(settings.resolved_vault_key) + return {"name": name, "value": crypto.decrypt(row["encrypted_value"])} + + +@router.delete("/vault/{name}", dependencies=[Depends(vault_limiter)]) +async def delete_vault_secret(name: str): + db = await get_db() + await db.execute("DELETE FROM credential_vault WHERE name = ?", (name,)) + return {"name": name, "deleted": True} + + +@router.get("/workflows") +async def list_workflows(): + db = await get_db() + rows = await db.fetchall("SELECT * FROM workflows ORDER BY created_at DESC") + workflows = [_serialize_workflow(row) for row in rows] + return {"workflows": workflows, "total": len(workflows)} + + +@router.post("/workflows") +async def create_workflow(payload: Dict[str, Any]): + name = str(payload.get("name", "")).strip() + if not name: + raise HTTPException(status_code=400, detail="Workflow name is required") + + steps = payload.get("steps", []) + if not isinstance(steps, list) or not steps: + raise HTTPException(status_code=400, detail="Workflow requires at least one step") + + workflow_id = str(uuid.uuid4()) + schedule_seconds = payload.get("schedule_seconds") + enabled = bool(payload.get("enabled", True)) + db = await get_db() + await db.execute( + """ + INSERT INTO workflows (id, name, schedule_seconds, enabled, steps_json) + VALUES (?, ?, ?, ?, ?) + """, + ( + workflow_id, + name, + int(schedule_seconds) if schedule_seconds else None, + 1 if enabled else 0, + json.dumps(steps), + ), + ) + row = await db.fetchone("SELECT * FROM workflows WHERE id = ?", (workflow_id,)) + return _serialize_workflow(row) if row else {"id": workflow_id, "created": True} + + +@router.post("/workflows/{workflow_id}/run") +async def run_workflow_once(workflow_id: str): + db = await get_db() + row = await db.fetchone("SELECT steps_json FROM workflows WHERE id = ?", (workflow_id,)) + if not row: + raise HTTPException(status_code=404, detail="Workflow not found") + steps = json.loads(row["steps_json"] or "[]") + created_task_ids: List[str] = [] + for step in steps: + task_id = await executor.create_task( + step.get("plugin_id"), + step.get("inputs", {}), + step.get("preset"), + consent_granted=True, + ) + asyncio.create_task(executor.execute_task(task_id)) + created_task_ids.append(task_id) + await db.execute("UPDATE workflows SET last_run_at = datetime('now') WHERE id = ?", (workflow_id,)) + return { + "workflow_id": workflow_id, + "queued_task_ids": created_task_ids, + "queued_tasks": created_task_ids, + } + + +@router.patch("/workflows/{workflow_id}") +async def update_workflow(workflow_id: str, payload: Dict[str, Any]): + db = await get_db() + row = await db.fetchone("SELECT id FROM workflows WHERE id = ?", (workflow_id,)) + if not row: + raise HTTPException(status_code=404, detail="Workflow not found") + + updates = [] + params: List[Any] = [] + if "name" in payload: + updates.append("name = ?") + params.append(str(payload["name"]).strip()) + if "steps" in payload: + updates.append("steps_json = ?") + params.append(json.dumps(payload["steps"])) + if "schedule_seconds" in payload: + val = payload["schedule_seconds"] + updates.append("schedule_seconds = ?") + params.append(int(val) if val else None) + if "enabled" in payload: + updates.append("enabled = ?") + params.append(1 if payload["enabled"] else 0) + + if not updates: + raise HTTPException(status_code=400, detail="No update fields provided") + + params.append(workflow_id) + await db.execute(f"UPDATE workflows SET {', '.join(updates)} WHERE id = ?", tuple(params)) + updated = await db.fetchone("SELECT * FROM workflows WHERE id = ?", (workflow_id,)) + return _serialize_workflow(updated) if updated else {"workflow_id": workflow_id, "updated": True} + + +@router.delete("/workflows/{workflow_id}") +async def delete_workflow(workflow_id: str): + db = await get_db() + await db.execute("DELETE FROM workflows WHERE id = ?", (workflow_id,)) + return {"workflow_id": workflow_id, "deleted": True} + + +@router.post("/workflows/scheduler/tick") +async def trigger_workflow_tick(): + await scheduler.tick() + return {"tick": "ok"} + + +@router.get("/finding/{finding_id}") +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 + FROM findings f + JOIN tasks t ON f.task_id = t.id + WHERE f.id = ? + """, + (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 = {} + + risk_factors = [] + if finding_row.get("risk_factors_json"): + try: + risk_factors = json.loads(finding_row["risk_factors_json"]) + except (json.JSONDecodeError, TypeError): + risk_factors = [] + + return { + "id": finding_row["id"], + "task_id": finding_row["task_id"], + "plugin_id": finding_row["plugin_id"], + "tool": finding_row["tool_name"], + "title": finding_row["title"], + "category": finding_row["category"], + "severity": finding_row["severity"], + "target": finding_row["target"], + "description": finding_row["description"], + "remediation": finding_row["remediation"], + "proof": finding_row["proof"], + "cvss": finding_row["cvss"], + "cve": finding_row["cve"], + "discovered_at": finding_row["discovered_at"], + "metadata": metadata, + "exploitability": finding_row.get("exploitability"), + "confidence": finding_row.get("confidence"), + "asset_exposure": finding_row.get("asset_exposure"), + "risk_score": finding_row.get("risk_score"), + "risk_factors": risk_factors, + } + + +@router.get("/attack-surface") +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"] + if target not in seen_targets: + entries.append({ + "id": str(uuid.uuid4()), + "category": f["category"], + "item": target, + "details": f"Active exposure identified in {f['category']}", + "risk": f["severity"], + "source": "Audit Scan", + "last_seen": f["discovered_at"] + }) + seen_targets.add(target) + + # Add other scanned targets + for t in tasks: + target = t["target"] + if target not in seen_targets: + entries.append({ + "id": str(uuid.uuid4()), + "category": "Infrastructure", + "item": target, + "details": f"Monitored via {t['tool_name']}", + "risk": "info", + "source": "Recon", + "last_seen": t["created_at"] + }) + seen_targets.add(target) + + return {"entries": entries} + + +@router.get("/assets") +async def get_assets(): + """Return a list of tracked assets.""" + db = await get_db() + # 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} + + +@router.post("/integrations/ticket", response_model=TicketResponse) +async def create_ticket(request: TicketCreateRequest): + """Create a ticket in an external issue tracker""" + db = await get_db() + crypto = VaultCrypto(settings.resolved_vault_key) + + config = {} + if request.provider == "jira": + keys = ["jiraUrl", "jiraEmail", "jiraToken", "jiraProject"] + elif request.provider == "github": + keys = ["githubToken", "githubRepo"] + else: + raise HTTPException(status_code=400, detail="Unsupported provider") + + for key in keys: + row = await db.fetchone("SELECT encrypted_value FROM credential_vault WHERE name = ?", (key,)) + if not row: + raise HTTPException(status_code=400, detail=f"Missing integration configuration: {key}") + config[key] = crypto.decrypt(row["encrypted_value"]) + + try: + if request.provider == "jira": + result = await create_jira_ticket(request.finding, config) + return TicketResponse(**result) + elif request.provider == "github": + result = await create_github_issue(request.finding, config) + return TicketResponse(**result) + except Exception as e: + logger.exception("Ticket creation failed") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/frontend/src/__tests__/Findings.test.tsx b/frontend/src/__tests__/Findings.test.tsx new file mode 100644 index 00000000..d23b133d --- /dev/null +++ b/frontend/src/__tests__/Findings.test.tsx @@ -0,0 +1,31 @@ +import { describe, it, expect, vi } from 'vitest' +import { createTicket } from '../api' + +// Mock the global request function by mocking the module +vi.mock('../api', async () => { + const actual = await vi.importActual('../api') + return { + ...actual, + createTicket: vi.fn().mockResolvedValue({ ticket_id: 'TEST-123', ticket_url: 'http://example.com/TEST-123' }), + upsertVaultSecret: vi.fn().mockResolvedValue({}), + } +}) + +describe('Export Flow', () => { + it('calls createTicket without config object (backend handles secrets)', async () => { + const finding = { + id: '123', + title: 'Test Finding', + severity: 'high', + status: 'new' + } + + // Call the exported mocked function directly to verify it works without config + await createTicket('jira', finding) + + expect(createTicket).toHaveBeenCalledWith('jira', finding) + // We expect it to be called with exactly 2 arguments (provider, finding), + // ensuring no credentials object is passed from the frontend. + expect(createTicket).toHaveBeenCalledTimes(1) + }) +}) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 28e1504f..641186b0 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -293,10 +293,18 @@ export function deleteWorkflow(workflowId: string): Promise<{ deleted: boolean } }) } -export function createTicket(provider: string, finding: any, config: Record): Promise<{ ticket_id: string; ticket_url: string }> { +export function createTicket(provider: string, finding: any): Promise<{ ticket_id: string; ticket_url: string }> { return request<{ ticket_id: string; ticket_url: string }>('/integrations/ticket', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ provider, finding, config }), + body: JSON.stringify({ provider, finding }), + }) +} + +export function upsertVaultSecret(name: string, payload: Record) { + return request(`/vault/${name}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), }) } diff --git a/frontend/src/pages/Findings.tsx b/frontend/src/pages/Findings.tsx index 4364dc0e..66456978 100644 --- a/frontend/src/pages/Findings.tsx +++ b/frontend/src/pages/Findings.tsx @@ -325,11 +325,8 @@ export default function Findings() { async function exportToTracker(finding: Finding & { status: FindingStatus }, provider: 'jira' | 'github') { try { - const savedConfig = localStorage.getItem('secuscan-config') - const config = savedConfig ? JSON.parse(savedConfig) : {} - addToast(`Exporting to ${provider.toUpperCase()}...`, 'info') - const result = await createTicket(provider, finding, config) + const result = await createTicket(provider, finding) addToast(`Successfully created ${provider.toUpperCase()} ticket: ${result.ticket_id}`, 'success') window.open(result.ticket_url, '_blank', 'noopener,noreferrer') } catch (error: any) { diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index eed75390..896716f9 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from 'react' +import { upsertVaultSecret } from '../api' import { motion, AnimatePresence } from 'framer-motion' import { useTheme } from '../components/ThemeContext' import { useToast } from '../components/ToastContext' @@ -63,11 +64,30 @@ export default function Settings() { } }, []) - const handleSave = () => { - localStorage.setItem('secuscan-config', JSON.stringify(config)) - addToast("Operational parameters synchronized", "success") - if (config.theme !== theme) { - setTheme(config.theme) + const handleSave = async () => { + try { + // Save integration secrets to backend vault + const secrets = ['shodanKey', 'virustotalKey', 'jiraToken', 'jiraUrl', 'jiraEmail', 'jiraProject', 'githubToken', 'githubRepo'] + for (const key of secrets) { + const val = config[key as keyof typeof config] as string + if (val && val !== '********') { + await upsertVaultSecret(key, { payload: val }) + } + } + + // Remove secrets from local storage config + const localConfig = { ...config } + for (const key of secrets) { + localConfig[key as keyof typeof config] = (localConfig[key as keyof typeof config] ? '********' : '') as never + } + + localStorage.setItem('secuscan-config', JSON.stringify(localConfig)) + addToast("Operational parameters synchronized", "success") + if (config.theme !== theme) { + setTheme(config.theme) + } + } catch (e: any) { + addToast(`Failed to synchronize parameters: ${e.message}`, "error") } } diff --git a/frontend/testing/unit/AppRoutes.test.tsx b/frontend/testing/unit/AppRoutes.test.tsx index 031ac013..6fb7416e 100644 --- a/frontend/testing/unit/AppRoutes.test.tsx +++ b/frontend/testing/unit/AppRoutes.test.tsx @@ -1,5 +1,7 @@ import { render, screen, waitFor } from '@testing-library/react' import { MemoryRouter, useLocation } from 'react-router-dom' +import { ThemeProvider } from '../../src/components/ThemeContext' +import { ToastProvider } from '../../src/components/ToastContext' import { AppRoutes } from '../../src/App' vi.mock('../../src/api', () => ({ @@ -44,8 +46,12 @@ describe('App route fallback', () => { it('redirects unknown routes to dashboard', async () => { render( - - + + + + + + , ) diff --git a/frontend/testing/unit/pages/Findings.test.tsx b/frontend/testing/unit/pages/Findings.test.tsx index 561ae086..cad7db97 100644 --- a/frontend/testing/unit/pages/Findings.test.tsx +++ b/frontend/testing/unit/pages/Findings.test.tsx @@ -55,11 +55,15 @@ const allFindings = [criticalFinding, highFinding, mediumFinding] // ── Helpers ─────────────────────────────────────────────────────────────────── +import { ToastProvider } from '../../../src/components/ToastContext' + function renderFindings() { return render( - - - , + + + + + , ) } diff --git a/testing/backend/unit/test_integrations.py b/testing/backend/unit/test_integrations.py new file mode 100644 index 00000000..237b2b17 --- /dev/null +++ b/testing/backend/unit/test_integrations.py @@ -0,0 +1,46 @@ +import pytest +from httpx import AsyncClient +from backend.secuscan.models import Finding +from typing import Dict, Any + +@pytest.mark.asyncio +async def test_create_ticket_missing_provider(client: AsyncClient): + finding = { + "id": "123", + "task_id": "task-1", + "title": "SQL Injection", + "description": "Found SQLi", + "remediation": "Use prepared statements", + "severity": "critical", + "category": "Injection", + "target": "http://example.com" + } + + response = await client.post("/api/v1/integrations/ticket", json={ + "provider": "unknown_provider", + "finding": finding + }) + + assert response.status_code == 400 + assert "Unsupported provider" in response.text + +@pytest.mark.asyncio +async def test_create_ticket_missing_credentials(client: AsyncClient): + finding = { + "id": "123", + "task_id": "task-1", + "title": "SQL Injection", + "description": "Found SQLi", + "remediation": "Use prepared statements", + "severity": "critical", + "category": "Injection", + "target": "http://example.com" + } + + response = await client.post("/api/v1/integrations/ticket", json={ + "provider": "jira", + "finding": finding + }) + + assert response.status_code == 400 + assert "Missing integration configuration" in response.text From bf513f41479ff1cddd92dd6aaf79c4daa0fa92bb Mon Sep 17 00:00:00 2001 From: Somil450 Date: Fri, 29 May 2026 00:14:05 +0530 Subject: [PATCH 3/5] Fix AppRoutes test with ToastProvider --- frontend/testing/unit/AppRoutes.test.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/frontend/testing/unit/AppRoutes.test.tsx b/frontend/testing/unit/AppRoutes.test.tsx index 6fb7416e..4f9f07b2 100644 --- a/frontend/testing/unit/AppRoutes.test.tsx +++ b/frontend/testing/unit/AppRoutes.test.tsx @@ -63,7 +63,11 @@ describe('App route fallback', () => { it('renders the loaded dashboard summary', async () => { render( - + + + + + , ) @@ -74,7 +78,11 @@ describe('App route fallback', () => { it('renders the findings workspace', async () => { render( - + + + + + , ) From 1991e43f7d367b68b854110a40238315f2101bfe Mon Sep 17 00:00:00 2001 From: Somil450 Date: Fri, 29 May 2026 00:23:57 +0530 Subject: [PATCH 4/5] Fix trailing whitespaces to pass formatting-hygiene --- backend/secuscan/integrations.py | 12 +-- backend/secuscan/routes.py | 2 +- frontend/src/__tests__/Findings.test.tsx | 2 +- frontend/src/pages/Findings.tsx | 11 +++ frontend/src/pages/Settings.tsx | 94 +++++++++++------------ testing/backend/unit/test_integrations.py | 8 +- 6 files changed, 70 insertions(+), 59 deletions(-) diff --git a/backend/secuscan/integrations.py b/backend/secuscan/integrations.py index 6d1e8351..a2ea96f6 100644 --- a/backend/secuscan/integrations.py +++ b/backend/secuscan/integrations.py @@ -15,7 +15,7 @@ async def create_jira_ticket(finding: Finding, config: Dict[str, str]) -> Dict[s raise ValueError("Missing Jira configuration parameters") api_url = f"{url}/rest/api/2/issue" - + description = f""" *Target:* {finding.target} *Severity:* {finding.severity} @@ -51,11 +51,11 @@ async def create_jira_ticket(finding: Finding, config: Dict[str, str]) -> Dict[s auth=(email, token), headers={"Content-Type": "application/json"} ) - + if response.status_code >= 400: logger.error(f"Jira API error: {response.text}") raise Exception(f"Failed to create Jira ticket: {response.status_code} {response.text}") - + data = response.json() return { "ticket_id": data.get("key"), @@ -71,7 +71,7 @@ async def create_github_issue(finding: Finding, config: Dict[str, str]) -> Dict[ raise ValueError("Missing GitHub configuration parameters") api_url = f"https://api.github.com/repos/{repo}/issues" - + body = f""" **Target:** `{finding.target}` **Severity:** {finding.severity.upper()} @@ -103,11 +103,11 @@ async def create_github_issue(finding: Finding, config: Dict[str, str]) -> Dict[ "X-GitHub-Api-Version": "2022-11-28" } ) - + if response.status_code >= 400: logger.error(f"GitHub API error: {response.text}") raise Exception(f"Failed to create GitHub issue: {response.status_code} {response.text}") - + data = response.json() return { "ticket_id": str(data.get("number")), diff --git a/backend/secuscan/routes.py b/backend/secuscan/routes.py index 1b745746..5c66924a 100644 --- a/backend/secuscan/routes.py +++ b/backend/secuscan/routes.py @@ -1239,7 +1239,7 @@ async def create_ticket(request: TicketCreateRequest): """Create a ticket in an external issue tracker""" db = await get_db() crypto = VaultCrypto(settings.resolved_vault_key) - + config = {} if request.provider == "jira": keys = ["jiraUrl", "jiraEmail", "jiraToken", "jiraProject"] diff --git a/frontend/src/__tests__/Findings.test.tsx b/frontend/src/__tests__/Findings.test.tsx index d23b133d..46804e5a 100644 --- a/frontend/src/__tests__/Findings.test.tsx +++ b/frontend/src/__tests__/Findings.test.tsx @@ -24,7 +24,7 @@ describe('Export Flow', () => { await createTicket('jira', finding) expect(createTicket).toHaveBeenCalledWith('jira', finding) - // We expect it to be called with exactly 2 arguments (provider, finding), + // We expect it to be called with exactly 2 arguments (provider, finding), // ensuring no credentials object is passed from the frontend. expect(createTicket).toHaveBeenCalledTimes(1) }) diff --git a/frontend/src/pages/Findings.tsx b/frontend/src/pages/Findings.tsx index 66456978..ad31c7b3 100644 --- a/frontend/src/pages/Findings.tsx +++ b/frontend/src/pages/Findings.tsx @@ -3,6 +3,17 @@ import { motion } from 'framer-motion' import { getFindings, createTicket } from '../api' import { formatLocaleDate, parseDateSafe, getCurrentTimeZone } from '../utils/date' import { useToast } from '../components/ToastContext' + +export interface RiskFactor { + factor: string + label: string + value: string | number + score: number + weight: number + contribution: number + detail: string +} + type Finding = { id: string severity: string diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 896716f9..d6ecf507 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -6,8 +6,8 @@ import { useToast } from '../components/ToastContext' const itemVariants = { hidden: { opacity: 0, y: 20 }, - visible: { - opacity: 1, + visible: { + opacity: 1, y: 0, transition: { type: 'spring', stiffness: 200, damping: 25 } } @@ -41,7 +41,7 @@ const DEFAULT_CONFIG = { export default function Settings() { const { theme, setTheme } = useTheme() const { addToast } = useToast() - + const [config, setConfig] = useState(() => { const saved = localStorage.getItem('secuscan-config') if (saved) { @@ -116,7 +116,7 @@ export default function Settings() {

{description}

- onChange(type === 'number' ? parseInt(e.target.value) || 0 : e.target.value)} @@ -132,7 +132,7 @@ export default function Settings() {

{description}

-