Dev.to Security 🔐 Cybersecurity 👁 0 📖 9 min read

Qué hace falta para fiarse del binario de una CLI de una sola persona

Cómo se construyeron el CI/CD y la cadena de suministro de VPC Atlas, y los comandos para comprobar cada descarga. Por qué una herramienta pequeña lleva tanto pipeline La herramienta solo hace llamadas de lec

Cómo se construyeron el CI/CD y la cadena de suministro de VPC Atlas, y los comandos para comprobar cada descarga.

Por qué una herramienta pequeña lleva tanto pipeline

La herramienta solo hace llamadas de lectura, pero se ejecuta con credenciales de una cuenta de AWS, así que quien la instala debería saber qué está ejecutando.

Quien hace merge y publica las releases soy yo, y nadie más. CODEOWNERS tiene una sola regla y un solo nombre, el mío. Sin un segundo revisor que pille un error o un atajo, escribí las comprobaciones que haría un revisor y puse a una máquina a ejecutarlas en cada cambio, en público.

El objetivo: que cualquiera que descargue un fichero de la página de releases pueda averiguar qué workflow lo construyó, y desde qué commit, sin fiarse de mi palabra. Lo que sigue son las decisiones y el motivo de cada una. Los motivos sirven para otros proyectos. El YAML, no.

La forma del pipeline

Hay cinco workflows. ci se ejecuta en cada pull request y en cada push a main. release se ejecuta cuando subo un tag. CodeQL y OpenSSF Scorecard corren además cada lunes, y las comprobaciones nocturnas, a las 05:00 UTC, vigilan lo que puede romperse sin tocar el código. Los cinco empiezan con acceso de solo lectura al repositorio y cada job tiene un tiempo máximo. Solo publica un tag con la forma vX.Y.Z:

on:
  push:
    tags: ["v[0-9]+.[0-9]+.[0-9]+"]
  workflow_dispatch:

permissions:
  contents: read

Qué se ejecuta en cada cambio

ci tiene nueve jobs y ninguno espera a otro, así que un cambio tarda lo que tarde el más lento. Un push nuevo a la misma rama cancela la ejecución anterior. Los nombres de los jobs son las comprobaciones obligatorias de main, y no se aceptan commits directos a main.

Lint y tipos. ruff y mypy sobre el Python, actionlint y zizmor sobre los ficheros de workflow. Cualquier hallazgo hace fallar el job. Los workflows pueden publicar mis releases, así que reciben el mismo escrutinio que el Python.

Tests. Linux con Python 3.12 y 3.13, macOS 14 y Windows 2022. Solo la combinación de Linux con Python 3.12 mide la cobertura, con --cov-fail-under=88.

Docs y paquete. mkdocs build --strict falla ante un enlace roto, y el sdist y el wheel se construyen desde un lock con hashes fijados. La revisión de dependencias bloquea los pull requests que añaden una dependencia con un aviso de seguridad de severidad alta.

Imágenes. Las imágenes del panel y de Lambda se construyen y se arrancan. El panel debe responder en /healthz, y la imagen de Lambda debe responder a un evento de HTTP API enviado a través del Runtime Interface Emulator. Trivy hace fallar el job ante una vulnerabilidad crítica con corrección disponible. El tope es de 40 minutos, el más alto de ci.

Chart de Helm. helm lint --strict y helm template se ejecutan con los valores por defecto y con todas las opciones activadas. Después el job construye la imagen desde el commit, levanta un clúster kind, instala el chart, ejecuta helm test, llega al panel con un port-forward, actualiza con todas las opciones activadas y vuelve a probar. El tope es de 30 minutos.

Instaladores. En windows-2022, ubuntu-22.04 y macos-14, el job construye el archivo comprimido, pasa el script de instalación al intérprete como lo hace curl -fsSL https://vpcatlas.com/install.sh | sh y ejecuta vpcatlas --version. Después le da al instalador una suma de verificación que no coincide y exige que falle con checksum mismatch, sin dejar nada instalado. Una comprobación que nunca se ha visto fallar demuestra poco.

Qué hace un tag

