Capensis.fr
Support professionnel

A la découverte de Inspec


#1

Inspec

Il a été discuté au sein de Capensis de lancer un POC afin d’étudier l’intégration de Inspec au sein de nos process internes (dont notre produit Canopsis) et ainsi l’ajouter à notre catalogue de compétences.

On vous propose donc un petit retour sur notre première expérience.

Kesako ?

Inspec est un framework qui a pour mission de tester votre infra selon vos règles d’intégration, de conformité, de sécurité et autres pré-requis qui vous sont propres afin de débusquer des régressions ou anomalies dans vos déploiements.

Donnez lui les règles et il débusquera les hors-la-loi.

En somme, Inspec, c’est un peu l’Inspecteur Harry de votre infra.

Dirty Harry

Mais encore ?

  • Première release : Juin 2015
  • Open Source Software
  • Supporté et maintenu par Chef ( Chef, Habitat, Omnibus … )
  • Basé sur Rspec
  • Plateforme agnostic
  • Tests exécutés en local ou en remote ( Docker, SSH, WinRM … )
  • Extensible à souhait
  • Langage aisé et léger
  • Possibilité d’exporter les rapports sous différents format : html, junit,json, … )
  • Communauté active ( Slack, GitHub, Blog )

Un des gros avantages de Inspec, est de pouvoir utiliser une syntaxe unique pour pouvoir tester certains éléments de votre infra.

Par exemple, sous Redhat, la conf apache est sous /etc/httpd alors que sous Debian elle est sous /etc/apache2.

Avec Inspec, nul besoin de se soucier de cela :

describe apache_conf do
  its('Listen') { should =~ [ '80', '443' ] }
end

Premiers pas…

Installation :

Os package

Des packages sont disponibles pour une très grande majorité d’OS à cette adresse.

Ruby package

Pour les personnes ayant déjà une installation ruby sur leur poste, il est tout à fait possible de l’installer via un bon vieux :

$ gem install inspec
Brew package

Pour les personnes “pommées” :

brew cask install inspec
Habitat

Pour les personnes utilisant habitat, il existe aussi un package habitat

Les “profiles” :

Structure :

Inspec fonctionne sur la base de “profiles”.

Un “profile” est une structure renfermant plusieurs éléments qui nous permettront de structurer nos tests :

  • ./inspec.yml (requis)
    • la configuration de notre profile
  • ./controls/ (requis)
    • les éléments de contrôles
  • ./libraries/ (optionnel)
    • les ressources personnalisées
  • ./files/ (optionnel)
    • les fichiers pouvant être importés dans un élément de contrôle ou ressource personnalisée

Tout ceci peut être généré via :

$ inspec init profile mon-profile
Create new profile at /tmp/mon-profile
 * Create file inspec.yml
 * Create directory libraries
 * Create file README.md
 * Create directory controls
 * Create file controls/example.rb
 * Create file libraries/.gitkeep

On se retrouve avec ce type de structure :

$ tree mon-profile/
mon-profile/
├── controls
│   └── example.rb
├── inspec.yml
├── libraries
└── README.md

A noter que tout fichier “.rb” présent dans les dossiers “controls”/“libraries” seront pris en compte lors de l’exécution d’un profile.

Lancement d’un profile :

Notre profile d’exemple ayant été crée plus haut, regardons ce qu’il contient avant de le lancer.

$ cat mon-profile/controls/example.rb 
# encoding: utf-8
# copyright: 2018, The Authors

title 'sample section'

# you can also use plain tests
describe file('/tmp') do
  it { should be_directory }
end

# you add controls here
control 'tmp-1.0' do                        # A unique ID for this control
  impact 0.7                                # The criticality, if this control fails.
  title 'Create /tmp directory'             # A human-readable title
  desc 'An optional description...'
  describe file('/tmp') do                  # The actual test
    it { should be_directory }
  end
end

Le langage de Inspec étant justement prévu pour être lisible, les commentaires devraient suffire à la compréhension de ce que réalise cet exemple.

