セキュリティの既定値を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 で /healthz | 200 |
| HTTP で同じ URL | 301 で HTTPS へ転送 |
RDS の StorageEncrypted | true |
| タスク定義の環境変数 | 接続文字列なし(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層で回す話)は、「入ってくるもの」を見ます。その反対側、「出ていくもの」に証明を付ける仕組みも入れました。
- 署名: cosign の keyless 署名。GitHub Actions の OIDC トークンで一時的な証明書を発行して署名するので、漏れる秘密鍵がそもそも存在しない
- SBOM: syft でイメージに含まれるパッケージの一覧(SPDX 形式)を作り、イメージに紐付ける
- provenance: どのワークフローが、どのコミットからビルドしたかの記録(GitHub の build attestation)
使う側は、デプロイ前に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 に入れました。閾値をわざと壊すと、このテストが失敗することも確認しています。
検査の検査
ここまでで、デプロイの可否もセキュリティの設定もアラートも、人の判断ではなく検査が決める構成になりました。そうなると、検査が壊れたときの被害が大きくなります。検査が誤って失敗し続ければ全員がそれを避ける方法を探し始め、誤って成功し続ければ誰も気づきません。
そこで検査そのものに回帰テストを持たせ、まとめて実行できるようにしました。
- デプロイの promotion gate(新しい版に試験的にリクエストを流して判定する仕組み)の判定ロジックを、Terraform に埋め込んだ文字列から独立したファイルに移し、単体テストを付けた。「ヘルスチェックは正常だが書き込みだけ 500」を落とすこと、「201 以外は成功と見なさない」ことなどを固定している
- 変更の影響範囲を判定するツール・API の互換性チェック・アラートのルールなど、検査ツール自身のテストを
mise run audit:checksで一括実行する - これを四半期に1回、GitHub Actions のスケジュール実行で回し、失敗したら issue を自動で作る。スケジュール実行の失敗は誰にも見られないまま放置されやすいので、通知先を issue にしている
あわせて、検査が誤検知し始めたときの規則も決めました。無効化せずに「失敗しても止めない」状態で動かし続ける(証拠を残すため)、その状態には issue と期限を必ず付ける、避ける経路は1つに限る、元に戻す条件は「しばらく成功していた」ではなく「原因が分かった」こと、の4つです。
まとめ
- セキュリティの既定値は、直すだけでは次の変更で元に戻る。直すたびに CI の検査を対で入れる
- 検査は「調べた件数を出す」「0 件なら失敗」「わざと違反を入れて落ちることを確かめる」の3つで、壊れても成功に見える状態を防ぐ
- 脆弱性の検出(入ってくるもの)とは別に、署名・SBOM・provenance で出ていくものに証明を付ける。付与の成功と検証の成功は別に確かめる
- 判断を検査に任せるほど、検査が壊れたときの影響が大きくなる。検査にも回帰テストと定期的な実行を持たせる