DEV Community

Cover image for Your Self-Hosted LLM Has No Auth by Default. One Config Line Decides Who Runs It.
Alexey Spinov
Alexey Spinov

Posted on • Originally published at finops.spinov.online

Your Self-Hosted LLM Has No Auth by Default. One Config Line Decides Who Runs It.

A self-hosted LLM backend signals its exposure the moment its config binds to all interfaces, and you can read that indicator from the text on disk before the service answers one request. exposure_gate.py lints your .env, compose file, and LiteLLM config.yaml offline with five checks. In this post's fixtures, one field flips exit 0 to exit 1.

AI disclosure: I wrote exposure_gate.py with an AI assistant and ran it myself, offline, before publishing. Every number in the output blocks below is pasted from a real local run on Python 3.13.5, standard library only, no network. I checked the exit codes (0 / 1 / 2) and hashed the full STDOUT twice to confirm it is byte-for-byte deterministic. The external claims (the Zenity Labs honeypot writeup, the Dev.to post on who pays for AI access) are their research, not mine, and I link the primary sources. Their numbers stay in their own paragraphs; my numbers come only from the synthetic fixtures shown here.

In short:

  • The exposure surface of a self-hosted LLM backend shows up in its config text, readable before the first request: bind address, published port, a missing or placeholder master_key, and provider keys sitting behind an open door. Whether that surface is actually reachable still depends on a firewall or proxy the text cannot see.
  • The gate runs five checks offline against .env, docker-compose.yml, LiteLLM config.yaml, a systemd unit, and mcp.json. No sockets, no scanning, no keys.
  • The demo that matters: two config directories, byte-identical except one line (OLLAMA_HOST=127.0.0.1 becomes OLLAMA_HOST=0.0.0.0). The verdict flips from exit 0, findings: 0, to exit 1, findings: 1.
  • A literal provider key in the same directory as any finding escalates to CRITICAL. That is the FinOps edge: an open door can reach your billing meter, not only your compute.
  • Standard library only (re, json, sys, pathlib). Offline, keyless, read-only, deterministic STDOUT. The tool and all fixtures are in this post.

A self-hosted LLM backend ships with no auth by default, and the config decides who reaches it

Here is the shape of the problem. You run Ollama locally, it works, you demo it on 127.0.0.1, everyone is happy. Then you move it to a box other people can reach, and somewhere in that move a line in a .env or a compose file changes the bind address so a teammate can hit the API. The service still works exactly the same for you. The only thing that changed is who else can reach it, and nothing in the running process complains.

That gap between "works for me" and "reachable by anyone" is where the risk lives, and the tell is sitting in plain text. You do not need a scanner to see it. You need to read the four or five files that decide the bind address and the auth, and read them the same way every time, which is what a program is for.

This axis is new for this blog. I have written about a leaked key's blast radius, which is about the permissions a stolen credential carries. This is the opposite end: a door with no key required at all. The network surface, not the credential.

What the honeypot researchers actually found

I am anchoring on primary research, and it is worth keeping their claims separate from my tool's output. On June 30, 2026, Zenity Labs published a honeypot writeup, Bring Your Own Agent, on attackers hijacking exposed AI backends. Their words, not mine, on Ollama: it "ships with no built-in authentication on its default port 11434: anything that can reach it can use it," and it "defaults to localhost but is commonly misconfigured to be bound to all interfaces via OLLAMA_HOST=0.0.0.0."

Their finding on LiteLLM is the same kind of default. Again their words: "It enforces access only if the operator sets a master key. If left unset, it accepts any key value." And on the placeholder that never gets changed: "it's also very common to stay with the default placeholder key (sk-1234), which has also been seen to be tested by attackers."

The part that made me want to write code: their mitigations are prose. The recommendations section reads "Do not expose model backends to the internet" and similar guidance, which is correct and which no CI pipeline can run. A checklist a human reads on a good day is a different object from a check a pipeline runs on every day. My five checks are their four failure modes plus one escalation, turned into exit codes. That is the whole contribution here.

The thesis, stated so you can break it

The exposure indicators of a self-hosted LLM backend are computable from the config text alone, deterministically, before the service accepts its first request. Bind address, published port, missing or placeholder master_key, and a literal provider key behind an open door: each is a line you can grep, and the verdict over a directory of them is a fixed function of the text.

