Skip to content

SHA Pinning Is Not Enough: Poetry Edition

A few weeks ago, in the context of the Trivy incident, RoseSecurity published the article "SHA Pinning Is Not Enough", explaining how SHA pinning an action guarantees you always get the same commit, but it does not guarantee that commit is safe, not even that it was ever part of the upstream project.

This sounds odd the first time you read it. How can GitHub resolve a SHA that isn't on any branch of the official repo? The answer has to do with how GitHub manages forks: a fork shares the object storage with the parent repo, which means that any commit in any fork of the network is reachable by SHA from the upstream URL.

So I thought: if this is a GitHub-specific behavior and not a GitHub Actions one, could it happen with other tools that let you point at a commit? Like a package manager such as poetry?

TLDR for busy people

GitHub resolves any commit SHA across the entire fork network from any repo URL in that network. That means a git+https://github.com/<upstream>@<sha> install can pull a fork's code if you put a fork SHA there:

  • pip does it for any fork commit (no PR needed).
  • poetry does it too, but only if the fork has had a PR opened to the upstream at some point (so a refs/pull/N/head ref is advertised).

SHA pinning protects against silent version updates, not against a fork SHA masquerading as an upstream one. Verify the commit actually lives in the upstream before trusting it.

Quick recap of RoseSecurity's article

The article walks through the March 2026 incident with actions/checkout, where an attacker got workflows around the world to point at commit 70379aad with a # v6.0.2 comment next to it. That comment is free text, with no validation, and the SHA resolved just fine because GitHub found it through the fork network. Result: malicious code running in pipelines that were "correctly" pinned.

The attack is simple (though not easy):

  1. Fork a popular repo (in this case actions/checkout).
  2. Push a malicious commit to your fork. That commit lives in the object storage shared across the entire fork network.
  3. Get the user to change the SHA in their repo, leaving the version comment intact.
  4. Since git fetch <upstream-url> <sha> resolves any SHA visible in the network, the malicious fork commit gets downloaded without a hitch.

In the Trivy case, the attackers hid it by impersonating Vercel's CEO (yes, you can set any author on a commit, which is why it's important to add a GPG key to sign them) and with a commit that looked like a handful of harmless visual changes.

Beyond SHA pinning

Slide from our talk at BSides Luxembourg 2026: Level Up Your CI/CD: Building a secure pipeline with OSS

As you can see in the slide, if we open the commit on GitHub, we see a discreet warning that the commit doesn't belong to any branch of the repo. And that's it.

When I read this I thought "ok, this is a problem with how GitHub Actions resolves SHAs". But then I thought, if I can point at a branch in my pyproject.toml, I can also point at a commit, and if I can point at a commit, will the same thing happen? πŸ€”

The POC

I created a test repo with very simple code. bug_free_pancake/main.py:

def hello() -> str:
    return "This is a dummy function"

And two forks, each with a different change:

  • pr-dep01/bug-free-pancake: commit with an open PR to the upstream1.

    --- a/bug_free_pancake/main.py
    +++ b/bug_free_pancake/main.py
    @@ -1,2 +1,2 @@
     def hello() -> str:
    -    return "This is a dummy function"
    +    return "This is my not-so-malicious fork! πŸ––"
    
  • pr-dep02/bug-free-pancake: commit without a PR to the upstream.

    --- a/bug_free_pancake/main.py
    +++ b/bug_free_pancake/main.py
    @@ -1,2 +1,2 @@
     def hello() -> str:
    -    return "This is a dummy function"
    +    return "This is a cool fork"
    

The question is simple: can I install the fork SHAs using the upstream URL?

The variables I'll use:

UPSTREAM_SHA=333bf41b711c19709246b2f543946949bab55205
FORK_WITH_PR_SHA=c56db67c4bbcb1d70c4a3b5c454426fa2548cf3b
FORK_NO_PR_SHA=8679baeb876818c29cfad3bc9d6a10bd7b2d6c31

Case A: pip with the upstream URL and the fork SHA (no PR)

python -m venv /tmp/fork-test-venv
source /tmp/fork-test-venv/bin/activate

pip install "git+https://github.com/paul-requests/bug-free-pancake.git@${FORK_NO_PR_SHA}"

python -c "import bug_free_pancake; print(bug_free_pancake.hello())"
# This is a cool fork

Boom! πŸ’₯ Installs without issues and returns the fork's hello(). And remember: there isn't even an open PR. The URL points at the upstream, but the SHA only exists in pr-dep02.

Case B: poetry with the same URL and the same SHA (no PR)

pyproject.toml
[tool.poetry]
name = "poetry-fork-nopr-test"
version = "0.1.0"
description = ""
authors = ["test"]
package-mode = false

[tool.poetry.dependencies]
python = "^3.13"
bug-free-pancake = { git = "https://github.com/paul-requests/bug-free-pancake.git", rev = "8679baeb876818c29cfad3bc9d6a10bd7b2d6c31" }

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

Poetry install failing with the SHA of a fork without a PR

Error 🚫. Same SHA, same URL, same backend (GitHub)… but Poetry can't find it.

Case C: poetry with a fork SHA that does have an open PR

But what happens if we try the FORK_WITH_PR_SHA commit, in the pr-dep01 fork, which does have a PR open to the upstream?

pyproject.toml
[tool.poetry]
name = "poetry-fork-test"
version = "0.1.0"
description = ""
authors = ["test"]
package-mode = false

[tool.poetry.dependencies]
python = "^3.13"
bug-free-pancake = { git = "https://github.com/paul-requests/bug-free-pancake.git", rev = "c56db67c4bbcb1d70c4a3b5c454426fa2548cf3b" }

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

