Pre-alpha · Open source · Apache-2.0

A second reviewer for every dependency update.

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.

Nothing is published to PyPI yet. The first release posts its findings as a comment on the pull request; the replays below show what it finds in 40 compromised packages on PyPI and npm.

A linocut of a pomegranate cut in half. Every chamber of seeds shows, and one seed is amber.

Tested on real releases

  • 51 of 51malicious releases flagged in replayFrom 40 compromised packages on PyPI and npm, December 2024 to September 2026, each compared with the release the project trusted before it. Every one flagged, none missed. Every result.
  • 366 of 18,181everyday updates flagged for a lookNormal releases of the 2,000 most downloaded PyPI and npm packages, about 1 in 50. Each flag points at a real change; none makes a false claim.
  • Noneof the package’s code is runGarnet reads each release as files. It never installs or imports it.

Incidents replayed on 2026-10-30 with uv run python -m backtest incidents, reading each project’s git tags as they stood when the release was uploaded (full output, every result). The everyday set is the last 11 releases of each of the top 1,000 PyPI packages and the top 1,000 npm packages, run on 2026-10-30: Garnet flagged 366 of those 18,181 updates (220 of 9,439 on PyPI, 146 of 8,742 on npm), and each flag was checked by hand against the release. Every one states something true about a real change, and none makes a false claim. The first pass found 30 false claims; they were bugs, fixed before the final run, and both passes are triaged in the reports for PyPI and npm. An update with a file Garnet couldn’t read is reported as “couldn’t check”, never as a pass. The 20 clean updates around the lightning and elementary-data incidents all passed.

Replays

Each replay downloads the release a project trusted and the compromised release that followed, checks both against the hashes PyPI published, and diffs them (for npm, where removed files are gone, it uses the registry facts the write-ups recorded), reading the project’s git tags as they stood on the day of the upload. Nothing is run. This is the actual output. Six of the 51 results are here, one of them from npm; every result, with the everyday-update test, is on the results page.

A linocut row of three closed pomegranates and a fourth cut open, one seed amber.

lightning 2.6.1 → 2.6.2 warn

PyPI · April 30, 2026 · MAL-2026-3201

Uploaded with a stolen token. A thread started in lightning/__init__.py launches a Bun-based credential stealer the moment anything imports the package. Install-script blocking does not help here, because the payload runs at import, not at install.

garnet diff lightning 2.6.1 2.6.2
lightning 2.6.1 → 2.6.2: WARN
  WARN  GRN-C003 new-import-time-exec: 2.6.2 adds import-time calls to sensitive APIs in 1 module(s)
           - lightning/__init__.py:14 calls subprocess.Popen (in _run_runtime(), reached at import)
  WARN  GRN-T004 no-matching-tag: 2.6.1 has a tag in github.com/Lightning-AI/lightning, but github.com/Lightning-AI/pytorch-lightning has none for 2.6.2
           - 2.6.1 → tag 2.6.1 at 655a3a91828694b9bfc241177f474741e483aeaa <https://github.com/Lightning-AI/lightning/tree/2.6.1>
           - 2.6.2: no tag named 2.6.2, v2.6.2, lightning-2.6.2 or lightning-v2.6.2 in github.com/Lightning-AI/pytorch-lightning <https://github.com/Lightning-AI/pytorch-lightning/tags>

verdict: warn
github-actions bot commented

Garnet Guard: 1 update needs a look

⚠ Warning

lightning 2.6.1 → 2.6.2

  • GRN-C003 2.6.2 adds import-time calls to sensitive APIs: lightning/__init__.py:14 calls subprocess.Popen (in _run_runtime(), reached at import).
  • GRN-T004 2.6.1 has a tag in github.com/Lightning-AI/lightning, but github.com/Lightning-AI/pytorch-lightning has none for 2.6.2.

Garnet Guard · what is this? · GRN-C003 · GRN-T004

The comment the first release posts on the pull request, rendered the way GitHub shows it. A finding is amber because it is a reason to look, not a verdict.

What this replay could not see

GRN-T001 (lost provenance) cannot fire here: lightning did not publish attestations before the incident, so there was no provenance to lose. GRN-T004 reads the tags as they stood on April 30. For lightning that matches today: there is no tag for 2.6.2, and we found no record of one pushed and later removed.

elementary-data 0.23.2 → 0.23.3 warn

PyPI · April 24, 2026 · MAL-2026-3083

