Im Folgenden geht es darum ein funktionierendes Proof-of-Concept zu basteln wie man Secrets in HashiCorp Vault speichern und dann mit einer Jenkins-Pipeline auslesen kann.
Hintergrund
Bevor es losgeht, hier erst einmal ein paar Sätze Kontext (Skip zum Ablauf):
So ziemlich jeder hat in seinem Buildprozess Geheimnisse, zum Beispiel Access-Tokens für Gitlab/Docker/o.Ä.., Zertifikate oder SSH-Keys, die im besten Fall nicht öffentlich zugänglich sind.
Ein gängiger Weg diese Geheimnisse in Jenkins zu hinterlegen, ist Jenkins eigene Secret-Verwaltung.
Dieses Vorgehen funktioniert, ist aber nicht optimal wenn man eine Jenkins-Instanz für verschiedene Projekte hat.
Das Hauptproblem ist das Jenkins-Credentials keine granulare Steuerung ermöglichen.
Einerseits in der User-Verwaltung (nur Administratoren können Secrets anlegen).
Andererseits fehlt Jenkins eine genaue Steuerung für das Auslesen der Credentials.
Konkret: Wer hat von Wo Wie lange Zugriff auf Was.
Ebenso fehlen genaue Access-Logs für die Credentials.
Wenn wir diese Probleme angehen wollen benötigen wir also ein anderes Tool, welches hoffentlich etwas mehr unseren Anforderungen entspricht. Wenn wir uns für ein passendes System entscheiden, hätten wir gleichzeitig noch die Möglichkeit eine Single Source of Truth zu etablieren.
Im Folgenden werden wir uns ansehen wie sich das Ganze mit HashiCorp Vault (im Folgenden nur „Vault” genannt) umsetzen lässt. Ein netter Nebeneffekt von Vault ist, dass es noch einiges an Utility bietet, z.B. dynamische Secrets für Datenbanken oder die leichtere Automatisierung von Passwort-Rotationen.
Doch was genau ist Vault?
Vault ist eine Open-Source-Anwendung zum Speichern, Verwalten und Verteilen von Geheimnissen (HashiCorp bietet auch Cloud-Versionen an, wir beschäftigen uns hier jedoch mit der Self-Hosted-Variante).
Vault selbst besteht aus vielen verschiedenen sogenannten Secret-Engines, die alle einzeln aktiviert oder deaktiviert werden können. Die Secret-Engines unterscheiden sich in ihrer Funktion, für uns ist aber zunächst nur die KV-v2 Engine relevant.
KV-v2 steht für key-value Version 2. Wie der Name bereits vermuten lässt, kann man mit dieser Engine seine Geheimnisse zusammen mit einem Key hinterlegen.
Ablauf
Bevor es jetzt losgeht noch ein paar Hinweise:
Im folgenden haben wir einige Abläufe vereinfacht, entsprechend gibt es ein paar Stellen die man so nicht produktiv setzen sollte. Wir haben uns bemüht, an den wichtigen Stellen dann auch eine bessere Alternative zu erklären.
Diese Hinweise sind jeweils immer Hellblau markiert. Bei den relevanten Vault-Befehlen haben wir den zu erwartenden Output in Grün markiert und wichtige Hinweise sind in Orange markiert, hier gleich der Erste:
Im folgendem wird sowohl eine docker als auch eine docker-compose Installation benötigt https://docs.docker.com/get-started/get-docker/ (Generell empfiehlt es sich Docker Desktop zu nutzen)
Wir starten mit dem anlegen einer Vault-Config damit wir im nächsten Schritt Vault und Jenkins lokal per Docker hochfahren und einrichten können.
Danach werden wir ein Geheimnis in Vault hinterlegen und den Zugriff darauf erstellen.
Anschließend fügen wir diesen Zugriff in Jenkins ein und lesen das Geheimnis per Pipeline aus.
Vorbereitung (Jenkins + Vault aufsetzen)
Wie bereits eben erwähnt legen wir zuerst eine Vault-Config an.
Dafür erstellen wir ein Verzeichnis „/vault-config/“ und packen in dieses Verzeichnis eine Datei mit dem Namen „initial-config.json“.
{
"storage": {
"file": {"path": "/vault/file"}
},
"listener": [{
"tcp": {
"address": "0.0.0.0:8200",
"tls_disable": true
}
}],
"default_lease_ttl": "168h",
"max_lease_ttl": "720h",
"ui": true
}Jetzt können wir Vault und Jenkins per docker-compose hochfahren.
Dazu diese docker-compose.yml neben dem Verzeichnis „/vault-config/“ anlegen und mit docker compose up ausführen.
version: '3.8'
services:
vault:
image: hashicorp/vault
environment:
VAULT_LISTEN_ADDRESS: "0.0.0.0:8200"
VAULT_ADDR: "http://0.0.0.0:8200"
ports:
- "8200:8200"
cap_add:
- IPC_LOCK
command: server
volumes:
- vault-file:/vault/file:rw
- type: bind
source: ./vault-config/
target: /vault/config
jenkins:
image: jenkins/jenkins:lts
ports:
- "8080:8080"
volumes:
- jenkins_home:/var/jenkins_home
- /var/run/docker.sock:/var/run/docker.sock
volumes:
vault-file:
jenkins_home:Dadurch wurden die beiden Container hochgefahren (Im Verzeichnis aus Zeile 16 muss unsere Vault-Config liegen, sonst fährt der Vault-Container nicht hoch).
Bevor wir uns vollständig Vault widmen, richten wir zunächst Jenkins ein.
Für die Einrichtung müssen wir das Web-Interface von Jenkins öffnen (es sollte unter localhost:8080 erreichbar sein) und dort den Einrichtungsprozess durchführen (in den Docker-Logs befindet sich das root-Token für Jenkins).
Nachdem wir den Jenkins fertig eingerichtet haben, müssen wir noch das Hashicorp-Vault-Plugin installieren (siehe Plugin install in Jenkins)
Nun können wir uns Vault widmen.
Für alle weiteren Kommandos müssen wir uns Zugang zu einer vault-CLI verschaffen. Das geht entweder ganz einfach per Docker Desktop „Im Terminal öffnen“ oder etwas umständlicher per docker-exec.
Dort initialisieren wir dann unseren Vault mit
vault operator init -key-shares=1 -key-threshold=1
Unseal Key 1: nHACa77F193D5J8RhxqwsOmllJsJ3jff/WJdWNoH2Fo=
Initial Root Token: hvs.ognCtf2fQrN1yaG1P6vUQXTZ
Vault initialized with 1 key shares and a key threshold of 1. Please ...
Unbedingt den Unseal-Key und das Root-Token aufschreiben! Diese werden noch benötigt.
Danach befindet sich der Vault im sogenannten „Sealed-State“. Das bedeutet, dass er keine Anfragen entgegennimmt und viel wichtiger keine Geheimnisse rausgibt. In diesem Zustand befindet sich Vault nach jedem Neustart, bzw. nach jedem „seal“-Befehl (Es gibt diese Art Kill-Switch auch direkt im UI).
Um Vault ansprechbar zu machen, müssen wir ihn mit folgendem Befehl öffnen:
vault operator unseal
Nun muss der zuvor notierte Unseal-Key eingegeben werden.
Key Value
--- -----
Seal Type shamir
Initialized true
Sealed false
An dem Feld „Sealed“ -> „false“ erkennen wir, dass der Vault geöffnet wurde.
Wir haben der Einfachheit halber sowohl key-shares als auch den key-threshold auf 1 gesetzt. Diese sind allerdings standardmäßig auf 5 und 3 gesetzt (und das auch aus gutem Grund).
Würden wir diese Standardeinstellungen verwenden, müssten wir den Unseal-Befehl dreimal mit jeweils unterschiedlichen Unseal-Keys wiederholen, damit sich Vault der Welt öffnet.
In der Praxis könnte man nun mehrere Unseal-Keys strategisch auf verschiedene Personen verteilen, um das unerwünschte Öffnen des Vaults zu erschweren.
Zurück zum Thema:
Vault spricht nun zwar mit uns, weist uns bei einigen der folgenden Befehle jedoch darauf hin, dass wir gar kein Recht auf eine Antwort haben. Deshalb benötigen wir für die Command-Line ein Anmelde-Token.
Um es einfach zu halten nehmen wir das Root-Token (Weitere Informationen zu Tokens befinden sich hier).
Wir können uns damit entweder direkt per Command Line einloggen
vault login
oder das Token im Environment (im weiteren als env-var bezeichnet) hinterlegen. In diesem Fall nutzt Vault das dort hinterlegte Token für die Anmeldung.
export VAULT_TOKEN=<root-token zb. hvs.46SFyUuEdha2f4nrl4X9ud40>
Bei solchen direkten Exports besteht immer das Problem, dass der Befehl mitsamt Secrets im Klartext in der Shell-History landet.
Um dies zu umgehen empfiehlt sich das folgende Vorgehen:
Den Inhalt der env-var (in diesem Fall das Root-Token) in einer Datei speichern (z.B. per „vim root_token”).
Anschließend wird die env-var mit dem Inhalt der Datei erstellt. In diesem Fall wäre das Folgendes: „VAULT_TOKEN=$(cat root_token)„.
Zum Schluss sollte man die Datei natürlich wieder löschen: „rm root_token“.
Jetzt haben wir einen benutzbaren Vault zur Verfügung.
Geheimnisse verstecken
Nun wollen wir zum spaßigen Teil kommen und lernen, wie wir Secrets in Vault ablegen können. Das Ganze funktioniert kurz und schmerzlos mit nur zwei Befehlen.
Erstmal müssen wir die KV-v2-Engine aktivieren.
vault secrets enable -version=2 -path=secret kv
Danach können wir mit dem Verstecken beginnen.
vault kv put secret/versteck geheimnis=53cr37
Damit haben wir unser Secret als Key-Value-Pair (mit dem Key „geheimnis“) unter dem Pfad „secret/data/versteck“ in Vault abgelegt (Vault fügt zu dem Pfad noch ein „/data/“ hinzu).
Wenn wir den Put-Befehl so ausführen, wird unser Geheimnis schon wieder im Klartext in der Shell-History gespeichert, die leider immer noch nicht ganz so geheim ist. Um dies zu verhindern kann man entweder das UI nutzen oder das Geheimnis aus einer Datei auslesen
Mithilfe der Kombination aus Pfad und Key können wir dann unser Secret abfragen.
vault kv get secret/versteck
Zu erwarten wäre hier folgender Output:
====== Data ======
Key Value
--- -----
geheimnis 53cr37
Jenkins und Vault miteinander bekannt machen
Um dieses Secret nun in Jenkins nutzen zu können, benötigen wir eine Anmeldemöglichkeit für Jenkins in Vault sowie eine Pipeline in Jenkins, die das Secret nutzt.
Ähnlich wie es in Vault mehrere Secret-Engines gibt, existieren in Vault auch mehrere Anmeldemethoden.
Wir werden als Anmeldemethode hier AppRole verwenden.
AppRole in Vault einrichten
Eine AppRole kann man sich wie einen Benutzer vorstellen. Es gibt eine Art Benutzernamen „RoleID“ und eine Art Passwort „SecretID“ (zusätzlich hat die AppRole noch einen Vault-internen Namen, der hier aber erstmal nicht relevant ist). Mit dieser Benutzername/Passwort-Kombination kann man sich bei Vault „anmelden“ und allerlei Sachen abfragen.
Nun kann man zum Glück nicht alles abfragen, sondern nur die Dinge für die man auch entsprechende Zugriffsrechte hat.
Mit genau dieser Erlaubnis beginnen wir nun.
Wir definieren eine sogenannte „policy“ und geben ihr die Rechte auf das oben angelegte Secret zuzugreifen.
Dazu legen wir im Docker-Container unter „/vault/config/“ eine Datei mit dem Namen „jenkins_policy.hcl“ an, in der wir die Policy niederschreiben.
In diese Datei schreiben wir nun Folgendes hinein:
path "secret/data/versteck" {
capabilities = ["read", "list"]
}Anschließend fügen wir diese Policy in Vault ein:
vault policy write jenkins-policy vault/config/jenkins_policy.hcl
Nun gibt es eine Policy mit Zugriff auf den Secret-Pfad „versteck“. Das heißt, die policy hat Lesezugriff auf alle key-value Secrets die unter „versteck“ liegen.
Mit nur einer Policy allein können wir erstmal wenig anfangen. Daher erstellen wir jetzt eine AppRole und weisen ihr diese Policy zu.
Zunächst aktivieren wir die AppRole-Authentification.
vault auth enable approle
Danach erstellen wir die AppRole und weisen ihr die Policy zu.
vault write auth/approle/role/jenkins-role token_type=batch secret_id_ttl=0 token_num_uses=0 token_ttl=0 token_max_ttl=0 secret_id_num_uses=0 policies="jenkins-policy"
Wir haben zum Testen eine einzige AppRole erstellt, deren Token eine unbegrenzte Anzahl an Verwendungen und eine unbegrenzte Gültigkeitsdauer haben.
In Live-Umgebungen lohnt es sich, den Anmeldeprozess etwas sicherer zu gestalten und etwas mehr Aufwand zu betreiben. Wie ein besserer Prozess aussehen würde, haben wir ganz unten einmal skizziert.
Wir haben nun eine AppRole mit Zugriffsrechten. Wie können wir diese nutzen?
Dazu müssen wir die RoleID auslesen (diese bleibt immer gleich) und eine SecretID erstellen. SecretIDs haben eine einstellbare Anzahl an Nutzungen und eine Time-to-Live-Frist, siehe Command oben.
Wir beginnen mit dem Auslesen der RoleID.
vault read auth/approle/role/jenkins-role/role-id
Das müsste in etwa so aussehen:
Key Value
--- -----
role_id 0fc9a138-db85-2f83-7653-766c0e2cef8e
Nun müssen wir uns noch eine SecretID erstellen.
vault write -force auth/approle/role/jenkins-role/secret-id
Hier ist das Feld „secret_id“ relevant.
Key Value
--- -----
secret_id fb834ff9-93aa-e2c4-e645-3ecc0b95eee9
secret_id_accessor 073a46d5-54b3-6836-0c69-22383f75bab0
secret_id_num_uses 0
secret_id_ttl 0s
AppRole testen
Hier eine kurze Anleitung ob das Erstellen der AppRole geklappt hat (Skip zum einbauen der AppRole):
Als erstes müssen wir sowohl die RoleID als auch die SecretID in env-vars speichern.
(Für Produktivumgebungen bitte für die Speicherung in env-vars den Workflow von oben nutzen)
export ROLE_ID=<z.B. db02de05-fa39-4855-059b-67221c5c2f63>
export SECRET_ID=<z.B. 6a174c20-f6de-a53c-74d2-6018fcceff64>
Danach erstellen wir ein Token (vergleichbar mit dem root-Token) für unsere AppRole.
vault write auth/approle/login role_id="$ROLE_ID" secret_id="$SECRET_ID"
Mit diesem Token können wir uns auch bei Vault Authentifizieren, dafür speichern wir es in einer env-var.
export APP_TOKEN=<240 Zeichen langes Token, z.B. hvb.AAAmDL-h4mOM27wgL...4MybZeKw59vcpf5zOaqHTxA2>
Und dann fragen wir unser angelegtes Secret noch einmal ab, wobei wir diesmal statt des Root-Tokens unser neues AppRole-Token nutzen.
VAULT_TOKEN=$APP_TOKEN vault kv get secret/versteck
AppRole in Jenkins einbauen
Und nun zum Finale – die Nutzung in Jenkins.
Um die AppRole in Jenkins einzubauen, gibt es verschiedene Varianten. Wir werden hier dazu das Vault-Plugin für Jenkins nutzen.
Wir fangen damit an das Paar aus RoleID und SecretID als Jenkins-Credential zu hinterlegen.
Wir erstellen ein neues Credential vom Typ „Vault App Role Credential“, tragen dort die RoleID und die SecretID ein und geben dem Secret die ID „vault-role“.

