119b3e2150
Multi-provider (Linode/AWS/FlokiNET) scan orchestration: - Flatten all country CIDRs, chunk by IP count (sprint/balanced/economy presets) - Assign chunks round-robin across providers - Cost+time estimate table before deploy - Ansible provisions N nodes in parallel, runs masscan/nmap/probe per node - Results fetched back and merged into unified JSON report - Scanner IPs logged per engagement for client IR reporting - Operator IP whitelisting, enhanced OPSEC mode, YAML targets.yaml support
293 lines
8.9 KiB
Python
293 lines
8.9 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
WEBRUNNER node scanner — runs on each cloud node.
|
|
Reads cidrs.txt, runs scan pipeline, writes results.json.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path
|
|
|
|
WORKDIR = Path("/root/webrunner")
|
|
|
|
|
|
def log(msg: str):
|
|
ts = time.strftime("%H:%M:%S")
|
|
print(f"[{ts}] {msg}", flush=True)
|
|
|
|
|
|
def run_masscan(ports: str, rate: int) -> list[dict]:
|
|
cidr_file = WORKDIR / "cidrs.txt"
|
|
out_file = WORKDIR / "masscan.json"
|
|
out_file.unlink(missing_ok=True)
|
|
|
|
cmd = [
|
|
"masscan",
|
|
f"--rate={rate}",
|
|
f"--ports={ports}",
|
|
"-iL", str(cidr_file),
|
|
"-oJ", str(out_file),
|
|
"--wait", "3",
|
|
]
|
|
log(f"masscan starting: rate={rate} ports={ports}")
|
|
try:
|
|
subprocess.run(cmd, timeout=36000, check=False)
|
|
except subprocess.TimeoutExpired:
|
|
log("masscan timed out after 10h")
|
|
|
|
if not out_file.exists():
|
|
return []
|
|
|
|
raw = out_file.read_text(errors="replace").strip()
|
|
if not raw or raw == "[]":
|
|
return []
|
|
raw = raw.rstrip(",\n")
|
|
if not raw.endswith("]"):
|
|
raw += "]"
|
|
if not raw.startswith("["):
|
|
raw = "[" + raw
|
|
try:
|
|
data = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
log("masscan JSON parse error")
|
|
return []
|
|
|
|
hits = []
|
|
for entry in data:
|
|
ip = entry.get("ip")
|
|
for pe in entry.get("ports", []):
|
|
port = pe.get("port")
|
|
if ip and port:
|
|
hits.append({"ip": ip, "port": port})
|
|
log(f"masscan found {len(hits)} open port/host pairs")
|
|
return hits
|
|
|
|
|
|
def group_hits_by_ip(hits: list[dict]) -> dict[str, list[int]]:
|
|
result: dict[str, list[int]] = {}
|
|
for h in hits:
|
|
result.setdefault(h["ip"], []).append(h["port"])
|
|
return result
|
|
|
|
|
|
def run_nmap(ip: str, ports: list[int]) -> dict:
|
|
port_str = ",".join(str(p) for p in sorted(set(ports)))
|
|
out_file = WORKDIR / f"nmap_{ip.replace('.', '_')}.xml"
|
|
|
|
cmd = [
|
|
"nmap", "-sV", "--version-intensity", "5",
|
|
"-p", port_str, "-T4", "--open",
|
|
"-oX", str(out_file), ip,
|
|
]
|
|
try:
|
|
subprocess.run(cmd, capture_output=True, timeout=60, check=False)
|
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
return {}
|
|
|
|
if not out_file.exists():
|
|
return {}
|
|
try:
|
|
tree = ET.parse(out_file)
|
|
except ET.ParseError:
|
|
return {}
|
|
|
|
result = {}
|
|
for port_el in tree.findall(".//port"):
|
|
portid = int(port_el.get("portid", 0))
|
|
state = port_el.find("state")
|
|
if state is None or state.get("state") != "open":
|
|
continue
|
|
svc = port_el.find("service")
|
|
info: dict = {}
|
|
if svc is not None:
|
|
info["service"] = svc.get("name", "")
|
|
info["product"] = svc.get("product", "")
|
|
info["version"] = svc.get("version", "")
|
|
info["banner"] = " ".join(filter(None, [
|
|
svc.get("product", ""), svc.get("version", ""), svc.get("extrainfo", "")
|
|
]))
|
|
result[portid] = info
|
|
return result
|
|
|
|
|
|
def probe_service(ip: str, port: int) -> str:
|
|
import socket
|
|
try:
|
|
with socket.create_connection((ip, port), timeout=3) as s:
|
|
s.settimeout(3)
|
|
try:
|
|
s.send(b"HEAD / HTTP/1.0\r\nHost: %b\r\n\r\n" % ip.encode())
|
|
banner = s.recv(512).decode(errors="replace").strip()
|
|
return banner[:200]
|
|
except Exception:
|
|
try:
|
|
banner = s.recv(512).decode(errors="replace").strip()
|
|
return banner[:200]
|
|
except Exception:
|
|
return ""
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def scan_masscan_only(ports: str, rate: int, node_name: str) -> list[dict]:
|
|
hits = run_masscan(ports, rate)
|
|
results = [{"ip": h["ip"], "port": h["port"]} for h in hits]
|
|
return results
|
|
|
|
|
|
def scan_nmap_only(ports: str, node_name: str) -> list[dict]:
|
|
cidr_file = WORKDIR / "cidrs.txt"
|
|
cidrs = [l.strip() for l in cidr_file.read_text().splitlines() if l.strip()]
|
|
port_str = ports
|
|
out_file = WORKDIR / "nmap_sweep.xml"
|
|
|
|
targets_arg = " ".join(cidrs[:50]) # nmap handles CIDRs natively
|
|
cmd = [
|
|
"nmap", "-sV", "--version-intensity", "3",
|
|
"-p", port_str, "-T4", "--open",
|
|
"-oX", str(out_file),
|
|
] + cidrs
|
|
|
|
log(f"nmap starting across {len(cidrs)} CIDRs")
|
|
try:
|
|
subprocess.run(cmd, timeout=36000, check=False)
|
|
except subprocess.TimeoutExpired:
|
|
log("nmap timed out")
|
|
|
|
if not out_file.exists():
|
|
return []
|
|
|
|
results = []
|
|
try:
|
|
tree = ET.parse(out_file)
|
|
except ET.ParseError:
|
|
return []
|
|
|
|
for host_el in tree.findall(".//host"):
|
|
addr_el = host_el.find("address[@addrtype='ipv4']")
|
|
if addr_el is None:
|
|
continue
|
|
ip = addr_el.get("addr", "")
|
|
for port_el in host_el.findall(".//port"):
|
|
state = port_el.find("state")
|
|
if state is None or state.get("state") != "open":
|
|
continue
|
|
portid = int(port_el.get("portid", 0))
|
|
svc = port_el.find("service")
|
|
entry: dict = {"ip": ip, "port": portid}
|
|
if svc is not None:
|
|
entry["service"] = svc.get("name", "")
|
|
entry["banner"] = " ".join(filter(None, [
|
|
svc.get("product", ""), svc.get("version", ""), svc.get("extrainfo", "")
|
|
]))
|
|
results.append(entry)
|
|
|
|
log(f"nmap found {len(results)} open ports")
|
|
return results
|
|
|
|
|
|
def scan_masscan_nmap(ports: str, rate: int, node_name: str) -> list[dict]:
|
|
hits = run_masscan(ports, rate)
|
|
if not hits:
|
|
return []
|
|
|
|
by_ip = group_hits_by_ip(hits)
|
|
log(f"nmap fingerprinting {len(by_ip)} hosts...")
|
|
results = []
|
|
for idx, (ip, ip_ports) in enumerate(by_ip.items()):
|
|
nmap_info = run_nmap(ip, ip_ports)
|
|
for port in ip_ports:
|
|
entry: dict = {"ip": ip, "port": port}
|
|
if port in nmap_info:
|
|
entry.update(nmap_info[port])
|
|
results.append(entry)
|
|
if (idx + 1) % 50 == 0:
|
|
log(f" nmap: {idx + 1}/{len(by_ip)} hosts done")
|
|
return results
|
|
|
|
|
|
def scan_geo_scout(ports: str, rate: int, node_name: str) -> list[dict]:
|
|
hits = run_masscan(ports, rate)
|
|
if not hits:
|
|
return []
|
|
|
|
by_ip = group_hits_by_ip(hits)
|
|
log(f"nmap + probe fingerprinting {len(by_ip)} hosts...")
|
|
|
|
targets_file = WORKDIR / "targets.yaml"
|
|
target_ports: dict[int, dict] = {}
|
|
if targets_file.exists():
|
|
try:
|
|
import yaml
|
|
with open(targets_file) as f:
|
|
tdata = yaml.safe_load(f)
|
|
for t in tdata.get("targets", []):
|
|
for p in t.get("ports", []):
|
|
target_ports[p] = t
|
|
except Exception:
|
|
pass
|
|
|
|
results = []
|
|
for idx, (ip, ip_ports) in enumerate(by_ip.items()):
|
|
nmap_info = run_nmap(ip, ip_ports)
|
|
for port in ip_ports:
|
|
entry: dict = {"ip": ip, "port": port}
|
|
if port in nmap_info:
|
|
entry.update(nmap_info[port])
|
|
# Banner grab / probe
|
|
banner = probe_service(ip, port)
|
|
if banner:
|
|
entry["probe_banner"] = banner
|
|
# Match against target profile
|
|
if port in target_ports:
|
|
t = target_ports[port]
|
|
entry["target_name"] = t.get("name", "")
|
|
entry["target_desc"] = t.get("description", "")
|
|
results.append(entry)
|
|
if (idx + 1) % 50 == 0:
|
|
log(f" geo-scout: {idx + 1}/{len(by_ip)} hosts done")
|
|
return results
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--mode", required=True,
|
|
choices=["masscan-only", "nmap-only", "masscan+nmap", "geo-scout"])
|
|
parser.add_argument("--ports", required=True)
|
|
parser.add_argument("--rate", type=int, default=3000)
|
|
parser.add_argument("--node-name", default="node")
|
|
args = parser.parse_args()
|
|
|
|
log(f"WEBRUNNER node scanner starting — mode={args.mode} node={args.node_name}")
|
|
|
|
if args.mode == "masscan-only":
|
|
results = scan_masscan_only(args.ports, args.rate, args.node_name)
|
|
elif args.mode == "nmap-only":
|
|
results = scan_nmap_only(args.ports, args.node_name)
|
|
elif args.mode == "masscan+nmap":
|
|
results = scan_masscan_nmap(args.ports, args.rate, args.node_name)
|
|
elif args.mode == "geo-scout":
|
|
results = scan_geo_scout(args.ports, args.rate, args.node_name)
|
|
else:
|
|
log(f"Unknown mode: {args.mode}")
|
|
sys.exit(1)
|
|
|
|
out = {
|
|
"node_name": args.node_name,
|
|
"scan_mode": args.mode,
|
|
"result_count": len(results),
|
|
"results": results,
|
|
}
|
|
|
|
out_file = WORKDIR / "results.json"
|
|
out_file.write_text(json.dumps(out, indent=2))
|
|
log(f"Done — {len(results)} results written to {out_file}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|