Salta al contenuto principale

08 - DevSecOps per Developer: SAST, DAST nel CI/CD

SAST con Semgrep, sonda DAST in-process e gate sul codice di uscita, provati su un'app Express di prova con tre difetti e la sua correzione.

Cosa ne pensi di questo articolo?

08 - DevSecOps per Developer: SAST, DAST nel CI/CD
Sicurezza WebFoto:Bibek ghoshPexels

In Breve

SAST con Semgrep, sonda DAST in-process e gate sul codice di uscita, provati su un'app Express di prova con tre difetti e la sua correzione.

La pipeline non capisce: fa domande e legge un codice di uscita

Un revisore umano non vede tutto, e non vede lo stesso tutte le volte. Una pipeline di sicurezza ripete a ogni pull request le stesse domande, e ciò che ne esce per il resto del sistema è un solo numero: il codice di uscita. Se è 0 la build continua, altrimenti si ferma. Le domande sono di due famiglie. La SAST (analisi statica) legge il codice e cerca forme pericolose; la DAST (analisi dinamica) interroga l'applicazione in esecuzione e osserva le risposte. Vedono cose diverse, e la prova che segue lo mostra su un caso concreto: il difetto che una trova e l'altra no.

Il bersaglio è un'applicazione Express di tre rotte scritta apposta, con tre difetti, e la sua versione corretta. Nessuna delle due viene avviata come server: la DAST di prova chiama la funzione dell'applicazione con light-my-request, senza porte né rete. La SAST è Semgrep 1.185.0, installato da PyPI in un ambiente virtuale temporaneo, con regole scritte nel listato e con --metrics=off, che il manuale indica per non inviare metriche d'uso. Versioni lette il 2026-11-13: Node v24.11.0, Express 5.3.0, light-my-request 6.8.0. Ogni uscita è identica in due esecuzioni consecutive.

Il bersaglio: tre difetti e la loro correzione

La prima rotta mette il parametro name dentro l'HTML senza escape (XSS riflesso, CWE-79). La seconda passa il parametro expr a eval (CWE-95): chi chiama l'endpoint esegue JavaScript sul server. La terza imposta un cookie di sessione senza attributi di protezione (CWE-1004 e CWE-614). Sono errori da manuale, scelti perché ciascuno ha un modo di essere visto diverso dal codice o dalla risposta.

import express from 'express';

export const app = express();

// 1) XSS riflesso: il parametro finisce nell'HTML senza escape
app.get('/hello', (req, res) => {
  res.send(`<h1>Ciao ${req.query.name}</h1>`);
});

// 2) Esecuzione di codice: il parametro viene valutato come JavaScript
app.get('/calc', (req, res) => {
  res.send(String(eval(req.query.expr)));
});

// 3) Cookie di sessione senza attributi di protezione
app.post('/login', (req, res) => {
  res.cookie('sid', 'abc123');
  res.json({ ok: true });
});

La versione corretta fa quattro cose, e conviene leggerle una per una. Un middleware aggiunge a ogni risposta una Content-Security-Policy restrittiva, X-Content-Type-Options: nosniff e Referrer-Policy; escapeHtml neutralizza i cinque caratteri speciali; la rotta /calc perde eval e accetta solo due interi di al massimo nove cifre, con un'espressione regolare ancorata; il cookie riceve httpOnly, secure e sameSite: 'strict'. Il valore da scegliere con più cura è sameSite: strict è il più protettivo, ma un link da un altro sito arriverà senza cookie, e se serve il rientro con sessione da link esterni si usa lax.

import express from 'express';

export const app = express();
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);

