AWS WAF v2のS3へのログ出力をAWS CDK(一部AWS CLI)で設定してみた

2021.06.10

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは、CX事業本部の若槻です。

前回のエントリでは、AWS WAFv2のWebACLの作成とCloudFrontへの適用をAWS CDKで行いました。

このAWS WAFv2にはログ出力の設定がありますが、そのためには別途FirehoseでDeliveryStreamを作成して紐付ける必要があります。

今回は、AWS WAF v2のFirehoseを使用したS3へのログ出力をAWS CDK(一部AWS CLI)で設定してみました。

やってみた

前回エントリで作成したリソースに修正を加える形で手順をご紹介します。

CDKモジュール追加

% npm i @aws-cdk/aws-kinesisfirehose @aws-cdk/aws-logs

CDKスタックへのリソース追加

ハイライト箇所が追記部分です。

waf-firehose-stack.ts

import * as cdk from "@aws-cdk/core";
import * as cloudfront from "@aws-cdk/aws-cloudfront";
import * as s3 from "@aws-cdk/aws-s3";
import * as s3deploy from "@aws-cdk/aws-s3-deployment";
import * as iam from "@aws-cdk/aws-iam";
import * as wafv2 from "@aws-cdk/aws-wafv2";
import * as firehose from "@aws-cdk/aws-kinesisfirehose";
import * as logs from "@aws-cdk/aws-logs";

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

    const websiteBucket = new s3.Bucket(this, "WebsiteBucket", {
      websiteErrorDocument: "index.html",
      websiteIndexDocument: "index.html",
    });

    const websiteIdentity = new cloudfront.OriginAccessIdentity(
      this,
      "WebsiteIdentity"
    );

    const webSiteBucketPolicyStatement = new iam.PolicyStatement({
      actions: ["s3:GetObject"],
      effect: iam.Effect.ALLOW,
      principals: [websiteIdentity.grantPrincipal],
      resources: [`${websiteBucket.bucketArn}/*`],
    });

    websiteBucket.addToResourcePolicy(webSiteBucketPolicyStatement);

    //AWSマネージドルールを適用するWebACLの作成
    const websiteWafV2WebAcl = new wafv2.CfnWebACL(this, "WafV2WebAcl", {
      defaultAction: { allow: {} },
      scope: "CLOUDFRONT",
      visibilityConfig: {
        cloudWatchMetricsEnabled: true,
        sampledRequestsEnabled: true,
        metricName: "websiteWafV2WebAcl",
      },
      rules: [
        {
          name: "AWSManagedRulesCommonRuleSet",
          priority: 1,
          statement: {
            managedRuleGroupStatement: {
              vendorName: "AWS",
              name: "AWSManagedRulesCommonRuleSet",
            },
          },
          overrideAction: { none: {} },
          visibilityConfig: {
            cloudWatchMetricsEnabled: true,
            sampledRequestsEnabled: true,
            metricName: "AWSManagedRulesCommonRuleSet",
          },
        },
        {
          name: "AWSManagedRulesAdminProtectionRuleSet",
          priority: 2,
          statement: {
            managedRuleGroupStatement: {
              vendorName: "AWS",
              name: "AWSManagedRulesAdminProtectionRuleSet",
            },
          },
          overrideAction: { none: {} },
          visibilityConfig: {
            cloudWatchMetricsEnabled: true,
            sampledRequestsEnabled: true,
            metricName: "AWSManagedRulesAdminProtectionRuleSet",
          },
        },
        {
          name: "AWSManagedRulesKnownBadInputsRuleSet",
          priority: 3,
          statement: {
            managedRuleGroupStatement: {
              vendorName: "AWS",
              name: "AWSManagedRulesKnownBadInputsRuleSet",
            },
          },
          overrideAction: { none: {} },
          visibilityConfig: {
            cloudWatchMetricsEnabled: true,
            sampledRequestsEnabled: true,
            metricName: "AWSManagedRulesKnownBadInputsRuleSet",
          },
        },
        {
          name: "AWSManagedRulesAmazonIpReputationList",
          priority: 4,
          statement: {
            managedRuleGroupStatement: {
              vendorName: "AWS",
              name: "AWSManagedRulesAmazonIpReputationList",
            },
          },
          overrideAction: { none: {} },
          visibilityConfig: {
            cloudWatchMetricsEnabled: true,
            sampledRequestsEnabled: true,
            metricName: "AWSManagedRulesAmazonIpReputationList",
          },
        },
        {
          name: "AWSManagedRulesAnonymousIpList",
          priority: 5,
          statement: {
            managedRuleGroupStatement: {
              vendorName: "AWS",
              name: "AWSManagedRulesAnonymousIpList",
            },
          },
          overrideAction: { none: {} },
          visibilityConfig: {
            cloudWatchMetricsEnabled: true,
            sampledRequestsEnabled: true,
            metricName: "AWSManagedRulesAnonymousIpList",
          },
        },
      ],
    });

    const websiteDistribution = new cloudfront.CloudFrontWebDistribution(
      this,
      "WebsiteDistribution",
      {
        errorConfigurations: [
          {
            errorCachingMinTtl: 300,
            errorCode: 403,
            responseCode: 200,
            responsePagePath: "/index.html",
          },
          {
            errorCachingMinTtl: 300,
            errorCode: 404,
            responseCode: 200,
            responsePagePath: "/index.html",
          },
        ],
        originConfigs: [
          {
            s3OriginSource: {
              s3BucketSource: websiteBucket,
              originAccessIdentity: websiteIdentity,
            },
            behaviors: [
              {
                isDefaultBehavior: true,
              },
            ],
          },
        ],
        priceClass: cloudfront.PriceClass.PRICE_CLASS_ALL,
        webACLId: websiteWafV2WebAcl.attrArn, //作成したWebACLをCloudFrontに適用する
      }
    );

    new s3deploy.BucketDeployment(this, "WebsiteDeploy", {
      sources: [s3deploy.Source.asset("./web/build")],
      destinationBucket: websiteBucket,
      distribution: websiteDistribution,
      distributionPaths: ["/*"],
    });

    const region = cdk.Stack.of(this).region;
    const accountId = cdk.Stack.of(this).account;
    const deliveryStreamName = "aws-waf-logs-demo";
    const logStreamName = "S3Delivery";

    //データ配信先S3バケット
    const wafLogBucket = new s3.Bucket(this, "wafLogBucket", {
      bucketName: `${deliveryStreamName}-${region}-${accountId}`,
    });

    //データ配信失敗時のイベントを記録するLogGroupとLogStream
    const wafLogDeliveryStreamLogGroup = new logs.CfnLogGroup(
      this,
      "wafLogDeliveryStreamLogGroup",
      {
        logGroupName: `/aws/kinesisfirehose/${deliveryStreamName}`,
      }
    );
    const wafLogDeliveryStreamLogStream = new logs.CfnLogStream(
      this,
      "wafLogDeliveryStreamLogStream",
      {
        logGroupName: wafLogDeliveryStreamLogGroup.logGroupName as string,
        logStreamName: logStreamName,
      }
    );
    //明示的に依存性を設定しなければLogStreamの作成が失敗する場合がある
    wafLogDeliveryStreamLogStream.addDependsOn(wafLogDeliveryStreamLogGroup);

    //配信ストリームに付与するロール
    const wafLogDeliveryStreamRole = new iam.Role(
      this,
      "wafLogDeliveryStreamRole",
      {
        assumedBy: new iam.ServicePrincipal("firehose.amazonaws.com"),
      }
    );
    wafLogDeliveryStreamRole.addToPolicy(
      new iam.PolicyStatement({
        actions: [
          "kinesis:DescribeStream",
          "kinesis:GetShardIterator",
          "kinesis:GetRecords",
        ],
        effect: iam.Effect.ALLOW,
        resources: [`arn:aws:kinesis:${region}:${accountId}:stream/*`],
      })
    );
    wafLogDeliveryStreamRole.addToPolicy(
      new iam.PolicyStatement({
        actions: [
          "s3:AbortMultipartUpload",
          "s3:GetBucketLocation",
          "s3:GetObject",
          "s3:ListBucket",
          "s3:ListBucketMultipartUploads",
          "s3:PutObject",
        ],
        effect: iam.Effect.ALLOW,
        resources: [wafLogBucket.bucketArn, `${wafLogBucket.bucketArn}/*`],
      })
    );
    wafLogDeliveryStreamRole.addToPolicy(
      new iam.PolicyStatement({
        actions: ["logs:PutLogEvents"],
        effect: iam.Effect.ALLOW,
        resources: [
          `arn:aws:logs:${region}:${accountId}:log-group:/aws/kinesisfirehose/*`,
        ],
      })
    );

    //配信ストリーム
    new firehose.CfnDeliveryStream(this, "wafLogDeliveryStream", {
      deliveryStreamName: deliveryStreamName,
      deliveryStreamType: "DirectPut",
      s3DestinationConfiguration: {
        bucketArn: wafLogBucket.bucketArn,
        roleArn: wafLogDeliveryStreamRole.roleArn,
        cloudWatchLoggingOptions: {
          enabled: true,
          logGroupName: wafLogDeliveryStreamLogGroup.logGroupName,
          logStreamName: logStreamName,
        },
        compressionFormat: "GZIP",
        prefix: "logs/",
        errorOutputPrefix: "errors/",
      },
    });
  }
}

