Hello les amis, chez mon client actuel, on commence à avoir quelques conteneurs qui tournent sur Kubernetes (avec l’ambition d’en mettre de plus en plus), et l’ami SCC (Security Command Center) nous trouve des CVEs qui s’empilent comme du sédiment, jusqu’à créer du pétrole de vulnérabilité.

J’avoue que la première phrase de l’article pique un peu, alors je vous mets quelques infos pour comprendre où je veux en venir.

Plus d’infos sur Security Command Center

Security Command Center permet aux opérations de sécurité (SOC), aux spécialistes des vulnérabilités et des stratégies, ainsi qu’à d’autres professionnels de la sécurité d’évaluer, d’analyser et de répondre aux problèmes de sécurité dans les environnements cloud.

Pour faire simple, c’est un outil payant qui centralise les remontées de risques de sécurité.

Plus d’infos sur les CVEs

Common Vulnerabilities and Exposures, ou CVE, est un dictionnaire des informations publiques relatives aux vulnérabilités de sécurité. Ce dictionnaire est maintenu par l’organisme MITRE, soutenu par le département de la Sécurité intérieure des États-Unis.

Pour faire simple, c’est une liste de vulnérabilités connues et documentées publiquement.

Plus d’infos sur la création du pétrole

Le pétrole résulte de la dégradation thermique de matières organiques contenues dans certaines roches : les roches-mères. Ce sont des restes fossilisés de végétaux aquatiques ou terrestres, de bactéries et d’animaux microscopiques s’accumulant au fond des océans, des lacs ou dans les deltas.

Donc, quand je dis que ça s’empile comme du sédiment jusqu’à créer du pétrole de vulnérabilité, il faut comprendre qu’on laisse bien moisir les vulnérabilités et qu’on attend bien longtemps avant de s’en préoccuper.

La posture chez GCP pour éviter les vulnérabilités dans les conteneurs, c’est de maîtriser 100 % de la construction des conteneurs en partant de leurs images de base, maintenues et mises à jour par les équipes de GCP. En principe, cela garantit le moins de vulnérabilités possible.

Documentation sur les images de base de GCP

C’est bien joli, mais si ton équipe ne sait pas construire correctement des images ou utilise des images issues de dépôts communautaires avec des outils déjà packagés, tu crois qu’ils auront envie de refaire une image complète à partir des images de base de GCP ? Ah, la flemme, toi-même tu sais ! Avec des devs de bonne volonté, tu peux espérer des patchs manuels de temps en temps, mais à grande échelle, c’est compliqué de suivre. Tu peux avoir des devs bon élèves qui mettent à jour leurs images au moment du build, mais si ta fréquence de déploiement est beaucoup moins élevée que la fréquence de publication des patchs pour les CVEs, mécaniquement, de nouvelles CVEs vont apparaître au fur et à mesure que le temps passe.

Le temps est l’ennemi ultime. Il emporte avec lui les bonnes volontés et l’espoir d’un run de prod sans accidents de sécurité.

Illustration d’un océan en vue de coupe : en haut de l’image, des logos Docker fuient des CVEs qui s’entassent au fond de l’océan pour former du pétrole de CVEs.

Et si on prenait le problème dans l’autre sens ?

Au fond, si on prend le problème dans l’autre sens, on a un alerting qui inonde de vulnérabilités et très peu de chances de traiter les vulnérabilités avec réactivité. Faire un channel d’alerting des findings de SCC ne sera d’aucun secours. Je l’ai fait, et en vérité, il a très vite été décommissionné. Deuxième idée : faire une spreadsheet qui s’auto-update avec les CVEs remontées par SCC pour rendre les vulnérabilités visibles. J’ai fait ça aussi. On regarde un peu les premières semaines, mais quelques semaines plus tard, tout le monde s’en fiche.

J’ai essayé de patcher manuellement quelques images, et franchement, c’est compliqué. Ce que j’ai pu analyser des CVEs les plus courantes, c’est que les binaires en C bas niveau, que tout le monde utilise, sont les plus touchés. Souvent des problèmes de buffer overflow sur des libs comme OpenSSL, cURL et consorts, qui sont incontournables dans l’écosystème Linux. Ce n’est pas une option de désinstaller ces libs, et il faut utiliser les channels de repo testing pour avoir les versions patchées. Pas ouf d’installer des versions instables dans un contexte de compute, mais dans un conteneur, avec une capacité de rollback quasi instantanée, ça semble être un risque un peu plus contrôlable.

Il faudrait presque patcher la CVE au moment où elle arrive pour espérer une réduction significative des remontées. Avec une escalade et des requalifications de sévérité, une CVE peut se déclencher plusieurs fois sur la même version. Du coup, est-ce que je serais assez fou pour faire un système de patching d’image au moment où elles arrivent dans SCC ? La réponse est OUI, Bien sur que OUI.

Rick and  Morty, You son of a bitch, I’am in

Concept, schéma, architecture et enclos aux cochons

