はじめに
セキュリティシフトレフト記事第二弾。
前回は、主にGitリポジトリにCommitする前のローカルPC上でのセキュリティチェックについて実施方法をまとめた。
今回は、Commit後に行うべきセキュリティチェックの構築方法についてまとめていく。
なお、今回以降、ブランチ戦略はGitLab Flowを想定しているが、GitHub FlowでもGit Flowでも考え方は同じなので、ブランチ名を適宜変更しながら対応していただければ良い。
どのタイミングでチェックをするのが適切か?
考察
さて、リポジトリCommit後に行うセキュリティチェックはどのタイミングで行うのが良いだろうか?
前回は、Commitに1度でも含めてしまったら危険なものを排除する最低限のチェックを行った。
Commitの都度チェックをかけるのでは過剰すぎるが、当然ながらチェックの結果コードが変わる可能性もあり得るため、レビュー後、プルリクエストの承認をするタイミングでエラーになると手戻りのコストが高くなる。
レビュアーがピカピカの状態でレビューすることを考慮すると、ここは、プルリクエストを発行したタイミングでチェックを行い、静的解析が終わったタイミングでレビュー実施するのが良いだろう。
また、静的解析の時間が長いとレビューまでのリードタイムが延びてしまうため、できるだけ効率良く終わらせたい。
静的解析は複数のチェックを並行して行っても差し支えが無いので、今回は並列実行のソリューションとして、AWS
AWS CodeBuildのバッチビルドを選択する。
※上記の通り、プルリクエスト発行のタイミングであるため、ビルドアーティファクトのレジストリへの格納はマージ完了後に行うため、今回の記事では静的解析完了でAWS CodeBuildが完了する形になっている。
システム構成図
上記を踏まえたAWSの構成図は以下のようになる。
ポイントは以下の通り。
- AWS CodeBuildの標準機能ではAWS CodeCommitのプルリクエスト契機でトリガーすることができないため、Amazon EventBridgeを介して起動する
- Amazon EventBridgeはAWS CodeBuildのバッチ起動に対応していないため、AWS Lambdaを介して起動する(詳細は以下のユーザーガイド参照)
AWS CodeBuildのバッチビルドの構成
AWS CodeBuildのバッチビルドは以下のように構成する。
なお、バッチビルドの基本については以下のユーザーガイド参照。
今回は、並行で動作させたいことと、BuildSpecの共通化は特にしなくて良いため、ビルドグラフで対応を行う。
以下のような依存関係のビルドグラフを構築する。
シンプル過ぎてあまりバッチビルドにするが無いように見えるかもしれないが、アプリケーション静的解析の段数や並列度が増える可能性があるため、これで良い。(アプリケーション静的解析については次回以降で説明するため、今回は対象外で、ダミーで正常終了するBuildSpecを用意する)
BuildSpecの構成
リポジトリのディレクトリ構成は以下のようになる。
上記の通り、11_golangci_lint.yml
の中身はダミーである。
リポジトリのルートディレクトリ
├── Dockerfile
├── buildspec
│ ├── 10_hadolint.yml
│ ├── 11_golangci_lint.yml
│ ├── 20_dockle.yml
│ └── 21_trivy.yml
└── buildspec.yml
ルートディレクトリ直下にあるbuildspec.ymlが全体を統制する。
上記設計に対して、ビルドグラフの記法に従い、以下のようにYAMLを書いていく。
version: 0.2
batch:
fast-fail: false
build-graph:
- identifier: hadolint
buildspec: buildspec/10_hadolint.yml
ignore-failure: false
- identifier: golangci_lint
buildspec: buildspec/11_golangci_lint.yml
ignore-failure: false
- identifier: dockle
buildspec: buildspec/20_dockle.yml
ignore-failure: false
depend-on:
- hadolint
- golangci_lint
- identifier: trivy
buildspec: buildspec/21_trivy.yml
ignore-failure: false
depend-on:
- hadolint
- golangci_lint
それぞれのビルドのセキュリティに関する静的解析について
いずれもシングルバイナリで提供しているものであるため、ローカルで動作させたり、Git Hooksに組み込んでも問題ない。ただし、Git Hooksに組み込んだからといって、サーバ側で実施をしなくて良いというわけではない。
セキュリティの考え方は基本的にゼロトラストであるべきなので、ローカルでチェックをしない輩がCommitしてきてもチェックアウトできるようにするのが肝要だ。
なお、後述するが、今回のAWS CodeBuildの実行イメージはaws/codebuild/amazonlinux2-x86_64-standard:5.0
を用いるため、別のアーキテクチャ、OSを使用する場合はそれに合わせてBuildSpecの内容を変更していただきたい。
hadolint
概要
hadolintはシングルバイナリで動作する軽量のDockerfileのLinterだ。
Dockerfileにおけるベストプラクティスに従っているかのチェックをしてくれるが、コンテナビルド前に実行する静的解析であるため、この後出てくる他のツールと比べるとカバレッジの範囲は狭い。
ただし、上記の通り軽量であるため、重いビルドを時間をかけて実行してからエラーがでるよりも早く検知ができるということが重要だ。
アプリケーションについてもビルド前にチェックをかけた方が、エラー時のダメージが小さいため、このチェックを並行でやっておくことで効率的に検査を行うことができるだろう。
BuildSpec
hadolintは引数でDockerfileを渡せば良い。
version: 0.2
env:
variables:
HADOLINT_PATH: "/tmp/hadolint"
phases:
install:
commands:
# install hadolint
- VERSION=$(curl --silent "https://api.github.com/repos/hadolint/hadolint/releases/latest" | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/')
- wget -q https://github.com/hadolint/hadolint/releases/download/v${VERSION}/hadolint-Linux-x86_64 -O ${HADOLINT_PATH} && chmod 755 ${HADOLINT_PATH}
build:
commands:
- ${HADOLINT_PATH} ./Dockerfile
dockle
概要
dockleも、hadolint同様にシングルバイナリで動作するDockerfileのLinterかつコンテナイメージそのものの静的解析を行う。イメージビルド後の実際のイメージ内の検査を行うため、hadolintよりも広い範囲のチェックを行える。チェック範囲の差分については、以下のReadmeの「Checkpoints Comparison」に詳細に記載されている。
BuildSpec
基本的にコンテナイメージを引数で渡せば良いが、今回、Nginxのイメージを渡したら、NGINX_GPGKEYS, NGINX_GPGKEY_PATHが入っているとしてエラーになった。Dockleは、シークレット情報の疑いがあるものをチェックするため、Nginxの公式イメージにもともと含まれていて安全であると分かった上記については、-ak(--accept-key)
オプションで除外している。
当然ながら何でもかんでも-ak
すれば良いものではない。AWSのシークレットも同様のエラーとなるため、誤って-ak
オプションで除外しすぎないように気を付けよう。
dockleはデフォルトではチェックで引っかかったものがあった際に、EXIT CODEが0で終了してしまい、AWS CodeBuildが正常終了してしまう。パイプライン中では必ず、--exit-code 1
オプションを付与して実行しよう。
version: 0.2
env:
variables:
IMAGE_NAME : "container_image"
IMAGE_TAG : "1.0"
DOCKLE_PATH: "/usr/bin/dockle"
phases:
install:
commands:
# install dockle
- VERSION=$(curl --silent "https://api.github.com/repos/goodwithtech/dockle/releases/latest" | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/')
- rpm -ivh https://github.com/goodwithtech/dockle/releases/download/v${VERSION}/dockle_${VERSION}_Linux-64bit.rpm
build:
commands:
- docker build . -t ${IMAGE_NAME}:${IMAGE_TAG}
- ${DOCKLE_PATH} --exit-code 1 -ak NGINX_GPGKEYS -ak NGINX_GPGKEY_PATH ${IMAGE_NAME}:${IMAGE_TAG}
Trivy
概要
Trivyは第1弾記事でも使用した、Aquasecurity社が提供する統合的なセキュリティチェッカーだ。前回はファイルにハードコーディングされているシークレット情報の検査に使用したが、今回はコンテナイメージのチェックに利用する。dockleとはチェック範囲が異なることと、こちらは脆弱性データベース(CVE)をもとに、コンテナイメージ内にある依存関係のあるベースイメージまで含めてチェックを行う。dockleと組み合わせることで、より強固なセキュリティチェックを行うことが可能だ。
BuildSpec
Trivyも、引数にビルド済みのコンテナイメージを渡せば良い。
Trivyもdockle同様、デフォルトではチェックで引っかかったものがあった際に、EXIT CODEが0で終了してしまい、AWS CodeBuildが正常終了してしまう。パイプライン中では必ず、--exit-code 1
オプションを付与して実行しよう。
version: 0.2
env:
variables:
IMAGE_NAME: "container_image"
IMAGE_TAG : "1.0"
TRIVY_PATH: "/usr/bin/trivy"
phases:
install:
commands:
# install Trivy
- VERSION=$(curl --silent https://api.github.com/repos/aquasecurity/trivy/releases/latest | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/')
- rpm -ivh https://github.com/aquasecurity/trivy/releases/download/v${VERSION}/trivy_${VERSION}_Linux-64bit.rpm
build:
commands:
- docker build . -t ${IMAGE_NAME}:${IMAGE_TAG}
- ${TRIVY_PATH} image --quiet --exit-code 1 ${IMAGE_NAME}:${IMAGE_TAG}
お気付きだろうが、dockleとTrivyと両方でコンテナのビルドを行っているため、多少リソースの無駄遣いをしている。AWS CodeBuildのバッチビルドは、それぞれのビルドを別インスタンスで起動するため、イメージを引き継ぐことができない。Amazon ECRに登録したイメージを検査することも可能だが、無駄にECRにイメージ登録はしたくないため、今回はそれぞれのビルドの中でコンテナイメージをビルドした。
TerraformによるAWSリソースの構築
さて、AWS CodeBuildのバッチビルドの構成要素について作り終わったところで、構築をしていく。
AWS CodeBuild
ここはそんなに難しくない。AWS CodeBuildを構築したことがある人であれば苦労しないだろう。
build_batch_config
は設定しないと差分検知してしまう(おそらくTerraformの不具合?)ので設定している。特に意図があっての設計値ではない。
resource "aws_codebuild_project" "example" {
name = local.codebuild_project_name
service_role = aws_iam_role.codebuild.arn
source {
type = "CODECOMMIT"
location = aws_codecommit_repository.example.clone_url_http
buildspec = "buildspec.yml"
}
artifacts {
type = "NO_ARTIFACTS"
}
environment {
type = "LINUX_CONTAINER"
compute_type = "BUILD_GENERAL1_SMALL"
image = "aws/codebuild/amazonlinux2-x86_64-standard:5.0"
privileged_mode = "true"
}
build_batch_config {
service_role = aws_iam_role.codebuild.arn
timeout_in_mins = 480
restrictions {
compute_types_allowed = []
maximum_builds_allowed = 100
}
}
logs_config {
cloudwatch_logs {
group_name = aws_cloudwatch_log_group.codebuild.name
}
}
cache {
type = "LOCAL"
modes = [
"LOCAL_CUSTOM_CACHE",
]
}
}
上記を作成するにあたって必要なIAMとAmazon CloudWatch Logsは以下のように記述している。
resource "aws_cloudwatch_log_group" "codebuild" {
name = local.clouddwatch_loggroup_name
retention_in_days = 3
}
resource "aws_iam_role" "codebuild" {
name = local.iam_codebuild_role_name
assume_role_policy = data.aws_iam_policy_document.codebuild_assume.json
}
data "aws_iam_policy_document" "codebuild_assume" {
statement {
effect = "Allow"
actions = [
"sts:AssumeRole",
]
principals {
type = "Service"
identifiers = [
"codebuild.amazonaws.com",
]
}
}
}
resource "aws_iam_role_policy" "codebuild" {
name = local.iam_codebuild_policy_name
role = aws_iam_role.codebuild.id
policy = data.aws_iam_policy_document.codebuild_custom.json
}
data "aws_iam_policy_document" "codebuild_custom" {
statement {
effect = "Allow"
actions = [
"codeBuild:StartBuild",
"codeBuild:StartBuildBatch",
]
resources = [
aws_codebuild_project.example.arn,
]
}
statement {
effect = "Allow"
actions = [
"codecommit:GitPull",
]
resources = [
aws_codecommit_repository.example.arn,
]
}
statement {
effect = "Allow"
actions = [
"logs:CreateLogStream",
"logs:PutLogEvents",
]
resources = [
aws_cloudwatch_log_group.codebuild.arn,
"${aws_cloudwatch_log_group.codebuild.arn}:log-stream:*",
]
}
}
Amazon EventBridge
Amazon EventBridgeについてはLambda起動のイベントターゲット設定を行う。
必要に応じて、aws_cloudwatch_event_target
にはdead_letter_config
の設定を入れよう。
※プロダクションのワークロードではないため、そこまで神経質にならず失敗したら自分でリトライすれば良いので、今回は省いている。
今回、AWS CodeCommitのプルリクエストを処理できるイベントのルールを作っている。
イベントにpullRequestSourceBranchUpdated
を入れておくことで、エラー後に再度featureブランチにCommitをしたら自動でAWS CodeBuildが再実行されるようにしてある。
sourceReference
で { "prefix" = "refs/heads/feature/" }
と記載することで、feature/hogehogeといった具合に前方一致でブランチを引っ掛けられるようになるため、複数イベントを作らなくて良いようにしている。
resource "aws_cloudwatch_event_rule" "codecommit_pull_request_example" {
name = local.event_rule_codecommit_pr_name
description = "AWS CodeCommit Pull Request Trigger"
event_pattern = jsonencode({
source = [
"aws.codecommit",
]
resources = [
aws_codecommit_repository.example.arn,
]
detail-type = [
"CodeCommit Pull Request State Change",
]
detail = {
event = [
"pullRequestCreated",
"pullRequestSourceBranchUpdated",
],
pullRequestStatus = [
"Open",
],
sourceReference = [
{ "prefix" = "refs/heads/feature/" },
]
destinationReference = [
"refs/heads/master",
]
},
})
}
resource "aws_cloudwatch_event_target" "codecommit_pull_request_example" {
target_id = local.event_target_codecommit_pr_name
rule = aws_cloudwatch_event_rule.codecommit_pull_request_example.name
arn = aws_lambda_function.codecommit_pull_request_example.arn
}
Amazon EventBridgeに対するIAMの権限付与はここでは不要だ。
実際はlambda:InvokeFunction
のAPIを実行するが、Lambdaについてはリソースベースポリシで権限付与するため、ここで設定をする必要がない。
AWS Lambda
Lambdaについては以下のように設定する。
面倒くさくてPythonのスクリプトをインラインで書いてしまったが、見づらいので外出ししておく。
実際に設定する際は、archive_file.codecommit_pull_request_example
の中に転記をしていただきたい。
AWS CodeCommitのpost_comment_for_pull_request
のAPIを実行しているのは、プルリクエスト作成後にAWS CodeBuildのログと紐づかないと扱いづらいためだ。実行結果の通知が必要な場合は、AWS CodeBuildの通知で更にコメントを追加するとより便利になる(ビルド結果の通知については今回の記事の本筋ではないので割愛する)。
import boto3
import pprint
def lambda_handler(event, context):
codebuild_client = boto3.client('codebuild')
codecommit_client = boto3.client('codecommit')
try:
codebuild_response = codebuild_client.start_build_batch(
projectName = '${aws_codebuild_project.example.name}',
sourceVersion = event['detail']['sourceCommit']
)
codecommit_client.post_comment_for_pull_request(
repositoryName = event['detail']['repositoryNames'][0],
pullRequestId = event['detail']['pullRequestId'],
beforeCommitId = event['detail']['destinationCommit'],
afterCommitId = event['detail']['sourceCommit'],
content = 'Build Start.\n\n[See Detail](https://${data.aws_region.current.name}.console.aws.amazon.com/codesuite/codebuild/${data.aws_caller_identity.current.account_id}/projects/${aws_codebuild_project.example.name}/batch/' + codebuild_response['buildBatch']['id'] + '?region=${data.aws_region.current.name})'
)
except Exception as e:
print(e)
data "archive_file" "codecommit_pull_request_example" {
type = "zip"
output_path = "../output/codecommitpr.zip"
source {
filename = "codecommitpr.py"
content = <<EOF
// ★★★上記Pythonの中身をここに転記する★★★
EOF
}
}
resource "aws_lambda_function" "codecommit_pull_request_example" {
depends_on = [
aws_cloudwatch_log_group.codecommit_pull_request_example_lambda,
]
function_name = local.lambda_function_eventbridge_codecommit_pr_name
filename = data.archive_file.codecommit_pull_request_example.output_path
role = aws_iam_role.eventbridge_lambda.arn
handler = "codecommitpr.lambda_handler"
source_code_hash = data.archive_file.codecommit_pull_request_example.output_base64sha256
runtime = "python3.10"
memory_size = 128
timeout = 60
}
resource "aws_lambda_permission" "codecommit_pull_request_example_allow_evnentbridge" {
statement_id = "AllowExecutionFromEventBridge"
function_name = aws_lambda_function.codecommit_pull_request_example.function_name
principal = "events.amazonaws.com"
source_arn = aws_cloudwatch_event_rule.codecommit_pull_request_example.arn
action = "lambda:InvokeFunction"
}
Lambdaを実行するためのIAMとAmazon CloudWatch Logsの設定は以下の通り。
resource "aws_cloudwatch_log_group" "codecommit_pull_request_example_lambda" {
name = "/aws/lambda/${local.lambda_function_eventbridge_codecommit_pr_name}"
retention_in_days = 3
}
resource "aws_iam_role" "eventbridge_lambda" {
name = local.iam_role_eventbridge_lambda_name
assume_role_policy = data.aws_iam_policy_document.eventbridge_lambda_assume.json
}
data "aws_iam_policy_document" "eventbridge_lambda_assume" {
statement {
effect = "Allow"
actions = [
"sts:AssumeRole",
]
principals {
type = "Service"
identifiers = [
"lambda.amazonaws.com",
]
}
}
}
resource "aws_iam_role_policy" "eventbridge_lambda" {
name = local.iam_policy_eventbridge_lambda_name
role = aws_iam_role.eventbridge_lambda.id
policy = data.aws_iam_policy_document.eventbridge_lambda_custom.json
}
data "aws_iam_policy_document" "eventbridge_lambda_custom" {
statement {
effect = "Allow"
actions = [
"codebuild:StartBuildBatch",
]
resources = [
aws_codebuild_project.example.arn,
]
}
statement {
effect = "Allow"
actions = [
"codecommit:PostCommentForPullRequest",
]
resources = [
aws_codecommit_repository.example.arn,
]
}
statement {
effect = "Allow"
actions = [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
]
resources = [
aws_cloudwatch_log_group.codecommit_pull_request_example_lambda.arn,
"${aws_cloudwatch_log_group.codecommit_pull_request_example_lambda.arn}:log-stream:*",
]
}
}
いざ、動かす!
上記をterraform apply
したら、実際にAWS CodeCommitでプルリクエストを発行してみよう。
アクティビティのタブに、AWS Lambdaから送信した情報が追記される。
このリンクを開くと、以下のように、どの検査でエラーになったか一目瞭然になっている。
これで、プルリクエストのレビュー前に必要なセキュリティが確保できるようになった!