Quando un sistema sanitario o finanziario chiama un modello linguistico esterno, ogni prompt può contenere un nome, un codice fiscale, una diagnosi o degli importi. Se tutto questo esce dalla tua infrastruttura senza alcun trattamento, il problema smette di essere tecnico e diventa un rischio legale e di fiducia.
L'idea di uno strato di privacy è semplice: prima della chiamata, individuare le entità sensibili, sostituire ciascuna con un segnaposto e conservare la mappa di ritorno dentro il tuo perimetro. Il modello ragiona sui segnaposto, e la risposta viene reidratata solo dalla tua parte.
input: Maria Souza, CPF 123.456.789-09, ricoverata il 12/03
modello: [PERSONA_1], CPF [CPF_1], ricoverata il [DATA_1]Fin qui sono tutti d'accordo. Il problema è che la versione ingenua di tutto questo protegge molto meno di quanto sembri, e i buchi stanno in quattro punti.
Un token stabile è pseudonimizzazione, non anonimizzazione
La tentazione è far sì che lo stesso identificativo diventi sempre lo stesso segnaposto, in tutte le chiamate, perché il modello “si ricordi” della persona. Questo crea collegabilità: chi vede le richieste può mettere insieme tutto ciò che è passato sotto lo stesso segnaposto e costruire un profilo senza mai vedere l'identificativo. E la mappa di ritorno diventa l'asset più sensibile del sistema, perché è l'unica cosa che separa un mucchio di segnaposto da un mucchio di pazienti.
Per la LGPD, la legge brasiliana sulla protezione dei dati, come per il GDPR, un dato pseudonimizzato resta un dato personale. Smette di esserlo solo quando l'anonimizzazione non può essere invertita, e una mappa di ritorno esiste proprio per invertirla. Quindi il segnaposto non ti porta fuori dall'ambito della legge; riduce solo quello che trapela se la chiamata viene intercettata.
Non serve nemmeno sostituire la mappa con un hash. Il CPF, il codice fiscale brasiliano, ha circa 10⁹ valori possibili, e calcolare lo SHA-256 di tutti ci sta in un pomeriggio su un portatile. Un hash senza chiave di un identificativo con uno spazio di valori piccolo è solo un CPF scritto in un altro modo.
Il minimo che considero ragionevole:
- Ambito per conversazione o per tenant, mai globale. All'interno di una conversazione bastano segnaposto sequenziali (
CPF_1,CPF_2). - Quando serve coerenza tra chiamate, come per un documento elaborato a pezzi, usa un HMAC con una chiave per ambito, e ruota la chiave.
- La mappa trattata come una cassaforte: cifrata, con accesso ristretto, conservazione breve e una propria traccia degli accessi.
token = "CPF_" + base32(HMAC(chiave[tenant][conversazione], cpf))[:8]Mandare PII fuori per rilevare PII
Un consiglio comune è partire dal deterministico, con regex e validatori per i documenti, e lasciare a un LLM i casi che le regole non raggiungono. Il consiglio è giusto, con una condizione che di solito resta fuori: quel modello deve girare dentro il perimetro. Se il rilevatore è un'API esterna, mandi fuori il testo grezzo proprio per scoprire cosa non potevi mandare fuori.
In pratica, il rilevamento resta tutto dalla tua parte: regex e validatori (cifra di controllo del CPF, numero CNS della tessera sanitaria, telefono, e-mail, date), liste di entità note, un NER come quello di Presidio con un modello per il portoghese e, se serve, un modello piccolo che gira in locale. Il modello esterno vede solo testo che è già passato da tutto questo.
testo
-> regex + validatori (CPF, CNS, telefono, e-mail, date)
-> NER / modello locale, dentro il perimetro
-> generalizzazione dei quasi-identificatori
-> segnaposto con ambito + mappa nella cassaforte
-> LLM esterno
-> validazione dei segnaposto nella risposta
-> reidratazioneIn sanità, la fuga di dati sta nei quasi-identificatori
Nome e codice fiscale sono la parte facile. Quello che reidentifica davvero un paziente è la combinazione di attributi che, presi da soli, sembrano innocui. Latanya Sweeney ha mostrato, nel 2000, che CAP, data di nascita e sesso bastavano a identificare in modo univoco circa l'87% della popolazione degli Stati Uniti. In una cartella clinica in testo libero va ancora peggio, perché questi attributi arrivano mescolati a tutto il resto.
Nessuna regex intercetta “paziente di 34 anni con HIV assistito a Sorocaba”. Non c'è un nome, non c'è un documento, e nel database di un'assicurazione sanitaria regionale potrebbe essere una persona sola. È qui che sta il vero problema.
Sostituire non risolve, perché il modello ha bisogno del contenuto clinico: in un riassunto del caso, la diagnosi è l'argomento. Lo strumento giusto è generalizzare, decidendo per ogni task cosa il modello ha davvero bisogno di sapere:
| Attributo | Originale | Generalizzato |
|---|---|---|
| Età | 34 anni | da 30 a 39 anni |
| Data | ricoverato il 12/03 | [DATA_1], oppure “G+3” rispetto al ricovero |
| Città | Sorocaba | entroterra dello stato di San Paolo |
| Diagnosi | HIV | mantenuta, se il task ne ha bisogno |
E si può misurare. Se, dopo la generalizzazione, un solo paziente del database corrisponde a “da 30 a 39 anni, entroterra di San Paolo, HIV, ricoverato a marzo”, la generalizzazione non è bastata. È l'intuizione del k-anonimato applicata a ciò che resta nel testo: ogni combinazione che arriva al modello dovrebbe corrispondere a più persone, non a una sola.
Anche la reidratazione fallisce
Il ritorno è dove nessuno guarda. Il modello può cambiare il formato del segnaposto ([CPF_1] diventa CPF 1, [cpf_1] o CPF_01), inventarne uno che non esiste ([PERSONA_3] quando le persone erano solo due) o scambiare i segnaposto tra loro. Un find-and-replace ingenuo lascia un segnaposto rotto sullo schermo o, peggio, mette nella risposta il nome del paziente sbagliato.
- Segnaposto con un formato rigido e difficile da parafrasare, e un riconoscitore che trova le varianti che il modello tende a produrre.
- Ogni segnaposto nella risposta deve esistere nella mappa di quella conversazione. Un segnaposto sconosciuto significa rifiuto o nuova generazione, mai tirare a indovinare.
- Un segnaposto di un'altra conversazione o di un altro tenant non si risolve mai, anche se esiste nella cassaforte.
- Dopo la reidratazione, nel testo non deve restare nessun segnaposto.
for token in find_tokens(risposta):
if token not in mappa_conversazione:
raise TokenSconosciuto(token)Misura quello che sfugge
Un mascheramento che nessuno testa è solo una sensazione di sicurezza. Tieni un insieme di casi sintetici che somiglino a quelli reali, compresi quasi-identificatori in testo libero, misura la recall del rilevatore per tipo di entità e conta quello che arriva al modello esterno. La metrica che conta è quello che è trapelato, non quello che è stato intercettato.
Questo va di pari passo con Il prompt non è un confine di sicurezza: uno strato si occupa di ciò che il modello vede, l'altro di ciò che riesce a fare.