セキュリティの既定値をCIで固定し、検査も検査する

秘密情報の平文、HTTPのまま、暗号化なし、rootで動くコンテナ。直すだけでは次の変更で元に戻るので、戻れないようにCIの検査を対で入れた。イメージ署名とSBOMの付与、アラートのルールそのもののテストまで、検査が壊れていないかを確かめる仕組みも含めて書く。

はじめに

検証リポジトリは、デプロイや DB マイグレーションの仕組みを実測するために作ったもので、AWS 上の構成は「立てて、測って、その日のうちに消す」使い捨てでした。そのため、セキュリティの基本的な設定が既定値のまま残っていました。見直して見つかったのは次の4つです。

項目状態
秘密情報DB のパスワードが、コンテナの環境変数に接続文字列ごと平文で入っていた
通信ロードバランサは HTTP(80 番)だけ。認証トークンが平文で流れていた
保存時の暗号化RDS の storage_encrypted が未設定(既定は false)
コンテナすべて root で動いていた

直すこと自体は難しくありません。問題は、直しただけでは次の変更で元に戻ることです。誰かが急いで環境変数にパスワードを足せば、それで終わりです。そこで、この記事では「直す」と「戻れないように CI で止める」を必ず対にしました。

アーキテクチャの守りたい性質を、人の注意ではなく実行できる検査で守る。この検査を進化的アーキテクチャでは 適応度関数(fitness function)と呼びます。

4つの既定値を直す

秘密情報: 実行時にだけ取り出す

接続文字列を AWS Systems Manager Parameter Store の SecureString(KMS で暗号化された値)に置き、ECS のタスク定義からは参照だけするようにしました。

secrets = [
  { name = "PHOTO_DSN", valueFrom = aws_ssm_parameter.photo_dsn.arn },
]

環境変数(environment)に書いた値は、タスク定義を読める人・コンソール・terraform plan の出力のすべてに見えます。secrets で参照すると、値はコンテナの起動時にだけ取り出されます。

見落としやすかったのは DB の初期化タスクです。mysql -p<パスワード> をコマンドに書いていたので、ECS のタスク実行履歴(DescribeTasks)にパスワードが残っていました。mysql クライアントは環境変数 MYSQL_PWD を読むので、これを secrets で渡してコマンドからパスワードを消しました。

1点、残る制約があります。Terraform で SSM の値を作ると、その値は Terraform の state ファイルには残ります。この検証では state をローカルに置き git から除外しているので許容していますが、本番では state の置き場所(暗号化されたリモート state)とセットで考える必要があります。

通信: 80 番はリダイレクトだけにする

443 番の HTTPS リスナーを立て、80 番は 301 で HTTPS へ転送するだけにしました。80 番で中身を返す経路を残さないことが目的です。証明書は検証用に自己署名を ACM へ取り込んでいます。確かめたいのはリスナー・転送・セキュリティグループの配線で、本番ではドメインを検証した ACM の証明書に差し替えるだけです。

暗号化とコンテナ

RDS と Aurora に storage_encrypted = true を入れました。コンテナは3つの Dockerfile すべてに専用ユーザー(uid 65532)を作って USER で切り替え、タスク定義では readonlyRootFilesystem = true(ルートのファイルシステムに書き込めない)にしました。ログは標準出力、状態は DB と S3 に置く設計なので、書き込む場所が無くても困りません。

実機で確認

確認結果
HTTPS で /healthz200
HTTP で同じ URL301 で HTTPS へ転送
RDS の StorageEncryptedtrue
タスク定義の環境変数接続文字列なし(secrets に3件だけ)
SSM から取り出した接続文字列が実際に効いているか認証付きの一覧 API が 200、トークンなしは 401(DB まで到達している)
コンテナのユーザーuid=65532(app)。書き込み不可のまま正常に稼働

戻れないように CI で止める

直した4つのうち3つを、CI の検査スクリプトにしました(TLS はリスナー構成の話なので対象外)。

規則検査のしかた
コンテナは root で動かないすべての Dockerfile の最終ステージに USER があること(USER root は不可)
DB は保存時に暗号化するすべての aws_db_instance / aws_rds_cluster に storage_encrypted = true があること
秘密を環境変数に平文で置かないTerraform の環境変数の定義に、パスワードやトークンの変数を参照する行が無いこと

この検査で大事にしたことが2つあります。

