Skip to main content

Rego 102 : combiner des requêtes avec AND/OR et des messages personnalisés

feature cloud security

9 novembre 2023

0 minutes de lecture

Cette série d’articles de blog propose une introduction en douceur à Rego, le langage de politiques créé par les concepteurs du moteur Open Policy Agent (OPA). Vous débutez et souhaitez vous lancer dans l’écriture de politiques sous forme de code avec Rego ? Vous êtes au bon endroit.

Dans cette série en trois parties, nous aborderons les sujets suivants :

Pour rappel, Rego est un langage déclaratif de requête créé par les concepteurs du framework Open Policy Agent (OPA). La Cloud Native Computing Foundation (CNCF) a accepté OPA en tant que projet hébergé de niveau incubation en avril 2019. OPA a obtenu son diplôme du statut de projet en incubation en 2021.

Rego sert à écrire des politiques sous forme de code, en appliquant des pratiques de programmation telles que le contrôle de version et la conception modulaire pour évaluer les ressources cloud et d’infrastructure sous forme de code (IaC). OPA est le moteur qui évalue les politiques écrites en Rego. Snyk utilise le langage Rego pour créer des règles personnalisées.

Récapitulatif de la partie 1

Dans la partie 1 de cette série d’articles, nous avons expliqué qu’une règle Rego est une affectation conditionnelle. Une règle interroge l’entrée afin de trouver une correspondance avec une condition. Si elle en trouve une, une valeur est affectée à une variable.

Vous pouvez lire une règle comme celle-ci :

THIS VARIABLE    :=	HAS THIS VALUE {
    IF THESE CONDITIONS ARE MET
}

Voici l’exemple que nous avions utilisé : il représente une politique d’entreprise selon laquelle seule Alice, administratrice réseau, doit être autorisée à créer et supprimer des réseaux virtuels dans le compte prod :

allow := true {
  input.user == "alice"
}

OPA évalue un document d’entrée JSON ou YAML par rapport à une règle afin de produire une décision de politique. Le document d’entrée ci-dessous représente l’utilisateur actuellement connecté :

{
  "user": "alice"
}

Si vous utilisez OPA pour évaluer l’entrée par rapport à cette règle, il trouve une correspondance avec la requête input.user == "alice". La variable dans l’en-tête de la règle, allow, reçoit donc la valeur indiquée dans cet en-tête : true. Voici le résultat qui le prouve :

{
  "allow": true
}

OPA a rendu sa décision : Alice, l’utilisateur actuellement connecté, est autorisée à créer et supprimer des réseaux virtuels dans le compte prod. L’entrée est conforme à la règle.

AND et OR

Jusqu’ici, nous n’avons présenté que des règles avec une seule requête. Une règle peut aussi contenir plusieurs requêtes. Dans ce cas, elles représentent plusieurs conditions qui doivent toutes être remplies pour qu’une valeur soit affectée à une variable. Un AND est implicite : « Cette condition doit être remplie ET celle-ci aussi. »

Par exemple, dans la règle ci-dessous, input.user == "alice" ET input.environment == "prod" doivent être vrais pour que la variable allow reçoive la valeur true :

allow := true {
  input.user == "alice"
  input.environment == "prod"
}

Dans certains cas, OR est plus approprié. Vous pouvez exprimer OR en utilisant le même en-tête dans plusieurs règles :

allow = true {
  input.user == "alice"
}

allow = true {
  input.user == "bob"
}

On peut lire cet ensemble de règles ainsi :

allow vaut true si user est égal à "alice" OU si user est égal à "bob".

Techniquement, cet ensemble forme une seule règle, car l’en-tête est le même dans les deux cas. Comme vous définissez cette règle en plusieurs étapes, on l’appelle une règle incrémentale. 

Vous pouvez aussi supprimer le deuxième en-tête et réunir les corps. Voici une façon plus concise d’écrire ce qui précède :

allow = true {
  input.user == "alice"
} {
  input.user == "bob"
}

