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.
Files changed (66) hide show
  1. heldfast/__init__.py +7 -0
  2. heldfast/__main__.py +4 -0
  3. heldfast/advisories.py +220 -0
  4. heldfast/artifacts.py +257 -0
  5. heldfast/auditlog.py +544 -0
  6. heldfast/check.py +43 -0
  7. heldfast/childenv.py +177 -0
  8. heldfast/cli.py +1207 -0
  9. heldfast/cli_parser.py +596 -0
  10. heldfast/clients.py +244 -0
  11. heldfast/confusables.py +129 -0
  12. heldfast/coverage.py +386 -0
  13. heldfast/digest.py +203 -0
  14. heldfast/discovery.py +241 -0
  15. heldfast/driftgrade.py +146 -0
  16. heldfast/enforcement.py +106 -0
  17. heldfast/feedlock.py +160 -0
  18. heldfast/fetch.py +153 -0
  19. heldfast/findings.py +143 -0
  20. heldfast/gateway.py +937 -0
  21. heldfast/guard.py +1756 -0
  22. heldfast/identity.py +200 -0
  23. heldfast/inspect.py +157 -0
  24. heldfast/integrity.py +203 -0
  25. heldfast/jsscan.py +681 -0
  26. heldfast/lifetime.py +129 -0
  27. heldfast/llm.py +334 -0
  28. heldfast/lockfile.py +332 -0
  29. heldfast/lookup.py +115 -0
  30. heldfast/model.py +266 -0
  31. heldfast/parsers.py +252 -0
  32. heldfast/pkgcache.py +541 -0
  33. heldfast/policy.py +650 -0
  34. heldfast/probe.py +532 -0
  35. heldfast/report/__init__.py +5 -0
  36. heldfast/report/json_out.py +54 -0
  37. heldfast/report/sarif.py +147 -0
  38. heldfast/report/terminal.py +153 -0
  39. heldfast/resultscreen.py +99 -0
  40. heldfast/review.py +215 -0
  41. heldfast/rule_docs.py +715 -0
  42. heldfast/rules/__init__.py +10 -0
  43. heldfast/rules/annotations.py +236 -0
  44. heldfast/rules/base.py +97 -0
  45. heldfast/rules/composition.py +306 -0
  46. heldfast/rules/credentials.py +188 -0
  47. heldfast/rules/drift.py +761 -0
  48. heldfast/rules/environment.py +301 -0
  49. heldfast/rules/execution.py +554 -0
  50. heldfast/rules/poisoning.py +701 -0
  51. heldfast/rules/presentation.py +163 -0
  52. heldfast/rules/transport.py +380 -0
  53. heldfast/secrets.py +108 -0
  54. heldfast/server.py +449 -0
  55. heldfast/sessions.py +278 -0
  56. heldfast/sourcescan.py +462 -0
  57. heldfast/status.py +356 -0
  58. heldfast/suppressions.py +144 -0
  59. heldfast/textdiff.py +168 -0
  60. heldfast/transparency.py +232 -0
  61. heldfast/updates.py +257 -0
  62. heldfast-0.2.0.dist-info/METADATA +362 -0
  63. heldfast-0.2.0.dist-info/RECORD +66 -0
  64. heldfast-0.2.0.dist-info/WHEEL +4 -0
  65. heldfast-0.2.0.dist-info/entry_points.txt +3 -0
  66. heldfast-0.2.0.dist-info/licenses/LICENSE +202 -0
heldfast/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """heldfast -- a zero-dependency security scanner for MCP servers and agent skills."""
2
+
3
+ __version__ = "0.2.0"
4
+
5
+ from .findings import Finding, Location, Severity
6
+
7
+ __all__ = ["Finding", "Location", "Severity", "__version__"]
heldfast/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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"