Here is how to falsify it. Show me a config with OLLAMA_HOST=0.0.0.0 and no upstream protection that this gate passes as clean, or a config bound strictly to 127.0.0.1 with a master_key pulled from the environment that this gate flags. Either one means the tool is broken. The ground truth for the comparison is the config text, never a network scan, and that boundary is the honest core of the tool. This is the earliest rung of the pre-execution idea I keep building on: a gate before the service is reachable at all, rather than a gate before some action it takes later.

Run it in sixty seconds

No keys. No network. No install beyond Python. Save the file, point it at a directory of configs, run one command. Here is the whole tool, one file, standard library only:

#!/usr/bin/env python3
"""
exposure_gate.py -- an offline config lint for self-hosted AI backends
(Ollama, LiteLLM, an MCP server in front of them), run BEFORE the service
takes its first request from a stranger.

It reads a directory line by line and classifies each file:
  * .env / *.env                    -- environment files
  * docker-compose.yml / compose.*  -- compose files
  * config.yaml / litellm*.yaml     -- LiteLLM config (also matched by content)
  * *.service                       -- systemd units
  * mcp.json / *.mcp.json           -- MCP server manifests (JSON-validated)

Five checks, each grounded in a public honeypot writeup (see the post):
  1 BIND_ALL        OLLAMA_HOST=0.0.0.0, --host 0.0.0.0, host: 0.0.0.0, HOST=::
  2 PORT_PUBLISHED  compose maps 11434/4000/8000 to all interfaces (no
                    127.0.0.1: prefix, or an explicit 0.0.0.0: prefix)
  3 AUTH_ABSENT     a LiteLLM config declares no master_key at all
  4 PLACEHOLDER_KEY master_key set to a known weak value (sk-1234, changeme...)
  5 PAID_KEYS_BEHIND_OPEN_DOOR  a literal provider key (OPENAI_API_KEY=sk-...)
                    sits in the same directory as at least one finding from
                    checks 1-4. Severity escalates to CRITICAL: an open door
                    reaches your billing meter, not only your compute.

Offline. Keyless. Read-only. Zero network. Standard library only
(re, json, sys, pathlib). It does NOT scan hosts, open sockets, contact
Shodan, validate a key against a provider, or detect malware. It lints the
TEXT of the config you hand it. A firewall can close a 0.0.0.0 bind; a
reverse proxy can expose a 127.0.0.1 one. The verdicts are exposure
INDICATORS in the config, never proof of reachability from the internet.

Exit codes (usable as a pre-expose CI gate):
  0  no exposure indicator in the scanned config
  1  >=1 exposure indicator (at least one BIND_ALL / PORT_PUBLISHED /
     AUTH_ABSENT / PLACEHOLDER_KEY / PAID_KEYS_BEHIND_OPEN_DOOR)
  2  bad input (path is not a directory, no recognizable config file to
     lint, or a JSON manifest that does not parse -- the lint fails closed)

Usage:
  python3 exposure_gate.py <config-dir>
"""

import json
import re
import sys
from pathlib import Path

KNOWN_AI_PORTS = ("11434", "4000", "8000")

PROVIDER_KEYS = (
    "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GROQ_API_KEY", "MISTRAL_API_KEY",
    "COHERE_API_KEY", "GOOGLE_API_KEY", "AZURE_API_KEY", "TOGETHER_API_KEY",
    "DEEPSEEK_API_KEY", "XAI_API_KEY", "PERPLEXITY_API_KEY", "FIREWORKS_API_KEY",
    "REPLICATE_API_TOKEN", "HUGGINGFACE_API_KEY", "HF_TOKEN",
)

PLACEHOLDER_VALUES = frozenset({
    "sk-1234", "sk-12345", "sk-xxxx", "sk-test", "changeme", "change-me",
    "changethis", "change-this", "test", "testkey", "test-key", "secret",
    "password", "admin", "default", "your-key-here", "your_key_here",
    "yourkey", "xxxx", "xxxxxxxx", "placeholder", "example",
})

