#!/usr/bin/env python3 """ banner_grabber.py — HTTP/FTP/TLS/SSH banner collection for FLOCK_scan Grabs what scanner.py's _http_get() leaves on the floor: - HTTP response headers (Server, X-Powered-By, Via, etc.) - FTP banner on port 21 (SpeedPourer version detection) - TLS certificate details (SANs, issuer, expiry, serial) - SSH version string (port 22) Usage: from modules.banner_grabber import grab_all_banners banners = grab_all_banners(host, timeout=5) """ import socket import ssl import re import json from datetime import datetime try: import requests from requests.packages.urllib3.exceptions import InsecureRequestWarning requests.packages.urllib3.disable_warnings(InsecureRequestWarning) HAVE_REQUESTS = True except ImportError: HAVE_REQUESTS = False # ── 1. HTTP Response Headers + Body ────────────────────────────────── def http_banner_grab(host, port=80, timeout=5, use_https=False): """ Full HTTP(S) GET — returns status code, headers dict, cookies, and truncated body so we can feed them to telemetry/banner analysis. Returns dict or None. """ scheme = "https" if use_https or port == 443 else "http" url = f"{scheme}://{host}:{port}/" if not HAVE_REQUESTS: return _http_banner_socket(host, port, timeout, use_https) try: r = requests.get( url, timeout=timeout, verify=False, headers={"User-Agent": "FLOCK_scan/3.0"}, allow_redirects=False, ) return { "status": r.status_code, "headers": dict(r.headers), "body": r.text[:5000], "cookies": r.cookies.get_dict(), "url": url, } except Exception: return None def _http_banner_socket(host, port, timeout, use_https): """Fallback raw-socket HTTP GET for environments without requests.""" try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(timeout) if use_https or port == 443: ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE s = ctx.wrap_socket(s, server_hostname=host) s.connect((host, port)) req = ( f"GET / HTTP/1.0\r\n" f"Host: {host}:{port}\r\n" f"User-Agent: FLOCK_scan/3.0\r\n" f"Connection: close\r\n\r\n" ) s.send(req.encode()) resp = b"" while True: chunk = s.recv(4096) if not chunk: break resp += chunk s.close() # Split headers / body raw = resp.decode(errors="replace") if "\r\n\r\n" in raw: header_text, body = raw.split("\r\n\r\n", 1) elif "\n\n" in raw: header_text, body = raw.split("\n\n", 1) else: header_text = raw body = "" headers = {} status = 0 for line in header_text.split("\r\n"): if line.startswith("HTTP/"): try: status = int(line.split()[1]) except (IndexError, ValueError): pass elif ":" in line: k, v = line.split(":", 1) headers[k.strip()] = v.strip() return { "status": status, "headers": headers, "body": body[:5000], "cookies": {}, "url": f"{'https' if use_https else 'http'}://{host}:{port}/", } except Exception: return None def http_extract_interesting(headers): """ From a headers dict, pull out the banner-level intel we care about. Returns a flat dict with keys: server, powered_by, aspnet_version, cloud_proxy, backend_cookie. """ h = {k.lower(): v for k, v in headers.items()} info = {} if "server" in h: info["server"] = h["server"] if "x-powered-by" in h: info["powered_by"] = h["x-powered-by"] if "x-aspnet-version" in h: info["aspnet_version"] = h["x-aspnet-version"] # Cloud proxy detection via = h.get("via", "") if "cloudfront" in via.lower(): info["cloud_proxy"] = "CloudFront" elif "akamai" in via.lower(): info["cloud_proxy"] = "Akamai" elif "cloudflare" in via.lower(): info["cloud_proxy"] = "CloudFlare" elif via: info["cloud_proxy"] = via[:64] x_cache = h.get("x-cache", "") if x_cache and "cloud_proxy" not in info: info["cloud_proxy_hint"] = x_cache[:64] # CSP / HSTS if "strict-transport-security" in h: info["hsts"] = "yes" if "content-security-policy" in h: csp = h["content-security-policy"] info["csp_report_uri"] = _extract_csp_report_uri(csp) # Backend fingerprint via Set-Cookie for cname in h.get("set-cookie", "").split(";"): cname = cname.strip().split("=")[0] if cname in ("PHPSESSID", "JSESSIONID", "connect.sid", "ASP.NET_SessionId", "PLAY_FLASH", "laravel_session", "symfony", "rack.session"): info["backend_cookie"] = cname break # Cloud headers for cloud_key in ("x-amz-request-id", "x-amz-id-2", "x-amz-cf-id", "x-amz-cf-pop", "x-azure-ref", "x-guploader-uploadid", "x-sucuri-id", "x-sucuri-cache", "cf-ray", "cf-cache-status"): if cloud_key in h: info[cloud_key] = h[cloud_key] return info def _extract_csp_report_uri(csp): m = re.search(r'report-uri\s+([^\s;]+)', csp) if m: return m.group(1) m = re.search(r'report-to\s+([^\s;]+)', csp) if m: return m.group(1) return None # ── 2. FTP Banner Grab ────────────────────────────────────────────── def ftp_banner_grab(host, port=21, timeout=5): """ Connect to FTP port, grab the welcome banner. SpeedPourer cameras advertise themselves here. """ try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(timeout) s.connect((host, port)) banner = s.recv(1024).decode(errors="replace").strip() s.close() result = { "port": port, "banner": banner, "is_speedpourer": "speedpourer" in banner.lower(), } # Extract version if present m = re.search(r'v?(\d+\.\d+[\.\d]*)', banner) if m: result["version"] = m.group(1) return result except socket.timeout: return None except ConnectionRefusedError: return None except Exception: return None def ftp_anonymous_login(host, port=21, timeout=5): """ Test if FTP allows anonymous login (common on misconfigured SpeedPourer). Returns True/False. """ try: from ftplib import FTP ftp = FTP() ftp.connect(host, port, timeout=timeout) resp = ftp.login("anonymous", "flock_scan@test.com") ftp.quit() return "230" in str(resp) except Exception: return False # ── 3. TLS Certificate Details ────────────────────────────────────── def tls_cert_grab(host, port=443, timeout=5): """ Connect and extract the full TLS certificate. Returns SANs, issuer, subject, validity window, serial. """ try: ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE with socket.create_connection((host, port), timeout=timeout) as sock: with ctx.wrap_socket(sock, server_hostname=host) as ssock: cert = ssock.getpeercert() der = ssock.getpeercert(binary_form=True) if not cert: return None # SANs sans = [] for ext_type, val in cert.get("subjectAltName", []): if ext_type == "DNS": sans.append(val) # Subject subject = dict(cert.get("subject", [])) issuer = dict(cert.get("issuer", [])) # Serial serial = cert.get("serialNumber", None) # Validity nb = cert.get("notBefore", "") na = cert.get("notAfter", "") # SHA-256 fingerprint from hashlib import sha256 fingerprint = sha256(der).hexdigest() return { "subject": {k: v for k, v in subject.items()}, "issuer": {k: v for k, v in issuer.items()}, "sans": sans, "serial_number": serial, "not_before": nb, "not_after": na, "sha256_fingerprint": fingerprint, "days_until_expiry": _days_between(datetime.now(), na) if na else None, } except Exception: return None def _days_between(d1, d2_str): """Parse an ASN.1 time string and return days between now and it.""" try: d2 = datetime.strptime(d2_str.replace("Z", ""), "%Y%m%d%H%M%S") delta = (d2 - d1).days return delta except Exception: return None # ── 4. SSH Version String ─────────────────────────────────────────── def ssh_banner_grab(host, port=22, timeout=5): """ Grab SSH protocol version string. Can identify OS / SSH server version. """ try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(timeout) s.connect((host, port)) banner = s.recv(256).decode(errors="replace").strip() s.close() return { "port": port, "banner": banner, "ssh_version": banner.split("-")[-1] if "-" in banner else None, } except Exception: return None # ── 5. HTTP OPTIONS / TRACE ───────────────────────────────────────── def http_options_scan(host, port=80, timeout=5, use_https=False): """ Send HTTP OPTIONS to discover allowed methods. PUT, DELETE, or PATCH exposed = interesting. """ scheme = "https" if use_https or port == 443 else "http" try: s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.settimeout(timeout) if use_https or port == 443: ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE s = ctx.wrap_socket(s, server_hostname=host) s.connect((host, port)) req = ( f"OPTIONS / HTTP/1.0\r\n" f"Host: {host}:{port}\r\n" f"User-Agent: FLOCK_scan/3.0\r\n\r\n" ) s.send(req.encode()) resp = s.recv(4096).decode(errors="replace") s.close() allow = None for line in resp.split("\r\n"): if line.lower().startswith("allow:"): allow = line.split(":", 1)[1].strip() break return {"allow": allow, "methods": allow.split(", ") if allow else []} except Exception: return None # ── Orchestrator ───────────────────────────────────────────────────── def grab_all_banners(host, timeout=5): """ Run all banner checks on a host. Returns a dict with results per service. """ results = {} # HTTP/HTTPS for port, https in [(80, False), (443, True)]: http_res = http_banner_grab(host, port=port, timeout=timeout, use_https=https) if http_res: results[f"http_{port}"] = { "url": http_res["url"], "status": http_res["status"], "headers": http_res["headers"], "interesting": http_extract_interesting(http_res["headers"]), "body_preview": http_res["body"][:500], "cookies": http_res["cookies"], } # OPTIONS scan opts = http_options_scan(host, port=port, timeout=timeout, use_https=https) if opts and opts.get("methods"): results[f"options_{port}"] = opts # FTP ftp = ftp_banner_grab(host, timeout=timeout) if ftp: results["ftp_21"] = ftp anon = ftp_anonymous_login(host, timeout=timeout) if anon: results["ftp_21"]["anonymous_login"] = True # TLS cert (always try 443) tls = tls_cert_grab(host, timeout=timeout) if tls: results["tls_443"] = tls # SSH ssh = ssh_banner_grab(host, timeout=timeout) if ssh: results["ssh_22"] = ssh return results # ── CLI test ───────────────────────────────────────────────────────── if __name__ == "__main__": import sys target = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1" print(json.dumps(grab_all_banners(target), indent=2, default=str))