diff --git a/backend/secuscan/database.py b/backend/secuscan/database.py index 8ff8775e..58cb3139 100644 --- a/backend/secuscan/database.py +++ b/backend/secuscan/database.py @@ -33,10 +33,11 @@ async def connect(self): """Establish database connection and ensure schema exists.""" # Ensure data directory exists Path(self.db_path).parent.mkdir(parents=True, exist_ok=True) - + conn = await aiosqlite.connect(self.db_path) self._connection = conn conn.row_factory = aiosqlite.Row + await conn.execute("PRAGMA foreign_keys = ON") await self._create_schema() async def disconnect(self): @@ -121,6 +122,37 @@ async def _create_schema(self): file_path TEXT ); + CREATE TABLE IF NOT EXISTS assets ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + name TEXT NOT NULL, + host_id TEXT REFERENCES assets(id) ON DELETE CASCADE, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')), + updated_at TIMESTAMP NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS asset_findings ( + asset_id TEXT NOT NULL REFERENCES assets(id) ON DELETE CASCADE, + finding_id TEXT NOT NULL REFERENCES findings(id) ON DELETE CASCADE, + PRIMARY KEY (asset_id, finding_id) + ); + + CREATE TABLE IF NOT EXISTS asset_tasks ( + asset_id TEXT NOT NULL REFERENCES assets(id) ON DELETE CASCADE, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + PRIMARY KEY (asset_id, task_id) + ); + + CREATE TABLE IF NOT EXISTS asset_reports ( + asset_id TEXT NOT NULL REFERENCES assets(id) ON DELETE CASCADE, + report_id TEXT NOT NULL REFERENCES reports(id) ON DELETE CASCADE, + PRIMARY KEY (asset_id, report_id) + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_assets_unique_host ON assets(name) WHERE type = 'host'; + CREATE UNIQUE INDEX IF NOT EXISTS idx_assets_unique_service ON assets(host_id, name) WHERE type = 'service'; + CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, @@ -182,7 +214,7 @@ async def _create_schema(self): # Migration logic: ensure latest columns exist in 'tasks' table tasks_columns = await self.fetchall("PRAGMA table_info(tasks)") existing_cols = {col["name"] for col in tasks_columns} - + needed_cols = { "exit_code": "INTEGER", "structured_json": "TEXT", diff --git a/backend/secuscan/executor.py b/backend/secuscan/executor.py index 3b45fbbe..e0e86f26 100644 --- a/backend/secuscan/executor.py +++ b/backend/secuscan/executor.py @@ -688,6 +688,7 @@ async def _upsert_findings_and_report(self, db, task_id: str, plugin, plugin_id: 1, ), ) + await self._update_assets_for_task(db, task_id) async def _upsert_findings_and_report_from_scanner(self, db, task_id: str, scanner: Any, plugin_id: str, target: str, status: str, result: Dict[str, Any]): """Persist modular scanner results into findings, and reports.""" @@ -743,6 +744,167 @@ async def _upsert_findings_and_report_from_scanner(self, db, task_id: str, scann 2, # Professional reports are typically multi-page ), ) + await self._update_assets_for_task(db, task_id) + + async def _update_assets_for_task(self, db, task_id: str): + """Analyze task execution results and update the asset inventory with deduplication.""" + task_row = await db.fetchone( + "SELECT target, plugin_id FROM tasks WHERE id = ?", + (task_id,) + ) + if not task_row: + return + + target = task_row["target"] + plugin_id = task_row["plugin_id"] + report_id = f"report:{task_id}" + + # Normalize target to extract host + host_name = target + if "://" in target: + host_name = target.split("://", 1)[1].split("/", 1)[0] + else: + host_name = target.split("/", 1)[0] + + if host_name.startswith("[") and "]" in host_name: + host_name = host_name.split("]")[0][1:] + elif host_name.count(":") == 1: + parts = host_name.split(":") + if parts[1].isdigit(): + host_name = parts[0] + + host_name = host_name.strip() + if not host_name: + return + + # Deduplicate and upsert host asset (race-safe) + host_asset_id = f"asset:host:{str(uuid.uuid4()).replace('-', '')[:16]}" + await db.execute( + """ + INSERT OR IGNORE INTO assets (id, type, name, host_id, metadata_json, created_at, updated_at) + VALUES (?, 'host', ?, NULL, '{}', (datetime('now')), (datetime('now'))) + """, + (host_asset_id, host_name) + ) + host_row = await db.fetchone( + "SELECT id FROM assets WHERE type = 'host' AND name = ?", + (host_name,) + ) + if host_row: + host_asset_id = host_row["id"] + + # Link host to task and report + await db.execute( + "INSERT OR IGNORE INTO asset_tasks (asset_id, task_id) VALUES (?, ?)", + (host_asset_id, task_id) + ) + await db.execute( + "INSERT OR IGNORE INTO asset_reports (asset_id, report_id) VALUES (?, ?)", + (host_asset_id, report_id) + ) + + # Fetch findings for this task + findings = await db.fetchall( + "SELECT id, title, category, severity, metadata_json FROM findings WHERE task_id = ?", + (task_id,) + ) + + for finding in findings: + finding_id = finding["id"] + category = finding["category"] + metadata = {} + if finding["metadata_json"]: + try: + metadata = json.loads(finding["metadata_json"]) + except Exception: + pass + + port = metadata.get("port") + protocol = metadata.get("protocol") or "tcp" + + # Fallback parsing for Port Scanner category + if not port and category == "Network Service": + port_match = re.search(r"Open Port:\s*(\d+)/(\w+)", finding["title"]) + if port_match: + port = port_match.group(1) + protocol = port_match.group(2) + + if port: + service_name = f"{port}/{protocol}" + # Deduplicate service asset under host (race-safe) + service_asset_id = f"asset:service:{str(uuid.uuid4()).replace('-', '')[:16]}" + service_meta = { + "port": str(port), + "protocol": protocol, + "service": metadata.get("service") or "unknown", + "version": metadata.get("version") or "" + } + await db.execute( + """ + INSERT OR IGNORE INTO assets (id, type, name, host_id, metadata_json, created_at, updated_at) + VALUES (?, 'service', ?, ?, ?, (datetime('now')), (datetime('now'))) + """, + (service_asset_id, service_name, host_asset_id, json.dumps(service_meta)) + ) + service_row = await db.fetchone( + "SELECT id FROM assets WHERE type = 'service' AND name = ? AND host_id = ?", + (service_name, host_asset_id) + ) + if service_row: + service_asset_id = service_row["id"] + + # Link service asset + await db.execute( + "INSERT OR IGNORE INTO asset_tasks (asset_id, task_id) VALUES (?, ?)", + (service_asset_id, task_id) + ) + await db.execute( + "INSERT OR IGNORE INTO asset_reports (asset_id, report_id) VALUES (?, ?)", + (service_asset_id, report_id) + ) + await db.execute( + "INSERT OR IGNORE INTO asset_findings (asset_id, finding_id) VALUES (?, ?)", + (service_asset_id, finding_id) + ) + else: + subdomain = metadata.get("subdomain") + if subdomain and isinstance(subdomain, str) and subdomain.strip(): + subdomain = subdomain.strip() + # Deduplicate subdomain host (race-safe) + sub_asset_id = f"asset:host:{str(uuid.uuid4()).replace('-', '')[:16]}" + await db.execute( + """ + INSERT OR IGNORE INTO assets (id, type, name, host_id, metadata_json, created_at, updated_at) + VALUES (?, 'host', ?, NULL, '{}', (datetime('now')), (datetime('now'))) + """, + (sub_asset_id, subdomain) + ) + sub_row = await db.fetchone( + "SELECT id FROM assets WHERE type = 'host' AND name = ?", + (subdomain,) + ) + if sub_row: + sub_asset_id = sub_row["id"] + + # Link subdomain + await db.execute( + "INSERT OR IGNORE INTO asset_tasks (asset_id, task_id) VALUES (?, ?)", + (sub_asset_id, task_id) + ) + await db.execute( + "INSERT OR IGNORE INTO asset_reports (asset_id, report_id) VALUES (?, ?)", + (sub_asset_id, report_id) + ) + await db.execute( + "INSERT OR IGNORE INTO asset_findings (asset_id, finding_id) VALUES (?, ?)", + (sub_asset_id, finding_id) + ) + else: + # Link directly to host + await db.execute( + "INSERT OR IGNORE INTO asset_findings (asset_id, finding_id) VALUES (?, ?)", + (host_asset_id, finding_id) + ) def _parse_results(self, plugin, output: str) -> Dict[str, Any]: """Route to appropriate parser based on plugin metadata.""" diff --git a/backend/secuscan/main.py b/backend/secuscan/main.py index 08eb02c2..a1219b68 100644 --- a/backend/secuscan/main.py +++ b/backend/secuscan/main.py @@ -18,6 +18,7 @@ from .plugins import init_plugins from .routes import router from .workflows import scheduler +from .models import HealthResponse # Configure logging @@ -119,7 +120,7 @@ async def redirect_api_openapi(): # Health check endpoint -@app.get("/api/v1/health") +@app.get("/api/v1/health", response_model=HealthResponse) async def health_check(): """Health check endpoint""" import platform diff --git a/backend/secuscan/models.py b/backend/secuscan/models.py index 264363e5..f7393ca4 100644 --- a/backend/secuscan/models.py +++ b/backend/secuscan/models.py @@ -101,13 +101,19 @@ class TaskResponse(BaseModel): exit_code: Optional[int] = None +class TaskStatusResponse(TaskResponse): + """Task status query response containing optional queue information""" + queue_position: Optional[int] = None + pending_count: Optional[int] = None + + class Finding(BaseModel): """Structured security finding""" id: Optional[str] = None title: str category: str severity: str - target: str + target: Optional[str] = None description: str remediation: Optional[str] = "" cvss: Optional[float] = None @@ -115,6 +121,7 @@ class Finding(BaseModel): proof: Optional[str] = None discovered_at: Optional[datetime] = None metadata: Dict[str, Any] = Field(default_factory=dict) + metadata_json: Optional[Dict[str, Any]] = None class TaskResult(BaseModel): @@ -124,7 +131,7 @@ class TaskResult(BaseModel): tool: str target: str timestamp: datetime - duration_seconds: Optional[float] + duration_seconds: Optional[float] = None status: TaskStatus summary: List[str] = [] @@ -161,3 +168,234 @@ class ErrorResponse(BaseModel): message: str field: Optional[str] = None details: Optional[Dict[str, Any]] = None + + +class TaskStartResponse(BaseModel): + """Response when a task starts successfully""" + task_id: str + status: str + created_at: str + stream_url: str + + +class TaskPagination(BaseModel): + """Pagination details for task list""" + page: int + per_page: int + total_pages: int + total_items: int + next: Optional[str] = None + previous: Optional[str] = None + + +class TasksResponse(BaseModel): + """List of tasks response""" + tasks: List[TaskResponse] + pagination: Optional[TaskPagination] = None + + +class FindingAssetRef(BaseModel): + """Asset details nested inside finding details""" + id: str + name: str + type: str + + +class FindingDetailsResponse(BaseModel): + """Detailed finding response including associated assets""" + id: str + task_id: Optional[str] = None + plugin_id: str + tool: str + title: str + category: str + severity: str + target: str + description: str + remediation: Optional[str] = "" + cvss: Optional[float] = None + cve: Optional[str] = None + proof: Optional[str] = None + discovered_at: str + metadata: Dict[str, Any] = Field(default_factory=dict) + assets: List[FindingAssetRef] + + +class FindingsResponse(BaseModel): + """List of findings response""" + findings: List[Finding] + + +class ReportItem(BaseModel): + """Single report item""" + id: str + task_id: Optional[str] = None + name: str + type: str + generated_at: str + status: str + findings: int + pages: int + file_path: Optional[str] = None + + +class ReportsResponse(BaseModel): + """List of reports response""" + reports: List[ReportItem] + + +class AssetResponseItem(BaseModel): + """Single asset item in assets list""" + id: str + type: str + name: str + host_id: Optional[str] = None + host_name: Optional[str] = None + metadata: Dict[str, Any] = Field(default_factory=dict) + created_at: str + updated_at: str + findings_count: int + tasks_count: int + reports_count: int + + +class AssetsResponse(BaseModel): + """List of assets response""" + assets: List[AssetResponseItem] + + +class GraphNode(BaseModel): + """Topology graph node""" + id: str + type: str + label: str + details: Dict[str, Any] = Field(default_factory=dict) + + +class GraphLink(BaseModel): + """Topology graph link""" + source: str + target: str + type: str + + +class GraphResponse(BaseModel): + """Topology graph response""" + nodes: List[GraphNode] + links: List[GraphLink] + + +class AssetDetailsResponse(BaseModel): + """Detailed asset view with linked resources""" + id: str + type: str + name: str + host_id: Optional[str] = None + host_name: Optional[str] = None + metadata: Dict[str, Any] = Field(default_factory=dict) + created_at: str + updated_at: str + findings: List[Dict[str, Any]] + tasks: List[Dict[str, Any]] + reports: List[Dict[str, Any]] + + +class WorkflowStep(BaseModel): + """Single step in a workflow""" + plugin_id: str + inputs: Dict[str, Any] = Field(default_factory=dict) + preset: Optional[str] = None + + +class Workflow(BaseModel): + """Workflow model with frontend-aligned schedule_interval""" + id: str + name: str + schedule_interval: str + enabled: bool + steps: List[WorkflowStep] + created_at: Optional[str] = None + last_run_at: Optional[str] = None + + +class WorkflowsResponse(BaseModel): + """List of workflows response""" + workflows: List[Workflow] + total: int + + +class WorkflowCreateResponse(BaseModel): + """Workflow creation response containing the full created object""" + id: str + name: str + schedule_interval: str + enabled: bool + steps: List[WorkflowStep] + created_at: Optional[str] = None + last_run_at: Optional[str] = None + + +class WorkflowRunResponse(BaseModel): + """Workflow execution trigger response""" + workflow_id: str + queued_tasks: List[str] + + +class WorkflowUpdateResponse(BaseModel): + """Workflow update response containing the full updated object""" + id: str + name: str + schedule_interval: str + enabled: bool + steps: List[WorkflowStep] + created_at: Optional[str] = None + last_run_at: Optional[str] = None + + +class WorkflowDeleteResponse(BaseModel): + """Workflow deletion response""" + workflow_id: str + deleted: bool + + +class ScanActivity(BaseModel): + """Scan activity statistics for dashboard""" + total: int + completed: int + running: int + + +class DashboardTask(BaseModel): + """Task metadata returned in dashboard summary""" + id: str + plugin_id: str + tool_name: str + target: str + status: str + created_at: str + duration_seconds: Optional[float] = None + + +class DashboardSummaryResponse(BaseModel): + """Dashboard statistics summary response""" + total_findings: int + critical_findings: int + high_findings: int + medium_findings: int + low_findings: int + info_findings: int + last_scan_time: Optional[str] = None + recent_findings: List[Finding] + scan_activity: ScanActivity + running_tasks: List[DashboardTask] + recent_tasks: List[DashboardTask] + + +class PluginSchemaResponse(BaseModel): + """Schema definition for plugin parameters""" + id: str + name: str + description: str + fields: List[PluginField] + presets: Dict[str, Dict[str, Any]] + safety: Dict[str, Any] diff --git a/backend/secuscan/routes.py b/backend/secuscan/routes.py index f1d53063..30246c7e 100644 --- a/backend/secuscan/routes.py +++ b/backend/secuscan/routes.py @@ -62,10 +62,58 @@ def build_report_filename(task: Dict[str, Any], extension: str) -> str: logger = logging.getLogger(__name__) +CRON_TO_SECONDS = { + "*/5 * * * *": 300, + "*/10 * * * *": 600, + "*/15 * * * *": 900, + "*/30 * * * *": 1800, + "0 * * * *": 3600, + "0 */2 * * *": 7200, + "0 */4 * * *": 14400, + "0 */8 * * *": 28800, + "0 */12 * * *": 43200, + "0 0 * * *": 86400, +} + +SECONDS_TO_CRON = {v: k for k, v in CRON_TO_SECONDS.items()} + +def cron_to_seconds(cron_str: str) -> int: + from fastapi import HTTPException + cron_str = cron_str.strip() + if cron_str.isdigit(): + return int(cron_str) + if cron_str in CRON_TO_SECONDS: + return CRON_TO_SECONDS[cron_str] + if cron_str.startswith("*/") and cron_str.endswith(" * * * *"): + try: + minutes = int(cron_str.split(" ")[0][2:]) + return minutes * 60 + except ValueError: + pass + raise HTTPException( + status_code=400, + detail=f"Unrecognized schedule_interval '{cron_str}'. " + "Use a numeric seconds value, a cron expression like '*/5 * * * *', " + "or one of the known shortcuts (e.g. '0 * * * *', '0 0 * * *')." + ) + +def seconds_to_cron(seconds: int) -> str: + if seconds in SECONDS_TO_CRON: + return SECONDS_TO_CRON[seconds] + if seconds > 0 and seconds % 60 == 0: + minutes = seconds // 60 + if minutes < 60: + return f"*/{minutes} * * * *" + return f"{seconds}" + from .cache import get_cache from .models import ( - TaskCreateRequest, TaskResponse, TaskResult, - PluginListResponse, ErrorResponse + TaskCreateRequest, TaskResponse, TaskResult, TaskStatusResponse, + PluginListResponse, ErrorResponse, TaskStartResponse, TasksResponse, + FindingsResponse, ReportsResponse, AssetsResponse, GraphResponse, + AssetDetailsResponse, WorkflowsResponse, WorkflowCreateResponse, + WorkflowRunResponse, WorkflowUpdateResponse, WorkflowDeleteResponse, + DashboardSummaryResponse, PluginSchemaResponse, Finding, FindingDetailsResponse ) from .config import settings from .database import get_db @@ -170,7 +218,7 @@ async def get_plugins_summary(): "category_counts": dict(sorted(category_counts.items())) } -@router.get("/plugin/{plugin_id}/schema") +@router.get("/plugin/{plugin_id}/schema", response_model=PluginSchemaResponse) async def get_plugin_schema(plugin_id: str): """Get plugin schema for UI generation""" plugin_manager = await get_plugin_manager_for_request() @@ -190,7 +238,7 @@ async def get_all_presets(): } -@router.post("/task/start") +@router.post("/task/start", response_model=TaskStartResponse) async def start_task( request: TaskCreateRequest, background_tasks: BackgroundTasks, @@ -274,7 +322,7 @@ async def start_task( "stream_url": f"/api/v1/task/{task_id}/stream" } -@router.get("/task/{task_id}/status") +@router.get("/task/{task_id}/status", response_model=TaskStatusResponse) async def get_task_status(task_id: str): """Get task status""" status = await executor.get_task_status(task_id) @@ -483,7 +531,7 @@ async def download_sarif_report(task_id: str): ) -@router.get("/task/{task_id}/result") +@router.get("/task/{task_id}/result", response_model=TaskResult) async def get_task_result(task_id: str): """Get task execution result""" db = await get_db() @@ -584,7 +632,7 @@ async def cancel_task(task_id: str): } -@router.get("/dashboard/summary") +@router.get("/dashboard/summary", response_model=DashboardSummaryResponse) async def get_dashboard_summary(): """Return aggregate dashboard data from the primary store, cached in Redis.""" @@ -649,7 +697,7 @@ async def build(): return await get_or_set_cached("summary:dashboard", build) -@router.get("/findings") +@router.get("/findings", response_model=FindingsResponse) async def get_findings(): """Return vulnerability findings.""" @@ -661,7 +709,7 @@ async def build(): return await get_or_set_cached("findings:list", build) -@router.get("/reports") +@router.get("/reports", response_model=ReportsResponse) async def get_reports(): """Return generated reports.""" @@ -673,7 +721,7 @@ async def build(): return await get_or_set_cached("reports:list", build) -@router.get("/tasks") +@router.get("/tasks", response_model=TasksResponse) async def list_tasks( page: int = 1, per_page: int = 25, @@ -764,6 +812,17 @@ async def delete_task_records(task_ids: List[str]): 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 orphaned assets + await db.execute( + f""" + DELETE FROM assets + WHERE id NOT IN (SELECT asset_id FROM asset_findings) + AND id NOT IN (SELECT asset_id FROM asset_tasks) + AND id NOT IN (SELECT asset_id FROM asset_reports) + AND id NOT IN (SELECT host_id FROM assets WHERE host_id IS NOT NULL) + """ + ) + # Cleanup files on disk for row in task_rows: if row and row["raw_output_path"]: @@ -831,6 +890,10 @@ async def clear_all_tasks(): # Purge other tables await db.execute("DELETE FROM findings") + await db.execute("DELETE FROM assets") + await db.execute("DELETE FROM asset_findings") + await db.execute("DELETE FROM asset_tasks") + await db.execute("DELETE FROM asset_reports") # Fallback cleanup for any orphaned files in data directories for subdir in ["raw", "reports"]: @@ -929,14 +992,36 @@ async def delete_vault_secret(name: str): return {"name": name, "deleted": True} -@router.get("/workflows") +@router.get("/workflows", response_model=WorkflowsResponse) 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)} + workflows = [] + for r in rows: + item = dict(r) + steps = [] + if item.get("steps_json"): + try: + steps = json.loads(item["steps_json"]) + except Exception: + pass + + schedule_seconds = item.get("schedule_seconds") + interval = seconds_to_cron(schedule_seconds) if schedule_seconds else "0 * * * *" + workflows.append({ + "id": item["id"], + "name": item["name"], + "schedule_interval": interval, + "enabled": bool(item["enabled"]), + "steps": steps, + "created_at": item.get("created_at"), + "last_run_at": item.get("last_run_at"), + }) + return {"workflows": workflows, "total": len(workflows)} -@router.post("/workflows") + +@router.post("/workflows", response_model=WorkflowCreateResponse) async def create_workflow(payload: Dict[str, Any]): name = str(payload.get("name", "")).strip() if not name: @@ -947,7 +1032,8 @@ async def create_workflow(payload: Dict[str, Any]): raise HTTPException(status_code=400, detail="Workflow requires at least one step") workflow_id = str(uuid.uuid4()) - schedule_seconds = payload.get("schedule_seconds") + schedule_interval = payload.get("schedule_interval") or "0 * * * *" + schedule_seconds = cron_to_seconds(schedule_interval) enabled = bool(payload.get("enabled", True)) db = await get_db() await db.execute( @@ -958,15 +1044,28 @@ async def create_workflow(payload: Dict[str, Any]): ( workflow_id, name, - int(schedule_seconds) if schedule_seconds else None, + schedule_seconds, 1 if enabled else 0, json.dumps(steps), ), ) - return {"id": workflow_id, "created": True} + row = await db.fetchone("SELECT * FROM workflows WHERE id = ?", (workflow_id,)) + if not row: + raise HTTPException(status_code=500, detail="Failed to retrieve created workflow") -@router.post("/workflows/{workflow_id}/run") + return { + "id": row["id"], + "name": row["name"], + "schedule_interval": seconds_to_cron(row["schedule_seconds"]) if row["schedule_seconds"] else "0 * * * *", + "enabled": bool(row["enabled"]), + "steps": steps, + "created_at": row["created_at"], + "last_run_at": row["last_run_at"] + } + + +@router.post("/workflows/{workflow_id}/run", response_model=WorkflowRunResponse) 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,)) @@ -987,7 +1086,7 @@ async def run_workflow_once(workflow_id: str): return {"workflow_id": workflow_id, "queued_tasks": created_task_ids} -@router.patch("/workflows/{workflow_id}") +@router.patch("/workflows/{workflow_id}", response_model=WorkflowUpdateResponse) 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,)) @@ -1002,7 +1101,10 @@ async def update_workflow(workflow_id: str, payload: Dict[str, Any]): if "steps" in payload: updates.append("steps_json = ?") params.append(json.dumps(payload["steps"])) - if "schedule_seconds" in payload: + if "schedule_interval" in payload: + updates.append("schedule_seconds = ?") + params.append(cron_to_seconds(payload["schedule_interval"])) + elif "schedule_seconds" in payload: val = payload["schedule_seconds"] updates.append("schedule_seconds = ?") params.append(int(val) if val else None) @@ -1015,10 +1117,30 @@ async def update_workflow(workflow_id: str, payload: Dict[str, Any]): params.append(workflow_id) await db.execute(f"UPDATE workflows SET {', '.join(updates)} WHERE id = ?", tuple(params)) - return {"workflow_id": workflow_id, "updated": True} + updated_row = await db.fetchone("SELECT * FROM workflows WHERE id = ?", (workflow_id,)) + if not updated_row: + raise HTTPException(status_code=500, detail="Failed to retrieve updated workflow") -@router.delete("/workflows/{workflow_id}") + steps = [] + if updated_row["steps_json"]: + try: + steps = json.loads(updated_row["steps_json"]) + except Exception: + pass + + return { + "id": updated_row["id"], + "name": updated_row["name"], + "schedule_interval": seconds_to_cron(updated_row["schedule_seconds"]) if updated_row["schedule_seconds"] else "0 * * * *", + "enabled": bool(updated_row["enabled"]), + "steps": steps, + "created_at": updated_row["created_at"], + "last_run_at": updated_row["last_run_at"] + } + + +@router.delete("/workflows/{workflow_id}", response_model=WorkflowDeleteResponse) async def delete_workflow(workflow_id: str): db = await get_db() await db.execute("DELETE FROM workflows WHERE id = ?", (workflow_id,)) @@ -1031,7 +1153,7 @@ async def trigger_workflow_tick(): return {"tick": "ok"} -@router.get("/finding/{finding_id}") +@router.get("/finding/{finding_id}", response_model=FindingDetailsResponse) async def get_finding_details(finding_id: str): """Get detailed information for a specific finding""" db = await get_db() @@ -1056,6 +1178,18 @@ async def get_finding_details(finding_id: str): except json.JSONDecodeError: metadata = {} + # Fetch associated assets + assets_rows = await db.fetchall( + """ + SELECT a.id, a.name, a.type + FROM assets a + JOIN asset_findings af ON a.id = af.asset_id + WHERE af.finding_id = ? + """, + (finding_id,) + ) + assets = [{"id": r["id"], "name": r["name"], "type": r["type"]} for r in assets_rows] + return { "id": finding_row["id"], "task_id": finding_row["task_id"], @@ -1071,7 +1205,8 @@ async def get_finding_details(finding_id: str): "cvss": finding_row["cvss"], "cve": finding_row["cve"], "discovered_at": finding_row["discovered_at"], - "metadata": metadata + "metadata": metadata, + "assets": assets } @@ -1120,11 +1255,246 @@ async def get_attack_surface(): return {"entries": entries} -@router.get("/assets") +@router.get("/assets", response_model=AssetsResponse) 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 + rows = await db.fetchall( + """ + SELECT a.id, a.type, a.name, a.host_id, h.name as host_name, a.metadata_json, a.created_at, a.updated_at, + (SELECT COUNT(*) FROM asset_findings WHERE asset_id = a.id) as findings_count, + (SELECT COUNT(*) FROM asset_tasks WHERE asset_id = a.id) as tasks_count, + (SELECT COUNT(*) FROM asset_reports WHERE asset_id = a.id) as reports_count + FROM assets a + LEFT JOIN assets h ON a.host_id = h.id + ORDER BY a.type ASC, a.name ASC + """ + ) + + assets = [] + for row in rows: + metadata = {} + if row["metadata_json"]: + try: + metadata = json.loads(row["metadata_json"]) + except json.JSONDecodeError: + pass + + assets.append({ + "id": row["id"], + "type": row["type"], + "name": row["name"], + "host_id": row["host_id"], + "host_name": row["host_name"], + "metadata": metadata, + "created_at": row["created_at"], + "updated_at": row["updated_at"], + "findings_count": row["findings_count"], + "tasks_count": row["tasks_count"], + "reports_count": row["reports_count"] + }) + + return {"assets": assets} + + +@router.get("/assets/graph", response_model=GraphResponse) +async def get_assets_graph(): + """Return a graph representing the connections between hosts, services, findings, tasks, and reports.""" + db = await get_db() + + nodes = [] + links = [] + seen_nodes = set() + + # 1. Fetch assets (hosts and services) + assets = await db.fetchall( + """ + SELECT a.id, a.type, a.name, a.host_id, h.name as host_name + FROM assets a + LEFT JOIN assets h ON a.host_id = h.id + """ + ) + for asset in assets: + nodes.append({ + "id": asset["id"], + "label": asset["name"], + "type": asset["type"], + "details": { + "host_name": asset["host_name"] + } + }) + seen_nodes.add(asset["id"]) + + if asset["host_id"]: + links.append({ + "source": asset["host_id"], + "target": asset["id"], + "type": "has_service" + }) + + # 2. Fetch findings and their links to assets + findings = await db.fetchall( + """ + SELECT f.id, f.title, f.severity, f.category, f.task_id, af.asset_id + FROM findings f + JOIN asset_findings af ON f.id = af.finding_id + """ + ) + for f in findings: + f_id = f["id"] + if f_id not in seen_nodes: + nodes.append({ + "id": f_id, + "label": f["title"], + "type": "finding", + "details": { + "severity": f["severity"], + "category": f["category"] + } + }) + seen_nodes.add(f_id) + + links.append({ + "source": f["asset_id"], + "target": f_id, + "type": "has_finding" + }) + + # Link task to finding if task node exists + if f["task_id"]: + links.append({ + "source": f["task_id"], + "target": f_id, + "type": "produced_finding" + }) + + # 3. Fetch tasks and their links to assets + tasks = await db.fetchall( + """ + SELECT t.id, t.tool_name, t.status, at.asset_id + FROM tasks t + JOIN asset_tasks at ON t.id = at.task_id + """ + ) + for t in tasks: + t_id = t["id"] + if t_id not in seen_nodes: + nodes.append({ + "id": t_id, + "label": f"Task: {t['tool_name']}", + "type": "task", + "details": { + "status": t["status"] + } + }) + seen_nodes.add(t_id) + + links.append({ + "source": t["asset_id"], + "target": t_id, + "type": "associated_task" + }) + + # 4. Fetch reports and their links to assets + reports = await db.fetchall( + """ + SELECT r.id, r.name, r.task_id, ar.asset_id + FROM reports r + JOIN asset_reports ar ON r.id = ar.report_id + """ + ) + for r in reports: + r_id = r["id"] + if r_id not in seen_nodes: + nodes.append({ + "id": r_id, + "label": r["name"], + "type": "report", + "details": {} + }) + seen_nodes.add(r_id) + + links.append({ + "source": r["asset_id"], + "target": r_id, + "type": "associated_report" + }) + + # Link task to report if task node exists + if r["task_id"]: + links.append({ + "source": r["task_id"], + "target": r_id, + "type": "produced_report" + }) + + return {"nodes": nodes, "links": links} + + +@router.get("/asset/{asset_id}", response_model=AssetDetailsResponse) +async def get_asset_details(asset_id: str): + """Get detailed information for a specific asset and its relationships.""" + db = await get_db() + + asset = await db.fetchone( + """ + SELECT a.id, a.type, a.name, a.host_id, h.name as host_name, a.metadata_json, a.created_at, a.updated_at + FROM assets a + LEFT JOIN assets h ON a.host_id = h.id + WHERE a.id = ? + """, + (asset_id,) + ) + if not asset: + raise HTTPException(status_code=404, detail="Asset not found") + + metadata = {} + if asset["metadata_json"]: + try: + metadata = json.loads(asset["metadata_json"]) + except json.JSONDecodeError: + pass + + findings = await db.fetchall( + """ + SELECT f.id, f.title, f.severity, f.category, f.discovered_at + FROM findings f + JOIN asset_findings af ON f.id = af.finding_id + WHERE af.asset_id = ? + """, + (asset_id,) + ) + + tasks = await db.fetchall( + """ + SELECT t.id, t.tool_name, t.status, t.created_at + FROM tasks t + JOIN asset_tasks at ON t.id = at.task_id + WHERE at.asset_id = ? + """, + (asset_id,) + ) + + reports = await db.fetchall( + """ + SELECT r.id, r.name, r.type, r.generated_at, r.status + FROM reports r + JOIN asset_reports ar ON r.id = ar.report_id + WHERE ar.asset_id = ? + """, + (asset_id,) + ) + + return { + "id": asset["id"], + "type": asset["type"], + "name": asset["name"], + "host_id": asset["host_id"], + "host_name": asset["host_name"], + "metadata": metadata, + "created_at": asset["created_at"], + "updated_at": asset["updated_at"], + "findings": findings, + "tasks": tasks, + "reports": reports + } \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f4f6b39f..85a301e4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,7 @@ import Settings from './pages/Settings' import Scans from './pages/Scans' import TaskDetails from './pages/TaskDetails' import Workflows from './pages/Workflows' +import Assets from './pages/Assets' import { ThemeProvider } from './components/ThemeContext' import { ToastProvider, ToastContainer } from './components/ToastContext' @@ -28,6 +29,7 @@ export function AppRoutes() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 7c7fc0e0..f7c03327 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -83,6 +83,231 @@ export interface TaskStartResponse { stream_url: string } +export interface HealthResponse { + status: string + version: string + uptime_seconds?: number + system: Record + limits?: Record +} + +export interface TaskResponse { + task_id: string + plugin_id: string + tool: string + target: string + status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled' + created_at: string + started_at?: string | null + completed_at?: string | null + duration_seconds?: number | null + inputs?: Record | null + preset?: string | null + error_message?: string | null + exit_code?: number | null +} + +export interface TaskStatusResponse extends TaskResponse { + queue_position?: number | null + pending_count?: number | null +} + +export interface TaskPagination { + page: number + per_page: number + total_pages: number + total_items: number + next?: string | null + previous?: string | null +} + +export interface TasksResponse { + tasks: TaskResponse[] + pagination?: TaskPagination | null +} + +export interface Finding { + id?: string | null + title: string + category: string + severity: string + target?: string | null + description: string + remediation?: string | null + cvss?: number | null + cve?: string | null + proof?: string | null + discovered_at?: string | null + metadata?: Record + metadata_json?: Record | null +} + +export interface TaskResult { + task_id: string + plugin_id: string + tool: string + target: string + timestamp: string + duration_seconds?: number | null + status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled' + summary?: string[] + severity_counts?: Record + findings?: Finding[] + structured?: Record + raw_output_path?: string | null + raw_output_excerpt?: string | null + errors?: Record[] + error_message?: string | null + exit_code?: number | null + metadata?: Record +} + +export interface ScanActivity { + total: number + completed: number + running: number +} + +export interface DashboardTask { + id: string + plugin_id: string + tool_name: string + target: string + status: string + created_at: string + duration_seconds?: number | null +} + +export interface DashboardSummaryResponse { + total_findings: number + critical_findings: number + high_findings: number + medium_findings: number + low_findings: number + info_findings: number + last_scan_time?: string | null + recent_findings: Finding[] + scan_activity: ScanActivity + running_tasks: DashboardTask[] + recent_tasks: DashboardTask[] +} + +export interface FindingsResponse { + findings: Finding[] +} + +export interface FindingAssetRef { + id: string + name: string + type: string +} + +export interface FindingDetailsResponse { + id: string + task_id?: string | null + plugin_id: string + tool: string + title: string + category: string + severity: string + target: string + description: string + remediation?: string | null + cvss?: number | null + cve?: string | null + proof?: string | null + discovered_at: string + metadata?: Record + assets: FindingAssetRef[] +} + +export interface ReportItem { + id: string + task_id?: string | null + name: string + type: string + generated_at: string + status: string + findings: number + pages: number + file_path?: string | null +} + +export interface ReportsResponse { + reports: ReportItem[] +} + +export interface AssetResponseItem { + id: string + type: string + name: string + host_id?: string | null + host_name?: string | null + metadata?: Record + created_at: string + updated_at: string + findings_count: number + tasks_count: number + reports_count: number +} + +export interface AssetsResponse { + assets: AssetResponseItem[] +} + +export interface GraphNode { + id: string + type: string + label: string + details?: Record +} + +export interface GraphLink { + source: string + target: string + type: string +} + +export interface GraphResponse { + nodes: GraphNode[] + links: GraphLink[] +} + +export interface AssetDetailsResponse { + id: string + type: string + name: string + host_id?: string | null + host_name?: string | null + metadata?: Record + created_at: string + updated_at: string + findings: Record[] + tasks: Record[] + reports: Record[] +} + +export interface WorkflowsResponse { + workflows: Workflow[] + total: number +} + +export interface TaskCancelResponse { + task_id: string + status: string + cancelled_at: string +} + +export interface WorkflowRunResponse { + workflow_id: string + queued_tasks: string[] +} + +export interface WorkflowDeleteResponse { + workflow_id: string + deleted: boolean +} + async function request(path: string, init?: RequestInit): Promise { const controller = new AbortController() const timeoutId = window.setTimeout(() => controller.abort(), 10000) @@ -98,46 +323,65 @@ async function request(path: string, init?: RequestInit): Promise { return response.json() } -export function getHealth() { - return request('/health') +export function getHealth(): Promise { + return request('/health') } -export function listPlugins() { +export function listPlugins(): Promise { return request('/plugins') } -export function getPluginSchema(id: string) { +export function getPluginSchema(id: string): Promise { return request(`/plugin/${id}/schema`) } -export function getDashboardSummary() { - return request('/dashboard/summary') +export function getDashboardSummary(): Promise { + return request('/dashboard/summary') } +export function getFindings(): Promise { + return request('/findings') +} -export function getFindings() { - return request('/findings') +export function getReports(): Promise { + return request('/reports') } +export function getAssets(): Promise { + return request('/assets') +} -export function getReports() { - return request('/reports') +export function getAssetsGraph(): Promise { + return request('/assets/graph') } -export function getTasks(params?: URLSearchParams) { +export function getAssetDetails(assetId: string): Promise { + return request(`/asset/${assetId}`) +} + +export function getFindingDetails(findingId: string): Promise { + return request(`/finding/${findingId}`) +} + +export function getTasks(params?: URLSearchParams): Promise { const suffix = params ? `?${params.toString()}` : '' - return request(`/tasks${suffix}`) + return request(`/tasks${suffix}`) } -export function getTaskStatus(taskId: string): Promise { - return request(`/task/${taskId}/status`) +export function getTaskStatus(taskId: string): Promise { + return request(`/task/${taskId}/status`) } -export function getTaskResult(taskId: string): Promise { - return request(`/task/${taskId}/result`) +export function getTaskResult(taskId: string): Promise { + return request(`/task/${taskId}/result`) } -export function startTask(plugin_id: string, inputs: Record, consent_granted: boolean, preset?: string) { +export function startTask( + plugin_id: string, + inputs: Record, + consent_granted: boolean, + preset?: string +): Promise { return request('/task/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -145,13 +389,13 @@ export function startTask(plugin_id: string, inputs: Record, co }) } -export function deleteTask(taskId: string) { +export function deleteTask(taskId: string): Promise<{ task_id: string; deleted: boolean }> { return request<{ task_id: string; deleted: boolean }>(`/task/${taskId}`, { method: 'DELETE', }) } -export function bulkDeleteTasks(taskIds: string[]) { +export function bulkDeleteTasks(taskIds: string[]): Promise<{ deleted_count: number; success: boolean }> { return request<{ deleted_count: number; success: boolean }>('/tasks/bulk', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, @@ -159,14 +403,14 @@ export function bulkDeleteTasks(taskIds: string[]) { }) } -export function clearAllTasks() { +export function clearAllTasks(): Promise<{ cleared: boolean; message: string }> { return request<{ cleared: boolean; message: string }>('/tasks/clear', { method: 'DELETE', }) } -export function cancelTask(taskId: string) { - return request(`/task/${taskId}/cancel`, { +export function cancelTask(taskId: string): Promise { + return request(`/task/${taskId}/cancel`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, }) @@ -179,6 +423,7 @@ export function streamTask(taskId: string, onEvent: (ev: MessageEvent) => void) es.onerror = () => {} return es } + export interface WorkflowStep { plugin_id: string inputs: Record @@ -210,7 +455,7 @@ export interface WorkflowUpdatePayload { } export function getWorkflows(): Promise { - return request('/workflows') + return request('/workflows').then(r => r.workflows) } export function createWorkflow(data: WorkflowCreatePayload): Promise { @@ -222,10 +467,10 @@ export function createWorkflow(data: WorkflowCreatePayload): Promise { } export function runWorkflow(workflowId: string): Promise<{ queued_task_ids: string[] }> { - return request<{ queued_task_ids: string[] }>(`/workflows/${workflowId}/run`, { + return request(`/workflows/${workflowId}/run`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - }) + }).then(r => ({ queued_task_ids: r.queued_tasks })) } export function updateWorkflow(workflowId: string, data: WorkflowUpdatePayload): Promise { @@ -236,8 +481,8 @@ export function updateWorkflow(workflowId: string, data: WorkflowUpdatePayload): }) } -export function deleteWorkflow(workflowId: string): Promise<{ deleted: boolean }> { - return request<{ deleted: boolean }>(`/workflows/${workflowId}`, { +export function deleteWorkflow(workflowId: string): Promise { + return request(`/workflows/${workflowId}`, { method: 'DELETE', }) } \ No newline at end of file diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 8853ca4f..0a087e3b 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -164,6 +164,7 @@ export default function Sidebar() { + diff --git a/frontend/src/pages/Assets.tsx b/frontend/src/pages/Assets.tsx new file mode 100644 index 00000000..8a213c76 --- /dev/null +++ b/frontend/src/pages/Assets.tsx @@ -0,0 +1,975 @@ +import React, { useEffect, useState, useMemo, useRef } from 'react' +import { Link, useSearchParams } from 'react-router-dom' +import { motion, AnimatePresence } from 'framer-motion' +import { getAssets, getAssetsGraph, getAssetDetails, getFindingDetails } from '../api' +import { routePath, routes } from '../routes' +import { formatLocaleDate } from '../utils/date' + +interface Asset { + id: string + type: 'host' | 'service' + name: string + host_id: string | null + host_name: string | null + metadata: Record + created_at: string + updated_at: string + findings_count: number + tasks_count: number + reports_count: number +} + +interface GraphNode { + id: string + label: string + type: 'host' | 'service' | 'finding' | 'task' | 'report' + x: number + y: number + vx: number + vy: number + details?: any +} + +interface GraphLink { + source: string + target: string + type: string +} + + +export default function Assets() { + const [assets, setAssets] = useState([]) + const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; links: GraphLink[] }>({ nodes: [], links: [] }) + const [loading, setLoading] = useState(true) + const [activeTab, setActiveTab] = useState<'list' | 'graph'>('list') + const [searchQuery, setSearchQuery] = useState('') + const [filterType, setFilterType] = useState<'all' | 'host' | 'service'>('all') + const [selectedAssetId, setSelectedAssetId] = useState(null) + const [selectedAssetDetails, setSelectedAssetDetails] = useState(null) + const [detailsLoading, setDetailsLoading] = useState(false) + const [searchParams] = useSearchParams() + + // Graph state + const [zoom, setZoom] = useState(1.0) + const [pan, setPan] = useState({ x: 0, y: 0 }) + const [isPanning, setIsPanning] = useState(false) + const panStart = useRef({ x: 0, y: 0 }) + const dragNodeId = useRef(null) + const svgRef = useRef(null) + const animationFrameId = useRef(null) + + // Highlight state + const [hoveredNodeId, setHoveredNodeId] = useState(null) + + // Fetch standard asset list + const loadAssetsData = () => { + setLoading(true) + getAssets() + .then((data: any) => { + setAssets(data.assets || []) + }) + .catch((err) => console.error(err)) + .finally(() => setLoading(false)) + } + + // Fetch graph details + const loadGraphData = () => { + getAssetsGraph() + .then((data: any) => { + // Initialize nodes with random positions near center + const width = 800 + const height = 500 + const initializedNodes = (data.nodes || []).map((node: any) => ({ + ...node, + x: width / 2 + (Math.random() - 0.5) * 200, + y: height / 2 + (Math.random() - 0.5) * 200, + vx: 0, + vy: 0, + })) + setGraphData({ + nodes: initializedNodes, + links: data.links || [], + }) + }) + .catch((err) => console.error(err)) + } + + useEffect(() => { + loadAssetsData() + loadGraphData() + + const params = new URLSearchParams(window.location.search) + const selected = params.get('selected') + if (selected) { + setSelectedAssetId(selected) + setActiveTab('list') + } + }, []) + + useEffect(() => { + const selected = searchParams.get('selected') + if (selected) { + setSelectedAssetId(selected) + setActiveTab('list') + } + }, [searchParams]) + + // Poll for updates in graph or lists + useEffect(() => { + const interval = setInterval(() => { + getAssets().then((data: any) => setAssets(data.assets || [])) + // Only poll graph if active to reduce computations + if (activeTab === 'graph') { + getAssetsGraph().then((data: any) => { + setGraphData((prev) => { + const nextNodes = (data.nodes || []).map((n: any) => { + const existing = prev.nodes.find((en) => en.id === n.id) + return existing ? { ...n, x: existing.x, y: existing.y, vx: existing.vx, vy: existing.vy } : { ...n, x: 400 + (Math.random() - 0.5) * 100, y: 250 + (Math.random() - 0.5) * 100, vx: 0, vy: 0 } + }) + return { nodes: nextNodes, links: data.links || [] } + }) + }) + } + }, 15000) + return () => clearInterval(interval) + }, [activeTab]) + + // Fetch single asset details when selected + useEffect(() => { + if (!selectedAssetId) { + setSelectedAssetDetails(null) + return + } + let active = true + setDetailsLoading(true) + // Extract real ID if it's prefix (finding details handle separately) + const isAsset = selectedAssetId.startsWith('asset:') + if (isAsset) { + getAssetDetails(selectedAssetId) + .then((data: any) => { + if (active) { + setSelectedAssetDetails({ ...data, isDirectAsset: true }) + } + }) + .catch((err) => { + if (active) { + console.error(err) + } + }) + .finally(() => { + if (active) { + setDetailsLoading(false) + } + }) + } else { + // It is a finding, task, or report node clicked in the graph + // Let's resolve its details accordingly + const parts = selectedAssetId.split(':') + const type = parts[0] + if (type === 'finding') { + getFindingDetails(selectedAssetId) + .then((data: any) => { + if (active) { + setSelectedAssetDetails({ ...data, type: 'finding', label: data.title }) + } + }) + .catch((err) => { + if (active) { + console.error(err) + } + }) + .finally(() => { + if (active) { + setDetailsLoading(false) + } + }) + } else if (type === 'report') { + if (active) { + setSelectedAssetDetails({ id: selectedAssetId, type: 'report', label: 'PDF/HTML Security Report' }) + setDetailsLoading(false) + } + } else { + // Task + if (active) { + setSelectedAssetDetails({ id: selectedAssetId, type: 'task', label: 'Scan Task Record' }) + setDetailsLoading(false) + } + } + } + return () => { + active = false + } + }, [selectedAssetId]) + + // Force-directed simulation loop + useEffect(() => { + if (activeTab !== 'graph' || graphData.nodes.length === 0) return + + const width = 800 + const height = 500 + const centerX = width / 2 + const centerY = height / 2 + + // Physics constants + const kRepulsion = 1200 + const kAttraction = 0.04 + const restLength = 80 + const kGravity = 0.015 + const friction = 0.85 + + const step = () => { + setGraphData((prev) => { + const nodes = prev.nodes.map((n) => ({ ...n })) + const links = prev.links + + // 1. Repulsion between all nodes + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + const n1 = nodes[i] + const n2 = nodes[j] + const dx = n2.x - n1.x + const dy = n2.y - n1.y + const dist = Math.sqrt(dx * dx + dy * dy) || 1 + if (dist < 300) { + const force = kRepulsion / (dist * dist) + const fx = (dx / dist) * force + const fy = (dy / dist) * force + + if (n1.id !== dragNodeId.current) { + n1.vx -= fx + n1.vy -= fy + } + if (n2.id !== dragNodeId.current) { + n2.vx += fx + n2.vy += fy + } + } + } + } + + // 2. Attraction along links + links.forEach((link) => { + const sourceNode = nodes.find((n) => n.id === link.source) + const targetNode = nodes.find((n) => n.id === link.target) + if (!sourceNode || !targetNode) return + + const dx = targetNode.x - sourceNode.x + const dy = targetNode.y - sourceNode.y + const dist = Math.sqrt(dx * dx + dy * dy) || 1 + const force = kAttraction * (dist - restLength) + const fx = (dx / dist) * force + const fy = (dy / dist) * force + + if (sourceNode.id !== dragNodeId.current) { + sourceNode.vx += fx + sourceNode.vy += fy + } + if (targetNode.id !== dragNodeId.current) { + targetNode.vx -= fx + targetNode.vy -= fy + } + }) + + // 3. Gravity & Update Positions + nodes.forEach((n) => { + if (n.id === dragNodeId.current) return + + const dx = centerX - n.x + const dy = centerY - n.y + n.vx += dx * kGravity + n.vy += dy * kGravity + + // Apply velocity and friction + n.x += n.vx + n.y += n.vy + n.vx *= friction + n.vy *= friction + + // Bound within viewport + n.x = Math.max(40, Math.min(width - 40, n.x)) + n.y = Math.max(40, Math.min(height - 40, n.y)) + }) + + return { nodes, links } + }) + + animationFrameId.current = requestAnimationFrame(step) + } + + animationFrameId.current = requestAnimationFrame(step) + return () => { + if (animationFrameId.current) cancelAnimationFrame(animationFrameId.current) + } + }, [activeTab, graphData.nodes.length]) + + // Filter list + const filteredAssets = useMemo(() => { + const query = searchQuery.trim().toLowerCase() + return assets.filter((asset) => { + const matchesType = filterType === 'all' || asset.type === filterType + const matchesSearch = + asset.name.toLowerCase().includes(query) || + (asset.host_name && asset.host_name.toLowerCase().includes(query)) || + (asset.type && asset.type.toLowerCase().includes(query)) + return matchesType && matchesSearch + }) + }, [assets, searchQuery, filterType]) + + // Node colors & icons based on Type + const getNodeStyles = (type: string, severity?: string) => { + switch (type) { + case 'host': + return { color: '#3b82f6', icon: 'dns', border: 'border-blue-500', glow: 'shadow-[0_0_12px_rgba(59,130,246,0.5)]' } + case 'service': + return { color: '#a855f7', icon: 'lan', border: 'border-purple-500', glow: 'shadow-[0_0_12px_rgba(168,85,247,0.5)]' } + case 'finding': + const sev = (severity || 'low').toLowerCase() + const col = sev === 'critical' || sev === 'high' ? '#ef4444' : sev === 'medium' ? '#f59e0b' : '#3b82f6' + return { color: col, icon: 'warning', border: 'border-red-500', glow: 'shadow-[0_0_12px_rgba(239,68,68,0.5)]' } + case 'task': + return { color: '#6b7280', icon: 'terminal', border: 'border-gray-500', glow: 'shadow-[0_0_12px_rgba(107,114,128,0.5)]' } + case 'report': + return { color: '#10b981', icon: 'summarize', border: 'border-emerald-500', glow: 'shadow-[0_0_12px_rgba(16,185,129,0.5)]' } + default: + return { color: '#9ca3af', icon: 'help', border: 'border-gray-400', glow: '' } + } + } + + // Pan / Zoom handlers + const handleMouseDown = (e: React.MouseEvent) => { + if (e.target instanceof SVGElement && e.target.tagName === 'svg') { + setIsPanning(true) + panStart.current = { x: e.clientX - pan.x, y: e.clientY - pan.y } + } + } + + const handleMouseMove = (e: React.MouseEvent) => { + if (isPanning) { + setPan({ + x: e.clientX - panStart.current.x, + y: e.clientY - panStart.current.y, + }) + } else if (dragNodeId.current) { + if (!svgRef.current) return + const rect = svgRef.current.getBoundingClientRect() + + // Calculate coordinates relative to SVG local viewport + const x = ((e.clientX - rect.left) / rect.width) * 800 + const y = ((e.clientY - rect.top) / rect.height) * 500 + + setGraphData((prev) => { + const nextNodes = prev.nodes.map((n) => { + if (n.id === dragNodeId.current) { + return { ...n, x, y, vx: 0, vy: 0 } + } + return n + }) + return { ...prev, nodes: nextNodes } + }) + } + } + + const handleMouseUp = () => { + setIsPanning(false) + dragNodeId.current = null + } + + const handleNodeDragStart = (id: string, e: React.MouseEvent) => { + e.stopPropagation() + dragNodeId.current = id + } + + // Neighbor nodes highlight helper + const adjacentNodeIds = useMemo(() => { + if (!hoveredNodeId) return new Set() + const ids = new Set([hoveredNodeId]) + graphData.links.forEach((link) => { + if (link.source === hoveredNodeId) ids.add(link.target) + if (link.target === hoveredNodeId) ids.add(link.source) + }) + return ids + }, [hoveredNodeId, graphData.links]) + + return ( +
+
+ + {/* Header */} +
+
+ Network Topology v3.2 +
+
+
+

