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.
+
+ ) : (
+
+
+
+
+ | Asset Target |
+ Type |
+ Parent Host |
+ Findings |
+ Tasks |
+ Reports |
+
+
+
+ {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.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)}%
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ {/* 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()