El token de GitHub que mi repo público nunca debió tener

El workflow de issues de un repo público con un token de scope repo, las tres opciones de token para un GitHub Project de usuario y la comprobación de scopes que ahora solo devuelve project

Acababa de enviar el mismo conjunto de archivos de agentes a diez repositorios, un pull request en cada uno, y uno de esos repositorios es público. Antes de hacer merge me hice una pregunta simple: ¿pusimos algo sensible en el público?

Los archivos estaban limpios. Ni claves, ni correos, ni rutas del directorio personal, ni números de cuenta. Los workflows se referían a su secreto por nombre, y GitHub enmascara los valores de los secretos. Según la lista de siempre, la respuesta era no.

La respuesta era sí, solo que no en el diff. El secreto al que se referían esos workflows era un personal access token clásico con el scope repo, y el mismo token estaba en todos los repositorios, públicos incluidos. repo significa control total de todos los repositorios privados de la cuenta.

TL;DR

  1. Un workflow de un repo público que se ejecuta con issues se ejecuta para cualquiera que pueda abrir una issue, con los secretos del repo disponibles. No se filtró nada, pero el alcance del token era la cuenta entera.
  2. Las soluciones de manual no funcionan para un GitHub Project de usuario: los tokens fine-grained no pueden acceder a Projects de una cuenta personal, y las GitHub Apps tampoco.
  3. Los repos públicos tienen ahora un segundo PAT clásico con solo el scope project. Sus issues son públicas de todos modos, y cerrar issues usa GITHUB_TOKEN.
  4. Tres capas lo mantienen así: el script de configuración rechaza un token amplio para un repo público, cada workflow comprueba los scopes de su token y falla cerrado, y un repo que se vuelve público se protege solo, en el momento y otra vez cada día.
  5. Sobrescribir un secreto no deja copia, pero tampoco revoca el token anterior. Revisa el historial de ejecuciones antes de decidir si rotarlo.

Un token en todos los repos

Todos mis proyectos personales alimentan un único GitHub Project, un tablero de usuario que uso como diagrama de Gantt. Dos workflows pequeños en cada repositorio lo mantienen al día: uno añade cada issue nueva al tablero y el otro la pasa a In Progress y Done a medida que se marcan las tareas de su spec.

El GITHUB_TOKEN integrado no puede hacer ninguna de las dos cosas. Está limitado a su repositorio, y el tablero vive a nivel de cuenta. Por eso los workflows usan un personal access token guardado como secreto PROJECT_TOKEN. Un token clásico con los scopes project y repo funcionaba en todas partes, repos privados incluidos, y un solo comando de rotación actualizaba las trece copias. Era cómodo. Así es como una credencial acaba en trece sitios con el mismo alcance.

Lo que expone un repositorio público

El workflow de issues de un repo público empieza con este disparador:

on:
  issues:
    types: [opened, reopened, transferred]

Cualquiera con una cuenta de GitHub puede abrir una issue en un repositorio público. Cada una inicia una ejecución con PROJECT_TOKEN en el entorno.

Tal como estaba escrito, no era explotable, y vale la pena explicar por qué:

  • El workflow solo pasa el número y el node ID de la issue, ambos generados por GitHub, nunca su título ni su cuerpo. Una issue preparada no tiene nada que inyectar.
  • El repositorio solo permite sus propias actions, así que ningún código de terceros se ejecuta junto al secreto.
  • El otro workflow se ejecuta con pushes a la rama de integración, lo que requiere permiso de escritura.
  • GitHub no pasa secretos a los workflows disparados desde forks.

Pero seguro tal como está escrito está a una edición de no serlo. Un disparador pull_request_target futuro, o un echo "${{ github.event.issue.title }}" en un paso run:, y un desconocido puede hacer que el runner imprima o envíe el token. Con el scope repo, eso son todos los repositorios privados de la cuenta. El riesgo no era un bug. Era el tamaño del radio de impacto.

Las dos soluciones obvias no aplican

