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 +16 -0
- ghostpkg/__main__.py +5 -0
- ghostpkg/assess.py +144 -0
- ghostpkg/cli.py +204 -0
- ghostpkg/data.py +399 -0
- ghostpkg/registries.py +130 -0
- ghostpkg-0.1.0.dist-info/METADATA +392 -0
- ghostpkg-0.1.0.dist-info/RECORD +11 -0
- ghostpkg-0.1.0.dist-info/WHEEL +4 -0
- ghostpkg-0.1.0.dist-info/entry_points.txt +2 -0
- ghostpkg-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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())
|