mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-21 07:41:28 +02:00
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
181 lines
5.3 KiB
Python
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()
|