BIND_ALL_RX = re.compile(
    r"(?:"
    r"OLLAMA_HOST\s*[=:]"
    r"|--host[=\s]+"
    r"|(?<![A-Za-z0-9_])host\s*:"
    r"|(?<![A-Za-z0-9_])HOST\s*="
    r")"
    r'\s*["\']?(?:0\.0\.0\.0|\[?::\]?)(?![0-9A-Fa-f])',
    re.IGNORECASE,
)

PORT_RX = re.compile(
    r'^\s*-\s*["\']?(?:(\d{1,3}(?:\.\d{1,3}){3}):)?(\d+):(\d+)["\']?\s*(?:#.*)?$'
)

PROVIDER_RX = re.compile(
    r"\b(" + "|".join(PROVIDER_KEYS) + r')\s*[=:]\s*["\']?([^"\'\s#]+)'
)

MASTER_KEY_RX = re.compile(r'\bmaster_key\s*:\s*["\']?([^"\'\s#]+)')

LITELLM_HINT_RX = re.compile(r"^\s*(model_list|litellm_settings|general_settings)\s*:")


def _bad(msg):
    print("ERROR: " + msg)
    raise SystemExit(2)


def _is_env_ref(value):
    """A value pulled from the environment or a secrets manager, not a literal."""
    return value.startswith(("os.environ/", "${", "$")) or value == ""


def _looks_literal(value):
    return (not _is_env_ref(value)
            and value.lower() not in PLACEHOLDER_VALUES
            and len(value) >= 8)


def classify(path, text):
    """Return the file category, or None if the file is not lintable here."""
    name = path.name.lower()
    if name in ("docker-compose.yml", "docker-compose.yaml",
                "compose.yml", "compose.yaml"):
        return "compose"
    if name.endswith((".yaml", ".yml")):
        if name.startswith(("config", "litellm")):
            return "litellm"
        for line in text.splitlines():
            if LITELLM_HINT_RX.match(line):
                return "litellm"
        return None
    if name == ".env" or name.endswith(".env"):
        return "env"
    if name.endswith(".service"):
        return "systemd"
    if name == "mcp.json" or name.endswith(".mcp.json"):
        return "mcp"
    return None


def collect(root):
    """Read every top-level file once. Validate any JSON. Fail closed on both
    a missing directory and a directory with nothing to lint."""
    if not root.is_dir():
        _bad("path is not a directory: %s" % root)
    files = []
    counts = {"env": 0, "compose": 0, "litellm": 0, "systemd": 0, "mcp": 0}
    for path in sorted(root.iterdir()):
        if not path.is_file():
            continue
        try:
            text = path.read_text(encoding="utf-8", errors="replace")
        except OSError as exc:
            _bad("cannot read %s: %s" % (path.name, exc))
        cat = classify(path, text)
        if cat is None:
            continue
        if cat == "mcp":
            try:
                json.loads(text)
            except json.JSONDecodeError as exc:
                _bad("%s is not valid JSON: %s" % (path.name, exc))
        counts[cat] += 1
        files.append((path.name, cat, text))
    if not files:
        _bad("no recognizable AI-backend config in %s "
             "(refusing to report a clean bill on an empty scan)" % root)
    return files, counts


