repovet-cli 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.
- repovet/__init__.py +3 -0
- repovet/__main__.py +3 -0
- repovet/checks.py +372 -0
- repovet/cli.py +104 -0
- repovet/github.py +159 -0
- repovet/radar.py +139 -0
- repovet/render.py +116 -0
- repovet/vet.py +175 -0
- repovet_cli-0.1.0.dist-info/METADATA +181 -0
- repovet_cli-0.1.0.dist-info/RECORD +14 -0
- repovet_cli-0.1.0.dist-info/WHEEL +5 -0
- repovet_cli-0.1.0.dist-info/entry_points.txt +2 -0
- repovet_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- repovet_cli-0.1.0.dist-info/top_level.txt +1 -0
repovet/__init__.py
ADDED
repovet/__main__.py
ADDED
repovet/checks.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Pure checks: take already-fetched GitHub data, return findings. No network here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
|
|
10
|
+
POINTS = {"high": 25, "medium": 12, "low": 5, "info": 0}
|
|
11
|
+
SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2, "info": 3}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class Finding:
|
|
16
|
+
severity: str # high | medium | low | info
|
|
17
|
+
category: str # stars | maintenance | people | license | install | identity
|
|
18
|
+
title: str
|
|
19
|
+
detail: str = ""
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def points(self) -> int:
|
|
23
|
+
return POINTS[self.severity]
|
|
24
|
+
|
|
25
|
+
def as_dict(self) -> dict:
|
|
26
|
+
return {"severity": self.severity, "category": self.category,
|
|
27
|
+
"title": self.title, "detail": self.detail}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_ts(value: str) -> datetime:
|
|
31
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def days_between(start: datetime, end: datetime) -> float:
|
|
35
|
+
return (end - start).total_seconds() / 86400
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# --------------------------------------------------------------------------- stars
|
|
39
|
+
def star_burst(star_pages: list[list[str]]) -> float | None:
|
|
40
|
+
"""Ratio of the fastest sampled star page to the median one, if it looks like a spike."""
|
|
41
|
+
rates = []
|
|
42
|
+
for page in star_pages:
|
|
43
|
+
if len(page) < 50:
|
|
44
|
+
continue
|
|
45
|
+
stamps = sorted(parse_ts(s) for s in page)
|
|
46
|
+
span = max(days_between(stamps[0], stamps[-1]), 1 / 1440)
|
|
47
|
+
rates.append(len(stamps) / span)
|
|
48
|
+
if len(rates) < 3:
|
|
49
|
+
return None
|
|
50
|
+
ordered = sorted(rates)
|
|
51
|
+
median = ordered[len(ordered) // 2]
|
|
52
|
+
if median <= 0:
|
|
53
|
+
return None
|
|
54
|
+
ratio = ordered[-1] / median
|
|
55
|
+
return ratio if ratio >= 50 else None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_ghost(profile: dict) -> bool:
|
|
59
|
+
"""Stargazer account that was brand new when it starred, or has no footprint at all."""
|
|
60
|
+
try:
|
|
61
|
+
fresh = days_between(parse_ts(profile["created_at"]),
|
|
62
|
+
parse_ts(profile["starred_at"])) < 14
|
|
63
|
+
except (KeyError, ValueError, TypeError):
|
|
64
|
+
fresh = False
|
|
65
|
+
empty = profile.get("followers", 1) == 0 and profile.get("public_repos", 1) == 0
|
|
66
|
+
return fresh or empty
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def check_stars(repo: dict, issue_total: int | None, star_pages: list[list[str]],
|
|
70
|
+
profiles: list[dict], now: datetime) -> list[Finding]:
|
|
71
|
+
out: list[Finding] = []
|
|
72
|
+
stars = repo.get("stargazers_count", 0)
|
|
73
|
+
forks = repo.get("forks_count", 0)
|
|
74
|
+
watchers = repo.get("subscribers_count")
|
|
75
|
+
age = max(days_between(parse_ts(repo["created_at"]), now), 1)
|
|
76
|
+
|
|
77
|
+
if stars >= 500:
|
|
78
|
+
ratio = forks / stars
|
|
79
|
+
detail = (f"{forks:,} forks vs {stars:,} stars ({ratio:.1%}). "
|
|
80
|
+
"Real projects usually sit around 5-15%.")
|
|
81
|
+
if ratio < 0.01:
|
|
82
|
+
out.append(Finding("medium", "stars", "Almost nobody forks it", detail))
|
|
83
|
+
elif ratio < 0.03:
|
|
84
|
+
out.append(Finding("low", "stars", "Low fork-to-star ratio", detail))
|
|
85
|
+
|
|
86
|
+
if stars >= 2000 and watchers is not None and watchers / stars < 0.001:
|
|
87
|
+
out.append(Finding("low", "stars", "Almost nobody watches it",
|
|
88
|
+
f"{watchers:,} watchers vs {stars:,} stars."))
|
|
89
|
+
|
|
90
|
+
if (stars >= 1000 and issue_total is not None and repo.get("has_issues", True)
|
|
91
|
+
and issue_total <= 3):
|
|
92
|
+
out.append(Finding(
|
|
93
|
+
"medium", "stars", "Stars without users",
|
|
94
|
+
f"{stars:,} stars but only {issue_total} issues/PRs ever opened. "
|
|
95
|
+
"Popular code that people really run collects bug reports."))
|
|
96
|
+
|
|
97
|
+
if age < 90 and stars / age > 1000:
|
|
98
|
+
out.append(Finding("info", "stars", "Explosive growth",
|
|
99
|
+
f"~{stars / age:,.0f} stars/day since creation. "
|
|
100
|
+
"Could be a real viral launch; verify before trusting."))
|
|
101
|
+
|
|
102
|
+
burst = star_burst(star_pages)
|
|
103
|
+
if burst:
|
|
104
|
+
out.append(Finding(
|
|
105
|
+
"low", "stars", "Star spike",
|
|
106
|
+
f"One sampled batch of stars arrived ~{burst:,.0f}x faster than the typical "
|
|
107
|
+
"pace. Can be an organic launch spike or a purchased batch."))
|
|
108
|
+
|
|
109
|
+
if len(profiles) >= 30:
|
|
110
|
+
share = sum(is_ghost(p) for p in profiles) / len(profiles)
|
|
111
|
+
detail = (f"{share:.0%} of the {len(profiles)} most recent stargazers are "
|
|
112
|
+
"brand-new or empty accounts (no repos, no followers).")
|
|
113
|
+
if share >= 0.35:
|
|
114
|
+
out.append(Finding("high", "stars", "Stargazers look fake", detail))
|
|
115
|
+
elif share >= 0.18:
|
|
116
|
+
out.append(Finding("medium", "stars", "Many low-quality stargazers", detail))
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# --------------------------------------------------------------------- maintenance
|
|
121
|
+
def check_maintenance(repo: dict, commits: list[dict], now: datetime) -> list[Finding]:
|
|
122
|
+
out: list[Finding] = []
|
|
123
|
+
stars = repo.get("stargazers_count", 0)
|
|
124
|
+
if repo.get("disabled"):
|
|
125
|
+
out.append(Finding("high", "maintenance", "Repository is disabled", ""))
|
|
126
|
+
if repo.get("archived"):
|
|
127
|
+
out.append(Finding("high", "maintenance", "Archived (read-only)",
|
|
128
|
+
"The owner has stopped maintaining it. No fixes will land."))
|
|
129
|
+
else:
|
|
130
|
+
idle = days_between(parse_ts(repo["pushed_at"]), now)
|
|
131
|
+
if idle > 730:
|
|
132
|
+
out.append(Finding("high", "maintenance", "Abandoned",
|
|
133
|
+
f"No push in {idle / 365:.1f} years."))
|
|
134
|
+
elif idle > 365:
|
|
135
|
+
out.append(Finding("medium", "maintenance", "Stale",
|
|
136
|
+
f"No push in {idle:.0f} days."))
|
|
137
|
+
elif idle > 180:
|
|
138
|
+
out.append(Finding("low", "maintenance", "Slowing down",
|
|
139
|
+
f"No push in {idle:.0f} days."))
|
|
140
|
+
|
|
141
|
+
age = days_between(parse_ts(repo["created_at"]), now)
|
|
142
|
+
if age < 30:
|
|
143
|
+
out.append(Finding("low", "maintenance", "Very new project",
|
|
144
|
+
f"Created {age:.0f} days ago. No track record yet."))
|
|
145
|
+
|
|
146
|
+
if commits and len(commits) < 5 and stars >= 500:
|
|
147
|
+
out.append(Finding("medium", "maintenance", "Barely any commits",
|
|
148
|
+
f"Only {len(commits)} commits on the default branch for a "
|
|
149
|
+
f"repo with {stars:,} stars."))
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# -------------------------------------------------------------------------- people
|
|
154
|
+
def top_author_share(commits: list[dict]) -> tuple[str | None, float]:
|
|
155
|
+
counts: dict[str, int] = {}
|
|
156
|
+
for commit in commits:
|
|
157
|
+
author = (commit.get("author") or {}).get("login") or (
|
|
158
|
+
((commit.get("commit") or {}).get("author") or {}).get("name"))
|
|
159
|
+
if author:
|
|
160
|
+
counts[author] = counts.get(author, 0) + 1
|
|
161
|
+
if not counts:
|
|
162
|
+
return None, 0.0
|
|
163
|
+
name, top = max(counts.items(), key=lambda item: item[1])
|
|
164
|
+
return name, top / sum(counts.values())
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def check_people(repo: dict, owner: dict | None, contributors: list[dict],
|
|
168
|
+
commits: list[dict], now: datetime) -> list[Finding]:
|
|
169
|
+
out: list[Finding] = []
|
|
170
|
+
stars = repo.get("stargazers_count", 0)
|
|
171
|
+
name, share = top_author_share(commits)
|
|
172
|
+
if len(commits) >= 20 and share >= 0.9 and len(contributors) <= 2:
|
|
173
|
+
out.append(Finding("low", "people", "Single maintainer",
|
|
174
|
+
f"{name} wrote {share:.0%} of the recent commits. "
|
|
175
|
+
"If they leave, the project stops."))
|
|
176
|
+
if owner and owner.get("type") == "User" and stars >= 1000:
|
|
177
|
+
owner_age = days_between(parse_ts(owner["created_at"]), now)
|
|
178
|
+
if owner_age < 90:
|
|
179
|
+
out.append(Finding("medium", "people", "Owner account is brand new",
|
|
180
|
+
f"Account created {owner_age:.0f} days ago yet owns a "
|
|
181
|
+
f"{stars:,}-star repo."))
|
|
182
|
+
elif owner.get("public_repos", 0) <= 2 and owner.get("followers", 0) < 20:
|
|
183
|
+
out.append(Finding("low", "people", "Owner has no track record",
|
|
184
|
+
f"{owner.get('public_repos', 0)} public repos, "
|
|
185
|
+
f"{owner.get('followers', 0)} followers."))
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ------------------------------------------------------------------------- license
|
|
190
|
+
RESTRICTIVE = ("AGPL", "GPL", "SSPL", "BUSL", "CC-BY-NC", "Elastic")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def check_license(repo: dict) -> list[Finding]:
|
|
194
|
+
lic = repo.get("license")
|
|
195
|
+
if not lic:
|
|
196
|
+
return [Finding("medium", "license", "No license",
|
|
197
|
+
"Without a license you have no legal right to reuse or modify "
|
|
198
|
+
"the code, even though it is public.")]
|
|
199
|
+
spdx = lic.get("spdx_id") or ""
|
|
200
|
+
if spdx in ("", "NOASSERTION"):
|
|
201
|
+
return [Finding("low", "license", "Custom or unrecognized license",
|
|
202
|
+
"GitHub could not classify the license. Read it before using.")]
|
|
203
|
+
if any(spdx.startswith(prefix) or prefix in spdx for prefix in RESTRICTIVE):
|
|
204
|
+
return [Finding("info", "license", f"Restrictive license ({spdx})",
|
|
205
|
+
"Copyleft or source-available terms. Check they fit your use.")]
|
|
206
|
+
return []
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ------------------------------------------------------------------------- install
|
|
210
|
+
EXEC_EXT = (".exe", ".scr", ".msi", ".lnk")
|
|
211
|
+
LIB_EXT = (".dll", ".so", ".dylib", ".jar", ".bin", ".pyc")
|
|
212
|
+
INSTALL_NAMES = ("install.sh", "setup.sh", "install.ps1", "setup.ps1", "install.bat",
|
|
213
|
+
"bootstrap.sh")
|
|
214
|
+
|
|
215
|
+
SCRIPT_PATTERNS = [
|
|
216
|
+
("high", r"(eval|exec)\s*\(.{0,80}(b64decode|atob|fromCharCode|decompress)",
|
|
217
|
+
"Runs obfuscated/decoded code"),
|
|
218
|
+
("high", r"powershell[^\n]{0,60}\s-e(nc|ncodedcommand)?\s+[A-Za-z0-9+/=]{20,}",
|
|
219
|
+
"Runs an encoded PowerShell command"),
|
|
220
|
+
("high", r"(IEX|Invoke-Expression)[^\n]{0,80}(DownloadString|Invoke-WebRequest|iwr|irm)",
|
|
221
|
+
"Downloads and executes remote PowerShell"),
|
|
222
|
+
("high", r"discord(app)?\.com/api/webhooks/|api\.telegram\.org/bot",
|
|
223
|
+
"Sends data to a Discord/Telegram webhook"),
|
|
224
|
+
("medium", r"(curl|wget)[^\n|]{0,200}\|\s*(sudo\s+)?(ba|z)?sh\b",
|
|
225
|
+
"Pipes a remote script straight into a shell"),
|
|
226
|
+
("medium", r"https?://\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}[:/]",
|
|
227
|
+
"Talks to a hard-coded IP address"),
|
|
228
|
+
("medium", r"base64\s+(-d|--decode)[^\n]{0,40}\|\s*(ba|z)?sh",
|
|
229
|
+
"Decodes base64 and runs it"),
|
|
230
|
+
]
|
|
231
|
+
README_PATTERNS = [
|
|
232
|
+
("medium", r"(\.zip|\.rar|\.7z|archive)[^\n]{0,200}pass(word)?\s*[:=`* ]+\S+"
|
|
233
|
+
r"|pass(word)?\s*[:=`* ]+\S+[^\n]{0,200}(\.zip|\.rar|\.7z)",
|
|
234
|
+
"README tells you to open a password-protected archive",
|
|
235
|
+
"Classic malware delivery: the password hides the payload from scanners."),
|
|
236
|
+
("low", r"(curl|wget)[^\n|]{0,200}\|\s*(sudo\s+)?(ba|z)?sh\b",
|
|
237
|
+
"README install pipes a remote script into a shell",
|
|
238
|
+
"Common, but read the script before running it."),
|
|
239
|
+
]
|
|
240
|
+
SETUP_PY_RISK = re.compile(r"\b(os\.system|subprocess\.|urllib\.request|requests\.(get|post)"
|
|
241
|
+
r"|socket\.)")
|
|
242
|
+
HOOK_RISK = re.compile(r"curl|wget|node\s+-e|powershell|base64|https?://", re.I)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def pick_files(tree_paths: list[str]) -> list[str]:
|
|
246
|
+
"""Which files are worth downloading for the install-risk check."""
|
|
247
|
+
wanted: list[str] = []
|
|
248
|
+
for path in tree_paths:
|
|
249
|
+
lower = path.lower()
|
|
250
|
+
base = lower.rsplit("/", 1)[-1]
|
|
251
|
+
depth = lower.count("/")
|
|
252
|
+
if depth == 0 and (base.startswith("readme") or base in ("package.json", "setup.py")):
|
|
253
|
+
wanted.append(path)
|
|
254
|
+
elif depth <= 1 and base in INSTALL_NAMES:
|
|
255
|
+
wanted.append(path)
|
|
256
|
+
return wanted[:8]
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def check_install(tree_paths: list[str], files: dict[str, str]) -> list[Finding]:
|
|
260
|
+
out: list[Finding] = []
|
|
261
|
+
|
|
262
|
+
executables = [p for p in tree_paths if p.lower().endswith(EXEC_EXT)]
|
|
263
|
+
libraries = [p for p in tree_paths if p.lower().endswith(LIB_EXT)]
|
|
264
|
+
if executables:
|
|
265
|
+
out.append(Finding("medium", "install", "Executables committed to the repo",
|
|
266
|
+
f"{len(executables)} file(s), e.g. {executables[0]}. "
|
|
267
|
+
"You cannot review what a binary does."))
|
|
268
|
+
elif libraries:
|
|
269
|
+
out.append(Finding("low", "install", "Prebuilt binaries committed",
|
|
270
|
+
f"{len(libraries)} file(s), e.g. {libraries[0]}."))
|
|
271
|
+
|
|
272
|
+
for path, text in files.items():
|
|
273
|
+
base = path.lower().rsplit("/", 1)[-1]
|
|
274
|
+
if base.startswith("readme"):
|
|
275
|
+
for severity, pattern, title, detail in README_PATTERNS:
|
|
276
|
+
if re.search(pattern, text, re.I):
|
|
277
|
+
out.append(Finding(severity, "install", title, detail))
|
|
278
|
+
continue
|
|
279
|
+
|
|
280
|
+
if base == "package.json":
|
|
281
|
+
try:
|
|
282
|
+
scripts = (json.loads(text).get("scripts") or {})
|
|
283
|
+
except (ValueError, AttributeError):
|
|
284
|
+
scripts = {}
|
|
285
|
+
for hook in ("preinstall", "install", "postinstall"):
|
|
286
|
+
command = scripts.get(hook)
|
|
287
|
+
if isinstance(command, str) and command.strip():
|
|
288
|
+
risky = HOOK_RISK.search(command)
|
|
289
|
+
out.append(Finding(
|
|
290
|
+
"medium" if risky else "low", "install",
|
|
291
|
+
f"npm {hook} hook runs on install",
|
|
292
|
+
f"`{command[:140]}`"))
|
|
293
|
+
if base == "setup.py" and SETUP_PY_RISK.search(text):
|
|
294
|
+
out.append(Finding("medium", "install",
|
|
295
|
+
"setup.py runs commands or network calls",
|
|
296
|
+
"Code executes on `pip install`, before you ever import it."))
|
|
297
|
+
for severity, pattern, title in SCRIPT_PATTERNS:
|
|
298
|
+
if re.search(pattern, text, re.I):
|
|
299
|
+
out.append(Finding(severity, "install", title, f"in {path}"))
|
|
300
|
+
return out
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
# ------------------------------------------------------------------------ identity
|
|
304
|
+
def norm_name(name: str) -> str:
|
|
305
|
+
return re.sub(r"[^a-z0-9]", "", name.lower())
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def jaccard(a: str | None, b: str | None) -> float:
|
|
309
|
+
words_a = set(re.findall(r"[a-z0-9]{3,}", (a or "").lower()))
|
|
310
|
+
words_b = set(re.findall(r"[a-z0-9]{3,}", (b or "").lower()))
|
|
311
|
+
if not words_a or not words_b:
|
|
312
|
+
return 0.0
|
|
313
|
+
return len(words_a & words_b) / len(words_a | words_b)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def check_identity(repo: dict, name_hits: list[dict]) -> list[Finding]:
|
|
317
|
+
out: list[Finding] = []
|
|
318
|
+
if repo.get("fork") and repo.get("parent"):
|
|
319
|
+
out.append(Finding("info", "identity",
|
|
320
|
+
f"Fork of {repo['parent'].get('full_name')}",
|
|
321
|
+
"Check whether you want the original instead."))
|
|
322
|
+
name = norm_name(repo["name"])
|
|
323
|
+
if len(name) < 4:
|
|
324
|
+
return out
|
|
325
|
+
me = repo["full_name"].lower()
|
|
326
|
+
stars = repo.get("stargazers_count", 0)
|
|
327
|
+
created = parse_ts(repo["created_at"])
|
|
328
|
+
same = [h for h in name_hits
|
|
329
|
+
if h["full_name"].lower() != me and norm_name(h["name"]) == name]
|
|
330
|
+
|
|
331
|
+
originals = [h for h in same
|
|
332
|
+
if h.get("stargazers_count", 0) > max(2 * stars, 50)
|
|
333
|
+
and parse_ts(h["created_at"]) < created]
|
|
334
|
+
if originals:
|
|
335
|
+
top = max(originals, key=lambda h: h.get("stargazers_count", 0))
|
|
336
|
+
similar = jaccard(repo.get("description"), top.get("description")) >= 0.4
|
|
337
|
+
out.append(Finding(
|
|
338
|
+
"medium" if similar else "low", "identity",
|
|
339
|
+
"Same name as a bigger, older repo",
|
|
340
|
+
f"{top['full_name']} ({top.get('stargazers_count', 0):,} stars) predates "
|
|
341
|
+
"this one" + (" and has a near-identical description." if similar else ".")
|
|
342
|
+
+ " Make sure this is the project you meant."))
|
|
343
|
+
else:
|
|
344
|
+
copies = [h for h in same if parse_ts(h["created_at"]) > created
|
|
345
|
+
and jaccard(repo.get("description"), h.get("description")) >= 0.4]
|
|
346
|
+
if copies:
|
|
347
|
+
out.append(Finding("info", "identity", f"{len(copies)} lookalike repo(s) exist",
|
|
348
|
+
"Others copy this name and description, e.g. "
|
|
349
|
+
f"{copies[0]['full_name']}. This one is the older repo."))
|
|
350
|
+
return out
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def alternatives(repo: dict, topic_hits: list[dict], limit: int = 5) -> list[dict]:
|
|
354
|
+
me = repo["full_name"].lower()
|
|
355
|
+
# Tiny repos that merely share a topic are noise, not alternatives.
|
|
356
|
+
floor = max(50, repo.get("stargazers_count", 0) // 100)
|
|
357
|
+
picked = [h for h in topic_hits if h["full_name"].lower() != me and not h.get("archived")
|
|
358
|
+
and h.get("stargazers_count", 0) >= floor]
|
|
359
|
+
picked.sort(key=lambda h: h.get("stargazers_count", 0), reverse=True)
|
|
360
|
+
return [{"full_name": h["full_name"], "stars": h.get("stargazers_count", 0),
|
|
361
|
+
"description": (h.get("description") or "")[:90]} for h in picked[:limit]]
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# ------------------------------------------------------------------------- scoring
|
|
365
|
+
def score(findings: list[Finding]) -> tuple[int, str]:
|
|
366
|
+
value = max(0, 100 - sum(f.points for f in findings))
|
|
367
|
+
has_high = any(f.severity == "high" for f in findings)
|
|
368
|
+
if value >= 80 and not has_high:
|
|
369
|
+
return value, "LOOKS OK"
|
|
370
|
+
if value >= 55:
|
|
371
|
+
return value, "CAUTION"
|
|
372
|
+
return value, "HIGH RISK"
|
repovet/cli.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Command line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .github import GitHub, GitHubError, NotFound, RateLimited, find_token
|
|
13
|
+
from .radar import detect_stack, radar
|
|
14
|
+
from .render import render_radar, render_vet
|
|
15
|
+
from .vet import vet
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_target(target: str) -> tuple[str, str]:
|
|
19
|
+
cleaned = target.strip()
|
|
20
|
+
cleaned = re.sub(r"^(https?://)?(www\.)?github\.com/", "", cleaned, flags=re.I)
|
|
21
|
+
cleaned = re.sub(r"^git@github\.com:", "", cleaned, flags=re.I)
|
|
22
|
+
parts = [p for p in cleaned.split("/") if p]
|
|
23
|
+
if len(parts) < 2:
|
|
24
|
+
raise ValueError(f"expected owner/repo or a GitHub URL, got: {target}")
|
|
25
|
+
owner, name = parts[0], parts[1]
|
|
26
|
+
if name.endswith(".git"):
|
|
27
|
+
name = name[:-4]
|
|
28
|
+
if not re.fullmatch(r"[A-Za-z0-9_.-]+", owner) or not re.fullmatch(r"[A-Za-z0-9_.-]+", name):
|
|
29
|
+
raise ValueError(f"not a valid repo reference: {target}")
|
|
30
|
+
return owner, name
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
34
|
+
parser = argparse.ArgumentParser(
|
|
35
|
+
prog="repovet",
|
|
36
|
+
description="Vet a GitHub repo before you trust it, and find new repos for your stack.")
|
|
37
|
+
parser.add_argument("--version", action="version", version=f"repovet {__version__}")
|
|
38
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
39
|
+
|
|
40
|
+
def common(p: argparse.ArgumentParser):
|
|
41
|
+
p.add_argument("--json", action="store_true", help="machine-readable output")
|
|
42
|
+
p.add_argument("--no-cache", action="store_true",
|
|
43
|
+
help="ignore the 1-hour response cache")
|
|
44
|
+
|
|
45
|
+
p_vet = sub.add_parser("vet", help="score one repo for trust signals")
|
|
46
|
+
p_vet.add_argument("repo", help="owner/repo or GitHub URL")
|
|
47
|
+
p_vet.add_argument("--fail-under", type=int, metavar="N",
|
|
48
|
+
help="exit with code 2 if the score is below N (for CI)")
|
|
49
|
+
common(p_vet)
|
|
50
|
+
|
|
51
|
+
p_radar = sub.add_parser("radar", help="new repos matching your stack")
|
|
52
|
+
p_radar.add_argument("--path", default=".", help="project folder to read the stack from")
|
|
53
|
+
p_radar.add_argument("--lang", action="append", default=[],
|
|
54
|
+
help="language to follow (repeatable); overrides detection")
|
|
55
|
+
p_radar.add_argument("--topic", action="append", default=[],
|
|
56
|
+
help="GitHub topic to follow (repeatable)")
|
|
57
|
+
p_radar.add_argument("--days", type=int, default=14, help="how far back to look")
|
|
58
|
+
p_radar.add_argument("--min-stars", type=int, default=100)
|
|
59
|
+
p_radar.add_argument("--top", type=int, default=15, help="how many results to show")
|
|
60
|
+
common(p_radar)
|
|
61
|
+
return parser
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main(argv: list[str] | None = None) -> int:
|
|
65
|
+
for stream in (sys.stdout, sys.stderr):
|
|
66
|
+
try:
|
|
67
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
68
|
+
except (AttributeError, ValueError):
|
|
69
|
+
pass
|
|
70
|
+
args = build_parser().parse_args(argv)
|
|
71
|
+
gh = GitHub(token=find_token(), cache_ttl=0 if args.no_cache else 3600)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
if args.command == "vet":
|
|
75
|
+
owner, name = parse_target(args.repo)
|
|
76
|
+
report = vet(gh, owner, name)
|
|
77
|
+
print(json.dumps(report, indent=2) if args.json else render_vet(report))
|
|
78
|
+
if args.fail_under is not None and report["score"] < args.fail_under:
|
|
79
|
+
return 2
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
languages, topics = list(args.lang), list(args.topic)
|
|
83
|
+
if not languages and not topics:
|
|
84
|
+
languages, topics = detect_stack(Path(args.path))
|
|
85
|
+
report = radar(gh, languages, topics, days=args.days,
|
|
86
|
+
min_stars=args.min_stars, top=args.top)
|
|
87
|
+
print(json.dumps(report, indent=2) if args.json else render_radar(report))
|
|
88
|
+
return 0
|
|
89
|
+
except ValueError as err:
|
|
90
|
+
print(f"error: {err}", file=sys.stderr)
|
|
91
|
+
return 1
|
|
92
|
+
except NotFound:
|
|
93
|
+
print("error: repository not found (private, renamed, or misspelled)", file=sys.stderr)
|
|
94
|
+
return 1
|
|
95
|
+
except RateLimited as err:
|
|
96
|
+
print(f"error: {err}. Set GITHUB_TOKEN to raise the limit.", file=sys.stderr)
|
|
97
|
+
return 1
|
|
98
|
+
except GitHubError as err:
|
|
99
|
+
print(f"error: {err}", file=sys.stderr)
|
|
100
|
+
return 1
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
raise SystemExit(main())
|
repovet/github.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Minimal GitHub API client: stdlib only, on-disk cache, clear rate-limit errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import time
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.parse
|
|
12
|
+
import urllib.request
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
API = "https://api.github.com"
|
|
16
|
+
RAW = "https://raw.githubusercontent.com"
|
|
17
|
+
USER_AGENT = "repovet-cli"
|
|
18
|
+
MAX_RAW_BYTES = 200_000
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GitHubError(Exception):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NotFound(GitHubError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RateLimited(GitHubError):
|
|
30
|
+
def __init__(self, reset_epoch: int | None = None):
|
|
31
|
+
self.reset_epoch = reset_epoch
|
|
32
|
+
wait = ""
|
|
33
|
+
if reset_epoch:
|
|
34
|
+
mins = max(0, int((reset_epoch - time.time()) / 60) + 1)
|
|
35
|
+
wait = f" (resets in ~{mins} min)"
|
|
36
|
+
super().__init__(f"GitHub API rate limit hit{wait}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def find_token() -> str | None:
|
|
40
|
+
"""Token from GITHUB_TOKEN / GH_TOKEN, else from the gh CLI if it is logged in."""
|
|
41
|
+
for key in ("GITHUB_TOKEN", "GH_TOKEN"):
|
|
42
|
+
value = os.environ.get(key, "").strip()
|
|
43
|
+
if value:
|
|
44
|
+
return value
|
|
45
|
+
try:
|
|
46
|
+
out = subprocess.run(
|
|
47
|
+
["gh", "auth", "token"], capture_output=True, text=True, timeout=5
|
|
48
|
+
)
|
|
49
|
+
if out.returncode == 0 and out.stdout.strip():
|
|
50
|
+
return out.stdout.strip()
|
|
51
|
+
except (OSError, subprocess.SubprocessError):
|
|
52
|
+
pass
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class GitHub:
|
|
57
|
+
def __init__(self, token: str | None = None, cache_ttl: int = 3600,
|
|
58
|
+
cache_dir: Path | None = None):
|
|
59
|
+
self.token = token
|
|
60
|
+
self.cache_ttl = cache_ttl
|
|
61
|
+
self.cache_dir = cache_dir or Path(
|
|
62
|
+
os.environ.get("REPOVET_CACHE") or Path.home() / ".cache" / "repovet"
|
|
63
|
+
)
|
|
64
|
+
self.requests_made = 0
|
|
65
|
+
|
|
66
|
+
# -- cache ---------------------------------------------------------------
|
|
67
|
+
def _cache_path(self, key: str) -> Path:
|
|
68
|
+
return self.cache_dir / (hashlib.sha256(key.encode()).hexdigest() + ".json")
|
|
69
|
+
|
|
70
|
+
def _cache_get(self, key: str):
|
|
71
|
+
if self.cache_ttl <= 0:
|
|
72
|
+
return None
|
|
73
|
+
path = self._cache_path(key)
|
|
74
|
+
try:
|
|
75
|
+
if time.time() - path.stat().st_mtime > self.cache_ttl:
|
|
76
|
+
return None
|
|
77
|
+
return json.loads(path.read_text(encoding="utf-8"))["body"]
|
|
78
|
+
except (OSError, ValueError, KeyError):
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
def _cache_put(self, key: str, body) -> None:
|
|
82
|
+
if self.cache_ttl <= 0:
|
|
83
|
+
return
|
|
84
|
+
try:
|
|
85
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
self._cache_path(key).write_text(json.dumps({"body": body}), encoding="utf-8")
|
|
87
|
+
except OSError:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
# -- http ----------------------------------------------------------------
|
|
91
|
+
def _open(self, url: str, headers: dict, data: bytes | None = None) -> bytes:
|
|
92
|
+
headers = {"User-Agent": USER_AGENT, **headers}
|
|
93
|
+
req = urllib.request.Request(url, data=data, headers=headers)
|
|
94
|
+
self.requests_made += 1
|
|
95
|
+
try:
|
|
96
|
+
with urllib.request.urlopen(req, timeout=25) as resp:
|
|
97
|
+
return resp.read()
|
|
98
|
+
except urllib.error.HTTPError as err:
|
|
99
|
+
body = err.read().decode("utf-8", "replace")[:500]
|
|
100
|
+
if err.code == 404:
|
|
101
|
+
raise NotFound(f"not found: {url}") from None
|
|
102
|
+
if err.code in (403, 429) and (
|
|
103
|
+
err.headers.get("x-ratelimit-remaining") == "0"
|
|
104
|
+
or "rate limit" in body.lower()
|
|
105
|
+
):
|
|
106
|
+
reset = err.headers.get("x-ratelimit-reset")
|
|
107
|
+
raise RateLimited(int(reset) if reset and reset.isdigit() else None) from None
|
|
108
|
+
raise GitHubError(f"HTTP {err.code} for {url}: {body[:160]}") from None
|
|
109
|
+
except (urllib.error.URLError, TimeoutError, OSError) as err:
|
|
110
|
+
raise GitHubError(f"network error for {url}: {err}") from None
|
|
111
|
+
|
|
112
|
+
def _auth(self) -> dict:
|
|
113
|
+
return {"Authorization": f"Bearer {self.token}"} if self.token else {}
|
|
114
|
+
|
|
115
|
+
def get(self, path: str, params: dict | None = None,
|
|
116
|
+
accept: str = "application/vnd.github+json"):
|
|
117
|
+
url = API + path
|
|
118
|
+
if params:
|
|
119
|
+
url += "?" + urllib.parse.urlencode(params)
|
|
120
|
+
key = f"{accept}|{url}"
|
|
121
|
+
cached = self._cache_get(key)
|
|
122
|
+
if cached is not None:
|
|
123
|
+
return cached
|
|
124
|
+
raw = self._open(url, {"Accept": accept, **self._auth()})
|
|
125
|
+
body = json.loads(raw.decode("utf-8")) if raw else None
|
|
126
|
+
if body is not None:
|
|
127
|
+
self._cache_put(key, body)
|
|
128
|
+
return body
|
|
129
|
+
|
|
130
|
+
def graphql(self, query: str, variables: dict):
|
|
131
|
+
if not self.token:
|
|
132
|
+
raise GitHubError("GraphQL needs a token")
|
|
133
|
+
key = "graphql|" + query + json.dumps(variables, sort_keys=True)
|
|
134
|
+
cached = self._cache_get(key)
|
|
135
|
+
if cached is not None:
|
|
136
|
+
return cached
|
|
137
|
+
payload = json.dumps({"query": query, "variables": variables}).encode()
|
|
138
|
+
raw = self._open(API + "/graphql",
|
|
139
|
+
{"Content-Type": "application/json", **self._auth()}, payload)
|
|
140
|
+
body = json.loads(raw.decode("utf-8"))
|
|
141
|
+
if body.get("errors"):
|
|
142
|
+
raise GitHubError("GraphQL: " + str(body["errors"][0].get("message")))
|
|
143
|
+
self._cache_put(key, body["data"])
|
|
144
|
+
return body["data"]
|
|
145
|
+
|
|
146
|
+
def raw(self, full_name: str, ref: str, path: str) -> str | None:
|
|
147
|
+
"""Fetch a file from raw.githubusercontent.com (not counted against the API limit)."""
|
|
148
|
+
url = f"{RAW}/{full_name}/{urllib.parse.quote(ref)}/{urllib.parse.quote(path)}"
|
|
149
|
+
key = "raw|" + url
|
|
150
|
+
cached = self._cache_get(key)
|
|
151
|
+
if cached is not None:
|
|
152
|
+
return cached
|
|
153
|
+
try:
|
|
154
|
+
data = self._open(url, {})
|
|
155
|
+
except GitHubError:
|
|
156
|
+
return None
|
|
157
|
+
text = data[:MAX_RAW_BYTES].decode("utf-8", "replace")
|
|
158
|
+
self._cache_put(key, text)
|
|
159
|
+
return text
|
repovet/radar.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Radar: new repos that match your stack, clones collapsed, ranked by real traction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import checks
|
|
11
|
+
from .github import GitHub, GitHubError
|
|
12
|
+
|
|
13
|
+
# dependency name -> GitHub topic worth searching
|
|
14
|
+
DEP_TOPICS = {
|
|
15
|
+
"react": "react", "next": "nextjs", "vue": "vue", "svelte": "svelte",
|
|
16
|
+
"express": "express", "tailwindcss": "tailwindcss", "electron": "electron",
|
|
17
|
+
"vite": "vite", "astro": "astro", "three": "threejs", "@anthropic-ai/sdk": "claude",
|
|
18
|
+
"openai": "openai", "langchain": "langchain", "django": "django", "fastapi": "fastapi",
|
|
19
|
+
"flask": "flask", "torch": "pytorch", "pandas": "pandas", "numpy": "numpy",
|
|
20
|
+
"anthropic": "claude", "transformers": "transformers", "playwright": "playwright",
|
|
21
|
+
"tokio": "tokio", "axum": "axum", "tauri": "tauri", "bevy": "bevy",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def detect_stack(path: Path) -> tuple[list[str], list[str]]:
|
|
26
|
+
"""Guess languages and topics from manifest files in a project folder."""
|
|
27
|
+
languages: list[str] = []
|
|
28
|
+
deps: list[str] = []
|
|
29
|
+
|
|
30
|
+
def add_lang(lang: str):
|
|
31
|
+
if lang not in languages:
|
|
32
|
+
languages.append(lang)
|
|
33
|
+
|
|
34
|
+
package = path / "package.json"
|
|
35
|
+
if package.is_file():
|
|
36
|
+
try:
|
|
37
|
+
manifest = json.loads(package.read_text(encoding="utf-8", errors="replace"))
|
|
38
|
+
names = list((manifest.get("dependencies") or {})) + list(
|
|
39
|
+
(manifest.get("devDependencies") or {}))
|
|
40
|
+
except (ValueError, OSError, AttributeError):
|
|
41
|
+
names = []
|
|
42
|
+
add_lang("TypeScript" if "typescript" in names or (path / "tsconfig.json").is_file()
|
|
43
|
+
else "JavaScript")
|
|
44
|
+
deps += names
|
|
45
|
+
|
|
46
|
+
for filename in ("requirements.txt", "pyproject.toml"):
|
|
47
|
+
manifest_path = path / filename
|
|
48
|
+
if manifest_path.is_file():
|
|
49
|
+
add_lang("Python")
|
|
50
|
+
try:
|
|
51
|
+
text = manifest_path.read_text(encoding="utf-8", errors="replace")
|
|
52
|
+
except OSError:
|
|
53
|
+
text = ""
|
|
54
|
+
deps += re.findall(r"^[\s\"']*([A-Za-z0-9_.-]+)\s*(?:[=<>~!\[\"',]|$)", text, re.M)
|
|
55
|
+
|
|
56
|
+
for filename, lang in (("go.mod", "Go"), ("Cargo.toml", "Rust"), ("pom.xml", "Java"),
|
|
57
|
+
("build.gradle", "Java"), ("build.gradle.kts", "Kotlin"),
|
|
58
|
+
("Gemfile", "Ruby"), ("composer.json", "PHP")):
|
|
59
|
+
manifest_path = path / filename
|
|
60
|
+
if manifest_path.is_file():
|
|
61
|
+
add_lang(lang)
|
|
62
|
+
if filename == "Cargo.toml":
|
|
63
|
+
try:
|
|
64
|
+
deps += re.findall(r"^([A-Za-z0-9_-]+)\s*=",
|
|
65
|
+
manifest_path.read_text(encoding="utf-8",
|
|
66
|
+
errors="replace"), re.M)
|
|
67
|
+
except OSError:
|
|
68
|
+
pass
|
|
69
|
+
if any(path.glob("*.csproj")) or any(path.glob("*.sln")):
|
|
70
|
+
add_lang("C#")
|
|
71
|
+
|
|
72
|
+
topics: list[str] = []
|
|
73
|
+
for dep in deps:
|
|
74
|
+
topic = DEP_TOPICS.get(dep.lower())
|
|
75
|
+
if topic and topic not in topics:
|
|
76
|
+
topics.append(topic)
|
|
77
|
+
return languages, topics
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def rank(items: list[dict], now: datetime) -> list[dict]:
|
|
81
|
+
"""Collapse same-name clones, then order by age-adjusted traction."""
|
|
82
|
+
groups: dict[str, list[dict]] = {}
|
|
83
|
+
for item in {i["full_name"]: i for i in items}.values():
|
|
84
|
+
groups.setdefault(checks.norm_name(item["name"]), []).append(item)
|
|
85
|
+
|
|
86
|
+
rows = []
|
|
87
|
+
for group in groups.values():
|
|
88
|
+
group.sort(key=lambda i: (-i.get("stargazers_count", 0), i["created_at"]))
|
|
89
|
+
best = group[0]
|
|
90
|
+
stars = best.get("stargazers_count", 0)
|
|
91
|
+
forks = best.get("forks_count", 0)
|
|
92
|
+
age = max(checks.days_between(checks.parse_ts(best["created_at"]), now), 1)
|
|
93
|
+
per_day = stars / age
|
|
94
|
+
flags = []
|
|
95
|
+
quality = 1.0
|
|
96
|
+
if stars >= 500 and forks / stars < 0.01:
|
|
97
|
+
flags.append("few forks")
|
|
98
|
+
quality *= 0.4
|
|
99
|
+
if not best.get("description"):
|
|
100
|
+
flags.append("no description")
|
|
101
|
+
quality *= 0.7
|
|
102
|
+
if not best.get("license"):
|
|
103
|
+
flags.append("no license")
|
|
104
|
+
quality *= 0.85
|
|
105
|
+
if len(group) > 1:
|
|
106
|
+
flags.append(f"+{len(group) - 1} same-name clone(s) hidden")
|
|
107
|
+
rows.append({
|
|
108
|
+
"full_name": best["full_name"], "stars": stars, "forks": forks,
|
|
109
|
+
"age_days": round(age), "stars_per_day": round(per_day, 1),
|
|
110
|
+
"language": best.get("language"), "description": best.get("description") or "",
|
|
111
|
+
"flags": flags, "rank_score": round(per_day * quality, 2),
|
|
112
|
+
"url": best.get("html_url"),
|
|
113
|
+
})
|
|
114
|
+
rows.sort(key=lambda r: r["rank_score"], reverse=True)
|
|
115
|
+
return rows
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def radar(gh: GitHub, languages: list[str], topics: list[str], days: int = 14,
|
|
119
|
+
min_stars: int = 100, top: int = 15) -> dict:
|
|
120
|
+
now = datetime.now(timezone.utc)
|
|
121
|
+
since = (now - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
122
|
+
base = f"created:>={since} stars:>={min_stars}"
|
|
123
|
+
queries = [f"{base} language:\"{lang}\"" for lang in languages[:3]]
|
|
124
|
+
queries += [f"{base} topic:{topic}" for topic in topics[:3]]
|
|
125
|
+
if not queries:
|
|
126
|
+
queries = [base]
|
|
127
|
+
|
|
128
|
+
items: list[dict] = []
|
|
129
|
+
errors: list[str] = []
|
|
130
|
+
for query in queries:
|
|
131
|
+
try:
|
|
132
|
+
result = gh.get("/search/repositories",
|
|
133
|
+
{"q": query, "sort": "stars", "order": "desc", "per_page": 50})
|
|
134
|
+
items += (result or {}).get("items", [])
|
|
135
|
+
except GitHubError as err:
|
|
136
|
+
errors.append(str(err))
|
|
137
|
+
break
|
|
138
|
+
return {"since": since, "languages": languages[:3], "topics": topics[:3],
|
|
139
|
+
"queries": queries, "results": rank(items, now)[:top], "errors": errors}
|
repovet/render.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Terminal output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
COLORS = {"red": "31", "yellow": "33", "green": "32", "cyan": "36", "dim": "2", "bold": "1"}
|
|
9
|
+
SEVERITY_COLOR = {"high": "red", "medium": "yellow", "low": "cyan", "info": "dim"}
|
|
10
|
+
VERDICT_COLOR = {"LOOKS OK": "green", "CAUTION": "yellow", "HIGH RISK": "red"}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def use_color() -> bool:
|
|
14
|
+
return sys.stdout.isatty() and not os.environ.get("NO_COLOR")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def paint(text: str, color: str, enabled: bool) -> str:
|
|
18
|
+
return f"\033[{COLORS[color]}m{text}\033[0m" if enabled else text
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def human(number) -> str:
|
|
22
|
+
if number is None:
|
|
23
|
+
return "?"
|
|
24
|
+
if number >= 1_000_000:
|
|
25
|
+
return f"{number / 1_000_000:.1f}M"
|
|
26
|
+
if number >= 10_000:
|
|
27
|
+
return f"{number / 1000:.0f}k"
|
|
28
|
+
if number >= 1000:
|
|
29
|
+
return f"{number / 1000:.1f}k"
|
|
30
|
+
return str(number)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def render_vet(report: dict) -> str:
|
|
34
|
+
color = use_color()
|
|
35
|
+
lines = []
|
|
36
|
+
verdict = report["verdict"]
|
|
37
|
+
lines.append(paint(report["repo"], "bold", color) + " " + (report.get("url") or ""))
|
|
38
|
+
if report.get("description"):
|
|
39
|
+
lines.append(paint(report["description"][:110], "dim", color))
|
|
40
|
+
filled = round(report["score"] / 5)
|
|
41
|
+
bar = "#" * filled + "-" * (20 - filled)
|
|
42
|
+
lines.append("")
|
|
43
|
+
lines.append(paint(f"{verdict} {report['score']}/100 [{bar}]",
|
|
44
|
+
VERDICT_COLOR[verdict], color))
|
|
45
|
+
lines.append("")
|
|
46
|
+
|
|
47
|
+
findings = report["findings"]
|
|
48
|
+
if findings:
|
|
49
|
+
for finding in findings:
|
|
50
|
+
tag = paint(f"[{finding['severity'].upper():<6}]",
|
|
51
|
+
SEVERITY_COLOR[finding["severity"]], color)
|
|
52
|
+
lines.append(f"{tag} {finding['title']}")
|
|
53
|
+
if finding["detail"]:
|
|
54
|
+
lines.append(f" {paint(finding['detail'], 'dim', color)}")
|
|
55
|
+
else:
|
|
56
|
+
lines.append(paint("No red flags found.", "green", color))
|
|
57
|
+
|
|
58
|
+
facts = report["facts"]
|
|
59
|
+
lines.append("")
|
|
60
|
+
lines.append(paint("Facts", "bold", color))
|
|
61
|
+
rows = [
|
|
62
|
+
("stars / forks / watchers",
|
|
63
|
+
f"{human(facts['stars'])} / {human(facts['forks'])} / {human(facts['watchers'])}"),
|
|
64
|
+
("issues + PRs ever", human(facts["issues_and_prs_ever"])),
|
|
65
|
+
("age / last push", f"{facts['age_days']}d / {facts['days_since_push']}d ago"),
|
|
66
|
+
("license", facts["license"] or "none"),
|
|
67
|
+
("language", facts["language"] or "?"),
|
|
68
|
+
("contributors", facts["contributors"]),
|
|
69
|
+
("top author", facts["top_author"] or "?"),
|
|
70
|
+
("latest release", facts["latest_release"] or "none"),
|
|
71
|
+
("owner", facts["owner"]),
|
|
72
|
+
]
|
|
73
|
+
for label, value in rows:
|
|
74
|
+
lines.append(f" {label:<26} {value}")
|
|
75
|
+
|
|
76
|
+
if report["alternatives"]:
|
|
77
|
+
lines.append("")
|
|
78
|
+
lines.append(paint("Similar repos (same topics)", "bold", color))
|
|
79
|
+
for alt in report["alternatives"]:
|
|
80
|
+
lines.append(f" {human(alt['stars']):>6} {alt['full_name']} "
|
|
81
|
+
+ paint(alt["description"], "dim", color))
|
|
82
|
+
|
|
83
|
+
notes = list(report["errors"])
|
|
84
|
+
if not report["authenticated"]:
|
|
85
|
+
notes.append("No GitHub token: stargazer checks (fake accounts, star spikes) skipped "
|
|
86
|
+
"and the API allows only ~6 vets/hour. Set GITHUB_TOKEN for full results.")
|
|
87
|
+
if notes:
|
|
88
|
+
lines.append("")
|
|
89
|
+
for note in notes:
|
|
90
|
+
lines.append(paint(f"note: {note}", "yellow", color))
|
|
91
|
+
lines.append("")
|
|
92
|
+
lines.append(paint("Heuristics, not proof. A flag is a reason to look closer.",
|
|
93
|
+
"dim", color))
|
|
94
|
+
return "\n".join(lines)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def render_radar(report: dict) -> str:
|
|
98
|
+
color = use_color()
|
|
99
|
+
scope = ", ".join(report["languages"] + report["topics"]) or "all languages"
|
|
100
|
+
lines = [paint(f"New repos since {report['since']} ({scope})", "bold", color), ""]
|
|
101
|
+
if not report["results"]:
|
|
102
|
+
lines.append("Nothing matched. Try --days 30 or --min-stars 20.")
|
|
103
|
+
for index, row in enumerate(report["results"], 1):
|
|
104
|
+
head = (f"{index:>2}. {paint(row['full_name'], 'bold', color)} "
|
|
105
|
+
f"{human(row['stars'])} stars {row['age_days']}d old "
|
|
106
|
+
f"~{row['stars_per_day']:,.0f}/day {row['language'] or ''}")
|
|
107
|
+
lines.append(head)
|
|
108
|
+
if row["description"]:
|
|
109
|
+
lines.append(f" {paint(row['description'][:100], 'dim', color)}")
|
|
110
|
+
if row["flags"]:
|
|
111
|
+
lines.append(f" {paint('! ' + '; '.join(row['flags']), 'yellow', color)}")
|
|
112
|
+
for error in report["errors"]:
|
|
113
|
+
lines.append(paint(f"note: {error}", "yellow", color))
|
|
114
|
+
lines.append("")
|
|
115
|
+
lines.append(paint("Check any of them with: repovet vet owner/name", "dim", color))
|
|
116
|
+
return "\n".join(lines)
|
repovet/vet.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Fetch everything needed to vet one repo, then run the checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
from . import checks
|
|
9
|
+
from .github import GitHub, GitHubError, NotFound, RateLimited
|
|
10
|
+
|
|
11
|
+
STAR_JSON = "application/vnd.github.star+json"
|
|
12
|
+
STARGAZER_QUERY = """
|
|
13
|
+
query($owner: String!, $name: String!) {
|
|
14
|
+
repository(owner: $owner, name: $name) {
|
|
15
|
+
stargazers(last: 100) {
|
|
16
|
+
edges {
|
|
17
|
+
starredAt
|
|
18
|
+
node { createdAt followers { totalCount } repositories(privacy: PUBLIC) { totalCount } }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _star_page_numbers(stars: int, samples: int) -> list[int]:
|
|
27
|
+
pages = min(math.ceil(stars / 100), 400) # GitHub caps stargazer pagination
|
|
28
|
+
if pages <= 0:
|
|
29
|
+
return []
|
|
30
|
+
if pages <= samples:
|
|
31
|
+
return list(range(1, pages + 1))
|
|
32
|
+
step = (pages - 1) / (samples - 1)
|
|
33
|
+
return sorted({round(1 + i * step) for i in range(samples)})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def collect(gh: GitHub, owner: str, name: str) -> dict:
|
|
37
|
+
"""Download all inputs. Only the first call may raise; the rest degrade gracefully."""
|
|
38
|
+
data: dict = {"errors": []}
|
|
39
|
+
repo = gh.get(f"/repos/{owner}/{name}")
|
|
40
|
+
data["repo"] = repo
|
|
41
|
+
full = repo["full_name"]
|
|
42
|
+
limited = False
|
|
43
|
+
|
|
44
|
+
def safe(key: str, default, fetch):
|
|
45
|
+
nonlocal limited
|
|
46
|
+
if limited:
|
|
47
|
+
data[key] = default
|
|
48
|
+
return
|
|
49
|
+
try:
|
|
50
|
+
value = fetch()
|
|
51
|
+
data[key] = default if value is None else value
|
|
52
|
+
except RateLimited as err:
|
|
53
|
+
limited = True
|
|
54
|
+
data[key] = default
|
|
55
|
+
data["errors"].append(str(err) + "; later checks skipped")
|
|
56
|
+
except GitHubError as err:
|
|
57
|
+
data[key] = default
|
|
58
|
+
data["errors"].append(f"{key}: {err}")
|
|
59
|
+
|
|
60
|
+
safe("owner", None, lambda: gh.get(f"/users/{repo['owner']['login']}"))
|
|
61
|
+
safe("commits", [], lambda: gh.get(f"/repos/{full}/commits", {"per_page": 100}))
|
|
62
|
+
safe("contributors", [], lambda: gh.get(f"/repos/{full}/contributors", {"per_page": 100}))
|
|
63
|
+
safe("releases", [], lambda: gh.get(f"/repos/{full}/releases", {"per_page": 3}))
|
|
64
|
+
safe("last_issue", [], lambda: gh.get(f"/repos/{full}/issues",
|
|
65
|
+
{"state": "all", "per_page": 1}))
|
|
66
|
+
safe("tree", {}, lambda: gh.get(f"/repos/{full}/git/trees/{repo['default_branch']}",
|
|
67
|
+
{"recursive": 1}))
|
|
68
|
+
|
|
69
|
+
def star_pages():
|
|
70
|
+
if not gh.token: # GitHub refuses anonymous stargazer listing
|
|
71
|
+
return []
|
|
72
|
+
pages = []
|
|
73
|
+
for number in _star_page_numbers(repo.get("stargazers_count", 0), 5):
|
|
74
|
+
try:
|
|
75
|
+
page = gh.get(f"/repos/{full}/stargazers",
|
|
76
|
+
{"per_page": 100, "page": number}, accept=STAR_JSON) or []
|
|
77
|
+
except NotFound:
|
|
78
|
+
raise GitHubError("GitHub did not return the stargazer list for this token; "
|
|
79
|
+
"star-spike check skipped") from None
|
|
80
|
+
pages.append([s["starred_at"] for s in page if s.get("starred_at")])
|
|
81
|
+
return pages
|
|
82
|
+
safe("star_pages", [], star_pages)
|
|
83
|
+
|
|
84
|
+
def profiles():
|
|
85
|
+
if not gh.token:
|
|
86
|
+
return []
|
|
87
|
+
result = gh.graphql(STARGAZER_QUERY, {"owner": owner, "name": name})
|
|
88
|
+
edges = ((result.get("repository") or {}).get("stargazers") or {}).get("edges") or []
|
|
89
|
+
found = [{"starred_at": e["starredAt"], "created_at": e["node"]["createdAt"],
|
|
90
|
+
"followers": e["node"]["followers"]["totalCount"],
|
|
91
|
+
"public_repos": e["node"]["repositories"]["totalCount"]}
|
|
92
|
+
for e in edges if e.get("node")]
|
|
93
|
+
if not found and repo.get("stargazers_count", 0) >= 30:
|
|
94
|
+
raise GitHubError("GitHub returned no stargazer profiles for this token; "
|
|
95
|
+
"ghost-account check skipped")
|
|
96
|
+
return found
|
|
97
|
+
safe("profiles", [], profiles)
|
|
98
|
+
|
|
99
|
+
safe("name_hits", [], lambda: (gh.get("/search/repositories", {
|
|
100
|
+
"q": f"{repo['name']} in:name", "sort": "stars", "per_page": 30}) or {}).get("items", []))
|
|
101
|
+
|
|
102
|
+
topics = repo.get("topics") or []
|
|
103
|
+
if topics:
|
|
104
|
+
query = " ".join(f"topic:{t}" for t in topics[:2])
|
|
105
|
+
safe("topic_hits", [], lambda: (gh.get("/search/repositories", {
|
|
106
|
+
"q": query, "sort": "stars", "per_page": 10}) or {}).get("items", []))
|
|
107
|
+
else:
|
|
108
|
+
data["topic_hits"] = []
|
|
109
|
+
|
|
110
|
+
tree_paths = [item["path"] for item in (data["tree"].get("tree") or [])
|
|
111
|
+
if item.get("type") == "blob"]
|
|
112
|
+
data["tree_paths"] = tree_paths
|
|
113
|
+
data["files"] = {}
|
|
114
|
+
for path in checks.pick_files(tree_paths):
|
|
115
|
+
text = gh.raw(full, repo["default_branch"], path)
|
|
116
|
+
if text:
|
|
117
|
+
data["files"][path] = text
|
|
118
|
+
return data
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def analyze(data: dict, authenticated: bool, now: datetime | None = None) -> dict:
|
|
122
|
+
now = now or datetime.now(timezone.utc)
|
|
123
|
+
repo = data["repo"]
|
|
124
|
+
last_issue = data.get("last_issue") or []
|
|
125
|
+
issue_total = last_issue[0].get("number") if last_issue else (
|
|
126
|
+
0 if "last_issue" in data and not data["errors"] else None)
|
|
127
|
+
|
|
128
|
+
findings = (
|
|
129
|
+
checks.check_stars(repo, issue_total, data.get("star_pages", []),
|
|
130
|
+
data.get("profiles", []), now)
|
|
131
|
+
+ checks.check_maintenance(repo, data.get("commits", []), now)
|
|
132
|
+
+ checks.check_people(repo, data.get("owner"), data.get("contributors", []),
|
|
133
|
+
data.get("commits", []), now)
|
|
134
|
+
+ checks.check_license(repo)
|
|
135
|
+
+ checks.check_install(data.get("tree_paths", []), data.get("files", {}))
|
|
136
|
+
+ checks.check_identity(repo, data.get("name_hits", []))
|
|
137
|
+
)
|
|
138
|
+
findings.sort(key=lambda f: checks.SEVERITY_ORDER[f.severity])
|
|
139
|
+
value, verdict = checks.score(findings)
|
|
140
|
+
|
|
141
|
+
owner = data.get("owner") or {}
|
|
142
|
+
contributors = data.get("contributors", [])
|
|
143
|
+
releases = data.get("releases", [])
|
|
144
|
+
author, share = checks.top_author_share(data.get("commits", []))
|
|
145
|
+
facts = {
|
|
146
|
+
"stars": repo.get("stargazers_count", 0),
|
|
147
|
+
"forks": repo.get("forks_count", 0),
|
|
148
|
+
"watchers": repo.get("subscribers_count"),
|
|
149
|
+
"issues_and_prs_ever": issue_total,
|
|
150
|
+
"age_days": round(checks.days_between(checks.parse_ts(repo["created_at"]), now)),
|
|
151
|
+
"days_since_push": round(checks.days_between(checks.parse_ts(repo["pushed_at"]), now)),
|
|
152
|
+
"license": (repo.get("license") or {}).get("spdx_id"),
|
|
153
|
+
"language": repo.get("language"),
|
|
154
|
+
"contributors": f"{len(contributors)}+" if len(contributors) >= 100 else len(contributors),
|
|
155
|
+
"top_author": f"{author} ({share:.0%} of recent commits)" if author else None,
|
|
156
|
+
"latest_release": releases[0].get("tag_name") if releases else None,
|
|
157
|
+
"owner": f"{repo['owner']['login']} ({owner.get('type', repo['owner'].get('type'))})",
|
|
158
|
+
"stargazer_profiles_sampled": len(data.get("profiles", [])),
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
"repo": repo["full_name"],
|
|
162
|
+
"url": repo.get("html_url"),
|
|
163
|
+
"description": repo.get("description"),
|
|
164
|
+
"score": value,
|
|
165
|
+
"verdict": verdict,
|
|
166
|
+
"findings": [f.as_dict() for f in findings],
|
|
167
|
+
"facts": facts,
|
|
168
|
+
"alternatives": checks.alternatives(repo, data.get("topic_hits", [])),
|
|
169
|
+
"errors": data.get("errors", []),
|
|
170
|
+
"authenticated": authenticated,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def vet(gh: GitHub, owner: str, name: str) -> dict:
|
|
175
|
+
return analyze(collect(gh, owner, name), authenticated=bool(gh.token))
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: repovet-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Vet a GitHub repo for fake stars, abandonment, risky install scripts and typosquatting. Radar for new repos in your stack.
|
|
5
|
+
Author: repovet contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/shivamkr06/repovet
|
|
8
|
+
Project-URL: Issues, https://github.com/shivamkr06/repovet/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/shivamkr06/repovet/blob/main/CHANGELOG.md
|
|
10
|
+
Keywords: github,fake-stars,supply-chain-security,typosquatting,repository-analysis,trust-score,dependency-vetting,trending-repositories,cli,developer-tools
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Security
|
|
23
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
24
|
+
Classifier: Topic :: Utilities
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# repovet
|
|
31
|
+
|
|
32
|
+
**Check a GitHub repo for fake stars, abandonment, malware-style install scripts and typosquatting before you clone, install or depend on it. Also finds new repos that match your stack.**
|
|
33
|
+
|
|
34
|
+
[](https://github.com/shivamkr06/repovet/actions/workflows/ci.yml)
|
|
35
|
+
[](LICENSE)
|
|
36
|
+

|
|
37
|
+

|
|
38
|
+
|
|
39
|
+
Hundreds of new repos trend on GitHub every week. Some are great, some are abandoned, some
|
|
40
|
+
have bought their stars, and a few are malware with a nice README. `repovet` is a small
|
|
41
|
+
command-line **GitHub repository trust scorer** that tells you which is which in a few
|
|
42
|
+
seconds.
|
|
43
|
+
|
|
44
|
+
- **`repovet vet owner/repo`** gives a 0-100 trust score, a verdict, and the reasons.
|
|
45
|
+
- **`repovet radar`** lists recently created repos for the languages and topics your project uses.
|
|
46
|
+
- Pure Python standard library. No dependencies. Works on Windows, macOS and Linux.
|
|
47
|
+
- Output as text or `--json`. Exit code for CI gating with `--fail-under`.
|
|
48
|
+
|
|
49
|
+
## Quick start
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install repovet-cli
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The command is `repovet`. From source instead:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install git+https://github.com/shivamkr06/repovet.git
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
repovet vet psf/requests
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Without installing, from a clone:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
PYTHONPATH=src python -m repovet vet psf/requests
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Example
|
|
72
|
+
|
|
73
|
+
```text
|
|
74
|
+
$ repovet vet psf/requests
|
|
75
|
+
|
|
76
|
+
psf/requests https://github.com/psf/requests
|
|
77
|
+
A simple, yet elegant, HTTP library.
|
|
78
|
+
|
|
79
|
+
LOOKS OK 100/100 [####################]
|
|
80
|
+
|
|
81
|
+
No red flags found.
|
|
82
|
+
|
|
83
|
+
Facts
|
|
84
|
+
stars / forks / watchers 55k / 11k / 1.3k
|
|
85
|
+
issues + PRs ever 7.6k
|
|
86
|
+
age / last push 5749d / 8d ago
|
|
87
|
+
license Apache-2.0
|
|
88
|
+
contributors 100+
|
|
89
|
+
top author nateprewitt (46% of recent commits)
|
|
90
|
+
latest release v2.34.2
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
A repo with problems looks like this:
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
CAUTION 78/100 [################----]
|
|
97
|
+
|
|
98
|
+
[MEDIUM] Stale
|
|
99
|
+
No push in 514 days.
|
|
100
|
+
[LOW ] Low fork-to-star ratio
|
|
101
|
+
9 forks vs 503 stars (1.8%). Real projects usually sit around 5-15%.
|
|
102
|
+
[LOW ] Single maintainer
|
|
103
|
+
m-ahmed-elbeskeri wrote 100% of the recent commits. If they leave, the project stops.
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## What `vet` checks
|
|
107
|
+
|
|
108
|
+
| Area | Signals |
|
|
109
|
+
|---|---|
|
|
110
|
+
| **Fake stars** | fork-to-star and watcher-to-star ratios, stars with almost no issues or PRs, sudden star spikes, share of brand-new or empty stargazer accounts (needs a token) |
|
|
111
|
+
| **Maintenance** | archived or disabled, time since last push, commit count, project age |
|
|
112
|
+
| **People** | single-maintainer risk, owner account age and track record |
|
|
113
|
+
| **License** | missing, unrecognized, or restrictive (AGPL, SSPL, BUSL, non-commercial) |
|
|
114
|
+
| **Install risk** | `preinstall`/`postinstall` hooks, `setup.py` that runs commands, `curl \| sh`, encoded PowerShell, obfuscated `exec`, Discord/Telegram webhooks, hard-coded IPs, committed executables, password-protected archives mentioned in the README |
|
|
115
|
+
| **Identity** | same name as a bigger, older repo (typosquat or copy), forks, lookalike repos |
|
|
116
|
+
|
|
117
|
+
It also lists similar repos with the same topics so you can compare before choosing.
|
|
118
|
+
|
|
119
|
+
Scoring: start at 100, subtract 25 per high, 12 per medium, 5 per low finding. 80+ with no
|
|
120
|
+
high findings is `LOOKS OK`, 55+ is `CAUTION`, below that is `HIGH RISK`.
|
|
121
|
+
|
|
122
|
+
## Use it in CI
|
|
123
|
+
|
|
124
|
+
Block a risky dependency before it lands:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
repovet vet owner/repo --fail-under 60
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Exit code is `2` when the score is below the threshold, `1` on errors, `0` otherwise.
|
|
131
|
+
`--json` prints the full report for scripts.
|
|
132
|
+
|
|
133
|
+
## Radar: new repos for your stack
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
repovet radar
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Reads `package.json`, `requirements.txt`, `pyproject.toml`, `go.mod`, `Cargo.toml` and others
|
|
140
|
+
in the current folder, then lists recently created repos in those languages and topics.
|
|
141
|
+
Same-name clones are collapsed into one entry, and repos are ranked by stars per day with a
|
|
142
|
+
penalty for hollow-looking traction (many stars, almost no forks).
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
repovet radar --lang Rust --topic cli --days 30 --min-stars 200
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## GitHub token
|
|
149
|
+
|
|
150
|
+
Works without a token, but the anonymous API allows about 6 vets per hour and the
|
|
151
|
+
stargazer checks (fake accounts, star spikes) are skipped, because GitHub does not list
|
|
152
|
+
stargazers anonymously. To lift both limits, set `GITHUB_TOKEN` (or `GH_TOKEN`) to a token
|
|
153
|
+
with no scopes. Some tokens (seen on new GitHub accounts) get no stargazer data back from
|
|
154
|
+
GitHub; `repovet` then skips those two checks and says so in a note. If the `gh` CLI is installed and logged in, its token is used automatically.
|
|
155
|
+
|
|
156
|
+
Responses are cached for one hour in `~/.cache/repovet` (`--no-cache` to bypass,
|
|
157
|
+
`REPOVET_CACHE` to move it). The token is only ever sent to `api.github.com`.
|
|
158
|
+
|
|
159
|
+
## Limits
|
|
160
|
+
|
|
161
|
+
These are heuristics. A flag is a reason to look closer, not proof of fraud, and a clean
|
|
162
|
+
score is not a security audit. The install check reads a handful of entry-point files, not
|
|
163
|
+
the whole codebase. Popular, legitimate projects can trip individual checks (a fast viral
|
|
164
|
+
launch looks like a star spike). Treat the verdict as triage.
|
|
165
|
+
|
|
166
|
+
## Development
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
python -m unittest discover -s tests
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for how to add a check. Report security issues as
|
|
173
|
+
described in [SECURITY.md](SECURITY.md). Changes are listed in [CHANGELOG.md](CHANGELOG.md).
|
|
174
|
+
|
|
175
|
+
## License
|
|
176
|
+
|
|
177
|
+
[MIT](LICENSE)
|
|
178
|
+
|
|
179
|
+
<sub>Keywords: github repository analyzer, fake github stars detector, repo trust score,
|
|
180
|
+
supply chain security, typosquatting detection, malicious repository checker, dependency
|
|
181
|
+
vetting, trending repositories, open source discovery.</sub>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
repovet/__init__.py,sha256=TNkpSNV1UYCFpwDJfHrup2YjQFBCDE4_IAxOAgqP7Fg,77
|
|
2
|
+
repovet/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
repovet/checks.py,sha256=Z4YgyXOLaqWtZq27mFEKjmqS-3DeVsxqclcWMKz9n7Q,16865
|
|
4
|
+
repovet/cli.py,sha256=_CNUhMVeXFrHkO276FvquRQUppVFd9S1Jl4wDi3_QG0,4271
|
|
5
|
+
repovet/github.py,sha256=38jgMrt4OJ8gz8X_ZL_Huas3pIzBTXmPgYdt46W9CnA,5863
|
|
6
|
+
repovet/radar.py,sha256=23OddelhT-fP4QrFR-WLwOJg29nAl9VEVyYVt4pBnHc,5763
|
|
7
|
+
repovet/render.py,sha256=S6TI4objmmckzDEE5IyQ_u6tch0BOjgQJdygmgk43KQ,4638
|
|
8
|
+
repovet/vet.py,sha256=DlC333DeDY1p_JZBnyzU1tf1Ssa6o_5PRaptZa-WRKo,7402
|
|
9
|
+
repovet_cli-0.1.0.dist-info/licenses/LICENSE,sha256=DzoZqOVMVnq7vu7pWKtRvN4qIXWTQ6KHzzAVfO_qieU,1077
|
|
10
|
+
repovet_cli-0.1.0.dist-info/METADATA,sha256=0TvuYV9TUDBRnI-btt3W0egdRgiSl0eJeLwgkiKOkwI,7091
|
|
11
|
+
repovet_cli-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
12
|
+
repovet_cli-0.1.0.dist-info/entry_points.txt,sha256=l9kT5oq9FlPKFgvV0JPgnAQkM7W2sgNgYmx8x9_Sscs,45
|
|
13
|
+
repovet_cli-0.1.0.dist-info/top_level.txt,sha256=5rC1z53MpyQ2G06ovrPJmMfE0IXU6PgcQ7THbIlmYYA,8
|
|
14
|
+
repovet_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 repovet contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
repovet
|