Tests de sécurité automatisés

Les chaînes de CI/CD permettent une industrialisation de la production logicielle en testant, packageant et déployant les applications automatiquement. Dans ces chaînes automatiques, l’aspect sécurité est régulièrement mis de côté et les tests associés sont bien souvent manuels.

Les tests automatiques de sécurité peuvent être catégorisés en 2 familles, le SAST (Static Application Security Testing) qui analyse de manière statique le code produit par les développeurs ou les fichiers de configuration paramétrés par les administrateurs système alors que le DAST (Dynamic Application Security Testing) fait des tests sur une application ou un serveur déployé et accessible. Si le SAST apparait en début de chaîne, sur le poste des développeurs ou sur l’outil d’intégration continue, le DAST intervient quant à lui en fin de chaîne lorsque l’application a été packagée et déployée sur un environnement.

Les outils présentés ici sont dans la catégorie DAST et permettent principalement de valider la non-régression sur certains points spécifiques (ports ouverts, qualité de la configuration TLS, protection contre Heartbleed, etc.). Il ne s’agit pas d’outils de “pentest automatique” se substituant à un pentesteur en recherchant des SQL injections, XSS, etc (même si des outils comme Arachni ou sqlmap sont intégrables).

Les 2 outils présentés sont Gauntlt et RobotFramework.

GAUNTLT

Gauntlt est un outil open source créé en 2012 permettant d’écrire des tests de sécurité grâce au langage Gherkin interprété par l’outil de test Cucumber. Gauntlt fait l’interface entre les outils de sécurité classiques (nmap, sslyze, etc.) et l’outil de test. Les outils de sécurité ne sont pas intégrés à gauntlt et doivent être installés sur la machine exécutant les tests.

Même si le projet ne semble plus très actif, les éléments de base présents sont suffisants pour mettre en place des tests de sécurité.

Gauntlt propose par défaut des adaptateurs pour les outils suivants :

Fonctionnement

Ecriture d’un test

L’exemple suivant utilise l’adaptateur nmap pour vérifier que le port 80 est ouvert sur scanme.nmap.org.

Etapes :

  • Vérifier que nmap est installé sur la machine
  • Définir les variables (hostname = scanme.nmap.org)
  • Définir un scenario de test
    • Décrire la commande nmap à lancer
    • Vérifier le résultat de la commande avec une expression régulière
@slow
Feature: simple nmap attack (sanity check)

  Background:
    Given "nmap" is installed
    And the following profile:
      | name     | value      |
      | hostname | scanme.nmap.org |

  Scenario: Verify server is available on standard web port
    When I launch an "nmap" attack with:
      """
      nmap -p 80 <hostname>
      """
    Then the output should match /80.tcp\s+open/

Ce test peut être sauvegardé dans un fichier ayant pour extension .attack.

A l’exécution du binaire gauntlt, l’outil joue les tests de tous les fichiers .attack présents dans le dossier courant. Il est également possible de spécifier un fichier directement en argument.

Resultat

En cas d’erreur :

Echec

Utilisation des alias

Cette première méthode de test a pour inconvénient de devoir spécifier pour chaque test la ligne de commande de l’outil sous-jacent (ici nmap). Pour rendre l’écriture de tests plus intuitive et simple, gauntlt propose des alias intégrants directement et à un seul endroit la ligne de commande de l’outil à utiliser.

Le 1er test peut par exemple évoluer en :

@slow
Feature: simple nmap attack

  Background:
    Given "nmap" is installed
    And the following profile:
      | name     | value      |
      | host     | scanme.nmap.org |
      | port     | 80         |

  Scenario: Test
    When I launch a "nmap-single_port" attack
    Then the output should match /80.tcp\s+open/

Explications :

  • nmap-single_port est un alias permettant d’exécuter la commande nmap -p<port> <host>.
  • <port> et host sont des variables à définir avant l’appel de l’alias

Les alias définis par défaut sont disponibles à l’adresse suivante : https://github.com/gauntlt/gauntlt/tree/master/lib/gauntlt/attack_aliases

Création d’alias

De nouveaux alias peuvent être créés en modifiant le fichier json correspondant à nmap ./lib/gauntlt/attack_aliases/nmap.json.

Par exemple :

"nmap-check_80_port" : {
    "command" : "nmap -p80 <host>",
    "description" : "Check the HTTP default port (80)",
    "requires" : [ "<host>" ]
}

Pour les alias, gauntlt prend en compte tous les fichiers json situés dans le dossier /lib/gauntlt/attack_aliases.

Création d’adaptateur

Pour ajouter le support d’un outil dans gauntlt, il faut décrire la syntaxe d’appel à l’outil de sécurité. Les adaptateurs sont situés dans le dossier ./lib/gauntlt/attack_adapters.

Dans l’exemple suivant, le support de l’outil ssllabs-scan est ajouté en créant le fichier suivant ./lib/gauntlt/attack_adapters/ssllabs.rb.

