Tornar al Blog

Cinc dies, el títol d'un issue i un token de Jira

Cinc dies, el títol d'un issue i un token de Jira

Per robar a Snowflake un token de Jira amb accés de lectura als seus projectes d'enginyeria, compliment de seguretat i programa de recompenses no va caldre cap credencial. Va caldre obrir un issue en un repositori públic seu i escriure l'exploit al títol.

Ho va explicar Wiz el 17 d'agost i la història s'ha resumit arreu amb el mateix titular: una IA va trencar el codi de Snowflake. És un bon titular i és aproximadament cert, però el diff explica una cosa més avorrida i força pitjor. Avís previ: nosaltres no venem GitHub, ni Copilot, ni cap eina que surti en aquest post. Sí que operem el nostre propi pipeline —i ja hem escrit sobre els seus deutes tècnics—, així que llegiu-ho amb això al cap.

Un workflow que obre tiquets

El repositori és snowflakedb/snowflake-connector-net, el connector .NET de Snowflake, públic. A dins hi ha un fitxer .github/workflows/jira_issue.yml que fa una cosa que hem muntat gairebé tots alguna vegada: quan algú obre un issue a GitHub, crea automàticament el tiquet corresponent al Jira intern. Per fer-ho el workflow necessita un token de Jira, i el token és on ha estat sempre: als secrets del repositori.

El 18 de juny del 2026 es va fusionar el pull request 1218, titulat «SNOW-2069227 : Update jira workflows». Feia des del 7 d'agost del 2025 que era obert: deu mesos i onze dies. Tocava dos fitxers, i que el lector es guardi aquest detall perquè és el nus de tot plegat. Abans del canvi, el títol de l'issue viatjava així (resumim el bloc, que és més llarg):

env:
  ISSUE_TITLE: ${{ github.event.issue.title }}
run: |
  TITLE=$(echo "$ISSUE_TITLE" | sed 's/"/\\"/g' | sed "s/'/\\\'/g")
  PAYLOAD=$(jq -n --arg issuetitle "$TITLE" '{ ... }')

I després del canvi, així:

run: TITLE=$(echo '${{ github.event.issue.title }}' | sed 's/"/\\"/g' | sed "s/'/\\\'/g")

Convé fixar-se que els dos sed ja hi eren abans. No són la novetat ni eren la defensa. El que protegia era l'altra cosa: el títol entrava com a dada en una variable d'entorn i arribava a jq com a argument. Després del canvi, el títol s'enganxa dins l'script abans que l'script existeixi, i els mateixos dos sed ja no serveixen de res: corren després que GitHub hagi substituït l'expressió, de manera que quan els toca actuar la cometa que trenca l'echo '…' ja és dins de l'script. Escapar una cosa que ja és codi no la torna a convertir en text.

El patró que es va treure no és una mania de ningú: és literalment el que recomana la documentació de GitHub sobre ús segur d'Actions. «Per a scripts en línia, l'enfocament preferit per gestionar entrada no confiable és assignar el valor de l'expressió a una variable d'entorn intermèdia», i l'exemple que hi posa és un títol que arriba de fora, github.event.pull_request.title. El canvi va substituir la recomanació del fabricant per la pràctica contra la qual avisa aquella mateixa pàgina.

El titular diu IA. L'historial diu quatre passos

Aquí convé abaixar el volum i obrir el pull request, que és públic. Són quatre commits. Un es titula, sense més adorn, «copilot suggestion» i porta la línia Co-authored-by: Copilot Autofix powered by AI. I aquell commit toca un sol fitxer, que no és el que es va trencar: és jira_close.yml, l'altre. El que hi fa és això:

       - 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/..."

És a dir: el commit atribuït a la màquina treu els secrets de dins del run: i els passa a env:. Exactament el patró que recomana GitHub, exactament el que va desaparèixer del fitxer del costat. Al mateix pull request, i en sentit contrari.

Amb la qual cosa el titular queda del revés. Qui va escriure la línia vulnerable no consta —Wiz va publicar aquell mateix dia una actualització dient que Copilot «va ser coautor, va revisar el pull request fusionat i el canvi, i el va donar per bo sense veure les vulnerabilitats crítiques», i que «no està clar si el canvi el va assistir una IA»—, i nosaltres no ho decidirem des de fora. L'única cosa que sí que consta amb nom de commit és que, en aquest pull request, el que va fer l'assistent va ser el correcte.

