llm-host-guard 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. llm_host_guard-0.2.0/LICENSE +21 -0
  2. llm_host_guard-0.2.0/PKG-INFO +132 -0
  3. llm_host_guard-0.2.0/README.md +118 -0
  4. llm_host_guard-0.2.0/llm_host_guard/__init__.py +2 -0
  5. llm_host_guard-0.2.0/llm_host_guard/__main__.py +4 -0
  6. llm_host_guard-0.2.0/llm_host_guard/checks/__init__.py +4 -0
  7. llm_host_guard-0.2.0/llm_host_guard/checks/agents.py +24 -0
  8. llm_host_guard-0.2.0/llm_host_guard/checks/config.py +91 -0
  9. llm_host_guard-0.2.0/llm_host_guard/checks/docker.py +57 -0
  10. llm_host_guard-0.2.0/llm_host_guard/checks/exposure.py +48 -0
  11. llm_host_guard-0.2.0/llm_host_guard/checks/firewall.py +92 -0
  12. llm_host_guard-0.2.0/llm_host_guard/checks/internet.py +160 -0
  13. llm_host_guard-0.2.0/llm_host_guard/checks/models.py +111 -0
  14. llm_host_guard-0.2.0/llm_host_guard/checks/ports.py +92 -0
  15. llm_host_guard-0.2.0/llm_host_guard/checks/runtime.py +149 -0
  16. llm_host_guard-0.2.0/llm_host_guard/checks/versions.py +39 -0
  17. llm_host_guard-0.2.0/llm_host_guard/cli.py +232 -0
  18. llm_host_guard-0.2.0/llm_host_guard/core.py +246 -0
  19. llm_host_guard-0.2.0/llm_host_guard/data/cves.json +28 -0
  20. llm_host_guard-0.2.0/llm_host_guard/data/signatures.json +307 -0
  21. llm_host_guard-0.2.0/llm_host_guard/fix.py +81 -0
  22. llm_host_guard-0.2.0/llm_host_guard.egg-info/PKG-INFO +132 -0
  23. llm_host_guard-0.2.0/llm_host_guard.egg-info/SOURCES.txt +30 -0
  24. llm_host_guard-0.2.0/llm_host_guard.egg-info/dependency_links.txt +1 -0
  25. llm_host_guard-0.2.0/llm_host_guard.egg-info/entry_points.txt +2 -0
  26. llm_host_guard-0.2.0/llm_host_guard.egg-info/top_level.txt +1 -0
  27. llm_host_guard-0.2.0/pyproject.toml +24 -0
  28. llm_host_guard-0.2.0/setup.cfg +4 -0
  29. llm_host_guard-0.2.0/tests/test_checks.py +149 -0
  30. llm_host_guard-0.2.0/tests/test_fix.py +121 -0
  31. llm_host_guard-0.2.0/tests/test_internet.py +66 -0
  32. llm_host_guard-0.2.0/tests/test_runtime.py +77 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 HomeAuto Solutions
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: llm-host-guard
3
+ Version: 0.2.0
4
+ Summary: Audit the attack surface of a machine running local LLMs (Ollama, LM Studio, vLLM, llama.cpp...). Stdlib only.
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/leinad07912-ux/llm-host-guard
7
+ Classifier: Topic :: Security
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Dynamic: license-file
14
+
15
+ # llm-host-guard
16
+
17
+ **Is my Ollama / LM Studio / vLLM box open to the world?** One command tells you, in plain English, and fixes it if you say yes.
18
+
19
+ ## 30-second start
20
+
21
+ ```
22
+ pipx install git+https://github.com/leinad07912-ux/llm-host-guard # or: pip install git+…
23
+ llm-host-guard
24
+ ```
25
+
26
+ No pipx? `git clone https://github.com/leinad07912-ux/llm-host-guard && cd llm-host-guard && python3 llm_host_guard.py` — no dependencies, Python 3.9+.
27
+
28
+ You get a verdict line, a ranked list with a fix under every item, and the next command to run:
29
+
30
+ ```
31
+ llm-host-guard v0.2.0 — neo (Linux, 192.168.50.156)
32
+
33
+ 🔴 2 thing(s) to fix NOW, 2 this week score 2/10
34
+
35
+ CRITICAL Ollama on *:11434 reachable from LAN with no auth (7 models listed)
36
+ fix: OLLAMA_HOST=127.0.0.1 … or `ufw allow from <LAN>/24 to any port 11434`
37
+ CRITICAL container web publishes 0.0.0.0:8080 — bypasses host firewall
38
+ HIGH sshd PasswordAuthentication yes
39
+ HIGH 3 pickle-format weight file(s) — execute code on load
40
+ OK ufw active, default deny incoming
41
+
42
+ score: 2/10
43
+
44
+ next: preview the fixes → llm-host-guard --fix --dry-run
45
+ apply them → sudo llm-host-guard --fix
46
+ ```
47
+
48
+ Then: `sudo llm-host-guard --fix` walks each fix, shows the exact command, asks y/N, prints the undo. `llm-host-guard --open` gives the same report as a page in your browser. `sudo llm-host-guard --watch 30 --telegram` keeps watching and messages you only when something new opens up.
49
+
50
+ ## Why
51
+
52
+ Ollama, LM Studio, vLLM, llama.cpp and friends ship with **no authentication**, often bind to **0.0.0.0**, load model files that can **execute code on open**, and run on laptops with no perimeter. Tens of thousands of open Ollama servers are already on Shodan. Tooling exists for agent safety ([agent-firewall](https://github.com/leinad07912-ux/agent-firewall), mcp-sentinel) and generic host hardening (lynis) — nothing covers the LLM-host gap specifically.
53
+
54
+ ## What it checks
55
+
56
+ | Check | Finds |
57
+ |---|---|
58
+ | `ports` | LLM servers (Ollama, LM Studio, vLLM, llama.cpp, Open WebUI, text-gen-webui, Jan, koboldcpp, LocalAI, Xinference, SGLang, GPT4All, TabbyAPI) bound non-loopback; **probes them from your LAN IP to prove they answer without auth** |
59
+ | `firewall` | ufw / firewalld / nftables / pf / Windows Firewall missing, disabled, not default-deny, or allowing LLM ports from Anywhere |
60
+ | `docker` | Containers publishing on 0.0.0.0 — these **bypass ufw** |
61
+ | `exposure` | Public IP on an interface; cloudflared / ngrok / frp / bore tunnels running |
62
+ | `models` | Pickle weights (`.pt .bin .ckpt`) = RCE on load; malformed GGUF / safetensors headers (parser-CVE bait); world-writable model dirs |
63
+ | `versions` | Ollama / vLLM / llama.cpp / Open WebUI against a bundled CVE table |
64
+ | `config` | `OLLAMA_ORIGINS=*` (any website can drive your LLM from your browser), `OLLAMA_HOST=0.0.0.0`, vLLM / llama-server without `--api-key`, Open WebUI signup, sshd password auth |
65
+ | `agents` | Claude Code / Cursor / Codex / MCP configs present → points you to agent-side tooling |
66
+ | `runtime` | **Behaviour of the running server**: an LLM process that spawned a shell/downloader/unexpected child (the shape of a poisoned-model exploit) → CRITICAL; server or its children connected to a public IP (fine during a pull, otherwise not) → HIGH. `/proc` on Linux, `ps`+`lsof` on macOS, no root. `--checks runtime --watch 0.1` for a 6-second loop. |
67
+ | `internet` (opt-in `--internet`) | What the outside already sees: your public IP's open ports + CVEs via Shodan InternetDB (free, keyless), and router UPnP port-forwards to LLM ports. The only check that makes outbound calls. |
68
+
69
+ Each finding carries the exact fix.
70
+
71
+ ## What the levels mean
72
+
73
+ | Level | Plain English |
74
+ |---|---|
75
+ | **CRITICAL** | Wide open right now. Anyone on your network (or the internet) can use or damage your LLM today. Fix first. |
76
+ | **HIGH** | A real hole, but an attacker needs one more thing — be on your WiFi, or get you to open a file. Fix this week. |
77
+ | **MED** | Exposed on purpose but wider than it needs to be. Tighten when convenient. |
78
+ | **LOW** | Looks exposed but something else already blocks it. Just be aware. |
79
+ | **INFO** | Not a problem, just something you should know exists. |
80
+ | **OK** | Checked, nothing wrong. |
81
+
82
+ Score starts at 10; each CRITICAL takes 3, HIGH 2, MED 1. Run with `sudo` so firewall rules that already protect a port get credited (a LAN-scoped Ollama drops from CRITICAL to MED, a DOCKER-USER-blocked container to LOW).
83
+
84
+ ## Usage
85
+
86
+ ```
87
+ llm-host-guard # terminal report, exit 2=critical 1=high/med 0=clean
88
+ llm-host-guard --json # machine-readable
89
+ llm-host-guard --html report.html # self-contained page; --open writes it and opens your browser
90
+ llm-host-guard --watch 15 --webhook https://… # rerun every 15 min, alert on new findings
91
+ llm-host-guard --watch 30 --telegram # same, to Telegram (token/chat via env, see below)
92
+ llm-host-guard --watch 1 --once --telegram # single diff pass, for cron
93
+ llm-host-guard --internet # + Shodan InternetDB + router UPnP (outbound calls)
94
+ llm-host-guard --checks ports,docker --model-dir /data/models
95
+ sudo llm-host-guard # optional: reads ufw rules + effective sshd config
96
+ ```
97
+
98
+ Run `--watch 30` under systemd / launchd for continuous monitoring; `--html` in watch mode keeps the report fresh.
99
+
100
+ Telegram: create a bot with @BotFather, DM it once, get your chat id from `https://api.telegram.org/bot<TOKEN>/getUpdates`, then export `LLM_HOST_GUARD_TELEGRAM_BOT_TOKEN` and `LLM_HOST_GUARD_TELEGRAM_CHAT_ID` (a systemd `EnvironmentFile=` keeps them off the command line). `examples/llm-host-guard-watch.service` is a ready user unit.
101
+
102
+ ## `--fix` (v2)
103
+
104
+ ```
105
+ llm-host-guard --fix --dry-run # preview every recipe, no root, runs nothing
106
+ sudo llm-host-guard --fix # prints each command, asks y/N per finding
107
+ sudo llm-host-guard --fix --yes # unattended
108
+ ```
109
+
110
+ Recipes exist for: open LLM port → scoped ufw rule; ufw "Anywhere" rule on an LLM port → re-scoped; `OLLAMA_HOST=0.0.0.0` under systemd → loopback drop-in; container on 0.0.0.0 → DOCKER-USER drop; sshd password auth → `sshd_config.d/00-…` drop-in (only if you already have an SSH key installed — it will not lock you out).
111
+
112
+ Every recipe only *adds* a rule or a drop-in file, never edits an existing file, and prints its undo command. Findings without a safe automatic recipe say why.
113
+
114
+ ## What it does not do
115
+
116
+ - Edit config files in place (`--fix` only adds drop-ins/rules)
117
+ - Replace a firewall or IDS — it tells you when you're missing one
118
+ - Detect prompt injection or scan MCP servers — see agent-firewall / mcp-sentinel
119
+ - Kill anything: `runtime` detects and tells you the pid; agent-firewall's eBPF guard is the kill layer
120
+
121
+ ## Platforms
122
+
123
+ Linux and macOS first-class. Windows best-effort (`netstat -ano`, `netsh advfirewall`; use `py -m llm_host_guard` if `llm-host-guard` isn't on PATH). Python ≥ 3.9. No dependencies.
124
+
125
+ Running from a clone without installing: `python3 llm_host_guard.py` or `python3 -m llm_host_guard` — identical.
126
+
127
+ ## Contributing
128
+
129
+ `python3 -m unittest` — tests use fixtures, no live system needed.
130
+ New server signatures → `data/signatures.json`. New CVEs → `data/cves.json` (include vendor advisory link in PR).
131
+
132
+ MIT.
@@ -0,0 +1,118 @@
1
+ # llm-host-guard
2
+
3
+ **Is my Ollama / LM Studio / vLLM box open to the world?** One command tells you, in plain English, and fixes it if you say yes.
4
+
5
+ ## 30-second start
6
+
7
+ ```
8
+ pipx install git+https://github.com/leinad07912-ux/llm-host-guard # or: pip install git+…
9
+ llm-host-guard
10
+ ```
11
+
12
+ No pipx? `git clone https://github.com/leinad07912-ux/llm-host-guard && cd llm-host-guard && python3 llm_host_guard.py` — no dependencies, Python 3.9+.
13
+
14
+ You get a verdict line, a ranked list with a fix under every item, and the next command to run:
15
+
16
+ ```
17
+ llm-host-guard v0.2.0 — neo (Linux, 192.168.50.156)
18
+
19
+ 🔴 2 thing(s) to fix NOW, 2 this week score 2/10
20
+
21
+ CRITICAL Ollama on *:11434 reachable from LAN with no auth (7 models listed)
22
+ fix: OLLAMA_HOST=127.0.0.1 … or `ufw allow from <LAN>/24 to any port 11434`
23
+ CRITICAL container web publishes 0.0.0.0:8080 — bypasses host firewall
24
+ HIGH sshd PasswordAuthentication yes
25
+ HIGH 3 pickle-format weight file(s) — execute code on load
26
+ OK ufw active, default deny incoming
27
+
28
+ score: 2/10
29
+
30
+ next: preview the fixes → llm-host-guard --fix --dry-run
31
+ apply them → sudo llm-host-guard --fix
32
+ ```
33
+
34
+ Then: `sudo llm-host-guard --fix` walks each fix, shows the exact command, asks y/N, prints the undo. `llm-host-guard --open` gives the same report as a page in your browser. `sudo llm-host-guard --watch 30 --telegram` keeps watching and messages you only when something new opens up.
35
+
36
+ ## Why
37
+
38
+ Ollama, LM Studio, vLLM, llama.cpp and friends ship with **no authentication**, often bind to **0.0.0.0**, load model files that can **execute code on open**, and run on laptops with no perimeter. Tens of thousands of open Ollama servers are already on Shodan. Tooling exists for agent safety ([agent-firewall](https://github.com/leinad07912-ux/agent-firewall), mcp-sentinel) and generic host hardening (lynis) — nothing covers the LLM-host gap specifically.
39
+
40
+ ## What it checks
41
+
42
+ | Check | Finds |
43
+ |---|---|
44
+ | `ports` | LLM servers (Ollama, LM Studio, vLLM, llama.cpp, Open WebUI, text-gen-webui, Jan, koboldcpp, LocalAI, Xinference, SGLang, GPT4All, TabbyAPI) bound non-loopback; **probes them from your LAN IP to prove they answer without auth** |
45
+ | `firewall` | ufw / firewalld / nftables / pf / Windows Firewall missing, disabled, not default-deny, or allowing LLM ports from Anywhere |
46
+ | `docker` | Containers publishing on 0.0.0.0 — these **bypass ufw** |
47
+ | `exposure` | Public IP on an interface; cloudflared / ngrok / frp / bore tunnels running |
48
+ | `models` | Pickle weights (`.pt .bin .ckpt`) = RCE on load; malformed GGUF / safetensors headers (parser-CVE bait); world-writable model dirs |
49
+ | `versions` | Ollama / vLLM / llama.cpp / Open WebUI against a bundled CVE table |
50
+ | `config` | `OLLAMA_ORIGINS=*` (any website can drive your LLM from your browser), `OLLAMA_HOST=0.0.0.0`, vLLM / llama-server without `--api-key`, Open WebUI signup, sshd password auth |
51
+ | `agents` | Claude Code / Cursor / Codex / MCP configs present → points you to agent-side tooling |
52
+ | `runtime` | **Behaviour of the running server**: an LLM process that spawned a shell/downloader/unexpected child (the shape of a poisoned-model exploit) → CRITICAL; server or its children connected to a public IP (fine during a pull, otherwise not) → HIGH. `/proc` on Linux, `ps`+`lsof` on macOS, no root. `--checks runtime --watch 0.1` for a 6-second loop. |
53
+ | `internet` (opt-in `--internet`) | What the outside already sees: your public IP's open ports + CVEs via Shodan InternetDB (free, keyless), and router UPnP port-forwards to LLM ports. The only check that makes outbound calls. |
54
+
55
+ Each finding carries the exact fix.
56
+
57
+ ## What the levels mean
58
+
59
+ | Level | Plain English |
60
+ |---|---|
61
+ | **CRITICAL** | Wide open right now. Anyone on your network (or the internet) can use or damage your LLM today. Fix first. |
62
+ | **HIGH** | A real hole, but an attacker needs one more thing — be on your WiFi, or get you to open a file. Fix this week. |
63
+ | **MED** | Exposed on purpose but wider than it needs to be. Tighten when convenient. |
64
+ | **LOW** | Looks exposed but something else already blocks it. Just be aware. |
65
+ | **INFO** | Not a problem, just something you should know exists. |
66
+ | **OK** | Checked, nothing wrong. |
67
+
68
+ Score starts at 10; each CRITICAL takes 3, HIGH 2, MED 1. Run with `sudo` so firewall rules that already protect a port get credited (a LAN-scoped Ollama drops from CRITICAL to MED, a DOCKER-USER-blocked container to LOW).
69
+
70
+ ## Usage
71
+
72
+ ```
73
+ llm-host-guard # terminal report, exit 2=critical 1=high/med 0=clean
74
+ llm-host-guard --json # machine-readable
75
+ llm-host-guard --html report.html # self-contained page; --open writes it and opens your browser
76
+ llm-host-guard --watch 15 --webhook https://… # rerun every 15 min, alert on new findings
77
+ llm-host-guard --watch 30 --telegram # same, to Telegram (token/chat via env, see below)
78
+ llm-host-guard --watch 1 --once --telegram # single diff pass, for cron
79
+ llm-host-guard --internet # + Shodan InternetDB + router UPnP (outbound calls)
80
+ llm-host-guard --checks ports,docker --model-dir /data/models
81
+ sudo llm-host-guard # optional: reads ufw rules + effective sshd config
82
+ ```
83
+
84
+ Run `--watch 30` under systemd / launchd for continuous monitoring; `--html` in watch mode keeps the report fresh.
85
+
86
+ Telegram: create a bot with @BotFather, DM it once, get your chat id from `https://api.telegram.org/bot<TOKEN>/getUpdates`, then export `LLM_HOST_GUARD_TELEGRAM_BOT_TOKEN` and `LLM_HOST_GUARD_TELEGRAM_CHAT_ID` (a systemd `EnvironmentFile=` keeps them off the command line). `examples/llm-host-guard-watch.service` is a ready user unit.
87
+
88
+ ## `--fix` (v2)
89
+
90
+ ```
91
+ llm-host-guard --fix --dry-run # preview every recipe, no root, runs nothing
92
+ sudo llm-host-guard --fix # prints each command, asks y/N per finding
93
+ sudo llm-host-guard --fix --yes # unattended
94
+ ```
95
+
96
+ Recipes exist for: open LLM port → scoped ufw rule; ufw "Anywhere" rule on an LLM port → re-scoped; `OLLAMA_HOST=0.0.0.0` under systemd → loopback drop-in; container on 0.0.0.0 → DOCKER-USER drop; sshd password auth → `sshd_config.d/00-…` drop-in (only if you already have an SSH key installed — it will not lock you out).
97
+
98
+ Every recipe only *adds* a rule or a drop-in file, never edits an existing file, and prints its undo command. Findings without a safe automatic recipe say why.
99
+
100
+ ## What it does not do
101
+
102
+ - Edit config files in place (`--fix` only adds drop-ins/rules)
103
+ - Replace a firewall or IDS — it tells you when you're missing one
104
+ - Detect prompt injection or scan MCP servers — see agent-firewall / mcp-sentinel
105
+ - Kill anything: `runtime` detects and tells you the pid; agent-firewall's eBPF guard is the kill layer
106
+
107
+ ## Platforms
108
+
109
+ Linux and macOS first-class. Windows best-effort (`netstat -ano`, `netsh advfirewall`; use `py -m llm_host_guard` if `llm-host-guard` isn't on PATH). Python ≥ 3.9. No dependencies.
110
+
111
+ Running from a clone without installing: `python3 llm_host_guard.py` or `python3 -m llm_host_guard` — identical.
112
+
113
+ ## Contributing
114
+
115
+ `python3 -m unittest` — tests use fixtures, no live system needed.
116
+ New server signatures → `data/signatures.json`. New CVEs → `data/cves.json` (include vendor advisory link in PR).
117
+
118
+ MIT.
@@ -0,0 +1,2 @@
1
+ """llm-host-guard: audit the attack surface of a machine running local LLMs."""
2
+ from .core import VERSION as __version__ # noqa: F401
@@ -0,0 +1,4 @@
1
+ import sys
2
+ from .cli import main
3
+
4
+ sys.exit(main())
@@ -0,0 +1,4 @@
1
+ from . import agents, config, docker, exposure, firewall, internet, models, ports, runtime, versions
2
+
3
+ ALL = {m.NAME: m for m in (ports, firewall, docker, exposure, models, versions, config, agents, runtime)}
4
+ OPTIONAL = {internet.NAME: internet} # makes outbound calls; enabled with --internet or --checks internet
@@ -0,0 +1,24 @@
1
+ """Agent tooling on the host. Detect + refer; don't duplicate agent-firewall / mcp-sentinel."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ from llm_host_guard.core import Ctx, Finding
7
+
8
+ NAME = "agents"
9
+
10
+
11
+ def run(ctx: Ctx) -> list[Finding]:
12
+ present = [t["name"] for t in ctx.signatures["agent_tools"]
13
+ if any((ctx.home / p.removeprefix("~/")).exists() for p in t["paths"])]
14
+ if not present:
15
+ return [Finding(NAME, "OK", "No coding-agent tooling detected")]
16
+ mcp_json = list(ctx.home.glob(".mcp.json")) + list(ctx.home.glob(".claude/**/mcp*.json"))
17
+ return [Finding(
18
+ NAME, "INFO", f"Agent tooling present: {', '.join(present)}",
19
+ f"Agents with shell access turn prompt injection into host compromise. {len(mcp_json)} MCP config(s) found. "
20
+ "This tool audits the LLM host surface only.",
21
+ "Constrain agent actions with agent-firewall (https://github.com/leinad07912-ux/agent-firewall) and scan "
22
+ "MCP servers/skills with mcp-sentinel.",
23
+ {"tools": present, "mcp_configs": [str(p) for p in mcp_json[:20]]},
24
+ )]
@@ -0,0 +1,91 @@
1
+ """Runtime + host config weaknesses: CORS *, 0.0.0.0 binds, missing API keys, SSH password auth."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from pathlib import Path
6
+
7
+ from llm_host_guard.core import Ctx, Finding, sh
8
+ from llm_host_guard import fix
9
+
10
+ NAME = "config"
11
+
12
+
13
+ def _ollama_is_systemd() -> bool:
14
+ return bool(sh(["systemctl", "cat", "ollama"]))
15
+
16
+
17
+ def _sshd_files_text() -> str:
18
+ files = [Path("/etc/ssh/sshd_config")] + sorted(Path("/etc/ssh/sshd_config.d").glob("*.conf"))
19
+ text = ""
20
+ for f in files:
21
+ try:
22
+ text += f.read_text(errors="ignore") + "\n"
23
+ except OSError:
24
+ continue
25
+ return text
26
+
27
+
28
+ def _ollama_env(ctx: Ctx) -> dict:
29
+ for l in ctx.listeners:
30
+ if "ollama" in l.proc.lower() and l.pid:
31
+ return ctx.env_of_pid(l.pid)
32
+ txt = sh(["systemctl", "show", "ollama", "-p", "Environment"]) or ""
33
+ return dict(kv.split("=", 1) for kv in txt.replace("Environment=", "").split() if "=" in kv)
34
+
35
+
36
+ def _sshd(ctx: Ctx) -> list[Finding]:
37
+ out = []
38
+ text = _sshd_files_text()
39
+ if not text:
40
+ return out
41
+ eff = sh(["sshd", "-T"]) or text # sshd -T only as root
42
+ if re.search(r"^\s*passwordauthentication\s+yes", eff, re.I | re.M):
43
+ keyed = fix.has_ssh_key(ctx)
44
+ out.append(Finding(NAME, "HIGH", "sshd PasswordAuthentication yes",
45
+ "Brute-forceable. AI-driven credential stuffing makes this worse, not better.",
46
+ "PasswordAuthentication no (keep one key-based session open while testing)",
47
+ fix_cmds=[fix.write_file_cmd(fix.DROPIN_SSHD, "PasswordAuthentication no\n"),
48
+ fix.sshd_reload_cmd()] if keyed else [],
49
+ undo_cmds=[fix.rm_file_cmd(fix.DROPIN_SSHD), fix.sshd_reload_cmd()] if keyed else [],
50
+ fix_note="keep this session open; test key login from a second terminal before closing"
51
+ if keyed else "no SSH authorized-keys file for the invoking user — refusing to disable passwords"))
52
+ if re.search(r"^\s*permitrootlogin\s+(yes|without-password|prohibit-password)", eff, re.I | re.M):
53
+ out.append(Finding(NAME, "MED", "sshd PermitRootLogin enabled", "", "PermitRootLogin no"))
54
+ return out
55
+
56
+
57
+ def run(ctx: Ctx) -> list[Finding]:
58
+ out = []
59
+ env = _ollama_env(ctx)
60
+ if env:
61
+ origins = env.get("OLLAMA_ORIGINS", "")
62
+ if "*" in origins:
63
+ out.append(Finding(NAME, "HIGH", "OLLAMA_ORIGINS=* (any website can drive your Ollama)",
64
+ "With wildcard CORS, JavaScript on any page you visit can call localhost:11434 "
65
+ "from your browser — run prompts, pull/delete models, exfiltrate outputs.",
66
+ "Set OLLAMA_ORIGINS to the exact origins that need it (e.g. http://localhost:3000)."))
67
+ scoped = [x for x in ctx.ufw_sources(11434) if x != "Anywhere"]
68
+ if env.get("OLLAMA_HOST", "").startswith(("0.0.0.0", ":", "[::]")) and not scoped:
69
+ sysd = _ollama_is_systemd()
70
+ out.append(Finding(NAME, "HIGH", f"OLLAMA_HOST={env['OLLAMA_HOST']} (all interfaces)",
71
+ "", "OLLAMA_HOST=127.0.0.1 unless LAN clients need it; then firewall to LAN.",
72
+ fix_cmds=[fix.write_file_cmd(fix.DROPIN_OLLAMA, "[Service]\nEnvironment=OLLAMA_HOST=127.0.0.1\n"),
73
+ "systemctl daemon-reload", "systemctl restart ollama"] if sysd else [],
74
+ undo_cmds=[fix.rm_file_cmd(fix.DROPIN_OLLAMA), "systemctl daemon-reload",
75
+ "systemctl restart ollama"] if sysd else [],
76
+ fix_note="binds Ollama to loopback — LAN clients (Home Assistant, Open WebUI elsewhere) will lose access; "
77
+ "if they need it, skip this and take the ufw-scoping recipe instead"
78
+ if sysd else "Ollama not systemd-managed — set OLLAMA_HOST=127.0.0.1 where it is launched"))
79
+ for l in ctx.listeners:
80
+ cmd = ctx.cmdline_of_pid(l.pid).lower() if l.pid else ""
81
+ if not cmd:
82
+ continue
83
+ if "vllm" in cmd and "--api-key" not in cmd and not l.loopback:
84
+ out.append(Finding(NAME, "HIGH", f"vLLM on :{l.port} without --api-key", "", "vllm serve ... --api-key <secret>"))
85
+ if "llama-server" in cmd and "--api-key" not in cmd and not l.loopback:
86
+ out.append(Finding(NAME, "HIGH", f"llama-server on :{l.port} without --api-key", "", "llama-server ... --api-key <secret>"))
87
+ if ("open-webui" in cmd or "open_webui" in cmd) and "enable_signup=false" not in (str(ctx.env_of_pid(l.pid)).lower()):
88
+ out.append(Finding(NAME, "MED", "Open WebUI signup may be enabled", "First visitor becomes admin; later visitors self-register.",
89
+ "ENABLE_SIGNUP=false after creating your admin"))
90
+ out += _sshd(ctx)
91
+ return out or [Finding(NAME, "OK", "No config weaknesses detected")]
@@ -0,0 +1,57 @@
1
+ """Docker port publishing bypasses ufw (DOCKER chain is evaluated before INPUT)."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ from llm_host_guard.core import Ctx, Finding, sh
7
+
8
+ NAME = "docker"
9
+ _MAP = re.compile(r"(\S+):(\d+)->(\d+)/tcp")
10
+
11
+
12
+ def parse_ps(text: str) -> list[tuple[str, str, int]]:
13
+ """[(container, bind_addr, host_port)] for published TCP ports."""
14
+ out = []
15
+ for line in text.splitlines():
16
+ if "\t" not in line:
17
+ continue
18
+ name, ports = line.split("\t", 1)
19
+ for addr, hp, _ in _MAP.findall(ports):
20
+ out.append((name, addr, int(hp)))
21
+ return out
22
+
23
+
24
+ def run(ctx: Ctx) -> list[Finding]:
25
+ ps = sh(["docker", "ps", "--format", "{{.Names}}\t{{.Ports}}"])
26
+ if ps is None:
27
+ return []
28
+ out = []
29
+ wild = sorted({(n, p) for n, a, p in parse_ps(ps) if a in ("0.0.0.0", "::", "[::]")})
30
+ llm_ports = {p for sig in ctx.signatures["products"] for p in sig["ports"]}
31
+ for name, port in wild:
32
+ if ctx.docker_user_drops(port):
33
+ out.append(Finding(NAME, "LOW", f"container {name} publishes 0.0.0.0:{port} but DOCKER-USER drops it",
34
+ "Reachable only via loopback thanks to your DOCKER-USER rule.", "", {"container": name, "port": port}))
35
+ continue
36
+ sev = "CRITICAL" if port in llm_ports else "HIGH"
37
+ rule = f"DOCKER-USER -i {ctx.default_iface()} -p tcp --dport {port} -j DROP"
38
+ out.append(Finding(
39
+ NAME, sev,
40
+ f"container {name} publishes 0.0.0.0:{port} — bypasses host firewall",
41
+ "Docker inserts its own iptables rules ahead of ufw/firewalld; a published port is reachable "
42
+ "from the network regardless of host firewall policy.",
43
+ f"Publish on loopback: `-p 127.0.0.1:{port}:<container_port>`; or set "
44
+ "`{\"ip\": \"127.0.0.1\"}` in /etc/docker/daemon.json; or add DOCKER-USER rules.",
45
+ {"container": name, "port": port},
46
+ fix_cmds=[f"iptables -I {rule}"], undo_cmds=[f"iptables -D {rule}"],
47
+ fix_note="live rule only; persist via /etc/ufw/after.rules (see examples/ufw-docker-user.rules) or iptables-persistent",
48
+ ))
49
+ user_chain = sh(["iptables", "-L", "DOCKER-USER", "-n"])
50
+ if wild and user_chain is None:
51
+ out.append(Finding(NAME, "INFO", "DOCKER-USER chain unreadable without sudo",
52
+ "Rerun with sudo to credit any DOCKER-USER DROP rules.", ""))
53
+ elif wild and user_chain.count("\n") <= 2:
54
+ out.append(Finding(NAME, "MED", "DOCKER-USER chain is empty",
55
+ "No custom filtering applies to published container ports.",
56
+ "iptables -I DOCKER-USER -i <wan-if> ! -s <LAN>/24 -j DROP"))
57
+ return out or [Finding(NAME, "OK", "No containers publishing ports on 0.0.0.0")]
@@ -0,0 +1,48 @@
1
+ """Internet exposure: public IP on an interface, tunnel daemons running."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ from llm_host_guard.core import Ctx, Finding, is_private, sh
7
+
8
+ NAME = "exposure"
9
+
10
+
11
+ def _iface_ips(ctx: Ctx) -> list[str]:
12
+ if ctx.os == "Windows":
13
+ txt = sh(["ipconfig"]) or ""
14
+ return re.findall(r"IPv4 Address[. ]*: (\S+)", txt)
15
+ txt = sh(["ip", "-4", "-o", "addr"]) or sh(["ifconfig"]) or ""
16
+ return re.findall(r"inet (?:addr:)?(\d+\.\d+\.\d+\.\d+)", txt)
17
+
18
+
19
+ def _procs(ctx: Ctx) -> str:
20
+ if ctx.os == "Windows":
21
+ return (sh(["tasklist"]) or "").lower()
22
+ return (sh(["ps", "-axo", "comm=,args="]) or "").lower()
23
+
24
+
25
+ def run(ctx: Ctx) -> list[Finding]:
26
+ out = []
27
+ public = [ip for ip in _iface_ips(ctx) if not is_private(ip)]
28
+ llm = [l for l in ctx.listeners if ctx.sig_for(l) and not l.loopback]
29
+ if public:
30
+ out.append(Finding(
31
+ NAME, "CRITICAL" if llm else "HIGH",
32
+ f"Host has a public IP ({', '.join(public)})",
33
+ f"{len(llm)} LLM listener(s) bound non-loopback are directly internet-reachable unless a "
34
+ "firewall blocks them. Shodan indexes open Ollama within hours.",
35
+ "Bind LLM servers to 127.0.0.1; firewall default-deny; use a VPN (Tailscale/WireGuard) for remote access.",
36
+ {"public_ips": public},
37
+ ))
38
+ procs = _procs(ctx)
39
+ tunnels = [t for t in ctx.signatures["tunnels"] if t in procs]
40
+ if tunnels:
41
+ out.append(Finding(
42
+ NAME, "HIGH" if llm else "MED",
43
+ f"Tunnel daemon running: {', '.join(tunnels)}",
44
+ "Tunnels punch through NAT and firewall. If a tunnel targets an LLM port it is on the public internet.",
45
+ "Verify tunnel ingress config; add authentication (Cloudflare Access, ngrok --basic-auth) or remove.",
46
+ {"tunnels": tunnels},
47
+ ))
48
+ return out or [Finding(NAME, "OK", "No public IP or tunnel daemon detected (NAT/router is still your only perimeter)")]
@@ -0,0 +1,92 @@
1
+ """Host firewall presence + policy. Read-only, works without sudo where possible."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from pathlib import Path
6
+
7
+ from llm_host_guard.core import Ctx, Finding, sh
8
+
9
+ NAME = "firewall"
10
+
11
+
12
+ def _ufw(ctx: Ctx) -> list[Finding]:
13
+ conf = Path("/etc/ufw/ufw.conf")
14
+ if not conf.exists():
15
+ return []
16
+ enabled = "ENABLED=yes" in conf.read_text(errors="ignore")
17
+ status = sh(["ufw", "status", "verbose"]) # works only as root; may be None
18
+ if status is None and not enabled:
19
+ return [Finding(NAME, "HIGH", "ufw installed but disabled",
20
+ "Every listening service is reachable from the network.",
21
+ "sudo ufw default deny incoming && sudo ufw allow from <LAN>/24 to any port 22 && sudo ufw enable")]
22
+ if status is None:
23
+ return [Finding(NAME, "INFO", "ufw enabled (rules unreadable without sudo)",
24
+ "Rerun with sudo to audit rule scope for LLM ports.", "")]
25
+ out = []
26
+ if "deny (incoming)" not in status:
27
+ out.append(Finding(NAME, "HIGH", "ufw default policy is not deny incoming", status.splitlines()[2] if len(status.splitlines()) > 2 else "",
28
+ "sudo ufw default deny incoming"))
29
+ llm_ports = {l.port for l in ctx.listeners if ctx.sig_for(l) and not l.loopback}
30
+ for line in status.splitlines():
31
+ m = re.match(r"^(\d+)(?:/tcp)?\s+ALLOW IN\s+(\S+)", line)
32
+ if m and int(m.group(1)) in llm_ports and m.group(2) == "Anywhere":
33
+ port, cidr = m.group(1), ctx.lan_cidr
34
+ out.append(Finding(NAME, "HIGH", f"ufw allows port {port} (LLM) from Anywhere",
35
+ "Rule is not scoped to your LAN.",
36
+ f"sudo ufw delete allow {port}/tcp && sudo ufw allow from {cidr} to any port {port} proto tcp",
37
+ fix_cmds=[f"ufw delete allow {port}/tcp",
38
+ f"ufw allow from {cidr} to any port {port} proto tcp comment llm-host-guard"],
39
+ undo_cmds=[f"ufw delete allow from {cidr} to any port {port} proto tcp",
40
+ f"ufw allow {port}/tcp"]))
41
+ return out or [Finding(NAME, "OK", "ufw active, default deny incoming, LLM ports scoped")]
42
+
43
+
44
+ def _firewalld() -> list[Finding]:
45
+ st = sh(["firewall-cmd", "--state"])
46
+ if st is None:
47
+ return []
48
+ if "running" not in st:
49
+ return [Finding(NAME, "HIGH", "firewalld installed but not running", "", "sudo systemctl enable --now firewalld")]
50
+ return [Finding(NAME, "OK", "firewalld running")]
51
+
52
+
53
+ def _nft() -> list[Finding]:
54
+ rs = sh(["nft", "list", "ruleset"])
55
+ if rs is None:
56
+ return []
57
+ if "hook input" not in rs:
58
+ return [Finding(NAME, "HIGH", "nftables present but no input hook chain", "No inbound filtering.",
59
+ "Enable ufw/firewalld or add an input chain with policy drop")]
60
+ return [Finding(NAME, "OK", "nftables input chain present")]
61
+
62
+
63
+ def _pf() -> list[Finding]:
64
+ info = sh(["pfctl", "-s", "info"])
65
+ if info is None:
66
+ return []
67
+ if "Status: Enabled" not in info:
68
+ return [Finding(NAME, "HIGH", "macOS pf firewall disabled", "",
69
+ "System Settings → Network → Firewall → On; or `sudo pfctl -e`")]
70
+ return [Finding(NAME, "OK", "pf enabled")]
71
+
72
+
73
+ def _win() -> list[Finding]:
74
+ st = sh(["netsh", "advfirewall", "show", "allprofiles", "state"])
75
+ if st is None:
76
+ return []
77
+ if "OFF" in st.upper():
78
+ return [Finding(NAME, "HIGH", "Windows Firewall OFF on at least one profile", "",
79
+ "netsh advfirewall set allprofiles state on")]
80
+ return [Finding(NAME, "OK", "Windows Firewall on")]
81
+
82
+
83
+ def run(ctx: Ctx) -> list[Finding]:
84
+ if ctx.os == "Darwin":
85
+ res = _pf()
86
+ elif ctx.os == "Windows":
87
+ res = _win()
88
+ else:
89
+ res = _ufw(ctx) or _firewalld() or _nft()
90
+ return res or [Finding(NAME, "HIGH", "No host firewall detected",
91
+ "Every listening service is reachable from the network.",
92
+ "Linux: sudo apt install ufw && sudo ufw default deny incoming && sudo ufw enable")]