Gitlab CI/CD - HashiCorp Vaultを使ったSecret対応処理

# 概要

Gitlab Runner の処理にて、HashiCorp Vault JWT認証を行い、vault secretを利用します。
Gitlab でシークレットレス管理を行うための参考情報となります。

# 前提条件

- Gitlab CI/CD、Hashirocp Vault の基本操作を理解していること。
  ※本記事ではgitlabやvaultの細かな操作方法についての説明は行いません。
- gitlab runner から vault へのアクセスが可能であること。
  ※vault へのアクセス制限がある場合は、self-managed runner を利用してください。
- vault から gitlab へのアクセスが可能であること。
- 本記事の内容は、HashiCorp Vault v1.15.4 BSL版、および Gitlab SaaS 16.8.0 /Self-Managed 15.11.13 のいずれもFreeプラン環境での動作を確認しています。

# 処理の流れ

本記事では、下記のパイプラインを作成します。

- vault_auth
  Vault認証を行い、認証Tokenを後続処理に渡す
- kv_job
  認証Tokenを用いて、Vault KV Secret (ID/Passwordなど)を読み取り、処理を実行
- vault_get_secret_aws
  認証Tokenを用いて、Vault AWS Secret (一時IAM AccessKey) を取得し、後続処理に渡す
- aws_job
  一時IAM AccessKeyを用いて、aws関連処理を実行
- vault_revoke_lease
  Vault AWS Secret(一時IAM AccessKey)の破棄
- vault_revoke_token
  Vault 認証Tokenの破棄

# Vault 環境準備

あらかじめ、vaultに、secret、policy、auth の設定を追加します。

## kv secret および policy の追加

```shell
# kv secret (version 1) 追加
vault secrets enable -path=kv/myapp -description="kv for myapp" kv

vault kv put kv/myapp/system -<<EOF
{
  "hostname": "system",
  "ipaddress": "192.168.0.1",
  "login_user": "user01",
  "login_password": "P@ssw0rd"
}
EOF

# kv secret 確認
vault kv list kv/myapp
vault kv get kv/myapp/system

# policy 追加
vault policy write kv_myapp_policy -<<EOF
# kv/myapp/ 以下のkey-value 管理
path "kv/myapp/*" {
  capabilities = [ "create", "read", "update", "delete", "list" ]
}
EOF

# policy 確認
vault policy list
vault policy read kv_myapp_policy
```

## aws secret および policy の追加

```shell

# aws secret の追加 ※別途AWS側でIAM UserまたはIAM Role の設定が必要
# 詳しくはこちら https://qiita.com/saitamanokusa/items/24888fdcabaa7b153be5#aws-secret
# ※aws の利用の用途がない場合は飛ばしてください
vault secrets enable -path=aws/myapp -description="aws for myapp" aws

access_key="xxxxxxxxxxx"
secret_key="xxxxxxxxxxx"
region="ap-northeast-1"
vault write aws/myapp/config/root access_key="$access_key" secret_key="$secret_key" region=$region
vault read aws/myapp/config/root

vault write aws/myapp/config/lease lease=15m lease_max=1h
vault read aws/myapp/config/lease

vault write aws/myapp/roles/s3_full credential_type=iam_user policy_arns=arn:aws:iam::aws:policy/AmazonS3FullAccess
vault list aws/myapp/roles
vault read aws/myapp/roles/s3_full

# policy 追加
vault policy write aws_myapp_policy -<<EOF
# aws/myapp/ 以下の管理
path "aws/myapp/*" {
  capabilities = ["create", "read", "update", "list" , "delete"]
}
path "sys/leases/lookup/aws/myapp/creds/*" {
  capabilities = ["create", "update"]
}
path "sys/leases/revoke/aws/myapp/creds/*" {
  capabilities = [ "read","update"]
}
EOF

# policy 確認
vault policy list
vault policy read aws_myapp_policy
```

## JWT認証の追加

