Aikido

Le 10 regole della NASA per la scrittura di codice critico per la sicurezza

Introduzione

Il software critico per la sicurezza, come quello utilizzato nei veicoli spaziali o nei sistemi automobilistici, richiede un codice estremamente affidabile. Per far fronte a questa esigenza, nel 2006 il Jet Propulsion Laboratory della NASA ha creato le regole di programmazione
” ‍“Power of 10”. Queste linee guida concise eliminano i complessi costrutti del linguaggio C, difficili da analizzare, e garantiscono che il codice rimanga semplice, verificabile e affidabile.

Oggi, strumenti come Aikido Code Quality possono essere configurati con controlli personalizzati per garantire il rispetto di tutte e dieci le regole su ogni nuova pull request. In questo articolo spieghiamo ciascuna regola, perché è importante, e forniamo esempi di codice che illustrano sia gli approcci errati che quelli corretti.

Perché queste regole sono importanti

Le regole della NASA si concentrano su leggibilità, analizzabilità e affidabilità, caratteristiche essenziali per applicazioni mission-critical quali il controllo dei veicoli spaziali e il software di volo. Vietando costrutti C poco chiari e imponendo controlli difensivi, le linee guida facilitano la revisione del codice e la verifica della sua correttezza. Esse integrano standard come MISRA C affrontando modelli che gli analizzatori statici spesso trascurano. Ad esempio, evitare la ricorsione e l’uso della memoria dinamica mantiene prevedibile l’utilizzo delle risorse, mentre l’applicazione di controlli sui valori di ritorno aiuta a individuare molti bug in fase di compilazione.

Infatti, lo studio della NASA su un sistema embedded destinato al mercato di massa, come il firmware dell’acceleratore elettronico della Toyota, ha individuato centinaia di violazioni delle regole. Ciò dimostra che i progetti reali spesso si imbattono negli stessi problemi che queste regole sono state concepite per prevenire. Ogni regola dell’elenco previene una classe di errori comuni (cicli incontrollati, dereferenziazioni di puntatori nulli, effetti collaterali invisibili, ecc.).  Ignorarle può portare a sottili errori di esecuzione, falle di sicurezza o comportamenti non deterministici. Al contrario, attenersi a tutte e dieci le regole rende la verifica statica molto più gestibile.

Gli strumenti automatizzati sono importanti. Le piattaforme per la qualità del codice possono essere configurate per individuare costrutti o schemi non consentiti. Queste regole vengono applicate automaticamente su ogni pull request, individuando eventuali problemi prima che il codice venga integrato.

Collegare il contesto alle regole

Prima di addentrarci nelle singole regole, è importante comprendere il contesto:

  • Linguaggio di destinazione: Le regole “Power of 10” della NASA sono state scritte per il C, un linguaggio che gode di un ampio supporto da parte degli strumenti (compilatori, analizzatori, debugger), ma che è anche noto per i suoi comportamenti indefiniti. Esse non prevedono né la garbage collection né una gestione avanzata della memoria. Utilizzando solo un codice C semplice e ben strutturato, è possibile avvalersi dell’analisi statica per dimostrare le proprietà del programma.
  • Analisi statica: esistono numerose regole volte a semplificare i controlli automatici. Ad esempio, vietare la ricorsione (Regola 1) e richiedere i limiti dei cicli (Regola 2) consente agli strumenti di verificare il numero di iterazioni o l’utilizzo dello stack di una funzione. Allo stesso modo, vietare le macro complesse e limitare i puntatori (Regole 8–9) rende espliciti i modelli di codice, anziché nasconderli nella “magia” del preprocessore o in indirezioni multiple.
  • Flusso di lavoro di sviluppo: nelle moderne pipeline DevSecOps, queste regole diventano parte integrante dei controlli CI. Gli strumenti per la qualità del codice possono integrarsi con GitHub, GitLab o Bitbucket per esaminare ogni pull request e individuare sia semplici problemi che schemi più complessi. È possibile creare una regola personalizzata per ciascuna linea guida della NASA, ad esempio “segnalare qualsiasi uso di goto o di chiamate ricorsive a funzioni” oppure “assicurarsi che ogni ciclo abbia un limite letterale”. Una volta configurate, queste regole vengono applicate automaticamente in ogni futura scansione del codice, individuando tempestivamente le violazioni e fornendo indicazioni su come risolverle.

