>

Analyser son code avec l’IA – Part 2: Intégration CICD

Introduction

Dans l’article précédent, l’outil Oasis (Ollama Automated Security Intelligence Scanner) a été conteneurisé et associé au pod de Ollama pour permettre de scanner du code à la recherche de failles de sécurité.

Pour rappel, Oasis exploite les modèles LLM disponibles sur Ollama pour identifier automatiquement des problématiques de sécurité potentielle au sein du code applicatif mis à dispositions dans un dossier.

L’outil se manipule à travers l’instruction oasis agrémentée d’arguments pour produire en sortie des rapports complets sur le statut du code en termes de sécurité. L’analyse à base d’IA est accélérée par l’usage d’un GPU disponible sur le nœud Kubernetes ou s’exécute Ollama.

Pour plus de détail, je vous invite à prendre connaissance du précédent article.

L’objectif est maintenant d’intégrer ce scan dans une chaîne CI initiale pour qu’il déclenche automatiquement l’analyse du code lorsqu’il sera envoyé dans un repo Git.

Pour cela nous allons capitaliser sur ce qui a été vu ici, à savoir les interactions possibles entre GitLab et Jenkins.

Je vous invite également à parcourir l’article dédié au sujet, car nous allons repartir de ce dernier en partant du principe que les prérequis nécessaires ont été mis en place entre Jenkins et Gitlab pour une bonne communication.

Voici à nouveau le schéma cible détaillant les éléments à mettre en œuvre et la logique recherchée.

Schéma de la cible

Cliquez sur l'image pour l'agrandir.

Création des objets Kubernetes

Le storage

Tout d’abord, il faut créer un volume persistant (PV) et un PersistentVolumeClaim (PVC) pour que l’agent Jenkins puisse y déposer le code récupéré depuis le repo Git afin de le soumettre à Oasis.

Ce volume va donc devoir pointer vers le même emplacement que celui monté dans le conteneur Oasis au sein du pod Ollama, puisqu’il devra être lu par ce dernier.

Si vous n’êtes pas à l’aise avec la notion de stockage persistant sous Kubernetes, n’hésitez pas à passer par ici et par .

Pour rappel, on avait employé un partage NFS et créé également un pv/pvc mais pour le pod Ollama du côté du namespace dédié à ce dernier.

On va reprendre la même configuration et la lier au namespace Jenkins (à l’exception du volume persistant qui n’a pas de notion de namespace).

D’abord, on définit le pv dans le fichier 01-pv-jenkins-pip-security-code-check.yml:

---
apiVersion: v1
kind: PersistentVolume
metadata:
  annotations:
    pv.kubernetes.io/provisioned-by: nfs.csi.k8s.io
  name: pv-jenkins-pip-security-code-check
  labels:
      environment: prd
      network: lan
      application: jenkins
      pipeline: security-code-check
spec:
  capacity:
    storage: 30Gi
  accessModes:
    - ReadWriteMany
  persistentVolumeReclaimPolicy: Retain
  csi:
    driver: nfs.csi.k8s.io
    volumeHandle: /Volume1/nfsshare/rubikub.coolcorp.priv/namespaces/prd-jenkins-lan/pipelines/security-code-check
    volumeAttributes:
      server: 192.168.10.152
      share: /Volume1/nfsshare/rubikub.coolcorp.priv/namespaces/prd-jenkins-lan/pipelines/security-code-check
    

On référence exactement le même export NFS que pour Ollama/Oasis, soit, dans mon exemple, /Volume1/nfsshare/rubikub.coolcorp.priv/namespaces/prd-jenkins-lan/pipelines/security-code-check.

Cela correspond à un espace sur mon NAS.

D’ailleurs, attention au droit positionné sur ce volume. Cela dépend de la solution de stockage utilisée, mais il faudra tenir compte des autorisations positionnées dans l’étape à venir pour la définition du pod associé à l’agent jenkins.

