diff --git a/README.md b/README.md
index 5aa014e..bf0720d 100644
--- a/README.md
+++ b/README.md
@@ -16,10 +16,16 @@
```bash
-pip install cognis-modpot
+pip install "git+https://github.com/cognis-digital/modpot.git"
modpot scan . # → prioritized findings in seconds
```
+
+## What is this?
+
+modpot is a fake industrial control system (ICS) that you run on a server to attract and log attackers who scan the internet for vulnerable factory or utility equipment. When someone probes it — trying to read sensor values, change control registers, or run diagnostic commands — it records exactly what they did as structured JSON logs you can feed into a SIEM, alert system, or spreadsheet. It speaks Modbus TCP, the most common protocol used in real water treatment plants, power grids, and factory floors, so it looks convincing to automated scanners. Security researchers, threat-intelligence teams, and network defenders use it to see who is actively targeting industrial equipment and what commands they try to run.
+
+
## Contents
- [Why modpot?](#why) · [Features](#features) · [Quick start](#quick-start) · [Example](#example) · [Architecture](#architecture) · [AI stack](#ai-stack) · [How it compares](#how-it-compares) · [Integrations](#integrations) · [Install anywhere](#install-anywhere) · [Related](#related) · [Contributing](#contributing)
@@ -48,10 +54,46 @@ OT threat-intel content engine — drop it on a VPS, share the 'someone tried to
+
+## Install
+
+`modpot` is source-available (not published to PyPI) — every method below installs
+straight from GitHub. Pick whichever you prefer; the one-line scripts auto-detect
+the best tool available on your machine.
+
+**One-liner (Linux / macOS):**
+```sh
+curl -fsSL https://raw.githubusercontent.com/cognis-digital/modpot/HEAD/install.sh | sh
+```
+
+**One-liner (Windows PowerShell):**
+```powershell
+irm https://raw.githubusercontent.com/cognis-digital/modpot/HEAD/install.ps1 | iex
+```
+
+**Or install manually — any one of:**
+```sh
+pipx install "git+https://github.com/cognis-digital/modpot.git" # isolated (recommended)
+uv tool install "git+https://github.com/cognis-digital/modpot.git" # uv
+pip install "git+https://github.com/cognis-digital/modpot.git" # pip
+```
+
+**From source:**
+```sh
+git clone https://github.com/cognis-digital/modpot.git
+cd modpot && pip install .
+```
+
+Then run:
+```sh
+modpot --help
+```
+
+
## Quick start
```bash
-pip install cognis-modpot
+pip install "git+https://github.com/cognis-digital/modpot.git"
modpot --version
modpot scan . # scan current project
modpot scan . --format json # machine-readable
diff --git a/install.ps1 b/install.ps1
new file mode 100644
index 0000000..f0335da
--- /dev/null
+++ b/install.ps1
@@ -0,0 +1,29 @@
+# Comprehensive installer for cognis-digital/modpot (Windows PowerShell).
+# Tries: pipx -> uv -> pip (git+https) -> from source.
+# modpot is source-available and not on PyPI; all paths install from GitHub.
+$ErrorActionPreference = "Stop"
+$Repo = "modpot"
+$Url = "git+https://github.com/cognis-digital/modpot.git"
+$Git = "https://github.com/cognis-digital/modpot.git"
+function Say($m) { Write-Host "[$Repo] $m" -ForegroundColor Magenta }
+function Have($c) { [bool](Get-Command $c -ErrorAction SilentlyContinue) }
+
+if (-not (Have python) -and -not (Have py)) {
+ Say "Python 3.9+ is required but was not found. Install Python first."; exit 1
+}
+if (Have pipx) {
+ Say "Installing with pipx (isolated, recommended)..."
+ pipx install $Url; if ($LASTEXITCODE -eq 0) { Say "Done. Run: modpot"; exit 0 }
+}
+if (Have uv) {
+ Say "Installing with uv..."
+ uv tool install $Url; if ($LASTEXITCODE -eq 0) { Say "Done. Run: modpot"; exit 0 }
+}
+if (Have pip) {
+ Say "Installing with pip (user site)..."
+ pip install --user $Url; if ($LASTEXITCODE -eq 0) { Say "Done. Run: modpot"; exit 0 }
+}
+Say "No packaging tool worked; falling back to a source clone."
+$Tmp = Join-Path $env:TEMP "$Repo-src"
+git clone --depth 1 $Git $Tmp
+Say "Cloned to $Tmp - run: cd $Tmp; python -m pip install ."
diff --git a/install.sh b/install.sh
index b03e6c2..4758389 100644
--- a/install.sh
+++ b/install.sh
@@ -1,10 +1,34 @@
-#!/usr/bin/env sh
-# Universal installer for modpot. Prefers uv > pipx > pip; installs from the repo.
-set -e
-SRC="git+https://github.com/cognis-digital/modpot.git"
-echo "Installing modpot ..."
-if command -v uv >/dev/null 2>&1; then uv tool install "$SRC"
-elif command -v pipx >/dev/null 2>&1; then pipx install "$SRC"
-elif command -v python3 >/dev/null 2>&1; then python3 -m pip install --user "$SRC"
-else echo "Need uv, pipx, or python3+pip"; exit 1; fi
-echo "Done. Run: modpot --help"
+#!/usr/bin/env sh
+# Comprehensive installer for cognis-digital/modpot (Linux / macOS).
+# Tries the best available method: pipx -> uv -> pip (git+https) -> from source.
+# modpot is source-available and not on PyPI; all paths install from GitHub.
+set -eu
+
+REPO="modpot"
+URL="git+https://github.com/cognis-digital/modpot.git"
+GITURL="https://github.com/cognis-digital/modpot.git"
+
+say() { printf '\033[1;35m[%s]\033[0m %s\n' "$REPO" "$1"; }
+have() { command -v "$1" >/dev/null 2>&1; }
+
+if ! have python3 && ! have python; then
+ say "Python 3.9+ is required but was not found. Install Python first."; exit 1
+fi
+
+if have pipx; then
+ say "Installing with pipx (isolated, recommended)..."
+ pipx install "$URL" && { say "Done. Run: modpot"; exit 0; }
+fi
+if have uv; then
+ say "Installing with uv..."
+ uv tool install "$URL" && { say "Done. Run: modpot"; exit 0; }
+fi
+if have pip3 || have pip; then
+ PIP="$(command -v pip3 || command -v pip)"
+ say "Installing with pip (user site)..."
+ "$PIP" install --user "$URL" && { say "Done. Run: modpot"; exit 0; }
+fi
+
+say "No packaging tool worked; falling back to a source clone."
+TMP="$(mktemp -d)"; git clone --depth 1 "$GITURL" "$TMP/$REPO"
+say "Cloned to $TMP/$REPO — run: cd $TMP/$REPO && python3 -m pip install ."
diff --git a/integrations/webhook.py b/integrations/webhook.py
index 91e0211..9bf7258 100644
--- a/integrations/webhook.py
+++ b/integrations/webhook.py
@@ -5,7 +5,7 @@
Usage: scan . --format json | python integrations/webhook.py --url URL
"""
from __future__ import annotations
-import argparse, json, sys, urllib.request
+import argparse, sys, urllib.request
def main() -> int:
ap = argparse.ArgumentParser()
diff --git a/layman.md b/layman.md
new file mode 100644
index 0000000..a5b10b9
--- /dev/null
+++ b/layman.md
@@ -0,0 +1 @@
+modpot is a fake industrial control system (ICS) that you run on a server to attract and log attackers who scan the internet for vulnerable factory or utility equipment. When someone probes it — trying to read sensor values, change control registers, or run diagnostic commands — it records exactly what they did as structured JSON logs you can feed into a SIEM, alert system, or spreadsheet. It speaks Modbus TCP, the most common protocol used in real water treatment plants, power grids, and factory floors, so it looks convincing to automated scanners. Security researchers, threat-intelligence teams, and network defenders use it to see who is actively targeting industrial equipment and what commands they try to run.
diff --git a/modpot/cli.py b/modpot/cli.py
index d3ddc71..38f4ac3 100644
--- a/modpot/cli.py
+++ b/modpot/cli.py
@@ -50,11 +50,22 @@
_SEV_RANK = {"info": 0, "low": 1, "medium": 2, "high": 3}
+_CONN_RECV_TIMEOUT = 30 # seconds; prevent hanging on stalled clients
+
+
def _read_lines(path: str) -> list[str]:
if path == "-":
- return sys.stdin.read().splitlines()
- with open(path, "r", encoding="utf-8") as fh:
- return fh.read().splitlines()
+ try:
+ return sys.stdin.read().splitlines()
+ except UnicodeDecodeError as exc:
+ raise OSError(f"stdin contains non-UTF-8 data: {exc}") from exc
+ try:
+ with open(path, "r", encoding="utf-8") as fh:
+ return fh.read().splitlines()
+ except UnicodeDecodeError as exc:
+ raise OSError(
+ f"{path} contains non-UTF-8 data (is it a binary file?): {exc}"
+ ) from exc
def _print_table(events: list[dict]) -> None:
@@ -102,11 +113,21 @@ def _cmd_analyze(args: argparse.Namespace) -> int:
if args.min_severity:
floor = _SEV_RANK.get(args.min_severity, 0)
events = [e for e in events if _SEV_RANK.get(e["severity"], 0) >= floor]
- _emit(events, args.format)
+ # subcommand --format takes precedence; fall back to top-level --format
+ fmt = getattr(args, "format", None) or "table"
+ _emit(events, fmt)
return 1 if _has_high(events) else 0
def _cmd_serve(args: argparse.Namespace) -> int:
+ import struct as _struct
+
+ if not (1 <= args.port <= 65535):
+ print(
+ f"error: port {args.port} is out of range (must be 1–65535)",
+ file=sys.stderr,
+ )
+ return 2
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
@@ -126,33 +147,43 @@ def _cmd_serve(args: argparse.Namespace) -> int:
conn, addr = srv.accept()
src = f"{addr[0]}:{addr[1]}"
with conn:
- while True:
- head = _recv_exact(conn, 7)
- if head is None:
- break
- import struct
-
- _, _, length, _ = struct.unpack(">HHHB", head)
- rest = _recv_exact(conn, max(length - 1, 0))
- if rest is None:
- break
- raw = head + rest
- try:
- frame = parse_frame(raw)
- event = frame_to_event(frame, src=src)
- conn.sendall(build_response(frame))
- except ParseError as exc:
- event = {
- "timestamp": datetime.now(timezone.utc).isoformat(),
- "src": src,
- "category": "unknown",
- "severity": "medium",
- "reasons": [f"unparseable frame: {exc}"],
- "raw_hex": raw.hex(),
- }
- if event.get("severity") == "high":
- saw_high = True
- print(json.dumps(event), flush=True)
+ conn.settimeout(_CONN_RECV_TIMEOUT)
+ try:
+ while True:
+ head = _recv_exact(conn, 7)
+ if head is None:
+ break
+ try:
+ _, _, length, _ = _struct.unpack(">HHHB", head)
+ except _struct.error:
+ break
+ if length < 1:
+ break
+ # Cap frame body to 260 bytes (Modbus PDU max 253 + 1 unit id).
+ # A larger length field is an attacker trying to exhaust memory.
+ body_len = min(max(length - 1, 0), 260)
+ rest = _recv_exact(conn, body_len)
+ if rest is None:
+ break
+ raw = head + rest
+ try:
+ frame = parse_frame(raw)
+ event = frame_to_event(frame, src=src)
+ conn.sendall(build_response(frame))
+ except ParseError as exc:
+ event = {
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ "src": src,
+ "category": "unknown",
+ "severity": "medium",
+ "reasons": [f"unparseable frame: {exc}"],
+ "raw_hex": raw.hex(),
+ }
+ if event.get("severity") == "high":
+ saw_high = True
+ print(json.dumps(event), flush=True)
+ except socket.timeout:
+ pass # stalled client; close connection and move on
except KeyboardInterrupt:
print("\n[modpot] stopped", file=sys.stderr)
finally:
@@ -205,6 +236,12 @@ def build_parser() -> argparse.ArgumentParser:
default=None,
help="only show events at or above this severity",
)
+ a.add_argument(
+ "--format",
+ choices=["table", "json"],
+ default=None,
+ help="output format (overrides top-level --format; default: table)",
+ )
a.set_defaults(func=_cmd_analyze)
s = sub.add_parser(
diff --git a/modpot/core.py b/modpot/core.py
index 890a892..5540ee0 100644
--- a/modpot/core.py
+++ b/modpot/core.py
@@ -165,21 +165,26 @@ def _decode_pdu(frame: ModbusFrame, fc: int, body: bytes) -> None:
frame.note = "function code body not decoded"
+_MAX_COIL_BITS = 2000 # Modbus spec limit for coil read responses
+_MAX_REG_COUNT = 125 # Modbus spec limit for register read responses
+
+
def build_response(frame: ModbusFrame) -> bytes:
"""Build a plausible honeypot response for a parsed request *frame*.
- Reads return zeroed register/coil data of the requested size; writes
+ Reads return zeroed register/coil data of the requested size (capped at
+ the Modbus spec maximums so the byte-count field never overflows); writes
echo back the request per spec. Unknown/unsupported codes return a
Modbus exception response (code 0x01, illegal function) so the
honeypot looks like a real but minimal device.
"""
fc = frame.function_code
if fc in (0x01, 0x02):
- qty = frame.quantity or 0
+ qty = min(frame.quantity or 0, _MAX_COIL_BITS)
nbytes = (qty + 7) // 8
pdu = bytes([fc, nbytes]) + b"\x00" * nbytes
elif fc in (0x03, 0x04):
- qty = frame.quantity or 0
+ qty = min(frame.quantity or 0, _MAX_REG_COUNT)
nbytes = qty * 2
pdu = bytes([fc, nbytes]) + b"\x00" * nbytes
elif fc in (0x05, 0x06):
diff --git a/modpot/mcp_server.py b/modpot/mcp_server.py
index b158c6a..d3c2631 100644
--- a/modpot/mcp_server.py
+++ b/modpot/mcp_server.py
@@ -1,6 +1,8 @@
-"""MODPOT MCP server — exposes scan() as an MCP tool for Cognis.Studio."""
+"""MODPOT MCP server — exposes analyze_capture() as an MCP tool for Cognis.Studio."""
from __future__ import annotations
-from modpot.core import scan, to_json
+
+import json
+
def serve() -> int:
"""Start an MCP stdio server. Requires the optional 'mcp' extra:
@@ -11,12 +13,20 @@ def serve() -> int:
except Exception:
print("Install the MCP extra: pip install 'cognis-modpot[mcp]'")
return 1
+
+ from modpot.core import analyze_capture
+
app = FastMCP("modpot")
@app.tool()
- def modpot_scan(target: str) -> str:
- """Spin up a high-interaction Modbus/DNP3 ICS honeypot that logs attacker register reads/writes as structured JSON.. Returns JSON findings."""
- return to_json(scan(target))
+ def modpot_analyze(hexlog: str) -> str:
+ """Decode and classify Modbus TCP frames from a newline-separated hex
+ capture log. Returns JSON findings as a string."""
+ if not hexlog or not hexlog.strip():
+ return json.dumps([])
+ lines = hexlog.splitlines()
+ events = analyze_capture(lines)
+ return json.dumps(events)
app.run()
return 0
diff --git a/tests/test_hardening.py b/tests/test_hardening.py
new file mode 100644
index 0000000..69e26e8
--- /dev/null
+++ b/tests/test_hardening.py
@@ -0,0 +1,113 @@
+"""Hardening tests: bad input, edge cases, and error-path coverage."""
+from __future__ import annotations
+
+import struct
+
+from modpot import core
+from modpot.cli import main
+
+
+# ---------------------------------------------------------------------------
+# build_response: oversized quantity must not crash (byte-count overflow fix)
+# ---------------------------------------------------------------------------
+
+def _build_raw(fc: int, addr: int = 0, qty: int = 1, tid: int = 1, uid: int = 1) -> bytes:
+ pdu = bytes([fc]) + struct.pack(">HH", addr, qty)
+ return struct.pack(">HHHB", tid, 0, len(pdu) + 1, uid) + pdu
+
+
+def test_build_response_coils_oversized_qty_no_crash():
+ """build_response must not raise ValueError when quantity > 2000 (coils)."""
+ raw = _build_raw(0x01, qty=65535)
+ frame = core.parse_frame(raw)
+ resp = core.build_response(frame)
+ # Response byte-count field (resp[8]) must fit in one byte.
+ assert 0 <= resp[8] <= 255
+ assert resp[7] == 0x01 # function code echoed
+
+
+def test_build_response_registers_oversized_qty_no_crash():
+ """build_response must not raise when quantity > 125 (holding registers)."""
+ raw = _build_raw(0x03, qty=60000)
+ frame = core.parse_frame(raw)
+ resp = core.build_response(frame)
+ assert 0 <= resp[8] <= 255
+ assert resp[7] == 0x03
+
+
+# ---------------------------------------------------------------------------
+# analyze_capture: empty / blank / comment-only input
+# ---------------------------------------------------------------------------
+
+def test_analyze_capture_empty_list():
+ events = core.analyze_capture([])
+ assert events == []
+
+
+def test_analyze_capture_blank_and_comment_lines():
+ events = core.analyze_capture(["", " ", "# this is a comment", "\t"])
+ assert events == []
+
+
+# ---------------------------------------------------------------------------
+# CLI: missing file -> exit 2, clear stderr message
+# ---------------------------------------------------------------------------
+
+def test_cli_missing_file_exits_2(capsys):
+ rc = main(["analyze", "/nonexistent/path/capture.hexlog"])
+ assert rc == 2
+ err = capsys.readouterr().err
+ assert "error:" in err.lower()
+ assert "nonexistent" in err or "capture.hexlog" in err
+
+
+# ---------------------------------------------------------------------------
+# CLI: binary (non-UTF-8) file -> exit 2, not a traceback
+# ---------------------------------------------------------------------------
+
+def test_cli_binary_file_exits_2(capsys, tmp_path):
+ bad = tmp_path / "binary.hexlog"
+ bad.write_bytes(b"\xff\xfe\x00\x01\x80\x90") # not valid UTF-8
+ rc = main(["analyze", str(bad)])
+ assert rc == 2
+ err = capsys.readouterr().err
+ assert "error:" in err.lower()
+
+
+# ---------------------------------------------------------------------------
+# CLI: no subcommand -> exit 2 (help printed)
+# ---------------------------------------------------------------------------
+
+def test_cli_no_subcommand_exits_2(capsys):
+ rc = main([])
+ assert rc == 2
+
+
+# ---------------------------------------------------------------------------
+# CLI: out-of-range port -> exit 2
+# ---------------------------------------------------------------------------
+
+def test_cli_serve_port_zero_exits_2(capsys):
+ rc = main(["serve", "--port", "0"])
+ assert rc == 2
+ err = capsys.readouterr().err
+ assert "port" in err.lower()
+ assert "range" in err.lower() or "0" in err
+
+
+def test_cli_serve_port_too_large_exits_2(capsys):
+ rc = main(["serve", "--port", "99999"])
+ assert rc == 2
+ err = capsys.readouterr().err
+ assert "port" in err.lower()
+
+
+# ---------------------------------------------------------------------------
+# mcp_server: module imports without errors (no dependency on mcp package)
+# ---------------------------------------------------------------------------
+
+def test_mcp_server_importable():
+ """mcp_server must import cleanly even when the 'mcp' extra is absent."""
+ import importlib
+ mod = importlib.import_module("modpot.mcp_server")
+ assert callable(mod.serve)