diff --git a/docker/docker-compose.demo.yml b/docker/docker-compose.demo.yml index 2e901d9..439d862 100644 --- a/docker/docker-compose.demo.yml +++ b/docker/docker-compose.demo.yml @@ -1,10 +1,14 @@ services: juice-shop: - image: bkimminich/juice-shop + image: bkimminich/juice-shop:v20.2.0 ports: - - "3000:3000" + - "127.0.0.1:3000:3000" networks: - demo + cap_drop: + - ALL + security_opt: + - no-new-privileges:true healthcheck: test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000"] interval: 10s @@ -23,8 +27,15 @@ services: depends_on: juice-shop: condition: service_healthy - # Nmap requires elevated privileges for SYN scans and OS detection. - privileged: true + # Grant only the network capabilities Nmap needs instead of full host-like + # container privilege. + cap_drop: + - ALL + cap_add: + - NET_ADMIN + - NET_RAW + security_opt: + - no-new-privileges:true networks: demo: diff --git a/requirements.txt b/requirements.txt index 77ceef4..348a5cd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ pydantic>=2.5 python-nmap>=0.7.1 -requests>=2.31 -cryptography>=42.0 -jinja2>=3.1 +requests>=2.32.4 +cryptography>=49.0.0 +jinja2>=3.1.6 plotly>=5.18 # dev diff --git a/sepulchrynscan/checks.py b/sepulchrynscan/checks.py index 6a10b15..acc4d25 100644 --- a/sepulchrynscan/checks.py +++ b/sepulchrynscan/checks.py @@ -9,6 +9,7 @@ from __future__ import annotations +import ipaddress import re import socket import ssl @@ -22,6 +23,39 @@ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) +_TARGET_SESSION = requests.Session() +# Scans must never inherit proxy settings or .netrc credentials and send them +# to an untrusted target. +_TARGET_SESSION.trust_env = False +_MAX_HTTP_BODY_BYTES = 64 * 1024 +_HTTP_CHUNK_BYTES = 8 * 1024 + + +def _url_host(host: str) -> str: + """Bracket IPv6 literals when constructing an HTTP URL.""" + try: + return f"[{host}]" if ipaddress.ip_address(host).version == 6 else host + except ValueError: + return host + + +def _read_limited_text(response: requests.Response) -> str: + """Decode at most the configured number of response-body bytes.""" + body = bytearray() + for chunk in response.iter_content(chunk_size=_HTTP_CHUNK_BYTES): + if not chunk: + continue + if isinstance(chunk, str): + chunk = chunk.encode(response.encoding or "utf-8", errors="replace") + remaining = _MAX_HTTP_BODY_BYTES - len(body) + if remaining <= 0: + break + body.extend(chunk[:remaining]) + if len(body) >= _MAX_HTTP_BODY_BYTES: + break + return bytes(body).decode(response.encoding or "utf-8", errors="replace") + + # --------------------------------------------------------------------------- # HTTP security headers # --------------------------------------------------------------------------- @@ -38,17 +72,22 @@ def _fetch_headers(host_ip: str, port: int) -> dict[str, str] | None: """Try HTTPS then HTTP; return response headers dict or None.""" for scheme in ("https", "http"): + response: requests.Response | None = None try: - url = f"{scheme}://{host_ip}:{port}/" - resp = requests.get( + url = f"{scheme}://{_url_host(host_ip)}:{port}/" + response = _TARGET_SESSION.get( url, timeout=5, - verify=False, - allow_redirects=True, + verify=False, # noqa: S501 - invalid certificates are scan data + allow_redirects=False, + stream=True, ) - return dict(resp.headers) - except Exception: + return dict(response.headers) + except requests.RequestException: continue + finally: + if response is not None: + response.close() return None @@ -175,7 +214,7 @@ def _check_tls(host_ip: str, port: int) -> list[Finding]: port=port, ) ) - except Exception: + except (OSError, ValueError): # Service does not speak TLS or is unreachable — skip silently pass @@ -263,13 +302,18 @@ def admin_panels(host: Host) -> list[Finding]: for scheme in ("https", "http"): found = False for path in _ADMIN_PATHS: + response: requests.Response | None = None try: - url = f"{scheme}://{host.ip}:{svc.port}{path}" - resp = requests.get( - url, timeout=5, verify=False, allow_redirects=True + url = f"{scheme}://{_url_host(host.ip)}:{svc.port}{path}" + response = _TARGET_SESSION.get( + url, + timeout=5, + verify=False, # noqa: S501 - invalid certificates are scan data + allow_redirects=False, + stream=True, ) - if resp.status_code == 200: - text = resp.text.lower() + if response.status_code == 200: + text = _read_limited_text(response).lower() hits = sum(1 for m in _ADMIN_MARKERS if m in text) if hits >= 2: findings.append( @@ -288,8 +332,11 @@ def admin_panels(host: Host) -> list[Finding]: ) found = True break - except Exception: + except requests.RequestException: continue + finally: + if response is not None: + response.close() if found: break return findings diff --git a/sepulchrynscan/cli.py b/sepulchrynscan/cli.py index 2d2566e..45c01eb 100644 --- a/sepulchrynscan/cli.py +++ b/sepulchrynscan/cli.py @@ -11,6 +11,7 @@ import argparse import ipaddress import os +import re import subprocess import sys import time @@ -23,6 +24,9 @@ from . import checks, config, cve, db, diff, discovery, report from .models import Scan, ScanStatus +_HOSTNAME_LABEL_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$") +_NMAP_IPV4_RANGE_RE = re.compile(r"^\d{1,3}(?:-\d{1,3})?(?:\.\d{1,3}(?:-\d{1,3})?){3}$") + def _uuid() -> str: return uuid.uuid4().hex[:12] @@ -39,20 +43,54 @@ def load_allowlist(path: Path | None = None) -> list[str]: ] +def _valid_scan_target(target: str) -> bool: + """Accept one IP, CIDR, or DNS hostname—not arbitrary Nmap syntax.""" + if not target or any(char.isspace() for char in target): + return False + + try: + ipaddress.ip_network(target, strict=False) + return True + except ValueError: + pass + + # Nmap treats compact forms such as 192.168.1-2.3 as address ranges, + # although the same characters are legal in a DNS label. + if "-" in target and _NMAP_IPV4_RANGE_RE.fullmatch(target): + return False + + hostname = target[:-1] if target.endswith(".") else target + try: + ascii_hostname = hostname.encode("idna").decode("ascii") + except UnicodeError: + return False + return 0 < len(ascii_hostname) <= 253 and all( + _HOSTNAME_LABEL_RE.fullmatch(label) for label in ascii_hostname.split(".") + ) + + def target_allowed(target: str, allowlist: list[str]) -> bool: """Return True if `target` matches any allowlist entry. Matching rules: - - Exact string match (hostnames, URLs). + - Exact string match (hostnames). - If entry is a CIDR and target is an IP, check IP ∈ network. - If entry is an IP and target is the same IP, match. + + Targets that contain whitespace, options, URLs, or other Nmap expression + syntax are rejected before allowlist matching. ``python-nmap`` tokenizes + its ``hosts`` value with ``shlex``, so accepting those strings would let a + target alter the Nmap command rather than name a single authorized scope. """ t = target.strip() + if not _valid_scan_target(t): + return False for entry in allowlist: - if entry == t: + candidate = entry.strip() + if candidate == t: return True try: - net = ipaddress.ip_network(entry, strict=False) + net = ipaddress.ip_network(candidate, strict=False) try: ip = ipaddress.ip_address(t) if ip in net: @@ -122,8 +160,16 @@ def _cmd_demo(_: argparse.Namespace) -> int: # Start the demo target (juice-shop only; scanner service is optional) print("starting demo target ...") - result = subprocess.run( - ["docker", "compose", "-f", str(compose_file), "up", "-d", "juice-shop"], + result = subprocess.run( # noqa: S603 - fixed command and local compose path + [ # noqa: S607 - Docker is intentionally resolved from the user's PATH + "docker", + "compose", + "-f", + str(compose_file), + "up", + "-d", + "juice-shop", + ], capture_output=True, text=True, ) @@ -205,7 +251,7 @@ def build_parser() -> argparse.ArgumentParser: s = sub.add_parser("scan", help="run a scan against a target") s.add_argument( - "target", help="IP, CIDR, hostname, or URL (must be in targets.allowlist)" + "target", help="IP, CIDR, or hostname (must be in targets.allowlist)" ) s.add_argument( "--offline", diff --git a/sepulchrynscan/config.py b/sepulchrynscan/config.py index 19076e5..51df1da 100644 --- a/sepulchrynscan/config.py +++ b/sepulchrynscan/config.py @@ -32,6 +32,7 @@ NMAP_TOP_PORTS = 1000 NMAP_ARGS = f"-sV --top-ports {NMAP_TOP_PORTS} --script vulners" +NMAP_TIMEOUT_SEC = 900 SEVERITY_WEIGHTS = { "Critical": 4.0, diff --git a/sepulchrynscan/discovery.py b/sepulchrynscan/discovery.py index 843d497..3637326 100644 --- a/sepulchrynscan/discovery.py +++ b/sepulchrynscan/discovery.py @@ -30,8 +30,12 @@ def run(target: str, arguments: str | None = None) -> list[Host]: scanner = nmap.PortScanner() nmap_args = arguments or config.NMAP_ARGS try: - scanner.scan(hosts=target, arguments=nmap_args) - except nmap.PortScannerError as exc: + scanner.scan( + hosts=target, + arguments=nmap_args, + timeout=config.NMAP_TIMEOUT_SEC, + ) + except (nmap.PortScannerError, nmap.PortScannerTimeout) as exc: warnings.warn(f"Nmap scan failed for {target}: {exc}") return [] diff --git a/sepulchrynscan/report.py b/sepulchrynscan/report.py index d3cda55..010a727 100644 --- a/sepulchrynscan/report.py +++ b/sepulchrynscan/report.py @@ -144,7 +144,7 @@ def render(scan: Scan, out_dir: Path) -> tuple[Path, Path]: scan=scan, findings=scan.findings, hosts=scan.hosts, - scan_json=scan.model_dump_json(), + scan_json=scan.model_dump(mode="json"), ), encoding="utf-8", ) diff --git a/sepulchrynscan/templates/executive.html b/sepulchrynscan/templates/executive.html index ad0dba6..152f677 100644 --- a/sepulchrynscan/templates/executive.html +++ b/sepulchrynscan/templates/executive.html @@ -179,9 +179,9 @@