Files

395 lines
15 KiB
Python

#!/usr/bin/env python3
"""
geo-scout — geo-targeted network scanner
masscan for port discovery, nmap for fingerprinting, probe engine for matching
"""
import ipaddress
import json
import os
import sys
import tempfile
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
import yaml
BASE_DIR = Path(__file__).parent
sys.path.insert(0, str(BASE_DIR))
from lib.cidr import fetch_rir_data, load_index, get_cidrs, merge_cidrs
from lib.scanner import check_deps, masscan_sweep, nmap_fingerprint
from lib.prober import run_probe
from lib.matcher import match_target
from lib import db
INPUTS_DIR = BASE_DIR / "inputs"
DEFAULT_COUNTRIES = INPUTS_DIR / "countries.yaml"
DEFAULT_TARGETS = INPUTS_DIR / "targets.yaml"
# ── ANSI colors ───────────────────────────────────────────────────────────────
R = "\033[0;31m"
G = "\033[0;32m"
Y = "\033[0;33m"
C = "\033[0;36m"
B = "\033[1;34m"
W = "\033[0;37m"
BOLD = "\033[1m"
RST = "\033[0m"
def err(msg): print(f"{R}[!]{RST} {msg}", file=sys.stderr)
def ok(msg): print(f"{G}[+]{RST} {msg}")
def info(msg): print(f"{C}[*]{RST} {msg}")
def warn(msg): print(f"{Y}[-]{RST} {msg}")
def banner(): print(f"""{B}
· * · * · * · * ·
* ╔══════════════════════════════════╗ *
· ║ · · · · · · · · ·║ ·
* ║ · ─────────────────────────── · ║ *
· ║ ───── · ╔═══╗ · ───── ║ ·
* ║ · ──── · ────║{R}{B}║──── · ───· ║ *
· ║ ───── · ╚═══╝ · ───── ║ ·
* ║ · ─────────────────────────── · ║ *
· ║ · · · · · · · · ·║ ·
* ╚══════════════════════════════════╝ *
· * · * · * · * ·
{RST}{C}
██████ ███████ ██████ ███████ ██████ ██████ ██ ██ ████████
██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
██ ███ █████ ██ ██ ████ ███████ ██ ██ ██ ██ ██ ██
██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
██████ ███████ ██████ ███████ ██████ ██████ ██████ ██
{RST}{B} ───────────────────────────────────────────────────────────────────────
{RST}{W} geo-targeted threat surface scanner {Y}v1.0{RST} {R}[ authorized use only ]{RST}
""")
# ── Loaders ───────────────────────────────────────────────────────────────────
def load_countries(path: Path) -> dict:
if not path.exists():
err(f"Countries file not found: {path}")
sys.exit(1)
with open(path) as f:
return yaml.safe_load(f)
def load_targets(path: Path) -> list[dict]:
if not path.exists():
err(f"Targets file not found: {path}")
sys.exit(1)
with open(path) as f:
data = yaml.safe_load(f)
return data.get("targets", [])
def collect_all_ports(targets: list[dict]) -> list[int]:
ports = set()
for t in targets:
ports.update(t.get("ports", []))
return sorted(ports)
def count_ips(cidrs: list[str]) -> int:
total = 0
for c in cidrs:
try:
total += ipaddress.ip_network(c, strict=False).num_addresses
except ValueError:
pass
return total
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 fmt_eta(total_ips: int, n_ports: int, rate: int) -> str:
if rate <= 0:
return "?"
secs = int(total_ips * n_ports / rate)
m, s = divmod(secs, 60)
h, m = divmod(m, 60)
return f"{h}h {m:02d}m" if h else f"{m}m {s:02d}s"
# ── Probe worker ──────────────────────────────────────────────────────────────
def probe_host(ip: str, port: int, targets: list[dict]) -> list[dict]:
matches = []
for target in targets:
if port not in target.get("ports", []):
continue
probe_results = []
for probe in target.get("probes", []):
result = run_probe(ip, port, probe)
probe_results.append((probe, result))
hit_probes = match_target(ip, port, probe_results)
for hp in hit_probes:
matches.append({
"ip": ip,
"port": port,
"target_name": target["name"],
"tags": target.get("tags", []),
**hp,
})
return matches
# ── Commands ──────────────────────────────────────────────────────────────────
def cmd_run(args):
banner()
missing = check_deps()
if missing:
err(f"Missing required tools: {', '.join(missing)}")
err("Install with: sudo apt install " + " ".join(missing))
sys.exit(1)
countries_path = Path(args.countries) if args.countries else DEFAULT_COUNTRIES
targets_path = Path(args.targets) if args.targets else DEFAULT_TARGETS
country_data = load_countries(countries_path)
targets = load_targets(targets_path)
profile = country_data.get("scan_profile", {})
rate = profile.get("rate", 1000)
max_hosts = profile.get("max_hosts_per_country", None)
countries = sorted(
country_data.get("countries", []),
key=lambda c: {"high": 0, "medium": 1, "low": 2}.get(c.get("priority", "medium"), 1)
)
info(f"Engagement : {BOLD}{args.engagement}{RST}")
info(f"Countries : {', '.join(c['code'] for c in countries)}")
info(f"Targets : {len(targets)}")
info(f"Rate : {rate} pps")
print()
info("Loading country CIDR data...")
fetch_rir_data()
cidr_index = load_index()
conn = db.get_db(args.engagement)
run_id = db.start_run(
conn,
[c["code"] for c in countries],
[t["name"] for t in targets],
)
all_ports = collect_all_ports(targets)
info(f"Scanning ports: {', '.join(str(p) for p in all_ports)}")
print()
total_hosts = 0
total_matches = 0
workers = args.workers
n_countries = len(countries)
_interrupted = False
def progress(msg: str, end="\n"):
print(f" {W}{msg}{RST}", end=end, flush=True)
try:
with tempfile.TemporaryDirectory() as tmpdir:
tmp = Path(tmpdir)
for idx, country in enumerate(countries, 1):
cc = country["code"].upper()
exclude = country.get("exclude_cidrs", [])
cidrs = get_cidrs(cc, cidr_index)
cidrs = merge_cidrs(cidrs, exclude)
if not cidrs:
warn(f"{cc}: no CIDR ranges found, skipping")
continue
if max_hosts:
cidrs = cidrs[:max_hosts // 256 + 1]
total_ips = count_ips(cidrs)
eta = fmt_eta(total_ips, len(all_ports), rate)
print(f"\n{C}[{idx}/{n_countries}]{RST} {BOLD}{cc}{RST}{len(cidrs)} CIDR blocks {W}({fmt_ip_count(total_ips)} IPs ~{eta} at {rate} pps){RST}")
info(f"{cc}: running masscan (Ctrl-C once = skip country, twice = abort all)...")
try:
open_ports = masscan_sweep(cidrs, all_ports, rate, tmp)
_interrupted = False
except KeyboardInterrupt:
print()
if _interrupted:
raise
_interrupted = True
warn(f"{cc}: skipping — press Ctrl-C again to abort entire scan")
continue
hosts_found = len(set(e["ip"] for e in open_ports))
total_hosts += hosts_found
ok(f"{cc}: {hosts_found} live hosts, {len(open_ports)} open ports found")
if not open_ports:
continue
# Group by IP for nmap
ip_ports: dict[str, list[int]] = {}
for entry in open_ports:
ip_ports.setdefault(entry["ip"], []).append(entry["port"])
total_nmap = len(ip_ports)
info(f"{cc}: fingerprinting {total_nmap} hosts with nmap...")
nmap_results: dict[str, dict] = {}
nmap_done = 0
with ThreadPoolExecutor(max_workers=min(workers, 10)) as ex:
futs = {ex.submit(nmap_fingerprint, ip, ports, tmp): ip
for ip, ports in ip_ports.items()}
for fut in as_completed(futs):
ip = futs[fut]
nmap_done += 1
progress(f"nmap: {nmap_done}/{total_nmap}", end="\r")
try:
nmap_results[ip] = fut.result()
except Exception:
nmap_results[ip] = {}
print()
total_probes = len(open_ports)
info(f"{cc}: probing {total_probes} open ports for target matches...")
probe_done = 0
with ThreadPoolExecutor(max_workers=workers) as ex:
futs = [ex.submit(probe_host, entry["ip"], entry["port"], targets)
for entry in open_ports]
for fut in as_completed(futs):
probe_done += 1
progress(f"probe: {probe_done}/{total_probes}", end="\r")
try:
matches = fut.result()
except Exception:
continue
for match in matches:
ip = match["ip"]
port = match["port"]
nmap_info = nmap_results.get(ip, {}).get(port, {})
hit = {
**match,
"country_code": cc,
"nmap_service": nmap_info.get("service", ""),
"nmap_banner": nmap_info.get("banner", ""),
}
db.insert_hit(conn, run_id, hit)
total_matches += 1
ver = f" [{match['version']}]" if match.get("version") else ""
print(f"\r {G}HIT{RST} {BOLD}{ip}:{port}{RST} {Y}{match['target_name']}{RST}{ver} [{match['confidence']}] {cc}")
print()
except KeyboardInterrupt:
print()
warn("Scan aborted by user")
db.finish_run(conn, run_id, total_hosts, total_matches)
conn.close()
print()
ok(f"Run complete. {total_hosts} hosts scanned, {G}{BOLD}{total_matches}{RST} matches.")
info(f"Results: results/{args.engagement}.db (run_id={run_id})")
def cmd_export(args):
conn = db.get_db(args.engagement)
fmt = args.format.lower()
if fmt == "json":
data = db.export_json(conn)
out = json.dumps(data, indent=2)
elif fmt == "csv":
lines = db.export_csv_lines(conn)
out = "\n".join(lines)
else:
err(f"Unknown format: {fmt}")
sys.exit(1)
if args.output:
Path(args.output).write_text(out)
ok(f"Exported {fmt.upper()}{args.output}")
else:
print(out)
conn.close()
def cmd_update_cidr(args):
banner()
info("Force-refreshing RIR delegated data...")
fetch_rir_data(force=True)
from lib.cidr import load_index
info("Rebuilding CIDR index...")
index = load_index(force_rebuild=True)
country_count = len(index)
total_cidrs = sum(len(v) for v in index.values())
ok(f"Index built: {country_count} countries, {total_cidrs} CIDR blocks")
def cmd_list(args):
conn = db.get_db(args.engagement)
rows = conn.execute(
"SELECT target_name, country_code, COUNT(*) as c FROM hits GROUP BY target_name, country_code ORDER BY c DESC"
).fetchall()
if not rows:
warn(f"No hits in engagement: {args.engagement}")
return
print(f"\n{'Target':<40} {'Country':<8} {'Hits':>6}")
print("-" * 58)
for r in rows:
print(f" {r['target_name']:<38} {r['country_code']:<8} {r['c']:>6}")
conn.close()
# ── Entry point ───────────────────────────────────────────────────────────────
def main():
import argparse
parser = argparse.ArgumentParser(
prog="geo-scout",
description="Geo-targeted network scanner"
)
sub = parser.add_subparsers(dest="command", required=True)
# run
p_run = sub.add_parser("run", help="Run a scan")
p_run.add_argument("--engagement", "-e", required=True, help="Engagement name (used for DB filename)")
p_run.add_argument("--countries", "-c", default=None, help="Path to countries.yaml")
p_run.add_argument("--targets", "-t", default=None, help="Path to targets.yaml")
p_run.add_argument("--workers", "-w", type=int, default=20, help="Probe thread workers (default: 20)")
# export
p_exp = sub.add_parser("export", help="Export results")
p_exp.add_argument("--engagement", "-e", required=True)
p_exp.add_argument("--format", "-f", default="csv", choices=["csv", "json"])
p_exp.add_argument("--output", "-o", default=None, help="Output file (stdout if omitted)")
# update-cidr
sub.add_parser("update-cidr", help="Force refresh RIR CIDR data")
# list
p_list = sub.add_parser("list", help="Summarize hits for an engagement")
p_list.add_argument("--engagement", "-e", required=True)
args = parser.parse_args()
if args.command == "run":
cmd_run(args)
elif args.command == "export":
cmd_export(args)
elif args.command == "update-cidr":
cmd_update_cidr(args)
elif args.command == "list":
cmd_list(args)
if __name__ == "__main__":
main()