From c20f189d49203482381f94e375f27aa60941eb74 Mon Sep 17 00:00:00 2001 From: n0mad1k Date: Thu, 30 Apr 2026 10:38:20 -0400 Subject: [PATCH] Initial build: geo-scout geo-targeted network scanner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- format-spec/countries-format.md | 77 ++++++ format-spec/targets-format.md | 174 ++++++++++++ geo-scout.py | 323 +++++++++++++++++++++++ inputs/countries.yaml | 20 ++ inputs/targets.yaml | 72 +++++ lib/__init__.py | 0 lib/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 142 bytes lib/__pycache__/cidr.cpython-313.pyc | Bin 0 -> 6155 bytes lib/__pycache__/db.cpython-313.pyc | Bin 0 -> 6391 bytes lib/__pycache__/matcher.cpython-313.pyc | Bin 0 -> 5046 bytes lib/__pycache__/prober.cpython-313.pyc | Bin 0 -> 7715 bytes lib/__pycache__/scanner.cpython-313.pyc | Bin 0 -> 5381 bytes lib/cidr.py | 104 ++++++++ lib/db.py | 117 ++++++++ lib/matcher.py | 90 +++++++ lib/prober.py | 125 +++++++++ lib/scanner.py | 118 +++++++++ requirements.txt | 1 + 18 files changed, 1221 insertions(+) create mode 100644 format-spec/countries-format.md create mode 100644 format-spec/targets-format.md create mode 100644 geo-scout.py create mode 100644 inputs/countries.yaml create mode 100644 inputs/targets.yaml create mode 100644 lib/__init__.py create mode 100644 lib/__pycache__/__init__.cpython-313.pyc create mode 100644 lib/__pycache__/cidr.cpython-313.pyc create mode 100644 lib/__pycache__/db.cpython-313.pyc create mode 100644 lib/__pycache__/matcher.cpython-313.pyc create mode 100644 lib/__pycache__/prober.cpython-313.pyc create mode 100644 lib/__pycache__/scanner.cpython-313.pyc create mode 100644 lib/cidr.py create mode 100644 lib/db.py create mode 100644 lib/matcher.py create mode 100644 lib/prober.py create mode 100644 lib/scanner.py create mode 100644 requirements.txt diff --git a/format-spec/countries-format.md b/format-spec/countries-format.md new file mode 100644 index 0000000..c5ad278 --- /dev/null +++ b/format-spec/countries-format.md @@ -0,0 +1,77 @@ +# countries.yaml — Format Specification + +## Purpose +Defines which countries to scan and scan parameters per country. +This file is read by geo-scout.py at runtime. The scanner resolves each +country code to its CIDR ranges using RIR delegated stats files. + +## Schema + +```yaml +scan_profile: + name: string # Label for this profile (used in logs/reports) + rate: integer # masscan packets/sec (default: 1000, max: 100000) + max_hosts_per_country: integer|null # Cap IPs per country. null = no limit + +countries: + - code: string # ISO 3166-1 alpha-2 (e.g. US, VE, NL, GB, NG) + priority: high|medium|low # Scan order. high = first + exclude_cidrs: # Optional CIDR blocks to skip within this country + - "x.x.x.x/xx" + notes: string # Optional context (not used by scanner) +``` + +## Rules + +- `code` must be a valid ISO 3166-1 alpha-2 code +- GB is the correct code for United Kingdom (not UK) +- `rate` applies globally to the masscan run, not per-country +- `exclude_cidrs` entries must be valid CIDR notation +- Countries are scanned in priority order: high → medium → low +- Within same priority, order in the list is preserved + +## Valid priority values +`high` | `medium` | `low` + +## Common country codes +| Country | Code | +|---------|------| +| United States | US | +| United Kingdom | GB | +| Venezuela | VE | +| Netherlands | NL | +| Canada | CA | +| Nigeria | NG | +| Russia | RU | +| Germany | DE | +| China | CN | +| Brazil | BR | +| Iran | IR | +| India | IN | +| France | FR | +| Australia | AU | + +## Example + +```yaml +scan_profile: + name: "latam-europe-sweep" + rate: 2000 + max_hosts_per_country: 100000 + +countries: + - code: VE + priority: high + exclude_cidrs: [] + notes: "Primary target" + + - code: US + priority: medium + exclude_cidrs: + - "10.0.0.0/8" + - "172.16.0.0/12" + - "192.168.0.0/16" + + - code: NL + priority: low +``` diff --git a/format-spec/targets-format.md b/format-spec/targets-format.md new file mode 100644 index 0000000..f7921b9 --- /dev/null +++ b/format-spec/targets-format.md @@ -0,0 +1,174 @@ +# targets.yaml — Format Specification + +## Purpose +Defines what to look for during a scan. Each target is a fingerprint with +one or more probes. The scanner runs masscan to find open ports, then fires +each probe against matching hosts, and applies pattern/version matching. + +## Schema + +```yaml +targets: + - name: string # Human-readable label (appears in results) + tags: [string, ...] # Free-form tags for grouping/filtering results + ports: [integer, ...] # Ports masscan will scan for this target + probes: + - type: tcp_banner|http|https|rtsp|udp + # --- For http/https --- + path: string # URL path (e.g. "/", "/login.asp", "/api/version") + method: GET|POST # Default: GET + headers: # Optional extra request headers + Header-Name: value + body: string # POST body (optional) + match_in: body|headers|[body, headers] # Where to search for patterns + # --- For tcp_banner --- + # (no extra fields — reads the raw TCP banner on connect) + # --- For rtsp --- + # (sends OPTIONS * RTSP/1.0 and matches banner) + # --- Pattern matching (all probe types) --- + patterns: # At least one must match (OR logic) + - "regex or plain string" + all_patterns: # All must match (AND logic, optional) + - "regex or plain string" + # --- Version extraction (optional) --- + version_extract: "regex with one capture group" + version_compare: # Optional — filter by version + operator: "<="|">="|"=="|"!="|"<"|">" + value: "string or number" + # --- Confidence --- + confidence: high|medium|low # Default: medium +``` + +## Rules + +- A target matches a host if ANY probe matches +- Within a probe, `patterns` uses OR logic (any one pattern is enough) +- `all_patterns` uses AND logic — use when you need multiple strings to co-occur +- `version_extract` must contain exactly one regex capture group `()` +- Version comparison is string-aware for dotted versions (e.g. "20.3" < "20.17") +- `match_in` defaults to `body` for http/https probes +- For `tcp_banner` type, the banner is the raw bytes received on connect +- Patterns are case-insensitive by default; prefix with `(?-i)` to force case-sensitive +- Tags are free-form strings — use them for filtering with `--tag` at runtime + +## Probe types +| Type | Description | +|------|-------------| +| `tcp_banner` | Connect and read raw banner | +| `http` | HTTP GET/POST, match response | +| `https` | HTTPS GET/POST, match response (cert errors ignored) | +| `rtsp` | RTSP OPTIONS probe, match response | +| `udp` | Send empty UDP, match response | + +## Common port reference +| Service | Ports | +|---------|-------| +| HTTP | 80, 8080, 8000, 8888 | +| HTTPS | 443, 8443 | +| SSH | 22 | +| Telnet | 23 | +| FTP | 21 | +| RTSP (cameras) | 554, 8554 | +| ONVIF (cameras) | 80, 8080 | +| DVR/NVR | 37777, 34567 | +| RDP | 3389 | +| SMB | 445 | +| Redis | 6379 | +| Elasticsearch | 9200 | +| MongoDB | 27017 | +| MySQL | 3306 | +| PostgreSQL | 5432 | + +## Examples + +### D-Link DIR-823X firmware 240126 or 240802 +```yaml +- name: "D-Link DIR-823X fw 240126/240802" + tags: [router, d-link, cpe, iot] + ports: [80, 443, 8080] + probes: + - type: http + path: "/" + match_in: body + patterns: ["DIR-823X"] + all_patterns: [] + - type: http + path: "/" + match_in: body + patterns: ["240126", "240802"] + confidence: high +``` + +### Exposed IP cameras (any brand) +```yaml +- name: "Exposed IP Camera" + tags: [camera, iot, surveillance] + ports: [80, 554, 8080, 8443, 37777, 34567] + probes: + - type: http + path: "/" + match_in: [body, headers] + patterns: + - "(?i)hikvision" + - "(?i)dahua" + - "(?i)ip camera" + - "(?i)ipcam" + - "(?i)webcam" + - "(?i)nvr" + - "(?i)dvr" + - "(?i)axis" + - "(?i)reolink" + - "(?i)amcrest" + - type: rtsp + patterns: ["RTSP/1.0 200"] + confidence: medium +``` + +### Cisco Catalyst SD-WAN Manager <= 20.17 +```yaml +- name: "Cisco SD-WAN vManage <= 20.17" + tags: [cisco, sdwan, network, cve] + ports: [443, 8443] + probes: + - type: https + path: "/dataservice/client/server" + method: GET + match_in: body + patterns: ["vmanage", "platformVersion"] + version_extract: '"platformVersion":"([0-9.]+)"' + version_compare: + operator: "<=" + value: "20.17" + confidence: high + - type: https + path: "/" + match_in: [body, headers] + patterns: ["vManage", "Cisco SD-WAN"] + confidence: low +``` + +### Ubuntu 24.04 SSH +```yaml +- name: "Ubuntu 24.04 SSH" + tags: [linux, ubuntu, ssh] + ports: [22] + probes: + - type: tcp_banner + patterns: + - "Ubuntu-24" + - "OpenSSH.*Ubuntu" + version_extract: "SSH-2.0-OpenSSH_([0-9p.]+)" + confidence: high +``` + +### Open Redis (unauthenticated) +```yaml +- name: "Open Redis" + tags: [database, redis, exposed] + ports: [6379] + probes: + - type: tcp_banner + patterns: ["redis_version"] + version_extract: "redis_version:([0-9.]+)" + confidence: high +``` diff --git a/geo-scout.py b/geo-scout.py new file mode 100644 index 0000000..9f31939 --- /dev/null +++ b/geo-scout.py @@ -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() diff --git a/inputs/countries.yaml b/inputs/countries.yaml new file mode 100644 index 0000000..efee87d --- /dev/null +++ b/inputs/countries.yaml @@ -0,0 +1,20 @@ +scan_profile: + name: "example-sweep" + rate: 1000 + max_hosts_per_country: null + +countries: + - code: VE + priority: high + exclude_cidrs: [] + + - code: US + priority: medium + exclude_cidrs: + - "10.0.0.0/8" + - "172.16.0.0/12" + - "192.168.0.0/16" + + - code: NL + priority: low + exclude_cidrs: [] diff --git a/inputs/targets.yaml b/inputs/targets.yaml new file mode 100644 index 0000000..0b11b72 --- /dev/null +++ b/inputs/targets.yaml @@ -0,0 +1,72 @@ +targets: + - name: "Ubuntu 24.04 SSH" + tags: [linux, ubuntu, ssh] + ports: [22] + probes: + - type: tcp_banner + patterns: + - "Ubuntu-24" + - "OpenSSH.*Ubuntu" + version_extract: "SSH-2\\.0-OpenSSH_([0-9p.]+)" + confidence: high + + - name: "D-Link DIR-823X fw 240126/240802" + tags: [router, d-link, cpe, iot] + ports: [80, 443, 8080] + probes: + - type: http + path: "/" + match_in: body + patterns: ["DIR-823X"] + confidence: medium + - type: http + path: "/" + match_in: body + all_patterns: ["DIR-823X"] + patterns: ["240126", "240802"] + confidence: high + + - name: "Exposed IP Camera" + tags: [camera, iot, surveillance] + ports: [80, 554, 8080, 8443, 37777, 34567] + probes: + - type: http + path: "/" + match_in: [body, headers] + patterns: + - "(?i)hikvision" + - "(?i)dahua" + - "(?i)ip.?camera" + - "(?i)ipcam" + - "(?i)webcam" + - "(?i)reolink" + - "(?i)amcrest" + - "(?i)axis" + confidence: medium + - type: rtsp + patterns: ["RTSP/1.0 200"] + confidence: medium + + - name: "Cisco SD-WAN vManage <= 20.17" + tags: [cisco, sdwan, network] + ports: [443, 8443] + probes: + - type: https + path: "/dataservice/client/server" + method: GET + match_in: body + patterns: ["platformVersion"] + version_extract: '"platformVersion":"([0-9.]+)"' + version_compare: + operator: "<=" + value: "20.17" + confidence: high + + - name: "Open Redis" + tags: [database, redis, exposed] + ports: [6379] + probes: + - type: tcp_banner + patterns: ["redis_version"] + version_extract: "redis_version:([0-9.]+)" + confidence: high diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/__pycache__/__init__.cpython-313.pyc b/lib/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a5a4470a674e981c183dfb11d1e0b88ced093684 GIT binary patch literal 142 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 M>QcW#s;0UB1D0qd6951J literal 0 HcmV?d00001 diff --git a/lib/__pycache__/db.cpython-313.pyc b/lib/__pycache__/db.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d14d847d9c85c654d47ca526a7919192319ff04f GIT binary patch literal 6391 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 N_Y4xm literal 0 HcmV?d00001 diff --git a/lib/__pycache__/prober.cpython-313.pyc b/lib/__pycache__/prober.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b0e3f638bca01fdb3e43741be8010ec6e97957d8 GIT binary patch literal 7715 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 E3#v5WSO5S3 literal 0 HcmV?d00001 diff --git a/lib/__pycache__/scanner.cpython-313.pyc b/lib/__pycache__/scanner.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0cde6981737811830a3884055da448c1f5e6882c GIT binary patch literal 5381 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 Path: + return CACHE_DIR / f"{rir}.txt" + + +def _index_path() -> Path: + return CACHE_DIR / "index.json" + + +def _is_stale(path: Path) -> bool: + if not path.exists(): + return True + return time.time() - path.stat().st_mtime > CACHE_TTL + + +def fetch_rir_data(force: bool = False) -> None: + CACHE_DIR.mkdir(parents=True, exist_ok=True) + for name, url in zip(RIR_NAMES, RIR_URLS): + path = _cache_path(name) + if not force and not _is_stale(path): + continue + print(f" Fetching {name}...", end=" ", flush=True) + try: + req = urllib.request.Request(url, headers={"User-Agent": "geo-scout/1.0"}) + with urllib.request.urlopen(req, timeout=30) as resp: + path.write_bytes(resp.read()) + print("done") + except Exception as e: + print(f"failed ({e})") + + +def _build_index() -> dict[str, list[str]]: + index: dict[str, list[str]] = {} + for name in RIR_NAMES: + path = _cache_path(name) + if not path.exists(): + continue + for line in path.read_text(errors="replace").splitlines(): + if line.startswith("#") or not line.strip(): + continue + parts = line.split("|") + if len(parts) < 7: + continue + _, cc, record_type, start, value, _, status = parts[:7] + if record_type != "ipv4" or status not in ("allocated", "assigned"): + continue + if not cc or cc == "*": + continue + cc = cc.upper() + try: + count = int(value) + prefix_len = 32 - (count - 1).bit_length() + cidr = f"{start}/{prefix_len}" + ipaddress.ip_network(cidr, strict=False) + except (ValueError, TypeError): + continue + index.setdefault(cc, []).append(cidr) + return index + + +def load_index(force_rebuild: bool = False) -> dict[str, list[str]]: + idx_path = _index_path() + if not force_rebuild and idx_path.exists() and not _is_stale(idx_path): + return json.loads(idx_path.read_text()) + index = _build_index() + idx_path.write_text(json.dumps(index)) + return index + + +def get_cidrs(country_code: str, index: dict[str, list[str]]) -> list[str]: + return index.get(country_code.upper(), []) + + +def merge_cidrs(cidrs: list[str], exclude: list[str]) -> list[str]: + if not exclude: + return cidrs + exclude_nets = [ipaddress.ip_network(c, strict=False) for c in exclude] + result = [] + for cidr in cidrs: + net = ipaddress.ip_network(cidr, strict=False) + excluded = any(net.overlaps(ex) for ex in exclude_nets) + if not excluded: + result.append(cidr) + return result diff --git a/lib/db.py b/lib/db.py new file mode 100644 index 0000000..8a3e442 --- /dev/null +++ b/lib/db.py @@ -0,0 +1,117 @@ +import json +import sqlite3 +from datetime import datetime +from pathlib import Path + +RESULTS_DIR = Path(__file__).parent.parent / "results" + + +def get_db(engagement: str) -> sqlite3.Connection: + RESULTS_DIR.mkdir(parents=True, exist_ok=True) + db_path = RESULTS_DIR / f"{engagement}.db" + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + _init(conn) + return conn + + +def _init(conn: sqlite3.Connection) -> None: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at TEXT NOT NULL, + finished_at TEXT, + countries TEXT, + targets TEXT, + total_hosts INTEGER DEFAULT 0, + total_matches INTEGER DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS hits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id INTEGER NOT NULL, + ip TEXT NOT NULL, + port INTEGER NOT NULL, + country_code TEXT, + target_name TEXT NOT NULL, + tags TEXT, + probe_type TEXT, + confidence TEXT, + version TEXT, + nmap_service TEXT, + nmap_banner TEXT, + found_at TEXT NOT NULL, + FOREIGN KEY (run_id) REFERENCES runs(id) + ); + + CREATE INDEX IF NOT EXISTS idx_hits_ip ON hits(ip); + CREATE INDEX IF NOT EXISTS idx_hits_target ON hits(target_name); + CREATE INDEX IF NOT EXISTS idx_hits_country ON hits(country_code); + CREATE INDEX IF NOT EXISTS idx_hits_run ON hits(run_id); + """) + conn.commit() + + +def start_run(conn: sqlite3.Connection, countries: list, targets: list) -> int: + cur = conn.execute( + "INSERT INTO runs (started_at, countries, targets) VALUES (?, ?, ?)", + (datetime.utcnow().isoformat(), json.dumps(countries), json.dumps(targets)), + ) + conn.commit() + return cur.lastrowid + + +def finish_run(conn: sqlite3.Connection, run_id: int, total_hosts: int, total_matches: int) -> None: + conn.execute( + "UPDATE runs SET finished_at=?, total_hosts=?, total_matches=? WHERE id=?", + (datetime.utcnow().isoformat(), total_hosts, total_matches, run_id), + ) + conn.commit() + + +def insert_hit(conn: sqlite3.Connection, run_id: int, hit: dict) -> None: + conn.execute( + """INSERT INTO hits + (run_id, ip, port, country_code, target_name, tags, probe_type, + confidence, version, nmap_service, nmap_banner, found_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + run_id, + hit["ip"], + hit["port"], + hit.get("country_code", ""), + hit["target_name"], + json.dumps(hit.get("tags", [])), + hit.get("probe_type", ""), + hit.get("confidence", ""), + hit.get("version", ""), + hit.get("nmap_service", ""), + hit.get("nmap_banner", ""), + datetime.utcnow().isoformat(), + ), + ) + conn.commit() + + +def export_json(conn: sqlite3.Connection, run_id: int | None = None) -> list[dict]: + if run_id: + rows = conn.execute("SELECT * FROM hits WHERE run_id=?", (run_id,)).fetchall() + else: + rows = conn.execute("SELECT * FROM hits").fetchall() + return [dict(r) for r in rows] + + +def export_csv_lines(conn: sqlite3.Connection, run_id: int | None = None) -> list[str]: + header = "ip,port,country_code,target_name,confidence,version,nmap_service,nmap_banner,found_at" + if run_id: + rows = conn.execute("SELECT * FROM hits WHERE run_id=?", (run_id,)).fetchall() + else: + rows = conn.execute("SELECT * FROM hits ORDER BY found_at").fetchall() + lines = [header] + for r in rows: + lines.append(",".join([ + str(r["ip"]), str(r["port"]), r["country_code"] or "", + r["target_name"], r["confidence"] or "", r["version"] or "", + r["nmap_service"] or "", r["nmap_banner"] or "", r["found_at"], + ])) + return lines diff --git a/lib/matcher.py b/lib/matcher.py new file mode 100644 index 0000000..7278e9d --- /dev/null +++ b/lib/matcher.py @@ -0,0 +1,90 @@ +import re +from typing import Optional +from .prober import ProbeResult + + +def _version_compare(extracted: str, operator: str, threshold: str) -> bool: + def to_tuple(v: str): + try: + return tuple(int(x) for x in v.split(".")) + except ValueError: + return (v,) + + ev = to_tuple(extracted) + tv = to_tuple(threshold) + if operator == "<=": + return ev <= tv + elif operator == ">=": + return ev >= tv + elif operator == "<": + return ev < tv + elif operator == ">": + return ev > tv + elif operator == "==": + return ev == tv + elif operator == "!=": + return ev != tv + return False + + +def _get_search_text(result: ProbeResult, match_in) -> str: + if isinstance(match_in, list): + parts = [] + if "body" in match_in: + parts.append(result.body or result.banner) + if "headers" in match_in: + parts.append(" ".join(f"{k}: {v}" for k, v in result.headers.items())) + return "\n".join(parts) + if match_in == "headers": + return " ".join(f"{k}: {v}" for k, v in result.headers.items()) + return result.body or result.banner + + +def match_probe(result: ProbeResult, probe: dict) -> tuple[bool, Optional[str]]: + if not result.success: + return False, None + + match_in = probe.get("match_in", "body") + text = _get_search_text(result, match_in) + + patterns = probe.get("patterns", []) + all_patterns = probe.get("all_patterns", []) + + if patterns: + if not any(re.search(p, text) for p in patterns): + return False, None + + if all_patterns: + if not all(re.search(p, text) for p in all_patterns): + return False, None + + extracted_version = None + version_extract = probe.get("version_extract") + if version_extract: + m = re.search(version_extract, text) + if m: + extracted_version = m.group(1) + + version_compare = probe.get("version_compare") + if version_compare and extracted_version: + op = version_compare.get("operator", "==") + val = str(version_compare.get("value", "")) + if not _version_compare(extracted_version, op, val): + return False, None + elif version_compare and not extracted_version: + return False, None + + return True, extracted_version + + +def match_target(host: str, port: int, probe_results: list[tuple[dict, ProbeResult]]) -> list[dict]: + matches = [] + for probe, result in probe_results: + matched, version = match_probe(result, probe) + if matched: + matches.append({ + "probe_type": result.probe_type, + "confidence": probe.get("confidence", "medium"), + "version": version, + }) + return matches diff --git a/lib/prober.py b/lib/prober.py new file mode 100644 index 0000000..1965822 --- /dev/null +++ b/lib/prober.py @@ -0,0 +1,125 @@ +import socket +import ssl +import urllib.request +import urllib.error +from dataclasses import dataclass, field + +CONNECT_TIMEOUT = 5 +READ_TIMEOUT = 8 + + +@dataclass +class ProbeResult: + probe_type: str + success: bool + banner: str = "" + body: str = "" + headers: dict = field(default_factory=dict) + error: str = "" + + +def _tcp_banner(host: str, port: int) -> ProbeResult: + try: + sock = socket.create_connection((host, port), timeout=CONNECT_TIMEOUT) + sock.settimeout(READ_TIMEOUT) + try: + data = sock.recv(4096) + return ProbeResult("tcp_banner", True, banner=data.decode("utf-8", errors="replace")) + except socket.timeout: + return ProbeResult("tcp_banner", True, banner="") + finally: + sock.close() + except Exception as e: + return ProbeResult("tcp_banner", False, error=str(e)) + + +def _http_probe(host: str, port: int, probe: dict, use_ssl: bool) -> ProbeResult: + scheme = "https" if use_ssl else "http" + path = probe.get("path", "/") + method = probe.get("method", "GET").upper() + extra_headers = probe.get("headers", {}) + body_data = probe.get("body", "") + + url = f"{scheme}://{host}:{port}{path}" + data = body_data.encode() if body_data else None + + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + + req = urllib.request.Request(url, data=data, method=method) + req.add_header("User-Agent", "Mozilla/5.0") + req.add_header("Connection", "close") + for k, v in extra_headers.items(): + req.add_header(k, v) + + try: + handler = urllib.request.HTTPSHandler(context=ctx) if use_ssl else urllib.request.HTTPHandler() + opener = urllib.request.build_opener(handler) + opener.addheaders = [] + with opener.open(req, timeout=READ_TIMEOUT) as resp: + raw = resp.read(65536) + body = raw.decode("utf-8", errors="replace") + headers = dict(resp.headers) + return ProbeResult( + "https" if use_ssl else "http", + True, + body=body, + headers=headers, + ) + except urllib.error.HTTPError as e: + try: + body = e.read(16384).decode("utf-8", errors="replace") + except Exception: + body = "" + return ProbeResult("https" if use_ssl else "http", True, body=body, headers=dict(e.headers)) + except Exception as e: + return ProbeResult("https" if use_ssl else "http", False, error=str(e)) + + +def _rtsp_probe(host: str, port: int) -> ProbeResult: + try: + sock = socket.create_connection((host, port), timeout=CONNECT_TIMEOUT) + sock.settimeout(READ_TIMEOUT) + request = f"OPTIONS * RTSP/1.0\r\nCSeq: 1\r\nUser-Agent: geo-scout\r\n\r\n" + sock.sendall(request.encode()) + try: + data = sock.recv(4096) + return ProbeResult("rtsp", True, banner=data.decode("utf-8", errors="replace")) + except socket.timeout: + return ProbeResult("rtsp", False, error="timeout") + finally: + sock.close() + except Exception as e: + return ProbeResult("rtsp", False, error=str(e)) + + +def _udp_probe(host: str, port: int) -> ProbeResult: + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(CONNECT_TIMEOUT) + sock.sendto(b"", (host, port)) + try: + data, _ = sock.recvfrom(4096) + return ProbeResult("udp", True, banner=data.decode("utf-8", errors="replace")) + except socket.timeout: + return ProbeResult("udp", False, error="timeout") + finally: + sock.close() + except Exception as e: + return ProbeResult("udp", False, error=str(e)) + + +def run_probe(host: str, port: int, probe: dict) -> ProbeResult: + ptype = probe.get("type", "tcp_banner") + if ptype == "tcp_banner": + return _tcp_banner(host, port) + elif ptype == "http": + return _http_probe(host, port, probe, use_ssl=False) + elif ptype == "https": + return _http_probe(host, port, probe, use_ssl=True) + elif ptype == "rtsp": + return _rtsp_probe(host, port) + elif ptype == "udp": + return _udp_probe(host, port) + return ProbeResult(ptype, False, error=f"unknown probe type: {ptype}") diff --git a/lib/scanner.py b/lib/scanner.py new file mode 100644 index 0000000..13fb533 --- /dev/null +++ b/lib/scanner.py @@ -0,0 +1,118 @@ +import json +import shutil +import subprocess +import tempfile +import xml.etree.ElementTree as ET +from pathlib import Path + + +def check_deps() -> list[str]: + missing = [] + for tool in ("masscan", "nmap"): + if not shutil.which(tool): + missing.append(tool) + return missing + + +def masscan_sweep(cidrs: list[str], ports: list[int], rate: int, output_dir: Path) -> list[dict]: + if not cidrs or not ports: + return [] + + port_str = ",".join(str(p) for p in sorted(set(ports))) + cidr_file = output_dir / "targets.txt" + cidr_file.write_text("\n".join(cidrs)) + out_file = output_dir / "masscan.json" + + cmd = [ + "masscan", + f"--rate={rate}", + f"--ports={port_str}", + "-iL", str(cidr_file), + "-oJ", str(out_file), + "--output-format", "json", + "--wait", "3", + ] + + try: + subprocess.run(cmd, capture_output=True, timeout=3600) + except subprocess.TimeoutExpired: + pass + except FileNotFoundError: + raise RuntimeError("masscan not found — install it first") + + if not out_file.exists(): + return [] + + raw = out_file.read_text(errors="replace").strip() + if not raw or raw == "[]": + return [] + + raw = raw.rstrip(",\n") + if not raw.endswith("]"): + raw += "]" + if not raw.startswith("["): + raw = "[" + raw + + try: + data = json.loads(raw) + except json.JSONDecodeError: + return [] + + results = [] + for entry in data: + ip = entry.get("ip") + for port_entry in entry.get("ports", []): + port = port_entry.get("port") + if ip and port: + results.append({"ip": ip, "port": port}) + return results + + +def nmap_fingerprint(host: str, ports: list[int], output_dir: Path) -> dict: + if not ports: + return {} + + port_str = ",".join(str(p) for p in sorted(set(ports))) + out_file = output_dir / f"nmap_{host.replace('.', '_')}.xml" + + cmd = [ + "nmap", + "-sV", "--version-intensity", "5", + "-p", port_str, + "-T4", + "--open", + "-oX", str(out_file), + host, + ] + + try: + subprocess.run(cmd, capture_output=True, timeout=60) + except (subprocess.TimeoutExpired, FileNotFoundError): + return {} + + if not out_file.exists(): + return {} + + try: + tree = ET.parse(out_file) + except ET.ParseError: + return {} + + result = {} + for port_el in tree.findall(".//port"): + portid = int(port_el.get("portid", 0)) + state = port_el.find("state") + if state is None or state.get("state") != "open": + continue + service = port_el.find("service") + info = {} + if service is not None: + info["service"] = service.get("name", "") + info["product"] = service.get("product", "") + info["version"] = service.get("version", "") + info["extrainfo"] = service.get("extrainfo", "") + info["banner"] = " ".join(filter(None, [ + info["product"], info["version"], info["extrainfo"] + ])) + result[portid] = info + return result diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3aecde9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pyyaml>=6.0