def scan(files):
    findings = []          # checks 1-4
    provider_literals = []  # (file, line, name) for the escalation

    def add(sev, check, fname, line, detail):
        findings.append({"sev": sev, "check": check, "file": fname,
                         "line": line, "detail": detail})

    for fname, cat, text in files:
        lines = text.splitlines()
        master_key_seen = False
        for i, raw in enumerate(lines, start=1):
            line = raw.rstrip("\n")

            if BIND_ALL_RX.search(line):
                add("FAIL", "BIND_ALL", fname, i,
                    "%s binds all interfaces" % line.strip()[:70])

            if cat == "compose":
                m = PORT_RX.match(line)
                if m:
                    ip, host_port, cont_port = m.group(1), m.group(2), m.group(3)
                    published = ip is None or ip == "0.0.0.0"
                    known = host_port in KNOWN_AI_PORTS or cont_port in KNOWN_AI_PORTS
                    if published and known:
                        where = "no 127.0.0.1: prefix" if ip is None else "0.0.0.0: prefix"
                        add("FAIL", "PORT_PUBLISHED", fname, i,
                            "%s:%s published to all interfaces (%s)"
                            % (host_port, cont_port, where))

            if cat == "litellm":
                mk = MASTER_KEY_RX.search(line)
                if mk:
                    master_key_seen = True
                    value = mk.group(1)
                    if _is_env_ref(value):
                        pass
                    elif value.lower() in PLACEHOLDER_VALUES:
                        add("FAIL", "PLACEHOLDER_KEY", fname, i,
                            "master_key=%s is a known weak placeholder" % value)

            for pm in PROVIDER_RX.finditer(line):
                name, value = pm.group(1), pm.group(2)
                if _looks_literal(value):
                    provider_literals.append((fname, i, name))

        if cat == "litellm" and not master_key_seen:
            add("FAIL", "AUTH_ABSENT", fname, 0,
                "LiteLLM config declares no master_key (accepts any key value)")

    # check 5: escalate when a literal provider key shares the dir with a finding
    door = None
    if findings:
        first = sorted(findings, key=lambda f: (f["file"], f["line"], f["check"]))[0]
        door = "%s at %s:%d" % (first["check"], first["file"], first["line"])
    if door and provider_literals:
        for fname, line, name in provider_literals:
            findings.append({
                "sev": "CRITICAL", "check": "PAID_KEYS_BEHIND_OPEN_DOOR",
                "file": fname, "line": line,
                "detail": "%s literal sits behind an open door (%s)" % (name, door),
            })

    findings.sort(key=lambda f: (f["file"], f["line"], f["check"]))
    return findings, bool(provider_literals)


def report(counts, findings, has_literals):
    out = ["EXPOSURE-GATE REPORT"]
    total = sum(counts.values())
    out.append("files scanned: %d (env: %d, compose: %d, litellm: %d, "
               "systemd: %d, mcp: %d)"
               % (total, counts["env"], counts["compose"], counts["litellm"],
                  counts["systemd"], counts["mcp"]))
    out.append("provider key literals present: %s" % ("yes" if has_literals else "no"))
    out.append("findings: %d" % len(findings))
    for f in findings:
        loc = "%s:%d" % (f["file"], f["line"]) if f["line"] else f["file"]
        out.append("  - [%-8s] %-26s %s  %s"
                   % (f["sev"], f["check"], loc, f["detail"]))
    if any(f["check"] in ("BIND_ALL", "PORT_PUBLISHED") for f in findings):
        out.append("note: BIND_ALL / PORT_PUBLISHED are config indicators, "
                   "not network truth. Verify your firewall / reverse proxy.")
    crit = sum(1 for f in findings if f["sev"] == "CRITICAL")
    if findings:
        out.append("VERDICT: FAIL: %d exposure indicator(s), %d escalated to CRITICAL"
                   % (len(findings), crit))
        code = 1
    else:
        out.append("VERDICT: PASS: no exposure indicator in the scanned config")
        code = 0
    print("\n".join(out))
    return code


def main(argv):
    if len(argv) != 2:
        print("usage: exposure_gate.py <config-dir>")
        raise SystemExit(2)
    files, counts = collect(Path(argv[1]))
    findings, has_literals = scan(files)
    raise SystemExit(report(counts, findings, has_literals))


if __name__ == "__main__":
    main(sys.argv)
Enter fullscreen mode Exit fullscreen mode

The baseline: a directory that is wired for localhost

The clean fixture is a small self-hosted stack wired the careful way. Ollama and LiteLLM behind localhost, ports bound to 127.0.0.1, the master_key and the provider keys pulled from the environment instead of written into the file. Four files:

# clean/.env
# self-hosted AI backend, local only
OLLAMA_HOST=127.0.0.1
OLLAMA_PORT=11434
OPENAI_API_KEY=${OPENAI_API_KEY}
ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
Enter fullscreen mode Exit fullscreen mode
# clean/docker-compose.yml
services:
  ollama:
    image: ollama/ollama:latest
    environment:
      - OLLAMA_HOST=127.0.0.1
    ports:
      - "127.0.0.1:11434:11434"
  litellm:
    image: ghcr.io/berriai/litellm:main
    command: ["--config", "/app/config.yaml"]
    ports:
      - "127.0.0.1:4000:4000"
Enter fullscreen mode Exit fullscreen mode
# clean/config.yaml
model_list:
  - model_name: gpt-4o
    litellm_params:
      model: openai/gpt-4o
      api_key: os.environ/OPENAI_API_KEY
general_settings:
  master_key: os.environ/LITELLM_MASTER_KEY
Enter fullscreen mode Exit fullscreen mode

The mcp.json in the same folder pins its server to 127.0.0.1 and reads its key from the environment too. Run the gate at it:

$ python3 exposure_gate.py fixtures/clean
EXPOSURE-GATE REPORT
files scanned: 4 (env: 1, compose: 1, litellm: 1, systemd: 0, mcp: 1)
provider key literals present: no
findings: 0
VERDICT: PASS: no exposure indicator in the scanned config
$ echo $?
0
Enter fullscreen mode Exit fullscreen mode

Exit 0. Zero findings across four files. Two things I want you to notice. The gate is not allergic to provider keys: OPENAI_API_KEY=${OPENAI_API_KEY} is a reference, so it counts as no literal at all, and the summary line says so. And the master_key set to os.environ/LITELLM_MASTER_KEY passes for the same reason. The gate rewards the config that keeps secrets out of the file.

One field flips the verdict

Now the demo this post exists for. The second directory is byte-identical to the clean one except for a single line in the .env. Same compose, same config, same mcp.json, verified identical. One bind address:

$ diff fixtures/clean/.env fixtures/flipped/.env
2c2
< OLLAMA_HOST=127.0.0.1
---
> OLLAMA_HOST=0.0.0.0
Enter fullscreen mode Exit fullscreen mode

That is the whole change. A teammate could not reach the box, someone widened the bind, the service kept working, and the diff is one line nobody reviews twice. Point the gate at the changed directory:

$ python3 exposure_gate.py fixtures/flipped
EXPOSURE-GATE REPORT
files scanned: 4 (env: 1, compose: 1, litellm: 1, systemd: 0, mcp: 1)
provider key literals present: no
findings: 1
  - [FAIL    ] BIND_ALL                   .env:2  OLLAMA_HOST=0.0.0.0 binds all interfaces
note: BIND_ALL / PORT_PUBLISHED are config indicators, not network truth. Verify your firewall / reverse proxy.
VERDICT: FAIL: 1 exposure indicator(s), 0 escalated to CRITICAL
$ echo $?
1
Enter fullscreen mode Exit fullscreen mode

Exit 1. Same four files, same reader, one field moved, and the verdict inverts. This is the shape I trust because it is small enough to be true: a config lint is worth having exactly when a one-line change like this is the difference between a private service and a public one, and when the running process gives you no signal either way. If your review process would catch that line every time, you do not need this. Mine would miss it on a Friday, and I have shipped the Friday version.

When the door leads to your billing account

The violating fixture is the stack after a few of those Friday changes pile up. Bind widened to 0.0.0.0, ports published without a localhost prefix, the LiteLLM config with no master_key at all, and two provider keys written into the .env as literals. Here is that .env, keys faked on purpose:

# violating/.env
# staging box, "just for testing"
OLLAMA_HOST=0.0.0.0
OLLAMA_PORT=11434
OPENAI_API_KEY=sk-FAKE-openai-3nP9xQ2wodceKm
ANTHROPIC_API_KEY=sk-FAKE-anthropic-7hK1mZ4tbrai
Enter fullscreen mode Exit fullscreen mode
$ python3 exposure_gate.py fixtures/violating
EXPOSURE-GATE REPORT
files scanned: 3 (env: 1, compose: 1, litellm: 1, systemd: 0, mcp: 0)
provider key literals present: yes
findings: 6
  - [FAIL    ] BIND_ALL                   .env:2  OLLAMA_HOST=0.0.0.0 binds all interfaces
  - [CRITICAL] PAID_KEYS_BEHIND_OPEN_DOOR .env:4  OPENAI_API_KEY literal sits behind an open door (BIND_ALL at .env:2)
  - [CRITICAL] PAID_KEYS_BEHIND_OPEN_DOOR .env:5  ANTHROPIC_API_KEY literal sits behind an open door (BIND_ALL at .env:2)
  - [FAIL    ] AUTH_ABSENT                config.yaml  LiteLLM config declares no master_key (accepts any key value)
  - [FAIL    ] PORT_PUBLISHED             docker-compose.yml:6  4000:4000 published to all interfaces (no 127.0.0.1: prefix)
  - [FAIL    ] PORT_PUBLISHED             docker-compose.yml:10  11434:11434 published to all interfaces (0.0.0.0: prefix)
note: BIND_ALL / PORT_PUBLISHED are config indicators, not network truth. Verify your firewall / reverse proxy.
VERDICT: FAIL: 6 exposure indicator(s), 2 escalated to CRITICAL
Enter fullscreen mode Exit fullscreen mode

Exit 1, six findings, and two of them are CRITICAL. That escalation is the whole reason I built the fifth check, and it inverts a good post I read last week. On June 30, a Dev.to author writing as dannwaneri published Someone Else Pays for Your AI Access, on how cheap resold model access pushes the real cost down to a third party doing account verification for pocket money. Their framing, their reporting, and it is worth your time.

My gate flags the reverse direction. When your LiteLLM or Ollama box is open and your OPENAI_API_KEY sits in the same .env, someone else does not pay for your access. They pay for theirs, on your invoice. An open backend with a provider key behind it is a metered resource anyone can spend. That is why PAID_KEYS_BEHIND_OPEN_DOOR escalates to CRITICAL: if that door is truly open, it does not only reach your GPU, it can reach your card. The gate keeps this claim narrow, and the narrowness matters. It flags a literal key sitting in the same directory as a finding. It does not read the key, test it, confirm the port is reachable, or measure a bill. That is a different tool from the one that checks which model actually answered and whether the billing lined up: that receipt is about a proxy charging you for the wrong thing, and this is about strangers charging you for the right thing on your own hardware.

Where the gate's judgment ends

The edge fixture is where I show you the seam, because a check that hides its own limits is worse than no check. Its .env binds to all interfaces with a comment claiming it is safe:

# edge/.env
# bound to VPN iface only, fronted by firewall
OLLAMA_HOST=0.0.0.0
OPENAI_API_KEY=${OPENAI_API_KEY}
Enter fullscreen mode Exit fullscreen mode
$ python3 exposure_gate.py fixtures/edge
EXPOSURE-GATE REPORT
files scanned: 2 (env: 1, compose: 0, litellm: 1, systemd: 0, mcp: 0)
provider key literals present: no
findings: 2
  - [FAIL    ] BIND_ALL                   .env:2  OLLAMA_HOST=0.0.0.0 binds all interfaces
  - [FAIL    ] PLACEHOLDER_KEY            config.yaml:7  master_key=changeme is a known weak placeholder
note: BIND_ALL / PORT_PUBLISHED are config indicators, not network truth. Verify your firewall / reverse proxy.
VERDICT: FAIL: 2 exposure indicator(s), 0 escalated to CRITICAL
Enter fullscreen mode Exit fullscreen mode

The gate flags BIND_ALL anyway, and it prints the note that keeps it honest: this is a config indicator, not network truth, so go verify your firewall. The comment might be true. A VPN interface and a real firewall in front could make 0.0.0.0 perfectly safe. The gate cannot see your firewall, so it reports what the text says and tells you where its knowledge stops. The second finding, master_key=changeme, is the placeholder case that is technically present and functionally absent, which is exactly the failure the honeypot writeup describes for sk-1234. The provider key here is a reference, so no escalation. Two FAILs, no CRITICAL, and a caveat printed in the same breath.

Bad input fails closed

A lint that crashes into a green light is worse than no lint. The last fixture is a directory whose mcp.json is truncated mid-array:

$ python3 exposure_gate.py fixtures/bad
ERROR: mcp.json is not valid JSON: Expecting ',' delimiter: line 6 column 1 (char 123)
$ echo $?
2
Enter fullscreen mode Exit fullscreen mode

