From 85cc09924267e4b23568d1e5fd2831b7b1886b76 Mon Sep 17 00:00:00 2001 From: toderian Date: Mon, 16 Feb 2026 12:25:29 +0000 Subject: [PATCH 01/25] fix: add pass and job duration --- .../cybersec/red_mesh/pentester_api_01.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/extensions/business/cybersec/red_mesh/pentester_api_01.py b/extensions/business/cybersec/red_mesh/pentester_api_01.py index dbba0e31..2068edfa 100644 --- a/extensions/business/cybersec/red_mesh/pentester_api_01.py +++ b/extensions/business/cybersec/red_mesh/pentester_api_01.py @@ -848,9 +848,13 @@ def _maybe_finalize_pass(self): # ═══════════════════════════════════════════════════ # STATE: All peers completed current pass # ═══════════════════════════════════════════════════ + pass_date_started = job_specs.get("pass_date_started", job_specs.get("date_created")) + pass_date_completed = self.time() pass_history.append({ "pass_nr": job_pass, - "completed_at": self.time(), + "date_started": pass_date_started, + "date_completed": pass_date_completed, + "duration": round(pass_date_completed - pass_date_started, 2) if pass_date_started else None, "reports": {addr: w.get("report_cid") for addr, w in workers.items()} }) @@ -859,6 +863,7 @@ def _maybe_finalize_pass(self): job_specs["job_status"] = "FINALIZED" job_specs["date_updated"] = self.time() job_specs["date_finalized"] = self.time() + job_specs["duration"] = round(self.time() - job_specs.get("date_created", self.time()), 2) self.P(f"[SINGLEPASS] Job {job_id} complete. Status set to FINALIZED.") # Run LLM auto-analysis on aggregated report (launcher only) @@ -875,6 +880,7 @@ def _maybe_finalize_pass(self): job_specs["job_status"] = "STOPPED" job_specs["date_updated"] = self.time() job_specs["date_finalized"] = self.time() + job_specs["duration"] = round(self.time() - job_specs.get("date_created", self.time()), 2) self.P(f"[CONTINUOUS] Pass {job_pass} complete for job {job_id}. Status set to STOPPED (soft stop was scheduled)") # Run LLM auto-analysis on aggregated report (launcher only) @@ -909,6 +915,7 @@ def _maybe_finalize_pass(self): # ═══════════════════════════════════════════════════ job_specs["job_pass"] = job_pass + 1 job_specs["next_pass_at"] = None + job_specs["pass_date_started"] = self.time() for addr in workers: workers[addr]["finished"] = False @@ -1274,6 +1281,7 @@ def launch_test( "job_pass": 1, "next_pass_at": None, "pass_history": [], + "pass_date_started": self.time(), # Dune sand walking "scan_min_delay": scan_min_delay, "scan_max_delay": scan_max_delay, @@ -1613,9 +1621,13 @@ def analyze_job( pass_history[-1]["llm_analysis_cid"] = analysis_cid else: # No pass_history yet - create one + pass_date_started = job_specs.get("pass_date_started", job_specs.get("date_created")) + pass_date_completed = self.time() pass_history.append({ "pass_nr": current_pass, - "completed_at": self.time(), + "date_started": pass_date_started, + "date_completed": pass_date_completed, + "duration": round(pass_date_completed - pass_date_started, 2) if pass_date_started else None, "reports": {addr: w.get("report_cid") for addr, w in workers.items()}, "llm_analysis_cid": analysis_cid, }) From b73755b07c5a2319872decd3a7a7bd4d41bc5083 Mon Sep 17 00:00:00 2001 From: toderian Date: Tue, 17 Feb 2026 14:53:31 +0000 Subject: [PATCH 02/25] feat: add deep ssh check, try login --- .devcontainer/requirements.txt | 3 +- .../business/cybersec/red_mesh/constants.py | 53 ++++- .../cybersec/red_mesh/redmesh_utils.py | 222 +++++++++++++++++- .../cybersec/red_mesh/service_mixin.py | 117 ++++++++- requirements.txt | 1 + 5 files changed, 373 insertions(+), 23 deletions(-) diff --git a/.devcontainer/requirements.txt b/.devcontainer/requirements.txt index d3c6ccc4..d423946a 100644 --- a/.devcontainer/requirements.txt +++ b/.devcontainer/requirements.txt @@ -4,4 +4,5 @@ decentra-vision python-telegram-bot[rate-limiter] protobuf==5.28.3 vectordb -ngrok \ No newline at end of file +ngrok +paramiko \ No newline at end of file diff --git a/extensions/business/cybersec/red_mesh/constants.py b/extensions/business/cybersec/red_mesh/constants.py index 65bd84b1..0442688d 100644 --- a/extensions/business/cybersec/red_mesh/constants.py +++ b/extensions/business/cybersec/red_mesh/constants.py @@ -106,4 +106,55 @@ # LLM Analysis types LLM_ANALYSIS_SECURITY_ASSESSMENT = "security_assessment" LLM_ANALYSIS_VULNERABILITY_SUMMARY = "vulnerability_summary" -LLM_ANALYSIS_REMEDIATION_PLAN = "remediation_plan" \ No newline at end of file +LLM_ANALYSIS_REMEDIATION_PLAN = "remediation_plan" + +# ===================================================================== +# Protocol fingerprinting and probe routing +# ===================================================================== + +# Fingerprint configuration +FINGERPRINT_TIMEOUT = 2 # seconds — passive banner grab timeout +FINGERPRINT_MAX_BANNER = 512 # bytes — max banner stored per port +FINGERPRINT_HTTP_TIMEOUT = 4 # seconds — active HTTP probe timeout (honeypots may be slow) +FINGERPRINT_NUDGE_TIMEOUT = 3 # seconds — generic \r\n nudge probe timeout + +# Well-known TCP port → protocol (fallback when banner is unrecognized) +WELL_KNOWN_PORTS = { + 21: "ftp", 22: "ssh", 23: "telnet", 25: "smtp", 42: "wins", + 53: "dns", 80: "http", 81: "http", 110: "pop3", 143: "imap", + 161: "snmp", 443: "https", 445: "smb", 502: "modbus", + 1433: "mssql", 3306: "mysql", 3389: "rdp", 5432: "postgresql", + 5900: "vnc", 6379: "redis", 8000: "http", 8008: "http", + 8080: "http", 8081: "http", 8443: "https", 8888: "http", + 9200: "http", 11211: "memcached", 27017: "mongodb", +} + +# Protocols where web vulnerability tests are applicable +WEB_PROTOCOLS = frozenset({"http", "https"}) + +# Which protocols each service probe is designed to test. +# Probes not listed here run unconditionally (forward-compatible with new probes). +PROBE_PROTOCOL_MAP = { + "_service_info_21": frozenset({"ftp"}), + "_service_info_22": frozenset({"ssh"}), + "_service_info_23": frozenset({"telnet"}), + "_service_info_25": frozenset({"smtp"}), + "_service_info_53": frozenset({"dns"}), + "_service_info_80": frozenset({"http"}), + "_service_info_443": frozenset({"https"}), + "_service_info_8080": frozenset({"http"}), + "_service_info_tls": frozenset({"https", "unknown", "wins"}), + "_service_info_1433": frozenset({"mssql"}), + "_service_info_3306": frozenset({"mysql"}), + "_service_info_3389": frozenset({"rdp"}), + "_service_info_5432": frozenset({"postgresql"}), + "_service_info_5900": frozenset({"vnc"}), + "_service_info_6379": frozenset({"redis"}), + "_service_info_9200": frozenset({"http", "https"}), + "_service_info_11211": frozenset({"memcached"}), + "_service_info_27017": frozenset({"mongodb"}), + "_service_info_161": frozenset({"snmp"}), + "_service_info_445": frozenset({"smb"}), + "_service_info_502": frozenset({"modbus"}), + "_service_info_generic": frozenset({"unknown", "wins"}), +} \ No newline at end of file diff --git a/extensions/business/cybersec/red_mesh/redmesh_utils.py b/extensions/business/cybersec/red_mesh/redmesh_utils.py index d1d93865..5f62dc5d 100644 --- a/extensions/business/cybersec/red_mesh/redmesh_utils.py +++ b/extensions/business/cybersec/red_mesh/redmesh_utils.py @@ -12,6 +12,12 @@ from .service_mixin import _ServiceInfoMixin from .web_mixin import _WebTestsMixin +from .constants import ( + PROBE_PROTOCOL_MAP, WEB_PROTOCOLS, + WELL_KNOWN_PORTS as _WELL_KNOWN_PORTS, + FINGERPRINT_TIMEOUT, FINGERPRINT_MAX_BANNER, FINGERPRINT_HTTP_TIMEOUT, + FINGERPRINT_NUDGE_TIMEOUT, +) COMMON_PORTS = [ @@ -147,6 +153,9 @@ def __init__( "web_tested": False, "web_tests_info": {}, + "port_protocols": {}, + "port_banners": {}, + "completed_tests": [], "done": False, "canceled": False, @@ -200,12 +209,14 @@ def get_worker_specific_result_fields(): return { "start_port" : min, "end_port" : max, - "ports_scanned" : sum, - + "ports_scanned" : sum, + "open_ports" : list, "service_info" : dict, "web_tests_info" : dict, "completed_tests" : list, + "port_protocols" : dict, + "port_banners" : dict, } @@ -226,11 +237,11 @@ def get_status(self, for_aggregations=False): completed_tests = self.state.get("completed_tests", []) open_ports = self.state.get("open_ports", []) if open_ports: - # Full work: port scan + all enabled features + 2 completion markers - max_features = len(self.__enabled_features) + 3 + # Full work: port scan + fingerprint + all enabled features + 2 completion markers + max_features = len(self.__enabled_features) + 4 else: - # No open ports: just port scan + 2 completion markers - max_features = 3 + # No open ports: port scan + fingerprint + service_info_completed + web_tests_completed + max_features = 4 progress = f"{(len(completed_tests) / max_features) * 100:.1f}%" dct_status = { @@ -260,6 +271,9 @@ def get_status(self, for_aggregations=False): dct_status["completed_tests"] = self.state["completed_tests"] + dct_status["port_protocols"] = self.state.get("port_protocols", {}) + dct_status["port_banners"] = self.state.get("port_banners", {}) + return dct_status @@ -358,6 +372,10 @@ def execute_job(self): if not self._check_stopped(): self._scan_ports_step() + if not self._check_stopped(): + self._fingerprint_ports() + self.state["completed_tests"].append("fingerprint_completed") + if not self._check_stopped(): self._gather_service_info() self.state["completed_tests"].append("service_info_completed") @@ -459,6 +477,175 @@ def _scan_ports_step(self, batch_size=None, batch_nr=1): return + def _fingerprint_ports(self): + """ + Classify each open port by protocol using passive banner grabbing. + + For each open port the method attempts, in order: + + 1. **Passive banner grab** — connect and recv without sending data. + 2. **Banner-based classification** — pattern-match known protocol greetings. + 3. **Well-known port lookup** — fall back to ``WELL_KNOWN_PORTS``. + 4. **Generic nudge probe** — send ``\\r\\n`` to elicit a response from + services that wait for client input (honeypots, RPC, custom daemons). + 5. **Active HTTP probe** — minimal ``HEAD /`` request for silent HTTP servers. + 6. **Default** — mark the port as ``"unknown"``. + + Results are stored in ``state["port_protocols"]`` and + ``state["port_banners"]``. + + Returns + ------- + None + """ + open_ports = self.state["open_ports"] + if not open_ports: + self.P("No open ports to fingerprint.") + return + + target = self.target + port_protocols = {} + port_banners = {} + + self.P(f"Fingerprinting {len(open_ports)} open ports.") + + for port in open_ports: + if self.stop_event.is_set(): + return + + protocol = None + banner_text = "" + + # --- 1. Passive banner grab --- + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(FINGERPRINT_TIMEOUT) + sock.connect((target, port)) + try: + raw = sock.recv(FINGERPRINT_MAX_BANNER) + except (socket.timeout, OSError): + raw = b"" + sock.close() + except Exception: + raw = b"" + + # --- Sanitize banner --- + if raw: + banner_text = ''.join( + ch if 32 <= ord(ch) < 127 else '.' for ch in raw[:FINGERPRINT_MAX_BANNER].decode("utf-8", errors="replace") + ) + + # --- 2. Classify banner by content --- + if raw: + text = raw.decode("utf-8", errors="replace") + text_upper = text.upper() + + if text.startswith("SSH-"): + protocol = "ssh" + elif text.startswith("220"): + if "FTP" in text_upper: + protocol = "ftp" + elif "SMTP" in text_upper or "ESMTP" in text_upper: + protocol = "smtp" + else: + protocol = _WELL_KNOWN_PORTS.get(port, "ftp") + elif text.startswith("RFB "): + protocol = "vnc" + elif raw[0:1] == b'\x0a': + protocol = "mysql" + elif "login:" in text.lower() or raw[0:1] == b'\xff': + protocol = "telnet" + elif text.startswith("HTTP/"): + protocol = "http" + elif text.startswith("+OK"): + protocol = "pop3" + elif text.startswith("* OK"): + protocol = "imap" + + # --- 3. Well-known port lookup --- + if protocol is None: + protocol = _WELL_KNOWN_PORTS.get(port) + + # --- 4. Generic nudge probe --- + # Some services (honeypots, RPC, custom daemons) don't speak first + # but will respond to any input. Send a minimal \r\n nudge. + if protocol is None: + try: + nudge_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + nudge_sock.settimeout(FINGERPRINT_NUDGE_TIMEOUT) + nudge_sock.connect((target, port)) + nudge_sock.sendall(b"\r\n") + try: + nudge_resp = nudge_sock.recv(FINGERPRINT_MAX_BANNER) + except (socket.timeout, OSError): + nudge_resp = b"" + nudge_sock.close() + except Exception: + nudge_resp = b"" + + if nudge_resp: + nudge_text = nudge_resp.decode("utf-8", errors="replace") + if not banner_text: + banner_text = ''.join( + ch if 32 <= ord(ch) < 127 else '.' + for ch in nudge_text[:FINGERPRINT_MAX_BANNER] + ) + if nudge_text.startswith("HTTP/"): + protocol = "http" + elif "=0.2.82 \ No newline at end of file From 47d0f197279193bd6f2c4d52df8328e628d5ebc5 Mon Sep 17 00:00:00 2001 From: toderian Date: Tue, 17 Feb 2026 15:05:39 +0000 Subject: [PATCH 03/25] fix: 21 ftp check --- .../cybersec/red_mesh/service_mixin.py | 204 ++++++++++++++++-- 1 file changed, 188 insertions(+), 16 deletions(-) diff --git a/extensions/business/cybersec/red_mesh/service_mixin.py b/extensions/business/cybersec/red_mesh/service_mixin.py index 5ab3c889..a3b453a7 100644 --- a/extensions/business/cybersec/red_mesh/service_mixin.py +++ b/extensions/business/cybersec/red_mesh/service_mixin.py @@ -20,6 +20,16 @@ ("test", "test"), ] +# Default credentials for FTP services. +_FTP_DEFAULT_CREDS = [ + ("root", "root"), + ("admin", "admin"), + ("admin", "password"), + ("ftp", "ftp"), + ("user", "user"), + ("test", "test"), +] + class _ServiceInfoMixin: """ Network service banner probes feeding RedMesh reports. @@ -173,7 +183,18 @@ def _service_info_tls(self, target, port): def _service_info_21(self, target, port): """ - Identify FTP banners and anonymous login exposure. + Assess FTP service security: banner, anonymous access, default creds, + server fingerprint, TLS support, write access, and honeypot detection. + + Checks performed (in order): + + 1. Banner grab and SYST/FEAT fingerprint. + 2. Anonymous login attempt. + 3. Write access test (STOR) after anonymous login. + 4. Directory listing and traversal. + 5. TLS support check (AUTH TLS). + 6. Default credential check. + 7. Honeypot detection — random credentials accepted. Parameters ---------- @@ -184,25 +205,176 @@ def _service_info_21(self, target, port): Returns ------- - str | None - FTP banner info or vulnerability message. + dict + Structured findings with banner, vulnerabilities, server_info, etc. """ - info = None + result = { + "banner": None, + "server_type": None, + "features": [], + "anonymous_access": False, + "write_access": False, + "tls_supported": False, + "vulnerabilities": [], + "accepted_credentials": [], + "directory_listing": None, + } + + def _ftp_connect(user=None, passwd=None): + """Open a fresh FTP connection and optionally login.""" + ftp = ftplib.FTP(timeout=5) + ftp.connect(target, port, timeout=5) + if user is not None: + ftp.login(user, passwd or "") + return ftp + + # --- 1. Banner grab --- try: - ftp = ftplib.FTP(timeout=3) - ftp.connect(target, port, timeout=3) - banner = ftp.getwelcome() - info = f"FTP banner: {banner}" + ftp = _ftp_connect() + result["banner"] = ftp.getwelcome() + except Exception as e: + return {"error": f"FTP probe failed on {target}:{port}: {e}"} + + # --- 2. Anonymous login --- + try: + resp = ftp.login() + result["anonymous_access"] = True + result["vulnerabilities"].append( + "FTP allows anonymous login." + ) + except Exception: + # Anonymous failed — close and move on to credential tests try: - ftp.login() # attempt anonymous login - info = f"VULNERABILITY: FTP allows anonymous login (banner: {banner})" + ftp.quit() except Exception: - info = f"FTP banner: {banner} | Anonymous login not allowed" - ftp.quit() - except Exception as e: - info = f"FTP probe failed on {target}:{port}: {e}" - self.P(info, color='y') - return info + pass + ftp = None + + # --- 2b. SYST / FEAT (after login — some servers require auth first) --- + if ftp: + try: + syst = ftp.sendcmd("SYST") + result["server_type"] = syst + except Exception: + pass + + try: + feat_resp = ftp.sendcmd("FEAT") + feats = [ + line.strip() for line in feat_resp.split("\n") + if line.strip() and not line.startswith("211") + ] + result["features"] = feats + except Exception: + pass + + # --- 3. Write access test (only if anonymous login succeeded) --- + if ftp and result["anonymous_access"]: + import io + try: + ftp.set_pasv(True) + test_data = io.BytesIO(b"RedMesh write access probe") + resp = ftp.storbinary("STOR __redmesh_probe.txt", test_data) + if resp and resp.startswith("226"): + result["write_access"] = True + result["vulnerabilities"].append( + "FTP anonymous write access enabled (file upload possible)." + ) + try: + ftp.delete("__redmesh_probe.txt") + except Exception: + pass + except Exception: + pass + + # --- 4. Directory listing and traversal --- + if ftp: + try: + pwd = ftp.pwd() + files = [] + try: + ftp.retrlines("LIST", files.append) + except Exception: + pass + if files: + result["directory_listing"] = files[:20] + except Exception: + pass + + # Check if CWD allows directory traversal + for test_dir in ["/etc", "/var", ".."]: + try: + resp = ftp.cwd(test_dir) + if resp and (resp.startswith("250") or resp.startswith("200")): + result["vulnerabilities"].append( + f"FTP directory traversal: CWD to '{test_dir}' succeeded." + ) + break + except Exception: + pass + try: + ftp.cwd("/") + except Exception: + pass + + if ftp: + try: + ftp.quit() + except Exception: + pass + + # --- 5. TLS support check --- + try: + ftp_tls = _ftp_connect() + resp = ftp_tls.sendcmd("AUTH TLS") + if resp.startswith("234"): + result["tls_supported"] = True + try: + ftp_tls.quit() + except Exception: + pass + except Exception: + if not result["tls_supported"]: + result["vulnerabilities"].append( + "FTP does not support TLS encryption (cleartext credentials)." + ) + + # --- 6. Default credential check --- + for user, passwd in _FTP_DEFAULT_CREDS: + try: + ftp_cred = _ftp_connect(user, passwd) + result["accepted_credentials"].append(f"{user}:{passwd}") + result["vulnerabilities"].append( + f"FTP default credential accepted: {user}:{passwd}" + ) + try: + ftp_cred.quit() + except Exception: + pass + except (ftplib.error_perm, ftplib.error_reply): + pass + except Exception: + pass + + # --- 7. Honeypot detection — try random credentials --- + import string as _string + ruser = "".join(random.choices(_string.ascii_lowercase, k=8)) + rpass = "".join(random.choices(_string.ascii_letters + _string.digits, k=12)) + try: + ftp_rand = _ftp_connect(ruser, rpass) + result["vulnerabilities"].append( + "FTP accepts arbitrary credentials (possible honeypot)." + ) + try: + ftp_rand.quit() + except Exception: + pass + except (ftplib.error_perm, ftplib.error_reply): + pass + except Exception: + pass + + return result def _service_info_22(self, target, port): """ From b8dd8463d42524285d4c2c18e7fe9aa384aa79a5 Mon Sep 17 00:00:00 2001 From: toderian Date: Thu, 19 Feb 2026 08:11:55 +0000 Subject: [PATCH 04/25] fix: improve telnet & 80 check --- .../cybersec/red_mesh/service_mixin.py | 485 ++++++++++++++++-- 1 file changed, 446 insertions(+), 39 deletions(-) diff --git a/extensions/business/cybersec/red_mesh/service_mixin.py b/extensions/business/cybersec/red_mesh/service_mixin.py index a3b453a7..b1a6afc4 100644 --- a/extensions/business/cybersec/red_mesh/service_mixin.py +++ b/extensions/business/cybersec/red_mesh/service_mixin.py @@ -30,6 +30,17 @@ ("test", "test"), ] +# Default credentials for Telnet services. +_TELNET_DEFAULT_CREDS = [ + ("root", "root"), + ("root", "toor"), + ("root", "password"), + ("admin", "admin"), + ("admin", "password"), + ("user", "user"), + ("test", "test"), +] + class _ServiceInfoMixin: """ Network service banner probes feeding RedMesh reports. @@ -42,7 +53,8 @@ class _ServiceInfoMixin: def _service_info_80(self, target, port): """ - Collect HTTP banner and server metadata for common web ports. + Assess HTTP service: server fingerprint, technology detection, + dangerous HTTP methods, and page title extraction. Parameters ---------- @@ -53,22 +65,88 @@ def _service_info_80(self, target, port): Returns ------- - str | None - Banner summary or error message. + dict + Structured findings. """ - info = None + import re as _re + + scheme = "https" if port in (443, 8443) else "http" + url = f"{scheme}://{target}" if port in (80, 443) else f"{scheme}://{target}:{port}" + + result = { + "banner": None, + "server": None, + "title": None, + "technologies": [], + "dangerous_methods": [], + "vulnerabilities": [], + } + + # --- 1. GET request — banner, server, title, tech fingerprint --- try: - scheme = "https" if port in (443, 8443) else "http" - url = f"{scheme}://{target}" - if port not in (80, 443): - url = f"{scheme}://{target}:{port}" self.P(f"Fetching {url} for banner...") - resp = requests.get(url, timeout=3, verify=False) - info = (f"HTTP {resp.status_code} {resp.reason}; Server: {resp.headers.get('Server')}") + resp = requests.get(url, timeout=5, verify=False, allow_redirects=True) + + result["banner"] = f"HTTP {resp.status_code} {resp.reason}" + result["server"] = resp.headers.get("Server") + powered_by = resp.headers.get("X-Powered-By") + + # Page title + title_match = _re.search( + r"(.*?)", resp.text[:5000], _re.IGNORECASE | _re.DOTALL + ) + if title_match: + result["title"] = title_match.group(1).strip()[:100] + + # Technology fingerprinting + body_lower = resp.text[:8000].lower() + tech_signatures = { + "WordPress": ["wp-content", "wp-includes"], + "Joomla": ["com_content", "/media/jui/"], + "Drupal": ["drupal.js", "sites/default/files"], + "Django": ["csrfmiddlewaretoken"], + "PHP": [".php", "phpsessid"], + "ASP.NET": ["__viewstate", ".aspx"], + "React": ["_next/", "__next_data__", "react"], + } + techs = [] + if result["server"]: + techs.append(result["server"]) + if powered_by: + techs.append(powered_by) + for tech, markers in tech_signatures.items(): + if any(m in body_lower for m in markers): + techs.append(tech) + result["technologies"] = techs + except Exception as e: - info = f"HTTP probe failed on {target}:{port}: {e}" - self.P(info, color='y') - return info + return {"error": f"HTTP probe failed on {target}:{port}: {e}"} + + # --- 2. Dangerous HTTP methods --- + dangerous = [] + for method in ("TRACE", "PUT", "DELETE"): + try: + r = requests.request(method, url, timeout=3, verify=False) + if r.status_code < 400: + dangerous.append(method) + except Exception: + pass + + result["dangerous_methods"] = dangerous + if "TRACE" in dangerous: + result["vulnerabilities"].append( + "HTTP TRACE method enabled (cross-site tracing / XST attack vector)." + ) + if "PUT" in dangerous: + result["vulnerabilities"].append( + "HTTP PUT method enabled (potential unauthorized file upload)." + ) + if "DELETE" in dangerous: + result["vulnerabilities"].append( + "HTTP DELETE method enabled (potential unauthorized file deletion)." + ) + + return result def _service_info_8080(self, target, port): @@ -490,7 +568,17 @@ def _service_info_22(self, target, port): def _service_info_25(self, target, port): """ - Capture SMTP banner data for mail infrastructure mapping. + Assess SMTP service security: banner, EHLO features, STARTTLS, + authentication methods, open relay, and user enumeration. + + Checks performed (in order): + + 1. Banner grab — fingerprint MTA software and version. + 2. EHLO — enumerate server capabilities (SIZE, AUTH, STARTTLS, etc.). + 3. STARTTLS support — check for encryption. + 4. AUTH methods — detect available authentication mechanisms. + 5. Open relay test — attempt MAIL FROM / RCPT TO without auth. + 6. VRFY / EXPN — test user enumeration commands. Parameters ---------- @@ -501,21 +589,153 @@ def _service_info_25(self, target, port): Returns ------- - str | None - SMTP banner text or error message. + dict + Structured findings. """ - info = None + import smtplib + + result = { + "banner": None, + "server_hostname": None, + "max_message_size": None, + "auth_methods": [], + "vulnerabilities": [], + } + + # --- 1. Connect and grab banner --- try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(3) - sock.connect((target, port)) - banner = sock.recv(1024).decode('utf-8', errors='ignore') - info = f"SMTP banner: {banner.strip()}" - sock.close() + smtp = smtplib.SMTP(timeout=5) + code, msg = smtp.connect(target, port) + result["banner"] = f"{code} {msg.decode(errors='replace')}" except Exception as e: - info = f"SMTP probe failed on {target}:{port}: {e}" - self.P(info, color='y') - return info + return {"error": f"SMTP probe failed on {target}:{port}: {e}"} + + # --- 2. EHLO — server capabilities --- + ehlo_features = [] + try: + code, msg = smtp.ehlo("probe.redmesh.local") + if code == 250: + for line in msg.decode(errors="replace").split("\n"): + feat = line.strip() + if feat: + ehlo_features.append(feat) + except Exception: + # Fallback to HELO + try: + smtp.helo("probe.redmesh.local") + except Exception: + pass + + # Parse meaningful fields from EHLO response + for idx, feat in enumerate(ehlo_features): + upper = feat.upper() + if idx == 0 and " Hello " in feat: + # First line is the server greeting: "hostname Hello client [ip]" + result["server_hostname"] = feat.split()[0] + if upper.startswith("SIZE "): + try: + size_bytes = int(feat.split()[1]) + result["max_message_size"] = f"{size_bytes // (1024*1024)}MB" + except (ValueError, IndexError): + pass + if upper.startswith("AUTH "): + result["auth_methods"] = feat.split()[1:] + + # --- 2b. Banner / hostname information disclosure --- + import re as _re + banner_text = result["banner"] or "" + # Extract MTA version from banner (e.g. "Exim 4.97", "Postfix", "Sendmail 8.x") + version_match = _re.search( + r"(Exim|Postfix|Sendmail|Microsoft ESMTP|hMailServer|Haraka|OpenSMTPD)" + r"[\s/]*([0-9][0-9.]*)?", + banner_text, _re.IGNORECASE, + ) + if version_match: + mta = version_match.group(0).strip() + result["vulnerabilities"].append( + f"SMTP banner discloses MTA software: {mta} (aids CVE lookup)." + ) + + if result["server_hostname"]: + # Check if hostname reveals container/internal info + hostname = result["server_hostname"] + if _re.search(r"[0-9a-f]{12}", hostname): + result["vulnerabilities"].append( + f"SMTP hostname leaks container ID: {hostname} (infrastructure disclosure)." + ) + + # --- 3. STARTTLS --- + starttls_supported = any("STARTTLS" in f.upper() for f in ehlo_features) + if not starttls_supported: + try: + code, msg = smtp.docmd("STARTTLS") + if code == 220: + starttls_supported = True + except Exception: + pass + + if not starttls_supported: + result["vulnerabilities"].append( + "SMTP does not support STARTTLS (credentials sent in cleartext)." + ) + + # --- 4. AUTH without credentials --- + if result["auth_methods"]: + try: + code, msg = smtp.docmd("AUTH LOGIN") + if code == 235: + result["vulnerabilities"].append( + "SMTP AUTH LOGIN accepted without credentials." + ) + except Exception: + pass + + # --- 5. Open relay test --- + try: + smtp.rset() + except Exception: + try: + smtp.quit() + except Exception: + pass + try: + smtp = smtplib.SMTP(target, port, timeout=5) + smtp.ehlo("probe.redmesh.local") + except Exception: + smtp = None + + if smtp: + try: + code_from, _ = smtp.docmd("MAIL FROM:") + if code_from == 250: + code_rcpt, _ = smtp.docmd("RCPT TO:") + if code_rcpt == 250: + result["vulnerabilities"].append( + "SMTP open relay detected (accepts mail to external domains without auth)." + ) + smtp.docmd("RSET") + except Exception: + pass + + # --- 6. VRFY / EXPN --- + if smtp: + for cmd_name in ("VRFY", "EXPN"): + try: + code, msg = smtp.docmd(cmd_name, "root") + if code in (250, 251, 252): + result["vulnerabilities"].append( + f"SMTP {cmd_name} command enabled (user enumeration possible)." + ) + except Exception: + pass + + if smtp: + try: + smtp.quit() + except Exception: + pass + + return result def _service_info_3306(self, target, port): """ @@ -616,7 +836,16 @@ def _service_info_6379(self, target, port): def _service_info_23(self, target, port): """ - Fetch Telnet negotiation banner. + Assess Telnet service security: banner, negotiation options, default + credentials, privilege level, system fingerprint, and honeypot detection. + + Checks performed (in order): + + 1. Banner grab and IAC option parsing. + 2. Default credential check — try common user:pass combos. + 3. Privilege escalation check — report if root shell is obtained. + 4. System fingerprint — run ``id`` and ``uname -a`` on successful login. + 5. Honeypot detection — random credentials should be rejected. Parameters ---------- @@ -627,24 +856,202 @@ def _service_info_23(self, target, port): Returns ------- - str | None - Telnet banner or error message. + dict + Structured findings. """ - info = None + import time as _time + + result = { + "banner": None, + "negotiation_options": [], + "vulnerabilities": [ + "Telnet service is running (unencrypted remote access)." + ], + "accepted_credentials": [], + "system_info": None, + } + + # --- 1. Banner grab + IAC negotiation parsing --- try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(2) + sock.settimeout(5) sock.connect((target, port)) - banner = sock.recv(1024).decode('utf-8', errors='ignore') - if banner: - info = f"VULNERABILITY: Telnet banner: {banner.strip()}" - else: - info = "VULNERABILITY: Telnet open with no banner" + raw = sock.recv(2048) sock.close() except Exception as e: - info = f"Telnet probe failed on {target}:{port}: {e}" - self.P(info, color='y') - return info + return {"error": f"Telnet probe failed on {target}:{port}: {e}"} + + # Parse IAC sequences + iac_options = [] + cmd_names = {251: "WILL", 252: "WONT", 253: "DO", 254: "DONT"} + opt_names = { + 0: "BINARY", 1: "ECHO", 3: "SGA", 5: "STATUS", + 24: "TERMINAL_TYPE", 31: "WINDOW_SIZE", 32: "TERMINAL_SPEED", + 33: "REMOTE_FLOW", 34: "LINEMODE", 36: "ENVIRON", 39: "NEW_ENVIRON", + } + i = 0 + text_parts = [] + while i < len(raw): + if raw[i] == 0xFF and i + 2 < len(raw): + cmd = cmd_names.get(raw[i + 1], f"CMD_{raw[i+1]}") + opt = opt_names.get(raw[i + 2], f"OPT_{raw[i+2]}") + iac_options.append(f"{cmd} {opt}") + i += 3 + else: + if 32 <= raw[i] < 127: + text_parts.append(chr(raw[i])) + i += 1 + + banner_text = "".join(text_parts).strip() + if banner_text: + result["banner"] = banner_text + elif iac_options: + result["banner"] = "(IAC negotiation only, no text banner)" + else: + result["banner"] = "(no banner)" + result["negotiation_options"] = iac_options + + # --- 2–4. Default credential check with system fingerprint --- + def _try_telnet_login(user, passwd): + """Attempt Telnet login, return (success, uid_line, uname_line).""" + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(5) + s.connect((target, port)) + + # Read until login prompt + buf = b"" + deadline = _time.time() + 5 + while _time.time() < deadline: + try: + chunk = s.recv(1024) + if not chunk: + break + buf += chunk + if b"login:" in buf.lower() or b"username:" in buf.lower(): + break + except socket.timeout: + break + + if b"login:" not in buf.lower() and b"username:" not in buf.lower(): + s.close() + return False, None, None + + s.sendall(user.encode() + b"\n") + + # Read until password prompt + buf = b"" + deadline = _time.time() + 5 + while _time.time() < deadline: + try: + chunk = s.recv(1024) + if not chunk: + break + buf += chunk + if b"assword:" in buf: + break + except socket.timeout: + break + + if b"assword:" not in buf: + s.close() + return False, None, None + + s.sendall(passwd.encode() + b"\n") + _time.sleep(1.5) + + # Read response + resp = b"" + try: + while True: + chunk = s.recv(4096) + if not chunk: + break + resp += chunk + except socket.timeout: + pass + + resp_text = resp.decode("utf-8", errors="replace") + + # Check for login failure indicators + fail_indicators = ["incorrect", "failed", "denied", "invalid", "login:"] + if any(ind in resp_text.lower() for ind in fail_indicators): + s.close() + return False, None, None + + # Login succeeded — try to get system info + uid_line = None + uname_line = None + try: + s.sendall(b"id\n") + _time.sleep(0.5) + id_resp = s.recv(2048).decode("utf-8", errors="replace") + for line in id_resp.replace("\r\n", "\n").split("\n"): + cleaned = line.strip() + # Remove ANSI/control sequences + import re + cleaned = re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", cleaned) + if "uid=" in cleaned: + uid_line = cleaned + break + except Exception: + pass + + try: + s.sendall(b"uname -a\n") + _time.sleep(0.5) + uname_resp = s.recv(2048).decode("utf-8", errors="replace") + for line in uname_resp.replace("\r\n", "\n").split("\n"): + cleaned = line.strip() + import re + cleaned = re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", cleaned) + if "linux" in cleaned.lower() or "unix" in cleaned.lower() or "darwin" in cleaned.lower(): + uname_line = cleaned + break + except Exception: + pass + + s.close() + return True, uid_line, uname_line + + except Exception: + return False, None, None + + system_info_captured = False + for user, passwd in _TELNET_DEFAULT_CREDS: + success, uid_line, uname_line = _try_telnet_login(user, passwd) + if success: + result["accepted_credentials"].append(f"{user}:{passwd}") + result["vulnerabilities"].append( + f"Telnet default credential accepted: {user}:{passwd}" + ) + # Check for root access + if uid_line and "uid=0" in uid_line: + result["vulnerabilities"].append( + f"Root shell access via Telnet with {user}:{passwd}." + ) + + # Capture system info once + if not system_info_captured and (uid_line or uname_line): + parts = [] + if uid_line: + parts.append(uid_line) + if uname_line: + parts.append(uname_line) + result["system_info"] = " | ".join(parts) + system_info_captured = True + + # --- 5. Honeypot detection — random credentials --- + import string as _string + ruser = "".join(random.choices(_string.ascii_lowercase, k=8)) + rpass = "".join(random.choices(_string.ascii_letters + _string.digits, k=12)) + success, _, _ = _try_telnet_login(ruser, rpass) + if success: + result["vulnerabilities"].append( + "Telnet accepts arbitrary credentials (possible honeypot)." + ) + + return result def _service_info_445(self, target, port): From 0b2fc5ba975a77ce6f32d0188e022cd81bc47abb Mon Sep 17 00:00:00 2001 From: toderian Date: Thu, 19 Feb 2026 17:05:54 +0000 Subject: [PATCH 05/25] feat: format pentester under OWASP format --- .../business/cybersec/red_mesh/constants.py | 47 +- .../cybersec/red_mesh/redmesh_utils.py | 10 +- .../cybersec/red_mesh/test_redmesh.py | 28 +- .../cybersec/red_mesh/web_api_mixin.py | 136 ++++ .../cybersec/red_mesh/web_discovery_mixin.py | 96 +++ .../cybersec/red_mesh/web_hardening_mixin.py | 255 ++++++++ .../cybersec/red_mesh/web_injection_mixin.py | 126 ++++ .../business/cybersec/red_mesh/web_mixin.py | 607 +----------------- 8 files changed, 668 insertions(+), 637 deletions(-) create mode 100644 extensions/business/cybersec/red_mesh/web_api_mixin.py create mode 100644 extensions/business/cybersec/red_mesh/web_discovery_mixin.py create mode 100644 extensions/business/cybersec/red_mesh/web_hardening_mixin.py create mode 100644 extensions/business/cybersec/red_mesh/web_injection_mixin.py diff --git a/extensions/business/cybersec/red_mesh/constants.py b/extensions/business/cybersec/red_mesh/constants.py index 0442688d..5116a70d 100644 --- a/extensions/business/cybersec/red_mesh/constants.py +++ b/extensions/business/cybersec/red_mesh/constants.py @@ -42,41 +42,32 @@ ] }, { - "id": "web_test_common", - "label": "Common exposure scan", - "description": "Probe default admin panels, disclosed files, and common misconfigurations.", + "id": "web_discovery", + "label": "Discovery", + "description": "Enumerate exposed files, admin panels, and homepage secrets (OWASP WSTG-INFO).", "category": "web", - "methods": [ - "_web_test_common", - "_web_test_homepage", - "_web_test_flags", - "_web_test_graphql_introspection", - "_web_test_metadata_endpoints" - ] + "methods": ["_web_test_common", "_web_test_homepage"] }, { - "id": "web_test_security_headers", - "label": "Security headers audit", - "description": "Check HSTS, CSP, X-Frame-Options, and other critical response headers.", + "id": "web_hardening", + "label": "Hardening audit", + "description": "Audit cookie flags, security headers, CORS policy, redirect handling, and HTTP methods (OWASP WSTG-CONF).", "category": "web", - "methods": [ - "_web_test_security_headers", - "_web_test_cors_misconfiguration", - "_web_test_open_redirect", - "_web_test_http_methods" - ] + "methods": ["_web_test_flags", "_web_test_security_headers", "_web_test_cors_misconfiguration", "_web_test_open_redirect", "_web_test_http_methods"] }, { - "id": "web_test_vulnerability", - "label": "Vulnerability probes", - "description": "Non-destructive probes for common web vulnerabilities.", + "id": "web_api_exposure", + "label": "API exposure", + "description": "Detect GraphQL introspection leaks, cloud metadata endpoints, and API auth bypass (OWASP WSTG-APIT).", "category": "web", - "methods": [ - "_web_test_path_traversal", - "_web_test_xss", - "_web_test_sql_injection", - "_web_test_api_auth_bypass" - ] + "methods": ["_web_test_graphql_introspection", "_web_test_metadata_endpoints", "_web_test_api_auth_bypass"] + }, + { + "id": "web_injection", + "label": "Injection probes", + "description": "Non-destructive probes for path traversal, reflected XSS, and SQL injection (OWASP WSTG-INPV).", + "category": "web", + "methods": ["_web_test_path_traversal", "_web_test_xss", "_web_test_sql_injection"] } ] diff --git a/extensions/business/cybersec/red_mesh/redmesh_utils.py b/extensions/business/cybersec/red_mesh/redmesh_utils.py index 5f62dc5d..fdf88d85 100644 --- a/extensions/business/cybersec/red_mesh/redmesh_utils.py +++ b/extensions/business/cybersec/red_mesh/redmesh_utils.py @@ -11,7 +11,10 @@ from copy import deepcopy from .service_mixin import _ServiceInfoMixin -from .web_mixin import _WebTestsMixin +from .web_discovery_mixin import _WebDiscoveryMixin +from .web_hardening_mixin import _WebHardeningMixin +from .web_api_mixin import _WebApiExposureMixin +from .web_injection_mixin import _WebInjectionMixin from .constants import ( PROBE_PROTOCOL_MAP, WEB_PROTOCOLS, WELL_KNOWN_PORTS as _WELL_KNOWN_PORTS, @@ -32,7 +35,10 @@ class PentestLocalWorker( _ServiceInfoMixin, - _WebTestsMixin + _WebDiscoveryMixin, + _WebHardeningMixin, + _WebApiExposureMixin, + _WebInjectionMixin, ): """ Execute a pentest workflow against a target on a dedicated thread. diff --git a/extensions/business/cybersec/red_mesh/test_redmesh.py b/extensions/business/cybersec/red_mesh/test_redmesh.py index 33572bb0..6c4d443e 100644 --- a/extensions/business/cybersec/red_mesh/test_redmesh.py +++ b/extensions/business/cybersec/red_mesh/test_redmesh.py @@ -75,7 +75,7 @@ def fake_get(url, timeout=2, verify=False): return resp with patch( - "extensions.business.cybersec.red_mesh.web_mixin.requests.get", + "extensions.business.cybersec.red_mesh.web_discovery_mixin.requests.get", side_effect=fake_get, ): result = worker._web_test_common("example.com", 80) @@ -87,7 +87,7 @@ def test_cryptographic_failures_cookie_flags(self): resp.headers = {"Set-Cookie": "sessionid=abc; Path=/"} resp.status_code = 200 with patch( - "extensions.business.cybersec.red_mesh.web_mixin.requests.get", + "extensions.business.cybersec.red_mesh.web_hardening_mixin.requests.get", return_value=resp, ): result = worker._web_test_flags("example.com", 443) @@ -101,7 +101,7 @@ def test_injection_sql_detected(self): resp.text = "sql syntax error near line" resp.status_code = 200 with patch( - "extensions.business.cybersec.red_mesh.web_mixin.requests.get", + "extensions.business.cybersec.red_mesh.web_injection_mixin.requests.get", return_value=resp, ): result = worker._web_test_sql_injection("example.com", 80) @@ -113,7 +113,7 @@ def test_insecure_design_path_traversal(self): resp.text = "root:x:0:0:root:/root:/bin/bash" resp.status_code = 200 with patch( - "extensions.business.cybersec.red_mesh.web_mixin.requests.get", + "extensions.business.cybersec.red_mesh.web_injection_mixin.requests.get", return_value=resp, ): result = worker._web_test_path_traversal("example.com", 80) @@ -125,7 +125,7 @@ def test_security_misconfiguration_missing_headers(self): resp.headers = {"Server": "Test"} resp.status_code = 200 with patch( - "extensions.business.cybersec.red_mesh.web_mixin.requests.get", + "extensions.business.cybersec.red_mesh.web_hardening_mixin.requests.get", return_value=resp, ): result = worker._web_test_security_headers("example.com", 80) @@ -231,7 +231,7 @@ def test_software_data_integrity_secret_leak(self): resp.text = "BEGIN RSA PRIVATE KEY" resp.status_code = 200 with patch( - "extensions.business.cybersec.red_mesh.web_mixin.requests.get", + "extensions.business.cybersec.red_mesh.web_discovery_mixin.requests.get", return_value=resp, ): result = worker._web_test_homepage("example.com", 80) @@ -270,7 +270,7 @@ def fake_get(url, timeout=2, verify=False): "extensions.business.cybersec.red_mesh.redmesh_utils.dir", return_value=["_web_test_common"], ), patch( - "extensions.business.cybersec.red_mesh.web_mixin.requests.get", + "extensions.business.cybersec.red_mesh.web_discovery_mixin.requests.get", side_effect=fake_get, ): worker._run_web_tests() @@ -314,7 +314,7 @@ def test_cross_site_scripting_detection(self): resp.text = f"Response with {payload} inside" resp.status_code = 200 with patch( - "extensions.business.cybersec.red_mesh.web_mixin.requests.get", + "extensions.business.cybersec.red_mesh.web_injection_mixin.requests.get", return_value=resp, ): result = worker._web_test_xss("example.com", 80) @@ -693,7 +693,7 @@ def test_web_graphql_introspection(self): resp.status_code = 200 resp.text = "{\"data\":{\"__schema\":{}}}" with patch( - "extensions.business.cybersec.red_mesh.web_mixin.requests.post", + "extensions.business.cybersec.red_mesh.web_api_mixin.requests.post", return_value=resp, ): result = worker._web_test_graphql_introspection("example.com", 80) @@ -708,7 +708,7 @@ def fake_get(url, timeout=3, verify=False, headers=None): return resp with patch( - "extensions.business.cybersec.red_mesh.web_mixin.requests.get", + "extensions.business.cybersec.red_mesh.web_api_mixin.requests.get", side_effect=fake_get, ): result = worker._web_test_metadata_endpoints("example.com", 80) @@ -719,7 +719,7 @@ def test_web_api_auth_bypass(self): resp = MagicMock() resp.status_code = 200 with patch( - "extensions.business.cybersec.red_mesh.web_mixin.requests.get", + "extensions.business.cybersec.red_mesh.web_api_mixin.requests.get", return_value=resp, ): result = worker._web_test_api_auth_bypass("example.com", 80) @@ -734,7 +734,7 @@ def test_cors_misconfiguration_detection(self): } resp.status_code = 200 with patch( - "extensions.business.cybersec.red_mesh.web_mixin.requests.get", + "extensions.business.cybersec.red_mesh.web_hardening_mixin.requests.get", return_value=resp, ): result = worker._web_test_cors_misconfiguration("example.com", 80) @@ -746,7 +746,7 @@ def test_open_redirect_detection(self): resp.status_code = 302 resp.headers = {"Location": "https://attacker.example"} with patch( - "extensions.business.cybersec.red_mesh.web_mixin.requests.get", + "extensions.business.cybersec.red_mesh.web_hardening_mixin.requests.get", return_value=resp, ): result = worker._web_test_open_redirect("example.com", 80) @@ -758,7 +758,7 @@ def test_http_methods_detection(self): resp.headers = {"Allow": "GET, POST, PUT"} resp.status_code = 200 with patch( - "extensions.business.cybersec.red_mesh.web_mixin.requests.options", + "extensions.business.cybersec.red_mesh.web_hardening_mixin.requests.options", return_value=resp, ): result = worker._web_test_http_methods("example.com", 80) diff --git a/extensions/business/cybersec/red_mesh/web_api_mixin.py b/extensions/business/cybersec/red_mesh/web_api_mixin.py new file mode 100644 index 00000000..064ecba3 --- /dev/null +++ b/extensions/business/cybersec/red_mesh/web_api_mixin.py @@ -0,0 +1,136 @@ +import requests + + +class _WebApiExposureMixin: + """ + Detect GraphQL introspection leaks, cloud metadata endpoints, + and API auth bypass (OWASP WSTG-APIT). + """ + + def _web_test_graphql_introspection(self, target, port): + """ + Check if GraphQL introspection is exposed in production endpoints. + + Parameters + ---------- + target : str + Hostname or IP address. + port : int + Web port to probe. + + Returns + ------- + str + Joined findings on GraphQL introspection exposure. + """ + findings = [] + scheme = "https" if port in (443, 8443) else "http" + base_url = f"{scheme}://{target}" + if port not in (80, 443): + base_url = f"{scheme}://{target}:{port}" + graphql_url = base_url.rstrip("/") + "/graphql" + try: + payload = {"query": "{__schema{types{name}}}"} + resp = requests.post(graphql_url, json=payload, timeout=5, verify=False) + if resp.status_code == 200 and "__schema" in resp.text: + finding = f"VULNERABILITY: GraphQL introspection enabled at {graphql_url}." + self.P(finding) + findings.append(finding) + else: + findings.append(f"OK: GraphQL introspection disabled or unreachable at {graphql_url}.") + except Exception as e: + message = f"ERROR: GraphQL probe failed on {graphql_url}: {e}" + self.P(message, color='y') + findings.append(message) + if not findings: + findings.append(f"OK: GraphQL introspection probe reported no issues at {graphql_url}.") + return "\n".join(findings) + + + def _web_test_metadata_endpoints(self, target, port): + """ + Probe cloud metadata paths to detect SSRF-style exposure. + + Parameters + ---------- + target : str + Hostname or IP address. + port : int + Web port to probe. + + Returns + ------- + str + Joined findings on metadata endpoint exposure. + """ + findings = [] + scheme = "https" if port in (443, 8443) else "http" + base_url = f"{scheme}://{target}" + if port not in (80, 443): + base_url = f"{scheme}://{target}:{port}" + metadata_paths = [ + "/latest/meta-data/", + "/metadata/computeMetadata/v1/", + "/computeMetadata/v1/", + ] + try: + for path in metadata_paths: + url = base_url.rstrip("/") + path + resp = requests.get(url, timeout=3, verify=False, headers={"Metadata-Flavor": "Google"}) + if resp.status_code == 200: + finding = f"VULNERABILITY: Cloud metadata endpoint exposed at {url}." + self.P(finding) + findings.append(finding) + except Exception as e: + message = f"ERROR: Metadata endpoint probe failed on {base_url}: {e}" + self.P(message, color='y') + findings.append(message) + if not findings: + findings.append(f"OK: Metadata endpoint probe did not detect exposure on {base_url}.") + return "\n".join(findings) + + + def _web_test_api_auth_bypass(self, target, port): + """ + Detect APIs that succeed despite invalid Authorization headers. + + Parameters + ---------- + target : str + Hostname or IP address. + port : int + Web port to probe. + + Returns + ------- + str + Joined findings related to auth bypass behavior. + """ + findings = [] + scheme = "https" if port in (443, 8443) else "http" + base_url = f"{scheme}://{target}" + if port not in (80, 443): + base_url = f"{scheme}://{target}:{port}" + candidate_paths = ["/api/", "/api/health", "/api/status"] + try: + for path in candidate_paths: + url = base_url.rstrip("/") + path + resp = requests.get( + url, + timeout=3, + verify=False, + headers={"Authorization": "Bearer invalid-token"}, + ) + if resp.status_code in (200, 204): + finding = f"VULNERABILITY: API endpoint {url} accepts invalid Authorization header." + self.P(finding) + findings.append(finding) + else: + findings.append(f"OK: API endpoint {url} rejected invalid Authorization header (status {resp.status_code}).") + except Exception as e: + message = f"ERROR: API auth bypass probe failed on {base_url}: {e}" + self.P(message, color='y') + findings.append(message) + if not findings: + findings.append(f"OK: API auth bypass not detected on {base_url}.") + return "\n".join(findings) diff --git a/extensions/business/cybersec/red_mesh/web_discovery_mixin.py b/extensions/business/cybersec/red_mesh/web_discovery_mixin.py new file mode 100644 index 00000000..0e144773 --- /dev/null +++ b/extensions/business/cybersec/red_mesh/web_discovery_mixin.py @@ -0,0 +1,96 @@ +import requests + + +class _WebDiscoveryMixin: + """ + Enumerate exposed files, admin panels, and homepage secrets (OWASP WSTG-INFO). + """ + + def _web_test_common(self, target, port): + """ + Look for exposed common endpoints and weak access controls. + + Parameters + ---------- + target : str + Hostname or IP address. + port : int + Web port to probe. + + Returns + ------- + str + Joined findings from endpoint checks. + """ + findings = [] + scheme = "https" if port in (443, 8443) else "http" + base_url = f"{scheme}://{target}" + if port not in (80, 443): + base_url = f"{scheme}://{target}:{port}" + try: + # Check common sensitive endpoints + for path in ["/robots.txt", "/.env", "/.git/", "/admin", "/login"]: + url = base_url + path + resp = requests.get(url, timeout=2, verify=False) + if resp.status_code == 200: + finding = f"VULNERABILITY: Accessible resource at {url} (200 OK)." + self.P(finding) + findings.append(finding) + elif resp.status_code in (401, 403): + self.P(f"Protected resource {url} (status {resp.status_code}).") + findings.append( + f"INFO: Access control enforced at {url} (status {resp.status_code})." + ) + except Exception as e: + message = f"ERROR: Common endpoint probe failed on {base_url}: {e}" + self.P(message, color='y') + findings.append(message) + if not findings: + findings.append(f"OK: No common endpoint exposures detected on {base_url}.") + return "\n".join(findings) + + + def _web_test_homepage(self, target, port): + """ + Scan landing pages for clear-text secrets or database dumps. + + Parameters + ---------- + target : str + Hostname or IP address. + port : int + Web port to probe. + + Returns + ------- + str + Joined findings from homepage inspection. + """ + findings = [] + scheme = "https" if port in (443, 8443) else "http" + base_url = f"{scheme}://{target}" + if port not in (80, 443): + base_url = f"{scheme}://{target}:{port}" + try: + # Check homepage for leaked info + resp_main = requests.get(base_url, timeout=3, verify=False) + text = resp_main.text[:10000] + for marker in ["API_KEY", "PASSWORD", "SECRET", "BEGIN RSA PRIVATE KEY"]: + if marker in text: + finding = ( + f"VULNERABILITY: sensitive '{marker}' found on {base_url}." + ) + findings.append(finding) + self.P(finding) + # Check for other potential leaks + if "database" in text.lower(): + finding = f"VULNERABILITY: potential database leak at {base_url}." + findings.append(finding) + self.P(finding) + except Exception as e: + message = f"ERROR: Homepage probe failed on {base_url}: {e}" + self.P(message, color='y') + findings.append(message) + if not findings: + findings.append(f"OK: No sensitive markers detected on {base_url} homepage.") + return "\n".join(findings) diff --git a/extensions/business/cybersec/red_mesh/web_hardening_mixin.py b/extensions/business/cybersec/red_mesh/web_hardening_mixin.py new file mode 100644 index 00000000..ce101d0e --- /dev/null +++ b/extensions/business/cybersec/red_mesh/web_hardening_mixin.py @@ -0,0 +1,255 @@ +import requests +from urllib.parse import quote + + +class _WebHardeningMixin: + """ + Audit cookie flags, security headers, CORS policy, redirect handling, + and HTTP methods (OWASP WSTG-CONF). + """ + + def _web_test_flags(self, target, port): + """ + Check cookies for Secure/HttpOnly/SameSite and directory listing. + + Parameters + ---------- + target : str + Hostname or IP address. + port : int + Web port to probe. + + Returns + ------- + str + Joined findings on cookie flags and directory listing. + """ + findings = [] + scheme = "https" if port in (443, 8443) else "http" + base_url = f"{scheme}://{target}" + if port not in (80, 443): + base_url = f"{scheme}://{target}:{port}" + + try: + resp_main = requests.get(base_url, timeout=3, verify=False) + # Check cookies for Secure/HttpOnly flags + cookies_hdr = resp_main.headers.get("Set-Cookie", "") + if cookies_hdr: + for cookie in cookies_hdr.split(","): + if "Secure" not in cookie: + finding = f"VULNERABILITY: Cookie missing Secure flag: {cookie.strip()} on {base_url}." + findings.append(finding) + self.P(finding) + if "HttpOnly" not in cookie: + finding = f"VULNERABILITY: Cookie missing HttpOnly flag: {cookie.strip()} on {base_url}." + findings.append(finding) + self.P(finding) + if "SameSite" not in cookie: + finding = f"VULNERABILITY: Cookie missing SameSite flag: {cookie.strip()} on {base_url}." + findings.append(finding) + self.P(finding) + # Detect directory listing + if "Index of /" in resp_main.text: + finding = f"VULNERABILITY: Directory listing exposed at {base_url}." + findings.append(finding) + self.P(finding) + except Exception as e: + message = f"ERROR: Cookie/flags probe failed on {base_url}: {e}" + self.P(message, color='y') + findings.append(message) + if not findings: + findings.append(f"OK: Cookie flags and directory listing checks passed for {base_url}.") + return "\n".join(findings) + + + def _web_test_security_headers(self, target, port): + """ + Flag missing HTTP security headers. + + Parameters + ---------- + target : str + Hostname or IP address. + port : int + Web port to probe. + + Returns + ------- + str + Joined findings about security headers presence. + """ + findings = [] + try: + scheme = "https" if port in (443, 8443) else "http" + base_url = f"{scheme}://{target}" + if port not in (80, 443): + base_url = f"{scheme}://{target}:{port}" + resp_main = requests.get(base_url, timeout=3, verify=False) + # Check for missing security headers + security_headers = [ + "Content-Security-Policy", + "X-Frame-Options", + "X-Content-Type-Options", + "Strict-Transport-Security", + "Referrer-Policy", + ] + for header in security_headers: + if header not in resp_main.headers: + finding = f"VULNERABILITY: Missing security header {header} on {base_url}." + self.P(finding) + findings.append(finding) + else: + findings.append(f"OK: Security header {header} present on {base_url}.") + except Exception as e: + message = f"ERROR: Security header probe failed on {base_url}: {e}" + self.P(message, color='y') + findings.append(message) + if not findings: + findings.append(f"OK: Security header check found no issues on {base_url}.") + return "\n".join(findings) + + + def _web_test_cors_misconfiguration(self, target, port): + """ + Detect overly permissive CORS policies. + + Parameters + ---------- + target : str + Hostname or IP address. + port : int + Web port to probe. + + Returns + ------- + str + Joined findings related to CORS policy. + """ + findings = [] + scheme = "https" if port in (443, 8443) else "http" + base_url = f"{scheme}://{target}" + if port not in (80, 443): + base_url = f"{scheme}://{target}:{port}" + try: + malicious_origin = "https://attacker.example" + resp = requests.get( + base_url, + timeout=3, + verify=False, + headers={"Origin": malicious_origin} + ) + acao = resp.headers.get("Access-Control-Allow-Origin", "") + acac = resp.headers.get("Access-Control-Allow-Credentials", "") + if acao in ("*", malicious_origin): + finding = f"VULNERABILITY: CORS misconfiguration: {acao} allowed on {base_url}." + self.P(finding) + findings.append(finding) + if acao == "*" and acac.lower() == "true": + finding = f"VULNERABILITY: CORS allows credentials for wildcard origin on {base_url}." + self.P(finding, color='r') + findings.append(finding) + elif acao: + info = f"OK: CORS allow origin {acao} on {base_url}." + self.P(info) + findings.append(info) + else: + findings.append(f"OK: No permissive CORS headers detected on {base_url}.") + except Exception as e: + message = f"ERROR: CORS probe failed on {base_url}: {e}" + self.P(message, color='y') + findings.append(message) + if not findings: + findings.append(f"OK: CORS probe did not detect misconfiguration on {base_url}.") + return "\n".join(findings) + + + def _web_test_open_redirect(self, target, port): + """ + Check common redirect parameters for open redirect abuse. + + Parameters + ---------- + target : str + Hostname or IP address. + port : int + Web port to probe. + + Returns + ------- + str + Joined findings about open redirects. + """ + findings = [] + scheme = "https" if port in (443, 8443) else "http" + base_url = f"{scheme}://{target}" + if port not in (80, 443): + base_url = f"{scheme}://{target}:{port}" + try: + payload = "https://attacker.example" + redirect_url = base_url.rstrip("/") + f"/login?next={quote(payload, safe=':/')}" + resp = requests.get( + redirect_url, + timeout=3, + verify=False, + allow_redirects=False + ) + if 300 <= resp.status_code < 400: + location = resp.headers.get("Location", "") + if payload in location: + finding = f"VULNERABILITY: Open redirect via next parameter at {redirect_url}." + self.P(finding) + findings.append(finding) + else: + findings.append( + f"OK: Redirect endpoint at {redirect_url} did not expose open redirect behavior." + ) + except Exception as e: + message = f"ERROR: Open redirect probe failed on {base_url}: {e}" + self.P(message, color='y') + findings.append(message) + if not findings: + findings.append(f"OK: Open redirect not detected at {base_url}.") + return "\n".join(findings) + + + def _web_test_http_methods(self, target, port): + """ + Surface risky HTTP verbs enabled on the root resource. + + Parameters + ---------- + target : str + Hostname or IP address. + port : int + Web port to probe. + + Returns + ------- + str + Joined findings related to allowed HTTP methods. + """ + findings = [] + scheme = "https" if port in (443, 8443) else "http" + base_url = f"{scheme}://{target}" + if port not in (80, 443): + base_url = f"{scheme}://{target}:{port}" + try: + resp = requests.options(base_url, timeout=3, verify=False) + allow = resp.headers.get("Allow", "") + if allow: + risky = [method for method in ("PUT", "DELETE", "TRACE", "CONNECT") if method in allow.upper()] + if risky: + finding = f"VULNERABILITY: Risky HTTP methods {', '.join(risky)} enabled on {base_url}." + self.P(finding) + findings.append(finding) + else: + findings.append(f"OK: Only safe HTTP methods exposed on {base_url} ({allow}).") + else: + findings.append(f"OK: OPTIONS response missing Allow header on {base_url}.") + except Exception as e: + message = f"ERROR: HTTP methods probe failed on {base_url}: {e}" + self.P(message, color='y') + findings.append(message) + if not findings: + findings.append(f"OK: HTTP methods probe did not detect risky verbs on {base_url}.") + return "\n".join(findings) diff --git a/extensions/business/cybersec/red_mesh/web_injection_mixin.py b/extensions/business/cybersec/red_mesh/web_injection_mixin.py new file mode 100644 index 00000000..2c9b0881 --- /dev/null +++ b/extensions/business/cybersec/red_mesh/web_injection_mixin.py @@ -0,0 +1,126 @@ +import requests +from urllib.parse import quote + + +class _WebInjectionMixin: + """ + Non-destructive probes for path traversal, reflected XSS, + and SQL injection (OWASP WSTG-INPV). + """ + + def _web_test_path_traversal(self, target, port): + """ + Attempt basic path traversal payload against the target. + + Parameters + ---------- + target : str + Hostname or IP address. + port : int + Web port to probe. + + Returns + ------- + str + Joined findings about traversal attempts. + """ + findings = [] + scheme = "https" if port in (443, 8443) else "http" + base_url = f"{scheme}://{target}" + if port not in (80, 443): + base_url = f"{scheme}://{target}:{port}" + try: + # Path traversal test + trav_url = base_url.rstrip("/") + "/../../../../etc/passwd" + resp_trav = requests.get(trav_url, timeout=2, verify=False) + if "root:x:" in resp_trav.text: + finding = f"VULNERABILITY: Path traversal at {trav_url}." + self.P(finding) + findings.append(finding) + except Exception as e: + message = f"ERROR: Path traversal probe failed on {base_url}: {e}" + self.P(message, color='r') + findings.append(message) + if not findings: + findings.append(f"OK: Path traversal payload not successful on {base_url}.") + return "\n".join(findings) + + + def _web_test_xss(self, target, port): + """ + Probe reflected XSS by injecting a harmless script tag. + + Parameters + ---------- + target : str + Hostname or IP address. + port : int + Web port to probe. + + Returns + ------- + str + Joined findings related to reflected XSS. + """ + findings = [] + scheme = "https" if port in (443, 8443) else "http" + base_url = f"{scheme}://{target}" + if port not in (80, 443): + base_url = f"{scheme}://{target}:{port}" + try: + # Basic XSS reflection test + payload = "" + test_url = base_url.rstrip("/") + f"/{payload}" + resp_test = requests.get(test_url, timeout=3, verify=False) + if payload in resp_test.text: + finding = f"VULNERABILITY: Reflected XSS at {test_url}." + self.P(finding) + findings.append(finding) + except Exception as e: + message = f"ERROR: XSS probe failed on {base_url}: {e}" + self.P(message, color='y') + findings.append(message) + if not findings: + findings.append(f"OK: Reflected XSS not observed at {base_url}.") + return "\n".join(findings) + + + def _web_test_sql_injection(self, target, port): + """ + Send boolean SQLi payload and look for database error leakage. + + Parameters + ---------- + target : str + Hostname or IP address. + port : int + Web port to probe. + + Returns + ------- + str + Joined findings related to SQL injection. + """ + findings = [] + scheme = "https" if port in (443, 8443) else "http" + base_url = f"{scheme}://{target}" + if port not in (80, 443): + base_url = f"{scheme}://{target}:{port}" + try: + # Simple SQL injection probe + inj_payload = quote("'1' OR '1'='1'") + inj_url = base_url + f"?id={inj_payload}" + resp_inj = requests.get(inj_url, timeout=3, verify=False) + errors = ["sql", "syntax", "mysql", "psql", "postgres", "sqlite", "ora-"] + body = resp_inj.text.lower() + if any(err in body for err in errors): + finding = f"VULNERABILITY: Potential SQL injection at {inj_url}." + self.P(finding) + findings.append(finding) + except Exception as e: + message = f"ERROR: SQL injection probe failed on {base_url}: {e}" + self.P(message, color='r') + findings.append(message) + if not findings: + findings.append(f"OK: SQL injection probe did not reveal errors on {base_url}.") + return "\n".join(findings) diff --git a/extensions/business/cybersec/red_mesh/web_mixin.py b/extensions/business/cybersec/red_mesh/web_mixin.py index 0eb170b2..61b2dcc5 100644 --- a/extensions/business/cybersec/red_mesh/web_mixin.py +++ b/extensions/business/cybersec/red_mesh/web_mixin.py @@ -1,593 +1,14 @@ -import requests -from urllib.parse import quote - -class _WebTestsMixin: - """ - HTTP-centric probes that emulate manual red-team playbooks. - - Methods perform lightweight checks for common web vulnerabilities across - discovered web services. - """ - - def _web_test_common(self, target, port): - """ - Look for exposed common endpoints and weak access controls. - - Parameters - ---------- - target : str - Hostname or IP address. - port : int - Web port to probe. - - Returns - ------- - str - Joined findings from endpoint checks. - """ - findings = [] - scheme = "https" if port in (443, 8443) else "http" - base_url = f"{scheme}://{target}" - if port not in (80, 443): - base_url = f"{scheme}://{target}:{port}" - try: - # Check common sensitive endpoints - for path in ["/robots.txt", "/.env", "/.git/", "/admin", "/login"]: - url = base_url + path - resp = requests.get(url, timeout=2, verify=False) - if resp.status_code == 200: - finding = f"VULNERABILITY: Accessible resource at {url} (200 OK)." - self.P(finding) - findings.append(finding) - elif resp.status_code in (401, 403): - self.P(f"Protected resource {url} (status {resp.status_code}).") - findings.append( - f"INFO: Access control enforced at {url} (status {resp.status_code})." - ) - except Exception as e: - message = f"ERROR: Common endpoint probe failed on {base_url}: {e}" - self.P(message, color='y') - findings.append(message) - if not findings: - findings.append(f"OK: No common endpoint exposures detected on {base_url}.") - return "\n".join(findings) - - - def _web_test_homepage(self, target, port): - """ - Scan landing pages for clear-text secrets or database dumps. - - Parameters - ---------- - target : str - Hostname or IP address. - port : int - Web port to probe. - - Returns - ------- - str - Joined findings from homepage inspection. - """ - findings = [] - scheme = "https" if port in (443, 8443) else "http" - base_url = f"{scheme}://{target}" - if port not in (80, 443): - base_url = f"{scheme}://{target}:{port}" - try: - # Check homepage for leaked info - resp_main = requests.get(base_url, timeout=3, verify=False) - text = resp_main.text[:10000] - for marker in ["API_KEY", "PASSWORD", "SECRET", "BEGIN RSA PRIVATE KEY"]: - if marker in text: - finding = ( - f"VULNERABILITY: sensitive '{marker}' found on {base_url}." - ) - findings.append(finding) - self.P(finding) - # Check for other potential leaks - if "database" in text.lower(): - finding = f"VULNERABILITY: potential database leak at {base_url}." - findings.append(finding) - self.P(finding) - except Exception as e: - message = f"ERROR: Homepage probe failed on {base_url}: {e}" - self.P(message, color='y') - findings.append(message) - if not findings: - findings.append(f"OK: No sensitive markers detected on {base_url} homepage.") - return "\n".join(findings) - - - def _web_test_security_headers(self, target, port): - """ - Flag missing HTTP security headers. - - Parameters - ---------- - target : str - Hostname or IP address. - port : int - Web port to probe. - - Returns - ------- - str - Joined findings about security headers presence. - """ - findings = [] - try: - scheme = "https" if port in (443, 8443) else "http" - base_url = f"{scheme}://{target}" - if port not in (80, 443): - base_url = f"{scheme}://{target}:{port}" - resp_main = requests.get(base_url, timeout=3, verify=False) - # Check for missing security headers - security_headers = [ - "Content-Security-Policy", - "X-Frame-Options", - "X-Content-Type-Options", - "Strict-Transport-Security", - "Referrer-Policy", - ] - for header in security_headers: - if header not in resp_main.headers: - finding = f"VULNERABILITY: Missing security header {header} on {base_url}." - self.P(finding) - findings.append(finding) - else: - findings.append(f"OK: Security header {header} present on {base_url}.") - except Exception as e: - message = f"ERROR: Security header probe failed on {base_url}: {e}" - self.P(message, color='y') - findings.append(message) - if not findings: - findings.append(f"OK: Security header check found no issues on {base_url}.") - return "\n".join(findings) - - - def _web_test_flags(self, target, port): - """ - Check cookies for Secure/HttpOnly/SameSite and directory listing. - - Parameters - ---------- - target : str - Hostname or IP address. - port : int - Web port to probe. - - Returns - ------- - str - Joined findings on cookie flags and directory listing. - """ - findings = [] - scheme = "https" if port in (443, 8443) else "http" - base_url = f"{scheme}://{target}" - if port not in (80, 443): - base_url = f"{scheme}://{target}:{port}" - - try: - resp_main = requests.get(base_url, timeout=3, verify=False) - # Check cookies for Secure/HttpOnly flags - cookies_hdr = resp_main.headers.get("Set-Cookie", "") - if cookies_hdr: - for cookie in cookies_hdr.split(","): - if "Secure" not in cookie: - finding = f"VULNERABILITY: Cookie missing Secure flag: {cookie.strip()} on {base_url}." - findings.append(finding) - self.P(finding) - if "HttpOnly" not in cookie: - finding = f"VULNERABILITY: Cookie missing HttpOnly flag: {cookie.strip()} on {base_url}." - findings.append(finding) - self.P(finding) - if "SameSite" not in cookie: - finding = f"VULNERABILITY: Cookie missing SameSite flag: {cookie.strip()} on {base_url}." - findings.append(finding) - self.P(finding) - # Detect directory listing - if "Index of /" in resp_main.text: - finding = f"VULNERABILITY: Directory listing exposed at {base_url}." - findings.append(finding) - self.P(finding) - except Exception as e: - message = f"ERROR: Cookie/flags probe failed on {base_url}: {e}" - self.P(message, color='y') - findings.append(message) - if not findings: - findings.append(f"OK: Cookie flags and directory listing checks passed for {base_url}.") - return "\n".join(findings) - - - def _web_test_xss(self, target, port): - """ - Probe reflected XSS by injecting a harmless script tag. - - Parameters - ---------- - target : str - Hostname or IP address. - port : int - Web port to probe. - - Returns - ------- - str - Joined findings related to reflected XSS. - """ - findings = [] - scheme = "https" if port in (443, 8443) else "http" - base_url = f"{scheme}://{target}" - if port not in (80, 443): - base_url = f"{scheme}://{target}:{port}" - try: - # Basic XSS reflection test - payload = "" - test_url = base_url.rstrip("/") + f"/{payload}" - resp_test = requests.get(test_url, timeout=3, verify=False) - if payload in resp_test.text: - finding = f"VULNERABILITY: Reflected XSS at {test_url}." - self.P(finding) - findings.append(finding) - except Exception as e: - message = f"ERROR: XSS probe failed on {base_url}: {e}" - self.P(message, color='y') - findings.append(message) - if not findings: - findings.append(f"OK: Reflected XSS not observed at {base_url}.") - return "\n".join(findings) - - - def _web_test_path_traversal(self, target, port): - """ - Attempt basic path traversal payload against the target. - - Parameters - ---------- - target : str - Hostname or IP address. - port : int - Web port to probe. - - Returns - ------- - str - Joined findings about traversal attempts. - """ - findings = [] - scheme = "https" if port in (443, 8443) else "http" - base_url = f"{scheme}://{target}" - if port not in (80, 443): - base_url = f"{scheme}://{target}:{port}" - try: - # Path traversal test - trav_url = base_url.rstrip("/") + "/../../../../etc/passwd" - resp_trav = requests.get(trav_url, timeout=2, verify=False) - if "root:x:" in resp_trav.text: - finding = f"VULNERABILITY: Path traversal at {trav_url}." - self.P(finding) - findings.append(finding) - except Exception as e: - message = f"ERROR: Path traversal probe failed on {base_url}: {e}" - self.P(message, color='r') - findings.append(message) - if not findings: - findings.append(f"OK: Path traversal payload not successful on {base_url}.") - return "\n".join(findings) - - - def _web_test_sql_injection(self, target, port): - """ - Send boolean SQLi payload and look for database error leakage. - - Parameters - ---------- - target : str - Hostname or IP address. - port : int - Web port to probe. - - Returns - ------- - str - Joined findings related to SQL injection. - """ - findings = [] - scheme = "https" if port in (443, 8443) else "http" - base_url = f"{scheme}://{target}" - if port not in (80, 443): - base_url = f"{scheme}://{target}:{port}" - try: - # Simple SQL injection probe - inj_payload = quote("'1' OR '1'='1'") - inj_url = base_url + f"?id={inj_payload}" - resp_inj = requests.get(inj_url, timeout=3, verify=False) - errors = ["sql", "syntax", "mysql", "psql", "postgres", "sqlite", "ora-"] - body = resp_inj.text.lower() - if any(err in body for err in errors): - finding = f"VULNERABILITY: Potential SQL injection at {inj_url}." - self.P(finding) - findings.append(finding) - except Exception as e: - message = f"ERROR: SQL injection probe failed on {base_url}: {e}" - self.P(message, color='r') - findings.append(message) - if not findings: - findings.append(f"OK: SQL injection probe did not reveal errors on {base_url}.") - return "\n".join(findings) - - - def _web_test_cors_misconfiguration(self, target, port): - """ - Detect overly permissive CORS policies. - - Parameters - ---------- - target : str - Hostname or IP address. - port : int - Web port to probe. - - Returns - ------- - str - Joined findings related to CORS policy. - """ - findings = [] - scheme = "https" if port in (443, 8443) else "http" - base_url = f"{scheme}://{target}" - if port not in (80, 443): - base_url = f"{scheme}://{target}:{port}" - try: - malicious_origin = "https://attacker.example" - resp = requests.get( - base_url, - timeout=3, - verify=False, - headers={"Origin": malicious_origin} - ) - acao = resp.headers.get("Access-Control-Allow-Origin", "") - acac = resp.headers.get("Access-Control-Allow-Credentials", "") - if acao in ("*", malicious_origin): - finding = f"VULNERABILITY: CORS misconfiguration: {acao} allowed on {base_url}." - self.P(finding) - findings.append(finding) - if acao == "*" and acac.lower() == "true": - finding = f"VULNERABILITY: CORS allows credentials for wildcard origin on {base_url}." - self.P(finding, color='r') - findings.append(finding) - elif acao: - info = f"OK: CORS allow origin {acao} on {base_url}." - self.P(info) - findings.append(info) - else: - findings.append(f"OK: No permissive CORS headers detected on {base_url}.") - except Exception as e: - message = f"ERROR: CORS probe failed on {base_url}: {e}" - self.P(message, color='y') - findings.append(message) - if not findings: - findings.append(f"OK: CORS probe did not detect misconfiguration on {base_url}.") - return "\n".join(findings) - - - def _web_test_open_redirect(self, target, port): - """ - Check common redirect parameters for open redirect abuse. - - Parameters - ---------- - target : str - Hostname or IP address. - port : int - Web port to probe. - - Returns - ------- - str - Joined findings about open redirects. - """ - findings = [] - scheme = "https" if port in (443, 8443) else "http" - base_url = f"{scheme}://{target}" - if port not in (80, 443): - base_url = f"{scheme}://{target}:{port}" - try: - payload = "https://attacker.example" - redirect_url = base_url.rstrip("/") + f"/login?next={quote(payload, safe=':/')}" - resp = requests.get( - redirect_url, - timeout=3, - verify=False, - allow_redirects=False - ) - if 300 <= resp.status_code < 400: - location = resp.headers.get("Location", "") - if payload in location: - finding = f"VULNERABILITY: Open redirect via next parameter at {redirect_url}." - self.P(finding) - findings.append(finding) - else: - findings.append( - f"OK: Redirect endpoint at {redirect_url} did not expose open redirect behavior." - ) - except Exception as e: - message = f"ERROR: Open redirect probe failed on {base_url}: {e}" - self.P(message, color='y') - findings.append(message) - if not findings: - findings.append(f"OK: Open redirect not detected at {base_url}.") - return "\n".join(findings) - - - def _web_test_http_methods(self, target, port): - """ - Surface risky HTTP verbs enabled on the root resource. - - Parameters - ---------- - target : str - Hostname or IP address. - port : int - Web port to probe. - - Returns - ------- - str - Joined findings related to allowed HTTP methods. - """ - findings = [] - scheme = "https" if port in (443, 8443) else "http" - base_url = f"{scheme}://{target}" - if port not in (80, 443): - base_url = f"{scheme}://{target}:{port}" - try: - resp = requests.options(base_url, timeout=3, verify=False) - allow = resp.headers.get("Allow", "") - if allow: - risky = [method for method in ("PUT", "DELETE", "TRACE", "CONNECT") if method in allow.upper()] - if risky: - finding = f"VULNERABILITY: Risky HTTP methods {', '.join(risky)} enabled on {base_url}." - self.P(finding) - findings.append(finding) - else: - findings.append(f"OK: Only safe HTTP methods exposed on {base_url} ({allow}).") - else: - findings.append(f"OK: OPTIONS response missing Allow header on {base_url}.") - except Exception as e: - message = f"ERROR: HTTP methods probe failed on {base_url}: {e}" - self.P(message, color='y') - findings.append(message) - if not findings: - findings.append(f"OK: HTTP methods probe did not detect risky verbs on {base_url}.") - return "\n".join(findings) - - - def _web_test_graphql_introspection(self, target, port): - """ - Check if GraphQL introspection is exposed in production endpoints. - - Parameters - ---------- - target : str - Hostname or IP address. - port : int - Web port to probe. - - Returns - ------- - str - Joined findings on GraphQL introspection exposure. - """ - findings = [] - scheme = "https" if port in (443, 8443) else "http" - base_url = f"{scheme}://{target}" - if port not in (80, 443): - base_url = f"{scheme}://{target}:{port}" - graphql_url = base_url.rstrip("/") + "/graphql" - try: - payload = {"query": "{__schema{types{name}}}"} - resp = requests.post(graphql_url, json=payload, timeout=5, verify=False) - if resp.status_code == 200 and "__schema" in resp.text: - finding = f"VULNERABILITY: GraphQL introspection enabled at {graphql_url}." - self.P(finding) - findings.append(finding) - else: - findings.append(f"OK: GraphQL introspection disabled or unreachable at {graphql_url}.") - except Exception as e: - message = f"ERROR: GraphQL probe failed on {graphql_url}: {e}" - self.P(message, color='y') - findings.append(message) - if not findings: - findings.append(f"OK: GraphQL introspection probe reported no issues at {graphql_url}.") - return "\n".join(findings) - - - def _web_test_metadata_endpoints(self, target, port): - """ - Probe cloud metadata paths to detect SSRF-style exposure. - - Parameters - ---------- - target : str - Hostname or IP address. - port : int - Web port to probe. - - Returns - ------- - str - Joined findings on metadata endpoint exposure. - """ - findings = [] - scheme = "https" if port in (443, 8443) else "http" - base_url = f"{scheme}://{target}" - if port not in (80, 443): - base_url = f"{scheme}://{target}:{port}" - metadata_paths = [ - "/latest/meta-data/", - "/metadata/computeMetadata/v1/", - "/computeMetadata/v1/", - ] - try: - for path in metadata_paths: - url = base_url.rstrip("/") + path - resp = requests.get(url, timeout=3, verify=False, headers={"Metadata-Flavor": "Google"}) - if resp.status_code == 200: - finding = f"VULNERABILITY: Cloud metadata endpoint exposed at {url}." - self.P(finding) - findings.append(finding) - except Exception as e: - message = f"ERROR: Metadata endpoint probe failed on {base_url}: {e}" - self.P(message, color='y') - findings.append(message) - if not findings: - findings.append(f"OK: Metadata endpoint probe did not detect exposure on {base_url}.") - return "\n".join(findings) - - - def _web_test_api_auth_bypass(self, target, port): - """ - Detect APIs that succeed despite invalid Authorization headers. - - Parameters - ---------- - target : str - Hostname or IP address. - port : int - Web port to probe. - - Returns - ------- - str - Joined findings related to auth bypass behavior. - """ - findings = [] - scheme = "https" if port in (443, 8443) else "http" - base_url = f"{scheme}://{target}" - if port not in (80, 443): - base_url = f"{scheme}://{target}:{port}" - candidate_paths = ["/api/", "/api/health", "/api/status"] - try: - for path in candidate_paths: - url = base_url.rstrip("/") + path - resp = requests.get( - url, - timeout=3, - verify=False, - headers={"Authorization": "Bearer invalid-token"}, - ) - if resp.status_code in (200, 204): - finding = f"VULNERABILITY: API endpoint {url} accepts invalid Authorization header." - self.P(finding) - findings.append(finding) - else: - findings.append(f"OK: API endpoint {url} rejected invalid Authorization header (status {resp.status_code}).") - except Exception as e: - message = f"ERROR: API auth bypass probe failed on {base_url}: {e}" - self.P(message, color='y') - findings.append(message) - if not findings: - findings.append(f"OK: API auth bypass not detected on {base_url}.") - return "\n".join(findings) +from .web_discovery_mixin import _WebDiscoveryMixin +from .web_hardening_mixin import _WebHardeningMixin +from .web_api_mixin import _WebApiExposureMixin +from .web_injection_mixin import _WebInjectionMixin + + +class _WebTestsMixin( + _WebDiscoveryMixin, + _WebHardeningMixin, + _WebApiExposureMixin, + _WebInjectionMixin, +): + """Backward-compatible combined mixin -- prefer importing individual mixins.""" + pass From ffbfbf50c3ad43156d742e6d068b4fa0675364e6 Mon Sep 17 00:00:00 2001 From: toderian Date: Thu, 19 Feb 2026 22:21:10 +0000 Subject: [PATCH 06/25] fix: add finding class | add cve_db | ensure severity is plain string in probe results --- .../business/cybersec/red_mesh/constants.py | 13 +- .../business/cybersec/red_mesh/cve_db.py | 146 +++ .../business/cybersec/red_mesh/findings.py | 48 + .../cybersec/red_mesh/pentester_api_01.py | 16 +- .../cybersec/red_mesh/service_mixin.py | 1031 +++++++++++++++-- .../cybersec/red_mesh/test_redmesh.py | 588 +++++++++- .../cybersec/red_mesh/web_discovery_mixin.py | 193 +++ .../cybersec/red_mesh/web_injection_mixin.py | 376 ++++-- 8 files changed, 2191 insertions(+), 220 deletions(-) create mode 100644 extensions/business/cybersec/red_mesh/cve_db.py create mode 100644 extensions/business/cybersec/red_mesh/findings.py diff --git a/extensions/business/cybersec/red_mesh/constants.py b/extensions/business/cybersec/red_mesh/constants.py index 5116a70d..8be0cddd 100644 --- a/extensions/business/cybersec/red_mesh/constants.py +++ b/extensions/business/cybersec/red_mesh/constants.py @@ -44,9 +44,9 @@ { "id": "web_discovery", "label": "Discovery", - "description": "Enumerate exposed files, admin panels, and homepage secrets (OWASP WSTG-INFO).", + "description": "Enumerate exposed files, admin panels, homepage secrets, tech fingerprinting, and VPN endpoints (OWASP WSTG-INFO).", "category": "web", - "methods": ["_web_test_common", "_web_test_homepage"] + "methods": ["_web_test_common", "_web_test_homepage", "_web_test_tech_fingerprint", "_web_test_vpn_endpoints"] }, { "id": "web_hardening", @@ -68,6 +68,13 @@ "description": "Non-destructive probes for path traversal, reflected XSS, and SQL injection (OWASP WSTG-INPV).", "category": "web", "methods": ["_web_test_path_traversal", "_web_test_xss", "_web_test_sql_injection"] + }, + { + "id": "active_auth", + "label": "Credential testing", + "description": "Test default/weak credentials on database and remote access services. May trigger account lockout.", + "category": "service", + "methods": ["_service_info_3306_creds", "_service_info_5432_creds"] } ] @@ -148,4 +155,6 @@ "_service_info_445": frozenset({"smb"}), "_service_info_502": frozenset({"modbus"}), "_service_info_generic": frozenset({"unknown", "wins"}), + "_service_info_3306_creds": frozenset({"mysql"}), + "_service_info_5432_creds": frozenset({"postgresql"}), } \ No newline at end of file diff --git a/extensions/business/cybersec/red_mesh/cve_db.py b/extensions/business/cybersec/red_mesh/cve_db.py new file mode 100644 index 00000000..2bea2f43 --- /dev/null +++ b/extensions/business/cybersec/red_mesh/cve_db.py @@ -0,0 +1,146 @@ +""" +Declarative CVE database for RedMesh version-based vulnerability matching. + +Each entry maps a product + version constraint to a known CVE. The +``check_cves()`` helper returns ``Finding`` instances that feed directly +into ``probe_result()``. +""" + +import re +from dataclasses import dataclass +from .findings import Finding, Severity + +CVE_DB_LAST_UPDATED = "2026-02-19" + + +@dataclass(frozen=True) +class CveEntry: + product: str + constraint: str # "<1.4.3", ">=2.4.49,<2.4.51", "<7.0" + cve_id: str + severity: Severity + title: str + cwe_id: str = "" + + +CVE_DATABASE: list = [ + # Elasticsearch + CveEntry("elasticsearch", "<1.2", "CVE-2014-3120", Severity.CRITICAL, "MVEL scripting RCE", "CWE-94"), + CveEntry("elasticsearch", "<1.4.3", "CVE-2015-1427", Severity.CRITICAL, "Groovy sandbox escape RCE", "CWE-94"), + CveEntry("elasticsearch", "<1.4.5", "CVE-2015-3337", Severity.HIGH, "Directory traversal via site plugin", "CWE-22"), + CveEntry("elasticsearch", "<5.6.5", "CVE-2017-11480", Severity.MEDIUM, "Cross-site scripting via URL access control bypass", "CWE-79"), + CveEntry("elasticsearch", "<6.4.3", "CVE-2018-17244", Severity.MEDIUM, "Security bypass in token generation", "CWE-287"), + + # OpenSSH + CveEntry("openssh", "<9.3", "CVE-2024-6387", Severity.CRITICAL, "regreSSHion: signal handler race RCE", "CWE-362"), + CveEntry("openssh", "<8.1", "CVE-2019-6111", Severity.HIGH, "SCP client-side file overwrite", "CWE-20"), + CveEntry("openssh", "<7.6", "CVE-2017-15906", Severity.MEDIUM, "Improper write restriction in readonly mode", "CWE-732"), + CveEntry("openssh", "<7.0", "CVE-2016-6210", Severity.MEDIUM, "User enumeration via timing", "CWE-200"), + + # Redis + CveEntry("redis", "<6.0.8", "CVE-2021-32761", Severity.HIGH, "Integer overflow in BITFIELD", "CWE-190"), + CveEntry("redis", "<6.2.7", "CVE-2022-24735", Severity.HIGH, "Lua sandbox escape via EVAL", "CWE-94"), + CveEntry("redis", "<7.0.5", "CVE-2022-35951", Severity.HIGH, "Integer overflow in XAUTOCLAIM", "CWE-190"), + + # MySQL + CveEntry("mysql", ">=5.7,<5.7.20", "CVE-2016-6662", Severity.CRITICAL, "Config file injection RCE", "CWE-94"), + CveEntry("mysql", ">=5.5,<5.5.52", "CVE-2016-6664", Severity.HIGH, "Privilege escalation via mysqld_safe", "CWE-269"), + CveEntry("mysql", ">=8.0,<8.0.23", "CVE-2021-2022", Severity.MEDIUM, "InnoDB buffer pool corruption", "CWE-787"), + + # Exim + CveEntry("exim", "<4.97.1", "CVE-2023-42115", Severity.CRITICAL, "AUTH out-of-bounds write", "CWE-787"), + CveEntry("exim", "<4.96.1", "CVE-2023-42114", Severity.HIGH, "NTLM challenge out-of-bounds read", "CWE-125"), + CveEntry("exim", "<4.94.2", "CVE-2021-27216", Severity.HIGH, "Privilege escalation via symlink attack", "CWE-59"), + + # Apache httpd + CveEntry("apache", ">=2.4.49,<2.4.51", "CVE-2021-41773", Severity.CRITICAL, "Path traversal + RCE", "CWE-22"), + CveEntry("apache", ">=2.4.0,<2.4.52", "CVE-2021-44790", Severity.CRITICAL, "mod_lua buffer overflow", "CWE-787"), + CveEntry("apache", "<2.4.49", "CVE-2021-40438", Severity.HIGH, "mod_proxy SSRF", "CWE-918"), + CveEntry("apache", "<2.2.34", "CVE-2017-7679", Severity.HIGH, "mod_mime buffer overread", "CWE-126"), + + # nginx + CveEntry("nginx", "<1.17.7", "CVE-2019-20372", Severity.MEDIUM, "HTTP request smuggling", "CWE-444"), + CveEntry("nginx", "<1.5.7", "CVE-2013-4547", Severity.HIGH, "URI processing security bypass", "CWE-20"), + + # Postfix + CveEntry("postfix", "<3.5.23", "CVE-2023-51764", Severity.MEDIUM, "SMTP smuggling via pipelining", "CWE-345"), + + # OpenSSL (detected via banner strings) + CveEntry("openssl", "<1.1.1", "CVE-2020-1971", Severity.HIGH, "NULL dereference in GENERAL_NAME_cmp", "CWE-476"), + CveEntry("openssl", "<3.0.7", "CVE-2022-3602", Severity.HIGH, "X.509 email address buffer overflow", "CWE-120"), + + # ProFTPD + CveEntry("proftpd", "<1.3.6", "CVE-2019-12815", Severity.CRITICAL, "Arbitrary file copy via mod_copy", "CWE-284"), + + # vsftpd + CveEntry("vsftpd", ">=2.3.4,<2.3.5", "CVE-2011-2523", Severity.CRITICAL, "Backdoor command execution", "CWE-506"), +] + + +def check_cves(product: str, version: str) -> list: + """Match version against CVE database. Returns list of Findings.""" + findings = [] + for entry in CVE_DATABASE: + if entry.product != product: + continue + if _matches_constraint(version, entry.constraint): + findings.append(Finding( + severity=entry.severity, + title=f"{entry.cve_id}: {entry.title} ({product} {version})", + description=f"{product} {version} is vulnerable to {entry.cve_id}.", + evidence=f"Detected version: {version}, affected: {entry.constraint}", + remediation=f"Upgrade {product} to a patched version.", + cwe_id=entry.cwe_id, + confidence="firm", + )) + return findings + + +def _matches_constraint(version: str, constraint: str) -> bool: + """Parse version constraint string and compare. + + Supports: ``<1.4.3``, ``>=2.4.49,<2.4.51``, ``<7.0``. + Comma-separated constraints are ANDed. + """ + parts = [c.strip() for c in constraint.split(",")] + parsed = _parse_version(version) + if parsed is None: + return False + for part in parts: + if not _check_single(parsed, part): + return False + return True + + +def _parse_version(version: str): + """Extract leading numeric version tuple from a string like '1.4.3-beta'.""" + m = re.match(r"(\d+(?:\.\d+)*)", version.strip()) + if not m: + return None + return tuple(int(x) for x in m.group(1).split(".")) + + +def _check_single(parsed: tuple, expr: str) -> bool: + """Evaluate one comparison like '<1.4.3' or '>=2.4.49'.""" + m = re.match(r"(>=|<=|>|<|==)(.+)", expr.strip()) + if not m: + return False + op, ver_str = m.group(1), m.group(2) + target = _parse_version(ver_str) + if target is None: + return False + # Normalize lengths for comparison + max_len = max(len(parsed), len(target)) + a = parsed + (0,) * (max_len - len(parsed)) + b = target + (0,) * (max_len - len(target)) + if op == "<": + return a < b + elif op == "<=": + return a <= b + elif op == ">": + return a > b + elif op == ">=": + return a >= b + elif op == "==": + return a == b + return False diff --git a/extensions/business/cybersec/red_mesh/findings.py b/extensions/business/cybersec/red_mesh/findings.py new file mode 100644 index 00000000..0061a128 --- /dev/null +++ b/extensions/business/cybersec/red_mesh/findings.py @@ -0,0 +1,48 @@ +""" +Structured vulnerability findings for RedMesh probes. + +Every probe returns a plain dict via ``probe_result()`` so that the +aggregator pipeline (merge_objects_deep, R1FS serialization) keeps working +unchanged. The ``Finding`` dataclass and ``Severity`` enum provide +type-safe construction and JSON-safe serialization. +""" + +from dataclasses import dataclass, asdict +from enum import Enum + + +class Severity(str, Enum): + CRITICAL = "CRITICAL" + HIGH = "HIGH" + MEDIUM = "MEDIUM" + LOW = "LOW" + INFO = "INFO" + + +_VULN_SEVERITIES = frozenset({Severity.CRITICAL, Severity.HIGH, Severity.MEDIUM}) + + +@dataclass(frozen=True) +class Finding: + severity: Severity + title: str + description: str + evidence: str = "" + remediation: str = "" + owasp_id: str = "" # e.g. "A07:2021" + cwe_id: str = "" # e.g. "CWE-287" + confidence: str = "firm" # certain | firm | tentative + + +def probe_result(*, raw_data: dict = None, findings: list = None) -> dict: + """Build a probe return dict: JSON-safe, merge_objects_deep-safe, backward-compat.""" + result = dict(raw_data or {}) + f_list = findings or [] + result["findings"] = [{**asdict(f), "severity": f.severity.value} for f in f_list] + result["vulnerabilities"] = [f.title for f in f_list if f.severity in _VULN_SEVERITIES] + return result + + +def probe_error(target: str, port: int, probe_name: str, exc: Exception) -> dict: + """Standardized error return for all probes.""" + return probe_result(raw_data={"error": f"{probe_name} failed on {target}:{port}: {exc}"}) diff --git a/extensions/business/cybersec/red_mesh/pentester_api_01.py b/extensions/business/cybersec/red_mesh/pentester_api_01.py index 2068edfa..33c28d90 100644 --- a/extensions/business/cybersec/red_mesh/pentester_api_01.py +++ b/extensions/business/cybersec/red_mesh/pentester_api_01.py @@ -663,7 +663,21 @@ def merge_objects_deep(self, obj_a, obj_b): merged[key] = value_b return merged elif isinstance(obj_a, list) and isinstance(obj_b, list): - return list(set(obj_a).union(set(obj_b))) + try: + return list(set(obj_a).union(set(obj_b))) + except TypeError: + import json as _json + seen = set() + merged = [] + for item in obj_a + obj_b: + try: + key = _json.dumps(item, sort_keys=True, default=str) + except (TypeError, ValueError): + key = id(item) + if key not in seen: + seen.add(key) + merged.append(item) + return merged elif isinstance(obj_a, set) and isinstance(obj_b, set): return obj_a.union(obj_b) else: diff --git a/extensions/business/cybersec/red_mesh/service_mixin.py b/extensions/business/cybersec/red_mesh/service_mixin.py index b1a6afc4..8b9851f4 100644 --- a/extensions/business/cybersec/red_mesh/service_mixin.py +++ b/extensions/business/cybersec/red_mesh/service_mixin.py @@ -1,4 +1,5 @@ import random +import re as _re import socket import struct import ftplib @@ -8,6 +9,9 @@ import paramiko +from .findings import Finding, Severity, probe_result, probe_error +from .cve_db import check_cves + # Default credentials commonly found on exposed SSH services. # Kept intentionally small — this is a quick check, not a brute-force. _SSH_DEFAULT_CREDS = [ @@ -217,7 +221,10 @@ def _service_info_443(self, target, port): def _service_info_tls(self, target, port): """ - Inspect TLS handshake details and certificate lifetime. + Inspect TLS handshake, certificate chain, and cipher strength. + + Uses a two-pass approach: unverified connect (always gets protocol/cipher), + then verified connect (detects self-signed / chain issues). Parameters ---------- @@ -228,35 +235,185 @@ def _service_info_tls(self, target, port): Returns ------- - str | None - TLS version/cipher summary or error message. + dict + Structured findings with protocol, cipher, cert details. """ - info = None + findings = [] + raw = {"protocol": None, "cipher": None, "cert_subject": None, "cert_issuer": None} + + # Pass 1: Unverified — always get protocol/cipher + proto, cipher, cert_der = self._tls_unverified_connect(target, port) + if proto is None: + return probe_error(target, port, "TLS", Exception("unverified connect failed")) + + raw["protocol"], raw["cipher"] = proto, cipher + findings += self._tls_check_protocol(proto, cipher) + + # Pass 2: Verified — detect self-signed / chain issues + findings += self._tls_check_certificate(target, port, raw) + + # Pass 3: Cert content checks (expiry, default CN) + findings += self._tls_check_expiry(raw) + findings += self._tls_check_default_cn(raw) + + if not findings: + findings.append(Finding(Severity.INFO, f"TLS {proto} {cipher}", "TLS configuration adequate.")) + + return probe_result(raw_data=raw, findings=findings) + + def _tls_unverified_connect(self, target, port): + """Unverified TLS connect to get protocol, cipher, and DER cert.""" try: - context = ssl.create_default_context() + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE with socket.create_connection((target, port), timeout=3) as sock: - with context.wrap_socket(sock, server_hostname=target) as ssock: - cert = ssock.getpeercert() + with ctx.wrap_socket(sock, server_hostname=target) as ssock: proto = ssock.version() - cipher = ssock.cipher() - expires = cert.get("notAfter") - info = f"TLS {proto} {cipher[0]}" - if proto and proto.upper() in ("SSLV3", "SSLV2", "TLSV1", "TLSV1.1"): - info = f"VULNERABILITY: Obsolete TLS protocol negotiated ({proto}) using {cipher[0]}" - if expires: - try: - exp = datetime.strptime(expires, "%b %d %H:%M:%S %Y %Z") - days = (exp - datetime.utcnow()).days - if days <= 30: - info = f"VULNERABILITY: TLS {proto} {cipher[0]}; certificate expires in {days} days" - else: - info = f"TLS {proto} {cipher[0]}; cert exp in {days} days" - except Exception: - info = f"TLS {proto} {cipher[0]}; cert expires {expires}" + cipher_info = ssock.cipher() + cipher_name = cipher_info[0] if cipher_info else "unknown" + cert_der = ssock.getpeercert(binary_form=True) + return proto, cipher_name, cert_der except Exception as e: - info = f"TLS probe failed on {target}:{port}: {e}" - self.P(info, color='y') - return info + self.P(f"TLS unverified connect failed on {target}:{port}: {e}", color='y') + return None, None, None + + def _tls_check_protocol(self, proto, cipher): + """Flag obsolete TLS/SSL protocols and weak ciphers.""" + findings = [] + if proto and proto.upper() in ("SSLV2", "SSLV3", "TLSV1", "TLSV1.1"): + findings.append(Finding( + severity=Severity.HIGH, + title=f"Obsolete TLS protocol: {proto}", + description=f"Server negotiated {proto} with cipher {cipher}. " + f"SSLv2/v3 and TLS 1.0/1.1 are deprecated and vulnerable.", + evidence=f"protocol={proto}, cipher={cipher}", + remediation="Disable SSLv2/v3/TLS 1.0/1.1 and require TLS 1.2+.", + owasp_id="A02:2021", + cwe_id="CWE-326", + confidence="certain", + )) + if cipher and any(w in cipher.lower() for w in ("rc4", "des", "null", "export")): + findings.append(Finding( + severity=Severity.HIGH, + title=f"Weak TLS cipher: {cipher}", + description=f"Cipher {cipher} is considered cryptographically weak.", + evidence=f"cipher={cipher}", + remediation="Disable weak ciphers (RC4, DES, NULL, EXPORT).", + owasp_id="A02:2021", + cwe_id="CWE-327", + confidence="certain", + )) + return findings + + def _tls_check_certificate(self, target, port, raw): + """Verified TLS pass — detect self-signed, untrusted issuer, hostname mismatch.""" + findings = [] + try: + ctx = ssl.create_default_context() + with socket.create_connection((target, port), timeout=3) as sock: + with ctx.wrap_socket(sock, server_hostname=target) as ssock: + cert = ssock.getpeercert() + subj = dict(x[0] for x in cert.get("subject", ())) + issuer = dict(x[0] for x in cert.get("issuer", ())) + raw["cert_subject"] = subj.get("commonName") + raw["cert_issuer"] = issuer.get("organizationName") or issuer.get("commonName") + raw["cert_not_after"] = cert.get("notAfter") + except ssl.SSLCertVerificationError as e: + err_msg = str(e).lower() + if "self-signed" in err_msg or "self signed" in err_msg: + findings.append(Finding( + severity=Severity.MEDIUM, + title="Self-signed TLS certificate", + description="The server presents a self-signed certificate that browsers will reject.", + evidence=str(e), + remediation="Replace with a certificate from a trusted CA.", + owasp_id="A02:2021", + cwe_id="CWE-295", + confidence="certain", + )) + elif "hostname mismatch" in err_msg: + findings.append(Finding( + severity=Severity.MEDIUM, + title="TLS certificate hostname mismatch", + description=f"Certificate CN/SAN does not match {target}.", + evidence=str(e), + remediation="Ensure the certificate covers the served hostname.", + owasp_id="A02:2021", + cwe_id="CWE-295", + confidence="certain", + )) + else: + findings.append(Finding( + severity=Severity.MEDIUM, + title="TLS certificate validation failed", + description="Certificate chain could not be verified.", + evidence=str(e), + remediation="Use a certificate from a trusted CA with a valid chain.", + owasp_id="A02:2021", + cwe_id="CWE-295", + confidence="firm", + )) + except Exception: + pass # Non-cert errors (connection reset, etc.) — skip + return findings + + def _tls_check_expiry(self, raw): + """Check certificate expiry from raw dict.""" + findings = [] + expires = raw.get("cert_not_after") + if not expires: + return findings + try: + exp = datetime.strptime(expires, "%b %d %H:%M:%S %Y %Z") + days = (exp - datetime.utcnow()).days + raw["cert_days_remaining"] = days + if days < 0: + findings.append(Finding( + severity=Severity.HIGH, + title=f"TLS certificate expired ({-days} days ago)", + description="The certificate has already expired.", + evidence=f"notAfter={expires}", + remediation="Renew the certificate immediately.", + owasp_id="A02:2021", + cwe_id="CWE-298", + confidence="certain", + )) + elif days <= 30: + findings.append(Finding( + severity=Severity.MEDIUM, + title=f"TLS certificate expiring soon ({days} days)", + description=f"Certificate expires in {days} days.", + evidence=f"notAfter={expires}", + remediation="Renew the certificate before expiry.", + owasp_id="A02:2021", + cwe_id="CWE-298", + confidence="certain", + )) + except Exception: + pass + return findings + + def _tls_check_default_cn(self, raw): + """Flag placeholder common names.""" + findings = [] + cn = raw.get("cert_subject") + if not cn: + return findings + cn_lower = cn.lower() + placeholders = ("example.com", "localhost", "internet widgits", "test", "changeme") + if any(p in cn_lower for p in placeholders): + findings.append(Finding( + severity=Severity.LOW, + title=f"TLS certificate placeholder CN: {cn}", + description="Certificate uses a default/placeholder common name.", + evidence=f"CN={cn}", + remediation="Replace with a certificate bearing the correct hostname.", + owasp_id="A02:2021", + cwe_id="CWE-295", + confidence="firm", + )) + return findings def _service_info_21(self, target, port): @@ -564,8 +721,66 @@ def _service_info_22(self, target, port): f"SSH default credential accepted: {cred}" ) + # --- 5. Cipher/KEX audit --- + result["weak_algorithms"] = self._ssh_check_ciphers(target, port, result) + + # --- 6. CVE check on banner version --- + if result["banner"]: + ssh_version = self._ssh_extract_version(result["banner"]) + if ssh_version: + result["ssh_version"] = ssh_version + cve_findings = check_cves("openssh", ssh_version) + for f in cve_findings: + result["vulnerabilities"].append(f.title) + return result + def _ssh_extract_version(self, banner): + """Extract OpenSSH version from banner like 'SSH-2.0-OpenSSH_8.9p1'.""" + m = _re.search(r'OpenSSH[_\s](\d+\.\d+(?:\.\d+)?)', banner, _re.IGNORECASE) + return m.group(1) if m else None + + def _ssh_check_ciphers(self, target, port, result): + """Audit SSH ciphers, KEX, and MACs via paramiko Transport.""" + weak_findings = [] + _WEAK_CIPHERS = {"3des-cbc", "blowfish-cbc", "arcfour", "arcfour128", "arcfour256", + "aes128-cbc", "aes192-cbc", "aes256-cbc", "cast128-cbc"} + _WEAK_KEX = {"diffie-hellman-group1-sha1", "diffie-hellman-group14-sha1", + "diffie-hellman-group-exchange-sha1"} + _WEAK_MACS = {"hmac-md5", "hmac-md5-96", "hmac-sha1", "hmac-sha1-96"} + + try: + transport = paramiko.Transport((target, port)) + transport.connect() + # Get negotiated algorithms from transport security options + sec_opts = transport.get_security_options() + + ciphers = set(sec_opts.ciphers) if sec_opts.ciphers else set() + kex = set(sec_opts.kex) if sec_opts.kex else set() + # key_types = set(sec_opts.keys) if sec_opts.keys else set() + + transport.close() + + weak_ciphers = ciphers & _WEAK_CIPHERS + weak_kex = kex & _WEAK_KEX + # Note: paramiko may not expose server-offered MACs directly, + # so we check against what the transport offers + + if weak_ciphers: + msg = f"SSH weak ciphers: {', '.join(sorted(weak_ciphers))}" + result["vulnerabilities"].append(msg) + weak_findings.append(msg) + + if weak_kex: + msg = f"SSH weak key exchange: {', '.join(sorted(weak_kex))}" + result["vulnerabilities"].append(msg) + weak_findings.append(msg) + + except Exception as e: + self.P(f"SSH cipher audit failed on {target}:{port}: {e}", color='y') + + return weak_findings + def _service_info_25(self, target, port): """ Assess SMTP service security: banner, EHLO features, STARTTLS, @@ -739,7 +954,7 @@ def _service_info_25(self, target, port): def _service_info_3306(self, target, port): """ - Perform a lightweight MySQL handshake to expose server version. + MySQL handshake probe: extract version, auth plugin, and check CVEs. Parameters ---------- @@ -750,25 +965,175 @@ def _service_info_3306(self, target, port): Returns ------- - str | None - MySQL version info or error message. + dict + Structured findings. """ - info = None + findings = [] + raw = {"version": None, "auth_plugin": None} try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(3) sock.connect((target, port)) - data = sock.recv(128) - if data and data[0] == 0x0a: - version = data[1:].split(b'\x00')[0].decode('utf-8', errors='ignore') - info = f"MySQL handshake version: {version}" - else: - info = "MySQL port open (no banner)" + data = sock.recv(256) sock.close() + + if data and len(data) > 4: + # MySQL protocol: first byte of payload is protocol version (0x0a = v10) + pkt_payload = data[4:] # skip 3-byte length + 1-byte seq + if pkt_payload and pkt_payload[0] == 0x0a: + version = pkt_payload[1:].split(b'\x00')[0].decode('utf-8', errors='ignore') + raw["version"] = version + + # Extract auth plugin name (at end of handshake after capabilities/salt) + try: + parts = pkt_payload.split(b'\x00') + if len(parts) >= 2: + last = parts[-2].decode('utf-8', errors='ignore') if parts[-1] == b'' else parts[-1].decode('utf-8', errors='ignore') + if 'mysql_native' in last or 'caching_sha2' in last or 'sha256' in last: + raw["auth_plugin"] = last + except Exception: + pass + + findings.append(Finding( + severity=Severity.LOW, + title=f"MySQL version disclosed: {version}", + description=f"MySQL {version} handshake received on {target}:{port}.", + evidence=f"version={version}, auth_plugin={raw['auth_plugin']}", + remediation="Restrict MySQL to trusted networks; consider disabling version disclosure.", + confidence="certain", + )) + + # CVE check + findings += check_cves("mysql", version) + else: + raw["protocol_byte"] = pkt_payload[0] if pkt_payload else None + findings.append(Finding( + severity=Severity.INFO, + title="MySQL port open (non-standard handshake)", + description=f"Port {port} responded but protocol byte is not 0x0a.", + confidence="tentative", + )) + else: + findings.append(Finding( + severity=Severity.INFO, + title="MySQL port open (no banner)", + description=f"No handshake data received on {target}:{port}.", + confidence="tentative", + )) except Exception as e: - info = f"MySQL probe failed on {target}:{port}: {e}" - self.P(info, color='y') - return info + return probe_error(target, port, "MySQL", e) + + return probe_result(raw_data=raw, findings=findings) + + def _service_info_3306_creds(self, target, port): + """ + MySQL default credential testing (opt-in via active_auth feature group). + + Attempts mysql_native_password auth with a small list of default credentials. + + Parameters + ---------- + target : str + Hostname or IP address. + port : int + Port being probed. + + Returns + ------- + dict + Structured findings. + """ + import hashlib + + findings = [] + raw = {"tested_credentials": 0, "accepted_credentials": []} + creds = [("root", ""), ("root", "root"), ("root", "password")] + + for username, password in creds: + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(3) + sock.connect((target, port)) + data = sock.recv(256) + + if not data or len(data) < 4: + sock.close() + continue + + pkt_payload = data[4:] + if not pkt_payload or pkt_payload[0] != 0x0a: + sock.close() + continue + + # Extract salt (scramble) from handshake + parts = pkt_payload[1:].split(b'\x00', 1) + rest = parts[1] if len(parts) > 1 else b'' + # Salt part 1: bytes 4..11 after capabilities (skip 4 bytes capabilities + 1 byte filler) + if len(rest) >= 13: + salt1 = rest[5:13] + else: + sock.close() + continue + # Salt part 2: after reserved bytes (skip 2+2+1+10 reserved = 15) + salt2 = b'' + if len(rest) >= 28: + salt2 = rest[28:40].rstrip(b'\x00') + salt = salt1 + salt2 + + # mysql_native_password auth response + if password: + sha1_pass = hashlib.sha1(password.encode()).digest() + sha1_sha1 = hashlib.sha1(sha1_pass).digest() + sha1_salt_sha1sha1 = hashlib.sha1(salt + sha1_sha1).digest() + auth_data = bytes(a ^ b for a, b in zip(sha1_pass, sha1_salt_sha1sha1)) + else: + auth_data = b'' + + # Build auth response packet + client_flags = struct.pack('= 5: + resp_type = resp[4] + if resp_type == 0x00: # OK packet + cred_str = f"{username}:{password}" if password else f"{username}:(empty)" + raw["accepted_credentials"].append(cred_str) + findings.append(Finding( + severity=Severity.CRITICAL, + title=f"MySQL default credential accepted: {cred_str}", + description=f"MySQL on {target}:{port} accepts {cred_str}.", + evidence=f"Auth response OK for {cred_str}", + remediation="Change default passwords and restrict access.", + owasp_id="A07:2021", + cwe_id="CWE-798", + confidence="certain", + )) + except Exception: + continue + + if not findings: + findings.append(Finding( + severity=Severity.INFO, + title="MySQL default credentials rejected", + description=f"Tested {raw['tested_credentials']} credential pairs, all rejected.", + confidence="certain", + )) + + return probe_result(raw_data=raw, findings=findings) def _service_info_3389(self, target, port): """ @@ -798,9 +1163,10 @@ def _service_info_3389(self, target, port): self.P(info, color='y') return info + # SAFETY: Read-only commands only. NEVER add CONFIG SET, SLAVEOF, MODULE LOAD, EVAL, DEBUG. def _service_info_6379(self, target, port): """ - Test Redis exposure by issuing a PING command. + Deep Redis probe: auth check, version, config readability, data size, client list. Parameters ---------- @@ -811,27 +1177,168 @@ def _service_info_6379(self, target, port): Returns ------- - str | None - Redis response summary or error message. + dict + Structured findings. """ - info = None + findings, raw = [], {"version": None, "os": None, "config_writable": False} + sock = self._redis_connect(target, port) + if not sock: + return probe_error(target, port, "Redis", Exception("connection failed")) + + auth_findings = self._redis_check_auth(sock, raw) + if not auth_findings: + # NOAUTH response — requires auth, stop here + sock.close() + return probe_result( + raw_data=raw, + findings=[Finding(Severity.INFO, "Redis requires authentication", "PING returned NOAUTH.")], + ) + + findings += auth_findings + findings += self._redis_check_info(sock, raw) + findings += self._redis_check_config(sock, raw) + findings += self._redis_check_data(sock, raw) + findings += self._redis_check_clients(sock, raw) + + # CVE check + if raw["version"]: + findings += check_cves("redis", raw["version"]) + + sock.close() + return probe_result(raw_data=raw, findings=findings) + + def _redis_connect(self, target, port): + """Open a TCP socket to Redis.""" try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(2) + sock.settimeout(3) sock.connect((target, port)) - sock.send(b"PING\r\n") - data = sock.recv(64).decode('utf-8', errors='ignore') - if data.startswith("+PONG"): - info = "VULNERABILITY: Redis responded to PING (no authentication)." - elif data.upper().startswith("-NOAUTH"): - info = "Redis requires authentication (NOAUTH)." - else: - info = f"Redis response: {data.strip()}" - sock.close() + return sock except Exception as e: - info = f"Redis probe failed on {target}:{port}: {e}" - self.P(info, color='y') - return info + self.P(f"Redis connect failed on {target}:{port}: {e}", color='y') + return None + + def _redis_cmd(self, sock, cmd): + """Send an inline Redis command and return the response string.""" + try: + sock.sendall(f"{cmd}\r\n".encode()) + data = sock.recv(4096).decode('utf-8', errors='ignore') + return data + except Exception: + return "" + + def _redis_check_auth(self, sock, raw): + """PING to check if auth is required. Returns findings if no auth, empty list if NOAUTH.""" + resp = self._redis_cmd(sock, "PING") + if resp.startswith("+PONG"): + return [Finding( + severity=Severity.CRITICAL, + title="Redis unauthenticated access", + description="Redis responded to PING without authentication.", + evidence=f"Response: {resp.strip()[:80]}", + remediation="Set a strong password via requirepass in redis.conf.", + owasp_id="A07:2021", + cwe_id="CWE-287", + confidence="certain", + )] + if "-NOAUTH" in resp.upper(): + return [] # signal: auth required + return [Finding( + severity=Severity.LOW, + title="Redis unusual PING response", + description=f"Unexpected response: {resp.strip()[:80]}", + confidence="tentative", + )] + + def _redis_check_info(self, sock, raw): + """Extract version and OS from INFO server.""" + findings = [] + resp = self._redis_cmd(sock, "INFO server") + if resp.startswith("-"): + return findings + for line in resp.split("\r\n"): + if line.startswith("redis_version:"): + raw["version"] = line.split(":", 1)[1].strip() + elif line.startswith("os:"): + raw["os"] = line.split(":", 1)[1].strip() + if raw["version"]: + findings.append(Finding( + severity=Severity.LOW, + title=f"Redis version disclosed: {raw['version']}", + description=f"Redis {raw['version']} on {raw['os'] or 'unknown OS'}.", + evidence=f"version={raw['version']}, os={raw['os']}", + remediation="Restrict INFO command access or rename it.", + confidence="certain", + )) + return findings + + def _redis_check_config(self, sock, raw): + """CONFIG GET dir — if accessible, it's an RCE vector.""" + findings = [] + resp = self._redis_cmd(sock, "CONFIG GET dir") + if resp.startswith("-"): + return findings # blocked, good + raw["config_writable"] = True + findings.append(Finding( + severity=Severity.CRITICAL, + title="Redis CONFIG command accessible (RCE vector)", + description="CONFIG GET is accessible, allowing attackers to write arbitrary files " + "via CONFIG SET dir / CONFIG SET dbfilename + SAVE.", + evidence=f"CONFIG GET dir response: {resp.strip()[:120]}", + remediation="Rename or disable CONFIG via rename-command in redis.conf.", + owasp_id="A05:2021", + cwe_id="CWE-94", + confidence="certain", + )) + return findings + + def _redis_check_data(self, sock, raw): + """DBSIZE — report if data is present.""" + findings = [] + resp = self._redis_cmd(sock, "DBSIZE") + if resp.startswith(":"): + try: + count = int(resp.strip().lstrip(":")) + raw["db_size"] = count + if count > 0: + findings.append(Finding( + severity=Severity.MEDIUM, + title=f"Redis database contains {count} keys", + description="Unauthenticated access to a Redis instance with live data.", + evidence=f"DBSIZE={count}", + remediation="Enable authentication and restrict network access.", + owasp_id="A01:2021", + cwe_id="CWE-284", + confidence="certain", + )) + except ValueError: + pass + return findings + + def _redis_check_clients(self, sock, raw): + """CLIENT LIST — extract connected client IPs.""" + findings = [] + resp = self._redis_cmd(sock, "CLIENT LIST") + if resp.startswith("-"): + return findings + ips = set() + for line in resp.split("\n"): + for part in line.split(): + if part.startswith("addr="): + ip_port = part.split("=", 1)[1] + ip = ip_port.rsplit(":", 1)[0] + ips.add(ip) + if ips: + raw["connected_clients"] = list(ips) + findings.append(Finding( + severity=Severity.LOW, + title=f"Redis client IPs disclosed ({len(ips)} clients)", + description=f"CLIENT LIST reveals connected IPs: {', '.join(sorted(ips)[:5])}", + evidence=f"IPs: {', '.join(sorted(ips)[:10])}", + remediation="Rename or disable CLIENT command.", + confidence="certain", + )) + return findings def _service_info_23(self, target, port): @@ -1091,7 +1598,13 @@ def _service_info_445(self, target, port): def _service_info_5900(self, target, port): """ - Read VNC handshake string to assess remote desktop exposure. + VNC handshake: read version banner, negotiate security types. + + Security types: + 1 (None) → CRITICAL: unauthenticated desktop access + 2 (VNC Auth) → MEDIUM: DES-based, max 8-char password + 19 (VeNCrypt) → INFO: TLS-secured + Other → LOW: unknown auth type Parameters ---------- @@ -1102,24 +1615,94 @@ def _service_info_5900(self, target, port): Returns ------- - str | None - VNC banner summary or error message. + dict + Structured findings. """ - info = None + findings = [] + raw = {"banner": None, "security_types": []} + try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(3) sock.connect((target, port)) - banner = sock.recv(12).decode('ascii', errors='ignore') - if banner: - info = f"VULNERABILITY: VNC protocol banner: {banner.strip()}" - else: - info = "VULNERABILITY: VNC open with no banner" + + # Read server banner (e.g. "RFB 003.008\n") + banner = sock.recv(12).decode('ascii', errors='ignore').strip() + raw["banner"] = banner + + if not banner.startswith("RFB"): + findings.append(Finding( + severity=Severity.MEDIUM, + title=f"VNC service detected (non-standard banner: {banner[:30]})", + description="VNC port open but banner is non-standard.", + evidence=f"Banner: {banner}", + remediation="Restrict VNC access to trusted networks or use SSH tunneling.", + confidence="tentative", + )) + sock.close() + return probe_result(raw_data=raw, findings=findings) + + # Echo version back to negotiate + sock.sendall(banner.encode('ascii') + b"\n") + + # Read security type list + sec_data = sock.recv(64) + sec_types = [] + if len(sec_data) >= 1: + num_types = sec_data[0] + if num_types > 0 and len(sec_data) >= 1 + num_types: + sec_types = list(sec_data[1:1 + num_types]) + raw["security_types"] = sec_types sock.close() + + _VNC_TYPE_NAMES = {1: "None", 2: "VNC Auth", 19: "VeNCrypt", 16: "Tight"} + type_labels = [f"{t}({_VNC_TYPE_NAMES.get(t, 'unknown')})" for t in sec_types] + raw["security_type_labels"] = type_labels + + if 1 in sec_types: + findings.append(Finding( + severity=Severity.CRITICAL, + title="VNC unauthenticated access (security type None)", + description=f"VNC on {target}:{port} allows connections without authentication.", + evidence=f"Banner: {banner}, security types: {type_labels}", + remediation="Disable security type None and require VNC Auth or VeNCrypt.", + owasp_id="A07:2021", + cwe_id="CWE-287", + confidence="certain", + )) + if 2 in sec_types: + findings.append(Finding( + severity=Severity.MEDIUM, + title="VNC password auth (DES-based, max 8 chars)", + description=f"VNC Auth uses DES encryption with a maximum 8-character password.", + evidence=f"Banner: {banner}, security types: {type_labels}", + remediation="Use VeNCrypt (TLS) or SSH tunneling instead of plain VNC Auth.", + owasp_id="A02:2021", + cwe_id="CWE-326", + confidence="certain", + )) + if 19 in sec_types: + findings.append(Finding( + severity=Severity.INFO, + title="VNC VeNCrypt (TLS-secured)", + description="VeNCrypt provides TLS-secured VNC connections.", + evidence=f"Banner: {banner}, security types: {type_labels}", + confidence="certain", + )) + if not sec_types: + findings.append(Finding( + severity=Severity.MEDIUM, + title=f"VNC service exposed: {banner}", + description="VNC protocol banner detected but security types could not be parsed.", + evidence=f"Banner: {banner}", + remediation="Restrict VNC access to trusted networks.", + confidence="firm", + )) + except Exception as e: - info = f"VNC probe failed on {target}:{port}: {e}" - self.P(info, color='y') - return info + return probe_error(target, port, "VNC", e) + + return probe_result(raw_data=raw, findings=findings) def _service_info_161(self, target, port): @@ -1286,7 +1869,13 @@ def _service_info_1433(self, target, port): def _service_info_5432(self, target, port): """ - Probe PostgreSQL for weak authentication methods. + Probe PostgreSQL authentication method by parsing the auth response byte. + + Auth codes: + 0 = AuthenticationOk (trust auth) → CRITICAL + 3 = CleartextPassword → MEDIUM + 5 = MD5Password → INFO (adequate, prefer SCRAM) + 10 = SASL (SCRAM-SHA-256) → INFO (strong) Parameters ---------- @@ -1297,10 +1886,11 @@ def _service_info_5432(self, target, port): Returns ------- - str | None - PostgreSQL response summary or error message. + dict + Structured findings. """ - info = None + findings = [] + raw = {"auth_type": None} try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(3) @@ -1309,18 +1899,166 @@ def _service_info_5432(self, target, port): startup = struct.pack('!I', len(payload) + 8) + struct.pack('!I', 196608) + payload sock.sendall(startup) data = sock.recv(128) - if b'AuthenticationCleartextPassword' in data: - info = ( - f"VULNERABILITY: PostgreSQL requests cleartext passwords on {target}:{port}" - ) - elif b'AuthenticationOk' in data: - info = f"PostgreSQL responded with AuthenticationOk on {target}:{port}" sock.close() + + # Parse auth response: type byte 'R' (0x52), then int32 length, then int32 auth code + if len(data) >= 9 and data[0:1] == b'R': + auth_code = struct.unpack('!I', data[5:9])[0] + raw["auth_type"] = auth_code + if auth_code == 0: + findings.append(Finding( + severity=Severity.CRITICAL, + title="PostgreSQL trust authentication (no password)", + description=f"PostgreSQL on {target}:{port} accepts connections without any password (auth code 0).", + evidence=f"Auth response code: {auth_code}", + remediation="Configure pg_hba.conf to require password or SCRAM authentication.", + owasp_id="A07:2021", + cwe_id="CWE-287", + confidence="certain", + )) + elif auth_code == 3: + findings.append(Finding( + severity=Severity.MEDIUM, + title="PostgreSQL cleartext password authentication", + description=f"PostgreSQL on {target}:{port} requests cleartext passwords.", + evidence=f"Auth response code: {auth_code}", + remediation="Switch to SCRAM-SHA-256 authentication in pg_hba.conf.", + owasp_id="A02:2021", + cwe_id="CWE-319", + confidence="certain", + )) + elif auth_code == 5: + findings.append(Finding( + severity=Severity.INFO, + title="PostgreSQL MD5 authentication", + description="MD5 password auth is adequate but SCRAM-SHA-256 is preferred.", + evidence=f"Auth response code: {auth_code}", + remediation="Consider upgrading to SCRAM-SHA-256.", + confidence="certain", + )) + elif auth_code == 10: + findings.append(Finding( + severity=Severity.INFO, + title="PostgreSQL SASL/SCRAM authentication", + description="Strong authentication (SCRAM-SHA-256) is in use.", + evidence=f"Auth response code: {auth_code}", + confidence="certain", + )) + elif b'AuthenticationCleartextPassword' in data: + # Fallback: text-based detection for older/non-standard servers + raw["auth_type"] = "cleartext_text" + findings.append(Finding( + severity=Severity.MEDIUM, + title="PostgreSQL cleartext password authentication", + description=f"PostgreSQL on {target}:{port} requests cleartext passwords.", + evidence="Text response contained AuthenticationCleartextPassword", + remediation="Switch to SCRAM-SHA-256 authentication.", + owasp_id="A02:2021", + cwe_id="CWE-319", + confidence="firm", + )) + elif b'AuthenticationOk' in data: + raw["auth_type"] = "ok_text" + findings.append(Finding( + severity=Severity.CRITICAL, + title="PostgreSQL trust authentication (no password)", + description=f"PostgreSQL on {target}:{port} accepted connection without authentication.", + evidence="Text response contained AuthenticationOk", + remediation="Configure pg_hba.conf to require password authentication.", + owasp_id="A07:2021", + cwe_id="CWE-287", + confidence="firm", + )) + + if not findings: + findings.append(Finding(Severity.INFO, "PostgreSQL probe completed", "No auth weakness detected.")) except Exception as e: - info = f"PostgreSQL probe failed on {target}:{port}: {e}" - self.P(info, color='y') - return info + return probe_error(target, port, "PostgreSQL", e) + + return probe_result(raw_data=raw, findings=findings) + + def _service_info_5432_creds(self, target, port): + """ + PostgreSQL default credential testing (opt-in via active_auth feature group). + + Attempts cleartext password auth with common defaults. + + Parameters + ---------- + target : str + Hostname or IP address. + port : int + Port being probed. + + Returns + ------- + dict + Structured findings. + """ + findings = [] + raw = {"tested_credentials": 0, "accepted_credentials": []} + creds = [("postgres", ""), ("postgres", "postgres"), ("postgres", "password")] + + for username, password in creds: + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(3) + sock.connect((target, port)) + payload = f'user\x00{username}\x00database\x00postgres\x00\x00'.encode() + startup = struct.pack('!I', len(payload) + 8) + struct.pack('!I', 196608) + payload + sock.sendall(startup) + data = sock.recv(128) + + if len(data) >= 9 and data[0:1] == b'R': + auth_code = struct.unpack('!I', data[5:9])[0] + if auth_code == 0: + cred_str = f"{username}:(empty)" if not password else f"{username}:{password}" + raw["accepted_credentials"].append(cred_str) + findings.append(Finding( + severity=Severity.CRITICAL, + title=f"PostgreSQL trust auth for {username}", + description=f"No password required for user {username}.", + evidence=f"Auth code 0 for {cred_str}", + remediation="Configure pg_hba.conf to require authentication.", + owasp_id="A07:2021", + cwe_id="CWE-287", + confidence="certain", + )) + elif auth_code == 3: + # Send cleartext password + pwd_bytes = password.encode() + b'\x00' + pwd_msg = b'p' + struct.pack('!I', len(pwd_bytes) + 4) + pwd_bytes + sock.sendall(pwd_msg) + resp = sock.recv(128) + if resp and resp[0:1] == b'R' and len(resp) >= 9: + result_code = struct.unpack('!I', resp[5:9])[0] + if result_code == 0: + cred_str = f"{username}:{password}" if password else f"{username}:(empty)" + raw["accepted_credentials"].append(cred_str) + findings.append(Finding( + severity=Severity.CRITICAL, + title=f"PostgreSQL default credential accepted: {cred_str}", + description=f"Cleartext password auth accepted for {cred_str}.", + evidence=f"Auth OK for {cred_str}", + remediation="Change default passwords.", + owasp_id="A07:2021", + cwe_id="CWE-798", + confidence="certain", + )) + raw["tested_credentials"] += 1 + sock.close() + except Exception: + continue + if not findings: + findings.append(Finding( + severity=Severity.INFO, + title="PostgreSQL default credentials rejected", + description=f"Tested {raw['tested_credentials']} credential pairs.", + confidence="certain", + )) + + return probe_result(raw_data=raw, findings=findings) def _service_info_11211(self, target, port): """ @@ -1358,7 +2096,7 @@ def _service_info_11211(self, target, port): def _service_info_9200(self, target, port): """ - Detect Elasticsearch/OpenSearch nodes leaking cluster metadata. + Deep Elasticsearch probe: cluster info, index listing, node IPs, CVE matching. Parameters ---------- @@ -1369,24 +2107,125 @@ def _service_info_9200(self, target, port): Returns ------- - str | None - Elasticsearch exposure summary or error message. + dict + Structured findings. """ - info = None + findings, raw = [], {"cluster_name": None, "version": None} + base_url = f"http://{target}" if port == 80 else f"http://{target}:{port}" + + findings += self._es_check_root(base_url, raw) + findings += self._es_check_indices(base_url, raw) + findings += self._es_check_nodes(base_url, raw) + + if raw["version"]: + findings += check_cves("elasticsearch", raw["version"]) + + if not findings: + findings.append(Finding(Severity.INFO, "Elasticsearch probe clean", "No issues detected.")) + + return probe_result(raw_data=raw, findings=findings) + + def _es_check_root(self, base_url, raw): + """GET / — extract version, cluster name.""" + findings = [] try: - scheme = "http" - base_url = f"{scheme}://{target}" - if port != 80: - base_url = f"{scheme}://{target}:{port}" resp = requests.get(base_url, timeout=3) - if resp.ok and 'cluster_name' in resp.text: - info = ( - f"VULNERABILITY: Elasticsearch cluster metadata exposed at {base_url}" - ) - except Exception as e: - info = f"Elasticsearch probe failed on {target}:{port}: {e}" - self.P(info, color='y') - return info + if resp.ok: + try: + data = resp.json() + raw["cluster_name"] = data.get("cluster_name") + ver_info = data.get("version", {}) + raw["version"] = ver_info.get("number") if isinstance(ver_info, dict) else None + raw["tagline"] = data.get("tagline") + findings.append(Finding( + severity=Severity.HIGH, + title=f"Elasticsearch cluster metadata exposed", + description=f"Cluster '{raw['cluster_name']}' version {raw['version']} accessible without auth.", + evidence=f"cluster={raw['cluster_name']}, version={raw['version']}", + remediation="Enable X-Pack security or restrict network access.", + owasp_id="A01:2021", + cwe_id="CWE-284", + confidence="certain", + )) + except Exception: + if 'cluster_name' in resp.text: + findings.append(Finding( + severity=Severity.HIGH, + title="Elasticsearch cluster metadata exposed", + description=f"Cluster metadata accessible at {base_url}.", + evidence=resp.text[:200], + remediation="Enable authentication.", + owasp_id="A01:2021", + cwe_id="CWE-284", + confidence="firm", + )) + except Exception: + pass + return findings + + def _es_check_indices(self, base_url, raw): + """GET /_cat/indices — list accessible indices.""" + findings = [] + try: + resp = requests.get(f"{base_url}/_cat/indices?v", timeout=3) + if resp.ok and resp.text.strip(): + lines = resp.text.strip().split("\n") + index_count = max(0, len(lines) - 1) # subtract header + raw["index_count"] = index_count + if index_count > 0: + findings.append(Finding( + severity=Severity.HIGH, + title=f"Elasticsearch {index_count} indices accessible", + description=f"{index_count} indices listed without authentication.", + evidence="\n".join(lines[:6]), + remediation="Enable authentication and restrict index access.", + owasp_id="A01:2021", + cwe_id="CWE-284", + confidence="certain", + )) + except Exception: + pass + return findings + + def _es_check_nodes(self, base_url, raw): + """GET /_nodes — extract transport/publish addresses (IP leak).""" + findings = [] + try: + resp = requests.get(f"{base_url}/_nodes", timeout=3) + if resp.ok: + data = resp.json() + nodes = data.get("nodes", {}) + ips = set() + for node in nodes.values(): + for key in ("transport_address", "publish_address", "host"): + val = node.get(key) or "" + # Extract IP from "1.2.3.4:9300" style + ip = val.rsplit(":", 1)[0] if ":" in val else val + if ip and ip not in ("127.0.0.1", "localhost", "0.0.0.0"): + ips.add(ip) + settings = node.get("settings", {}) + if isinstance(settings, dict): + net = settings.get("network", {}) + if isinstance(net, dict): + for k in ("host", "publish_host"): + v = net.get(k) + if v and v not in ("127.0.0.1", "localhost", "0.0.0.0"): + ips.add(v) + if ips: + raw["node_ips"] = list(ips) + findings.append(Finding( + severity=Severity.MEDIUM, + title=f"Elasticsearch node IPs disclosed ({len(ips)})", + description=f"Node API exposes internal IPs: {', '.join(sorted(ips)[:5])}", + evidence=f"IPs: {', '.join(sorted(ips)[:10])}", + remediation="Restrict /_nodes endpoint access.", + owasp_id="A01:2021", + cwe_id="CWE-200", + confidence="certain", + )) + except Exception: + pass + return findings def _service_info_502(self, target, port): diff --git a/extensions/business/cybersec/red_mesh/test_redmesh.py b/extensions/business/cybersec/red_mesh/test_redmesh.py index 6c4d443e..b523e32e 100644 --- a/extensions/business/cybersec/red_mesh/test_redmesh.py +++ b/extensions/business/cybersec/red_mesh/test_redmesh.py @@ -1,3 +1,4 @@ +import json import sys import struct import unittest @@ -29,9 +30,9 @@ def P(self, message, **kwargs): class RedMeshOWASPTests(unittest.TestCase): - - + + def setUp(self): if MANUAL_RUN: print() @@ -58,6 +59,44 @@ def _build_worker(self, ports=None, exceptions=None): worker.stop_event.is_set.return_value = False return owner, worker + def _assert_has_finding(self, result, substring): + """Assert that a finding/vulnerability with 'substring' exists in result. + + Handles both legacy string results and new dict results with findings/vulnerabilities. + """ + if isinstance(result, str): + self.assertIn(substring, result) + return + + if isinstance(result, dict): + # Check 'vulnerabilities' list (string titles) + vulns = result.get("vulnerabilities", []) + for v in vulns: + if substring in str(v): + return + + # Check 'findings' list (dicts with 'title' and 'description') + findings = result.get("findings", []) + for f in findings: + if isinstance(f, dict): + if substring in str(f.get("title", "")) or substring in str(f.get("description", "")): + return + elif substring in str(f): + return + + # Check 'error' key + if substring in str(result.get("error", "")): + return + + # Fallback: check entire dict as string + result_str = json.dumps(result, default=str) + if substring in result_str: + return + + self.fail(f"Finding '{substring}' not found in result: {json.dumps(result, indent=2, default=str)[:500]}") + else: + self.fail(f"Unexpected result type {type(result)}: {result}") + def test_broken_access_control_detected(self): owner, worker = self._build_worker() @@ -105,7 +144,7 @@ def test_injection_sql_detected(self): return_value=resp, ): result = worker._web_test_sql_injection("example.com", 80) - self.assertIn("VULNERABILITY: Potential SQL injection", result) + self._assert_has_finding(result, "SQL injection") def test_insecure_design_path_traversal(self): owner, worker = self._build_worker() @@ -117,7 +156,7 @@ def test_insecure_design_path_traversal(self): return_value=resp, ): result = worker._web_test_path_traversal("example.com", 80) - self.assertIn("VULNERABILITY: Path traversal", result) + self._assert_has_finding(result, "Path traversal") def test_security_misconfiguration_missing_headers(self): owner, worker = self._build_worker() @@ -134,20 +173,23 @@ def test_security_misconfiguration_missing_headers(self): def test_vulnerable_component_banner_exposed(self): owner, worker = self._build_worker(ports=[80]) worker.state["open_ports"] = [80] + # Set enabled features to include the probe + worker._PentestLocalWorker__enabled_features = ["_service_info_80"] resp = MagicMock() resp.status_code = 200 resp.reason = "OK" resp.headers = {"Server": "Apache/2.2.0"} + resp.text = "" with patch( - "extensions.business.cybersec.red_mesh.redmesh_utils.dir", - return_value=["_service_info_80"], - ), patch( "extensions.business.cybersec.red_mesh.service_mixin.requests.get", return_value=resp, + ), patch( + "extensions.business.cybersec.red_mesh.service_mixin.requests.request", + side_effect=Exception("skip methods check"), ): worker._gather_service_info() banner = worker.state["service_info"][80]["_service_info_80"] - self.assertIn("Apache/2.2.0", banner) + self._assert_has_finding(banner, "Apache/2.2.0") def test_identification_auth_failure_anonymous_ftp(self): owner, worker = self._build_worker(ports=[21]) @@ -173,7 +215,7 @@ def quit(self): return_value=DummyFTP(), ): result = worker._service_info_21("example.com", 21) - self.assertIn("VULNERABILITY: FTP allows anonymous login", result) + self._assert_has_finding(result, "FTP allows anonymous login") def test_service_checks_cover_non_standard_ports(self): owner, worker = self._build_worker(ports=[2121]) @@ -199,7 +241,7 @@ def quit(self): return_value=DummyFTP(), ): result = worker._service_info_21("example.com", 2121) - self.assertIn("FTP allows anonymous login", result) + self._assert_has_finding(result, "FTP allows anonymous login") def test_service_info_runs_all_methods_for_each_port(self): owner, worker = self._build_worker(ports=[1234]) @@ -213,12 +255,9 @@ def fake_service_two(target, port): setattr(worker, "_service_info_fake_one", fake_service_one) setattr(worker, "_service_info_fake_two", fake_service_two) + worker._PentestLocalWorker__enabled_features = ["_service_info_fake_one", "_service_info_fake_two"] - with patch( - "extensions.business.cybersec.red_mesh.redmesh_utils.dir", - return_value=["_service_info_fake_one", "_service_info_fake_two"], - ): - worker._gather_service_info() + worker._gather_service_info() service_snap = worker.state["service_info"][1234] self.assertEqual(len(service_snap), 2) @@ -258,6 +297,7 @@ def register(name): def test_web_tests_include_uncommon_ports(self): owner, worker = self._build_worker(ports=[9000]) worker.state["open_ports"] = [9000] + worker._PentestLocalWorker__enabled_features = ["_web_test_common"] def fake_get(url, timeout=2, verify=False): resp = MagicMock() @@ -267,9 +307,6 @@ def fake_get(url, timeout=2, verify=False): return resp with patch( - "extensions.business.cybersec.red_mesh.redmesh_utils.dir", - return_value=["_web_test_common"], - ), patch( "extensions.business.cybersec.red_mesh.web_discovery_mixin.requests.get", side_effect=fake_get, ): @@ -290,12 +327,9 @@ def fake_web_two(target, port): setattr(worker, "_web_test_fake_one", fake_web_one) setattr(worker, "_web_test_fake_two", fake_web_two) + worker._PentestLocalWorker__enabled_features = ["_web_test_fake_one", "_web_test_fake_two"] - with patch( - "extensions.business.cybersec.red_mesh.redmesh_utils.dir", - return_value=["_web_test_fake_one", "_web_test_fake_two"], - ): - worker._run_web_tests() + worker._run_web_tests() web_snap = worker.state["web_tests_info"][10000] self.assertEqual(len(web_snap), 2) @@ -318,7 +352,7 @@ def test_cross_site_scripting_detection(self): return_value=resp, ): result = worker._web_test_xss("example.com", 80) - self.assertIn("VULNERABILITY: Reflected XSS", result) + self._assert_has_finding(result, "XSS") def test_tls_certificate_expiration_reporting(self): owner, worker = self._build_worker(ports=[443]) @@ -330,36 +364,123 @@ def __enter__(self): def __exit__(self, exc_type, exc, tb): return False - class DummySSL: + class DummySSLUnverified: def __enter__(self): return self def __exit__(self, exc_type, exc, tb): return False - def getpeercert(self): - return {"notAfter": "Dec 31 12:00:00 2030 GMT"} - def version(self): return "TLSv1.3" def cipher(self): return ("TLS_AES_256_GCM_SHA384", None, None) - class DummyContext: + def getpeercert(self, binary_form=False): + if binary_form: + return b"dummy" + return {"notAfter": "Dec 31 12:00:00 2030 GMT", + "subject": ((("commonName", "example.com"),),), + "issuer": ((("organizationName", "Test CA"),),)} + + class DummySSLVerified: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def getpeercert(self): + return {"notAfter": "Dec 31 12:00:00 2030 GMT", + "subject": ((("commonName", "example.com"),),), + "issuer": ((("organizationName", "Test CA"),),)} + + call_count = [0] + + class DummyContextUnverified: + check_hostname = True + verify_mode = None + + def wrap_socket(self, sock, server_hostname=None): + return DummySSLUnverified() + + class DummyContextVerified: + def wrap_socket(self, sock, server_hostname=None): + return DummySSLVerified() + + def mock_ssl_context(protocol=None): + return DummyContextUnverified() + + with patch( + "extensions.business.cybersec.red_mesh.service_mixin.socket.create_connection", + return_value=DummyConn(), + ), patch( + "extensions.business.cybersec.red_mesh.service_mixin.ssl.SSLContext", + return_value=DummyContextUnverified(), + ), patch( + "extensions.business.cybersec.red_mesh.service_mixin.ssl.create_default_context", + return_value=DummyContextVerified(), + ): + info = worker._service_info_tls("example.com", 443) + self.assertIsInstance(info, dict) + self.assertIn("findings", info) + # Should find TLS info (protocol is TLSv1.3 which is fine) + self.assertIn("protocol", info) + self.assertEqual(info["protocol"], "TLSv1.3") + + def test_tls_self_signed_detection(self): + owner, worker = self._build_worker(ports=[443]) + + class DummyConn: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + class DummySSLUnverified: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def version(self): + return "TLSv1.2" + + def cipher(self): + return ("AES256-SHA", None, None) + + def getpeercert(self, binary_form=False): + return b"dummy" if binary_form else {} + + class DummyContextUnverified: + check_hostname = True + verify_mode = None + + def wrap_socket(self, sock, server_hostname=None): + return DummySSLUnverified() + + class DummyContextVerified: def wrap_socket(self, sock, server_hostname=None): - return DummySSL() + raise ssl.SSLCertVerificationError("self-signed certificate") + + import ssl with patch( "extensions.business.cybersec.red_mesh.service_mixin.socket.create_connection", return_value=DummyConn(), + ), patch( + "extensions.business.cybersec.red_mesh.service_mixin.ssl.SSLContext", + return_value=DummyContextUnverified(), ), patch( "extensions.business.cybersec.red_mesh.service_mixin.ssl.create_default_context", - return_value=DummyContext(), + return_value=DummyContextVerified(), ): info = worker._service_info_tls("example.com", 443) - self.assertIn("TLS", info) - self.assertIn("exp", info) + + self._assert_has_finding(info, "Self-signed") def test_port_scan_detects_open_ports(self): owner, worker = self._build_worker(ports=[80, 81]) @@ -410,7 +531,7 @@ def close(self): return_value=DummySocket(), ): info = worker._service_info_23("example.com", 23) - self.assertIn("VULNERABILITY: Telnet", info) + self._assert_has_finding(info, "Telnet") def test_service_smb_probe(self): owner, worker = self._build_worker(ports=[445]) @@ -439,11 +560,14 @@ def close(self): return_value=DummySocket(), ): info = worker._service_info_445("example.com", 445) - self.assertIn("VULNERABILITY: SMB", info) + self._assert_has_finding(info, "SMB") - def test_service_vnc_banner(self): + def test_service_vnc_unauthenticated(self): + """VNC with security type None (1) should report CRITICAL.""" owner, worker = self._build_worker(ports=[5900]) + recv_calls = [0] + class DummySocket: def __init__(self, *args, **kwargs): pass @@ -455,7 +579,15 @@ def connect(self, addr): return None def recv(self, nbytes): - return b"RFB 003.008\n" + recv_calls[0] += 1 + if recv_calls[0] == 1: + return b"RFB 003.008\n" + else: + # num_types=1, type=1 (None) + return bytes([1, 1]) + + def sendall(self, data): + return None def close(self): return None @@ -465,7 +597,43 @@ def close(self): return_value=DummySocket(), ): info = worker._service_info_5900("example.com", 5900) - self.assertIn("VULNERABILITY: VNC", info) + self._assert_has_finding(info, "unauthenticated") + + def test_service_vnc_password_auth(self): + """VNC with security type 2 (VNC Auth) should report MEDIUM.""" + owner, worker = self._build_worker(ports=[5900]) + + recv_calls = [0] + + class DummySocket: + def __init__(self, *args, **kwargs): + pass + + def settimeout(self, timeout): + return None + + def connect(self, addr): + return None + + def recv(self, nbytes): + recv_calls[0] += 1 + if recv_calls[0] == 1: + return b"RFB 003.008\n" + else: + return bytes([1, 2]) + + def sendall(self, data): + return None + + def close(self): + return None + + with patch( + "extensions.business.cybersec.red_mesh.service_mixin.socket.socket", + return_value=DummySocket(), + ): + info = worker._service_info_5900("example.com", 5900) + self._assert_has_finding(info, "DES-based") def test_service_snmp_public(self): owner, worker = self._build_worker(ports=[161]) @@ -492,7 +660,7 @@ def close(self): return_value=DummyUDPSocket(), ): info = worker._service_info_161("example.com", 161) - self.assertIn("VULNERABILITY: SNMP", info) + self._assert_has_finding(info, "SNMP") def test_service_dns_version_disclosure(self): owner, worker = self._build_worker(ports=[53]) @@ -528,7 +696,7 @@ def close(self): return_value=DummyUDPSocket(), ): info = worker._service_info_53("example.com", 53) - self.assertIn("VULNERABILITY: DNS version disclosure", info) + self._assert_has_finding(info, "DNS version disclosure") def test_service_memcached_stats(self): owner, worker = self._build_worker(ports=[11211]) @@ -557,19 +725,25 @@ def close(self): return_value=DummySocket(), ): info = worker._service_info_11211("example.com", 11211) - self.assertIn("VULNERABILITY: Memcached", info) + self._assert_has_finding(info, "Memcached") def test_service_elasticsearch_metadata(self): owner, worker = self._build_worker(ports=[9200]) resp = MagicMock() resp.ok = True - resp.text = '{"cluster_name":"example"}' + resp.status_code = 200 + resp.text = '{"cluster_name":"example","version":{"number":"7.10.0"},"tagline":"You Know, for Search"}' + resp.json.return_value = { + "cluster_name": "example", + "version": {"number": "7.10.0"}, + "tagline": "You Know, for Search", + } with patch( "extensions.business.cybersec.red_mesh.service_mixin.requests.get", return_value=resp, ): info = worker._service_info_9200("example.com", 9200) - self.assertIn("VULNERABILITY: Elasticsearch", info) + self._assert_has_finding(info, "Elasticsearch") def test_service_modbus_identification(self): owner, worker = self._build_worker(ports=[502]) @@ -598,9 +772,45 @@ def close(self): return_value=DummySocket(), ): info = worker._service_info_502("example.com", 502) - self.assertIn("VULNERABILITY: Modbus", info) + self._assert_has_finding(info, "Modbus") + + def test_service_postgres_trust_auth(self): + """Auth code 0 (trust) should be CRITICAL.""" + owner, worker = self._build_worker(ports=[5432]) + + class DummySocket: + def __init__(self, *args, **kwargs): + pass + + def settimeout(self, timeout): + return None + + def connect(self, addr): + return None + + def sendall(self, data): + self.sent = data + + def recv(self, nbytes): + # 'R' + int32(8) + int32(0) = AuthenticationOk + return b'R' + struct.pack('!II', 8, 0) + + def close(self): + return None + + with patch( + "extensions.business.cybersec.red_mesh.service_mixin.socket.socket", + return_value=DummySocket(), + ): + info = worker._service_info_5432("example.com", 5432) + self._assert_has_finding(info, "trust authentication") + # Verify it's CRITICAL severity + for f in info.get("findings", []): + if "trust" in f.get("title", "").lower(): + self.assertEqual(f["severity"], "CRITICAL") def test_service_postgres_cleartext(self): + """Auth code 3 (cleartext) should be MEDIUM.""" owner, worker = self._build_worker(ports=[5432]) class DummySocket: @@ -617,7 +827,8 @@ def sendall(self, data): self.sent = data def recv(self, nbytes): - return b"AuthenticationCleartextPassword" + # 'R' + int32(8) + int32(3) = CleartextPassword + return b'R' + struct.pack('!II', 8, 3) def close(self): return None @@ -627,7 +838,10 @@ def close(self): return_value=DummySocket(), ): info = worker._service_info_5432("example.com", 5432) - self.assertIn("VULNERABILITY: PostgreSQL", info) + self._assert_has_finding(info, "cleartext") + for f in info.get("findings", []): + if "cleartext" in f.get("title", "").lower(): + self.assertEqual(f["severity"], "MEDIUM") def test_service_mssql_prelogin(self): owner, worker = self._build_worker(ports=[1433]) @@ -656,7 +870,7 @@ def close(self): return_value=DummySocket(), ): info = worker._service_info_1433("example.com", 1433) - self.assertIn("VULNERABILITY: MSSQL", info) + self._assert_has_finding(info, "MSSQL") def test_service_mongo_unauth(self): owner, worker = self._build_worker(ports=[27017]) @@ -685,7 +899,7 @@ def close(self): return_value=DummySocket(), ): info = worker._service_info_27017("example.com", 27017) - self.assertIn("VULNERABILITY: MongoDB", info) + self._assert_has_finding(info, "MongoDB") def test_web_graphql_introspection(self): owner, worker = self._build_worker() @@ -764,12 +978,286 @@ def test_http_methods_detection(self): result = worker._web_test_http_methods("example.com", 80) self.assertIn("VULNERABILITY: Risky HTTP methods", result) + # ===== NEW TESTS — findings.py ===== + + def test_findings_severity_json_serializable(self): + """Severity enum serializes via json.dumps.""" + from extensions.business.cybersec.red_mesh.findings import Severity + self.assertEqual(json.dumps(Severity.CRITICAL), '"CRITICAL"') + self.assertEqual(json.dumps(Severity.INFO), '"INFO"') + + def test_findings_dataclass_serializable(self): + """Finding serializes via asdict.""" + from extensions.business.cybersec.red_mesh.findings import Finding, Severity + from dataclasses import asdict + f = Finding(Severity.HIGH, "Test", "Description", evidence="proof") + d = asdict(f) + self.assertEqual(d["severity"], "HIGH") + self.assertEqual(d["title"], "Test") + self.assertEqual(d["evidence"], "proof") + # Ensure JSON-serializable + json.dumps(d) + + def test_probe_result_structure(self): + """probe_result produces dict with both findings and vulnerabilities.""" + from extensions.business.cybersec.red_mesh.findings import Finding, Severity, probe_result + findings = [ + Finding(Severity.CRITICAL, "Crit vuln", "Critical."), + Finding(Severity.LOW, "Low issue", "Low."), + Finding(Severity.INFO, "Info note", "Info."), + ] + result = probe_result(raw_data={"banner": "test"}, findings=findings) + self.assertEqual(result["banner"], "test") + self.assertEqual(len(result["findings"]), 3) + # vulnerabilities only includes CRITICAL/HIGH/MEDIUM + self.assertEqual(result["vulnerabilities"], ["Crit vuln"]) + + def test_probe_error_structure(self): + """probe_error returns correct structure.""" + from extensions.business.cybersec.red_mesh.findings import probe_error + result = probe_error("host", 80, "TestProbe", Exception("oops")) + self.assertIn("error", result) + self.assertIn("TestProbe", result["error"]) + self.assertIn("findings", result) + self.assertEqual(result["findings"], []) + + # ===== NEW TESTS — cve_db.py ===== + + def test_cve_matches_constraint_less_than(self): + from extensions.business.cybersec.red_mesh.cve_db import _matches_constraint + self.assertTrue(_matches_constraint("1.4.1", "<1.4.3")) + self.assertFalse(_matches_constraint("1.4.3", "<1.4.3")) + self.assertFalse(_matches_constraint("1.4.4", "<1.4.3")) + + def test_cve_matches_constraint_range(self): + from extensions.business.cybersec.red_mesh.cve_db import _matches_constraint + self.assertTrue(_matches_constraint("5.7.16", ">=5.7,<5.7.20")) + self.assertFalse(_matches_constraint("5.7.20", ">=5.7,<5.7.20")) + self.assertFalse(_matches_constraint("5.6.99", ">=5.7,<5.7.20")) + + def test_cve_check_elasticsearch(self): + from extensions.business.cybersec.red_mesh.cve_db import check_cves + findings = check_cves("elasticsearch", "1.4.1") + cve_ids = [f.title for f in findings] + self.assertTrue(any("CVE-2015-1427" in t for t in cve_ids)) + + def test_cve_check_no_match(self): + from extensions.business.cybersec.red_mesh.cve_db import check_cves + findings = check_cves("elasticsearch", "99.0.0") + self.assertEqual(len(findings), 0) + + # ===== NEW TESTS — Redis deep probe ===== + + def test_redis_unauthenticated_access(self): + owner, worker = self._build_worker(ports=[6379]) + + cmd_responses = { + "PING": "+PONG\r\n", + "INFO server": "$100\r\nredis_version:6.0.5\r\nos:Linux 5.4.0\r\n", + "CONFIG GET dir": "*2\r\n$3\r\ndir\r\n$4\r\n/tmp\r\n", + "DBSIZE": ":42\r\n", + "CLIENT LIST": "id=1 addr=10.0.0.1:12345 fd=5\r\n", + } + + class DummySocket: + def __init__(self, *args, **kwargs): + self._buf = b"" + + def settimeout(self, timeout): + return None + + def connect(self, addr): + return None + + def sendall(self, data): + cmd = data.decode().strip() + self._buf = cmd_responses.get(cmd, "-ERR\r\n").encode() + + def recv(self, nbytes): + data = self._buf + self._buf = b"" + return data + + def close(self): + return None + + with patch( + "extensions.business.cybersec.red_mesh.service_mixin.socket.socket", + return_value=DummySocket(), + ): + info = worker._service_info_6379("example.com", 6379) + + self._assert_has_finding(info, "unauthenticated") + self._assert_has_finding(info, "CONFIG") + self.assertIsInstance(info, dict) + self.assertEqual(info.get("version"), "6.0.5") + + def test_redis_requires_auth(self): + owner, worker = self._build_worker(ports=[6379]) + + class DummySocket: + def __init__(self, *args, **kwargs): + pass + + def settimeout(self, timeout): + return None + + def connect(self, addr): + return None + + def sendall(self, data): + pass + + def recv(self, nbytes): + return b"-NOAUTH Authentication required\r\n" + + def close(self): + return None + + with patch( + "extensions.business.cybersec.red_mesh.service_mixin.socket.socket", + return_value=DummySocket(), + ): + info = worker._service_info_6379("example.com", 6379) + + self._assert_has_finding(info, "requires authentication") + + # ===== NEW TESTS — MySQL version extraction ===== + + def test_mysql_version_extraction(self): + owner, worker = self._build_worker(ports=[3306]) + + class DummySocket: + def __init__(self, *args, **kwargs): + pass + + def settimeout(self, timeout): + return None + + def connect(self, addr): + return None + + def recv(self, nbytes): + # MySQL handshake: 3-byte length + seq + protocol(0x0a) + version + null + version = b"8.0.28" + payload = bytes([0x0a]) + version + b'\x00' + b'\x00' * 50 + pkt_len = len(payload).to_bytes(3, 'little') + return pkt_len + b'\x00' + payload + + def close(self): + return None + + with patch( + "extensions.business.cybersec.red_mesh.service_mixin.socket.socket", + return_value=DummySocket(), + ): + info = worker._service_info_3306("example.com", 3306) + + self.assertIsInstance(info, dict) + self.assertEqual(info.get("version"), "8.0.28") + self._assert_has_finding(info, "8.0.28") + + # ===== NEW TESTS — Tech fingerprint ===== + + def test_tech_fingerprint(self): + owner, worker = self._build_worker() + resp = MagicMock() + resp.headers = {"Server": "Apache/2.4.52", "X-Powered-By": "PHP/8.1"} + resp.text = '' + resp.status_code = 200 + with patch( + "extensions.business.cybersec.red_mesh.web_discovery_mixin.requests.get", + return_value=resp, + ): + result = worker._web_test_tech_fingerprint("example.com", 80) + self.assertIsInstance(result, dict) + self._assert_has_finding(result, "Apache/2.4.52") + self._assert_has_finding(result, "PHP/8.1") + self._assert_has_finding(result, "WordPress") + + # ===== NEW TESTS — VPN endpoint detection ===== + + def test_vpn_endpoint_detection(self): + owner, worker = self._build_worker() + + def fake_get(url, timeout=3, verify=False, allow_redirects=False): + resp = MagicMock() + if "/remote/login" in url: + resp.status_code = 200 + resp.text = "Please Login - fortinet FortiGate" + resp.headers = {"Set-Cookie": ""} + else: + resp.status_code = 404 + resp.text = "" + resp.headers = {} + return resp + + with patch( + "extensions.business.cybersec.red_mesh.web_discovery_mixin.requests.get", + side_effect=fake_get, + ): + result = worker._web_test_vpn_endpoints("example.com", 443) + self._assert_has_finding(result, "FortiGate") + + +class TestFindingsModule(unittest.TestCase): + """Standalone tests for findings.py module.""" + + def test_severity_is_str_enum(self): + from extensions.business.cybersec.red_mesh.findings import Severity + self.assertIsInstance(Severity.CRITICAL, str) + self.assertEqual(Severity.CRITICAL, "CRITICAL") + + def test_finding_is_frozen(self): + from extensions.business.cybersec.red_mesh.findings import Finding, Severity + f = Finding(Severity.HIGH, "test", "desc") + with self.assertRaises(AttributeError): + f.title = "modified" + + def test_finding_hashable(self): + from extensions.business.cybersec.red_mesh.findings import Finding, Severity + f1 = Finding(Severity.HIGH, "test", "desc") + f2 = Finding(Severity.HIGH, "test", "desc") + self.assertEqual(hash(f1), hash(f2)) + s = {f1, f2} + self.assertEqual(len(s), 1) + + +class TestCveDatabase(unittest.TestCase): + """Standalone tests for cve_db.py module.""" + + def test_all_entries_valid(self): + from extensions.business.cybersec.red_mesh.cve_db import CVE_DATABASE, _matches_constraint + for entry in CVE_DATABASE: + self.assertTrue(entry.product) + self.assertTrue(entry.cve_id.startswith("CVE-")) + self.assertTrue(entry.title) + # Constraint should be parseable + result = _matches_constraint("0.0.1", entry.constraint) + self.assertIsInstance(result, bool) + + def test_openssh_regresshion(self): + from extensions.business.cybersec.red_mesh.cve_db import check_cves + findings = check_cves("openssh", "8.9") + cve_ids = [f.title for f in findings] + self.assertTrue(any("CVE-2024-6387" in t for t in cve_ids), f"Expected regreSSHion CVE, got: {cve_ids}") + + def test_apache_path_traversal(self): + from extensions.business.cybersec.red_mesh.cve_db import check_cves + findings = check_cves("apache", "2.4.49") + cve_ids = [f.title for f in findings] + self.assertTrue(any("CVE-2021-41773" in t for t in cve_ids)) + class VerboseResult(unittest.TextTestResult): def addSuccess(self, test): super().addSuccess(test) - self.stream.writeln() # emits an extra “\n” after the usual “ok” + self.stream.writeln() # emits an extra "\n" after the usual "ok" if __name__ == "__main__": runner = unittest.TextTestRunner(verbosity=2, resultclass=VerboseResult) - runner.run(unittest.defaultTestLoader.loadTestsFromTestCase(RedMeshOWASPTests)) + suite = unittest.TestSuite() + suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(RedMeshOWASPTests)) + suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestFindingsModule)) + suite.addTests(unittest.defaultTestLoader.loadTestsFromTestCase(TestCveDatabase)) + runner.run(suite) diff --git a/extensions/business/cybersec/red_mesh/web_discovery_mixin.py b/extensions/business/cybersec/red_mesh/web_discovery_mixin.py index 0e144773..c1106254 100644 --- a/extensions/business/cybersec/red_mesh/web_discovery_mixin.py +++ b/extensions/business/cybersec/red_mesh/web_discovery_mixin.py @@ -1,5 +1,8 @@ +import re as _re import requests +from .findings import Finding, Severity, probe_result + class _WebDiscoveryMixin: """ @@ -94,3 +97,193 @@ def _web_test_homepage(self, target, port): if not findings: findings.append(f"OK: No sensitive markers detected on {base_url} homepage.") return "\n".join(findings) + + def _web_test_tech_fingerprint(self, target, port): + """ + Technology fingerprinting: extract Server header, X-Powered-By, + meta generator, and detect tech mismatches. + + Parameters + ---------- + target : str + Hostname or IP address. + port : int + Web port to probe. + + Returns + ------- + dict + Structured findings with technology details. + """ + findings_list = [] + raw = {"server": None, "powered_by": None, "generator": None, "technologies": []} + scheme = "https" if port in (443, 8443) else "http" + base_url = f"{scheme}://{target}" if port in (80, 443) else f"{scheme}://{target}:{port}" + + try: + resp = requests.get(base_url, timeout=4, verify=False) + + # Server header + server = resp.headers.get("Server") + if server: + raw["server"] = server + raw["technologies"].append(server) + findings_list.append(Finding( + severity=Severity.LOW, + title=f"Server header disclosed: {server}", + description=f"Server header reveals software: {server}.", + evidence=f"Server: {server}", + remediation="Remove or obfuscate the Server header.", + owasp_id="A05:2021", + cwe_id="CWE-200", + confidence="certain", + )) + + # X-Powered-By header + powered_by = resp.headers.get("X-Powered-By") + if powered_by: + raw["powered_by"] = powered_by + raw["technologies"].append(powered_by) + findings_list.append(Finding( + severity=Severity.LOW, + title=f"X-Powered-By disclosed: {powered_by}", + description=f"X-Powered-By header reveals technology: {powered_by}.", + evidence=f"X-Powered-By: {powered_by}", + remediation="Remove X-Powered-By header.", + owasp_id="A05:2021", + cwe_id="CWE-200", + confidence="certain", + )) + + # Meta generator tag + body = resp.text[:10000] + gen_match = _re.search( + r'', + remediation="Remove the generator meta tag.", + owasp_id="A05:2021", + cwe_id="CWE-200", + confidence="certain", + )) + + # Tech mismatch detection + if raw["generator"] and raw["server"]: + gen_lower = raw["generator"].lower() + srv_lower = raw["server"].lower() + # Flag CMS + unexpected server combo (e.g. MediaWiki on Python/aiohttp) + cms_indicators = {"wordpress": "php", "mediawiki": "php", + "drupal": "php", "joomla": "php"} + for cms, expected_tech in cms_indicators.items(): + if cms in gen_lower and expected_tech not in srv_lower: + findings_list.append(Finding( + severity=Severity.MEDIUM, + title=f"Technology mismatch: {raw['generator']} on {raw['server']}", + description=f"{raw['generator']} typically runs on {expected_tech}, " + f"but server is {raw['server']}. Possible honeypot or proxy.", + evidence=f"Generator={raw['generator']}, Server={raw['server']}", + remediation="Verify this is intentional.", + confidence="tentative", + )) + break + + except Exception as e: + self.P(f"Tech fingerprint failed on {base_url}: {e}", color='y') + + if not findings_list: + findings_list.append(Finding( + severity=Severity.INFO, + title="No technology disclosed", + description=f"Server headers and HTML do not reveal technology stack.", + confidence="firm", + )) + + return probe_result(raw_data=raw, findings=findings_list) + + def _web_test_vpn_endpoints(self, target, port): + """ + Detect VPN management endpoints from major vendors. + + Probes: + - Cisco ASA: /+CSCOE+/logon.html + webvpn cookie + - FortiGate: /remote/login + - Pulse Secure: /dana-na/auth/url_default/welcome.cgi + - Palo Alto GP: /global-protect/login.esp + + Parameters + ---------- + target : str + Hostname or IP address. + port : int + Web port to probe. + + Returns + ------- + dict + Structured findings. + """ + findings_list = [] + raw = {"vpn_endpoints": []} + scheme = "https" if port in (443, 8443) else "http" + base_url = f"{scheme}://{target}" if port in (80, 443) else f"{scheme}://{target}:{port}" + + vpn_checks = [ + { + "path": "/+CSCOE+/logon.html", + "product": "Cisco ASA/AnyConnect", + "check": lambda resp: resp.status_code == 200 and ("webvpn" in resp.headers.get("Set-Cookie", "").lower() or "webvpn" in resp.text.lower()), + }, + { + "path": "/remote/login", + "product": "FortiGate SSL VPN", + "check": lambda resp: resp.status_code == 200 and ("fortinet" in resp.text.lower() or "fortitoken" in resp.text.lower() or "fgt_lang" in resp.headers.get("Set-Cookie", "").lower()), + }, + { + "path": "/dana-na/auth/url_default/welcome.cgi", + "product": "Pulse Secure / Ivanti VPN", + "check": lambda resp: resp.status_code in (200, 302) and ("pulse" in resp.text.lower() or "dana" in resp.text.lower() or "dsid" in resp.headers.get("Set-Cookie", "").lower()), + }, + { + "path": "/global-protect/login.esp", + "product": "Palo Alto GlobalProtect", + "check": lambda resp: resp.status_code == 200 and ("global-protect" in resp.text.lower() or "panGPBannerContent" in resp.text), + }, + ] + + for entry in vpn_checks: + try: + url = base_url.rstrip("/") + entry["path"] + resp = requests.get(url, timeout=3, verify=False, allow_redirects=False) + if entry["check"](resp): + raw["vpn_endpoints"].append({"product": entry["product"], "path": entry["path"]}) + findings_list.append(Finding( + severity=Severity.MEDIUM, + title=f"VPN endpoint detected: {entry['product']}", + description=f"{entry['product']} login page accessible at {url}.", + evidence=f"URL: {url}, status={resp.status_code}", + remediation="Restrict VPN management portal access; verify patching status.", + owasp_id="A05:2021", + cwe_id="CWE-200", + confidence="firm", + )) + except Exception: + pass + + if not findings_list: + findings_list.append(Finding( + severity=Severity.INFO, + title="No VPN endpoints detected", + description=f"Checked Cisco ASA, FortiGate, Pulse Secure, Palo Alto GP on {base_url}.", + confidence="firm", + )) + + return probe_result(raw_data=raw, findings=findings_list) diff --git a/extensions/business/cybersec/red_mesh/web_injection_mixin.py b/extensions/business/cybersec/red_mesh/web_injection_mixin.py index 2c9b0881..6db28c6b 100644 --- a/extensions/business/cybersec/red_mesh/web_injection_mixin.py +++ b/extensions/business/cybersec/red_mesh/web_injection_mixin.py @@ -1,8 +1,40 @@ +import time import requests from urllib.parse import quote +from .findings import Finding, Severity, probe_result, probe_error -class _WebInjectionMixin: + +class _InjectionTestBase: + """Shared execution engine for injection-style web tests.""" + + def _run_injection_test(self, target, port, *, params, payloads, check_fn, + finding_factory, max_findings=3): + """ + Iterate params x payloads, call check_fn(response, needle) for each, + create findings via finding_factory(param, payload, response, url). + """ + scheme = "https" if port in (443, 8443) else "http" + base_url = f"{scheme}://{target}" if port in (80, 443) else f"{scheme}://{target}:{port}" + findings = [] + + for param in params: + if len(findings) >= max_findings: + break + for payload, needle in payloads: + try: + url = f"{base_url}?{param}={payload}" + resp = requests.get(url, timeout=3, verify=False) + if check_fn(resp, needle): + findings.append(finding_factory(param, payload, resp, url)) + break # Found for this param, next param + except Exception: + pass + + return findings + + +class _WebInjectionMixin(_InjectionTestBase): """ Non-destructive probes for path traversal, reflected XSS, and SQL injection (OWASP WSTG-INPV). @@ -10,7 +42,7 @@ class _WebInjectionMixin: def _web_test_path_traversal(self, target, port): """ - Attempt basic path traversal payload against the target. + Attempt path traversal via URL path and query parameters with encoding variants. Parameters ---------- @@ -21,34 +53,102 @@ def _web_test_path_traversal(self, target, port): Returns ------- - str - Joined findings about traversal attempts. + dict + Structured findings. """ - findings = [] + findings_list = [] scheme = "https" if port in (443, 8443) else "http" - base_url = f"{scheme}://{target}" - if port not in (80, 443): - base_url = f"{scheme}://{target}:{port}" - try: - # Path traversal test - trav_url = base_url.rstrip("/") + "/../../../../etc/passwd" - resp_trav = requests.get(trav_url, timeout=2, verify=False) - if "root:x:" in resp_trav.text: - finding = f"VULNERABILITY: Path traversal at {trav_url}." - self.P(finding) - findings.append(finding) - except Exception as e: - message = f"ERROR: Path traversal probe failed on {base_url}: {e}" - self.P(message, color='r') - findings.append(message) - if not findings: - findings.append(f"OK: Path traversal payload not successful on {base_url}.") - return "\n".join(findings) + base_url = f"{scheme}://{target}" if port in (80, 443) else f"{scheme}://{target}:{port}" + + unix_needles = ("root:x:", "root:*:", "daemon:") + win_needles = ("[boot loader]", "[operating systems]", "[fonts]") + + # --- 1. Path-based traversal --- + path_payloads = [ + "/../../../../etc/passwd", + "/..%2f..%2f..%2f..%2fetc/passwd", + "/....//....//....//....//etc/passwd", + "/../../../../windows/win.ini", + ] + for payload_path in path_payloads: + if len(findings_list) >= 3: + break + try: + url = base_url.rstrip("/") + payload_path + resp = requests.get(url, timeout=2, verify=False) + if any(n in resp.text for n in unix_needles): + findings_list.append(Finding( + severity=Severity.CRITICAL, + title=f"Path traversal: /etc/passwd via path", + description=f"Server returned /etc/passwd content via path traversal.", + evidence=f"URL: {url}, body contains passwd markers", + remediation="Sanitize path components; use a web application firewall.", + owasp_id="A01:2021", + cwe_id="CWE-22", + confidence="certain", + )) + break + if any(n in resp.text for n in win_needles): + findings_list.append(Finding( + severity=Severity.CRITICAL, + title=f"Path traversal: win.ini via path", + description=f"Server returned Windows system file content.", + evidence=f"URL: {url}, body contains win.ini markers", + remediation="Sanitize path components.", + owasp_id="A01:2021", + cwe_id="CWE-22", + confidence="certain", + )) + break + except Exception: + pass + + # --- 2. Query parameter traversal --- + params = ["file", "path", "page", "doc", "template", "include", "name"] + payloads_qs = [ + ("../../../../etc/passwd", unix_needles), + ("..%2f..%2f..%2f..%2fetc/passwd", unix_needles), + ("..%252f..%252f..%252f..%252fetc/passwd", unix_needles), # double-encoded + ("..\\..\\..\\..\\windows\\win.ini", win_needles), + ] + for param in params: + if len(findings_list) >= 3: + break + for payload, needles in payloads_qs: + try: + url = f"{base_url}?{param}={payload}" + resp = requests.get(url, timeout=2, verify=False) + if any(n in resp.text for n in needles): + findings_list.append(Finding( + severity=Severity.CRITICAL, + title=f"Path traversal via ?{param}= parameter", + description=f"Parameter '{param}' allows reading system files.", + evidence=f"URL: {url}", + remediation=f"Validate and sanitize the '{param}' parameter.", + owasp_id="A01:2021", + cwe_id="CWE-22", + confidence="certain", + )) + break + except Exception: + pass + + if not findings_list: + findings_list.append(Finding( + severity=Severity.INFO, + title="Path traversal not detected", + description=f"Tested path and query param traversal on {base_url}.", + confidence="firm", + )) + + return probe_result(findings=findings_list) def _web_test_xss(self, target, port): """ - Probe reflected XSS by injecting a harmless script tag. + Probe reflected XSS via URL path injection and query parameters. + + Tests multiple payloads across common parameter names. Parameters ---------- @@ -59,35 +159,83 @@ def _web_test_xss(self, target, port): Returns ------- - str - Joined findings related to reflected XSS. + dict + Structured findings. """ - findings = [] + findings_list = [] scheme = "https" if port in (443, 8443) else "http" - base_url = f"{scheme}://{target}" - if port not in (80, 443): - base_url = f"{scheme}://{target}:{port}" - try: - # Basic XSS reflection test - payload = "" - test_url = base_url.rstrip("/") + f"/{payload}" - resp_test = requests.get(test_url, timeout=3, verify=False) - if payload in resp_test.text: - finding = f"VULNERABILITY: Reflected XSS at {test_url}." - self.P(finding) - findings.append(finding) - except Exception as e: - message = f"ERROR: XSS probe failed on {base_url}: {e}" - self.P(message, color='y') - findings.append(message) - if not findings: - findings.append(f"OK: Reflected XSS not observed at {base_url}.") - return "\n".join(findings) + base_url = f"{scheme}://{target}" if port in (80, 443) else f"{scheme}://{target}:{port}" + + xss_payloads = [ + ('', 'onerror=alert'), + ('', 'onload=alert'), + ('', '
  • (.*?)", body[:5000], _re.IGNORECASE | _re.DOTALL) + if title_m: + result["title"] = title_m.group(1).strip()[:100] + else: + result["banner"] = "(empty reply)" + findings.append(Finding( + severity=Severity.INFO, + title="HTTP service returns empty reply", + description=f"TCP port {port} accepts connections but the server " + "closes without sending any HTTP response data.", + evidence=f"Raw socket to {target}:{port} — connected OK, received 0 bytes.", + remediation="Investigate why the server sends empty replies; " + "verify proxy/upstream configuration.", + cwe_id="CWE-200", + confidence="certain", + )) + except Exception: + return probe_error(target, port, "HTTP", e) + return probe_result(raw_data=result, findings=findings) # --- 2. Dangerous HTTP methods --- dangerous = [] diff --git a/extensions/business/cybersec/red_mesh/test_redmesh.py b/extensions/business/cybersec/red_mesh/test_redmesh.py index 2ba60ebc..cff52620 100644 --- a/extensions/business/cybersec/red_mesh/test_redmesh.py +++ b/extensions/business/cybersec/red_mesh/test_redmesh.py @@ -2227,6 +2227,107 @@ def fake_get(url, timeout=2, verify=False): self.assertTrue(found, f"Expected security.txt finding, got: {result.get('findings', [])}") + # --- Item 6: HTTP empty reply fallback --- + + def test_http_empty_reply_fallback(self): + """HTTP probe should fall back to raw socket when requests.get fails with empty reply.""" + _, worker = self._build_worker(ports=[81]) + + class DummySocket: + def __init__(self, chunks): + self._chunks = list(chunks) + def settimeout(self, t): pass + def connect(self, addr): pass + def send(self, data): pass + def recv(self, n): + return self._chunks.pop(0) if self._chunks else b"" + def close(self): pass + + from requests.exceptions import ConnectionError as ReqConnError + + # Case 1: requests fails, raw socket also gets empty reply + with patch( + "extensions.business.cybersec.red_mesh.service_mixin.requests.get", + side_effect=ReqConnError("RemoteDisconnected"), + ), patch( + "extensions.business.cybersec.red_mesh.service_mixin.socket.socket", + return_value=DummySocket([b""]), + ): + result = worker._service_info_http("10.0.0.1", 81) + self.assertIsNotNone(result, "Should return a result, not None") + self.assertEqual(result.get("banner"), "(empty reply)") + titles = [f["title"] for f in result.get("findings", [])] + self.assertTrue(any("empty reply" in t.lower() for t in titles), + f"Expected empty reply finding, got: {titles}") + + def test_http_empty_reply_fallback_with_banner(self): + """HTTP probe raw socket fallback should capture server banner and detect Host-header drop.""" + _, worker = self._build_worker(ports=[81]) + + class DummySocket: + def __init__(self, chunks): + self._chunks = list(chunks) + def settimeout(self, t): pass + def connect(self, addr): pass + def send(self, data): pass + def recv(self, n): + return self._chunks.pop(0) if self._chunks else b"" + def close(self): pass + + from requests.exceptions import ConnectionError as ReqConnError + raw_resp = b"HTTP/1.1 200 OK\r\nServer: nginx/1.24.0\r\n\r\n" + + with patch( + "extensions.business.cybersec.red_mesh.service_mixin.requests.get", + side_effect=ReqConnError("RemoteDisconnected"), + ), patch( + "extensions.business.cybersec.red_mesh.service_mixin.socket.socket", + return_value=DummySocket([raw_resp, b""]), + ): + result = worker._service_info_http("10.0.0.1", 81) + self.assertIsNotNone(result, "Should return a result, not None") + self.assertEqual(result.get("banner"), "HTTP/1.1 200 OK") + self.assertEqual(result.get("server"), "nginx/1.24.0") + titles = [f["title"] for f in result.get("findings", [])] + self.assertTrue(any("host header" in t.lower() for t in titles), + f"Expected Host-header-drop finding, got: {titles}") + + def test_http_fallback_directory_listing(self): + """HTTP probe raw socket fallback should detect directory listing.""" + _, worker = self._build_worker(ports=[81]) + + class DummySocket: + def __init__(self, chunks): + self._chunks = list(chunks) + def settimeout(self, t): pass + def connect(self, addr): pass + def send(self, data): pass + def recv(self, n): + return self._chunks.pop(0) if self._chunks else b"" + def close(self): pass + + from requests.exceptions import ConnectionError as ReqConnError + raw_resp = ( + b"HTTP/1.1 200 OK\r\nServer: nginx\r\n\r\n" + b"Directory listing for /" + b'
  • ../' + ) + + with patch( + "extensions.business.cybersec.red_mesh.service_mixin.requests.get", + side_effect=ReqConnError("RemoteDisconnected"), + ), patch( + "extensions.business.cybersec.red_mesh.service_mixin.socket.socket", + return_value=DummySocket([raw_resp, b""]), + ): + result = worker._service_info_http("10.0.0.1", 81) + self.assertIsNotNone(result) + titles = [f["title"] for f in result.get("findings", [])] + self.assertTrue(any("directory listing" in t.lower() for t in titles), + f"Expected directory listing finding, got: {titles}") + self.assertEqual(result.get("title"), "Directory listing for /") + + class VerboseResult(unittest.TextTestResult): def addSuccess(self, test): super().addSuccess(test) From bf2de9db3d29cffa4dd851dfe88a73212cdbd124 Mon Sep 17 00:00:00 2001 From: toderian Date: Thu, 26 Feb 2026 23:15:56 +0000 Subject: [PATCH 19/25] fix: timelines wordings --- extensions/business/cybersec/red_mesh/pentester_api_01.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/extensions/business/cybersec/red_mesh/pentester_api_01.py b/extensions/business/cybersec/red_mesh/pentester_api_01.py index 520661e7..a0458d17 100644 --- a/extensions/business/cybersec/red_mesh/pentester_api_01.py +++ b/extensions/business/cybersec/red_mesh/pentester_api_01.py @@ -1019,13 +1019,14 @@ def _maybe_finalize_pass(self): job_specs["job_status"] = "FINALIZED" created_at = self._get_timeline_date(job_specs, "created") or self.time() job_specs["duration"] = round(self.time() - created_at, 2) - self._emit_timeline_event(job_specs, "finalized", "Job finalized") + self._emit_timeline_event(job_specs, "scan_completed", "Scan completed") self.P(f"[SINGLEPASS] Job {job_id} complete. Status set to FINALIZED.") # Run LLM auto-analysis on aggregated report (launcher only) if self.cfg_llm_agent_api_enabled: self._run_aggregated_llm_analysis(job_id, job_specs, workers, pass_nr=job_pass) + self._emit_timeline_event(job_specs, "finalized", "Job finalized") self.chainstore_hset(hkey=self.cfg_instance_id, key=job_key, value=job_specs) continue @@ -1036,13 +1037,14 @@ def _maybe_finalize_pass(self): job_specs["job_status"] = "STOPPED" created_at = self._get_timeline_date(job_specs, "created") or self.time() job_specs["duration"] = round(self.time() - created_at, 2) - self._emit_timeline_event(job_specs, "stopped", "Job stopped") + self._emit_timeline_event(job_specs, "scan_completed", f"Scan completed (pass {job_pass})") self.P(f"[CONTINUOUS] Pass {job_pass} complete for job {job_id}. Status set to STOPPED (soft stop was scheduled)") # Run LLM auto-analysis on aggregated report (launcher only) if self.cfg_llm_agent_api_enabled: self._run_aggregated_llm_analysis(job_id, job_specs, workers, pass_nr=job_pass) + self._emit_timeline_event(job_specs, "stopped", "Job stopped") self.chainstore_hset(hkey=self.cfg_instance_id, key=job_key, value=job_specs) continue # end if From 96e04e93aea5d36742087d0dbca101bb9f71f7e5 Mon Sep 17 00:00:00 2001 From: toderian Date: Fri, 27 Feb 2026 01:03:12 +0000 Subject: [PATCH 20/25] feat: save username when creating a job --- .../business/cybersec/red_mesh/pentester_api_01.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/extensions/business/cybersec/red_mesh/pentester_api_01.py b/extensions/business/cybersec/red_mesh/pentester_api_01.py index a0458d17..cefa56c6 100644 --- a/extensions/business/cybersec/red_mesh/pentester_api_01.py +++ b/extensions/business/cybersec/red_mesh/pentester_api_01.py @@ -1250,6 +1250,8 @@ def launch_test( scanner_identity: str = "", scanner_user_agent: str = "", authorized: bool = False, + created_by_name: str = "", + created_by_id: str = "", ): """ Start a pentest on the specified target. @@ -1470,8 +1472,16 @@ def launch_test( "scanner_identity": scanner_identity, "scanner_user_agent": scanner_user_agent, "authorized": True, + # User identity (forwarded from Navigator UI) + "created_by_name": created_by_name or None, + "created_by_id": created_by_id or None, } - self._emit_timeline_event(job_specs, "created", "Job created", actor=self.ee_id, actor_type="node") + self._emit_timeline_event( + job_specs, "created", + f"Job created by {created_by_name}", + actor=created_by_name, + actor_type="user" + ) self._emit_timeline_event(job_specs, "started", "Scan started", actor=self.ee_id, actor_type="node") self.chainstore_hset( hkey=self.cfg_instance_id, From d856a797b775af7dba8f4441c6649a7810557a69 Mon Sep 17 00:00:00 2001 From: toderian Date: Fri, 27 Feb 2026 01:24:08 +0000 Subject: [PATCH 21/25] feat: add quick AI summary --- .../business/cybersec/red_mesh/constants.py | 1 + .../cybersec/red_mesh/pentester_api_01.py | 3 + .../red_mesh/redmesh_llm_agent_api.py | 11 ++- .../red_mesh/redmesh_llm_agent_mixin.py | 95 +++++++++++++++++++ 4 files changed, 109 insertions(+), 1 deletion(-) diff --git a/extensions/business/cybersec/red_mesh/constants.py b/extensions/business/cybersec/red_mesh/constants.py index e751a6c4..caa23682 100644 --- a/extensions/business/cybersec/red_mesh/constants.py +++ b/extensions/business/cybersec/red_mesh/constants.py @@ -112,6 +112,7 @@ LLM_ANALYSIS_SECURITY_ASSESSMENT = "security_assessment" LLM_ANALYSIS_VULNERABILITY_SUMMARY = "vulnerability_summary" LLM_ANALYSIS_REMEDIATION_PLAN = "remediation_plan" +LLM_ANALYSIS_QUICK_SUMMARY = "quick_summary" # ===================================================================== # Protocol fingerprinting and probe routing diff --git a/extensions/business/cybersec/red_mesh/pentester_api_01.py b/extensions/business/cybersec/red_mesh/pentester_api_01.py index cefa56c6..9ceaef9b 100644 --- a/extensions/business/cybersec/red_mesh/pentester_api_01.py +++ b/extensions/business/cybersec/red_mesh/pentester_api_01.py @@ -1025,6 +1025,7 @@ def _maybe_finalize_pass(self): # Run LLM auto-analysis on aggregated report (launcher only) if self.cfg_llm_agent_api_enabled: self._run_aggregated_llm_analysis(job_id, job_specs, workers, pass_nr=job_pass) + self._run_quick_summary_analysis(job_id, job_specs, workers, pass_nr=job_pass) self._emit_timeline_event(job_specs, "finalized", "Job finalized") self.chainstore_hset(hkey=self.cfg_instance_id, key=job_key, value=job_specs) @@ -1043,6 +1044,7 @@ def _maybe_finalize_pass(self): # Run LLM auto-analysis on aggregated report (launcher only) if self.cfg_llm_agent_api_enabled: self._run_aggregated_llm_analysis(job_id, job_specs, workers, pass_nr=job_pass) + self._run_quick_summary_analysis(job_id, job_specs, workers, pass_nr=job_pass) self._emit_timeline_event(job_specs, "stopped", "Job stopped") self.chainstore_hset(hkey=self.cfg_instance_id, key=job_key, value=job_specs) @@ -1052,6 +1054,7 @@ def _maybe_finalize_pass(self): # Run LLM auto-analysis for this pass (launcher only) if self.cfg_llm_agent_api_enabled: self._run_aggregated_llm_analysis(job_id, job_specs, workers, pass_nr=job_pass) + self._run_quick_summary_analysis(job_id, job_specs, workers, pass_nr=job_pass) # Schedule next pass interval = job_specs.get("monitor_interval", self.cfg_monitor_interval) diff --git a/extensions/business/cybersec/red_mesh/redmesh_llm_agent_api.py b/extensions/business/cybersec/red_mesh/redmesh_llm_agent_api.py index 27e2752f..e2a43d55 100644 --- a/extensions/business/cybersec/red_mesh/redmesh_llm_agent_api.py +++ b/extensions/business/cybersec/red_mesh/redmesh_llm_agent_api.py @@ -49,6 +49,7 @@ LLM_ANALYSIS_SECURITY_ASSESSMENT, LLM_ANALYSIS_VULNERABILITY_SUMMARY, LLM_ANALYSIS_REMEDIATION_PLAN, + LLM_ANALYSIS_QUICK_SUMMARY, ) __VER__ = '0.1.0' @@ -123,6 +124,8 @@ 5. Verification steps to confirm remediation Be practical and provide copy-paste ready solutions where possible.""", + + LLM_ANALYSIS_QUICK_SUMMARY: """You are a cybersecurity expert. Based on the scan results below, write a quick executive summary in exactly 2-4 sentences. Cover: how many ports/services were found, the overall risk posture (critical/high/medium/low), and the single most important finding or action item. Be specific but extremely concise -- this is a dashboard glance summary, not a full report.""", } @@ -503,6 +506,7 @@ def analyze_scan( LLM_ANALYSIS_SECURITY_ASSESSMENT, LLM_ANALYSIS_VULNERABILITY_SUMMARY, LLM_ANALYSIS_REMEDIATION_PLAN, + LLM_ANALYSIS_QUICK_SUMMARY, ] if analysis_type not in valid_types: return { @@ -535,7 +539,12 @@ def analyze_scan( # Build and send request # Use higher max_tokens for analysis by default - effective_max_tokens = max_tokens if max_tokens is not None else 2048 + if max_tokens is not None: + effective_max_tokens = max_tokens + elif analysis_type == LLM_ANALYSIS_QUICK_SUMMARY: + effective_max_tokens = 256 + else: + effective_max_tokens = 2048 payload = self._build_deepseek_request( messages=messages, diff --git a/extensions/business/cybersec/red_mesh/redmesh_llm_agent_mixin.py b/extensions/business/cybersec/red_mesh/redmesh_llm_agent_mixin.py index 97319fb4..1085dfa1 100644 --- a/extensions/business/cybersec/red_mesh/redmesh_llm_agent_mixin.py +++ b/extensions/business/cybersec/red_mesh/redmesh_llm_agent_mixin.py @@ -296,6 +296,101 @@ def _run_aggregated_llm_analysis( self.P(f"Error saving LLM analysis to R1FS: {e}", color='r') return None + def _run_quick_summary_analysis( + self, + job_id: str, + job_specs: dict, + workers: dict, + pass_nr: int = None + ) -> Optional[str]: + """ + Run a short (2-4 sentence) AI quick summary on the aggregated report. + + Same pattern as _run_aggregated_llm_analysis but uses the quick_summary + analysis type with a low token budget. + + Parameters + ---------- + job_id : str + Identifier of the job. + job_specs : dict + Job specification (will be updated with quick_summary_cid). + workers : dict + Worker entries containing report data. + pass_nr : int, optional + Pass number for continuous monitoring jobs. + + Returns + ------- + str or None + Quick summary CID if successful, None otherwise. + """ + target = job_specs.get("target", "unknown") + pass_info = f" (pass {pass_nr})" if pass_nr else "" + self.P(f"Running quick summary analysis for job {job_id}{pass_info}, target {target}...") + + # Collect and aggregate reports from all workers + aggregated_report = self._collect_aggregated_report(workers) + + if not aggregated_report: + self.P(f"No data for quick summary for job {job_id}", color='y') + return None + + # Add job metadata to report for context + aggregated_report["_job_metadata"] = { + "job_id": job_id, + "target": target, + "num_workers": len(workers), + "worker_addresses": list(workers.keys()), + "start_port": job_specs.get("start_port"), + "end_port": job_specs.get("end_port"), + "enabled_features": job_specs.get("enabled_features", []), + "run_mode": job_specs.get("run_mode", "SINGLEPASS"), + "pass_nr": pass_nr, + } + + # Call LLM analysis with quick_summary type + analysis_result = self._call_llm_agent_api( + endpoint="/analyze_scan", + method="POST", + payload={ + "scan_results": aggregated_report, + "analysis_type": "quick_summary", + "focus_areas": None, + } + ) + + if not analysis_result or "error" in analysis_result: + self.P( + f"Quick summary failed for job {job_id}: {analysis_result.get('error') if analysis_result else 'No response'}", + color='y' + ) + return None + + # Save to R1FS + try: + summary_cid = self.r1fs.add_json(analysis_result, show_logs=False) + if summary_cid: + # Store in pass_history + pass_history = job_specs.get("pass_history", []) + for entry in pass_history: + if entry.get("pass_nr") == pass_nr: + entry["quick_summary_cid"] = summary_cid + break + self._emit_timeline_event( + job_specs, "llm_analysis", + f"Quick summary completed for pass {pass_nr}", + meta={"quick_summary_cid": summary_cid, "pass_nr": pass_nr} + ) + self.P(f"Quick summary for pass {pass_nr} saved, CID: {summary_cid}") + return summary_cid + else: + self.P(f"Failed to save quick summary to R1FS for job {job_id}", color='y') + return None + except Exception as e: + self.P(f"Error saving quick summary to R1FS: {e}", color='r') + return None + def _get_llm_health_status(self) -> dict: """ Check health of the LLM Agent API connection. From 00f739557754bc9937bb0e25cb0466457d6ed2ca Mon Sep 17 00:00:00 2001 From: toderian Date: Fri, 27 Feb 2026 03:05:22 +0000 Subject: [PATCH 22/25] feat: add risc score --- .../business/cybersec/red_mesh/constants.py | 12 +- .../cybersec/red_mesh/pentester_api_01.py | 114 ++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/extensions/business/cybersec/red_mesh/constants.py b/extensions/business/cybersec/red_mesh/constants.py index caa23682..b076307c 100644 --- a/extensions/business/cybersec/red_mesh/constants.py +++ b/extensions/business/cybersec/red_mesh/constants.py @@ -166,4 +166,14 @@ "_service_info_generic": frozenset({"unknown", "wins"}), "_service_info_mysql_creds": frozenset({"mysql"}), "_service_info_postgresql_creds": frozenset({"postgresql"}), -} \ No newline at end of file +} + +# ===================================================================== +# Risk score computation +# ===================================================================== + +RISK_SEVERITY_WEIGHTS = {"CRITICAL": 40, "HIGH": 25, "MEDIUM": 10, "LOW": 2, "INFO": 0} +RISK_CONFIDENCE_MULTIPLIERS = {"certain": 1.0, "firm": 0.8, "tentative": 0.5} +RISK_SIGMOID_K = 0.02 +RISK_CRED_PENALTY_PER = 15 +RISK_CRED_PENALTY_CAP = 30 \ No newline at end of file diff --git a/extensions/business/cybersec/red_mesh/pentester_api_01.py b/extensions/business/cybersec/red_mesh/pentester_api_01.py index 9ceaef9b..ba827e55 100644 --- a/extensions/business/cybersec/red_mesh/pentester_api_01.py +++ b/extensions/business/cybersec/red_mesh/pentester_api_01.py @@ -40,6 +40,11 @@ LLM_ANALYSIS_SECURITY_ASSESSMENT, LLM_ANALYSIS_VULNERABILITY_SUMMARY, LLM_ANALYSIS_REMEDIATION_PLAN, + RISK_SEVERITY_WEIGHTS, + RISK_CONFIDENCE_MULTIPLIERS, + RISK_SIGMOID_K, + RISK_CRED_PENALTY_PER, + RISK_CRED_PENALTY_CAP, ) __VER__ = '0.8.2' @@ -954,6 +959,106 @@ def _maybe_close_jobs(self): return + def _compute_risk_score(self, aggregated_report): + """ + Compute a 0-100 risk score from an aggregated scan report. + + The score combines four components: + A. Finding severity (weighted by confidence) + B. Open ports (diminishing returns) + C. Attack surface breadth (distinct protocols) + D. Default credentials penalty + + Parameters + ---------- + aggregated_report : dict + Aggregated report with service_info, web_tests_info, correlation_findings, + open_ports, and port_protocols. + + Returns + ------- + dict + ``{"score": int, "breakdown": dict}`` + """ + import math + + findings_score = 0.0 + finding_counts = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0, "INFO": 0} + cred_count = 0 + + def process_findings(findings_list): + nonlocal findings_score, cred_count + for finding in findings_list: + if not isinstance(finding, dict): + continue + severity = finding.get("severity", "INFO").upper() + confidence = finding.get("confidence", "firm").lower() + weight = RISK_SEVERITY_WEIGHTS.get(severity, 0) + multiplier = RISK_CONFIDENCE_MULTIPLIERS.get(confidence, 0.5) + findings_score += weight * multiplier + if severity in finding_counts: + finding_counts[severity] += 1 + title = finding.get("title", "") + if isinstance(title, str) and "default credential accepted" in title.lower(): + cred_count += 1 + + # A. Iterate service_info findings + service_info = aggregated_report.get("service_info", {}) + for port_key, probes in service_info.items(): + if not isinstance(probes, dict): + continue + for probe_name, probe_data in probes.items(): + if not isinstance(probe_data, dict): + continue + process_findings(probe_data.get("findings", [])) + + # A. Iterate web_tests_info findings + web_tests_info = aggregated_report.get("web_tests_info", {}) + for port_key, tests in web_tests_info.items(): + if not isinstance(tests, dict): + continue + for test_name, test_data in tests.items(): + if not isinstance(test_data, dict): + continue + process_findings(test_data.get("findings", [])) + + # A. Iterate correlation_findings + correlation_findings = aggregated_report.get("correlation_findings", []) + if isinstance(correlation_findings, list): + process_findings(correlation_findings) + + # B. Open ports — diminishing returns: 15 × (1 - e^(-ports/8)) + open_ports = aggregated_report.get("open_ports", []) + nr_ports = len(open_ports) if isinstance(open_ports, list) else 0 + open_ports_score = 15.0 * (1.0 - math.exp(-nr_ports / 8.0)) + + # C. Attack surface breadth — distinct protocols: 10 × (1 - e^(-protocols/4)) + port_protocols = aggregated_report.get("port_protocols", {}) + nr_protocols = len(set(port_protocols.values())) if isinstance(port_protocols, dict) else 0 + breadth_score = 10.0 * (1.0 - math.exp(-nr_protocols / 4.0)) + + # D. Default credentials penalty + credentials_penalty = min(cred_count * RISK_CRED_PENALTY_PER, RISK_CRED_PENALTY_CAP) + + # Raw total + raw_total = findings_score + open_ports_score + breadth_score + credentials_penalty + + # Normalize to 0-100 via logistic curve + score = int(round(100.0 * (2.0 / (1.0 + math.exp(-RISK_SIGMOID_K * raw_total)) - 1.0))) + score = max(0, min(100, score)) + + return { + "score": score, + "breakdown": { + "findings_score": round(findings_score, 1), + "open_ports_score": round(open_ports_score, 1), + "breadth_score": round(breadth_score, 1), + "credentials_penalty": credentials_penalty, + "raw_total": round(raw_total, 1), + "finding_counts": finding_counts, + }, + } + def _maybe_finalize_pass(self): """ Launcher finalizes completed passes and orchestrates continuous monitoring. @@ -1014,6 +1119,15 @@ def _maybe_finalize_pass(self): "reports": {addr: w.get("report_cid") for addr, w in workers.items()} }) + # Compute risk score for this pass + aggregated_for_score = self._collect_aggregated_report(workers) + if aggregated_for_score: + risk_result = self._compute_risk_score(aggregated_for_score) + pass_history[-1]["risk_score"] = risk_result["score"] + pass_history[-1]["risk_breakdown"] = risk_result["breakdown"] + job_specs["risk_score"] = risk_result["score"] + self.P(f"Risk score for job {job_id} pass {job_pass}: {risk_result['score']}/100") + # Handle SINGLEPASS - set FINALIZED and exit (no scheduling) if run_mode == "SINGLEPASS": job_specs["job_status"] = "FINALIZED" From 67d81a637f6d522e97f866d675f4bfa7b37a79a9 Mon Sep 17 00:00:00 2001 From: toderian Date: Sun, 1 Mar 2026 19:42:12 +0000 Subject: [PATCH 23/25] feat: add delete for dev env --- .../cybersec/red_mesh/pentester_api_01.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/extensions/business/cybersec/red_mesh/pentester_api_01.py b/extensions/business/cybersec/red_mesh/pentester_api_01.py index ba827e55..4e709451 100644 --- a/extensions/business/cybersec/red_mesh/pentester_api_01.py +++ b/extensions/business/cybersec/red_mesh/pentester_api_01.py @@ -1767,6 +1767,67 @@ def stop_and_delete_job(self, job_id : str): return {"status": "success", "job_id": job_id} + @BasePlugin.endpoint + def purge_job(self, job_id: str): + """ + Purge a job: delete all R1FS artifacts then tombstone the CStore entry. + Job must be finished/canceled — cannot purge a running job. + + Parameters + ---------- + job_id : str + Identifier of the job to purge. + + Returns + ------- + dict + Status of the purge operation including CID deletion counts. + """ + raw = self.chainstore_hget(hkey=self.cfg_instance_id, key=job_id) + if not isinstance(raw, dict): + return {"status": "error", "message": f"Job {job_id} not found."} + + _, job_specs = self._normalize_job_record(job_id, raw) + + # Reject if job is still running + workers = job_specs.get("workers", {}) + if any(not w.get("finished") for w in workers.values()): + return {"status": "error", "message": "Cannot purge a running job. Stop it first."} + + # Collect all CIDs (deduplicated) + cids = set() + for addr, w in workers.items(): + cid = w.get("report_cid") + if cid: + cids.add(cid) + + for entry in job_specs.get("pass_history", []): + for addr, cid in entry.get("reports", {}).items(): + if cid: + cids.add(cid) + for key in ("llm_analysis_cid", "quick_summary_cid"): + cid = entry.get(key) + if cid: + cids.add(cid) + + # Delete from R1FS (best-effort) + deleted = 0 + for cid in cids: + try: + self.r1fs.delete_file(cid, show_logs=False, raise_on_error=False) + deleted += 1 + except Exception as e: + self.P(f"Failed to delete CID {cid}: {e}", color='y') + + # Tombstone CStore entry + self.chainstore_hset(hkey=self.cfg_instance_id, key=job_id, value=None) + + self.P(f"Purged job {job_id}: {deleted}/{len(cids)} CIDs deleted.") + self._log_audit_event("job_purged", {"job_id": job_id, "cids_deleted": deleted, "cids_total": len(cids)}) + + return {"status": "success", "job_id": job_id, "cids_deleted": deleted, "cids_total": len(cids)} + + @BasePlugin.endpoint def get_report(self, cid: str): """ From fc3b6d09152280e123e5fbd0b07299664f45a7bc Mon Sep 17 00:00:00 2001 From: toderian Date: Sun, 1 Mar 2026 19:52:27 +0000 Subject: [PATCH 24/25] fix: optimize dashboard loading --- extensions/business/cybersec/red_mesh/pentester_api_01.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/extensions/business/cybersec/red_mesh/pentester_api_01.py b/extensions/business/cybersec/red_mesh/pentester_api_01.py index 4e709451..64ef67fc 100644 --- a/extensions/business/cybersec/red_mesh/pentester_api_01.py +++ b/extensions/business/cybersec/red_mesh/pentester_api_01.py @@ -1706,6 +1706,9 @@ def list_network_jobs(self): for job_key, job_spec in raw_network_jobs.items(): normalized_key, normalized_spec = self._normalize_job_record(job_key, job_spec) if normalized_key and normalized_spec: + # Replace heavy pass_history with a lightweight count for listing + pass_history = normalized_spec.pop("pass_history", None) + normalized_spec["pass_count"] = len(pass_history) if isinstance(pass_history, list) else 0 normalized_jobs[normalized_key] = normalized_spec return normalized_jobs From eed09750873a23bcc58a3a7262761493dfa68443 Mon Sep 17 00:00:00 2001 From: toderian Date: Sun, 1 Mar 2026 22:47:16 +0000 Subject: [PATCH 25/25] fix: port 42 detection --- .../business/cybersec/red_mesh/constants.py | 8 +- .../cybersec/red_mesh/service_mixin.py | 283 ++++++++++++++++++ 2 files changed, 288 insertions(+), 3 deletions(-) diff --git a/extensions/business/cybersec/red_mesh/constants.py b/extensions/business/cybersec/red_mesh/constants.py index b076307c..373154f8 100644 --- a/extensions/business/cybersec/red_mesh/constants.py +++ b/extensions/business/cybersec/red_mesh/constants.py @@ -19,6 +19,7 @@ "_service_info_dns", "_service_info_snmp", "_service_info_smb", + "_service_info_wins", "_service_info_generic" ] }, @@ -127,7 +128,7 @@ # Well-known TCP port → protocol (fallback when banner is unrecognized) WELL_KNOWN_PORTS = { 21: "ftp", 22: "ssh", 23: "telnet", 25: "smtp", 42: "wins", - 53: "dns", 80: "http", 81: "http", 110: "pop3", 143: "imap", + 53: "dns", 80: "http", 81: "http", 110: "pop3", 137: "nbns", 143: "imap", 161: "snmp", 443: "https", 445: "smb", 465: "smtp", # SMTPS 502: "modbus", 587: "smtp", 993: "imap", 995: "pop3", # TLS-wrapped mail 1433: "mssql", 3306: "mysql", 3389: "rdp", 5432: "postgresql", @@ -150,7 +151,7 @@ "_service_info_http": frozenset({"http"}), "_service_info_https": frozenset({"https"}), "_service_info_http_alt": frozenset({"http"}), - "_service_info_tls": frozenset({"https", "unknown", "wins"}), + "_service_info_tls": frozenset({"https", "unknown"}), "_service_info_mssql": frozenset({"mssql"}), "_service_info_mysql": frozenset({"mysql"}), "_service_info_rdp": frozenset({"rdp"}), @@ -163,7 +164,8 @@ "_service_info_snmp": frozenset({"snmp"}), "_service_info_smb": frozenset({"smb"}), "_service_info_modbus": frozenset({"modbus"}), - "_service_info_generic": frozenset({"unknown", "wins"}), + "_service_info_wins": frozenset({"wins", "nbns"}), + "_service_info_generic": frozenset({"unknown"}), "_service_info_mysql_creds": frozenset({"mysql"}), "_service_info_postgresql_creds": frozenset({"postgresql"}), } diff --git a/extensions/business/cybersec/red_mesh/service_mixin.py b/extensions/business/cybersec/red_mesh/service_mixin.py index 4e0a74d0..22891003 100644 --- a/extensions/business/cybersec/red_mesh/service_mixin.py +++ b/extensions/business/cybersec/red_mesh/service_mixin.py @@ -2238,6 +2238,289 @@ def _service_info_smb(self, target, port): # default port: 445 return probe_result(raw_data=raw, findings=findings) + # NetBIOS name suffix → human-readable type + _NBNS_SUFFIX_TYPES = { + 0x00: "Workstation", + 0x03: "Messenger (logged-in user)", + 0x20: "File Server (SMB sharing)", + 0x1C: "Domain Controller", + 0x1B: "Domain Master Browser", + 0x1E: "Browser Election Service", + } + + def _service_info_wins(self, target, port): # ports: 42 (WINS/TCP), 137 (NBNS/UDP) + """ + Probe WINS / NetBIOS Name Service for name enumeration and service detection. + + Port 42 (TCP): WINS replication — sends MS-WINSRA Association Start Request + to fingerprint the service and extract NBNS version. Also fires a UDP + side-probe to port 137 for NetBIOS name enumeration. + Port 137 (UDP): NBNS — sends wildcard node-status query (RFC 1002) to + enumerate registered NetBIOS names. + + Parameters + ---------- + target : str + Hostname or IP address. + port : int + Port being probed. + + Returns + ------- + dict + Structured findings. + """ + findings = [] + raw = {"banner": None, "netbios_names": [], "wins_responded": False} + + # -- Build NetBIOS wildcard node-status query (RFC 1002) -- + tid = struct.pack('>H', random.randint(0, 0xFFFF)) + # Flags: 0x0010 (recursion desired) + # Questions: 1, Answers/Auth/Additional: 0 + header = tid + struct.pack('>HHHHH', 0x0010, 1, 0, 0, 0) + # Encoded wildcard name "*" (first-level NetBIOS encoding) + # '*' (0x2A) → half-bytes 0x02, 0x0A → chars 'C','K', padded with 'A' (0x00 half-bytes) + qname = b'\x20' + b'CKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + b'\x00' + # Type: NBSTAT (0x0021), Class: IN (0x0001) + question = struct.pack('>HH', 0x0021, 0x0001) + nbns_query = header + qname + question + + def _parse_nbns_response(data): + """Parse a NetBIOS node-status response and return list of (name, suffix, flags).""" + names = [] + if len(data) < 14: + return names + # Verify transaction ID matches + if data[:2] != tid: + return names + ancount = struct.unpack('>H', data[6:8])[0] + if ancount == 0: + return names + # Skip past header (12 bytes) then answer name (compressed pointer or full) + idx = 12 + if idx < len(data) and data[idx] & 0xC0 == 0xC0: + idx += 2 + else: + while idx < len(data) and data[idx] != 0: + idx += data[idx] + 1 + idx += 1 + # Type (2) + Class (2) + TTL (4) + RDLength (2) = 10 bytes + if idx + 10 > len(data): + return names + idx += 10 + if idx >= len(data): + return names + num_names = data[idx] + idx += 1 + # Each name entry: 15 bytes name + 1 byte suffix + 2 bytes flags = 18 bytes + for _ in range(num_names): + if idx + 18 > len(data): + break + name_bytes = data[idx:idx + 15] + suffix = data[idx + 15] + flags = struct.unpack('>H', data[idx + 16:idx + 18])[0] + name = name_bytes.decode('ascii', errors='ignore').rstrip() + names.append((name, suffix, flags)) + idx += 18 + return names + + def _udp_nbns_probe(udp_port): + """Send UDP NBNS wildcard query, return parsed names or empty list.""" + sock = None + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(3) + sock.sendto(nbns_query, (target, udp_port)) + data, _ = sock.recvfrom(1024) + return _parse_nbns_response(data) + except Exception: + return [] + finally: + if sock is not None: + sock.close() + + def _add_nbns_findings(names, probe_label): + """Populate raw data and findings from enumerated NetBIOS names.""" + raw["netbios_names"] = [ + {"name": n, "suffix": f"0x{s:02X}", "type": self._NBNS_SUFFIX_TYPES.get(s, f"Unknown(0x{s:02X})")} + for n, s, _f in names + ] + name_list = "; ".join( + f"{n} <{s:02X}> ({self._NBNS_SUFFIX_TYPES.get(s, 'unknown')})" + for n, s, _f in names + ) + findings.append(Finding( + severity=Severity.HIGH, + title="NetBIOS name enumeration successful", + description=( + f"{probe_label} responded to a wildcard node-status query, " + "leaking computer name, domain membership, and potentially logged-in users." + ), + evidence=f"Names: {name_list[:200]}", + remediation="Block UDP port 137 at the firewall; disable NetBIOS over TCP/IP in network adapter settings.", + owasp_id="A01:2021", + cwe_id="CWE-200", + confidence="certain", + )) + findings.append(Finding( + severity=Severity.INFO, + title=f"NetBIOS names discovered ({len(names)} entries)", + description=f"Enumerated names: {name_list}", + evidence=f"Names: {name_list[:300]}", + confidence="certain", + )) + + try: + if port == 137: + # -- Direct UDP NBNS probe -- + names = _udp_nbns_probe(137) + if names: + raw["banner"] = f"NBNS: {len(names)} name(s) enumerated" + _add_nbns_findings(names, f"NBNS on {target}:{port}") + else: + raw["banner"] = "NBNS port open (no response to wildcard query)" + findings.append(Finding( + severity=Severity.INFO, + title="NBNS port open but no names returned", + description=f"UDP port {port} on {target} did not respond to NetBIOS wildcard query.", + confidence="tentative", + )) + else: + # -- TCP WINS replication probe (MS-WINSRA Association Start Request) -- + # Also attempt UDP NBNS side-probe to port 137 for name enumeration + names = _udp_nbns_probe(137) + if names: + _add_nbns_findings(names, f"NBNS side-probe to {target}:137") + + # Build MS-WINSRA Association Start Request per [MS-WINSRA] §2.2.3: + # Common Header (16 bytes): + # Packet Length: 41 (0x00000029) — excludes this field + # Reserved: 0x00007800 (opcode, ignored by spec) + # Destination Assoc Handle: 0x00000000 (first message, unknown) + # Message Type: 0x00000000 (Association Start Request) + # Body (25 bytes): + # Sender Assoc Handle: random 4 bytes + # NBNS Major Version: 2 (required) + # NBNS Minor Version: 5 (Win2k+) + # Reserved: 21 zero bytes (pad to 41) + sender_ctx = random.randint(1, 0xFFFFFFFF) + wrepl_header = struct.pack('>I', 41) # Packet Length + wrepl_header += struct.pack('>I', 0x00007800) # Reserved / opcode + wrepl_header += struct.pack('>I', 0) # Destination Assoc Handle + wrepl_header += struct.pack('>I', 0) # Message Type: Start Request + wrepl_body = struct.pack('>I', sender_ctx) # Sender Assoc Handle + wrepl_body += struct.pack('>HH', 2, 5) # Major=2, Minor=5 + wrepl_body += b'\x00' * 21 # Reserved padding + wrepl_packet = wrepl_header + wrepl_body + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(3) + sock.connect((target, port)) + sock.sendall(wrepl_packet) + + # Distinguish three recv outcomes: + # data received → parse as WREPL (confirmed WINS) + # timeout → connection held open, no reply (likely WINS, non-partner) + # empty / closed → server sent FIN immediately (unconfirmed service) + data = None + recv_timed_out = False + try: + data = sock.recv(1024) + except socket.timeout: + recv_timed_out = True + finally: + sock.close() + + if data and len(data) >= 20: + raw["wins_responded"] = True + # Parse response: first 4 bytes = Packet Length, next 16 = common header + resp_msg_type = struct.unpack('>I', data[12:16])[0] if len(data) >= 16 else None + version_info = "" + if resp_msg_type == 1 and len(data) >= 24: + # Association Start Response — extract version + resp_major = struct.unpack('>H', data[20:22])[0] if len(data) >= 22 else None + resp_minor = struct.unpack('>H', data[22:24])[0] if len(data) >= 24 else None + if resp_major is not None: + version_info = f" (NBNS version {resp_major}.{resp_minor})" + raw["nbns_version"] = {"major": resp_major, "minor": resp_minor} + raw["banner"] = f"WINS replication service{version_info}" + findings.append(Finding( + severity=Severity.MEDIUM, + title="WINS replication service exposed", + description=( + f"WINS on {target}:{port} responded to a WREPL Association Start Request{version_info}. " + "WINS is a legacy name-resolution service vulnerable to spoofing, enumeration, and " + "multiple remote code execution flaws (CVE-2004-1080, CVE-2009-1923, CVE-2009-1924). " + "It should not be accessible from untrusted networks." + ), + evidence=f"WREPL response ({len(data)} bytes): {data[:24].hex()}", + remediation=( + "Decommission WINS or restrict TCP port 42 to trusted replication partners. " + "If WINS is required, apply all patches (MS04-045, MS09-039) and set the registry key " + "RplOnlyWCnfPnrs=1 to accept replication only from configured partners." + ), + owasp_id="A01:2021", + cwe_id="CWE-284", + confidence="certain", + )) + elif data: + # Got some data but not enough for a valid WREPL response + raw["wins_responded"] = True + raw["banner"] = f"Port {port} responded ({len(data)} bytes, non-WREPL)" + findings.append(Finding( + severity=Severity.LOW, + title=f"Service on port {port} responded but is not standard WINS", + description=( + f"TCP port {port} on {target} returned data that does not match the " + "WINS replication protocol (MS-WINSRA). Another service may be listening." + ), + evidence=f"Response ({len(data)} bytes): {data[:32].hex()}", + confidence="tentative", + )) + elif recv_timed_out: + # Connection accepted AND held open after our WREPL packet, but no + # reply — consistent with WINS silently dropping a non-partner request + # (RplOnlyWCnfPnrs=1). A non-WINS service would typically RST or FIN. + raw["banner"] = "WINS likely (connection held, no WREPL reply)" + findings.append(Finding( + severity=Severity.MEDIUM, + title="WINS replication port open (non-partner rejected)", + description=( + f"TCP port {port} on {target} accepted a WREPL Association Start Request " + "and held the connection open without responding, consistent with a WINS " + "server configured to reject non-partner replication (RplOnlyWCnfPnrs=1). " + "An exposed WINS port is a legacy attack surface subject to remote code " + "execution flaws (CVE-2004-1080, CVE-2009-1923, CVE-2009-1924)." + ), + evidence="TCP connection accepted and held open; WREPL handshake: no reply after 3 s", + remediation=( + "Block TCP port 42 at the firewall if WINS replication is not needed. " + "If required, restrict to trusted replication partners only." + ), + owasp_id="A01:2021", + cwe_id="CWE-284", + confidence="firm", + )) + else: + # recv returned empty — server immediately closed the connection. + # Cannot confirm WINS; don't produce a finding. The port scan + # already reports the open port; a "service unconfirmed" finding + # adds no actionable value to the report. + pass + except Exception as e: + return probe_error(target, port, "WINS/NBNS", e) + + if not findings: + # Could not confirm WINS — downgrade the protocol label so the UI + # does not display an unverified "WINS" tag from WELL_KNOWN_PORTS. + port_protocols = self.state.get("port_protocols") + if port_protocols and port_protocols.get(port) in ("wins", "nbns"): + port_protocols[port] = "unknown" + return None + + return probe_result(raw_data=raw, findings=findings) + + def _service_info_vnc(self, target, port): # default port: 5900 """ VNC handshake: read version banner, negotiate security types.