La primera sugerencia, del agente de IA con el que trabajo, fue sustituir el token por una GitHub App o por un token fine-grained. Las dos son el consejo moderno, y las dos están mal aquí.

  • Una GitHub App tiene identidad de bot, los permisos que elijas y tokens que caducan en una hora. Pero un bot no puede añadirse como colaborador de un Project de usuario, y los tokens de instalación de una app reciben Resource not accessible by integration en uno. Las apps sirven para Projects de una organización.
  • Un token fine-grained puede limitarse a unos pocos repositorios y permisos concretos. La propia documentación de GitHub lista la carencia: los tokens fine-grained no pueden acceder a Projects de una cuenta de usuario.

El agente recomendó la GitHub App antes de comprobar si admite Projects de usuario. Cuando le pregunté qué era exactamente una GitHub App, volvió a la documentación y a los hilos de la comunidad, encontró la limitación y se corrigió. Después recomendó un token fine-grained, y encontró la misma limitación en las carencias documentadas de GitHub mientras escribía el script de configuración. Las dos correcciones salieron de leer la documentación del caso exacto, no del caso general.

Mover el tablero a una organización gratuita desbloquearía ambas opciones. Es un cambio mayor del que este problema necesita.

Un segundo token, estrecho

Los repos públicos no necesitan el scope repo en absoluto. Sus issues son públicas, así que resolver el node ID de una issue funciona sin él, y cerrar una issue ya usa GITHUB_TOKEN. Lo que necesitan es el scope project, para añadir elementos y cambiar el campo de estado.

Así que los repos públicos tienen ahora un segundo token clásico con exactamente ese scope. Si se filtra, alguien puede editar el tablero. No puede leer ni hacer push a nada privado.

Un token clásico informa de sus scopes en la cabecera X-OAuth-Scopes de cualquier respuesta de la API, lo que convierte "¿es este el estrecho?" en una pregunta que un script puede responder. El script de configuración compara cada scope con una lista corta permitida antes de guardar nada en un repo público:

token_scopes() {
  GH_TOKEN="$1" gh api -i /user 2>/dev/null | tr -d '\r' \
    | sed -n 's/^[Xx]-[Oo][Aa]uth-[Ss]copes: *//p' | head -1
}

is_narrow_token() {
  local scope
  for scope in $(token_scopes "$1" | tr ',' ' '); do
    case "$scope" in project|read:project|read:org|read:user) ;; *) return 1 ;; esac
  done
  return 0
}

Primero lee la visibilidad de cada repositorio. Un repo público recibe el token estrecho, y el amplio queda rechazado ahí por construcción. Probé el filtro en los dos sentidos: el token amplio se rechaza, project solo se acepta y project, public_repo se rechaza.

Tres capas para que siga estrecho

Un script solo ayuda cuando alguien lo ejecuta. Hay dos situaciones que lo esquivan: un token guardado a mano y un repositorio privado que después se vuelve público con el token amplio todavía dentro. Las dos están cubiertas.

Los workflows comprueban su propio token. Los dos workflows empiezan ahora con un paso que se ejecuta antes de que el token haga cualquier otra cosa:

- name: Refuse a broad token in a public repo
  env:
    REPO_IS_PRIVATE: ${{ github.event.repository.private }}
  run: |
    if [ "$REPO_IS_PRIVATE" = "true" ]; then exit 0; fi
    SCOPES=$(GH_TOKEN="$PROJECT_TOKEN" gh api -i /user 2>/dev/null | tr -d '\r' \
      | sed -n 's/^[Xx]-[Oo][Aa]uth-[Ss]copes: *//p' | head -1)
    for SCOPE in $(printf '%s' "$SCOPES" | tr ',' ' '); do
      case "$SCOPE" in
        project|read:project|read:org|read:user) ;;
        *) echo "::error::This repo is public but PROJECT_TOKEN has the \"$SCOPE\" scope."; exit 1 ;;
      esac
    done

Los repositorios privados se lo saltan. Un repositorio público con algo más amplio se detiene con el comando para arreglarlo en lugar de seguir. Una visibilidad desconocida se trata como pública.

