ghostpkg 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.
ghostpkg/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """ghostpkg -- catch package names that do not exist before you install them."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .assess import Finding, Verdict, assess
6
+ from .registries import PackageFacts, RegistryError, fetch
7
+
8
+ __all__ = [
9
+ "Finding",
10
+ "PackageFacts",
11
+ "RegistryError",
12
+ "Verdict",
13
+ "assess",
14
+ "fetch",
15
+ "__version__",
16
+ ]
ghostpkg/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
ghostpkg/assess.py ADDED
@@ -0,0 +1,144 @@
1
+ """Risk assessment for a package name.
2
+
3
+ The policy here is shaped by a measurement, not by taste. Validation against
4
+ the live PyPI feed showed that "young, one release, no repository link"
5
+ describes a malicious slopsquat and an honest new project equally well: a
6
+ detector that blocks on youth flags 100% of legitimate brand-new packages.
7
+
8
+ So the default profile blocks on exactly one thing -- the package does not
9
+ exist -- because that signal is precise and it is the one that actually
10
+ corresponds to a hallucination. Everything softer is reported as a warning
11
+ and left to a human. `--strict` promotes warnings to blocks for people who
12
+ want that trade, but it is not the default and it is not recommended for CI
13
+ that installs new packages.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+ from enum import Enum
20
+
21
+ from .data import TOP_PYPI
22
+ from .registries import PackageFacts
23
+
24
+ YOUNG_DAYS = 90
25
+ NEW_DAYS = 365
26
+
27
+
28
+ class Verdict(str, Enum):
29
+ OK = "OK"
30
+ WARN = "WARN"
31
+ BLOCK = "BLOCK"
32
+
33
+
34
+ @dataclass
35
+ class Finding:
36
+ name: str
37
+ ecosystem: str
38
+ verdict: Verdict
39
+ reasons: list[str] = field(default_factory=list)
40
+ facts: PackageFacts | None = None
41
+
42
+ @property
43
+ def is_blocked(self) -> bool:
44
+ return self.verdict is Verdict.BLOCK
45
+
46
+
47
+ def edit_distance(left: str, right: str, cutoff: int = 3) -> int:
48
+ """Levenshtein distance, abandoning early once it exceeds `cutoff`."""
49
+ if left == right:
50
+ return 0
51
+ if abs(len(left) - len(right)) > cutoff:
52
+ return cutoff + 1
53
+
54
+ previous = list(range(len(right) + 1))
55
+ for i, a in enumerate(left, 1):
56
+ current = [i]
57
+ for j, b in enumerate(right, 1):
58
+ current.append(
59
+ min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (a != b))
60
+ )
61
+ if min(current) > cutoff:
62
+ return cutoff + 1
63
+ previous = current
64
+ return previous[-1]
65
+
66
+
67
+ def _typo_budget(name: str) -> int:
68
+ """How many edits still count as a plausible typo of a popular name.
69
+
70
+ Short names are inherently close to each other -- 'flask', 'black' and
71
+ 'click' sit within two edits -- so a flat budget produces false positives
72
+ on exactly the packages people use most.
73
+ """
74
+ return 2 if len(name) >= 10 else 1
75
+
76
+
77
+ def nearest_popular(name: str, popular: frozenset[str] = TOP_PYPI) -> tuple[str, int] | None:
78
+ """Closest popular package name within the typo budget, if any."""
79
+ lowered = name.lower()
80
+ if lowered in popular:
81
+ return None
82
+
83
+ budget = _typo_budget(lowered)
84
+ best: tuple[str, int] | None = None
85
+ for candidate in popular:
86
+ if abs(len(candidate) - len(lowered)) > budget:
87
+ continue
88
+ distance = edit_distance(lowered, candidate, cutoff=budget)
89
+ if 0 < distance <= budget and (best is None or distance < best[1]):
90
+ best = (candidate, distance)
91
+ if distance == 1:
92
+ break
93
+ return best
94
+
95
+
96
+ def assess(facts: PackageFacts, strict: bool = False) -> Finding:
97
+ if not facts.exists:
98
+ return Finding(
99
+ name=facts.name,
100
+ ecosystem=facts.ecosystem,
101
+ verdict=Verdict.BLOCK,
102
+ reasons=[f"does not exist on {facts.ecosystem}"],
103
+ facts=facts,
104
+ )
105
+
106
+ reasons: list[str] = []
107
+
108
+ if facts.age_days is not None:
109
+ if facts.age_days < YOUNG_DAYS:
110
+ reasons.append(f"first published {facts.age_days} days ago")
111
+ elif facts.age_days < NEW_DAYS:
112
+ reasons.append(f"first published {facts.age_days} days ago (under a year)")
113
+
114
+ is_young = facts.age_days is not None and facts.age_days < NEW_DAYS
115
+
116
+ if is_young:
117
+ neighbour = nearest_popular(facts.name)
118
+ if neighbour is not None:
119
+ popular_name, distance = neighbour
120
+ reasons.append(
121
+ f"{distance} character{'s' if distance > 1 else ''} away from "
122
+ f"'{popular_name}', and recently published"
123
+ )
124
+
125
+ if is_young and facts.release_count <= 1:
126
+ reasons.append("only one release")
127
+
128
+ if is_young and not facts.has_repo_url:
129
+ reasons.append("no repository or homepage link")
130
+
131
+ if not reasons:
132
+ verdict = Verdict.OK
133
+ elif strict:
134
+ verdict = Verdict.BLOCK
135
+ else:
136
+ verdict = Verdict.WARN
137
+
138
+ return Finding(
139
+ name=facts.name,
140
+ ecosystem=facts.ecosystem,
141
+ verdict=verdict,
142
+ reasons=reasons,
143
+ facts=facts,
144
+ )
ghostpkg/cli.py ADDED
@@ -0,0 +1,204 @@
1
+ """Command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import concurrent.futures
7
+ import json
8
+ import os
9
+ import re
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from .assess import Finding, Verdict, assess
14
+ from .registries import RegistryError, fetch
15
+
16
+ EXIT_OK = 0
17
+ EXIT_BLOCKED = 1
18
+ EXIT_ERROR = 2
19
+
20
+ MAX_WORKERS = 8
21
+
22
+
23
+ def _use_colour(stream) -> bool:
24
+ if os.environ.get("NO_COLOR"):
25
+ return False
26
+ return hasattr(stream, "isatty") and stream.isatty()
27
+
28
+
29
+ class Palette:
30
+ def __init__(self, enabled: bool) -> None:
31
+ self.enabled = enabled
32
+
33
+ def _wrap(self, code: str, text: str) -> str:
34
+ return f"\033[{code}m{text}\033[0m" if self.enabled else text
35
+
36
+ def red(self, text: str) -> str:
37
+ return self._wrap("31;1", text)
38
+
39
+ def yellow(self, text: str) -> str:
40
+ return self._wrap("33;1", text)
41
+
42
+ def green(self, text: str) -> str:
43
+ return self._wrap("32", text)
44
+
45
+ def dim(self, text: str) -> str:
46
+ return self._wrap("2", text)
47
+
48
+
49
+ MARKS = {
50
+ Verdict.BLOCK: ("BLOCKED", "red"),
51
+ Verdict.WARN: ("WARNING", "yellow"),
52
+ Verdict.OK: ("ok", "green"),
53
+ }
54
+
55
+
56
+ def render(findings: list[Finding], palette: Palette, quiet: bool) -> None:
57
+ for finding in findings:
58
+ label, colour = MARKS[finding.verdict]
59
+ paint = getattr(palette, colour)
60
+ if finding.verdict is Verdict.OK:
61
+ if quiet:
62
+ continue
63
+ detail = ""
64
+ if finding.facts and finding.facts.age_days is not None:
65
+ years = finding.facts.age_days / 365.0
66
+ detail = palette.dim(
67
+ f" ({finding.facts.release_count} releases, {years:.1f}y old)"
68
+ )
69
+ print(f" {paint(label):<8} {finding.name}{detail}")
70
+ continue
71
+
72
+ print(f" {paint(label):<8} {finding.name}")
73
+ for reason in finding.reasons:
74
+ print(f" {palette.dim('- ' + reason)}")
75
+
76
+
77
+ def summarise(findings: list[Finding], palette: Palette) -> None:
78
+ blocked = [f for f in findings if f.verdict is Verdict.BLOCK]
79
+ warned = [f for f in findings if f.verdict is Verdict.WARN]
80
+
81
+ print()
82
+ if blocked:
83
+ names = ", ".join(f.name for f in blocked)
84
+ print(palette.red(f" {len(blocked)} blocked: {names}"))
85
+ if warned:
86
+ print(palette.yellow(f" {len(warned)} to review by hand"))
87
+ if not blocked and not warned:
88
+ print(palette.green(f" all {len(findings)} packages look fine"))
89
+
90
+
91
+ def evaluate(names: list[str], ecosystem: str, strict: bool) -> list[Finding]:
92
+ def one(name: str) -> Finding:
93
+ return assess(fetch(name, ecosystem), strict=strict)
94
+
95
+ with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
96
+ return list(pool.map(one, names))
97
+
98
+
99
+ REQUIREMENT_LINE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)")
100
+
101
+
102
+ def parse_requirements(text: str) -> list[str]:
103
+ names = []
104
+ for raw in text.splitlines():
105
+ line = raw.strip()
106
+ if not line or line.startswith(("#", "-", "git+", "http")):
107
+ continue
108
+ match = REQUIREMENT_LINE.match(line)
109
+ if match:
110
+ names.append(match.group(1))
111
+ return names
112
+
113
+
114
+ def parse_package_json(text: str) -> list[str]:
115
+ data = json.loads(text)
116
+ names: list[str] = []
117
+ for key in ("dependencies", "devDependencies", "optionalDependencies"):
118
+ names.extend((data.get(key) or {}).keys())
119
+ return names
120
+
121
+
122
+ def load_manifest(path: Path) -> tuple[list[str], str]:
123
+ text = path.read_text(encoding="utf-8")
124
+ if path.name == "package.json":
125
+ return parse_package_json(text), "npm"
126
+ return parse_requirements(text), "pypi"
127
+
128
+
129
+ def build_parser() -> argparse.ArgumentParser:
130
+ parser = argparse.ArgumentParser(
131
+ prog="ghostpkg",
132
+ description="Catch package names that do not exist before you install them.",
133
+ )
134
+ parser.add_argument("--version", action="version", version="ghostpkg 0.1.0")
135
+ sub = parser.add_subparsers(dest="command", required=True)
136
+
137
+ check = sub.add_parser("check", help="check one or more package names")
138
+ check.add_argument("names", nargs="+")
139
+ check.add_argument("-e", "--ecosystem", choices=("pypi", "npm"), default="pypi")
140
+
141
+ scan = sub.add_parser("scan", help="check every dependency in a manifest")
142
+ scan.add_argument("path", type=Path, help="requirements.txt or package.json")
143
+
144
+ for command in (check, scan):
145
+ command.add_argument(
146
+ "--strict",
147
+ action="store_true",
148
+ help="treat warnings as blocking (flags legitimate new packages too)",
149
+ )
150
+ command.add_argument("--json", action="store_true", help="machine-readable output")
151
+ command.add_argument("-q", "--quiet", action="store_true", help="hide passing packages")
152
+
153
+ return parser
154
+
155
+
156
+ def main(argv: list[str] | None = None) -> int:
157
+ args = build_parser().parse_args(argv)
158
+
159
+ if args.command == "scan":
160
+ if not args.path.exists():
161
+ print(f"ghostpkg: no such file: {args.path}", file=sys.stderr)
162
+ return EXIT_ERROR
163
+ try:
164
+ names, ecosystem = load_manifest(args.path)
165
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
166
+ print(f"ghostpkg: could not parse {args.path}: {exc}", file=sys.stderr)
167
+ return EXIT_ERROR
168
+ if not names:
169
+ print(f"ghostpkg: no dependencies found in {args.path}", file=sys.stderr)
170
+ return EXIT_OK
171
+ else:
172
+ names, ecosystem = args.names, args.ecosystem
173
+
174
+ try:
175
+ findings = evaluate(names, ecosystem, args.strict)
176
+ except RegistryError as exc:
177
+ print(f"ghostpkg: {exc}", file=sys.stderr)
178
+ return EXIT_ERROR
179
+
180
+ if args.json:
181
+ print(
182
+ json.dumps(
183
+ [
184
+ {
185
+ "name": f.name,
186
+ "ecosystem": f.ecosystem,
187
+ "verdict": f.verdict.value,
188
+ "reasons": f.reasons,
189
+ }
190
+ for f in findings
191
+ ],
192
+ indent=2,
193
+ )
194
+ )
195
+ else:
196
+ palette = Palette(_use_colour(sys.stdout))
197
+ render(findings, palette, args.quiet)
198
+ summarise(findings, palette)
199
+
200
+ return EXIT_BLOCKED if any(f.is_blocked for f in findings) else EXIT_OK
201
+
202
+
203
+ if __name__ == "__main__":
204
+ sys.exit(main())