درس 11 — Le code Terraform, couche par couche

10. `70_detection.tf` : journaux d'audit et alertes de sécurité

13 دقائق قراءة

هذا المحتوى غير مترجم بعد. تُعرض النسخة الأصلية.

Active les journaux d'audit utiles, crée un canal d'alerte e-mail et déclenche une alerte dès qu'un garde-fou est modifié. Bases Terraform : 00-bases-terraform.md.


1. À quoi sert ce fichier

Protéger ne suffit pas : il faut voir quand quelqu'un touche aux protections. Ce fichier active les journaux d'accès aux données (désactivés par défaut chez Google) pour trois services sensibles. Il crée ensuite une métrique qui compte les modifications de garde-fous dans les journaux, et une alerte qui envoie un e-mail à l'équipe sécurité dès que ce compteur dépasse zéro. Une seconde alerte optionnelle surveille les pics de requêtes bloquées par Model Armor.


2. Vue d'ensemble

Bloc (adresse)Type de blocRôle en une phrase
google_project_iam_audit_config.auditresource (for_each, 3)Active les journaux « Data Access » (lecture et écriture) de 3 services.
google_monitoring_notification_channel.securityresource (0 ou 1)Le canal e-mail de l'équipe sécurité.
google_logging_metric.security_changesresourceCompte les entrées de journal qui modifient un garde-fou.
google_monitoring_alert_policy.security_changesresourceAlerte dès que ce compteur dépasse 0 en 5 minutes.
google_logging_metric.armor_matchesresource (0 ou 1)Compte les blocages Model Armor (seulement si le contenu est journalisé).
google_monitoring_alert_policy.armor_spikeresource (0 ou 1)Alerte si plus de 20 blocages en 10 minutes.

3. Le code expliqué, bloc par bloc

