Saltar a contenido

SHA Pinning Is Not Enough: Poetry Edition

Hace unas semanas, relacionado con el incidente de Trivy, RoseSecurity publicó el artículo "SHA Pinning Is Not Enough", en el que explicaba cómo hacer SHA pinning de una action garantiza que siempre obtienes el mismo commit, pero no garantiza que ese commit sea seguro, ni siquiera que haya formado parte del proyecto upstream.

Esto suena raro la primera vez que lo lees. ¿Cómo va a resolver GitHub un SHA que no está en ninguna branch del repo oficial? La respuesta tiene que ver con cómo GitHub gestiona los forks: un fork comparte el almacenamiento de objetos con el repo padre, y eso significa que cualquier commit en cualquier fork de la red es alcanzable por SHA desde la URL del upstream.

Entonces, pensé: si esto es un comportamiento específico de GitHub y no de GitHub Actions, ¿podría pasar con otras herramientas que permitan apuntar a un commit? ¿Como un gestor de paquetes tipo poetry?

TLDR para los que tienen prisa

GitHub resuelve cualquier SHA de commit en toda la red de forks desde cualquier URL del repo en esa red. Eso significa que una instalación con git+https://github.com/<upstream>@<sha> puede traer código de un fork si pones ahí un SHA del fork:

  • pip lo hace para cualquier commit del fork (sin necesidad de PR).
  • poetry también, pero solo si el fork ha tenido alguna vez un PR abierto al upstream (de modo que exista un ref anunciado refs/pull/N/head).

El SHA pinning protege contra actualizaciones silenciosas de versión, no contra un SHA de fork haciéndose pasar por uno del upstream. Verifica que el commit realmente vive en el upstream antes de confiar en él.

Recap rápido del artículo de RoseSecurity

El artículo desgrana el incidente de marzo de 2026 con actions/checkout, donde un atacante consiguió que workflows de medio mundo apuntaran al commit 70379aad con un comentario # v6.0.2 al lado. Al final ese comentario es texto libre, sin validación, y el SHA resolvía perfectamente porque GitHub lo encontraba en la red de forks. Resultado: código malicioso ejecutándose en pipelines que estaban "correctamente" pinneados.

El ataque es sencillo (que no fácil):

  1. Forkeas un repo popular (en este caso actions/checkout).
  2. Pusheas un commit malicioso a tu fork. Ese commit existe en el almacenamiento de objetos compartido por toda la red de forks.
  3. Consigues que el usuario cambie el SHA en su repo, manteniendo el comentario de versión intacto.
  4. Como git fetch <url-upstream> <sha> resuelve cualquier SHA visible en la red, el commit malicioso del fork se descarga sin problemas.

En el caso de Trivy lo ocultaron impersonando al CEO de Vercel (sí, puedes poner como autor de un commit a quien quieras, y por eso es importante añadir una GPG key para firmarlos) y con un commit que parecía unos meros cambios visuales.

Beyond SHA pinning

Slide de nuestra charla en BSides Luxembourg 2026: Level Up Your CI/CD: Building a secure pipeline with OSS

Como se ve en la slide, si abrimos el commit en Github, vemos un discreto warning de que el commit no pertenece a ninguna rama del repositorio. Y ya estaría.

Cuando leí esto pensé "vale, esto es un problema de cómo GitHub Actions resuelve los SHAs". Pero después pensé, si yo puedo apuntar a una rama en mi pyproject.toml, también puedo apuntar a un commit, y si puedo apuntar a un commit, pasará lo mismo? 🤔

El POC

Creé un repo de prueba con un código muy simple. bug_free_pancake/main.py:

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

Y dos forks con un cambio diferente en cada uno:

  • pr-dep01/bug-free-pancake: commit con PR abierto al 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 sin PR al 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"
    

La pregunta es simple: ¿puedo instalar el SHA de los forks usando la URL del upstream?

Las variables que voy a usar:

UPSTREAM_SHA=333bf41b711c19709246b2f543946949bab55205
FORK_WITH_PR_SHA=c56db67c4bbcb1d70c4a3b5c454426fa2548cf3b
FORK_NO_PR_SHA=8679baeb876818c29cfad3bc9d6a10bd7b2d6c31

