電通総研 テックブログ

電通総研が運営する技術ブログ

AWSインフラをCDKでIaC化したらcdk-nagでセキュリティスキャンしたくない?(特にサプレスの方法)

こんにちは。X(クロス)イノベーション本部 ソフトウェアデザインセンター セキュリティグループの耿です。

クラウドインフラをIaC化すると、静的セキュリティスキャンができるようになります。インフラをデプロイする前にセキュリティ上の問題や、ベストプラクティスに沿っていない構成を知ることができるため、ぜひスキャンしておきたいところです。
今までのスキャンツールとしては、以下のようなものがありました。

  • tfsec
    • OSS
    • Terraformに対応
  • cfn_nag
    • OSS
    • CloudFormationテンプレートに対応
  • terrascan
    • OSS
    • Terraform、CloudFormationテンプレート、Azure Resource Managerなどに対応
  • Snyk IaC
    • 有償(無償プランあり)
    • Terraform、CloudFormationテンプレート、Azure Resource Managerなどに対応

最近、AWS CDKで使いやすい、AWS開発のOSSツール cdk-nag の存在を知り、非常にテンションが上がっているためブログを書いております!
前半では従来のツールでCDKコードをスキャンするときの問題点とcdk-nagの導入方法、後半では実用する時に欠かせない、cdk-nagエラーのサプレス方法を書いていきます。

TL;DR

  • CDKのセキュリティスキャンはcdk-nagが使いやすい
  • 意味のあるCIにするためには正しいサプレスが重要。細かい粒度でサプレスするべき
  • appliesTo でリソースレベルよりもさらに細かい粒度でサプレスできる
  • CDKが勝手に作成するリソースについては、 applyToChildrentrue にするか、addResourceSuppressionsByPath でサプレスできる
  • サプレスはCDKコードの最後にまとめて書くのが良さそう
  • 既存のCDKに導入する時、コメントアウトして少しずつ対応すると良い

cfn_nagでCDKをスキャンするときの問題点

cdk-nagの存在を知る前までは、cfn_nagとterrascanをGitHub Actionsのワークフローに組み込んで使っていました。どちらのツールも直接CDKをサポートしていませんが、cdk synth で生成されるCloudFormationテンプレートに対してスキャンをかけることができます。

例として、cfn_nagのスキャン結果は次のようになります(一部抜粋、編集済)

------------------------------------------------------------
cdk.out/MyStack.template.json
------------------------------------------------------------
| WARN W89
|
| Resource: ["AWS679f53fac002430cb0da5b7982bd22872D164C4C", "SomeTrivialFunction4CC789DF"]
| Line Numbers: [647, 2395]
|
| Lambda functions should be deployed inside a VPC

これには次のような問題があることがわかりました。

問題点1:CDKコードとの関連が分かりにくい

スキャン結果では行番号を出力してくれていますが、CloudFormationテンプレート内の行番号なので、CDKで探す際の参考にはなりません。
またリソースの論理IDも出力されますが、こちらもCloudFormationテンプレートに変換した後のIDなので、CDKで記述したIDとは少し異なります。明示的にCDKで定義したリソースならば、IDの先頭の文字列を見ればどのリソースかなんとなく分かりますが、CDKが自動で作成するリソースについてはIDが勝手に割り振られるため、何によって作成されたリソースかは判断つきません。

問題点2:サプレスがイケてない

こちらが一番重要です。cfn_nagでは特定のリソースに対して特定のルールをサプレスしたい場合、CloudFromationテンプレートのMetadataに記載することになります。
CDKからMetadataを追加する場合、一度CloudFormationリソースとして取得する必要があり、コードが煩雑になります。(CDKが自動で作成するリソースのルールをサプレスする方法は、まだ試していませんがさらに大変そうです)

const cfnFunction = someTrivialFunction.node.defaultChild as lambda.CfnFunction;
cfnFunction.cfnOptions.metadata = {
  cfn_nag: { rules_to_suppress: [{ id: "W89", reason: "No need to deploy this function inside VPC" }] },
};

cdk-nagセットアップ

ここからはcdk-nagを使っていきます。AWSのブログcdk-nagのREADMEに従ってセットアップします。

cdk-nagパッケージをインストールします。

npm install -D cdk-nag

v2.15.32時点でルールパックは5種類ありますが、今回は AWS Solutions を利用します。またエラー詳細を出力するように verbose: true を指定します。

import * as cdk from "aws-cdk-lib";
import { AwsSolutionsChecks } from "cdk-nag";
import { ExampleStack } from "../lib/example-stack";

