diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..1212e3498 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +hexstrike_env/ +hexstrike-env/ +.git/ +*.log +*.jsonl +*.pid +__pycache__/ +*.pyc +.DS_Store +scope.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..72030bfa2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.pyc +hexstrike-env/ +hexstrike_env/ +*.log +*.jsonl +*.pid +.DS_Store +scope.json +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..a2e705003 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,156 @@ +FROM kalilinux/kali-rolling + +LABEL maintainer="thesaint" +LABEL description="HexStrike AI MCP server, hardened + containerized for isolated per-engagement use" + +ENV DEBIAN_FRONTEND=noninteractive +ENV PYTHONUNBUFFERED=1 + +# ── Security tools ─────────────────────────────────────────────────────── +# Kali's apt repos cover most of the "Core Tools (Essential)" set from the +# README directly. This is NOT the full 150+ tool list (many of the +# ProjectDiscovery/Go-based recon tools — nuclei, subfinder, httpx, katana, +# dalfox, ffuf — aren't in apt and need `go install`; add them below in the +# "Go-based tools" block as needed). Extend this list rather than installing +# tools ad-hoc inside a running container, so the image stays reproducible. +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 python3-pip python3-venv python3-dev build-essential \ + nmap masscan \ + gobuster nikto whatweb wafw00f sslscan \ + sqlmap wpscan \ + hydra john hashcat medusa \ + smbmap enum4linux responder \ + radare2 binwalk foremost steghide exiftool checksec \ + golang-go libcap2-bin \ + git curl wget ca-certificates \ + arsenal-ng gef wpprobe xsstrike sstimap python3-atomic-operator \ + amass feroxbuster python3-impacket netexec certipy-ad \ + && rm -rf /var/lib/apt/lists/* + +# RustScan isn't in Kali's apt repo (checked the live index directly) — +# it ships prebuilt releases on GitHub instead, as rustscan.deb.zip (a +# .deb wrapped in a zip, not a raw .deb — worth knowing, the naming +# isn't what you'd guess). Using GitHub's stable /latest/download/ redirect +# rather than the releases API, which is rate-limited per-IP and this +# build already got throttled by it once this session. python3 (already +# installed above) handles the unzip so this doesn't need a new apt +# package just for one archive. +RUN curl -sL https://github.com/RustScan/RustScan/releases/latest/download/rustscan.deb.zip -o /tmp/rustscan.zip \ + && python3 -m zipfile -e /tmp/rustscan.zip /tmp/rustscan_extracted/ \ + && dpkg -i /tmp/rustscan_extracted/*.deb \ + && rm -rf /tmp/rustscan.zip /tmp/rustscan_extracted + +# ── Go-based tools (ProjectDiscovery etc.) ────────────────────────────── +# Each install is capped at 3min and non-fatal: a single stalled module +# proxy fetch shouldn't block the whole image build. Missing tools are +# reported in the build log — rerun `docker build` later to retry them, +# or add more here with the same pattern. +ENV GOPATH=/opt/go +# /opt/go/bin goes FIRST, not appended. apt/pip install same-named CLIs for +# unrelated tools (pip's httpx[cli] HTTP client vs. ProjectDiscovery's httpx +# recon scanner is the concrete case that shipped broken — appended-PATH +# order let the pip shim silently shadow the real binary). Putting our Go +# tools first makes every `go install` binary in this list authoritative, +# not just httpx. +ENV PATH=/opt/go/bin:$PATH +ENV GOPROXY=https://proxy.golang.org,direct +RUN --mount=type=cache,target=/root/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + for pkg in \ + "github.com/projectdiscovery/httpx/cmd/httpx" \ + "github.com/ffuf/ffuf/v2" \ + "github.com/projectdiscovery/subfinder/v2/cmd/subfinder" \ + "github.com/projectdiscovery/nuclei/v3/cmd/nuclei" \ + "github.com/tomnomnom/waybackurls" \ + ; do \ + echo "→ go install $pkg"; \ + timeout 300 go install "${pkg}@latest" 2>&1 | tail -20 || echo "⚠️ SKIPPED (timeout/failed): $pkg"; \ + done + +# ── Non-root execution ─────────────────────────────────────────────────── +# Run as an unprivileged user; grant raw-socket capabilities directly to +# the two binaries that need them (SYN scans, packet crafting) instead of +# running the whole container as root or with --privileged. +RUN useradd -m -u 1000 -s /bin/bash hexstrike && \ + setcap cap_net_raw,cap_net_admin+eip /usr/bin/nmap && \ + setcap cap_net_raw,cap_net_admin+eip /usr/bin/masscan + +# gef (GDB Enhanced Features): the apt package only drops gef.py at +# /usr/share/gdb/gef.py, it doesn't wire it into gdb's startup — normally +# gef's own installer appends a `source` line to ~/.gdbinit, apt doesn't. +RUN echo "source /usr/share/gdb/gef.py" >> /etc/gdb/gdbinit + +# amass's apt wrapper (/usr/bin/amass) checks for libpostal's data dir and, +# if missing, shells out to `sudo libpostal_data download all` — which +# fails outright under the non-root hexstrike user, and even as root would +# pull ~1-2GB just for one optional address-normalization feature amass +# barely uses (its actual recon functionality — DNS enum, ASN lookups, +# cert-transparency, subdomain brute-force — doesn't touch libpostal at +# all). Satisfying the wrapper's existence check with an empty sentinel +# avoids both the sudo failure and the multi-GB download; only the +# address-parsing-specific amass features would be affected, not the +# ones this is actually installed for. +RUN mkdir -p /var/lib/libpostal && touch /usr/share/libpostal/transliteration + +WORKDIR /app +COPY requirements.txt . +# venv, not system pip — Kali ships several Python packages (bcrypt etc.) +# as dpkg-managed system packages that pip can't safely uninstall/upgrade +# even with --break-system-packages (PEP 668). Isolating into a venv +# avoids that whole conflict class. +# +# IMPORTANT: deliberately NOT prepending /opt/venv/bin to the global PATH +# here (as an earlier version of this file did). Several apt-packaged +# tools (confirmed: impacket-secretsdump and friends) ship thin shell +# wrappers that call bare `python3` rather than an absolute path — with +# venv-first PATH, those silently resolve to THIS isolated venv instead of +# system python3, and since venvs don't see system dist-packages, they +# fail with ModuleNotFoundError for a library that's actually installed +# and present. Root-caused by hand: verified the wrapper's shebang/exec +# line, verified the library was genuinely on disk, verified system +# python3's sys.path included dist-packages, isolated it to the PATH +# shadowing. Fix: reference the venv by absolute path (/opt/venv/bin/pip, +# /opt/venv/bin/python3 in entrypoint.sh) everywhere WE need it, and leave +# bare `python3` on PATH resolving to the system interpreter so every +# other apt-packaged tool's wrapper keeps working as its maintainer +# intended. This likely isn't impacket-specific — any Kali tool with a +# similar wrapper pattern was at risk before this fix. +RUN python3 -m venv /opt/venv +RUN /opt/venv/bin/pip install --no-cache-dir -r requirements.txt +# xsstrike pulls this in at runtime on first use if missing — bake it in +# so a per-engagement container doesn't need outbound pip access mid-test. +RUN /opt/venv/bin/pip install --no-cache-dir fuzzywuzzy +# atomic-operator itself comes from apt (python3-atomic-operator, above) — +# NOT pip. Its dependency chain (atomic-operator-runner pins pydantic 1.x; +# its other dependency `fire` still imports the stdlib `pipes` module, +# removed in Python 3.13/Kali-rolling's default python3 — a known, +# still-open upstream bug, google/python-fire#444) would conflict badly +# with mcp/fastmcp's pydantic 2.x requirement if installed into this venv +# via pip — confirmed by hand, it silently downgrades pydantic and breaks +# hexstrike_mcp.py's imports at runtime. Kali's own apt packaging already +# resolves both problems correctly (confirmed by hand: apt installs into +# system dist-packages, completely separate from this venv, and imports +# clean with no patching needed) — apt is the correct install path here, +# not pip. + +COPY hexstrike_server.py hexstrike_mcp.py scope.example.json ./ +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# /tmp is where the server's existing tool-output code writes by default +# (hexstrike_files, autorecon_*, prowler_* etc — see FileOperationsManager +# and per-tool output_dir defaults). Bind-mounting the HOST's per-engagement +# directory over this container's /tmp is what gives each engagement its +# own isolated, inspectable output — see hex-engage in .zshrc. +RUN mkdir -p /tmp/hexstrike_files && chown -R hexstrike:hexstrike /app /tmp/hexstrike_files + +USER hexstrike +EXPOSE 8888 + +# NOTE: binding 0.0.0.0 here is intentional and NOT a regression of the +# 127.0.0.1-only rule from the bare-metal setup. Inside Docker's isolated +# network namespace, 0.0.0.0 just means "listen on this container's +# interface" — actual host/LAN exposure is controlled entirely by the +# `-p` flag on `docker run`. hex-engage always maps `-p 127.0.0.1:PORT:8888`, +# so the net effect on the host is identical: loopback-only. +ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md index eeb3b3e85..4f3ab62fd 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,10 @@ sudo apt update && sudo apt install google-chrome-stable ### Start the Server ```bash -# Start the MCP server +# Required: set an auth token — the server refuses to start without one +export HEXSTRIKE_API_TOKEN=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))") + +# Start the MCP server (loopback-only by default) python3 hexstrike_server.py # Optional: Start with debug mode @@ -198,20 +201,56 @@ python3 hexstrike_server.py --debug # Optional: Custom port configuration python3 hexstrike_server.py --port 8888 + +# Optional: allowlist authorized engagement targets (see scope.example.json) +export HEXSTRIKE_SCOPE_FILE=/path/to/scope.json ``` ### Verify Installation ```bash -# Test server health -curl http://localhost:8888/health +# Test server liveness (fast, no tool sweep) +curl -H "X-HexStrike-Token: $HEXSTRIKE_API_TOKEN" http://localhost:8888/ping + +# Test server health (full tool-availability sweep, ~30s) +curl -H "X-HexStrike-Token: $HEXSTRIKE_API_TOKEN" http://localhost:8888/health # Test AI agent capabilities curl -X POST http://localhost:8888/api/intelligence/analyze-target \ -H "Content-Type: application/json" \ + -H "X-HexStrike-Token: $HEXSTRIKE_API_TOKEN" \ -d '{"target": "example.com", "analysis_type": "comprehensive"}' ``` +### Docker (recommended for real engagements) + +The included `Dockerfile` builds a self-contained image (Kali base + the core tool set + the Go-based recon tools) and isolates the server from your host filesystem. This is the safer path if you're pointing this at anything beyond your own lab — the server executes arbitrary commands (`/api/command`), so contain the blast radius rather than running it bare-metal. + +```bash +docker build -t hexstrike-ai:latest . +``` + +Run one container per engagement, each with its own scope file and its own output directory, so concurrent engagements can't cross-contaminate and every engagement has an inspectable audit trail: + +```bash +mkdir -p ./engagements/acme-corp/workspace +cp scope.example.json ./engagements/acme-corp/scope.json # edit: authorized domains/CIDRs only + +docker run -d --name hexstrike-acme-corp \ + -p 127.0.0.1:8888:8888 \ + --cap-add=NET_RAW --cap-add=NET_ADMIN \ + -v "$(pwd)/engagements/acme-corp/workspace:/tmp/hexstrike_files" \ + -v "$(pwd)/engagements/acme-corp/scope.json:/app/scope.json:ro" \ + -e HEXSTRIKE_API_TOKEN="$HEXSTRIKE_API_TOKEN" \ + -e HEXSTRIKE_SCOPE_FILE=/app/scope.json \ + -e HEXSTRIKE_AUDIT_LOG=/tmp/hexstrike_files/audit.jsonl \ + hexstrike-ai:latest +``` + +Tool output and the audit log both land in `./engagements/acme-corp/workspace` on the host — inspectable, and gone (well, archived, not scanning) as soon as you `docker rm` the container. `--cap-add=NET_RAW --cap-add=NET_ADMIN` is what lets `nmap`/`masscan` do raw-socket scans (SYN scans etc.) despite the container running as a non-root user — the image doesn't grant that capability container-wide, it's set directly on those two binaries via `setcap`. + +The `0.0.0.0` bind you'll see if you inspect the image is intentional and not a contradiction of the loopback-only default described above — Docker's network namespace means it's harmless in isolation; the `-p 127.0.0.1:8888:8888` mapping is what actually determines host-level exposure, and that's loopback-only here too. + --- ## AI Client Integration Setup @@ -507,6 +546,7 @@ Configure VS Code settings in `.vscode/settings.json`: | Endpoint | Method | Description | |----------|--------|-------------| | `/health` | GET | Server health check with tool availability | +| `/ping` | GET | Lightweight liveness check (no tool sweep, instant) | | `/api/command` | POST | Execute arbitrary commands with caching | | `/api/telemetry` | GET | System performance metrics | | `/api/cache/stats` | GET | Cache performance statistics | @@ -647,7 +687,20 @@ python3 hexstrike_mcp.py --debug - Run in isolated environments or dedicated security testing VMs - AI agents can execute arbitrary security tools - ensure proper oversight - Monitor AI agent activities through the real-time dashboard -- Consider implementing authentication for production deployments + +### Built-in Hardening + +The server ships with the following on by default or available via env vars — set these up before pointing this at anything real: + +| Control | Env Var | Default | Notes | +|---|---|---|---| +| Bind address | `HEXSTRIKE_HOST` / `--host` | `127.0.0.1` | Server **refuses to run open on `0.0.0.0`** unless you explicitly pass a different `--host`. Loopback-only by default. | +| Shared-secret auth | `HEXSTRIKE_API_TOKEN` | **required** | Server exits at startup if unset — it will not run unauthenticated. Every route (including `/health`, `/ping`) requires header `X-HexStrike-Token: `. Generate with `python3 -c "import secrets; print(secrets.token_urlsafe(32))"`. | +| Liveness check | — | `/ping` | Lightweight, instant. `/health` does a full ~30s tool-availability sweep — use `/ping` for polling/monitoring, `/health` for diagnostics. | +| Engagement scope | `HEXSTRIKE_SCOPE_FILE` | off (opt-in) | Points to a JSON file (`{"domains": [...], "networks": ["CIDR", ...]}` — see `scope.example.json`) allowlisting authorized targets. Requests referencing an out-of-scope host — via structured params **or** parsed out of a raw command string — get `403`. Set this per engagement; it is the single most important control if you're running this against client-owned infrastructure. | +| Audit log | `HEXSTRIKE_AUDIT_LOG` | `hexstrike_audit.jsonl` | JSONL, one line per authenticated request: timestamp, source IP, method, path, extracted targets, response status. Keep it for engagement records. | + +None of this replaces running the server on isolated infrastructure — it reduces the blast radius of the server being reachable or misused, it doesn't sandbox the 150+ tools it invokes. ### Legal & Ethical Use diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 000000000..a27cbc8b7 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -euo pipefail + +# HEXSTRIKE_API_TOKEN is validated by the server itself (fails closed at +# import time if unset) — no need to duplicate that check here. This just +# gives a clearer error before Python even starts, and reports the scope +# state so a misconfigured engagement is obvious from `docker logs`. +if [ -z "${HEXSTRIKE_API_TOKEN:-}" ]; then + echo "❌ HEXSTRIKE_API_TOKEN not set — refusing to start. Pass it via -e HEXSTRIKE_API_TOKEN=..." >&2 + exit 1 +fi + +if [ -z "${HEXSTRIKE_SCOPE_FILE:-}" ]; then + echo "⚠️ HEXSTRIKE_SCOPE_FILE not set — scope enforcement is OFF. Do not point this at a real client engagement without it." +else + echo "🎯 Scope enforcement active: ${HEXSTRIKE_SCOPE_FILE}" +fi + +exec /opt/venv/bin/python3 hexstrike_server.py --host 0.0.0.0 --port 8888 diff --git a/hexstrike_mcp.py b/hexstrike_mcp.py index 23b083b47..c49020f99 100644 --- a/hexstrike_mcp.py +++ b/hexstrike_mcp.py @@ -159,14 +159,27 @@ def __init__(self, server_url: str, timeout: int = DEFAULT_REQUEST_TIMEOUT): self.timeout = timeout self.session = requests.Session() + # The server has required auth (X-HexStrike-Token) since the + # security-hardening work — this client predates that and never + # sent it, meaning every single request would 401 silently. + # Fail-closed here rather than sending unauthenticated requests + # that are guaranteed to fail, matching the server's own posture. + api_token = os.environ.get("HEXSTRIKE_API_TOKEN") + if not api_token: + logger.error("❌ HEXSTRIKE_API_TOKEN is not set — the server will reject every request. Set it in the MCP client's env config.") + else: + self.session.headers.update({"X-HexStrike-Token": api_token}) + # Try to connect to server with retries connected = False for i in range(MAX_RETRIES): try: logger.info(f"🔗 Attempting to connect to HexStrike AI API at {server_url} (attempt {i+1}/{MAX_RETRIES})") - # First try a direct connection test before using the health endpoint + # /ping, not /health — health runs a ~30s full tool-availability + # sweep, which would stall every MCP client startup for no + # reason; /ping is the fast liveness check for exactly this. try: - test_response = self.session.get(f"{self.server_url}/health", timeout=5) + test_response = self.session.get(f"{self.server_url}/ping", timeout=5) test_response.raise_for_status() health_check = test_response.json() connected = True diff --git a/hexstrike_server.py b/hexstrike_server.py index baa5db420..12b792bfb 100644 --- a/hexstrike_server.py +++ b/hexstrike_server.py @@ -19,6 +19,8 @@ """ import argparse +import hmac +import ipaddress import json import logging import os @@ -44,6 +46,7 @@ import signal import requests import re +import shlex import socket import urllib.parse from dataclasses import dataclass, field @@ -94,6 +97,184 @@ app = Flask(__name__) app.config['JSON_SORT_KEYS'] = False +# ── Shared-secret auth gate ───────────────────────────────────────────── +# HexStrike executes arbitrary commands/code on this host (/api/command, +# /api/python/execute, etc). This tool is for authorized penetration +# testing / ethical hacking use only — it must never accept requests it +# can't authenticate, so the server refuses to start without a token +# rather than silently running open. +HEXSTRIKE_API_TOKEN = os.environ.get("HEXSTRIKE_API_TOKEN") +if not HEXSTRIKE_API_TOKEN: + print("FATAL: HEXSTRIKE_API_TOKEN is not set. Refusing to start unauthenticated.") + print("Set it in your shell env (e.g. ~/.zshrc_secrets) before running this server.") + sys.exit(1) + +@app.before_request +def _require_hexstrike_token(): + supplied = request.headers.get("X-HexStrike-Token", "") + if not hmac.compare_digest(supplied, HEXSTRIKE_API_TOKEN): + return jsonify({"error": "unauthorized"}), 401 + +# ── Engagement scope enforcement ──────────────────────────────────────── +# This tool is for authorized penetration testing / ethical hacking only. +# Without a scope check, an AI agent chaining tool calls (or content it +# scraped that contains injected instructions) can drift a target outside +# the client's written authorization — that's the #1 real-world liability +# risk for a solo pentester, not a code bug. Enforcement is opt-in via +# HEXSTRIKE_SCOPE_FILE so existing labs/CTF use isn't broken by default, +# but any real client engagement should set it. +HEXSTRIKE_SCOPE_FILE = os.environ.get("HEXSTRIKE_SCOPE_FILE") +_SCOPE_DOMAINS = set() +_SCOPE_NETWORKS = [] + +# Absolute path to ProjectDiscovery's Go httpx binary. The image also has +# pip's httpx[cli] package on PATH ahead of /opt/go/bin (same command name, +# unrelated tool — an HTTP client CLI, not a recon scanner), so a bare +# "httpx" call silently runs the wrong binary. Overridable via env for +# non-default image layouts. +HEXSTRIKE_HTTPX_BIN = os.environ.get("HEXSTRIKE_HTTPX_BIN", "/opt/go/bin/httpx") + +# Deterministic bound (minutes) for amass enum. Kept comfortably under the +# 300s generic command-timeout so amass exits on its own with whatever +# results it has, rather than getting SIGKILLed mid-write by the wrapper. +HEXSTRIKE_AMASS_TIMEOUT_MIN = int(os.environ.get("HEXSTRIKE_AMASS_TIMEOUT_MIN", "4")) + +def _load_scope(): + global _SCOPE_DOMAINS, _SCOPE_NETWORKS + if not HEXSTRIKE_SCOPE_FILE: + return + try: + with open(HEXSTRIKE_SCOPE_FILE) as f: + scope = json.load(f) + _SCOPE_DOMAINS = {d.lower().lstrip("*.") for d in scope.get("domains", [])} + _SCOPE_NETWORKS = [ipaddress.ip_network(n, strict=False) for n in scope.get("networks", [])] + logger.info(f"🎯 Scope loaded: {len(_SCOPE_DOMAINS)} domain(s), {len(_SCOPE_NETWORKS)} network(s) from {HEXSTRIKE_SCOPE_FILE}") + except Exception as e: + logger.error(f"💥 Failed to load scope file {HEXSTRIKE_SCOPE_FILE}: {e}") + raise + +_load_scope() + +_TARGET_KEYS = ("target", "host", "domain", "ip", "url", "rhost", "hostname") + +# ── Command-string target extraction ──────────────────────────────────── +# /api/command (and similar free-text endpoints) takes a raw shell string, +# not structured params — "nmap bank.com" never populates a "target" key, +# so the JSON-key scope check above can't see it. This is a heuristic +# second pass over the command text itself. Deliberately conservative: +# only domains ending in a known real TLD are treated as targets, so +# ordinary filenames/flags in the command (results.txt, wordlist.txt, +# nuclei-templates.yaml, config.ini) don't get misread as out-of-scope +# hosts. It will miss obfuscated/encoded targets — it's a safety net for +# the common case, not a guarantee. +_KNOWN_TLDS = frozenset(""" +com net org io co gov edu mil info biz ai dev app xyz online site tech +cloud shop store live us uk ca au de fr jp cn in br nl ru es it ch se no +fi dk pl be at nz sg hk kr mx za me tv +""".split()) +_CMD_DOMAIN_RE = re.compile(r"\b(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}\b") +_CMD_IPV4_RE = re.compile(r"\b(?:(?:25[0-5]|2[0-4]\d|1?\d{1,2})\.){3}(?:25[0-5]|2[0-4]\d|1?\d{1,2})\b") + +def _extract_command_targets(command): + if not isinstance(command, str) or not command.strip(): + return [] + found = set() + for match in _CMD_DOMAIN_RE.findall(command): + tld = match.rsplit(".", 1)[-1].lower() + if tld in _KNOWN_TLDS: + found.add(match.lower()) + for m in _CMD_IPV4_RE.finditer(command): + found.add(m.group(0)) + return list(found) + +def _extract_targets(payload): + if not isinstance(payload, dict): + return [] + found = [] + for key in _TARGET_KEYS: + val = payload.get(key) + if isinstance(val, str) and val.strip(): + # Batch tools (httpx, dnsx, etc.) accept comma-separated hosts + # in a single target/host field. Splitting here so each host is + # scope-checked individually — an unsplit CSV string never + # matches a single-domain scope entry and the whole request + # gets falsely blocked as out-of-scope. + found.extend(p.strip() for p in val.split(",") if p.strip()) + # Scan EVERY string field, not just "command" — endpoints vary in what + # they call their free-text field (command, script, code, payload, + # query, ...). Found in the wild: /api/python/execute uses "script", + # which wasn't covered by a command-only check and let arbitrary + # Python (making its own HTTP requests to any target) bypass scope + # enforcement entirely. Scanning every string value closes that gap + # and any similarly-shaped endpoint we haven't specifically audited. + for key, val in payload.items(): + if isinstance(val, str) and val.strip(): + found.extend(_extract_command_targets(val)) + return list(dict.fromkeys(found)) # dedup, preserve order + +def _host_of(value): + v = re.sub(r"^[a-zA-Z][a-zA-Z0-9+.-]*://", "", value) # strip scheme + v = v.split("/")[0].split("?")[0].split(":")[0] # strip path/query/port + return v.lower() + +def _in_scope(value): + host = _host_of(value) + try: + ip = ipaddress.ip_address(host) + return any(ip in net for net in _SCOPE_NETWORKS) + except ValueError: + pass + return host in _SCOPE_DOMAINS or any(host.endswith("." + d) for d in _SCOPE_DOMAINS) + +@app.before_request +def _enforce_scope(): + if not HEXSTRIKE_SCOPE_FILE or not (_SCOPE_DOMAINS or _SCOPE_NETWORKS): + return # scope enforcement not configured — opt-in, no-op by default + if request.path in ("/health", "/ping"): + return + payload = request.get_json(silent=True) or {} + targets = _extract_targets(payload) + out_of_scope = [t for t in targets if not _in_scope(t)] + if out_of_scope: + logger.warning(f"🚫 OUT-OF-SCOPE request blocked: {request.path} targets={out_of_scope}") + return jsonify({"error": "out_of_scope", "targets": out_of_scope}), 403 + +# ── Audit log ──────────────────────────────────────────────────────────── +# Immutable-ish JSONL trail of every authenticated request: what ran, +# against what, when. Needed for client deliverables and chain of custody, +# not optional for a real engagement. +_audit_logger = logging.getLogger("hexstrike.audit") +_audit_logger.setLevel(logging.INFO) +_audit_handler = logging.FileHandler(os.environ.get("HEXSTRIKE_AUDIT_LOG", "hexstrike_audit.jsonl")) +_audit_handler.setFormatter(logging.Formatter("%(message)s")) +_audit_logger.addHandler(_audit_handler) +_audit_logger.propagate = False + +@app.after_request +def _audit_log(response): + if request.path not in ("/health", "/ping"): + payload = request.get_json(silent=True) or {} + response_snippet = None + try: + if not response.direct_passthrough: + response_snippet = response.get_data(as_text=True)[:2000] + except Exception: + response_snippet = "" + _audit_logger.info(json.dumps({ + "ts": datetime.utcnow().isoformat() + "Z", + "remote_addr": request.remote_addr, + "method": request.method, + "path": request.path, + "targets": _extract_targets(payload), + "command": payload.get("command") if isinstance(payload, dict) else None, + "params": {k: v for k, v in payload.items() if k != "command"} if isinstance(payload, dict) else None, + "status": response.status_code, + "response_snippet": response_snippet, + })) + return response + +_SERVER_START_TIME = time.time() + # API Configuration API_PORT = int(os.environ.get('HEXSTRIKE_PORT', 8888)) API_HOST = os.environ.get('HEXSTRIKE_HOST', '127.0.0.1') @@ -9020,6 +9201,17 @@ def list_files(self, directory: str = ".") -> Dict[str, Any]: # API Routes +@app.route("/ping", methods=["GET"]) +def ping(): + """Lightweight liveness check — no tool detection, no subprocess calls. + Use this for hex-up startup polling and frequent status checks; + use /health when you actually need the full tool-availability sweep.""" + return jsonify({ + "status": "ok", + "version": "6.0.0", + "uptime_seconds": round(time.time() - _SERVER_START_TIME, 2) + }) + @app.route("/health", methods=["GET"]) def health_check(): """Health check endpoint with comprehensive tool detection""" @@ -9947,8 +10139,16 @@ def execute_httpx_scan(target, params): """Execute httpx scan with optimized parameters""" try: additional_args = params.get('additional_args', '-tech-detect -status-code') - # Use shell command with pipe for httpx - cmd = f"echo {target} | httpx {additional_args}" + # HEXSTRIKE_HTTPX_BIN pins the absolute path to ProjectDiscovery's Go + # httpx binary. A bare "httpx" is ambiguous: pip's httpx[cli] package + # installs a same-named console script earlier on PATH (/usr/bin), + # which silently shadows the real recon tool and fails with a + # click-style "No such option" error instead of running the scan. + # Targets are piped via stdin (one per line) rather than -l, since + # -l expects a filename, not an inline host list. + targets = [t.strip() for t in str(target).split(",") if t.strip()] + stdin_hosts = "\\n".join(targets) + cmd = f"printf '%b' {shlex.quote(stdin_hosts)} | {HEXSTRIKE_HTTPX_BIN} {additional_args}" return execute_command(cmd) except Exception as e: @@ -10021,6 +10221,13 @@ def execute_amass_scan(target, params): cmd_parts = ['amass', 'enum', '-d', target] if additional_args: cmd_parts.extend(additional_args.split()) + # amass's own -timeout (minutes) bounds runtime deterministically — + # without it, passive/active enum can run well past the generic + # execute_command wrapper's window and get hard-killed mid-write + # instead of exiting cleanly with partial results. Only added when + # the caller hasn't already set one via additional_args. + if "-timeout" not in cmd_parts: + cmd_parts.extend(["-timeout", str(HEXSTRIKE_AMASS_TIMEOUT_MIN)]) return execute_command(' '.join(cmd_parts)) except Exception as e: @@ -11350,6 +11557,12 @@ def amass(): if additional_args: command += f" {additional_args}" + # See execute_amass_scan() for why: bound runtime with amass's own + # -timeout so it exits deterministically instead of getting + # hard-killed by the generic command wrapper mid-write. + if "-timeout" not in command: + command += f" -timeout {HEXSTRIKE_AMASS_TIMEOUT_MIN}" + logger.info(f"🔍 Starting Amass {mode}: {domain}") result = execute_command(command) logger.info(f"📊 Amass completed for {domain}") @@ -13151,7 +13364,12 @@ def httpx(): logger.warning("🌐 httpx called without target parameter") return jsonify({"error": "Target parameter is required"}), 400 - command = f"httpx -l {target} -t {threads}" + # See execute_httpx_scan() for why: absolute path avoids the + # pip httpx[cli] PATH collision, and stdin piping replaces -l + # (which expects a filename, not an inline comma/space list). + targets = [t.strip() for t in re.split(r"[,\s]+", str(target)) if t.strip()] + stdin_hosts = "\\n".join(targets) + command = f"printf '%b' {shlex.quote(stdin_hosts)} | {HEXSTRIKE_HTTPX_BIN} -t {threads}" if probe: command += " -probe" @@ -17260,6 +17478,7 @@ def get_alternative_tools(): parser = argparse.ArgumentParser(description="Run the HexStrike AI API Server") parser.add_argument("--debug", action="store_true", help="Enable debug mode") parser.add_argument("--port", type=int, default=API_PORT, help=f"Port for the API server (default: {API_PORT})") + parser.add_argument("--host", type=str, default="127.0.0.1", help="Bind host (default: 127.0.0.1, loopback-only)") args = parser.parse_args() if args.debug: @@ -17286,4 +17505,4 @@ def get_alternative_tools(): if line.strip(): logger.info(line) - app.run(host="0.0.0.0", port=API_PORT, debug=DEBUG_MODE) + app.run(host=args.host, port=API_PORT, debug=DEBUG_MODE) diff --git a/scope.example.json b/scope.example.json new file mode 100644 index 000000000..bbb6cb720 --- /dev/null +++ b/scope.example.json @@ -0,0 +1,4 @@ +{ + "domains": ["example.com"], + "networks": ["10.10.0.0/24"] +}