Vocabulaire.

  • Cloud Logging : le service qui stocke les journaux (logs) de Google Cloud.
  • Journaux d'audit (audit logs) : qui a fait quoi, quand. Deux familles utiles ici :
    • Admin Activity (activité d'administration) : les modifications de configuration. Toujours actifs, gratuits, sans contenu utilisateur ;
    • Data Access (accès aux données) : les lectures et écritures de données. Désactivés par défaut, il faut les activer service par service.
  • Cloud Monitoring : le service des métriques et des alertes.
  • Métrique basée sur les journaux (log-based metric) : un compteur qui augmente de 1 à chaque entrée de journal correspondant à un filtre.

3.1 Les journaux d'audit : google_project_iam_audit_config.audit

resource "google_project_iam_audit_config" "audit" {
  for_each = toset(["discoveryengine.googleapis.com", "modelarmor.googleapis.com", "secretmanager.googleapis.com"])
  project  = var.project_id
  service  = each.value
  audit_log_config {
    log_type = "DATA_READ"
  }
  audit_log_config {
    log_type = "DATA_WRITE"
  }
}
  • for_each = toset([...]) : crée un exemplaire par élément de la liste (00-bases §8.2). toset transforme la liste en ensemble (obligatoire : for_each n'accepte pas une liste simple). On obtient 3 ressources, d'adresses :
    • google_project_iam_audit_config.audit["discoveryengine.googleapis.com"] (Gemini Enterprise : qui utilise l'app) ;
    • google_project_iam_audit_config.audit["modelarmor.googleapis.com"] (les appels de filtrage) ;
    • google_project_iam_audit_config.audit["secretmanager.googleapis.com"] (chaque lecture d'un secret de connecteur).
  • project = var.project_id : le projet concerné.
  • service = each.value : le service en cours (each.value = l'élément de l'ensemble).
  • Deux sous-blocs audit_log_config : on active DATA_READ (lectures) et DATA_WRITE (écritures). Le bloc est simplement écrit deux fois : quand le nombre de blocs est fixe, pas besoin de dynamic.

Pourquoi pas Cloud KMS ? (commentaire du fichier et README §10.1) : chaque déchiffrement serait journalisé, soit des millions de lignes sans valeur. Les opérations importantes sur les clés (désactivation, destruction) sont déjà dans les journaux Admin Activity, toujours actifs.

3.2 Le canal d'alerte : google_monitoring_notification_channel.security

resource "google_monitoring_notification_channel" "security" {
  count        = var.alert_email == "" ? 0 : 1
  display_name = "Équipe sécurité IA"
  type         = "email"
  labels       = { email_address = var.alert_email }
}
  • count = var.alert_email == "" ? 0 : 1 : pas d'adresse → pas de canal (00-bases §8.1). Avec le terraform.tfvars.example, alert_email = "securite-ia@acme.example", donc 1 canal.
  • display_name : le nom affiché dans Cloud Monitoring.
  • type = "email" : un canal de type e-mail (il existe aussi SMS, Slack, PagerDuty…).
  • labels = { email_address = var.alert_email } : les paramètres du canal. Pour un e-mail, un seul : l'adresse.

3.3 La métrique « garde-fou modifié » : google_logging_metric.security_changes

resource "google_logging_metric" "security_changes" {
  name   = "ge_security_config_changes"
  filter = <<-EOT
    logName:"cloudaudit.googleapis.com%2Factivity"
    (
      (protoPayload.serviceName="modelarmor.googleapis.com" AND protoPayload.methodName=~"(UpdateTemplate|DeleteTemplate|UpdateFloorSetting)")
      OR (protoPayload.serviceName="cloudkms.googleapis.com" AND protoPayload.methodName=~"(DestroyCryptoKeyVersion|UpdateCryptoKeyVersion|UpdateCryptoKeyPrimaryVersion)")
      OR (protoPayload.serviceName="discoveryengine.googleapis.com" AND protoPayload.methodName=~"(UpdateAssistant|DeleteEngine|UpdateAclConfig|CmekConfig)")
      OR (protoPayload.methodName=~"SetIamPolicy" AND protoPayload.serviceName=("cloudresourcemanager.googleapis.com" OR "cloudkms.googleapis.com" OR "secretmanager.googleapis.com"))
    )
  EOT
  • name = "ge_security_config_changes" : le nom de la métrique. Cloud Monitoring la publiera sous le type logging.googleapis.com/user/ge_security_config_changes (le préfixe user/ signale une métrique créée par vous).
  • filter = <<-EOT … EOT : le filtre, écrit sur plusieurs lignes grâce à un heredoc (00-bases §12.3). Attention : ce texte n'est pas du Terraform, c'est le langage de requête de Cloud Logging (le même que dans Logs Explorer).

Le filtre, ligne par ligne :

LigneSignification
logName:"cloudaudit.googleapis.com%2Factivity"Ne garder que les journaux Admin Activity. logName vaut par exemple projects/mon-projet/logs/cloudaudit.googleapis.com%2Factivity. L'opérateur : veut dire « contient » (pas « égal »), donc pas besoin d'écrire le début. %2F est le caractère / encodé, tel qu'il apparaît dans le nom du journal.
(passage à la ligne)Deux conditions écrites l'une après l'autre sont reliées par un ET implicite. L'entrée doit donc être un journal d'activité ET satisfaire le bloc entre parenthèses.
( … )Regroupe quatre cas reliés par OR (OU) : il suffit qu'un seul soit vrai.
protoPayload.serviceName="modelarmor.googleapis.com"protoPayload est le contenu de l'entrée d'audit. serviceName = le service appelé. = = égal exactement.
AND protoPayload.methodName=~"(UpdateTemplate|DeleteTemplate|UpdateFloorSetting)"methodName = l'opération appelée. =~ = « correspond à l'expression régulière ». (A|B|C) = A ou B ou C. Cas 1 : un template Model Armor modifié ou supprimé, ou le plancher modifié.
OR (… "cloudkms.googleapis.com" AND … "(DestroyCryptoKeyVersion|UpdateCryptoKeyVersion|UpdateCryptoKeyPrimaryVersion)")Cas 2 : une version de clé KMS détruite, modifiée (par exemple désactivée) ou une autre version rendue primaire.
OR (… "discoveryengine.googleapis.com" AND … "(UpdateAssistant|DeleteEngine|UpdateAclConfig|CmekConfig)")Cas 3 : l'assistant modifié, l'app supprimée, la configuration d'identité modifiée, ou toute opération dont le nom contient CmekConfig.
OR (protoPayload.methodName=~"SetIamPolicy" AND protoPayload.serviceName=("…resourcemanager…" OR "…kms…" OR "…secretmanager…"))Cas 4 : un droit IAM modifié (SetIamPolicy) sur le projet, une clé ou un secret. =~ (expression régulière) attrape aussi les noms longs comme google.iam.v1.IAMPolicy.SetIamPolicy. serviceName=("a" OR "b") est un raccourci pour serviceName="a" OR serviceName="b".

Une expression régulière sans ^ ni $ cherche le motif n'importe où dans le texte. Comme les noms de méthode réels sont souvent longs (par exemple google.cloud.discoveryengine.v1.AssistantService.UpdateAssistant), =~"UpdateAssistant" les trouve quand même.

  metric_descriptor {
    metric_kind = "DELTA"
    value_type  = "INT64"
  }
  depends_on = [google_project_service.apis]
}
  • metric_descriptor : la forme de la métrique.
    • metric_kind = "DELTA" : chaque point mesure « combien pendant l'intervalle » (et non un total cumulé).
    • value_type = "INT64" : un nombre entier. Ici : le nombre d'entrées de journal correspondantes.
  • depends_on = [google_project_service.apis] : attendre que les API Logging et Monitoring soient activées (00_foundation.tf).

3.4 L'alerte : google_monitoring_alert_policy.security_changes

resource "google_monitoring_alert_policy" "security_changes" {
  display_name = "Gemini Enterprise : modification d'un garde-fou de sécurité"
  combiner     = "OR"
  conditions {
    display_name = "Au moins une modification en 5 minutes"
    condition_threshold {
      filter          = "metric.type=\"logging.googleapis.com/user/${google_logging_metric.security_changes.name}\""
      comparison      = "COMPARISON_GT"
      threshold_value = 0
      duration        = "0s"
  • display_name : le titre de l'alerte (objet de l'e-mail).
  • combiner = "OR" : comment combiner plusieurs conditions. Il n'y en a qu'une, donc OR ou AND revient au même ; l'argument est obligatoire.
  • conditions { … } : la condition qui déclenche l'alerte.
    • condition_threshold : une condition à seuil (« si la valeur dépasse X »).
    • filter : quelle métrique surveiller. Les \" sont des guillemets échappés : ils font partie du texte, au lieu de fermer la chaîne Terraform. Le résultat est : metric.type="logging.googleapis.com/user/ge_security_config_changes". Utiliser .name (et non écrire le nom en dur) crée une dépendance : Terraform crée la métrique avant l'alerte.
    • comparison = "COMPARISON_GT" : « strictement supérieur à » (GT = greater than).
    • threshold_value = 0 : le seuil. Donc : dès qu'il y a au moins 1 modification.
    • duration = "0s" : pendant combien de temps la condition doit rester vraie avant d'alerter. 0s = tout de suite.
      aggregations {
        alignment_period     = "300s"
        per_series_aligner   = "ALIGN_SUM"
        cross_series_reducer = "REDUCE_SUM"
      }
    }
  }

Les agrégations, simplement. Une métrique peut produire plusieurs « séries » (une par combinaison d'étiquettes, par exemple une par ressource). Monitoring les ramène à un seul chiffre en deux étapes :

  1. alignment_period = "300s" + per_series_aligner = "ALIGN_SUM" : dans chaque série, on découpe le temps en tranches de 5 minutes et on additionne les points de chaque tranche ;
  2. cross_series_reducer = "REDUCE_SUM" : on additionne toutes les séries entre elles.

Résultat : « nombre total de modifications de garde-fous sur les 5 dernières minutes ». Exemple : 2 changements de template et 1 changement IAM en 5 minutes → 3, et 3 > 0 → alerte.

  notification_channels = google_monitoring_notification_channel.security[*].id
  documentation {
    mime_type = "text/markdown"
    content   = "Un garde-fou (Model Armor, clé KMS, droits, assistant) a été modifié. Vérifier qu'il s'agit d'un changement validé (PR Terraform). Sinon : procédure d'incident du guide."
  }
}
  • notification_channels = google_monitoring_notification_channel.security[*].id : l'expression splat [*]. Elle signifie « l'attribut id de tous les exemplaires », sous forme de liste. C'est la façon propre de lire une ressource créée avec count sans savoir s'il y en a 0 ou 1 :

    • avec alert_email = "securite-ia@acme.example" (1 canal) → ["projects/acme-gemini-enterprise-prd/notificationChannels/1234567890"] (numéro d'exemple) ;
    • avec alert_email = "" (0 canal) → [] : l'alerte existe mais n'envoie rien (visible seulement dans la console).

    [*].id équivaut à [for c in google_monitoring_notification_channel.security : c.id] (00-bases §12.2). Écrire security[0].id planterait quand il n'y a pas de canal.

  • documentation : le texte joint à l'alerte, en Markdown. Il dit au destinataire quoi faire (README §10.3).

3.5 Option : pic de blocages Model Armor

Ces deux ressources n'existent que si var.log_screened_content = true, c'est-à-dire si Model Armor journalise le contenu filtré. Défaut : false, car Google le déconseille pour Gemini Enterprise (les questions des utilisateurs deviendraient lisibles dans les journaux). Sans ces journaux, il n'y a rien à compter.

resource "google_logging_metric" "armor_matches" {
  count  = var.log_screened_content ? 1 : 0
  name   = "ge_model_armor_matches"
  filter = <<-EOT
    jsonPayload.@type="type.googleapis.com/google.cloud.modelarmor.logging.v1.SanitizeOperationLogEntry"
    jsonPayload.sanitizationResult.filterMatchState="MATCH_FOUND"
  EOT
  metric_descriptor {
    metric_kind = "DELTA"
    value_type  = "INT64"
  }
}
  • count = var.log_screened_content ? 1 : 0 : 1 si l'option est activée, 0 sinon.
  • Le filtre, ligne par ligne :
    • jsonPayload.@type="…SanitizeOperationLogEntry" : ne garder que les journaux d'opérations de filtrage Model Armor (jsonPayload = le contenu d'un journal structuré ; @type = son type) ;
    • jsonPayload.sanitizationResult.filterMatchState="MATCH_FOUND" : et seulement celles où un filtre a trouvé quelque chose (= requête ou réponse bloquée ou signalée).
    • Les deux lignes sont reliées par un ET implicite.
  • metric_descriptor : comme en 3.3.
resource "google_monitoring_alert_policy" "armor_spike" {
  count        = var.log_screened_content ? 1 : 0
  display_name = "Gemini Enterprise : pic de requêtes bloquées"
  combiner     = "OR"
  conditions {
    display_name = "Plus de 20 blocages en 10 minutes"
    condition_threshold {
      filter          = "metric.type=\"logging.googleapis.com/user/${google_logging_metric.armor_matches[0].name}\""
      comparison      = "COMPARISON_GT"
      threshold_value = 20
      duration        = "0s"
      aggregations {
        alignment_period     = "600s"
        per_series_aligner   = "ALIGN_SUM"
        cross_series_reducer = "REDUCE_SUM"
      }
    }
  }
  notification_channels = google_monitoring_notification_channel.security[*].id
}
  • Même structure que 3.4, avec trois différences :
    • même count que la métrique : l'une ne va pas sans l'autre ;
    • armor_matches[0].name : [0] car la métrique est créée avec count. Ici on peut écrire [0], parce que l'alerte n'existe que si la métrique existe (même condition) ;
    • seuil 20 sur 600s (10 minutes) : quelques blocages isolés sont normaux ; plus de 20 en 10 minutes ressemble à une attaque (tentatives répétées d'injection de prompt) ou à un filtre mal réglé.

4. Ce que Terraform crée concrètement

Avec le terraform.tfvars.example (alert_email renseigné, log_screened_content absent donc false) : 6 ressources

  • 3 configurations d'audit (Gemini Enterprise, Model Armor, Secret Manager : lectures et écritures journalisées) ;
  • 1 canal e-mail vers securite-ia@acme.example ;
  • 1 métrique ge_security_config_changes ;
  • 1 alerte « modification d'un garde-fou de sécurité ».

La métrique et l'alerte « pic de blocages » ne sont pas créées (0 + 0).


5. Dépendances avec les autres fichiers

LienFichierFiche
Utilise var.project_id, var.alert_email, var.log_screened_contentvariables.tf02-variables.md
Attend google_project_service.apis00_foundation.tf03-foundation.md
Surveille les droits IAM créés par10_identity_access.tf, 20_data_protection.tf04-identity-access.md, 05-data-protection.md
Surveille les templates et le plancher Model Armor ; log_screened_content y règle aussi la journalisation du contenu30_ai_guardrails.tf06-ai-guardrails.md
Surveille l'assistant et l'app50_app.tf08-app.md

6. Pour reproduire / adapter

Besoin type : créer une alerte sur un autre événement d'audit (exemple : quelqu'un supprime un secret).

# 1. Compter les entrées de journal qui correspondent à l'événement.
resource "google_logging_metric" "secret_deleted" {
  name   = "secret_deleted"                                   # lettres, chiffres, _ : sera "user/secret_deleted"
  filter = <<-EOT
    logName:"cloudaudit.googleapis.com%2Factivity"
    protoPayload.serviceName="secretmanager.googleapis.com"
    protoPayload.methodName=~"DeleteSecret"
  EOT
  metric_descriptor {
    metric_kind = "DELTA"                                     # "combien pendant l'intervalle"
    value_type  = "INT64"
  }
  depends_on = [google_project_service.apis]
}

# 2. Alerter dès que le compteur dépasse le seuil.
resource "google_monitoring_alert_policy" "secret_deleted" {
  display_name = "Secret supprimé"
  combiner     = "OR"
  conditions {
    display_name = "Au moins une suppression en 5 minutes"
    condition_threshold {
      filter          = "metric.type=\"logging.googleapis.com/user/${google_logging_metric.secret_deleted.name}\""
      comparison      = "COMPARISON_GT"
      threshold_value = 0                                     # seuil
      duration        = "0s"                                  # alerter tout de suite
      aggregations {
        alignment_period     = "300s"                         # fenêtre de 5 minutes
        per_series_aligner   = "ALIGN_SUM"
        cross_series_reducer = "REDUCE_SUM"
      }
    }
  }
  notification_channels = google_monitoring_notification_channel.security[*].id   # réutiliser le canal existant
  documentation {
    mime_type = "text/markdown"
    content   = "Un secret a été supprimé. Vérifier la PR correspondante."
  }
}
  1. Trouver l'événement réel : faire l'action une fois en recette, puis la chercher dans Logs Explorer. Noter protoPayload.serviceName et protoPayload.methodName exacts.
  2. Tester le filtre dans Logs Explorer : il doit afficher l'entrée, et seulement les entrées voulues.
  3. Copier le filtre dans google_logging_metric : https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/logging_metric
  4. Créer l'alerte : https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/monitoring_alert_policy (canal : https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/monitoring_notification_channel)
  5. Si l'événement est dans les journaux Data Access, vérifier que l'audit du service est activé (https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/google_project_iam, section google_project_iam_audit_config) et utiliser cloudaudit.googleapis.com%2Fdata_access au lieu de %2Factivity.
  6. apply, refaire l'action en recette, vérifier la réception de l'e-mail.

7. Pièges et bonnes pratiques

  • Les noms de méthodes sont à valider (README §16) : ils viennent de la documentation, pas encore de journaux réels. Après le premier changement observé, comparez avec Logs Explorer et ajustez le filtre. Les cas utilisent =~ (contient / expression régulière) plutôt que = (égalité exacte) pour tolérer les noms de méthodes longs.
  • Les changements faits par Terraform déclenchent aussi l'alerte. C'est voulu : une modification légitime passe par une PR, et l'alerte sert de confirmation. Une alerte sans PR correspondante = incident.
  • Une métrique basée sur les journaux ne compte qu'à partir de sa création : elle ne voit pas le passé.
  • google_project_iam_audit_config fait autorité pour son service : il remplace la configuration d'audit existante de ce service (par exemple des exemptions ajoutées à la main). Ne gérez l'audit d'un service qu'à un seul endroit.
  • Les journaux Data Access ont un coût (volume ingéré dans Cloud Logging). D'où l'exclusion de Cloud KMS.
  • Sans alert_email, les alertes existent mais ne préviennent personne. Renseignez toujours une boîte suivie (liste de diffusion de l'équipe, pas une personne).
  • Échapper les guillemets dans un filter sur une seule ligne (\"). Dans un heredoc <<-EOT, pas besoin : c'est pour ça que les filtres longs y sont écrits.
  • Activer log_screened_content est un choix vie privée, pas technique : à décider avec le DPO (README §15).
التالي