+ Assets Desk +

+

+ Active tracked surface // {assets.filter((a) => a.type === 'host').length} hosts // {assets.filter((a) => a.type === 'service').length} service endpoints +

+
+ + {/* Toggle tabs */} +
+ + +
+
+
+ + {/* Content Panel split into main view and sidebar details */} +
+ + {/* Main Workspace Area */} +
+ + {activeTab === 'list' ? ( + /* --- INVENTORY LIST VIEW --- */ +
+ + {/* Search & Filters */} +
+
+ setSearchQuery(e.target.value)} + placeholder="Search asset target, host name, or service port..." + className="h-11 w-full border-2 border-silver-bright/10 bg-charcoal-dark px-4 text-xs font-mono text-silver-bright placeholder:text-silver/20 focus:border-rag-blue focus:outline-none" + /> + {searchQuery.trim() && ( + + )} +
+ +
+ + + +
+
+ + {/* Table Layout */} +
+ {loading ? ( +
+ Retrieving tracked asset entities... +
+ ) : filteredAssets.length === 0 ? ( +
+ No tracked asset models match filters. +
+ ) : ( +
+ + + + + + + + + + + + + {filteredAssets.map((asset) => { + const isSelected = selectedAssetId === asset.id + const isHost = asset.type === 'host' + return ( + setSelectedAssetId(asset.id)} + className={`cursor-pointer hover:bg-silver-bright/3 transition-colors ${ + isSelected ? 'bg-silver-bright/8 hover:bg-silver-bright/8' : '' + }`} + > + + + + + + + + ) + })} + +
Asset TargetTypeParent HostFindingsTasksReports
+ {asset.name} + + + {asset.type.toUpperCase()} + + + {asset.host_name || '—'} + + 0 ? 'text-rag-amber bg-rag-amber/10' : 'text-silver/30' + }`}> + {asset.findings_count.toString().padStart(2, '0')} + + + 0 ? 'text-silver-bright bg-silver-bright/10' : 'text-silver/30' + }`}> + {asset.tasks_count.toString().padStart(2, '0')} + + + 0 ? 'text-rag-green bg-rag-green/10' : 'text-silver/30' + }`}> + {asset.reports_count.toString().padStart(2, '0')} + +
+
+ )} +
+
+ ) : ( + /* --- GRAPH TOPOLOGY VIEW --- */ +
+
+ + Interactive topology map // Drag nodes to position // Zoom: {Math.round(zoom * 100)}% + + +
+ + + +
+
+ +
+ + {/* SVG Definitions for arrowheads, filters */} + + + + + + + {/* Scale and pan group */} + + + {/* Connection Lines (Links) */} + {graphData.links.map((link, idx) => { + const sourceNode = graphData.nodes.find((n) => n.id === link.source) + const targetNode = graphData.nodes.find((n) => n.id === link.target) + if (!sourceNode || !targetNode) return null + + const isHighlighted = hoveredNodeId === null || + (hoveredNodeId === link.source || hoveredNodeId === link.target) + + return ( + + ) + })} + + {/* Interactive Nodes */} + {graphData.nodes.map((node) => { + const styles = getNodeStyles(node.type, node.details?.severity) + const isHovered = hoveredNodeId === node.id + const isDimmed = hoveredNodeId !== null && !adjacentNodeIds.has(node.id) + const isSelected = selectedAssetId === node.id + + return ( + handleNodeDragStart(node.id, e)} + onClick={(e) => { e.stopPropagation(); setSelectedAssetId(node.id) }} + onMouseEnter={() => setHoveredNodeId(node.id)} + onMouseLeave={() => setHoveredNodeId(null)} + opacity={isDimmed ? 0.35 : 1.0} + style={{ transition: 'opacity 0.25s' }} + > + {/* Inner Circle Glow */} + {(isHovered || isSelected) && ( + + )} + + {/* Node circle */} + + + {/* Node icon text */} + + {styles.icon} + + + {/* Text labels */} + + {node.label.length > 20 ? `${node.label.slice(0, 18)}...` : node.label} + + + ) + })} + + + +
+
+ )} +
+ + {/* Details Sidebar */} + + +
+ +
+
+ ) +} diff --git a/frontend/src/pages/Findings.tsx b/frontend/src/pages/Findings.tsx index cfc78824..6fefc035 100644 --- a/frontend/src/pages/Findings.tsx +++ b/frontend/src/pages/Findings.tsx @@ -1,6 +1,8 @@ import React, { useEffect, useMemo, useState } from 'react' +import { Link } from 'react-router-dom' import { motion } from 'framer-motion' -import { getFindings } from '../api' +import { getFindings, getFindingDetails } from '../api' +import { routes } from '../routes' import { formatLocaleDate, parseDateSafe, getCurrentTimeZone } from '../utils/date' type Finding = { id: string @@ -102,6 +104,43 @@ export default function Findings() { const [dateTo, setDateTo] = useState('') const [selectedFindingId, setSelectedFindingId] = useState(null) const [reviewState, setReviewState] = useState({}) + const [selectedFindingAssets, setSelectedFindingAssets] = useState([]) + const [assetsLoading, setAssetsLoading] = useState(false) + + useEffect(() => { + if (!selectedFindingId) { + setSelectedFindingAssets([]) + return + } + let active = true + setAssetsLoading(true) + const promise = getFindingDetails(selectedFindingId) + if (promise && typeof promise.then === 'function') { + promise + .then((data: any) => { + if (active) { + setSelectedFindingAssets(data?.assets || []) + } + }) + .catch((err) => { + if (active) { + console.error(err) + setSelectedFindingAssets([]) + } + }) + .finally(() => { + if (active) { + setAssetsLoading(false) + } + }) + } else { + setSelectedFindingAssets([]) + setAssetsLoading(false) + } + return () => { + active = false + } + }, [selectedFindingId]) const [copiedFindingId, setCopiedFindingId] = useState(null) useEffect(() => { @@ -691,6 +730,29 @@ export default function Findings() {

+ +
+

Associated Assets

+
+ {assetsLoading ? ( +

Loading linked assets...

+ ) : selectedFindingAssets.length === 0 ? ( +

No assets mapped to this finding.

+ ) : ( +
+ {selectedFindingAssets.map((asset) => ( + + {asset.name} ({asset.type.toUpperCase()}) + + ))} +
+ )} +
+
diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 38caa98f..e80c6076 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -8,6 +8,7 @@ export const routes = { workflows: '/workflows', settings: '/settings', task: '/task/:taskId', + assets: '/assets', } as const export const routePath = { diff --git a/frontend/testing/unit/AppRoutes.test.tsx b/frontend/testing/unit/AppRoutes.test.tsx index 031ac013..d1042699 100644 --- a/frontend/testing/unit/AppRoutes.test.tsx +++ b/frontend/testing/unit/AppRoutes.test.tsx @@ -33,6 +33,7 @@ vi.mock('../../src/api', () => ({ ], }), cancelTask: vi.fn(), + getFindingDetails: vi.fn().mockResolvedValue({ assets: [] }), })) function PathProbe() { diff --git a/frontend/testing/unit/pages/Findings.test.tsx b/frontend/testing/unit/pages/Findings.test.tsx index b5c6e39a..35c337d5 100644 --- a/frontend/testing/unit/pages/Findings.test.tsx +++ b/frontend/testing/unit/pages/Findings.test.tsx @@ -7,6 +7,7 @@ import * as dateUtils from '../../../src/utils/date' vi.mock('../../../src/api', () => ({ getFindings: vi.fn(), + getFindingDetails: vi.fn().mockResolvedValue({ assets: [] }), API_BASE: 'http://127.0.0.1:8000', })) diff --git a/frontend/testing/unit/pages/Reports.preferredFormat.test.tsx b/frontend/testing/unit/pages/Reports.preferredFormat.test.tsx index e5687411..0853ef73 100644 --- a/frontend/testing/unit/pages/Reports.preferredFormat.test.tsx +++ b/frontend/testing/unit/pages/Reports.preferredFormat.test.tsx @@ -43,7 +43,7 @@ function renderReports() { beforeEach(() => { localStorage.clear() vi.mocked(getReports).mockResolvedValue({ reports: [readyReport] }) - vi.mocked(getDashboardSummary).mockResolvedValue(emptySummary) + vi.mocked(getDashboardSummary).mockResolvedValue(emptySummary as any) }) describe('Reports — preferred export format', () => { diff --git a/frontend/testing/unit/pages/Reports.test.tsx b/frontend/testing/unit/pages/Reports.test.tsx index 3da4ecd5..a9a83d21 100644 --- a/frontend/testing/unit/pages/Reports.test.tsx +++ b/frontend/testing/unit/pages/Reports.test.tsx @@ -35,8 +35,13 @@ const failedReport = { findings: 0, assets: 0, pages: 0, } const emptySummary = { - total_findings: 0, total_assets: 0, critical_findings: 0, - high_findings: 0, total_attack_surface: 0, + total_findings: 0, critical_findings: 0, high_findings: 0, + medium_findings: 0, low_findings: 0, info_findings: 0, + last_scan_time: null, + recent_findings: [], + scan_activity: { total: 0, completed: 0, running: 0 }, + running_tasks: [], + recent_tasks: [], } function renderReports() { diff --git a/frontend/testing/unit/pages/Workflows.test.tsx b/frontend/testing/unit/pages/Workflows.test.tsx index b6058a83..4694ffb5 100644 --- a/frontend/testing/unit/pages/Workflows.test.tsx +++ b/frontend/testing/unit/pages/Workflows.test.tsx @@ -142,7 +142,7 @@ describe('Workflows — toggle action', () => { describe('Workflows — delete action', () => { beforeEach(() => { vi.mocked(getWorkflows).mockResolvedValue([mockWorkflow]) - vi.mocked(deleteWorkflow).mockResolvedValue({ deleted: true }) + vi.mocked(deleteWorkflow).mockResolvedValue({ deleted: true } as any) vi.mocked(deleteWorkflow).mockClear() }) diff --git a/testing/backend/integration/test_api_contract.py b/testing/backend/integration/test_api_contract.py new file mode 100644 index 00000000..45b7f3ef --- /dev/null +++ b/testing/backend/integration/test_api_contract.py @@ -0,0 +1,241 @@ +import re +from pathlib import Path +from backend.secuscan.main import app + +# Map of TS Interface names to OpenAPI Schema names +TS_TO_OPENAPI_MAP = { + "HealthResponse": "HealthResponse", + "TaskResponse": "TaskResponse", + "TaskStatusResponse": "TaskStatusResponse", + "TaskPagination": "TaskPagination", + "TasksResponse": "TasksResponse", + "Finding": "Finding", + "TaskResult": "TaskResult", + "ScanActivity": "ScanActivity", + "DashboardSummaryResponse": "DashboardSummaryResponse", + "FindingsResponse": "FindingsResponse", + "FindingAssetRef": "FindingAssetRef", + "FindingDetailsResponse": "FindingDetailsResponse", + "ReportItem": "ReportItem", + "ReportsResponse": "ReportsResponse", + "AssetResponseItem": "AssetResponseItem", + "AssetsResponse": "AssetsResponse", + "GraphNode": "GraphNode", + "GraphLink": "GraphLink", + "GraphResponse": "GraphResponse", + "AssetDetailsResponse": "AssetDetailsResponse", + "WorkflowStep": "WorkflowStep", + "Workflow": "Workflow", + "WorkflowRunResponse": "WorkflowRunResponse", + "WorkflowDeleteResponse": "WorkflowDeleteResponse", + "WorkflowsResponse": "WorkflowsResponse", +} + +# Extra checks for OpenAPI schemas that map to the same TS interface +EXTRA_OPENAPI_SCHEMA_CHECKS = { + "Workflow": ["WorkflowCreateResponse", "WorkflowUpdateResponse"], +} + +def parse_ts_interfaces(ts_content: str): + # Regex to capture interface declarations: export interface Name [extends Parent] { ... } + interface_pattern = re.compile( + r'export\s+interface\s+(\w+)(?:\s+extends\s+(\w+))?\s*\{([^}]+)\}', + re.MULTILINE + ) + + interfaces = {} + for match in interface_pattern.finditer(ts_content): + name = match.group(1) + extends_name = match.group(2) + body = match.group(3) + + fields = {} + for line in body.split('\n'): + line = line.strip() + if not line or line.startswith('//') or line.startswith('/*') or line.startswith('*'): + continue + + # Matches: fieldName?: type; or fieldName: type; + field_match = re.match(r'^(\w+)(\?)?\s*:\s*([^;]+);?$', line) + if field_match: + field_name = field_match.group(1) + optional = bool(field_match.group(2)) + field_type = field_match.group(3).strip() + fields[field_name] = { + "optional": optional, + "type": field_type + } + + interfaces[name] = { + "extends": extends_name, + "fields": fields + } + + # Resolve extends inheritance + resolved_interfaces = {} + for name, data in interfaces.items(): + fields = dict(data["fields"]) + parent = data["extends"] + visited = set() + while parent: + if parent in visited: + break + visited.add(parent) + if parent in interfaces: + for p_field, p_data in interfaces[parent]["fields"].items(): + if p_field not in fields: + fields[p_field] = p_data + parent = interfaces[parent]["extends"] + else: + break + resolved_interfaces[name] = fields + + return resolved_interfaces + + +def check_type_match(ts_type: str, schema: dict, schemas: dict, path: str): + # Strip null/undefined union parts from TS type + parts = [t.strip() for t in ts_type.split("|")] + clean_parts = [p for p in parts if p not in ["null", "undefined"]] + if not clean_parts: + return + clean_ts_type = clean_parts[0] + + # Resolve ref + if "$ref" in schema: + ref_name = schema["$ref"].split("/")[-1] + schema = schemas.get(ref_name, {}) + + # Resolve anyOf / oneOf + if "anyOf" in schema: + non_null_schemas = [s for s in schema["anyOf"] if s.get("type") != "null"] + if non_null_schemas: + schema = non_null_schemas[0] + else: + schema = schema["anyOf"][0] + elif "oneOf" in schema: + non_null_schemas = [s for s in schema["oneOf"] if s.get("type") != "null"] + if non_null_schemas: + schema = non_null_schemas[0] + else: + schema = schema["oneOf"][0] + + # Resolve ref inside resolved schemas + if "$ref" in schema: + ref_name = schema["$ref"].split("/")[-1] + schema = schemas.get(ref_name, {}) + + schema_types = schema.get("type", "any") + + if isinstance(schema_types, list): + schema_type = [t for t in schema_types if t != "null"][0] if [t for t in schema_types if t != "null"] else "any" + else: + schema_type = schema_types + + # Resolve array types + if clean_ts_type.endswith("[]"): + item_ts_type = clean_ts_type[:-2] + if schema_type != "array": + raise AssertionError(f"{path}: TS expects array, but OpenAPI type is '{schema_type}'") + items_schema = schema.get("items", {}) + check_type_match(item_ts_type, items_schema, schemas, f"{path}[]") + return + + # Resolve literal unions (e.g. 'queued' | 'running') + if len(clean_parts) > 1 and all(p.startswith(("'", '"')) and p.endswith(("'", '"')) for p in clean_parts): + literals = [p.strip("'\"") for p in clean_parts] + if schema_type != "string": + raise AssertionError(f"{path}: TS literal union expects string type in OpenAPI, got '{schema_type}'") + schema_enum = schema.get("enum", []) + for lit in literals: + if lit not in schema_enum: + raise AssertionError(f"{path}: TS enum value '{lit}' not in OpenAPI enum values: {schema_enum}") + return + + # Check primitive type matches + if clean_ts_type == "string": + if schema_type != "string": + raise AssertionError(f"{path}: TS type is string, but OpenAPI type is '{schema_type}'") + elif clean_ts_type == "number": + if schema_type not in ["integer", "number"]: + raise AssertionError(f"{path}: TS type is number, but OpenAPI type is '{schema_type}'") + elif clean_ts_type == "boolean": + if schema_type != "boolean": + raise AssertionError(f"{path}: TS type is boolean, but OpenAPI type is '{schema_type}'") + elif clean_ts_type in ["any", "unknown", "object"] or clean_ts_type.startswith("Record<"): + # Matches any type or object + pass + else: + # Reference type check + expected_ref = TS_TO_OPENAPI_MAP.get(clean_ts_type, clean_ts_type) + if "$ref" in schema: + ref_name = schema["$ref"].split("/")[-1] + if ref_name != expected_ref: + raise AssertionError(f"{path}: TS expects reference to '{clean_ts_type}' (mapped to '{expected_ref}'), but OpenAPI references '{ref_name}'") + elif schema_type == "object" or schema_type == "any": + pass + else: + # Check if ref is in schemas + if expected_ref not in schemas: + raise AssertionError(f"{path}: TS references custom interface '{clean_ts_type}' not found in OpenAPI components/schemas") + + +def verify_interface_schema_match(ts_name: str, ts_fields: dict, schema: dict, schemas: dict): + schema_properties = schema.get("properties", {}) + schema_required = schema.get("required", []) + + # Verify that all properties in OpenAPI schema exist in TS interface + for prop_name, prop_schema in schema_properties.items(): + path = f"{ts_name}.{prop_name}" + # Check if property is in TS + if prop_name not in ts_fields: + if prop_name in schema_required: + raise AssertionError(f"Required OpenAPI property '{prop_name}' in schema is missing in TS interface '{ts_name}'") + continue + + ts_field = ts_fields[prop_name] + + # Check optionality matching + if prop_name in schema_required and ts_field["optional"]: + raise AssertionError(f"{path}: Property is required in OpenAPI, but optional ('?') in TS") + + # Verify type matching + check_type_match(ts_field["type"], prop_schema, schemas, path) + + # Verify that any non-optional property present in TS also exists in the OpenAPI schema properties + for field_name, field_data in ts_fields.items(): + if field_name not in schema_properties and not field_data["optional"]: + raise AssertionError(f"Non-optional TS property '{field_name}' in '{ts_name}' does not exist in OpenAPI schema properties") + + +def test_api_contract_drift(): + """Verify that TypeScript client interfaces in api.ts match backend Pydantic models.""" + # Find api.ts path relative to this test file + repo_root = Path(__file__).resolve().parent.parent.parent.parent + api_ts_path = repo_root / "frontend" / "src" / "api.ts" + + assert api_ts_path.exists(), f"Could not find api.ts at {api_ts_path}" + + with open(api_ts_path, "r", encoding="utf-8") as f: + ts_content = f.read() + + ts_interfaces = parse_ts_interfaces(ts_content) + openapi = app.openapi() + openapi_schemas = openapi.get("components", {}).get("schemas", {}) + + # Verify that each mapped interface matches + for ts_name, openapi_name in TS_TO_OPENAPI_MAP.items(): + assert ts_name in ts_interfaces, f"TS interface '{ts_name}' missing from api.ts" + assert openapi_name in openapi_schemas, f"OpenAPI schema '{openapi_name}' missing from backend" + + ts_fields = ts_interfaces[ts_name] + schema = openapi_schemas[openapi_name] + verify_interface_schema_match(ts_name, ts_fields, schema, openapi_schemas) + + # Extra checks for schemas mapped to the same TS interface + for ts_name, extra_openapi_names in EXTRA_OPENAPI_SCHEMA_CHECKS.items(): + ts_fields = ts_interfaces[ts_name] + for openapi_name in extra_openapi_names: + assert openapi_name in openapi_schemas, f"OpenAPI schema '{openapi_name}' missing from backend" + schema = openapi_schemas[openapi_name] + verify_interface_schema_match(ts_name, ts_fields, schema, openapi_schemas) diff --git a/testing/backend/unit/test_assets.py b/testing/backend/unit/test_assets.py new file mode 100644 index 00000000..67a4644b --- /dev/null +++ b/testing/backend/unit/test_assets.py @@ -0,0 +1,216 @@ +import pytest +import asyncio +import uuid +import json +from backend.secuscan.database import get_db, init_db +from backend.secuscan.executor import executor +from backend.secuscan.models import TaskStatus + +@pytest.mark.asyncio +async def test_database_schema_assets(setup_test_environment): + """Verify that assets and relationship tables are created correctly in the database schema.""" + db = await init_db(f"{setup_test_environment}/test_secuscan.db") + + # Check if tables exist + tables = await db.fetchall("SELECT name FROM sqlite_master WHERE type='table'") + table_names = {t["name"] for t in tables} + + assert "assets" in table_names + assert "asset_findings" in table_names + assert "asset_tasks" in table_names + assert "asset_reports" in table_names + + await db.disconnect() + +@pytest.mark.asyncio +async def test_asset_deduplication_and_relationships(setup_test_environment): + """Test that host assets are deduplicated and linked to tasks and reports correctly.""" + db = await init_db(f"{setup_test_environment}/test_secuscan.db") + + # 1. Create a dummy task + task_id1 = "task:test1" + await db.execute( + """ + INSERT INTO tasks (id, plugin_id, tool_name, target, status, inputs_json) + VALUES (?, 'nmap', 'Nmap', 'http://192.168.1.5:8080/path', 'completed', '{}') + """, + (task_id1,) + ) + # Create report + await db.execute( + "INSERT INTO reports (id, task_id, name, type) VALUES (?, ?, 'Test Report', 'technical')", + (f"report:{task_id1}", task_id1) + ) + + # Create findings + finding_id1 = "finding:1" + await db.execute( + """ + INSERT INTO findings (id, task_id, plugin_id, title, category, severity, target, description, metadata_json) + VALUES (?, ?, 'nmap', 'Open Port: 8080/tcp', 'Network Service', 'low', '192.168.1.5', 'Port 8080 open', '{"port": 8080, "protocol": "tcp"}') + """, + (finding_id1, task_id1) + ) + + # Run updater + await executor._update_assets_for_task(db, task_id1) + + # Check that host asset was created with normalized name + host_assets = await db.fetchall("SELECT * FROM assets WHERE type='host'") + assert len(host_assets) == 1 + assert host_assets[0]["name"] == "192.168.1.5" + host_id = host_assets[0]["id"] + + # Check service asset was created under host + service_assets = await db.fetchall("SELECT * FROM assets WHERE type='service'") + assert len(service_assets) == 1 + assert service_assets[0]["name"] == "8080/tcp" + assert service_assets[0]["host_id"] == host_id + service_id = service_assets[0]["id"] + + # Check relationships + task_links = await db.fetchall("SELECT * FROM asset_tasks") + assert len(task_links) == 2 # one for host, one for service + + finding_links = await db.fetchall("SELECT * FROM asset_findings") + assert len(finding_links) == 1 + assert finding_links[0]["asset_id"] == service_id + assert finding_links[0]["finding_id"] == finding_id1 + + # 2. Run a second task on the SAME target to verify deduplication + task_id2 = "task:test2" + await db.execute( + """ + INSERT INTO tasks (id, plugin_id, tool_name, target, status, inputs_json) + VALUES (?, 'nmap', 'Nmap', '192.168.1.5', 'completed', '{}') + """, + (task_id2,) + ) + # Create report + await db.execute( + "INSERT INTO reports (id, task_id, name, type) VALUES (?, ?, 'Test Report', 'technical')", + (f"report:{task_id2}", task_id2) + ) + + # Run updater + await executor._update_assets_for_task(db, task_id2) + + # Verify no duplicate host asset was created + all_hosts = await db.fetchall("SELECT * FROM assets WHERE type='host'") + assert len(all_hosts) == 1 + + await db.disconnect() + +def test_assets_rest_endpoints(test_client): + """Test standard REST API endpoints for assets using test client.""" + # Insert dummy data through sqlite connection directly (using the initialized test_client db) + async def insert_data(): + db = await get_db() + # Task + await db.execute( + "INSERT INTO tasks (id, plugin_id, tool_name, target, status) VALUES ('task:test', 'subfinder', 'Subfinder', 'example.com', 'completed')" + ) + # Create report + await db.execute( + "INSERT INTO reports (id, task_id, name, type) VALUES ('report:task:test', 'task:test', 'Test Report', 'technical')" + ) + # Findings + await db.execute( + """ + INSERT INTO findings (id, task_id, plugin_id, title, category, severity, target, description, metadata_json) + VALUES ('finding:sub', 'task:test', 'subfinder', 'Subdomain Found', 'Asset Discovery', 'info', 'example.com', 'Subdomain discovered', '{"subdomain": "api.example.com"}') + """ + ) + await executor._update_assets_for_task(db, 'task:test') + + asyncio.run(insert_data()) + + # Test GET /api/v1/assets + response = test_client.get("/api/v1/assets") + assert response.status_code == 200 + data = response.json() + assert "assets" in data + # We should have "example.com" (host) and "api.example.com" (subdomain host) + names = {a["name"] for a in data["assets"]} + assert "example.com" in names + assert "api.example.com" in names + + # Test GET /api/v1/assets/graph + response_graph = test_client.get("/api/v1/assets/graph") + assert response_graph.status_code == 200 + graph = response_graph.json() + assert "nodes" in graph + assert "links" in graph + node_labels = {n["label"] for n in graph["nodes"]} + assert "example.com" in node_labels + assert "api.example.com" in node_labels + assert "Subdomain Found" in node_labels + + # Find an asset ID + asset_id = next(a["id"] for a in data["assets"] if a["name"] == "example.com") + + # Test GET /api/v1/asset/{id} + response_details = test_client.get(f"/api/v1/asset/{asset_id}") + assert response_details.status_code == 200 + details = response_details.json() + assert details["name"] == "example.com" + assert "tasks" in details + assert "findings" in details + + # Test GET /api/v1/finding/{id} to verify asset links nested inside finding details + response_finding = test_client.get("/api/v1/finding/finding:sub") + assert response_finding.status_code == 200 + f_details = response_finding.json() + assert "assets" in f_details + assert len(f_details["assets"]) > 0 + +def test_negative_cases(test_client): + """Verify negative boundaries like fetching non-existent asset details or parameter injections.""" + # 1. Fetch non-existent asset ID -> should return 404 + response = test_client.get("/api/v1/asset/asset:host:nonexistent") + assert response.status_code == 404 + assert response.json()["detail"] == "Asset not found" + + # 2. Fetch non-existent finding details -> should return 404 + response = test_client.get("/api/v1/finding/finding:nonexistent") + assert response.status_code == 404 + +@pytest.mark.asyncio +async def test_ipv6_normalization_and_uniqueness_constraints(setup_test_environment): + """Verify that IPv6 targets are parsed correctly and that database-level unique constraints prevent duplicate inserts.""" + db = await init_db(f"{setup_test_environment}/test_secuscan.db") + + # 1. Test IPv6 parsing + ipv6_task_id = "task:ipv6" + await db.execute( + """ + INSERT INTO tasks (id, plugin_id, tool_name, target, status, inputs_json) + VALUES (?, 'nmap', 'Nmap', 'http://[2001:db8::1]:8080/path', 'completed', '{}') + """, + (ipv6_task_id,) + ) + await db.execute( + "INSERT INTO reports (id, task_id, name, type) VALUES (?, ?, 'Test Report', 'technical')", + (f"report:{ipv6_task_id}", ipv6_task_id) + ) + await executor._update_assets_for_task(db, ipv6_task_id) + + # Check normalized host + host_assets = await db.fetchall("SELECT * FROM assets WHERE type='host'") + host_names = {h["name"] for h in host_assets} + assert "2001:db8::1" in host_names + + # 2. Test SQLite uniqueness constraints by trying to insert a duplicate host directly + import sqlite3 + try: + await db.execute( + """ + INSERT INTO assets (id, type, name, host_id, metadata_json) + VALUES ('asset:host:duplicate', 'host', '2001:db8::1', NULL, '{}') + """ + ) + assert False, "Should have raised IntegrityError" + except sqlite3.IntegrityError: + pass + + await db.disconnect()