Snyk IaC で CloudFormation テンプレートをセキュリティ解析してみた

Snyk CLI でローカル環境で手軽に Cloudfromation テンプレートにスキャンかけられて面白かったというお話です。
2022.07.28

先日 Snyk のウェビナーに参加していました。そこで CloudFormation のコード(yaml 形式のファイル)に対して CLI で手軽にセキュリティチェックしていたのが印象的でした。そのときは RDS を作成するテンプレートから、IAM のデータベース認証が無効化されていますと指摘されていました。

近しいものでは以下の記事で取り上げられています。

ちょうど CloudFormation でテンプレートを書いているところだったので手元のコードをローカル環境でスキャンをかけてみました。Snyk CLI のインストールから Snyk IaC のセキュリテイチェックの結果を紹介します。

実行結果

S3 バケットのアクセスログ設定、WAF の設定はどう?という結果が返ってきました。詳細は本文で。

Snyk CLI の準備

CI/CD に組み込まなくても Snyk CLI があればローカル環境で手軽に Snyk IaC の恩恵を授かることができることを知ったので公式ドキュメントを参考に実行環境を作ります。5分で終わります。

実行環境

項目
macOS 12.4
CPU Apple M1

インストール

Apple M1 machine について注釈がありましたがbrew installで問題ありませんでした。

Install or update the Snyk CLI - Snyk User Docs

$ brew tap snyk/tap
$ brew install snyk

$ snyk --version
1.976.0 (standalone)

認証

APIキーや、クレデンシャルをローカルに保存しておくこともなく、コマンド入力から Web ブラウザが開きAuthenticatをクリックするだけでした。

Auth - Snyk User Docs

$ snyk auth

Snyk CLI の準備が整いました。ドキュメントに目を通していた時間の方が長いくらいです。

Snyk IaC で Cloudformation と向き合う

CloudFront + S3 の静的サイトを Cloudformation で デプロイして、コンテンツは GitHub Actions から S3 バケットにデプロイする構成です。

AWS のリソース構築用に Cloudformation のテンプレートを書いていました。Cloudformation のテンプレートは GitHub Actions で CI/CD する予定まではなかったですが、ローカルでセキュリティチェックしてくれるならありがたいのでは?ということで Snyk IaC をカジュアルに使い始めてみます。

Cloudformation テンプレートは長いので折りたたみます。
AWSTemplateFormatVersion: "2010-09-09"
Description: Cloudfront and S3 with CloudFront Functions

Parameters:
  ProjectName:
    Description: Project Name
    Type: String
    Default: unnamed
  Environment:
    Description: Environment
    Type: String
    Default: dev
    AllowedValues:
      - prod
      - dev
  S3BucketName:
    Description: Bucket name for static site
    Type: String
  AliasName:
    Description: Alias for CloudFront
    Type: String
  HostedZoneId:
    Description: Route53 Host Zone ID
    Type: String
  CertificateId:
    Description: ACM Certificate ID must be us-east-1 region
    Type: String

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: Common Settings
        Parameters:
          - ProjectName
          - Environment
      - Label:
          default: SSL Settings
        Parameters:
          - AliasName
          - HostedZoneId
          - CertificateId

