Published by

Il y a 3 semaines -

Temps de lecture 13 minutes

Evitez le remote state Terraform entre modules

Dans tout projet non trivial utilisant Terraform, il va être nécessaire de créer des modules et d’être capable de relier ces modules ensemble. Assez rapidement, une data source de type remote state va sans doute être utilisée. Mais bien que simple d’utilisation, cela a des implications sur la sécurité. C’est ce que nous allons voir ensemble.

Le state

L’élément le plus important à comprendre pour la sécurité de Terraform est son state. L’état cible des ressources va être déclaré en HCL (Hashicorp configuration language). Et le state va ainsi contenir l’ensemble des entrées nécessaires pour créer les ressources mais aussi leurs sorties, qui peuvent être à leur tour les entrées d’autres ressources. Et c’est en comparant le state et l’état actuel observé que Terraform va définir les changements à appliquer.


Un simple exemple du state

La première étape va être de déclarer les providers. Ici, seul random -un provider local- sera utilisé car suffisant pour expliquer la gestion du state.

# main.tf

terraform {
  required_providers {
    random = {
      source  = "hashicorp/random"
      version = "3.1.0"
    }
  }
}

provider "random" {
}

A la suite de cela, le projet Terraform doit être initialisé pour récupérer les providers.

$ terraform init

Initializing the backend...

Initializing provider plugins...
- Finding hashicorp/random versions matching "3.1.0"...
- Installing hashicorp/random v3.1.0...
- Installed hashicorp/random v3.1.0 (signed by HashiCorp)

# [...]

Terraform has been successfully initialized!

Et une première ressource de type random_string peut être déclarée.

# main.tf
# [...] provider declaration

resource "random_string" "random" {
  length           = 16
  special          = true
  override_special = "/@£$"
}

Lancer la création du plan va expliquer la création de cette ressource, avec toutes ses entrées.

$ terraform plan -out plan_start

