This release is a pre-release and may not be stable for production use.
Garnet Guard
Garnet Guard checks every dependency update against the last release you trusted, and tells you when it looks different in ways that matter.
Trust the last release. Check the next.
Status: pre-alpha. 0.1.0a1 is an early pre-release on PyPI:
pip install --pre garnet-guard. The GitHub Action is a preview, and the rest of this README describes the first full release. Website, rule pages and replays: garnetguard.dev.
Why
The most damaging supply-chain attacks of 2025–26 did not come from obscure typosquats. They came from packages people already trusted. An attacker took over a maintainer account, a stored publish token or a project's CI, and pushed a new version that looked routine:
- lightning 2.6.2 / 2.6.3 (PyPI, April 2026) was uploaded outside the project's CI, with no matching git tag, and ran its payload at import time.
- elementary-data 0.23.3 (PyPI, April 2026) was published by the project's own hijacked pipeline and added a
.pthfile that runs on every Python start. - litellm 1.82.7 / 1.82.8 and telnyx 4.87.1 / 4.87.2 (PyPI, March 2026) were uploaded with publish tokens stolen through a poisoned security scanner in CI, and ran their payloads at import time; litellm 1.82.8 also added a
.pthfile. - ultralytics 8.3.41 / 8.3.42 (PyPI, December 2024) were built and signed by the project's own release workflow after its build cache was poisoned; 8.3.45 / 8.3.46 followed days later with a publish token left in CI.
- chalk, debug and the Shai-Hulud worm (npm, September 2025) spread through hijacked accounts to 500+ packages.
Malware feeds caught these only after the fact. Cooldowns only buy time. pip and uv do not verify PyPI's signing attestations at all. But each malicious release differed from the previous good release in ways a machine can check. Garnet checks them.
What it flags
| Signal | Example |
|---|---|
| Trust downgrade | The new release has no provenance attestation when the old one did, was built by a different repo or workflow, or has no matching git tag. |
| Risky content change | A new .pth file, new build hooks, new import-time exec/subprocess/network code, new obfuscated blobs or binaries. |
| Known bad | The release matches an OSV / OpenSSF malicious-package advisory. |
Garnet never runs the code it inspects. It downloads both releases and reads them statically.
How you'll use it
On Dependabot and Renovate PRs (first release). Add the Garnet Guard Action, and it posts one comment per dependency PR. Updates with nothing worth a look collapse out of the way:
A sample comment. The examplelib findings are illustrative; the replays below are real.
Add it as .github/workflows/garnet-guard.yml:
name: Garnet Guard
on:
pull_request:
paths: ["**/uv.lock", "**/poetry.lock", "**/pylock.toml", "**/pylock.*.toml", "**/requirements*.txt", "garnet.toml"]
permissions: {}
jobs:
garnet-guard:
runs-on: ubuntu-latest
permissions:
contents: read # read the lockfiles and the base commit
pull-requests: write # post or update the comment
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: orca-solutions/garnet-guard@<release-commit-sha> # v0.1.0
Until the Action has a tagged release, <release-commit-sha> is a placeholder. Inputs, permissions and the fork-safe two-workflow pattern are in docs/action.md. The Action is a preview.
Locally. garnet check compares two lockfiles, for example your branch against main, and checks every package that changed. Once you've reviewed a finding, an ignore in garnet.toml silences it until a date you choose (docs/policy.md).
In agent hooks (planned). A pre-install hook stops Claude Code or Codex from quietly adding a risky dependency.
Replays of real attacks
We fed the original malicious files from 36 compromised PyPI packages (47 malicious releases, December 2024 to September 2026) through garnet diff, using each project's previous release as the baseline. The files are still served by files.pythonhosted.org and are checked against PyPI's published hashes. Nothing was executed. Known-bad feeds were switched off, so these results show only what the release diff found by itself. Git tags are checked as they stood on the day of each upload.
44 of 47 malicious releases got a warning. 3 got none.
| Package | Malicious releases | Result | Rules that fired |
|---|---|---|---|
| lightning | 2.6.2, 2.6.3 | warn | GRN-C003 subprocess.Popen reached at import from lightning/__init__.py:14; GRN-T004 no tag |
| elementary-data | 0.23.3 | warn | GRN-C001 new elementary.pth with an exec of a base64 payload |
| litellm | 1.82.7, 1.82.8 | warn | GRN-C003 subprocess.run at import in litellm/proxy/proxy_server.py; 1.82.8 also GRN-C001 new litellm_init.pth |
| telnyx | 4.87.1, 4.87.2 | warn | GRN-C003 subprocess, urllib.request reached at import in telnyx/_client.py; GRN-T004 no tag |
| ultralytics | 8.3.45, 8.3.46 | warn | GRN-T001 no attestation where 8.3.44 had one; GRN-C003 os.system at import in ultralytics/__init__.py; GRN-T004 no tag |
| ultralytics | 8.3.41 | warn | GRN-T004 only: no tag v8.3.41 |
| ultralytics | 8.3.42 | pass (missed) | none: see the limits below |
| num2words | 0.5.15, 0.5.16 | warn | GRN-T004 no tag; GRN-E002 couldn't parse num2words/_build.py, a Windows DLL named .py |
| xinference | 2.6.0, 2.6.1, 2.6.2 | warn | GRN-C003 subprocess at import in xinference/__init__.py; GRN-T004 no tag |
| mistralai | 2.4.6 | warn | GRN-C003 subprocess reached at import in mistralai/client/__init__.py; GRN-T004 no tag |
| guardrails-ai | 0.10.1 | warn | GRN-C003 urllib.request, subprocess.run at import in guardrails/__init__.py |
| durabletask | 1.4.1, 1.4.2, 1.4.3 | warn | GRN-C003 urllib.request.urlretrieve, subprocess.Popen at import in durabletask/__init__.py; GRN-T004 no tag |
| memoryos | 2.0.34 | warn | GRN-C002 build backend changed to an in-tree sckit_poetry_build |
| gpsea, pyphetools, ppkt2synergy | 0.9.14, 0.9.120, 0.1.1 | warn | GRN-C001 new .pth file that downloads and runs Bun; gpsea also GRN-T004 |
| embiggen | 0.11.97 | pass (missed) | none: see the limits below |
| cmd2func, coolbox, executor-engine, executor-http, funcdesc, mrbios, okite, uprobe | 0.2.2, 0.4.1, 0.3.4, 0.1.3, 0.2.2, 0.1.1, 0.0.7, 0.1.3 | warn | GRN-C001 new <name>-setup.pth (the same Hades payload); GRN-T004 no tag |
| mem8, phenopacket-store-toolkit | 6.0.1, 0.1.7 | warn | GRN-C001 new <name>-setup.pth; GRN-T004 no tag |
| bramin, dreamgen, ensmallen, magique, magique-ai, napari-ufish, nucbox, pantheon-agents, pantheon-toolsets, synago | 0.0.3, 1.8.1, 0.8.101, 0.6.9, 0.4.5, 0.0.3, 0.1.2, 0.6.1, 0.5.5, 0.1.1 | warn | GRN-C001 new <name>-setup.pth only; GRN-T004 had nothing to check, see the limits below |
| mrmustard | 0.7.4 | pass (missed) | none: see the limits below |
| 20 clean updates (list) | 0 warn | none | |
| 9,439 updates of the top 1,000 PyPI packages (report) | 220 flagged for a look (2.3%); none makes a false claim | GRN-C003 65, GRN-C002 57, GRN-T004 49, GRN-T001 18, GRN-T003 16, GRN-T002 14, GRN-C001 3 |
The same replay covers the npm account takeovers of August and September 2025. npm deletes the tarball of a removed version, so these use the registry facts the public write-ups recorded (provenance present or not, the package.json scripts) and fetch the previous release and the repository's tags live. The malicious tarballs were not inspected.
| Release | Result | Rules that fired |
|---|---|---|
| chalk 5.6.0 → 5.6.1 | warn | GRN-T004 no tag v5.6.1 where v5.6.0 exists |
| debug 4.4.1 → 4.4.2 | warn | GRN-T004 no tag 4.4.2 |
| nx 21.4.1 → 21.5.0 | warn | GRN-T001 21.4.1 had provenance, 21.5.0 has none; GRN-C002 postinstall became node telemetry.js; GRN-T004 no tag |
| @ctrl/tinycolor 4.1.0 → 4.1.1 | warn | GRN-C002 new postinstall running node bundle.js; GRN-T004 no tag |
| 8,742 updates of the top 1,000 npm packages (report) | 146 flagged for a look (1.7%); none makes a false claim | GRN-T004 89, GRN-T001 48, GRN-T002 3, GRN-C002 3, GRN-T003 2, GRN-T005 1 |
Limits, stated plainly:
- ultralytics 8.3.42 was missed. The project's own release workflow built and published it through Trusted Publishing, with valid attestations and its usual git tag, after a GitHub Actions cache-poisoning attack. The miner runs when a model is downloaded, not at import, so no current rule sees it. Against today's tags GRN-T004 would report it, but only because the tag was deleted afterwards.
- embiggen 0.11.97 was missed. The trusted release, 0.11.96, shipped only an sdist, and 0.11.97 shipped only a wheel. Content rules found no pair of artifacts to compare, and they passed silently instead of saying they couldn't check. That's a bug, and a fix is planned. embiggen doesn't tag releases, so GRN-T004 couldn't fire either.
- mrmustard 0.7.4 was missed.
mrmustard/__init__.pycalls a new function at import, and GRN-C003 follows it. That function starts a thread whose target is a function defined inside it, and the thread body is wheresubprocessandurllib.requestare called. GRN-C003 follows a function handed to a call only when it is defined at module level, so it stopped one step short. A fix is planned. Nov0.7.4tag exists, but the package's metadata links no repository, so GRN-T004 had nowhere to look. - 24 of the 47 releases carry the same June 2026 Hades
<name>-setup.pthpayload, one release in each of 24 packages. They are 24 instances of one technique, mostly in small projects: four of those packages had a single earlier release, and bramin's trusted release dates from 2020. Counted by technique, the set covers five: a.pthstartup hook, code that runs at import, a swapped build backend, a DLL named.py, and a payload that runs on use rather than at import. - GRN-T001 (lost provenance) fired only for ultralytics 8.3.45 and 8.3.46. ultralytics is the only one of these projects that attested its releases before the attack. PyPI no longer serves provenance for removed releases, so the replay records ultralytics' missing attestations from PyPI's analysis.
- When the attacker controls the project's CI (elementary-data, ultralytics 8.3.41/8.3.42, memoryos), provenance looks normal, and content rules carry the result. memoryos' payload hides behind a logging setup function, so GRN-C003 missed it, and only its sdist's changed build backend was flagged. num2words' payload loads a DLL through
getattr(ctypes, "CDLL"), which GRN-C003 doesn't follow. Planned GRN-C004 (new binary) would report both. - GRN-T004 uses the git tags as they stood when each release was uploaded, recorded with evidence in incidents.toml. Against today's tags it would also report elementary-data 0.23.3 and ultralytics 8.3.42 (both tags were deleted later), and miss telnyx 4.87.1, gpsea 0.9.14 and uprobe 0.1.3 (all three projects later tagged a real release with that number). It couldn't fire for litellm, embiggen and bramin, which don't tag plain version numbers or don't tag at all; for the nine packages whose metadata links no repository (ensmallen, guardrails-ai, magique, magique-ai, mrmustard, napari-ufish, nucbox, pantheon-toolsets, synago) and ppkt2synergy, whose repository has no tags; or for pyphetools and dreamgen, whose repositories are no longer public (reported as GRN-E001). pantheon-agents has a
v0.6.1tag today that points at a commit from eleven days before the upload; a lightweight tag carries no date of its own, so the replay treats it as already there and the rule stays quiet. - ensmallen's
datasets/modules are larger than Garnet's parse limit in both releases. It reported that as GRN-E003 and still found the.pth. - In telnyx 4.87.1 a typo stopped the payload from running. GRN-C003 reads code; it reports what the module calls at import, not whether the call would succeed.
- The litellm 1.82.7 sdist isn't replayed, because no public source gives its URL. Its wheel is, and content rules compare wheels.
- chalk and debug are reported by the missing tag alone: they never used provenance, and the one changed file,
index.js, is gone from the registry. A JavaScript content rule is still to come. - Twenty clean updates is a small sample. The clean-corpus backtests cover the last 11 releases of the top 1,000 PyPI and npm packages: every warning there states something true, but none of those updates is malicious, so each is a false alarm a reviewer has to dismiss.
Reproduce it with uv run python -m backtest. Incident sources: Snyk on lightning, CSA on elementary-data, PyPI's incident report on litellm and telnyx, PyPI's analysis of ultralytics, The Hacker News on the Hades campaign, StepSecurity on mrmustard, the OSV advisory named on each entry in incidents.toml, the PyPI snapshots in DataDog's malicious-software-packages dataset, and the file hashes in ossf/malicious-packages.
Try it today (0.1 preview)
garnet diff compares two releases of one PyPI or npm package, and garnet check compares two lockfiles:
pip install --pre garnet-guard # or run it without installing: uvx garnet-guard check --git-base main
garnet diff requests 2.32.4 2.32.5
garnet diff pypi:lightning 2.6.4 2.6.5 --format json
garnet diff npm:chalk 5.6.0 5.6.2
garnet check --git-base main # every lockfile here, against the same files on main
garnet check --base old/uv.lock --head uv.lock
garnet policy check # validate garnet.toml and list its ignores
garnet explain GRN-C003
The replays run from a checkout of this repository:
uv sync
uv run python -m backtest # replay 51 malicious releases and the clean corpus
garnet check reads uv.lock, poetry.lock, pylock.toml and requirements files. Lines it can't check (not pinned with ==, or from git, a path or a private index) are listed as GRN-E004 or GRN-E005, never skipped. garnet --help lists every option.
The exit code is 0 below --fail-on (default: the policy's fail_on, else block), 1 for warn, 2 for block and 3 if Garnet Guard itself failed.
Ecosystems
Python first (uv.lock, poetry.lock, pylock.toml, pinned requirements.txt). npm is checked by garnet diff npm:<name> <old> <new> (provenance, tags, install scripts, OSV); the npm lockfile parsers (package-lock, pnpm-lock, yarn.lock) follow.
Project
- Vision and founding principles
- v1.0 specification and code design and API
- Architecture: how the engine works.
- Code tour: a plain-language guide to the code as it is today, for new contributors and reviewers.
- Rules: one page per rule ID.
- Threat model: what Garnet catches, what it can't, and how Garnet protects itself.
- Roadmap
- Open core: what's free forever, and what the paid Garnet Guard App adds.
- Questions and ideas: GitHub issues
- Contributing, Governance, Security policy, Code of conduct
- Architecture decision records
License
Apache-2.0. See LICENSE.
Metadata
Release files for garnet-guard 0.1.0a1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| garnet_guard-0.1.0a1.tar.gz | 885.6 kB | With provenance | Details for garnet_guard-0.1.0a1.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| garnet_guard-0.1.0a1-py3-none-any.whl | Python 3 | none | any | Details for garnet_guard-0.1.0a1-py3-none-any.whl |
Total release size: 997.7 kB
Release files / garnet_guard-0.1.0a1.tar.gz
| Download URL | garnet_guard-0.1.0a1.tar.gz |
|---|---|
| Size | 885.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
421d1a003c83a1cc313517d58bde08ff280c0b6f112da8476ec899d40f9a4e08
|
|
BLAKE2b-256 checksum How to use checksums |
92a6eeed73929f5c679a091c6f858a8c71e009b2d9dd553492f9bf6fb1a4cd50
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 30, 2026.
Transparency logRelease files / garnet_guard-0.1.0a1-py3-none-any.whl
| Download URL | garnet_guard-0.1.0a1-py3-none-any.whl |
|---|---|
| Size | 112.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d9a1caffc78b58db46048d3f9c49d503420450dfe4ed0cc558259b7cd6ea1a37
|
|
BLAKE2b-256 checksum How to use checksums |
3d7cae08888f4a6177fc5d1206374a1621cef8e97b5f8c86443843ad7e69d573
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 30, 2026.
Transparency logSecurity for garnet-guard 0.1.0a1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for garnet_guard-0.1.0a1.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 30, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.