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.
- llm_host_guard-0.2.0/LICENSE +21 -0
- llm_host_guard-0.2.0/PKG-INFO +132 -0
- llm_host_guard-0.2.0/README.md +118 -0
- llm_host_guard-0.2.0/llm_host_guard/__init__.py +2 -0
- llm_host_guard-0.2.0/llm_host_guard/__main__.py +4 -0
- llm_host_guard-0.2.0/llm_host_guard/checks/__init__.py +4 -0
- llm_host_guard-0.2.0/llm_host_guard/checks/agents.py +24 -0
- llm_host_guard-0.2.0/llm_host_guard/checks/config.py +91 -0
- llm_host_guard-0.2.0/llm_host_guard/checks/docker.py +57 -0
- llm_host_guard-0.2.0/llm_host_guard/checks/exposure.py +48 -0
- llm_host_guard-0.2.0/llm_host_guard/checks/firewall.py +92 -0
- llm_host_guard-0.2.0/llm_host_guard/checks/internet.py +160 -0
- llm_host_guard-0.2.0/llm_host_guard/checks/models.py +111 -0
- llm_host_guard-0.2.0/llm_host_guard/checks/ports.py +92 -0
- llm_host_guard-0.2.0/llm_host_guard/checks/runtime.py +149 -0
- llm_host_guard-0.2.0/llm_host_guard/checks/versions.py +39 -0
- llm_host_guard-0.2.0/llm_host_guard/cli.py +232 -0
- llm_host_guard-0.2.0/llm_host_guard/core.py +246 -0
- llm_host_guard-0.2.0/llm_host_guard/data/cves.json +28 -0
- llm_host_guard-0.2.0/llm_host_guard/data/signatures.json +307 -0
- llm_host_guard-0.2.0/llm_host_guard/fix.py +81 -0
- llm_host_guard-0.2.0/llm_host_guard.egg-info/PKG-INFO +132 -0
- llm_host_guard-0.2.0/llm_host_guard.egg-info/SOURCES.txt +30 -0
- llm_host_guard-0.2.0/llm_host_guard.egg-info/dependency_links.txt +1 -0
- llm_host_guard-0.2.0/llm_host_guard.egg-info/entry_points.txt +2 -0
- llm_host_guard-0.2.0/llm_host_guard.egg-info/top_level.txt +1 -0
- llm_host_guard-0.2.0/pyproject.toml +24 -0
- llm_host_guard-0.2.0/setup.cfg +4 -0
- llm_host_guard-0.2.0/tests/test_checks.py +149 -0
- llm_host_guard-0.2.0/tests/test_fix.py +121 -0
- llm_host_guard-0.2.0/tests/test_internet.py +66 -0
- 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,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")]
|