sniffmcp-cli 0.4.1__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.
sniffmcp/fleet.py ADDED
@@ -0,0 +1,182 @@
1
+ """Fleet scanning: audit every MCP server a user has installed, in one pass.
2
+
3
+ Config sources:
4
+ - Claude Desktop: claude_desktop_config.json ({"mcpServers": {...}})
5
+ - Claude Code: ~/.claude.json (user-scope "mcpServers" and per-project
6
+ "projects.<path>.mcpServers"), plus ./.mcp.json
7
+ - Cursor: ~/.cursor/mcp.json
8
+ - Windsurf: ~/.codeium/windsurf/mcp_config.json
9
+ - Any JSON file with an "mcpServers" object, or a bare {name: config} map
10
+
11
+ Scanning a stdio entry runs its command, exactly as your client does on
12
+ startup. Per-server failures never abort the fleet scan — one dead server must
13
+ not hide the other fourteen.
14
+ """
15
+ from __future__ import annotations
16
+ import asyncio, json, os
17
+ from pathlib import Path
18
+
19
+ from .client import ConnectError
20
+ from .engine import scan
21
+ from .models import Finding
22
+ from .report import to_json_report, gate
23
+
24
+ CONFIG_CANDIDATES = [
25
+ ("claude-desktop", "~/Library/Application Support/Claude/claude_desktop_config.json"),
26
+ ("claude-desktop", "~/.config/Claude/claude_desktop_config.json"),
27
+ ("claude-desktop", "~/AppData/Roaming/Claude/claude_desktop_config.json"),
28
+ ("cursor", "~/.cursor/mcp.json"),
29
+ ("claude-code", "~/.claude.json"),
30
+ ("claude-code", "./.mcp.json"),
31
+ ("windsurf", "~/.codeium/windsurf/mcp_config.json"),
32
+ ]
33
+
34
+
35
+ def discover_config_paths() -> list[str]:
36
+ found = []
37
+ for _label, p in CONFIG_CANDIDATES:
38
+ pp = Path(os.path.expanduser(p)).resolve()
39
+ if pp.exists() and str(pp) not in found:
40
+ found.append(str(pp))
41
+ return found
42
+
43
+
44
+ def _servers_in(block) -> dict[str, dict]:
45
+ if not isinstance(block, dict):
46
+ return {}
47
+ return {name: cfg for name, cfg in block.items()
48
+ if isinstance(cfg, dict) and ("command" in cfg or "url" in cfg)}
49
+
50
+
51
+ def load_servers(path: str) -> dict[str, dict]:
52
+ raw = json.loads(Path(path).read_text())
53
+ if not isinstance(raw, dict):
54
+ raise ValueError(f"{path}: not a JSON object")
55
+ servers = _servers_in(raw.get("mcpServers")) if "mcpServers" in raw else {}
56
+ # Claude Code keeps project-scoped servers under projects.<path>.mcpServers
57
+ for proj, block in (raw.get("projects") or {}).items():
58
+ for name, cfg in _servers_in((block or {}).get("mcpServers")).items():
59
+ servers.setdefault(f"{name} ({Path(proj).name})", cfg)
60
+ if not servers and "mcpServers" not in raw and "projects" not in raw:
61
+ servers = _servers_in(raw) # bare {name: config} map
62
+ return servers
63
+
64
+
65
+ async def scan_one(name: str, config: dict, fail_on: str = "high", launch: bool = True) -> dict:
66
+ try:
67
+ report, manifest = await scan(config, launch=launch)
68
+ except ConnectError as e:
69
+ return {"name": name, "target": config.get("url") or config.get("command", "?"),
70
+ "status": "error", "error": str(e)}
71
+ out = to_json_report(report.target, report.score, report.grade, report.findings, report.manifest_hash)
72
+ out.update(name=name, transport=report.transport, status="ok", tool_count=report.tool_count,
73
+ tool_names=[t.get("name") for t in manifest.get("tools", [])],
74
+ gate=gate(report.findings, fail_on))
75
+ return out
76
+
77
+
78
+ def tool_collisions(results: list[dict]) -> list[Finding]:
79
+ """The same tool name exposed by two servers: the agent picks one, and a
80
+ malicious server can shadow a trusted one this way."""
81
+ owners: dict[str, list[str]] = {}
82
+ for r in results:
83
+ for t in r.get("tool_names") or []:
84
+ owners.setdefault(t, []).append(r["name"])
85
+ return [Finding("FLEET-01", "medium", f"Tool name '{t}' exposed by {len(s)} servers", ", ".join(s),
86
+ "Remove one, or check which server your client actually routes this name to.", tool_name=t)
87
+ for t, s in sorted(owners.items()) if len(s) > 1]
88
+
89
+
90
+ async def scan_fleet(paths: list[str] | None = None, fail_on: str = "high", launch: bool = True) -> dict:
91
+ if not paths:
92
+ paths = discover_config_paths()
93
+ if not paths:
94
+ raise ValueError("no config files found; pass paths explicitly")
95
+
96
+ servers: dict[str, dict] = {}
97
+ sources: dict[str, str] = {}
98
+ warnings = []
99
+ for p in paths:
100
+ try:
101
+ for name, cfg in load_servers(p).items():
102
+ servers.setdefault(name, cfg) # first definition wins
103
+ sources.setdefault(name, p)
104
+ except Exception as e:
105
+ warnings.append(f"skipping {p}: {e}")
106
+
107
+ sem = asyncio.Semaphore(4) # don't fork-bomb the user's machine
108
+ async def bounded(name, cfg):
109
+ async with sem:
110
+ r = await scan_one(name, cfg, fail_on, launch)
111
+ r["source"] = sources.get(name, "")
112
+ return r
113
+ results = await asyncio.gather(*[bounded(n, c) for n, c in servers.items()])
114
+ # Parallel cold starts (several `npx -y` downloads at once) can time out on a
115
+ # first run. Retry those one at a time; the package cache is warm by now.
116
+ for i, r in enumerate(results):
117
+ if r["status"] == "error" and "timed out" in r["error"] and "command" in servers[r["name"]]:
118
+ retry = await scan_one(r["name"], servers[r["name"]], fail_on, launch)
119
+ retry["source"] = r["source"]
120
+ results[i] = retry
121
+
122
+ ok = [r for r in results if r["status"] == "ok"]
123
+ errs = [r for r in results if r["status"] == "error"]
124
+ scores = [r["score"] for r in ok]
125
+ # one injection pattern across several servers is a bigger story than isolated lows
126
+ freq: dict[str, int] = {}
127
+ for r in ok:
128
+ for cid in {f["check_id"] for f in r["findings"] if not f.get("suppressed")
129
+ and f["severity"] in ("critical", "high")}:
130
+ freq[cid] = freq.get(cid, 0) + 1
131
+ collisions = tool_collisions(ok)
132
+
133
+ summary = {
134
+ "scanned": len(ok), "failed": len(errs), "total": len(results),
135
+ "avg_score": round(sum(scores) / len(scores), 1) if scores else None,
136
+ "grade_distribution": {g: sum(1 for r in ok if r["grade"] == g) for g in "ABCDF"},
137
+ "servers_at_or_below_C": [r["name"] for r in ok if r["grade"] in ("C", "D", "F")],
138
+ "high_or_critical_by_check": dict(sorted(freq.items(), key=lambda kv: -kv[1])),
139
+ "tool_name_collisions": [{"tool": f.tool_name, "servers": f.evidence} for f in collisions],
140
+ "worst": sorted(((r["name"], r["score"]) for r in ok), key=lambda x: x[1])[:5],
141
+ # exit contract: 1 = findings at/above threshold, 3 = some servers not analysed, 0 = clean
142
+ "gate": 1 if any(r.get("gate") == 1 for r in ok) else (3 if errs else 0),
143
+ "warnings": warnings,
144
+ }
145
+ return {"summary": summary, "servers": results, "fail_on": fail_on}
146
+
147
+
148
+ def fleet_console(fleet: dict) -> None:
149
+ s = fleet["summary"]
150
+ print(f"\nFLEET: {s['scanned']}/{s['total']} servers scanned ({s['failed']} unreachable)")
151
+ print(f"avg score {s['avg_score']} grades {s['grade_distribution']}")
152
+ for r in sorted((r for r in fleet["servers"] if r["status"] == "ok"), key=lambda r: r["score"]):
153
+ flagged = [f for f in r["findings"] if f["severity"] in ("critical", "high") and not f.get("suppressed")]
154
+ print(f" {r['score']:3}/100 {r['grade']} {r['name']} ({r['tool_count']} tools)")
155
+ for f in flagged[:5]:
156
+ print(f" [{f['severity']}] {f['title']}")
157
+ for c in s["tool_name_collisions"]:
158
+ print(f" [collision] tool '{c['tool']}' in: {c['servers']}")
159
+ for r in fleet["servers"]:
160
+ if r["status"] == "error":
161
+ print(f" [error] {r['name']}: {r['error'][:200]}")
162
+ for w in s["warnings"]:
163
+ print(f" [warning] {w}")
164
+
165
+
166
+ def fleet_markdown(fleet: dict, title: str = "sniffmcp") -> str:
167
+ """Compact Markdown for a CI job summary."""
168
+ s = fleet["summary"]
169
+ L = [f"### {title}", "",
170
+ f"{s['scanned']}/{s['total']} MCP servers analysed"
171
+ + (f", {s['failed']} not reachable" if s["failed"] else "") + "", "",
172
+ "| server | grade | high / critical findings |", "|---|---|---|"]
173
+ for r in sorted(fleet["servers"], key=lambda r: r.get("score", -1)):
174
+ if r["status"] != "ok":
175
+ L.append(f"| `{r['name']}` | – | not analysed: {r['error'][:80]} |")
176
+ continue
177
+ bad = [f"{f['check_id']} {f['title']}" for f in r["findings"]
178
+ if f["severity"] in ("critical", "high") and not f.get("suppressed")]
179
+ L.append(f"| `{r['name']}` | {r['grade']} ({r['score']}) | {'<br>'.join(bad[:4]) or '—'} |")
180
+ for c in s["tool_name_collisions"]:
181
+ L.append(f"\nTool name `{c['tool']}` is exposed by several servers: {c['servers']}")
182
+ return "\n".join(L)
sniffmcp/injection.py ADDED
@@ -0,0 +1,183 @@
1
+ """Injection analysis for text the model reads: tool descriptions, parameter
2
+ descriptions inside inputSchema, prompt descriptions, server instructions.
3
+
4
+ Calibrated for precision over recall. A rule earns a place here only if it
5
+ rarely fires on legitimate servers ("Send the message to a channel" and a docs
6
+ URL are normal; "do not tell the user" is not). Every rule is run against the
7
+ benign corpus in tests/ before it ships.
8
+ """
9
+ from __future__ import annotations
10
+ import re
11
+ from .models import Finding
12
+
13
+ # (label, severity, regex). Order is irrelevant; each rule fires at most once per text.
14
+ RULES: list[tuple[str, str, re.Pattern]] = [
15
+ ("instruction override", "critical", re.compile(
16
+ r"(?i)\b(ignore|disregard|forget|override)\s+(all\s+|any\s+)?(the\s+|your\s+)?(previous|prior|above|earlier|preceding|other|system)\s+"
17
+ r"(instructions?|prompts?|messages?|rules?|guidelines?)")),
18
+ # "you are now a PLAYER in the game" / "Where you are now" are normal; jailbreak framings are not
19
+ ("role reassignment", "high", re.compile(
20
+ r"(?i)\b(you are now (in )?(dan|developer mode|jailbroken|unrestricted|no longer bound)"
21
+ r"|your new (instructions|role) (is|are)|act as the system)\b")),
22
+ # Self-referential hiding only. Real servers say "never tell the user the UETR alone is
23
+ # enough" (accuracy) and "do not show this wallet ID to the user" (UX); neither hides
24
+ # what the agent is doing. "Don't tell the user about this step" does.
25
+ ("concealment from user", "critical", re.compile(
26
+ r"(?i)(\b(do not|don't|never|must not)\s+(tell|inform|notify|alert)\s+the\s+user"
27
+ r"(\s*([.,;:)!]|$)|\s+(about\s+(this|it|these|that)|that\s+you|what\s+you|you\s+(are|have|did|will)))"
28
+ r"|\b(do not|don't|never)\s+(tell|mention|reveal|disclose)\s+(this|it|these)(\s+\w+)?\s+(to\s+)?the\s+user"
29
+ r"|\b(do not|don't|never)\s+(show|reveal|disclose|mention)\s+(this|these|the)\s+instructions?\b"
30
+ r"|\binstructions?\s+(is|are)\s+for\s+you\s+only"
31
+ r"|\b(do not|don't|never)\s+(mention|reveal|disclose)\s+that\s+you\b"
32
+ r"|without\s+(telling|informing|notifying|alerting)\s+the\s+user"
33
+ r"|hide\s+(this|it)\s+from\s+the\s+user\b(?!'s)"
34
+ r"|the\s+user\s+(must|should)\s+(not|never)\s+(know|see|be told))")),
35
+ # Medium alone (setup docs name config files; SSH *public* keys are meant to be shared).
36
+ # Combined with another indicator it compounds to critical.
37
+ ("sensitive file target", "medium", re.compile(
38
+ r"(?i)(~/\.ssh/(?!\S*\.pub\b)|\bid_(rsa|ed25519|ecdsa)\b(?!\.pub)|\.aws/credentials|\.kube/config"
39
+ r"|\.bash_history|\.zsh_history|\.git-credentials|\.docker/config\.json|/etc/shadow"
40
+ r"|\.(cursor|vscode)/mcp\.json|claude_desktop_config\.json|\.claude\.json)")),
41
+ # paired tags only: "<system>:<id>" placeholders and "[Admin]" labels are common (seen in the wild)
42
+ ("fake privileged tag", "high", re.compile(
43
+ r"(?is)<\s*(important|system|instructions?)\s*>.{3,}?<\s*/\s*\1\s*>|\[\s*system\s*\]\s*[:\-]")),
44
+ ("cross-tool instruction", "medium", re.compile(
45
+ r"(?i)\b(before|after|when|whenever)\s+(using|calling|invoking|running)\s+(any|every|all|other|another)\s+tools?\b")),
46
+ # needs a data object; "send them to https://…/signup" (users) and "POST it to <own API>" are normal
47
+ ("exfiltration directive", "high", re.compile(
48
+ r"(?i)\b(send|post|upload|forward|transmit|exfiltrate)\s+(everything|all\s+(files|data|keys|secrets|messages|history)"
49
+ r"|(the|their|its|all|any|your)\s+(\w+\s+)?(contents?|conversation|chat\s+history|history|keys?|api\s+keys?"
50
+ r"|tokens?|credentials?|secrets?|passwords?|env\w*|\.env))\b(?:[^.\n]|\.(?=\S)){0,60}?\sto\s+"
51
+ r"(https?://|[\w.+-]+@[\w-]+\.)")),
52
+ ("encoded payload", "high", re.compile(r"[A-Za-z0-9+/]{120,}={0,2}")),
53
+ # Text telling the agent to persist something into its *own* configuration (skills, rules,
54
+ # CLAUDE.md / AGENTS.md). It outlives the session and the server. Seen in the wild: 7 of
55
+ # 10,409 registry servers on 2026-10-09 (mostly skill/memory features). Needs an explicit
56
+ # target ("write X to/into/in <config>"); mentions and URL paths like /agents.md don't count.
57
+ ("writes into agent config", "medium", re.compile(
58
+ r"(?is)\b(write|save|create|append|add|put|install|copy|store)\b[^.]{0,100}?(\b(to|into|in|at)\b|[:(])[^.]{0,30}?"
59
+ r"(~/\.claude/(skills|commands|agents)|\.claude/(skills|commands|agents)|\.cursor/rules|\.cursorrules|\.windsurfrules"
60
+ r"|\.codex/|~/\.gemini/|(?<![/\w])(?-i:CLAUDE\.md|AGENTS\.md|GEMINI\.md))")),
61
+ # ...and the dangerous version: fetching or running code into that config.
62
+ ("installs code into agent config", "high", re.compile(
63
+ r"(?is)(~/\.claude/(skills|commands|agents)|\.claude/(skills|commands|agents)|\.cursor/rules|\.codex/)\S*"
64
+ r".{0,200}?\b(curl|wget|npm\s+install|pnpm\s+(add|install)|pip\s+install|git\s+clone|bash\s|sh\s+-c|node\s+\S+\.m?js)"
65
+ r"|\b(curl|wget|git\s+clone)\b.{0,200}?(~/\.claude/(skills|commands|agents)|\.cursor/rules|\.codex/)")),
66
+ ]
67
+
68
+ # A rule that matches a *mention* (a security tool listing "ignore previous instructions"
69
+ # as a pattern it detects, or "never allow X to override system rules") is not an attack.
70
+ MENTION_BEFORE = re.compile(
71
+ r"(?i)([\"'“‘(/`]\s*$|\b(never|not|don't|do not)\s+(allow|let)\b[^.]{0,40}$"
72
+ r"|\b(detect|detects|flag|flags|block|blocks|catch|catches|against|patterns?|phrases?|phrasing|such as|e\.g\.|like|attempts?\s+to)\b[^.]{0,60}$)")
73
+ MENTION_RULES = {"instruction override", "role reassignment"}
74
+
75
+
76
+ def looks_encoded(s: str) -> bool:
77
+ """Base64 payloads mix cases and digits with few slashes; hex strings, slash-separated
78
+ word lists and long identifiers don't."""
79
+ if re.fullmatch(r"[0-9a-fA-F]+", s) or s.count("/") > len(s) / 25:
80
+ return False
81
+ return bool(re.search(r"[a-z]", s) and re.search(r"[A-Z]", s) and re.search(r"[0-9]", s))
82
+
83
+
84
+ # Invisible / direction-control characters have no business in text a model reads:
85
+ # zero-width, bidi overrides, and Unicode tag characters (ASCII smuggling).
86
+ HIDDEN_CHARS = re.compile("[​-‏‪-‮⁠-⁤⁦-⁩\U000e0000-\U000e007f]")
87
+
88
+ JOINER_SCRIPTS = re.compile("[\u0600-\u06ff\u0750-\u077f\u0900-\u0dff\U0001f300-\U0001faff]")
89
+ LONG_DESCRIPTION = 2500
90
+
91
+
92
+ def _counts(label: str, text: str, m: re.Match) -> bool:
93
+ if label == "encoded payload":
94
+ return looks_encoded(m.group(0))
95
+ if label in MENTION_RULES:
96
+ return not MENTION_BEFORE.search(text[max(0, m.start() - 80):m.start()])
97
+ return True
98
+
99
+
100
+ def scan_text(text: str) -> list[tuple[str, str, str]]:
101
+ """Return [(label, severity, snippet)] for one piece of model-visible text."""
102
+ if not text:
103
+ return []
104
+ hits = []
105
+ for label, sev, rx in RULES:
106
+ m = next((m for m in rx.finditer(text) if _counts(label, text, m)), None)
107
+ if m:
108
+ a, b = max(0, m.start() - 30), min(len(text), m.end() + 30)
109
+ hits.append((label, sev, text[a:b].replace("\n", " ")))
110
+ hidden = HIDDEN_CHARS.findall(text)
111
+ if hidden and JOINER_SCRIPTS.search(text):
112
+ # ZWNJ/ZWJ are ordinary in Persian, Arabic, Indic scripts and emoji sequences
113
+ hidden = [c for c in hidden if c not in "\u200c\u200d"]
114
+ if hidden:
115
+ codes = sorted({f"U+{ord(c):04X}" for c in hidden})
116
+ hits.append(("hidden unicode characters", "critical", f"{len(hidden)} chars: {', '.join(codes[:6])}"))
117
+ # Independent indicators in one text compound: a fake <IMPORTANT> tag on its own
118
+ # might be sloppy docs, but a tag plus a credential path is an attack.
119
+ serious = {label for label, sev, _ in hits if sev in ("critical", "high") or label == "sensitive file target"}
120
+ if len(serious) >= 2 and not any(sev == "critical" for _, sev, _ in hits):
121
+ hits.append(("multiple injection indicators", "critical", ", ".join(sorted(serious))))
122
+ return hits
123
+
124
+
125
+ def schema_texts(schema: dict, path: str = "") -> list[tuple[str, str]]:
126
+ """Every description/title/default/enum string nested in a JSON schema."""
127
+ out = []
128
+ if not isinstance(schema, dict):
129
+ return out
130
+ for key in ("description", "title"):
131
+ if isinstance(schema.get(key), str):
132
+ out.append((f"{path or 'schema'}.{key}", schema[key]))
133
+ if isinstance(schema.get("default"), str):
134
+ out.append((f"{path or 'schema'}.default", schema["default"]))
135
+ for name, sub in (schema.get("properties") or {}).items():
136
+ out.extend(schema_texts(sub, f"{path}.{name}" if path else name))
137
+ for key in ("items", "additionalProperties"):
138
+ if isinstance(schema.get(key), dict):
139
+ out.extend(schema_texts(schema[key], f"{path}[]" if path else "[]"))
140
+ for key in ("anyOf", "oneOf", "allOf"):
141
+ for i, sub in enumerate(schema.get(key) or []):
142
+ out.extend(schema_texts(sub, f"{path}<{key}{i}>"))
143
+ return out
144
+
145
+
146
+ def tool_texts(tool: dict) -> list[tuple[str, str]]:
147
+ """(location, text) for every model-visible string on a tool."""
148
+ out = [("description", tool.get("description") or "")]
149
+ if tool.get("title"):
150
+ out.append(("title", tool["title"]))
151
+ out.extend(schema_texts(tool.get("inputSchema") or {}))
152
+ return out
153
+
154
+
155
+ def analyze_descriptions(tools: list[dict], llm_hook=None) -> list[Finding]:
156
+ """Heuristic pass over every tool (INJ-HEUR), plus an optional second opinion
157
+ (INJ-LLM). llm_hook: (name, description) -> ("clean"|"suspicious", reason)."""
158
+ findings = []
159
+ for t in tools or []:
160
+ name = t.get("name")
161
+ for where, text in tool_texts(t):
162
+ for label, sev, snippet in scan_text(text):
163
+ findings.append(Finding(
164
+ "INJ-HEUR", sev, f"Injection indicator in tool '{name}': {label}",
165
+ f"{where}: …{snippet}…",
166
+ "Text in tool metadata is read by the model as instructions. Do not install, "
167
+ "or pin and review the server source before trusting it.",
168
+ tool_name=name))
169
+ desc = t.get("description") or ""
170
+ if len(desc) > LONG_DESCRIPTION:
171
+ findings.append(Finding(
172
+ "INJ-HEUR", "info", f"Very long description on '{name}' ({len(desc)} chars)",
173
+ "Long descriptions cost context on every turn and make hidden instructions easier to bury.",
174
+ "Review the full text once.", tool_name=name))
175
+ if llm_hook:
176
+ try:
177
+ verdict, reason = llm_hook(name, desc)
178
+ except Exception as e:
179
+ verdict, reason = "error", str(e)
180
+ if verdict == "suspicious":
181
+ findings.append(Finding("INJ-LLM", "medium", f"LLM flagged description of '{name}'",
182
+ reason, "Review this description manually.", tool_name=name))
183
+ return findings
sniffmcp/models.py ADDED
@@ -0,0 +1,53 @@
1
+ """Core data types shared by checks, scoring, reports and the watcher."""
2
+ from __future__ import annotations
3
+ import hashlib, json, time
4
+ from dataclasses import dataclass, field, asdict
5
+
6
+
7
+ @dataclass
8
+ class Finding:
9
+ check_id: str
10
+ severity: str # critical | high | medium | low | info
11
+ title: str
12
+ evidence: str = ""
13
+ remediation: str = ""
14
+ tool_name: str | None = None
15
+ kind: str = "" # for drift findings: breaking | risky | benign
16
+ suppressed: bool = False
17
+
18
+
19
+ @dataclass
20
+ class ScanReport:
21
+ target: str
22
+ transport: str
23
+ score: int
24
+ grade: str
25
+ findings: list[Finding]
26
+ manifest_hash: str
27
+ tool_count: int
28
+ scanned_at: float = field(default_factory=time.time)
29
+
30
+ def to_dict(self) -> dict:
31
+ d = asdict(self)
32
+ d["findings"] = [asdict(f) for f in self.findings]
33
+ return d
34
+
35
+
36
+ def _tool_key(t: dict) -> dict:
37
+ return {"name": t.get("name"), "description": t.get("description") or "",
38
+ "inputSchema": t.get("inputSchema") or {}, "annotations": t.get("annotations") or {}}
39
+
40
+
41
+ def canonical_manifest_hash(tools, resources=None, prompts=None) -> str:
42
+ """SHA-256 over everything the agent sees: tool names, descriptions, input
43
+ schemas (parameter descriptions are an injection surface too), annotations,
44
+ resources and prompts. Order-independent."""
45
+ blob = {
46
+ "tools": sorted((_tool_key(t) for t in tools or []), key=lambda t: t["name"] or ""),
47
+ "resources": sorted(({"uri": str(r.get("uri", "")), "name": r.get("name"),
48
+ "description": r.get("description") or ""}
49
+ for r in resources or []), key=lambda r: r["uri"]),
50
+ "prompts": sorted(({"name": p.get("name"), "description": p.get("description") or ""}
51
+ for p in prompts or []), key=lambda p: p["name"] or ""),
52
+ }
53
+ return hashlib.sha256(json.dumps(blob, sort_keys=True).encode()).hexdigest()
sniffmcp/osv.py ADDED
@@ -0,0 +1,132 @@
1
+ """Known-malware and known-vulnerability lookups via OSV.dev (free, public, no key).
2
+
3
+ OSV aggregates the OpenSSF malicious-packages feed (IDs "MAL-…") and vulnerability
4
+ databases (GHSA, PYSEC, CVE aliases). We use:
5
+ POST /v1/querybatch up to 1000 (package, version) queries per call -> vuln IDs
6
+ GET /v1/vulns/{id} summary, aliases, severity for each ID we need to show
7
+
8
+ Checks run *before* a stdio server is spawned: a scanner must not execute a package
9
+ that is already known to be malware in order to find out it is malicious.
10
+ Set SNIFFMCP_OFFLINE=1 to skip all lookups.
11
+ """
12
+ from __future__ import annotations
13
+ import asyncio, os, re
14
+
15
+ import httpx2
16
+
17
+ from . import __version__
18
+ from .models import Finding
19
+
20
+ API = "https://api.osv.dev/v1"
21
+ ECOSYSTEM = {"npm": "npm", "pypi": "PyPI"}
22
+ BATCH = 1000
23
+ UA = {"User-Agent": f"sniffmcp/{__version__}"}
24
+ GHSA_SEVERITY = {"CRITICAL": "high", "HIGH": "high", "MODERATE": "medium", "MEDIUM": "medium", "LOW": "low"}
25
+
26
+
27
+ def offline() -> bool:
28
+ return os.environ.get("SNIFFMCP_OFFLINE") == "1"
29
+
30
+
31
+ def split_spec(ecosystem: str, spec: str) -> tuple[str, str | None]:
32
+ """'@scope/pkg@1.2.3' -> ('@scope/pkg', '1.2.3'); 'pkg==1.0' / 'pkg[extra]@1.0' -> ('pkg', '1.0')."""
33
+ if ecosystem == "npm":
34
+ at = spec.rfind("@")
35
+ if at > 0:
36
+ ver = spec[at + 1:]
37
+ return spec[:at], (ver if re.match(r"^v?\d+\.\d+\.\d+", ver) else None)
38
+ return spec, None
39
+ m = re.match(r"^([A-Za-z0-9._-]+)(\[[^\]]*\])?\s*(?:==|@)\s*v?([\w.+-]+)$", spec)
40
+ if m:
41
+ return m.group(1), m.group(3)
42
+ return re.sub(r"\[.*\]$", "", spec.strip()), None
43
+
44
+
45
+ async def latest_version(http, ecosystem: str, name: str) -> str | None:
46
+ """What an unpinned `npx pkg` / `uvx pkg` would run today."""
47
+ try:
48
+ if ecosystem == "npm":
49
+ r = await http.get(f"https://registry.npmjs.org/{name.replace('/', '%2F')}",
50
+ headers={"Accept": "application/vnd.npm.install-v1+json"})
51
+ return (r.json().get("dist-tags") or {}).get("latest") if r.status_code == 200 else None
52
+ r = await http.get(f"https://pypi.org/pypi/{name}/json")
53
+ return (r.json().get("info") or {}).get("version") if r.status_code == 200 else None
54
+ except Exception:
55
+ return None
56
+
57
+
58
+ async def query_batch(http, queries: list[tuple[str, str, str | None]]) -> list[list[str]]:
59
+ """[(ecosystem, name, version|None)] -> [[vuln ids]] in the same order."""
60
+ out: list[list[str]] = []
61
+ for i in range(0, len(queries), BATCH):
62
+ chunk = queries[i:i + BATCH]
63
+ body = {"queries": [{"package": {"name": n, "ecosystem": ECOSYSTEM[e]}, **({"version": v} if v else {})}
64
+ for e, n, v in chunk]}
65
+ r = await http.post(f"{API}/querybatch", json=body)
66
+ r.raise_for_status()
67
+ out.extend([[v["id"] for v in (res.get("vulns") or [])] for res in r.json().get("results", [])])
68
+ return out
69
+
70
+
71
+ async def details(http, ids, concurrency: int = 10) -> dict[str, dict]:
72
+ sem, out = asyncio.Semaphore(concurrency), {}
73
+
74
+ async def one(vid):
75
+ async with sem:
76
+ try:
77
+ r = await http.get(f"{API}/vulns/{vid}")
78
+ out[vid] = r.json() if r.status_code == 200 else {"id": vid}
79
+ except Exception:
80
+ out[vid] = {"id": vid}
81
+ await asyncio.gather(*(one(v) for v in set(ids)))
82
+ return out
83
+
84
+
85
+ def severity(vuln: dict) -> str:
86
+ if vuln.get("id", "").startswith("MAL-"):
87
+ return "critical"
88
+ ghsa = str((vuln.get("database_specific") or {}).get("severity", "")).upper()
89
+ return GHSA_SEVERITY.get(ghsa, "medium")
90
+
91
+
92
+ def describe(vuln: dict) -> str:
93
+ aliases = [a for a in vuln.get("aliases") or [] if a.startswith("CVE-")]
94
+ return " ".join(filter(None, [vuln.get("id"), f"({', '.join(aliases[:2])})" if aliases else "",
95
+ "—", (vuln.get("summary") or "no summary")[:160]]))
96
+
97
+
98
+ async def advisories_for_spec(ecosystem: str, spec: str) -> list[Finding]:
99
+ """Findings for one package spec as it would be launched (pinned, or today's latest)."""
100
+ name, version = split_spec(ecosystem, spec)
101
+ pinned = version is not None
102
+ try:
103
+ async with httpx2.AsyncClient(timeout=20, headers=UA) as http:
104
+ version = version or await latest_version(http, ecosystem, name)
105
+ at_version, any_version = await query_batch(http, [(ecosystem, name, version), (ecosystem, name, None)])
106
+ info = await details(http, at_version + [i for i in any_version if i.startswith("MAL-")])
107
+ except Exception as e:
108
+ return [Finding("SM-12", "info", "OSV lookup failed; package not checked for advisories",
109
+ f"{type(e).__name__}: {e}"[:200], "Retry online, or check https://osv.dev manually.")]
110
+ where = f"{name}@{version}" if version else name
111
+ note = "" if pinned else " (unpinned: this is today's latest, which is what npx/uvx would run)"
112
+ out = []
113
+ for vid in at_version:
114
+ v = info.get(vid, {"id": vid})
115
+ if vid.startswith("MAL-"):
116
+ out.append(Finding("SM-11", "critical", f"Known malicious package: {where}", describe(v) + note,
117
+ "Remove this server now. If it ever ran, rotate every credential it could reach.",
118
+ kind="malware"))
119
+ else:
120
+ out.append(Finding("SM-12", severity(v), f"Known vulnerability in {where}", describe(v) + note,
121
+ "Upgrade to a fixed version (see the advisory).", kind="vulnerability"))
122
+ other_mal = [i for i in any_version if i.startswith("MAL-") and i not in at_version]
123
+ if other_mal:
124
+ out.append(Finding("SM-11", "high", f"Other versions of {name} are known malware",
125
+ ", ".join(describe(info.get(i, {"id": i})) for i in other_mal[:2]),
126
+ "The package or its account was compromised before. Pin a version you have verified.",
127
+ kind="malware"))
128
+ return out
129
+
130
+
131
+ def blocks_launch(findings: list[Finding]) -> bool:
132
+ return any(f.check_id == "SM-11" and f.severity == "critical" for f in findings)