const app = new cdk.App();
new ExampleStack(app, "ExampleStack", {});

cdk.Aspects.of(app).add(new AwsSolutionsChecks({ verbose: true }));

cfn_nagと異なり、cdk-nagはCDKと直接統合されており、CDKのAspects を利用し、Synthesize前のPrepareの段階でコードを実行する仕組みです。サプレスされていないルール違反がある場合はフローの途中でエラーが返るため、cdk deploy を実行しても後続のデプロイ処理がされません。CI/CDに組み込みやすいです。

スキャンしてみる

まずは簡単なS3バケットを作って、 cdk synth を実行します。

import { Stack, StackProps } from "aws-cdk-lib";
import * as s3 from "aws-cdk-lib/aws-s3";
import { Construct } from "constructs";

export class ExampleStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const myBucket = new s3.Bucket(this, "MyBucket", {});
  }
}

するとエラーが4つ出ました。

[Error at /ExampleStack/MyBucket/Resource] AwsSolutions-S1: The S3 Bucket has server access logs disabled. The bucket should have server access logging enabled to provide detailed records for the requests that are made to the bucket.

[Error at /ExampleStack/MyBucket/Resource] AwsSolutions-S2: The S3 Bucket does not have public access restricted and blocked. The bucket should have public access restricted and blocked to prevent unauthorized access.

[Error at /ExampleStack/MyBucket/Resource] AwsSolutions-S3: The S3 Bucket does not default encryption enabled. The bucket should minimally have SSE enabled to help protect data-at-rest.

[Error at /ExampleStack/MyBucket/Resource] AwsSolutions-S10: The S3 Bucket or bucket policy does not require requests to use SSL. You can use HTTPS (TLS) to help prevent potential attackers from eavesdropping on or manipulating network traffic using person-in-the-middle or similar attacks. You should allow only encrypted connections over HTTPS (TLS) using the aws:SecureTransport condition on Amazon S3 bucket policies.

内容は以下のとおりです

  • AwsSolutions-S1:サーバーアクセスログが有効でない
  • AwsSolutions-S2:ブロックパブリックアクセスが有効でない
  • AwsSolutions-S3:デフォルト暗号化が有効でない
  • AwsSolutions-S10:TLS通信の強制が有効でない

同時に cdk.out/AwsSolutions-ExampleStack-NagReport.csv にも結果レポートが出力されます。違反のあったルールだけではなく、クリアしたルールとサプレスしたルールについても出力されています。

サーバーアクセスログ以外の3つのエラーに対応してみます。

    const myBucket = new s3.Bucket(this, "MyBucket", {
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      encryption: s3.BucketEncryption.S3_MANAGED,
      enforceSSL: true,
    });

再び cdk synth を実行すると、エラーが1つだけになりました。

[Error at /ExampleStack/MyBucket/Resource] AwsSolutions-S1: The S3 Bucket has server access logs disabled. The bucket should have server access logging enabled to provide detailed records for the requests that are made to the bucket.

今回、このバケットではサーバーアクセスログを取得しないものとして、このルールをサプレスしようと思います。

cdk-nagのサプレス方法

なぜサプレスが重要なのか

スキャン結果を必要に応じて簡単にサプレスできることは、意味のあるCIをするために非常に重要です。

スキャンルールはあくまでもベストプラクティス集であり、その全てをクリアできないケース(あるいはする必要がないケース)が多いでしょう。不要なルールがサプレスされずに結果に残り続けると、確認する際のノイズになり、差分も分かりにくくなります。CIに組み込んでいる場合、ノイズが多いとそのうち結果を確認しなくなります。

また、なるべく細かい粒度(リソースレベルかそれ以下)でサプレスすることも重要です。プロジェクト全体で絶対に対応しない方針としたものであれば良いのですが、不用意にグローバルでルールをサプレスしてしまうと、リソースを追加した際に気にかけておくべきベストプラクティスを見逃すことになります。

基本的なサプレス

サプレスは id にルールIDを渡し、次のように行います。

import { NagSuppressions } from "cdk-nag";

...

    NagSuppressions.addResourceSuppressions(myBucket, [
      { id: "AwsSolutions-S1", reason: "Bucket storing logs. No need to export logs itself" },
    ]);

cdk synth を実行するとエラーがなくなり、CloudFormationテンプレートが出力されました。

cdk-nagでは reason の記載が必須であり、さらには文字数が10文字未満だと次のようなエラーになります。将来読んでも理由がよくわかるように正確に書きましょう。