1つ目は、調べた件数を必ず出力し、0 件なら失敗にすることです。「Dockerfile 3 本・DB リソース 2 件・tf 4 本を確認」と出力します。ファイルの置き場所が変わって対象が 0 件になると、違反も 0 件になり、検査は成功してしまいます。検査が壊れると失敗ではなく成功として出てくる(fail open)ので、それを防ぎます。

2つ目は、違反をわざと入れて、検査が落ちることを確かめてから信用することです。USER の行を消す・暗号化の行を消す・環境変数にパスワードを足す、の3通りを試し、3つとも検査が失敗することを確認しました。

配る側の証明: 署名・SBOM・provenance

依存ライブラリやイメージの脆弱性を見つける仕組み(5層で回す話)は、「入ってくるもの」を見ます。その反対側、「出ていくもの」に証明を付ける仕組みも入れました。

使う側は、デプロイ前に3つすべてを検証し、1つでも欠けたら失敗にします。署名の検証では「このリポジトリの、このワークフローが署名したこと」まで条件にします。

cosign verify \
  --certificate-identity-regexp "^https://github.com/<org>/<repo>/\.github/workflows/image-release\.yml@" \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  "$IMAGE"

3つのイメージで付与と検証が通ることに加えて、別のワークフローを名乗って検証すると拒否されることも確かめました。「レジストリに push できた」ことは「自分たちがビルドした」ことを意味しないので、この条件が要ります。

動かすまでに踏んだ罠3つ

1. GitHub Actions の式の誤りは、実行するまで分からない。 split() という関数は GitHub Actions の式に存在しません。ところがワークフローのファイルを push しても何もエラーは出ず、手動実行(workflow_dispatch)した時点で初めて 422 が返りました。

2. ghcr のリポジトリ名は小文字でなければならない。 github.repository は rikukaInoue/greenfield のように大文字を含みうるので、イメージ名に使う前に小文字に変換します。

3. SBOM を付けたのに、検証すると「無い」と言われる。 cosign の attest は成功していて、cosign tree で見るとイメージに SBOM が付いていました。ところが cosign verify-attestation --type spdxjson は「provenance しか見つからない」と失敗しました。cosign v3 の検証は OCI の referrers という新しい方式で紐付いたものしか読まず、attest の既定は古い方式(.att タグ)で付けるためです。--new-bundle-format=true で付けて解決しました。付与に成功したことは、検証できることを意味しないという例です。

アラートのルール自体をテストする

監視にも同じ考え方を当てました。常設のアラートは5本に絞り、それぞれに「鳴ったら何を見るか」の手順書(Runbook)を1対1で付けました。

アラート意味
DB 接続プールの飽和使用中の接続が上限の 80% を 5 分超える
認可サービスの遅延95 パーセンタイルが 100ms を 10 分超える
outbox の滞留最も古い未送信イベントが 60 秒以上残る
メトリクスが読めないSQL 由来のメトリクスが「読めない」を表す -1 を返している
contract マイグレーションの放置未適用の削除系マイグレーションが 3 日以上残る

4つ目は監視の監視です。メトリクスを集めるクエリが失敗したとき 0 を返すと「問題なし」と区別できないので、-1 を返す規約にしてあり、その -1 自体を検知します。

アラートのルールは、閾値の書き間違い1つで「永遠に鳴らない」状態になります。そこで Prometheus 付属の promtool test rules で、閾値を超える時系列を流したら鳴る、超えない時系列なら鳴らないの両方をテストし、CI に入れました。閾値をわざと壊すと、このテストが失敗することも確認しています。

検査の検査

ここまでで、デプロイの可否もセキュリティの設定もアラートも、人の判断ではなく検査が決める構成になりました。そうなると、検査が壊れたときの被害が大きくなります。検査が誤って失敗し続ければ全員がそれを避ける方法を探し始め、誤って成功し続ければ誰も気づきません。

そこで検査そのものに回帰テストを持たせ、まとめて実行できるようにしました。

あわせて、検査が誤検知し始めたときの規則も決めました。無効化せずに「失敗しても止めない」状態で動かし続ける(証拠を残すため)、その状態には issue と期限を必ず付ける、避ける経路は1つに限る、元に戻す条件は「しばらく成功していた」ではなく「原因が分かった」こと、の4つです。

まとめ

← Back to all posts