Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions backend/secuscan/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
162 changes: 162 additions & 0 deletions backend/secuscan/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down
3 changes: 2 additions & 1 deletion backend/secuscan/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from .plugins import init_plugins
from .routes import router
from .workflows import scheduler
from .models import HealthResponse


# Configure logging
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading