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.
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
import structlog
|
||||
|
||||
from app.config import settings
|
||||
from app.models import ToolResult
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
# Track running processes for cancellation: scan_id -> list[asyncio.subprocess.Process]
|
||||
_running: dict[str, list[asyncio.subprocess.Process]] = {}
|
||||
|
||||
|
||||
def register_process(scan_id: str, proc: asyncio.subprocess.Process) -> None:
|
||||
_running.setdefault(scan_id, []).append(proc)
|
||||
|
||||
|
||||
def unregister_process(scan_id: str, proc: asyncio.subprocess.Process) -> None:
|
||||
procs = _running.get(scan_id, [])
|
||||
if proc in procs:
|
||||
procs.remove(proc)
|
||||
|
||||
|
||||
async def cancel_scan_processes(scan_id: str) -> None:
|
||||
for proc in _running.get(scan_id, []):
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
_running[scan_id] = []
|
||||
|
||||
|
||||
async def run_tool(
|
||||
command: list[str],
|
||||
scan_id: str | None = None,
|
||||
cwd: str | None = None,
|
||||
timeout: int | None = None,
|
||||
on_line: Callable[[str], Awaitable[None]] | None = None,
|
||||
) -> ToolResult:
|
||||
"""Spawn process without shell; stream stdout line-by-line."""
|
||||
timeout = timeout or settings.tool_timeout_seconds
|
||||
start = time.monotonic()
|
||||
stdout_parts: list[str] = []
|
||||
stderr_parts: list[str] = []
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*command,
|
||||
cwd=cwd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
if scan_id:
|
||||
register_process(scan_id, proc)
|
||||
|
||||
async def read_stream(stream: asyncio.StreamReader, is_stderr: bool) -> None:
|
||||
while True:
|
||||
line = await stream.readline()
|
||||
if not line:
|
||||
break
|
||||
text = line.decode(errors="replace").rstrip("\n")
|
||||
if is_stderr:
|
||||
stderr_parts.append(text)
|
||||
else:
|
||||
stdout_parts.append(text)
|
||||
if on_line:
|
||||
await on_line(text)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.gather(
|
||||
read_stream(proc.stdout, False), # type: ignore[arg-type]
|
||||
read_stream(proc.stderr, True), # type: ignore[arg-type]
|
||||
),
|
||||
timeout=timeout,
|
||||
)
|
||||
exit_code = await proc.wait()
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
exit_code = -1
|
||||
stderr_parts.append(f"timeout after {timeout}s")
|
||||
log.warning("tool_timeout", command=command, timeout=timeout)
|
||||
finally:
|
||||
if scan_id:
|
||||
unregister_process(scan_id, proc)
|
||||
|
||||
duration = time.monotonic() - start
|
||||
return ToolResult(
|
||||
stdout="\n".join(stdout_parts),
|
||||
stderr="\n".join(stderr_parts),
|
||||
exit_code=exit_code,
|
||||
duration=duration,
|
||||
)
|
||||
Reference in New Issue
Block a user