Lançons ce test en local sur notre machine pour voir si vous aviez vu juste :

$ inspec exec mon-profile/

Profile: InSpec Profile (mon-profile)
Version: 0.1.0
Target:  local://

  ✔  tmp-1.0: Create /tmp directory
     ✔  File /tmp should be directory

  File /tmp
     ✔  should be directory

Profile Summary: 1 successful control, 0 control failures, 0 controls skipped
Test Summary: 2 successful, 0 failures, 0 skipped

Ici vous pouvez voir que les 2 tests consistaient à vérifier que le /tmp est bien un répertoire.

Lancement d’un profile non local

Inspec propose aussi la possibilité de lancer des “profiles” qui sont hébergés à distance ( repo git, tar.gz ), ce qui nous donne :

$ inspec exec https://github.com/learn-chef/auditd/releases/download/v0.1.0/auditd-0.1.0.tar.gz

ou encore

$ inspec exec https://github.com/dev-sec/linux-baseline

Les “controls” :

L’utilisation des blocks “control” permet de regrouper un ensemble de tests couvrant un même périmètre. Afin de mieux s’y retrouver plus tard lorsque les tests deviennent conséquents, il est conseillé de créer un fichier par “controls”. Cette organisation permet de voir plus clairement les “controls” disponibles afin de pouvoir ultérieurement filtrer les “controls” que nous souhaitons exécuter dans le cas d’une exécution partielle.

Ajoutons par exemple un “controls” que nous nommerons “01-security.rb”.

$ cat > mon-profile/controls/01-security.rb
control 'security' do
	impact 1.0
	title 'Check security'

	describe etc_fstab.where { mount_point == '/tmp' } do
		its('mount_options') { should include 'noexec' }
	end

	describe iptables do
		 it { should have_rule('-P INPUT DROP') }
	end
end

Et si nous voulons lancer uniquement ce nouveau “controls”, nous pouvons le préciser avec l’option “–controls” de Inspec.

Ce qui nous donne :

$ inspec exec mon-profile/ --controls security 

Profile: InSpec Profile (mon-profile)
Version: 0.1.0
Target:  local://

  ×  security: Check security (2 failed)
     ×  etc_fstab with mount_point == "/tmp" mount_options should include "noexec"
     expected [] to include "noexec"
     ×  Iptables should have rule "-P INPUT DROP"
     expected Iptables to have rule "-P INPUT DROP"


Profile Summary: 0 successful controls, 1 control failure, 0 controls skipped
Test Summary: 0 successful, 2 failures, 0 skipped

Les “resources” :

Les “resources” permettent de créer un objet qui nous permettra d’effectuer ensuite des tests sur les “properties” exposées. Nous avons par exemple utilisé précédemment les “resources” “file”, “etc_fstab”, “iptables”

Il existe une multitude de "resources" disponibles en natif dans Inspec.

Chaque “resource” expose des “properties” propres à elle. A titre d’exemple, vous pourrez voir que la “resource” “etc_fstab” expose les “properties” suivantes :

- device_name
- mount_point
- file_system_type
- mount_options
- dump_options
- file_system_options

Que faire si une “resource” dont nous avons besoin n’est pas présente ?

Il suffit de créer une "custom resource " que nous mettrons dans notre dossier “libraries” à la racine de notre “profile”.

Et si vous estimez que votre “custom resource” est utile à la communauté, , n’hésitez pas à soumettre une petite “Pull Request” afin que celle-ci soit intégrée lors d’une prochaine release.

Les “matchers”

Les “matchers” permettent de comparer les “properties” de nos “resources” à ce que nous attendons lors de nos tests.

Chaque “property” peut être comparée à partir d’un “matcher” , dont ceux par défaut sont :

- be
- be_in
- cmp
- eq
- include
- match

Mais chaque “resource” peut aussi disposer de ses propres “matchers”.