// Intestazioni di sicurezza su ogni risposta
app.use((req, res, next) => {
  res.set('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'");
  res.set('X-Content-Type-Options', 'nosniff');
  res.set('Referrer-Policy', 'no-referrer');
  next();
});

app.get('/hello', (req, res) => {
  res.send(`<h1>Ciao ${escapeHtml(req.query.name ?? '')}</h1>`);
});

// Nessun eval: solo due numeri interi e un'addizione
app.get('/calc', (req, res) => {
  const { a, b } = req.query;
  if (!/^\d{1,9}$/.test(a ?? '') || !/^\d{1,9}$/.test(b ?? '')) return res.status(400).send('parametri non validi');
  res.send(String(Number(a) + Number(b)));
});

app.post('/login', (req, res) => {
  res.cookie('sid', 'abc123', { httpOnly: true, secure: true, sameSite: 'strict', path: '/' });
  res.json({ ok: true });
});

SAST: una regola è una domanda sul codice

Una regola Semgrep descrive una forma di codice. Le tre del listato sono volutamente semplici: eval(...) dentro un gestore di rotta, res.send con un template letterale che contiene req.query.qualcosa, e res.cookie chiamato con due soli argomenti, cioè senza opzioni. La gravità (ERROR o WARNING) è una scelta di chi scrive la regola e serve poi a decidere che cosa blocca la build. Nota di onestà: la regola su eval scatta per qualsiasi eval in un gestore, non solo per quelli alimentati da dati dell'utente; per questo il messaggio dice «dentro un gestore di rotta» e non «con dati della richiesta».

rules:
  - id: eval-in-gestore-di-rotta
    languages: [javascript]
    severity: ERROR
    message: eval() dentro un gestore di rotta (CWE-95)
    patterns:
      - pattern: eval(...)
      - pattern-inside: |
          app.$METHOD($PATH, ($REQ, $RES) => { ... })
  - id: html-con-input-non-escapato
    languages: [javascript]
    severity: ERROR
    message: parametro della richiesta interpolato in una risposta HTML senza escape (CWE-79)
    pattern: $RES.send(`...${$REQ.query.$P}...`)
  - id: cookie-senza-opzioni
    languages: [javascript]
    severity: WARNING
    message: res.cookie() senza httpOnly/secure/sameSite (CWE-1004, CWE-614)
    pattern: $RES.cookie($N, $V)

Lo script chiama semgrep scan con execFileSync e stampa le segnalazioni ordinate per riga. Il dettaglio che sorprende è nel catch: senza --error Semgrep esce con 0 anche quando trova qualcosa, ma recuperare l'stdout dall'eccezione evita sorprese se un domani qualcuno aggiunge quel flag. Sulla versione vulnerabile compaiono tre segnalazioni, con riga e gravità, e sulla versione corretta zero.

import { execFileSync } from 'node:child_process';

// Analisi statica con Semgrep e regole locali (nessuna rete, nessuna telemetria)
function scan(path) {
  let out;
  try {
    out = execFileSync('semgrep', ['scan', '--config', 'rules.yml', '--metrics=off', '--disable-version-check', '--quiet', '--json', path],
      { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
  } catch (e) { out = e.stdout; }
  return JSON.parse(out).results
    .sort((a, b) => a.start.line - b.start.line)
    .map((r) => `${r.path}:${String(r.start.line).padStart(2)}  ${r.extra.severity.padEnd(7)} ${r.check_id}`);
}

for (const file of ['target/app-vuln.mjs', 'target/app-fixed.mjs']) {
  const findings = scan(file);
  console.log(`${file}: ${findings.length} segnalazioni`);
  for (const f of findings) console.log('  ' + f);
}
// Output:
// target/app-vuln.mjs: 3 segnalazioni
//   target/app-vuln.mjs: 7  ERROR   html-con-input-non-escapato
//   target/app-vuln.mjs:12  ERROR   eval-in-gestore-di-rotta
//   target/app-vuln.mjs:17  WARNING cookie-senza-opzioni
// target/app-fixed.mjs: 0 segnalazioni

Le tre righe coincidono con i tre difetti e dicono dove intervenire: è il vantaggio della SAST, che indica la riga e non richiede un'applicazione in esecuzione, quindi gira in pochi secondi su ogni pull request. Il suo limite sta in ciò che la regola riconosce. Il listato seguente scrive lo stesso XSS in tre modi: la versione con template viene segnalata, quella con concatenazione di stringhe no, e quella con il commento nosemgrep nemmeno, per costruzione, perché il commento sopprime la regola. La seconda assenza è un falso negativo vero: la regola riconosce una forma, non il significato, e per coprire anche la concatenazione bisogna scrivere un'altra regola o usare la modalità taint di Semgrep (che qui non ho usato).

import { execFileSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

// Stessa vulnerabilità XSS, scritta in tre modi. Quale vede la regola html-con-input-non-escapato?
const dir = mkdtempSync(join(tmpdir(), 'sast-var-'));
const handler = (name, body) => `app.get('/${name}', (req, res) => {\n${body}\n});\n`;
const files = {
  'template.mjs':      handler('a', "  res.send(`<h1>Ciao ${req.query.name}</h1>`);"),
  'concatenazione.mjs': handler('b', "  res.send('<h1>Ciao ' + req.query.name + '</h1>');"),
  'soppressa.mjs':     handler('c', "  // nosemgrep: html-con-input-non-escapato\n  res.send(`<h1>Ciao ${req.query.name}</h1>`);"),
};
for (const [name, src] of Object.entries(files)) writeFileSync(join(dir, name), src);

const raw = execFileSync('semgrep', ['scan', '--config', 'rules.yml', '--metrics=off', '--disable-version-check', '--quiet', '--json', dir], { encoding: 'utf8' });
const hit = new Set(JSON.parse(raw).results.map((r) => r.path.split('/').pop()));
for (const name of Object.keys(files)) console.log(name.padEnd(20), hit.has(name) ? 'segnalata' : 'NON segnalata');
rmSync(dir, { recursive: true, force: true });
// Output:
// template.mjs         segnalata
// concatenazione.mjs   NON segnalata
// soppressa.mjs        NON segnalata

Da questo discendono due abitudini. La prima: una regola nuova si prova sempre su una variante che deve sfuggire, come qui, per sapere che cosa non vede. La seconda: ogni nosemgrep è una decisione da rivedere in code review, con motivo scritto accanto, altrimenti la soppressione diventa il modo più rapido di far tornare verde la pipeline.

DAST: la stessa applicazione vista da fuori

La sonda dinamica non legge il codice: manda richieste e confronta le risposte. Invia il payload <script>alert(1)</script> e controlla se torna intatto nel corpo, chiede typeof process a /calc (un'espressione innocua, ma che riesce solo se il server la esegue), controlla la presenza di tre intestazioni di sicurezza e legge gli attributi del cookie. È un giocattolo: non crawla, non prova variazioni, non cerca vulnerabilità sconosciute. Un vero scanner come OWASP ZAP, che il suo sito presenta come scanner di applicazioni web libero e open source, lavora contro un'applicazione in rete con ben altra copertura. Qui non l'ho eseguito.

import inject from 'light-my-request';

// Sonda dinamica in-process: chiama l'applicazione, non legge il codice. Nessuna porta, nessun server.
const PAYLOAD = '<script>alert(1)</script>';
const REQUIRED_HEADERS = ['content-security-policy', 'x-content-type-options', 'referrer-policy'];

async function probe(label, app) {
  console.log(`== ${label}`);
  const xss = await inject(app, { url: '/hello?name=' + encodeURIComponent(PAYLOAD) });
  console.log('XSS riflesso   :', xss.body.includes(PAYLOAD) ? 'VULNERABILE (payload nel corpo, non escapato)' : 'ok (payload neutralizzato)');

  const rce = await inject(app, { url: '/calc?expr=' + encodeURIComponent('typeof process') });
  console.log('eval del server:', rce.body === 'object' ? 'VULNERABILE (il server ha eseguito il mio codice)' : `ok (risposta ${rce.statusCode})`);

  const missing = REQUIRED_HEADERS.filter((h) => !(h in xss.headers));
  console.log('intestazioni   :', missing.length ? 'mancano ' + missing.join(', ') : 'tutte presenti');

  const login = await inject(app, { method: 'POST', url: '/login' });
  const cookie = [login.headers['set-cookie']].flat().join(';').toLowerCase();
  const lacks = ['httponly', 'secure', 'samesite'].filter((a) => !cookie.includes(a));
  console.log('cookie di sessione:', lacks.length ? 'mancano ' + lacks.join(', ') : 'httponly, secure, samesite presenti');
}

for (const [label, file] of [['app-vuln', './target/app-vuln.mjs'], ['app-fixed', './target/app-fixed.mjs']]) {
  await probe(label, (await import(file)).app);
}
// Output:
// == app-vuln
// XSS riflesso   : VULNERABILE (payload nel corpo, non escapato)
// eval del server: VULNERABILE (il server ha eseguito il mio codice)
// intestazioni   : mancano content-security-policy, x-content-type-options, referrer-policy
// cookie di sessione: mancano httponly, secure, samesite
// == app-fixed
// XSS riflesso   : ok (payload neutralizzato)
// eval del server: ok (risposta 400)
// intestazioni   : tutte presenti
// cookie di sessione: httponly, secure, samesite presenti

La versione vulnerabile fallisce quattro controlli su quattro; la corretta li supera tutti. La differenza con la SAST è istruttiva: le intestazioni di sicurezza mancanti la SAST di prova non le ha segnalate affatto (il cookie sì), perché l'assenza di una riga di codice è difficile da descrivere con una regola, mentre nella risposta è evidente. In senso opposto la DAST dice «il server ha eseguito il mio codice» ma non in quale riga: serve poi andare a cercarla. Per questo le due analisi si sommano invece di sostituirsi.

Il gate: dal risultato al codice di uscita

Con --error Semgrep esce con 1 se ci sono segnalazioni, come indica la riga corrispondente del manuale. È il pezzo che collega l'analisi alla pipeline. Il listato lo prova sulle due versioni e usa --severity, che limita l'analisi alle regole del livello indicato, per mostrare un'adozione graduale: si comincia bloccando solo ERROR e si lasciano le altre come avviso.

import { spawnSync } from 'node:child_process';

// Quello che la pipeline vede è solo il codice di uscita: 0 la build prosegue, diverso da 0 si ferma
const gate = (path, extra = []) => spawnSync('semgrep',
  ['scan', '--config', 'rules.yml', '--metrics=off', '--disable-version-check', '--quiet', '--error', ...extra, path],
  { stdio: 'ignore' }).status;

console.log('app-vuln  , tutte le regole       -> exit', gate('target/app-vuln.mjs'));
console.log('app-fixed , tutte le regole       -> exit', gate('target/app-fixed.mjs'));
// --severity filtra le regole per gravità: con WARNING scatta solo la regola sul cookie
console.log('app-vuln  , solo severity ERROR   -> exit', gate('target/app-vuln.mjs', ['--severity', 'ERROR']));
console.log('app-vuln  , solo severity WARNING -> exit', gate('target/app-vuln.mjs', ['--severity', 'WARNING']));
// Output:
// app-vuln  , tutte le regole       -> exit 1
// app-fixed , tutte le regole       -> exit 0
// app-vuln  , solo severity ERROR   -> exit 1
// app-vuln  , solo severity WARNING -> exit 1

L'uscita mostra il vincolo principale di un gate: sulla versione corretta il codice è 0, su quella vulnerabile è 1 in entrambe le modalità, perché i difetti di gravità ERROR ci sono. Il trade-off da scegliere è la severità che blocca. Un gate che blocca tutto rischia di essere disattivato alla prima settimana di falsi positivi; un gate che non blocca mai non è un gate. Il percorso ragionevole è partire dal livello più alto, misurare quante segnalazioni sono vere e abbassare la soglia solo quando la squadra riesce a tenere il passo.

Il workflow: la stessa cosa in CI, con permessi minimi

Il file seguente è uno schema da adattare, non è stato eseguito: GitHub non è stato interpellato, e gli SHA delle azioni sono segnaposto da sostituire con quelli reali, verificati. Ho controllato solo che sia YAML valido con un parser. I punti da guardare sono quattro: i permessi di livello superiore ridotti a contents: read, le azioni fissate a uno SHA (la guida di GitHub, già citata nell'articolo sulla supply chain, raccomanda questo e indica come protezione più forte i lockfile dei workflow), la versione di Semgrep fissata e npm ci --ignore-scripts per l'installazione.

# Schema da adattare: NON eseguito in questo articolo. Gli SHA vanno sostituiti con quelli reali
# delle versioni che hai verificato; semgrep va installato in una versione fissata.
name: security
on:
  pull_request:
permissions:
  contents: read            # il GITHUB_TOKEN parte dal minimo, ogni job chiede di più solo se serve

jobs:
  sast:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@<sha-completo-di-40-caratteri>   # azione fissata a uno SHA
        with:
          persist-credentials: false
      - uses: actions/setup-python@<sha-completo-di-40-caratteri>
        with:
          python-version: '3.12'
      - run: pip install semgrep==1.185.0                       # versione fissata, non "latest"
      - run: semgrep scan --config rules.yml --metrics=off --error --severity ERROR .

  dependencies:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@<sha-completo-di-40-caratteri>
        with:
          persist-credentials: false
      - uses: actions/setup-node@<sha-completo-di-40-caratteri>
        with:
          node-version: 24
      - run: npm ci --ignore-scripts                            # lockfile rispettato, script disattivati
      - run: npm audit --audit-level=high

Il job sast riproduce il gate sul codice, e il job dependencies applica ciò che l'articolo Supply chain security ha provato con npm ci e npm audit. La scelta di non fare gate sulla DAST, qui assente, è deliberata: serve un ambiente di prova in esecuzione, e conviene aggiungerlo solo quando c'è uno staging stabile, per non rendere la pipeline lenta e instabile.

Che cosa questa prova non copre, e da dove partire

Restano fuori la scansione dei segreti, l'analisi dell'infrastruttura come codice, quella delle immagini dei container e la DAST vera: ognuna meriterebbe la sua prova, e qui sono solo nominate. Per decidere che cosa verificare, il riferimento è l'OWASP ASVS, la cui pagina indica come ultima versione stabile la 5.0.0: è un elenco di requisiti numerati con cui trasformare «sicuro» in controlli verificabili. Le regole che questa pipeline fa rispettare vengono dagli articoli precedenti: gli errori di autorizzazione e di token in API Security, quelli sul cifrare e sull'hash in Errori crittografici, l'XSS e le intestazioni in XSS, CSRF e CSP. La serie parte dall'inquadramento in OWASP Top 10 2025.

Che cosa sai fare adesso

Sai scrivere una regola SAST, provarla anche su una variante che deve sfuggire e sapere che cosa non vede. Sai scrivere una sonda dinamica che controlla le risposte di un'applicazione senza aprire porte, e spiegare perché SAST e DAST trovano difetti diversi. Sai trasformare il risultato in un codice di uscita, scegliere la severità che blocca e costruire un workflow con permessi minimi e azioni fissate. Il lavoro vero comincia dopo: tenere le regole, rivedere le soppressioni e leggere le segnalazioni prima che qualcuno le disattivi.

Domande frequenti

Qual è la differenza tra SAST e DAST?

La SAST analizza il codice senza eseguirlo e indica la riga, la DAST interroga l'applicazione in esecuzione e osserva le risposte. La prima vede difetti nel codice, la seconda difetti di configurazione e di comportamento; trovano insiemi diversi e si usano insieme.

Una regola SAST che non segnala nulla significa che il codice è sicuro?

No. Una regola riconosce una forma di codice, non il suo significato: nella prova la stessa XSS scritta con una concatenazione di stringhe invece che con un template non viene segnalata. Ogni regola va provata anche su una variante che deve sfuggire.

Come si fa fallire la pipeline su una segnalazione di Semgrep?

Con il flag --error, che secondo il manuale fa uscire il comando con codice 1 se ci sono segnalazioni. Con --severity si sceglie la gravità delle regole da eseguire, per bloccare prima solo le più gravi.

Perché fissare le azioni GitHub a uno SHA?

Perché un tag può essere spostato su altro codice, mentre uno SHA completo identifica un commit preciso. La guida di GitHub lo raccomanda e indica come protezione più forte i lockfile dei workflow, che registrano il commit di ogni azione.

Nella stessa serie trovi anche SQL injection e validazione dell'input e sessioni e cookie.

Fonti

I listati JavaScript e le regole sono stati eseguiti due volte ciascuno con uscita identica, con Semgrep in un ambiente virtuale temporaneo. Il workflow YAML non è stato eseguito (solo analizzato sintatticamente) e ZAP non è stato usato.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti