Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions src/ai_surface/data/mcp/allowlist_bypass_binaries.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
"""
Argument-injection-prone binaries for command-allowlist bypass detection.

Many MCP servers implement a "command allowlist" by checking only argv[0]
(the binary name) against a list like ALLOW_COMMANDS=git, without inspecting
the rest of argv. That's not a real restriction if the allowlisted binary
itself exposes an argument or config-driven way to run arbitrary commands.

This list is intentionally not closed — add entries as new primitives surface.
"""

# Binary name (as it would appear in argv[0]) -> one-line description of its
# argument/config-level execution primitive.
ARG_INJECTION_PRONE_BINARIES = {
"git": "git -c alias.<x>=!<cmd> defines a shell alias; -c core.fsmonitor=<cmd> or "
"-c diff.external=<cmd> also execute arbitrary commands",
"find": "-exec/-execdir runs an arbitrary command on matched files",
"python": "-c '<code>' executes arbitrary Python, including os.system()/subprocess calls",
"python3": "-c '<code>' executes arbitrary Python, including os.system()/subprocess calls",
"perl": "-e '<code>' executes arbitrary Perl, including system()/exec()",
"awk": 'system("<cmd>") inside a BEGIN block executes an arbitrary command',
"gawk": 'system("<cmd>") inside a BEGIN block executes an arbitrary command',
"sed": "GNU sed's e command/flag (s/.../cmd/e or e cmd) executes an arbitrary shell command",
"tar": "--checkpoint-action=exec=<cmd> or --to-command=<cmd> executes an arbitrary command",
"npx": "-c '<script>' runs an arbitrary shell command via the underlying package runner",
"node": "-e '<code>' executes arbitrary JavaScript, including child_process calls",
"bash": "-c '<cmd>' runs an arbitrary command string; allowlisting a shell voids the allowlist",
"sh": "-c '<cmd>' runs an arbitrary command string; allowlisting a shell voids the allowlist",
"env": "env <cmd> execs an arbitrary binary, so the argv[0] check only ever sees env",
"xargs": "xargs <cmd> execs an arbitrary command with input-supplied arguments",
"ssh": "-o ProxyCommand=<cmd>, and -o PermitLocalCommand=yes -o LocalCommand=<cmd>, "
"execute arbitrary commands on the local host",
}

# Allowlist-style env var keys are recognized by splitting the key on "_" and
# matching whole tokens — NOT by substring. Substring matching is what makes
# DISALLOW_COMMANDS (a denylist) look like an allowlist, since "DISALLOW"
# contains "ALLOW".
_DENY_TOKENS = {
"DISALLOW",
"DISALLOWED",
"DENY",
"DENIED",
"BLOCK",
"BLOCKED",
"FORBID",
"FORBIDDEN",
"EXCLUDE",
"EXCLUDED",
"DENYLIST",
"BLOCKLIST",
"BLACKLIST",
"NO",
"NOT",
"NEVER",
}

_ALLOW_TOKENS = {"ALLOW", "ALLOWED", "ALLOWLIST", "WHITELIST", "PERMIT", "PERMITTED"}

_SUBJECT_TOKENS = {
"COMMAND",
"COMMANDS",
"CMD",
"CMDS",
"PATTERN",
"PATTERNS",
"BINARY",
"BINARIES",
"EXECUTABLE",
"EXECUTABLES",
}

# A key must END in one of these, so that a toggle like ALLOW_COMMAND_LOGGING
# (which ends in LOGGING) is not mistaken for a list of allowed commands.
_LIST_TAIL_TOKENS = {"ALLOWLIST", "WHITELIST", "LIST", "ALLOWED", "ALLOW"}

# Characters that separate entries in an allowlist value. Whitespace is
# deliberately NOT a separator: an entry with arguments (e.g. "git status") is
# a full-argument-vector allowlist, which is the safe pattern, not a bare
# binary-name allowlist.
_VALUE_SEPARATORS = [",", ";", "|", "\n"]


