From d34953ff307408c0bf9cb90accd5d16d3968afa5 Mon Sep 17 00:00:00 2001 From: VIDYANKSHINI Date: Sun, 24 May 2026 09:24:27 +0530 Subject: [PATCH 1/6] feat: add asset inventory graph linking hosts, services, findings, tasks, and reports --- backend/secuscan/database.py | 28 + backend/secuscan/executor.py | 164 +++ backend/secuscan/routes.py | 273 ++++- frontend/src/App.tsx | 2 + frontend/src/api.ts | 16 + frontend/src/components/Sidebar.tsx | 1 + frontend/src/pages/Assets.tsx | 961 ++++++++++++++++++ frontend/src/pages/Findings.tsx | 52 +- frontend/src/routes.ts | 1 + frontend/testing/unit/AppRoutes.test.tsx | 1 + frontend/testing/unit/pages/Findings.test.tsx | 1 + testing/backend/unit/test_assets.py | 162 +++ 12 files changed, 1656 insertions(+), 6 deletions(-) create mode 100644 frontend/src/pages/Assets.tsx create mode 100644 testing/backend/unit/test_assets.py diff --git a/backend/secuscan/database.py b/backend/secuscan/database.py index 8ff8775e..27c69328 100644 --- a/backend/secuscan/database.py +++ b/backend/secuscan/database.py @@ -121,6 +121,34 @@ 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 TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, diff --git a/backend/secuscan/executor.py b/backend/secuscan/executor.py index 3b45fbbe..218c25af 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,169 @@ 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 ":" in host_name: + if host_name.startswith("[") and "]" in host_name: + host_name = host_name.split("]")[0][1:] + else: + host_name = host_name.split(":", 1)[0] + + host_name = host_name.strip() + if not host_name: + return + + # Deduplicate and upsert host asset + 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"] + else: + host_asset_id = f"asset:host:{str(uuid.uuid4()).replace('-', '')[:16]}" + await db.execute( + """ + INSERT 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) + ) + + # 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 + 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"] + else: + 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 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)) + ) + + # 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 + sub_row = await db.fetchone( + "SELECT id FROM assets WHERE type = 'host' AND name = ?", + (subdomain,) + ) + if sub_row: + sub_asset_id = sub_row["id"] + else: + sub_asset_id = f"asset:host:{str(uuid.uuid4()).replace('-', '')[:16]}" + await db.execute( + """ + INSERT INTO assets (id, type, name, host_id, metadata_json, created_at, updated_at) + VALUES (?, 'host', ?, NULL, '{}', (datetime('now')), (datetime('now'))) + """, + (sub_asset_id, subdomain) + ) + + # 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/routes.py b/backend/secuscan/routes.py index f1d53063..518d28ac 100644 --- a/backend/secuscan/routes.py +++ b/backend/secuscan/routes.py @@ -764,6 +764,16 @@ 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) + """ + ) + # Cleanup files on disk for row in task_rows: if row and row["raw_output_path"]: @@ -831,6 +841,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"]: @@ -1056,6 +1070,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 +1097,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 } @@ -1124,7 +1151,243 @@ async def get_attack_surface(): 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 + 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 + + findings_count = (await db.fetchone("SELECT COUNT(*) as count FROM asset_findings WHERE asset_id = ?", (row["id"],)))["count"] + tasks_count = (await db.fetchone("SELECT COUNT(*) as count FROM asset_tasks WHERE asset_id = ?", (row["id"],)))["count"] + reports_count = (await db.fetchone("SELECT COUNT(*) as count FROM asset_reports WHERE asset_id = ?", (row["id"],)))["count"] + + 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": findings_count, + "tasks_count": tasks_count, + "reports_count": reports_count + }) + + return {"assets": assets} + + +@router.get("/assets/graph") +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}") +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..83ea294b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -124,6 +124,22 @@ export function getReports() { return request('/reports') } +export function getAssets() { + return request('/assets') +} + +export function getAssetsGraph() { + return request('/assets/graph') +} + +export function getAssetDetails(assetId: string) { + return request(`/asset/${assetId}`) +} + +export function getFindingDetails(findingId: string) { + return request(`/finding/${findingId}`) +} + export function getTasks(params?: URLSearchParams) { const suffix = params ? `?${params.toString()}` : '' return request(`/tasks${suffix}`) 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..17168f00 --- /dev/null +++ b/frontend/src/pages/Assets.tsx @@ -0,0 +1,961 @@ +import React, { useEffect, useState, useMemo, useRef } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { motion, AnimatePresence } from 'framer-motion' +import { getAssets, getAssetsGraph, getAssetDetails } 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 +} + +const severityConfig: Record = { + critical: 'bg-rag-red text-black border-rag-red/30', + high: 'bg-rag-amber text-black border-rag-amber/30', + medium: 'bg-rag-blue text-black border-rag-blue/30', + low: 'bg-charcoal-dark text-silver-bright border border-silver-bright/15', + info: 'bg-charcoal-dark text-silver border border-silver/15', +} + +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 navigate = useNavigate() + + // 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 handleLocationChange = () => { + const params = new URLSearchParams(window.location.search) + const selected = params.get('selected') + if (selected) { + setSelectedAssetId(selected) + setActiveTab('list') + } + } + window.addEventListener('popstate', handleLocationChange) + const interval = setInterval(handleLocationChange, 500) + return () => { + window.removeEventListener('popstate', handleLocationChange) + clearInterval(interval) + } + }, []) + + // 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 + } + 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) => { + setSelectedAssetDetails({ ...data, isDirectAsset: true }) + }) + .catch((err) => console.error(err)) + .finally(() => 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') { + fetch(`${window.location.origin}/api/v1/finding/${selectedAssetId}`) + .then((r) => r.json()) + .then((data) => setSelectedAssetDetails({ ...data, type: 'finding', label: data.title })) + .finally(() => setDetailsLoading(false)) + } else if (type === 'report') { + setSelectedAssetDetails({ id: selectedAssetId, type: 'report', label: 'PDF/HTML Security Report' }) + setDetailsLoading(false) + } else { + // Task + setSelectedAssetDetails({ id: selectedAssetId, type: 'task', label: 'Scan Task Record' }) + setDetailsLoading(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 */} + + { setActiveTab('list'); setSelectedAssetId(null) }} + className={`px-6 py-2.5 text-[10px] font-black uppercase tracking-[0.15em] transition-all ${ + activeTab === 'list' + ? 'bg-silver-bright text-black shadow-[2px_2px_0px_rgba(0,0,0,1)]' + : 'text-silver/60 hover:text-silver-bright' + }`} + > + Inventory List + + { setActiveTab('graph'); setSelectedAssetId(null); loadGraphData() }} + className={`px-6 py-2.5 text-[10px] font-black uppercase tracking-[0.15em] transition-all ${ + activeTab === 'graph' + ? 'bg-silver-bright text-black shadow-[2px_2px_0px_rgba(0,0,0,1)]' + : 'text-silver/60 hover:text-silver-bright' + }`} + > + Topology Graph + + + + + + {/* 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() && ( + setSearchQuery('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-silver/50 hover:text-silver-bright"> + ✕ + + )} + + + + setFilterType('all')} + className={`h-11 border px-4 text-[10px] font-black uppercase tracking-[0.16em] transition-all ${ + filterType === 'all' + ? 'border-black bg-silver-bright text-black shadow-[3px_3px_0px_rgba(0,0,0,1)]' + : 'border-silver-bright/10 bg-charcoal-dark text-silver/65 hover:border-silver-bright/30' + }`} + > + All Types + + setFilterType('host')} + className={`h-11 border px-4 text-[10px] font-black uppercase tracking-[0.16em] transition-all ${ + filterType === 'host' + ? 'border-black bg-rag-blue text-black shadow-[3px_3px_0px_rgba(0,0,0,1)]' + : 'border-silver-bright/10 bg-charcoal-dark text-silver/65 hover:border-silver-bright/30' + }`} + > + Hosts + + setFilterType('service')} + className={`h-11 border px-4 text-[10px] font-black uppercase tracking-[0.16em] transition-all ${ + filterType === 'service' + ? 'border-black bg-purple-500 text-black shadow-[3px_3px_0px_rgba(0,0,0,1)]' + : 'border-silver-bright/10 bg-charcoal-dark text-silver/65 hover:border-silver-bright/30' + }`} + > + Services + + + + + {/* 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)}% + + + + setZoom((z) => Math.min(2.5, z + 0.1))} + className="w-10 h-10 border border-silver-bright/10 bg-charcoal-dark hover:border-silver-bright/35 flex items-center justify-center text-silver-bright" + title="Zoom In" + > + zoom_in + + setZoom((z) => Math.max(0.4, z - 0.1))} + className="w-10 h-10 border border-silver-bright/10 bg-charcoal-dark hover:border-silver-bright/35 flex items-center justify-center text-silver-bright" + title="Zoom Out" + > + zoom_out + + { setZoom(1.0); setPan({ x: 0, y: 0 }) }} + className="h-10 px-3 border border-silver-bright/10 bg-charcoal-dark hover:border-silver-bright/35 flex items-center justify-center text-[10px] font-black uppercase tracking-wider text-silver-bright" + title="Reset Pan & Zoom" + > + RESET View + + + + + + + {/* 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..2a43003d 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,31 @@ 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 + } + setAssetsLoading(true) + const promise = getFindingDetails(selectedFindingId) + if (promise && typeof promise.then === 'function') { + promise + .then((data: any) => { + setSelectedFindingAssets(data?.assets || []) + }) + .catch((err) => { + console.error(err) + setSelectedFindingAssets([]) + }) + .finally(() => setAssetsLoading(false)) + } else { + setSelectedFindingAssets([]) + setAssetsLoading(false) + } + }, [selectedFindingId]) const [copiedFindingId, setCopiedFindingId] = useState(null) useEffect(() => { @@ -691,6 +718,29 @@ export default function Findings() {
+ Active tracked surface // {assets.filter((a) => a.type === 'host').length} hosts // {assets.filter((a) => a.type === 'service').length} service endpoints +
Associated Assets
Loading linked assets...
No assets mapped to this finding.