In sintesi, le 10 regole della NASA incarnano un approccio alla programmazione in C improntato alla sicurezza e all’analizzabilità. Di seguito elenchiamo ciascuna regola, mostriamo esempi di codice corretto e scorretto e spieghiamo perché la regola esiste e quali rischi mitiga.

Le 10 regole di programmazione della NASA

1. Evitare flussi di controllo complessi.

Non utilizzare goto, setjmp o longjmp; evitare di scrivere funzioni ricorsive in qualsiasi parte del codice.

‍❌ Esempionon conforme

// Non-compliant: recursive function call
int factorial(int n) {
    if (n <= 1) return 1;
    return n * factorial(n-1);   // recursion (direct)
}

Esempio conforme (utilizza un ciclo)

// Compliant: uses an explicit loop instead of recursion
int factorial(int n) {
    int result = 1;
    for (int i = n; i > 1; --i) {
        result *= i;
    }
    return result;
}

Perché è importante:la ricorsione e i "goto" creano un flusso di controllo non lineare difficile da analizzare. Le chiamate ricorsive rendono il grafico delle chiamate ciclico e la profondità dello stack illimitata; i "goto" generano codice "spaghetti". Utilizzando cicli semplici e codice lineare, un analizzatore statico può verificare facilmente l'utilizzo dello stack e i percorsi del programma. La violazione di questa regola potrebbe portare a overflow dello stack imprevisti o a percorsi logici difficili da esaminare manualmente.

2. I cicli devono avere un limite superiore fisso.

Ogni ciclo dovrebbe avere un limite verificabile in fase di compilazione.

Esempio non conforme (ciclo senza limite):

// Non-compliant: loop with dynamic or unknown bound
int i = 0;
while (array[i] != 0) {
    doSomething(array[i]);
    i++;
}

Esempio conforme (ciclo a intervallo fisso):

// Compliant: loop with explicit fixed upper bound and assert
#define MAX_LEN 100
for (int i = 0; i < MAX_LEN; i++) {
    if (array[i] == 0) break;
    doSomething(array[i]);
}

Perché è importante: I cicli senza limiti possono continuare all'infinito o superare i limiti delle risorse.  Con un limite fisso, gli strumenti possono verificare in modo statico il numero massimo di iterazioni. Nei sistemi critici per la sicurezza, l’assenza di un limite potrebbe causare un ciclo fuori controllo. Imponendo un limite esplicito (o una dimensione statica dell’array), garantiamo che i cicli terminino in modo prevedibile. Senza questa regola, un errore nella logica del ciclo potrebbe non essere rilevato fino alla distribuzione (ad esempio, un errore “off-by-one” che causa un ciclo infinito).

3. Nessuna memoria dinamica dopo l'inizializzazione.

Evitare l'uso di malloc/free o qualsiasi utilizzo dell'heap nel codice in esecuzione; utilizzare esclusivamente l'allocazione fissa o su stack.

Esempio non conforme (utilizza malloc)

// Non-compliant: dynamic allocation inside the code
void storeData(int size) {
    int *buffer = malloc(size * sizeof(int));
    if (buffer == NULL) return;
    // ... use buffer ...
    free(buffer);
}

Esempio conforme (allocazione statica)

// Compliant: fixed-size array on stack or global
#define MAX_SIZE 256
void storeData() {
    int buffer[MAX_SIZE];
    // ... use buffer without dynamic alloc ...
}

Perché è importante: L'allocazione dinamica della memoria durante l'esecuzione può portare a comportamenti imprevedibili, frammentazione della memoria o errori di allocazione, specialmente in sistemi con risorse limitate come i veicoli spaziali o i controllori embedded. Se malloc o free falliscono nel corso della missione, il software potrebbe bloccarsi o comportarsi in modo imprevedibile. L'utilizzo esclusivo di memoria di dimensione fissa o allocata nello stack garantisce un comportamento deterministico, semplifica la validazione e previene le perdite di memoria durante l'esecuzione.

4. Le funzioni stanno tutte in una pagina (~60 righe).

Cerca di mantenere ogni funzione breve (circa ≤ 60 righe).