Ma première idée, c’était d’utiliser copacetic, un petit tool rigolo qui, sur le papier, est capable de patcher les images sans rebuilder, en mettant à jour exactement la version de la lib qui contient le patch de résolution de la CVE. Juste pour vous dire à quel point j’y croyais, c’est cet outil qui m’avait inspiré cette idée.

En combinaison avec un outil capable de scanner les conteneurs et de faire un rapport de vulnérabilité, j’ai nommé l’ami Trivy..

L’intérêt de cette combinaison, c’est que copacetic est capable de patcher les vulnérabilités en se basant sur le rapport de trivy. Sur le papier, ça fait une Starsky et Hutch, puisque les deux outils s’intègrent parfaitement et copacetic est capable de builder et pousser la nouvelle image directement dans le repo cible (ou un autre repo de mon choix).

Petite précision, mais qui a toute son importance : trivy me permet de filtrer les libs systèmes des libs de code. Je veux absolument éviter de péter le code, et je laisse cette responsabilité aux devs. Par contre, les libs systèmes, aucune pitié. OpenSSL et cURL avant d’avoir des breaking changes majeurs, je peux me coucher très tôt. Par contre, 2 semaines sans CVEs, c’est une autre paire de manches.

meme, chat triste, le mainteneur de libssl quand une nouvelle CVE est publiée.

Du coup, c’était sympa, mais la réalité du terrain, c’est que copacetic n’est pas en mesure de gérer certains cas un peu rigolos :

  • La version patchée n’existe pas dans le repo stable.
  • L’utilisateur dans le conteneur n’est pas root.
  • La dépendance nécessite de mettre à jour les libs C liées (copacetic est un fonctionnaire, il ne patch que les libs dans le rapport copacetic).

Donc moi, ça m’énerve, parce que j’ai une somme d’images avec des OS hétérogènes : Debian/Ubuntu/Alpine, parfois j’ai apt, parfois c’est apk. Vous sentez ce délicat fumet ? On s’approche de l’enclos des cochons, et ce n’est pas l’odeur du napalm au petit matin !

Et oui, Trivy/copacetic, c’était élégant, c’était simple, mais on repart encore sur une technique de nettoyeur de tranchées en 14/18. On va tailler dans le gras, et on n’oublie pas le gaz moutarde pour l’assaisonnement.

Spider Cochon, Les Simpsons, quand j’avais une idée élégante mais que je sors les gants de boxe pour coder.

Donc l’idée, c’est qu’on va conserver quand même Trivy, parce que le rapport au format JSON, c’est quand même bien pratique. Par contre, on va changer de tactique et faire une mécanique bien plus bourrine avec des commandes buildah,, qui est capable d’ajouter des layers Docker à une image existante, devenir root et conserver quand même l’état du conteneur, les variables d’env, l’utilisateur de base, les métadonnées.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
trivy remonte les CVE et l'OS du container
on fait un dictionnaire unique des CVE existantes
si ubuntu/debian, commande apt, si alpine, commande apk

on devient root
on fait update et upgrade

on relance trivy
    si il retrouve encore des vulns
        on met les repo test
        on fait update
        on upgrade seulement les repo avec des vulns restantes
    on incrémente le nom de l'image si elle a déjà été patchée

C’est bourrin, c’est imparfait, mais en utilisant apt et apk, on est certain que les libs liées sont à jour. On installe les libs testing seulement sur les libs vulnérables, et comme on sait que ce sont souvent des libs C, qui sont relativement stables en termes de fonctionnalités dans le temps, le risque de casse est limité. On laisse les devs mettre à jour eux-mêmes les libs de code, qui ont plus de chances d’introduire des breaking changes.

Détails d’implémentation

Maintenant, je vais rentrer un peu plus dans les détails d’implémentation de ma solution. Je voulais une solution serverless, pour éviter au maximum de cramer du CPU pour rien, puisque 99% du temps le service n’aurait pas été appelé. Un patch de conteneur dure au maximum 10 minutes, c’est donc une charge hyper volatile et sporadique.

Comme je n’ai pas réussi à me passer du socket Docker pour Buildah et que Cloud Run ne me permet pas de faire du Docker dans le conteneur, j’ai choisi d’utiliser Cloud Run comme une sorte de proxy de requêtes qui va déclencher un Cloud Batch responsable du traitement.

Plus d’info sur Cloud Batch

Service par lot entièrement géré permettant de planifier, mettre en file d’attente et exécuter des jobs par lot sur l’infrastructure de Google.

La bonne traduction française dégueu du site officiel de GCP : En gros, c’est un système de batch managé, qui utilise des Compute Engine pour lancer des tâches, soit en mode script, soit en conteneurs.

Ça nous donne SCC qui écrit dans Pub/Sub, Pub/Sub qui est lu par Cloud Run, Cloud Run qui déclenche Cloud Batch. Dans Cloud Batch, on récupère l’image Docker, on scanne les vulnérabilités avec Trivy, on patch avec Buildah, Docker push, et c’est fini.

Dans la configuration du batch, on rend disponible le socket Docker, pour pouvoir faire du Docker dans Docker. Et dans mon conteneur de build, on y met Docker Buildx, Buildah, Trivy et gcloud.