Error: MyBucket: 
        Error(s) detected in suppression with 'id' AwsSolutions-S1. The suppression must have a 'reason' of 10 characters or more.

サプレスのために記載したreason は、出力したCloudFormationテンプレートの Metadata に書き込まれます。CloudFormationテンプレートに入力されたマルチバイト文字は文字化けしてしまい、cdk diff として間違って検出されてしまうため、今のところ reason 欄は英語で記載するのが良さそうです。
(2022/9/2追記)cdk-nagに送ったPRがマージされ、v2.18.0からはreasonに日本語を記載しても問題にならなくなりました!

スタック全体で特定のルールをサプレス

特定のルールをスタック全体でサプレスしたい場合は、次のように書けますが、あまり濫用しない方が良いでしょう。

    NagSuppressions.addStackSuppressions(this, [
      { id: "AwsSolutions-S1", reason: "Enabling server access logs is not requiered" },
    ]);

より細かい粒度のサプレス

S3バケットへのアクセスを許可するIAMポリシーを作ってみます。

import * as iam from "aws-cdk-lib/aws-iam";

...

    const myIamPolicy = new iam.ManagedPolicy(this, "MyIAMPolicy", {
      statements: [
        new iam.PolicyStatement({
          resources: [myBucket.bucketArn, `${myBucket.bucketArn}/*`],
          actions: ["s3:Get*", "s3:ListBucket"],
          effect: iam.Effect.ALLOW,
        }),
      ],
    });

エラーが2つ出ました。

[Error at /ExampleStack/MyIAMPolicy/Resource] AwsSolutions-IAM5[Action::s3:Get*]: The IAM entity contains wildcard permissions and does not have a cdk-nag rule suppression with evidence for those permission. Metadata explaining the evidence (e.g. via supporting links) for wildcard permissions allows for transparency to operators. This is a granular rule that returns individual findings that can be suppressed with 'appliesTo'. The findings are in the format 'Action::<action>' for policy actions and 'Resource::<resource>' for resources. Example: appliesTo: ['Action::s3:*'].