def is_allowlist_env_key(key: str) -> bool:
"""Check whether an env var key looks like a command-allowlist declaration."""
tokens = [t for t in key.upper().replace("-", "_").split("_") if t]
if not tokens:
return False

# A deny-style key (DISALLOW_COMMANDS, COMMAND_DENYLIST, NO_ALLOW_COMMANDS)
# describes the opposite of this risk.
if any(t in _DENY_TOKENS for t in tokens):
return False

if not any(t in _ALLOW_TOKENS for t in tokens):
return False
if not any(t in _SUBJECT_TOKENS for t in tokens):
return False

return tokens[-1] in _SUBJECT_TOKENS or tokens[-1] in _LIST_TAIL_TOKENS


def _split_entries(value: str) -> list[str]:
"""Split an allowlist value into individual entries."""
entries = [value]
for sep in _VALUE_SEPARATORS:
next_entries = []
for e in entries:
next_entries.extend(e.split(sep))
entries = next_entries
return [e.strip() for e in entries if e.strip()]


def _normalize_entry(entry: str) -> str:
"""
Reduce an allowlist entry to the bare binary name it allows.

Returns "" for entries that don't name a bare binary — notably entries that
carry arguments ("git status"), which are full-argument-vector allowlists
and are not bypassable by argv[0] alone.
"""
entry = entry.strip().strip("\"'")
# ALLOW_PATTERNS values are often anchored regexes (^git$).
entry = entry.lstrip("^").rstrip("$").strip()
if not entry or any(c.isspace() for c in entry):
return ""
return entry.replace("\\", "/").rsplit("/", 1)[-1].lower()


def detect_unsafe_command_allowlist(env: dict) -> list[str]:
"""
Scan env vars for allowlist-style keys whose values reference a binary
known to have argument-level execution primitives.

Returns a sorted list of matched binary names (empty if none found).
"""
if not env:
return []

matched = set()
for key, value in env.items():
if not is_allowlist_env_key(key):
continue
if isinstance(value, (list, tuple)):
value = ",".join(str(v) for v in value)
if not isinstance(value, str):
continue
for entry in _split_entries(value):
binary = _normalize_entry(entry)
if binary in ARG_INJECTION_PRONE_BINARIES:
matched.add(binary)

return sorted(matched)