Schéma de patching CVE : à gauche, la Cloud Function qui déclenche Cloud Batch, au milieu un compute avec Docker, Buildah et Trivy qui patch le conteneur et le pousse dans Artifact Registry.

Dernières frictions, mais pas des moindres : on ne peut pas auto-déployer les images sans les tester en amont et faire valider la non-régression par les devs. Bon, ça c’est plus un problème humain qu’un problème tech, donc on va dire qu’on s’en fout dans le contexte de cet article, meh !

Il est frais, mon poisson, il est frais !

C’est avec un peu de déception et d’incompréhension, je dois le dire, que cette initiative n’a pas été suivie par mon client, qui a décidé de mettre ce projet au second plan. Dommage pour lui, comme je considère officiellement le projet abandonné, je n’ai aucun mal à le proposer dans une version plus légère ici, avec son accord.

Comme je n’ai pas de charge de travail sur GKE ni de SCC activé sur mon orga de lab, je vais réduire mon code au minimum et utiliser une Cloud Function qui prend en paramètre une image Docker. Cette fonction déclenchera un Cloud Batch, patchera l’image et poussera l’image de base et l’image patchée dans un Artifact Registry (pour visuellement plus facilement voir les vulns présentes). En somme, tout pareil que si j’avais un outil qui me remontait mes images vulnérables. Donc ça commence dans un premier temps par une image Docker qui servira de base pour ma machine de patch.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
FROM google/cloud-sdk:latest