Vous vous demandez peut-être pourquoi nous avons utilisé l’opérateur d’unification = plutôt que l’opérateur d’affectation :=. C’est parce que les variables sont immuables dans Rego. Même si les règles ayant le même en-tête sont considérées comme une seule règle incrémentale, l’utilisation de l’opérateur d’affectation revient à « affecter » plusieurs fois la même variable — ce que Rego n’autorise pas. Nous utilisons donc l’opérateur d’unification dans l’en-tête de la règle, car il unifie plusieurs règles portant le même nom.

Si vous avez du mal à vous rappeler quel opérateur utiliser dans l’en-tête d’une règle, les valeurs par défaut et une petite astuce syntaxique simplifient les choses.

Valeurs par défaut dans les en-têtes de règles

La valeur par défaut attribuée à une variable dans l’en-tête d’une règle est true. Rego propose donc une simplification syntaxique : lorsqu’une règle attribue la valeur true à la variable, vous pouvez omettre := true de l’en-tête de la règle.

Cela signifie que cette règle AND…

allow := true {
  input.user == "alice"
  input.environment == "prod"
}

…équivaut à cette règle AND :

allow {
  input.user == "alice"
  input.environment == "prod"
}

Et de même, cette règle OR…

allow = true {
  input.user == "alice"
} {
  input.user == "bob"
}

…équivaut à cette règle OR :

allow {
  input.user == "alice"
} {
  input.user == "bob"
}

Plutôt sympa, non ?

Mot-clé default

Comme nous l’avons vu dans la partie 1, si aucune correspondance n’est trouvée dans l’entrée pour une requête de règle, la variable de l’en-tête de la règle ne reçoit pas la valeur indiquée dans l’en-tête.

Pour le montrer, revenons à notre exemple de règle, selon laquelle seule Alice, administratrice réseau, doit être autorisée à créer et supprimer des réseaux virtuels dans le compte prod :

allow := true {
  input.user == "alice"
}

Prenons maintenant un document d’entrée où l’utilisateur actuellement connecté est Bob :

{
  "user": "bob"
}

Comme input.user n’est pas égal à "alice", OPA ne trouve pas de correspondance dans l’entrée lorsqu’il évalue la règle. Par conséquent, allow ne reçoit pas la valeur true, et l’évaluation renvoie un ensemble vide :

{}

Dans ce cas, on dit que la valeur de allow est indéfinie. Lorsqu’OPA interroge l’entrée pour évaluer une règle, il renvoie uniquement les valeurs correspondantes. S’il n’y a aucune valeur correspondante, il n’y a rien à renvoyer : l’ensemble est donc vide.

Et si nous voulons qu’OPA renvoie false lorsque allow n’est pas explicitement défini sur true ? Nous pouvons utiliser le mot-clé default pour définir une valeur par défaut. Ainsi, si l’évaluation d’une règle ne renvoie pas explicitement true, elle renvoie une valeur spécifique (ici, false) au lieu d’un ensemble de résultats vide. Pour cela, nous écrivons une règle supplémentaire qui utilise également allow :

default allow = false

Désormais, si OPA détermine que input.user n’est pas égal à "alice", allow ne renvoie pas un ensemble vide. Il prend plutôt la valeur par défaut que nous avons déclarée, soit false :

{
  "allow": false
}

Notez que lorsque vous utilisez le mot-clé default, vous devez utiliser l’opérateur d’unification = plutôt que l’opérateur d’affectation :=, aussi bien dans la règle qui définit la valeur par défaut que dans celle qui définit l’affectation conditionnelle. Là encore, c’est parce que les variables sont immuables. Si vous utilisez l’opérateur d’affectation, vous « affectez » plusieurs fois la même variable, ce que Rego n’autorise pas. Utilisez plutôt l’opérateur d’unification :

default allow = false

allow = true {
  input.user == "alice"
}

Vous pouvez bien sûr tirer parti de la simplification syntaxique décrite plus haut et omettre = true dans la règle avec l’affectation conditionnelle. C’est tout à fait acceptable et peut-être plus simple, car vous n’avez pas à vous rappeler quel opérateur utiliser pour l’affectation conditionnelle :

default allow = false

allow {
  input.user == "alice"
}

Messages personnalisés

