Files
CoM-c2itall/utils/provider_rates.py
T
n0mad1k 184a5b9469 Phase-accurate webrunner cost/time estimator with masscan-Tor OPSEC warnings
Estimator rewrite:
- Phase-decomposed estimate_scan_hours: masscan, nmap-only, nmap-fingerprint,
  probe, nuclei phases each timed separately
- Accepts 6 tuning params (nmap_timing, nmap_workers, nuclei_rate,
  nuclei_concurrency, hit_rate, use_tor) — was previously ignoring all of them
- nmap-only mode fixed: was underestimated ~100x (treated nmap like packet
  scanning); now uses NMAP_TIME_BY_TIMING table per timing template
- masscan+nuclei mode now properly modeled (was falling through to
  masscan-only formula, missing the nuclei phase entirely)
- Tor multiplier scoped per-phase: applied to nmap/nuclei/probe only.
  Masscan uses raw sockets and Tor cannot proxy raw sockets — was
  previously applying Tor multiplier to masscan rate (incorrect)
- 5min/node provisioning overhead added (matters vs Linode 1h billing min)
- build_estimate_table accepts tuning dict, threads through to engine

Operator-facing changes (deploy_webrunner.py):
- Tuning section moved BEFORE estimate display so numbers reflect actual
  operator choices, not defaults
- Estimate header now shows active tuning values when set
- Caveats block printed below estimate explaining empirical limits
  (hit rate range, nuclei template variance, nmap timing tradeoffs,
  provisioning, billing minimums)
- CRITICAL OPSEC banner displayed when Tor enabled with masscan modes:
  loud red warning that masscan SYN packets bypass Tor and reveal cloud
  node IP. Includes confirmation gate — operator must affirm they
  understand before proceeding. Suggests nmap-only mode for full Tor
  coverage if attribution risk is unacceptable

Why: operator was getting estimates off by 100x for nmap-only and 0%
accurate for masscan+nuclei. Tuning controls were invisible to the
estimator. Tor + masscan was silently broken-by-design (raw sockets
bypass proxychains) without operator awareness.
2026-05-03 06:45:36 -04:00