Caso A: pip con la URL del upstream y el SHA del fork (sin 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! 💥 Instala sin problemas y devuelve el hello() del fork. Y recordemos: no hay ni un PR abierto. La URL apunta al upstream, pero el SHA solo existe en pr-dep02.

Caso B: poetry con la misma URL y el mismo SHA (sin 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 fallando con el SHA de un fork sin PR

Error 🚫. Mismo SHA, misma URL, mismo backend (GitHub)… pero Poetry no lo encuentra.

Caso C: poetry con un SHA de fork que tiene PR abierto

Pero ¿qué pasa si probamos con el commit de FORK_WITH_PR_SHA, en el fork de pr-dep01, que sí tiene un PR abierto al 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 resolviendo el SHA de un fork con PR abierto

Funciona! 🙌 Misma URL del upstream, pero el simple hecho de que el SHA pertenezca a un fork con PR abierto lo convierte en instalable desde Poetry.

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

Resumen del POC

# Instalador URL en la dependencia SHA ¿PR abierto? Resultado
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) ✅ Resuelve vía refs/pull/1/head; "This is my not-so-malicious fork! 🖖"
D poetry upstream UPSTREAM_SHA n/a ✅ "This is a dummy function" (control)

¿Por qué pip sí y poetry no?

La diferencia está en cómo cada uno habla con el remoto.

Si pregunto al upstream qué refs anuncia, para el FORK_NO_PR_SHA (el commit de pr-dep02, sin PR) no hay nada relacionado:

git ls-remote https://github.com/paul-requests/bug-free-pancake.git
# 333bf41…  HEAD
# 333bf41…  refs/heads/main
# (el FORK_WITH_PR_SHA aparecerá más abajo, en refs/pull/1/head; lo veremos en un momento)

El FORK_NO_PR_SHA no aparece por ninguna parte. Y aun así, pip instala. ¿Cómo?

Porque pip usa el binario git del sistema, y ejecuta algo equivalente a:

git fetch <upstream-url> <sha>

GitHub permite hacer fetch por SHA para cualquier commit de la red de forks (padre + todos los forks comparten almacenamiento de objetos), incluso aunque ningún ref upstream apunte a ese commit. Es exactamente el mismo comportamiento que explota el ataque a Trivy usando actions/checkout.

poetry, en cambio, usa dulwich (una implementación pura en Python del protocolo de Git) para clonar dependencias. Y dulwich solo resuelve SHAs contra los refs que el remoto anuncia. Como el FORK_NO_PR_SHA no está en refs/heads/main ni en ningún otro ref del upstream, dulwich lo da por inexistente y aborta.

Pero basta con abrir un PR del fork al upstream para que GitHub anuncie un nuevo ref. Con el PR de pr-dep01 ya abierto:

git ls-remote https://github.com/paul-requests/bug-free-pancake.git
# 333bf41…  HEAD
# 333bf41…  refs/heads/main
# c56db67…  refs/pull/1/head      <-- aparece al abrir el PR
# b1f1e35…  refs/pull/1/merge

A partir de ese momento, dulwich sabe resolver el FORK_WITH_PR_SHA porque está anunciado en refs/pull/1/head. La URL sigue siendo la del upstream, y aun así Poetry instala el código del fork.

Paco y Andoni en BSides Luxembourg 2026

Paco y yo durante BSides Luxembourg. Aquí Paco pensaba que estaba preparando el taller que teníamos en 2 horas, pero estaba escribiendo el primer borrador de este post.

Conclusión

El artículo de RoseSecurity me pareció muy bueno porque ataca un mito ("si SHA-pineas, estás seguro") con un caso real, y se centra en workflows, donde la mitigación habitual ("haz SHA pinning de tus actions") era la línea de defensa principal. Lo que este POC demuestra es que el mismo vector se traslada a otras cadenas de suministro como los gestores de paquetes Python:

  • Si tu pyproject.toml/requirements.txt instala desde git+https://github.com/<org>/<repo>.git@<sha>, la URL del repo te da una falsa sensación de pertenencia. Lo que realmente determina qué código se instala es el SHA, y un SHA puede vivir en un fork.
  • Con pip, no hace falta ni que haya un PR abierto. El SHA del fork resuelve directamente desde la URL del upstream.
  • Con poetry, hace falta que haya un PR abierto al upstream (o cualquier otra cosa que cree un ref anunciado: basta con abrir un PR, aunque luego se cierre, porque GitHub mantiene refs/pull/N/head indefinidamente). El listón es ligeramente más alto, pero al final, abrir un PR no requiere permisos en el upstream.

El patrón de ataque queda casi calcado al de GitHub Actions:

  1. Forkeo una librería Python popular.
  2. Pusheo un commit con código malicioso a mi fork.
  3. Busco algún proyecto que instale esa dependencia usando la referencia a git, o convenzo a la víctima de que use "esa versión" para probar algo.
  4. Una review verá la URL del repo legítimo y el SHA es otro hex string indistinguible.
  5. Se instala la versión maliciosa ☠️

Este POC es solo una pequeña extensión del artículo original: el mismo gap en cómo Git resuelve SHAs en forks reaparece en pip y poetry, con matices distintos pero la misma lección2.

El SHA dice "este commit", no dice "este commit en este repo". Y esta diferencia no es obvia hasta que vemos un caso así.

Las tres cosas que yo me llevaría de todo esto para aplicar en proyectos reales:

  • Exigir firmas de commit en cambios sobre archivos sensibles (pyproject.toml, poetry.lock, requirements*.txt). Como vimos en el POC con pr-dep01, Author y Commit son metadatos arbitrarios que cualquiera puede falsificar; la firma GPG/SSH es lo único que GitHub valida criptográficamente, y por tanto la única forma fiable de saber quién pusheó realmente un commit.
  • Revisar los SHAs como si fueran código. Comprobad que el commit existe en una rama del upstream antes de aceptar un cambio. Y recordad que el # v1.2.3 al lado del SHA es solo un comentario, texto libre que nadie valida, no parte del pin.
  • Evaluar tener un mirror o proxy interno para las dependencias, igual que muchas empresas hacen ya con imágenes de Docker. Este punto no es tan sencillo, pero viendo el panorama reciente, puede acabar mereciendo la pena.

Cualquier feedback o si quieres simplemente charlar sobre el tema, puedes comentar en este post de LinkedIn .

Os animáis a probar con otro gestor de paquetes a ver qué pasa? 😅

Saludos, y que la fuerza os acompañe.


Referencias del POC:


  1. Este commit aparece como autor y committer Andoni A. <…[email protected]> (mi cuenta real), aunque en realidad lo pusheé con la cuenta paul-requests. El nombre y email de autor/committer son metadatos de Git arbitrarios que cualquiera puede modificar antes de un git commit. Lo único que GitHub valida criptográficamente es la firma GPG/SSH del commit. 

  2. Y probablemente aplique a otros gestores de paquetes y herramientas. 😵‍💫