def get_reason(binary: str) -> str:
"""Get the one-line execution-primitive description for a binary."""
return ARG_INJECTION_PRONE_BINARIES.get(binary.lower(), "")
1 change: 1 addition & 0 deletions src/ai_surface/data/mcp/owasp_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"filesystem-access": ["LLM06"],
"filesystem-write": ["LLM06"],
"network-access": ["LLM06"],
"unsafe-command-allowlist": ["LLM06"],
"secrets-in-env": ["LLM02", "LLM07"],
"secrets-detected": ["LLM02", "LLM07"],
"admin-credentials": ["LLM02", "LLM06"],
Expand Down
15 changes: 15 additions & 0 deletions src/ai_surface/data/mcp/risk_definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,21 @@
"allowlist of commands and run in a sandbox."
),
},
"unsafe-command-allowlist": {
"severity": "critical",
"description": (
"MCP is configured with a command allowlist (e.g. ALLOW_COMMANDS) that "
"names a binary with its own argument-level execution primitive. "
"Checking argv[0] alone does not restrict execution when the allowlisted "
"binary can be told to run arbitrary commands (git -c alias, find -exec, "
"python -c)."
),
"remediation": (
"Do not rely on binary-name allowlists alone; allowlist the full argument "
"vector, strip dangerous flags (-c, -exec, -e, --checkpoint-action), or run "
"the binary in a sandbox with no ambient credentials or network access."
),
},
"filesystem-access": {
"severity": "high",
"description": (
Expand Down
7 changes: 7 additions & 0 deletions src/ai_surface/detectors/mcp_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from typing import Any

from ..data.mcp import owasp_llm, registry
from ..data.mcp.allowlist_bypass_binaries import detect_unsafe_command_allowlist
from ..data.mcp.risk_definitions import get_risk_flag_info
from ..data.mcp.secret_patterns import detect_secrets
from ..types import CATEGORY_MCP_SERVER as _CATEGORY
Expand Down Expand Up @@ -160,6 +161,12 @@ def _identify_risks(
if command and (command.startswith("./") or command.startswith("/")):
risks.append("local-binary")

# A command allowlist (e.g. ALLOW_COMMANDS) that names a binary with its own
# argument-level execution primitive does not actually restrict execution:
# allowlisting argv[0] alone is bypassable (git -c alias, find -exec, python -c).
if detect_unsafe_command_allowlist(env):
risks.append("unsafe-command-allowlist")

return risks


Expand Down
1 change: 1 addition & 0 deletions src/ai_surface/verdicts.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"broad-permissions",
"high-blast-radius",
"excessive-agency",
"unsafe-command-allowlist",
}

#: Flags that are inference, reputation, or absence-of-signal. Named explicitly
Expand Down
68 changes: 68 additions & 0 deletions tests/test_allowlist_bypass.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Tests for unsafe-command-allowlist detection (argv[0] allowlist bypass).

Ported from mcp-audit. Detects command allowlists (e.g. ALLOW_COMMANDS) that
name a binary with its own argument-level execution primitive, which makes the
argv[0]-only allowlist bypassable (git -c alias, find -exec, python -c).
"""
from __future__ import annotations

from ai_surface.data.mcp.allowlist_bypass_binaries import (
detect_unsafe_command_allowlist,
is_allowlist_env_key,
)


# --- true positives ---------------------------------------------------------- #
def test_bare_risky_binary_flags():
assert detect_unsafe_command_allowlist({"ALLOW_COMMANDS": "git,ls"}) == ["git"]


def test_variant_key_and_separator():
assert detect_unsafe_command_allowlist({"ALLOWED_COMMANDS": "find;cat"}) == ["find"]


def test_prefixed_key_and_path_form():
assert detect_unsafe_command_allowlist(
{"MCP_ALLOW_COMMANDS": "/usr/bin/python"}
) == ["python"]


def test_added_shell_and_exec_binaries():
got = detect_unsafe_command_allowlist({"ALLOW_COMMANDS": "bash,sh,env,xargs,ssh"})
assert got == ["bash", "env", "sh", "ssh", "xargs"]


def test_mixed_full_command_and_bare_binary():
# "git status" is a full-argv allowlist (safe); bare "find" is bypassable.
assert detect_unsafe_command_allowlist({"ALLOW_COMMANDS": "git status, find"}) == [
"find"
]


# --- false positives we must not raise --------------------------------------- #
def test_denylist_key_not_flagged():
assert detect_unsafe_command_allowlist({"DISALLOW_COMMANDS": "git"}) == []
assert is_allowlist_env_key("DISALLOW_COMMANDS") is False


def test_deny_and_block_prefixes_not_flagged():
assert detect_unsafe_command_allowlist({"DENY_COMMANDS": "git"}) == []
assert detect_unsafe_command_allowlist({"BLOCK_COMMANDS": "find"}) == []


def test_unrelated_toggle_not_flagged():
# ALLOW_COMMAND_LOGGING is a boolean toggle, not a command list.
assert detect_unsafe_command_allowlist({"ALLOW_COMMAND_LOGGING": "true"}) == []
assert is_allowlist_env_key("ALLOW_COMMAND_LOGGING") is False


def test_full_argv_allowlist_not_flagged():
assert detect_unsafe_command_allowlist({"ALLOW_COMMANDS": "git status"}) == []


def test_clean_allowlist_not_flagged():
assert detect_unsafe_command_allowlist({"ALLOW_COMMANDS": "ls,cat,echo"}) == []


def test_empty_env():
assert detect_unsafe_command_allowlist({}) == []
Loading