このCDKスタック中ではWebACLとDeliveryStreamの紐付けは行いません。@aws-cdk/aws-wafv2のドキュメントを見る限り、紐付けるためのプロパティがなく出来ないためです。

よって紐付けの設定のみAWS CLIで手動で行います。

CDKデプロイ

% cdk deploy

WebACLとDeliveryStreamを紐付ける

まずWebACLとDeliveryStreamのArnを確認します。(CDKスタック内でconsole.logでも確認可能です)

REGION=us-east-1
ACCOUNT_ID=XXXXXXXXXXXX

% aws wafv2 list-web-acls \
  --scope CLOUDFRONT \
  --region ${REGION}
% aws firehose describe-delivery-stream \
  --delivery-stream-name aws-waf-logs-demo \
  --region ${REGION} \
  --query DeliveryStreamDescription.DeliveryStreamARN

下記のコマンドで両者のArnを指定して紐付けます。

% aws wafv2 put-logging-configuration \
  --logging-configuration \
    ResourceArn=${WEB_ACL_ARN},LogDestinationConfigs=${FIREHOSE_DELIVERY_STREAM_ARN} \
  --region ${REGION}

動作確認

WebACLが適用されているCloudFrontがホストしているWebサイトにアクセスします。

10分ほどしたらS3バケットのlogsプレフィクス配下にログが作成されました。