I el repartiment de culpes tapa el que és interessant. Entre aquella línia i producció hi va haver tres portes: la persona que va obrir el pull request, la persona diferent que va fer el merge deu mesos després i l'anàlisi estàtica de GitHub Advanced Security, que —explica Wiz— va extreure el fitxer vulnerable del pull request final i no va marcar la injecció. Tres portes, totes obertes, cap trencada. Ningú no va fer res estrany: algú va revisar un canvi de format i li va semblar un canvi de format.

La conclusió de Wiz la signem sencera: «la intenció de seguretat es pot perdre quan els patrons de codi més segurs no s'imposen de manera explícita». Aquí hi ha el verb que importa, imposar. Aquelles quatre línies d'env: eren una decisió de seguretat que depenia que qui toqués el fitxer la reconegués com a tal. Deu mesos després, ningú no la va reconèixer.

L'«if» que semblava un filtre

Hi ha un segon detall en aquell workflow que a nosaltres ens ha fet pensar més que la injecció, perquè no té res a veure amb la IA i ho hem vist en pipelines nostres i de clients. El job estava protegit per una condició que, si en traiem el braç que no ve al cas, diu això:

if: github.event_name == 'issues' &&
    github.event.pull_request.user.login != 'whitesource-for-github-com[bot]'

Es llegeix com un filtre raonable: no disparis això per al bot de torn. Però la primera meitat ja obliga que l'esdeveniment sigui issues, i en un esdeveniment de tipus issues l'objecte pull_request no existeix. La condició comprovava l'autor d'un pull request que no hi era. La documentació de GitHub explica la meitat del que passa aleshores: quan els tipus no coincideixen, per comparar-los els converteix a número, i la seva taula diu que null val 0 mentre que una cadena que no sigui un número JSON vàlid es converteix en NaN. L'altra meitat —que un 0 i un NaN no arriben a ser iguals— la doc no l'escriu: només aclareix el cas de les comparacions relacionals i remet a la definició de NaN. El resultat observat, en canvi, no admet dubte, i Wiz ho diu en una línia: la condició es redueix a comparar null amb una cadena, i això sempre és cert.

És a dir: una línia que en la revisió es llegeix com una restricció i en execució és un «sí» permanent. La nostra lectura, que no és a l'informe: això té tota la pinta de ser una condició escrita per a un altre esdeveniment i copiada a un workflow que es dispara amb un de diferent. És l'error més humà de tot el cas i l'únic que es pot buscar avui mateix als teus repositoris amb un grep.

El que es van endur no era codi

La càrrega que va fer servir Wiz cabia al títol d'un issue i feia una sola cosa: una petició sortint a un domini seu amb les variables del pas codificades en base64. De tornada van arribar el token de Jira, el correu del compte i la URL de la instància. La primera versió de la càrrega ni tan sols va funcionar —van fer servir un coixinet, que es va menjar el parèntesi de tancament i va deixar el shell esperant un final que no arribava—; l'agent va ajustar el payload i ho va tornar a provar.

Aquí hi ha el punt que ens importa de debò, i no és de GitHub ni d'IA. El token no era «un token per obrir tiquets». Pertanyia a un compte de QA i donava lectura sobre projectes d'enginyeria, de compliment de seguretat i del programa de recompenses. Ningú no li va donar aquell abast a propòsit: se li va donar el token que hi havia. Un pas d'un pipeline hereta el permís del secret que li poses al costat, i aquell permís gairebé mai no es dimensiona pel que fa el pas, sinó pel que fa l'equip. La documentació de GitHub avisa del mateix amb altres paraules: l'executor té accés a tots els secrets configurats al repositori.

Cinc dies, i no els va fixar Snowflake

La cronologia és curta. El 18 de juny entra el canvi. El 23 de juny el troben, l'exploten i el reporten; Snowflake el corregeix aquell mateix dia. El 24 de juny roten el token. Cinc dies de finestra, i els registres d'auditoria van confirmar després que en aquells cinc dies no hi va accedir ningú més: totes les consultes anòmales venien de les adreces de la prova.

