395 lines
15 KiB
Python
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()
|