Terraform will perform the following actions:

  # random_string.random will be created
  + resource "random_string" "random" {
      + id               = (known after apply)
      + length           = 16
      + lower            = true
      + min_lower        = 0
      + min_numeric      = 0
      + min_special      = 0
      + min_upper        = 0
      + number           = true
      + override_special = "/@£$"
      + result           = (known after apply)
      + special          = true
      + upper            = true
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Et l’apply va effectuer la création en elle-même et enregistrer dans le state les sorties des ressources ainsi créées.

$ terraform apply "plan_start"

random_string.random: Creating...
random_string.random: Creation complete after 0s [id=kImh7q6QddNvcmix]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Et si on affiche le state, on y retrouve effectivement l’ensemble des entrées et sorties de la seule ressource de type random_string.

$ terraform show

# random_string.random:
resource "random_string" "random" {
    id               = "kImh7q6QddNvcmix"
    length           = 16
    lower            = true
    min_lower        = 0
    min_numeric      = 0
    min_special      = 0
    min_upper        = 0
    number           = true
    override_special = "/@£$"
    result           = "kImh7q6QddNvcmix"
    special          = true
    upper            = true
}

L’utilisation de variable ne change rien pour le state

C’est une bonne pratique de ne pas renseigner les données sensibles directement dans les fichiers HCL *.tf mais via le système de variables. Cela permet ainsi de versionner le code sans exposer les secrets. Cependant, cela ne change pas la criticité du state : on va toujours y trouver les mêmes informations.

Cette fois-ci, length est définie via une variable my_input.

# main.tf
# [...] provider declaration

variable "my_input" {
  type = number
}

resource "random_string" "random" {
  length           = var.my_input
  special          = true
  override_special = "/@£$"
}

Mais le plan est inchangé par l’utilisation de cette variable puisque la valeur ne change pas.

$ terraform plan -var "my_input=16"

random_string.random: Refreshing state... [id=kImh7q6QddNvcmix]

No changes. Your infrastructure matches the configuration.

Et bien sur, l’apply et le state sont aussi identiques puisque le plan ne change pas. Même si une partie de la configuration est externalisée, Terraform doit tout de même conserver la valeur afin de pouvoir détecter s’il y a un changement à appliquer ou non.

Signaler les données sensible ne change rien pour le state

Depuis la v0.14, Terraform permet aussi de signaler les données sensibles. C’est encore une bonne pratique afin que ces données ne soient pas visibles dans l’affichage du plan ou de l’apply. Mais il s’agit seulement d’une modification de l’affichage. Les valeurs stockées dans le state restent, encore une fois, non modifiées car Terraform se repose sur celles-ci pour identifier les changements.

Cette fois-ci, on déclare my_input comme variable sensible. Et on utilise random_password au lieu de random_string afin d’avoir une sortie sensible : la valeur du password result.

# main.tf
# [...] provider declaration

variable "my_input" {
  type      = number
  sensitive = true
}

resource "random_password" "random" {
  length           = var.my_input
  special          = true
  override_special = "/@£$"
}

Lors de l’affichage du plan, les données sensibles en entrée (length) et en sortie (result) sont masquées.

$ terraform plan -var "my_input=16" -out plan_sensitive

Terraform will perform the following actions:

  # random_password.random will be created
  + resource "random_password" "random" {
      + id               = (known after apply)
      + length           = (sensitive)
      + lower            = true
      + min_lower        = 0
      + min_numeric      = 0
      + min_special      = 0
      + min_upper        = 0
      + number           = true
      + override_special = "/@£$"
      + result           = (sensitive value)
      + special          = true
      + upper            = true
    }

  # random_string.random will be destroyed
  - resource "random_string" "random" {
      - id               = "kImh7q6QddNvcmix" -> null
      - length           = 16 -> null
      - lower            = true -> null
      - min_lower        = 0 -> null
      - min_numeric      = 0 -> null
      - min_special      = 0 -> null
      - min_upper        = 0 -> null
      - number           = true -> null
      - override_special = "/@£$" -> null
      - result           = "kImh7q6QddNvcmix" -> null
      - special          = true -> null
      - upper            = true -> null
    }

Plan: 1 to add, 0 to change, 1 to destroy.

L’apply ne va pas non plus afficher ces données sensibles.

$ terraform apply "plan_sensitive"

random_string.random: Destroying... [id=kImh7q6QddNvcmix]
random_string.random: Destruction complete after 0s
random_password.random: Creating...
random_password.random: Creation complete after 0s [id=none]

Apply complete! Resources: 1 added, 0 changed, 1 destroyed.

Et la commande “simple“ d’affichage du state va aussi les masquer.

$ terraform show

# random_password.random:
resource "random_password" "random" {
    id               = "none"
    length           = (sensitive)
    lower            = true
    min_lower        = 0
    min_numeric      = 0
    min_special      = 0
    min_upper        = 0
    number           = true
    override_special = "/@£$"
    result           = (sensitive value)
    special          = true
    upper            = true
}

Mais si on affiche véritablement le contenu du state, toutes les données, même déclarées comme sensibles, sont bien présentes.

$ terraform show -json | jq .

{
  "format_version": "0.2",
  "terraform_version": "1.0.2",
  "values": {
    "root_module": {
      "resources": [
        {
          "address": "random_password.random",
          "mode": "managed",
          "type": "random_password",
          "name": "random",
          "provider_name": "registry.terraform.io/hashicorp/random",
          "schema_version": 0,
          "values": {
            "id": "none",
            "keepers": null,
            "length": 16,
            "lower": true,
            "min_lower": 0,
            "min_numeric": 0,
            "min_special": 0,
            "min_upper": 0,
            "number": true,
            "override_special": "/@£$",
            "result": "WA�PLnsZTQMN3Hkg",
            "special": true,
            "upper": true
          },
          "sensitive_values": {
            "length": true
          }
        }
      ]
    }
  }
}

Les valeurs de length et result sont en effet bien présentes.

Le lien avec le remote state entre des modules : les outputs

Un projet infrastructure-as-code est, par définition, une base de code. Et comme pour toute base de code, il est pertinent de l’organiser en sous-ensembles afin de faciliter sa maintenance. Dans le cadre de Terraform, le concept de module est utilisé. Et bien sûr ces modules ne sont pas 100% isolés, ils doivent donc avoir des entrées et des sorties pour échanger des informations. Les entrées sont gérées par le système de variables et les sorties peuvent être gérées via des outputs.

On ajoute un output my_output pour communiquer le password.

# main.tf
# [...] provider declaration
# [...] variable declaration
# [...] random_password declaration

output "my_output" {
  value     = random_password.random.result
  sensitive = true
}

Les données sensibles restent toujours masquées dans l’affichage du plan, même l’output.

$ terraform plan -var "my_input=16" -out plan_output

Changes to Outputs:
  + my_output = (sensitive value)

Et il en va de même durant l’apply.

$ terraform apply "plan_output"

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Outputs:

my_output = 

Mais ces données restent toujours contenues dans le state.

$ terraform show -json | jq .

{
  "format_version": "0.2",
  "terraform_version": "1.0.2",
  "values": {
    "outputs": {
      "my_output": {
        "sensitive": true,
        "value": "WA�PLnsZTQMN3Hkg"
      }
    },
    "root_module": {
      "resources": [
        {
          "address": "random_password.random",
# [...]
          "values": {
# [...]
            "result": "WA�PLnsZTQMN3Hkg"
          },
          "sensitive_values": {
            "length": true
          }
        }
      ]
    }
  }
}

La problématique : maîtriser ce qui est véritablement partagé

Une équipe qui se repose sur les outputs d’un module peut ne pas avoir conscience des secrets qui sont créés et partagés implicitement.

Il existe deux façons d’utiliser les outputs d’un module.

1) La première utilisation est en interne d’un même projet Terraform.