Als Letztes fehlt nur noch eine Pipeline, die das Secret ausliest.
Dazu erstellen wir eine normale Pipeline mit einem Pipeline-Skript.
node {
def configuration = [vaultUrl: 'http://host.docker.internal:8200',
vaultCredentialId: 'vault-role',
engineVersion: 2]
def secrets = [
[path: 'secret/versteck', engineVersion: 2, secretValues: [
[envVar: 'geheimnis', vaultKey: 'geheimnis']]]
]
withVault([configuration: configuration, vaultSecrets: secrets]) {
sh "echo $geheimnis > confidential.txt"
}
}Mithilfe des zuvor angelegten Jenkins-Credentials holt sich diese Pipeline das Secret von Vault ab und hinterlegt es in der env-var „Passwort“ (weitere Informationen siehe: https://plugins.jenkins.io/hashicorp-vault-plugin/#plugin-content-usage-via-jenkinsfile).
Diese env-var speichern wir dann in einer Datei namens „confidential.txt“.
Wir speichern es in einer Datei, da eine direkte Log-Ausgabe von Jenkins zum Glück zensiert wird.
Diese Zensur passiert aber nicht bei der Speicherung in Dateien, daher können wir uns den Inhalt von „$geheimnis“ in der „confidential.txt“ anschauen.
Wenn wir dann die Pipeline speichern und einmal ausführen bleibt uns als letzte Amtshandlung nur noch übrig zu schauen ob sich unser Geheimnis in der Datei „confidential.txt“ befindet.
Die Datei ist wie folgt zu finden:


Und fertig.
Addendum
Ein kurzer Exkurs zu AppRole-Best Practices:
Generell ist es eine gute Idee sich die Granularität von Vault zunutze zu machen, d.h. am besten eine AppRole pro Anwendung die nur Zugriff auf die benötigten Secrets erhält.
Es ist ebenfalls nicht optimal all diese AppRoles hardcoded in Jenkins einzubauen, so wie wir es hier gemacht haben. Besser ist es eine weitere AppRole zu erstellen, die nur die SecretID der eigentlichen AppRole auslesen kann. Diese wird dann in Jenkins eingebaut und kann der Pipeline dann eine gewrappte SecretID reinreichen. Die Pipeline kennt dann nur die RoleID und kann zusammen mit der übergebenen SecretID das tatsächliche Secret abfragen.
Zu diesem Ablauf hat Hashicorp selbst bereits eine ausführliche Erläuterung geschrieben.
– Korbinian Hurt, Sebastian Jenke

Kommentar verfassen