Regula(OPA/Rego)によるCDKのデプロイ前セキュリティチェックを組織内の大量のAWSアカウントにすべなく展開する方法

※本記事はre:Invent 2022 の以下セッションを参考にしています
AWS re:Invent 2022 - Goldman Sachs: Using policy as code to deploy new apps in minutes (COP313) - YouTube

本記事のまとめ

  • 非準拠リソースを「CDKのデプロイ前にブロック」する仕組みを組織内の大量のAWSアカウントにすべなく展開する方法
  • セルフサービスのAWSアカウント作成機能で「パイプライン用アカウント」と「アプリケーション用アカウント」がセットで払い出しされる
  • AWSリソースはパイプライン用アカウント経由で「Open Policy Agent (OPA)」によるセキュリティチェックを通らないとデプロイできない

※OPA:オープンソースの汎用的なポリシーエンジン。JSON等の構造化されたデータがRegoという宣言型言語で記述されたポリシーを満たしているかチェックできる

今回のお話

先日のFin-JAWSでお話した以下資料の右側「ないとき」の課題に対する1つの対処法についてまとめます。
非準拠リソースをデプロイ前にブロックする仕組みをどのように組織内の大量のアカウントにすべなく展開するか」という課題です。
(Fin-JAWSでは「Control Towerのプロアクティブなコントロール」を使う方法をご紹介しました)

冒頭にリンクを貼りましたが、Goldman Sachsがre:Invent 2022で関連する事例発表をしていました。
ただ、詳細までは分からなかったので「このセッションを参考にしつつ私なりのプロトタイプを作ってみた話」をまとめています。

Goldman Sachsが構築した "Fast Track"

Goldman Sachsは "Fast Track" という仕組みを構築しています。

Fast Track を図解

セッション動画に基づき、Goldman Sachsが構築した "Fast Track" を図解しました。

ポイントは以下の通りです。

  1. 開発者が「セルフサービス」でアカウントを作成できる
  2. アプリケーションを稼働させるアカウント」と、そのアカウントにCDKでAWSリソースをデプロイするための「デプロイパイプライン用アカウント」のセットで自動構築される
  3. デプロイパイプライン内では「Open Pollicy Agent」(OPA) で、リソースの設定が自社のルールに準拠しているか「デプロイ前にチェック」される
  4. AWSリソースの構築はデプロイパイプライン用アカウントを経由して行うことで「セキュリティチェックが強制」され「開発者は本番データから明確に隔離される
  5. ルール準拠のためのコードを各プロジェクトで個別に書いていると冗長なので、「ルール準拠済みのCDK Constructs」を提供する

Fast Trackを備えたアカウントのセットを自動構築し、パイプライン用アカウントからAWSリソースをデプロイさせることで、組織内のAWSアカウントにすべなくデプロイ前セキュリティチェックを展開するということです。

Fast Track の効果

2年前のGoldman Sachsでは、案件着手から実際にAWSリソースを構築できるようになるまでに「数週間〜1ヶ月」かかっていたそうです。
その要因は、インフラ設計について「人力によるリスク評価」をパスしないとリソースも作れないし、アカウントすら作られないという運用になっていたからとのこと。
特に、金融機関だと似たような状況(申請や調整ばかりに時間がかかってしまう)の方も多いのではないかと思います。
Goldman Sachsも同じ悩みを抱えていたのです。

しかし、Goldman Sachsは「そういうものだ」とあきらめずに「Fast Track」という仕組みにより、この期間を「数十分」に短縮しました。

マネしてみよう

Goldman Sachsをマネして作ってみます。

アカウント作成機能の作り方

今回は、アカウント作成機能の構築は省略しました。(個人的に新しい話じゃないので)。 セッションの中で紹介されていたGoldman Sachsの方式を図解だけしています。

アカウント作成依頼を受け付ける処理は、アカウント一覧(DynamoDB)に起票するだけ。 アカウント一覧の変更履歴をDynamoDB Streamsで拾って、アカウント作成をキックする方式はシンプルで良いなと思いました。

デプロイパイプラインの作り方

デプロイパイプライン用アカウントの中身を見ていきましょう。
セッションから詳細までは分からなかったのですが、こんな感じだろうという私なりの図解です。

※以降はセッションにもとづく内容ではなく「私がこう作ってみました」という話です

Goldman Sachsはセキュリティチェックに「OPA」を使っていますが、今回は「Regula」を代わりに使いたいと思います。 「Regula」はCloudFormation向けの基本的なチェックルールが既定で組み込まれているためです。 OPAと同じRegoファイルでカスタムルールを組み込むこともできます。 カスタムルールを定義したRegoファイルは管理者アカウントのS3バケットにチェックの都度取りに行く構成にしています。

