Initial build: geo-scout geo-targeted network scanner
masscan port discovery → nmap fingerprinting → probe engine → pattern/version matching Dynamic YAML inputs: countries.yaml (country codes + CIDR resolution) and targets.yaml (fingerprint probes) RIR delegated stats for authoritative country CIDR data (no external API) Supports tcp_banner, http, https, rtsp, udp probe types Version extraction and comparison operators (<=, >=, ==, etc.) SQLite results per engagement, CSV/JSON export format-spec/ docs for generating input files externally
This commit is contained in:
+323
@@ -0,0 +1,323 @@
|
||||
#!/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
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmp = Path(tmpdir)
|
||||
|
||||
for country in countries:
|
||||
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]
|
||||
|
||||
info(f"{BOLD}{cc}{RST}: {len(cidrs)} CIDR blocks → masscan...")
|
||||
|
||||
open_ports = masscan_sweep(cidrs, all_ports, rate, tmp)
|
||||
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)")
|
||||
|
||||
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"])
|
||||
|
||||
info(f"{cc}: fingerprinting {hosts_found} hosts with nmap...")
|
||||
nmap_results: dict[str, dict] = {}
|
||||
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]
|
||||
try:
|
||||
nmap_results[ip] = fut.result()
|
||||
except Exception:
|
||||
nmap_results[ip] = {}
|
||||
|
||||
info(f"{cc}: probing and matching targets...")
|
||||
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||
futs = []
|
||||
for entry in open_ports:
|
||||
futs.append(ex.submit(probe_host, entry["ip"], entry["port"], targets))
|
||||
|
||||
for fut in as_completed(futs):
|
||||
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" {G}HIT{RST} {BOLD}{ip}:{port}{RST} {Y}{match['target_name']}{RST}{ver} [{match['confidence']}] {cc}")
|
||||
|
||||
print()
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user