Par exemple, dans mon cas, j’utilise un groupe « rubikube » disposant de l’id de groupe 2 sur mon NAS. Mon pod associé à l’agent Jenkins devra tenir compte de cela. Nous reviendrons sur ce point plus tard.

Droits sur le volume NFS

Cliquez sur l'image pour l'agrandir.

ID du group sur le NAS

Cliquez sur l'image pour l'agrandir.

Dans le Namespace prd-jenkins-lan, correspondant au Namespace dans lequel j’ai déployé Jenkins, on définit le pvc via le fichier 02-pvc-jenkins-pip-security-code-check.yml.

---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: pvc-jenkins-pip-security-code-check
  namespace: prd-jenkins-lan
  labels:
      environment: prd
      network: lan
      application: jenkins
      pipeline: security-code-check
spec:
  accessModes:
    - ReadWriteMany
  resources:
    requests:
      storage: 30Gi
  volumeName: pv-jenkins-pip-security-code-check
  storageClassName: ""
    

Ce pvc pointe directement vers le pv précédent.

On applique les deux objets avec la commande kubectl apply -f nom_du_fichier.yml

Le secret

Récupération des composants de connexions SSH

Pour que l’agent Jenkins puisse récupérer, le code dans le repo git, il va falloir qu’il se présente avec une clef SSH connu et rattaché à un compte disposant des droits nécessaires sur le repo.

On génère donc une combinaison clef privée/clef publique (avec puttygen), puis nous ajoutons la clef publique obtenue dans les paramètres du compte gitlab qu’on souhaite utiliser.

Récupération des rapports

Cliquez sur l'image pour l'agrandir.

Dans l’article sur l’interaction entre Jenkins et Gitlab, nous avions créé un compte de service svc-jenkins-togitlab dans l’annuaire Active Directory pour ce type de besoin. C’est donc dans les paramètres de ce compte dans GitLab qu’on va ajouter la clef publique.

Connexion à gitlab avec le compte de service

Cliquez sur l'image pour l'agrandir.

Ajout de la clef au compte de service dans gitlab

Cliquez sur l'image pour l'agrandir.

Ajout de la clef au compte de service dans gitlab

Cliquez sur l'image pour l'agrandir.

Pour la clef privée, on va la garder de côté pour la suite. Dans l’immédiat, il va être nécessaire de récupérer cette fois-ci la clef publique présentée côté interface de gitlab. Il faut que l'agent Jenkins puisse faire confiance à GitLab et que le certificat présenté par GitLab soit connu de l'agent Jenkins.

Conservation de la clef privée

Cliquez sur l'image pour l'agrandir.

GitLab a également été déployé sous Kubernetes comme détaillé ici.

Son composant « gitlab shell » chargé d’écouter en ssh les instructions git est exposé au sein du cluster à travers le Service gitlab-gitlab-shell dans le Namespace prd-gitlab-lan.

Service chargé de rediriger le trafic ssh

Cliquez sur l'image pour l'agrandir.

Pour être certains d’avoir la bonne signature ssh, on peut démarrer un pod éphémère avec un conteneur basique chargé d’exécuter la commande ssh-keyscan pour récupérer dans un fichier known_hosts l’empreinte de l’hôte.

On lance donc un Pod alpine, soit un Pod qui exécute l’image de base de l’OS allégé Alpine, pour s’y connecter immédiatement en mode interactif: kubectl run -it sshscan --rm --restart=Never --image=alpine -- sh

De là on installe ssh-keyscan.

apk add openssh

Puis on capture l’empreinte souhaitée.

ssh-keyscan -H gitlab-gitlab-shell.prd-gitlab-lan.svc.cluster.local > known_hosts
Récupération de l'emprunte SSH de gitlab

Cliquez sur l'image pour l'agrandir.

Notez la syntaxe gitlab-gitlab-shell.prd-gitlab-lan.svc.cluster.local pour atteindre « gitlab shell ».

