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
382 lines
15 KiB
Python
382 lines
15 KiB
Python
#!/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, wait_for_input,
|
|
get_public_ip, load_vars_file,
|
|
)
|
|
from utils.ssh_utils import generate_ssh_key
|
|
from utils.name_generator import generate_deployment_id
|
|
from utils.naming_utils import get_deployment_name_with_options
|
|
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,
|
|
)
|
|
|
|
GEO_SCOUT_INPUTS = Path(__file__).parents[3] / 'geo-scout' / 'inputs'
|
|
|
|
SUPPORTED_PROVIDERS = ['linode', 'aws', 'flokinet']
|
|
PROVIDER_LABELS = {'linode': 'Linode', 'aws': 'AWS', 'flokinet': 'FlokiNET'}
|
|
|
|
|
|
# ── menu ──────────────────────────────────────────────────────────────────────
|
|
|
|
def webrunner_menu():
|
|
while True:
|
|
clear_screen()
|
|
print_banner()
|
|
print(f"{COLORS['CYAN']}WEBRUNNER — Distributed Geo-Targeted Recon{COLORS['RESET']}")
|
|
print(f"{COLORS['CYAN']}==========================================={COLORS['RESET']}")
|
|
print(f"1) New Scan Deployment")
|
|
print(f"\n99) Return to Main Menu")
|
|
|
|
choice = input(f"\nSelect: ").strip()
|
|
if choice == "1":
|
|
config = gather_webrunner_parameters()
|
|
if config:
|
|
execute_webrunner_deployment(config)
|
|
wait_for_input()
|
|
elif choice == "99":
|
|
break
|
|
else:
|
|
print(f"{COLORS['RED']}Invalid option.{COLORS['RESET']}")
|
|
wait_for_input()
|
|
|
|
|
|
# ── parameter gathering ───────────────────────────────────────────────────────
|
|
|
|
def _select_countries() -> tuple[list[str], dict[str, list], str]:
|
|
print(f"\n{COLORS['BLUE']}Country Selection:{COLORS['RESET']}")
|
|
print(f"1) Use geo-scout 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(GEO_SCOUT_INPUTS / 'countries.yaml')
|
|
raw = input(f"Path [{default_path}]: ").strip()
|
|
path = Path(raw) if raw else GEO_SCOUT_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, str(path)
|
|
|
|
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, "manual"
|
|
|
|
|
|
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 _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 _get_targets_info(scan_mode: str) -> tuple[str, list[int]]:
|
|
if scan_mode == 'geo-scout':
|
|
default = str(GEO_SCOUT_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 _show_estimate_table(total_ips: int, n_ports: int, providers: list[str], scan_mode: str):
|
|
rows = build_estimate_table(total_ips, n_ports, providers, scan_mode)
|
|
|
|
W = COLORS['WHITE']
|
|
C = COLORS['CYAN']
|
|
Y = COLORS['YELLOW']
|
|
G = COLORS['GREEN']
|
|
R = COLORS['RESET']
|
|
|
|
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)}{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")
|
|
|
|
|
|
def gather_webrunner_parameters() -> dict | None:
|
|
clear_screen()
|
|
print_banner()
|
|
print(f"{COLORS['CYAN']}WEBRUNNER — New Scan Deployment{COLORS['RESET']}")
|
|
print(f"{COLORS['CYAN']}================================{COLORS['RESET']}\n")
|
|
|
|
engagement = input("Engagement name: ").strip()
|
|
if not engagement:
|
|
print(f"{COLORS['RED']}Engagement name required.{COLORS['RESET']}")
|
|
wait_for_input()
|
|
return None
|
|
|
|
country_codes, exclude_map, _ = _select_countries()
|
|
if not country_codes:
|
|
print(f"{COLORS['RED']}No countries selected.{COLORS['RESET']}")
|
|
wait_for_input()
|
|
return None
|
|
|
|
print(f"{COLORS['GREEN']}Countries: {', '.join(country_codes)}{COLORS['RESET']}")
|
|
|
|
scan_mode = _select_scan_mode()
|
|
providers = _select_providers()
|
|
print(f"{COLORS['GREEN']}Providers: {', '.join(PROVIDER_LABELS[p] for p in providers)}{COLORS['RESET']}")
|
|
|
|
targets_file, ports = _get_targets_info(scan_mode)
|
|
|
|
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. Ensure geo-scout RIR data is available.{COLORS['RESET']}")
|
|
wait_for_input()
|
|
return None
|
|
|
|
print(f"{COLORS['GREEN']}Total: {fmt_ip_count(total_ips)} IPs across {len(country_codes)} countries{COLORS['RESET']}")
|
|
|
|
_show_estimate_table(total_ips, len(ports), providers, scan_mode)
|
|
|
|
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']
|
|
|
|
opsec_raw = input(f"\nEnhanced OPSEC mode? (randomize names, auto-teardown) [y/N]: ").strip().lower()
|
|
enhanced_opsec = opsec_raw in ['y', 'yes']
|
|
|
|
print(f"\n{COLORS['CYAN']}[*] Generating deployment assets...{COLORS['RESET']}")
|
|
deployment_id = generate_deployment_id()
|
|
webrunner_name = get_deployment_name_with_options('webrunner', deployment_id, prefix='wr-')
|
|
|
|
raw_key_path = generate_ssh_key(webrunner_name)
|
|
if not raw_key_path:
|
|
print(f"{COLORS['RED']}Failed to generate SSH key.{COLORS['RESET']}")
|
|
wait_for_input()
|
|
return None
|
|
|
|
config: dict = {
|
|
'engagement': engagement,
|
|
'deployment_id': deployment_id,
|
|
'deployment_type': 'webrunner',
|
|
'webrunner_deployment': True,
|
|
'webrunner_name': webrunner_name,
|
|
'ssh_key_path': f"{raw_key_path}.pub",
|
|
'ssh_key_name': os.path.basename(raw_key_path),
|
|
'providers': providers,
|
|
'scan_mode': scan_mode,
|
|
'preset': preset_key,
|
|
'chunk_size': chunk_size,
|
|
'country_codes': country_codes,
|
|
'ports': ports,
|
|
'ports_str': ','.join(str(p) for p in ports),
|
|
'targets_file': targets_file,
|
|
'masscan_rate': SCAN_MODES.get(scan_mode, {}).get('rate', 3000),
|
|
'enhanced_opsec': enhanced_opsec,
|
|
'linode_instance_type': DEFAULT_INSTANCE.get('linode', 'g6-standard-2'),
|
|
'linode_region': 'us-east',
|
|
'aws_instance_type': DEFAULT_INSTANCE.get('aws', 't3.small'),
|
|
'aws_region': 'us-east-1',
|
|
}
|
|
|
|
operator_ip = get_public_ip()
|
|
if operator_ip:
|
|
config['operator_ip'] = operator_ip
|
|
print(f"{COLORS['GREEN']}Operator IP: {operator_ip}{COLORS['RESET']}")
|
|
|
|
for provider in providers:
|
|
vars_data = load_vars_file(provider)
|
|
if vars_data:
|
|
config.update({k: v for k, v in vars_data.items() if k not in config})
|
|
|
|
print(f"{COLORS['CYAN']}[*] Distributing {fmt_ip_count(total_ips)} IPs across nodes...{COLORS['RESET']}")
|
|
all_cidrs: list[str] = []
|
|
for cc in country_codes:
|
|
all_cidrs.extend(cc_cidrs.get(cc.upper(), []))
|
|
|
|
chunks = chunk_cidrs(all_cidrs, chunk_size)
|
|
node_chunks = [
|
|
{
|
|
'idx': i,
|
|
'node_name': f"{webrunner_name}-{i + 1:02d}",
|
|
'provider': providers[i % len(providers)],
|
|
'cidrs': chunk['cidrs'],
|
|
'ip_count': chunk['ip_count'],
|
|
}
|
|
for i, chunk in enumerate(chunks)
|
|
]
|
|
|
|
print(f"{COLORS['GREEN']}Nodes: {len(node_chunks)} ({preset_key}, {fmt_ip_count(chunk_size)}/node){COLORS['RESET']}")
|
|
|
|
print(f"\n{COLORS['YELLOW']}{'─' * 50}{COLORS['RESET']}")
|
|
print(f" Deployment: {webrunner_name}")
|
|
print(f" Nodes: {len(node_chunks)}")
|
|
print(f" Providers: {', '.join(PROVIDER_LABELS[p] for p in providers)}")
|
|
print(f" Scan mode: {scan_mode}")
|
|
print(f" Ports: {', '.join(str(p) for p in ports[:8])}{'...' if len(ports) > 8 else ''}")
|
|
print(f" Total IPs: {fmt_ip_count(total_ips)}")
|
|
print(f"{COLORS['YELLOW']}{'─' * 50}{COLORS['RESET']}")
|
|
|
|
confirm = input(f"\nDeploy {len(node_chunks)} nodes? [y/N]: ").strip().lower()
|
|
if confirm not in ['y', 'yes']:
|
|
print(f"{COLORS['YELLOW']}Deployment cancelled.{COLORS['RESET']}")
|
|
return None
|
|
|
|
config['node_chunks'] = node_chunks
|
|
config['total_ips'] = total_ips
|
|
return config
|
|
|
|
|
|
# ── execution ─────────────────────────────────────────────────────────────────
|
|
|
|
def execute_webrunner_deployment(config: dict):
|
|
deployment_id = config['deployment_id']
|
|
node_chunks = config.pop('node_chunks')
|
|
|
|
os.makedirs('logs', exist_ok=True)
|
|
chunks_file = f"logs/node_chunks_{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'] = f"logs/scanner_ips_{config['webrunner_name']}.txt"
|
|
config['results_dir'] = f"logs/webrunner_{deployment_id}"
|
|
|
|
for provider in config['providers']:
|
|
set_provider_environment({**config, 'provider': provider})
|
|
|
|
playbook = 'providers/webrunner.yml'
|
|
n_nodes = len(node_chunks)
|
|
|
|
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_{deployment_id}/")
|
|
print(f" Scanner IPs: logs/scanner_ips_{config['webrunner_name']}.txt")
|
|
print(f" Log: logs/deployment_{deployment_id}.log")
|
|
else:
|
|
print(f"\n{COLORS['RED']}[-] WEBRUNNER failed. Check: logs/deployment_{deployment_id}.log{COLORS['RESET']}")
|