[Error at /ExampleStack/MyIAMPolicy/Resource] AwsSolutions-IAM5[Resource::<MyBucketF68F3FF0.Arn>/*]: The IAM entity contains wildcard permissions and does not have a cdk-nag rule suppression with evidence for those permission. Metadata explaining the evidence (e.g. via supporting links) for wildcard permissions allows for transparency to operators. This is a granular rule that returns individual findings that can be suppressed with 'appliesTo'. The findings are in the format 'Action::<action>' for policy actions and 'Resource::<resource>' for resources. Example: appliesTo: ['Action::s3:*'].

IAMポリシーにワイルドカード(*)が使われており、広いアクセス権限を付与していることに対するエラーです。ActionとResourceの両方でワイルドカードを使っているため、両方について指摘されています。今回は問題ないこととしてサプレスするとします。

このIAMポリシー全体に対してルール AwsSolutions-IAM5 をサプレスすることも可能ですが、そうすると将来的に追加するあらゆるワイルドカードも許可してしまいます。そうではなく、特定のActionやResourceに対してのみワイルドカードを許可する方が良いでしょう。

エラーをよく見ると、今回はルールIDの後に括弧がついており([Action::s3:Get*] [Resource::<MyBucketF68F3FF0.Arn>/*])、問題となった具体的なActionやResourceがわかるようになっています。
appliesTo を使うことで、特定の記述のみをサプレスできます。

    NagSuppressions.addResourceSuppressions(myIamPolicy, [
      {
        id: "AwsSolutions-IAM5",
        reason: "Necessary to grant Get access to all objects in the bucket",
        appliesTo: ["Action::s3:Get*", "Resource::<MyBucketF68F3FF0.Arn>/*"],
      },
    ]);

Resourceは前に作成したS3バケットへの参照となっていますが、ルールIDの後の括弧にある Resource::<MyBucketF68F3FF0.Arn>/* をそのまま appliesTo に追加して構いません。これでサプレスされました。

ちなみに、appliesTo の中に正規表現を書くこともできます。

    NagSuppressions.addResourceSuppressions(myIamPolicy, [
      {
        id: "AwsSolutions-IAM5",
        reason: "Necessary to grant Get access to all objects in the bucket",
        appliesTo: [
          "Action::s3:Get*",
          {
            regex: "/^Resource::<MyBucketF68F3FF0.Arn>(.*)$/g",
          },
        ],
      },
    ]);

CDKが作成するリソースのサプレス

今度はLambda関数を作ります。

import { Runtime } from "aws-cdk-lib/aws-lambda";
import * as lambdaNodejs from "aws-cdk-lib/aws-lambda-nodejs";
import * as logs from "aws-cdk-lib/aws-logs";

...

    const myTrivialFunction = new lambdaNodejs.NodejsFunction(this, "MyTrivialFunction", {
      entry: "aws-cdk/functions/my-trivial-function.ts",
      runtime: Runtime.NODEJS_16_X,
      logRetention: logs.RetentionDays.SIX_MONTHS,
      bundling: { forceDockerBundling: false },
    });

3つのエラーが出ました。まずは1つ目です。

[Error at /ExampleStack/MyTrivialFunction/ServiceRole/Resource] AwsSolutions-IAM4[Policy::arn:<AWS::Partition>:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]: The IAM user, role, or group uses AWS managed policies. An AWS managed policy is a standalone policy that is created and administered by AWS. Currently, many AWS managed policies do not restrict resource scope. Replace AWS managed policies with system specific (customer) managed policies.This is a granular rule that returns individual findings that can be suppressed with 'appliesTo'. The findings are in the format 'Policy::<policy>' for AWS managed policies. Example: appliesTo: ['Policy::arn:<AWS::Partition>:iam::aws:policy/foo'].

このLambda関数が明示的に実行ロールを指定していないため、実行ロールが自動で作成されており、 AWSLambdaBasicExecutionRole ポリシーが付与されています。AWSマネージドポリシーはリソースレベルで権限を絞っていないため、独自のポリシーを使うべきという指摘です。今回は問題ないとし、サプレスします。

しかし今までに述べた方法ではうまくいきません。エラーになっているリソースは関数そのものではなく、関数が使用しているロールだからです。この場合、addResourceSuppressions の3つ目の引数の applyToChildrentrue を渡すことでサプレスできます。(合わせ技でappliesTo には具体的なポリシーを指定しています)

    NagSuppressions.addResourceSuppressions(
      myTrivialFunction,
      [
        {
          id: "AwsSolutions-IAM4",
          reason: "OK to use AWS managed AWSLambdaBasicExecutionRole",
          appliesTo: ["Policy::arn:<AWS::Partition>:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"],
        },
      ],
      true
    );

2つ目、3つ目のエラーは次の通りです。

[Error at /ExampleStack/LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8a/ServiceRole/Resource] AwsSolutions-IAM4[Policy::arn:<AWS::Partition>:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole]: The IAM user, role, or group uses AWS managed policies. An AWS managed policy is a standalone policy that is created and administered by AWS. Currently, many AWS managed policies do not restrict resource scope. Replace AWS managed policies with system specific (customer) managed policies.This is a granular rule that returns individual findings that can be suppressed with 'appliesTo'. The findings are in the format 'Policy::<policy>' for AWS managed policies. Example: appliesTo: ['Policy::arn:<AWS::Partition>:iam::aws:policy/foo'].

[Error at /ExampleStack/LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8a/ServiceRole/DefaultPolicy/Resource] AwsSolutions-IAM5[Resource::*]: The IAM entity contains wildcard permissions and does not have a cdk-nag rule suppression with evidence for those permission. Metadata explaining the evidence (e.g. via supporting links) for wildcard permissions allows for transparency to operators. This is a granular rule that returns individual findings that can be suppressed with 'appliesTo'. The findings are in the format 'Action::<action>' for policy actions and 'Resource::<resource>' for resources. Example: appliesTo: ['Action::s3:*'].

リソースパスが長くランダム風の文字列になっており、CDKが自動で作成したリソースであることがわかります。Lambda関数を作る時に logRetention を指定した場合、ログリテンションポリシーを設定するためのLambda関数が1つ作られ、その実行ロールに対する指摘です。
明示的にCDKに記述したLambda関数の子リソースではないため、applyToChildrentrue にしてもサプレスできません。エラーメッセージに出力されているリソースパスを使い、addResourceSuppressionsByPath でスタックからこのリソースを見つけてもらいます。

    NagSuppressions.addResourceSuppressionsByPath(
      this,
      "/ExampleStack/LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8a/ServiceRole/Resource",
      [
        {
          id: "AwsSolutions-IAM4",
          reason: "CDK managed resource",
          appliesTo: ["Policy::arn:<AWS::Partition>:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"],
        },
      ]
    );

    NagSuppressions.addResourceSuppressionsByPath(
      this,
      "/ExampleStack/LogRetentionaae0aa3c5b4d4f87b02d85b201efdd8a/ServiceRole/DefaultPolicy/Resource",
      [
        {
          id: "AwsSolutions-IAM5",
          reason: "CDK managed resource",
          appliesTo: ["Resource::*"],
        },
      ]
    );

ちなみにCDKでカスタムリソースを作成する場合もLambda関数が作られますが、そのサプレスにもこの方法を利用できます。

interfaceEndpoints作成時のWarningのサプレス

VPCとセキュリティグループ、VPCエンドポイントを作ります。

import * as ec2 from "aws-cdk-lib/aws-ec2";

...
    const vpc = new ec2.Vpc(this, "VPC", {
      flowLogs: {
        s3: {
          destination: ec2.FlowLogDestination.toS3(myBucket, "vpc"),
          trafficType: ec2.FlowLogTrafficType.ALL,
        },
      },
    });

    const appSecurityGroup = new ec2.SecurityGroup(this, "appSecurityGroup", {
      vpc: vpc,
      allowAllOutbound: true,
    });

    const httpsSecurityGroup = new ec2.SecurityGroup(this, "httpsSecurityGroup", {
      vpc: vpc,
      allowAllOutbound: true,
    });
    httpsSecurityGroup.addIngressRule(appSecurityGroup, ec2.Port.tcp(443));

    vpc.addInterfaceEndpoint("SSMEndpoint", {
      service: ec2.InterfaceVpcEndpointAwsService.SSM,
      subnets: { subnets: vpc.publicSubnets },
      securityGroups: [httpsSecurityGroup],
      privateDnsEnabled: true,
    });

Cloudformationテンプレートは問題なく生成されますが、実はチェック失敗のWarningが出力されています。

[Warning at /ExampleStack/httpsSecurityGroup/Resource] CdkNagValidationFailure: 'AwsSolutions-EC23' threw an error during validation. This is generally caused by a parameter referencing an intrinsic function. For more details enable verbose logging.' The parameter resolved to to a non-primitive value "{"Fn::GetAtt":["VPCB9E5F0B4","CidrBlock"]}", therefore the rule could not be validated.

こちらのIssueに記載がある通り、想定通りの挙動でサプレスして良いとのことなので、次のように idCdkNagValidationFailure を渡すことでサプレスできます。

    NagSuppressions.addResourceSuppressions(httpsSecurityGroup, [
      { id: "CdkNagValidationFailure", reason: "https://github.com/cdklabs/cdk-nag/issues/817" },
    ]);

サプレスはどこに書くべきか

プロジェクトの対応方針によっては大量にサプレスが発生する場合があります。cdk-nagはCDKコード内にサプレスを記載するため、どこに書くべきかについて考えてみます。2つのパターンが考えられます。

  1. エラーが発生したリソース付近に書く
  2. サプレスのみをまとめて書く

サプレスをどこに書くべきか

「1. エラーが発生したリソース付近に書く」場合、どのリソースに対するサプレスかがわかりやすい一方で、インフラを定義するコードとセキュリティスキャンのサプレスコードが入り混じるため、見通しの悪いコードになってしまう印象があります。IaCコードを読む時は、サプレス済みのセキュリティスキャンルールは意識の外に置きたいです。
そこで個人的には「2. サプレスのみをまとめて書く」のが良いのではないかと思います。スタックの最後にサプレスをまとめて記述すれば、コードを読む際の邪魔にはなりません。それでもリソースの数が増えるとどこで何をサプレスしているのか探しにくくなるので、CDKを書く際には1つのスタックにリソースを詰め込みすぎず、適度な長さに分割するのが良いでしょう。

まとめ

さまざまなケースを取り上げてサプレスする方法を説明しました。ハードルは下がったと思うので、ぜひcdk-nagを導入してセキュリティチェックをしていきましょう。

既存のCDKコードにcdk-nagを入れる場合、たくさんのエラーが出る可能性があります。まずはコードを全てコメントアウトした上で少しずつコメントを外し、 cdk synth でエラーをステップ・バイ・ステップで対応していくのが良いと思います。

今回はAWS Solutionsルールパックのみでしたが、そのうち他のルールパックについても試してみたいと思います。ここまで読んでいただきありがとうございました。


私たちは同じチームで働いてくれる仲間を大募集しています!たくさんのご応募をお待ちしています。
- セキュリティエンジニア(セキュリティ設計)

執筆:@kou.kinyo2、レビュー:@kou.kinyo2Shodoで執筆されました