En effet le service associé à ce dernier est dans le namespace prd-gitlab-lan et notre pod éphémère est dans le namespace default. Il me faut donc utiliser le nom dns complet du service pour passer d’un namespace à un autre.

Une fois le fichier récupéré, on peut lire son contenu pour le sauvegarder et l’utiliser par la suite.

Création du secret

En effet, nous allons maintenant créer le secret sec-jenkinstogitlab-ssh, qui contiendra à la fois:

  • La clé privée mise de côté précédemment et correspondant au compte de service, svc-jenkins-togitlab qu'on va rattacher au paramètre id_rsa du secret
  • Le contenu du fichier known_hosts, récupéré à l'instant, dans le paramètre du meme nom au niveau du secret.

Soit la commande suivante:

kubectl create secret generic sec-jenkinstogitlab-ssh --from-file=id_rsa=X:\gitlab\gitlab.coolcorp.priv\sa-jenkins-agent --from-file=known_hosts=X:\gitlab\gitlab.coolcorp.priv\known_hosts -n prd-jenkins-lan

(Ici, j’ai placé la clef privée et le fichier known_hosts sur mon poste client dans X:\gitlab\gitlab.coolcorp.priv).

Configuration de Jenkins

On a donc maintenant tout le nécessaire pour s’attaquer à Jenkins.

Pour bien comprendre la suite, il faut avoir un minimum de connaissance sur ce dernier. N’hésitez pas à lire mon article sur son installation sous Kubernetes, cela pourrait déjà vous permettre d’être plus à l’aise.

Les parametres

En premier lieu, on va utiliser des paramètres à notre pipeline, plus spécifiquement des hidden parameters, afin d’éviter de mettre en dure dans le code de notre pipeline trop d’éléments qu’on pourrait être amené à changer par la suite.

Il faut s’assurer d’avoir le plugin associé. Pour cela, on se rend dans l’administration de Jenkins, dans les plugins, puis on s’assure d’installer hidden Parameter.

Installation du plugin jenkins Hidden Parameter

Cliquez sur l'image pour l'agrandir.

On peut désormais créer la pipeline. Dans mon cas je la nomme security-code-check.

On lui donne un petit descriptif, puis on coche la case Ce build a des paramètres.

On crée un hidden parameter par variable qu’on va souhaiter utiliser dans notre pipeline.

Création de la pipeline

Cliquez sur l'image pour l'agrandir.

Soit pour démarrer les paramètres suivants:

Nom Valeur Rôle
NFS_CLONE_PATH /nfs/repo Chemin au sein du conteneur de l’agent jenkins dans lequel copier le code (corresponds au pv pointant vers le partage nfs)
POD_NAMESPACE prd-mygptgpu-lan Namespace dans lequel s’exécute le pod contenant l’application oasis
CONTAINER_NAME oasis Nom du conteneur oasis dans le pod Ollama
GITLAB_REPO git@gitlab-gitlab-shell.prd-gitlab-lan.svc.cluster.local:apps/demo-check-code.git Url du repo contenant le code à copier pour l’analyser. Comme jenkins tourne sur le même cluster K8S que gitlab, on peut passer par les services internes
OASIS_ARGS -sm gemma3:4b -m llama3:8b --clear-cache-scan --vulns all Argument que l’on va passer à oasis au sein du conteneur (argument qu’on n’avait passé à la main précédemment dans le premier article)
JENKINS_AGENT_IMAGE registry.gitlab.com/apps.coolcorp.priv/jenkins-custom:agent-2.492.2-lts-v2 Image de l’agent Jenkins à exécuter (voir article sur installation jenkins sous Kubernetes)
SSH_SECRET_NAME sec-jenkinstogitlab-ssh Nom du secret contenant la clef privée à utiliser pour se connecter à gitlab et l’empreinte ssh de l’hôte gitlab. C’est le secret créé précédemment.
PVC_NAME pvc-jenkins-pip-security-code-check Nom du PVC à utiliser pour l’agent jenkins. C’est le pvc créé en début d’article nous permettant de pointer vers le pv qui lui-même pointe vers l’export NFS commun à l’agent jenkins et à Oasis
PV_NAME pv-jenkins-pip-security-code-check Nom du PV à utiliser pour l’agent jenkins. C’est uniquement pour utiliser son nom comme nom de volume dans le pod de l’agent jenkins