When /^"ssllabs" is installed$/ do
  ensure_cli_installed("ssllabs-scan")
end

When /^I launch (?:a|an) "ssllabs" attack with:$/ do |command|
  run_with_profile command
end

When /^I launch (?:a|an) "ssllabs-(.*?)" attack$/ do |type|
  attack_alias = 'ssllabs-' + type
  ssllabs_attack = load_attack_alias(attack_alias)                

  Kernel.puts "Running a #{attack_alias} attack. This attack has this description:\n #{ssllabs_attack['description']}"

  run_with_profile ssllabs_attack['command']
end

Sont définis ici :

  • La syntaxe et le test à faire pour vérifier que ssllabs-scan est bien installé
  • La syntaxe pour exécuter une commande simple avec ssllabs-scan
  • La syntaxe pour utiliser un alias

Un alias est ensuite créé dans ./lib/gauntlt/attack_aliases/ssllabs.json pour ne récupérer que la note finale de l’analyse (entre F et A) :

{
  "ssllabs-grade": {
    "command": "ssllabs-scan --quiet --grade <host>",
    "description": "Give SSL grade based on Qualys SSL Labs",
    "requires": [
      "<host>"
    ]
  }
}

Enfin, pour utiliser l’adaptateur, le fichier d’attaque suivant est créé :

@slow
Feature: SSL Grade

Background:
Given "ssllabs" is installed
And the default aruba timeout is 360 seconds
And the following profile:
| name     | value     |
| host     | google.fr |

Scenario: Test with alias
When I launch a "ssllabs-grade" attack
Then the output should match /"A"[\r\n](.*)"A"/

Scenario: Test without alias
When I launch a "ssllabs" attack with:
    """
    ssllabs-scan --quiet --usecache --grade <host>
    """
Then the output should match /"A"[\r\n](.*)"A"/

A noter :

  • Le but du test est de vérifier que le site testé à la note A
  • 2 scenarios sont définis, le 1er utilise l’alias créé alors que le 2ème utilise le mode classique
  • L’analyse par ssllabs-scan est assez longue et nécessite l’augmentation du timeout à 360 secondes.
  • Le site testé (google.fr) dispose d’une ipv4 et d’une ipv6, ssllabs-scan renvoie dont 2 lignes avec 2 notes

Résultat :

ssllabs

RobotFramework

RobotFramework est un outil répandu permettant d’automatiser toute sorte de tests (IHM, API, etc.) via différents modules. Les tests de sécurité ne font pas exception et sont intégrables via des modules additionnels faisant appel aux outils de sécurité traditionnels (nmap, sslyze, etc.).

La société we45 propose à travers son Github un ensemble de modules pour des outils de sécurité répandus (Sslyze, Burp, Arachni, Nikto, etc.). Le module pour nmap est quant à lui disponible ici. Comme pour gauntlt, les modules ne contiennent pas les outils de sécurité, ces derniers doivent être installés sur la machine exécutant les tests.

Attention la plupart de ces modules sont écrits pour la version 2 de python. Une petite passe de correction peut être nécessaire pour les faire tourner sous python 3.

Écriture d’un test

Par exemple, l’écriture d’un test pour le module Sslyze donne :

*** Settings ***
Library  RoboSslyze

*** Variables ***
${TARGET}  www.google.com

*** Test Cases ***
Test for SSL
    test ssl basic  ${TARGET}
    test ssl server headers  ${TARGET}

Explications :

  • Définition des modules à charger
  • Définition des variables qui seront prises en entrée des tests
  • Appels des tests (se référer à la documentation du module pour connaître les tests disponibles)

Résultat :

Sslyze

Modification d’un module

Le module nmap permet uniquement de lancer des scans (OS, ports, etc.) et renvoie le résultat de la commande dans un fichier texte situé dans le répertoire courant. Une analyse humaine est ensuite nécessaire pour interpréter les résultats. Le résultat dans Robot Framework est quant à lui toujours en succès.

La modification suivante a pour but de comparer la liste des ports ouverts retournée par la commande nmap avec une liste de ports autorisés définis dans les variables du test.

Dans le fichier RoboNmap.py, la méthode concernée par le test nmap all tcp scan est nmap_all_tcp_scan :

Remplacer :

def nmap_all_tcp_scan(self, target, file_export = None):

par

def nmap_all_tcp_scan(self, target, authorized_ports, file_export = None):

afin de pouvoir injecter la liste des ports autorisés lors de l’appel au test.

Ensuite dans le corps de la méthode, remplacer :

try:
    parsed = NmapParser.parse(nmproc.stdout)
    print(parsed)
    self.results = parsed
except NmapParserException as ne:
    print('EXCEPTION: Exception in Parsing results: {0}'.format(ne.msg))

par