Published by the project's own CI after a GitHub Actions script injection, so the upload path looked normal. The release adds elementary.pth, which runs a base64 payload every time Python starts, for every program on the machine.

garnet diff elementary-data 0.23.2 0.23.3
elementary-data 0.23.2 → 0.23.3: WARN
  WARN  GRN-C001 new-pth-file: 0.23.3 adds code that runs at every Python startup (.pth)
           - new elementary.pth (246,691 bytes, 1 executable line(s)): import os, subprocess, tempfile, base64; x = os.path.join(tempfile.gettempdir(), ".trinny-security-update"); result = "ignore" if os.path.exists(x) else exec(b…

verdict: warn

What this replay could not see

Because the project's own pipeline published the release, provenance checks alone would have passed it. The tag check stays quiet too: the attacker's workflow run pushed a v0.23.3 tag of its own, on a commit no branch contains (StepSecurity's analysis), so a matching tag existed when the release went out. That tag has since been deleted; against today's tags GRN-T004 reports it missing, and the full output shows both results. The finding that counts is in the content: the new .pth file.

litellm 1.82.6 → 1.82.8 warn

PyPI · March 24, 2026 · PYSEC-2026-2

A second upload 13 minutes later: the same payload in proxy_server.py, plus a new litellm_init.pth that starts it every time Python starts, for every program on the machine.

garnet diff litellm 1.82.6 1.82.8
litellm 1.82.6 → 1.82.8: WARN
  WARN  GRN-C001 new-pth-file: 1.82.8 adds code that runs at every Python startup (.pth)
           - new litellm_init.pth (34,628 bytes, 1 executable line(s)): import os, subprocess, sys; subprocess.Popen([sys.executable, "-c", "import base64; exec(base64.b64decode('aW1wb3J0IHN1YnByb2Nlc3MKaW1wb3J0IHRlbXBmaWxlCmltcG9y…
  WARN  GRN-C003 new-import-time-exec: 1.82.8 adds import-time calls to sensitive APIs in 1 module(s)
           - litellm/proxy/proxy_server.py:139 calls subprocess.run

verdict: warn

What this replay could not see

The same limits as 1.82.7 for GRN-T001 and GRN-T004: no earlier attestations, and no plain version tags to compare with. Both the wheel and the source archive are replayed here.

telnyx 4.87.0 → 4.87.2 warn

PyPI · March 27, 2026 · PYSEC-2026-3 · GHSA-955r-262c-33jc

A second upload 16 minutes later with the typo fixed, so the payload runs when telnyx is imported: it fetches a second stage over the network and starts a process.

garnet diff telnyx 4.87.0 4.87.2
telnyx 4.87.0 → 4.87.2: WARN
  WARN  GRN-C003 new-import-time-exec: 4.87.2 adds import-time calls to sensitive APIs in 1 module(s)
           - telnyx/_client.py:7782 calls subprocess.run (in setup(), reached at import)
           - telnyx/_client.py:7786 calls urllib.request.Request (in setup(), reached at import)
           - telnyx/_client.py:7787 calls urllib.request.urlopen (in setup(), reached at import)
           - telnyx/_client.py:7801 calls subprocess.Popen (in setup(), reached at import)
           - telnyx/_client.py:7810 calls subprocess.Popen (in FetchAudio(), reached at import)
  WARN  GRN-T004 no-matching-tag: github.com/team-telnyx/telnyx-python has a tag for 4.87.0 but none for 4.87.2
           - 4.87.0 → tag v4.87.0 at fa704347b541de2884fa34021b805d0bda544ae8 <https://github.com/team-telnyx/telnyx-python/tree/v4.87.0>
           - 4.87.2: no tag named 4.87.2, v4.87.2, telnyx-4.87.2 or telnyx-v4.87.2 in github.com/team-telnyx/telnyx-python <https://github.com/team-telnyx/telnyx-python/tags>

verdict: warn

What this replay could not see

GRN-T001 cannot fire, as for 4.87.1. The tags read the same today as on the day: the repository has no tag for 4.87.2 now, and we found no record of one pushed and later removed.

nx 21.4.1 → 21.5.0 warn

npm · August 26, 2025 · MAL-2025-41439

Published with an npm token stolen through a GitHub Actions injection. The postinstall script in package.json became node telemetry.js, a credential stealer that drives local AI tools, and the release carried no provenance where every earlier one had.

garnet diff npm:nx 21.4.1 21.5.0
nx 21.4.1 → 21.5.0: WARN
  WARN  GRN-C002 new-build-hook: 21.5.0 adds or changes a script that runs when the package is installed
           - package.json: postinstall script changed to node telemetry.js (was node ./bin/post-install || exit 0)
  WARN  GRN-T001 lost-provenance: 1 file(s) in 21.5.0 have no attestation; every file of 21.4.1 had one
           - nx-21.5.0.tgz: no attestation
           - 21.4.1 was attested by github.com/nrwl/nx .github/workflows/publish.yml
  WARN  GRN-T004 no-matching-tag: github.com/nrwl/nx has a tag for 21.4.1 but none for 21.5.0
           - 21.4.1 → tag 21.4.1 at 0f3070161c15cfb0dfbf7f730c257d6322a2235a <https://github.com/nrwl/nx/tree/21.4.1>
           - 21.5.0: no tag named 21.5.0, v21.5.0, nx-21.5.0 or nx-v21.5.0 in github.com/nrwl/nx <https://github.com/nrwl/nx/tags>

verdict: warn

What this replay could not see

npm removes the files of a malicious version along with its registry record, so this replay checks the registry facts the write-ups recorded (provenance, publisher, install scripts) and reads the repository’s tags; the tarball itself is not inspected. Earlier nx releases carried provenance from the publish workflow, which is why GRN-T001 fires here.

ultralytics 8.3.40 → 8.3.42 warn

PyPI · December 5, 2024 · PYSEC-2024-154

Built, attested and tagged by the project's own release workflow after a GitHub Actions cache-poisoning attack, so every trust signal was true. The injected downloader runs when a model is fetched, not at import, so no import-time rule sees it. What gives it away is the source: the wheel's downloads.py is not the file at tag v8.3.42, and the extra code starts a process. The rules as first written missed this release; this one was written for it.

garnet diff ultralytics 8.3.40 8.3.42
ultralytics 8.3.40 → 8.3.42: WARN
  WARN  GRN-C007 code-not-in-source: 8.3.42 contains code that is not in github.com/ultralytics/ultralytics at tag v8.3.42 (1 file(s))
           - 8.3.42 → tag v8.3.42 at 950d9f73fc3e in github.com/ultralytics/ultralytics; 150 Python file(s) match the source, 2 don't <https://github.com/ultralytics/ultralytics/tree/v8.3.42>
           - ultralytics/utils/downloads.py:292 calls subprocess.Popen (in safe_run()); the source differs from ultralytics/utils/downloads.py at v8.3.42 <https://github.com/ultralytics/ultralytics/blob/950d9f73fc3e12c87e45f4f792281ee8e3cfc75f/ultralytics/utils/downloads.py>
  WARN  GRN-E001 fetch-failed: Couldn't evaluate GRN-T001: provenance of 8.3.42 is unknown
           - ultralytics-8.3.42-py3-none-any.whl: provenance not available
           - ultralytics-8.3.42.tar.gz: provenance not available

verdict: warn

What this replay could not see

GRN-E001 is a couldn't-check notice: PyPI no longer serves provenance for removed releases, so the replay cannot evaluate GRN-T001; on the day the attestation was valid. GRN-T004 stayed quiet because the workflow tagged v8.3.42 before publishing; that tag has since been deleted, so today's tags would report the release for the wrong reason. The results page names the other releases the first rules missed, the fixes that flag them now, and the gaps still open.

20 clean updates pass

Routine releases of popular packages, including the clean releases on either side of the lightning and elementary-data incidents.

A reviewer that warns on everything gets ignored. These updates produced no warning from a detection rule. Nothing to open. They are a smoke test; the everyday test is 366 of 18,181 updates of the 2,000 most downloaded PyPI and npm packages flagged for a look, none a false claim (results).

  • lightning 2.6.4 → 2.6.5
  • lightning 2.6.5 → 2.6.6
  • elementary-data 0.23.2 → 0.23.4
  • requests 2.32.4 → 2.32.5
  • urllib3 2.4.0 → 2.5.0
  • certifi 2025.7.14 → 2025.8.3
  • idna 3.9 → 3.10
  • attrs 25.3.0 → 25.4.0
  • packaging 25.0 → 26.0
  • jinja2 3.1.5 → 3.1.6
  • click 8.2.1 → 8.3.0
  • pydantic 2.11.7 → 2.12.0
  • httpx 0.28.0 → 0.28.1
  • setuptools 80.8.0 → 80.9.0
  • django 5.2.6 → 5.2.7
  • numpy 2.3.3 → 2.3.4
  • boto3 1.40.40 → 1.40.41
  • rich 14.1.0 → 14.2.0
  • sigstore 4.0.0 → 4.1.0
  • pypi-attestations 0.0.28 → 0.0.29

Malicious files are not hosted here. The replay fetches them from files.pythonhosted.org by URL and SHA-256, listed in backtest/incidents.toml, with hashes from the PyPI snapshots in Datadog's malicious-software-packages-dataset and from the advisories. The four npm replays use the registry facts recorded in backtest/npm-incidents.toml, because npm deletes the files of a removed version. Advisories are from OSV; every case, with its sources, is on the results page, with every known gap and what is being done about it.

How it works

The attacks that hurt in 2025 and 2026 were not new packages. They were new versions of packages people already trusted, and each one looked different from the last good release in ways a machine can check.

A linocut of a closed pomegranate.

Trust downgrade

The new release has no provenance attestation when the old one did, was built by a different repository or workflow, or has no matching git tag.

GRN-T001GRN-T002GRN-T003GRN-T004

Risky content change

A new .pth file, a new build hook, new code that runs a process, loads code or uses the network at import time, or a file in the wheel that is not in the source at the release's git tag.

GRN-C001GRN-C002GRN-C003GRN-C007

Known bad

The release matches an OSV malicious-package advisory, so a feed has already confirmed it.

GRN-K001

  • Never runs the code it inspects. Releases are read as bytes and parsed with ast.
  • “Couldn't check” is never “pass”. A fetch or parse that fails is reported as a finding.
  • Quiet on clean updates. Passing packages collapse to one line in the comment.
  • Explainable. Every finding names a rule and its evidence, and garnet explain says what to do.

What the replayed rules say

From garnet explain. Every rule has a page at /rules/GRN-XXXX.

GRN-C001 new-pth-file
Python runs import lines in .pth files in site-packages every time the interpreter starts, for every program, before any of your code. Almost no package needs a new one. What to do: read the .pth file. If it is a legitimate path hook (editable installs, namespace helpers) the release notes usually say so; otherwise treat the release as compromised.
GRN-C003 new-import-time-exec
Module top-level code runs the moment anything imports the package, including your test suite and CI. What to do: read the flagged lines. Network or subprocess calls at import are rare in libraries; ask whether the release notes explain them.
GRN-T001 lost-provenance
The trusted version was published by CI with a Sigstore attestation proving which repository and workflow built it. A new file with no attestation was most likely uploaded some other way, for example with a stolen or leaked API token. What to do: don't merge yet. Check the project's repository for a release workflow run and a tag for this version.
GRN-T004 no-matching-tag
Projects that tag releases normally tag every release. A version on the registry with no tag was probably not built from the repository's history, which is typical of a token-based upload. What to do: check the repository's tags and releases page. A missing tag can be an oversight, so confirm with the maintainers before merging.

Get started

Garnet Guard is pre-alpha. You can run the prototype from source today; the PyPI package and the GitHub Action come with the first release.

A linocut of seven pomegranate seeds, one of them amber.

1 Today, from source

$ git clone https://github.com/orca-solutions/garnet-guard
$ cd garnet-guard && uv sync
$ uv run garnet diff lightning 2.6.1 2.6.2
$ uv run python -m backtest   # replay every incident

This block becomes the terminal recording when it is ready.

2 First release: one command planned

uvx garnet-guard check --git-base origin/main

Compares your lockfile with the one on main. Works with uv.lock, poetry.lock, pylock.toml and pinned requirements.txt.

3 On every Dependabot PR planned

permissions:
  contents: read
  pull-requests: write
steps:
  - uses: orca-solutions/garnet-guard@<sha>
    with:
      fail-on: block

npm releases are checked too (garnet diff npm:<name>), with the npm lockfiles. Agent hooks for Claude Code and Codex, which stop an agent from quietly adding a risky dependency, are on the roadmap.

Open, and staying that way

  • Apache-2.0. The whole engine and every rule.
  • No telemetry. No account, no API key, nothing phones home. This page loads nothing from third parties either.
  • Detection is never paywalled. Free for public and private repositories. See OPEN_CORE.md.
  • Complementary. Keep your cooldown, pip-audit and malware feeds. Garnet answers a different question: is this release built and shaped like the last one you trusted?