Files
rpcscope/rpcscope/runner.py
T
2026-10-15 12:34:26 -04:00

720 lines
37 KiB
Python

from __future__ import annotations
import ipaddress
import json
import math
import os
import re
import shutil
import signal
import subprocess
import tempfile
import threading
import time
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
from .catalog import CheckDefinition
from .checks import CheckResult, classify_msf, classify_nxc, index_messages
from .longlogon import probe_longlogon
from .models import CheckAttempt, HostEvidence
from .netexec import (NxcCapabilities, host_failures, inspect_capabilities, invocation_failed,
plan_batches, skip_reason, validate_literal_credentials)
from .parsers import load_enum4linux, parse_enum4linux, parse_nxc, parse_rpcdump
from .progress import ProgressEvent
from .reporting import coverage
from .storage import atomic_text, prepare_output, redact_text, write_sanitized_json
from .targets import discover_services, expand_targets
@dataclass
class ScanConfig:
targets: list[str]
username: str = ""
password: str = field(default="", repr=False)
domain: str = ""
shares: bool = False
lhost: str | None = None
jobs: int = 8
discovery_workers: int = 256
timeout: int = 120
discovery_timeout: int = 600
tcp_discovery: bool = False
batch_checks: bool = False # CLI uses batches; preserve isolated library behavior.
connect_timeout: float = 1.0
max_hosts: int = 4096
use_enum4linux: bool = True
use_rpcdump: bool = True
use_msf: bool = False
checks: list[CheckDefinition] = field(default_factory=list)
dry_run: bool = False
out_dir: Path = Path("rpcscope-output")
commands: list[list[str]] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
cancelled: bool = False
def _which(*names: str) -> str | None:
return next((found for name in names if (found := shutil.which(name))), None)
def doctor() -> dict[str, str | None]:
return {
"nxc": _which("nxc", "netexec"),
"enum4linux-ng": _which("enum4linux-ng", "enum4linux-ng.py"),
"rpcdump": _which("impacket-rpcdump", "rpcdump.py", "rpcdump"),
"msfconsole": _which("msfconsole"),
}
def _redacted(command: list[str], password: str) -> list[str]:
redacted = [redact_text(item, password) for item in command]
if "-x" in command:
index = command.index("-x") + 1
if index < len(redacted):
redacted[index] = "<redacted Metasploit resource script>"
return redacted
def _terminate(process: subprocess.Popen) -> None:
"""Stop the collector and its children, including inherited log handles."""
if os.name == "posix":
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
elif process.poll() is None:
try:
subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=5, check=False)
except (OSError, subprocess.TimeoutExpired):
process.kill()
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
def _run(command: list[str], timeout: int, log: Path, password: str,
cancel: threading.Event | None = None) -> tuple[int, str]:
"""Spool output to disk; avoid pipe deadlocks and retain timed-out output."""
with tempfile.TemporaryFile() as capture:
limit = 16 * 1024 * 1024
kwargs = {"start_new_session": True} if os.name == "posix" else {
"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP,
}
try:
process = subprocess.Popen(command, stdout=capture, stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
env={**os.environ, "NO_COLOR": "1", "COLUMNS": "512", "PYTHONUNBUFFERED": "1"},
**kwargs)
except OSError as exc:
text = f"Could not start collector: {exc}\n"
text = redact_text(text, password)
atomic_text(log, text)
return 127, text
deadline = time.monotonic() + timeout
reason = ""
try:
while process.poll() is None:
if cancel and cancel.is_set():
reason = "RPCScope collection cancelled"
break
if os.fstat(capture.fileno()).st_size > limit:
reason = "RPCScope output limit (16 MiB) reached"
break
remaining = deadline - time.monotonic()
if remaining <= 0:
reason = f"RPCScope timeout after {timeout}s"
break
try:
process.wait(timeout=min(0.2, remaining))
except subprocess.TimeoutExpired:
pass
except KeyboardInterrupt:
if cancel:
cancel.set()
reason = "RPCScope collection cancelled"
except BaseException:
_terminate(process)
raise
if reason:
_terminate(process)
capture.seek(0)
data = capture.read(limit + 1)
rc = process.returncode or 0
if len(data) > limit:
data = data[:limit]
if "output limit" not in reason:
reason += "\nRPCScope output limit (16 MiB) reached"
rc = 125
if reason:
rc = 130 if cancel and cancel.is_set() else (rc if rc == 125 else 124)
text = data.decode("utf-8", errors="replace").replace("\r\n", "\n").replace("\r", "\n")
if reason:
text += "\n" + reason.strip() + "\n"
saved = redact_text(text, password)
atomic_text(log, saved)
return rc, text
def normalize_credentials(config: ScanConfig) -> None:
for separator in ("\\", "/"):
if separator in config.username:
domain, username = config.username.split(separator, 1)
if config.domain and config.domain.casefold() != domain.casefold():
raise ValueError("domain conflicts with the domain in the username")
config.domain, config.username = domain, username
break
if config.password and not config.username:
raise ValueError("a password requires a username")
if any(ord(char) < 32 for char in config.username + config.domain + config.password):
raise ValueError("credentials cannot contain control characters")
def _nxc_auth(config: ScanConfig) -> list[str]:
# Equals form preserves empty credentials and passwords beginning with '-'.
result = [f"-u={config.username}", f"-p={config.password}"]
if config.domain:
result.extend(["-d", config.domain])
return result
def _msf_command(tool: str, address: str, config: ScanConfig) -> list[str]:
def quote(value: str) -> str:
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
settings = ["use auxiliary/scanner/smb/smb_ms17_010", f"set RHOSTS {address}"]
if config.username:
settings.extend([f"set SMBUser {quote(config.username)}", f"set SMBPass {quote(config.password)}"])
if config.domain:
settings.append(f"set SMBDomain {quote(config.domain)}")
settings.extend(["run", "exit -y"])
return [tool, "-q", "-x", "; ".join(settings)]
def _record_check(host: HostEvidence, key: str, outcome: CheckResult, source: Path,
backend: str, password: str) -> None:
detail = redact_text(outcome.evidence, password)
saved = str(source) if source.exists() else ""
host.merge(HostEvidence(host.address, checks={key: outcome.status}, sources=[saved] if saved else [],
check_details={key: detail},
check_attempts=[CheckAttempt(key, backend, outcome.status, detail, saved)]))
def scan(config: ScanConfig, *, progress: Callable[[ProgressEvent], None] | None = None,
checkpoint: Callable[[dict[str, HostEvidence]], None] | None = None) -> dict[str, HostEvidence]:
for value in (config.jobs, config.discovery_workers, config.timeout, config.discovery_timeout, config.max_hosts):
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
raise ValueError("workers, jobs, execution timeouts and host limits must be positive integers")
if (isinstance(config.connect_timeout, bool) or not isinstance(config.connect_timeout, (int, float))
or not math.isfinite(config.connect_timeout) or config.connect_timeout <= 0):
raise ValueError("connection timeout must be positive and finite")
normalize_credentials(config)
# Public API callers may supply duplicate definitions; account/execute once.
unique_checks = {}
for definition in config.checks:
if definition.key in unique_checks and unique_checks[definition.key] != definition:
raise ValueError(f"conflicting definitions for check {definition.key}")
unique_checks[definition.key] = definition
config.checks = list(unique_checks.values())
tools = doctor()
nxc = str(tools["nxc"] or "nxc")
discovery = [nxc, "smb", *config.targets, *_nxc_auth(config), "-t", str(config.discovery_workers), "--no-progress"]
if config.shares:
discovery.extend(["--shares", "<version-specific-read-only-options>"])
if config.dry_run:
if config.tcp_discovery:
config.commands.append(_redacted(["<native:tcp-discovery>", *config.targets, "--ports", "135,445",
"--workers", str(config.discovery_workers), "--connect-timeout", str(config.connect_timeout),
"--max-hosts", str(config.max_hosts)], config.password))
discovery = [nxc, "smb", "<TCP/445-listeners>", *_nxc_auth(config), "-t", str(config.discovery_workers), "--no-progress",
*(["--shares", "<version-specific-read-only-options>"] if config.shares else [])]
config.commands.append(_redacted(discovery, config.password))
if config.dry_run:
address = "<discovered-host>"
if config.batch_checks:
dummy = HostEvidence("192.0.2.255", services={"135": "open", "445": "open"})
batches, _ = plan_batches(config.checks, {dummy.address: dummy}, jobs=config.jobs,
tcp_discovery=True, has_nxc=True, username=config.username)
for batch in batches:
config.commands.append(_redacted([nxc, "smb", "<live-host-batch>", *_nxc_auth(config),
"-t", str(config.jobs), "--no-progress", "-M", batch.module,
*(["-o", *batch.options] if batch.options else [])], config.password))
for definition in config.checks:
if definition.enabled and definition.backend == "native":
config.commands.append(["<native:longlogon>", address, "UDP/389", "EX-control+guarded-minimal-legacy"])
elif not config.batch_checks and definition.enabled and (not definition.requires_credentials or config.username):
config.commands.append(_redacted([nxc, "smb", address, *_nxc_auth(config),
"-M", definition.nxc_module,
*(["-o", *definition.nxc_options] if definition.nxc_options else [])], config.password))
if config.use_msf:
config.commands.append([str(tools["msfconsole"] or "msfconsole"), "-q", "-x",
"<MS17-010 scanner resource script; credentials redacted>"])
if config.use_rpcdump:
config.commands.append([str(tools["rpcdump"] or "impacket-rpcdump"), "-port", "135", address])
if config.use_enum4linux:
config.commands.append(_redacted([
str(tools["enum4linux-ng"] or "enum4linux-ng"), "-A", f"-u={config.username}",
f"-p={config.password}", *(["-w", config.domain] if config.domain else []),
"-oJ", "<private-temp-prefix>", address,
], config.password))
return {}
if not tools["nxc"] and not config.tcp_discovery:
raise RuntimeError("NetExec was not found (expected nxc or netexec in PATH)")
if tools["nxc"]:
validate_literal_credentials(config.username, config.password)
scope = expand_targets(config.targets, max_hosts=config.max_hosts) if config.tcp_discovery else []
prepare_output(config.out_dir)
raw = config.out_dir / "raw"
raw.mkdir(mode=0o700)
cancel = threading.Event()
lock = threading.Lock()
total = len(scope) + 1 if config.tcp_discovery else 1
completed = 0
def emit(kind: str, stage: str, host: str = "", status: str = "", detail: str = "", duration: float = 0.0) -> None:
nonlocal completed
with lock:
if kind in {"finish", "advance"}:
completed += 1
if progress:
progress(ProgressEvent(kind, stage, host, status, completed, total, detail, duration))
def error(message: str) -> None:
with lock:
message = redact_text(message, config.password)
if message not in config.errors:
config.errors.append(message)
capabilities: NxcCapabilities | None = None
def nxc_command(addresses, threads: int, *, shares: bool = False) -> list[str]:
nonlocal capabilities
if capabilities is None:
capabilities = inspect_capabilities(nxc) if tools["nxc"] else NxcCapabilities()
command = [nxc, "smb", *addresses, *_nxc_auth(config), "-t", str(threads), "--no-progress",
*capabilities.performance_options()]
if shares:
share_options = capabilities.readonly_shares()
if share_options is None:
error("Share enumeration skipped: installed NetExec help does not establish read-only share-check support")
else:
command.extend(share_options)
return command
def run(stage: str, address: str, command: list[str], path: Path, timeout: int) -> tuple[int, str]:
emit("start", stage, address)
with lock:
if command != discovery:
config.commands.append(_redacted(command, config.password))
code, text = _run(command, timeout, path, config.password, cancel=cancel)
if code:
error(f"{stage}{' for ' + address if address else ''} exited with status {code}; see {path.name}")
return code, text
discovery_start = time.monotonic()
if config.tcp_discovery:
hosts = {address: HostEvidence(address, services={"135": "unprobed", "445": "unprobed"})
for address in scope}
emit("start", "tcp-discovery", detail=f"{len(scope)} target(s); TCP/135 and TCP/445; {config.discovery_workers} workers")
def discovered(address: str, services: dict[int, str]) -> None:
hosts[address].services.update({str(port): state for port, state in services.items()})
if "open" in services.values():
emit("finish", "tcp-discovery", address, "alive",
detail=" ".join(f"{port}:{state}" for port, state in services.items()))
else:
emit("advance", "tcp-discovery")
discovery_failed = False
try:
discover_services(scope, jobs=config.discovery_workers, timeout=config.connect_timeout,
cancel=cancel, on_result=discovered,
deadline=discovery_start + config.discovery_timeout)
except KeyboardInterrupt:
cancel.set()
except (OSError, RuntimeError) as exc:
discovery_failed = True
error(f"TCP discovery failed locally: {exc}")
# NetExec discovery gets only SMB listeners. RPC-only hosts stay in scope.
smb_targets = [address for address, host in hosts.items() if host.services.get("445") == "open"]
rc = 1 if discovery_failed else 0
if (any("unprobed" in host.services.values() for host in hosts.values())
and not cancel.is_set() and not discovery_failed):
error("TCP discovery deadline reached; unprobed targets remain incomplete")
rc = 124
emit("summary", "tcp-discovery", status="complete" if rc == 0 else "incomplete",
detail=f"{sum('open' in host.services.values() for host in hosts.values())} alive; {len(smb_targets)} SMB listener(s)")
emit("start", "discovery", detail=f"{len(smb_targets)} SMB listener(s)")
if smb_targets and not tools["nxc"]:
error("NetExec was not found; SMB collection/checks are incomplete")
rc = 127
for address in smb_targets:
hosts[address].collection["discovery"] = "missing"
elif smb_targets and not cancel.is_set():
for index in range(0, len(smb_targets), 128):
batch = smb_targets[index:index + 128]
command = nxc_command(batch, config.discovery_workers, shares=config.shares)
remaining = config.discovery_timeout - int(time.monotonic() - discovery_start)
if remaining <= 0:
error("Discovery timeout reached; partial host inventory retained")
rc = 124
break
path = raw / f"nxc-{index // 128 + 1:03d}.txt"
# One discovery stage may have several command-line-sized batches.
config.commands.append(_redacted(command, config.password))
code, output = _run(command, remaining, path, config.password, cancel=cancel)
if not code and invocation_failed(output, tuple(batch)):
code = 1
error(f"SMB discovery invocation reported an error; see {path.name}")
rc = code or rc
failed_hosts = host_failures(output, tuple(batch))
for address in batch:
hosts[address].collection["discovery"] = "success" if code == 0 and address not in failed_hosts else "failed"
if address in failed_hosts:
error(f"NetExec caught an exception for {address} during discovery; see {path.name}")
rc = rc or 1
for address, evidence in parse_nxc(output, str(path)).items():
if address in batch:
hosts[address].merge(evidence)
if code:
error(f"SMB discovery batch exited with status {code}; see {path.name}")
if cancel.is_set():
break
for host in hosts.values():
host.collection["tcp-discovery"] = (
"success" if "unprobed" not in host.services.values()
else "incomplete"
)
if "open" in host.services.values():
host.collection.setdefault("discovery", "pending" if host.services.get("445") == "open"
else "skipped-service-unavailable")
else:
discovery = nxc_command(config.targets, config.discovery_workers, shares=config.shares)
config.commands.append(_redacted(discovery, config.password))
rc, output = run("discovery", "", discovery, raw / "nxc.txt", config.discovery_timeout)
parsed_discovery = parse_nxc(output, str(raw / "nxc.txt"))
if not rc and invocation_failed(output, tuple(parsed_discovery)):
rc = 1
error("SMB discovery invocation reported an error; see nxc.txt")
hosts = {address: host for address, host in parsed_discovery.items()
if "open" in host.services.values()}
failed_hosts = host_failures(output, tuple(hosts))
for address, host in hosts.items():
host.collection["discovery"] = "success" if rc == 0 and address not in failed_hosts else "failed"
if address in failed_hosts:
error(f"NetExec caught an exception for {address} during discovery; see nxc.txt")
active_hosts = {address: host for address, host in hosts.items()
if not config.tcp_discovery or "open" in host.services.values()}
for host in active_hosts.values():
if config.shares and capabilities is not None and capabilities.readonly_shares() is None:
host.collection["shares"] = "skipped-unsupported"
host.notes.append("NetExec share enumeration was skipped because a read-only mode could not be verified.")
if config.use_enum4linux:
host.collection["enum4linux"] = "pending"
if config.use_rpcdump:
host.collection["rpcdump"] = "pending"
if config.use_msf:
host.collection["msf-ms17-010"] = "pending"
host.checks.update({definition.key: "pending" for definition in config.checks})
if config.use_msf:
host.checks.setdefault("ms17-010", "pending")
per_host = int(config.use_enum4linux) + int(config.use_rpcdump) + len(config.checks) + int(config.use_msf)
total += len(active_hosts) * per_host
emit("finish", "discovery", status="success" if rc == 0 else "failed",
detail=f"{len(active_hosts)} alive; {len(hosts) - len(active_hosts)} unreachable omitted",
duration=time.monotonic() - discovery_start)
if checkpoint:
checkpoint(hosts)
if cancel.is_set():
config.cancelled = True
error("Scan interrupted during discovery; partial evidence was retained")
for host in hosts.values():
host.checks = {key: "skipped-cancelled" for key in host.checks}
host.collection = {key: "skipped-cancelled" if status == "pending" else status
for key, status in host.collection.items()}
if checkpoint:
checkpoint(hosts)
emit("summary", "scan", status="interrupted", detail=f"{len(hosts)} partial target(s)")
return hosts
if not active_hosts:
emit("summary", "scan", status="incomplete" if rc else "complete", detail="no reachable SMB/RPC hosts observed")
return hosts
smb_available = not config.tcp_discovery or any(host.services.get("445") == "open" for host in hosts.values())
rpc_available = not config.tcp_discovery or any(host.services.get("135") == "open" for host in hosts.values())
for tool, enabled in [("enum4linux-ng", config.use_enum4linux and smb_available),
("rpcdump", config.use_rpcdump and rpc_available),
("msfconsole", config.use_msf and smb_available)]:
if enabled and not tools[tool]:
error(f"{tool} was requested but not found")
if config.batch_checks:
batches, skipped = plan_batches(config.checks, active_hosts, jobs=config.jobs,
tcp_discovery=config.tcp_discovery, has_nxc=bool(tools["nxc"]), username=config.username)
for address, definition, outcome in skipped:
host = hosts[address]
_record_check(host, definition.key, outcome, raw / "not-run", "netexec", config.password)
host.collection[f"check:{definition.key}"] = outcome.status
emit("finish", definition.key, address, outcome.status)
try:
for number, batch in enumerate(batches, 1):
if cancel.is_set():
break
started = time.monotonic()
path = raw / f"batch-{number:04d}-{batch.module}.txt"
command = [*nxc_command(batch.targets, min(config.jobs, len(batch.targets))), "-M", batch.module]
if batch.options:
command.extend(["-o", *batch.options])
code, text = run(f"batch:{batch.module}", "", command, path, config.timeout * len(batch.checks))
if not code and invocation_failed(text, batch.targets):
code = 1
error(f"NetExec invocation reported an error in {batch.module}; see {path.name}")
indexed = index_messages(text)
failed_hosts = host_failures(text, batch.targets)
for address, evidence in parse_nxc(text, str(path)).items():
if address in batch.targets:
hosts[address].merge(evidence)
for address in batch.targets:
host = hosts[address]
effective_code = code or (1 if address in failed_hosts else 0)
if address in failed_hosts:
error(f"NetExec caught an exception for {address} in {batch.module}; see {path.name}")
for definition in batch.checks:
outcome = classify_nxc(definition.key, address, "", effective_code, index=indexed)
_record_check(host, definition.key, outcome, path, "netexec", config.password)
host.collection[f"check:{definition.key}"] = "failed" if effective_code else "success"
emit("finish", definition.key, address, outcome.status, duration=time.monotonic() - started)
emit("summary", "check-batch", status="failed" if code or failed_hosts else "complete",
detail=f"{batch.module}; {len(batch.targets)} alive host(s)")
if checkpoint:
checkpoint(hosts)
except KeyboardInterrupt:
cancel.set()
if cancel.is_set():
config.cancelled = True
error("Scan interrupted; completed batch evidence was retained")
for host in hosts.values():
host.checks = {key: "skipped-cancelled" if status == "pending" else status
for key, status in host.checks.items()}
host.collection = {key: "skipped-cancelled" if status == "pending" else status
for key, status in host.collection.items()}
if checkpoint:
checkpoint(hosts)
emit("summary", "scan", status="interrupted", detail="completed batch evidence retained")
return hosts
def collect(address: str) -> HostEvidence:
result = HostEvidence(address)
safe = address.replace(":", "_")
def unavailable(ports: tuple[int, ...]) -> bool:
return config.tcp_discovery and any(hosts[address].services.get(str(port)) != "open" for port in ports)
def collector(stage: str, tool: str, action: Callable[[], str], ports: tuple[int, ...]) -> None:
if cancel.is_set():
status = "skipped-cancelled"
elif unavailable(ports):
status = "skipped-service-unavailable"
elif not tools[tool]:
status = "missing"
else:
started = time.monotonic()
try:
status = action()
except (OSError, ValueError, TypeError) as exc:
status = "failed"
error(f"{stage} for {address}: {exc}")
result.collection[stage] = status
emit("finish", stage, address, status, duration=time.monotonic() - started)
return
result.collection[stage] = status
emit("finish", stage, address, status)
def enum_action() -> str:
destination = raw / f"enum4linux-{safe}.json"
with tempfile.TemporaryDirectory(prefix=".enum-", dir=raw) as temporary:
prefix = Path(temporary) / "result"
command = [str(tools["enum4linux-ng"]), "-A", f"-u={config.username}",
f"-p={config.password}", "-t", str(min(config.timeout, 60)),
"-oJ", str(prefix), address]
if config.domain:
command.extend(["-w", config.domain])
code, _ = run("enum4linux", address, command, raw / f"enum4linux-{safe}.log", config.timeout)
exported = Path(str(prefix) + ".json")
if not exported.exists():
if code == 0:
error(f"enum4linux for {address} produced no JSON export")
return "failed"
data = json.loads(exported.read_text(encoding="utf-8"))
write_sanitized_json(destination, data, config.password)
result.merge(parse_enum4linux(data, str(destination)))
return "success" if code == 0 else "failed"
def rpc_action() -> str:
path = raw / f"rpcdump-{safe}.txt"
# TCP endpoint mapper does not authenticate. Omitting credentials
# also prevents rpcdump from prompting for a legitimate blank password.
command = [str(tools["rpcdump"]), "-port", "135", address]
code, text = run("rpcdump", address, command, path, config.timeout)
result.merge(parse_rpcdump(text, address, str(path)))
return "success" if code == 0 else "failed"
def store_check(key: str, outcome: CheckResult, source: Path, backend: str = "netexec") -> None:
_record_check(result, key, outcome, source, backend, config.password)
for definition in config.checks:
if config.batch_checks and definition.backend == "netexec":
continue
started = time.monotonic()
path = raw / f"check-{definition.key}-{safe}.txt"
execution = "success"
if cancel.is_set():
outcome = CheckResult("skipped-cancelled", "scan interrupted")
elif (reason := skip_reason(definition, hosts[address], tcp_discovery=config.tcp_discovery,
has_nxc=bool(tools["nxc"]), username=config.username)):
outcome = reason
elif definition.backend == "native":
emit("start", definition.key, address)
with lock:
config.commands.append(["<native:longlogon>", address, "UDP/389", "EX-control+guarded-minimal-legacy"])
outcome = probe_longlogon(address, timeout=min(3.0, config.timeout / 2), cancel=cancel)
atomic_text(path, redact_text(f"{outcome.status}\n{outcome.evidence}\n", config.password))
else:
command = [*nxc_command([address], 1), "-M", definition.nxc_module]
if definition.nxc_options:
command.extend(["-o", *definition.nxc_options])
try:
code, text = run(definition.key, address, command, path, config.timeout)
if not code and (invocation_failed(text, (address,)) or host_failures(text, (address,))):
code = 1
error(f"NetExec reported an error for {address} in {definition.key}; see {path.name}")
outcome = classify_nxc(definition.key, address, text, code)
execution = "success" if code == 0 else "failed"
except (OSError, ValueError) as exc:
error(f"{definition.key} for {address}: {exc}")
outcome = CheckResult("inconclusive", "checker failed; inspect collection notes")
execution = "failed"
store_check(definition.key, outcome, path, backend=definition.backend)
result.collection[f"check:{definition.key}"] = outcome.status if outcome.status.startswith("skipped-") else execution
emit("finish", definition.key, address, outcome.status, duration=time.monotonic() - started)
if config.use_msf:
started = time.monotonic()
path = raw / f"msf-ms17-010-{safe}.txt"
execution = "success"
if cancel.is_set():
outcome = CheckResult("skipped-cancelled", "scan interrupted")
elif unavailable((445,)):
outcome = CheckResult("skipped-service-unavailable", "TCP/445 was not reachable")
elif not tools["msfconsole"]:
outcome = CheckResult("skipped-missing-tool", "msfconsole is not installed")
elif ";" in config.username + config.password + config.domain:
outcome = CheckResult("skipped-unsupported", "credentials contain a Metasploit command separator")
else:
try:
code, text = run("msf-ms17-010", address,
_msf_command(str(tools["msfconsole"]), address, config), path, config.timeout)
outcome = classify_msf(address, text, code)
execution = "success" if code == 0 else "failed"
except (OSError, ValueError) as exc:
error(f"msf-ms17-010 for {address}: {exc}")
outcome = CheckResult("inconclusive", "checker failed; inspect collection notes")
execution = "failed"
store_check("ms17-010", outcome, path, backend="metasploit")
result.collection["msf-ms17-010"] = outcome.status if outcome.status.startswith("skipped-") else execution
emit("finish", "msf-ms17-010", address, outcome.status, duration=time.monotonic() - started)
if config.use_rpcdump:
collector("rpcdump", "rpcdump", rpc_action, (135,))
if config.use_enum4linux:
collector("enum4linux", "enum4linux-ng", enum_action, (445,))
return result
pool = ThreadPoolExecutor(max_workers=config.jobs)
addresses = iter(active_hosts)
pending = {}
try:
for _ in range(min(config.jobs, len(active_hosts))):
address = next(addresses)
pending[pool.submit(collect, address)] = address
while pending:
done, _ = wait(pending, timeout=0.2, return_when=FIRST_COMPLETED)
for future in done:
address = pending.pop(future)
try:
hosts[address].merge(future.result())
except Exception as exc:
hosts[address].notes.append("host collection failed; remaining results are incomplete")
hosts[address].collection["host"] = "failed"
hosts[address].checks = {key: "inconclusive" if status == "pending" else status
for key, status in hosts[address].checks.items()}
error(f"collection error for {address}: {exc}")
if checkpoint:
checkpoint(hosts)
next_address = next(addresses, None)
if next_address is not None:
pending[pool.submit(collect, next_address)] = next_address
except KeyboardInterrupt:
config.cancelled = True
cancel.set()
error("Scan interrupted; completed evidence was retained")
for future in pending:
future.cancel()
except BaseException:
cancel.set()
for future in pending:
future.cancel()
raise
finally:
pool.shutdown(wait=True, cancel_futures=True)
if config.cancelled:
for future, address in pending.items():
if not future.cancelled() and future.done():
try:
hosts[address].merge(future.result())
except Exception:
pass
for host in hosts.values():
host.checks = {key: "skipped-cancelled" if status == "pending" else status
for key, status in host.checks.items()}
host.collection = {key: "skipped-cancelled" if status == "pending" else status
for key, status in host.collection.items()}
if checkpoint:
checkpoint(hosts)
incomplete = config.errors or any(coverage(host)[2] for host in hosts.values())
emit("summary", "scan", status="interrupted" if config.cancelled else ("incomplete" if incomplete else "complete"),
detail=f"{len(active_hosts)} alive; {len(hosts) - len(active_hosts)} unreachable omitted; {len(config.errors)} collection note(s)")
return hosts
def analyze_artifacts(nxc: Path | None, rpcdump_dir: Path | None,
enum4linux_dir: Path | None, errors: list[str] | None = None) -> dict[str, HostEvidence]:
hosts: dict[str, HostEvidence] = {}
notes = errors if errors is not None else []
if nxc:
hosts.update(parse_nxc(nxc.read_text(encoding="utf-8", errors="replace"), str(nxc)))
if enum4linux_dir:
for path in sorted(enum4linux_dir.glob("*.json")):
try:
evidence = load_enum4linux(path)
if evidence.address == "unknown":
raise ValueError("missing target identity")
hosts.setdefault(evidence.address, HostEvidence(evidence.address)).merge(evidence)
except (OSError, ValueError, TypeError) as exc:
notes.append(f"{path.name}: {exc}")
if rpcdump_dir:
for path in sorted(rpcdump_dir.glob("*.txt")):
match = re.search(r"(?<![\d.])(\d{1,3}(?:\.\d{1,3}){3})(?![\d.])", path.stem)
if not match:
notes.append(f"{path.name}: no target IP in filename")
continue
try:
address = str(ipaddress.ip_address(match.group(1)))
evidence = parse_rpcdump(path.read_text(encoding="utf-8", errors="replace"), address, str(path))
hosts.setdefault(address, HostEvidence(address)).merge(evidence)
except (OSError, ValueError) as exc:
notes.append(f"{path.name}: {exc}")
return hosts