Esempio non conforme

// Non-compliant: hundreds of lines in one function (not shown)
void processAllData() {
    // ... imagine 100+ lines of code doing many tasks ...
}

Esempio conforme (funzioni modulari)

// Compliant: break the task into clear sub-functions
void processAllData() {
    preprocessData();
    analyzeData();
    postprocessData();
}
void preprocessData() { /* ... */ }
void analyzeData()   { /* ... */ }
void postprocessData(){ /* ... */ }

Perché è importante: Le funzioni estremamente lunghe sono difficili da comprendere, testare e verificare nel loro insieme. Limitando ogni funzione a un unico compito concettuale (e a una pagina stampata), le revisioni del codice e i controlli statici diventano più gestibili. Se una funzione si estende su troppe righe, è possibile che sfuggano errori logici o condizioni al contorno. Suddividere il codice in funzioni più piccole migliora la chiarezza e rende più facile far rispettare altre regole (come la densità delle asserzioni e i controlli sui valori di ritorno per ogni funzione).

5. Utilizzare almeno due istruzioni `assert` per ogni funzione.

Ogni funzione dovrebbe eseguire controlli di sicurezza.

Esempio non conforme (assenza di asserzioni):

int get_element(int *array, size_t size, size_t index) {
return array[index];
}

Esempio conforme (con asserzioni):

int get_element(int *array, size_t size, size_t index) {
    assert(array != NULL);        // Assertion 1: pointer validity
    assert(index < size);          // Assertion 2: bounds check
    
    if (array == NULL) return -1;  // Recovery: return error
    if (index >= size) return -1;  // Recovery: return error
    
    return array[index];
}

Perché è importante: Le asserzioni costituiscono la prima linea di difesa contro le condizioni non valide. La NASA ha riscontrato che una maggiore densità di asserzioni aumenta significativamente la probabilità di individuare i bug. Con almeno due asserzioni per funzione (che verificano precondizioni, limiti e invarianti), il codice documenta autonomamente i propri presupposti e segnala immediatamente le anomalie durante i test. Senza asserzioni, un valore inatteso potrebbe propagarsi silenziosamente, causando un errore lontano dalla fonte dell'errore stesso.

6. Dichiarare i dati con un ambito il più ristretto possibile.

Mantenere le variabili il più possibile locali; evitare le variabili globali.

Esempio non conforme (dati globali):

// Non-compliant: global variable visible everywhere
int statusFlag;
void setStatus(int f) {
    statusFlag = f;
}

Esempio conforme (ambito locale):

// Compliant: local variable inside function
void setStatus(int f) {
    int statusFlag = f;
    // ... use statusFlag only here ...
}

Perché è importante: Ridurre al minimo l'ambito riduce l'accoppiamento e le interazioni indesiderate. Se una variabile è necessaria solo all'interno di una funzione, dichiararla a livello globale comporta il rischio che altro codice la modifichi in modo imprevisto. Mantenendo i dati a livello locale, ogni funzione diventa più autonoma e priva di effetti collaterali, il che semplifica l'analisi e il collaudo. Le violazioni (come il riutilizzo dello stato globale) possono portare a bug difficili da individuare a causa di aliasing o modifiche impreviste.

7. Verificare tutti i valori di ritorno e i parametri delle funzioni.

Chi chiama la funzione deve verificare ogni valore di ritorno non nullo; ogni funzione deve verificare la validità dei propri parametri di input.

❌ Esempio non conforme (ignora il valore restituito)

int bad_mission_control(int velocity, int time) {
    int distance;
    calculate_trajectory(velocity, time, &distance);  // Didn't check!
    return distance;  // Could be garbage if calculation failed
}

Esempio conforme

int good_mission_control(int velocity, int time) {
    int distance;
    int status = calculate_trajectory(velocity, time, &distance);
    
    if (status != 0) {  // Checked the return value
        return -1;  // Propagate error to caller
    }
    
    return distance;  // Safe to use
}