try:
    parsed = NmapParser.parse(nmproc.stdout) 
    logger.info(parsed)
    self.results = parsed

    for scanned_hosts in self.results.hosts:
        for serv in scanned_hosts.services:
            port = str(serv._portid)
            if port not in authorized_ports and serv.state == 'open':
                logger.warn("Port {0} is open".format(port))

except NmapParserException as ne:
    print('EXCEPTION: Exception in Parsing results: {0}'.format(ne.msg))

Explications :

  • Les résultats obtenus par la commande nmap sont parsés
  • Ils sont comparés avec la liste de ports définis dans la variable authorized_ports
  • Seuls les ports avec l’état open et étant absents de la liste autorisée sont remontés dans un warning

Dans le fichier de test :

*** Settings ***
Library  ./RoboNmap.py
Library  Collections

*** Variables ***
${TARGET}  mydomain.local
${AUTHORIZED_PORTS}     80     443

*** Test Cases ***
Check headers
    http headers scan  ${TARGET}

Ici, seuls les ports 80 et 443 sont autorisés avec le statut open

Exécution avant la modification :

robonmap_original

Avec la modification

robonmap_custom

Pour être réellement opérationnel ce module nécessite encore quelques modifications comme une comparaison exacte avec la liste des ports autorisés.

Création d’un module additionnel

Si un module n’est pas disponible, il est possible de le créer. Dans le cas ci-dessous, l’objectif est de faire des vérifications sur les requêtes HTTP pour vérifier la présence de certains headers de sécurité (CSP, HSTS, etc.). Pour cette analyse, aucun outil externe n’est requis, le module requests de python permettant de faire des requêtes HTTP est suffisant.

Dans un fichier RoboHttp.py :

from robot.api import logger
import requests

class RoboHttp(object):
    ROBOT_LIBRARY_SCOPE = 'GLOBAL'

    def __init__(self):
        logger.info("Http initialized")
        self.results = None
    
    def http_headers_scan(self, target):
        response = requests.get('https://' + target)
    
        xssProtectionHeaderName = 'X-XSS-Protection'
        matches = [x for x in response.headers if x.lower() == xssProtectionHeaderName.lower()]
        if not any(matches):
            logger.warn('{0} header missing'.format(xssProtectionHeaderName))
        else:
            logger.info('{0} header found with value "{1}"'.format(xssProtectionHeaderName, response.headers[matches[0]]))
    
        xFrameOptionsHeaderName = 'X-Frame-Options'
        matches = [x for x in response.headers if x.lower() == xFrameOptionsHeaderName.lower()]
        if not any(matches):
            logger.warn('{0} header missing'.format(xFrameOptionsHeaderName))
        else:
            logger.info('{0} header found with value "{1}"'.format(xFrameOptionsHeaderName, response.headers[matches[0]]))
    
        cspHeaderName = 'Content-Security-Policy'
        matches = [x for x in response.headers if x.lower() == cspHeaderName.lower()]
        if not any(matches):
            logger.warn('{0} header missing'.format(cspHeaderName))
        else:
            logger.info('{0} header found with value "{1}"'.format(cspHeaderName, response.headers[matches[0]]))
    
        hstsHeaderName = 'Strict-Transport-Security'
        matches = [x for x in response.headers if x.lower() == hstsHeaderName.lower()]
        if not any(matches):
            logger.warn('{0} header missing'.format(hstsHeaderName))
        else:
            logger.info('{0} header found with value "{1}"'.format(hstsHeaderName, response.headers[matches[0]]))
    
        serverHeaderName = 'Server'
        matches = [x for x in response.headers if x.lower() == serverHeaderName.lower()]
        if any(matches):
            logger.warn('{0} header found with value "{1}"'.format(serverHeaderName, response.headers[matches[0]]))
        else:
            logger.info('{0} header missing'.format(hstsHeaderName))

Le module ne définit qu’un seul test http headers scan et donc qu’une seule méthode http_headers_scan. Cette méthode prend en entrée un paramètre représentant la cible à tester target. Le module logger importé permet de piloter le résultat du test. La méthodelogger.warn est utilisée pour renvoyer des warnings au niveau du test sans pour autant le mettre en échec.

Plus d’informations sur le module logger sont disponibles ici.

Une fois le module créé, le fichier de test associé ressemble à :

*** Settings ***
Library  ./RoboHttp.py

*** Variables ***
${TARGET}  mydomain.local

*** Test Cases ***
Check headers
    http headers scan  ${TARGET}

Résultat :

robohttp

Conclusion

L’aspect sécurité est identique entre les 2 solutions proposées puisque les outils de sécurité utilisés en arrière plan sont identiques (nmap, sslyze, etc.). D’un point de vue développement, l’approche gauntlt permet d’ajouter le support de nouveaux outils grâce à un langage naturel (Gherkin) sans requérir à des compétences en développement.


Voir également