Cinc dies és una resposta excel·lent. El que fa pensar és qui va fixar el rellotge. No va ser Snowflake revisant el seu codi, ni un investigador tafanejant el repositori un diumenge: va ser un agent automàtic de Wiz —«Red Agent», l'anomenen— rastrejant l'organització de GitHub de Snowflake dins del seu programa de recompenses de HackerOne. Al juliol vam escriure sobre un atacant que va fer servir un agent d'IA per a la part avorrida d'una intrusió i dèiem que la IA no havia trencat res de nou, només havia fet la feina bruta més ràpid i sense cansar-se. Això és la mateixa frase des de l'altre costat del tauler. I convé no exagerar-ho: aquí hi havia un programa de recompenses, és a dir, algú pagant perquè li mirin. El que canvia no és que existeixin els caçadors, sinó el preu de llegir-se un repositori sencer línia a línia, que ha deixat de ser una tarda de feina per ser un tràmit.

El que mirem nosaltres (i el que no farem)

Res del que segueix no és original ni difícil; és la llista curta a què ha baixat aquest cas quan l'hem passat pels nostres propis repositoris.

  • Cap expressió ${{ }} dins d'un bloc run:. A variable d'entorn i entre cometes, sempre, encara que la dada sembli inofensiva. És una regla de sintaxi, no de criteri, i per això es pot automatitzar i revisar d'un cop d'ull.
  • Un workflow que es dispara amb issues, issue_comment o pull_request_target és codi exposat a internet. Qualsevol amb un compte gratuït decideix quan s'executa i amb quina entrada. Es revisa amb aquell criteri, no amb el d'un script intern.
  • El secret es dimensiona pel pas, no per l'equip. Si el pas crea un tiquet en un projecte, la credencial crea tiquets en aquell projecte. És la part més tediosa i l'única que redueix el mal quan la resta falla.
  • Un if: que menciona un context que aquell esdeveniment no omple no és un filtre. Es comprova contra el payload real de l'esdeveniment, no llegint-lo. Mitja hora de grep pels workflows buscant condicions que parlen de pull_request en disparadors d'issues (i a l'inrevés) sol donar alguna sorpresa.
  • Un diff suggerit per una màquina es revisa igual que un d'humà. Ni més ni menys. La conclusió de Wiz és exactament aquesta, i la documentació de GitHub sobre el seu propi autofix ja ho diu sense que ningú ho pregunti: els suggeriments poden «no remeiar la vulnerabilitat subjacent o introduir vulnerabilitats noves» i requereixen revisió i acceptació explícites abans d'aplicar-se.

I el que no farem, encara que sigui la conclusió còmoda d'aquest cas: prohibir l'autofix, ni els assistents, ni els canvis suggerits per màquina. Seria treure la lliçó equivocada. Fa unes setmanes escrivíem, arran dels projectes d'agents que es cancel·len, que el problema rarament és el model i gairebé sempre la governança binària: o li dones tot o no li dones res. Aquí passa igual. Un assistent que proposa un canvi en un fitxer de documentació i un que proposa un canvi al fitxer que subjecta el token de producció no poden passar per la mateixa porta, i avui gairebé arreu passen per la mateixa.

Recomanat no és el mateix que imposat

Tornem al verb de Wiz, perquè d'allà surt l'única cosa accionable. Imposar no és el mateix que recomanar, i en aquell repositori el patró segur estava recomanat: existia, funcionava, i no hi havia absolutament res que impedís treure'l. Cap prova fallava. Cap revisor no tenia per què saber-ho. La pàgina de la documentació de GitHub que ho explica continuava intacta al seu lloc, sense que ningú tingués motiu per obrir-la.

Hi ha dues maneres d'imposar-ho, i totes dues costen poc. Una és un comentari de quatre paraules a sobre de la línia —«això evita injecció de shell»—, perquè el que s'escriu ja no el llegeix només el següent company. L'altra, més fiable, és que el mateix pipeline s'hi negui: hi ha analitzadors específics de workflows de GitHub Actions que detecten una expressió dins d'un run: i trenquen la comprovació. Un canvi així no es discuteix a la revisió, perquè no hi arriba.

Fonts (verificades el 18 d'agost del 2026): la troballa, la càrrega feta servir i la fallada del primer intent amb el coixinet, el compte [email protected] amb lectura sobre projectes d'enginyeria, compliment de seguretat i programa de recompenses, la cronologia (canvi fusionat el 18 de juny del 2026, troballa, explotació i report per HackerOne el 23 de juny, correcció de Snowflake el mateix dia, rotació del token el 24), la finestra de cinc dies, la confirmació pels registres d'auditoria que només hi van accedir les adreces de la prova, que l'escaneig de GitHub Advanced Security va extreure el workflow vulnerable del pull request final i no va marcar la injecció, que va ser el «Red Agent» de Wiz rastrejant l'organització de GitHub de Snowflake dins del seu programa de HackerOne, que la contribució documentada de l'autofix va ser un arranjament a part a jira_close.yml del mateix pull request, la reducció de la condició a comparar null amb una cadena, la conclusió «la intenció de seguretat es pot perdre quan els patrons de codi més segurs no s'imposen de manera explícita» i l'actualització posterior del mateix dia («Copilot va ser coautor, va revisar el pull request fusionat i el canvi, i el va donar per bo sense veure les vulnerabilitats crítiques… no està clar si el canvi el va assistir una IA»), de l'anàlisi de Wiz publicada el 17 d'agost del 2026. Verificats directament contra el repositori públic, no contra la cobertura: el títol del pull request 1218, la seva obertura el 7 d'agost del 2025 i la seva fusió el 18 de juny del 2026, els dos fitxers tocats, els seus quatre commits, que van ser dos comptes diferents els que el van obrir i el van fusionar, el contingut de jira_issue.yml abans i després del canvi (inclosos els dos sed, que ja hi eren abans, i el jq --arg), la condició if: completa —que és del job i té un segon braç per a issue_comment que hem omès per claredat—, el diff del commit 6d0e2fa («copilot suggestion»), que mou els secrets de dins del run: a env: a jira_close.yml i és l'únic fitxer que toca, i la línia Co-authored-by: Copilot Autofix powered by AI del commit 4a1b8ce. La cita «per a scripts en línia, l'enfocament preferit per gestionar entrada no confiable és assignar el valor de l'expressió a una variable d'entorn intermèdia», l'exemple amb github.event.pull_request.title i l'avís que l'executor té accés a tots els secrets del repositori, de la guia d'ús segur de GitHub Actions; la taula de conversió a número (null a 0, cadena que no sigui un número JSON vàlid a NaN) i el fet que aquella pàgina només documenta el cas de les comparacions relacionals, de la referència d'expressions; i les frases sobre que els suggeriments de l'autofix poden no remeiar la vulnerabilitat o introduir vulnerabilitats noves i requereixen revisió i acceptació explícites, de la nota d'ús responsable de Copilot Autofix (GitHub). La declaració de Snowflake, de la cobertura de SC Media. Són nostres: el càlcul dels deu mesos i onze dies que el pull request va estar obert, l'observació que els sed ja hi eren i de per què van deixar de servir, la lectura que la condició sembla escrita per a un altre esdeveniment, la llista de comprovacions, la posició sobre no prohibir els assistents i la distinció entre recomanar i imposar. Les cites en català de textos originalment en anglès són traducció nostra. No atribuïm la línia vulnerable a cap persona ni a cap eina: el repartiment exacte entre model i autor no consta públicament, i l'única aportació de l'assistent que sí que consta en aquest pull request va en sentit contrari.

Quins permisos té allò que automatitzes?

Als projectes d'automatització i IA que muntem, el primer que es decideix no és quin model es fa servir: és què pot tocar cada pas i amb quina credencial. I a dades i aplicacions, revisar el pipeline que desplega és tan part de la feina com revisar l'aplicació que desplega.

Parlar amb everyWAN

Etiquetes:

Compartir:

Subscriu-te al nostre butlletí

Per rebre històries del món IT, novetats d'everyWAN i ofertes exclusives per a subscriptors, dona't d'alta a la nostra llista de correu

everyWAN
everyWAN