とりあえず、コンセプトを動かしてみただけの乱暴なコードですが、今回作ったパイプラインは次の通りです。

パイプライン用スタック fast-track-stack.ts

import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as codepipeline from 'aws-cdk-lib/aws-codepipeline';
import * as kms from 'aws-cdk-lib/aws-kms';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as codecommit from 'aws-cdk-lib/aws-codecommit';
import * as codepipeline_actions from 'aws-cdk-lib/aws-codepipeline-actions';
import * as codebuild from 'aws-cdk-lib/aws-codebuild';
import { getBuildspecForSynth, getBuildspecForSacn } from './fast-track-config';

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

    // アーティファクト用のS3バケット
    const accountId = cdk.Stack.of(this).account;
    const pipelineArtifactKey = new kms.Key(this, 'FastTrackPipelineKey');
    const pipelineArtifactBucket = new s3.Bucket(this, 'FastTrackPipelineArtifactBucket', {
      bucketName: 'fast-track-pipeline-artifact-bucket-' + accountId,
      encryptionKey: pipelineArtifactKey,
      encryption: s3.BucketEncryption.KMS,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      autoDeleteObjects: true,
      removalPolicy: cdk.RemovalPolicy.DESTROY 
    });

    // パイプライン
    const pipeline = new codepipeline.Pipeline(this, 'FastTrackPipeline', {
      pipelineName: 'FastTrackPipeline',
      artifactBucket: pipelineArtifactBucket,
      crossAccountKeys: true
    });
    
    // Source stage
    const codeCommitRepositoryName = this.node.tryGetContext('codeCommitRepositoryName');
    const branchName = this.node.tryGetContext('branchName');
    const repo = codecommit.Repository.fromRepositoryName(this, 'AppRepository', codeCommitRepositoryName);
    const sourceStage = pipeline.addStage({
      stageName: 'Source'
    });
    // Source action
    const sourceOutput = new codepipeline.Artifact();
    const sourceAction = new codepipeline_actions.CodeCommitSourceAction({
      actionName: 'CloneRepository',
      repository: repo,
      branch: branchName,
      output: sourceOutput
    });
    sourceStage.addAction(sourceAction);

    // Build stage
    const buildStage = pipeline.addStage({
      stageName: 'Build'
    });
    // buildspec
    const buildspecForSynth = codebuild.BuildSpec.fromObject(getBuildspecForSynth());
    // Build project
    const buildProjectForSynth = new codebuild.PipelineProject(this, 'SynthProject', {
      projectName: 'cdkSynthProject',
      buildSpec: buildspecForSynth,
      environment: {
        buildImage: codebuild.LinuxBuildImage.STANDARD_5_0,
        privileged: true
      }
    });
    // Synth action
    const synthOutput = new codepipeline.Artifact();
    const synthAction = new codepipeline_actions.CodeBuildAction({
      actionName: 'Synth',
      project: buildProjectForSynth,
      input: sourceOutput,
      outputs: [synthOutput]
    })
    buildStage.addAction(synthAction);

    // Scan stage
    const scanStage = pipeline.addStage({
      stageName: 'Scan'
    });
    // buildspec
    const stackName = this.node.tryGetContext('stackName');
    const severity = this.node.tryGetContext('severity');  // 指定したseverity以上が検出されるとパイプラインが止まる
    const buildspecForScan = codebuild.BuildSpec.fromObject(
      getBuildspecForSacn(stackName + '.template.json', severity)
    );
    // Build project
    // zipで固めたRegoファイル群(カスタムルール)をS3バケットからScanの都度取得するように構成
    const regoBucketArn = this.node.tryGetContext('regoBucketArn');
    const zipedRegoFilesPath = this.node.tryGetContext('zipedRegoFilesPath');
    const regoBucket = s3.Bucket.fromBucketArn(
      this,
      'RegoBucket',
      regoBucketArn
    );
    const buildProjectForScan = new codebuild.Project(this, 'ScanProject', {
      projectName: 'CfnScanProject',
      buildSpec: buildspecForScan,
      environment: {
        buildImage: codebuild.LinuxBuildImage.STANDARD_5_0,
        privileged: true
      },
      secondarySources: [
        codebuild.Source.s3({
          bucket: regoBucket,
          path: zipedRegoFilesPath,
          identifier: 'rules'
      })]
    });
    // Scan by Regula
    const scanOutput = new codepipeline.Artifact();
    const scanAction = new codepipeline_actions.CodeBuildAction({
      actionName: 'ScanByRegula',
      project: buildProjectForScan,
      input: synthOutput,
      outputs: [scanOutput]
    })
    scanStage.addAction(scanAction);

    // Deploy stage
    const deployStage = pipeline.addStage({
      stageName: 'Deploy',
    });
    // Change Set作成
    const targetAccountId = this.node.tryGetContext('targetAccountId');
    const targetRegion = this.node.tryGetContext('targetRegion');
    const changeSetName = stackName + 'ChangeSet';
    const prepareAction = new codepipeline_actions.CloudFormationCreateReplaceChangeSetAction({
      actionName: 'Prepare',
      account: targetAccountId,
      region: targetRegion,
      stackName: stackName,
      changeSetName: changeSetName,
      adminPermissions: true,
      templatePath: scanOutput.atPath(stackName + '.template.json'),
      runOrder: 1
    });
    deployStage.addAction(prepareAction);
    // デプロイ
    const deployAction = new codepipeline_actions.CloudFormationExecuteChangeSetAction({
      actionName: 'ExecuteChanges',
      account: targetAccountId,
      region: targetRegion,
      stackName: stackName,
      changeSetName: changeSetName,
      runOrder: 2
    });
    deployStage.addAction(deployAction);
  }
}

