aisvs-scanner 1.0.0__py3-none-any.whl
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.
- aisvs_scanner/__init__.py +19 -0
- aisvs_scanner/__main__.py +6 -0
- aisvs_scanner/analyzers/__init__.py +4 -0
- aisvs_scanner/analyzers/artifact.py +162 -0
- aisvs_scanner/analyzers/mcp_config.py +185 -0
- aisvs_scanner/analyzers/taint.py +322 -0
- aisvs_scanner/catalog.py +150 -0
- aisvs_scanner/cli.py +193 -0
- aisvs_scanner/data/aisvs-1.01.json +2147 -0
- aisvs_scanner/data/aisvs-1.02-dev.json +2147 -0
- aisvs_scanner/engine.py +283 -0
- aisvs_scanner/models.py +162 -0
- aisvs_scanner/profile.py +131 -0
- aisvs_scanner/reporters/__init__.py +16 -0
- aisvs_scanner/reporters/csv_rep.py +36 -0
- aisvs_scanner/reporters/html_rep.py +708 -0
- aisvs_scanner/reporters/json_rep.py +12 -0
- aisvs_scanner/reporters/markdown.py +68 -0
- aisvs_scanner/reporters/sarif.py +82 -0
- aisvs_scanner/reporters/terminal.py +120 -0
- aisvs_scanner/rules/__init__.py +20 -0
- aisvs_scanner/rules/base.py +163 -0
- aisvs_scanner/rules/r_agents_mcp.py +191 -0
- aisvs_scanner/rules/r_io_access.py +143 -0
- aisvs_scanner/rules/r_model_infra.py +155 -0
- aisvs_scanner/rules/r_rag_robust_monitor.py +118 -0
- aisvs_scanner/util.py +52 -0
- aisvs_scanner/walker.py +157 -0
- aisvs_scanner-1.0.0.dist-info/METADATA +413 -0
- aisvs_scanner-1.0.0.dist-info/RECORD +34 -0
- aisvs_scanner-1.0.0.dist-info/WHEEL +5 -0
- aisvs_scanner-1.0.0.dist-info/entry_points.txt +2 -0
- aisvs_scanner-1.0.0.dist-info/licenses/LICENSE +176 -0
- aisvs_scanner-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""OWASP AISVS Scanner package."""
|
|
2
|
+
from .catalog import load as load_catalog
|
|
3
|
+
from .engine import SCANNER_VERSION, ScanConfig, Scanner
|
|
4
|
+
from .models import Finding, Requirement, RequirementResult, ScanResult, Severity, Status
|
|
5
|
+
|
|
6
|
+
__version__ = SCANNER_VERSION
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"SCANNER_VERSION",
|
|
10
|
+
"Finding",
|
|
11
|
+
"Requirement",
|
|
12
|
+
"RequirementResult",
|
|
13
|
+
"ScanConfig",
|
|
14
|
+
"ScanResult",
|
|
15
|
+
"Scanner",
|
|
16
|
+
"Severity",
|
|
17
|
+
"Status",
|
|
18
|
+
"load_catalog",
|
|
19
|
+
]
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Binary and serialized model artifact analyzer.
|
|
2
|
+
|
|
3
|
+
Inspects model files (.pkl, .pt, .bin, .joblib, .onnx, .safetensors, .h5, .gguf):
|
|
4
|
+
- Scans pickle streams for dangerous globals (os.system, eval, exec, subprocess, posix._spawn, etc.)
|
|
5
|
+
- Detects unsafe formats vs safe serialization formats (safetensors, GGUF)
|
|
6
|
+
- Identifies oversized or suspicious archives / zip slips
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import io
|
|
11
|
+
import os
|
|
12
|
+
import pickle
|
|
13
|
+
import struct
|
|
14
|
+
from typing import List, Set, Tuple
|
|
15
|
+
|
|
16
|
+
from ..models import Evidence, Finding, Location, Severity
|
|
17
|
+
from ..walker import FileCtx
|
|
18
|
+
|
|
19
|
+
DANGEROUS_PICKLE_GLOBALS = {
|
|
20
|
+
("os", "system"),
|
|
21
|
+
("os", "popen"),
|
|
22
|
+
("os", "posix_spawn"),
|
|
23
|
+
("os", "spawnlp"),
|
|
24
|
+
("os", "spawnlpe"),
|
|
25
|
+
("posix", "system"),
|
|
26
|
+
("nt", "system"),
|
|
27
|
+
("subprocess", "Popen"),
|
|
28
|
+
("subprocess", "call"),
|
|
29
|
+
("subprocess", "check_call"),
|
|
30
|
+
("subprocess", "check_output"),
|
|
31
|
+
("subprocess", "run"),
|
|
32
|
+
("builtins", "eval"),
|
|
33
|
+
("builtins", "exec"),
|
|
34
|
+
("__builtin__", "eval"),
|
|
35
|
+
("__builtin__", "exec"),
|
|
36
|
+
("builtins", "compile"),
|
|
37
|
+
("builtins", "getattr"),
|
|
38
|
+
("builtins", "__import__"),
|
|
39
|
+
("importlib", "import_module"),
|
|
40
|
+
("pty", "spawn"),
|
|
41
|
+
("commands", "getoutput"),
|
|
42
|
+
("sys", "modules"),
|
|
43
|
+
("socket", "socket"),
|
|
44
|
+
("urllib.request", "urlopen"),
|
|
45
|
+
("requests", "get"),
|
|
46
|
+
("requests", "post"),
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
SAFE_EXTENSIONS = {".safetensors", ".gguf"}
|
|
50
|
+
DANGEROUS_EXTENSIONS = {".pkl", ".pickle", ".joblib", ".pt", ".pth", ".bin", ".ckpt", ".dill"}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _RestrictedUnpickler(pickle.Unpickler):
|
|
54
|
+
def __init__(self, *args, **kwargs):
|
|
55
|
+
super().__init__(*args, **kwargs)
|
|
56
|
+
self.found_globals: List[Tuple[str, str]] = []
|
|
57
|
+
|
|
58
|
+
def find_class(self, module, name):
|
|
59
|
+
self.found_globals.append((module, name))
|
|
60
|
+
return object
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _scan_pickle_bytes(raw: bytes) -> List[Tuple[str, str]]:
|
|
64
|
+
"""Scan raw bytes for pickle GLOBAL opcodes without executing arbitrary code."""
|
|
65
|
+
globals_found: List[Tuple[str, str]] = []
|
|
66
|
+
|
|
67
|
+
# Try using opcode disassembler or restricted unpickler
|
|
68
|
+
try:
|
|
69
|
+
unpickler = _RestrictedUnpickler(io.BytesIO(raw))
|
|
70
|
+
while True:
|
|
71
|
+
try:
|
|
72
|
+
unpickler.load()
|
|
73
|
+
except (pickle.UnpicklingError, EOFError):
|
|
74
|
+
break
|
|
75
|
+
except Exception:
|
|
76
|
+
break
|
|
77
|
+
globals_found.extend(unpickler.found_globals)
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
# Fallback byte pattern search for GLOBAL opcode: b'c' <module>\n<name>\n or b'\x93' (SHORT_BINUNICODE pair)
|
|
82
|
+
if not globals_found:
|
|
83
|
+
import re
|
|
84
|
+
for m in re.finditer(rb'c([a-zA-Z0-9_.]+)\n([a-zA-Z0-9_]+)\n', raw):
|
|
85
|
+
try:
|
|
86
|
+
mod = m.group(1).decode("utf-8")
|
|
87
|
+
name = m.group(2).decode("utf-8")
|
|
88
|
+
globals_found.append((mod, name))
|
|
89
|
+
except Exception:
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
return globals_found
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def analyze(f: FileCtx) -> Tuple[List[Finding], List[Evidence]]:
|
|
96
|
+
findings: List[Finding] = []
|
|
97
|
+
evidence: List[Evidence] = []
|
|
98
|
+
|
|
99
|
+
if f.lang != "artifact":
|
|
100
|
+
return findings, evidence
|
|
101
|
+
|
|
102
|
+
ext = f.ext.lower()
|
|
103
|
+
|
|
104
|
+
if ext in SAFE_EXTENSIONS:
|
|
105
|
+
evidence.append(
|
|
106
|
+
Evidence(
|
|
107
|
+
rule_id="AIS-EV-SAFE-WEIGHTS",
|
|
108
|
+
title=f"Safe weights format ({ext}) deployed",
|
|
109
|
+
requirements=("4.1.2", "6.1.1"),
|
|
110
|
+
location=Location(f.rel, 1, f"Artifact format: {ext}"),
|
|
111
|
+
)
|
|
112
|
+
)
|
|
113
|
+
return findings, evidence
|
|
114
|
+
|
|
115
|
+
# For potentially dangerous serialization formats:
|
|
116
|
+
if ext in DANGEROUS_EXTENSIONS:
|
|
117
|
+
findings.append(
|
|
118
|
+
Finding(
|
|
119
|
+
rule_id="AIS-ARTIFACT-001",
|
|
120
|
+
title=f"Executable serialization format artifact present ({ext})",
|
|
121
|
+
severity=Severity.HIGH,
|
|
122
|
+
requirements=("4.1.2", "6.1.1"),
|
|
123
|
+
message=(
|
|
124
|
+
f"Model artifact `{f.rel}` uses pickle-based/executable format `{ext}`. "
|
|
125
|
+
"Pickle-based weights allow arbitrary code execution upon loading."
|
|
126
|
+
),
|
|
127
|
+
location=Location(f.rel, 1, f"File size: {f.size} bytes"),
|
|
128
|
+
remediation=(
|
|
129
|
+
"Convert weights to a memory-mapped, safe format such as safetensors or GGUF. "
|
|
130
|
+
"If loading PyTorch weights, strictly require weights_only=True or verify digital signature."
|
|
131
|
+
),
|
|
132
|
+
confidence="high",
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Inspect binary for dangerous globals if under 100MB
|
|
137
|
+
if f.size <= 100_000_000:
|
|
138
|
+
try:
|
|
139
|
+
raw_head = f.path.read_bytes()[:10_000_000]
|
|
140
|
+
found_globals = _scan_pickle_bytes(raw_head)
|
|
141
|
+
for mod, name in found_globals:
|
|
142
|
+
if (mod, name) in DANGEROUS_PICKLE_GLOBALS or mod in {"os", "subprocess", "posix", "sys", "builtins"}:
|
|
143
|
+
findings.append(
|
|
144
|
+
Finding(
|
|
145
|
+
rule_id="AIS-ARTIFACT-002",
|
|
146
|
+
title=f"Malicious/dangerous opcode in artifact: {mod}.{name}",
|
|
147
|
+
severity=Severity.CRITICAL,
|
|
148
|
+
requirements=("4.1.2", "6.1.1"),
|
|
149
|
+
message=(
|
|
150
|
+
f"Detected dangerous global reference `{mod}.{name}` inside model artifact `{f.rel}`. "
|
|
151
|
+
"This indicates weaponized or dangerous code embedded in model weights."
|
|
152
|
+
),
|
|
153
|
+
location=Location(f.rel, 1, f"Global: {mod}.{name}"),
|
|
154
|
+
remediation="Quarantine and do not load this artifact. Review source and replace with verified weights.",
|
|
155
|
+
confidence="high",
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
break
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
return findings, evidence
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Analyzer for MCP client configuration files (mcp.json, claude_desktop_config.json, .cursor/mcp.json, ...)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, Dict, Iterable, List, Tuple
|
|
7
|
+
|
|
8
|
+
from ..models import Evidence, Finding, Location, Severity
|
|
9
|
+
from ..walker import FileCtx
|
|
10
|
+
|
|
11
|
+
COVERS = {"10.1.1", "10.1.2", "10.1.3", "10.2.1", "10.3.1", "10.4.7", "10.4.8", "9.2.1", "5.1.2"}
|
|
12
|
+
|
|
13
|
+
MCP_FILE_NAMES = {"mcp.json", ".mcp.json", "claude_desktop_config.json", "mcp_config.json", "cline_mcp_settings.json",
|
|
14
|
+
"mcp_settings.json", "settings.json", "settings.local.json", "config.json"}
|
|
15
|
+
SECRET_KEY = re.compile(r"key|token|secret|password|passwd|auth|credential", re.I)
|
|
16
|
+
ENV_REF = re.compile(r"^\s*(?:\$\{?[\w:.-]+\}?|%[\w]+%|\{\{.*\}\}|<.*>|\$\(.*\)|input:[\w.-]+|\$\{input:[\w.-]+\})\s*$")
|
|
17
|
+
BROAD_PATHS = re.compile(r"^(?:/|~|~/|\$HOME|%USERPROFILE%|[A-Za-z]:\\?|/Users|/home|/etc|/var)/?$")
|
|
18
|
+
PLACEHOLDER = re.compile(r"your[_-]|xxx|example|placeholder|<.*>|changeme|\.\.\.", re.I)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _strip_jsonc(text: str) -> str:
|
|
22
|
+
text = re.sub(r"(?m)^\s*//.*$", "", text)
|
|
23
|
+
text = re.sub(r"/\*.*?\*/", "", text, flags=re.S)
|
|
24
|
+
return re.sub(r",(\s*[}\]])", r"\1", text)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _load(f: FileCtx):
|
|
28
|
+
try:
|
|
29
|
+
return json.loads(f.text)
|
|
30
|
+
except Exception:
|
|
31
|
+
try:
|
|
32
|
+
return json.loads(_strip_jsonc(f.text))
|
|
33
|
+
except Exception:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _servers(doc: Any) -> Dict[str, dict]:
|
|
38
|
+
out: Dict[str, dict] = {}
|
|
39
|
+
|
|
40
|
+
def visit(node: Any):
|
|
41
|
+
if isinstance(node, dict):
|
|
42
|
+
for key in ("mcpServers", "mcp_servers", "servers", "context_servers"):
|
|
43
|
+
v = node.get(key)
|
|
44
|
+
if isinstance(v, dict):
|
|
45
|
+
for name, spec in v.items():
|
|
46
|
+
if isinstance(spec, dict) and any(k in spec for k in ("command", "url", "serverUrl", "httpUrl", "type", "args")):
|
|
47
|
+
out[name] = spec
|
|
48
|
+
mcp = node.get("mcp")
|
|
49
|
+
if isinstance(mcp, dict):
|
|
50
|
+
visit(mcp)
|
|
51
|
+
for v in node.values():
|
|
52
|
+
if isinstance(v, (dict, list)) and v is not mcp:
|
|
53
|
+
visit(v)
|
|
54
|
+
elif isinstance(node, list):
|
|
55
|
+
for v in node:
|
|
56
|
+
visit(v)
|
|
57
|
+
|
|
58
|
+
visit(doc)
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _line_of(f: FileCtx, needle: str) -> int:
|
|
63
|
+
for i, line in enumerate(f.lines, 1):
|
|
64
|
+
if needle in line:
|
|
65
|
+
return i
|
|
66
|
+
return 1
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _unpinned_package(cmd: str, args: List[str]) -> str:
|
|
70
|
+
base = cmd.split("/")[-1].lower()
|
|
71
|
+
pkgs = [a for a in args if isinstance(a, str) and not a.startswith("-")]
|
|
72
|
+
if base in {"npx", "bunx", "pnpx"} or (base in {"pnpm", "yarn", "bun"} and args[:1] in (["dlx"], ["x"])):
|
|
73
|
+
for a in pkgs:
|
|
74
|
+
if a in {"dlx", "x", "exec"}:
|
|
75
|
+
continue
|
|
76
|
+
at = a.rfind("@")
|
|
77
|
+
if at <= 0 or a.endswith("@latest"):
|
|
78
|
+
return a
|
|
79
|
+
return ""
|
|
80
|
+
if base in {"uvx", "pipx"}:
|
|
81
|
+
for a in pkgs:
|
|
82
|
+
if a == "run":
|
|
83
|
+
continue
|
|
84
|
+
if not re.search(r"==|@[\w.]|\.whl$|^git\+.*@", a):
|
|
85
|
+
return a
|
|
86
|
+
return ""
|
|
87
|
+
if base in {"docker", "podman"} and "run" in args:
|
|
88
|
+
imgs = [a for a in args[args.index("run") + 1 :] if not a.startswith("-")]
|
|
89
|
+
# first positional after flags that aren't values is the image (heuristic)
|
|
90
|
+
for a in imgs:
|
|
91
|
+
if "/" in a or ":" in a or re.match(r"^[a-z0-9._-]+$", a):
|
|
92
|
+
if "@sha256:" in a:
|
|
93
|
+
return ""
|
|
94
|
+
if ":" not in a.split("/")[-1] or a.endswith(":latest"):
|
|
95
|
+
return a
|
|
96
|
+
return ""
|
|
97
|
+
return ""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def analyze(f: FileCtx) -> Tuple[List[Finding], List[Evidence]]:
|
|
101
|
+
findings: List[Finding] = []
|
|
102
|
+
evidence: List[Evidence] = []
|
|
103
|
+
if f.lang != "json" or f.size > 1_000_000:
|
|
104
|
+
return findings, evidence
|
|
105
|
+
if f.name.lower() not in MCP_FILE_NAMES and "mcpServers" not in f.text and "mcp_servers" not in f.text and "context_servers" not in f.text:
|
|
106
|
+
return findings, evidence
|
|
107
|
+
doc = _load(f)
|
|
108
|
+
if doc is None:
|
|
109
|
+
return findings, evidence
|
|
110
|
+
servers = _servers(doc)
|
|
111
|
+
if not servers:
|
|
112
|
+
return findings, evidence
|
|
113
|
+
|
|
114
|
+
def add(rule, title, sev, reqs, msg, needle, fix, conf="high"):
|
|
115
|
+
line = _line_of(f, needle)
|
|
116
|
+
findings.append(Finding(rule, title, sev, tuple(reqs.split()), msg, Location(f.rel, line, f.snippet(line)), fix, conf))
|
|
117
|
+
|
|
118
|
+
all_pinned = True
|
|
119
|
+
for name, spec in servers.items():
|
|
120
|
+
cmd = str(spec.get("command", ""))
|
|
121
|
+
args = [str(a) for a in spec.get("args", []) if not isinstance(a, (dict, list))] if isinstance(spec.get("args"), list) else []
|
|
122
|
+
url = str(spec.get("url") or spec.get("serverUrl") or spec.get("httpUrl") or "")
|
|
123
|
+
|
|
124
|
+
pkg = _unpinned_package(cmd, args) if cmd else ""
|
|
125
|
+
if pkg:
|
|
126
|
+
all_pinned = False
|
|
127
|
+
add("AIS-MCPCFG-001", "Unpinned MCP server package", Severity.MEDIUM, "10.1.1",
|
|
128
|
+
f"MCP server `{name}` runs `{cmd} {pkg}` without a pinned version/digest; a compromised release executes on next start",
|
|
129
|
+
pkg, "Pin an exact version (or image digest), verify provenance/signature, and review before upgrading.")
|
|
130
|
+
if cmd.split("/")[-1].lower() in {"sh", "bash", "zsh", "cmd", "cmd.exe", "powershell", "pwsh"} and any(a in {"-c", "/c", "-Command"} for a in args):
|
|
131
|
+
joined = " ".join(args)
|
|
132
|
+
sev = Severity.HIGH if re.search(r"curl|wget|\|\s*(?:ba)?sh|Invoke-WebRequest|iex", joined) else Severity.MEDIUM
|
|
133
|
+
add("AIS-MCPCFG-002", "MCP server launched through a shell wrapper", sev, "10.1.1 10.1.3",
|
|
134
|
+
f"MCP server `{name}` is started via `{cmd} -c ...`", cmd, "Launch the server binary directly with fixed arguments inside a sandbox.")
|
|
135
|
+
if url:
|
|
136
|
+
if url.startswith("http://") and not re.match(r"http://(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|host\.docker\.internal)", url):
|
|
137
|
+
add("AIS-MCPCFG-003", "Remote MCP server over plaintext HTTP", Severity.HIGH, "10.3.1",
|
|
138
|
+
f"MCP server `{name}` connects to `{url}` without TLS", url, "Use https:// with authentication.")
|
|
139
|
+
hdrs = spec.get("headers") if isinstance(spec.get("headers"), dict) else {}
|
|
140
|
+
has_auth = False
|
|
141
|
+
for hk, hv in hdrs.items():
|
|
142
|
+
if SECRET_KEY.search(hk):
|
|
143
|
+
has_auth = True
|
|
144
|
+
if isinstance(hv, str) and not ENV_REF.match(re.sub(r"^(?:Bearer|Basic)\s+", "", hv, flags=re.I)) and not PLACEHOLDER.search(hv):
|
|
145
|
+
add("AIS-MCPCFG-004", "Hard-coded credential in MCP server headers", Severity.HIGH, "5.1.2 10.2.1",
|
|
146
|
+
f"MCP server `{name}` embeds a literal credential in header `{hk}`", hk,
|
|
147
|
+
"Reference an environment variable/secret store and use short-lived OAuth tokens.")
|
|
148
|
+
if not has_auth and url.startswith("http") and not re.match(r"https?://(?:localhost|127\.0\.0\.1|\[::1\])", url) and not spec.get("auth") and not spec.get("oauth"):
|
|
149
|
+
add("AIS-MCPCFG-005", "Remote MCP server configured without visible authentication", Severity.LOW, "10.2.1",
|
|
150
|
+
f"MCP server `{name}` ({url}) has no Authorization header/OAuth setting in this file", url,
|
|
151
|
+
"Require OAuth 2.1 bearer tokens validated by the server for every request (may be handled by client OAuth discovery).", "low")
|
|
152
|
+
env = spec.get("env") if isinstance(spec.get("env"), dict) else {}
|
|
153
|
+
for ek, ev in env.items():
|
|
154
|
+
if SECRET_KEY.search(ek) and isinstance(ev, str) and ev and not ENV_REF.match(ev) and not PLACEHOLDER.search(ev) and len(ev) >= 8:
|
|
155
|
+
add("AIS-MCPCFG-004", "Hard-coded credential in MCP server env", Severity.HIGH, "5.1.2",
|
|
156
|
+
f"MCP server `{name}` embeds a literal secret in env `{ek}`", ek,
|
|
157
|
+
"Reference an environment variable/secret store; rotate the exposed value.")
|
|
158
|
+
for a in args:
|
|
159
|
+
if BROAD_PATHS.match(a.strip()):
|
|
160
|
+
add("AIS-MCPCFG-006", "MCP server granted overly broad file-system scope", Severity.MEDIUM, "10.1.3",
|
|
161
|
+
f"MCP server `{name}` is given `{a}` as an allowed path", a,
|
|
162
|
+
"Scope servers to the narrowest project directory and use read-only mounts/sandboxing.")
|
|
163
|
+
break
|
|
164
|
+
if cmd.split("/")[-1].lower() in {"docker", "podman"} and "run" in args:
|
|
165
|
+
joined = " ".join(args)
|
|
166
|
+
if not re.search(r"--read-only|--cap-drop|--network[ =]none|--security-opt", joined):
|
|
167
|
+
add("AIS-MCPCFG-007", "Containerized MCP server without hardening flags", Severity.LOW, "10.1.3",
|
|
168
|
+
f"MCP server `{name}` runs in a container with no --read-only/--cap-drop/--network restrictions", cmd,
|
|
169
|
+
"Add --read-only, --cap-drop=ALL, --security-opt no-new-privileges and restrict network/mounts.", "medium")
|
|
170
|
+
aa = spec.get("autoApprove") or spec.get("alwaysAllow") or spec.get("auto_approve")
|
|
171
|
+
if aa:
|
|
172
|
+
star = aa == ["*"] or aa is True or (isinstance(aa, list) and "*" in aa)
|
|
173
|
+
add("AIS-MCPCFG-008", "MCP tools auto-approved without user confirmation", Severity.HIGH if star else Severity.MEDIUM, "9.2.1 10.4.7",
|
|
174
|
+
f"MCP server `{name}` auto-approves {'all tools' if star else 'tools: ' + ', '.join(map(str, aa)) if isinstance(aa, list) else 'tools'}",
|
|
175
|
+
"autoApprove" if "autoApprove" in f.text else "alwaysAllow",
|
|
176
|
+
"Require explicit approval for tools with side effects; limit auto-approval to read-only tools.")
|
|
177
|
+
if spec.get("trust") is True:
|
|
178
|
+
add("AIS-MCPCFG-008", "MCP server marked fully trusted (approval bypass)", Severity.HIGH, "9.2.1 10.4.7",
|
|
179
|
+
f"MCP server `{name}` has `trust: true`", '"trust"', "Remove trust flag; approve tool calls explicitly.")
|
|
180
|
+
|
|
181
|
+
if servers and all_pinned:
|
|
182
|
+
evidence.append(Evidence("AIS-EV-MCPCFG-PINNED", "All configured MCP servers are version-pinned", ("10.1.1",), Location(f.rel, 1, f.snippet(1))))
|
|
183
|
+
if re.search(r"allowedMcpServers|enabledMcpjsonServers|allowlist|allowed_servers", f.text):
|
|
184
|
+
evidence.append(Evidence("AIS-EV-MCPCFG-ALLOWLIST", "MCP server allow-list configured", ("10.1.2",), Location(f.rel, 1, "")))
|
|
185
|
+
return findings, evidence
|