diff --git a/backend/secuscan/routes.py b/backend/secuscan/routes.py index f4b3289c..b754e49f 100644 --- a/backend/secuscan/routes.py +++ b/backend/secuscan/routes.py @@ -2,7 +2,16 @@ API routes for SecuScan backend """ -from fastapi import APIRouter, HTTPException, BackgroundTasks, Response, Request, Depends, Body, Query +from fastapi import ( + APIRouter, + HTTPException, + BackgroundTasks, + Response, + Request, + Depends, + Body, + Query, +) from fastapi.responses import JSONResponse from typing import Any, Optional, List, Dict, Callable import json @@ -40,6 +49,7 @@ "build_report_filename", ] + def _parse_workflow_steps(raw_steps: Any) -> List[Dict[str, Any]]: if isinstance(raw_steps, list): parsed = raw_steps @@ -66,7 +76,10 @@ def _parse_workflow_steps(raw_steps: Any) -> List[Dict[str, Any]]: normalized.append(model.model_dump()) return normalized -def _serialize_workflow(row: Dict[str, Any], queued_task_ids: Optional[List[str]] = None) -> Dict[str, Any]: + +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"], @@ -91,13 +104,23 @@ def _json_payload(value: Any, fallback: str) -> str: from .cache import get_cache, invalidate_view_cache from .models import ( - TaskCreateRequest, TaskResponse, TaskResult, - PluginListResponse, ErrorResponse, BulkDeleteRequest, - NotificationRuleCreate, NotificationRuleUpdate, - NotificationChannelType, TaskStatus, - ExecutionContext, WorkflowStep, ValidationMode, EvidenceLevel, + TaskCreateRequest, + TaskResponse, + TaskResult, + PluginListResponse, + ErrorResponse, + BulkDeleteRequest, + NotificationRuleCreate, + NotificationRuleUpdate, + NotificationChannelType, + TaskStatus, + ExecutionContext, + WorkflowStep, + ValidationMode, + EvidenceLevel, NotificationDiagnosticsResponse, - ScanWebhookSettingsRequest, ScanWebhookSettingsResponse, + ScanWebhookSettingsRequest, + ScanWebhookSettingsResponse, ) from .config import settings from .database import get_db @@ -106,14 +129,24 @@ def _json_payload(value: Any, fallback: str) -> str: from .executor import executor from .redaction import redact, redact_inputs from .ratelimit import ( - rate_limiter, concurrent_limiter, workflow_rate_limiter, - task_start_limiter, vault_limiter, - report_download_limiter, read_heavy_limiter, - resolve_client_identity, admin_limiter, + rate_limiter, + concurrent_limiter, + workflow_rate_limiter, + task_start_limiter, + vault_limiter, + report_download_limiter, + read_heavy_limiter, + resolve_client_identity, + admin_limiter, scheduler_tick_limiter, ) from .rate_limiter import check_scan_rate_limit -from .validation import validate_target, validate_task_start_payload, validate_url, validate_preset_name +from .validation import ( + validate_target, + validate_task_start_payload, + validate_url, + validate_preset_name, +) from .reporting import reporting from .vault import VaultCrypto from .workflows import scheduler, _finalize_workflow_run @@ -135,7 +168,9 @@ def _json_payload(value: Any, fallback: str) -> str: _EMAIL_PATTERN = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") -def _validate_notification_target(channel_type: NotificationChannelType, target: str) -> str: +def _validate_notification_target( + channel_type: NotificationChannelType, target: str +) -> str: cleaned = target.strip() if not cleaned: raise HTTPException(status_code=400, detail="Notification target is required") @@ -147,18 +182,19 @@ def _validate_notification_target(channel_type: NotificationChannelType, target: if settings.notification_ssrf_enabled: from .validation import resolve_and_validate_target, validate_webhook_target + ssrf_ok, ssrf_err = resolve_and_validate_target(cleaned) if not ssrf_ok: raise HTTPException( status_code=400, - detail=f"Webhook target blocked by SSRF protection: {ssrf_err}" + detail=f"Webhook target blocked by SSRF protection: {ssrf_err}", ) # Additional independent check against notification_blocked_ip_ranges target_ok, target_err = validate_webhook_target(cleaned) if not target_ok: raise HTTPException( status_code=400, - detail=f"Webhook target blocked by SSRF protection: {target_err}" + detail=f"Webhook target blocked by SSRF protection: {target_err}", ) return cleaned @@ -203,23 +239,67 @@ async def get_or_set_cached(key: str, builder): return value -async def require_owned_task(db, task_id: str, owner: str, columns: str = "owner_id") -> Dict[str, Any]: +_TASK_COLUMNS: frozenset = frozenset( + { + "id", + "owner_id", + "plugin_id", + "tool_name", + "target", + "inputs_json", + "execution_context_json", + "preset", + "status", + "scan_phase", + "phase_timestamps_json", + "consent_granted", + "safe_mode", + "created_at", + "started_at", + "completed_at", + "duration_seconds", + "exit_code", + "structured_json", + "raw_output_path", + "command_used", + "error_message", + "container_id", + "cpu_seconds", + "memory_peak_mb", + } +) + + +async def require_owned_task( + db, task_id: str, owner: str, columns: str = "owner_id" +) -> Dict[str, Any]: """Fetch a task and enforce that it belongs to ``owner`` (issue #401). Returns the selected row on success. Raises 404 when the task does not exist and 403 when it is owned by a different user/workspace. ``columns`` must include ``owner_id`` so the ownership comparison can be made. """ + selected = [col.strip() for col in columns.split(",")] + unknown = [col for col in selected if col not in _TASK_COLUMNS] + if unknown: + raise HTTPException( + status_code=400, + detail=f"Unknown column(s) in task query: {', '.join(unknown)}", + ) row = await db.fetchone(f"SELECT {columns} FROM tasks WHERE id = ?", (task_id,)) if row is None: raise HTTPException(status_code=404, detail="Task not found") if row.get("owner_id") != owner: - raise HTTPException(status_code=403, detail="You do not have access to this task") + raise HTTPException( + status_code=403, detail="You do not have access to this task" + ) return row 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) + logger.exception( + "Report generation failed for task_id=%s format=%s", task_id, report_format + ) return JSONResponse( status_code=500, content={ @@ -243,16 +323,18 @@ async def get_plugin_manager_for_request(): return get_plugin_manager() -@router.get("/plugins", response_model=PluginListResponse, dependencies=[Depends(read_heavy_limiter)]) +@router.get( + "/plugins", + response_model=PluginListResponse, + dependencies=[Depends(read_heavy_limiter)], +) 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) - ) + return PluginListResponse(plugins=plugins, total=len(plugins)) + @router.get("/plugins/summary") async def get_plugins_summary(): @@ -269,9 +351,7 @@ async def get_plugins_summary(): for plugin in plugins: category = plugin.get("category", "unknown") - category_counts[category] = ( - category_counts.get(category, 0) + 1 - ) + category_counts[category] = category_counts.get(category, 0) + 1 availability = plugin.get("availability", {}) runnable = availability.get("runnable", False) @@ -284,9 +364,10 @@ async def get_plugins_summary(): "total_plugins": total_plugins, "runnable_count": runnable_count, "unavailable_count": unavailable_count, - "category_counts": dict(sorted(category_counts.items())) + "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""" @@ -307,7 +388,10 @@ async def get_all_presets(): } -@router.post("/task/start", dependencies=[Depends(task_start_limiter), Depends(check_scan_rate_limit)]) +@router.post( + "/task/start", + dependencies=[Depends(task_start_limiter), Depends(check_scan_rate_limit)], +) async def start_task( request: TaskCreateRequest, background_tasks: BackgroundTasks, @@ -320,7 +404,9 @@ async def start_task( # ── Payload size / field-length guard ───────────────────────────────── raw_body = await raw_request.body() execution_context = normalize_execution_context(request.execution_context) - ok, status_code, error_msg = validate_task_start_payload(raw_body, request.inputs, execution_context) + ok, status_code, error_msg = validate_task_start_payload( + raw_body, request.inputs, execution_context + ) if not ok: raise HTTPException(status_code=status_code, detail=error_msg) @@ -338,7 +424,7 @@ async def start_task( ) raise HTTPException( status_code=400, - detail="Consent required. You must acknowledge the legal notice." + detail="Consent required. You must acknowledge the legal notice.", ) # Get plugin @@ -347,7 +433,9 @@ async def start_task( 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}") + raise HTTPException( + status_code=404, detail=f"Plugin not found: {request.plugin_id}" + ) preset_ok, preset_error = validate_preset_name( request.plugin_id, @@ -358,18 +446,32 @@ async def start_task( logger.warning("Task start failed: %s", preset_error) raise HTTPException(status_code=400, detail=preset_error) - target_policy = await get_target_policy(db, owner, execution_context.get("target_policy_id")) - credential_profile = await get_credential_profile(db, owner, execution_context.get("credential_profile_id")) - session_profile = await get_session_profile(db, owner, execution_context.get("session_profile_id")) + target_policy = await get_target_policy( + db, owner, execution_context.get("target_policy_id") + ) + credential_profile = await get_credential_profile( + db, owner, execution_context.get("credential_profile_id") + ) + session_profile = await get_session_profile( + db, owner, execution_context.get("session_profile_id") + ) if execution_context.get("target_policy_id") and not target_policy: - raise HTTPException(status_code=400, detail="Target policy not found for this workspace") + raise HTTPException( + status_code=400, detail="Target policy not found for this workspace" + ) if execution_context.get("credential_profile_id") and not credential_profile: - raise HTTPException(status_code=400, detail="Credential profile not found for this workspace") + raise HTTPException( + status_code=400, detail="Credential profile not found for this workspace" + ) if execution_context.get("session_profile_id") and not session_profile: - raise HTTPException(status_code=400, detail="Session profile not found for this workspace") + raise HTTPException( + status_code=400, detail="Session profile not found for this workspace" + ) - if (credential_profile or session_profile) and not (target_policy and target_policy.get("allow_authenticated_scan")): + if (credential_profile or session_profile) and not ( + target_policy and target_policy.get("allow_authenticated_scan") + ): raise HTTPException( status_code=400, detail="Authenticated scans require a target policy with authenticated scanning enabled.", @@ -377,10 +479,13 @@ async def start_task( requires_exploit_policy = ( plugin.safety.get("level") == "exploit" - or execution_context.get("validation_mode") == ValidationMode.CONTROLLED_EXTRACT.value + or execution_context.get("validation_mode") + == ValidationMode.CONTROLLED_EXTRACT.value ) - if requires_exploit_policy and not (target_policy and target_policy.get("allow_exploit_validation")): + if requires_exploit_policy and not ( + target_policy and target_policy.get("allow_exploit_validation") + ): raise HTTPException( status_code=400, detail="Offensive validation requires a target policy that explicitly allows exploit validation.", @@ -408,13 +513,21 @@ async def start_task( try: tval = int(effective_inputs[tkey]) except (TypeError, ValueError): - raise HTTPException(status_code=400, detail=f"Invalid value for {tkey}: must be an integer") + raise HTTPException( + status_code=400, + detail=f"Invalid value for {tkey}: must be an integer", + ) if tval <= 0 or tval > settings.sandbox_timeout: - raise HTTPException(status_code=400, detail=f"{tkey} must be between 1 and {settings.sandbox_timeout} seconds") + raise HTTPException( + status_code=400, + detail=f"{tkey} must be between 1 and {settings.sandbox_timeout} seconds", + ) if target := effective_inputs.get("target"): target_str = str(target) - should_validate_target = plugin.category != "code" and not is_filesystem_target(target_str) + should_validate_target = plugin.category != "code" and not is_filesystem_target( + target_str + ) if should_validate_target: try: @@ -423,14 +536,19 @@ async def start_task( timeout=float(settings.dns_resolution_timeout_seconds), ) except asyncio.TimeoutError: - logger.warning("Task start failed: Target validation timed out for '%s'", target_str) + logger.warning( + "Task start failed: Target validation timed out for '%s'", + target_str, + ) raise HTTPException( status_code=400, detail="Target validation timed out in safe mode (SecuScan Guardrail)", ) if not is_valid: - logger.warning(f"Task start failed: Target validation failed for '{target}': {error_msg}") + logger.warning( + f"Task start failed: Target validation failed for '{target}': {error_msg}" + ) await db.log_audit( "scan_blocked_target_validation", f"Scan start blocked: target validation failed for plugin {request.plugin_id}", @@ -450,7 +568,9 @@ async def start_task( client_id = resolve_client_identity(raw_request) 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), + plugin.safety.get("rate_limit", {}).get( + "max_per_hour", settings.max_tasks_per_hour + ), client_id=client_id, ) @@ -477,7 +597,9 @@ async def start_task( 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") + 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. @@ -493,10 +615,14 @@ async def start_task( "task_id": task_id, "status": "queued", "created_at": "now", - "stream_url": f"/api/v1/task/{task_id}/stream" + "stream_url": f"/api/v1/task/{task_id}/stream", } -@router.post("/task/{task_id}/retry", dependencies=[Depends(task_start_limiter) , Depends(check_scan_rate_limit)]) + +@router.post( + "/task/{task_id}/retry", + dependencies=[Depends(task_start_limiter), Depends(check_scan_rate_limit)], +) async def retry_task( task_id: str, background_tasks: BackgroundTasks, @@ -507,23 +633,31 @@ async def retry_task( Retry a failed or cancelled scan task. """ db = await get_db() - task = await require_owned_task(db, task_id, owner, columns="id, owner_id, status, plugin_id") + task = await require_owned_task( + db, task_id, owner, columns="id, owner_id, status, plugin_id" + ) if task["status"] in ["queued", "running"]: raise HTTPException(status_code=409, detail="Task is already queued or running") elif task["status"] not in ["failed", "cancelled"]: - raise HTTPException(status_code=400, detail="Only failed or cancelled tasks can be retried") + raise HTTPException( + status_code=400, detail="Only failed or cancelled tasks can be retried" + ) # Check plugin rate limits plugin_manager = await get_plugin_manager_for_request() plugin = plugin_manager.get_plugin(task["plugin_id"]) if not plugin: - raise HTTPException(status_code=404, detail=f"Plugin not found: {task['plugin_id']}") + raise HTTPException( + status_code=404, detail=f"Plugin not found: {task['plugin_id']}" + ) client_id = resolve_client_identity(raw_request) can_execute, error_msg = await rate_limiter.can_execute( task["plugin_id"], - plugin.safety.get("rate_limit", {}).get("max_per_hour", settings.max_tasks_per_hour), + plugin.safety.get("rate_limit", {}).get( + "max_per_hour", settings.max_tasks_per_hour + ), client_id=client_id, ) @@ -535,7 +669,7 @@ async def retry_task( "UPDATE tasks SET status = 'queued', error_message = NULL, exit_code = NULL, " "started_at = NULL, completed_at = NULL " "WHERE id = ? AND status IN ('failed', 'cancelled')", - (task_id,) + (task_id,), ) if cursor.rowcount == 0: raise HTTPException(status_code=409, detail="Task is already queued or running") @@ -547,17 +681,16 @@ async def retry_task( # Re-acquire concurrency slot can_acquire, error_msg = await concurrent_limiter.acquire(task_id) if not can_acquire: - await executor.mark_task_failed(task_id, reason="Concurrency limit reached; task was not retried") + await executor.mark_task_failed( + task_id, reason="Concurrency limit reached; task was not retried" + ) raise HTTPException(status_code=503, detail=error_msg) background_tasks.add_task(executor.execute_task, task_id) await invalidate_view_cache() - return { - "task_id": task_id, - "status": "queued", - "message": "Task retry initiated" - } + return {"task_id": task_id, "status": "queued", "message": "Task retry initiated"} + @router.get("/task/{task_id}/status") async def get_task_status(task_id: str, owner: str = Depends(get_current_owner)): @@ -572,6 +705,7 @@ async def get_task_status(task_id: str, owner: str = Depends(get_current_owner)) return status + @router.get("/task/{task_id}/stream") async def stream_task_output(task_id: str, owner: str = Depends(get_current_owner)): """Stream task output via Server-Sent Events (SSE)""" @@ -588,22 +722,25 @@ async def event_generator(): # First, send the initial status and phase yield { "event": "status", - "data": json.dumps({"status": status["status"], "scan_phase": status.get("scan_phase")}) + "data": json.dumps( + {"status": status["status"], "scan_phase": status.get("scan_phase")} + ), } # 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,)) + task_row = await db.fetchone( + "SELECT raw_output_path FROM tasks WHERE id = ?", (task_id,) + ) if task_row and task_row["raw_output_path"]: for chunk in iter_raw_output_chunks(task_row["raw_output_path"]): - yield { - "event": "output", - "data": json.dumps({"chunk": chunk}) - } + yield {"event": "output", "data": json.dumps({"chunk": chunk})} except Exception as exc: - logger.warning("Failed to replay raw output for task %s: %s", task_id, exc) + logger.warning( + "Failed to replay raw output for task %s: %s", task_id, exc + ) return # Subscribe to live events @@ -613,21 +750,31 @@ async def event_generator(): # the task may have completed between the initial check and this # subscription, so we'd never receive a terminal event. current_status = await executor.get_task_status(task_id) - if current_status and current_status["status"] in ["completed", "failed", "cancelled"]: + if current_status and current_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,)) + task_row = await db.fetchone( + "SELECT raw_output_path FROM tasks WHERE id = ?", (task_id,) + ) if task_row and task_row["raw_output_path"]: - for chunk in iter_raw_output_chunks(task_row["raw_output_path"]): + for chunk in iter_raw_output_chunks( + task_row["raw_output_path"] + ): yield { "event": "output", - "data": json.dumps({"chunk": chunk}) + "data": json.dumps({"chunk": chunk}), } except Exception as exc: - logger.warning("Failed to replay raw output for task %s: %s", task_id, exc) + logger.warning( + "Failed to replay raw output for task %s: %s", task_id, exc + ) yield { "event": "status", - "data": json.dumps({"status": current_status["status"]}) + "data": json.dumps({"status": current_status["status"]}), } return @@ -644,19 +791,19 @@ async def event_generator(): if event["type"] == "status": yield { "event": "status", - "data": json.dumps({"status": event["data"]}) + "data": json.dumps({"status": event["data"]}), } if event["data"] in ["completed", "failed", "cancelled"]: break elif event["type"] == "phase": yield { "event": "phase", - "data": json.dumps({"scan_phase": event["data"]}) + "data": json.dumps({"scan_phase": event["data"]}), } elif event["type"] == "output": yield { "event": "output", - "data": json.dumps({"chunk": event["data"]}) + "data": json.dumps({"chunk": event["data"]}), } except asyncio.CancelledError: pass @@ -665,34 +812,49 @@ async def event_generator(): return EventSourceResponse(event_generator()) -@router.get("/task/{task_id}/report/csv", dependencies=[Depends(report_download_limiter)]) + +@router.get( + "/task/{task_id}/report/csv", dependencies=[Depends(report_download_limiter)] +) async def download_csv_report(task_id: str, owner: str = Depends(get_current_owner)): """Download task results as a CSV report.""" db = await get_db() task_row = await db.fetchone( "SELECT id, owner_id, plugin_id, tool_name, target, status, created_at, preset, inputs_json, command_used, structured_json FROM tasks WHERE id = ?", - (task_id,) + (task_id,), ) if not task_row: raise HTTPException(status_code=404, detail="Task not found") if task_row["owner_id"] != owner: - raise HTTPException(status_code=403, detail="You do not have access to this task") + raise HTTPException( + status_code=403, detail="You do not have access to this task" + ) 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}) + 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"]}, + context={ + "format": "csv", + "task_id": task_id, + "plugin_id": task_row["plugin_id"], + }, task_id=task_id, plugin_id=task_row["plugin_id"], ) @@ -700,37 +862,54 @@ async def download_csv_report(task_id: str, owner: str = Depends(get_current_own return Response( content=csv_data, media_type="text/csv", - headers={"Content-Disposition": f'attachment; filename="{build_report_filename(dict(task_row), "csv")}"'} + headers={ + "Content-Disposition": f'attachment; filename="{build_report_filename(dict(task_row), "csv")}"' + }, ) -@router.get("/task/{task_id}/report/html", dependencies=[Depends(report_download_limiter)]) + +@router.get( + "/task/{task_id}/report/html", dependencies=[Depends(report_download_limiter)] +) async def download_html_report(task_id: str, owner: str = Depends(get_current_owner)): """Download task results as an HTML report.""" db = await get_db() task_row = await db.fetchone( "SELECT id, owner_id, plugin_id, tool_name, target, status, created_at, preset, inputs_json, command_used, structured_json FROM tasks WHERE id = ?", - (task_id,) + (task_id,), ) if not task_row: raise HTTPException(status_code=404, detail="Task not found") if task_row["owner_id"] != owner: - raise HTTPException(status_code=403, detail="You do not have access to this task") + raise HTTPException( + status_code=403, detail="You do not have access to this task" + ) 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}) + 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"]}, + context={ + "format": "html", + "task_id": task_id, + "plugin_id": task_row["plugin_id"], + }, task_id=task_id, plugin_id=task_row["plugin_id"], ) @@ -738,37 +917,56 @@ async def download_html_report(task_id: str, owner: str = Depends(get_current_ow return Response( content=html_content, media_type="text/html", - headers={"Content-Disposition": f'attachment; filename="{build_report_filename(dict(task_row), "html")}"'} + headers={ + "Content-Disposition": f'attachment; filename="{build_report_filename(dict(task_row), "html")}"' + }, ) -@router.get("/task/{task_id}/report/pdf", dependencies=[Depends(report_download_limiter)]) + +@router.get( + "/task/{task_id}/report/pdf", dependencies=[Depends(report_download_limiter)] +) async def download_pdf_report(task_id: str, owner: str = Depends(get_current_owner)): """Download task results as a PDF report.""" db = await get_db() task_row = await db.fetchone( "SELECT id, owner_id, plugin_id, tool_name, target, status, created_at, preset, inputs_json, command_used, structured_json FROM tasks WHERE id = ?", - (task_id,) + (task_id,), ) if not task_row: raise HTTPException(status_code=404, detail="Task not found") if task_row["owner_id"] != owner: - raise HTTPException(status_code=403, detail="You do not have access to this task") + raise HTTPException( + status_code=403, detail="You do not have access to this task" + ) 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})) + 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"]}, + context={ + "format": "pdf", + "task_id": task_id, + "plugin_id": task_row["plugin_id"], + }, task_id=task_id, plugin_id=task_row["plugin_id"], ) @@ -776,38 +974,54 @@ async def download_pdf_report(task_id: str, owner: str = Depends(get_current_own return Response( content=pdf_bytes, media_type="application/pdf", - headers={"Content-Disposition": f'attachment; filename="{build_report_filename(dict(task_row), "pdf")}"'} + headers={ + "Content-Disposition": f'attachment; filename="{build_report_filename(dict(task_row), "pdf")}"' + }, ) -@router.get("/task/{task_id}/report/sarif", dependencies=[Depends(report_download_limiter)]) +@router.get( + "/task/{task_id}/report/sarif", dependencies=[Depends(report_download_limiter)] +) async def download_sarif_report(task_id: str, owner: str = Depends(get_current_owner)): """Download task results as a SARIF report.""" db = await get_db() task_row = await db.fetchone( "SELECT id, owner_id, plugin_id, tool_name, target, status, created_at, preset, inputs_json, command_used, structured_json FROM tasks WHERE id = ?", - (task_id,) + (task_id,), ) if not task_row: raise HTTPException(status_code=404, detail="Task not found") if task_row["owner_id"] != owner: - raise HTTPException(status_code=403, detail="You do not have access to this task") + raise HTTPException( + status_code=403, detail="You do not have access to this task" + ) 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}) + 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"]}, + context={ + "format": "sarif", + "task_id": task_id, + "plugin_id": task_row["plugin_id"], + }, task_id=task_id, plugin_id=task_row["plugin_id"], ) @@ -815,7 +1029,9 @@ async def download_sarif_report(task_id: str, owner: str = Depends(get_current_o return Response( content=sarif_data, media_type="application/sarif+json", - headers={"Content-Disposition": f'attachment; filename="{build_report_filename(dict(task_row), "sarif")}"'} + headers={ + "Content-Disposition": f'attachment; filename="{build_report_filename(dict(task_row), "sarif")}"' + }, ) @@ -840,7 +1056,7 @@ async def get_task_result(task_id: str, owner: str = Depends(get_current_owner)) raw_output_path, command_used, error_message, exit_code FROM tasks WHERE id = ? """, - (task_id,) + (task_id,), ) if not task_row: @@ -865,24 +1081,35 @@ async def get_task_result(task_id: str, owner: str = Depends(get_current_owner)) asset_services = deserialize_asset_service_rows(asset_rows) if not findings and isinstance(structured, dict): - findings = [item for item in structured.get("findings", []) if isinstance(item, dict)] + findings = [ + item for item in structured.get("findings", []) if isinstance(item, dict) + ] 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 - finding_groups = structured.get("finding_groups") if isinstance(structured, dict) else None + finding_groups = ( + structured.get("finding_groups") if isinstance(structured, dict) else None + ) if not isinstance(finding_groups, list) or not finding_groups: finding_groups = build_finding_groups(findings) - asset_summary = structured.get("asset_summary") if isinstance(structured, dict) else None + asset_summary = ( + structured.get("asset_summary") if isinstance(structured, dict) else None + ) if not isinstance(asset_summary, list) or not asset_summary: asset_summary = build_asset_summary(findings, asset_services) scan_diff = structured.get("scan_diff") if isinstance(structured, dict) else None if not isinstance(scan_diff, dict): - scan_diff = {"new": [], "resolved": [], "changed": [], "summary": {"new_count": 0, "resolved_count": 0, "changed_count": 0}} + scan_diff = { + "new": [], + "resolved": [], + "changed": [], + "summary": {"new_count": 0, "resolved_count": 0, "changed_count": 0}, + } if isinstance(structured, dict): structured["findings"] = findings @@ -892,32 +1119,51 @@ async def get_task_result(task_id: str, owner: str = Depends(get_current_owner)) structured["asset_services"] = asset_services structured["severity_counts"] = severity_counts - 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 [] + 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) + 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.") + 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.") + 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.") + 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.") + 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.") + 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: + with open(task_row["raw_output_path"], "r") as f: raw_output = f.read(100000) except Exception: pass @@ -932,7 +1178,9 @@ async def get_task_result(task_id: str, owner: str = Depends(get_current_owner)) "status": task_row["status"], "preset": task_row["preset"], "inputs": redact_inputs(json.loads(task_row["inputs_json"] or "{}")), - "execution_context": normalize_execution_context(json.loads(task_row["execution_context_json"] or "{}")), + "execution_context": normalize_execution_context( + json.loads(task_row["execution_context_json"] or "{}") + ), "summary": summary, "severity_counts": severity_counts, "findings": findings, @@ -944,10 +1192,14 @@ async def get_task_result(task_id: str, owner: str = Depends(get_current_owner)) "raw_output_excerpt": raw_output, "raw_output": raw_output, "command_used": task_row["command_used"], - "errors": [{"message": redact(task_row["error_message"])}] if task_row["error_message"] else [], - "error_message": redact(task_row["error_message"]) if task_row["error_message"] else None, + "errors": [{"message": redact(task_row["error_message"])}] + if task_row["error_message"] + else [], + "error_message": redact(task_row["error_message"]) + if task_row["error_message"] + else None, "exit_code": task_row["exit_code"], - "metadata": {} + "metadata": {}, } if task_row["status"] in ["completed", "failed", "cancelled"]: @@ -967,11 +1219,7 @@ async def cancel_task(task_id: str, owner: str = Depends(get_current_owner)): 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" - } + return {"task_id": task_id, "status": "cancelled", "cancelled_at": "now"} @router.get("/dashboard/summary", dependencies=[Depends(read_heavy_limiter)]) @@ -981,11 +1229,18 @@ async def get_dashboard_summary(owner: str = Depends(get_current_owner)): async def build(): db = await get_db() - async def query_or_default(label: str, query_fn: Callable[[], Any], default: Any) -> Any: + async def query_or_default( + label: str, query_fn: Callable[[], Any], default: Any + ) -> Any: try: return await query_fn() except Exception as exc: - logger.warning("Dashboard summary query '%s' failed for owner %s: %s", label, owner, exc) + logger.warning( + "Dashboard summary query '%s' failed for owner %s: %s", + label, + owner, + exc, + ) return default # Get data @@ -1030,7 +1285,11 @@ async def query_or_default(label: str, query_fn: Callable[[], Any], default: Any ), None, ) - total_findings = total_findings_row["total"] if total_findings_row else sum(severity_counts.values()) + total_findings = ( + total_findings_row["total"] + if total_findings_row + else sum(severity_counts.values()) + ) critical_findings: int = severity_counts.get("critical", 0) high_findings: int = severity_counts.get("high", 0) @@ -1059,7 +1318,13 @@ async def query_or_default(label: str, query_fn: Callable[[], Any], default: Any ) recent_findings: List[Dict] = parse_json_fields( recent_rows, - ["metadata_json", "risk_factors_json", "evidence_json", "asset_refs_json", "references_json"], + [ + "metadata_json", + "risk_factors_json", + "evidence_json", + "asset_refs_json", + "references_json", + ], ) for finding in recent_findings: if "risk_factors_json" in finding: @@ -1072,10 +1337,13 @@ async def query_or_default(label: str, query_fn: Callable[[], Any], default: Any finding["references"] = finding.pop("references_json") risk_scores = [ - f.get("risk_score") for f in recent_findings + 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 + avg_risk_score = ( + round(sum(risk_scores) / len(risk_scores), 1) if risk_scores else None + ) return { "total_findings": total_findings, @@ -1085,12 +1353,20 @@ async def query_or_default(label: str, query_fn: Callable[[], Any], default: Any "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, + "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, + "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 query_or_default( @@ -1101,7 +1377,7 @@ async def query_or_default(label: str, query_fn: Callable[[], Any], default: Any ), [], ), - [] + [], ), "recent_tasks": parse_json_fields( await query_or_default( @@ -1112,8 +1388,8 @@ async def query_or_default(label: str, query_fn: Callable[[], Any], default: Any ), [], ), - [] - ) + [], + ), } return await get_or_set_cached(f"summary:dashboard:{owner}", build) @@ -1156,7 +1432,9 @@ async def build(): } # Cache key includes pagination params so different pages do not collide. - return await get_or_set_cached(f"findings:list:{owner}:page={page}:per_page={per_page}", build) + return await get_or_set_cached( + f"findings:list:{owner}:page={page}:per_page={per_page}", build + ) @router.get("/finding-groups", dependencies=[Depends(read_heavy_limiter)]) @@ -1181,7 +1459,9 @@ async def build(): "per_page": per_page, } - return await get_or_set_cached(f"findings:groups:{owner}:page={page}:per_page={per_page}", build) + return await get_or_set_cached( + f"findings:groups:{owner}:page={page}:per_page={per_page}", build + ) @router.get("/task/{task_id}/diff", dependencies=[Depends(read_heavy_limiter)]) @@ -1194,7 +1474,9 @@ async def get_task_diff(task_id: str, owner: str = Depends(get_current_owner)): if not task_row: raise HTTPException(status_code=404, detail="Task not found") if task_row["owner_id"] != owner: - raise HTTPException(status_code=403, detail="You do not have access to this task") + raise HTTPException( + status_code=403, detail="You do not have access to this task" + ) structured = {} if task_row["structured_json"]: @@ -1204,7 +1486,12 @@ async def get_task_diff(task_id: str, owner: str = Depends(get_current_owner)): structured = {} diff = structured.get("scan_diff") if isinstance(structured, dict) else None if not isinstance(diff, dict): - diff = {"new": [], "resolved": [], "changed": [], "summary": {"new_count": 0, "resolved_count": 0, "changed_count": 0}} + diff = { + "new": [], + "resolved": [], + "changed": [], + "summary": {"new_count": 0, "resolved_count": 0, "changed_count": 0}, + } return diff @@ -1250,7 +1537,7 @@ async def list_tasks( allowed_values = ", ".join([s.value for s in TaskStatus]) raise HTTPException( status_code=400, - detail=f"Invalid task status '{status}'. Allowed values: {allowed_values}" + detail=f"Invalid task status '{status}'. Allowed values: {allowed_values}", ) where_clauses.append("status = ?") @@ -1268,11 +1555,26 @@ async def list_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 + 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", "execution_context_json"]) + tasks_list = parse_json_fields( + tasks, + [ + "structured_json", + "config_json", + "metadata_json", + "inputs_json", + "execution_context_json", + ], + ) for t in tasks_list: if "id" in t: t["task_id"] = t.pop("id") @@ -1297,6 +1599,7 @@ def build_page_url(page_num): if status: query_params["status"] = status return f"/api/v1/tasks?{urlencode(query_params)}" + return { "tasks": tasks_list, "pagination": { @@ -1305,13 +1608,14 @@ def build_page_url(page_num): "total_pages": total_pages, "total_items": total, "next": build_page_url(next_page), - "previous": build_page_url(prev_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. @@ -1333,7 +1637,7 @@ async def delete_task_records(task_ids: List[str]): placeholders = ",".join(["?"] * len(chunk)) rows = await db.fetchall( f"SELECT raw_output_path FROM tasks WHERE id IN ({placeholders})", - tuple(chunk) + tuple(chunk), ) all_task_rows.extend(rows) @@ -1346,12 +1650,12 @@ async def delete_task_records(task_ids: List[str]): placeholders = ",".join(["?"] * len(chunk)) running = await db.fetchone( f"SELECT 1 FROM tasks WHERE id IN ({placeholders}) AND status = 'running' LIMIT 1", - tuple(chunk) + tuple(chunk), ) if running: raise HTTPException( status_code=400, - detail="Cannot delete running tasks. Abort them first." + detail="Cannot delete running tasks. Abort them first.", ) for i in range(0, len(task_ids), SQLITE_CHUNK_SIZE): @@ -1359,25 +1663,32 @@ async def delete_task_records(task_ids: List[str]): placeholders = ",".join(["?"] * len(chunk)) # Delete notification_history first (depends on findings via finding_id) await db.execute_no_commit( - f"DELETE FROM notification_history WHERE finding_id IN (SELECT id FROM findings WHERE task_id IN ({placeholders}))", tuple(chunk) + f"DELETE FROM notification_history WHERE finding_id IN (SELECT id FROM findings WHERE task_id IN ({placeholders}))", + tuple(chunk), ) await db.execute_no_commit( - f"DELETE FROM findings WHERE task_id IN ({placeholders})", tuple(chunk) + f"DELETE FROM findings WHERE task_id IN ({placeholders})", + tuple(chunk), ) await db.execute_no_commit( - f"DELETE FROM reports WHERE task_id IN ({placeholders})", tuple(chunk) + f"DELETE FROM reports WHERE task_id IN ({placeholders})", + tuple(chunk), ) await db.execute_no_commit( - f"DELETE FROM audit_log WHERE task_id IN ({placeholders})", tuple(chunk) + f"DELETE FROM audit_log WHERE task_id IN ({placeholders})", + tuple(chunk), ) await db.execute_no_commit( - f"DELETE FROM crawl_runs WHERE task_id IN ({placeholders})", tuple(chunk) + f"DELETE FROM crawl_runs WHERE task_id IN ({placeholders})", + tuple(chunk), ) await db.execute_no_commit( - f"DELETE FROM asset_services WHERE task_id IN ({placeholders})", tuple(chunk) + f"DELETE FROM asset_services WHERE task_id IN ({placeholders})", + tuple(chunk), ) await db.execute_no_commit( - f"DELETE FROM tasks WHERE id IN ({placeholders})", tuple(chunk) + f"DELETE FROM tasks WHERE id IN ({placeholders})", + tuple(chunk), ) # Cleanup files on disk (outside the transaction — file deletion is not @@ -1390,7 +1701,10 @@ async def delete_task_records(task_ids: List[str]): if path.exists(): path.unlink() except Exception as e: - logger.error(f"Failed to delete raw output file {row['raw_output_path']}: {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, owner: str = Depends(get_current_owner)): @@ -1402,28 +1716,33 @@ async def delete_task(task_id: str, owner: str = Depends(get_current_owner)): # cannot be deleted across owners (issue #401). existing = await db.fetchone("SELECT owner_id FROM tasks WHERE id = ?", (task_id,)) if existing is not None and existing["owner_id"] != owner: - raise HTTPException(status_code=403, detail="You do not have access to this task") + raise HTTPException( + status_code=403, detail="You do not have access to this task" + ) # 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.") + raise HTTPException( + status_code=400, detail="Cannot delete a running task. Abort it first." + ) # If the task is currently executing but the DB hasn't been updated yet, fail closed. if task_id in executor.running_tasks: - raise HTTPException(status_code=400, detail="Cannot delete a running task. Abort it first.") + 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 - } + return {"task_id": task_id, "deleted": True} @router.delete("/tasks/bulk", dependencies=[Depends(admin_limiter)]) -async def bulk_delete_tasks(request: BulkDeleteRequest, owner: str = Depends(get_current_owner)): +async def bulk_delete_tasks( + request: BulkDeleteRequest, owner: str = Depends(get_current_owner) +): """Delete multiple tasks at once (max 500 IDs per request)""" task_ids = request.root # RootModel exposes data via .root db = await get_db() @@ -1448,22 +1767,24 @@ async def bulk_delete_tasks(request: BulkDeleteRequest, owner: str = Depends(get placeholders = ",".join(["?"] * len(owned_ids)) running_tasks = await db.fetchone( f"SELECT id FROM tasks WHERE id IN ({placeholders}) AND status = 'running' LIMIT 1", - tuple(owned_ids) + tuple(owned_ids), ) if running_tasks: - raise HTTPException(status_code=400, detail="Cannot delete running tasks. Abort them first.") + raise HTTPException( + status_code=400, detail="Cannot delete running tasks. Abort them first." + ) # If the task is currently executing but the DB hasn't been updated yet, fail closed. if any(tid in executor.running_tasks for tid in owned_ids): - raise HTTPException(status_code=400, detail="Cannot delete running tasks. Abort them first.") + raise HTTPException( + status_code=400, detail="Cannot delete running tasks. Abort them first." + ) await delete_task_records(owned_ids) await invalidate_view_cache() - return { - "deleted_count": len(owned_ids), - "success": True - } + return {"deleted_count": len(owned_ids), "success": True} + @router.delete("/tasks/clear", dependencies=[Depends(admin_limiter)]) async def clear_all_tasks(owner: str = Depends(get_current_owner)): @@ -1480,7 +1801,9 @@ async def clear_all_tasks(owner: str = Depends(get_current_owner)): (owner,), ) if running_tasks: - raise HTTPException(status_code=400, detail="Cannot clear history while tasks are running.") + raise HTTPException( + status_code=400, detail="Cannot clear history while tasks are running." + ) # Get the caller's task IDs to delete records and cleanup files own_tasks = await db.fetchall("SELECT id FROM tasks WHERE owner_id = ?", (owner,)) @@ -1496,7 +1819,7 @@ async def clear_all_tasks(owner: str = Depends(get_current_owner)): return { "cleared": True, - "message": "All scan history and associated data has been purged." + "message": "All scan history and associated data has been purged.", } @@ -1507,26 +1830,26 @@ async def get_settings(): "network": { "bind_address": settings.bind_address, "port": settings.bind_port, - "allow_remote": False + "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 - } + "memory_mb": settings.sandbox_memory_mb, + }, }, "safety": { "require_consent": settings.require_consent, "safe_mode_default": settings.safe_mode_default, - "allowed_networks": settings.allowed_networks + "allowed_networks": settings.allowed_networks, }, "execution_context": { "validation_modes": [mode.value for mode in ValidationMode], "evidence_levels": [level.value for level in EvidenceLevel], "default": ExecutionContext().model_dump(), - } + }, } @@ -1579,6 +1902,7 @@ async def upsert_vault_secret( ) return {"name": name, "stored": True} + @router.get("/vault/{name}", dependencies=[Depends(vault_limiter)]) async def get_vault_secret( name: str, @@ -1605,6 +1929,7 @@ async def get_vault_secret( "value": crypto.decrypt(row["encrypted_value"]), } + @router.delete("/vault/{name}", dependencies=[Depends(vault_limiter)]) async def delete_vault_secret( name: str, @@ -1612,7 +1937,6 @@ async def delete_vault_secret( ): db = await get_db() - cursor = await db.execute( """ DELETE FROM credential_vault @@ -1629,6 +1953,7 @@ async def delete_vault_secret( "deleted": True, } + @router.get("/target-policies") async def list_target_policies(owner: str = Depends(get_current_owner)): db = await get_db() @@ -1663,7 +1988,9 @@ def _validate_lengths( @router.post("/target-policies", dependencies=[Depends(admin_limiter)]) -async def create_target_policy(payload: Dict[str, Any], owner: str = Depends(get_current_owner)): +async def create_target_policy( + payload: Dict[str, Any], owner: str = Depends(get_current_owner) +): name = str(payload.get("name", "")).strip() if not name: raise HTTPException(status_code=400, detail="Target policy name is required") @@ -1702,9 +2029,14 @@ async def create_target_policy(payload: Dict[str, Any], owner: str = Depends(get @router.patch("/target-policies/{policy_id}", dependencies=[Depends(admin_limiter)]) -async def update_target_policy(policy_id: str, payload: Dict[str, Any], owner: str = Depends(get_current_owner)): +async def update_target_policy( + policy_id: str, payload: Dict[str, Any], owner: str = Depends(get_current_owner) +): db = await get_db() - row = await db.fetchone("SELECT id FROM target_policies WHERE id = ? AND owner_id = ?", (policy_id, owner)) + row = await db.fetchone( + "SELECT id FROM target_policies WHERE id = ? AND owner_id = ?", + (policy_id, owner), + ) if not row: raise HTTPException(status_code=404, detail="Target policy not found") @@ -1720,8 +2052,14 @@ async def update_target_policy(policy_id: str, payload: Dict[str, Any], owner: s for key in ("name", "description", "default_validation_mode"): if key in payload: updates.append(f"{key} = ?") - params.append(str(payload[key]).strip() if payload[key] is not None else None) - for key in ("allow_public_targets", "allow_exploit_validation", "allow_authenticated_scan"): + params.append( + str(payload[key]).strip() if payload[key] is not None else None + ) + for key in ( + "allow_public_targets", + "allow_exploit_validation", + "allow_authenticated_scan", + ): if key in payload: updates.append(f"{key} = ?") params.append(1 if payload[key] else 0) @@ -1733,15 +2071,26 @@ async def update_target_policy(policy_id: str, payload: Dict[str, Any], owner: s params.append(_json_payload(payload["metadata"], "{}")) updates.append("updated_at = datetime('now')") params.extend([policy_id, owner]) - await db.execute(f"UPDATE target_policies SET {', '.join(updates)} WHERE id = ? AND owner_id = ?", tuple(params)) - updated = await db.fetchone("SELECT * FROM target_policies WHERE id = ?", (policy_id,)) - return deserialize_resource_rows([updated])[0] if updated else {"id": policy_id, "updated": True} + await db.execute( + f"UPDATE target_policies SET {', '.join(updates)} WHERE id = ? AND owner_id = ?", + tuple(params), + ) + updated = await db.fetchone( + "SELECT * FROM target_policies WHERE id = ?", (policy_id,) + ) + return ( + deserialize_resource_rows([updated])[0] + if updated + else {"id": policy_id, "updated": True} + ) @router.delete("/target-policies/{policy_id}", dependencies=[Depends(admin_limiter)]) async def delete_target_policy(policy_id: str, owner: str = Depends(get_current_owner)): db = await get_db() - await db.execute("DELETE FROM target_policies WHERE id = ? AND owner_id = ?", (policy_id, owner)) + await db.execute( + "DELETE FROM target_policies WHERE id = ? AND owner_id = ?", (policy_id, owner) + ) return {"id": policy_id, "deleted": True} @@ -1756,10 +2105,14 @@ async def list_credential_profiles(owner: str = Depends(get_current_owner)): @router.post("/credential-profiles", dependencies=[Depends(admin_limiter)]) -async def create_credential_profile(payload: Dict[str, Any], owner: str = Depends(get_current_owner)): +async def create_credential_profile( + payload: Dict[str, Any], owner: str = Depends(get_current_owner) +): name = str(payload.get("name", "")).strip() if not name: - raise HTTPException(status_code=400, detail="Credential profile name is required") + raise HTTPException( + status_code=400, detail="Credential profile name is required" + ) _validate_lengths(name=name, resource_type="Credential profile") profile_id = str(uuid.uuid4()) @@ -1781,14 +2134,23 @@ async def create_credential_profile(payload: Dict[str, Any], owner: str = Depend _json_payload(payload.get("login_recipe"), "{}"), ), ) - row = await db.fetchone("SELECT * FROM credential_profiles WHERE id = ?", (profile_id,)) + row = await db.fetchone( + "SELECT * FROM credential_profiles WHERE id = ?", (profile_id,) + ) return deserialize_resource_rows([row])[0] if row else {"id": profile_id} -@router.patch("/credential-profiles/{profile_id}", dependencies=[Depends(admin_limiter)]) -async def update_credential_profile(profile_id: str, payload: Dict[str, Any], owner: str = Depends(get_current_owner)): +@router.patch( + "/credential-profiles/{profile_id}", dependencies=[Depends(admin_limiter)] +) +async def update_credential_profile( + profile_id: str, payload: Dict[str, Any], owner: str = Depends(get_current_owner) +): db = await get_db() - row = await db.fetchone("SELECT id FROM credential_profiles WHERE id = ? AND owner_id = ?", (profile_id, owner)) + row = await db.fetchone( + "SELECT id FROM credential_profiles WHERE id = ? AND owner_id = ?", + (profile_id, owner), + ) if not row: raise HTTPException(status_code=404, detail="Credential profile not found") @@ -1809,15 +2171,31 @@ async def update_credential_profile(profile_id: str, payload: Dict[str, Any], ow params.append(_json_payload(payload["login_recipe"], "{}")) updates.append("updated_at = datetime('now')") params.extend([profile_id, owner]) - await db.execute(f"UPDATE credential_profiles SET {', '.join(updates)} WHERE id = ? AND owner_id = ?", tuple(params)) - updated = await db.fetchone("SELECT * FROM credential_profiles WHERE id = ?", (profile_id,)) - return deserialize_resource_rows([updated])[0] if updated else {"id": profile_id, "updated": True} + await db.execute( + f"UPDATE credential_profiles SET {', '.join(updates)} WHERE id = ? AND owner_id = ?", + tuple(params), + ) + updated = await db.fetchone( + "SELECT * FROM credential_profiles WHERE id = ?", (profile_id,) + ) + return ( + deserialize_resource_rows([updated])[0] + if updated + else {"id": profile_id, "updated": True} + ) -@router.delete("/credential-profiles/{profile_id}", dependencies=[Depends(admin_limiter)]) -async def delete_credential_profile(profile_id: str, owner: str = Depends(get_current_owner)): +@router.delete( + "/credential-profiles/{profile_id}", dependencies=[Depends(admin_limiter)] +) +async def delete_credential_profile( + profile_id: str, owner: str = Depends(get_current_owner) +): db = await get_db() - await db.execute("DELETE FROM credential_profiles WHERE id = ? AND owner_id = ?", (profile_id, owner)) + await db.execute( + "DELETE FROM credential_profiles WHERE id = ? AND owner_id = ?", + (profile_id, owner), + ) return {"id": profile_id, "deleted": True} @@ -1832,7 +2210,9 @@ async def list_session_profiles(owner: str = Depends(get_current_owner)): @router.post("/session-profiles", dependencies=[Depends(admin_limiter)]) -async def create_session_profile(payload: Dict[str, Any], owner: str = Depends(get_current_owner)): +async def create_session_profile( + payload: Dict[str, Any], owner: str = Depends(get_current_owner) +): name = str(payload.get("name", "")).strip() if not name: raise HTTPException(status_code=400, detail="Session profile name is required") @@ -1856,14 +2236,21 @@ async def create_session_profile(payload: Dict[str, Any], owner: str = Depends(g notes or None, ), ) - row = await db.fetchone("SELECT * FROM session_profiles WHERE id = ?", (profile_id,)) + row = await db.fetchone( + "SELECT * FROM session_profiles WHERE id = ?", (profile_id,) + ) return deserialize_resource_rows([row])[0] if row else {"id": profile_id} @router.patch("/session-profiles/{profile_id}", dependencies=[Depends(admin_limiter)]) -async def update_session_profile(profile_id: str, payload: Dict[str, Any], owner: str = Depends(get_current_owner)): +async def update_session_profile( + profile_id: str, payload: Dict[str, Any], owner: str = Depends(get_current_owner) +): db = await get_db() - row = await db.fetchone("SELECT id FROM session_profiles WHERE id = ? AND owner_id = ?", (profile_id, owner)) + row = await db.fetchone( + "SELECT id FROM session_profiles WHERE id = ? AND owner_id = ?", + (profile_id, owner), + ) if not row: raise HTTPException(status_code=404, detail="Session profile not found") @@ -1885,15 +2272,29 @@ async def update_session_profile(profile_id: str, payload: Dict[str, Any], owner params.append(_json_payload(payload["extra_headers"], "{}")) updates.append("updated_at = datetime('now')") params.extend([profile_id, owner]) - await db.execute(f"UPDATE session_profiles SET {', '.join(updates)} WHERE id = ? AND owner_id = ?", tuple(params)) - updated = await db.fetchone("SELECT * FROM session_profiles WHERE id = ?", (profile_id,)) - return deserialize_resource_rows([updated])[0] if updated else {"id": profile_id, "updated": True} + await db.execute( + f"UPDATE session_profiles SET {', '.join(updates)} WHERE id = ? AND owner_id = ?", + tuple(params), + ) + updated = await db.fetchone( + "SELECT * FROM session_profiles WHERE id = ?", (profile_id,) + ) + return ( + deserialize_resource_rows([updated])[0] + if updated + else {"id": profile_id, "updated": True} + ) @router.delete("/session-profiles/{profile_id}", dependencies=[Depends(admin_limiter)]) -async def delete_session_profile(profile_id: str, owner: str = Depends(get_current_owner)): +async def delete_session_profile( + profile_id: str, owner: str = Depends(get_current_owner) +): db = await get_db() - await db.execute("DELETE FROM session_profiles WHERE id = ? AND owner_id = ?", (profile_id, owner)) + await db.execute( + "DELETE FROM session_profiles WHERE id = ? AND owner_id = ?", + (profile_id, owner), + ) return {"id": profile_id, "deleted": True} @@ -1934,7 +2335,9 @@ async def list_workflows(owner: str = Depends(get_current_owner)): @router.post("/workflows", dependencies=[Depends(admin_limiter)]) -async def create_workflow(payload: Dict[str, Any], owner: str = Depends(get_current_owner)): +async def create_workflow( + payload: Dict[str, Any], owner: str = Depends(get_current_owner) +): name = str(payload.get("name", "")).strip() if not name: raise HTTPException(status_code=400, detail="Workflow name is required") @@ -1942,11 +2345,14 @@ async def create_workflow(payload: Dict[str, Any], owner: str = Depends(get_curr steps = _parse_workflow_steps(payload.get("steps", [])) if not steps: - raise HTTPException(status_code=400, detail="Workflow requires at least one step") + raise HTTPException( + status_code=400, detail="Workflow requires at least one step" + ) schedule_timezone = payload.get("schedule_timezone") if schedule_timezone is not None: from .workflows import validate_schedule_timezone + is_valid, err_msg = validate_schedule_timezone(schedule_timezone) if not is_valid: raise HTTPException(status_code=400, detail=err_msg) @@ -1960,7 +2366,10 @@ async def create_workflow(payload: Dict[str, Any], owner: str = Depends(get_curr if parsed_schedule < 60: raise ValueError() except ValueError: - raise HTTPException(status_code=400, detail="Invalid schedule_seconds, must be an integer >= 60") + raise HTTPException( + status_code=400, + detail="Invalid schedule_seconds, must be an integer >= 60", + ) else: parsed_schedule = None @@ -1987,17 +2396,19 @@ async def create_workflow(payload: Dict[str, Any], owner: str = Depends(get_curr async def _verify_workflow_owner(db, workflow_id: str, owner: str): """Check the workflow exists and belongs to the caller. Returns the row or raises 404/403.""" - row = await db.fetchone( - "SELECT * FROM workflows WHERE id = ?", (workflow_id,) - ) + row = await db.fetchone("SELECT * FROM workflows WHERE id = ?", (workflow_id,)) if not row: raise HTTPException(status_code=404, detail="Workflow not found") if row["owner_id"] != owner: - raise HTTPException(status_code=403, detail="You do not have access to this workflow") + raise HTTPException( + status_code=403, detail="You do not have access to this workflow" + ) return row -@router.post("/workflows/{workflow_id}/run", dependencies=[Depends(check_scan_rate_limit)]) +@router.post( + "/workflows/{workflow_id}/run", dependencies=[Depends(check_scan_rate_limit)] +) async def run_workflow_once(workflow_id: str, owner: str = Depends(get_current_owner)): db = await get_db() row = await _verify_workflow_owner(db, workflow_id, owner) @@ -2025,8 +2436,12 @@ async def run_workflow_once(workflow_id: str, owner: str = Depends(get_current_o version_number = active_version["version_number"] created_task_ids: List[str] = [] for step in steps: - execution_context = normalize_execution_context(step.get("execution_context") or {}) - target_policy = await get_target_policy(db, owner, execution_context.get("target_policy_id")) + execution_context = normalize_execution_context( + step.get("execution_context") or {} + ) + target_policy = await get_target_policy( + db, owner, execution_context.get("target_policy_id") + ) safe_mode = bool( settings.safe_mode_default and not (target_policy and target_policy.get("allow_public_targets")) @@ -2046,13 +2461,22 @@ async def run_workflow_once(workflow_id: str, owner: str = Depends(get_current_o can_acquire, concurrency_err = await concurrent_limiter.acquire(task_id) if not can_acquire: - await executor.mark_task_failed(task_id, reason="Concurrency limit reached; task was not started") - logger.warning("Workflow %s: concurrency limit reached for step %s", workflow_id, step.get("plugin_id")) + await executor.mark_task_failed( + task_id, reason="Concurrency limit reached; task was not started" + ) + logger.warning( + "Workflow %s: concurrency limit reached for step %s", + workflow_id, + step.get("plugin_id"), + ) continue 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,)) + await db.execute( + "UPDATE workflows SET last_run_at = datetime('now') WHERE id = ?", + (workflow_id,), + ) run_id = await db.record_workflow_run( workflow_id=workflow_id, version_id=version_id, @@ -2070,9 +2494,13 @@ async def run_workflow_once(workflow_id: str, owner: str = Depends(get_current_o } - @router.get("/workflows/{workflow_id}/runs") -async def list_workflow_runs(workflow_id: str, owner: str = Depends(get_current_owner), limit: int = 50, offset: int = 0): +async def list_workflow_runs( + workflow_id: str, + owner: str = Depends(get_current_owner), + limit: int = 50, + offset: int = 0, +): """Return paginated run history for a workflow.""" if limit < 1 or limit > 500: raise HTTPException(status_code=400, detail="limit must be between 1 and 500") @@ -2080,11 +2508,15 @@ async def list_workflow_runs(workflow_id: str, owner: str = Depends(get_current_ raise HTTPException(status_code=400, detail="offset must be non-negative") db = await get_db() await _verify_workflow_owner(db, workflow_id, owner) - return await db.get_workflow_runs(workflow_id=workflow_id, limit=limit, offset=offset) + return await db.get_workflow_runs( + workflow_id=workflow_id, limit=limit, offset=offset + ) @router.get("/workflows/{workflow_id}/versions") -async def list_workflow_versions(workflow_id: str, owner: str = Depends(get_current_owner)): +async def list_workflow_versions( + workflow_id: str, owner: str = Depends(get_current_owner) +): """Return all saved version snapshots for a workflow, newest first.""" db = await get_db() await _verify_workflow_owner(db, workflow_id, owner) @@ -2093,7 +2525,9 @@ async def list_workflow_versions(workflow_id: str, owner: str = Depends(get_curr @router.post("/workflows/{workflow_id}/rollback/{version_number}") -async def rollback_workflow(workflow_id: str, version_number: int, owner: str = Depends(get_current_owner)): +async def rollback_workflow( + workflow_id: str, version_number: int, owner: str = Depends(get_current_owner) +): """Restore a workflow to a previously saved version. The target version's full definition replaces the live workflow fields. @@ -2116,7 +2550,14 @@ async def rollback_workflow(workflow_id: str, version_number: int, owner: str = enabled = bool(defn.get("enabled", True)) await db.execute( "UPDATE workflows SET name = ?, steps_json = ?, schedule_seconds = ?, enabled = ?, schedule_timezone = ? WHERE id = ?", - (name, json.dumps(steps), schedule_seconds, 1 if enabled else 0, schedule_timezone, workflow_id), + ( + name, + json.dumps(steps), + schedule_seconds, + 1 if enabled else 0, + schedule_timezone, + workflow_id, + ), ) new_version = await db.snapshot_workflow_version( workflow_id=workflow_id, @@ -2137,7 +2578,9 @@ async def rollback_workflow(workflow_id: str, version_number: int, owner: str = @router.patch("/workflows/{workflow_id}", dependencies=[Depends(admin_limiter)]) -async def update_workflow(workflow_id: str, payload: Dict[str, Any], owner: str = Depends(get_current_owner)): +async def update_workflow( + workflow_id: str, payload: Dict[str, Any], owner: str = Depends(get_current_owner) +): db = await get_db() row = await _verify_workflow_owner(db, workflow_id, owner) @@ -2164,6 +2607,7 @@ async def update_workflow(workflow_id: str, payload: Dict[str, Any], owner: str tz_val = payload["schedule_timezone"] if tz_val is not None: from .workflows import validate_schedule_timezone + is_valid, err_msg = validate_schedule_timezone(tz_val) if not is_valid: raise HTTPException(status_code=400, detail=err_msg) @@ -2178,9 +2622,11 @@ async def update_workflow(workflow_id: str, payload: Dict[str, Any], owner: str updates.append("enabled = ?") params.append(1 if new_enabled else 0) - enabled_changed = (new_enabled != old_enabled) + enabled_changed = new_enabled != old_enabled params.append(workflow_id) - await db.execute(f"UPDATE workflows SET {', '.join(updates)} WHERE id = ?", tuple(params)) + await db.execute( + f"UPDATE workflows SET {', '.join(updates)} WHERE id = ?", tuple(params) + ) updated = await db.fetchone("SELECT * FROM workflows WHERE id = ?", (workflow_id,)) if updated is None: return {"workflow_id": workflow_id, "updated": True} @@ -2196,14 +2642,9 @@ async def update_workflow(workflow_id: str, payload: Dict[str, Any], owner: str if enabled_changed: await db.log_audit( - event_type=( - "workflow_enabled" - if new_enabled - else "workflow_disabled" - ), + event_type=("workflow_enabled" if new_enabled else "workflow_disabled"), message=( - f"Workflow {workflow_id} " - f"{'enabled' if new_enabled else 'disabled'}" + f"Workflow {workflow_id} {'enabled' if new_enabled else 'disabled'}" ), context={ "workflow_id": workflow_id, @@ -2223,7 +2664,10 @@ async def delete_workflow(workflow_id: str, owner: str = Depends(get_current_own return {"workflow_id": workflow_id, "deleted": True} -@router.post("/workflows/scheduler/tick", dependencies=[Depends(scheduler_tick_limiter), Depends(check_scan_rate_limit)]) +@router.post( + "/workflows/scheduler/tick", + dependencies=[Depends(scheduler_tick_limiter), Depends(check_scan_rate_limit)], +) async def trigger_workflow_tick(): await scheduler.tick() return {"tick": "ok"} @@ -2241,12 +2685,16 @@ async def list_notification_rules(owner: str = Depends(get_current_owner)): @router.post("/notifications/rules") -async def create_notification_rule(payload: NotificationRuleCreate, owner: str = Depends(get_current_owner)): +async def create_notification_rule( + payload: NotificationRuleCreate, owner: str = Depends(get_current_owner) +): name = payload.name.strip() if not name: raise HTTPException(status_code=400, detail="Rule name is required") - target = _validate_notification_target(payload.channel_type, payload.target_url_or_email) + target = _validate_notification_target( + payload.channel_type, payload.target_url_or_email + ) rule_id = str(uuid.uuid4()) db = await get_db() await db.execute( @@ -2270,14 +2718,17 @@ async def create_notification_rule(payload: NotificationRuleCreate, owner: str = (rule_id,), ) if not row: - raise HTTPException(status_code=500, detail="Failed to create notification rule") + raise HTTPException( + status_code=500, detail="Failed to create notification rule" + ) return _serialize_notification_rule(row) + @router.get("/rate-limit/status") async def get_rate_limit_status(request: Request): """Get current rate limit status for the client.""" - limiter = getattr(request.app.state, 'scan_rate_limiter', None) - if limiter and hasattr(limiter, 'get_status'): + limiter = getattr(request.app.state, "scan_rate_limiter", None) + if limiter and hasattr(limiter, "get_status"): client_id = request.client.host if request.client else "unknown" status_info = await limiter.get_status(client_id) return { @@ -2298,7 +2749,9 @@ async def _verify_notification_rule_owner(db, rule_id: str, owner: str): if not row: raise HTTPException(status_code=404, detail="Notification rule not found") if row["owner_id"] != owner: - raise HTTPException(status_code=403, detail="You do not have access to this notification rule") + raise HTTPException( + status_code=403, detail="You do not have access to this notification rule" + ) return row @@ -2310,7 +2763,11 @@ async def get_notification_rule(rule_id: str, owner: str = Depends(get_current_o @router.patch("/notifications/rules/{rule_id}") -async def update_notification_rule(rule_id: str, payload: NotificationRuleUpdate, owner: str = Depends(get_current_owner)): +async def update_notification_rule( + rule_id: str, + payload: NotificationRuleUpdate, + owner: str = Depends(get_current_owner), +): """Patch a notification rule. Returns ``409 Conflict`` with the latest persisted rule when an optimistic @@ -2381,7 +2838,9 @@ async def update_notification_rule(rule_id: str, payload: NotificationRuleUpdate @router.delete("/notifications/rules/{rule_id}") -async def delete_notification_rule(rule_id: str, owner: str = Depends(get_current_owner)): +async def delete_notification_rule( + rule_id: str, owner: str = Depends(get_current_owner) +): db = await get_db() await _verify_notification_rule_owner(db, rule_id, owner) await db.execute("DELETE FROM notification_rules WHERE id = ?", (rule_id,)) @@ -2401,7 +2860,12 @@ async def get_scan_webhook_settings(owner: str = Depends(get_current_owner)): (owner,), ) if not row: - return {"webhook_url": None, "platform": None, "configured": False, "updated_at": None} + return { + "webhook_url": None, + "platform": None, + "configured": False, + "updated_at": None, + } webhook_url = row["webhook_url"] return { "webhook_url": webhook_url, @@ -2417,7 +2881,9 @@ async def upsert_scan_webhook_settings( owner: str = Depends(get_current_owner), ): """Create or update the scan-completion webhook URL for the current owner.""" - target = _validate_notification_target(NotificationChannelType.WEBHOOK, payload.webhook_url) + target = _validate_notification_target( + NotificationChannelType.WEBHOOK, payload.webhook_url + ) db = await get_db() row = await notification_service.set_scan_webhook_url(db, owner, target) return { @@ -2491,14 +2957,16 @@ async def get_finding_details(finding_id: str, owner: str = Depends(get_current_ JOIN tasks t ON f.task_id = t.id WHERE f.id = ? """, - (finding_id,) + (finding_id,), ) if not finding_row: raise HTTPException(status_code=404, detail="Finding not found") if finding_row["owner_id"] != owner: - raise HTTPException(status_code=403, detail="You do not have access to this finding") + raise HTTPException( + status_code=403, detail="You do not have access to this finding" + ) metadata = {} if finding_row["metadata_json"]: @@ -2563,30 +3031,34 @@ async def get_attack_surface(owner: str = Depends(get_current_owner)): 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"] - }) + 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"] - }) + 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} @@ -2608,6 +3080,7 @@ async def get_assets(owner: str = Depends(get_current_owner)): assets = [{"id": str(uuid.uuid4()), "name": row["target"]} for row in rows] return {"assets": assets} + # ── Network Policy Management Endpoints ───────────────────────────────────── from fastapi.security import APIKeyHeader @@ -2617,6 +3090,7 @@ async def get_assets(owner: str = Depends(get_current_owner)): api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) + def verify_admin_access( api_key: Optional[str] = Security(api_key_header), request: Request = None, @@ -2628,14 +3102,14 @@ def verify_admin_access( if not settings.admin_api_key: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Admin API Key is not configured on the server. Please set SECUSCAN_ADMIN_API_KEY." + detail="Admin API Key is not configured on the server. Please set SECUSCAN_ADMIN_API_KEY.", ) # Entropy check: enforce a strong API key if len(settings.admin_api_key) < 16: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Admin API Key is too weak. It must be at least 16 characters long." + detail="Admin API Key is too weak. It must be at least 16 characters long.", ) candidate = api_key @@ -2656,20 +3130,25 @@ def verify_admin_access( if not candidate or not hmac.compare_digest(candidate, settings.admin_api_key): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid or missing Admin API Key" + detail="Invalid or missing Admin API Key", ) return candidate + @router.get( "/admin/diagnostics/notifications", response_model=NotificationDiagnosticsResponse, - dependencies=[Depends(verify_admin_access), Depends(admin_limiter)] + dependencies=[Depends(verify_admin_access), Depends(admin_limiter)], ) async def get_notification_diagnostics(): """Get active notification delivery configuration and retry policy""" return notification_service.get_delivery_configuration() -@router.get("/admin/network-policy", dependencies=[Depends(verify_admin_access), Depends(admin_limiter)]) + +@router.get( + "/admin/network-policy", + dependencies=[Depends(verify_admin_access), Depends(admin_limiter)], +) async def get_network_policy(): """Get current network policy configuration""" engine = get_policy_engine() @@ -2680,7 +3159,11 @@ async def get_network_policy(): "audit_entries_count": len(engine.audit_entries), } -@router.post("/admin/network-policy/allow", dependencies=[Depends(verify_admin_access), Depends(admin_limiter)]) + +@router.post( + "/admin/network-policy/allow", + dependencies=[Depends(verify_admin_access), Depends(admin_limiter)], +) async def add_allow_rule(request: dict): """Add network to allowlist""" engine = get_policy_engine() @@ -2694,7 +3177,11 @@ async def add_allow_rule(request: dict): except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) -@router.post("/admin/network-policy/deny", dependencies=[Depends(verify_admin_access), Depends(admin_limiter)]) + +@router.post( + "/admin/network-policy/deny", + dependencies=[Depends(verify_admin_access), Depends(admin_limiter)], +) async def add_deny_rule(request: dict): """Add network to denylist""" engine = get_policy_engine() @@ -2708,11 +3195,13 @@ async def add_deny_rule(request: dict): except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) -@router.get("/admin/network-audit-log", dependencies=[Depends(verify_admin_access), Depends(admin_limiter)]) + +@router.get( + "/admin/network-audit-log", + dependencies=[Depends(verify_admin_access), Depends(admin_limiter)], +) async def get_audit_log( - plugin_id: Optional[str] = None, - action: Optional[str] = None, - limit: int = 100 + plugin_id: Optional[str] = None, action: Optional[str] = None, limit: int = 100 ): """Retrieve network audit log entries""" engine = get_policy_engine() @@ -2722,9 +3211,7 @@ async def get_audit_log( policy_action = PolicyAction[action.upper()] entries = engine.get_audit_entries( - plugin_id=plugin_id, - action=policy_action, - limit=limit + plugin_id=plugin_id, action=policy_action, limit=limit ) return { @@ -2732,7 +3219,11 @@ async def get_audit_log( "total": len(entries), } -@router.get("/admin/network-audit-log/export", dependencies=[Depends(verify_admin_access), Depends(admin_limiter)]) + +@router.get( + "/admin/network-audit-log/export", + dependencies=[Depends(verify_admin_access), Depends(admin_limiter)], +) async def export_audit_log(format: str = "json"): """Export audit log in specified format""" engine = get_policy_engine() @@ -2746,11 +3237,14 @@ async def export_audit_log(format: str = "json"): return Response( content=content, media_type=mime_type, - headers={"Content-Disposition": f"attachment; filename=network-audit.{format}"} + headers={"Content-Disposition": f"attachment; filename=network-audit.{format}"}, ) -@router.get("/admin/vault/diagnostics", dependencies=[Depends(verify_admin_access), Depends(admin_limiter)]) +@router.get( + "/admin/vault/diagnostics", + dependencies=[Depends(verify_admin_access), Depends(admin_limiter)], +) async def get_vault_diagnostics(): """Report non-secret diagnostics for the credential vault key. Surfaces a one-way fingerprint of the active vault key so operators can confirm key-rotation state without the key material ever leaving the server.