buildspec定義 fast-track-config.ts

export function getBuildspecForSynth() {
    const buildspec = {
        'version': 0.2,
        'phases': {
        'build': {
            'commands': [
            'npm ci',
            'npm run build',
            'npm run test',
            'npx cdk synth'
            ]
        }
        },
        'artifacts': {
        'base-directory': 'cdk.out',
        'files': '**/*'
        }
    };
    return buildspec;
}

export function getBuildspecForSacn(cfnTemplateName: string, severity: string) {
    const buildspec = {
        'version': 0.2,
        'phases': {
          'install': {
            'commands': [
              'wget https://github.com/fugue/regula/releases/download/v2.1.0/regula_2.1.0_Linux_x86_64.tar.gz',
              'tar -xvf regula_2.1.0_Linux_x86_64.tar.gz',
              'mv regula /usr/local/bin/regula',
              'chmod +x /usr/local/bin/regula',
            ]
          },
          'build': {
            'commands': [
              'regula run ' + cfnTemplateName + ' -s ' + severity + ' --include $CODEBUILD_SRC_DIR_rules'
            ]
          }
        },
        'artifacts': {
          'files': '**/*'
        }
    };
    return buildspec;
}

非準拠リソース/準拠リソースをデプロイしてみる

では「非準拠リソース」を含むCDKコードをリポジトリにpushします。 SSE-S3で暗号化しただけのS3バケットです。

new s3.Bucket(this, 'IncompliantBucket', {
      bucketName: 'incompliant-bucket' + accountId,
      encryption: s3.BucketEncryption.S3_MANAGED
});

リポジトリにpushするとパイプラインが動き、Scanという「Regulaによるセキュリティチェックを実行するステージで失敗」しました。

中身を確認すると、Regulaが以下を出力して「異常終了」(exit status 1)しています。

CUSTOM_0001: S3 bucket must be encrypted by CMK [High]

  [1]: IncompliantBucket4C0C2AB7
       in SampleAppStack.template.json:3:3

FG_R00229: S3 buckets should have all `block public access` options enabled [High]
           https://docs.fugue.co/FG_R00229.html

  [1]: IncompliantBucket4C0C2AB7
       in SampleAppStack.template.json:3:3

FG_R00100: S3 bucket policies should only allow requests that use HTTPS [Medium]
           https://docs.fugue.co/FG_R00100.html

  [1]: IncompliantBucket4C0C2AB7
       in SampleAppStack.template.json:3:3

Found 3 problems.

3点、ルール違反があることを示しています。

  1. S3バケットはCMKで暗号化しなければならない(カスタムルール)
  2. S3バケットは「ブロックパブリックアクセス」を有効化しなければならない(組み込みルール)
  3. S3バケットHTTPSによるリクエストを強制しなければならない(組み込みルール)

では「ルールに準拠したS3バケット」をpushしてみます。
次のようなS3バケットです。急にコードの行数が増えましたね。

const s3Key = new kms.Key(this, 'S3BucketKey', {
      alias: 's3-bucket-key',
      enableKeyRotation: true
    });
