#!/usr/bin/env python3 # LAB ONLY # AUTHORIZED TARGETS ONLY """ Authorized Site Scope Mapper 同 IP / 同证书 / 同反向代理 关联面分析工具 支持: - 单主机 DNS 解析 - 反向 DNS 查询 - HTTP 响应头、标题与重定向观察 - TLS 证书主题与 SAN 提取 - 基于目标自身信息的关联主机汇总 Usage: python3 site-scope-mapper.py --target app.example.test --ack-authorized python3 site-scope-mapper.py --target 203.0.113.10 --ports 80,443,8443 --json --ack-authorized 授权边界: - 仅用于自有资产、测试环境或已明确授权的目标 - 允许公网验证,但默认只围绕单个目标做最小化关联分析 - 不面向无授权第三方网站或泛互联网枚举 """ import argparse import ipaddress import json import re import socket import ssl import warnings from dataclasses import asdict, dataclass, field from typing import Dict, List, Optional, Set warnings.filterwarnings("ignore", message="urllib3 v2 only supports OpenSSL") import requests DEFAULT_PORTS = [80, 443, 8080, 8443] @dataclass class HTTPObservation: scheme: str port: int status_code: Optional[int] = None location: Optional[str] = None server: Optional[str] = None title: Optional[str] = None final_url: Optional[str] = None error: Optional[str] = None @dataclass class TLSObservation: port: int subject_cn: Optional[str] = None issuer_cn: Optional[str] = None san: List[str] = field(default_factory=list) not_before: Optional[str] = None not_after: Optional[str] = None error: Optional[str] = None def is_ip(value: str) -> bool: try: ipaddress.ip_address(value) return True except ValueError: return False def resolve_host(target: str) -> Dict[str, List[str]]: records = {"ipv4": [], "ipv6": []} try: infos = socket.getaddrinfo(target, None) except socket.gaierror: return records for info in infos: family = info[0] address = info[4][0] if family == socket.AF_INET and address not in records["ipv4"]: records["ipv4"].append(address) if family == socket.AF_INET6 and address not in records["ipv6"]: records["ipv6"].append(address) return records def reverse_dns(address: str) -> Optional[str]: try: host, _, _ = socket.gethostbyaddr(address) return host except Exception: return None def extract_title(html: str) -> Optional[str]: match = re.search(r"(.*?)", html, re.IGNORECASE | re.DOTALL) if not match: return None return re.sub(r"\s+", " ", match.group(1)).strip()[:160] def observe_http(target: str, scheme: str, port: int, timeout: float) -> HTTPObservation: url = f"{scheme}://{target}:{port}/" try: response = requests.get( url, timeout=timeout, allow_redirects=False, verify=False, headers={"User-Agent": "websafe-site-scope-mapper/1.0"}, ) title = extract_title(response.text or "") return HTTPObservation( scheme=scheme, port=port, status_code=response.status_code, location=response.headers.get("Location"), server=response.headers.get("Server"), title=title, final_url=response.url, ) except Exception as exc: return HTTPObservation(scheme=scheme, port=port, error=str(exc)) def observe_tls(target: str, port: int, timeout: float) -> TLSObservation: context = ssl.create_default_context() context.check_hostname = False context.verify_mode = ssl.CERT_NONE try: with socket.create_connection((target, port), timeout=timeout) as sock: with context.wrap_socket(sock, server_hostname=target) as ssock: cert = ssock.getpeercert() san = [] for item in cert.get("subjectAltName", []): if len(item) == 2: san.append(item[1]) subject = dict(x[0] for x in cert.get("subject", [])) issuer = dict(x[0] for x in cert.get("issuer", [])) return TLSObservation( port=port, subject_cn=subject.get("commonName"), issuer_cn=issuer.get("commonName"), san=san, not_before=cert.get("notBefore"), not_after=cert.get("notAfter"), ) except Exception as exc: return TLSObservation(port=port, error=str(exc)) def parse_ports(value: str) -> List[int]: ports: List[int] = [] for part in value.split(","): part = part.strip() if not part: continue port = int(part) if port not in ports: ports.append(port) if len(ports) > 10: raise ValueError("为避免扩大扫描范围,最多允许 10 个端口") return ports def render_text(report: Dict) -> str: lines = [] lines.append("=" * 68) lines.append("Authorized Site Scope Mapper") lines.append("=" * 68) lines.append(f"Target: {report['target']}") lines.append(f"Target Type: {report['target_type']}") lines.append("") dns_records = report["dns"] lines.append("DNS:") lines.append(f" IPv4: {', '.join(dns_records['ipv4']) or '-'}") lines.append(f" IPv6: {', '.join(dns_records['ipv6']) or '-'}") lines.append(f" PTR : {', '.join(report['reverse_dns']) or '-'}") lines.append("") lines.append("HTTP Observations:") for item in report["http"]: lines.append( f" - {item['scheme']}:{item['port']} status={item.get('status_code') or '-'} " f"server={item.get('server') or '-'} title={item.get('title') or '-'} " f"location={item.get('location') or '-'}" ) lines.append("") lines.append("TLS Observations:") for item in report["tls"]: lines.append( f" - port {item['port']} subject={item.get('subject_cn') or '-'} " f"issuer={item.get('issuer_cn') or '-'} SAN={len(item.get('san', []))}" ) lines.append("") lines.append("Related Hosts:") related = report["related_hosts"] if related: for host in related: lines.append(f" - {host}") else: lines.append(" - None derived from on-target data") return "\n".join(lines) def main() -> int: parser = argparse.ArgumentParser(description="Authorized Site Scope Mapper") parser.add_argument("--target", required=True, help="目标主机名或 IP") parser.add_argument( "--ports", default="80,443,8080,8443", help="需要观察的端口列表,默认 80,443,8080,8443", ) parser.add_argument("--timeout", type=float, default=4.0, help="请求超时时间") parser.add_argument("--json", action="store_true", help="输出 JSON") parser.add_argument( "--ack-authorized", action="store_true", help="确认目标属于自有资产或已明确授权", ) args = parser.parse_args() if not args.ack_authorized: parser.error("必须显式提供 --ack-authorized 以确认目标范围合法") ports = parse_ports(args.ports) target_type = "ip" if is_ip(args.target) else "hostname" dns_records = resolve_host(args.target) if target_type == "hostname" else {"ipv4": [args.target], "ipv6": []} reverse_hosts: Set[str] = set() for address in dns_records["ipv4"] + dns_records["ipv6"]: ptr = reverse_dns(address) if ptr: reverse_hosts.add(ptr) http_results: List[HTTPObservation] = [] tls_results: List[TLSObservation] = [] for port in ports: schemes = ["https"] if port in (443, 8443) else ["http"] if port not in (80, 443): schemes.append("https") for scheme in schemes: http_results.append(observe_http(args.target, scheme, port, args.timeout)) if port in (443, 8443): tls_results.append(observe_tls(args.target, port, args.timeout)) related_hosts: Set[str] = set(reverse_hosts) for item in tls_results: related_hosts.update(host for host in item.san if host) report = { "target": args.target, "target_type": target_type, "dns": dns_records, "reverse_dns": sorted(reverse_hosts), "http": [asdict(item) for item in http_results], "tls": [asdict(item) for item in tls_results], "related_hosts": sorted(related_hosts), } if args.json: print(json.dumps(report, indent=2, ensure_ascii=True)) else: print(render_text(report)) return 0 if __name__ == "__main__": raise SystemExit(main())