slopguard-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.
@@ -0,0 +1,183 @@
1
+ """Individual risk-signal functions.
2
+
3
+ Each function consumes the dependency, optional registry metadata, and optional
4
+ context (popularity sets, hallucination DB hits). They return a :class:`Signal`
5
+ or ``None``. The engine composes them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from datetime import UTC, datetime, timedelta
12
+
13
+ from slopguard.models import Dependency, HallucinationEntry, Signal
14
+ from slopguard.registry.base import PackageMetadata
15
+
16
+ # Weights mirror the spec section 9 table.
17
+ W_DB_HIT = 0.90
18
+ W_NOT_FOUND = 0.85
19
+ W_RECENT_30 = 0.20
20
+ W_RECENT_7 = 0.35
21
+ W_LOW_DOWNLOADS = 0.15
22
+ W_NEW_PUBLISHER = 0.20
23
+ W_SOLO_NEW = 0.30
24
+ W_LEVENSHTEIN = 0.25
25
+ W_NAME_PATTERN = 0.10
26
+
27
+ _NAME_PATTERN_RE = re.compile(
28
+ r"^(?:[a-z0-9]+[-_])+(helpers?|utils?|async|pro|kit|sdk|tools?|extras?|plus|next|core)$",
29
+ re.IGNORECASE,
30
+ )
31
+
32
+
33
+ def _now() -> datetime:
34
+ return datetime.now(UTC)
35
+
36
+
37
+ def _ensure_aware(dt: datetime) -> datetime:
38
+ if dt.tzinfo is None:
39
+ return dt.replace(tzinfo=UTC)
40
+ return dt
41
+
42
+
43
+ def hallucination_db_hit(dep: Dependency, entry: HallucinationEntry | None) -> Signal | None:
44
+ if entry is None:
45
+ return None
46
+ models = ", ".join(entry.models_observed) if entry.models_observed else "unspecified models"
47
+ return Signal(
48
+ type="hallucination_db_hit",
49
+ weight=W_DB_HIT,
50
+ detail=(f"Matched seed DB entry; recurrence {entry.recurrence_rate:.2f} across {models}."),
51
+ )
52
+
53
+
54
+ def registry_not_found(meta: PackageMetadata) -> Signal | None:
55
+ if meta.exists:
56
+ return None
57
+ return Signal(
58
+ type="registry_not_found",
59
+ weight=W_NOT_FOUND,
60
+ detail="Registry returned 404 — package name is not currently published.",
61
+ )
62
+
63
+
64
+ def recently_published(meta: PackageMetadata) -> tuple[Signal | None, Signal | None]:
65
+ """Return (very_recent, recent) signals — at most one will be non-None."""
66
+ if not meta.exists or meta.first_release is None:
67
+ return (None, None)
68
+ age = _now() - _ensure_aware(meta.first_release)
69
+ if age < timedelta(days=7):
70
+ return (
71
+ Signal(
72
+ type="very_recently_published",
73
+ weight=W_RECENT_7,
74
+ detail=f"First release {age.days} day(s) ago.",
75
+ ),
76
+ None,
77
+ )
78
+ if age < timedelta(days=30):
79
+ return (
80
+ None,
81
+ Signal(
82
+ type="recently_published",
83
+ weight=W_RECENT_30,
84
+ detail=f"First release {age.days} day(s) ago.",
85
+ ),
86
+ )
87
+ return (None, None)
88
+
89
+
90
+ def low_downloads(meta: PackageMetadata) -> Signal | None:
91
+ if not meta.exists or meta.downloads_recent is None:
92
+ return None
93
+ if meta.downloads_recent >= 100:
94
+ return None
95
+ return Signal(
96
+ type="low_downloads",
97
+ weight=W_LOW_DOWNLOADS,
98
+ detail=f"{meta.downloads_recent} recent downloads.",
99
+ )
100
+
101
+
102
+ def new_publisher(meta: PackageMetadata) -> Signal | None:
103
+ if not meta.exists or meta.publisher_created is None:
104
+ return None
105
+ age = _now() - _ensure_aware(meta.publisher_created)
106
+ if age < timedelta(days=30):
107
+ return Signal(
108
+ type="new_publisher",
109
+ weight=W_NEW_PUBLISHER,
110
+ detail=f"Publisher account created {age.days} day(s) ago.",
111
+ )
112
+ return None
113
+
114
+
115
+ def single_release_new_account(meta: PackageMetadata) -> Signal | None:
116
+ if not meta.exists or meta.publisher_created is None or meta.publisher_package_count is None:
117
+ return None
118
+ age = _now() - _ensure_aware(meta.publisher_created)
119
+ if meta.publisher_package_count == 1 and age < timedelta(days=60):
120
+ return Signal(
121
+ type="single_release_new_account",
122
+ weight=W_SOLO_NEW,
123
+ detail=(f"Publisher's only release; account {age.days} day(s) old."),
124
+ )
125
+ return None
126
+
127
+
128
+ def levenshtein(a: str, b: str) -> int:
129
+ """Iterative Levenshtein distance. Small inputs — no heuristic shortcuts."""
130
+ if a == b:
131
+ return 0
132
+ if not a:
133
+ return len(b)
134
+ if not b:
135
+ return len(a)
136
+ prev = list(range(len(b) + 1))
137
+ for i, ca in enumerate(a, start=1):
138
+ curr = [i] + [0] * len(b)
139
+ for j, cb in enumerate(b, start=1):
140
+ cost = 0 if ca == cb else 1
141
+ curr[j] = min(
142
+ curr[j - 1] + 1, # insertion
143
+ prev[j] + 1, # deletion
144
+ prev[j - 1] + cost, # substitution
145
+ )
146
+ prev = curr
147
+ return prev[-1]
148
+
149
+
150
+ def levenshtein_typo(dep: Dependency, popular: frozenset[str]) -> Signal | None:
151
+ """Flag when the dep name is Levenshtein 1 or 2 from a popular package (and not that package)."""
152
+ lower = dep.name.lower()
153
+ if lower in popular:
154
+ return None
155
+ best: tuple[int, str] | None = None
156
+ for candidate in popular:
157
+ # Quick length-based prune.
158
+ if abs(len(candidate) - len(lower)) > 2:
159
+ continue
160
+ d = levenshtein(lower, candidate)
161
+ if d <= 2 and (best is None or d < best[0]):
162
+ best = (d, candidate)
163
+ if d == 1:
164
+ break
165
+ if best is None:
166
+ return None
167
+ d, candidate = best
168
+ return Signal(
169
+ type="levenshtein_typo",
170
+ weight=W_LEVENSHTEIN,
171
+ detail=f"Levenshtein {d} from popular package '{candidate}'.",
172
+ )
173
+
174
+
175
+ def name_pattern_suspicious(dep: Dependency) -> Signal | None:
176
+ """Flag classic hallucination shapes like `<stem>-helpers`, `<stem>-utils`."""
177
+ if not _NAME_PATTERN_RE.match(dep.name):
178
+ return None
179
+ return Signal(
180
+ type="name_pattern_suspicious",
181
+ weight=W_NAME_PATTERN,
182
+ detail="Name matches a known hallucination shape (e.g. <stem>-helpers / -utils / -async / -pro).",
183
+ )
slopguard/update.py ADDED
@@ -0,0 +1,15 @@
1
+ """Stub for the future ``slopguard update`` remote DB refresh.
2
+
3
+ # TODO(v0.2): implement a signed-bundle download from a trusted host.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+
9
+ def run() -> int:
10
+ """Print a not-yet-implemented message and exit 0."""
11
+ print(
12
+ "slopguard update is not implemented in v0.1. The embedded seed database "
13
+ "ships with the package and is refreshed by upgrading slopguard itself."
14
+ )
15
+ return 0
@@ -0,0 +1,197 @@
1
+ Metadata-Version: 2.4
2
+ Name: slopguard-cli
3
+ Version: 0.1.0
4
+ Summary: Defend developers and AI coding agents against slopsquatting (hallucinated package names).
5
+ Project-URL: Homepage, https://github.com/hariomunknownslab/slopguard
6
+ Project-URL: Repository, https://github.com/hariomunknownslab/slopguard
7
+ Project-URL: Issues, https://github.com/hariomunknownslab/slopguard/issues
8
+ Author-email: SlopGuard <contact@unknownslab.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai,llm,package-hallucination,security,slopsquatting,supply-chain
12
+ Classifier: Development Status :: 3 - Alpha
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.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Security
20
+ Classifier: Topic :: Software Development :: Quality Assurance
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: httpx>=0.27.0
23
+ Requires-Dist: pydantic>=2.6.0
24
+ Requires-Dist: pyyaml>=6.0.1
25
+ Requires-Dist: rich>=13.7.0
26
+ Requires-Dist: tomli>=2.0.1; python_version < '3.11'
27
+ Requires-Dist: typer>=0.12.0
28
+ Provides-Extra: dev
29
+ Requires-Dist: build>=1.2.0; extra == 'dev'
30
+ Requires-Dist: jsonschema>=4.21.0; extra == 'dev'
31
+ Requires-Dist: mypy>=1.10.0; extra == 'dev'
32
+ Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
33
+ Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
34
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
35
+ Requires-Dist: respx>=0.21.0; extra == 'dev'
36
+ Requires-Dist: ruff>=0.4.0; extra == 'dev'
37
+ Requires-Dist: types-pyyaml>=6.0.12; extra == 'dev'
38
+ Description-Content-Type: text/markdown
39
+
40
+ # SlopGuard
41
+
42
+ [![CI](https://github.com/hariomunknownslab/slopguard/actions/workflows/ci.yml/badge.svg)](https://github.com/hariomunknownslab/slopguard/actions/workflows/ci.yml)
43
+ [![PyPI](https://img.shields.io/pypi/v/slopguard.svg)](https://pypi.org/project/slopguard/)
44
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
45
+
46
+ **Slopsquatting** is what happens when an LLM hallucinates a plausible-sounding
47
+ package name that does not exist on the public registry — and then an attacker
48
+ registers that exact name with malware so the *next* developer (or AI agent)
49
+ who follows the suggestion installs it. SlopGuard scans your project's
50
+ dependencies, flags entries that are either known LLM hallucinations or that
51
+ show the behavioural fingerprint of a slopsquat, and exits non-zero so CI
52
+ fails the build before the malware reaches `node_modules` or `site-packages`.
53
+
54
+ > SlopGuard stops AI coding agents from installing packages that LLMs hallucinated.
55
+
56
+ ## Install
57
+
58
+ ```bash
59
+ pip install slopguard-cli
60
+ # Homebrew formula ships in a later release:
61
+ # brew install slopguard
62
+ ```
63
+
64
+ > The PyPI **distribution** name is `slopguard-cli` (the name `slopguard`
65
+ > overlapped with an unrelated existing package on PyPI). The installed
66
+ > command, the Python import, and everything else stays `slopguard`.
67
+
68
+ Python 3.11+ is required.
69
+
70
+ ## Usage
71
+
72
+ ### 1. Scan the current directory
73
+
74
+ ```bash
75
+ slopguard scan
76
+ ```
77
+
78
+ SlopGuard auto-discovers `package.json`, `package-lock.json`,
79
+ `requirements.txt`, `pyproject.toml`, and `Pipfile` (up to two levels deep),
80
+ probes each name against the public registry, and prints a Rich table:
81
+
82
+ ```text
83
+ SlopGuard v0.1.0 — scanning /home/dev/myproj
84
+
85
+ Detected manifests:
86
+ • package.json (npm, 32 deps)
87
+ • requirements.txt (pypi, 15 deps)
88
+
89
+ Scanned 47 dependencies in 3.1s.
90
+
91
+ ┏━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
92
+ ┃ Package ┃ Risk ┃ Reason ┃
93
+ ┡━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
94
+ │ react-codeshift │ HALLUCIN. │ Matched seed DB entry; recurrence 0.71. │
95
+ │ langchain-helpers │ SUSPICIOUS │ Created 14 days ago, 48 downloads, new auth. │
96
+ │ openai-utils │ SUSPICIOUS │ Levenshtein 2 from popular package 'openai'. │
97
+ │ requests │ CLEAN │ Established package. │
98
+ └────────────────────┴────────────┴──────────────────────────────────────────────┘
99
+
100
+ Summary: 1 hallucinated, 2 suspicious, 44 clean, 0 error(s).
101
+ Exit code: 1
102
+ ```
103
+
104
+ ### 2. Scan a specific path
105
+
106
+ ```bash
107
+ slopguard scan ./mono/services/api
108
+ ```
109
+
110
+ ### 3. CI mode — JSON output, strict failure threshold
111
+
112
+ ```bash
113
+ slopguard scan --format json --output report.json --fail-on hallucinated
114
+ ```
115
+
116
+ See [`.github/workflows/slopguard.yml.example`](.github/workflows/slopguard.yml.example)
117
+ for a drop-in GitHub Actions workflow and [`docs/ci-integration.md`](docs/ci-integration.md)
118
+ for details on other CI providers.
119
+
120
+ ## How it works
121
+
122
+ For every dependency, SlopGuard computes a small set of independent signals
123
+ and combines them into a single risk score in `[0.0, 1.0]`:
124
+
125
+ - **Hallucination-DB hit** (weight 0.90) — exact match in an embedded seed
126
+ database of names known to be hallucinated by major LLMs.
127
+ - **Registry not found** (0.85) — the registry returns 404 for the name. The
128
+ most common slopsquat shape: a name that doesn't exist *yet*.
129
+ - **Very recently / recently published** (0.35 / 0.20) — first release < 7
130
+ days / < 30 days old.
131
+ - **Low downloads** (0.15) — < 100 downloads in the last month (npm) or last
132
+ week (PyPI).
133
+ - **New publisher** (0.20) and **single-release new account** (0.30) — a
134
+ brand-new account whose only release is the package you're about to install.
135
+ - **Levenshtein typo** (0.25) — name is 1–2 edits away from a top-1000
136
+ popular package (likely a typosquat).
137
+ - **Suspicious name pattern** (0.10) — matches a classic LLM-hallucination
138
+ shape like `<stem>-helpers`, `<stem>-utils`, `<stem>-async`, `<stem>-pro`.
139
+
140
+ The default cutoffs map scores `≥ 0.85` → **hallucinated**, `≥ 0.40` →
141
+ **suspicious**, else **clean**. Both thresholds are tunable in
142
+ `.slopguard.yaml`. See [`docs/detection.md`](docs/detection.md) for the full
143
+ table, the order of operations, and edge cases.
144
+
145
+ ## Configuration
146
+
147
+ `.slopguard.yaml`, picked up automatically from the scan target or any
148
+ ancestor (up to 3 levels):
149
+
150
+ ```yaml
151
+ ignore:
152
+ packages: ["internal-tool"]
153
+ patterns: ["^@mycompany/"]
154
+
155
+ fail_on: suspicious # any | hallucinated | suspicious | none
156
+
157
+ network:
158
+ enabled: true
159
+ timeout_seconds: 5
160
+ concurrency: 16
161
+
162
+ scoring:
163
+ suspicious_min_score: 0.4
164
+ hallucinated_min_score: 0.85
165
+ ```
166
+
167
+ CLI flags override the file. See [`docs/usage.md`](docs/usage.md) for the full
168
+ reference.
169
+
170
+ ## What it does NOT do (v0.1)
171
+
172
+ - No live LLM probing — the hallucination database is a static seed for v0.1.
173
+ - No SaaS dashboard, no auth, no billing, no telemetry to any remote server.
174
+ - No tarpit registry, no defensive package registration.
175
+ - No Cursor / Claude Code / Copilot IDE plugins.
176
+ - No support for crates.io, pkg.go.dev, Maven Central, RubyGems, NuGet —
177
+ Python and JavaScript only.
178
+ - No license scanning, no CVE matching, no SBOM generation.
179
+ - No remote configuration, no SaaS API client.
180
+
181
+ The full v0.2+ roadmap is tracked in the build spec, section 14.
182
+
183
+ ## Privacy & trust
184
+
185
+ SlopGuard makes **only** the network calls you opt into (the public registry
186
+ probes against `registry.npmjs.org` and `pypi.org`). No analytics, no
187
+ ping-home, no telemetry. The trust model is the moat: run `--no-network` if
188
+ you want to be sure.
189
+
190
+ ## Contributing
191
+
192
+ See [CONTRIBUTING.md](CONTRIBUTING.md). PRs welcome — especially curated
193
+ additions to the hallucination database.
194
+
195
+ ## License
196
+
197
+ MIT. Copyright © 2026 SlopGuard. See [LICENSE](LICENSE).
@@ -0,0 +1,28 @@
1
+ slopguard/__init__.py,sha256=N7XKzw5SjJe5DhhJFB7TyZBBbtCWs98fKxuKuKhFE94,169
2
+ slopguard/__main__.py,sha256=-G9dcipk-3ulFo3WkJ3XEedb9dNQwG-gCpOmjzxiYcs,178
3
+ slopguard/cli.py,sha256=jod0Oo-66G6P5GMKVA8G1JN7t54uzxtmFU_R-SMqMVk,10676
4
+ slopguard/config.py,sha256=ImXQe4yiaJEu74rG3kNPqT9RPkeFJvvUpJ9irpjhzLs,4437
5
+ slopguard/models.py,sha256=mr8dhFabHFSFrkd5ykPkQZpRLyN8j85OVhPkPqP-zjM,3126
6
+ slopguard/update.py,sha256=68_ziw1EBw3uxw69ejgxpBnMZxajtCNea2CoApA4bKA,446
7
+ slopguard/data/__init__.py,sha256=YeEejiB_6P1XOQLAvc2cK7bZt6NZRHxS4jLuGmG-WMo,1632
8
+ slopguard/parsers/__init__.py,sha256=HS-tbUIOz3z0mCgDCTz8Du3dv4ncD2V1ao7njVc_wNo,306
9
+ slopguard/parsers/base.py,sha256=Iz4Y5vAXvnK-tdSGGANeJOxgXr5HcaV4D03v5HsIdS4,700
10
+ slopguard/parsers/npm.py,sha256=YgIaHUachzFQOGFqJW1Kc2sA2W_8D7NgbkmN7hpxeEI,5616
11
+ slopguard/parsers/python.py,sha256=Ve-EAJNuoozpVstes_RkNRE9QMyPED9frgm6_kOXKHA,10635
12
+ slopguard/registry/__init__.py,sha256=b-ucqaPqh5G9E14C5hEsyZD7MTchAzeSrysZkVXxnJI,361
13
+ slopguard/registry/base.py,sha256=EHXL0EB5RYIZU5DxaUlxI4crVDnojdzpER3_erTd_3Q,3870
14
+ slopguard/registry/npm.py,sha256=_dOJ4KDvIg3TKQ-thjpwkAu3MubFZilaAHlvFfnZeB8,2857
15
+ slopguard/registry/pypi.py,sha256=txgNcdycePL_RO-6BxddGAPPi0kqsA7bQ71UUeLPWBs,3499
16
+ slopguard/report/__init__.py,sha256=g7l1qlNFip6ZYb4VDGA-ioHwZnQfAfPWoBfIdOwo_U8,234
17
+ slopguard/report/json.py,sha256=MwgXj7-7Ao1RRv-7JL6yaa51ixSJLeTPUiXHzWUOido,522
18
+ slopguard/report/terminal.py,sha256=80SPLI8xhyyBcSJp6xHsgB350qw9RRwFmYAoh0HhU_w,2866
19
+ slopguard/scoring/__init__.py,sha256=1kfQQ_QATivy1lDcdPmeZqbPX2WSlRfUQGJpn1C3g7A,144
20
+ slopguard/scoring/engine.py,sha256=cPfKRlL4y4D74jAxnDcR5xutK5I1khw9mKn_2AUAS0I,8595
21
+ slopguard/scoring/signals.py,sha256=wTwSFDto7l_BIs17780jxLsO9EbK0R3V0BIfSbNwZ3Y,5732
22
+ slopguard/data/hallucinations_seed.json,sha256=5zqSAqAFe6xZhwMl1GYs5EsSKg4DdfLSt2rPBcr81HE,162120
23
+ slopguard/data/popular_packages.json,sha256=kLzd3KX5W2QBd_FM9ZZ03pPx-sB7yUmqykiykbla-wU,35020
24
+ slopguard_cli-0.1.0.dist-info/METADATA,sha256=Uh97auOxGIPtALJ8ukxfnM5Uxe5f-JZ_pDZk2fVyZMo,8070
25
+ slopguard_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
26
+ slopguard_cli-0.1.0.dist-info/entry_points.txt,sha256=h_zIuznebScv0IDoby-nTcA2mBiS0rWJnDSKzlwfzTI,48
27
+ slopguard_cli-0.1.0.dist-info/licenses/LICENSE,sha256=C7LCX7SDI6sNelsCL7-nRLejbHbLa4OOJwJcSTO6kL4,1066
28
+ slopguard_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ slopguard = slopguard.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SlopGuard
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.