Para publicar, pongo la misma versión en pyproject.toml, src/vpcatlas/__init__.py y deploy/helm/vpcatlas/Chart.yaml, incorporo el cambio con un pull request, pongo el tag al commit de merge y lo subo. El primer job se niega a seguir si el tag y los cuatro campos de versión no coinciden:

chart=$(sed -n 's/^version: *\(.*\)$/\1/p' deploy/helm/vpcatlas/Chart.yaml)
app=$(sed -n 's/^appVersion: *"\(.*\)"$/\1/p' deploy/helm/vpcatlas/Chart.yaml)
if [ "$package" != "$chart" ] || [ "$package" != "$app" ]; then
  echo "pyproject.toml says ${package} but Chart.yaml says version ${chart} and appVersion ${app}" >&2
  exit 1
fi
if [ "${GITHUB_REF_TYPE}" = "tag" ] && [ "${GITHUB_REF_NAME#v}" != "$package" ]; then
  echo "Tag ${GITHUB_REF_NAME} does not match the version ${package}" >&2
  exit 1
fi

Después, por orden:

  1. binaries se construyen con PyInstaller para Linux, macOS (Intel y Apple silicon) y Windows. Cada binario se ejecuta, y el archivo comprimido ya desempaquetado debe imprimir --version en menos de tres segundos. La compilación de Linux x86_64 escribe además el SBOM.
  2. release escribe SHA256SUMS, lo comprueba con sha256sum -c, genera la atestación de cada archivo comprimido, del fichero de sumas y del SBOM, y crea la release de GitHub.
  3. docker y lambda construyen cada imagen (la del panel, para linux/amd64 y linux/arm64), la analizan con Trivy y se detienen antes del push si hay una vulnerabilidad crítica con corrección. Luego la suben, la firman por digest con cosign y generan su atestación.
  4. chart sube el chart de Helm a ghcr.io/karina-pp/charts/vpcatlas, lo firma, genera su atestación y lo descarga de nuevo para verificar ambas.
  5. pypi publica con trusted publishing: PyPI confía en la ejecución del workflow, así que no existe ningún token. Una variable del repositorio activa el job, porque antes hay que registrar el publicador en pypi.org.
  6. verify va el último. Todos los jobs anteriores prueban mis ficheros. Este prueba lo que recibe el usuario.

Ejecuta curl -fsSL https://vpcatlas.com/install.sh | sh en Linux y macOS y irm https://vpcatlas.com/install.ps1 | iex en Windows, con la versión fijada, desde el sitio en vivo y no desde el commit. vpcatlas --version debe imprimir exactamente vpcatlas X.Y.Z. En Linux verifica además la atestación y la firma de la imagen publicada. Si fallan el sitio, el script, los ficheros de la release o las firmas, este job falla.

Si lanzo el workflow a mano desde la pestaña Actions, prueba y construye pero no publica nada, porque todos los jobs que publican llevan una condición sobre el tag. Es el ensayo.

Decisiones que mantienen pequeña la cadena de suministro

Fijar por contenido. Los workflows usan 25 actions de otros repositorios, cada una fijada a un SHA de commit completo con la versión en un comentario:

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
  with:
    persist-credentials: false

Las imágenes base se fijan por digest y los requisitos de Python por hash, y se instalan con --require-hashes. Un tag se puede mover después. Un SHA de commit, no. Dependabot propone las actualizaciones cada lunes, con una espera de siete días, así que una versión nueva tarda una semana en llegarme.

Mínimo privilegio. Los cinco workflows empiezan con permissions: contents: read. Un job pide más solo para lo que hace, como id-token: write para firmar. Los 21 checkouts llevan persist-credentials: false, así que el token no se queda en la copia de trabajo.

Una identidad en lugar de un secreto. Las firmas y las atestaciones usan la identidad efímera que GitHub da a cada ejecución del workflow, y PyPI usa trusted publishing. La única credencial de larga duración que queda es el token de Docker Hub, porque Docker Hub no acepta la identidad de GitHub.

Sin caché donde publico. El workflow de release no restaura ninguna caché de compilación, ni siquiera la de Trivy. Una caché escrita por una ejecución nunca debe acabar dentro de un fichero publicado.