Ces paramètres et leur valeur restent des exemples adaptés à mon article. Bien entendu, ils peuvent évoluer en fonction de vos usages et de votre propre déploiement.

Chacun d'entre eux doit etre renseigné en tant que Hidden Parameter

Création d'un parametre pour la pipeline

Cliquez sur l'image pour l'agrandir.

Ensemble des parametres pour la pipeline

Cliquez sur l'image pour l'agrandir.

On va pouvoir écrire le code de la pipeline.

La pipeline

Comme j’ai pu l’expliqué dans mon article sur les interactions possible entre Gitlab et Jenkins, j’utilise la récupération du jenkins.file directement depuis un repo git.

Cela facilite l’écriture et le partage du code, car jenkins peut automatiquement récupérer la dernière version de la pipeline lors de son exécution.

N’hésitez pas à parcourir le tutoriel associé pour bien comprendre ce point.

Voici le contenu du jenkins files security-code-check.jenkinsfile:


pipeline {

    agent none

    environment {
        CLONE_PATH          = "${env.NFS_CLONE_PATH}"
        POD_NAMESPACE       = "${env.POD_NAMESPACE}"
        CONTAINER_NAME      = "${env.CONTAINER_NAME}"
        GITLAB_REPO         = "${env.GITLAB_REPO}"
        OASIS_ARGS          = "${env.OASIS_ARGS}"
        JENKINS_AGENT_IMAGE = "${env.JENKINS_AGENT_IMAGE}"
        SSH_SECRET_NAME     = "${env.SSH_SECRET_NAME}"
        PVC_NAME            = "${env.PVC_NAME}"
        PV_NAME             = "${env.PV_NAME}"
     
    }

    stages {
        stage('Run with dynamic pod') {
            steps {
                script {
                    def podYaml = """
apiVersion: v1
kind: Pod
metadata:
  namespace: prd-jenkins-lan
  labels:
    purpose: pip-security-code-check
spec:
  serviceAccountName: sa-jenkins-agent
  securityContext:
    fsGroup: 4
  containers:
  - name: jnlp
    image: ${JENKINS_AGENT_IMAGE}
    securityContext:
      runAsUser: 2
    resources:
      limits:
        memory: "512Mi"
        cpu: "500m"
    volumeMounts:
    - name: ${PV_NAME}
      mountPath: /nfs
    - name: ssh-key-volume
      mountPath: /bin/.ssh/
      readOnly: true
  imagePullSecrets:
    - name: sec-jenkins-registry
  volumes:
  - name: ssh-key-volume
    secret:
      secretName: ${SSH_SECRET_NAME}
      defaultMode: 0400
  - name: ${PV_NAME}
    persistentVolumeClaim:
      claimName: ${PVC_NAME}
"""

                    podTemplate(yaml: podYaml, serviceAccount: "sa-jenkins-agent") {
                        node(POD_LABEL) {

                            stage('Clone repo into NFS') {
                                sh "rm -rf ${CLONE_PATH} && mkdir -p ${CLONE_PATH}"
                                sh '''
                                    ssh -vT git@gitlab-gitlab-shell.prd-gitlab-lan.svc.cluster.local || echo "SSH FAILED"
                                '''
                                git branch: 'main',
                                    url: "${GITLAB_REPO}",
                                    changelog: false,
                                    poll: false
                                sh "cp -r . ${CLONE_PATH}/"
                            }

                            stage('Find oasis pod') {
                                script {
                                    def podName = sh(
                                        script: """
                                        kubectl get pods -n ${POD_NAMESPACE} --field-selector=status.phase=Running \\
                                          -o custom-columns=NAME:.metadata.name --no-headers \\
                                          | grep '^deploy-ollama-default-' | head -n 1
                                        """,
                                        returnStdout: true
                                    ).trim()

                                    if (!podName) {
                                        error "Pod 'deploy-ollama-default-*' en statut Running introuvable."
                                    }

                                    env.OASIS_POD = podName
                                }
                            }

                            stage('Run analysis in oasis container') {
                                script {
                                    def cmd = "oasis -i ${CLONE_PATH} ${OASIS_ARGS}"
                                    sh "kubectl exec -n ${POD_NAMESPACE} ${env.OASIS_POD} -c ${CONTAINER_NAME} -- ${cmd}"
                                }
                            }

                        }
                    }
                }
            }
        }
    }
}
    

