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/__init__.py
ADDED
slopsentry/checks/age.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Flag packages that look freshly registered: young, few releases, no project links."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from slopsentry.checks import CheckResult
|
|
7
|
+
from slopsentry.registry import PyPIClient, RegistryError
|
|
8
|
+
|
|
9
|
+
NEW_PACKAGE_DAYS = 90
|
|
10
|
+
FEW_RELEASES = 2
|
|
11
|
+
|
|
12
|
+
NEW_POINTS = 40
|
|
13
|
+
FEW_RELEASES_POINTS = 20
|
|
14
|
+
NO_LINKS_POINTS = 20
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parse_time(value: str) -> datetime:
|
|
18
|
+
# Python 3.10's fromisoformat doesn't accept the "Z" suffix PyPI uses.
|
|
19
|
+
if value.endswith("Z"):
|
|
20
|
+
value = value[:-1] + "+00:00"
|
|
21
|
+
return datetime.fromisoformat(value)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _upload_times(project: dict[str, Any]) -> list[datetime]:
|
|
25
|
+
return [
|
|
26
|
+
_parse_time(f["upload_time_iso_8601"])
|
|
27
|
+
for files in (project.get("releases") or {}).values()
|
|
28
|
+
for f in files
|
|
29
|
+
if f.get("upload_time_iso_8601")
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _has_links(info: dict[str, Any]) -> bool:
|
|
34
|
+
return bool(info.get("home_page")) or any((info.get("project_urls") or {}).values())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def check_age(name: str, client: PyPIClient, now: datetime | None = None) -> CheckResult:
|
|
38
|
+
try:
|
|
39
|
+
project = await client.get_project(name)
|
|
40
|
+
except RegistryError as exc:
|
|
41
|
+
return ("error", str(exc), 0)
|
|
42
|
+
if project is None:
|
|
43
|
+
return ("skip", "not found on PyPI", 0)
|
|
44
|
+
# PyPI has deprecated `releases` in this endpoint. Absent means unknown, not new.
|
|
45
|
+
if "releases" not in project:
|
|
46
|
+
return ("skip", "release history unavailable", 0)
|
|
47
|
+
|
|
48
|
+
now = now or datetime.now(timezone.utc)
|
|
49
|
+
findings: list[str] = []
|
|
50
|
+
points = 0
|
|
51
|
+
|
|
52
|
+
uploads = _upload_times(project)
|
|
53
|
+
if not uploads:
|
|
54
|
+
findings.append("no released files")
|
|
55
|
+
points += NEW_POINTS
|
|
56
|
+
else:
|
|
57
|
+
age_days = (now - min(uploads)).days
|
|
58
|
+
if age_days < NEW_PACKAGE_DAYS:
|
|
59
|
+
findings.append(f"first release {age_days} days ago")
|
|
60
|
+
points += NEW_POINTS
|
|
61
|
+
|
|
62
|
+
releases = sum(1 for files in (project.get("releases") or {}).values() if files)
|
|
63
|
+
if releases <= FEW_RELEASES:
|
|
64
|
+
findings.append(f"only {releases} release{'s' if releases != 1 else ''}")
|
|
65
|
+
points += FEW_RELEASES_POINTS
|
|
66
|
+
|
|
67
|
+
if not _has_links(project.get("info") or {}):
|
|
68
|
+
findings.append("no homepage or repository link")
|
|
69
|
+
points += NO_LINKS_POINTS
|
|
70
|
+
|
|
71
|
+
if findings:
|
|
72
|
+
return ("warn", "; ".join(findings), points)
|
|
73
|
+
first = min(uploads).date().isoformat()
|
|
74
|
+
return ("pass", f"first released {first}, {releases} releases, has project links", 0)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Check that a package exists on PyPI."""
|
|
2
|
+
|
|
3
|
+
from slopsentry.checks import CheckResult
|
|
4
|
+
from slopsentry.registry import PyPIClient, RegistryError
|
|
5
|
+
|
|
6
|
+
MISSING_POINTS = 100
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def check_exists(name: str, client: PyPIClient) -> CheckResult:
|
|
10
|
+
try:
|
|
11
|
+
project = await client.get_project(name)
|
|
12
|
+
except RegistryError as exc:
|
|
13
|
+
return ("error", str(exc), 0)
|
|
14
|
+
|
|
15
|
+
if project is None:
|
|
16
|
+
return ("fail", "not found on PyPI", MISSING_POINTS)
|
|
17
|
+
return ("pass", "found on PyPI", 0)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Flag names that look like a popular PyPI package: typos, separators and affixes."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from functools import cache
|
|
6
|
+
from importlib.resources import files
|
|
7
|
+
|
|
8
|
+
from slopsentry.checks import CheckResult
|
|
9
|
+
from slopsentry.checks.age import check_age
|
|
10
|
+
from slopsentry.registry import PyPIClient, normalize_name
|
|
11
|
+
|
|
12
|
+
LOOKALIKE_POINTS = 50
|
|
13
|
+
# An established package (age check passes cleanly) that resembles a popular one is most
|
|
14
|
+
# likely a legitimate neighbour, e.g. simpy/sympy: shown, but not enough to warn alone.
|
|
15
|
+
ESTABLISHED_LOOKALIKE_POINTS = 15
|
|
16
|
+
|
|
17
|
+
# Edit distance allowed: 1, or 2 for names longer than LONG_NAME characters.
|
|
18
|
+
LONG_NAME = 10
|
|
19
|
+
# Guard 1: names shorter than this get no edit-distance matching (od/id, sax/six).
|
|
20
|
+
MIN_EDIT_LENGTH = 5
|
|
21
|
+
|
|
22
|
+
PREFIXES = ("py-", "python-")
|
|
23
|
+
SUFFIXES = ("-py", "-python", "-lib")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class PopularIndex:
|
|
28
|
+
names: tuple[str, ...] # normalised, most downloaded first
|
|
29
|
+
rank: dict[str, int] = field(init=False)
|
|
30
|
+
squashed: dict[str, list[str]] = field(init=False)
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
rank = {name: i for i, name in enumerate(self.names)}
|
|
34
|
+
squashed: dict[str, list[str]] = {}
|
|
35
|
+
for name in self.names:
|
|
36
|
+
squashed.setdefault(name.replace("-", ""), []).append(name)
|
|
37
|
+
object.__setattr__(self, "rank", rank)
|
|
38
|
+
object.__setattr__(self, "squashed", squashed)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def popular_index(names: list[str]) -> PopularIndex:
|
|
42
|
+
return PopularIndex(tuple(normalize_name(n) for n in names))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@cache
|
|
46
|
+
def bundled_index() -> PopularIndex:
|
|
47
|
+
data = files("slopsentry.data").joinpath("top_pypi.json").read_text(encoding="utf-8")
|
|
48
|
+
return PopularIndex(tuple(json.loads(data)["packages"]))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def edit_distance(a: str, b: str, limit: int) -> int:
|
|
52
|
+
"""Damerau-Levenshtein (optimal string alignment) distance, or limit + 1 if over limit."""
|
|
53
|
+
if abs(len(a) - len(b)) > limit:
|
|
54
|
+
return limit + 1
|
|
55
|
+
before: list[int] = []
|
|
56
|
+
prev = list(range(len(b) + 1))
|
|
57
|
+
for i in range(1, len(a) + 1):
|
|
58
|
+
cur = [i] + [0] * len(b)
|
|
59
|
+
for j in range(1, len(b) + 1):
|
|
60
|
+
cur[j] = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] != b[j - 1]))
|
|
61
|
+
if i > 1 and j > 1 and a[i - 1] == b[j - 2] and a[i - 2] == b[j - 1]:
|
|
62
|
+
cur[j] = min(cur[j], before[j - 2] + 1)
|
|
63
|
+
if min(cur) > limit:
|
|
64
|
+
return limit + 1
|
|
65
|
+
before, prev = prev, cur
|
|
66
|
+
return prev[-1]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _one_segment_differs(a: str, b: str) -> bool:
|
|
70
|
+
"""Guard 2: family members like mypy-boto3-emr and mypy-boto3-ec2."""
|
|
71
|
+
sa, sb = a.split("-"), b.split("-")
|
|
72
|
+
return len(sa) == len(sb) > 1 and sum(x != y for x, y in zip(sa, sb, strict=True)) == 1
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _affix_variants(name: str) -> set[str]:
|
|
76
|
+
"""Names reachable by removing or adding one common affix."""
|
|
77
|
+
variants = {name[len(p) :] for p in PREFIXES if name.startswith(p)}
|
|
78
|
+
variants |= {name[: -len(s)] for s in SUFFIXES if name.endswith(s)}
|
|
79
|
+
variants |= {p + name for p in PREFIXES} | {name + s for s in SUFFIXES}
|
|
80
|
+
return {v for v in variants if v and v != name}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def find_lookalike(name: str, index: PopularIndex) -> str | None:
|
|
84
|
+
"""Return the popular package NAME most resembles, or None. NAME must not be popular."""
|
|
85
|
+
name = normalize_name(name)
|
|
86
|
+
# (distance, rank, target): separator and affix matches count as distance 0.
|
|
87
|
+
matches: list[tuple[int, int, str]] = []
|
|
88
|
+
|
|
89
|
+
for target in index.squashed.get(name.replace("-", ""), []):
|
|
90
|
+
if target != name:
|
|
91
|
+
matches.append((0, index.rank[target], target))
|
|
92
|
+
for target in _affix_variants(name):
|
|
93
|
+
if target in index.rank:
|
|
94
|
+
matches.append((0, index.rank[target], target))
|
|
95
|
+
|
|
96
|
+
if len(name) >= MIN_EDIT_LENGTH:
|
|
97
|
+
limit = 2 if len(name) > LONG_NAME else 1
|
|
98
|
+
for target in index.names:
|
|
99
|
+
if abs(len(target) - len(name)) > limit:
|
|
100
|
+
continue
|
|
101
|
+
distance = edit_distance(name, target, limit)
|
|
102
|
+
if distance > limit:
|
|
103
|
+
continue
|
|
104
|
+
if distance == 2 and _one_segment_differs(name, target):
|
|
105
|
+
continue
|
|
106
|
+
matches.append((distance, index.rank[target], target))
|
|
107
|
+
|
|
108
|
+
return min(matches)[2] if matches else None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def check_lookalike(
|
|
112
|
+
name: str, client: PyPIClient, index: PopularIndex | None = None
|
|
113
|
+
) -> CheckResult:
|
|
114
|
+
index = index or bundled_index()
|
|
115
|
+
if normalize_name(name) in index.rank:
|
|
116
|
+
return ("skip", "popular package", 0)
|
|
117
|
+
|
|
118
|
+
target = find_lookalike(name, index)
|
|
119
|
+
if target is None:
|
|
120
|
+
return ("pass", "no close match among popular packages", 0)
|
|
121
|
+
|
|
122
|
+
# Reuses the cached registry response, so this costs no extra request.
|
|
123
|
+
age_status, _, _ = await check_age(name, client)
|
|
124
|
+
if age_status == "pass":
|
|
125
|
+
return ("warn", f"looks like {target} (established package)", ESTABLISHED_LOOKALIKE_POINTS)
|
|
126
|
+
return ("warn", f"looks like {target}", LOOKALIKE_POINTS)
|
slopsentry/cli.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""SlopSentry command-line interface."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.markup import escape
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from slopsentry import hook as hook_mod
|
|
14
|
+
from slopsentry.registry import PyPIClient
|
|
15
|
+
from slopsentry.scoring import BLOCK_AT, WARN_AT, Report, score
|
|
16
|
+
from slopsentry.spec import InvalidPackageSpec, parse_package_name
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(help="Check packages for slopsquatting before they are installed.")
|
|
19
|
+
console = Console()
|
|
20
|
+
err_console = Console(stderr=True)
|
|
21
|
+
|
|
22
|
+
# Tests replace these to inject a client backed by a mock transport.
|
|
23
|
+
client_factory: Callable[[], PyPIClient] = PyPIClient
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def hook_client_factory() -> PyPIClient:
|
|
27
|
+
return PyPIClient(timeout=hook_mod.HOOK_TIMEOUT)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
VERDICT_EXIT = {"pass": 0, "warn": 0, "block": 1}
|
|
31
|
+
STRICT_ERROR_EXIT = 2
|
|
32
|
+
INVALID_INPUT_EXIT = 2
|
|
33
|
+
|
|
34
|
+
VERDICT_STYLES = {"pass": "green", "warn": "yellow", "block": "red", "error": "red"}
|
|
35
|
+
STATUS_STYLES = {"pass": "green", "warn": "yellow", "skip": "dim", "fail": "red", "error": "yellow"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def _score(package: str) -> Report:
|
|
39
|
+
client = client_factory()
|
|
40
|
+
try:
|
|
41
|
+
return await score(package, client)
|
|
42
|
+
finally:
|
|
43
|
+
await client.aclose()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _print_table(report: Report) -> None:
|
|
47
|
+
table = Table(title=f"SlopSentry: {escape(report.package)}")
|
|
48
|
+
table.add_column("Check")
|
|
49
|
+
table.add_column("Status")
|
|
50
|
+
table.add_column("Detail")
|
|
51
|
+
table.add_column("Points", justify="right")
|
|
52
|
+
for r in report.results:
|
|
53
|
+
style = STATUS_STYLES[r.status]
|
|
54
|
+
table.add_row(r.check, f"[{style}]{r.status}[/{style}]", escape(r.detail), str(r.points))
|
|
55
|
+
console.print(table)
|
|
56
|
+
console.print(f"Total: {report.total} points (warn at {WARN_AT}, block at {BLOCK_AT})")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _print_summary(report: Report, strict_error: bool, with_reasons: bool) -> None:
|
|
60
|
+
label = "error" if strict_error else report.verdict
|
|
61
|
+
style = VERDICT_STYLES[label]
|
|
62
|
+
line = f"[{style}]{label.upper()}[/{style}] {escape(report.package)}: score {report.total}"
|
|
63
|
+
if strict_error:
|
|
64
|
+
line += ", could not be verified (--strict)"
|
|
65
|
+
elif report.has_errors:
|
|
66
|
+
line += ", could not be fully verified (allowing; use --strict to block)"
|
|
67
|
+
console.print(line)
|
|
68
|
+
|
|
69
|
+
if with_reasons and label != "pass":
|
|
70
|
+
for r in report.reasons:
|
|
71
|
+
console.print(f" - {r.check}: {escape(r.detail)} (+{r.points})")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.callback()
|
|
75
|
+
def main() -> None:
|
|
76
|
+
"""Check packages for slopsquatting before they are installed."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.command()
|
|
80
|
+
def check(
|
|
81
|
+
spec: str = typer.Argument(
|
|
82
|
+
..., metavar="PACKAGE", help="Package name or requirement, e.g. requests[socks]==2.31.0."
|
|
83
|
+
),
|
|
84
|
+
strict: bool = typer.Option(
|
|
85
|
+
False, "--strict", help="Exit 2 instead of warning when the registry is unreachable."
|
|
86
|
+
),
|
|
87
|
+
explain: bool = typer.Option(
|
|
88
|
+
False, "--explain", help="Show every check's status, detail and points."
|
|
89
|
+
),
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Score PACKAGE for slopsquatting risk: pass, warn or block."""
|
|
92
|
+
try:
|
|
93
|
+
package = parse_package_name(spec)
|
|
94
|
+
except InvalidPackageSpec as exc:
|
|
95
|
+
err_console.print(f"[red]ERROR[/red] {escape(str(exc))}", highlight=False)
|
|
96
|
+
raise typer.Exit(INVALID_INPUT_EXIT) from None
|
|
97
|
+
|
|
98
|
+
report = asyncio.run(_score(package))
|
|
99
|
+
strict_error = strict and report.has_errors
|
|
100
|
+
|
|
101
|
+
if explain:
|
|
102
|
+
_print_table(report)
|
|
103
|
+
_print_summary(report, strict_error, with_reasons=not explain)
|
|
104
|
+
|
|
105
|
+
raise typer.Exit(STRICT_ERROR_EXIT if strict_error else VERDICT_EXIT[report.verdict])
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@app.command()
|
|
109
|
+
def hook(
|
|
110
|
+
debug: bool = typer.Option(False, "--debug", help="Also print swallowed exceptions to stderr."),
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Claude Code PreToolUse hook: read the event on stdin, print a decision as JSON.
|
|
113
|
+
|
|
114
|
+
Always exits 0. Warn asks the user, block denies, anything else is allowed silently.
|
|
115
|
+
"""
|
|
116
|
+
try:
|
|
117
|
+
output = hook_mod.run_hook(sys.stdin.read(), hook_client_factory)
|
|
118
|
+
except Exception:
|
|
119
|
+
hook_mod.log_exception(debug)
|
|
120
|
+
output = None
|
|
121
|
+
if output is not None:
|
|
122
|
+
sys.stdout.write(json.dumps(output) + "\n")
|
|
123
|
+
raise typer.Exit(0)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Bundled data files."""
|