Si nous regardons nos exemples précédents, nous pouvons voir que nous avons utilisé le “matcher” “have_rule” de la “resource” “iptables”

describe iptables do
        it { should have_rule('-P INPUT DROP') }
end

“What did you Inspec” ?

Cette petite présentation finie, comment s’est passé notre POC ?

Plutôt bien grâce à la souplesse de Inspec

Les prérequis

Nous souhaitions dans la mesure du possible que les tests Inspec répondent à quelques besoins :

  • Exécutable par un utilisateur/client de Canopsis pour tester sa propre installation
  • Utilisable par le support Canopsis pour tester une installation afin d’en détecter les anomalies
  • Utilisable dans le cadre de CI/CD.
  • Utilisable par les développeurs pour valider leurs changements
  • Utilisable peu importe le mode de déploiement (docker, bare metal, etc…)
  • Utilisable si l’installation est HA

Les “surprises”

Au final pas tant que cela, à vrai dire, 2 petites de rien du tout et qui ont été résolues rapidement grâce aux conseils de l’équipe Inspec

L’organisation des profiles

Le produit Canopsis est un ensemble de “briques” au nombre de 15 (hors rabbitmq, influxdb, mongodb). Nous avions donc commencé à faire un profil unique testant ces 15 briques avec un découpage par sous dossier pour chaque brique. Ce qui donnait quelque chose dans cet esprit :

mon-profile/
├── controls
│   ├── brique-x
│   │   ├── 01-test.rb
│   │   └── 02-test.rb
│   ├── brique-y
│   │   ├── 01-test.rb
│   │   └── 02-test.rb
│   ├── brique-z
│   │   ├── 01-test.rb
│   │   └── 02-test.rb    ---> multiples controls inside
│   └── frontend
│       ├── 01-connections_backend.rb
│       └── 02-frontend_config.rb
├── inspec.yml
├── libraries
│   └── user_resource.rb
└── README.md

Vous remarquerez un fichier dans “libraries” qui est une “custom resource”, et c’est entre autre l’utilisation de ces “customs resources” qui a fait que nous sommes passés à une structure “profile inheritance”.

Le produit “Canopsis” dans son mode “docker-compose”, utilise un container pour chaque “briques” (docker style), et lorsque nous voulions tester une seule brique, nous faisions un :

$ inspec exec mon-profile/controls/brique-x -t docker://canopsis_brique-x_1

Cela fontionnait bien, jusqu’au moment où … nous avons intégré une “custom resource” (dont on vous parle après) pour les besoins des tests sur le frontend.

Le fait de préciser le sous dossier “controls/frontend” fait que Inspec ne retrouvait pas notre “custom resource” et on se retrouvait donc avec une jolie erreur “Undefined Method”.

Ajouté à cela, nous souhaitions pouvoir utiliser les “controls” utilisant ces “customs resources” dans d’autres “controls” (aka Profile Inheritance ) qui concernent les briques lorsque les briques ne sont pas sur un même host (Mode Docker pour l’exemple).

Nous avions donc 2 possibilités :

  • Disposer de 15 profiles distincts dans 15 repo distincts alors que cela concerne un produit unique. (souplesse vs maintenance ?)
  • Disposer d’un seul repo contenant nos 15 profiles

Nous avons donc opté pour le moment pour la deuxième option.

Ce qui donne quelque chose dans cet esprit :

inspec-canopsis/
├── profiles
│   ├── brique-1
│   │   ├── controls
│   │   │   └── 01-brique-1.rb
│   │   ├── inspec.yml
│   │   └── vendor
│   ├── brique-2
│   │   ├── controls
│   │   │   └── 01-brique-2.rb
│   │   └── inspec.yml
...
...
│   ├── brique-15
│   │   ├── controls
│   │   │   └── 01-brique-15.rb
│   │   └── inspec.yml
│   └── frontend
│       ├── controls
│       │   ├── 01-rabbitmq_backend.rb
│       │   ├── 02-mongodb_backend.rb
│       ├── files
│       │   ├── check_rabbitmq_connection.py
│       │   └── pymongo-cli.py
│       ├── inspec.yml
│       └── libraries
│           ├── resource_mongo_command.rb
│           └── resource_rabbitmq_check_connection.rb
└── README.md