Resources:
  # ------------------------------------------------------------------------------------ #
  # S3
  # ------------------------------------------------------------------------------------ #
  # Static site bucket
  S3Bucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub ${ProjectName}-${Environment}-${S3BucketName}-${AWS::AccountId}
      OwnershipControls:
        Rules:
          - ObjectOwnership: "BucketOwnerEnforced"
      PublicAccessBlockConfiguration:
        BlockPublicAcls: True
        BlockPublicPolicy: True
        IgnorePublicAcls: True
        RestrictPublicBuckets: True
      VersioningConfiguration:
        Status: Enabled
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: "AES256"
            BucketKeyEnabled: false
      LifecycleConfiguration:
        Rules:
          - Id: AbortIncompleteMultipartUpload
            AbortIncompleteMultipartUpload:
              DaysAfterInitiation: 7
            Status: "Enabled"
          - Id: NoncurrentVersionExpiration
            NoncurrentVersionExpiration:
              NewerNoncurrentVersions: 3
              NoncurrentDays: 1
            Status: Enabled
  # Bucket Policy for CloudFront
  BucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref S3Bucket
      PolicyDocument:
        Statement:
          - Action: s3:GetObject
            Effect: Allow
            Resource: !Sub arn:aws:s3:::${ProjectName}-${Environment}-${S3BucketName}-${AWS::AccountId}/*
            Principal:
              AWS: !Sub arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ${CloudFrontOriginAccessIdentity}

  # ------------------------------------------------------------------------------------ #
  # CloudFront
  # ------------------------------------------------------------------------------------ #
  CloudFrontDistribution:
    Type: "AWS::CloudFront::Distribution"
    Properties:
      DistributionConfig:
        PriceClass: PriceClass_All
        Origins:
          - DomainName: !Sub ${ProjectName}-${Environment}-${S3BucketName}-${AWS::AccountId}.s3.${AWS::Region}.amazonaws.com
            Id: !Sub "S3origin-${ProjectName}-${Environment}-${S3BucketName}-${AWS::AccountId}"
            S3OriginConfig:
              OriginAccessIdentity: !Sub "origin-access-identity/cloudfront/${CloudFrontOriginAccessIdentity}"
        DefaultRootObject: index.html
        DefaultCacheBehavior:
          TargetOriginId: !Sub "S3origin-${ProjectName}-${Environment}-${S3BucketName}-${AWS::AccountId}"
          Compress: true
          ViewerProtocolPolicy: redirect-to-https
          # CachePolicyId: 658327ea-f89d-4fab-a63d-7e88639e58f6 # CachingOptimized
          CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad # CachingDisabled
          AllowedMethods:
            - GET
            - HEAD
          CachedMethods:
            - GET
            - HEAD
          ForwardedValues:
            Cookies:
              Forward: none
            QueryString: false
          # CloudFront Function Association
          FunctionAssociations:
            - EventType: viewer-request
              FunctionARN: !GetAtt CloudFrontFunction.FunctionMetadata.FunctionARN

        Logging:
          Bucket: !GetAtt S3BucketLogs.DomainName
          IncludeCookies: false
        Aliases:
          - !Ref AliasName
        ViewerCertificate:
          SslSupportMethod: sni-only
          MinimumProtocolVersion: TLSv1.2_2021
          AcmCertificateArn: !Sub "arn:aws:acm:us-east-1:${AWS::AccountId}:certificate/${CertificateId}"
        HttpVersion: http2
        IPV6Enabled: true
        Enabled: true
  # OAI
  CloudFrontOriginAccessIdentity:
    Type: AWS::CloudFront::CloudFrontOriginAccessIdentity
    Properties:
      CloudFrontOriginAccessIdentityConfig:
        Comment: Unique Domain Hosting Environment
  # CloudFront log bucket
  S3BucketLogs:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      BucketName: !Sub ${ProjectName}-${Environment}-${S3BucketName}-cloudfrontlogs-${AWS::AccountId}
      OwnershipControls:
        Rules:
          - ObjectOwnership: "ObjectWriter"
      PublicAccessBlockConfiguration:
        BlockPublicAcls: True
        BlockPublicPolicy: True
        IgnorePublicAcls: True
        RestrictPublicBuckets: True
      VersioningConfiguration:
        Status: Enabled
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: "AES256"
            BucketKeyEnabled: false
      LifecycleConfiguration:
        Rules:
          - Id: AbortIncompleteMultipartUpload
            AbortIncompleteMultipartUpload:
              DaysAfterInitiation: 7
            Status: "Enabled"
          - Id: CurrentVersionExpiration
            ExpirationInDays: 180
            Status: "Enabled"
          - Id: NoncurrentVersionExpiration
            NoncurrentVersionExpiration:
              NoncurrentDays: 30
            Status: "Enabled"
  # Alias Record
  Route53RecordSet:
    Type: AWS::Route53::RecordSet
    Properties:
      Name: !Sub ${AliasName}
      HostedZoneId: !Sub ${HostedZoneId}
      Type: A
      AliasTarget:
        DNSName: !GetAtt CloudFrontDistribution.DomainName
        HostedZoneId: Z2FDTNDATAQYW2 # fixed

  # ------------------------------------------------------------------------------------ #
  # CloudFront Functions
  # ------------------------------------------------------------------------------------ #
  CloudFrontFunction:
    Type: "AWS::CloudFront::Function"
    Properties:
      Name: "add-index-function"
      FunctionCode: |
        function handler(event) {
            var request = event.request;
            var uri = request.uri;

            // Check whether the URI is missing a file name.
            if (uri.endsWith('/')) {
                request.uri += 'index.html';
            }
            // Check whether the URI is missing a file extension.
            else if (!uri.includes('.')) {
                request.uri += '/index.html';
            }

            return request;
        }
      FunctionConfig:
        Comment: "Add index.html to the path"
        Runtime: "cloudfront-js-1.0"
      AutoPublish: true

セキュリティチェックしてみる

snyk iac testとコマンドを打つだけでセキュリティチェックの結果が返ってきました。

実行結果

$ snyk iac test

Snyk Infrastructure as Code

✔ Test completed.

Issues

Low Severity Issues: 3

  [Low] S3 server access logging is disabled
  Info:    The s3 access logs will not be collected. There will be no audit trail of access to s3 objects
  Rule:    https://snyk.io/security-rules/SNYK-CC-TF-45
  Path:    [DocId: 0] > Resources > S3BucketLogs > Properties > LoggingConfiguration
  File:    cloudfront-s3.yaml
  Resolve: Set `Properties.LoggingConfiguration` attribute

  [Low] S3 server access logging is disabled
  Info:    The s3 access logs will not be collected. There will be no audit trail of access to s3 objects
  Rule:    https://snyk.io/security-rules/SNYK-CC-TF-45
  Path:    [DocId: 0] > Resources > S3Bucket > Properties > LoggingConfiguration
  File:    cloudfront-s3.yaml
  Resolve: Set `Properties.LoggingConfiguration` attribute

  [Low] Cloudfront distribution without WAF
  Info:    The AWS WAF is not in front of cloudfront distribution. The WAF service will not protect the application from common web based attacks such as SQL injections
  Rule:    https://snyk.io/security-rules/SNYK-CC-TF-75
  Path:    [DocId: 0] > Resources[CloudFrontDistribution] > Properties > DistributionConfig > WebACLId
  File:    cloudfront-s3.yaml
  Resolve: Set `Properties.DistributionConfig.WebACLId` attribute to existing AWS WAF acl ARN

-------------------------------------------------------

Test Summary

  Organization: ohmura.yasutaka
  Project name: cloudformation

✔ Files without issues: 2
✗ Files with issues: 1
  Ignored issues: 0
  Total issues: 3 [ 0 critical, 0 high, 0 medium, 3 low ]

-------------------------------------------------------

Tip

  New: Share your test results in the Snyk Web UI with the option --report

構成図で説明するとこうなります。今回は意図的に設定していない箇所でした。

  • 静的サイトの S3 バケットのアクセスログも保存した方が好ましいのは事実です
  • CloudFront のアクセスログ保存用の S3 バケットにアクセスログ設定は必要ない
  • WAF は利用しない

「S3 バケットのアクセスログ機能の存在」や、「CloudFront に WAF を設定できること」を知らないといったように誰しもセキュアな設定・機能を把握できているとは限らないため、テンプレートを書いている時点で設計を見直せるのはよいことです。デプロイしてから設計・設定を変更出戻りの工数の方が大きですからね。

Web UI のレポート

実行結果を共有するなら Web UI が便利そうなので試してみます。(実行結果に書いてあったので知りました。)

Tip

  New: Share your test results in the Snyk Web UI with the option --report

実行結果は同じ情報が返ってきたので省略します。違いは文末に URL のリンクが案内されました。

実行結果

$ snyk iac test --report

... snip ...

------------------------------------------------------

Report Complete

  Your test results are available at: https://snyk.io/org/ohmura.yasutaka/projects
  under the name: cloudformation

テキストで表示されていたメッセージが Web UI で確認できました。

Cloudformation のなんのプロパティを追加すればよいのか教えてくれるのは丁寧ですね。今回は意図的な設定しないため無視してみます。

永続的に表示されなくてよい(Ignore permanently)設定で指摘を無視します。

何かしら理由を書いてすべて無視しました。無視した理由と入力者の名前が記録されるのは後で追えるからよいですね。

snyk iac testを再実行すると無視した項目が再検出されました。

実行結果

$ snyk iac test

... snip ...

-------------------------------------------------------

Test Summary

  Organization: ohmura.yasutaka
  Project name: bigmuramura/hugo-cloudfront-s3

✔ Files without issues: 2
✗ Files with issues: 1
  Ignored issues: 0
  Total issues: 3 [ 0 critical, 0 high, 0 medium, 3 low ]

-------------------------------------------------------

Tip

  New: Share your test results in the Snyk Web UI with the option --report

どうやら.snykポリシーファイルに記述しないといけないようです。

IaC update-exclude-policy - Snyk User Docs

便利に扱うためには多少の学習コストが必要でした。今回はカジュアルにチェックしたかっただけなので今後学習が必要な箇所を把握できたので十分です。

おわりに

Free plan でもカジュアルにチェックできたので CloudFormation のテンプレート書いているとき思い出したら試してみてください。自分の知らないセキュアな設定を教えてくれるかもしれませんし、凡ミスでガバガバセキュリティだったことに気付けるかもしれませんよ。

Terraform ユーザーはこちらを参照ください。