In questa pagina

Apri un report SAST qualsiasi su una codebase in produzione. Vedrai numeri a quattro cifre accanto alla parola «critico». Riapri la stessa scansione una settimana dopo: gli stessi numeri, più o meno. Alla fine un security engineer affoga nel triage e il team rilascia comunque. Questo articolo parla di cosa stia succedendo davvero in quei report, e del perché quasi tutto quello che vedi non è ciò che dichiara di essere.
La prima volta che vedi uno strumento SAST dire «1.247 vulnerabilità», una vocina fa la domanda giusta: ci sono davvero milleduecentoquarantasette bug sfruttabili in questo repository? Sai già che la risposta è no. Quello che forse non sai è esattamente perché la risposta è no, e cosa dovrebbe essere vero perché quel numero significhi qualcosa.
Questo pezzo parla di tre filtri: raggiungibilità, sfruttabilità e logica di business. Qualsiasi strumento di analisi statica che valga la pena deve applicarli, in un ordine o nell'altro. Quasi tutti barano su almeno uno. Ecco cosa fa ciascun filtro, con codice concreto, e perché il semplice pattern matching continua a produrre report a quattro cifre un anno dopo che un report a quattro cifre fa mollare qualsiasi team.
Il 1.200 non è un bug di SAST. È il progetto.
Uno scanner SAST a pattern fa più o meno questo: parsa il sorgente, cammina l'AST e, per ogni nodo, controlla se corrisponde a una lista di forme pericolose. eval(x). exec(x). Una stringa SQL con + tra letterali. Un'assegnazione a innerHTML. Ogni corrispondenza diventa un finding.
È rapido e portabile tra linguaggi. È anche del tutto indifferente al fatto che qualcuno possa davvero innescare quella forma pericolosa. Quando lo scanner segnala 1.247 finding, quello che ti sta dicendo in realtà è: «ho trovato 1.247 pattern sintattici che potrebbero, in principio, essere pericolosi in qualche contesto di chiamata che non mi sono preso la briga di guardare».
È quest'ultima frase a reggere tutto il peso. Smontiamola.
Filtro uno: raggiungibilità
Un finding è raggiungibile quando un attaccante può davvero eseguire il codice che lo contiene.
Guarda questo handler Express.
app.post("/admin/migrate", requireAdmin, async (req, res) => {
const { sql } = req.body;
const result = await db.raw(sql); // SAST flags this
res.json(result);
});
Uno scanner a pattern segnalerà la riga db.raw(sql). SQL injection da input utente. Severità: critica. Lo scanner non sbaglia sulla forma; è quella la forma di una SQL injection. Ma per sfruttarla la richiesta deve passare da requireAdmin. Il che significa che l'attaccante deve già essere admin. Il che significa che qui non c'è una SQL injection, c'è un problema di «ci fidiamo che gli admin non spacchino il database», che è un'altra conversazione.
La raggiungibilità chiede: da qualsiasi punto d'ingresso controllabile dall'esterno (una richiesta, un messaggio di coda, un payload deserializzato), i dati possono arrivare a quella forma pericolosa? Se la risposta è no, il finding non è reale, anche se il pattern sintattico lo è.
Ci sono tre modi comuni in cui il codice è irraggiungibile in questo senso:
- Cancelli di autenticazione o autorizzazione stanno tra il punto d'ingresso e il sink. Il middleware taglia il percorso pericoloso.
- Sanificazione trasforma l'input dell'attaccante prima che arrivi al sink. Un
parseIntche solleva un errore su tutto ciò che non è cifra. Una WHERE costruita con query parametrizzata. Un framework che fa escape automatico dell'HTML. - La funzione non viene mai chiamata da nessun percorso raggiungibile. Il codice morto è una categoria reale e le codebase moderne ne sono piene. Vecchi strumenti di amministrazione. Un handler registrato su una rotta che non esiste più. Una funzione di libreria usata solo dai test.
La versione onesta di «1.247 finding» somiglia più a «di cui 380 raggiungibili da almeno un punto d'ingresso controllabile dall'esterno». Hai già tagliato la coda di due terzi e non hai ancora iniziato a guardare la sfruttabilità.
Filtro due: sfruttabilità
Raggiungibile non significa sfruttabile. Significa solo che il percorso dei dati esiste.
Prendi lo stesso esempio di db.raw, senza il middleware di auth:
app.post("/api/search", async (req, res) => {
const { q } = req.body;
const result = await db.raw(
"SELECT id, title FROM articles WHERE title LIKE ?",
[`%${q}%`]
);
res.json(result);
});
Il pattern è ancora lì. Il percorso è ancora raggiungibile da qualsiasi client non autenticato. Il finding dice ancora «SQL injection». Ma il parametro legato fa sì che il database tratti q come un valore e non come SQL. Non c'è injection, qualunque cosa contenga q. Il finding non è sfruttabile.
La sfruttabilità riguarda la forma che i dati assumono tra la sorgente e il sink. Qui contano tre cose:
- Confini di codifica. Una WHERE costruita con parametri legati è a prova di exploit sul livello SQL. Un template engine con escape automatico lo è sul livello HTML. Gli scanner a pattern non vedono sempre questi confini, perché vivono in chiamate del framework (
db.raw(template, params)) e non in pattern sintattici. - Restringimento dei tipi. Se un valore passa da un
parseInt, uno schema Joi o un type guard TypeScript prima del sink, la sua forma è vincolata. La SQL injection richiede controllo sulla stringa grezza. Un intero tipizzato non inietta SQL. - Semantica del sink.
child_process.exec("ls")non è un'injection se la stringa è un letterale.eval(JSON.stringify(x))è innocuo pur contenendo la parolaeval. Il sink è pericoloso; la chiamata no.
Dopo questo filtro il numero scende di nuovo. Da 380 finding raggiungibili puoi arrivare a 60 davvero sfruttabili. La maggior parte dei team si ferma qui, dichiara quei 60 il backlog «reale» e comincia a smistare.
È un errore. I 60 che ti restano sono bug con un CWE. I bug che fanno male non hanno sempre un CWE.
Filtro tre: logica di business
Ecco una vulnerabilità che nessuno scanner SAST può segnalare con pattern matching, per quanto sia sveglio:
app.post("/checkout", requireAuth, async (req, res) => {
const { items } = req.body;
let total = 0;
for (const it of items) {
const product = await db.products.findById(it.productId);
total += product.price * it.quantity; // it.quantity can be -1
}
await charge(req.user, total);
await fulfilOrder(req.user, items);
});
Metti quantity a -1 e il prezzo si sottrae. Impila articoli con quantità negativa contro uno positivo e il totale arriva a zero. Il charge(0) passa. L'ordine parte.
Questo non è nella OWASP Top 10. Non è un pattern CWE. Non esiste una forma sintattica che dica «le quantità negative si possono sfruttare»; la forma total += a * b è aritmetica normale. La vulnerabilità è che il business ammette quantità negative in ingresso mentre il business intende che ogni quantità sia un intero positivo. Quell'intenzione vive nella tua codebase, nelle specifiche di prodotto, in test che nessuno ha scritto. Non vive in un database di CWE.
Le falle di logica di business stanno dietro a quasi metà delle violazioni che producono perdite finanziarie reali. Accumulo di coupon. Escalation di privilegi abusando di un flusso. Race condition nelle transizioni di stato di un account. Lo scanner che ha trovato 1.247 finding sintattici si è perso tutte queste, perché guardava la forma del codice e non la sua intenzione.
Per coglierle servono regole estratte dalla codebase stessa: «questo campo è sempre un intero positivo qui, qui e qui, ma il controller non lo valida». E questo richiede leggere più dell'AST.
Quindi come si passa da 1.200 a 12?
La pipeline che produce 12 da 1.200 non è una regex più sveglia. È una forma di analisi completamente diversa. In sintesi:
Costruisci un grafo della codebase
Simboli, chiamate di funzione, flussi di tipi, cancelli del framework, binding delle rotte. Non un flusso piatto di token. Un grafo che l'analizzatore possa camminare.
Percorrilo per raggiungibilità e sfruttabilità
Da ogni punto d'ingresso, segui i dati. Fermati ai sanificatori, ai cancelli, ai restringimenti di tipo. Solo i percorsi che sopravvivono al cammino sono candidati reali.
Tira le regole di business fuori dal codice stesso
Estrai invarianti dalla codebase: questo campo è sempre positivo, questa transizione passa sempre da un pagamento, questo flusso finisce sempre in un log di audit. Ogni invariante diventa un vincolo che l'agente (o la persona) deve rispettare.
Il primo passo è quello che la maggior parte dei prodotti SAST moderni chiama «code property graph», o grafo di conoscenza. Costruirlo è difficile. Camminarlo, di più. Ma è l'unico modo onesto di rispondere «questo finding è reale?» senza costringere un security engineer a leggere tutta la tua codebase.
I 12 che sopravvivono a tutti e tre i filtri sono quelli che meritano un ticket Jira. I 1.235 che non sono sopravvissuti non sono «falsi positivi» in nessun senso morale; sono pattern sintattici che lo scanner non ha potuto escludere senza un grafo. Se il tuo scanner non ha un grafo, ti arrivano tutti e 1.247 ogni lunedì.
Perché conta più nel 2026 che nel 2022
Nel 2022 il report da 1.247 finding era fastidioso. Un security engineer lo smistava lentamente, nessuno ci godeva, ma la codebase cresceva a ritmo umano e la coda pure.
Nel 2026 la codebase cresce a ritmo di agente. Gli agenti di codice IA scrivono già una quota rilevante di ogni riga che arriva in produzione. Producono codice nei pattern che riconosce l'agente, non in quelli che riconosce il security engineer. Il numero 1.247 scala con la velocità di scrittura, non con quella dello smistamento.
Hai due opzioni. O l'analisi diventa radicalmente più sveglia (basata su grafo, filtrata per raggiungibilità, sfruttabilità e logica di business prima di emettere finding), oppure la coda ti mangia. Non esiste una terza opzione in cui una persona legge tutto.
Una piccola nota
Abbiamo costruito CybeDefend attorno a questa idea. L'architettura è un grafo di conoscenza del codice più uno strato di mining della logica di business (lo chiamiamo BLSA) più un'interfaccia inline perché l'analizzatore giri mentre l'agente scrive, non dopo il merge. Se sei arrivato fin qui e vuoi vedere gli stessi tre filtri girare sul tuo repository, il percorso senza installazione è la lettura più rapida di come è davvero il tuo backlog: trenta minuti dal clone al primo verdetto, senza carta.
Il numero sul report non è il numero di bug. È il numero di pattern che lo scanner non ha potuto escludere senza fare più lavoro. I team che vincono sono quelli il cui scanner fa quel lavoro.