Avec cette structure, il est désormais possible de :

  • lancer tout les profiles sur un même host
  • lancer un profile sur un host spécifique
  • réutiliser nos “controls” qui utilisent nos “libraries” en important le profile qui les contient

NB: il est aussi possible de créer un profile ne contenant que des “librairies” afin que ce profil serve uniquement de “resource pack”.

Check sans CLI :
Les prémices :

Le frontend de Canopsis utilise différents backend dont ( RabbitMQ, InfluxDB, MongoDB ).
Les premiers checks logiques à effectuer sont aussi basiques que de checker la disponibilité du port sur le host qui héberge le backend depuis le frontend afin de valider que les éventuelles règles de Firewall sont OK.

Assez simple à réaliser avec Inspec :

describe host( 'host_mongo', port: 'port', protocol: 'tcp') do
    it { should be_reachable }
end

Mais ce type de test ne valide pas que nos credentials sont corrects pour se connecter…

Il serait donc bon de pouvoir initier une vraie connexion avec les credentials de l’appli n’est-ce pas ?

Sauf que … il existe pas de “resource” MongoDB/InfluxDB/RabbitMQ à l’heure actuelle permettant de se connecter depuis le host qu’on test afin de valider tout cela.

Mais comment faire ?

Après une petite recherche sur le net, nous sommes tombés sur un “profile” qui vérifie des éléments de MongoDB. Mais comment est-ce que cela fonctionne ? Cela utilise bien sûr une “custom resource”.
Et que fait cette “custom resource” ? Elle utilise le client mongo installé sur le serveur testé, parse le retour, et nous renvoie un objet avec des “properties” que nous pouvons tester avec les “matchers”.

Première réaction : Youhouuuuu
Deuxième réaction : Heuuuu, j’entends déjà le refus d’ajouter le client mongo aux images docker ou encore l’ajout du client dans les playbooks etc…
Troisième réaction : Hummmmm il doit y avoir un moyen…

Eureka :

Notre application utilise bien les backend n’est-ce pas ? et pourtant, pas de CLI sur le frontend.
Notre application tournant sous Python, il y a donc forcement une librairie python dont on peut se servir…

Nous prendrons pour exemple la connexion au backend RabbitMQ.

1ère étape :

Disposer d’un script Python nous permettant de tester la connexion au serveur RabbitMQ. Nous utiliserons le script trouvé ici et que nous placerons dans le dossier “files” de notre “profile”

2ème étape :

Écrire une “custom resource” nous permettant de déposer ce script sur le host distant, puis de disposer d’une resource se servant de ce script.

Plutôt qu’un long discours, voici l’exemple de la “custom resource” “resource_rabbitmq_check_connection.rb” que nous placerons dans le dossier “libraries” de notre “profile”

require 'json'