Il est parfois préférable de renvoyer une série de messages plutôt qu’un simple résultat de réussite ou d’échec, soit true, soit false/undefined. Pour cela, utilisez l’en-tête de règle deny[msg] et attribuez le message souhaité à la variable msg. La règle ci-dessous vérifie si l’utilisateur n’est pas Alice et, le cas échéant, attribue la chaîne « User is denied access » à msg, qui est ensuite ajoutée à l’ensemble deny (nous parlerons davantage des ensembles dans la partie 3) :

deny[msg] {
  input.user != "alice"
  msg := "User is denied access"
}

Pour tester cela, supposons que notre document d’entrée contienne le nom de l’utilisateur actuellement connecté :

{
	"user": "bob"
}

Lorsque nous évaluons la règle par rapport à l’entrée ci-dessus avec le Rego Playground d’OPA ou la commande opa eval -i input.json -d check_user.rego "data.rules.check_user" --format pretty (pour savoir comment faire, consultez l’article de blog de la partie 1), le résultat ressemble à ceci :

{
  "deny": [
	"User is denied access"
  ]
}

Que se passe-t-il ici ? Nous créons en fait une règle d’ensemble, un concept que nous retrouverons dans le prochain article de cette série. Pour l’instant, retenez qu’au lieu d’un seul résultat true, false ou undefined, nous renvoyons un ensemble de messages attribués à la variable deny. Dans Rego, un ensemble est une liste non ordonnée d’éléments uniques, comme des nombres entiers { 1, 2, 3 }, des chaînes { "alice", "bob", "carlotta" } ou même d’autres ensembles { { 1, 2}, {3, 4} }. Vous pouvez créer un ensemble à partir de n’importe quel type Rego pris en charge, ou même mélanger différents types dans un même ensemble.

Dans notre exemple, chaque élément de l’ensemble deny est une chaîne contenant un message. Pour ce document d’entrée, l’ensemble ne contient qu’un élément, mais il peut en contenir davantage. Nous vous en montrerons un exemple plus loin dans cet article.

Et si nous voulons ajouter des informations au message ? Nous pouvons utiliser la fonction intégrée sprintf pour afficher la valeur du champ input.user qui a provoqué un résultat deny :

deny[msg] {
  input.user != "alice"
  msg := sprintf("User %v is denied access", [input.user])
}

La fonction sprintf prend deux arguments : une chaîne et un tableau de valeurs. Dans cet exemple, le seul élément du tableau est une chaîne représentée par input.user. Nous utilisons %v comme espace réservé dans le premier argument, qui est remplacé par la valeur du tableau lors de l’évaluation de la règle.

Maintenant, si nous évaluons la règle avec l’entrée suivante…

{
	"user": "bob"
}

…nous obtenons ce résultat :

{
  "deny": [
    "User bob is denied access"
  ]
}

Le mot-clé not

Vous pouvez nier une expression en la faisant précéder du mot-clé not, qui en inverse le sens. La plupart du temps, vous l’utiliserez dans une requête pour vérifier l’absence d’une propriété dans l’entrée. Par exemple, cette requête :

input.tags.environment

…signifie « Le document d’entrée possède une propriété tags.environment, dont la valeur n’est pas false », tandis que cette requête :

not input.tags.environment

…signifie « Le document d’entrée ne possède pas de propriété tags.environment, ou tags.environment est défini sur false. » Il n’y a ni chevauchement ni zone grise : une expression et son inverse s’excluent mutuellement.

Voici un exemple de règle qui attribue true à deny si l’entrée ne contient pas de balise department :

deny {
  not input.tags.department
}

Utilisons cette entrée :

{
  "tags": {
    "environment": "staging"
  }
}

Si nous évaluons cette entrée par rapport à la règle ci-dessus, nous constatons que deny renvoie true, car la propriété department obligatoire est absente :

{
  "deny": true
}

Évaluer un exemple de règle avec OPA

Mettons en pratique les concepts abordés dans cet article en évaluant un exemple de règle. Comme dans la partie 1, nous nous concentrerons sur deux façons d’utiliser OPA :

  • Utiliser le Rego Playground

  • Utiliser l’outil en ligne de commande d’OPA

Pour savoir comment utiliser ces interfaces, consultez la partie 1.

