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 ADDED
@@ -0,0 +1,3 @@
1
+ """SlopSentry: check packages for slopsquatting before installing them."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ """Checks: each returns (status, detail, points)."""
2
+
3
+ CheckResult = tuple[str, str, int]
@@ -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."""