Perché è importante: Ignorare i valori di ritorno o i parametri non validi è una delle principali fonti di bug. Ad esempio, non verificare malloc potrebbe portare a un dereferenziamento di un puntatore nullo. Allo stesso modo, non convalidare gli input (ad esempio indici di array o stringhe di formato) può causare overflow del buffer o arresti anomali. La NASA richiede che ogni valore di ritorno venga gestito (o esplicitamente convertito in void per segnalare l'intenzione) e che ogni argomento venga verificato. Questo approccio onnicomprensivo garantisce che nessun errore venga ignorato silenziosamente.

8. Limitare l'uso del preprocessore alle inclusioni e alle macro semplici.

Evita macro complesse o espedienti di compilazione condizionale.

Esempio non conforme (macro complessa):

#define DECLARE_FUNC(nome) void func_##nome(void)

DECLARE_FUNC(init);  // Si espande in: void func_init(void)

Esempio conforme (macro semplici / inline):

// Compliant: use inline function or straightforward definitions
static inline int sqr(int x) { return x*x; }
#define MAX_BUFFER 256

Perché è importante: Le macro complesse (in particolare quelle su più righe o simili a funzioni) possono nascondere la logica, confondere il flusso di controllo e ostacolare l'analisi statica. Limitare il preprocessore a compiti banali (ad esempio costanti e intestazioni) mantiene il codice esplicito. Ad esempio, sostituire le macro con funzioni inline migliora il controllo dei tipi e la debuggabilità. Senza questa regola, sottili bug di espansione delle macro o errori di compilazione condizionale potrebbero sfuggire alle revisioni senza essere notati.

9. Limitare l'uso dei puntatori.

Limitare l'indirezione a un unico livello: evitare i puntatori di tipo int** e i puntatori a funzione.

Esempio non conforme (indirezione multipla):

// Non conforme: doppio puntatore e puntatore a funzione
int **doublePtr;
int (*funcPtr)(int) = someFunction;

Esempio conforme (puntatore singolo):

// Conforme: puntatori a livello singolo, nessun puntatore a funzione
int *singlePtr;
// Utilizzare una chiamata esplicita invece del puntatore alla funzione
int result = someFunction(5);

Perché è importante: La presenza di più livelli di puntatori e puntatori a funzione complica il flusso dei dati e rende difficile tracciare a quale memoria o codice si sta accedendo. Gli analizzatori statici devono risolvere ogni indirezione, cosa che in generale può risultare indecidibile. Limitando i riferimenti a un singolo puntatore, il codice rimane più semplice e sicuro. La violazione di questa regola può portare a aliasing poco chiari (un puntatore che modifica i dati tramite un altro) o a comportamenti imprevisti delle callback, entrambi rischiosi in contesti critici per la sicurezza.

10. Compilare con tutti gli avvisi abilitati e correggerli.

Abilitare tutti gli avvisi del compilatore e risolverli prima del rilascio.

Esempio non conforme (codice con avvisi)

// Non-compliant: code that generates warnings (uninitialized, suspicious assignment)
int x;
if (x = 5) {  // bug: should be '==' or initialize x
    // ...
}
printf("%d\n", x);  // warning: 'x' is used uninitialized

Esempio conforme (compilazione senza errori)

// Compliant: initialize variables and use '==' in condition
int x = 0;
if (x == 5) {
    // ...
}
printf("%d\n", x);

Perché è importante: Gli avvisi del compilatore spesso segnalano bug reali (come variabili non inizializzate, discrepanze di tipo o assegnazioni involontarie). La regola della NASA impone che nessun avviso venga ignorato. Prima di qualsiasi rilascio, il codice deve compilarsi senza avvisi con le impostazioni di verbosità al massimo. Questa pratica consente di individuare tempestivamente molti errori banali. Se un avviso non può essere risolto, il codice deve essere ristrutturato o documentato in modo che l’avviso non si verifichi mai.

Ciascuna di queste regole elimina una categoria di errori nascosti. Se applicate nel loro insieme, rendono il codice C molto più prevedibile e verificabile.

Conclusione

Le 10 regole della NASA (il “Power of 10”) forniscono uno standard di programmazione chiaro ed efficace per il software critico in linguaggio C. Evitando costrutti complessi e imponendo controlli, riducono la probabilità di bug nascosti e rendono fattibile l’analisi statica. Nello sviluppo moderno, queste linee guida possono essere automatizzate tramite strumenti di controllo della qualità del codice. È possibile definire regole personalizzate per segnalare qualsiasi violazione delle linee guida della NASA; tali regole possono essere eseguite su ogni pull request, fornendo un feedback immediato agli sviluppatori.

L'adozione tempestiva di questi controlli garantisce un codice più sicuro, di qualità superiore e più facile da mantenere. Anche al di fuori del settore aerospaziale, i principi rimangono validi: funzioni piccole e chiare, cicli espliciti, programmazione difensiva e nessuna acrobazia con i puntatori da far rizzare i capelli. Seguire e automatizzare queste regole con uno strumento per la qualità del codice aiuta il vostro team a individuare gli errori in fase precoce e a distribuire software più affidabile.

Domande frequenti

Hai delle domande?

Le norme della NASA valgono solo per i progetti spaziali o anche per quelli embedded?

Niente affatto. Queste regole hanno avuto origine in un contesto in cui la sicurezza è fondamentale, ma sono facilmente generalizzabili. Qualsiasi progetto in C che dia importanza alla manutenibilità e all’affidabilità può trarne vantaggio. Infatti, queste regole integrano gli standard del settore come MISRA C. Molti sviluppatori al di fuori della NASA hanno riscontrato che l’applicazione anche solo di una parte di queste linee guida migliora la qualità del codice.

Come posso applicare queste regole in modo automatico?

Utilizza uno strumento di analisi statica o di revisione del codice. Lo strumento Code Quality di Aikido Security ti permette di creare regole personalizzate. Puoi scrivere una piccola regola per ogni linea guida – ad esempio, una che segnali qualsiasi istruzione `goto` o qualsiasi funzione più lunga di 60 righe – e salvarla in Aikido. Aikido verifica quindi ogni nuova pull request in base alle regole personalizzate, bloccando le fusioni in caso di violazione. Il tutto si integra perfettamente con GitHub/GitLab/Bitbucket ecc.

Perché devo evitare l'uso della memoria dinamica e della ricorsione?

Gli allocatori di memoria dinamici (come malloc) possono fallire o comportarsi in modo imprevedibile, mentre la ricorsione non gestita rende illimitato l’utilizzo dello stack. Nei software critici, spesso è necessario dimostrare i limiti delle risorse e gestire i casi peggiori. Impedendo l’uso di malloc e della ricorsione in fase di esecuzione, si garantisce che tutta la memoria e la profondità delle chiamate siano note in anticipo. Ciò previene bug classici come perdite di memoria, overflow o overflow dello stack, che sono particolarmente pericolosi quando sono in gioco vite umane o apparecchiature del valore di svariati milioni di dollari.

E se il mio progetto dovesse infrangere una di queste regole?

Le linee guida della NASA sono rigorose per definizione. Se è necessario discostarsene (ad esempio, utilizzando un buffer dinamico di piccole dimensioni), è opportuno farlo in modo consapevole: documentare l’eccezione, giustificarla ed eventualmente aggiungere controlli in fase di esecuzione. Alcuni team scelgono di considerare alcune regole come avvisi anziché come errori, ma l’approccio più sicuro consiste nel rifattorizzare il codice per renderlo conforme. Le regole della NASA sono prudenti, ma è proprio per questo che funzionano. Se si utilizza Aikido o un altro strumento, è possibile contrassegnare una regola come a bassa priorità, ma è comunque preferibile risolvere il problema di fondo.

L'Aikido è in grado di distinguere le violazioni delle norme della NASA da altre questioni?

Sì. Le regole di Aikido sono personalizzabili e possono essere contrassegnate con tag. È possibile assegnare alle regole personalizzate nomi come “Regola NASA 1”, “Regola NASA 2” e così via, in modo che le violazioni indichino chiaramente quale linea guida sia stata infranta. Aikido tiene inoltre traccia delle metriche nel tempo, consentendo di visualizzare indicatori quali il “tasso di conformità alle regole NASA” nell’intero codice sorgente. Questa tracciabilità aiuta i team a stabilire le priorità nelle correzioni e a dimostrare la conformità durante gli audit.

Metti in sicurezza ora

Metti in sicurezza il tuo codice, il cloud e il runtime in un unico sistema centralizzato.
Trova e risolvi le vulnerabilità rapidamente e automaticamente.

Nessuna carta di credito richiesta | Risultati della scansione in 32 secondi.