const compliantBucket = new s3.Bucket(this, 'CompliantBucket', {
      bucketName: 'compliant-bucket-' + accountId,
      encryption: s3.BucketEncryption.KMS,
      encryptionKey: s3Key,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      versioned: true
    });
const compliantBucketPolicy = new iam.PolicyStatement({
      effect: iam.Effect.DENY,
      actions: ['s3:*'],
      principals: [new iam.AnyPrincipal()],
      resources: [
        'arn:aws:s3:::' + 'compliant-bucket-' + accountId,
        'arn:aws:s3:::' + 'compliant-bucket-' + accountId + '/*'
      ],
      conditions: {
        Bool: { 'aws:SecureTransport': false }
      }
    });
compliantBucket.addToResourcePolicy(compliantBucketPolicy);

先ほど失敗した「Scanステージを無事にパスしてデプロイに進んでいます

Regulaは以下のねぎらいのメッセージとともに「正常終了」しています。

No problems found. You did a lot of work today.

このように「ルールに非準拠のリソースはデプロイ前に止めて、準拠したリソースだけがデプロイされる」仕組みが実現できました。
このパイプラインを「新規アカウント作成処理の中でパイプライン用アカウントにデプロイ」すればOKです。

Regula (OPA/Rego) のカスタムルールの作り方

カスタムルールは「Regoという宣言型言語」で記述します。OPAと同じRegoが使えます。 Regoは大学時代にやった「Prolog」を思い出しました。個人的にPrologは結構好きなのでそこまで抵抗は感じませんでした。

先ほど非準拠リソースとして検知された「S3バケットはCMKで暗号化しなければならない」は「カスタムルールとして独自に追加」しています。 Regula組み込みのルールでは、方式を問わずサーバサイド暗号化が有効になっていれば非準拠にはなりません。 そのため、自社に「CMKで暗号化すべし」というルールがある場合はカスタムルールを作る必要があります。

CMKで暗号化されていることをチェックするために以下のカスタムルールを作りました。
SSE設定としてKMSMasterKeyIDがセットされていないリソースは非準拠と判定」しています。

s3-bucket-kms-encrypt.rego

package rules.s3_bucket_encryption

__rego__metadoc__ := {
  "id": "CUSTOM_0001",
  "title": "S3 bucket must be encrypted by CMK",
  "description": "Per company policy, it is required for all S3 bucket to be encrypted by CMK.",
  "custom": {
    "controls": {
      "CORPORATE-POLICY": [
        "CORPORATE-POLICY_1.1"
      ]
    },
    "severity": "High"
  }
}

input_type := "cfn"
resource_type := "AWS::S3::Bucket"

default allow = false

allow {
  cmk_encrypted := [cmk |
    cmk := input.BucketEncryption.ServerSideEncryptionConfiguration[_].ServerSideEncryptionByDefault.KMSMasterKeyID
  ]
  count(cmk_encrypted) > 0
}

以下のようにRegulaを実行することで、カスタムルールも含めたチェックができます。

regula run <チェック対象のCFnテンプレート> --include <Regoファイル群を格納したディレクトリ>

パイプライン内では、管理者アカウントのS3バケットからRegoファイル群を取得してRegulaを実行しています。

ルールに準拠するためのコードが社内で冗長にならないようにCustom Constructsを公開

ルールを作るだけだと「ルールに準拠するためのコードを社内の色々なプロジェクトで重複して書く」ことになってしまいます。 先ほども、ルールに準拠したS3バケットはコードの行数が20行以上になっていました。

Goldman Sachsでは「ルールに準拠したCustom Constructsを作って社内に公開」することで冗長にならないようにしています。 開発者はこのCustom Constructsを利用することにより、「ルール準拠のための共通したコードを書かなくても良い」ようになっています。 例えば、S3バケットであれば、リクエストをHTTPSに限定するバケットポリシーのStatementはCustom Constructs内に実装しておいて、開発者は当該Statementは書かなくても良いようにします。

おわりに

特に、規制業界のAWS環境では「権限がガチガチに縛られていて使いにくい」というケースは多いと思います。 「規制業界だから仕方がない」ではなく、このような「仕組みで乗り越えていく姿勢」が大事かなぁと思いました。 「CDKで書いてくれるなら自由にできるよ」と言えるので、自由を与える前提として勉強してもらえるし、IaC文化が根付くきっかけになるかもしれません。

とはいえ、今回はプロトタイプを作っただけなので、「実際に運用してみると色々と検討要素が湧いてきそうな予感」が多分にします。 今後、運用まで深堀りして検討してみたいと思います。

以上