Para robarle a Snowflake un token de Jira con acceso de lectura a sus proyectos de ingeniería, cumplimiento de seguridad y programa de recompensas no hizo falta ninguna credencial. Hizo falta abrir un issue en un repositorio público suyo y escribir el exploit en el título.
Lo contó Wiz el 17 de agosto y la historia se ha resumido en todas partes con el mismo titular: una IA rompió el código de Snowflake. Es un buen titular y es aproximadamente cierto, pero el diff cuenta algo más aburrido y bastante peor. Aviso previo: nosotros no vendemos GitHub, ni Copilot, ni ninguna herramienta que salga en este post. Sí operamos nuestro propio pipeline —y ya hemos escrito sobre sus deudas técnicas—, así que léase con eso en la cabeza.
Un workflow que abre tickets
El repositorio es snowflakedb/snowflake-connector-net, el conector .NET de Snowflake, público. Dentro tiene un fichero .github/workflows/jira_issue.yml que hace algo que hemos montado casi todos alguna vez: cuando alguien abre un issue en GitHub, crea automáticamente el ticket correspondiente en el Jira interno. Para eso el workflow necesita un token de Jira, y el token está donde siempre, en los secretos del repositorio.
El 18 de junio de 2026 se fusionó el pull request 1218, titulado «SNOW-2069227 : Update jira workflows». Llevaba abierto desde el 7 de agosto de 2025: diez meses y once días. Tocaba dos ficheros, y guarde el lector ese detalle porque es el nudo de todo esto. Antes del cambio, el título del issue viajaba así (resumimos el bloque, que es más largo):
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
run: |
TITLE=$(echo "$ISSUE_TITLE" | sed 's/"/\\"/g' | sed "s/'/\\\'/g")
PAYLOAD=$(jq -n --arg issuetitle "$TITLE" '{ ... }')
Y después del cambio, así:
run: TITLE=$(echo '${{ github.event.issue.title }}' | sed 's/"/\\"/g' | sed "s/'/\\\'/g")
Conviene fijarse en que los dos sed ya estaban antes. No son la novedad ni eran la defensa. Lo que protegía era lo otro: el título entraba como dato en una variable de entorno y llegaba a jq como argumento. Después del cambio, el título se pega dentro del script antes de que el script exista, y los mismos dos sed ya no valen para nada: corren después de que GitHub haya sustituido la expresión, así que cuando les toca actuar la comilla que rompe el echo '…' ya está dentro del script. Escapar algo que ya es código no lo devuelve a ser texto.
El patrón que se quitó no es una manía de nadie: es literalmente el que recomienda la documentación de GitHub sobre uso seguro de Actions. «Para scripts en línea, el enfoque preferido para manejar entrada no confiable es asignar el valor de la expresión a una variable de entorno intermedia», y el ejemplo que pone es un título que llega de fuera, github.event.pull_request.title. El cambio sustituyó la recomendación del fabricante por la práctica contra la que avisa esa misma página.
El titular dice IA. El historial dice cuatro pasos
Aquí conviene bajar el volumen y abrir el pull request, que es público. Son cuatro commits. Uno se titula, sin más adorno, «copilot suggestion» y lleva la línea Co-authored-by: Copilot Autofix powered by AI. Y ese commit toca un solo fichero, que no es el que se rompió: es jira_close.yml, el otro. Lo que hace ahí es esto:
- name: Close Jira Issue
+ env:
+ ISSUE_KEY: ${{ steps.extract.outputs.jira }}
+ JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }}
+ JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }}
+ JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
run: |
- ISSUE_KEY="${{ steps.extract.outputs.jira }}"
- JIRA_API_URL="${{ secrets.JIRA_BASE_URL }}/rest/api/2/issue/..."
+ JIRA_API_URL="${JIRA_BASE_URL}/rest/api/2/issue/..."
Es decir: el commit atribuido a la máquina saca los secretos de dentro del run: y los pasa a env:. Exactamente el patrón que recomienda GitHub, exactamente el que desapareció del fichero de al lado. En el mismo pull request, y en sentido contrario.
Con lo cual el titular queda del revés. Quién escribió la línea vulnerable no consta —Wiz publicó ese mismo día una actualización diciendo que Copilot «fue co-autor, revisó el pull request fusionado y el cambio, y lo dio por bueno sin ver las vulnerabilidades críticas», y que «no está claro si el cambio lo asistió una IA»—, y nosotros no vamos a decidirlo desde fuera. Lo único que sí consta con nombre de commit es que, en este pull request, lo que hizo el asistente fue lo correcto.
Y el reparto de culpas tapa lo interesante. Entre esa línea y producción hubo tres puertas: la persona que abrió el pull request, la persona distinta que le dio al merge diez meses después y el análisis estático de GitHub Advanced Security, que —cuenta Wiz— extrajo el fichero vulnerable del pull request final y no marcó la inyección. Tres puertas, todas abiertas, ninguna rota. Nadie hizo nada raro: alguien revisó un cambio de formato y le pareció un cambio de formato.
La conclusión de Wiz la firmamos entera: «la intención de seguridad se puede perder cuando los patrones de código más seguros no se imponen de forma explícita». Ahí está el verbo que importa, imponer. Aquellas cuatro líneas de env: eran una decisión de seguridad que dependía de que quien tocara el fichero la reconociera como tal. Diez meses después, nadie la reconoció.
El «if» que parecía un filtro
Hay un segundo detalle en ese workflow que a nosotros nos ha dado más que pensar que la inyección, porque no tiene nada que ver con la IA y lo hemos visto en pipelines nuestros y de clientes. El job estaba protegido por una condición que, quitando el brazo que no viene al caso, dice esto:
if: github.event_name == 'issues' &&
github.event.pull_request.user.login != 'whitesource-for-github-com[bot]'
Se lee como un filtro razonable: no dispares esto para el bot de turno. Pero la primera mitad ya obliga a que el evento sea issues, y en un evento de tipo issues el objeto pull_request no existe. La condición estaba comprobando el autor de un pull request que no había. La documentación de GitHub explica la mitad de lo que pasa entonces: cuando los tipos no coinciden, para comparar los convierte a número, y su tabla dice que null vale 0 mientras que una cadena que no sea un número JSON válido se convierte en NaN. La otra mitad —que un 0 y un NaN no llegan a ser iguales— la doc no la escribe: solo aclara el caso de las comparaciones relacionales y remite a la definición de NaN. El resultado observado, en cambio, no admite duda, y Wiz lo dice en una línea: la condición se reduce a comparar null con una cadena, y eso siempre es cierto.
Es decir: una línea que en la revisión se lee como una restricción y en ejecución es un «sí» permanente. Nuestra lectura, que no está en el informe: eso tiene toda la pinta de ser una condición escrita para otro evento y copiada a un workflow que se dispara con uno distinto. Es el error más humano de todo el caso y el único que se puede buscar hoy mismo en tus repositorios con un grep.
Lo que se llevaron no era código
La carga que usó Wiz cabía en el título de un issue y hacía una sola cosa: una petición saliente a un dominio suyo con las variables del paso codificadas en base64. De vuelta llegaron el token de Jira, el correo de la cuenta y la URL de la instancia. La primera versión de la carga ni siquiera funcionó —usaron una almohadilla, que se comió el paréntesis de cierre y dejó al shell esperando un final que no llegaba—; el agente ajustó el payload y volvió a intentarlo.
Aquí está el punto que nos importa de verdad, y no es de GitHub ni de IA. El token no era «un token para abrir tickets». Pertenecía a una cuenta de QA y daba lectura sobre proyectos de ingeniería, de cumplimiento de seguridad y del programa de recompensas. Nadie le dio ese alcance a propósito: se le dio el token que había. Un paso de un pipeline hereda el permiso del secreto que le pones al lado, y ese permiso casi nunca se dimensiona por lo que hace el paso, sino por lo que hace el equipo. La documentación de GitHub avisa de lo mismo con otras palabras: el ejecutor tiene acceso a todos los secretos configurados en el repositorio.
Cinco días, y no los fijó Snowflake
La cronología es corta. El 18 de junio entra el cambio. El 23 de junio lo encuentran, lo explotan y lo reportan; Snowflake lo corrige ese mismo día. El 24 de junio rotan el token. Cinco días de ventana, y los registros de auditoría confirmaron después que en esos cinco días no accedió nadie más: todas las consultas anómalas venían de las direcciones de la prueba.
Cinco días es una respuesta excelente. Lo que da que pensar es quién fijó el reloj. No fue Snowflake revisando su código, ni un investigador curioseando el repositorio un domingo: fue un agente automático de Wiz —«Red Agent», lo llaman— rastreando la organización de GitHub de Snowflake dentro de su programa de recompensas de HackerOne. En julio escribimos sobre un atacante que usó un agente de IA para la parte aburrida de una intrusión y decíamos que la IA no había roto nada nuevo, solo había hecho el trabajo sucio más rápido y sin cansarse. Esto es la misma frase desde el otro lado del tablero. Y conviene no exagerarlo: aquí había un programa de recompensas, es decir, alguien pagando por que le miren. Lo que cambia no es que existan los cazadores, sino el precio de leerse un repositorio entero línea a línea, que ha dejado de ser una tarde de trabajo para ser un trámite.
Lo que miramos nosotros (y lo que no vamos a hacer)
Nada de lo que sigue es original ni difícil; es la lista corta a la que ha bajado este caso cuando la hemos pasado por nuestros propios repositorios.
- Ninguna expresión
${{ }}dentro de un bloquerun:. A variable de entorno y entre comillas, siempre, aunque el dato parezca inofensivo. Es una regla de sintaxis, no de criterio, y por eso se puede automatizar y revisar de un vistazo. - Un workflow que se dispara con
issues,issue_commentopull_request_targetes código expuesto a internet. Cualquiera con una cuenta gratuita decide cuándo se ejecuta y con qué entrada. Se revisa con ese criterio, no con el de un script interno. - El secreto se dimensiona por el paso, no por el equipo. Si el paso crea un ticket en un proyecto, la credencial crea tickets en ese proyecto. Es la parte más tediosa y la única que reduce el daño cuando lo demás falla.
- Un
if:que menciona un contexto que ese evento no rellena no es un filtro. Se comprueba contra el payload real del evento, no leyéndolo. Media hora degreppor los workflows buscando condiciones que hablan depull_requesten disparadores deissues(y al revés) suele dar alguna sorpresa. - Un diff sugerido por una máquina se revisa igual que uno humano. Ni más ni menos. La conclusión de Wiz es exactamente esa, y la documentación de GitHub sobre su propio autofix ya lo dice sin que nadie se lo pregunte: las sugerencias pueden «no remediar la vulnerabilidad subyacente o introducir vulnerabilidades nuevas» y requieren revisión y aceptación explícitas antes de aplicarse.
Y lo que no vamos a hacer, aunque sea la conclusión cómoda de este caso: prohibir el autofix, ni los asistentes, ni los cambios sugeridos por máquina. Sería sacar la lección equivocada. Hace unas semanas escribíamos, a cuenta de los proyectos de agentes que se cancelan, que el problema rara vez es el modelo y casi siempre la gobernanza binaria: o le das todo o no le das nada. Aquí pasa igual. Un asistente que propone un cambio en un fichero de documentación y uno que propone un cambio en el fichero que sujeta el token de producción no pueden pasar por la misma puerta, y hoy en casi todas partes pasan por la misma.
Recomendado no es lo mismo que impuesto
Volvamos al verbo de Wiz, porque de ahí sale lo único accionable. Imponer no es lo mismo que recomendar, y en aquel repositorio el patrón seguro estaba recomendado: existía, funcionaba, y no había absolutamente nada que impidiera quitarlo. Ninguna prueba fallaba. Ningún revisor tenía por qué saberlo. La página de la documentación de GitHub que lo explica seguía intacta en su sitio, sin que nadie tuviera motivo para abrirla.
Hay dos maneras de imponerlo, y las dos cuestan poco. Una es un comentario de cuatro palabras encima de la línea —«esto evita inyección de shell»—, porque lo que se lee ya no lo lee solo el siguiente compañero. La otra, más fiable, es que el propio pipeline se niegue: existen analizadores específicos de workflows de GitHub Actions que detectan una expresión dentro de un run: y rompen la comprobación. Un cambio así no se discute en la revisión, porque no llega a la revisión.
Fuentes (verificadas el 18 de agosto de 2026): el hallazgo, la carga usada y el fallo del primer intento con la almohadilla, la cuenta [email protected] con lectura sobre proyectos de ingeniería, cumplimiento de seguridad y programa de recompensas, la cronología (cambio fusionado el 18 de junio de 2026, hallazgo, explotación y reporte por HackerOne el 23 de junio, corrección de Snowflake el mismo día, rotación del token el 24), la ventana de cinco días, la confirmación por registros de auditoría de que solo accedieron las direcciones de la prueba, que el escaneo de GitHub Advanced Security extrajo el workflow vulnerable del pull request final y no marcó la inyección, que fue el «Red Agent» de Wiz rastreando la organización de GitHub de Snowflake dentro de su programa de HackerOne, que la contribución documentada del autofix fue un arreglo aparte en jira_close.yml del mismo pull request, la reducción de la condición a comparar null con una cadena, la conclusión «la intención de seguridad se puede perder cuando los patrones de código más seguros no se imponen de forma explícita» y la actualización posterior del mismo día («Copilot fue co-autor, revisó el pull request fusionado y el cambio, y lo dio por bueno sin ver las vulnerabilidades críticas… no está claro si el cambio lo asistió una IA»), del análisis de Wiz publicado el 17 de agosto de 2026. Verificados directamente contra el repositorio público, no contra la cobertura: el título del pull request 1218, su apertura el 7 de agosto de 2025 y su fusión el 18 de junio de 2026, los dos ficheros tocados, sus cuatro commits, que fueron dos cuentas distintas las que lo abrieron y lo fusionaron, el contenido de jira_issue.yml antes y después del cambio (incluidos los dos sed, que ya estaban antes, y el jq --arg), la condición if: completa —que es del job y tiene un segundo brazo para issue_comment que hemos omitido por claridad—, el diff del commit 6d0e2fa («copilot suggestion»), que mueve los secretos de dentro del run: a env: en jira_close.yml y es el único fichero que toca, y la línea Co-authored-by: Copilot Autofix powered by AI del commit 4a1b8ce. La cita «para scripts en línea, el enfoque preferido para manejar entrada no confiable es asignar el valor de la expresión a una variable de entorno intermedia», el ejemplo con github.event.pull_request.title y el aviso de que el ejecutor tiene acceso a todos los secretos del repositorio, de la guía de uso seguro de GitHub Actions; la tabla de conversión a número (null a 0, cadena que no sea un número JSON válido a NaN) y el hecho de que esa página solo documenta el caso de las comparaciones relacionales, de la referencia de expresiones; y las frases sobre que las sugerencias del autofix pueden no remediar la vulnerabilidad o introducir vulnerabilidades nuevas y requieren revisión y aceptación explícitas, de la nota de uso responsable de Copilot Autofix (GitHub). La declaración de Snowflake, de la cobertura de SC Media. Son nuestros: el cálculo de los diez meses y once días que el pull request estuvo abierto, la observación de que los sed ya estaban y de por qué dejaron de servir, la lectura de que la condición parece escrita para otro evento, la lista de comprobaciones, la posición sobre no prohibir los asistentes y la distinción entre recomendar e imponer. Las citas en castellano de textos originalmente en inglés son traducción nuestra. No atribuimos la línea vulnerable a ninguna persona ni a ninguna herramienta: el reparto exacto entre modelo y autor no consta públicamente, y la única aportación del asistente que sí consta en este pull request va en el sentido contrario.
¿Qué permisos tiene lo que automatizas?
En los proyectos de automatización e IA que montamos, lo primero que se decide no es qué modelo se usa: es qué puede tocar cada paso y con qué credencial. Y en datos y aplicaciones, revisar el pipeline que despliega es tan parte del trabajo como revisar la aplicación que despliega.
Hablar con everyWAN