Un solo SBOM. Un fichero CycloneDX, escrito por Syft, lista lo que hay dentro de la compilación de Linux x86_64 y tiene su atestación sobre ese archivo comprimido. Los demás se construyen con el mismo lock de dependencias, así que un fichero vale para todos.

Vigilar lo ya publicado. CodeQL con las consultas security-extended, Scorecard, pip-audit y un análisis nocturno con Trivy de la imagen de Docker Hub vigilan lo que ya está fuera. Una noche con fallos abre un único issue con la etiqueta nightly, y los fallos posteriores comentan en él.

Cómo comprobar una descarga

SHA256SUMS viaja con el archivo comprimido, así que solo demuestra que el fichero no se dañó por el camino. La atestación demuestra que lo construyó el workflow de release de este repositorio, y esa es la comprobación que importa. Necesitas la CLI de GitHub y, para las imágenes, cosign 3 o superior:

gh attestation verify vpcatlas-X.Y.Z-linux-x86_64.tar.gz --owner karina-pp
gh attestation verify SHA256SUMS --owner karina-pp
sha256sum --check --ignore-missing SHA256SUMS       # macOS: shasum -a 256 --check --ignore-missing SHA256SUMS
gh attestation verify oci://index.docker.io/vpcatlas/vpcatlas:X.Y.Z --owner karina-pp
cosign verify index.docker.io/vpcatlas/vpcatlas:X.Y.Z \
  --certificate-identity-regexp '^https://github.com/karina-pp/vpcatlas/' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com

Una ejecución correcta nombra el workflow y el commit que produjeron el fichero. Si falla, no instales el fichero. Los scripts de instalación hacen por ti la comprobación del archivo comprimido cuando gh está instalado y con la sesión iniciada, y VPCATLAS_SKIP_ATTESTATION=1 la desactiva.

Qué cuesta y qué me saltaría

El primer coste es el tamaño: los cinco ficheros de workflow suman unas 1.650 líneas de YAML. El mantenimiento es el segundo: un lote semanal de pull requests de Dependabot y runners que cambian bajo tus pies. GitHub retiró el runner de macOS 13 con Intel, así que el workflow de release construye ahora ese binario en macos-15-intel. Las herramientas también discrepan: las líneas que usan la action local de preparación llevan un comentario para ignorar un aviso, porque zizmor y actionlint 1.7.12 no pueden quedar contentos a la vez. El tercer coste es la espera. En ci, los topes van de 10 minutos para la revisión de dependencias a 40 para las imágenes.

Para un proyecto más pequeño, con un solo binario y sin chart, sin imagen de Lambda ni script de instalación, me saltaría el clúster kind, la prueba con el emulador, la matriz de instaladores, el benchmark nocturno y Scorecard. Scorecard puntúa el repositorio, y lo que protege son las versiones fijadas y los permisos que puntúa. Conservaría lo que casi no cuesta: SHAs fijados, contents: read por defecto, zizmor, Dependabot, SHA256SUMS con una atestación de procedencia de la compilación y un último job que instala la release publicada como lo haría un usuario.

Lo que aún falta

  • Sin certificado de firma de código. La atestación dice quién construyó un fichero, pero Windows y macOS no la leen, así que SmartScreen y Gatekeeper pueden seguir avisando ante un fichero descargado con el navegador.
  • La verificación depende de gh. Sin la CLI de GitHub con la sesión iniciada, los instaladores solo comprueban la suma de verificación, que demuestra que el fichero llegó entero, no quién lo hizo.
  • Las firmas llegan tarde. Las imágenes firmadas y los SBOM existen solo en las releases hechas con este pipeline. Las anteriores solo llevan las atestaciones de los archivos comprimidos y SHA256SUMS.
  • Cinco archivos, a veces cuatro. Linux arm64 se construye solo mientras hay una variable del repositorio activa, así que una release puede llevar cuatro.

Dónde mirar

Los workflows están en el repositorio, cada ejecución aparece en su página de Actions y la lista completa de comprobaciones está en la página de ingeniería de la documentación. El paquete está en PyPI y la imagen en Docker Hub. Si algún paso no comprueba lo que dice su nombre, dímelo.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.