L’équipe va utiliser le module directement en tant que sous-module et ses outputs resteront privés car le state du projet, contenant plusieurs modules, ne sera pas partagé avec d’autres équipes. Cela limite les risques mais la criticité du state peut être mal interprétée. En effet, le state va tout de même contenir tous les secrets des modules utilisés, même lorsqu’ils sont des détails internes d’implémentations.

# use_via_child_module.tf
# [...] provider declaration

module "my_module" {
  source   = "./my/local/and/relative/path"
  my_input = 16
}

locals {
  password = module.my_module.my_output
}

En initialisant ce projet (init), créant le plan (plan) et l’appliquant (apply), on retrouvera bien toutes les ressources de toutes les instances de modules utilisés dans le state, avec leurs entrées et sorties. Les ressources seront seulement regroupées différemment, par sous-module (child).

$ terraform show -json | jq .

{
  "format_version": "0.2",
  "terraform_version": "1.0.2",
  "values": {
    "root_module": {
      "child_modules": [
        {
          "resources": [
            {
              "address": "module.my_module.random_password.random",
# [...]
              "values": {
# [...]
                "result": "YGuffpaRmTfz7aQF"
              },
              "sensitive_values": {
                "length": true
              }
            }
          ],
          "address": "module.my_module"
        }
      ]
    }
  }
}

2) La seconde utilisation est de récupérer les outputs d’un module utilisé par une autre équipe via une data source de type remote state.

L’équipe publiant les outputs pourrait croire, à tord, qu’elle ne fournit que les outputs et qu’elle contrôle ainsi ce qui est communiqué. Mais en fait, un droit d’accès au state doit être fourni et donc avec celui-ci, l’ensemble de ses secrets, même ceux non déclarés via les outputs, deviennent accessibles.

# use_via_remote_state.tf
# [...] provider declaration

data "terraform_remote_state" "state" {
  # [...]
}

locals {
  password = data.terraform_remote_state.state.outputs.my_output
}

La ‘solution’ : abandonner le remote state et n’utiliser que des data sources

Bien que moins documenté et plus complexe à mettre en place, il est donc recommandé de ne pas utiliser des outputs avec remote state en cas de partage d’information entre équipes. A la place, il est nécessaire d’exposer les données via un gestionnaire de secret. L’exemple porte sur AWS mais le principe restera le même pour les autres cloud providers fournissant leurs propres implémentations.

Dans une première étape, l’équipe publicatrice n’utilisera plus d’output dans son module créant les ressources mais va enregistrer chaque sortie en tant que secret.

# main_producer.tf
# [...] provider declaration
# [...] random_password declaration

resource "aws_secretsmanager_secret" "example" {
  name = "exported-random"
}