RUN apt-get update && apt-get upgrade -y && \
    apt-get install -y wget tar buildah && \
    apt-get clean && rm -rf /var/lib/apt/lists/*

ENV DOCKER_BUILDKIT=1
ENV BUILDKIT_PROGRESS=plain
ENV DOCKER_CLI_EXPERIMENTAL=enabled
ENV STORAGE_DRIVER=vfs

ARG BUILDX_URL=https://github.com/docker/buildx/releases/download/v0.4.2/buildx-v0.4.2.linux-amd64

RUN mkdir -p $HOME/.docker/cli-plugins && \
    wget -O $HOME/.docker/cli-plugins/docker-buildx $BUILDX_URL && \
    chmod a+x $HOME/.docker/cli-plugins/docker-buildx

RUN wget https://github.com/aquasecurity/trivy/releases/download/v0.51.2/trivy_0.51.2_Linux-64bit.deb && \
    dpkg -i trivy_0.51.2_Linux-64bit.deb && \
    rm trivy_0.51.2_Linux-64bit.deb

COPY requirements.txt .
RUN pip3 install -r requirements.txt
COPY ./patch-image.py .

Dans les trucs un peu subtils, on a besoin de buildx et de VFS pour que buildah arrête de chouiner et puisse fonctionner dans le contexte du container. J’attrape directement le .deb de trivy depuis le repo GitHub officiel (j’aurais pu mettre le repo source, mais un peu la flemme).

Mon fichier patch_image.py contiendra mon script de colle pour faire fonctionner les briques entre elles.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
#!/usr/bin/env python3
import os
import subprocess
import pprint
import sys
import json
from json import dumps
import requests


def run_command(command):
    """
    general propose function use to run system command
    expected argument command
    print on shell stdout and stderr
    return status code
    """
    print(f"run: {command}")
    try:
        with subprocess.Popen(
            command,
            shell=True,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            universal_newlines=True,
        ) as process:

            for line in iter(process.stdout.readline, ""):
                print(line.strip())

            for line in iter(process.stderr.readline, ""):
                print(line.strip())

            process.wait()
            exit_code = process.returncode
            print(f"Command finished with exit code: {exit_code}")

    except Exception as e:
        print(f"Error running command: {str(e)}")
        exit_code = 255
    return exit_code


def check_if_image_exist_and_increment_until_new_image_name(docker_vars):
    # if docker manifest return 1, image not exist, so we can use this tag for the patched image
    image_not_exist = 0
    while True:
        image_not_exist = run_command(
            f"docker manifest inspect {docker_vars['target_docker_image']}"
        )
        if image_not_exist != 0:
            break
        # iterate tag if image exist on remote repository
        print(
            f"image {docker_vars['target_docker_image']} already exist, incrementing the patched tag"
        )
        docker_vars = set_target_image_new_tag(docker_vars)
    return docker_vars


def set_target_image_new_tag(docker_vars):
    # iterate tag if tag from original image is already patched
    # check if image exist and iterate tag if image exist on remote repo

    if "patched" in docker_vars["original_tag"] or docker_vars.get("new_tag"):
        print("image is already tag with '-patched', iterate tag.")

        tag_digit = docker_vars["original_tag"].split("-patched-")

        if docker_vars.get("new_tag"):
            digit = docker_vars["new_tag"].split("-patched-")
        if len(digit) > 1 and digit[1].isdigit():
            new_patch_num = int(digit[1]) + 1
        else:
            new_patch_num = 1

        if not "-patched-" in docker_vars["new_tag"]:
            # this case is only on first tag -patched to prevent -patched-patched-1 numerotation
            docker_vars["new_tag"] = f"{digit[0]}-{new_patch_num}"
        else:
            docker_vars["new_tag"] = f"{digit[0]}-patched-{new_patch_num}"

    else:
        docker_vars["new_tag"] = f"{docker_vars['original_tag']}-patched"

    docker_vars["target_docker_image"] = (
        "/".join(docker_vars["current_docker_image"].split("/")[:-1])
        + "/"
        + docker_vars.get("new_tag")
    )
    docker_vars = check_if_image_exist_and_increment_until_new_image_name(docker_vars)
    return docker_vars


def parse_docker_image_vars(docker_vars):
    docker_vars["original_docker_image"] = os.getenv("ORIGINAL_DOCKER_IMAGE")

    docker_vars["current_docker_image"] = docker_vars["original_docker_image"]
    if "/" in docker_vars["original_docker_image"]: 
        docker_vars["original_tag"] = docker_vars["original_docker_image"].split("/")[-1]
    else:
        docker_vars["original_tag"] = docker_vars["original_docker_image"]
    return docker_vars


def push_image_to_repo_if_target_repo_set(docker_vars):
    # if the variable TARGET_REPO is set, the docker image and the patched image
    # will be push on this repository
    # else the patched image will be sent to the same repo as original image

    target_repo = os.getenv("TARGET_REPO")
    if target_repo:
        authenticate_to_gcp_registry()
        print(f"env TARGET_REPO set, push image to repo {target_repo}")
        rewrite_original_docker_image = (
            f"{target_repo}/{docker_vars.get('original_tag')}"
        )

        print("image is on a gcp registry, running gcloud set projet on this projet.")
        remote_project_name = os.getenv("BATCH_PROJECT_NAME")

        if remote_project_name and "/" in remote_project_name:
            remote_project_name = remote_project_name.split("/")[1]

        gcp_set_project(remote_project_name)

        run_command(f"docker pull {docker_vars.get('original_docker_image')}")

        run_command(
            f"docker tag {docker_vars.get('original_docker_image')} {rewrite_original_docker_image}"
        )

        target_project_name = target_repo.split("/")[1]
        gcp_set_project(target_project_name)
        run_command("docker images")
        run_command(f"docker push {rewrite_original_docker_image}")
        docker_vars["current_docker_image"] = rewrite_original_docker_image
    return docker_vars


def gcp_set_project(project):
    run_command(f"gcloud config set project {project}")


def authenticate_to_gcp_registry():
    run_command("gcloud auth configure-docker europe-west9-docker.pkg.dev --quiet")


def run_trivy_scan(docker_vars):
    docker_vars["trivy_result_json_file"] = f"{docker_vars.get('new_tag')}.json"
    run_command(
        f"trivy image --vuln-type os --ignore-unfixed -f json -o {docker_vars.get('trivy_result_json_file')} {docker_vars.get('current_docker_image')}"
    )


def read_trivy_file(docker_vars):
    with open(docker_vars.get("trivy_result_json_file")) as file:
        trivy_report = json.loads(file.read())
    return trivy_report


def extract_trivy_report(trivy_extract, trivy_report):
    trivy_extract["os_family"] = (
        trivy_report.get("Metadata", {}).get("OS", {}).get("Family")
    )

    try:
        trivy_extract["vulns_list"] = trivy_report["Results"][0]["Vulnerabilities"]
    except KeyError:
        trivy_extract["vulns_list"] = []
        print("no vuln found.")

    trivy_extract["uniq_vulns"] = list(
        set([vuln.get("PkgName") for vuln in trivy_extract.get("vulns_list", [])])
    )
    return trivy_extract


def manage_alpine_buildah_update(container_name, trivy_extract):
    print("Found Alpine OS")
    run_command(
        f"buildah run --user=root --isolation=chroot {container_name} -- apk update"
    )
    run_command(
        f"buildah run --user=root --isolation=chroot {container_name} apk upgrade --update-cache --available"
    )

    run_command(
        f"buildah run --user=root --isolation=chroot {container_name} /bin/sh -c \"echo '@edge https://dl-cdn.alpinelinux.org/alpine/edge/main' >> /etc/apk/repositories\""
    )
    run_command(
        f"buildah run --user=root --isolation=chroot {container_name} -- apk update"
    )

    for vuln in trivy_extract.get("uniq_vulns"):
        run_command(
            f"buildah run --user=root --isolation=chroot {container_name} apk add {vuln}@edge"
        )


def manage_debian_based_os_buildah_update(container_name, trivy_extract):
    run_command(
        f"buildah run --user=root --isolation=chroot {container_name} -- apt update"
    )
    run_command(
        f"buildah run --user=root --isolation=chroot {container_name} -- apt upgrade -y"
    )

    run_command(
        f"buildah run --user=root --isolation=chroot {container_name} /bin/sh -c \"echo 'deb http://deb.debian.org/debian testing main' >> /etc/apt/sources.list.d/testing.list\""
    )
    run_command(
        f"buildah run --user=root --isolation=chroot {container_name} /bin/sh -c \"echo 'Package: *\nPin: release a=testing\nPin-Priority: 100\n' > /etc/apt/preferences.d/pinning\""
    )
    run_command(
        f"buildah run --user=root --isolation=chroot {container_name} -- apt update"
    )
    for vuln in trivy_extract.get("uniq_vulns"):
        run_command(
            f"buildah run --user=root --isolation=chroot {container_name} apt-get install -t testing -y {vuln}"
        )


def buildah_commit_push_clean(container_name, docker_vars):
    run_command(
        f"buildah commit {container_name} {docker_vars.get('target_docker_image')}"
    )
    run_command(
        f"buildah push {docker_vars.get('target_docker_image')} {docker_vars.get('target_docker_image')}"
    )
    run_command(f"buildah rm {container_name}")


def update_upgrade_container(docker_vars):
    print("parsing trivy report.")
    trivy_extract = {}
    trivy_report = read_trivy_file(docker_vars)
    trivy_extract = extract_trivy_report(trivy_extract, trivy_report)


    container_name = "update-container"
    run_command(f"buildah rm {container_name} || true")
    run_command(
        f"buildah --name {container_name} from {docker_vars.get('current_docker_image')}"
    )
    if not trivy_extract.get("os_family"):
        print ("OS not found in trivy report, exiting script.")
        sys.exit(-1)

    if trivy_extract.get("os_family") and "alpine" in trivy_extract.get("os_family"):
        manage_alpine_buildah_update(container_name, trivy_extract)

    elif any(
        family == trivy_extract.get("os_family") for family in ["debian", "ubuntu"]
    ):
        manage_debian_based_os_buildah_update(container_name, trivy_extract)

    else:
        print(
            f"OS family {trivy_extract.get('os_family')} not supported for automatic updates"
        )
        message = f"update failed, unsupported OS {trivy_extract.get('os_family')}"
        send_message_to_google_chat(message)
        sys.exit(-1)

    buildah_commit_push_clean(container_name, docker_vars)


def main():
    docker_vars = {}
    docker_vars = parse_docker_image_vars(docker_vars)

    docker_vars = push_image_to_repo_if_target_repo_set(docker_vars)
    docker_vars = set_target_image_new_tag(docker_vars)

    authenticate_to_gcp_registry()
    run_trivy_scan(docker_vars)
    update_upgrade_container(docker_vars)


if __name__ == "__main__":
    main()

Donc, on a un script qui fait la liaison entre trivy, buildah, le parsing des libs vulnérables, le patching des vulns en testing en fonction de la lib, l’incrément de l’image si l’image a déjà été patchée les fois précédentes.

Maintenant qu’on a un container avec notre logique de patch, il faut faire le code d’infra pour mettre tout ça en musique.

La première chose à mettre en place, c’est le VPC avec un Cloud NAT pour que notre machine de patch puisse accéder à Internet sans besoin d’une IP publique directe (petit aspect sécurité basique).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
resource "google_compute_subnetwork" "subnetwork" {
  name          = var.subnet
  ip_cidr_range = var.ip_cidr_range
  region        = var.region
  network       = google_compute_network.vpc.id
}

resource "google_compute_network" "vpc" {
  name                    = var.vpc
  auto_create_subnetworks = false
}

resource "google_compute_router" "router" {
  name    = "my-router"
  region  = google_compute_subnetwork.subnetwork.region
  network = google_compute_network.vpc.id

  bgp {
    asn = 64514
  }
}

resource "google_compute_router_nat" "nat" {
  name                               = "my-router-nat"
  router                             = google_compute_router.router.name
  region                             = google_compute_router.router.region
  nat_ip_allocate_option             = "AUTO_ONLY"
  source_subnetwork_ip_ranges_to_nat = "ALL_SUBNETWORKS_ALL_IP_RANGES"

  log_config {
    enable = true
    filter = "ERRORS_ONLY"
  }
}

Je ne me casse pas trop la tête, j’active les logs sur le NAT quand même, pour avoir des infos en cas de problème.

J’avais prévu de faire un truc un peu plus modulaire, mais en fait non, chaussette sale it is, bisous !

Chaussette sale et trouée, portée par des jambes poilues.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
locals {
  sa_compute_roles = [
    "roles/artifactregistry.createOnPushWriter",
    "roles/logging.logWriter",
    "roles/batch.agentReporter"
  ]
  sa_cloudfunction_roles = [
    "roles/batch.jobsEditor",
    "roles/cloudfunctions.invoker",
    "roles/iam.serviceAccountUser"
  ]
}

resource "google_service_account" "sa_compute" {
  account_id   = "compute-patch-cve"
  display_name = "compute patch cve"
}


resource "google_service_account" "sa_cloudfunction" {
  account_id   = "cf-trigger-patch-cve"
  display_name = "cloudfunction trigger patch cve"
}

resource "google_project_iam_member" "sa_compute_bindings" {
  for_each = toset(local.sa_compute_roles)
  project  = var.project
  role     = each.value
  member   = "serviceAccount:${google_service_account.sa_compute.email}"
}

resource "google_project_iam_member" "sa_cloudfunction_bindings" {
  for_each = toset(local.sa_cloudfunction_roles)
  project  = var.project
  role     = each.value
  member   = "serviceAccount:${google_service_account.sa_cloudfunction.email}"
}

data "archive_file" "function_zip" {
  type        = "zip"
  source_dir  = "../../cloudfunction"
  output_path = "${path.module}/function-source.zip"
  excludes    = [".venv", "Pipfile*"]
}

resource "google_storage_bucket" "bucket" {
  name                        = "function-bucket-patch-cve"
  location                    = "europe-west9"
  uniform_bucket_level_access = true
}

resource "google_storage_bucket_object" "object" {
  name   = "${data.archive_file.function_zip.output_md5}.zip"
  bucket = google_storage_bucket.bucket.name
  source = "function-source.zip"
}


resource "google_cloudfunctions2_function" "function" {
  name     = "trigger_patch"
  location = "europe-west9"

  build_config {
    runtime     = "python312"
    entry_point = "trigger_patch"
    source {
      storage_source {
        bucket = google_storage_bucket.bucket.name
        object = google_storage_bucket_object.object.name
      }
    }
}
    service_config {
      environment_variables = {
        BATCH_PROJECT_NAME = "patch-container"
        BATCH_REGION       = "europe-west9"
        BATCH_NETWORK      = "projects/patch-container/global/networks/patch-vpc"
        BATCH_SUBNETWORK   = "projects/patch-container/regions/europe-west9/subnetworks/patch-subnetwork-europe-west9"
        SA_BATCH_COMPUTE   = google_service_account.sa_compute.email
        BATCH_DOCKER_IMAGE = "europe-west9-docker.pkg.dev/patch-container/patch-container/patch-container:v0.0.2"
        TARGET_REPO        = "europe-west9-docker.pkg.dev/patch-container/patch-container"
        LOG_EXECUTION_ID   = "true"
      }
    service_account_email = google_service_account.sa_cloudfunction.email
    }
}

Et paf, le bloc de canard ! C’est bientôt Noël, j’anticipe l’indigestion !

Quand on mélange du Terraform et du Python, illustration d’une femme qui a mal au cœur.

Donc, en vrac, on a :

  • Un compte de service pour les autorisations accroché à mon compute de patch, il doit pouvoir faire du Container Registry, écrire des logs et reporter son état à Cloud Batch.
  • Un compte de service pour la Cloud Function qui déclenche Cloud Batch, qui a le droit de s’invoquer, de faire des batchs et d’impliquer son service account de Cloud Batch (pour accrocher le service account compute de Cloud Batch).
  • Une mécanique infernale avec du bucket et du zip, pour générer une archive qui change quand le code de la Cloud Function change, l’uploader dans un bucket et déployer la Cloud Function dans une nouvelle version.

La Cloud Function avec plein de paramètres (je m’en fous qu’ils soient exposés, je détruis le projet avant de publier).

Bon, c’est juste de la plomberie, mais c’est important d’avoir tout ça pour comprendre la mécanique et la configuration de la Cloud Function qui suit.

Pis là, je te mets le code de ma Cloud Function de l’enfer, qui va finalement déclencher mon Cloud Batch avec l’image Docker en paramètre.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
#!/bin/env python3
import functions_framework
import os
import sys
import json
import base64
import requests
from datetime import datetime
import google.auth
import google.auth.transport.requests


if not os.getenv("LOCAL_DEV", False):
    import google.cloud.logging

    client = google.cloud.logging.Client()
    client.setup_logging()

import logging

logging.basicConfig(level=logging.INFO)

if not os.getenv("LOCAL_DEV", False):
    logging.basicConfig(level=logging.DEBUG)


def get_auth_token() -> str:
    creds, project = google.auth.default()
    auth_req = google.auth.transport.requests.Request()
    creds.refresh(auth_req)
    return creds.token


def get_mandatory_env_var_or_exit() -> dict:
    env_vars = [
        "BATCH_PROJECT_NAME",
        "BATCH_REGION",
        "BATCH_NETWORK",
        "BATCH_SUBNETWORK",
        "SA_BATCH_COMPUTE",
        "BATCH_DOCKER_IMAGE",
        "TARGET_REPO",
    ]

    checked_vars = {}
    for var in env_vars:
        checked_vars[var] = os.getenv(var)
        if not checked_vars[var]:
            message = (
                f"env var {var} is required to run the application, fail to start."
            )
            logging.fatal(message)
            sys.exit(-1)
    return checked_vars


def trigger_cloud_batch_patch_image(docker_image, env_vars) -> int:
    headers = {"Authorization": f"Bearer {get_auth_token()}"}

G    image_tag = docker_image.split("/")[-1].replace(":", "-").replace(".", "-")
    url = f"https://batch.googleapis.com/v1alpha/projects/{env_vars.get('BATCH_PROJECT_NAME')}/locations/{env_vars.get('BATCH_REGION')}/jobs?job_id=patch-{image_tag}-{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}"
    batch = {
        "taskGroups": [
            {
                "taskSpec": {
                    "runnables": [
                        {
                            "container": {
                                "imageUri": env_vars.get("BATCH_DOCKER_IMAGE"),
                                "entrypoint": "./patch-image.py",
                                "volumes": [
                                    "/var/run/docker.sock:/var/run/docker.sock:rw",
                                    "/sys/fs/cgroup:/sys/fs/cgroup:rw",
                                ],
                                "options": "--privileged --cap-add=CAP_SETGID --cap-add=SETUID --cap-add=SYS_RESOURCE --cap-add=SYS_PTRACE --cap-add=DAC_OVERRIDE --cap-add=SYS_ADMIN",
                            },
                            "environment": {
                                "variables": {
                                    "ORIGINAL_DOCKER_IMAGE": docker_image,
                                    "TARGET_REPO": env_vars.get("TARGET_REPO"),
                                }
                            },
                        }
                    ],
                    "computeResource": {"cpuMilli": 2000, "memoryMib": 16},
                    "maxRunDuration": "600s",
                },
                "taskCount": 1,
                "parallelism": 1,
            }
        ],
        "allocationPolicy": {
            "network": {
                "networkInterfaces": [
                    {
                        "network": env_vars.get("BATCH_NETWORK"),
                        "subnetwork": env_vars.get("BATCH_SUBNETWORK"),
                        "noExternalIpAddress": True,
                    }
                ]
            },
            "instances": [{"policy": {"machineType": "e2-standard-4"}}],
            "serviceAccount": {"email": env_vars.get("SA_BATCH_COMPUTE")},
        },
        "logsPolicy": {"destination": "CLOUD_LOGGING"},
    }

    logging.info(batch)
    response = requests.post(url, headers=headers, json=batch, timeout=60)
    logging.info(response.text)
    logging.info(f"trigger cloudbatch status code : {response.status_code}")
    return response.status_code


@functions_framework.http
def trigger_patch(request):

    env_vars = get_mandatory_env_var_or_exit()
    request_json = request.get_json(silent=True)
    if request_json and "docker_image" in request_json:
        docker_image = request_json["docker_image"]
    else:
        msg = "Missing required environment variable docker_image."
        logging.error(msg)
        return msg

    return_code = trigger_cloud_batch_patch_image(docker_image, env_vars)
    return str(return_code)

Le fichier des requirements que j’ai extrait de pipenv (ça, c’est si tu es assez fou pour essayer de le refaire marcher sur ton env GCP).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
-i https://pypi.org/simple
blinker==1.8.2; python_version >= '3.8'
cachetools==5.5.0; python_version >= '3.7'
certifi==2024.8.30; python_version >= '3.6'
charset-normalizer==3.4.0; python_version >= '3.7'
click==8.1.7; python_version >= '3.7'
cloudevents==1.11.0
deprecated==1.2.14; python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'
deprecation==2.1.0
flask==3.0.3; python_version >= '3.8'
functions-framework==3.8.1
google-api-core[grpc]==2.21.0; python_version >= '3.7'
google-auth==2.35.0
google-cloud-appengine-logging==1.5.0; python_version >= '3.7'
google-cloud-audit-log==0.3.0; python_version >= '3.7'
google-cloud-core==2.4.1; python_version >= '3.7'
google-cloud-logging==3.11.3
googleapis-common-protos==1.65.0; python_version >= '3.7'
grpc-google-iam-v1==0.13.1; python_version >= '3.7'
grpcio==1.67.0
grpcio-status==1.67.0
gunicorn==23.0.0; platform_system != 'Windows'
idna==3.10; python_version >= '3.6'
importlib-metadata==8.4.0; python_version >= '3.8'
itsdangerous==2.2.0; python_version >= '3.8'
jinja2==3.1.4; python_version >= '3.7'
markupsafe==3.0.2; python_version >= '3.9'
opentelemetry-api==1.27.0; python_version >= '3.8'
packaging==24.1; python_version >= '3.8'
proto-plus==1.25.0; python_version >= '3.7'
protobuf==5.28.3; python_version >= '3.8'
pyasn1==0.6.1; python_version >= '3.8'
pyasn1-modules==0.4.1; python_version >= '3.8'
requests==2.32.3
rsa==4.9; python_version >= '3.6' and python_version < '4'
urllib3==2.2.3; python_version >= '3.8'
watchdog==5.0.3; python_version >= '3.9'
werkzeug==3.0.5; python_version >= '3.8'
wrapt==1.16.0; python_version >= '3.6'
zipp==3.20.2; python_version >= '3.8'

Donc, la seule “magie” qui peut rester dans le code, c’est dans le dictionnaire de configuration de la Cloud Function. Sur la partie volume et configuration, le container Docker doit tourner avec des droits complètement pétés, parce qu’il utilise le daemon Docker dans le container qui le fait tourner.

Tout le reste, c’est de l’enrobage un peu simplet pour vérifier que les variables d’env sont configurées, parser le dict JSON envoyé en paramètre pour attraper l’image Docker.

Et tout ça, pour quoi finalement ? Pour la gloire de Satan, bien évidemment.

1
2
3
4
5
6
7
curl -m 70 -X POST https://europe-west9-patch-container.cloudfunctions.net/trigger_patch \
-H "Authorization: bearer $(gcloud auth print-identity-token)" \
-H "Content-Type: application/json" \
-d '{
  "docker_image": "memcached:1.6.31"
}'
200

Et ouais, ça fait 200, pas mal non ? C’est Français

la classe américaine, pas mal non ? c’est français

Test sur 3 images

J’ai fait un tour sur Docker Hub pour sélectionner 3 images intéressantes pour démontrer mon système. Finalement, ce n’est pas si simple que ça : il me faut des images avec des CVE systèmes patchables et des CVE de libs patchables, pour démontrer que les libs systèmes sont patchées, mais pas les libs de code pour limiter la casse. Et le dernier critère, mais pas des moindres, des images officielles de produits connus et bien utilisés :)

J’ai donc choisi mes 3 starters Pokémon : Vault, Memcached et Alpine. Là, vous ne pouvez pas me dire que j’ai choisi les fonds de tiroir de Docker Hub sur des versions de 5 ans d’âge. Mais j’avoue que Docker Hub a fait un effort particulier pour renforcer la visibilité sur les images vulnérables et que c’était la chasse au trésor.

1
2
3
4
REPOSITORY                                                 TAG                IMAGE ID       CREATED        SIZE
alpine                                                     3.20.0             1d34ffeaf190   6 months ago   7.79MB
memcached                                                  1.6.31             b047d324da06   2 months ago   84.8MB
vault                                                      1.13.3             806d527c0cfb   8 months ago   255MB

Je vous mets le rapport de sécurité digest de Docker Hub et après, on regarde en détail le post-traitement.

alpine-3.20.0 Critical 1, High 2, Medium 3, Low 0, Unspecified 0

memcached:1.6.31 Critical 1, High 0, Medium 1, Low 12, Unspecified 0

vault:1.13.3 Critical 4, High 25, Medium 31, Low 3, Unspecified 2

Donc, j’ai pris des images raisonnablement récentes avec des CVE système patchables.

Je lance ma moulinette et voilà les résultats que j’ai obtenus avec le scanner de vulnérabilités de tonton GCP.

6 vulnérabilité sur l’image de base, 0 sur l’image patché

35 vulnérabilité sur l’image de base, 33 sur l’image patché

43 vulnérabilité sur l’image de base, 33 sur l’image patché

Maintenant, en regardant dans les détails des vulnérabilités restantes, vous allez comprendre pourquoi j’obtiens ces résultats.

Sur l’image Alpine, c’est assez simple : seules des vulnérabilités d’OS patchables, et à la fin du traitement, toutes les libs ont été patchées.

liste des CVE sur alpine

Sur l’image Memcached, seules 2 vulnérabilités OS sont patchables. On conserve donc forcément toutes les CVE existantes qui n’ont pas de patch.

liste des CVE sur memcached

Sur l’image Vault, on a 42 CVE patchables, mais seulement 10 concernent des libs systèmes. On conserve donc les CVE liées aux libs et celles pour lesquelles aucun patch n’est disponible.

liste des CVE sur vault

Limites du système et conclusion

Mon système offre une résolution partielle au problème douloureux de la gestion des CVE. Il fait un compromis en patchant les libs systèmes, qui sont très souvent des CVE liées à des libs C, lesquelles sont pénibles à patcher mais peu risquées à faire évoluer vers des versions plus récentes.

Les libs de code sont beaucoup plus à risque, car elles peuvent entraîner des breaking changes potentiels, compliqués à déboguer en raison de la mise à niveau de libs que l’on ne maîtrise pas.

Ce système apporte un moyen mécanique et standardisé de diminuer la surface des vulnérabilités (en s’attaquant à la classe de CVE la plus courante, on réduit de facto le nombre de vulnérabilités), mais il reste relativement complexe et un peu limité.

Nous n’adressons pas les CVE liées aux libs de code, donc le code vulnérable doit tout de même être audité et patché d’une autre manière, pourquoi pas avec Renovate, par exemple ? RenovateBot.

Il est possible d’introduire des changements cassants, donc il est essentiel de bien tester les nouvelles images patchées.

Mon système de patching est conçu principalement pour les images basées sur Debian et Alpine. Les images à base de Red Hat ou d’autres distributions plus exotiques ne sont actuellement pas prises en charge.

Le système décharge une partie de la difficulté de la mise à jour, mais il faut quand même allouer du temps et des personnes pour effectuer les changements d’images et les tests.

Néanmoins, il permet d’apporter une solution sur les images non construites par vos équipes, qui sont installées et utilisées pendant une longue période.

La meilleure solution pour avoir des images avec le moins de CVE reste de construire vos images vous-même et de contrôler drastiquement ce que vous y mettez…

Vous vous souvenez de l’image Docker que j’ai utilisée pour ce projet ?

Eh bien, je l’ai fait passer à la moulinette… et ce n’est pas glorieux, hahaha !

image de base 750 vulns, image patché 750 vulns

750 vulnérabilités, que des libs de code, ça craint, mouhahaha ! Eh bien, bravo et Joyeux Noël ! :)

meme chernobyl, not great, not terrible, 750 CVE sur un seul container, procedure standard