Poetry install resolving the SHA of a fork with an open PR

It works! πŸ™Œ Same upstream URL, but simply because the SHA belongs to a fork with an open PR, it becomes installable from Poetry.

python -c "import bug_free_pancake; print(bug_free_pancake.hello())"
# This is my not-so-malicious fork! πŸ––

POC summary

# Installer URL in the dependency SHA Open PR? Result
A pip upstream FORK_NO_PR_SHA (pr-dep02) no βœ… "This is a cool fork"
B poetry upstream FORK_NO_PR_SHA (pr-dep02) no 🚫 Failed to clone … verify ref exists on remote
C poetry upstream FORK_WITH_PR_SHA (pr-dep01) yes βœ… Resolves via refs/pull/1/head; "This is my not-so-malicious fork! πŸ––"
D poetry upstream UPSTREAM_SHA n/a βœ… "This is a dummy function" (control)

Why does pip work but poetry doesn't?

The difference is in how each one talks to the remote.

If I ask the upstream what refs it advertises, for the FORK_NO_PR_SHA (the pr-dep02 commit, no PR) there's nothing related:

git ls-remote https://github.com/paul-requests/bug-free-pancake.git
# 333bf41…  HEAD
# 333bf41…  refs/heads/main
# (the FORK_WITH_PR_SHA will show up further down, in refs/pull/1/head; we'll see it in a moment)

The FORK_NO_PR_SHA doesn't show up anywhere. And yet, pip installs it. How?

Because pip uses the system's git binary, and runs something equivalent to:

git fetch <upstream-url> <sha>

GitHub allows fetching by SHA for any commit in the fork network (parent + all forks share object storage), even when no upstream ref points at that commit. It's exactly the same behavior the Trivy attack exploited via actions/checkout.

poetry, on the other hand, uses dulwich (a pure-Python implementation of the Git protocol) to clone dependencies. And dulwich only resolves SHAs against the refs the remote advertises. Since FORK_NO_PR_SHA is not in refs/heads/main or any other upstream ref, dulwich treats it as nonexistent and aborts.

But all it takes is opening a PR from the fork to the upstream for GitHub to advertise a new ref. With the pr-dep01 PR already open:

git ls-remote https://github.com/paul-requests/bug-free-pancake.git
# 333bf41…  HEAD
# 333bf41…  refs/heads/main
# c56db67…  refs/pull/1/head      <-- appears when the PR is opened
# b1f1e35…  refs/pull/1/merge

From that moment on, dulwich can resolve the FORK_WITH_PR_SHA because it's advertised in refs/pull/1/head. The URL is still the upstream's, and yet Poetry installs the fork's code.

Paco and Andoni at BSides Luxembourg 2026

Paco and I during BSides Luxembourg. Paco here thought I was preparing the workshop we had in 2 hours, but I was writing the initial draft for this post.

Conclusion

I really liked RoseSecurity's article because it tackles a myth ("if you SHA-pin, you're safe") with a real case, and it focuses on workflows, where the usual mitigation ("SHA-pin your actions") was the main line of defense. What this POC shows is that the same vector carries over to other supply chains, like Python package managers:

  • If your pyproject.toml/requirements.txt installs from git+https://github.com/<org>/<repo>.git@<sha>, the repo URL gives you a false sense of ownership. What actually determines which code gets installed is the SHA, and a SHA can live in a fork.
  • With pip, you don't even need an open PR. The fork SHA resolves directly from the upstream URL.
  • With poetry, you do need an open PR to the upstream (or anything else that creates an advertised ref: opening a PR is enough, even if you later close it, because GitHub keeps refs/pull/N/head indefinitely). The bar is slightly higher, but at the end of the day, opening a PR doesn't require permissions on the upstream.

The attack pattern looks almost identical to the GitHub Actions one:

  1. I fork a popular Python library.
  2. I push a commit with malicious code to my fork.
  3. I find a project that installs that dependency through a git reference, or convince the victim to use "that version" to try something out.
  4. A review will see the legit repo URL and the SHA is just another hex string, indistinguishable from any other.
  5. The malicious version gets installed ☠️

This POC is just a small extension of the original article: the same gap in how Git resolves SHAs across forks reappears in pip and poetry, with different nuances but the same lesson2.

A SHA says "this commit", it doesn't say "this commit in this repo". And that difference isn't obvious until we see a case like this.

The three things I'd take away from all of this to apply on real projects:

  • Require commit signatures on changes to sensitive files (pyproject.toml, poetry.lock, requirements*.txt). As we saw in the POC with pr-dep01, Author and Commit are arbitrary metadata anyone can spoof; the GPG/SSH signature is the only thing GitHub cryptographically validates, and therefore the only reliable way to know who actually pushed a commit.
  • Review SHAs as if they were code. Check that the commit exists in a branch of the upstream before accepting a change. And remember that the # v1.2.3 next to the SHA is just a comment, free text that nobody validates, not part of the pin.
  • Consider running an internal mirror or proxy for your dependencies, the way many companies already do with Docker images. This one isn't trivial, but given the recent landscape, it may well end up being worth it.

Any feedback or just want to chat about it? Feel free to drop a comment on this LinkedIn post .

Up for trying it on another package manager to see what happens? πŸ˜…

Saludos, and may the force be with you.


POC References:


  1. This commit shows up with author and committer Andoni A. <…[email protected]> (my real account), even though I actually pushed it from the paul-requests account. The author/committer name and email are arbitrary Git metadata that anyone can modify before running git commit. The only thing GitHub cryptographically validates is the GPG/SSH signature on the commit. β†©

  2. And it probably applies to other package managers and tools too. πŸ˜΅β€πŸ’« β†©