installgate 0.1.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.
- contracts/README.md +11 -0
- contracts/__init__.py +31 -0
- contracts/contracts.py +260 -0
- contracts/contracts.ts +151 -0
- engine/README.md +36 -0
- engine/__init__.py +1 -0
- engine/cache.py +64 -0
- engine/checks.py +695 -0
- engine/clients/__init__.py +64 -0
- engine/clients/huggingface.py +69 -0
- engine/clients/npm.py +91 -0
- engine/clients/osv.py +91 -0
- engine/clients/pypi.py +197 -0
- engine/data/pypi-top5000-2026-10-27.txt +5011 -0
- engine/evidence.py +55 -0
- engine/parsers.py +216 -0
- engine/policy.py +153 -0
- engine/policy.yaml +92 -0
- engine/runtime_floors.yaml +67 -0
- engine/stage1.py +137 -0
- installgate-0.1.0.dist-info/METADATA +439 -0
- installgate-0.1.0.dist-info/RECORD +58 -0
- installgate-0.1.0.dist-info/WHEEL +4 -0
- installgate-0.1.0.dist-info/entry_points.txt +3 -0
- installgate-0.1.0.dist-info/licenses/LICENSE +202 -0
- mcp_server/README.md +19 -0
- mcp_server/__init__.py +1 -0
- mcp_server/agent.py +61 -0
- mcp_server/api.py +141 -0
- mcp_server/check.py +412 -0
- mcp_server/cli.py +270 -0
- mcp_server/limit.py +74 -0
- mcp_server/render.py +241 -0
- mcp_server/samples.py +53 -0
- mcp_server/server.py +27 -0
- mcp_server/spend.py +90 -0
- remediate/README.md +18 -0
- remediate/__init__.py +1 -0
- remediate/candidates.py +118 -0
- remediate/diff.py +13 -0
- remediate/pinning.py +27 -0
- remediate/sandbox.py +1 -0
- router/README.md +60 -0
- router/__init__.py +1 -0
- router/compare.py +572 -0
- router/prompts/explain.txt +8 -0
- router/prompts/extract.txt +3 -0
- router/prompts/rank_substitute.txt +1 -0
- router/receipt.py +41 -0
- router/smoke.py +49 -0
- router/tavily.py +129 -0
- router/tiers.py +321 -0
- router/token_factory.py +267 -0
- validator/README.md +11 -0
- validator/__init__.py +1 -0
- validator/citations.py +64 -0
- validator/failclosed.py +53 -0
- validator/lattice.py +52 -0
engine/checks.py
ADDED
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
"""Stage 1 checks. Pure functions: facts in, Evidence out. They never fetch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
from contracts.contracts import Ecosystem, Evidence, EvidenceKind, TargetRef
|
|
14
|
+
from rapidfuzz.distance import DamerauLevenshtein
|
|
15
|
+
|
|
16
|
+
from engine.evidence import EvidenceBuilder
|
|
17
|
+
|
|
18
|
+
DATA_DIR = Path(__file__).parent / "data"
|
|
19
|
+
FLOORS_PATH = Path(__file__).parent / "runtime_floors.yaml"
|
|
20
|
+
|
|
21
|
+
NEW_PROJECT_DAYS = 30
|
|
22
|
+
NEW_RELEASE_DAYS = 30
|
|
23
|
+
TYPOSQUAT_NEW_PROJECT_DAYS = 7
|
|
24
|
+
TYPOSQUAT_MAX_DISTANCE = 2
|
|
25
|
+
# Below this length a 1-edit distance is normal between unrelated names.
|
|
26
|
+
TYPOSQUAT_MIN_LENGTH = 5
|
|
27
|
+
# Minimum reference names an ecosystem needs before a near-miss means anything,
|
|
28
|
+
# per ecosystem. `None` disables the check for that ecosystem outright.
|
|
29
|
+
#
|
|
30
|
+
# A snapshot smaller than its threshold is a misconfiguration, not a coverage
|
|
31
|
+
# decision: the file was meant to be there and is short, so the check raises and
|
|
32
|
+
# the pipeline fails closed. A `None` is a deliberate "we do not cover this yet".
|
|
33
|
+
MIN_SNAPSHOT_NAMES: dict[Ecosystem, int | None] = {
|
|
34
|
+
# A real top-5000 snapshot is in place (see the file header for its source
|
|
35
|
+
# and date). The bar sits just below it so a truncated or half-written file
|
|
36
|
+
# fails the check instead of quietly shrinking its coverage.
|
|
37
|
+
Ecosystem.PYPI: 4000,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Ecosystems the typosquat check applies to at all. Container images and model
|
|
41
|
+
# repositories are namespaced, so a flat popular-name list is the wrong tool.
|
|
42
|
+
# npm has no vetted popular-name snapshot; exists + pin still run (P1/P9).
|
|
43
|
+
TYPOSQUAT_ECOSYSTEMS = frozenset({Ecosystem.PYPI})
|
|
44
|
+
|
|
45
|
+
# Dated snapshots, fetched by eval/fetch_top_pypi.py. npm has no entry: the
|
|
46
|
+
# check is switched off for it, so there is nothing to point at.
|
|
47
|
+
_SNAPSHOTS = {
|
|
48
|
+
Ecosystem.PYPI: "pypi-top5000-2026-10-27.txt",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_SOURCE_FOR = {
|
|
52
|
+
Ecosystem.PYPI: "pypi",
|
|
53
|
+
Ecosystem.NPM: "npm",
|
|
54
|
+
Ecosystem.HUGGINGFACE: "huggingface",
|
|
55
|
+
Ecosystem.DOCKER: "docker_registry",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SnapshotUnavailable(RuntimeError):
|
|
60
|
+
"""The typosquat reference set is missing or too small to decide anything."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_PEP503_SEPARATORS = re.compile(r"[-_.]+")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def normalize_name(name: str, ecosystem: Ecosystem) -> str:
|
|
67
|
+
"""PEP 503 normalisation for PyPI, plain lowercase elsewhere.
|
|
68
|
+
|
|
69
|
+
`Requests_OAuthlib` and `requests-oauthlib` are the same project to PyPI, so
|
|
70
|
+
comparing them unnormalised reports a two-edit "typosquat" of a package
|
|
71
|
+
against itself.
|
|
72
|
+
"""
|
|
73
|
+
lowered = name.strip().lower()
|
|
74
|
+
if ecosystem is Ecosystem.PYPI:
|
|
75
|
+
return _PEP503_SEPARATORS.sub("-", lowered)
|
|
76
|
+
return lowered
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _parse_iso(raw: Any) -> datetime | None:
|
|
80
|
+
if not raw:
|
|
81
|
+
return None
|
|
82
|
+
try:
|
|
83
|
+
return datetime.fromisoformat(str(raw).replace("Z", "+00:00")).astimezone(UTC)
|
|
84
|
+
except ValueError:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@functools.lru_cache(maxsize=4)
|
|
89
|
+
def load_top_names(ecosystem: Ecosystem) -> tuple[str, ...]:
|
|
90
|
+
"""Dated top-5k snapshot used as the typosquat reference set."""
|
|
91
|
+
filename = _SNAPSHOTS.get(ecosystem)
|
|
92
|
+
if filename is None:
|
|
93
|
+
return ()
|
|
94
|
+
path = DATA_DIR / filename
|
|
95
|
+
if not path.exists():
|
|
96
|
+
return ()
|
|
97
|
+
names = [
|
|
98
|
+
normalize_name(line, ecosystem)
|
|
99
|
+
for line in path.read_text(encoding="utf-8").splitlines()
|
|
100
|
+
if line.strip() and not line.startswith("#")
|
|
101
|
+
]
|
|
102
|
+
# Normalisation collapses spellings, so dedupe while keeping file order.
|
|
103
|
+
return tuple(dict.fromkeys(names))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@functools.lru_cache(maxsize=1)
|
|
107
|
+
def load_runtime_floors() -> dict[str, dict[str, str]]:
|
|
108
|
+
"""Image -> release branch -> patched floor.
|
|
109
|
+
|
|
110
|
+
A single floor per image cannot describe a project that patches several
|
|
111
|
+
branches at once, so floors are keyed by branch (`"8.2"`, `"20"`).
|
|
112
|
+
"""
|
|
113
|
+
if not FLOORS_PATH.exists():
|
|
114
|
+
return {}
|
|
115
|
+
data = yaml.safe_load(FLOORS_PATH.read_text(encoding="utf-8")) or {}
|
|
116
|
+
images = data.get("images") or {}
|
|
117
|
+
out: dict[str, dict[str, str]] = {}
|
|
118
|
+
for image, spec in images.items():
|
|
119
|
+
branches = (spec or {}).get("branches") or {}
|
|
120
|
+
out[str(image)] = {str(b): str(f) for b, f in branches.items()}
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _check_unavailable(
|
|
125
|
+
builder: EvidenceBuilder, target: TargetRef, reason: str, detail: str, **extra: Any
|
|
126
|
+
) -> Evidence:
|
|
127
|
+
"""Soft evidence that a check could not run, so a silent gap cannot read as clean.
|
|
128
|
+
|
|
129
|
+
`reason` must be one of CHECK_UNAVAILABLE_REASONS; the contract enforces it.
|
|
130
|
+
The records come from our own tables (policy_yaml), not from a registry.
|
|
131
|
+
"""
|
|
132
|
+
return builder.build(
|
|
133
|
+
EvidenceKind.CHECK_UNAVAILABLE,
|
|
134
|
+
source="policy_yaml",
|
|
135
|
+
trust="deterministic",
|
|
136
|
+
severity="soft",
|
|
137
|
+
target=target,
|
|
138
|
+
observed={"reason": reason, "detail": detail, **extra},
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def check_exists(
|
|
143
|
+
target: TargetRef, facts: dict[str, Any], builder: EvidenceBuilder
|
|
144
|
+
) -> Evidence | None:
|
|
145
|
+
"""P1: the package does not exist. Hard — a hallucinated name can be squatted tomorrow."""
|
|
146
|
+
if facts.get("exists"):
|
|
147
|
+
return None
|
|
148
|
+
return builder.build(
|
|
149
|
+
EvidenceKind.NOT_FOUND,
|
|
150
|
+
source=_SOURCE_FOR[target.ecosystem], # type: ignore[arg-type]
|
|
151
|
+
trust="deterministic",
|
|
152
|
+
severity="hard",
|
|
153
|
+
target=target,
|
|
154
|
+
observed={"name": target.name, "ecosystem": target.ecosystem.value},
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def check_age(
|
|
159
|
+
target: TargetRef,
|
|
160
|
+
facts: dict[str, Any],
|
|
161
|
+
builder: EvidenceBuilder,
|
|
162
|
+
now: datetime | None = None,
|
|
163
|
+
) -> list[Evidence]:
|
|
164
|
+
"""P6: a brand-new project. Soft. A new release of an old project is not P6."""
|
|
165
|
+
if not facts.get("exists"):
|
|
166
|
+
return []
|
|
167
|
+
now = now or datetime.now(UTC)
|
|
168
|
+
out: list[Evidence] = []
|
|
169
|
+
source = _SOURCE_FOR[target.ecosystem]
|
|
170
|
+
|
|
171
|
+
first = _parse_iso(facts.get("first_upload"))
|
|
172
|
+
if first is not None:
|
|
173
|
+
# A release dated after the clock means the clock is stale, not that the
|
|
174
|
+
# package is from the future. Clamp, or a negative age silently satisfies
|
|
175
|
+
# every "younger than N days" test.
|
|
176
|
+
age_days = max((now - first).days, 0)
|
|
177
|
+
if age_days < NEW_PROJECT_DAYS:
|
|
178
|
+
out.append(
|
|
179
|
+
builder.build(
|
|
180
|
+
EvidenceKind.NEW_PROJECT,
|
|
181
|
+
source=source, # type: ignore[arg-type]
|
|
182
|
+
trust="deterministic",
|
|
183
|
+
severity="soft",
|
|
184
|
+
target=target,
|
|
185
|
+
observed={"first_upload": first.isoformat(), "age_days": age_days},
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
latest = _parse_iso(facts.get("latest_upload"))
|
|
190
|
+
if latest is not None:
|
|
191
|
+
age_days = max((now - latest).days, 0)
|
|
192
|
+
if age_days < NEW_RELEASE_DAYS:
|
|
193
|
+
# A new version of an old project is not P6. numpy shipping 2.5.4
|
|
194
|
+
# is not a review; a brand-new name, or a missing first_upload, is.
|
|
195
|
+
established = first is not None and max((now - first).days, 0) >= NEW_PROJECT_DAYS
|
|
196
|
+
if not established:
|
|
197
|
+
out.append(
|
|
198
|
+
builder.build(
|
|
199
|
+
EvidenceKind.NEW_RELEASE,
|
|
200
|
+
source=source, # type: ignore[arg-type]
|
|
201
|
+
trust="deterministic",
|
|
202
|
+
severity="soft",
|
|
203
|
+
target=target,
|
|
204
|
+
observed={
|
|
205
|
+
"latest_upload": latest.isoformat(),
|
|
206
|
+
"age_days": age_days,
|
|
207
|
+
"latest_version": facts.get("latest_version"),
|
|
208
|
+
},
|
|
209
|
+
)
|
|
210
|
+
)
|
|
211
|
+
return out
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def check_maintainers(
|
|
215
|
+
target: TargetRef,
|
|
216
|
+
facts: dict[str, Any],
|
|
217
|
+
builder: EvidenceBuilder,
|
|
218
|
+
known_maintainers: list[str] | None = None,
|
|
219
|
+
now: datetime | None = None,
|
|
220
|
+
) -> Evidence | None:
|
|
221
|
+
"""P8: the maintainer set changed and a release followed soon after.
|
|
222
|
+
|
|
223
|
+
`known_maintainers` is the previously cached set. With no prior snapshot there is
|
|
224
|
+
nothing to compare against, so this check stays silent rather than guessing.
|
|
225
|
+
"""
|
|
226
|
+
if not facts.get("exists") or known_maintainers is None:
|
|
227
|
+
return None
|
|
228
|
+
current = set(facts.get("maintainers") or [])
|
|
229
|
+
previous = set(known_maintainers)
|
|
230
|
+
if not previous or current == previous:
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
now = now or datetime.now(UTC)
|
|
234
|
+
latest = _parse_iso(facts.get("latest_upload"))
|
|
235
|
+
if latest is None or max((now - latest).days, 0) >= NEW_RELEASE_DAYS:
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
return builder.build(
|
|
239
|
+
EvidenceKind.MAINTAINER_CHANGE,
|
|
240
|
+
source=_SOURCE_FOR[target.ecosystem], # type: ignore[arg-type]
|
|
241
|
+
trust="deterministic",
|
|
242
|
+
severity="soft",
|
|
243
|
+
target=target,
|
|
244
|
+
observed={
|
|
245
|
+
"added": sorted(current - previous),
|
|
246
|
+
"removed": sorted(previous - current),
|
|
247
|
+
"latest_upload": latest.isoformat(),
|
|
248
|
+
},
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _same_version(left: str, right: str) -> bool:
|
|
253
|
+
"""Compare two versions, tolerating `1.0` against `1.0.0` where possible."""
|
|
254
|
+
try:
|
|
255
|
+
from packaging.version import InvalidVersion, Version
|
|
256
|
+
except ImportError: # pragma: no cover - packaging ships with the toolchain
|
|
257
|
+
return left.strip() == right.strip()
|
|
258
|
+
try:
|
|
259
|
+
return Version(left) == Version(right)
|
|
260
|
+
except InvalidVersion:
|
|
261
|
+
return left.strip() == right.strip()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _parse_version(raw: str) -> Any | None:
|
|
265
|
+
try:
|
|
266
|
+
from packaging.version import InvalidVersion, Version
|
|
267
|
+
except ImportError: # pragma: no cover - packaging ships with the toolchain
|
|
268
|
+
return None
|
|
269
|
+
try:
|
|
270
|
+
return Version(raw)
|
|
271
|
+
except InvalidVersion:
|
|
272
|
+
return None
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# Range event keys we know how to read on a version-ordered range.
|
|
276
|
+
_KNOWN_EVENTS = frozenset({"introduced", "fixed", "last_affected"})
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _range_covers(rng: dict[str, Any], version: str | None) -> bool | None:
|
|
280
|
+
"""Evaluate one OSV range against a version.
|
|
281
|
+
|
|
282
|
+
The overwhelmingly common shape for a malicious package is a single
|
|
283
|
+
`introduced: "0"` with no fix: the project was malicious from its first
|
|
284
|
+
release, so every version is covered and no comparison is needed. Treating
|
|
285
|
+
that as undecided downgrades a real BLOCK to REVIEW.
|
|
286
|
+
"""
|
|
287
|
+
events = rng.get("events") or []
|
|
288
|
+
|
|
289
|
+
# A GIT range is ordered by commit, not by version. Mapping one to a release
|
|
290
|
+
# needs the repository, which we do not read, so there is nothing to compare
|
|
291
|
+
# and the answer is "cannot tell" rather than a guess in either direction.
|
|
292
|
+
if str(rng.get("type", "")).upper() == "GIT":
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
# `limit` bounds a GIT traversal and has no version meaning on an
|
|
296
|
+
# ECOSYSTEM or SEMVER range. Seeing one here means the record is shaped in
|
|
297
|
+
# a way this code was not written for; say so instead of ignoring it.
|
|
298
|
+
if any(key not in _KNOWN_EVENTS for event in events for key in event):
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
introduced = [str(e["introduced"]) for e in events if "introduced" in e]
|
|
302
|
+
closing = [
|
|
303
|
+
str(e.get("fixed") or e.get("last_affected"))
|
|
304
|
+
for e in events
|
|
305
|
+
if "fixed" in e or "last_affected" in e
|
|
306
|
+
]
|
|
307
|
+
|
|
308
|
+
if not closing and "0" in introduced:
|
|
309
|
+
return True
|
|
310
|
+
if version is None:
|
|
311
|
+
return None
|
|
312
|
+
|
|
313
|
+
current = _parse_version(version)
|
|
314
|
+
if current is None:
|
|
315
|
+
return None
|
|
316
|
+
|
|
317
|
+
# Pair each introduced with the next closing event, in order.
|
|
318
|
+
lower = _parse_version(introduced[0]) if introduced and introduced[0] != "0" else None
|
|
319
|
+
if introduced and introduced[0] != "0" and lower is None:
|
|
320
|
+
return None
|
|
321
|
+
if lower is not None and bool(current < lower):
|
|
322
|
+
return False
|
|
323
|
+
if not closing:
|
|
324
|
+
return True
|
|
325
|
+
|
|
326
|
+
upper_raw = closing[0]
|
|
327
|
+
upper = _parse_version(upper_raw)
|
|
328
|
+
if upper is None:
|
|
329
|
+
return None
|
|
330
|
+
is_fixed = any("fixed" in e for e in events)
|
|
331
|
+
return bool(current < upper) if is_fixed else bool(current <= upper)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def record_covers(record: dict[str, Any], version: str | None) -> bool | None:
|
|
335
|
+
"""Does this advisory cover `version`? None when it cannot be decided.
|
|
336
|
+
|
|
337
|
+
True - the record names this version, covers the whole project, or has a
|
|
338
|
+
range open from the first release.
|
|
339
|
+
False - the record scopes itself and this release falls outside.
|
|
340
|
+
None - the version is unknown, or a range we cannot order. Undecided must
|
|
341
|
+
never read as clean.
|
|
342
|
+
"""
|
|
343
|
+
if record.get("whole_package"):
|
|
344
|
+
return True
|
|
345
|
+
|
|
346
|
+
versions = [str(v) for v in (record.get("versions") or [])]
|
|
347
|
+
if version is not None and versions and any(_same_version(version, v) for v in versions):
|
|
348
|
+
return True
|
|
349
|
+
|
|
350
|
+
verdicts = [_range_covers(r, version) for r in (record.get("ranges") or [])]
|
|
351
|
+
if any(v is True for v in verdicts):
|
|
352
|
+
return True
|
|
353
|
+
if any(v is None for v in verdicts):
|
|
354
|
+
return None
|
|
355
|
+
|
|
356
|
+
if version is None:
|
|
357
|
+
return None
|
|
358
|
+
if versions or verdicts:
|
|
359
|
+
return False
|
|
360
|
+
return None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def check_osv_malicious(
|
|
364
|
+
target: TargetRef, facts: dict[str, Any], builder: EvidenceBuilder
|
|
365
|
+
) -> Evidence | None:
|
|
366
|
+
"""P2: OSV holds a MAL- record covering this release.
|
|
367
|
+
|
|
368
|
+
A MAL- record is usually scoped to particular versions. `litellm` carries
|
|
369
|
+
MAL-2026-2144 for three releases out of hundreds, so matching on the name
|
|
370
|
+
alone would hard-block every clean version of a widely used package.
|
|
371
|
+
|
|
372
|
+
Severity carries how certain the match is, and the policy turns an
|
|
373
|
+
uncertain one into REVIEW rather than BLOCK:
|
|
374
|
+
hard - the record covers this exact version, or the whole project
|
|
375
|
+
soft - a record exists for the name but this install cannot be placed in
|
|
376
|
+
it: nothing is pinned yet, or the record uses version ranges
|
|
377
|
+
none - the record names versions and this release is not among them
|
|
378
|
+
"""
|
|
379
|
+
if not facts.get("malicious"):
|
|
380
|
+
return None
|
|
381
|
+
|
|
382
|
+
records = facts.get("mal_records")
|
|
383
|
+
if not records:
|
|
384
|
+
# Facts from an older cache or a hand-written fixture carry no version
|
|
385
|
+
# detail, so a name match is the whole of what we know.
|
|
386
|
+
records = [{"id": i, "whole_package": True} for i in (facts.get("mal_ids") or [])]
|
|
387
|
+
|
|
388
|
+
verdicts = [(r, record_covers(r, target.version)) for r in records]
|
|
389
|
+
covering = [r for r, covered in verdicts if covered is True]
|
|
390
|
+
undecided = [r for r, covered in verdicts if covered is None]
|
|
391
|
+
|
|
392
|
+
matched = covering or undecided
|
|
393
|
+
if not matched:
|
|
394
|
+
return None
|
|
395
|
+
|
|
396
|
+
return builder.build(
|
|
397
|
+
EvidenceKind.OSV_MALICIOUS,
|
|
398
|
+
source="osv",
|
|
399
|
+
trust="deterministic",
|
|
400
|
+
severity="hard" if covering else "soft",
|
|
401
|
+
target=target,
|
|
402
|
+
observed={
|
|
403
|
+
"mal_ids": sorted(str(r.get("id")) for r in matched),
|
|
404
|
+
"version_checked": target.version,
|
|
405
|
+
"affected_versions": sorted(
|
|
406
|
+
{str(v) for r in matched for v in (r.get("versions") or [])}
|
|
407
|
+
),
|
|
408
|
+
"match": "version_covered" if covering else "version_undetermined",
|
|
409
|
+
},
|
|
410
|
+
source_url="https://api.osv.dev/v1/query",
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def typosquat_coverage(ecosystem: Ecosystem) -> str | None:
|
|
415
|
+
"""Why this ecosystem has no typosquat coverage, or None when it needs none.
|
|
416
|
+
|
|
417
|
+
Only name-based package registries are in scope. A container image or a
|
|
418
|
+
model repository is addressed by `namespace/name`, and a near-miss there is
|
|
419
|
+
a different problem with different reference data — so the check is not
|
|
420
|
+
applicable rather than missing, and must not hold back an ALLOW.
|
|
421
|
+
"""
|
|
422
|
+
if ecosystem not in TYPOSQUAT_ECOSYSTEMS:
|
|
423
|
+
return None
|
|
424
|
+
if MIN_SNAPSHOT_NAMES.get(ecosystem) is None:
|
|
425
|
+
return (
|
|
426
|
+
f"typosquat check is disabled for {ecosystem.value}: no vetted "
|
|
427
|
+
"popular-name source for this milestone"
|
|
428
|
+
)
|
|
429
|
+
if ecosystem not in _SNAPSHOTS:
|
|
430
|
+
return f"no popular-name reference set exists for {ecosystem.value}"
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def nearest_popular_name(name: str, ecosystem: Ecosystem) -> tuple[str, int] | None:
|
|
435
|
+
"""Closest name in the snapshot, if it is a near-miss rather than a match.
|
|
436
|
+
|
|
437
|
+
Raises SnapshotUnavailable when a snapshot that should be there is too short
|
|
438
|
+
to be trusted — an empty answer would be indistinguishable from "name is fine".
|
|
439
|
+
An ecosystem with the check switched off is handled by `typosquat_coverage`
|
|
440
|
+
before this is called.
|
|
441
|
+
"""
|
|
442
|
+
threshold = MIN_SNAPSHOT_NAMES.get(ecosystem)
|
|
443
|
+
candidate_names = load_top_names(ecosystem)
|
|
444
|
+
if threshold is not None and len(candidate_names) < threshold:
|
|
445
|
+
raise SnapshotUnavailable(
|
|
446
|
+
f"{ecosystem.value} reference set has {len(candidate_names)} names, "
|
|
447
|
+
f"fewer than the {threshold} required to judge a typosquat"
|
|
448
|
+
)
|
|
449
|
+
if not candidate_names:
|
|
450
|
+
return None
|
|
451
|
+
lowered = normalize_name(name, ecosystem)
|
|
452
|
+
if lowered in candidate_names or len(lowered) < TYPOSQUAT_MIN_LENGTH:
|
|
453
|
+
return None
|
|
454
|
+
|
|
455
|
+
best: tuple[str, int] | None = None
|
|
456
|
+
for popular in candidate_names:
|
|
457
|
+
if abs(len(popular) - len(lowered)) > TYPOSQUAT_MAX_DISTANCE:
|
|
458
|
+
continue
|
|
459
|
+
distance = DamerauLevenshtein.distance(
|
|
460
|
+
lowered, popular, score_cutoff=TYPOSQUAT_MAX_DISTANCE
|
|
461
|
+
)
|
|
462
|
+
if distance == 0 or distance > TYPOSQUAT_MAX_DISTANCE:
|
|
463
|
+
continue
|
|
464
|
+
if best is None or distance < best[1]:
|
|
465
|
+
best = (popular, distance)
|
|
466
|
+
if distance == 1:
|
|
467
|
+
break
|
|
468
|
+
return best
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@dataclass(frozen=True)
|
|
472
|
+
class TyposquatResult:
|
|
473
|
+
"""Outcome of the near-miss check.
|
|
474
|
+
|
|
475
|
+
`covered` is False when a snapshot that should exist is missing. npm is not
|
|
476
|
+
in that set: typosquat does not apply, exists/pin still can ALLOW.
|
|
477
|
+
"""
|
|
478
|
+
|
|
479
|
+
covered: bool
|
|
480
|
+
evidence: list[Evidence]
|
|
481
|
+
reason: str | None = None
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def check_typosquat(
|
|
485
|
+
target: TargetRef,
|
|
486
|
+
facts: dict[str, Any],
|
|
487
|
+
builder: EvidenceBuilder,
|
|
488
|
+
now: datetime | None = None,
|
|
489
|
+
) -> TyposquatResult:
|
|
490
|
+
"""P5 typosquat (soft), and P4 typosquat on a days-old project (hard, derived)."""
|
|
491
|
+
uncovered = typosquat_coverage(target.ecosystem)
|
|
492
|
+
if uncovered is not None:
|
|
493
|
+
unavailable = _check_unavailable(
|
|
494
|
+
builder, target, "typosquat_unsupported_ecosystem", uncovered
|
|
495
|
+
)
|
|
496
|
+
return TyposquatResult(covered=False, evidence=[unavailable], reason=uncovered)
|
|
497
|
+
|
|
498
|
+
match = nearest_popular_name(target.name, target.ecosystem)
|
|
499
|
+
if match is None:
|
|
500
|
+
return TyposquatResult(covered=True, evidence=[])
|
|
501
|
+
popular, distance = match
|
|
502
|
+
source = _SOURCE_FOR[target.ecosystem]
|
|
503
|
+
observed = {
|
|
504
|
+
"candidate": target.name,
|
|
505
|
+
"nearest_popular": popular,
|
|
506
|
+
"distance": distance,
|
|
507
|
+
"snapshot": _SNAPSHOTS.get(target.ecosystem),
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
out = [
|
|
511
|
+
builder.build(
|
|
512
|
+
EvidenceKind.TYPOSQUAT,
|
|
513
|
+
source=source, # type: ignore[arg-type]
|
|
514
|
+
trust="deterministic",
|
|
515
|
+
severity="soft",
|
|
516
|
+
target=target,
|
|
517
|
+
observed=observed,
|
|
518
|
+
)
|
|
519
|
+
]
|
|
520
|
+
|
|
521
|
+
now = now or datetime.now(UTC)
|
|
522
|
+
first = _parse_iso(facts.get("first_upload"))
|
|
523
|
+
if first is not None and max((now - first).days, 0) < TYPOSQUAT_NEW_PROJECT_DAYS:
|
|
524
|
+
out.append(
|
|
525
|
+
builder.build(
|
|
526
|
+
EvidenceKind.TYPOSQUAT_NEW_PROJECT,
|
|
527
|
+
source=source, # type: ignore[arg-type]
|
|
528
|
+
trust="deterministic",
|
|
529
|
+
severity="hard",
|
|
530
|
+
target=target,
|
|
531
|
+
observed={
|
|
532
|
+
**observed,
|
|
533
|
+
"first_upload": first.isoformat(),
|
|
534
|
+
"age_days": max((now - first).days, 0),
|
|
535
|
+
},
|
|
536
|
+
)
|
|
537
|
+
)
|
|
538
|
+
return TyposquatResult(covered=True, evidence=out)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def check_hf_format(
|
|
542
|
+
target: TargetRef, facts: dict[str, Any], builder: EvidenceBuilder
|
|
543
|
+
) -> list[Evidence]:
|
|
544
|
+
"""P7: pickle weights with no safetensors sibling. Loading a pickle executes code."""
|
|
545
|
+
if not facts.get("exists") or not facts.get("has_pickle"):
|
|
546
|
+
return []
|
|
547
|
+
|
|
548
|
+
out = [
|
|
549
|
+
builder.build(
|
|
550
|
+
EvidenceKind.HF_PICKLE,
|
|
551
|
+
source="huggingface",
|
|
552
|
+
trust="deterministic",
|
|
553
|
+
severity="soft",
|
|
554
|
+
target=target,
|
|
555
|
+
observed={"pickle_files": facts.get("pickle_files") or []},
|
|
556
|
+
source_url=f"https://huggingface.co/{target.name}",
|
|
557
|
+
)
|
|
558
|
+
]
|
|
559
|
+
if facts.get("has_safetensors"):
|
|
560
|
+
out.append(
|
|
561
|
+
builder.build(
|
|
562
|
+
EvidenceKind.HF_SAFETENSORS_SIBLING,
|
|
563
|
+
source="huggingface",
|
|
564
|
+
trust="deterministic",
|
|
565
|
+
severity="info",
|
|
566
|
+
target=target,
|
|
567
|
+
observed={"safetensors_files": facts.get("safetensors_files") or []},
|
|
568
|
+
source_url=f"https://huggingface.co/{target.name}",
|
|
569
|
+
)
|
|
570
|
+
)
|
|
571
|
+
return out
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
_VERSION_PART = re.compile(r"\d+")
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _version_tuple(version: str) -> tuple[int, ...] | None:
|
|
578
|
+
"""Numeric prefix of a tag. `3.11.9-slim` -> (3, 11, 9). Non-numeric tags -> None."""
|
|
579
|
+
head = version.split("-")[0]
|
|
580
|
+
parts = head.split(".")
|
|
581
|
+
if not parts or not _VERSION_PART.fullmatch(parts[0]):
|
|
582
|
+
return None
|
|
583
|
+
numbers: list[int] = []
|
|
584
|
+
for part in parts:
|
|
585
|
+
if not _VERSION_PART.fullmatch(part):
|
|
586
|
+
break
|
|
587
|
+
numbers.append(int(part))
|
|
588
|
+
return tuple(numbers) or None
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
@dataclass(frozen=True)
|
|
592
|
+
class RuntimeFloorResult:
|
|
593
|
+
"""Outcome of the base-tag check.
|
|
594
|
+
|
|
595
|
+
`comparable` is the half that matters for FR-09: when we cannot place a tag
|
|
596
|
+
on a known release branch we have an absence of knowledge, not evidence of an
|
|
597
|
+
unpatched runtime. `evidence` is then a soft check_unavailable record, which
|
|
598
|
+
P10 turns into REVIEW, never BLOCK.
|
|
599
|
+
"""
|
|
600
|
+
|
|
601
|
+
comparable: bool
|
|
602
|
+
evidence: Evidence | None
|
|
603
|
+
reason: str
|
|
604
|
+
branch: str | None = None
|
|
605
|
+
floor: str | None = None
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _match_branch(tag: tuple[int, ...], branches: dict[str, str]) -> tuple[str, str] | None:
|
|
609
|
+
"""Longest branch whose version tuple prefixes the tag. `8.2.20` -> branch `8.2`."""
|
|
610
|
+
best: tuple[str, str] | None = None
|
|
611
|
+
best_len = -1
|
|
612
|
+
for branch, floor in branches.items():
|
|
613
|
+
prefix = _version_tuple(branch)
|
|
614
|
+
if prefix is None or len(prefix) > len(tag):
|
|
615
|
+
continue
|
|
616
|
+
if tag[: len(prefix)] == prefix and len(prefix) > best_len:
|
|
617
|
+
best, best_len = (branch, floor), len(prefix)
|
|
618
|
+
return best
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def check_runtime_floor(
|
|
622
|
+
target: TargetRef,
|
|
623
|
+
builder: EvidenceBuilder,
|
|
624
|
+
floors: dict[str, dict[str, str]] | None = None,
|
|
625
|
+
) -> RuntimeFloorResult:
|
|
626
|
+
"""P3: a Dockerfile base tag below the patched floor of its own release branch.
|
|
627
|
+
|
|
628
|
+
BLOCK is reserved for a tag we can place on a known branch and show to be
|
|
629
|
+
behind that branch's floor. Everything else — an image we hold no floors for,
|
|
630
|
+
a branch not in the table, a floating tag (`latest`, `bookworm`), or a
|
|
631
|
+
digest-only reference with no tag — is not comparable and resolves to REVIEW
|
|
632
|
+
(FR-09).
|
|
633
|
+
"""
|
|
634
|
+
if target.ecosystem != Ecosystem.DOCKER:
|
|
635
|
+
return RuntimeFloorResult(True, None, "not a container image")
|
|
636
|
+
|
|
637
|
+
floors = floors if floors is not None else load_runtime_floors()
|
|
638
|
+
image = target.name.rsplit("/", 1)[-1].lower()
|
|
639
|
+
branches = floors.get(image)
|
|
640
|
+
|
|
641
|
+
def unavailable(code: str, detail: str) -> RuntimeFloorResult:
|
|
642
|
+
record = _check_unavailable(builder, target, code, detail, image=image, tag=target.version)
|
|
643
|
+
return RuntimeFloorResult(False, record, detail)
|
|
644
|
+
|
|
645
|
+
if not target.version:
|
|
646
|
+
return unavailable(
|
|
647
|
+
"digest_only", "digest-only reference: no tag to compare against a floor"
|
|
648
|
+
)
|
|
649
|
+
if not branches:
|
|
650
|
+
return unavailable("image_not_in_table", f"no patched floors recorded for {image!r}")
|
|
651
|
+
|
|
652
|
+
actual = _version_tuple(target.version)
|
|
653
|
+
if actual is None:
|
|
654
|
+
return unavailable(
|
|
655
|
+
"floating_tag", f"floating tag {target.version!r} carries no comparable version"
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
matched = _match_branch(actual, branches)
|
|
659
|
+
if matched is None:
|
|
660
|
+
return unavailable(
|
|
661
|
+
"no_floor_for_branch",
|
|
662
|
+
f"{image} {target.version!r} is on a branch with no recorded floor",
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
branch, floor = matched
|
|
666
|
+
required = _version_tuple(floor)
|
|
667
|
+
if required is None:
|
|
668
|
+
return unavailable(
|
|
669
|
+
"no_floor_for_branch", f"floor {floor!r} for branch {branch} is unusable"
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
depth = min(len(actual), len(required))
|
|
673
|
+
if actual[:depth] >= required[:depth]:
|
|
674
|
+
if len(actual) < len(required):
|
|
675
|
+
# `8.3` or `8.3-apache` floats to whatever patch the registry serves
|
|
676
|
+
# today, so it cannot be shown to be at or above the floor.
|
|
677
|
+
return unavailable(
|
|
678
|
+
"floating_tag", f"tag {target.version!r} does not pin a patch release"
|
|
679
|
+
)
|
|
680
|
+
return RuntimeFloorResult(True, None, "at or above the branch floor", branch, floor)
|
|
681
|
+
|
|
682
|
+
evidence = builder.build(
|
|
683
|
+
EvidenceKind.RUNTIME_BELOW_FLOOR,
|
|
684
|
+
source="docker_registry",
|
|
685
|
+
trust="deterministic",
|
|
686
|
+
severity="hard",
|
|
687
|
+
target=target,
|
|
688
|
+
observed={
|
|
689
|
+
"image": image,
|
|
690
|
+
"tag": target.version,
|
|
691
|
+
"branch": branch,
|
|
692
|
+
"patched_floor": floor,
|
|
693
|
+
},
|
|
694
|
+
)
|
|
695
|
+
return RuntimeFloorResult(True, evidence, "below the branch floor", branch, floor)
|