Initial public portfolio release

Sanitized version of red team infrastructure automation platform.
Operational content (implant pipelines, lures, credential capture)
replaced with documented stubs. Architecture and infrastructure
automation code intact.
This commit is contained in:
Operator
2026-06-23 16:12:14 -04:00
commit 0799bfbae8
239 changed files with 40012 additions and 0 deletions
View File
+621
View File
@@ -0,0 +1,621 @@
#!/usr/bin/env python3
"""
WEBRUNNER — Distributed geo-targeted recon scanning module for c2itall
Provisions cloud nodes across multiple providers, distributes CIDR space,
runs masscan/nmap/geo-scout in parallel, collects and merges results.
"""
import json
import os
import sys
from pathlib import Path
_base = os.path.join(os.path.dirname(__file__), '..', '..')
if _base not in sys.path:
sys.path.insert(0, _base)
from utils.common import (
COLORS, clear_screen, print_banner, generate_deployment_id,
setup_logging, get_public_ip, confirm_action, wait_for_input,
)
from utils.provider_utils import gather_provider_config
from utils.ssh_utils import generate_ssh_key
from utils.naming_utils import show_naming_relationship
from utils.deployment_engine import execute_playbook, set_provider_environment
from utils.chunk_utils import get_country_cidrs, chunk_cidrs, ip_count
from utils.provider_rates import (
PRESETS, SCAN_MODES, DEFAULT_INSTANCE,
build_estimate_table, fmt_hours, fmt_ip_count,
)
WEBRUNNER_INPUTS = Path(__file__).parent / 'inputs'
SUPPORTED_PROVIDERS = ['linode', 'aws', 'flokinet']
PROVIDER_LABELS = {'linode': 'Linode', 'aws': 'AWS', 'flokinet': 'FlokiNET'}
# ── menu ──────────────────────────────────────────────────────────────────────
def webrunner_menu():
config = gather_webrunner_parameters()
if config:
execute_webrunner_deployment(config)
# ── helpers ───────────────────────────────────────────────────────────────────
def _select_providers() -> list[str]:
print(f"\n{COLORS['BLUE']}Provider Selection (multi-select):{COLORS['RESET']}")
for i, key in enumerate(SUPPORTED_PROVIDERS, 1):
print(f" {i}) {PROVIDER_LABELS[key]}")
raw = input("Select providers (e.g. 1 or 1,2) [1]: ").strip() or "1"
selected = []
for part in raw.split(','):
try:
idx = int(part.strip()) - 1
if 0 <= idx < len(SUPPORTED_PROVIDERS):
key = SUPPORTED_PROVIDERS[idx]
if key not in selected:
selected.append(key)
except ValueError:
pass
return selected or ['linode']
def _select_countries() -> tuple[list[str], dict[str, list]]:
print(f"\n{COLORS['BLUE']}Country Selection:{COLORS['RESET']}")
print(f"1) Use bundled countries.yaml")
print(f"2) Enter country codes manually")
choice = input("Select [1]: ").strip() or "1"
exclude_map: dict[str, list] = {}
if choice == "1":
default_path = str(WEBRUNNER_INPUTS / 'countries.yaml')
raw = input(f"Path [{default_path}]: ").strip()
path = Path(raw) if raw else WEBRUNNER_INPUTS / 'countries.yaml'
if not path.exists():
print(f"{COLORS['RED']}File not found: {path}{COLORS['RESET']}")
return [], {}
import yaml
with open(path) as f:
data = yaml.safe_load(f)
countries = data.get('countries', [])
codes = [c['code'].upper() for c in countries]
for c in countries:
if c.get('exclude_cidrs'):
exclude_map[c['code'].upper()] = c['exclude_cidrs']
return codes, exclude_map
raw = input("Country codes (comma-separated, e.g. RU,IR,CN): ").strip()
codes = [c.strip().upper() for c in raw.split(',') if c.strip()]
return codes, exclude_map
def _select_scan_mode() -> str:
print(f"\n{COLORS['BLUE']}Scan Mode:{COLORS['RESET']}")
modes = list(SCAN_MODES.items())
for i, (key, val) in enumerate(modes, 1):
print(f" {i}) {key:<20} {val['desc']}")
choice = input("Select [1]: ").strip() or "1"
try:
idx = int(choice) - 1
if 0 <= idx < len(modes):
return modes[idx][0]
except ValueError:
pass
return 'geo-scout'
def _load_vars_file(path: str) -> dict:
if not path:
return {}
p = Path(path)
if not p.exists():
print(f"{COLORS['YELLOW']} Vars file not found: {path} — continuing interactively{COLORS['RESET']}")
return {}
import yaml
try:
with open(p) as f:
data = yaml.safe_load(f) or {}
print(f"{COLORS['GREEN']} Loaded vars: {p.name}{COLORS['RESET']}")
return data
except Exception as e:
print(f"{COLORS['YELLOW']} Could not parse vars file ({e}) — continuing interactively{COLORS['RESET']}")
return {}
def _get_targets_info(scan_mode: str) -> tuple[str, list[int]]:
if scan_mode == 'geo-scout':
default = str(WEBRUNNER_INPUTS / 'targets.yaml')
raw = input(f"Path to targets.yaml [{default}]: ").strip()
path = raw or default
import yaml
try:
with open(path) as f:
data = yaml.safe_load(f)
ports: set[int] = set()
for t in data.get('targets', []):
ports.update(t.get('ports', []))
return path, sorted(ports)
except Exception as e:
print(f"{COLORS['YELLOW']}Could not load targets ({e}), using defaults.{COLORS['RESET']}")
return path, [22, 80, 443, 8080, 8443]
default_ports = "22,80,443,8080,8443"
raw = input(f"Ports to scan [{default_ports}]: ").strip() or default_ports
ports_list: list[int] = []
for p in raw.split(','):
try:
ports_list.append(int(p.strip()))
except ValueError:
pass
return "", sorted(set(ports_list)) or [80, 443, 22]
def _get_nuclei_info(vars_overrides: dict) -> tuple[str, str]:
"""Returns (local_template_path, remote_template_path)."""
print(f"\n{COLORS['BLUE']}Nuclei Template:{COLORS['RESET']}")
if 'nuclei_template' in vars_overrides:
local_path = vars_overrides['nuclei_template']
print(f" Template: {local_path} (from vars file)")
else:
local_path = input(" Path to nuclei template (.yaml): ").strip()
if not local_path or not Path(local_path).exists():
print(f"{COLORS['RED']} Template not found: {local_path}{COLORS['RESET']}")
return "", ""
return local_path, "/root/webrunner/nuclei_template.yaml"
def _get_tuning_params(scan_mode: str, default_rate: int, vars_overrides: dict) -> dict:
"""Gather advanced tuning params. Returns dict of tuning config keys."""
tuning: dict = {}
show_header = True
def _prompt(label: str, key: str, default, cast=int) -> None:
nonlocal show_header
if show_header:
print(f"\n{COLORS['BLUE']}Advanced Tuning (Enter = use default):{COLORS['RESET']}")
show_header = False
if key in vars_overrides:
value = cast(vars_overrides[key])
source = "vars file"
else:
raw = input(f" {label} [{default}]: ").strip()
value = cast(raw) if raw else default
source = None
if isinstance(value, (int, float)) and value <= 0:
print(f"{COLORS['YELLOW']} {label}={value} invalid (must be > 0); using default {default}{COLORS['RESET']}")
value = default
tuning[key] = value
if source:
print(f" {label}: {value} ({source})")
_prompt("masscan rate (pkt/s)", "masscan_rate", default_rate)
if scan_mode in ('masscan+nmap', 'geo-scout'):
_prompt("nmap timing T1-T4", "nmap_timing", 4)
_prompt("nmap per-host timeout (s)", "nmap_timeout", 60)
_prompt("nmap parallel workers", "nmap_workers", 10)
if scan_mode == 'masscan+nuclei':
_prompt("nuclei rate limit (req/s)", "nuclei_rate", 150)
_prompt("nuclei concurrency", "nuclei_concurrency", 25)
_prompt("nuclei timeout (s)", "nuclei_timeout", 10)
return tuning
def _show_masscan_tor_warning():
R = COLORS['RED']
Y = COLORS['YELLOW']
W = COLORS['WHITE']
Z = COLORS['RESET']
print(f"\n{R}{'' * 70}{Z}")
print(f"{R}█ ⚠ CRITICAL OPSEC WARNING — MASSCAN BYPASSES TOR █{Z}")
print(f"{R}{'' * 70}{Z}")
print(f"{W} masscan uses raw sockets and CANNOT be tunneled through Tor or")
print(f" proxychains. Every SYN packet sent during the masscan phase reveals")
print(f" THIS CLOUD NODE'S IP to the targets and any monitoring along the path.{Z}")
print()
print(f"{Y} Tor will protect: {Z}{W}nmap fingerprinting, nuclei requests, probe banners{Z}")
print(f"{Y} Tor will NOT protect: {Z}{R}masscan SYN scan (bypasses the proxy entirely){Z}")
print()
print(f"{W} If you need full Tor coverage, use 'nmap-only' mode (slow but fully")
print(f" proxied via -sT). The masscan phase is fundamentally incompatible.{Z}\n")
def _show_caveats_block(scan_mode: str, use_tor: bool):
Y = COLORS['YELLOW']
W = COLORS['WHITE']
R = COLORS['RED']
Z = COLORS['RESET']
print(f"{Y} Caveats — empirical estimates, real scans vary ±30%:{Z}")
print(f"{W} • Hit rate assumes ~1% of IPs have open ports on selected ports.")
print(f" Real range: 0.5%5% (higher for SSH/HTTP/HTTPS, lower for niche")
print(f" ports). Override via vars file: hit_rate: 0.03{Z}")
if scan_mode in ('masscan+nuclei',):
print(f"{W} • Nuclei estimate assumes 5s/target (typical CVE template, 13 HTTP")
print(f" requests). Heavy templates with many requests/matchers run 25×")
print(f" longer.{Z}")
if scan_mode in ('masscan+nmap', 'geo-scout', 'nmap-only'):
print(f"{W} • nmap timing: T1=60s/host, T2=30s, T3=15s, T4=10s. Lower T values")
print(f" evade rate-limit detection but multiply scan time accordingly.{Z}")
if use_tor:
print(f"{R} • Tor adds 5× latency to nmap/nuclei/probe phases. Real overhead")
print(f" varies 3×–10× depending on circuit quality. Masscan is NOT")
print(f" proxied — see the warning banner above.{Z}")
print(f"{W} • Provisioning: ~5 min/node included. Add 510 min for first-time")
print(f" cloud-provider auth or new region.")
print(f" • Cost: Linode/FlokiNET bill minimum 1h per node. AWS bills per")
print(f" second. Short scans still incur the per-provider minimum.{Z}\n")
def _show_estimate_table(total_ips: int, n_ports: int, providers: list[str], scan_mode: str, use_tor: bool = False, tuning: dict | None = None):
rows = build_estimate_table(total_ips, n_ports, providers, scan_mode, use_tor=use_tor, tuning=tuning)
C = COLORS['CYAN']
W = COLORS['WHITE']
Y = COLORS['YELLOW']
G = COLORS['GREEN']
R = COLORS['RESET']
masscan_modes = ('masscan-only', 'masscan+nmap', 'geo-scout', 'masscan+nuclei')
if use_tor and scan_mode in masscan_modes:
_show_masscan_tor_warning()
tor_note = " [Tor: estimates reflect nmap/probe latency only]" if use_tor else ""
print(f"\n{C}{'' * 70}{R}")
print(f"{C} WEBRUNNER — Cost & Time Estimate{R}")
print(f"{W} Total: {fmt_ip_count(total_ips)} IPs {n_ports} ports Mode: {scan_mode}{R}")
print(f"{W} Providers: {', '.join(PROVIDER_LABELS[p] for p in providers)}{Y}{tor_note}{R}")
if tuning:
bits = []
if 'masscan_rate' in tuning:
bits.append(f"masscan={tuning['masscan_rate']}pps")
if 'nmap_timing' in tuning:
bits.append(f"nmap=T{tuning['nmap_timing']}/{tuning.get('nmap_workers', 10)}w")
if 'nuclei_rate' in tuning and scan_mode == 'masscan+nuclei':
bits.append(f"nuclei={tuning['nuclei_rate']}rps/{tuning.get('nuclei_concurrency', 25)}c")
if bits:
print(f"{W} Tuning: {' · '.join(bits)}{R}")
print(f"{C}{'' * 70}{R}")
print(f" {'Preset':<14} {'Nodes':>6} {'IPs/Node':>10} {'Time/Node':>12} {'Total $':>10}")
print(f" {'' * 56}")
for row in rows:
star = " *" if row['preset'] == 'balanced' else " "
color = G if row['preset'] == 'balanced' else W
ips_per_node = total_ips // row['n_nodes'] if row['n_nodes'] else total_ips
print(
f"{color}{star}{row['label']:<12} {row['n_nodes']:>6} "
f"{fmt_ip_count(ips_per_node):>10} "
f"{fmt_hours(row['hours_per_node']):>12} "
f"${row['total_cost_usd']:>9.2f}{R}"
)
print(f"{C}{'' * 70}{R}")
print(f"{Y} * = recommended (Pareto-optimal: speed vs. billing minimum){R}\n")
_show_caveats_block(scan_mode, use_tor)
# ── parameter gathering ───────────────────────────────────────────────────────
def gather_webrunner_parameters() -> dict | None:
clear_screen()
print_banner()
print(f"{COLORS['WHITE']}WEBRUNNER SETUP{COLORS['RESET']}")
print(f"{COLORS['WHITE']}==============={COLORS['RESET']}")
config: dict = {}
# Deployment ID
config['deployment_id'] = generate_deployment_id()
print(f"Deployment ID: {COLORS['CYAN']}{config['deployment_id']}{COLORS['RESET']}")
# Optional vars file — pre-fills tuning defaults
vars_raw = input("Vars file (optional, skip to configure interactively): ").strip()
vars_overrides = _load_vars_file(vars_raw)
# Engagement name — auto if blank
raw_eng = input(f"Engagement name [{config['deployment_id']}]: ").strip()
config['engagement'] = raw_eng or config['deployment_id']
# Provider selection — multi-select, each provider prompts for creds + region
providers = _select_providers()
config['providers'] = providers
for provider in providers:
provider_config = gather_provider_config(provider)
if not provider_config:
return None
config.update(provider_config)
print(f"{COLORS['GREEN']}Providers: {', '.join(PROVIDER_LABELS[p] for p in providers)}{COLORS['RESET']}")
# Deployment type
config['deployment_type'] = 'webrunner'
config['webrunner_deployment'] = True
# Naming — auto-derive from deployment_id, no second prompt
config['webrunner_name'] = f"wr-{config['deployment_id']}"
# SSH key
ssh_key_path = generate_ssh_key(config['webrunner_name'])
if not ssh_key_path:
print(f"{COLORS['RED']}Failed to generate SSH key.{COLORS['RESET']}")
return None
config['ssh_key_path'] = f"{ssh_key_path}.pub"
config['ssh_key_name'] = os.path.basename(ssh_key_path)
print(f"{COLORS['GREEN']}SSH key generated: {ssh_key_path}{COLORS['RESET']}")
# Country / CIDR selection
country_codes, exclude_map = _select_countries()
if not country_codes:
print(f"{COLORS['RED']}No countries selected.{COLORS['RESET']}")
return None
config['country_codes'] = country_codes
print(f"{COLORS['GREEN']}Countries: {', '.join(country_codes)}{COLORS['RESET']}")
# Scan mode
scan_mode = _select_scan_mode()
config['scan_mode'] = scan_mode
# Nuclei template (masscan+nuclei only)
config['nuclei_template_local'] = ''
config['nuclei_template_remote'] = ''
if scan_mode == 'masscan+nuclei':
local_tmpl, remote_tmpl = _get_nuclei_info(vars_overrides)
if not local_tmpl:
return None
config['nuclei_template_local'] = local_tmpl
config['nuclei_template_remote'] = remote_tmpl
# Targets / ports
targets_file, ports = _get_targets_info(scan_mode)
config['targets_file'] = targets_file
config['ports'] = ports
config['ports_str'] = ','.join(str(p) for p in ports)
config['masscan_rate'] = SCAN_MODES.get(scan_mode, {}).get('rate', 3000)
# Resolve CIDR data
print(f"\n{COLORS['CYAN']}[*] Resolving CIDR data for {len(country_codes)} countries...{COLORS['RESET']}")
cc_cidrs = get_country_cidrs(country_codes, exclude_map)
total_ips = sum(sum(ip_count(c) for c in cidrs) for cidrs in cc_cidrs.values())
if total_ips == 0:
print(f"{COLORS['RED']}No IPs resolved. Check your country codes or network connectivity.{COLORS['RESET']}")
return None
print(f"{COLORS['GREEN']}Total: {fmt_ip_count(total_ips)} IPs across {len(country_codes)} countries{COLORS['RESET']}")
config['total_ips'] = total_ips
# Tor routing — ask before estimate so table reflects the slowdown
tor_raw = input(f"\nRoute scans through Tor? [y/N]: ").strip().lower()
config['use_tor'] = tor_raw in ['y', 'yes']
masscan_modes = ('masscan-only', 'masscan+nmap', 'geo-scout', 'masscan+nuclei')
if config['use_tor'] and scan_mode in masscan_modes:
_show_masscan_tor_warning()
confirm = input(f"{COLORS['RED']} Continue with Tor enabled, knowing masscan will leak this node's IP? [y/N]: {COLORS['RESET']}").strip().lower()
if confirm not in ['y', 'yes']:
print(f"{COLORS['YELLOW']} Tor disabled. To get full Tor coverage, re-run with scan mode 'nmap-only'.{COLORS['RESET']}")
config['use_tor'] = False
elif config['use_tor']:
print(f"{COLORS['GREEN']} Tor routing enabled — all nmap traffic will use proxychains → SOCKS5 9050.{COLORS['RESET']}")
# Advanced tuning — gather BEFORE estimate so table reflects operator choices
default_rate = config['masscan_rate']
tuning = _get_tuning_params(scan_mode, default_rate, vars_overrides)
config.update(tuning)
# Cost / time estimate — reflects tuning + Tor throughput impact
_show_estimate_table(total_ips, len(ports), providers, scan_mode, use_tor=config['use_tor'], tuning=tuning)
# Preset selection
print(f"{COLORS['BLUE']}Select deployment preset:{COLORS['RESET']}")
preset_list = list(PRESETS.items())
for i, (key, val) in enumerate(preset_list, 1):
star = " (recommended)" if key == 'balanced' else ""
print(f" {i}) {val['label']}{star}{val['desc']}")
print(f" 4) Custom chunk size")
preset_choice = input("Select [2]: ").strip() or "2"
if preset_choice == "4":
raw = input("IPs per node: ").strip()
try:
chunk_size = int(raw.replace(',', '').replace('_', ''))
preset_key = 'custom'
except ValueError:
chunk_size = PRESETS['balanced']['chunk_size']
preset_key = 'balanced'
else:
try:
idx = int(preset_choice) - 1
if 0 <= idx < len(preset_list):
preset_key, preset_val = preset_list[idx]
chunk_size = preset_val['chunk_size']
else:
preset_key = 'balanced'
chunk_size = PRESETS['balanced']['chunk_size']
except ValueError:
preset_key = 'balanced'
chunk_size = PRESETS['balanced']['chunk_size']
config['preset'] = preset_key
config['chunk_size'] = chunk_size
config['cidr_country_map_file'] = '' # filled in execute_webrunner_deployment
# Distribute CIDRs into node chunks
all_cidrs: list[str] = []
for cc in country_codes:
all_cidrs.extend(cc_cidrs.get(cc.upper(), []))
chunks = chunk_cidrs(all_cidrs, chunk_size)
# Build per-provider region pools for round-robin assignment
provider_regions: dict[str, list[str]] = {}
for p in providers:
if p == 'linode':
provider_regions[p] = config.get('linode_regions', [config.get('linode_region', 'us-east')])
elif p == 'aws':
provider_regions[p] = config.get('aws_regions', [config.get('aws_region', 'us-east-1')])
elif p == 'flokinet':
provider_regions[p] = [config.get('flokinet_region', 'default')]
else:
provider_regions[p] = ['default']
provider_counters: dict[str, int] = {p: 0 for p in providers}
node_chunks = []
for i, chunk in enumerate(chunks):
provider = providers[i % len(providers)]
regions = provider_regions[provider]
region = regions[provider_counters[provider] % len(regions)]
provider_counters[provider] += 1
node_chunks.append({
'idx': i,
'node_name': f"{config['webrunner_name']}-{i + 1:02d}",
'provider': provider,
'region': region,
'cidrs': chunk['cidrs'],
'ip_count': chunk['ip_count'],
})
config['node_chunks'] = node_chunks
print(f"{COLORS['GREEN']}Nodes: {len(node_chunks)} ({preset_key}, {fmt_ip_count(chunk_size)}/node){COLORS['RESET']}")
# Warn if any provider's node count exceeds safe per-region quota
_PROVIDER_CAPS = {'linode': 20, 'aws': 32, 'flokinet': 10}
provider_node_counts: dict[str, int] = {}
for nc in node_chunks:
provider_node_counts[nc['provider']] = provider_node_counts.get(nc['provider'], 0) + 1
for p, count in provider_node_counts.items():
n_regions = len(provider_regions.get(p, ['default']))
cap = _PROVIDER_CAPS.get(p, 20)
per_region = (count + n_regions - 1) // n_regions
if per_region > cap:
print(f"{COLORS['YELLOW']} Warning: {count} {PROVIDER_LABELS[p]} nodes across {n_regions} region(s) "
f"= ~{per_region}/region; default quota is ~{cap}/region.{COLORS['RESET']}")
# Operator IP
config['operator_ip'] = get_public_ip()
if config['operator_ip']:
print(f"{COLORS['GREEN']}Operator IP: {config['operator_ip']}{COLORS['RESET']}")
# OPSEC / teardown options
print(f"\n{COLORS['BLUE']}Deployment Options:{COLORS['RESET']}")
opsec_raw = input(f"Enhanced OPSEC mode? (randomize node names, minimal logging) [y/N]: ").strip().lower()
config['enhanced_opsec'] = opsec_raw in ['y', 'yes']
teardown_raw = input(f"Teardown nodes after scan completes? [Y/n]: ").strip().lower()
config['teardown_after_scan'] = teardown_raw not in ['n', 'no']
if config['teardown_after_scan']:
print(f"{COLORS['YELLOW']} Nodes will be destroyed automatically when scan completes.{COLORS['RESET']}")
else:
print(f"{COLORS['CYAN']} Nodes will remain running after scan — remember to teardown manually.{COLORS['RESET']}")
# Apply per-provider instance defaults if not set by gather_provider_config
if 'linode_instance_type' not in config:
config['linode_instance_type'] = DEFAULT_INSTANCE.get('linode', 'g6-nanode-1')
if 'aws_instance_type' not in config:
config['aws_instance_type'] = DEFAULT_INSTANCE.get('aws', 't3.small')
return config
# ── execution ─────────────────────────────────────────────────────────────────
def execute_webrunner_deployment(config: dict):
clear_screen()
print_banner()
print(f"\n{COLORS['GREEN']}Starting WEBRUNNER deployment...{COLORS['RESET']}")
log_file = setup_logging(config['deployment_id'], "webrunner_deployment")
node_chunks = config.pop('node_chunks')
n_nodes = len(node_chunks)
# Summary
print(f"\n{COLORS['CYAN']}Deployment Summary:{COLORS['RESET']}")
print(f" Deployment ID: {config['deployment_id']}")
print(f" Name: {config['webrunner_name']}")
naming_info = show_naming_relationship(config['webrunner_name'], config['deployment_id'], 'webrunner')
if naming_info:
print(f" └─ {naming_info['relationship_text']}")
print(f" Engagement: {config['engagement']}")
print(f" Providers: {', '.join(PROVIDER_LABELS[p] for p in config['providers'])}")
print(f" Nodes: {n_nodes} ({config['preset']}, {fmt_ip_count(config['chunk_size'])}/node)")
print(f" Scan mode: {config['scan_mode']}")
print(f" Ports: {', '.join(str(p) for p in config['ports'][:8])}{'...' if len(config['ports']) > 8 else ''}")
print(f" masscan rate: {config.get('masscan_rate', '')} pkt/s")
if config['scan_mode'] in ('masscan+nmap', 'geo-scout'):
print(f" nmap: T{config.get('nmap_timing', 4)} timeout={config.get('nmap_timeout', 60)}s workers={config.get('nmap_workers', 10)}")
if config['scan_mode'] == 'masscan+nuclei':
tmpl_name = os.path.basename(config.get('nuclei_template_local', ''))
print(f" nuclei: {tmpl_name} rate={config.get('nuclei_rate', 150)} concurrency={config.get('nuclei_concurrency', 25)} timeout={config.get('nuclei_timeout', 10)}s")
print(f" Total IPs: {fmt_ip_count(config['total_ips'])}")
print(f" Tor routing: {'Yes' if config['use_tor'] else 'No'}")
print(f" Enhanced OPSEC: {'Yes' if config['enhanced_opsec'] else 'No'}")
print(f" Teardown after: {'Yes' if config['teardown_after_scan'] else 'No'}")
if config.get('ssh_key_path'):
key_name = os.path.basename(config['ssh_key_path']).replace('.pub', '')
print(f" SSH Key: {key_name}")
if not confirm_action(f"\n{COLORS['YELLOW']}Proceed with WEBRUNNER deployment?{COLORS['RESET']}", default=True):
print(f"\n{COLORS['YELLOW']}Deployment cancelled.{COLORS['RESET']}")
return
# Write node chunks file — absolute paths so Ansible lookup('file',...) works
# regardless of playbook-relative CWD
logs_dir = os.path.abspath(os.path.join(_base, 'logs'))
os.makedirs(logs_dir, exist_ok=True)
# Save CIDR→country map for merge_results per-country attribution
cc_map_file = os.path.join(logs_dir, f"cidr_country_map_{config['deployment_id']}.json")
if config.get('country_codes'):
from utils.chunk_utils import get_country_cidrs
cc_cidrs_saved = get_country_cidrs(config['country_codes'], {})
with open(cc_map_file, 'w') as f:
json.dump(cc_cidrs_saved, f)
config['cidr_country_map_file'] = cc_map_file
chunks_file = os.path.join(logs_dir, f"node_chunks_{config['deployment_id']}.json")
with open(chunks_file, 'w') as f:
json.dump(node_chunks, f)
config['node_chunks_file'] = chunks_file
config['scanner_ip_log'] = os.path.join(logs_dir, f"scanner_ips_{config['webrunner_name']}.txt")
config['results_dir'] = os.path.join(logs_dir, f"webrunner_{config['deployment_id']}")
for provider in config['providers']:
set_provider_environment({**config, 'provider': provider})
playbook = 'providers/webrunner.yml'
print(f"\n{COLORS['CYAN']}[*] Launching WEBRUNNER — {n_nodes} nodes across "
f"{', '.join(PROVIDER_LABELS[p] for p in config['providers'])}{COLORS['RESET']}")
print(f"{COLORS['YELLOW']} Provisioning may take several minutes per node.{COLORS['RESET']}\n")
success = execute_playbook(playbook, config)
if success:
print(f"\n{COLORS['GREEN']}[+] WEBRUNNER complete.{COLORS['RESET']}")
print(f" Results: logs/webrunner_{config['deployment_id']}/")
print(f" Scanner IPs: logs/scanner_ips_{config['webrunner_name']}.txt")
print(f" Log: logs/deployment_{config['deployment_id']}.log")
else:
print(f"\n{COLORS['RED']}[-] WEBRUNNER failed. Check: logs/deployment_{config['deployment_id']}.log{COLORS['RESET']}")
wait_for_input()
@@ -0,0 +1,89 @@
# countries.yaml — Format Specification
## Purpose
Defines which countries to scan and optional per-country exclusions.
Used by all WEBRUNNER scan modes. The scanner resolves each country code
to its CIDR ranges using RIR delegated stats files (ARIN, RIPE, APNIC,
LACNIC, AFRINIC — cached 24h locally).
## Scope options
- **Targeted countries**: list specific ISO codes in this file
- **Single country**: just one entry
- **Global sweep**: include every country you want — WEBRUNNER distributes
CIDRs across nodes automatically regardless of count
## Relationship to scan_vars.yaml
`scan_profile.rate` in this file sets the masscan default, but it is
overridden by `masscan_rate` in `scan_vars.yaml` or the interactive tuning
prompt. Prefer `scan_vars.yaml` for operator-level tuning.
## Schema
```yaml
scan_profile:
name: string # Label for this profile (used in logs/reports)
rate: integer # masscan packets/sec (default: 1000, max: 100000)
max_hosts_per_country: integer|null # Cap IPs per country. null = no limit
countries:
- code: string # ISO 3166-1 alpha-2 (e.g. US, VE, NL, GB, NG)
priority: high|medium|low # Scan order. high = first
exclude_cidrs: # Optional CIDR blocks to skip within this country
- "x.x.x.x/xx"
notes: string # Optional context (not used by scanner)
```
## Rules
- `code` must be a valid ISO 3166-1 alpha-2 code
- GB is the correct code for United Kingdom (not UK)
- `rate` applies globally to the masscan run, not per-country
- `exclude_cidrs` entries must be valid CIDR notation
- Countries are scanned in priority order: high → medium → low
- Within same priority, order in the list is preserved
## Valid priority values
`high` | `medium` | `low`
## Common country codes
| Country | Code |
|---------|------|
| United States | US |
| United Kingdom | GB |
| Venezuela | VE |
| Netherlands | NL |
| Canada | CA |
| Nigeria | NG |
| Russia | RU |
| Germany | DE |
| China | CN |
| Brazil | BR |
| Iran | IR |
| India | IN |
| France | FR |
| Australia | AU |
## Example
```yaml
scan_profile:
name: "latam-europe-sweep"
rate: 2000
max_hosts_per_country: 100000
countries:
- code: VE
priority: high
exclude_cidrs: []
notes: "Primary target"
- code: US
priority: medium
exclude_cidrs:
- "10.0.0.0/8"
- "172.16.0.0/12"
- "192.168.0.0/16"
- code: NL
priority: low
```
+51
View File
@@ -0,0 +1,51 @@
scan_profile:
name: "adversarial-nations-panos"
rate: 3000
max_hosts_per_country: null
countries:
# Largest internet footprint first — most likely to surface exposed devices
- code: RU
priority: high
exclude_cidrs: []
notes: "Russia"
- code: IR
priority: high
exclude_cidrs: []
notes: "Iran"
- code: BY
priority: medium
exclude_cidrs: []
notes: "Belarus"
- code: VE
priority: medium
exclude_cidrs: []
notes: "Venezuela"
- code: SY
priority: medium
exclude_cidrs: []
notes: "Syria"
- code: NI
priority: low
exclude_cidrs: []
notes: "Nicaragua"
- code: CU
priority: low
exclude_cidrs: []
notes: "Cuba"
- code: MM
priority: low
exclude_cidrs: []
notes: "Myanmar"
- code: KP
priority: low
exclude_cidrs: []
notes: "North Korea — minimal public internet, low yield expected"
@@ -0,0 +1,31 @@
# scan_vars.yaml — WEBRUNNER pre-configuration
# Copy this file, fill in values, and provide the path at the "Vars file" prompt.
# All fields are optional. Omit any field to be prompted interactively.
# ── masscan ───────────────────────────────────────────────────────────────────
masscan_rate: 5000 # packets/sec (default: mode-dependent, 300010000)
# Lower for clients with strict IPS/rate-limiting
# ── nmap (masscan+nmap and geo-scout modes) ───────────────────────────────────
nmap_timing: 3 # T1=sneaky T2=polite T3=normal T4=aggressive (default: 4)
nmap_timeout: 120 # per-host timeout in seconds (default: 60)
# Increase for slow/filtered networks
nmap_workers: 10 # parallel nmap threads per node (default: 10)
# ── nuclei (masscan+nuclei mode only) ─────────────────────────────────────────
nuclei_template: "/path/to/your/cve-template.yaml"
# Local path — copied to nodes at provision time
# Never fetched from the internet during scans
nuclei_rate: 150 # requests/sec rate limit (default: 150)
# Lower for clients with WAF/rate-limiting
nuclei_concurrency: 25 # concurrent goroutines (default: 25)
nuclei_timeout: 10 # per-request timeout in seconds (default: 10)
# ── example: conservative client profile ─────────────────────────────────────
# masscan_rate: 1000
# nmap_timing: 2
# nmap_timeout: 180
# nmap_workers: 5
# nuclei_rate: 50
# nuclei_concurrency: 10
# nuclei_timeout: 20
+210
View File
@@ -0,0 +1,210 @@
# targets.yaml — Format Specification
## Purpose
Defines what to look for during **geo-scout** mode scans. Each target is a
fingerprint with one or more probes. The scanner runs masscan to find open
ports, then fires each probe against matching hosts, and applies
pattern/version matching.
For **masscan+nuclei** mode, use a nuclei template instead (see below).
targets.yaml is ignored in nuclei mode.
## Nuclei template mode (masscan+nuclei)
Provide a standard nuclei YAML template file. WEBRUNNER:
1. Runs masscan to find open `ip:port` pairs
2. Feeds those pairs as targets to nuclei with your template
3. Outputs per-host match results + per-country vulnerable host counts
**OPSEC note:** Templates are copied to nodes at provision time from your
local path. No live template fetches happen during scans.
**Template path:** Specify in `scan_vars.yaml` (`nuclei_template: /path/to/template.yaml`)
or enter the path at the interactive prompt.
### Minimal nuclei template structure
```yaml
id: cve-2024-example
info:
name: Example CVE
severity: critical
tags: [cve, rce]
http:
- method: GET
path:
- "{{BaseURL}}/vulnerable/endpoint"
matchers:
- type: word
words:
- "vulnerable_string"
```
---
## Schema
```yaml
targets:
- name: string # Human-readable label (appears in results)
tags: [string, ...] # Free-form tags for grouping/filtering results
ports: [integer, ...] # Ports masscan will scan for this target
probes:
- type: tcp_banner|http|https|rtsp|udp
# --- For http/https ---
path: string # URL path (e.g. "/", "/login.asp", "/api/version")
method: GET|POST # Default: GET
headers: # Optional extra request headers
Header-Name: value
body: string # POST body (optional)
match_in: body|headers|[body, headers] # Where to search for patterns
# --- For tcp_banner ---
# (no extra fields — reads the raw TCP banner on connect)
# --- For rtsp ---
# (sends OPTIONS * RTSP/1.0 and matches banner)
# --- Pattern matching (all probe types) ---
patterns: # At least one must match (OR logic)
- "regex or plain string"
all_patterns: # All must match (AND logic, optional)
- "regex or plain string"
# --- Version extraction (optional) ---
version_extract: "regex with one capture group"
version_compare: # Optional — filter by version
operator: "<="|">="|"=="|"!="|"<"|">"
value: "string or number"
# --- Confidence ---
confidence: high|medium|low # Default: medium
```
## Rules
- A target matches a host if ANY probe matches
- Within a probe, `patterns` uses OR logic (any one pattern is enough)
- `all_patterns` uses AND logic — use when you need multiple strings to co-occur
- `version_extract` must contain exactly one regex capture group `()`
- Version comparison is string-aware for dotted versions (e.g. "20.3" < "20.17")
- `match_in` defaults to `body` for http/https probes
- For `tcp_banner` type, the banner is the raw bytes received on connect
- Patterns are case-insensitive by default; prefix with `(?-i)` to force case-sensitive
- Tags are free-form strings — use them for filtering with `--tag` at runtime
## Probe types
| Type | Description |
|------|-------------|
| `tcp_banner` | Connect and read raw banner |
| `http` | HTTP GET/POST, match response |
| `https` | HTTPS GET/POST, match response (cert errors ignored) |
| `rtsp` | RTSP OPTIONS probe, match response |
| `udp` | Send empty UDP, match response |
## Common port reference
| Service | Ports |
|---------|-------|
| HTTP | 80, 8080, 8000, 8888 |
| HTTPS | 443, 8443 |
| SSH | 22 |
| Telnet | 23 |
| FTP | 21 |
| RTSP (cameras) | 554, 8554 |
| ONVIF (cameras) | 80, 8080 |
| DVR/NVR | 37777, 34567 |
| RDP | 3389 |
| SMB | 445 |
| Redis | 6379 |
| Elasticsearch | 9200 |
| MongoDB | 27017 |
| MySQL | 3306 |
| PostgreSQL | 5432 |
## Examples
### D-Link DIR-823X firmware 240126 or 240802
```yaml
- name: "D-Link DIR-823X fw 240126/240802"
tags: [router, d-link, cpe, iot]
ports: [80, 443, 8080]
probes:
- type: http
path: "/"
match_in: body
patterns: ["DIR-823X"]
all_patterns: []
- type: http
path: "/"
match_in: body
patterns: ["240126", "240802"]
confidence: high
```
### Exposed IP cameras (any brand)
```yaml
- name: "Exposed IP Camera"
tags: [camera, iot, surveillance]
ports: [80, 554, 8080, 8443, 37777, 34567]
probes:
- type: http
path: "/"
match_in: [body, headers]
patterns:
- "(?i)hikvision"
- "(?i)dahua"
- "(?i)ip camera"
- "(?i)ipcam"
- "(?i)webcam"
- "(?i)nvr"
- "(?i)dvr"
- "(?i)axis"
- "(?i)reolink"
- "(?i)amcrest"
- type: rtsp
patterns: ["RTSP/1.0 200"]
confidence: medium
```
### Cisco Catalyst SD-WAN Manager <= 20.17
```yaml
- name: "Cisco SD-WAN vManage <= 20.17"
tags: [cisco, sdwan, network, cve]
ports: [443, 8443]
probes:
- type: https
path: "/dataservice/client/server"
method: GET
match_in: body
patterns: ["vmanage", "platformVersion"]
version_extract: '"platformVersion":"([0-9.]+)"'
version_compare:
operator: "<="
value: "20.17"
confidence: high
- type: https
path: "/"
match_in: [body, headers]
patterns: ["vManage", "Cisco SD-WAN"]
confidence: low
```
### Ubuntu 24.04 SSH
```yaml
- name: "Ubuntu 24.04 SSH"
tags: [linux, ubuntu, ssh]
ports: [22]
probes:
- type: tcp_banner
patterns:
- "Ubuntu-24"
- "OpenSSH.*Ubuntu"
version_extract: "SSH-2.0-OpenSSH_([0-9p.]+)"
confidence: high
```
### Open Redis (unauthenticated)
```yaml
- name: "Open Redis"
tags: [database, redis, exposed]
ports: [6379]
probes:
- type: tcp_banner
patterns: ["redis_version"]
version_extract: "redis_version:([0-9.]+)"
confidence: high
```
+103
View File
@@ -0,0 +1,103 @@
targets:
- name: "Palo Alto PAN-OS < 10.2.14"
tags: [panos, firewall, palo-alto, cve-2024-3400, network-device]
ports: [443, 4443, 8443]
probes:
- type: https
path: "/api/?type=version"
method: GET
match_in: body
patterns: ["sw-version"]
version_extract: "<sw-version>([0-9]+\\.[0-9]+\\.[0-9]+)"
version_compare:
operator: "<"
value: "10.2.14"
confidence: high
- type: https
path: "/php/login.php"
method: GET
match_in: [body, headers]
patterns:
- "Palo Alto Networks"
- "PAN-OS"
confidence: low
- type: https
path: "/global-protect/portal/gp-portal-esp.esp"
method: GET
match_in: body
patterns: ["globalprotect", "PAN-OS"]
confidence: low
- name: "Ubuntu 24.04 SSH"
tags: [linux, ubuntu, ssh]
ports: [22]
probes:
- type: tcp_banner
patterns:
- "Ubuntu-24"
- "OpenSSH.*Ubuntu"
version_extract: "SSH-2\\.0-OpenSSH_([0-9p.]+)"
confidence: high
- name: "D-Link DIR-823X fw 240126/240802"
tags: [router, d-link, cpe, iot]
ports: [80, 443, 8080]
probes:
- type: http
path: "/"
match_in: body
patterns: ["DIR-823X"]
confidence: medium
- type: http
path: "/"
match_in: body
all_patterns: ["DIR-823X"]
patterns: ["240126", "240802"]
confidence: high
- name: "Exposed IP Camera"
tags: [camera, iot, surveillance]
ports: [80, 554, 8080, 8443, 37777, 34567]
probes:
- type: http
path: "/"
match_in: [body, headers]
patterns:
- "(?i)hikvision"
- "(?i)dahua"
- "(?i)ip.?camera"
- "(?i)ipcam"
- "(?i)webcam"
- "(?i)reolink"
- "(?i)amcrest"
- "(?i)axis"
confidence: medium
- type: rtsp
patterns: ["RTSP/1.0 200"]
confidence: medium
- name: "Cisco SD-WAN vManage <= 20.17"
tags: [cisco, sdwan, network]
ports: [443, 8443]
probes:
- type: https
path: "/dataservice/client/server"
method: GET
match_in: body
patterns: ["platformVersion"]
version_extract: '"platformVersion":"([0-9.]+)"'
version_compare:
operator: "<="
value: "20.17"
confidence: high
- name: "Open Redis"
tags: [database, redis, exposed]
ports: [6379]
probes:
- type: tcp_banner
patterns: ["redis_version"]
version_extract: "redis_version:([0-9.]+)"
confidence: high
@@ -0,0 +1,19 @@
---
# Fetch scan results from node back to controller
- name: Check results exist on {{ node_name }}
stat:
path: /root/webrunner/results.json
register: results_stat
- name: Fetch results from {{ node_name }}
fetch:
src: /root/webrunner/results.json
dest: "{{ results_dir }}/{{ node_name }}_results.json"
flat: true
when: results_stat.stat.exists
- name: Warn if no results from {{ node_name }}
debug:
msg: "No results.json found on {{ node_name }} — scan may have failed"
when: not results_stat.stat.exists
@@ -0,0 +1,98 @@
---
# Configure a WEBRUNNER scan node — install deps, create workspace
- name: Update apt cache
apt:
update_cache: true
cache_valid_time: 3600
retries: 3
delay: 10
- name: Install scan tools
apt:
name:
- masscan
- nmap
- python3
- python3-pip
- curl
state: present
retries: 3
delay: 10
- name: Install Tor and proxychains
apt:
name:
- tor
- proxychains4
state: present
retries: 3
delay: 10
when: use_tor | default(false) | bool
- name: Start and enable Tor service
systemd:
name: tor
state: started
enabled: true
when: use_tor | default(false) | bool
- name: Configure proxychains for Tor SOCKS5
copy:
content: |
strict_chain
proxy_dns
tcp_read_time_out 15000
tcp_connect_time_out 8000
[ProxyList]
socks5 127.0.0.1 9050
dest: /etc/proxychains4.conf
mode: '0644'
when: use_tor | default(false) | bool
- name: Wait for Tor to establish circuit
wait_for:
port: 9050
host: 127.0.0.1
timeout: 60
delay: 5
when: use_tor | default(false) | bool
- name: Create webrunner workspace
file:
path: /root/webrunner
state: directory
mode: '0700'
- name: Copy node scanner script
copy:
src: "{{ playbook_dir }}/../modules/webrunner/tasks/node_scanner.py"
dest: /root/webrunner/node_scanner.py
mode: '0755'
- name: Copy targets.yaml for geo-scout mode
copy:
src: "{{ targets_file }}"
dest: /root/webrunner/targets.yaml
mode: '0644'
when: scan_mode == 'geo-scout' and targets_file != ""
ignore_errors: true
- name: Install nuclei vulnerability scanner
shell: |
if ! command -v nuclei &>/dev/null; then
curl -sL "https://github.com/projectdiscovery/nuclei/releases/download/v3.3.9/nuclei_3.3.9_linux_amd64.tar.gz" | tar -xz -C /usr/local/bin nuclei
chmod +x /usr/local/bin/nuclei
fi
args:
executable: /bin/bash
when: scan_mode == 'masscan+nuclei'
retries: 2
delay: 5
- name: Upload nuclei template
copy:
src: "{{ nuclei_template_local }}"
dest: /root/webrunner/nuclei_template.yaml
mode: '0644'
when: scan_mode == 'masscan+nuclei' and nuclei_template_local | default('') != ''
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""
WEBRUNNER merge — combine per-node results.json files into a unified report.
Run by Ansible on the controller after all nodes complete.
"""
import argparse
import ipaddress
import json
import sys
from pathlib import Path
def _build_cidr_map(cc_cidrs: dict) -> list[tuple]:
result = []
for cc, cidrs in cc_cidrs.items():
for cidr in cidrs:
try:
net = ipaddress.ip_network(cidr, strict=False)
result.append((net, cc.upper()))
except ValueError:
pass
result.sort(key=lambda x: x[0].prefixlen, reverse=True)
return result
def _lookup_country(ip: str, cidr_map: list[tuple]) -> str:
try:
addr = ipaddress.ip_address(ip)
except ValueError:
return ""
for net, cc in cidr_map:
if addr in net:
return cc
return ""
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--results-dir", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--deployment-id", required=True)
parser.add_argument("--cidr-map", default="", help="JSON file mapping country codes to CIDR lists")
args = parser.parse_args()
results_dir = Path(args.results_dir)
node_files = sorted(results_dir.glob("*_results.json"))
if not node_files:
print(f"No result files found in {results_dir}", file=sys.stderr)
sys.exit(1)
cidr_map: list[tuple] = []
if args.cidr_map:
try:
cc_cidrs = json.loads(Path(args.cidr_map).read_text())
cidr_map = _build_cidr_map(cc_cidrs)
except (OSError, json.JSONDecodeError):
pass
merged: dict = {
"deployment_id": args.deployment_id,
"nodes": [],
"total_results": 0,
"results": [],
}
seen: set[str] = set() # deduplicate by ip:port
for f in node_files:
try:
data = json.loads(f.read_text())
except (json.JSONDecodeError, OSError) as e:
print(f"Skipping {f.name}: {e}", file=sys.stderr)
continue
node_summary = {
"node_name": data.get("node_name", f.stem),
"scan_mode": data.get("scan_mode", "unknown"),
"result_count": data.get("result_count", 0),
}
merged["nodes"].append(node_summary)
for entry in data.get("results", []):
key = f"{entry.get('ip')}:{entry.get('port')}"
if key not in seen:
seen.add(key)
if cidr_map:
entry["country"] = _lookup_country(entry.get("ip", ""), cidr_map)
merged["results"].append(entry)
merged["total_results"] = len(merged["results"])
if cidr_map:
country_counts: dict[str, int] = {}
for entry in merged["results"]:
cc = entry.get("country", "")
country_counts[cc] = country_counts.get(cc, 0) + 1
merged["country_summary"] = dict(
sorted(country_counts.items(), key=lambda x: x[1], reverse=True)
)
Path(args.output).write_text(json.dumps(merged, indent=2))
print(f"Merged {len(node_files)} node(s) — {merged['total_results']} unique results")
for n in merged["nodes"]:
print(f" {n['node_name']}: {n['result_count']} results ({n['scan_mode']})")
if cidr_map and merged.get("country_summary"):
print("\nVulnerable hosts by country:")
for cc, count in list(merged["country_summary"].items())[:20]:
label = cc if cc else "unknown"
print(f" {label:<6} {count}")
if __name__ == "__main__":
main()
+416
View File
@@ -0,0 +1,416 @@
#!/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 os
import re
import subprocess
import sys
import time
import xml.etree.ElementTree as ET
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
WORKDIR = Path("/root/webrunner")
NMAP_WORKERS = int(os.environ.get("WEBRUNNER_NMAP_WORKERS", "10"))
NMAP_TIMING = int(os.environ.get("WEBRUNNER_NMAP_TIMING", "4"))
NMAP_TIMEOUT = int(os.environ.get("WEBRUNNER_NMAP_TIMEOUT", "60"))
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, f"-T{NMAP_TIMING}", "--open",
"-oX", str(out_file), ip,
]
try:
subprocess.run(cmd, capture_output=True, timeout=NMAP_TIMEOUT, 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: " + ip.encode() + b"\r\n\r\n")
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"
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 (workers={NMAP_WORKERS})...")
results = []
done = 0
with ThreadPoolExecutor(max_workers=NMAP_WORKERS) as pool:
futures = {pool.submit(run_nmap, ip, ip_ports): (ip, ip_ports) for ip, ip_ports in by_ip.items()}
for future in as_completed(futures):
ip, ip_ports = futures[future]
nmap_info = future.result()
for port in ip_ports:
entry: dict = {"ip": ip, "port": port}
if port in nmap_info:
entry.update(nmap_info[port])
results.append(entry)
done += 1
if done % 50 == 0:
log(f" nmap: {done}/{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
def scan_one(ip: str, ip_ports: list[int]) -> list[dict]:
nmap_info = run_nmap(ip, ip_ports)
entries = []
for port in ip_ports:
entry: dict = {"ip": ip, "port": port}
if port in nmap_info:
entry.update(nmap_info[port])
banner = probe_service(ip, port)
if banner:
entry["probe_banner"] = banner
if port in target_ports:
t = target_ports[port]
entry["target_name"] = t.get("name", "")
entry["target_desc"] = t.get("description", "")
entries.append(entry)
return entries
results = []
done = 0
with ThreadPoolExecutor(max_workers=NMAP_WORKERS) as pool:
futures = {pool.submit(scan_one, ip, ip_ports): ip for ip, ip_ports in by_ip.items()}
for future in as_completed(futures):
results.extend(future.result())
done += 1
if done % 50 == 0:
log(f" geo-scout: {done}/{len(by_ip)} hosts done")
return results
def run_nuclei(targets: list[str], template: str, rate: int, concurrency: int, timeout: int) -> list[dict]:
targets_file = WORKDIR / "nuclei_targets.txt"
targets_file.write_text("\n".join(targets) + "\n")
out_file = WORKDIR / "nuclei_results.jsonl"
out_file.unlink(missing_ok=True)
cmd = [
"nuclei",
"-t", template,
"-l", str(targets_file),
"-rl", str(rate),
"-c", str(concurrency),
"-timeout", str(timeout),
"-j",
"-o", str(out_file),
"-silent",
"-no-color",
"-disable-update-check",
]
log(f"nuclei starting: template={template} targets={len(targets)} rate={rate} concurrency={concurrency}")
try:
subprocess.run(cmd, timeout=43200, check=False)
except subprocess.TimeoutExpired:
log("nuclei timed out after 12h")
if not out_file.exists():
return []
results = []
for line in out_file.read_text(errors="replace").splitlines():
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
host = entry.get("host", "")
ip = entry.get("ip", "")
if not ip and host:
raw = host.split("//")[-1].split("/")[0]
ip_part = raw.rsplit(":", 1)[0] if ":" in raw else raw
m = re.match(r"^(\d+\.\d+\.\d+\.\d+)$", ip_part)
ip = m.group(1) if m else ip_part
port = 0
matched_at = entry.get("matched-at", host)
raw_host = matched_at.split("//")[-1].split("/")[0]
if ":" in raw_host:
try:
port = int(raw_host.rsplit(":", 1)[1])
except (ValueError, IndexError):
pass
results.append({
"ip": ip,
"port": port,
"template_id": entry.get("template-id", ""),
"vuln_name": entry.get("info", {}).get("name", ""),
"severity": entry.get("info", {}).get("severity", ""),
"matched_at": entry.get("matched-at", ""),
"vuln": True,
})
log(f"nuclei found {len(results)} vulnerable hosts/services")
return results
def scan_masscan_nuclei(ports: str, rate: int, template: str, nuclei_rate: int, nuclei_concurrency: int, nuclei_timeout: int, node_name: str) -> list[dict]:
if not template:
log("ERROR: --template required for masscan+nuclei mode")
return []
hits = run_masscan(ports, rate)
if not hits:
return []
targets = [f"{h['ip']}:{h['port']}" for h in hits]
log(f"nuclei scanning {len(targets)} ip:port targets from masscan...")
return run_nuclei(targets, template, nuclei_rate, nuclei_concurrency, nuclei_timeout)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--mode", required=True,
choices=["masscan-only", "nmap-only", "masscan+nmap", "geo-scout", "masscan+nuclei"])
parser.add_argument("--ports", required=True)
parser.add_argument("--rate", type=int, default=3000)
parser.add_argument("--node-name", default="node")
parser.add_argument("--template", default="")
parser.add_argument("--nmap-timing", type=int, default=None, choices=[1, 2, 3, 4])
parser.add_argument("--nmap-timeout", type=int, default=None)
parser.add_argument("--nmap-workers", type=int, default=None)
parser.add_argument("--nuclei-rate", type=int, default=150)
parser.add_argument("--nuclei-concurrency", type=int, default=25)
parser.add_argument("--nuclei-timeout", type=int, default=10)
args = parser.parse_args()
global NMAP_WORKERS, NMAP_TIMING, NMAP_TIMEOUT
if args.nmap_workers is not None:
NMAP_WORKERS = args.nmap_workers
if args.nmap_timing is not None:
NMAP_TIMING = args.nmap_timing
if args.nmap_timeout is not None:
NMAP_TIMEOUT = args.nmap_timeout
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)
elif args.mode == "masscan+nuclei":
results = scan_masscan_nuclei(
args.ports, args.rate, args.template,
args.nuclei_rate, args.nuclei_concurrency, args.nuclei_timeout,
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()
+88
View File
@@ -0,0 +1,88 @@
---
# Run the assigned scan on this WEBRUNNER node
# Per-node vars: node_name, node_cidrs, node_ip_count, node_idx
# Global vars (from extra-vars): scan_mode, ports_str, masscan_rate, webrunner_name, deployment_id, use_tor
- name: Write CIDR list for {{ node_name }}
copy:
content: "{{ node_cidrs | join('\n') }}\n"
dest: /root/webrunner/cidrs.txt
mode: '0644'
- name: Run scan on {{ node_name }} ({{ node_ip_count }} IPs, mode={{ scan_mode }})
command:
argv:
- python3
- /root/webrunner/node_scanner.py
- --mode
- "{{ scan_mode }}"
- --ports
- "{{ ports_str }}"
- --rate
- "{{ masscan_rate }}"
- --node-name
- "{{ node_name }}"
- --nmap-timing
- "{{ nmap_timing | default(4) }}"
- --nmap-timeout
- "{{ nmap_timeout | default(60) }}"
- --nmap-workers
- "{{ nmap_workers | default(10) }}"
- --nuclei-rate
- "{{ nuclei_rate | default(150) }}"
- --nuclei-concurrency
- "{{ nuclei_concurrency | default(25) }}"
- --nuclei-timeout
- "{{ nuclei_timeout | default(10) }}"
- --template
- "{{ nuclei_template_remote | default('') }}"
args:
chdir: /root/webrunner
register: scan_output
async: 43200
poll: 60
ignore_errors: true
when: not (use_tor | default(false) | bool)
- name: Run scan via Tor on {{ node_name }} ({{ node_ip_count }} IPs, mode={{ scan_mode }})
command:
argv:
- proxychains4
- -f
- /etc/proxychains4.conf
- python3
- /root/webrunner/node_scanner.py
- --mode
- "{{ scan_mode }}"
- --ports
- "{{ ports_str }}"
- --rate
- "{{ masscan_rate }}"
- --node-name
- "{{ node_name }}"
- --nmap-timing
- "{{ nmap_timing | default(4) }}"
- --nmap-timeout
- "{{ nmap_timeout | default(60) }}"
- --nmap-workers
- "{{ nmap_workers | default(10) }}"
- --nuclei-rate
- "{{ nuclei_rate | default(150) }}"
- --nuclei-concurrency
- "{{ nuclei_concurrency | default(25) }}"
- --nuclei-timeout
- "{{ nuclei_timeout | default(10) }}"
- --template
- "{{ nuclei_template_remote | default('') }}"
args:
chdir: /root/webrunner
register: scan_output
async: 43200
poll: 60
ignore_errors: true
when: use_tor | default(false) | bool
- name: Show scan output for {{ node_name }}
debug:
var: scan_output.stdout_lines
when: scan_output.stdout_lines is defined