Cette fois, nous allons étudier un exemple plus concret portant sur un pod Kubernetes. Voici le manifeste JSON que nous utiliserons comme entrée :

{
  "apiVersion": "v1",
  "kind": "Pod",
  "metadata": {
    "name": "nginx-demo",
    "labels": {
      "release" : "stable"
    }
  },
  "spec": {
    "containers": [
      {
        "name": "nginx",
        "image": "nginx:1.14.2",
        "ports": [
          {
            "containerPort": 80
          }
        ]
      }
    ]
  }
}

Et voici la règle par rapport à laquelle nous allons l’évaluer. Elle vise à appliquer la politique d’entreprise suivante : « Les pods Kubernetes doivent être étiquetés avec release et environment » :

deny[msg] {
  input.kind == "Pod"
  not input.metadata.labels.release
  msg := sprintf("Pod %v is missing release label", [input.metadata.name])
} {
  input.kind == "Pod"
  not input.metadata.labels.environment
  msg := sprintf("Pod %v is missing environment label", [input.metadata.name])
}

Cette règle illustre quelques-uns des concepts abordés dans cet article :

  • deny[msg] pour renvoyer un ensemble de messages personnalisés plutôt que true ou false/undefined

  • Une structure de règle avec AND et OR :

    • Refuser si l’objet Kubernetes est un pod ET qu’il ne possède pas l’étiquette release, OU :

    • Refuser si l’objet Kubernetes est un pod ET qu’il ne possède pas l’étiquette environment

  • Le mot-clé not pour vérifier l’absence d’une propriété

  • La fonction sprintf pour renvoyer un message indiquant le nom du pod non conforme

Pour vous simplifier la tâche, nous avons créé un playground contenant déjà ces éléments : https://play.openpolicyagent.org/p/KNVK9kEvIT 

Si vous évaluez la règle en sélectionnant le bouton Evaluate dans le playground, ou en exécutant une commande telle que opa eval -i input.json -d check_pod.rego "data.rules.check_pod" --format pretty si OPA est installé localement, vous obtiendrez le résultat suivant :

{
  "deny": [
    "Pod nginx-demo is missing environment label"
  ]
}

Comme nous pouvons le constater, le pod Kubernetes que nous vérifions n’est pas conforme à notre règle, car l’entrée ne contient pas de propriété labels.environment .

Supprimons maintenant la propriété labels.release. La section labels de l’entrée devrait ressembler à ceci :

    "labels": {
    }

Si vous évaluez la règle maintenant, vous verrez que l’ensemble deny contient deux messages :

{
  "deny": [
    "Pod nginx-demo is missing environment label",
    "Pod nginx-demo is missing release label"
  ]
}

Enfin, ajoutons à l’entrée les propriétés labels.release et labels.environment, afin qu’elle ressemble à ceci :

    "labels": {
      "release" : "stable",
      "environment": "prod"
    }

Que se passe-t-il si nous évaluons à nouveau la règle ? Nous constatons que l’ensemble deny est vide :

{
  "deny": []
}

Cela signifie que notre pod est conforme, car OPA n’a ajouté aucun message à l’ensemble deny. Hourra !

Et ensuite ?

N’hésitez pas à revenir sur notre blog pour lire la troisième partie de notre guide d’initiation à Rego, dans laquelle nous explorerons les règles d’ensemble, les règles d’objet, les fonctions et l’itération.

En attendant, voici quelques ressources utiles :

Si vous souhaitez utiliser Rego pour écrire des règles personnalisées pour Snyk IaC, consultez notre documentation. En plus des ensembles de règles intégrés de Snyk, dédiés à la sécurité et associés aux exigences de conformité, les règles personnalisées d’IaC+ vous permettent de définir des contrôles de sécurité adaptés à l’ensemble de votre SDLC.

IaC+ vous offre une vue unique et des contrôles pour vos problèmes de configuration, du code au cloud, grâce à une interface de gestion des problèmes, un ensemble de règles et un moteur de stratégies couvrant les IDE, les SCM, les CLI, les pipelines CI/CD, Terraform Cloud et les environnements cloud déployés tels qu’AWS, Azure et Google Cloud.