```shell
# gitlab.com JWT認証用の auth を追加
vault auth enable -path jwt/gitlab.com -description="for GitLab CI/CD on gitlab.com" jwt

# JWKSエンドポイントなど  ※Self-Managed版はドメイン名を修正してください
vault write auth/jwt/gitlab.com/config \
    jwks_url="https://gitlab.com/-/jwks" \
    bound_issuer="https://gitlab.com"

vault read auth/jwt/gitlab.com/config

# ※bound_claimsは環境に合わせて設定値を修正してください
vault write auth/jwt/gitlab.com/role/myrole - <<EOF
{
  "role_type": "jwt",
  "user_claim": "user_email",
  "bound_claims_type": "string",
  "bound_claims": {
    "iss": "https://gitlab.com",
    "project_path": "gitalab_sample/vault_lab"
  },
  "policies": [
    "aws_myapp_policy",
    "kv_myapp_policy"
  ],
  "ttl": "15m",
  "token_explicit_max_ttl": "60m"
}
EOF

vault list auth/jwt/gitlab.com/role
vault read auth/jwt/gitlab.com/role/myrole
```

vaultの習得は、チュートリアルが充実していますので、そちらをご参照ください。
https://developer.hashicorp.com/vault/tutorials
主なコマンド操作はこちらにも記載があります。
https://qiita.com/saitamanokusa/items/24888fdcabaa7b153be5

# gitlab 資材の配置

下記のファイルを用意します。

```shell
$ tree -a
.
├── .gitlab-ci.yml
└── script
    ├── vault_auth_jwt.sh
    ├── vault_get_secret_aws.sh
    ├── vault_revoke_lease.sh
    └── vault_revoke_token.sh
```

## .gitlab-ci.yml

rules などは環境に合わせて追加してください。

```yaml:.gitlab-ci.yml
default:
  image: hashicorp/vault:1.15.4
  before_script:
    - apk add bash curl jq

variables:
  VAULT_ADDR: "https://vault.saitamanokusa.net:8200"
  VAULT_SKIP_VERIFY: "true"
  VAULT_AUTH_PATH: jwt/gitlab.com
  VAULT_AUTH_ROLE: myrole
  VAULT_AWS_SECRET_PATH: aws/myapp
  VAULT_AWS_SECRET_ROLE: s3_full

vault_auth:
  id_tokens:
    VAULT_AUTH_JWT:
      aud: https://gitlab.com
  artifacts:
    reports:
      dotenv: vault.env
  script:
    - bash ./script/vault_auth_jwt.sh

kv_job:
  needs:
    - vault_auth
  script:
    - res=$(vault kv get -format=json kv/myapp/system)
    - myapp_hostname=$(echo "$res" | jq -r '.data.hostname')
    - myapp_ipaddress=$(echo "$res" | jq -r '.data.ipaddress')
    - myapp_user=$(echo "$res" | jq -r '.data.login_user')
    - myapp_password=$(echo "$res" | jq -r '.data.login_password')
    - echo "hostname:$myapp_hostname"
    - echo "ipaddress:$myapp_ipaddress"
    - echo "user:$myapp_user"
    - echo "password:$myapp_password"

vault_get_secret_aws:
  needs:
    - vault_auth
  artifacts:
    reports:
      dotenv: vault.env
  before_script:
    - apk add bash curl jq aws-cli
  script:
    - env|grep VAULT
    - bash ./script/vault_get_secret_aws.sh

aws_job:
  needs:
    - vault_get_secret_aws
  before_script:
    - apk add bash curl jq aws-cli
  script:
    - env|grep AWS
    - aws s3 ls

vault_revoke_lease:
  needs:
    - vault_get_secret_aws
    - aws_job
  script:
    - bash ./script/vault_revoke_lease.sh

vault_revoke_token:
  needs:
    - vault_auth
    - kv_job
    - vault_revoke_lease
  script:
    - bash ./script/vault_revoke_token.sh
```

## script

