heldfast 0.2.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.
- heldfast/__init__.py +7 -0
- heldfast/__main__.py +4 -0
- heldfast/advisories.py +220 -0
- heldfast/artifacts.py +257 -0
- heldfast/auditlog.py +544 -0
- heldfast/check.py +43 -0
- heldfast/childenv.py +177 -0
- heldfast/cli.py +1207 -0
- heldfast/cli_parser.py +596 -0
- heldfast/clients.py +244 -0
- heldfast/confusables.py +129 -0
- heldfast/coverage.py +386 -0
- heldfast/digest.py +203 -0
- heldfast/discovery.py +241 -0
- heldfast/driftgrade.py +146 -0
- heldfast/enforcement.py +106 -0
- heldfast/feedlock.py +160 -0
- heldfast/fetch.py +153 -0
- heldfast/findings.py +143 -0
- heldfast/gateway.py +937 -0
- heldfast/guard.py +1756 -0
- heldfast/identity.py +200 -0
- heldfast/inspect.py +157 -0
- heldfast/integrity.py +203 -0
- heldfast/jsscan.py +681 -0
- heldfast/lifetime.py +129 -0
- heldfast/llm.py +334 -0
- heldfast/lockfile.py +332 -0
- heldfast/lookup.py +115 -0
- heldfast/model.py +266 -0
- heldfast/parsers.py +252 -0
- heldfast/pkgcache.py +541 -0
- heldfast/policy.py +650 -0
- heldfast/probe.py +532 -0
- heldfast/report/__init__.py +5 -0
- heldfast/report/json_out.py +54 -0
- heldfast/report/sarif.py +147 -0
- heldfast/report/terminal.py +153 -0
- heldfast/resultscreen.py +99 -0
- heldfast/review.py +215 -0
- heldfast/rule_docs.py +715 -0
- heldfast/rules/__init__.py +10 -0
- heldfast/rules/annotations.py +236 -0
- heldfast/rules/base.py +97 -0
- heldfast/rules/composition.py +306 -0
- heldfast/rules/credentials.py +188 -0
- heldfast/rules/drift.py +761 -0
- heldfast/rules/environment.py +301 -0
- heldfast/rules/execution.py +554 -0
- heldfast/rules/poisoning.py +701 -0
- heldfast/rules/presentation.py +163 -0
- heldfast/rules/transport.py +380 -0
- heldfast/secrets.py +108 -0
- heldfast/server.py +449 -0
- heldfast/sessions.py +278 -0
- heldfast/sourcescan.py +462 -0
- heldfast/status.py +356 -0
- heldfast/suppressions.py +144 -0
- heldfast/textdiff.py +168 -0
- heldfast/transparency.py +232 -0
- heldfast/updates.py +257 -0
- heldfast-0.2.0.dist-info/METADATA +362 -0
- heldfast-0.2.0.dist-info/RECORD +66 -0
- heldfast-0.2.0.dist-info/WHEEL +4 -0
- heldfast-0.2.0.dist-info/entry_points.txt +3 -0
- heldfast-0.2.0.dist-info/licenses/LICENSE +202 -0
heldfast/__init__.py
ADDED
heldfast/__main__.py
ADDED
heldfast/advisories.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""What the ecosystem already knows about an npm release, before you take it.
|
|
2
|
+
|
|
3
|
+
The drift feed compares what a server's tools say. The attacks that have
|
|
4
|
+
actually reached MCP servers never changed a word of that. postmark-mcp
|
|
5
|
+
1.0.16 copied every email it sent to its author. The Shai-Hulud worms put an
|
|
6
|
+
install hook into releases of Postman's, Browserbase's and AntV's servers.
|
|
7
|
+
A scanner that promised "your code never leaves the machine" uploaded it,
|
|
8
|
+
`.env` files included. Five malicious releases in eight months, and every one
|
|
9
|
+
would have compared equal to the release before it.
|
|
10
|
+
|
|
11
|
+
A pin keeps you on the version you approved, so none of that reaches you
|
|
12
|
+
until you move. This is for the moment you move. It reads three facts per
|
|
13
|
+
release from two public sources:
|
|
14
|
+
|
|
15
|
+
- advisories, from OSV (api.osv.dev), which carries the OpenSSF
|
|
16
|
+
malicious-packages reports (`MAL-`) and GitHub's advisories (`GHSA-`);
|
|
17
|
+
- when the release was published, and
|
|
18
|
+
- which install scripts it runs, from the npm registry's own record.
|
|
19
|
+
|
|
20
|
+
Every one of those five was reported within nine days of its release, and
|
|
21
|
+
the worm releases within a day. A release is not proposed until it is old
|
|
22
|
+
enough for that to have happened, a reported one is never proposed, and one
|
|
23
|
+
that adds an install script is never quiet. It sends the package name and
|
|
24
|
+
the versions asked about, nothing else.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from typing import Any
|
|
33
|
+
from urllib.error import URLError
|
|
34
|
+
from urllib.parse import quote
|
|
35
|
+
from urllib.request import Request
|
|
36
|
+
|
|
37
|
+
from .fetch import USER_AGENT, urlopen
|
|
38
|
+
|
|
39
|
+
OSV_URL = "https://api.osv.dev/v1/querybatch"
|
|
40
|
+
NPM_URL = "https://registry.npmjs.org/"
|
|
41
|
+
TIMEOUT = 20.0
|
|
42
|
+
# A packument lists every release a package ever had; a busy one runs to a
|
|
43
|
+
# few megabytes. Past this it is not read.
|
|
44
|
+
MAX_BYTES = 64 * 1024 * 1024
|
|
45
|
+
INSTALL_HOOKS = ("preinstall", "install", "postinstall")
|
|
46
|
+
# Every malicious MCP release so far was reported within nine days.
|
|
47
|
+
DEFAULT_MIN_AGE = 14
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AdvisoryError(Exception):
|
|
51
|
+
"""OSV or the npm registry could not be asked."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _read(req: Request) -> Any:
|
|
55
|
+
with urlopen(req, timeout=TIMEOUT) as resp:
|
|
56
|
+
raw = resp.read(MAX_BYTES + 1)
|
|
57
|
+
if len(raw) > MAX_BYTES:
|
|
58
|
+
raise AdvisoryError(f"{req.full_url}: answer larger than {MAX_BYTES} bytes")
|
|
59
|
+
return json.loads(raw.decode("utf-8"))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_json(url: str) -> Any:
|
|
63
|
+
"""GET JSON; None on 404, AdvisoryError otherwise. Tests replace this."""
|
|
64
|
+
try:
|
|
65
|
+
return _read(Request(url, headers={"User-Agent": USER_AGENT,
|
|
66
|
+
"Accept": "application/json"}))
|
|
67
|
+
except URLError as exc:
|
|
68
|
+
if getattr(exc, "code", None) == 404:
|
|
69
|
+
return None
|
|
70
|
+
raise AdvisoryError(f"{url}: {exc}") from exc
|
|
71
|
+
except (OSError, ValueError) as exc:
|
|
72
|
+
raise AdvisoryError(f"{url}: {exc}") from exc
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def post_json(url: str, body: Any) -> Any:
|
|
76
|
+
"""POST JSON and parse the answer; AdvisoryError on failure. Tests replace this."""
|
|
77
|
+
req = Request(url, data=json.dumps(body).encode("utf-8"), method="POST",
|
|
78
|
+
headers={"User-Agent": USER_AGENT, "Content-Type": "application/json",
|
|
79
|
+
"Accept": "application/json"})
|
|
80
|
+
try:
|
|
81
|
+
return _read(req)
|
|
82
|
+
except (URLError, OSError, ValueError) as exc:
|
|
83
|
+
raise AdvisoryError(f"{url}: {exc}") from exc
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def known(package: str, versions: list[str]) -> dict[str, list[str]]:
|
|
87
|
+
"""OSV advisory ids for each version of an npm package."""
|
|
88
|
+
if not versions:
|
|
89
|
+
return {}
|
|
90
|
+
body = post_json(OSV_URL, {"queries": [
|
|
91
|
+
{"package": {"name": package, "ecosystem": "npm"}, "version": v} for v in versions]})
|
|
92
|
+
results = body.get("results") if isinstance(body, dict) else None
|
|
93
|
+
if not isinstance(results, list) or len(results) != len(versions):
|
|
94
|
+
raise AdvisoryError(f"{OSV_URL}: not an answer to {len(versions)} queries")
|
|
95
|
+
out: dict[str, list[str]] = {}
|
|
96
|
+
for version, result in zip(versions, results):
|
|
97
|
+
vulns = result.get("vulns") if isinstance(result, dict) else None
|
|
98
|
+
out[version] = sorted(str(v["id"]) for v in vulns or []
|
|
99
|
+
if isinstance(v, dict) and v.get("id"))
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def malware(ids: list[str]) -> list[str]:
|
|
104
|
+
return [i for i in ids if i.startswith("MAL-")]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class Release:
|
|
109
|
+
present: bool # still downloadable from npm
|
|
110
|
+
published: datetime | None
|
|
111
|
+
hooks: dict[str, str] = field(default_factory=dict)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def releases(package: str) -> dict[str, Release]:
|
|
115
|
+
"""Every release npm has a record of: publish time, and install scripts
|
|
116
|
+
for the ones still published. A release that was pulled keeps its time."""
|
|
117
|
+
doc = get_json(NPM_URL + quote(package, safe="@"))
|
|
118
|
+
if doc is None:
|
|
119
|
+
return {}
|
|
120
|
+
if not isinstance(doc, dict):
|
|
121
|
+
raise AdvisoryError(f"registry.npmjs.org: no record for {package}")
|
|
122
|
+
times = doc.get("time") if isinstance(doc.get("time"), dict) else {}
|
|
123
|
+
manifests = doc.get("versions") if isinstance(doc.get("versions"), dict) else {}
|
|
124
|
+
out: dict[str, Release] = {}
|
|
125
|
+
for version in set(times) | set(manifests):
|
|
126
|
+
if version in ("created", "modified"):
|
|
127
|
+
continue
|
|
128
|
+
man = manifests.get(version)
|
|
129
|
+
scripts = man.get("scripts") if isinstance(man, dict) else None
|
|
130
|
+
hooks = {k: str(scripts[k]) for k in INSTALL_HOOKS
|
|
131
|
+
if isinstance(scripts, dict) and k in scripts}
|
|
132
|
+
out[version] = Release(present=isinstance(man, dict),
|
|
133
|
+
published=_when(times.get(version)), hooks=hooks)
|
|
134
|
+
return out
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _when(text: Any) -> datetime | None:
|
|
138
|
+
if not isinstance(text, str):
|
|
139
|
+
return None
|
|
140
|
+
try:
|
|
141
|
+
when = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
142
|
+
except ValueError:
|
|
143
|
+
return None
|
|
144
|
+
return when if when.tzinfo else when.replace(tzinfo=timezone.utc)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class Screen:
|
|
149
|
+
"""What the ecosystem says about the pinned release and the newer ones."""
|
|
150
|
+
|
|
151
|
+
alarm: str = "" # the pinned release itself is reported as malware
|
|
152
|
+
target: str = "" # the newest release it is safe to propose
|
|
153
|
+
passed_over: list[str] = field(default_factory=list)
|
|
154
|
+
concerns: list[str] = field(default_factory=list) # why the target is not quiet
|
|
155
|
+
waiting: bool = False # a newer release is only too young, not refused
|
|
156
|
+
error: str = ""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def screen(package: str, current: str, newer: list[str], min_age: int = DEFAULT_MIN_AGE,
|
|
160
|
+
now: datetime | None = None) -> Screen:
|
|
161
|
+
"""Screen the pinned release, and pick the newest newer one worth proposing.
|
|
162
|
+
|
|
163
|
+
Newest first: a release reported as malware, pulled from npm, or younger
|
|
164
|
+
than `min_age` days is passed over, with the reason, and the next older
|
|
165
|
+
one is considered. The one chosen is not quiet if an advisory names it or
|
|
166
|
+
it runs an install script the pinned release did not.
|
|
167
|
+
"""
|
|
168
|
+
now = now or datetime.now(timezone.utc)
|
|
169
|
+
out = Screen()
|
|
170
|
+
try:
|
|
171
|
+
ids = known(package, [current, *newer])
|
|
172
|
+
rel = releases(package) if newer else {}
|
|
173
|
+
except AdvisoryError as exc:
|
|
174
|
+
out.error = str(exc)
|
|
175
|
+
return out
|
|
176
|
+
bad = malware(ids.get(current, []))
|
|
177
|
+
if bad:
|
|
178
|
+
out.alarm = (f"{package}@{current}, which this config runs, is reported as "
|
|
179
|
+
f"malware ({', '.join(bad)}). Remove it, and rotate every "
|
|
180
|
+
f"credential the machine running it could reach.")
|
|
181
|
+
for version in reversed(newer):
|
|
182
|
+
why = _refusal(version, ids.get(version, []), rel.get(version), min_age, now)
|
|
183
|
+
if why is None:
|
|
184
|
+
out.target = version
|
|
185
|
+
break
|
|
186
|
+
out.passed_over.append(f"{version}: {why}")
|
|
187
|
+
out.waiting = out.waiting or why.startswith("published ")
|
|
188
|
+
if out.target:
|
|
189
|
+
out.concerns = _concerns(out.target, ids.get(out.target, []), rel.get(current),
|
|
190
|
+
rel[out.target])
|
|
191
|
+
return out
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _refusal(version: str, ids: list[str], rel: Release | None, min_age: int,
|
|
195
|
+
now: datetime) -> str | None:
|
|
196
|
+
bad = malware(ids)
|
|
197
|
+
if bad:
|
|
198
|
+
return f"reported as malware ({', '.join(bad)})"
|
|
199
|
+
if rel is None or not rel.present:
|
|
200
|
+
return "no longer on npm; a release pulled after publication is usually pulled for a reason"
|
|
201
|
+
if rel.published is None:
|
|
202
|
+
return "npm gives no publish time, so its age cannot be checked"
|
|
203
|
+
age = (now - rel.published).total_seconds() / 86400
|
|
204
|
+
if age < min_age:
|
|
205
|
+
return (f"published {int(age)} day(s) ago; proposed once it is {min_age} days old, "
|
|
206
|
+
f"after the time malicious releases have taken to be reported")
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _concerns(target: str, ids: list[str], before: Release | None, after: Release) -> list[str]:
|
|
211
|
+
out = []
|
|
212
|
+
if ids:
|
|
213
|
+
out.append(f"{target} has advisories: {', '.join(ids)}")
|
|
214
|
+
old = before.hooks if before is not None and before.present else {}
|
|
215
|
+
for hook, command in sorted(after.hooks.items()):
|
|
216
|
+
if old.get(hook) != command:
|
|
217
|
+
verb = "changes" if hook in old else "adds"
|
|
218
|
+
out.append(f"{target} {verb} an install script, which runs on install before "
|
|
219
|
+
f"any tool is listed: {hook}: {command[:120]}")
|
|
220
|
+
return out
|
heldfast/artifacts.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""The code behind the launch command, not just the command.
|
|
2
|
+
|
|
3
|
+
MCPA016 notices when `"command": "node", "args": ["server.js"]` becomes
|
|
4
|
+
something else. It cannot notice when that line stays byte-identical and
|
|
5
|
+
`server.js` is rewritten, which is the same rug pull one layer down and
|
|
6
|
+
considerably easier to do: editing a file nobody diffs beats editing a config
|
|
7
|
+
somebody committed.
|
|
8
|
+
|
|
9
|
+
So the lockfile records a digest of the scripts a server starts, and a later
|
|
10
|
+
scan compares. The launch command is the promise; this is what was actually
|
|
11
|
+
behind it.
|
|
12
|
+
|
|
13
|
+
What gets hashed is deliberately narrow.
|
|
14
|
+
|
|
15
|
+
A script named in the arguments is hashed -- `server.js`, `main.py`, `run.sh`.
|
|
16
|
+
That is the author's own code and the thing that changes when a server is
|
|
17
|
+
tampered with or updated.
|
|
18
|
+
|
|
19
|
+
The command is hashed only when it is a path. `node` and `python` resolved
|
|
20
|
+
from PATH are not: system interpreters update on their own schedule for
|
|
21
|
+
reasons that have nothing to do with this server, and a rule that fires every
|
|
22
|
+
time someone patches Node is a rule people turn off. `/opt/mcp/bin/server` is
|
|
23
|
+
a path, is the server, and is hashed.
|
|
24
|
+
|
|
25
|
+
Nothing is hashed over the network here. A published package's tarball
|
|
26
|
+
hash is recorded separately (see integrity.py) when the registry answers;
|
|
27
|
+
this module reads files that are already on the machine, which is the
|
|
28
|
+
part nobody else is watching.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import hashlib
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
# Extensions that mean "somebody wrote this", as opposed to "the OS shipped
|
|
40
|
+
# this". An interpreter is not interesting; what it is told to run is.
|
|
41
|
+
SCRIPT_SUFFIXES = {
|
|
42
|
+
".js", ".mjs", ".cjs", ".ts", ".tsx", ".py", ".pyw", ".rb", ".php",
|
|
43
|
+
".sh", ".bash", ".zsh", ".ps1", ".pl", ".lua", ".jar", ".exe", ".bin",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# Programs that run somebody else's code rather than being it. The command is
|
|
47
|
+
# the interpreter whether it was written bare (`node`) or as a path
|
|
48
|
+
# (`/usr/bin/node`, or a venv's `python.exe`), and the docstring above has
|
|
49
|
+
# always said interpreters are not hashed -- but the check was "is it spelled
|
|
50
|
+
# as a path", so an absolute one was hashed anyway. A `uv` that pins its own
|
|
51
|
+
# Python by full path, a venv's `python.exe`, an nvm `node`: every one of them
|
|
52
|
+
# recorded the interpreter's bytes as if they were the server's, so a security
|
|
53
|
+
# patch to Python reported the server as tampered with.
|
|
54
|
+
_VERSIONISH = re.compile(r"[0-9][0-9.-]*")
|
|
55
|
+
|
|
56
|
+
INTERPRETERS = frozenset({
|
|
57
|
+
"python", "python2", "python3", "pythonw", "py", "uv", "uvx", "pipx",
|
|
58
|
+
"node", "nodejs", "npx", "bun", "bunx", "deno", "pnpm", "yarn", "npm",
|
|
59
|
+
"ruby", "perl", "php", "java", "dotnet", "go", "lua",
|
|
60
|
+
"sh", "bash", "zsh", "dash", "fish", "pwsh", "powershell", "cmd",
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_interpreter(text: str) -> bool:
|
|
65
|
+
"""True for `node`, for `/usr/bin/node`, and for a Windows python.exe.
|
|
66
|
+
|
|
67
|
+
A trailing version is still the same program -- `node-18`, `python3.12` --
|
|
68
|
+
but only when what follows the separator is a version. Splitting on `-`
|
|
69
|
+
unconditionally would read `python-wrapper-server` as an interpreter and
|
|
70
|
+
quietly stop hashing somebody's server.
|
|
71
|
+
"""
|
|
72
|
+
stem = Path(text).stem.lower()
|
|
73
|
+
if stem in INTERPRETERS:
|
|
74
|
+
return True
|
|
75
|
+
for sep in (".", "-"):
|
|
76
|
+
head, found, rest = stem.partition(sep)
|
|
77
|
+
if found and head in INTERPRETERS and _VERSIONISH.fullmatch(rest):
|
|
78
|
+
return True
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Hashing something enormous on every scan is a cost with no matching benefit;
|
|
83
|
+
# a server script that large is not the case this is for.
|
|
84
|
+
MAX_BYTES = 16 * 1024 * 1024
|
|
85
|
+
|
|
86
|
+
_SKIP_ARGS = {"-y", "--yes", "-e", "-c", "--"}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def digest_file(path: Path) -> str | None:
|
|
90
|
+
"""sha256 of a file, or None if it cannot or should not be read."""
|
|
91
|
+
try:
|
|
92
|
+
if not path.is_file():
|
|
93
|
+
return None
|
|
94
|
+
if path.stat().st_size > MAX_BYTES:
|
|
95
|
+
return None
|
|
96
|
+
digest = hashlib.sha256()
|
|
97
|
+
with path.open("rb") as handle:
|
|
98
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
99
|
+
digest.update(chunk)
|
|
100
|
+
return digest.hexdigest()
|
|
101
|
+
except OSError:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _looks_like_path(value: str) -> bool:
|
|
106
|
+
return bool(value) and not value.startswith("-") and (
|
|
107
|
+
"/" in value or "\\" in value or Path(value).suffix.lower() in SCRIPT_SUFFIXES
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _candidates(server: Any) -> list[Path]:
|
|
112
|
+
"""Files worth hashing for this server, in the order they appear."""
|
|
113
|
+
out: list[Path] = []
|
|
114
|
+
|
|
115
|
+
# Relative arguments are relative to the config that names them, which is
|
|
116
|
+
# how a project-local `server.js` is meant to resolve.
|
|
117
|
+
try:
|
|
118
|
+
source = str(getattr(server, "source", "") or "")
|
|
119
|
+
base = Path(source).resolve().parent if source else Path.cwd()
|
|
120
|
+
except (OSError, ValueError, TypeError):
|
|
121
|
+
base = Path.cwd()
|
|
122
|
+
|
|
123
|
+
def add(value: str) -> None:
|
|
124
|
+
text = str(value or "").strip().strip('"').strip("'")
|
|
125
|
+
if not text or text in _SKIP_ARGS:
|
|
126
|
+
return
|
|
127
|
+
if not _looks_like_path(text):
|
|
128
|
+
return
|
|
129
|
+
candidate = Path(os.path.expandvars(text)).expanduser()
|
|
130
|
+
for resolved in ([candidate] if candidate.is_absolute() else
|
|
131
|
+
[base / candidate, Path.cwd() / candidate]):
|
|
132
|
+
try:
|
|
133
|
+
if resolved.is_file():
|
|
134
|
+
out.append(resolved.resolve())
|
|
135
|
+
return
|
|
136
|
+
except OSError:
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
command = str(getattr(server, "command", "") or "")
|
|
140
|
+
# Only a command written as a path, and only when that path is not an
|
|
141
|
+
# interpreter. A bare `node` came off PATH; an absolute one is the same
|
|
142
|
+
# program with its location spelled out, and neither is the server.
|
|
143
|
+
if command and ("/" in command or "\\" in command) and not _is_interpreter(
|
|
144
|
+
command):
|
|
145
|
+
add(command)
|
|
146
|
+
|
|
147
|
+
for arg in getattr(server, "args", None) or []:
|
|
148
|
+
add(str(arg))
|
|
149
|
+
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def named_scripts(server: Any) -> list[str]:
|
|
154
|
+
"""Script-looking tokens in the launch command, whether or not they exist.
|
|
155
|
+
|
|
156
|
+
`_candidates` deliberately returns only files that are present, because
|
|
157
|
+
hashing is the point there. Answering "was a script named at all" needs
|
|
158
|
+
the question asked before that filter: a command naming `server.js` when
|
|
159
|
+
no such file is on the machine is a broken launch, not a server with
|
|
160
|
+
nothing to pin, and the two were indistinguishable from the outside.
|
|
161
|
+
"""
|
|
162
|
+
out: list[str] = []
|
|
163
|
+
command = str(getattr(server, "command", "") or "")
|
|
164
|
+
if command and ("/" in command or "\\" in command) and not _is_interpreter(
|
|
165
|
+
command):
|
|
166
|
+
out.append(command)
|
|
167
|
+
for arg in getattr(server, "args", None) or []:
|
|
168
|
+
text = str(arg or "").strip().strip('"').strip("'")
|
|
169
|
+
if text and text not in _SKIP_ARGS and _looks_like_path(text):
|
|
170
|
+
out.append(text)
|
|
171
|
+
return out
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _key_for(server: Any, path: Path) -> str:
|
|
175
|
+
"""How this file is named in the lockfile.
|
|
176
|
+
|
|
177
|
+
Relative to the directory of the config that named the server, with
|
|
178
|
+
forward slashes, whenever the file is inside it -- which is the ordinary
|
|
179
|
+
case, a project-local `server.js` beside the `.mcp.json` that starts it.
|
|
180
|
+
A file outside that tree keeps its absolute path, because there is no
|
|
181
|
+
shorter true name for it; matching does not depend on either spelling.
|
|
182
|
+
"""
|
|
183
|
+
try:
|
|
184
|
+
source = str(getattr(server, "source", "") or "")
|
|
185
|
+
if not source:
|
|
186
|
+
return str(path)
|
|
187
|
+
base = Path(source).resolve().parent
|
|
188
|
+
return path.resolve().relative_to(base).as_posix()
|
|
189
|
+
except (OSError, ValueError, TypeError, AttributeError):
|
|
190
|
+
return str(path)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def artifact_digests(server: Any) -> dict[str, str]:
|
|
194
|
+
"""{name: sha256} for the scripts this server starts."""
|
|
195
|
+
out: dict[str, str] = {}
|
|
196
|
+
for path in _candidates(server):
|
|
197
|
+
key = _key_for(server, path)
|
|
198
|
+
if key in out:
|
|
199
|
+
continue
|
|
200
|
+
digest = digest_file(path)
|
|
201
|
+
if digest:
|
|
202
|
+
out[key] = digest
|
|
203
|
+
return out
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def unmatched(recorded: dict[str, str] | None,
|
|
207
|
+
current: dict[str, str]) -> list[tuple[str, str]]:
|
|
208
|
+
"""Approved (name, digest) pairs that nothing this server starts matches.
|
|
209
|
+
|
|
210
|
+
Compared by content, not by name. The lockfile is meant to travel -- to a
|
|
211
|
+
colleague's checkout, into CI, into a container -- and it recorded the
|
|
212
|
+
absolute path of every script, so the same bytes one directory over read
|
|
213
|
+
as "no longer readable at that path" and MCPA031 fired HIGH on a tree
|
|
214
|
+
nobody had touched. The claim on the tin is one artifact, three places;
|
|
215
|
+
a file that only validates where it was written is a cache, not a lock.
|
|
216
|
+
|
|
217
|
+
What was always being asserted is that the code behind the launch command
|
|
218
|
+
is the code that was reviewed. Its path is how it was found, not what was
|
|
219
|
+
approved, so a digest still present under any name is a match. A digest
|
|
220
|
+
that is present nowhere is the rug pull this rule is for, and still is.
|
|
221
|
+
|
|
222
|
+
Reading an old lockfile keyed by absolute path therefore keeps working
|
|
223
|
+
with no migration: those digests match by content like any other.
|
|
224
|
+
"""
|
|
225
|
+
if not recorded:
|
|
226
|
+
return []
|
|
227
|
+
have = set(current.values())
|
|
228
|
+
return [(name, digest) for name, digest in sorted(recorded.items())
|
|
229
|
+
if digest not in have]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def mismatch(recorded: dict[str, str] | None, server: Any = None) -> str | None:
|
|
233
|
+
"""None if every approved script is still present, by content.
|
|
234
|
+
|
|
235
|
+
An empty pin is not a pass -- it is 'there was no local file to hash',
|
|
236
|
+
which `coverage` already says. This only refuses a digest that moved.
|
|
237
|
+
Scan reports that as MCPA031. Guard and gateway must not start the
|
|
238
|
+
child in the same situation, or the pin is a scan-time opinion.
|
|
239
|
+
"""
|
|
240
|
+
if not recorded:
|
|
241
|
+
return None
|
|
242
|
+
if server is None:
|
|
243
|
+
# No launch command to re-read, so fall back to the recorded names.
|
|
244
|
+
# Only reachable from a caller that has no server; both real ones
|
|
245
|
+
# pass it.
|
|
246
|
+
current = {name: d for name, d in
|
|
247
|
+
((name, digest_file(Path(name))) for name in recorded)
|
|
248
|
+
if d}
|
|
249
|
+
else:
|
|
250
|
+
current = artifact_digests(server)
|
|
251
|
+
missing = unmatched(recorded, current)
|
|
252
|
+
if not missing:
|
|
253
|
+
return None
|
|
254
|
+
name, _ = missing[0]
|
|
255
|
+
if not current:
|
|
256
|
+
return f"approved script {name} is no longer readable"
|
|
257
|
+
return f"approved script {name} has changed since approval"
|