#!/usr/bin/env python3 """ geo-scout — geo-targeted network scanner masscan for port discovery, nmap for fingerprinting, probe engine for matching """ 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} __ _ ___ ___ ___ ___ ___ _ _ _ / _` / _ \\/ _ \\ ___ / __|/ __/ _ \\| | | | |_ | (_| | __/ (_) |___\\__ \\ (_| (_) | |_| | _| \\__, |\\___|\\___/ |___/\\___\\___/ \\__,_|\\__| |___/ {RST}{W} geo-targeted network scanner v1.0{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) # ── 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] print(f"\n{C}[{idx}/{n_countries}]{RST} {BOLD}{cc}{RST} — {len(cidrs)} CIDR blocks") info(f"{cc}: running masscan at {rate} pps (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()