mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-21 07:41:28 +02:00
510 lines
16 KiB
Python
510 lines
16 KiB
Python
"""Ports, services and exposure classes — the SSOT for the scan pipeline and the query layer."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass
|
|
from enum import StrEnum
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
|
|
MAX_PORT = 65535
|
|
DEFAULT_WEB_PORTS: tuple[int, ...] = (80, 443)
|
|
|
|
|
|
class ServiceClass(StrEnum):
|
|
WEB = "web"
|
|
REMOTE = "remote"
|
|
DATABASE = "database"
|
|
MAIL = "mail"
|
|
INFRA = "infra"
|
|
OTHER = "other"
|
|
|
|
|
|
SERVICE_CLASS_LABELS: dict[str, str] = {
|
|
ServiceClass.WEB.value: "Web",
|
|
ServiceClass.REMOTE.value: "Remote access",
|
|
ServiceClass.DATABASE.value: "Data store",
|
|
ServiceClass.MAIL.value: "Mail",
|
|
ServiceClass.INFRA.value: "Infrastructure",
|
|
ServiceClass.OTHER.value: "Other",
|
|
}
|
|
|
|
# a service belongs to exactly one class; order is the tab order, not a precedence
|
|
SERVICE_CLASS_ORDER: tuple[str, ...] = tuple(SERVICE_CLASS_LABELS)
|
|
|
|
|
|
class PortState(StrEnum):
|
|
OPEN = "open"
|
|
FILTERED = "filtered"
|
|
CLOSED = "closed"
|
|
|
|
|
|
class PortSource(StrEnum):
|
|
NAABU = "naabu"
|
|
INTERNETDB = "internetdb"
|
|
HTTP_PROBE = "http_probe"
|
|
BANNER = "banner"
|
|
|
|
|
|
PORT_SOURCE_LABELS: dict[str, str] = {
|
|
PortSource.NAABU.value: "Port scan",
|
|
PortSource.INTERNETDB.value: "External scanner",
|
|
PortSource.HTTP_PROBE.value: "HTTP probe",
|
|
PortSource.BANNER.value: "Service banner",
|
|
}
|
|
|
|
# active observation outranks passive intel when both report the same port
|
|
PORT_SOURCE_RANK: dict[str, int] = {
|
|
PortSource.INTERNETDB.value: 0,
|
|
PortSource.BANNER.value: 1,
|
|
PortSource.HTTP_PROBE.value: 2,
|
|
PortSource.NAABU.value: 3,
|
|
}
|
|
|
|
|
|
class ScanPolicy(StrEnum):
|
|
FULL = "full"
|
|
WEB = "web"
|
|
SKIP = "skip"
|
|
|
|
|
|
SCAN_POLICY_LABELS: dict[str, str] = {
|
|
ScanPolicy.FULL.value: "Scanned in full",
|
|
ScanPolicy.WEB.value: "Web ports only",
|
|
ScanPolicy.SKIP.value: "Not scanned",
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ServiceSpec:
|
|
name: str
|
|
label: str
|
|
klass: str
|
|
description: str = ""
|
|
tls: bool = False
|
|
|
|
|
|
def _s(
|
|
name: str,
|
|
label: str,
|
|
klass: ServiceClass,
|
|
description: str = "",
|
|
*,
|
|
tls: bool = False,
|
|
) -> ServiceSpec:
|
|
return ServiceSpec(
|
|
name=name,
|
|
label=label,
|
|
klass=klass.value,
|
|
description=description,
|
|
tls=tls,
|
|
)
|
|
|
|
|
|
# port -> canonical service. Keep names lowercase and stable: they are query values.
|
|
WELL_KNOWN: dict[int, ServiceSpec] = {
|
|
21: _s(
|
|
"ftp",
|
|
"FTP",
|
|
ServiceClass.INFRA,
|
|
"File transfer, credentials in the clear unless FTPS",
|
|
),
|
|
22: _s("ssh", "SSH", ServiceClass.REMOTE, "Secure Shell remote administration"),
|
|
23: _s("telnet", "Telnet", ServiceClass.REMOTE, "Telnet remote shell, unencrypted"),
|
|
25: _s("smtp", "SMTP", ServiceClass.MAIL, "Mail transfer between servers"),
|
|
53: _s("dns", "DNS", ServiceClass.INFRA, "Domain name resolution"),
|
|
69: _s(
|
|
"tftp", "TFTP", ServiceClass.INFRA, "Trivial file transfer, no authentication"
|
|
),
|
|
88: _s("kerberos", "Kerberos", ServiceClass.INFRA, "Kerberos authentication"),
|
|
110: _s("pop3", "POP3", ServiceClass.MAIL, "Mailbox retrieval, unencrypted"),
|
|
111: _s(
|
|
"rpcbind",
|
|
"RPC portmapper",
|
|
ServiceClass.INFRA,
|
|
"RPC portmapper, enumerates other services",
|
|
),
|
|
135: _s("msrpc", "MSRPC", ServiceClass.INFRA, "Windows RPC endpoint mapper"),
|
|
137: _s("netbios", "NetBIOS", ServiceClass.INFRA, "NetBIOS name service"),
|
|
139: _s(
|
|
"netbios-ssn",
|
|
"NetBIOS session",
|
|
ServiceClass.INFRA,
|
|
"NetBIOS session service over SMB",
|
|
),
|
|
143: _s("imap", "IMAP", ServiceClass.MAIL, "Mailbox access, unencrypted"),
|
|
161: _s(
|
|
"snmp",
|
|
"SNMP",
|
|
ServiceClass.INFRA,
|
|
"SNMP management, often left on a default community string",
|
|
),
|
|
389: _s("ldap", "LDAP", ServiceClass.INFRA, "Directory service, unencrypted"),
|
|
427: _s("slp", "SLP", ServiceClass.INFRA, "Service Location Protocol"),
|
|
443: _s("https", "HTTPS", ServiceClass.WEB, "Web server over TLS", tls=True),
|
|
445: _s("smb", "SMB", ServiceClass.INFRA, "Windows file sharing"),
|
|
465: _s(
|
|
"smtps",
|
|
"SMTPS",
|
|
ServiceClass.MAIL,
|
|
"Mail submission over implicit TLS",
|
|
tls=True,
|
|
),
|
|
500: _s("isakmp", "IKE", ServiceClass.INFRA, "IPsec key exchange"),
|
|
512: _s(
|
|
"rexec", "rexec", ServiceClass.REMOTE, "Remote command execution, unencrypted"
|
|
),
|
|
513: _s("rlogin", "rlogin", ServiceClass.REMOTE, "Remote login, unencrypted"),
|
|
514: _s("syslog", "Syslog", ServiceClass.INFRA, "Syslog collection"),
|
|
515: _s("printer", "LPD", ServiceClass.INFRA, "Line printer daemon"),
|
|
548: _s("afp", "AFP", ServiceClass.INFRA, "Apple file sharing"),
|
|
554: _s("rtsp", "RTSP", ServiceClass.OTHER, "Streaming media control"),
|
|
587: _s(
|
|
"smtp", "SMTP submission", ServiceClass.MAIL, "Authenticated mail submission"
|
|
),
|
|
623: _s(
|
|
"ipmi",
|
|
"IPMI",
|
|
ServiceClass.INFRA,
|
|
"Baseboard management controller, out-of-band server access",
|
|
),
|
|
636: _s(
|
|
"ldaps", "LDAPS", ServiceClass.INFRA, "Directory service over TLS", tls=True
|
|
),
|
|
873: _s("rsync", "rsync", ServiceClass.INFRA, "rsync file synchronisation"),
|
|
993: _s("imaps", "IMAPS", ServiceClass.MAIL, "Mailbox access over TLS", tls=True),
|
|
995: _s(
|
|
"pop3s", "POP3S", ServiceClass.MAIL, "Mailbox retrieval over TLS", tls=True
|
|
),
|
|
1080: _s(
|
|
"socks",
|
|
"SOCKS proxy",
|
|
ServiceClass.INFRA,
|
|
"SOCKS proxy. An open proxy relays traffic for any client.",
|
|
),
|
|
1433: _s(
|
|
"mssql", "Microsoft SQL Server", ServiceClass.DATABASE, "Microsoft SQL Server"
|
|
),
|
|
1521: _s("oracle", "Oracle DB", ServiceClass.DATABASE, "Oracle database listener"),
|
|
1723: _s("pptp", "PPTP", ServiceClass.REMOTE, "PPTP VPN, cryptographically broken"),
|
|
2049: _s("nfs", "NFS", ServiceClass.INFRA, "Network file system export"),
|
|
2181: _s(
|
|
"zookeeper",
|
|
"ZooKeeper",
|
|
ServiceClass.INFRA,
|
|
"ZooKeeper coordination, usually unauthenticated",
|
|
),
|
|
2375: _s(
|
|
"docker",
|
|
"Docker API",
|
|
ServiceClass.INFRA,
|
|
"Docker API without TLS, equivalent to root on the host",
|
|
),
|
|
2376: _s(
|
|
"docker", "Docker API", ServiceClass.INFRA, "Docker API over TLS", tls=True
|
|
),
|
|
2379: _s(
|
|
"etcd",
|
|
"etcd",
|
|
ServiceClass.DATABASE,
|
|
"etcd key-value store, holds cluster secrets",
|
|
),
|
|
3128: _s("http-proxy", "HTTP proxy", ServiceClass.WEB, "Forward web proxy"),
|
|
3268: _s(
|
|
"ldap", "Global catalog", ServiceClass.INFRA, "Active Directory global catalog"
|
|
),
|
|
3306: _s("mysql", "MySQL", ServiceClass.DATABASE, "MySQL or MariaDB database"),
|
|
3389: _s("rdp", "RDP", ServiceClass.REMOTE, "Windows remote desktop"),
|
|
4369: _s(
|
|
"epmd",
|
|
"Erlang port mapper",
|
|
ServiceClass.INFRA,
|
|
"Erlang port mapper, enumerates node names",
|
|
),
|
|
4444: _s(
|
|
"metasploit",
|
|
"Metasploit",
|
|
ServiceClass.OTHER,
|
|
"Common Metasploit listener port",
|
|
),
|
|
5060: _s("sip", "SIP", ServiceClass.OTHER, "SIP telephony signalling"),
|
|
5222: _s("xmpp", "XMPP", ServiceClass.OTHER, "XMPP client connection"),
|
|
5353: _s("mdns", "mDNS", ServiceClass.INFRA, "Multicast DNS"),
|
|
5432: _s("postgresql", "PostgreSQL", ServiceClass.DATABASE, "PostgreSQL database"),
|
|
5601: _s(
|
|
"kibana", "Kibana", ServiceClass.WEB, "Kibana dashboards for Elasticsearch"
|
|
),
|
|
5672: _s("amqp", "AMQP", ServiceClass.INFRA, "AMQP message broker"),
|
|
5900: _s(
|
|
"vnc", "VNC", ServiceClass.REMOTE, "VNC remote desktop, often password-only"
|
|
),
|
|
5901: _s("vnc", "VNC", ServiceClass.REMOTE, "VNC remote desktop, second display"),
|
|
5984: _s(
|
|
"couchdb",
|
|
"CouchDB",
|
|
ServiceClass.DATABASE,
|
|
"CouchDB, unauthenticated in older releases",
|
|
),
|
|
6379: _s(
|
|
"redis", "Redis", ServiceClass.DATABASE, "Redis, no authentication by default"
|
|
),
|
|
7001: _s(
|
|
"weblogic",
|
|
"WebLogic",
|
|
ServiceClass.WEB,
|
|
"Oracle WebLogic admin and application traffic",
|
|
),
|
|
7199: _s(
|
|
"cassandra",
|
|
"Cassandra JMX",
|
|
ServiceClass.DATABASE,
|
|
"Cassandra JMX, remote code execution if exposed",
|
|
),
|
|
8086: _s(
|
|
"influxdb", "InfluxDB", ServiceClass.DATABASE, "InfluxDB time-series database"
|
|
),
|
|
8500: _s(
|
|
"consul",
|
|
"Consul",
|
|
ServiceClass.WEB,
|
|
"Consul service catalogue and key-value store",
|
|
),
|
|
8834: _s(
|
|
"nessus", "Nessus", ServiceClass.WEB, "Nessus vulnerability scanner console"
|
|
),
|
|
9042: _s(
|
|
"cassandra", "Cassandra", ServiceClass.DATABASE, "Cassandra native protocol"
|
|
),
|
|
9092: _s("kafka", "Kafka", ServiceClass.INFRA, "Kafka broker"),
|
|
9160: _s(
|
|
"cassandra",
|
|
"Cassandra Thrift",
|
|
ServiceClass.DATABASE,
|
|
"Cassandra Thrift interface",
|
|
),
|
|
9200: _s(
|
|
"elasticsearch",
|
|
"Elasticsearch",
|
|
ServiceClass.DATABASE,
|
|
"Elasticsearch REST API, unauthenticated in older releases",
|
|
),
|
|
9300: _s(
|
|
"elasticsearch",
|
|
"Elasticsearch transport",
|
|
ServiceClass.DATABASE,
|
|
"Elasticsearch node transport",
|
|
),
|
|
10250: _s("kubelet", "kubelet", ServiceClass.WEB, "Kubernetes node agent API"),
|
|
11211: _s(
|
|
"memcached",
|
|
"Memcached",
|
|
ServiceClass.DATABASE,
|
|
"Memcached, no authentication and usable for amplification",
|
|
),
|
|
15672: _s(
|
|
"rabbitmq", "RabbitMQ", ServiceClass.WEB, "RabbitMQ management interface"
|
|
),
|
|
27017: _s(
|
|
"mongodb",
|
|
"MongoDB",
|
|
ServiceClass.DATABASE,
|
|
"MongoDB, unauthenticated before the 3.6 defaults",
|
|
),
|
|
27018: _s(
|
|
"mongodb", "MongoDB shard", ServiceClass.DATABASE, "MongoDB shard member"
|
|
),
|
|
50000: _s("db2", "IBM Db2", ServiceClass.DATABASE, "IBM Db2 database"),
|
|
}
|
|
|
|
# ports that commonly answer HTTP; the web probe seeds every host with these
|
|
WEB_PORTS: tuple[int, ...] = (
|
|
80, 81, 88, 443, 591, 593, 832, 981, 1010, 1099, 1311, 2082, 2083, 2086, 2087,
|
|
2095, 2096, 2480, 3000, 3001, 3002, 3003, 3128, 3333, 4243, 4443, 4567, 4711,
|
|
4712, 4993, 5000, 5001, 5104, 5108, 5280, 5281, 5601, 5800, 6543, 7000, 7001,
|
|
7002, 7396, 7474, 8000, 8001, 8008, 8009, 8014, 8042, 8060, 8069, 8080, 8081,
|
|
8083, 8088, 8090, 8091, 8095, 8118, 8123, 8172, 8181, 8222, 8243, 8280, 8281,
|
|
8333, 8337, 8443, 8500, 8834, 8880, 8888, 8983, 9000, 9001, 9043, 9060, 9080,
|
|
9090, 9091, 9200, 9443, 9502, 9800, 9981, 10000, 10250, 11371, 12443, 15672,
|
|
16080, 17778, 18091, 18092, 20720, 32000, 55440, 55672,
|
|
) # fmt: skip
|
|
|
|
# administrative and datastore ports whose exposure is the finding
|
|
SENSITIVE_PORTS: list[int] = [
|
|
21, 22, 23, 25, 53, 111, 135, 139, 389, 445, 512, 513, 514, 623, 873, 1080,
|
|
1433, 1521, 1723, 2049, 2181, 2375, 2376, 2379, 3306, 3389, 4369, 5432, 5601,
|
|
5672, 5900, 5901, 5984, 6379, 8086, 9042, 9092, 9160, 9200, 9300, 11211,
|
|
15672, 27017, 27018, 50000,
|
|
] # fmt: skip
|
|
|
|
_TLS_PORTS: frozenset[int] = frozenset(
|
|
{443, 465, 636, 989, 990, 993, 995, 2376, 4443, 5061, 8443, 9443, 12443}
|
|
)
|
|
|
|
|
|
class PortProfile(StrEnum):
|
|
WEB = "web"
|
|
EXPOSURE = "exposure"
|
|
TOP_100 = "top-100"
|
|
TOP_1000 = "top-1000"
|
|
FULL = "full"
|
|
CUSTOM = "custom"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PortProfileSpec:
|
|
key: str
|
|
label: str
|
|
description: str
|
|
approx: int
|
|
|
|
|
|
PORT_PROFILES: tuple[PortProfileSpec, ...] = (
|
|
PortProfileSpec(
|
|
PortProfile.WEB.value,
|
|
"Web ports",
|
|
"Ports that commonly serve HTTP. Fastest path to the web surface.",
|
|
len(WEB_PORTS),
|
|
),
|
|
PortProfileSpec(
|
|
PortProfile.EXPOSURE.value,
|
|
"Web and sensitive",
|
|
"Web ports plus remote-access and datastore ports. The default.",
|
|
len(set(WEB_PORTS) | set(SENSITIVE_PORTS)),
|
|
),
|
|
PortProfileSpec(
|
|
PortProfile.TOP_100.value,
|
|
"Top 100",
|
|
"The 100 most common ports.",
|
|
100,
|
|
),
|
|
PortProfileSpec(
|
|
PortProfile.TOP_1000.value,
|
|
"Top 1000",
|
|
"The 1,000 most common ports. Slower, broad coverage.",
|
|
1000,
|
|
),
|
|
PortProfileSpec(
|
|
PortProfile.FULL.value,
|
|
"All ports",
|
|
"Every port from 1 to 65535. Slowest and loudest.",
|
|
MAX_PORT,
|
|
),
|
|
PortProfileSpec(
|
|
PortProfile.CUSTOM.value,
|
|
"Custom",
|
|
"The list or range set on the stage.",
|
|
0,
|
|
),
|
|
)
|
|
|
|
EXPOSURE_PORTS: tuple[int, ...] = tuple(sorted(set(WEB_PORTS) | set(SENSITIVE_PORTS)))
|
|
|
|
# the only ports a CDN edge proxies; scanning past them profiles the CDN, not the target
|
|
CDN_EDGE_PORTS: tuple[int, ...] = (
|
|
80, 443, 2052, 2053, 2082, 2083, 2086, 2087, 2095, 2096, 8080, 8443, 8880,
|
|
) # fmt: skip
|
|
|
|
|
|
_WEB_PORT_SET: frozenset[int] = frozenset(WEB_PORTS)
|
|
_SENSITIVE_SET: frozenset[int] = frozenset(SENSITIVE_PORTS)
|
|
_STANDARD_WEB: frozenset[int] = frozenset({80, 443})
|
|
_BY_NAME: dict[str, ServiceSpec] = {}
|
|
for _spec in WELL_KNOWN.values():
|
|
_BY_NAME.setdefault(_spec.name, _spec)
|
|
_BY_NAME.setdefault("http", _s("http", "HTTP", ServiceClass.WEB, "Web server"))
|
|
_BY_NAME.setdefault(
|
|
"https", _s("https", "HTTPS", ServiceClass.WEB, "Web server over TLS", tls=True)
|
|
)
|
|
|
|
|
|
# IANA's registry, generated by scripts/fetch_port_registry.py. It is the fallback,
|
|
# never the authority: IANA still calls 9200 wap-wsp and 5601 esmagent.
|
|
_REGISTRY_PATH = Path(__file__).parent / "data" / "port_registry.json"
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def _registry() -> dict[str, list[str]]:
|
|
try:
|
|
return json.loads(_REGISTRY_PATH.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
return {}
|
|
|
|
|
|
def registered(port: int) -> tuple[str | None, str]:
|
|
"""The IANA service name and description for a port, or (None, "")."""
|
|
entry = _registry().get(str(port))
|
|
if not entry:
|
|
return None, ""
|
|
return entry[0], entry[1] if len(entry) > 1 else ""
|
|
|
|
|
|
def service_for_port(port: int) -> str | None:
|
|
spec = WELL_KNOWN.get(port)
|
|
if spec is not None:
|
|
return spec.name
|
|
if port in _WEB_PORT_SET:
|
|
return "http"
|
|
return registered(port)[0]
|
|
|
|
|
|
def describe(port: int, service: str | None = None) -> tuple[str, bool]:
|
|
"""One line on what a port is for, and whether it is only IANA's registration."""
|
|
spec = _spec_for(port, service)
|
|
if spec is not None and spec.description:
|
|
return spec.description, False
|
|
text = registered(port)[1]
|
|
return (text, True) if text else ("", False)
|
|
|
|
|
|
def _spec_for(port: int, service: str | None) -> ServiceSpec | None:
|
|
"""The port's own entry unless a banner named something the port does not imply.
|
|
|
|
_BY_NAME keeps one spec per name, so consulting it first would give 2376 the
|
|
description of 2375 and tell an operator that Docker over TLS is unauthenticated.
|
|
"""
|
|
spec = WELL_KNOWN.get(port)
|
|
if spec is not None and (not service or service == spec.name):
|
|
return spec
|
|
return _BY_NAME.get(service or "") or spec
|
|
|
|
|
|
def service_class(service: str | None, port: int, *, is_http: bool = False) -> str:
|
|
"""The one class a service belongs to. HTTP evidence always wins."""
|
|
if is_http:
|
|
return ServiceClass.WEB.value
|
|
spec = _spec_for(port, service)
|
|
if spec is not None:
|
|
return spec.klass
|
|
if port in _WEB_PORT_SET:
|
|
return ServiceClass.WEB.value
|
|
return ServiceClass.OTHER.value
|
|
|
|
|
|
def service_label(service: str | None) -> str:
|
|
named = _BY_NAME.get(service or "")
|
|
return named.label if named else (service or "Unknown")
|
|
|
|
|
|
def port_interest(port: int) -> int:
|
|
"""Display rank, mirroring the exposure panel: sensitive, non-web, off-port web, web."""
|
|
if port in _SENSITIVE_SET:
|
|
return 0
|
|
if port not in _WEB_PORT_SET:
|
|
return 1
|
|
return 3 if port in _STANDARD_WEB else 2
|
|
|
|
|
|
def likely_tls(port: int) -> bool:
|
|
spec = WELL_KNOWN.get(port)
|
|
return port in _TLS_PORTS or bool(spec and spec.tls)
|
|
|
|
|
|
def profile_ports(profile: str) -> tuple[int, ...] | None:
|
|
"""The explicit port list for a profile, or None when the tool expands it itself."""
|
|
if profile == PortProfile.WEB.value:
|
|
return WEB_PORTS
|
|
if profile == PortProfile.EXPOSURE.value:
|
|
return EXPOSURE_PORTS
|
|
return None
|