Au début de ce fichier, nous récupérons la valeur des différents paramètres que nous avons spécifiés précédemment en tant que paramètres cachés.

Puis, en utilisant une partie de ces paramètres, on construit le code du Pod qui sera exécuté pour lancer l’agent jenkins. C’est ce dernier qui se chargera des différentes instructions.

Ces instructions vont être les suivantes:

  1. L’agent clone les sources du code présent dans le repo indiqué en paramètres. Le code est copié sur le volume NFS rattaché au pv
  2. L’agent identifie le pod oasis en interrogeant le cluster via le nom du Deployment associé
  3. L’agent se connecte au contexte du conteneur dans le pod identifié et exécute la commande de scanne telle qu’indiquée par le paramètre caché OASIS_ARGS.

Veuillez noter que, pour la définition du pod, je réfère à l’option securityContext : runAsUser : 2

Ceci correspond à l’ID de mon user associé à mon volume NFS, comme je l’évoque précédemment. Ceci permet au Pod d’agir avec les bons droits et de pouvoir écrire sur le volume NFS de mon NAS utilisé comme cible du pv.

Je dépose mon fichier dans le répo Git réservé à mes jenkins files et je l’identifie dans l’interface utilisateur de Jenkins.

Récupération automatique du code de la pipeline

Cliquez sur l'image pour l'agrandir.

En ce qui concerne l’exécution automatique de la pipeline via une opération de push du code source vers Gitlab, je vais simplement répéter ce que j’ai déjà dit dans mon article au sujet des relations entre Jenkins et Gitlab.

Il suffit de mettre en place un webhook: je vous invite à lire le tutoriel associé au besoin.

Il ne reste plus qu’à tester.

Test et exemple

Pour l’exemple, on va utiliser un code fictif associé à une application flask, soit le même framework utilisé pour mon site.

On y trouve une arborescence classique à Flask avec du Python et du HTML. Grâce à l’IA, j’ai demandé un code volontairement rempli de faille de sécurité.

Voici le contenu du fichier app.py:

from flask import Flask, request, render_template_string, redirect, session, make_response
import sqlite3
import os
import config  # ← import de secrets en dur

app = Flask(__name__)
app.secret_key = config.SECRET_KEY  # ❌ Clé secrète non sécurisée

# ❌ Pas d'authentification DB
def get_db():
    return sqlite3.connect("users.db")

# ❌ Création non protégée
@app.route("/init")
def init():
    db = get_db()
    db.execute("CREATE TABLE IF NOT EXISTS users (username TEXT, password TEXT)")
    db.execute("INSERT INTO users VALUES ('admin', 'admin')")
    db.commit()
    return "Database initialized"

# ❌ Login vulnérable à l'injection SQL
@app.route("/login", methods=["GET", "POST"])
def login():
    if request.method == "POST":
        user = request.form["username"]
        pw = request.form["password"]
        db = get_db()
        # 🔥 SQL injection possible
        query = f"SELECT * FROM users WHERE username='{user}' AND password='{pw}'"
        result = db.execute(query).fetchone()
        if result:
            session["user"] = user
            return redirect("/dashboard")
        else:
            return "Invalid credentials", 403
    return render_template_string(open("templates/login.html").read())