% aws s3 ls s3://aws-waf-logs-demo-${REGION}-${ACCOUNT_ID} --recursive
2021-06-10 19:04:27       1559 logs/2021/06/10/09/aws-waf-logs-demo-1-2021-06-10-09-58-08-9864cb19-1d2e-4a57-9e30-0ddfdca6f5e3.gz

S3 SELECTを使用して下記のような指定でログの内容を確認できました。

  • 入力設定
    • 形式:JSON
    • JSONコンテンツタイプ:行
    • 圧縮:GZIP
  • 出力設定
    • 形式JSON

WebACLのログのフィールド仕様は下記ドキュメントから確認可能です。

Log Examples

{
    "timestamp": 1576280412771,
    "formatVersion": 1,
    "webaclId": "arn:aws:wafv2:ap-southeast-2:EXAMPLE12345:regional/webacl/STMTest/1EXAMPLE-2ARN-3ARN-4ARN-123456EXAMPLE",
    "terminatingRuleId": "STMTest_SQLi_XSS",
    "terminatingRuleType": "REGULAR",
    "action": "BLOCK",
    "terminatingRuleMatchDetails": [
        {
            "conditionType": "SQL_INJECTION",
            "location": "HEADER",
            "matchedData": [
                "10",
                "AND",
                "1"
            ]
        }
    ],
    "httpSourceName": "-",
    "httpSourceId": "-",
    "ruleGroupList": [],
    "rateBasedRuleList": [],
    "nonTerminatingMatchingRules": [],
    "httpRequest": {
        "clientIp": "1.1.1.1",
        "country": "AU",
        "headers": [
            {
                "name": "Host",
                "value": "localhost:1989"
            },
            {
                "name": "User-Agent",
                "value": "curl/7.61.1"
            },
            {
                "name": "Accept",
                "value": "*/*"
            },
            {
                "name": "x-stm-test",
                "value": "10 AND 1=1"
            }
        ],
        "uri": "/foo",
        "args": "",
        "httpVersion": "HTTP/1.1",
        "httpMethod": "GET",
        "requestId": "rid"
    },
    "labels": [
        {
            "name": "value"
        }
    ]
}

おわりに

AWS WAF v2のFirehoseを使用したS3へのログ出力をAWS CDK(一部AWS CLI)で設定してみました。

WebACLのログからアクセスのAllow/Block/Countの状況を確認して、ルールの最適化を行いたい場合にこの設定がされていると便利ですね。

参考

以上