Add WEBRUNNER distributed geo-targeted recon module

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
This commit is contained in:
n0mad1k
2026-04-30 16:26:45 -04:00
parent 448ccaf8f8
commit 119b3e2150
15 changed files with 1314 additions and 3 deletions
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
import ipaddress
import sys
from pathlib import Path
def _try_load_geo_scout() -> bool:
candidates = [
Path(__file__).parents[2] / 'geo-scout',
Path.home() / 'tools' / 'geo-scout',
]
for p in candidates:
if (p / 'lib' / 'cidr.py').exists():
if str(p) not in sys.path:
sys.path.insert(0, str(p))
return True
return False
def ip_count(cidr: str) -> int:
try:
return ipaddress.ip_network(cidr, strict=False).num_addresses
except ValueError:
return 0
def get_country_cidrs(country_codes: list[str], exclude_map: dict[str, list] | None = None) -> dict[str, list[str]]:
if exclude_map is None:
exclude_map = {}
_try_load_geo_scout()
try:
from lib.cidr import fetch_rir_data, load_index, get_cidrs, merge_cidrs
except ImportError:
return {cc.upper(): [] for cc in country_codes}
fetch_rir_data()
index = load_index()
result = {}
for cc in country_codes:
cc = cc.upper()
cidrs = get_cidrs(cc, index)
cidrs = merge_cidrs(cidrs, exclude_map.get(cc, []))
result[cc] = cidrs
return result
def chunk_cidrs(all_cidrs: list[str], chunk_size: int) -> list[dict]:
chunks = []
current_cidrs: list[str] = []
current_count = 0
for cidr in all_cidrs:
n = ip_count(cidr)
if n == 0:
continue
if current_count > 0 and current_count + n > chunk_size * 1.5:
chunks.append({'cidrs': current_cidrs, 'ip_count': current_count})
current_cidrs = []
current_count = 0
current_cidrs.append(cidr)
current_count += n
if current_count >= chunk_size:
chunks.append({'cidrs': current_cidrs, 'ip_count': current_count})
current_cidrs = []
current_count = 0
if current_cidrs:
chunks.append({'cidrs': current_cidrs, 'ip_count': current_count})
return chunks
+5 -3
View File
@@ -215,9 +215,10 @@ def show_naming_relationship(name, deployment_id, deployment_type):
'c2': 's-', # s for server
'tracker': 't-',
'attack_box': 'a-',
'payload': 'p-'
'payload': 'p-',
'webrunner': 'wr-',
}
expected_prefix = prefix_map.get(deployment_type, '')
if expected_prefix and name.startswith(expected_prefix):
@@ -239,6 +240,7 @@ def get_deployment_type_prefix(deployment_type):
'tracker': 't-',
'attack_box': 'a-',
'payload': 'p-',
'phishing': 'p-'
'phishing': 'p-',
'webrunner': 'wr-',
}
return prefix_map.get(deployment_type, '')
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
import math
INSTANCE_RATES = {
'linode': {
'g6-nanode-1': 0.0075,
'g6-standard-2': 0.018,
'g6-standard-4': 0.036,
'g6-standard-8': 0.072,
},
'aws': {
't3.micro': 0.0104,
't3.small': 0.0208,
't3.medium': 0.0416,
't3.large': 0.0832,
},
'flokinet': {
'vps-1': 0.0083,
'vps-2': 0.0139,
'vps-4': 0.0278,
},
}
SCAN_MODES = {
'geo-scout': {'rate': 3000, 'desc': 'masscan + nmap + probe fingerprinting'},
'masscan-only': {'rate': 10000, 'desc': 'masscan port discovery only'},
'nmap-only': {'rate': 500, 'desc': 'nmap full fingerprint only'},
'masscan+nmap': {'rate': 5000, 'desc': 'masscan + nmap (no probes)'},
}
PRESETS = {
'sprint': {'chunk_size': 500_000, 'label': 'Sprint', 'desc': '~500K IPs/node'},
'balanced': {'chunk_size': 2_000_000, 'label': 'Balanced', 'desc': '~2M IPs/node (recommended)'},
'economy': {'chunk_size': 5_000_000, 'label': 'Economy', 'desc': '~5M IPs/node'},
}
DEFAULT_INSTANCE = {
'linode': 'g6-standard-2',
'aws': 't3.small',
'flokinet': 'vps-2',
}
BILLING_MINIMUM = {
'linode': 1.0,
'aws': 0.017, # billed per second, ~1 min minimum in practice
'flokinet': 1.0,
}
def estimate_scan_hours(ip_count: int, n_ports: int, rate: int) -> float:
if rate <= 0:
return 0.0
return (ip_count * n_ports / rate) / 3600
def node_cost(provider: str, instance_type: str, scan_hours: float) -> float:
rate = INSTANCE_RATES.get(provider, {}).get(instance_type, 0.018)
billed_hours = max(scan_hours, BILLING_MINIMUM.get(provider, 1.0))
return rate * billed_hours
def fmt_hours(h: float) -> str:
total_mins = int(h * 60)
hrs, mins = divmod(total_mins, 60)
return f"{hrs}h {mins:02d}m" if hrs else f"{mins}m"
def fmt_ip_count(n: int) -> str:
if n >= 1_000_000:
return f"{n / 1_000_000:.1f}M"
if n >= 1_000:
return f"{n / 1_000:.0f}K"
return str(n)
def build_estimate_table(
total_ips: int,
n_ports: int,
providers: list[str],
scan_mode: str,
) -> list[dict]:
mode_rate = SCAN_MODES.get(scan_mode, {'rate': 3000})['rate']
rows = []
for preset_key, preset in PRESETS.items():
n_chunks = max(1, math.ceil(total_ips / preset['chunk_size']))
hours = estimate_scan_hours(preset['chunk_size'], n_ports, mode_rate)
total_cost = 0.0
for i in range(n_chunks):
provider = providers[i % len(providers)]
instance = DEFAULT_INSTANCE.get(provider, 'g6-standard-2')
total_cost += node_cost(provider, instance, hours)
rows.append({
'preset': preset_key,
'label': preset['label'],
'desc': preset['desc'],
'n_nodes': n_chunks,
'hours_per_node': hours,
'total_cost_usd': total_cost,
})
return rows