209 lines
7.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)'},
'masscan+nuclei': {'rate': 5000, 'desc': 'masscan discovery + nuclei CVE template'},
}
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-nanode-1',
'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,
}
# ── Empirical timing constants ────────────────────────────────────────────────
# Per-host nmap time by timing template (seconds, -sV --version-intensity 5)
NMAP_TIME_BY_TIMING = {
1: 60.0, # T1 sneaky
2: 30.0, # T2 polite
3: 15.0, # T3 normal
4: 10.0, # T4 aggressive (baseline)
}
# Average per-target time for typical nuclei CVE template (13 HTTP requests).
# Heavy templates with many requests/matchers will run 25× longer.
NUCLEI_TIME_PER_TARGET_SEC = 5.0
# TCP banner grab time (geo-scout probe phase)
PROBE_TIME_PER_HOST_SEC = 3.0
# Default fraction of scanned IPs with open ports on common ports.
# Real-world range: 0.5%5% depending on ports & geography.
MASSCAN_HIT_RATE = 0.01
# Defaults — must match WEBRUNNER tuning defaults
_NMAP_WORKERS = 10
_NUCLEI_RATE = 150
_NUCLEI_CONCURRENCY = 25
# Tor latency multiplier — applies ONLY to TCP probe phases (nmap/nuclei/probe).
# Masscan uses raw sockets and bypasses proxychains entirely — Tor cannot
# protect masscan SYN packets. The cloud node's IP is exposed to every
# masscan target regardless of this setting.
TOR_PHASE_MULTIPLIER = 5.0
# Per-node provisioning overhead (apt install, optional nuclei download).
# Nodes provision in parallel so this is roughly constant.
PROVISION_OVERHEAD_HOURS = 5 / 60
def estimate_scan_hours(
ip_count: int,
n_ports: int,
rate: int,
scan_mode: str = 'masscan-only',
*,
nmap_timing: int = 4,
nmap_workers: int = _NMAP_WORKERS,
nuclei_rate: int = _NUCLEI_RATE,
nuclei_concurrency: int = _NUCLEI_CONCURRENCY,
hit_rate: float = MASSCAN_HIT_RATE,
use_tor: bool = False,
) -> float:
"""Phase-decomposed scan time estimate. Returns hours per node."""
if rate <= 0:
return PROVISION_OVERHEAD_HOURS
nmap_workers = max(nmap_workers, 1)
seconds = 0.0
# masscan phase: raw sockets, no Tor penalty (Tor cannot proxy raw sockets)
if scan_mode in ('masscan-only', 'masscan+nmap', 'geo-scout', 'masscan+nuclei'):
seconds += ip_count * n_ports / rate
# nmap-only phase: every IP gets full -sV fingerprint
if scan_mode == 'nmap-only':
per_host = NMAP_TIME_BY_TIMING.get(nmap_timing, 10.0)
if use_tor:
per_host *= TOR_PHASE_MULTIPLIER
seconds += (ip_count * per_host) / nmap_workers
# nmap fingerprint phase: only on masscan hits
if scan_mode in ('masscan+nmap', 'geo-scout'):
nmap_hosts = ip_count * hit_rate
per_host = NMAP_TIME_BY_TIMING.get(nmap_timing, 10.0)
if use_tor:
per_host *= TOR_PHASE_MULTIPLIER
seconds += (nmap_hosts * per_host) / nmap_workers
# probe banner phase: geo-scout only, on masscan hits
if scan_mode == 'geo-scout':
probe_hosts = ip_count * hit_rate
per_probe = PROBE_TIME_PER_HOST_SEC
if use_tor:
per_probe *= TOR_PHASE_MULTIPLIER
seconds += (probe_hosts * per_probe) / nmap_workers
# nuclei phase: only on masscan-discovered ip:port pairs
if scan_mode == 'masscan+nuclei':
nuclei_targets = ip_count * hit_rate
per_target = NUCLEI_TIME_PER_TARGET_SEC
if use_tor:
per_target *= TOR_PHASE_MULTIPLIER
rate_throughput = float(nuclei_rate)
parallel_throughput = nuclei_concurrency / per_target
effective_rps = max(min(rate_throughput, parallel_throughput), 1.0)
seconds += nuclei_targets / effective_rps
return seconds / 3600 + PROVISION_OVERHEAD_HOURS
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,
use_tor: bool = False,
tuning: dict | None = None,
) -> list[dict]:
tuning = tuning or {}
masscan_rate = int(tuning.get('masscan_rate') or SCAN_MODES.get(scan_mode, {'rate': 3000})['rate'])
kwargs = dict(
nmap_timing=int(tuning.get('nmap_timing', 4)),
nmap_workers=int(tuning.get('nmap_workers', _NMAP_WORKERS)),
nuclei_rate=int(tuning.get('nuclei_rate', _NUCLEI_RATE)),
nuclei_concurrency=int(tuning.get('nuclei_concurrency', _NUCLEI_CONCURRENCY)),
hit_rate=float(tuning.get('hit_rate', MASSCAN_HIT_RATE)),
use_tor=use_tor,
)
rows = []
for preset_key, preset in PRESETS.items():
n_chunks = max(1, math.ceil(total_ips / preset['chunk_size']))
typical_ips = min(preset['chunk_size'], total_ips)
hours_per_node = estimate_scan_hours(typical_ips, n_ports, masscan_rate, scan_mode, **kwargs)
total_cost = 0.0
for i in range(n_chunks):
chunk_ips = min(preset['chunk_size'], total_ips - i * preset['chunk_size'])
chunk_hours = estimate_scan_hours(chunk_ips, n_ports, masscan_rate, scan_mode, **kwargs)
provider = providers[i % len(providers)]
instance = DEFAULT_INSTANCE.get(provider, 'g6-standard-2')
total_cost += node_cost(provider, instance, chunk_hours)
rows.append({
'preset': preset_key,
'label': preset['label'],
'desc': preset['desc'],
'n_nodes': n_chunks,
'hours_per_node': hours_per_node,
'total_cost_usd': total_cost,
})
return rows