resource "aws_secretsmanager_secret_version" "example" {
  secret_id     = aws_secretsmanager_secret.example.id
  secret_string = random_password.random.result
}

L’équipe consommatrice se synchronise alors via le gestionnaire de secrets et non plus un remote state qui risquerait de partager beaucoup plus que voulu.

# main_consumer.tf
# [...] provider declaration

variable "my_secret_id" {
  type      = string
}

data "aws_secretsmanager_secret_version" "example" {
  secret_id = var.my_secret_id
}

locals {
  password = data.aws_secretsmanager_secret_version.example.secret_string
}

Dans le cas de communications inter-équipes, appliquer cette approche directement peut conduire à beaucoup de code dupliqué car toutes les équipes consommatrices vont devoir récupérer les données de la même manière. Et c’est un risque d’autant plus important si le nombre de secrets à échanger est important car il en sera de même pour le code dupliqué.

Rajouter une abstraction pour simplifier l’utilisation

Il est néanmoins possible de simplifier l’extraction des informations via différentes méthodes. Dans un premier temps, il est possible de réduire le problème en agrégeant certaines informations via l’utilisation de json pour un secret. Mais à terme la solution peut être la création de module data-only dont la seule responsabilité sera de s’interfacer au bon système afin de récupérer l’information.

L’équipe publicatrice a donc au minimum deux modules à maintenir. Le premier (main_producer.tf) ne change pas : il crée les ressources, expose les informations via le secret manager et est utilisé uniquement par l’équipe publicatrice. Le second (data_interface.tf) est nouveau. Il ne crée aucune ressource mais récupère l’information via des data sources. Il est maintenu par l’équipe publicatrice qui sait comment sont exposées les informations mais il est utilisé par les équipes consommatrices.

# data_interface.tf
# [...] provider declaration

variable "my_secret_id" {
  type      = string
}

data "aws_secretsmanager_secret_version" "example" {
  secret_id = var.my_secret_id
}

output "my_output" {
  value     = data.aws_secretsmanager_secret_version.example.secret_string
  sensitive = true
}

Pour chaque équipe consommatrice, l’utilisation devient alors très proche de celle avec un remote state sauf que celui-ci est désormais remplacé par un module spécifique (data_interface.tf).

# main_consumer.tf
# [...] provider declaration

variable "my_secret_id" {
  type      = string
}

module "data_interface" {
  # path to the directory containing data_interface.fr
  path = "./path/to/data-only/interface/module"
  my_secret_id = var.my_secret_id
}

locals {
  password = module.data_interface.my_output
}

On retrouve l’utilisation d’outputs mais cette fois-ci, ils sont 100% internes au projet Terraform. La communication entre les équipes ne passe plus par un remote state mais bien par le gestionnaire de secret. Avec la mise en place d’un module data-only, l’équipe publicatrice garde en plus davantage de liberté sur l’évolution du transfert des informations. L’implémentation peut en effet changer totalement sans impact sur les équipes consommatrices tant que les outputs restent identiques.

Conclusion

Le mécanisme d’échange de données entre modules Terraform via output et éventuellement remote state est très simple à prendre en main mais peut soulever de nombreuses problématiques de sécurité.

Si une équipe expose les outputs d’un module à une autre équipe, elle doit faire attention à la criticité de l’ensemble des secrets stockés dans le state. Cela peut être complexe à un moment donné et encore plus dans le futur avec l’arrivée d’évolutions imprévues. Ces évolutions pourraient de plus remettre en cause l’utilisation du système d’output si jamais un secret est trop critique pour être partagé. Cela pourrait donc avoir un impact important sur les autres équipes consommatrices.

La solution est d’abandonner le remote state et de n’utiliser que des data sources mais cela est plus complexe à mettre en place, bien que l’utilisation finale reste simple. Il n’est donc pas productif d’interdire systématiquement l’utilisation du remote state mais il faut bien garder en tête que son utilisation peut être dangereuse.

Published by

Publié par Bertrand Dechoux

Consultant et Formateur Hadoop @BertrandDechoux

Commentaire

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Nous recrutons

Être un Sapient, c'est faire partie d'un groupe de passionnés ; C'est l'opportunité de travailler et de partager avec des pairs parmi les plus talentueux.