slopsentry 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.
- slopsentry/__init__.py +3 -0
- slopsentry/checks/__init__.py +3 -0
- slopsentry/checks/age.py +74 -0
- slopsentry/checks/exists.py +17 -0
- slopsentry/checks/lookalike.py +126 -0
- slopsentry/cli.py +123 -0
- slopsentry/data/__init__.py +1 -0
- slopsentry/data/top_pypi.json +5010 -0
- slopsentry/hook.py +310 -0
- slopsentry/registry.py +62 -0
- slopsentry/scoring.py +81 -0
- slopsentry/spec.py +18 -0
- slopsentry-0.1.0.dist-info/METADATA +235 -0
- slopsentry-0.1.0.dist-info/RECORD +17 -0
- slopsentry-0.1.0.dist-info/WHEEL +4 -0
- slopsentry-0.1.0.dist-info/entry_points.txt +2 -0
- slopsentry-0.1.0.dist-info/licenses/LICENSE +21 -0
slopsentry/hook.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Claude Code PreToolUse hook: check the packages in Bash install commands.
|
|
2
|
+
|
|
3
|
+
The hook fails open. Anything it can't parse, a registry outage or a crash allows the
|
|
4
|
+
command; only a scored warn or block produces output.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shlex
|
|
12
|
+
import sys
|
|
13
|
+
import traceback
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from slopsentry.registry import PyPIClient, normalize_name
|
|
20
|
+
from slopsentry.scoring import Report, score, verdict_for
|
|
21
|
+
from slopsentry.spec import InvalidPackageSpec, parse_package_name
|
|
22
|
+
|
|
23
|
+
HOOK_TIMEOUT = 5.0
|
|
24
|
+
|
|
25
|
+
SEPARATORS = {"&&", "||", ";", "|", "&", "|&", ";;", "(", ")", "\n"}
|
|
26
|
+
# Characters shlex would otherwise split words on, which appear in URLs and specs.
|
|
27
|
+
EXTRA_WORDCHARS = ":@%+,^![]{}$#"
|
|
28
|
+
|
|
29
|
+
ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
|
30
|
+
PIP = re.compile(r"^pip(3(\.\d+)?)?$")
|
|
31
|
+
PYTHON = re.compile(r"^python(3(\.\d+)?)?$")
|
|
32
|
+
|
|
33
|
+
# Options that take a value, per tool. Their values are never package names.
|
|
34
|
+
PIP_VALUE_OPTS = {
|
|
35
|
+
"-r", "--requirement", "-c", "--constraint", "-e", "--editable", "-t", "--target",
|
|
36
|
+
"--platform", "--python-version", "--implementation", "--abi", "--root", "--prefix",
|
|
37
|
+
"--src", "--upgrade-strategy", "-C", "--config-settings", "--global-option",
|
|
38
|
+
"--no-binary", "--only-binary", "--progress-bar", "--root-user-action", "--report",
|
|
39
|
+
"--group", "-i", "--index-url", "--extra-index-url", "-f", "--find-links", "--log",
|
|
40
|
+
"--proxy", "--retries", "--timeout", "--exists-action", "--trusted-host", "--cert",
|
|
41
|
+
"--client-cert", "--cache-dir", "--python", "--keyring-provider", "--use-feature",
|
|
42
|
+
"--use-deprecated", "--resume-retries",
|
|
43
|
+
} # fmt: skip
|
|
44
|
+
UV_VALUE_OPTS = {
|
|
45
|
+
"-r", "--requirements", "--requirement", "-e", "--editable", "-c", "--constraints",
|
|
46
|
+
"--constraint", "--overrides", "-b", "--build-constraints", "--group", "--optional",
|
|
47
|
+
"--extra", "--tag", "--branch", "--rev", "-m", "--marker", "--index", "--default-index",
|
|
48
|
+
"-i", "--index-url", "--extra-index-url", "-f", "--find-links", "-p", "--python",
|
|
49
|
+
"--python-version", "--python-platform", "-t", "--target", "--prefix", "--package",
|
|
50
|
+
"--script", "--directory", "--project", "--config-file", "--cache-dir", "-P",
|
|
51
|
+
"--upgrade-package", "--reinstall-package", "--no-binary-package", "--no-build-package",
|
|
52
|
+
"--index-strategy", "--keyring-provider", "--resolution", "--prerelease",
|
|
53
|
+
"--exclude-newer", "--link-mode", "--color", "--refresh-package", "--bounds", "-C",
|
|
54
|
+
"--config-setting", "--allow-insecure-host",
|
|
55
|
+
} # fmt: skip
|
|
56
|
+
POETRY_VALUE_OPTS = {
|
|
57
|
+
"-G", "--group", "-E", "--extras", "--source", "--python", "--platform", "--markers",
|
|
58
|
+
"-C", "--directory", "-P", "--project",
|
|
59
|
+
} # fmt: skip
|
|
60
|
+
|
|
61
|
+
# A custom index means packages may legitimately be missing from PyPI: skip the command.
|
|
62
|
+
PIP_INDEX_OPTS = {"-i", "--index-url", "--extra-index-url", "-f", "--find-links"}
|
|
63
|
+
UV_INDEX_OPTS = PIP_INDEX_OPTS | {"--index", "--default-index"}
|
|
64
|
+
POETRY_INDEX_OPTS = {"--source"}
|
|
65
|
+
|
|
66
|
+
SUDO_VALUE_OPTS = {"-u", "-g", "-C", "-D", "-p", "-U", "-r", "-t", "-T"}
|
|
67
|
+
ENV_VALUE_OPTS = {"-u", "--unset", "-C", "--chdir", "-S", "--split-string"}
|
|
68
|
+
|
|
69
|
+
ARCHIVE_SUFFIXES = (".whl", ".tar.gz", ".tgz", ".tar.bz2", ".zip")
|
|
70
|
+
|
|
71
|
+
ClientFactory = Callable[[], PyPIClient]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def read_event(raw: str) -> str | None:
|
|
75
|
+
"""Return the Bash command from a PreToolUse event, or None for anything else."""
|
|
76
|
+
try:
|
|
77
|
+
event = json.loads(raw)
|
|
78
|
+
except ValueError:
|
|
79
|
+
return None
|
|
80
|
+
if not isinstance(event, dict) or event.get("tool_name") != "Bash":
|
|
81
|
+
return None
|
|
82
|
+
tool_input = event.get("tool_input")
|
|
83
|
+
if not isinstance(tool_input, dict):
|
|
84
|
+
return None
|
|
85
|
+
command = tool_input.get("command")
|
|
86
|
+
return command if isinstance(command, str) else None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _tokenize(command: str) -> list[str]:
|
|
90
|
+
lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
|
|
91
|
+
lexer.wordchars += EXTRA_WORDCHARS
|
|
92
|
+
# shlex ends a word at `#`; bash only starts a comment at the start of a word.
|
|
93
|
+
lexer.commenters = ""
|
|
94
|
+
lexer.whitespace = " \t\r" # newline separates commands, so keep it as a token
|
|
95
|
+
return list(lexer)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _segments(tokens: list[str]) -> list[list[str]]:
|
|
99
|
+
"""Split tokens into simple commands, dropping redirections and their targets."""
|
|
100
|
+
segments: list[list[str]] = [[]]
|
|
101
|
+
skip_next = in_comment = False
|
|
102
|
+
for tok in tokens:
|
|
103
|
+
if in_comment:
|
|
104
|
+
in_comment = tok != "\n"
|
|
105
|
+
if not in_comment:
|
|
106
|
+
segments.append([])
|
|
107
|
+
elif skip_next:
|
|
108
|
+
skip_next = False
|
|
109
|
+
elif tok.startswith("#"):
|
|
110
|
+
in_comment = True
|
|
111
|
+
elif tok in SEPARATORS:
|
|
112
|
+
segments.append([])
|
|
113
|
+
elif set(tok) <= set("<>&"):
|
|
114
|
+
# Redirection like `> log` or `2>&1`: drop its target and any fd number before it.
|
|
115
|
+
skip_next = True
|
|
116
|
+
if segments[-1] and segments[-1][-1].isdigit():
|
|
117
|
+
segments[-1].pop()
|
|
118
|
+
else:
|
|
119
|
+
segments[-1].append(tok)
|
|
120
|
+
return [s for s in segments if s]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _strip_prefixes(words: list[str]) -> list[str]:
|
|
124
|
+
"""Drop leading VAR=value assignments, `env` and `sudo` (with their options)."""
|
|
125
|
+
i = 0
|
|
126
|
+
while i < len(words):
|
|
127
|
+
word = words[i]
|
|
128
|
+
if ASSIGNMENT.match(word):
|
|
129
|
+
i += 1
|
|
130
|
+
elif word in ("sudo", "env"):
|
|
131
|
+
value_opts = SUDO_VALUE_OPTS if word == "sudo" else ENV_VALUE_OPTS
|
|
132
|
+
i += 1
|
|
133
|
+
while i < len(words) and words[i].startswith("-"):
|
|
134
|
+
i += 2 if words[i] in value_opts else 1
|
|
135
|
+
else:
|
|
136
|
+
break
|
|
137
|
+
return words[i:]
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _skip_options(words: list[str], value_opts: set[str]) -> list[str]:
|
|
141
|
+
"""Drop leading options so the subcommand comes first."""
|
|
142
|
+
i = 0
|
|
143
|
+
while i < len(words) and words[i].startswith("-"):
|
|
144
|
+
i += 2 if words[i] in value_opts else 1
|
|
145
|
+
return words[i:]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _install_args(words: list[str]) -> tuple[list[str], set[str], set[str], bool] | None:
|
|
149
|
+
"""If WORDS is a supported install command, return (args, value opts, index opts, poetry)."""
|
|
150
|
+
if not words:
|
|
151
|
+
return None
|
|
152
|
+
program = os.path.basename(words[0])
|
|
153
|
+
rest = words[1:]
|
|
154
|
+
|
|
155
|
+
if PYTHON.match(program):
|
|
156
|
+
if rest[:2] != ["-m", "pip"]:
|
|
157
|
+
return None
|
|
158
|
+
program, rest = "pip", rest[2:]
|
|
159
|
+
|
|
160
|
+
if PIP.match(program):
|
|
161
|
+
rest = _skip_options(rest, PIP_VALUE_OPTS)
|
|
162
|
+
if rest[:1] == ["install"]:
|
|
163
|
+
return rest[1:], PIP_VALUE_OPTS, PIP_INDEX_OPTS, False
|
|
164
|
+
elif program == "uv":
|
|
165
|
+
rest = _skip_options(rest, UV_VALUE_OPTS)
|
|
166
|
+
if rest[:1] == ["add"]:
|
|
167
|
+
return rest[1:], UV_VALUE_OPTS, UV_INDEX_OPTS, False
|
|
168
|
+
if rest[:2] == ["pip", "install"]:
|
|
169
|
+
return rest[2:], UV_VALUE_OPTS, UV_INDEX_OPTS, False
|
|
170
|
+
elif program == "poetry":
|
|
171
|
+
rest = _skip_options(rest, POETRY_VALUE_OPTS)
|
|
172
|
+
if rest[:1] == ["add"]:
|
|
173
|
+
return rest[1:], POETRY_VALUE_OPTS, POETRY_INDEX_OPTS, True
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _uses_custom_index(args: list[str], index_opts: set[str]) -> bool:
|
|
178
|
+
for arg in args:
|
|
179
|
+
if arg.split("=", 1)[0] in index_opts:
|
|
180
|
+
return True
|
|
181
|
+
# Attached short form, e.g. -ihttps://example.com/simple
|
|
182
|
+
if not arg.startswith("--") and len(arg) > 2 and arg[:2] in index_opts:
|
|
183
|
+
return True
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _is_local_or_url(arg: str) -> bool:
|
|
188
|
+
return (
|
|
189
|
+
arg.startswith((".", "/", "~", "git+", "file:"))
|
|
190
|
+
or "/" in arg
|
|
191
|
+
or "\\" in arg
|
|
192
|
+
or "://" in arg
|
|
193
|
+
or arg.endswith(ARCHIVE_SUFFIXES)
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _package_names(args: list[str], value_opts: set[str], poetry: bool) -> list[str]:
|
|
198
|
+
names = []
|
|
199
|
+
i = 0
|
|
200
|
+
while i < len(args):
|
|
201
|
+
arg = args[i]
|
|
202
|
+
i += 1
|
|
203
|
+
if arg.startswith("-"):
|
|
204
|
+
if arg in value_opts:
|
|
205
|
+
i += 1 # skip the option's value (-r file, -e path, --group dev, ...)
|
|
206
|
+
continue
|
|
207
|
+
if i < len(args) and args[i] == "@":
|
|
208
|
+
i += 2 # `pkg @ url` direct reference: installed from the URL, not PyPI
|
|
209
|
+
continue
|
|
210
|
+
if _is_local_or_url(arg):
|
|
211
|
+
continue
|
|
212
|
+
if "@" in arg:
|
|
213
|
+
if not poetry:
|
|
214
|
+
continue # PEP 508 `pkg@url` direct reference
|
|
215
|
+
arg = arg.split("@", 1)[0] # poetry's `pkg@^1.0` version constraint
|
|
216
|
+
try:
|
|
217
|
+
names.append(parse_package_name(arg))
|
|
218
|
+
except InvalidPackageSpec:
|
|
219
|
+
continue
|
|
220
|
+
return names
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def extract_packages(command: str) -> list[str]:
|
|
224
|
+
"""Return the PyPI package names that COMMAND would install, de-duplicated in order."""
|
|
225
|
+
try:
|
|
226
|
+
tokens = _tokenize(command)
|
|
227
|
+
except ValueError:
|
|
228
|
+
return [] # unbalanced quotes and the like: can't tell, so allow
|
|
229
|
+
|
|
230
|
+
names: dict[str, str] = {}
|
|
231
|
+
for segment in _segments(tokens):
|
|
232
|
+
found = _install_args(_strip_prefixes(segment))
|
|
233
|
+
if found is None:
|
|
234
|
+
continue
|
|
235
|
+
args, value_opts, index_opts, poetry = found
|
|
236
|
+
if _uses_custom_index(args, index_opts):
|
|
237
|
+
continue
|
|
238
|
+
for name in _package_names(args, value_opts, poetry):
|
|
239
|
+
names.setdefault(normalize_name(name), name)
|
|
240
|
+
return list(names.values())
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
async def _score_all(packages: list[str], client_factory: ClientFactory) -> list[Report]:
|
|
244
|
+
client = client_factory()
|
|
245
|
+
try:
|
|
246
|
+
return list(await asyncio.gather(*(score(p, client) for p in packages)))
|
|
247
|
+
finally:
|
|
248
|
+
await client.aclose()
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _describe(report: Report) -> str:
|
|
252
|
+
verdict = verdict_for(report.total)
|
|
253
|
+
reasons = "; ".join(f"{r.check}: {r.detail}" for r in report.reasons if r.status != "error")
|
|
254
|
+
return f"- {report.package}: {verdict.upper()}, score {report.total} ({reasons})"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def decide(reports: list[Report]) -> dict[str, Any] | None:
|
|
258
|
+
"""Turn reports into Claude Code hook output, or None to allow silently.
|
|
259
|
+
|
|
260
|
+
Registry errors score 0 points, so an unverifiable package is allowed here.
|
|
261
|
+
"""
|
|
262
|
+
verdicts = [verdict_for(r.total) for r in reports]
|
|
263
|
+
flagged = [r for r, v in zip(reports, verdicts, strict=True) if v != "pass"]
|
|
264
|
+
if not flagged:
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
decision = "deny" if "block" in verdicts else "ask"
|
|
268
|
+
action = "blocked" if decision == "deny" else "flagged"
|
|
269
|
+
lines = [f"SlopSentry {action} {len(flagged)} of {len(reports)} packages:"]
|
|
270
|
+
lines += [_describe(r) for r in flagged]
|
|
271
|
+
if decision == "deny":
|
|
272
|
+
lines.append(
|
|
273
|
+
"These may be hallucinated or lookalike packages. Check the names; "
|
|
274
|
+
"if one is intended, ask the user to confirm and install it themselves."
|
|
275
|
+
)
|
|
276
|
+
return {
|
|
277
|
+
"hookSpecificOutput": {
|
|
278
|
+
"hookEventName": "PreToolUse",
|
|
279
|
+
"permissionDecision": decision,
|
|
280
|
+
"permissionDecisionReason": "\n".join(lines),
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def run_hook(raw: str, client_factory: ClientFactory) -> dict[str, Any] | None:
|
|
286
|
+
command = read_event(raw)
|
|
287
|
+
if command is None:
|
|
288
|
+
return None
|
|
289
|
+
packages = extract_packages(command)
|
|
290
|
+
if not packages:
|
|
291
|
+
return None # nothing to check: no registry client is created
|
|
292
|
+
return decide(asyncio.run(_score_all(packages, client_factory)))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def log_path() -> Path:
|
|
296
|
+
return Path.home() / ".slopsentry" / "hook.log"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def log_exception(debug: bool) -> None:
|
|
300
|
+
"""Record the exception being handled. Never raises: the hook must still allow."""
|
|
301
|
+
entry = f"[{datetime.now(timezone.utc).isoformat()}] {traceback.format_exc()}"
|
|
302
|
+
try:
|
|
303
|
+
path = log_path()
|
|
304
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
305
|
+
with path.open("a", encoding="utf-8") as log:
|
|
306
|
+
log.write(entry)
|
|
307
|
+
except OSError:
|
|
308
|
+
pass
|
|
309
|
+
if debug:
|
|
310
|
+
sys.stderr.write(entry)
|
slopsentry/registry.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Registry API clients (PyPI for now), with a per-client cache."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
PYPI_URL = "https://pypi.org/pypi/{name}/json"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def normalize_name(name: str) -> str:
|
|
12
|
+
"""Normalise a PyPI project name per PEP 503: lowercase, runs of -_. become -."""
|
|
13
|
+
return re.sub(r"[-_.]+", "-", name).lower()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RegistryError(Exception):
|
|
17
|
+
"""The registry could not be reached or returned an unexpected response."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PyPIClient:
|
|
21
|
+
def __init__(self, http: httpx.AsyncClient | None = None, timeout: float = 10.0) -> None:
|
|
22
|
+
self._http = http or httpx.AsyncClient(timeout=timeout, follow_redirects=True)
|
|
23
|
+
self._cache: dict[str, dict[str, Any] | None] = {}
|
|
24
|
+
# Errors are remembered too, so several checks don't each wait out the same outage.
|
|
25
|
+
self._errors: dict[str, RegistryError] = {}
|
|
26
|
+
|
|
27
|
+
async def get_project(self, name: str) -> dict[str, Any] | None:
|
|
28
|
+
"""Return the project's JSON metadata, or None if it does not exist."""
|
|
29
|
+
key = normalize_name(name)
|
|
30
|
+
if key in self._cache:
|
|
31
|
+
return self._cache[key]
|
|
32
|
+
if key in self._errors:
|
|
33
|
+
raise self._errors[key]
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
data = await self._fetch(key)
|
|
37
|
+
except RegistryError as exc:
|
|
38
|
+
self._errors[key] = exc
|
|
39
|
+
raise
|
|
40
|
+
self._cache[key] = data
|
|
41
|
+
return data
|
|
42
|
+
|
|
43
|
+
async def _fetch(self, key: str) -> dict[str, Any] | None:
|
|
44
|
+
try:
|
|
45
|
+
response = await self._http.get(PYPI_URL.format(name=key))
|
|
46
|
+
except httpx.HTTPError as exc:
|
|
47
|
+
raise RegistryError(f"could not reach PyPI: {exc}") from exc
|
|
48
|
+
|
|
49
|
+
if response.status_code == 404:
|
|
50
|
+
return None
|
|
51
|
+
if response.status_code != 200:
|
|
52
|
+
raise RegistryError(f"PyPI returned HTTP {response.status_code}")
|
|
53
|
+
try:
|
|
54
|
+
data = response.json()
|
|
55
|
+
except ValueError as exc:
|
|
56
|
+
raise RegistryError("PyPI returned a response that is not valid JSON") from exc
|
|
57
|
+
if not isinstance(data, dict):
|
|
58
|
+
raise RegistryError("PyPI returned an unexpected JSON shape")
|
|
59
|
+
return data
|
|
60
|
+
|
|
61
|
+
async def aclose(self) -> None:
|
|
62
|
+
await self._http.aclose()
|
slopsentry/scoring.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Run every check, total the points, and turn the total into a verdict."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from slopsentry.checks import CheckResult
|
|
7
|
+
from slopsentry.checks.age import check_age
|
|
8
|
+
from slopsentry.checks.exists import check_exists
|
|
9
|
+
from slopsentry.checks.lookalike import check_lookalike
|
|
10
|
+
from slopsentry.registry import PyPIClient
|
|
11
|
+
|
|
12
|
+
# Verdict thresholds. Tune here: total < WARN_AT passes, total >= BLOCK_AT blocks.
|
|
13
|
+
WARN_AT = 30
|
|
14
|
+
BLOCK_AT = 70
|
|
15
|
+
|
|
16
|
+
Check = Callable[[str, PyPIClient], Awaitable[CheckResult]]
|
|
17
|
+
|
|
18
|
+
ALL_CHECKS: list[tuple[str, Check]] = [
|
|
19
|
+
("exists", check_exists),
|
|
20
|
+
("age", check_age),
|
|
21
|
+
("lookalike", check_lookalike),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
REASON_STATUSES = ("warn", "fail", "error")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def verdict_for(total: int) -> str:
|
|
28
|
+
if total >= BLOCK_AT:
|
|
29
|
+
return "block"
|
|
30
|
+
if total >= WARN_AT:
|
|
31
|
+
return "warn"
|
|
32
|
+
return "pass"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class NamedResult:
|
|
37
|
+
check: str
|
|
38
|
+
status: str
|
|
39
|
+
detail: str
|
|
40
|
+
points: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Report:
|
|
45
|
+
package: str
|
|
46
|
+
results: list[NamedResult]
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def total(self) -> int:
|
|
50
|
+
return sum(r.points for r in self.results)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def has_errors(self) -> bool:
|
|
54
|
+
return any(r.status == "error" for r in self.results)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def verdict(self) -> str:
|
|
58
|
+
verdict = verdict_for(self.total)
|
|
59
|
+
# An unverified package must not read as a pass.
|
|
60
|
+
if verdict == "pass" and self.has_errors:
|
|
61
|
+
return "warn"
|
|
62
|
+
return verdict
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def reasons(self) -> list[NamedResult]:
|
|
66
|
+
"""Results worth showing the user, with repeated details (e.g. one outage) collapsed."""
|
|
67
|
+
seen: set[str] = set()
|
|
68
|
+
reasons = []
|
|
69
|
+
for r in self.results:
|
|
70
|
+
if r.status in REASON_STATUSES and r.detail not in seen:
|
|
71
|
+
seen.add(r.detail)
|
|
72
|
+
reasons.append(r)
|
|
73
|
+
return reasons
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def score(package: str, client: PyPIClient) -> Report:
|
|
77
|
+
results = []
|
|
78
|
+
for name, check in ALL_CHECKS:
|
|
79
|
+
status, detail, points = await check(package, client)
|
|
80
|
+
results.append(NamedResult(name, status, detail, points))
|
|
81
|
+
return Report(package, results)
|
slopsentry/spec.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Parse package arguments like `requests==2.31.0` or `requests[socks]` into a name."""
|
|
2
|
+
|
|
3
|
+
from packaging.requirements import InvalidRequirement, Requirement
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class InvalidPackageSpec(ValueError):
|
|
7
|
+
"""The argument is not a valid package name or requirement."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_package_name(spec: str) -> str:
|
|
11
|
+
"""Return the project name from a requirement string, dropping extras, versions and markers."""
|
|
12
|
+
if not spec.strip():
|
|
13
|
+
raise InvalidPackageSpec("package name is empty")
|
|
14
|
+
try:
|
|
15
|
+
return Requirement(spec).name
|
|
16
|
+
except InvalidRequirement as exc:
|
|
17
|
+
reason = str(exc).splitlines()[0]
|
|
18
|
+
raise InvalidPackageSpec(f"invalid package spec {spec!r}: {reason}") from exc
|