Volverse público vuelve a proteger el repo. GitHub tiene un evento public que se dispara una vez, cuando un repositorio pasa de privado a público, y solo un administrador puede causarlo. Cada repositorio recibe un pequeño workflow independiente con ese evento. Envía un repository_dispatch al repositorio privado que guarda los workflows canónicos, donde un segundo workflow guarda el token estrecho en cada repositorio público que tenga la automatización.

Ese segundo workflow también se ejecuta cada mañana, así que un evento perdido se corrige solo en un día. Comprueba algo que el token no puede: los repositorios privados solo pueden compartir workflows reutilizables con otros repositorios privados. Un repositorio que se vuelve público sigue llamando a workflows a los que ya no tiene acceso, y su automatización se detiene. La ejecución diaria compara los archivos de workflow de cada repo público con las copias completas y falla, lo que envía un correo, hasta que coinciden.

Cómo un repo público se mantiene con el token estrecho: volverse público dispara una ejecución que guarda el token de solo project, y el paso de guarda de los workflows lo comprueba

La ejecución diaria cuesta alrededor de un minuto de Actions.

Probarlo, y la pregunta que vino después

La primera ejecución manual del workflow de protección verificó los dos tokens, encontró el único repositorio público con la automatización y guardó en él el token estrecho. Para ver la guarda funcionando de verdad, abrí ahí una issue desechable. La ejecución registró Public repo: PROJECT_TOKEN is narrow (project)., añadió la issue al tablero y pasó. Después quité el elemento del tablero y cerré la issue, así que el Gantt nunca la mostró.

La siguiente pregunta era la correcta: ¿se borró el token antiguo del repo público? Sobrescribir un secreto de GitHub reemplaza su valor, y GitHub no guarda historial, así que el repositorio público ya no tiene el token amplio. Pero sobrescribir no es revocar. El token amplio sigue siendo válido y lo siguen usando los repositorios privados. La única vía por la que podía filtrarse era una ejecución de workflow, así que el historial lo resuelve: seis ejecuciones en ese repositorio antes del cambio, todas iniciadas por mí. No hace falta rotarlo. Si el historial hubiera mostrado ejecuciones desde forks o de desconocidos, habría creado un token amplio nuevo y revocado el anterior ese mismo día.

Lo que esto no cubre

  • De público a privado otra vez. Nada detecta esa dirección. Las issues de ese repositorio pasan a ser privadas y necesitan otra vez el token amplio, así que ejecuto el script de configuración en él a mano.
  • Los tokens fine-grained pueden ponerse al día. GitHub lista la carencia de Projects de usuario como una que piensa cerrar. Los tokens fine-grained no envían la cabecera X-OAuth-Scopes, así que las dos comprobaciones aceptan uno como estrecho, y el script igualmente verifica que puede leer el tablero antes de guardarlo.
  • El token amplio sigue existiendo. En la práctica es más estrecho, porque ya no está donde desconocidos pueden disparar ejecuciones, pero su scope no ha cambiado. Rotarlo en todos los repos es un solo comando.

Reglas que se generalizan

  • Audita una credencial por su alcance, no por su visibilidad. Un secreto enmascarado con alcance de cuenta es un riesgo mayor que uno filtrado sin ninguno.
  • Ajusta el token al público del repositorio. Los repositorios públicos se ejecutan con eventos que pueden causar desconocidos, así que reciben el token más estrecho que haga el trabajo.
  • Lee la página de limitaciones para tu caso exacto. Las dos alternativas modernas eran correctas en general y equivocadas para un Project de usuario.
  • Haz que el workflow compruebe su propia credencial. Un paso de guarda que falla cerrado detecta un error para el que nunca se ejecutó ningún script de configuración.
  • Trata un cambio de visibilidad como un evento, no como un ajuste. Si volverse público cambia lo que un repositorio puede tener, automatiza las consecuencias.
  • Sobrescribir no es revocar. Decide sobre la rotación con la evidencia, el historial de ejecuciones, no por si el secreto se reemplazó.

Referencias