Sekrety w automatyzacji i CI/CD: jedno źródło, krótkie poświadczenia, zero wartości w Git
Hasła w playbookach, tokeny w zmiennych pipeline'u, ręcznie tworzone Secrety w klastrze. Jak w małym zespole przenieść sekrety do jednego źródła i dostarczać je konsumentom bez zapisywania wartości w repozytorium.
Przewodnik Od commita do produkcji Część 4 z 6
- Jeden szablon pipeline'u dla wszystkich projektów: include, wersje i kontrola dryfu
- Budowanie obrazów bez demona Dockera: kaniko, Buildah i BuildKit w GitLab CI
- Tag obrazu to wersja: dlaczego nigdy :latest i jak wycofać wdrożenie jednym commitem
- Sekrety w automatyzacji i CI/CD: jedno źródło, krótkie poświadczenia, zero wartości w Git
- GitOps a skrypt deployu: pętla uzgadniania, dryf i wycofanie na przykładzie Argo CD
- Kopie w Kubernetes: co zabezpieczać, gdy klaster da się odtworzyć z repozytorium
W środowisku, które rozwija się kilka lat bez planu na sekrety, poświadczenia są wszędzie: w zmiennych pipeline’u w kilkunastu projektach, w zaszyfrowanych plikach w repozytoriach z kluczem w czyimś katalogu domowym, w Secretach Kubernetes utworzonych ręcznie, w plikach konfiguracyjnych na serwerach i w notatkach administratorów. Rotacja jednego hasła do bazy wymaga znalezienia wszystkich miejsc, w których jest używane, a nikt nie wie, gdzie są wszystkie.
Porządek nie wymaga drogiej licencji, tylko kilku decyzji i trzech mechanizmów: operatora, który przenosi sekrety z menedżera do klastra, tokenów OIDC dla pipeline’ów i skanera, który pilnuje, żeby nic nie wróciło do repozytorium.
Zasady, niezależnie od narzędzia
- Jedno źródło prawdy. Każdy sekret ma jedno miejsce, w którym jest przechowywany i zmieniany. Wszystkie inne miejsca pobierają go stamtąd.
- Nic jawnego w repozytorium. Repozytorium zawiera odwołanie do sekretu, nie jego wartość.
- Minimalne uprawnienia na konsumenta. Pipeline budujący obraz nie ma dostępu do hasła bazy produkcyjnej. Aplikacja czyta tylko swoje sekrety.
- Rotacja bez zmiany kodu. Zmiana hasła to zmiana w jednym miejscu i najwyżej restart konsumenta.
- Wiadomo, kto czytał. Przynajmniej dla sekretów o wysokiej wartości.
Gdzie trzymać sekrety
| Opcja | Audyt odczytów | Rotacja | Kto widzi wartość | Kiedy wystarcza |
|---|---|---|---|---|
| Zmienne CI (maskowane, chronione) | brak | ręcznie w każdym projekcie | opiekunowie projektu | sekrety używane tylko przez pipeline, na start |
| SOPS lub Sealed Secrets w repozytorium | brak | ponowne szyfrowanie i commit | każdy, kto ma klucz | GitOps bez osobnego serwera |
| Menedżer sekretów (np. OpenBao) | tak | jedna zmiana w źródle | konsumenci objęci polityką | wielu konsumentów, wymagany audyt |
Wśród menedżerów open source jest OpenBao, rozwijany pod egidą Linux Foundation po zmianie licencji HashiCorp Vault w 2023 roku i zgodny z API Vaulta. Przy wyborze dowolnego narzędzia warto sprawdzić, czy logowanie przez katalog (LDAP) lub SSO jest dostępne w darmowej wersji; część produktów trzyma je w planach płatnych. Hasła ludzi należą do osobnego menedżera haseł: mają innych właścicieli i inny cykl życia niż sekrety automatów.
Kubernetes: ExternalSecret zamiast ręcznego Secretu
External Secrets Operator czyta sekrety z menedżera i tworzy z nich zwykłe obiekty Secret. Repozytorium zawiera tylko definicję „pobierz to i to”, którą można bez obaw wersjonować i przeglądać w merge requestach. Połączenie z menedżerem opisuje jeden obiekt dla całego klastra:
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
name: openbao
spec:
provider:
vault:
server: https://bao.corp.example.com
path: secret
version: v2
auth:
kubernetes:
mountPath: kubernetes
role: external-secrets
serviceAccountRef:
name: external-secrets
namespace: external-secrets
Aplikacja dostaje własny ExternalSecret w swojej przestrzeni nazw:
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: orders-db
namespace: orders
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: openbao
target:
name: orders-db
creationPolicy: Owner
data:
- secretKey: DB_PASSWORD
remoteRef:
key: apps/orders/db
property: password
refreshInterval określa, jak często operator sprawdza wartość w menedżerze, więc rotacja w źródle trafia do Secretu najpóźniej po godzinie; aplikacja, która czyta go jako zmienne środowiskowe, potrzebuje jeszcze restartu. creationPolicy: Owner sprawia, że Secret ma właściciela w postaci ExternalSecret i znika razem z nim. Wersja API external-secrets.io/v1 jest jedyną obsługiwaną od wydania 0.17 operatora; manifesty z v1beta1 trzeba zaktualizować przed aktualizacją operatora.
GitLab CI: token OIDC zamiast długotrwałego tokenu
Zamiast trzymać w zmiennych projektu token do menedżera sekretów, zadanie prosi GitLab o podpisany token JWT (id_tokens), ważny tylko w czasie trwania zadania i zawierający informację, z jakiego projektu i gałęzi pochodzi. Menedżer weryfikuje podpis i wydaje własny token na kilka minut. Konfiguracja po stronie menedżera, wykonywana raz przez administratora:
vault write auth/jwt/config \
oidc_discovery_url="https://gitlab.example.com" \
bound_issuer="https://gitlab.example.com"
vault write auth/jwt/role/ci-orders-deploy - <<'EOF'
{
"role_type": "jwt",
"user_claim": "project_path",
"bound_audiences": ["https://bao.corp.example.com"],
"bound_claims": {
"project_path": "apps/orders-api",
"ref_protected": "true"
},
"policies": ["orders-deploy-read"],
"token_explicit_max_ttl": 300
}
EOF
Rola przyjmie wyłącznie token z projektu apps/orders-api uruchomiony na chronionej gałęzi lub tagu. Merge request z gałęzi roboczej nie dostanie sekretu, nawet jeśli ktoś zmieni w nim .gitlab-ci.yml. Zadanie w pipeline:
deploy-config:
stage: deploy
image: $SECRETS_CLI_IMAGE # obraz z klientem vault, wersja przypięta w szablonie
id_tokens:
BAO_ID_TOKEN:
aud: https://bao.corp.example.com
variables:
VAULT_ADDR: https://bao.corp.example.com
script:
- export VAULT_TOKEN="$(vault write -field=token auth/jwt/login role=ci-orders-deploy jwt="$BAO_ID_TOKEN")"
- export DB_PASSWORD="$(vault kv get -field=password secret/apps/orders/db)"
- ./scripts/migrate.sh
rules:
- if: $CI_COMMIT_REF_PROTECTED == "true"
Słowo kluczowe secrets: z wbudowaną integracją Vault jest w płatnych planach GitLaba; w wersji bezpłatnej ten sam efekt daje id_tokens i logowanie w skrypcie, jak wyżej.
Ansible: no_log i diff
Playbook pobiera sekret ze zmiennej dostarczonej przez AWX (poświadczenie z wtyczką odczytu z menedżera sekretów) albo z lookupu. Zadania, które go używają, nie mogą go wypisać:
- name: Rejestracja hosta w API monitoringu
ansible.builtin.uri:
url: https://monitoring.corp.example.com/api/hosts
method: POST
headers:
Authorization: "Bearer {{ monitoring_api_token }}"
body_format: json
body:
name: "{{ inventory_hostname }}"
status_code: [200, 201]
no_log: true
- name: Plik konfiguracyjny z hasłem do bazy
ansible.builtin.template:
src: app.conf.j2
dest: /etc/app/app.conf
owner: root
group: app
mode: "0640"
diff: false
no_log: true
no_log: true ukrywa parametry i wynik zadania, także przy uruchomieniu z -vvv. diff: false zapobiega wypisaniu zawartości pliku przy uruchomieniu z --diff. no_log nie chroni jednak zmiennej zarejestrowanej przez register: jeśli później trafi do debug, wartość pojawi się w logu zadania.
Inwentaryzacja i skanowanie: gitleaks
Migracja zaczyna się od listy. Gitleaks skanuje całą historię repozytorium, nie tylko bieżące pliki:
gitleaks git --redact --report-format json --report-path orders-api-leaks.json ~/src/orders-api
Od wersji 8.19 historię skanuje gitleaks git, a katalog bez historii gitleaks dir; starsze polecenia detect i protect są wycofywane. --redact ukrywa wartości w wyniku, żeby raport sam nie stał się wyciekiem. W pipeline GitLab ma gotowy szablon wykrywania sekretów oparty na Gitleaks, dostępny we wszystkich planach:
include:
- template: Jobs/Secret-Detection.gitlab-ci.yml
Jednorazowy skan historii w pipeline włącza zmienna SECRET_DETECTION_HISTORIC_SCAN: "true". Każdy sekret znaleziony w historii uznaje się za ujawniony i wymienia. Usunięcie go z bieżącej wersji pliku nie usuwa go z historii ani z kopii repozytorium na laptopach.
Sprawdź u siebie
Skan wszystkich sklonowanych repozytoriów:
for repo in ~/src/*/; do
echo "== $repo"
gitleaks git --redact "$repo"
done
# Dobrze: informacja o braku wycieków dla każdego repozytorium.
# Źle: liczba znalezisk większa od zera; każde to sekret do rotacji.
Secrety bez właściciela, czyli utworzone ręcznie lub przez skrypt, posortowane od najstarszego:
kubectl get secrets -A -o json | jq -r '.items[]
| select(.metadata.ownerReferences == null)
| select(.type != "helm.sh/release.v1" and .type != "kubernetes.io/service-account-token")
| "\(.metadata.creationTimestamp)\t\(.metadata.namespace)/\(.metadata.name)"' | sort
# Każda pozycja starsza niż np. rok to kandydat do migracji na ExternalSecret i rotacji.
# Certyfikaty TLS z cert-managera też się tu pojawią i są w porządku.
Stan synchronizacji ExternalSecretów:
kubectl get externalsecrets -A
# Dobrze: READY True i STATUS SecretSynced.
# Źle: SecretSyncedError, czyli aplikacja działa na starej wartości albo nie wystartuje po restarcie.
Szyfrowanie etcd i uprawnienia do Secretów:
sudo k3s secrets-encrypt status
kubectl auth can-i list secrets -n orders --as=system:serviceaccount:ci:default
# Dobrze: Encryption Status: Enabled oraz odpowiedź no dla kont, które nie powinny czytać sekretów.