# ❌ XSS via GET param
@app.route("/dashboard")
def dashboard():
    user = session.get("user", "guest")
    msg = request.args.get("msg", "")
   

# ❌ Téléchargement de fichiers sans contrôle (path traversal possible)
@app.route("/download")
def download():
    filename = request.args.get("file")
    try:
        with open(f"files/{filename}", "rb") as f:
            response = make_response(f.read())
            response.headers["Content-Disposition"] = f"attachment; filename={filename}"
            return response
    except FileNotFoundError:
        return "File not found", 404

# ❌ Exposition de l'API avec une clé codée en dur
@app.route("/api/data")
def api():
    key = request.args.get("key")
    if key != config.API_KEY:
        return "Forbidden", 403
    return {"secret_data": "This is internal"}

if __name__ == "__main__":
    app.run(debug=True)
    

On pousse le code dans le repo (le repo de l'apps démo).

Quelque seconde après, grâce au webhook jenkins appelé par Gitlab, la pipeline Jenkins se déclenche.

Déclenchement de la pipeline

Cliquez sur l'image pour l'agrandir.

On peut suivre son déroulement dans l’interface de Jenkins.

Suivi du déroulé de la pipeline

Cliquez sur l'image pour l'agrandir.

Comme souhaité, l’agent jenkins est démarré via un Pod et il exécute les instructions attendues pour le lancement du scan du code précédemment récupéré dans le volume NFS.

Celui-ci va durer plusieurs minutes et va solliciter le GPU du nœud Kubernetes sur lequel ollama est exécuté.

Charge GPU sur le node

Cliquez sur l'image pour l'agrandir.

En fin de pipeline, on peut accéder aux rapports générés par Oasis, soit à travers l’interface web, soit directement dans le volume NFS de sortie choisi lors du premier article où on a déployé Oasis.

Comme pour la première fois, on n’a plusieurs formats de rapport, PDF, HTLM ou markdown.

Ensemble des rapport PDF

Cliquez sur l'image pour l'agrandir.

Si on parcourt les rapports PDF on retrouve toutes les failles de sécurité du code avec les différentes recommandations associées pour les corriger.

Résumé des failles trouvées

Cliquez sur l'image pour l'agrandir.

Exemple de faille

Cliquez sur l'image pour l'agrandir.

Exemple de faille

Cliquez sur l'image pour l'agrandir.

Recommandation suite à une faille

Cliquez sur l'image pour l'agrandir.

Conclusion

À travers deux articles, j’ai souhaité démontrer l’intérêt de mêler CICD et IA, non pas juste à travers un discours marketing, mais via un exemple concret.

Grâce à des outils open source, il est possible d’intégrer des fonctionnalités d’analyse cyber avancés via de l’IA, le tout intégré dans une chaîne CICD pour automatiser le process.

On peut, par exemple, imaginer invalider des livraisons si des failles sont détectées pour éviter d'exécuter un code à risque.

À l’heure ou tout le monde agite le risque Cyber à la moindre occasion, ce genre de combinaison peut prendre tout son sens. À noter que le process peut se faire localement sans avoir besoin de partager la moindre donnée à l'extérieur.

Ce qui peut être intéressant quand le code à analyser est sensible ou si l’entreprise est soumise à des contraintes spécifiques à ce niveau.

Bien entendu, la démonstration reste à affiner et à améliorer pour une implémentation en production dans un contexte professionnel.

On s’aperçoit néanmoins qu’en oubliant un peu les slides "tape à l’œil" pour se relever les manches et se reconcentrer sur l’ingénierie, on peut réellement tirer parti de toutes ces évolutions technologiques.

L’IA n’est pas un danger pour les métiers de l’IT, mais un accélérateur, un moyen d’être plus efficace et performant… sous conditions d’oublier un peu le marketing et de ne pas avoir peur d’expérimenter !