class RabbitMQCheckConnection < Inspec.resource(1)
  name 'rabbitmq_check_connection'
  desc 'Perform a rabbitmq connection with a tiny script'
  example <<-EOL
    describe rabbitmq_check_connection() do
      its('exit_status') { should eq 0 }
    end
    EOL

  attr_reader :command, :database, :params

  def initialize(options = {})
    @username          = options[:username]
    @password          = options[:password]
    @host              = options.fetch(:host, '127.0.0.1')
    @port              = options.fetch(:port, '5672')
    @ssl               = options.fetch(:ssl, 'true')
    @virtualhost       = options[:virtualhost]

    @check_rabbitmq_file = 'check_rabbitmq_connection.py'
    @check_rabbitmq_dest_path = "/opt/canopsis/tmp/inspec"
    @check_rabbitmq_dest_file = "#{@check_rabbitmq_dest_path}/#{@check_rabbitmq_file}"
    @python_path = '/opt/canopsis/bin/python'
    @pip_path = '/opt/canopsis/bin/pip'
    check_pika if not defined? @@already_done
    create_tmp_script if not defined? @@already_done
    @inspec_command = run_rabbitmq_check_connection
    @params = parse(@inspec_command.stdout)
  end

  def stdout
    @inspec_command.stdout
  end

  def stderr
    @inspec_command.stderr
  end

  def to_s
    str = "RabbitMQ Check Connection (#{@command}"
    str += "host: #{@host}" unless @host.nil?
    str += ", port: #{@port}" unless @port.nil?
    str += ", ssl: true " unless @ssl == false
    str += ", virtualhost: #{@virtualhost}" unless @virtualhost.nil?
    str += ", username: #{@username}" unless @username.nil?
    str += ", password: <hidden>" unless @password.nil?
    str += ')'

    str
  end

  private

  def parse(output)
    # return right away if stdout is nil
    return [] if output.nil?
    output
  end

  def check_pika
    check_pip = inspec.command("command -v #{@pip_path}")
    if check_pip.exit_status != 0
      raise Inspec::Exceptions::ResourceFailed, "Unable to find pip command via #{check_pip}"
    end
    check_command = inspec.command("#{@pip_path} show pika")
    if check_command.exit_status != 0
      raise Inspec::Exceptions::ResourceFailed, "Unable to find pika package via #{check_command}"
    end
  end

  def create_tmp_script
    rabbitmq_check_script = inspec.profile.file(@check_rabbitmq_file)
    rabbitmq_check_script.gsub!('"','\"')
    create_script_dir = inspec.command(%{
      mkdir -p #{@check_rabbitmq_dest_path} \
      && chown canopsis:canopsis #{@check_rabbitmq_dest_path}
    })
    if create_script_dir.exit_status != 0
      raise Inspec::Exceptions::ResourceFailed, "Unable to create #{@check_rabbitmq_dest_path}, see stderr bellow:\n#{create_script_dir.stderr}"
    end
    create_script_command = inspec.command(%{
      echo "#{rabbitmq_check_script}" > #{@check_rabbitmq_dest_file} \
      && chown canopsis:canopsis #{@check_rabbitmq_dest_file}
    })
    if create_script_command.exit_status != 0
      raise Inspec::Exceptions::ResourceFailed, "Unable to create #{@check_rabbitmq_dest_file}, see stderr bellow:\n#{create_script_command.stderr}"
    end
    @@already_done = 1
  end

  def run_rabbitmq_check_connection
    rabbitmq_cmd = inspec.command(format_command)
    if rabbitmq_cmd.exit_status != 0 and rabbitmq_cmd.stderr != ''
      raise Inspec::Exceptions::ResourceFailed, "Unexpected error with : #{rabbitmq_cmd}"
    end
    rabbitmq_cmd
  end

  def ssl_disabled?
    ['true', true].include?(@ssl)
  end

  def format_command
    cmd = %{ #{@python_path} #{@check_rabbitmq_dest_file} }
    cmd += " --ssl" if ssl_disabled?
    cmd += " --virtual_host=#{@virtualhost}" unless @virtualhost.nil?
    cmd += " --username=#{@username}" unless @username.nil?
    cmd += " --password=#{@password}" unless @password.nil?
    cmd += " --server=#{@host}" unless @host.nil?
    cmd += " --port=#{@port}" unless @port.nil?
    cmd
  end
end

NB: Je tiens à préciser que ceci est un hat trick et cette méthode n’est à employer que si aucune “resource” Inspec n’est disponible ou si les éléments nécessaires aux bons tests ne sont pas présents sur le host testé et qu’ils ne peuvent être ajoutés pour une raison valable. Cette méthode écrit 1 fichier sur le host distant et n’a logiquement pas à arriver dans une phase de test.

NB: Les scripts d’exemples sont une ébauche, ne tenez donc pas rigueur à son auteur du manque de consistance dans l’indentation ou encore de la qualité du script car tout ceci doit être réécrit plus proprement

3ème étape :

Utiliser notre “custom resource” à l’aide d’un block “control”.
A titre d’exemple voici le test qui vérifie la bonne connexion de notre frontend à notre backend rabbitmq en se basant sur la configuration réel de notre produit fraîchement déployé.

rabbitmq_conf_file = attribute('rabbitmq_conf_file', default: '/opt/canopsis/etc/amqp.conf', description: 'Path to the ampq.conf file')
rabbitmq_conf_file = file(rabbitmq_conf_file)

control 'rabbitmq-backend' do
	title 'Test config / connection to RabbitMQ backend'

	describe rabbitmq_conf_file do
		it { should exist }
	end

	if rabbitmq_conf_file.exist?

		parsed_rabbitmq_conf = parse_config(rabbitmq_conf_file.content)

		describe parsed_rabbitmq_conf do
			its('master.host') { should_not eq nil and should_not eq '' }
			its('master.port') { should_not eq nil and should_not eq '' }
			its('master.userid') { should_not eq nil and should_not eq '' }
			its('master.password') { should_not eq nil and should_not eq '' }
			its('master.virtual_host') { should_not eq nil and should_not eq '' }
			its('master.exchange_name') { should_not eq nil and should_not eq '' }
		end
		
		describe host( parsed_rabbitmq_conf.master.host, port: parsed_rabbitmq_conf.master.port, protocol: 'tcp') do
                        it { should be_reachable }
                end

		describe rabbitmq_check_connection(
			username: "#{parsed_rabbitmq_conf.master.userid}",
			password: "#{parsed_rabbitmq_conf.master.password}",
			host: "#{parsed_rabbitmq_conf.master.host}",
			port: "#{parsed_rabbitmq_conf.master.port}",
			virtualhost: "#{parsed_rabbitmq_conf.master.virtual_host}",
			ssl: false
		) do
			its('stdout.strip') { should eq 'OK' }
		end

	else
		describe 'conf file not found' do
			skip "Skipping RabbitMQ tests because it seems than the configuration file does't exist"
		end
	end
end

4ème étape :

Testons que cela fonctionne :slight_smile:

$ inspec exec profiles/frontend/ --controls rabbitmq-backend -t docker://canopsis_frontend_1

Profile: Test Canopsis Frontend (inspec-canopsis-frontend)
Version: 0.0.1
Target:  docker://ad9a9f34aef8f2c1e942efdfdf32bee76260039142a96b68b7078435c849e7d0

  ✔  rabbitmq-backend: Test config / connection to RabbitMQ backend
     ✔  File /opt/canopsis/etc/amqp.conf should exist
     ✔  Parse Config  master.host should not eq ""
     ✔  Parse Config  master.port should not eq ""
     ✔  Parse Config  master.userid should not eq ""
     ✔  Parse Config  master.password should not eq ""
     ✔  Parse Config  master.virtual_host should not eq ""
     ✔  Parse Config  master.exchange_name should not eq ""
     ✔  Host rabbitmq port 5672 proto tcp should be reachable
     ✔  RabbitMQ Check Connection (host: rabbit_host, port: 5672, virtualhost: virtualhost_rabbit, username: rabbit_user, password: <hidden>) stdout.strip should eq "OK"


Profile Summary: 1 successful control, 0 control failures, 0 controls skipped
Test Summary: 9 successful, 0 failures, 0 skipped

Conclusion

Voilà, notre petit tour d’horizon de Inspec se termine et nous espérons vous avoir apporté un éclaircissement sur ces capacités mais aussi la souplesse de celui-ci.

N’hésitez pas à nous solliciter si vous rencontrez un point de blocage, nous pourrons peut-être vous apporter réponse :slight_smile:

@Bientôt


Vagrant, Ansible et Docker, un trio efficace