```shell:script/vault_auth_jwt.sh
#!/usr/bin/env bash

set -euo pipefail

# vault auth
if ! res=$(vault write -format=json "auth/${VAULT_AUTH_PATH}/login" role="$VAULT_AUTH_ROLE" jwt="$VAULT_AUTH_JWT"); then
    echo "vaultの 応答がありません" >&2
    exit 1
fi

# curl コマンドの場合
# if ! res=$(curl -sk -X PUT \
#     -d "{\"jwt\":\"${VAULT_AUTH_JWT}\",\"role\":\"${VAULT_AUTH_ROLE}\"}" \
#     "${VAULT_ADDR}/v1/auth/${VAULT_AUTH_PATH}/login"); then
#     echo "vaultの 応答がありません" >&2
#     exit 1
# fi

vault_token=$(echo "$res" | jq -r '.auth.client_token')
[ ! "$vault_token" ] || [ "$vault_token" = "null" ] && { echo -e "vaultの応答に問題があります\n${res}" >&2 && exit 1; }

# 標準出力でvaultのレスポンスを返す
echo "$res" | jq -r

# 後続処理用に変数を定義
if [ "${GITLAB_CI:=}" ]; then echo "VAULT_TOKEN=$vault_token" >>"vault.env"; fi

exit
```

```shell:script/vault_get_secret_aws.sh
#!/usr/bin/env bash

set -euo pipefail

# vault secret 取得
if ! res=$(vault read -format=json "${VAULT_AWS_SECRET_PATH}/creds/${VAULT_AWS_SECRET_ROLE}"); then
    echo "vaultの 応答がありません" >&2
    exit 1
fi

# curl コマンドの場合
# if ! res=$(curl -s -k \
#     -H "X-Vault-Request: true" -H "X-Vault-Token: $VAULT_TOKEN" \
#     "${VAULT_ADDR}/v1/${VAULT_AWS_SECRET_PATH}/creds/${VAULT_AWS_SECRET_ROLE}"); then
#     echo "vaultの 応答がありません" >&2
#     exit 1
# fi

# 動作確認および後続処理用に値を取得
vault_lease_id=$(echo "$res" | jq -r '.lease_id')
access_key=$(echo "$res" | jq -r '.data.access_key')
secret_key=$(echo "$res" | jq -r '.data.secret_key')
security_token=$(echo "$res" | jq -r '.data.security_token')
[ "$security_token" = "null" ] && security_token=""
# 応答内容に問題がある場合はエラーを返す
[ ! "$access_key" ] || [ "$vault_lease_id" = "null" ] && { echo -e "vaultの応答に問題があります\n${res}" >&2 && exit 1; }

# 即応答を返すと、取得したAccessKeyで認証エラーが出るため、
# aws認証が通るようになるまで待機
export AWS_ACCESS_KEY_ID="$access_key"
export AWS_SECRET_ACCESS_KEY="$secret_key"
export AWS_SESSION_TOKEN="$security_token"

declare count=0
while ! aws sts get-caller-identity >/dev/null 2>&1; do
    sleep 1 >/dev/null 2>&1
    count=$((count + 1))
    [[ $count -gt 60 ]] && { echo "aws 認証タイムアウト" >&2 && exit 1; }
done

# 認証が不安定であるため追加で5秒待機（credential_type=assumed_role利用時は不要）
sleep 5 >/dev/null 2>&1

# 標準出力でvault のレスポンスを返す
echo "$res" | jq

# 後続処理用に変数を定義
if [ "${GITLAB_CI:=}" ]; then
    {
        echo "VAULT_TOKEN=$VAULT_TOKEN"
        echo "VAULT_LEASE_ID=$vault_lease_id"
        echo "AWS_ACCESS_KEY_ID=$access_key"
        echo "AWS_SECRET_ACCESS_KEY=$secret_key"
        echo "AWS_SESSION_TOKEN=$security_token"
    } >>"vault.env"
fi

exit
```

