Files
rengine/tools/banner/client.py
T
Yogesh OjhaandClaude Opus 5 2375f1f1e4 feat(scan): service-level port pipeline, Services tab and Exposure panel
The unit of exposure is now a service — (ip, port) — not a bare open port.

Pipeline (EXPANSION): L0 produce hosts, L1 cdn_check + passive_ports,
L2 port_scan, L3 http_probe, L4 screenshot/waf_detect/service_fingerprint.

- cdn_check applies to every target type and records cdn_type, so a domain
  scan finally knows which addresses are CDN-fronted. cdn/waf is an edge you
  do not own (edge ports only); cloud is your own machine (scanned in full).
- port_scan decides and records a per-address scan_policy (full/web/skip)
  honouring the scan context's excluded_ips, and offers port profiles,
  exclusions, scan type, a tarpit threshold and an address budget.
- passive_ports reads Shodan internetdb through naabu -passive: keyless,
  touches_target=False, so a passive-intensity scan still gets exposure data.
- http_probe seeds its host x port matrix from open ports and folds every
  live response back onto the port (is_http, tls, product).
- service_fingerprint names the software behind non-web ports with a pure
  Python banner prober (tools/banner/), including a SOCKS5 CONNECT so it
  honours the context proxy. naabu -sV is unimplemented.
- shared/definitions/ports.py is the one vocabulary; ip_inventory and
  port_inventory are the only writers of ip_addresses/ports.
- open_ports_found is measured from the table (DERIVED_COUNTS): four stages
  write ports, so summing stage results double-counted.
- naabu failures no longer report zero open ports as success.

Surfaces: a Services tab (SERVICE_QUERY grammar, 16 fields, 18 counted
leads, 9 group dimensions, detail sheet) built on the shared results shell,
and an outlier-first Exposure panel on the Overview. Every count is a
promise — leads, groups and panel pivots were verified to equal the row
count they land on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BnC2xMMzV6WQnpDp5zcXoU
2026-09-03 23:50:17 +05:30

181 lines
5.3 KiB
Python

"""Connect to a listening port, read what it says, and name the service."""
from __future__ import annotations
import contextlib
import socket
import ssl
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from urllib.parse import urlsplit
from shared.logging import get_logger
from tools.banner.signatures import (
GENERIC_PAYLOAD,
PAYLOADS,
READ_LIMIT,
identify,
readable,
)
logger = get_logger(__name__)
_SOCKS_VERSION = 5
_SOCKS_NO_AUTH = 0
_SOCKS_USERPASS = 2
_SOCKS_CONNECT = 1
_SOCKS_DOMAIN = 3
_SOCKS_IPV4 = 1
_SOCKS_IPV6 = 4
class BannerError(Exception):
"""The prober could not be configured for the requested transport."""
@dataclass(frozen=True)
class Endpoint:
ip: str
port: int
service: str | None = None
tls: bool = False
@dataclass
class Fingerprint:
ip: str
port: int
tls: bool = False
service: str | None = None
product: str | None = None
version: str | None = None
banner: str | None = None
@property
def identified(self) -> bool:
return bool(self.service or self.product or self.banner)
def _socks5_connect(proxy: str, host: str, port: int, timeout: float) -> socket.socket:
parts = urlsplit(proxy if "://" in proxy else f"socks5://{proxy}")
if not parts.hostname:
msg = f"unusable proxy {proxy!r}"
raise BannerError(msg)
sock = socket.create_connection(
(parts.hostname, parts.port or 1080), timeout=timeout
)
try:
methods = [_SOCKS_NO_AUTH] + ([_SOCKS_USERPASS] if parts.username else [])
sock.sendall(bytes([_SOCKS_VERSION, len(methods), *methods]))
_, method = sock.recv(2)
if method == _SOCKS_USERPASS:
user = (parts.username or "").encode()
password = (parts.password or "").encode()
sock.sendall(
bytes([1, len(user)]) + user + bytes([len(password)]) + password
)
if sock.recv(2)[1] != 0:
msg = "proxy rejected the credentials"
raise BannerError(msg)
elif method != _SOCKS_NO_AUTH:
msg = "proxy offered no usable authentication method"
raise BannerError(msg)
target = host.encode()
sock.sendall(
bytes([_SOCKS_VERSION, _SOCKS_CONNECT, 0, _SOCKS_DOMAIN, len(target)])
+ target
+ port.to_bytes(2, "big")
)
reply = sock.recv(4)
if len(reply) < 4 or reply[1] != 0: # noqa: PLR2004
msg = "proxy refused the connection"
raise BannerError(msg)
length = {_SOCKS_IPV4: 4, _SOCKS_IPV6: 16}.get(reply[3])
if length is None:
length = sock.recv(1)[0]
sock.recv(length + 2)
except Exception:
sock.close()
raise
return sock
class BannerClient:
"""A bounded, thread-pooled TCP prober. Sends one short probe per port."""
def __init__(
self,
*,
timeout: float = 4.0,
concurrency: int = 32,
proxy_url: str | None = None,
) -> None:
self.timeout = timeout
self.concurrency = max(1, concurrency)
self.proxy_url = proxy_url
def probe_all(self, endpoints: list[Endpoint]) -> list[Fingerprint]:
if not endpoints:
return []
with ThreadPoolExecutor(max_workers=self.concurrency) as pool:
return [f for f in pool.map(self.probe, endpoints) if f is not None]
def probe(self, endpoint: Endpoint) -> Fingerprint | None:
result = Fingerprint(ip=endpoint.ip, port=endpoint.port, tls=endpoint.tls)
try:
data, tls = self._read(endpoint)
except (OSError, ssl.SSLError, BannerError, IndexError):
return None
result.tls = tls
if not data:
return None
result.banner = readable(data)
for key, value in identify(data, endpoint.service).items():
setattr(result, key, value)
return result if result.identified else None
def _connect(self, endpoint: Endpoint) -> socket.socket:
if self.proxy_url:
return _socks5_connect(
self.proxy_url, endpoint.ip, endpoint.port, self.timeout
)
return socket.create_connection(
(endpoint.ip, endpoint.port), timeout=self.timeout
)
def _read(self, endpoint: Endpoint) -> tuple[bytes, bool]:
sock = self._connect(endpoint)
tls = False
try:
sock.settimeout(self.timeout)
if endpoint.tls:
sock = _TLS_CONTEXT.wrap_socket(sock)
tls = True
data = self._recv(sock)
if not data:
sock.sendall(PAYLOADS.get(endpoint.service or "", GENERIC_PAYLOAD))
data = self._recv(sock)
return data, tls
finally:
with contextlib.suppress(OSError):
sock.close()
@staticmethod
def _recv(sock: socket.socket) -> bytes:
try:
return sock.recv(READ_LIMIT)
except (TimeoutError, OSError):
return b""
def _tls_context() -> ssl.SSLContext:
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
context.set_ciphers("DEFAULT@SECLEVEL=1")
return context
_TLS_CONTEXT = _tls_context()