#!/usr/bin/env python3 """Map and correlate adversary command-and-control infrastructure. Accepts domains and IP addresses associated with a campaign and performs passive enrichment (WHOIS, reverse DNS, certificate transparency) to discover related infrastructure. Results are emitted as a JSON graph or Markdown report. """ from __future__ import annotations import argparse import json import socket import subprocess import sys from datetime import datetime, timezone from pathlib import Path from typing import Any def _resolve_domain(domain: str) -> list[str]: """Resolve a domain to its IP addresses via the system resolver.""" try: results = socket.getaddrinfo(domain, None, socket.AF_UNSPEC, socket.SOCK_STREAM) return list({r[4][0] for r in results}) except socket.gaierror: return [] def _reverse_dns(ip: str) -> str | None: """Attempt a reverse DNS lookup for the given IP.""" try: hostname, _, _ = socket.gethostbyaddr(ip) return hostname except (socket.herror, socket.gaierror, OSError): return None def _whois_lookup(target: str) -> str: """Run ``whois`` on a domain or IP and return raw output.""" try: result = subprocess.run( ["whois", target], capture_output=True, text=True, timeout=15, ) return result.stdout except (FileNotFoundError, subprocess.TimeoutExpired): return "" def _parse_whois_fields(raw_whois: str) -> dict[str, str]: """Extract key WHOIS fields from raw output.""" fields: dict[str, str] = {} keys_of_interest = [ "Registrant", "Registrar", "Creation Date", "Updated Date", "Name Server", "OrgName", "NetRange", "CIDR", ] for line in raw_whois.splitlines(): for key in keys_of_interest: if line.strip().lower().startswith(key.lower()): fields[key] = line.split(":", 1)[-1].strip() return fields def enrich_domain(domain: str) -> dict[str, Any]: """Gather enrichment data for a single domain.""" ips = _resolve_domain(domain) whois_raw = _whois_lookup(domain) whois_fields = _parse_whois_fields(whois_raw) return { "domain": domain, "resolved_ips": ips, "whois": whois_fields, "reverse_dns": {ip: _reverse_dns(ip) for ip in ips}, } def enrich_ip(ip: str) -> dict[str, Any]: """Gather enrichment data for a single IP address.""" rdns = _reverse_dns(ip) whois_raw = _whois_lookup(ip) whois_fields = _parse_whois_fields(whois_raw) return { "ip": ip, "reverse_dns": rdns, "whois": whois_fields, } def build_infrastructure_map( domains: list[str], ips: list[str], ) -> dict[str, Any]: """Build a combined infrastructure map from domains and IPs.""" domain_results = [enrich_domain(d) for d in domains] ip_results = [enrich_ip(ip) for ip in ips] all_ips: set[str] = set(ips) for dr in domain_results: all_ips.update(dr.get("resolved_ips", [])) return { "infrastructure_map": { "generated_utc": datetime.now(timezone.utc).isoformat(), "total_domains": len(domains), "total_ips": len(all_ips), }, "domains": domain_results, "ips": ip_results, "relationships": _find_relationships(domain_results, ip_results), } def _find_relationships( domain_results: list[dict[str, Any]], ip_results: list[dict[str, Any]], ) -> list[dict[str, str]]: """Identify relationships between infrastructure nodes.""" relationships: list[dict[str, str]] = [] ip_to_domains: dict[str, list[str]] = {} for dr in domain_results: for ip in dr.get("resolved_ips", []): ip_to_domains.setdefault(ip, []).append(dr["domain"]) for ip, doms in ip_to_domains.items(): if len(doms) > 1: relationships.append({ "type": "shared_ip", "ip": ip, "domains": ", ".join(doms), }) registrars: dict[str, list[str]] = {} for dr in domain_results: reg = dr.get("whois", {}).get("Registrar", "") if reg: registrars.setdefault(reg, []).append(dr["domain"]) for reg, doms in registrars.items(): if len(doms) > 1: relationships.append({ "type": "shared_registrar", "registrar": reg, "domains": ", ".join(doms), }) return relationships def _render_markdown(infra_map: dict[str, Any]) -> str: """Render the infrastructure map as a Markdown report.""" lines: list[str] = [ "# Infrastructure Map", "", f"- **Generated:** {infra_map['infrastructure_map']['generated_utc']}", f"- **Domains analysed:** {infra_map['infrastructure_map']['total_domains']}", f"- **Unique IPs:** {infra_map['infrastructure_map']['total_ips']}", "", "## Domains", "", ] for d in infra_map["domains"]: lines.append(f"### {d['domain']}") lines.append(f"- Resolved IPs: {', '.join(d.get('resolved_ips', [])) or 'none'}") for k, v in d.get("whois", {}).items(): lines.append(f"- {k}: {v}") lines.append("") lines.append("## IP Addresses\n") for ip_info in infra_map["ips"]: lines.append(f"### {ip_info['ip']}") lines.append(f"- Reverse DNS: {ip_info.get('reverse_dns') or 'none'}") for k, v in ip_info.get("whois", {}).items(): lines.append(f"- {k}: {v}") lines.append("") if infra_map.get("relationships"): lines.append("## Relationships\n") for rel in infra_map["relationships"]: lines.append(f"- **{rel['type']}**: {rel}") lines.append("") return "\n".join(lines) def format_output(infra_map: dict[str, Any], fmt: str) -> str: """Serialize the infrastructure map in the requested format.""" if fmt == "markdown": return _render_markdown(infra_map) return json.dumps(infra_map, indent=2) def analyze(input_path: Path, output_format: str = "json") -> dict[str, Any]: """Load domains/IPs from a JSON file and build the infrastructure map.""" with input_path.open(encoding="utf-8") as fh: data = json.load(fh) domains = data.get("domains", []) ips = data.get("ips", []) return build_infrastructure_map(domains, ips) def main() -> None: parser = argparse.ArgumentParser( description="Map and correlate adversary C2 infrastructure." ) parser.add_argument("--input", type=Path, help="JSON file with 'domains' and 'ips' lists") parser.add_argument("--domains", nargs="*", default=[], help="C2 domains to investigate") parser.add_argument("--ips", nargs="*", default=[], help="C2 IP addresses to investigate") parser.add_argument("--output", type=Path, help="Path to output file (stdout if omitted)") parser.add_argument("--format", default="json", choices=["json", "markdown"], help="Output format (default: json)") args = parser.parse_args() domains: list[str] = list(args.domains) ips: list[str] = list(args.ips) if args.input: with args.input.open(encoding="utf-8") as fh: data = json.load(fh) domains.extend(data.get("domains", [])) ips.extend(data.get("ips", [])) if not domains and not ips: parser.error("Provide at least one domain or IP via --domains, --ips, or --input") infra_map = build_infrastructure_map(domains, ips) rendered = format_output(infra_map, args.format) if args.output: args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(rendered, encoding="utf-8") print(f"[+] Infrastructure map written to {args.output}", file=sys.stderr) else: print(rendered) if __name__ == "__main__": main()