Files
talos/backend/app/scanner.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

275 lines
10 KiB
Python

import asyncio
import re
import uuid
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import httpx
import structlog
from app import db, llm, profiles, tools
from app.config import settings
from app.models import Finding, Scan
log = structlog.get_logger(__name__)
_active_scans: set[str] = set()
_target_active: dict[str, set[str]] = defaultdict(set)
_cancelled: set[str] = set()
_scan_semaphore: asyncio.Semaphore | None = None
def _semaphore() -> asyncio.Semaphore:
global _scan_semaphore
if _scan_semaphore is None:
_scan_semaphore = asyncio.Semaphore(settings.max_concurrent_scans)
return _scan_semaphore
class WsBroadcaster:
def __init__(self) -> None:
self._queues: list[asyncio.Queue[dict[str, Any]]] = []
def subscribe(self) -> asyncio.Queue[dict[str, Any]]:
q: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
self._queues.append(q)
return q
async def send(self, message: dict[str, Any]) -> None:
for q in list(self._queues):
await q.put(message)
_scan_broadcasters: dict[str, WsBroadcaster] = {}
def get_broadcaster(scan_id: str) -> WsBroadcaster:
if scan_id not in _scan_broadcasters:
_scan_broadcasters[scan_id] = WsBroadcaster()
return _scan_broadcasters[scan_id]
def is_scan_active(scan_id: str) -> bool:
return scan_id in _active_scans
async def target_busy(target: str) -> tuple[bool, str]:
"""One queued or running scan per target at a time."""
if await db.has_active_scan_for_target(target):
return True, "A scan is already queued or running for this target"
return False, ""
def _audit_log(target: str, profile: str, source_ip: str) -> None:
path = Path(settings.audit_log_path)
path.parent.mkdir(parents=True, exist_ok=True)
ts = datetime.now(timezone.utc).isoformat()
line = f"{ts}\ttarget={target}\tprofile={profile}\tsource_ip={source_ip}\n"
with path.open("a", encoding="utf-8") as f:
f.write(line)
def _extract_open_ports(nmap_output: str) -> str:
ports: list[str] = []
for m in re.finditer(r'portid="(\d+)"', nmap_output):
ports.append(m.group(1))
if not ports:
for m in re.finditer(r"(\d+)/tcp\s+open", nmap_output):
ports.append(m.group(1))
return ",".join(sorted(set(ports), key=int)) if ports else "80,443"
async def _resolve_final_url(start_url: str) -> str:
timeout = httpx.Timeout(connect=5.0, read=10.0, write=5.0, pool=5.0)
async with httpx.AsyncClient(follow_redirects=True, timeout=timeout, verify=True) as client:
try:
r = await client.head(start_url)
except Exception:
r = await client.get(start_url)
return str(r.url)
def _ensure_https(url: str) -> str:
if url.startswith("https://"):
return url
if url.startswith("http://"):
return "https://" + url.removeprefix("http://")
return "https://" + url
def _classify_nonzero_phase(pdef: profiles.PhaseDef, output: str) -> str | None:
if pdef.name == "dir_enum":
low = output.lower()
if "no wordlist found" in low or ("wordlist" in low and ("no such file" in low or "not found" in low)):
return "inconclusive"
if "429" in low or "too many requests" in low:
return "blocked"
if "403" in low or "forbidden" in low:
return "blocked"
if "timeout" in low:
return "inconclusive"
if pdef.name == "ssl_check":
low = output.lower()
if "timeout" in low:
return "inconclusive"
return None
async def queue_scan(scan: Scan, source_ip: str = "unknown") -> None:
"""Enqueue scan; waits on semaphore when max concurrent scans are active."""
_audit_log(scan.target, scan.profile, source_ip)
asyncio.create_task(_run_with_slot(scan.id))
async def _run_with_slot(scan_id: str) -> None:
async with _semaphore():
await _run_scan(scan_id)
async def cancel_scan(scan_id: str) -> bool:
_cancelled.add(scan_id)
await tools.cancel_scan_processes(scan_id)
await db.update_scan_status(scan_id, "cancelled")
bc = get_broadcaster(scan_id)
await bc.send({"type": "done", "scan_id": scan_id, "status": "cancelled"})
scan = await db.get_scan(scan_id)
if scan and scan.bulk_id:
await db.refresh_bulk_run_status(scan.bulk_id)
return True
async def _run_scan(scan_id: str) -> None:
scan = await db.get_scan(scan_id)
if not scan:
return
_active_scans.add(scan_id)
_target_active[scan.target].add(scan_id)
bc = get_broadcaster(scan_id)
llm_down = False
try:
await db.update_scan_status(scan_id, "running")
if scan.bulk_id:
await db.refresh_bulk_run_status(scan.bulk_id)
phase_defs = profiles.get_phase_defs(scan.profile)
open_ports = "80,443"
ctx_base = profiles._ctx(scan.target, scan.options, scan_id)
ctx_dynamic: dict[str, str] = {}
if scan.options.include_web or scan.options.include_ssl:
try:
start = profiles._target_url(scan.target)
final_url = await _resolve_final_url(start)
final_https_url = _ensure_https(final_url)
except Exception as exc:
final_url = profiles._target_url(scan.target)
final_https_url = profiles._https_target_url(scan.target)
ctx_dynamic["url_resolution_error"] = str(exc)
ctx_dynamic["final_url"] = final_url
ctx_dynamic["final_https_url"] = final_https_url
phase_rows: list[tuple[str, str, str, str]] = []
for pdef in phase_defs:
if not pdef.should_run(scan.options):
continue
cmd = profiles.render_command(
pdef.command_template,
{**ctx_base, **ctx_dynamic, "open_ports": open_ports},
)
phase_rows.append((str(uuid.uuid4()), pdef.name, pdef.tool, " ".join(cmd)))
phases = await db.create_phases(scan_id, phase_rows)
runnable_defs = [pd for pd in phase_defs if pd.should_run(scan.options)]
for p in phases:
await bc.send({"type": "phase", "phase_id": p.id, "phase": p.name, "status": p.status})
for phase, pdef in zip(phases, runnable_defs):
if scan_id in _cancelled:
break
ctx = {**ctx_base, **ctx_dynamic, "open_ports": open_ports}
command = profiles.render_command(pdef.command_template, ctx)
if pdef.name == "ssh_brute" and "22" not in (open_ports or "").split(","):
note = f"skipped: ssh port 22 not in open ports ({open_ports})"
await db.update_phase(phase.id, "skipped", raw_output=note, exit_code=0)
await bc.send({"type": "phase", "phase_id": phase.id, "phase": phase.name, "status": "skipped"})
continue
await db.update_phase(phase.id, "running", started=True)
await bc.send({"type": "phase", "phase_id": phase.id, "phase": phase.name, "status": "running"})
async def on_line(line: str, pid: str = phase.id) -> None:
await db.append_phase_output(pid, line)
await bc.send({"type": "log", "phase_id": pid, "line": line})
last_result = None
attempts = 1 + max(0, pdef.retries)
for attempt in range(attempts):
if attempt > 0 and pdef.retry_delay_seconds > 0:
await asyncio.sleep(pdef.retry_delay_seconds)
last_result = await tools.run_tool(
command,
scan_id=scan_id,
on_line=on_line,
timeout=pdef.timeout_seconds,
)
if last_result.exit_code in pdef.success_exit_codes:
break
assert last_result is not None
result = last_result
status = "complete" if result.exit_code in pdef.success_exit_codes else "failed"
full_out = result.stdout + ("\n" + result.stderr if result.stderr else "")
if status == "failed":
classified = _classify_nonzero_phase(pdef, full_out)
if classified:
status = classified
await db.update_phase(phase.id, status, raw_output=full_out, exit_code=result.exit_code)
await bc.send(
{
"type": "phase",
"phase_id": phase.id,
"phase": phase.name,
"status": status,
"duration": result.duration,
}
)
if phase.name == "port_scan" and result.stdout:
open_ports = _extract_open_ports(result.stdout)
if pdef.llm_analyze and full_out.strip():
phase.raw_output = full_out
findings, unavailable = await llm.analyze_phase(
phase,
full_out,
target=scan.target,
profile=scan.profile,
model=scan.options.llm_model,
)
if unavailable:
llm_down = True
for f in findings:
await db.save_finding(f)
await bc.send({"type": "finding", "finding": f.model_dump(mode="json")})
final = "cancelled" if scan_id in _cancelled else "complete"
await db.update_scan_status(scan_id, final, llm_unavailable=llm_down)
await bc.send({"type": "done", "scan_id": scan_id, "status": final, "llm_unavailable": llm_down})
except Exception as exc:
log.exception("scan_failed", scan_id=scan_id, error=str(exc))
await db.update_scan_status(scan_id, "failed")
await bc.send({"type": "error", "message": str(exc)})
await bc.send({"type": "done", "scan_id": scan_id, "status": "failed"})
finally:
_active_scans.discard(scan_id)
_target_active[scan.target].discard(scan_id)
_cancelled.discard(scan_id)
if scan and scan.bulk_id:
await db.refresh_bulk_run_status(scan.bulk_id)