Zaktualizowano 6 min czytania

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.

Rys. 1. Repozytorium zawiera tylko odwołania; wartości żyją w jednym miejscu z audytem, a konsumenci pobierają je w czasie działania lub dostają krótkotrwałe poświadczenia.

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

  1. 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.
  2. Nic jawnego w repozytorium. Repozytorium zawiera odwołanie do sekretu, nie jego wartość.
  3. Minimalne uprawnienia na konsumenta. Pipeline budujący obraz nie ma dostępu do hasła bazy produkcyjnej. Aplikacja czyta tylko swoje sekrety.
  4. Rotacja bez zmiany kodu. Zmiana hasła to zmiana w jednym miejscu i najwyżej restart konsumenta.
  5. Wiadomo, kto czytał. Przynajmniej dla sekretów o wysokiej wartości.

Gdzie trzymać sekrety

OpcjaAudyt odczytówRotacjaKto widzi wartośćKiedy wystarcza
Zmienne CI (maskowane, chronione)brakręcznie w każdym projekcieopiekunowie projektusekrety używane tylko przez pipeline, na start
SOPS lub Sealed Secrets w repozytoriumbrakponowne szyfrowanie i commitkażdy, kto ma kluczGitOps bez osobnego serwera
Menedżer sekretów (np. OpenBao)takjedna zmiana w źródlekonsumenci 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.