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 four compromised packages.

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

Replay results

  • 7 of 7malicious releases flagged in replaySeven releases of four compromised packages in 2026, from the files and tags alone. See each one.
  • 12 of 974everyday updates flagged for a lookNormal releases of the top 100 PyPI packages. Each flag points at a real change that turned out harmless; 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-29 with uv run python -m backtest incidents, reading each project’s git tags as they stood when the release was uploaded (full output). The everyday set is the last 11 releases of each of the top 100 PyPI packages, re-run on 2026-10-29: Garnet flagged 12 of those updates, and each flag was checked by hand against the release. The first run, on 2026-10-10, flagged 17, and 5 of those were false alarms; they were bugs, fixed before the re-run, and both runs are triaged in the report. Separately, 33 updates had a file Garnet couldn’t read, which it reports 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, reading the project’s git tags as they stood on the day of the upload. Nothing is run. This is the actual output.

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.

lightning 2.6.1 → 2.6.3 warn

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

A second upload of the same payload minutes later, with no git tag and no workflow run. Garnet compares it with 2.6.1, the last release the project would have trusted.

garnet diff lightning 2.6.1 2.6.3
lightning 2.6.1 → 2.6.3: WARN
  WARN  GRN-C003 new-import-time-exec: 2.6.3 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.3
           - 2.6.1 → tag 2.6.1 at 655a3a91828694b9bfc241177f474741e483aeaa <https://github.com/Lightning-AI/lightning/tree/2.6.1>
           - 2.6.3: no tag named 2.6.3, v2.6.3, lightning-2.6.3 or lightning-v2.6.3 in github.com/Lightning-AI/pytorch-lightning <https://github.com/Lightning-AI/pytorch-lightning/tags>

verdict: warn

What this replay could not see

The same limits as 2.6.2: no earlier attestations for GRN-T001 to compare against, and no record of a 2.6.3 tag on the day or since.

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.7 warn

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

Uploaded with a PyPI token stolen through a compromised step in the project's CI. A payload added to litellm/proxy/proxy_server.py runs the moment anything imports the proxy module.

garnet diff litellm 1.82.6 1.82.7
litellm 1.82.6 → 1.82.7: WARN
  WARN  GRN-C003 new-import-time-exec: 1.82.7 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

GRN-T001 (lost provenance) cannot fire: litellm did not publish attestations before the attack. GRN-T004 cannot fire either: litellm tags its builds v1.82.6-nightly, v1.82.6.rc.1 and so on, never a plain version, so the trusted release had no tag to compare with. Only the wheel is replayed, because no public source gives the source archive's address, so GRN-C002, which reads source archives for new build hooks, could not run.

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.1 warn

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

Uploaded with a stolen PyPI token. Code appended to telnyx/_client.py fetches a second stage the moment anything imports the package. A typo stopped it from running in this version.

garnet diff telnyx 4.87.0 4.87.1
telnyx 4.87.0 → 4.87.1: WARN
  WARN  GRN-C003 new-import-time-exec: 4.87.1 adds import-time calls to sensitive APIs in 1 module(s)
           - 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.1
           - 4.87.0 → tag v4.87.0 at fa704347b541de2884fa34021b805d0bda544ae8 <https://github.com/team-telnyx/telnyx-python/tree/v4.87.0>
           - 4.87.1: no tag named 4.87.1, v4.87.1, telnyx-4.87.1 or telnyx-v4.87.1 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 (lost provenance) cannot fire: telnyx did not publish attestations before the attack. The tag finding holds for the day only: Telnyx published its own v4.87.1 tag that evening, 16 hours after the upload (SafeDep found none on the day), so the replay leaves that later tag out, and against today's tags GRN-T004 stays quiet; the full output shows both results. A flag is not proof the payload works: a typo stopped this one, and GRN-C003 reports the call at import, not whether it would succeed. Only the wheel is replayed, because no public source gives the source archive's address, so GRN-C002 could not run.

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.

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.

  • 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 Datadog's malicious-software-packages-dataset for lightning and elementary-data, and from the advisories and SafeDep's and StepSecurity's write-ups for litellm and telnyx. Advisories are from OSV.

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, or new code that runs a process, loads code or uses the network at import time.

GRN-C001GRN-C002GRN-C003

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 support follows Python. 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?