Exit 2, distinct from the exit 1 of a real finding, so your CI can tell "the config is exposed" apart from "I could not read the config." A path that is not a directory, a folder with no recognizable AI-backend config at all, and a malformed JSON manifest all land on exit 2. The empty-directory case is deliberate: an empty scan returns exit 2, never a smug exit 0, because a clean bill on nothing read is the most dangerous output a security tool can print.

I ran each of the five scenarios twice and hashed the full STDOUT both times (each value below is the SHA-256 of STDOUT with the trailing newline stripped, so hash it the same way to reproduce it). Clean is 25302df7..., flipped is d4ca3dd3..., violating is b426a505..., edge is 93ed5200..., bad is 8613da88..., and every hash matched its rerun on Python 3.13.5, offline. Findings are sorted by file, line, and check name, so the output never wobbles between runs. Determinism is not decoration here. A gate whose output shifts run to run cannot be a CI gate, because the diff you review has to be the config's fault, never the tool's mood.

Where this sits next to the neighbors

This is a spoke on the pre-execution gate for AI agents cluster, and it is the earliest one: the checks here run before the service is reachable, not before an action is taken. A few neighbors do adjacent things, and the differences are the point.

  • The supply-chain gate that runs before npm install guards the moment a dependency name resolves. This one guards the moment a service starts listening. Both are pre-something gates on a JSON or YAML file, aimed at different first requests.
  • The secret packaging gap is about a secret that leaks into a published artifact through a files allowlist. This gate does not care whether the key leaks out. It cares that a running port lets the world in.
  • The credential leak at the router boundary is about credentials flowing outward through a router into some destination you did not vet. This is the inbound mirror: the router or backend itself listening to the whole internet.

Four checks about the same object, exposure, each looking at a different face of it. None of them overlaps because the object under the check is different every time: the dependency name, the packaged file, the outbound destination, the inbound bind.

What this is NOT

I would rather undersell this than have you run it as something it cannot be.

  • It is not a port scanner and not Shodan. It opens no sockets and touches no network. That is the design, and it is also the honest limit: a green run means the text looks closed, never that the internet cannot reach you.
  • It reads the config, not the network. A firewall can seal a 0.0.0.0 bind and a reverse proxy can publish a 127.0.0.1 one. Every verdict is an indicator in the text, and the tool prints that caveat itself on every network finding.
  • AUTH_ABSENT and PLACEHOLDER_KEY read the file, not the running proxy. LiteLLM can take its master_key from the LITELLM_MASTER_KEY environment variable, a CLI flag, or a secret manager, none of which this lint can see. So AUTH_ABSENT's printed "accepts any key value" means "no master_key in this file." Confirm auth is not supplied some other way before you trust it. And BIND_ALL flags any HOST=0.0.0.0, LLM service or not: a 0.0.0.0 from an unrelated service in the same folder will FAIL the gate too, so read the file it names before acting.
  • It is not a secret scanner. PAID_KEYS_BEHIND_OPEN_DOOR flags a literal provider key sitting in the same directory as a finding. Directory adjacency is the whole signal: it does not read the key, test its validity, confirm the port is reachable, or prove the key even belongs to the exposed service.
  • Absence of findings is not a clean bill of health. The gate covers five patterns drawn from one honeypot writeup, and it reads only the exact filenames in the one directory you point it at. The port check knows three ports (11434, 4000, 8000) in the short host:container compose form; a backend published on another port, in Compose long-form, over IPv6 [::], through network_mode: host, in a docker-compose.prod.yml, or in a nested folder passes clean. A green run is not proof of a closed door.
  • The numbers here are fixture units, not a production measurement. The 6 findings and 2 CRITICAL describe this post's synthetic directory. Run it on your own configs to get a number that means something about your stack.

The question I actually want answered

Where does your bind address live, honestly? A checked-in .env, a Helm value, a systemd Environment= line, a Terraform variable three modules deep, or a shell export somebody ran once on the box and nobody wrote down. I suspect the last one is more common than any of us would admit, and it is the one no lint can reach. If your bind address is somewhere a program can read before deploy, this gate can check it. If it is in someone's shell history, that is the gap I most want to hear about.

If this was useful, follow along here for the next runnable gate in the series, and drop the widest bind you have ever found on a box that "was only for testing" in the comments. I read every one.

Top comments (0)