Files
talos/backend/app/main.py
T
ilia 12a6e2b6bc
CI / skip-ci-check (push) Successful in 8s
CI / secret-scan (push) Successful in 7s
CI / python-ci (push) Failing after 19s
Initial commit: Talos edge-facing scan orchestrator.
FastAPI + Kali tools UI for authorized scans of levkin.ca / LAN targets.
Gitignore local data/.env; add pytest + gitleaks CI.
2026-07-12 11:46:54 -04:00

277 lines
9.6 KiB
Python

import asyncio
import json
from collections import defaultdict
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
import httpx
import structlog
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from app import db, export, llm, profiles, scanner, tools
from app.config import settings
from app.models import BulkScanCreate, ScanCreate
from app.safety import validate_scan_request
structlog.configure(processors=[structlog.processors.JSONRenderer()])
log = structlog.get_logger(__name__)
def _frontend_dir() -> Path:
here = Path(__file__).resolve()
for candidate in (here.parent.parent / "frontend", here.parent.parent.parent / "frontend"):
if (candidate / "index.html").exists():
return candidate
return here.parent.parent.parent / "frontend"
FRONTEND_DIR = _frontend_dir()
@asynccontextmanager
async def lifespan(_app: FastAPI):
Path(settings.db_path).parent.mkdir(parents=True, exist_ok=True)
await db.init_db()
yield
await db.close_db()
app = FastAPI(title="Talos", lifespan=lifespan)
if FRONTEND_DIR.exists():
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
@app.get("/")
async def index() -> FileResponse:
return FileResponse(FRONTEND_DIR / "index.html")
@app.get("/api/health")
async def health() -> dict[str, Any]:
ollama_ok = await llm.ollama_health()
tool_checks: dict[str, str] = {}
for name, cmd in [("nmap", ["nmap", "--version"]), ("curl", ["curl", "--version"])]:
try:
r = await tools.run_tool(cmd, timeout=10)
tool_checks[name] = "ok" if r.exit_code == 0 else "error"
except Exception:
tool_checks[name] = "error"
return {
"ollama": "ok" if ollama_ok else "down",
"tools": tool_checks,
"db": "ok",
"max_concurrent_scans": settings.max_concurrent_scans,
}
@app.get("/api/profiles")
async def api_profiles() -> list[dict[str, object]]:
return profiles.get_profiles_payload()
@app.post("/api/scans")
async def create_scan(body: ScanCreate, request: Request) -> dict[str, str]:
target = validate_scan_request(body.target, body.authorized)
busy, reason = await scanner.target_busy(target)
if busy:
raise HTTPException(status_code=429, detail=reason)
scan = await db.create_scan(target, body.profile, body.options)
source_ip = request.client.host if request.client else "unknown"
await scanner.queue_scan(scan, source_ip=source_ip)
return {"scan_id": scan.id}
@app.post("/api/scans/bulk")
async def create_bulk_scans(body: BulkScanCreate, request: Request) -> dict[str, Any]:
source_ip = request.client.host if request.client else "unknown"
targets = [t.strip() for t in (body.targets or []) if t and t.strip()]
if not targets:
raise HTTPException(status_code=400, detail="At least one target is required")
bulk = await db.create_bulk_run(body.profile, total=0)
queued: list[dict[str, str]] = []
rejected: list[dict[str, str]] = []
seen_targets: set[str] = set()
for raw in targets:
try:
target = validate_scan_request(raw, body.authorized)
if target in seen_targets:
rejected.append({"target": target, "reason": "Duplicate target in this bulk list"})
continue
seen_targets.add(target)
busy, reason = await scanner.target_busy(target)
if busy:
rejected.append({"target": target, "reason": reason})
continue
scan = await db.create_scan(
target, body.profile, body.options, bulk_id=bulk.id
)
await scanner.queue_scan(scan, source_ip=source_ip)
queued.append({"target": target, "scan_id": scan.id})
except HTTPException as exc:
rejected.append({"target": raw, "reason": str(exc.detail)})
await db.refresh_bulk_run_status(bulk.id)
bulk = await db.get_bulk_run(bulk.id)
return {
"bulk_id": bulk.id,
"queued": queued,
"rejected": rejected,
"bulk": bulk.model_dump(mode="json") if bulk else None,
}
@app.get("/api/bulks/{bulk_id}")
async def get_bulk_status(bulk_id: str) -> dict[str, Any]:
bulk = await db.refresh_bulk_run_status(bulk_id)
if not bulk:
raise HTTPException(status_code=404, detail="Bulk run not found")
scans = await db.list_scans_for_bulk(bulk_id)
return {
"bulk": bulk.model_dump(mode="json"),
"scans": [
{
"id": s.id,
"target": s.target,
"status": s.status,
"created_at": s.created_at.isoformat(),
}
for s in scans
],
}
@app.get("/api/bulks/{bulk_id}/export")
async def export_bulk(bulk_id: str, format: str = "cursor") -> Any:
payload = await db.get_bulk_export_payload(bulk_id)
if not payload:
raise HTTPException(status_code=404, detail="Bulk run not found")
if format in ("md", "cursor"):
text = export.render_bulk_markdown(payload, for_cursor=(format == "cursor"))
return PlainTextResponse(text, media_type="text/markdown")
if format == "json":
return JSONResponse(export.render_bulk_json(payload))
raise HTTPException(status_code=400, detail="format must be cursor, md, or json")
@app.get("/api/scans")
async def list_scans() -> list[dict[str, Any]]:
scans = await db.list_scans()
return [
{
"id": s.id,
"target": s.target,
"profile": s.profile,
"status": s.status,
"created_at": s.created_at.isoformat(),
}
for s in scans
]
@app.get("/api/scans/{scan_id}")
async def get_scan_detail(scan_id: str) -> dict[str, Any]:
scan = await db.get_scan(scan_id)
if not scan:
raise HTTPException(status_code=404, detail="Scan not found")
phases = await db.get_phases(scan_id)
findings = await db.get_findings(scan_id)
return {
"scan": scan.model_dump(mode="json"),
"phases": [p.model_dump(mode="json") for p in phases],
"findings": [f.model_dump(mode="json") for f in findings],
}
@app.get("/api/scans/{scan_id}/findings")
async def get_scan_findings(scan_id: str) -> dict[str, list[dict[str, Any]]]:
scan = await db.get_scan(scan_id)
if not scan:
raise HTTPException(status_code=404, detail="Scan not found")
findings = await db.get_findings(scan_id)
grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
for f in findings:
grouped[f.severity].append(f.model_dump(mode="json"))
return dict(grouped)
@app.post("/api/scans/{scan_id}/cancel")
async def cancel_scan(scan_id: str) -> dict[str, bool]:
scan = await db.get_scan(scan_id)
if not scan:
raise HTTPException(status_code=404, detail="Scan not found")
await scanner.cancel_scan(scan_id)
return {"cancelled": True}
@app.get("/api/scans/{scan_id}/export")
async def export_scan(scan_id: str, format: str = "json") -> Any:
detail = await get_scan_detail(scan_id)
if format == "md":
lines = [
f"# Talos Report — {detail['scan']['target']}",
f"Profile: {detail['scan']['profile']}",
f"Status: {detail['scan']['status']}",
"",
]
for f in detail["findings"]:
lines.append(f"## [{f['severity'].upper()}] {f['title']}")
lines.append(f["description"])
lines.append(f"**Recommendation:** {f['recommendation']}")
lines.append("")
return PlainTextResponse("\n".join(lines), media_type="text/markdown")
return JSONResponse(detail)
@app.websocket("/ws/scans/{scan_id}")
async def ws_scan(websocket: WebSocket, scan_id: str) -> None:
scan = await db.get_scan(scan_id)
if not scan:
await websocket.close(code=4004)
return
await websocket.accept()
bc = scanner.get_broadcaster(scan_id)
queue = bc.subscribe()
try:
# If the client connects immediately after queueing a scan, the scan task may not
# have created phase rows yet. Poll briefly so the UI can render the full phase list.
phases = await db.get_phases(scan_id)
if not phases and scan.status in ("queued", "running"):
for _ in range(20): # ~2s total
await asyncio.sleep(0.1)
phases = await db.get_phases(scan_id)
if phases:
break
for p in phases:
await websocket.send_json(
{"type": "phase", "phase_id": p.id, "phase": p.name, "status": p.status}
)
findings = await db.get_findings(scan_id)
for f in findings:
await websocket.send_json({"type": "finding", "finding": f.model_dump(mode="json")})
while True:
try:
msg = await asyncio.wait_for(queue.get(), timeout=1.0)
await websocket.send_json(msg)
if msg.get("type") == "done":
break
except TimeoutError:
if not scanner.is_scan_active(scan_id):
current = await db.get_scan(scan_id)
if current and current.status in ("complete", "failed", "cancelled"):
await websocket.send_json(
{"type": "done", "scan_id": scan_id, "status": current.status}
)
break
except WebSocketDisconnect:
pass
finally:
if queue in bc._queues:
bc._queues.remove(queue)