```shell:script/vault_revoke_lease.sh
#!/usr/bin/env bash

set -euo pipefail

# vault revoke処理
if ! res=$(vault lease revoke "${VAULT_LEASE_ID}"); then
    echo "vaultの 応答がありません" >&2
    exit 1
fi

# curl コマンドの場合
# if ! res=$(curl -s -k -X PUT \
#     -H "X-Vault-Request: true" -H "X-Vault-Token: ${VAULT_TOKEN}" \
#     -d '{"sync":false}' \
#     "${VAULT_ADDR}/v1/sys/leases/revoke/${VAULT_LEASE_ID}"); then
#     echo "vaultの 応答がありません" >&2
#     exit 1
# fi

# 標準出力でvaultのレスポンスを返す
echo "$res"

exit
```

```shell:script/vault_revoke_token.sh
#!/usr/bin/env bash

set -euo pipefail

# token reveoke
if ! res=$(vault token revoke -self); then
  echo "vaultの 応答がありません" >&2
  exit 1
fi

# curl コマンドの場合
# if ! res=$(curl -s -k -X PUT \
#   -H "X-Vault-Request: true" -H "X-Vault-Token: ${VAULT_TOKEN}" \
#   "${VAULT_ADDR}/v1/auth/token/revoke-self"); then
#   echo "vaultの 応答がありません" >&2
#   exit 1
# fi

# 標準出力でvaultのレスポンスを返す
echo "$res"

exit
```

# 動作チェック

vault設定および資材の準備ができましたら、実際にpipelineを実行してみてください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2529537/2cf51272-42b1-bbb5-159a-90f8bc087804.png)

もし vault_auth がエラーとなる場合は、runner → vault → gitlab のアクセス制限や、`bound_claims` の設定をご確認ください。
vault_get_secret_aws がエラーとなる場合は、AWS IAM User/IAM Roleに割り当てたPolicyのチェックや、sleep コマンドの時間を調整してみてください。

# 【参考】vault 認証roleのbound_claims設定について

本記事で利用するJWT認証では、vault jwt認証のrole - `bound_claims`にて、認証元の正当性をチェックします。
しかし初めて利用する場合は、`bound_claims` に何を設定すればいいかが分かりづらいかと思います。
この設定値のチェック方法について解説します。

下記のような`.gitlab-ci.yml`でパイプラインを実行してみてください。

```yaml:.gitlab-ci.yml
default:
  image: alpine:3.18

get_jwt:
  id_tokens:
    VAULT_AUTH_JWT:
      aud: https://gitlab.com
  artifacts:
    reports:
      dotenv: vault.env
  script:
    - echo "VAULT_AUTH_JWT=$VAULT_AUTH_JWT">>vault.env

check_jwt:
  needs:
    - get_jwt
  script:
    - echo $VAULT_AUTH_JWT
```

後続ジョブ`check_jwt`にて、JWTの値が出力されますので、これをコピーしてください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2529537/56228d97-b675-cf8d-28d1-0afc6956b635.png)

あとは、こちらのサイトで、取得したJWTの値を貼り付け、Payloadを確認してみてください。
https://jwt.io/

vault 認証時に渡される情報が確認できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2529537/39edcfe8-6881-8710-8666-4ed8474c0e6f.png)

この値を参考に、`namespace_path` や `project_path` など適切な値を使って、`bound_claims`を設定します。

# 最後に

今回ご紹介した方法では、key-value および aws accesskey のみ利用していますが、vault には他にも便利なsecret機能が多くあります。
これらを利用することで、Gitlab のプロジェクト上にシークレット情報を一切持たない構成も可能です。
シークレットの一元管理やセキュリティ対策を行う際の、参考としていただければ幸いです。

# 参考

https://docs.gitlab.com/ee/ci/secrets/id_token_authentication.html
https://docs.gitlab.com/ee/ci/examples/authenticating-with-hashicorp-vault/
https://developer.hashicorp.com/vault/docs/auth/jwt
https://developer.hashicorp.com/vault/api-docs/auth/jwt

※`secrets:`はGitlab Premium以上の機能です