はじめに
私は社内向けプラットフォームチームに所属しており、AWS Service Catalog × CDK でセルフサービス型機能の構築・運用を行っています。以下記事を過去に執筆しました。
上記では、CDK の実装ノウハウが中心で、開発フローや少々複雑な ProductStack(L2Construct)の仕様などには触れていませんでした。また、Service Catalog 製品のテストコードを例示していましたが、cdk-nag を適用できない方式でした。
そこで、本記事では以下をご紹介します。
- なぜ Service Catalog 製品に cdk-nag を適用するのか
- Service Catalog 製品テンプレートの生成タイミング
- cdk-nag による Service Catalog 製品の静的解析方法
- cdk-nag のエラーに伴う修正・再テストの流れ
本記事のポイント
Service Catalog 製品のテンプレートを CfnInclude でテスト用スタックに取り込めば、cdk-nag を適用できます。
前提
想定読者
- Service Catalog をこれから使い始めようとしている人
- AWS CDK における Service Catalog 利用方法に興味がある人
- AWS Constructs Library1、および CDK におけるテスト2の概念を理解している人
実行環境
- CDK v2.113.0
- Jest v29.6.4
なぜ Service Catalog 製品に cdk-nag を適用するのか
cdk-nag とは、AWS CDK と統合したセキュリティ・コンプライアンスチェックツールです。各社のポリシーに沿った独自ルールパックを生成できます。cdk deploy
などの CDK CLI 実行時に CDK で生成したテンプレートに対して、cdk-nag のルールパックに沿った静的解析を行います。デプロイする前に、ルール違反している設定に気付けるため、非常に便利です。
一方、Service Catalog 製品とは、配布を目的とした AWS サービス群です。CFn テンプレートなどで定義し、各社のセキュリティ・コンプライアンスに沿った製品をセルフサービス型で組織内に展開できます。
2023 年の様々なアップデートにより、CDK でも Service Catalog を扱いやすくなりました。CDK で Service Catalog を構築・運用すると、DevOps のサイクルを円滑に回せます。そのサイクルに Service Catalog 製品の cdk-nag 解析を加えることで、セキュリティ・コンプライアンスに遵守した製品を迅速に配布できます。DevSecOps にも繋がるため、Service Catalog 製品に cdk-nag を適用するのは、非常にオススメです。
Service Catalog 製品テンプレートの生成タイミング
以下では、Service Catalog 製品テンプレートがどのタイミングで作られるのか、2種類の図で解説します。後述のサンプルコードも併せてご確認ください。
Service Catalog 製品の開発フロー
以下は、CDK において Service Catalog 製品を開発する際の大まかなフロー図です3。
CDK で Service Catalog 製品を定義するには、上記の ProductStack を使用します。ProductStack の定義内容が製品テンプレートとして出力されます。
Stack の生成により、製品テンプレートを出力した後でないと、製品を cdk-nag で静的解析できません。
CDK × Service Catalog 構成概要
ProductStack と通常の Stack との違いが少々分かりづらいため、イメージ図を以下に掲載します。
本記事で静的解析の対象としているのは、上記の任意ディレクトリに格納された CFn テンプレート(製品テンプレート)です。
ProductStack 単体では、製品テンプレートを出力することはできません。図の通り、ProductStack は他の Stack から呼ばれて使用されます。
もし、単体テストなどで ProductStack を直接生成しようとすると、以下の警告文がでます。
Product stacks must be defined within scope of another non-product stack
ProductStack の問題点
前述の通り、cdk-nag の主な実行タイミングは、CDK CLI 実行時です。しかし、ProductStack で生成した製品テンプレートについては、CDK CLI を実行しても cdk-nag で解析されません。
cdk-nag 静的解析結果のサンプル
例えば、通常の Stack で S3 バケットをデフォルト設定で定義すると、cdk-nag からエラーが返されます。
import * as cdk from 'aws-cdk-lib';
import * as s3 from 'aws-cdk-lib/aws-s3';
import { AwsSolutionsChecks } from 'cdk-nag'
const app = new cdk.App();
cdk.Aspects.of(app).add(new AwsSolutionsChecks());
new s3.Bucket(new cdk.Stack(app, "SampleStack"), 'Bucket');
[Error at /SampleStack/Bucket/Resource] AwsSolutions-S1: The S3 Bucket has server access logs disabled.
[Error at /SampleStack/Bucket/Resource] AwsSolutions-S10: The S3 Bucket or bucket policy does not require requests to use SSL.
ProductStack を使用したサンプルコード
一方、ProductStack で上記同様に S3 バケットを定義した場合は cdk-nag で解析されず、エラーが返されません。
import * as cdk from 'aws-cdk-lib';
import { CdkScStack } from '../lib/cdk_sc-stack';
import { AwsSolutionsChecks } from 'cdk-nag'
const app = new cdk.App();
cdk.Aspects.of(app).add(new AwsSolutionsChecks());
new CdkScStack(app, 'CdkScStack');
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as servicecatalog from 'aws-cdk-lib/aws-servicecatalog';
// 利用者に配布する CFn テンプレートのリソースを ProductStack 配下で定義
class S3BucketProduct extends servicecatalog.ProductStack {
constructor(scope: Construct, id: string) {
super(scope, id);
new s3.Bucket(this, 'Bucket');
}
}
// Service Catalog 関連のリソースを通常の Stack で定義
export class CdkScStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// ProductStack のリソース定義にバージョン名を付与して、スナップショットを出力
const productStackHistory = new servicecatalog.ProductStackHistory(
this, 'ProductStackHistory', {
productStack: new S3BucketProduct(this, 'S3BucketProduct'),
currentVersionName: 'v1',
currentVersionLocked: true,
});
// 利用者に開示する製品を登録
const product = new servicecatalog.CloudFormationProduct(this, 'MyFirstProduct', {
productName: "My Product",
owner: "Product Owner",
productVersions: [
productStackHistory.currentVersion(),
],
});
// ポートフォリオ(製品の集合)を定義
const portfolio = new servicecatalog.Portfolio(this, 'Portfolio', {
displayName: 'MyPortfolio',
providerName: 'MyTeam',
});
portfolio.addProduct(product);
}
}
cdk synth
時は、cdk-nag でエラーが発生せず、CdkScStack に関する CFn テンプレートの生成結果が表示されます。
なお、re:Invent 2023 で発表されたばかりの Amazon Q に聞いてみましたが、上記同様に app レイヤーで cdk-nag を適用する方法が提示されました。
なぜ解析されない?
CDK で生成される CFn テンプレートは基本的に cdk.out へ出力されます。一方、Service Catalog 製品は、別の CFn テンプレートとして、CDK プロジェクト上の任意ディレクトリに出力されます。
以下のリファレンスによると、Service Catalog 製品の CFn テンプレートについては、cdk deploy
のコマンドでスタックとして認識されず、Asset として S3 バケットにアップロードされるだけのようです。
This stack will not be treated as an independent deployment artifact (won't be listed in "cdk list" or deployable through "cdk deploy"), but rather only synthesized as a template and uploaded as an asset to S3.
出典:https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_servicecatalog.ProductStack.html
この辺りを突き詰めていくと根本原因が判明しそうですが、後述の issue にも関わってくる内容なので、本記事では深く触れません。
単体テストで cdk-nag を実行する
Stack の単体テストで cdk-nag を実行するという方法が公式ブログで紹介されています4。この方法なら、Stack のリソースを静的解析できそうですが、以下 Issue で ProductStack のバグが報告されています。2023年12月5日時点の CDK 最新バージョン"v2.113.0"においても、本バグは解消されていません。
そこで、ProductStack のテストに関する Tips として、過去記事で以下を紹介していました。
本 Tips では、Service Catalog 製品スナップショットの CFn テンプレート(JSON ファイル)に対して、readFileSync と fromJSON メソッドを使っていました。
beforeAll(() => {
// JSON 形式のスナップショットを読み取り
const snapshot = JSON.parse(readFileSync(snapVersionPath, "utf-8"));
// スナップショットを fromJSON メソッドで Template 化
template = Template.fromJSON(snapshot);
});
上記サンプルコードでは、JSON ファイルを Template 形式に変換しており、CDK のテストで一般的に使われる Stack を生成していません。
しかし、cdk-nag を適用するには、Aspects の of メソッドに Stack などの IConstruct を渡す必要があります5。過去に紹介したサンプルコードでは、Template 形式に変換していたため、cdk-nag で静的解析できませんでした。
public static of(scope: IConstruct): Aspects
出典:https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.Aspects.html#static-ofscope
解決策
前振りが長くなりましたが、解決策はシンプルです。
Service Catalog 製品のテンプレートを CfnInclude でテスト用スタックに取り込めば、cdk-nag を適用できます。コード例は以下の通りです。
beforeEach(() => {
// テスト用の仮スタックを定義
stack = new cdk.Stack(new cdk.App(), "TestStack");
// テスト用の仮スタックに Service Catalog 製品テンプレートをインクルード
new CfnInclude(stack, "ServiceCatalogProductTemplate", {
templateFile: snapVersionPath,
});
// テスト用の仮スタックを cdk-nag で静的解析
cdk.Aspects.of(stack).add(new AwsSolutionsChecks());
template = Template.fromStack(stack);
});
前提として、cdk synth
などで、Service Catalog 製品のテンプレートを生成しておく必要があります6。その後、CfnInclude でテスト用の仮スタックに Service Catalog 製品テンプレートを取り込んでいます。この方法ならば、通常のアサーションテストに加えて、cdk-nag の実行も可能です。
テストコードサンプルの全容は以下の通りです。
テストコードサンプル全容
// WARNING: 前提として、製品テンプレートが出力されていること
import { Template, Annotations, Match } from "aws-cdk-lib/assertions";
import * as cdk from "aws-cdk-lib";
import { CfnInclude } from "aws-cdk-lib/cloudformation-include";
import { AwsSolutionsChecks } from 'cdk-nag';
import { SynthesisMessage } from "aws-cdk-lib/cx-api";
// TODO: 製品テンプレート出力に伴い、Service Catalog関連の以下情報を適宜変更
const productSnapshotDirectoryName = "product-stack-snapshots";
const stackName = "CdkScStack";
const productName = "S3BucketProduct";
const productVersion = "v1";
// テスト対象のService Catalog製品テンプレートについてファイルパスを指定
// TODO: ProductStackHistory・productNameサフィックスの文字列は適宜修正
const snapVersionPath = [
`${productSnapshotDirectoryName}/${stackName}ProductStackHistory11BC36A9`,
`${stackName}${productName}335B97F3`,
`${productVersion}.product.template.json`
].join('.');
console.log(snapVersionPath);
describe("ServiceCatalogProductのテスト", () => {
let template: Template;
let stack: cdk.Stack;
beforeEach(() => {
// テスト用の仮スタックを定義
stack = new cdk.Stack(new cdk.App(), "TestStack");
// テスト用の仮スタックに Service Catalog 製品テンプレートをインクルード
new CfnInclude(stack, "ServiceCatalogProductTemplate", {
templateFile: snapVersionPath,
});
// テスト用の仮スタックを cdk-nag で静的解析
cdk.Aspects.of(stack).add(new AwsSolutionsChecks());
template = Template.fromStack(stack);
});
describe("cdk-nagの確認", () => {
test("No unsuppressed Warnings", () => {
const warnings = Annotations.fromStack(stack).findWarning(
"*",
Match.stringLikeRegexp("AwsSolutions-.*"),
);
try {
expect(warnings).toHaveLength(0);
} catch (e) {
throw new Error(createCdkNagLog(warnings));
}
});
test("No unsuppressed Errors", () => {
const errors = Annotations.fromStack(stack).findError(
"*",
Match.stringLikeRegexp("AwsSolutions-.*"),
);
try {
expect(errors).toHaveLength(0);
} catch (e) {
throw new Error(createCdkNagLog(errors));
}
});
});
test("S3 Bucketの数が1である", () => {
template.resourceCountIs("AWS::S3::Bucket", 1);
});
});
function createCdkNagLog(messages: SynthesisMessage[]): string {
let log = "";
messages.forEach((message) => {
switch (message.level) {
case "info":
log += "\u001b[34m"; // blue
break;
case "warning":
log += "\u001b[33m"; // yellow
break;
case "error":
log += "\u001b[31m"; // red
break;
default:
log += "\u001b[30m"; // black
break;
}
log += `[${message.level} at ${message.id}] ${message.entry.data as string}\u001b[0m`;
});
return log;
}
上記のfunction createCdkNagLog
は、エラーメッセージを整形するためのメソッドで、以下記事で紹介した内容をそのまま使っています。
Service Catalog 製品の静的解析・修正
以下では、実際に Service Catalog 製品を cdk-nag で解析し、エラー結果を基に修正・再テストするという流れを提示します。
単体テストでの cdk-nag 実行結果
解決策で掲載したテストコードを実行します。
前述の結果と同様に、cdk-nag から S3 バケットの違反に関するエラーが出力されました。
Service Catalog 製品を修正
cdk-nag のエラー2件に従って、以下方針で Service Catalog 製品の定義を見直します。
- AwsSolutions-S1: The S3 Bucket has server access logs disabled.
→ ルールを抑制 - AwsSolutions-S10: The S3 Bucket or bucket policy does not require requests to use SSL.
→ S3バケットで SSL を強制
今回は、既に実環境へ Service Catalog 製品 v1 をリリースしているという想定で、Service Catalog 製品のバージョンを一つ繰り上げます7。コード例は以下の通りです。
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as servicecatalog from 'aws-cdk-lib/aws-servicecatalog';
+ import { NagSuppressions } from 'cdk-nag';
class S3BucketProduct extends servicecatalog.ProductStack {
constructor(scope: Construct, id: string) {
super(scope, id);
- new s3.Bucket(this, 'Bucket');
+ const bucket = new s3.Bucket(this, 'Bucket', {
+ enforceSSL: true,
+ });
+ NagSuppressions.addResourceSuppressions(bucket, [{
+ id: "AwsSolutions-S1",
+ reason: "Demonstration"
+ }])
}
}
export class CdkScStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const productStackHistory = new servicecatalog.ProductStackHistory(this, 'ProductStackHistory', {
productStack: new S3BucketProduct(this, 'S3BucketProduct'),
- currentVersionName: 'v1',
+ currentVersionName: 'v2',
currentVersionLocked: true,
});
上記修正後、cdk synth
や (CdkScStack)自体のテストなどで Service Catalog 製品テンプレートを出力します。
製品バージョンを繰り上げたので、テストコードの定義も同様に変更します。この変更により、テスト対象の Service Catalog 製品テンプレートを切替できます。
// Service Catalog 関連の情報を定義
const productSnapDirectoryName = "product-stack-snapshots";
const stackName = "CdkScStack";
const productName = "S3BucketProduct";
- const productVersion = "v1";
+ const productVersion = "v2";
再テスト
この状態でテストを再度実行します。
無事に cdk-nag のエラーが出力されなくなりました。
念の為、修正後の Service Catalog 製品テンプレート(スナップショット)を確認してみます。
Service Catalog 製品テンプレート全容
{
"Resources": {
"Bucket83908E77": {
"Type": "AWS::S3::Bucket",
"UpdateReplacePolicy": "Retain",
"DeletionPolicy": "Retain",
"Metadata": {
"aws:cdk:path": "CdkScStack/S3BucketProduct/Bucket/Resource",
"cdk_nag": {
"rules_to_suppress": [
{
"reason": "Demonstration",
"id": "AwsSolutions-S1"
}
]
}
}
},
"BucketPolicyE9A3008A": {
"Type": "AWS::S3::BucketPolicy",
"Properties": {
"Bucket": {
"Ref": "Bucket83908E77"
},
"PolicyDocument": {
"Statement": [
{
"Action": "s3:*",
"Condition": {
"Bool": {
"aws:SecureTransport": "false"
}
},
"Effect": "Deny",
"Principal": {
"AWS": "*"
},
"Resource": [
{
"Fn::GetAtt": [
"Bucket83908E77",
"Arn"
]
},
{
"Fn::Join": [
"",
[
{
"Fn::GetAtt": [
"Bucket83908E77",
"Arn"
]
},
"/*"
]
]
}
]
}
],
"Version": "2012-10-17"
}
},
"Metadata": {
"aws:cdk:path": "CdkScStack/S3BucketProduct/Bucket/Policy/Resource"
}
}
}
}
rules_to_suppress で AwsSolutions-S1 のルールが抑制され、バケットポリシーで SSL が強制されていますね…!
おわりに
本記事では、cdk-nag による Service Catalog 製品の静的解析方法を紹介しました。
今回、Service Catalog 製品に特化した事例紹介でしたが、既存の CloudFormation テンプレートに対して整備済みの cdk-nag ルールパックで検査したいといった場合にも、同様のテストコードを流用できそうです。
本記事がどなたかのお役になれば幸いです。
参考資料
- AWS Cloud Development Kit と cdk-nag でアプリケーションのセキュリティとコンプライアンスを管理する
- 【AWS】cdk-nagで単体テストのメッセージを整形し、コーディングしながら爆速でセキュリティ遵守する!
- AWS CDKでService Catalogを構築・運用するための9Tips ~事例と共に~
- aws-cdk-lib/assertions: Cannot run testing against ProductStack objects
- AWS CDK API Reference
-
AWS Constructs Library の解説については、AWS Black Belt Online Seminar AWS CDK 概要 (Basic #1)をご参照ください。 ↩
-
CDK のテストについては、デベロッパーガイドに詳細な解説が記載されています。AWS CDK Workshop では、Construct のテストを体感することができます。 ↩
-
フローがややこしくなるため、製品の cdk-nag 以外に関するテストやタグオプションについては、記載対象外としています。 ↩
-
https://aws.amazon.com/jp/blogs/news/manage-application-security-and-compliance-with-the-aws-cloud-development-kit-and-cdk-nag/ ↩
-
前述のサンプルコードでは、
cdk.Aspects.of(app).add(new AwsSolutionsChecks());
の部分で、cdk.App の IConstruct を渡しています。 ↩ -
当チームでは、Service Catalog Portfolio が含まれているスタックのテストを先に実施して、製品テンプレートを生成しています。サンプルコードでは、CdkScStack が「Service Catalog Portfolio が含まれているスタック」に該当します。 ↩
-
currentVersionLocked: false
で製品スナップショットの上書きを許可している場合、バージョンの繰り上げは必須ではありません。今回のサンプルコードのようにcurrentVersionLocked: true
で製品スナップショットの上書きを禁止している場合は、バージョンを繰り上げずに既存のスナップショットを消すという方法でも、後続のテストを実施可能です。 ↩