LoginSignup
14
9

AWS WAFのログから特定Cookieの機密情報をKinesis Data Firehose + Lambda関数でマスクしてS3に保存するIaCをCDKで実装する

Last updated at Posted at 2023-12-26

AWS CDK Advent Calendar 2023の記事です。終わっても空いてた枠にシュルっと入りました。

NewsPicksのSREチームでセキュリティ周りも担当しているあんどぅといいます。

AWS WAFを利用する中で少しニッチな、しかし重要なロギングについてCDKで実装した例をご紹介します。
ボリュームが多いので、同じような構成でCDKを利用して実装する方の参考になれば幸いです。

WAFのログは重要。プライバシー保護も重要

同じアドカレ内でも書かれていますが、WAFを運用していると誤遮断がつきものなので、WAFのログは非常に重要です。

WAFのルールを適用する際は、COUNTモードで適用して一定期間運用した後に、不正と判断されたリクエストのログを確認して、問題ない検知をしていることを確認してから、BLOCKモードでルールを適用するのが定石です。

なのでWAFのログは見る必要があります。WAFのログで何が見られるかというと

WAFがトラフィックを許可および遮断したコンテキスト情報はもちろん、httpRequest というフィールドではリクエストのメタ情報として全てのヘッダーを確認することができます。
ヘッダーということは、以下のようなものもログに記載されるということです。

  • Authorization ヘッダー : APIのBearerトークンが記載されています
  • X-XSRF-TOKEN ヘッダー : Spring SecurityのCSRF対策トークンが記載されています
  • Cookie ヘッダー : 一部のCookieにユーザー固有のセッション情報が保存されています。またXSRF-TOKENはCookieにも設定されています(Double Submit Cookie パターン)

もちろんWAFのログにアクセスできるエンジニアは限定されるのですが、SREチームの業務としてWAFの運用を行うためには不要な情報です。限られたエンジニアであってもリクエストの認証情報にアクセスできる状況はサイバー攻撃のリスクをゼロにすることはできず、ユーザーを盗聴・改ざん・なりすまし・否認の脅威にさらしてしまいかねません。

ということで、業務上確認する必要のない機密情報はWAFのログに記録しないようにする方法を調べるのでした。

WAFのログをマスキングする方法の検討

AWS WAFではログのマスキング(RedactedFields)がサポートされています。
CloudFormationでの設定方法と動作イメージは以下の記事がわかりやすいですね。

ただし、RedactedFieldsは [URI パス]、[クエリ文字列]、[単一ヘッダー]、および [HTTP メソッド] の指定となっています。Authorizationヘッダーならこれで良いのですが、Cookieも一つのヘッダーになるので、全てのCookieをマスクするかしないかしか選べません。

例えばユーザーからお問い合わせがあった場合、WAFのログで遮断されているかどうかを調べるという運用が発生しますが、Cookieにユーザー識別子を保存しているため、Cookieヘッダー全体をマスクしてしまうと個別のユーザーの追跡が難しくなり都合が悪いです。

そのため「特定Cookieの機密情報をマスクする」処理を自前で構築する必要が出てきました。
AWS WAFのログはKinesis Data Firehose -> S3に保存することが可能ですが、Kinesis Data FirehoseではLambda関数によるデータ変換に対応しています。

つまり、AWS WAFのログがKinesis Data Firehoseを介してS3へ保存される際に、Lambda関数で変換処理を行って特定のCookieをマスクしてあげれば良いことになります。

AWSサービスで実現するための構成および本記事の解説範囲としては以下となります。

image.png

  • WAFのログをAWS WAF -> Kinesis Data Firehose -> (Lambda関数でマスキング) -> (parquet形式に変換) -> S3へ保存します
  • S3に保存されたログを効率よくAthenaでクエリして確認するために、JSON形式から列指向のparquet形式に変換し、運用時にAthenaからも利用するGlueデータベース・テーブルを作成します

CDKの実装

目標としては上述のAWSリソース一式が、CDKの既存スタックから以下3行で作成できるL3 Constructを作成します。
すでに作成されている、WAFを接続するALBを渡してあげればよい状態ですね。
スタック内でALBを作成していない場合や手動作成の場合でもALBをLookupして接続することもできます。

some-stack.ts
    new MaskedLogWebAcl(this, "MaskedLogWebAcl", {
        loadBalancer: loadBalancer, // IApplicationLoadBalancer
    });

    // このように、手動作成されたALBをLookupして渡すこともできる
    new MaskedLogWebAcl(this, "MaskedLogWebAcl", {
        loadBalancer: elbv2.ApplicationLoadBalancer.fromLookup(this, "ALB", {
            loadBalancerArn:
                "arn:aws:elasticloadbalancing:us-east-2:123456789012:loadbalancer/app/my-load-balancer/1234567890123456",
        }),
    });

このL3 Constructを作成するためのディレクトリ・モジュール構成は以下としました。

lib/waf/
  └ log-bucket.ts                        --- ①S3バケットのL2拡張Construct
  └ log-data-catalog.ts                  --- ②Glueデータベース・テーブルのL2拡張Construct
  └ log-delivery-stream.ts               --- ④Kinesis Data FirehoseのL2拡張Construct
  └ log-transform-function.handler.ts    --- ③ログのマスキングを行うLambda関数のコード
  └ log-transform-function.ts            --- ③Lambda関数のL2拡張Construct
  └ masked-log-web-acl.ts                --- ⑤全体を作成するL3 Construct

①〜⑤の順番で実装を解説します

image.png

①S3バケットのL2拡張Construct

あまり大したことはしていませんが、WAFログを保存するバケットとして適当な設定をしておきます。
サービス次第では結構なログ容量になるため、ライフサイクルルールは30日で低頻度アクセスクラスに移動して180日で削除、としています。

log-bucket.ts
import { Duration, RemovalPolicy, Tags } from "aws-cdk-lib";
import {
    BlockPublicAccess,
    Bucket,
    BucketAccessControl,
    BucketEncryption,
    ObjectOwnership,
    StorageClass,
} from "aws-cdk-lib/aws-s3";
import { Construct } from "constructs";

export class WafLogBucket extends Bucket {
    constructor(scope: Construct, id: string) {

        super(scope, id, {
            versioned: false,
            removalPolicy: RemovalPolicy.DESTROY,
            autoDeleteObjects: true,
            publicReadAccess: false,
            blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
            encryption: BucketEncryption.S3_MANAGED,
            objectOwnership: ObjectOwnership.BUCKET_OWNER_PREFERRED,
            accessControl: BucketAccessControl.PRIVATE,
            lifecycleRules: [
                {
                    id: "move_IA_after_30d_delete_after_180d",
                    expiration: Duration.days(180),
                    transitions: [{ storageClass: StorageClass.INFREQUENT_ACCESS, transitionAfter: Duration.days(30) }],
                },
            ],
        });
    }
}

②Glueデータベース・テーブルのL2拡張Construct

少しでも楽をするためにGA前ですがaws-glue-alphaのL2 Constructを利用します。
Glueデータベースとテーブルを作っているだけですが、前述のS3バケットをPropsとして受け取って設定しています。

log-data-catalog.ts
import { Column, Database, DataFormat, Schema, Table } from "@aws-cdk/aws-glue-alpha";
import { Bucket } from "aws-cdk-lib/aws-s3";
import { Construct } from "constructs";

export interface WafLogDatabaseProps {
    readonly logBucket: Bucket;
}

export class WafLogDatabase extends Database {
    constructor(scope: Construct, id: string, props: WafLogDatabaseProps) {
        const { prefix, logBucket } = props;
        super(scope, id, {
            databaseName: "waf-log-db",
            locationUri: logBucket.s3UrlForObject(),
        });
    }
}

export interface WafLogTableProps {
    readonly database: Database;
    readonly logBucket: Bucket;
}

export class WafLogTable extends Table {
    constructor(scope: Construct, id: string, props: WafLogTableProps) {
        const { database, logBucket } = props;

        super(scope, id, {
            database: database,
            bucket: logBucket,
            s3Prefix: "firehose/", // 後述のKinesis Data Firehoseの出力先パスと合わせる
            tableName: "waf-logs",
            dataFormat: DataFormat.PARQUET,
            columns: getColumns(),
            partitionKeys: [ // 今だとPartition Projectionの方がよさそうではある
                { name: "year", type: Schema.STRING },
                { name: "month", type: Schema.STRING },
                { name: "day", type: Schema.STRING },
                { name: "hour", type: Schema.STRING },
            ],
        });
    }
}

// 以下ドキュメントの通りにWAFログ用テーブル定義のDDLをCDKに写経しただけです。
// see https://docs.aws.amazon.com/ja_jp/athena/latest/ug/waf-logs.html#create-waf-table
function getColumns(): Column[] {
    return [
        { name: "timestamp", type: Schema.BIG_INT },
        { name: "formatversion", type: Schema.INTEGER },
        { name: "webaclid", type: Schema.STRING },
        { name: "terminatingruleid", type: Schema.STRING },
        { name: "terminatingruletype", type: Schema.STRING },
        { name: "action", type: Schema.STRING },
        { name: "terminatingrulematchdetails", type: Schema.array(Schema.STRING) },
        { name: "httpsourcename", type: Schema.STRING },
        { name: "httpsourceid", type: Schema.STRING },
        { name: "rulegrouplist", type: Schema.array(Schema.STRING) },
        { name: "ratebasedrulelist", type: Schema.array(Schema.STRING) },
        {
            name: "nonterminatingmatchingrules",
            type: Schema.array(
                Schema.struct([
                    { name: "conditiontype", type: Schema.STRING },
                    { name: "location", type: Schema.STRING },
                    { name: "matcheddata", type: Schema.array(Schema.STRING) },
                ])
            ),
        },
        { name: "requestheadersinserted", type: Schema.STRING },
        { name: "responsecodesent", type: Schema.STRING },
        {
            name: "httprequest",
            type: Schema.struct([
                { name: "clientip", type: Schema.STRING },
                { name: "country", type: Schema.STRING },
                {
                    name: "headers",
                    type: Schema.array(
                        Schema.struct([
                            { name: "name", type: Schema.STRING },
                            { name: "value", type: Schema.STRING },
                        ])
                    ),
                },
                { name: "uri", type: Schema.STRING },
                { name: "args", type: Schema.STRING },
                { name: "httpversion", type: Schema.STRING },
                { name: "httpmethod", type: Schema.STRING },
                { name: "requestid", type: Schema.STRING },
            ]),
        },
        { name: "labels", type: Schema.array(Schema.struct([{ name: "name", type: Schema.STRING }])) },
        {
            name: "captcharesponse",
            type: Schema.struct([
                { name: "responsecode", type: Schema.STRING },
                { name: "solvetimestamp", type: Schema.BIG_INT },
                { name: "failurereason", type: Schema.STRING },
            ]),
        },
    ];
}

③ログのマスキングを行うLambda関数

このLambda関数でマスキングするためにここまで面倒なことをしているといっても過言ではありません

Lambda関数のコード

ヘッダーがCookieの場合に値をマスクするということがやりたいだけなのですが、
Cookieのkey-valueのペアはヘッダーのテキスト内に複数回登場するので、正規表現で置換しています。
(ChatGPTが正規表現の完璧な解説をしてくれましたのでコードコメントに記載しています)
テストコードは割愛しますが、正規表現の置換は実際のログデータのフォーマットからしっかりテストする必要があります。

log-transform-function.handler.ts
interface Event {
    readonly invocationId: string; // ex) invocationIdExample
    readonly deliveryStreamArn: string; // ex) arn:aws:kinesis:EXAMPLE
    readonly region: string; // ex) us-east-1
    readonly records: InputRecord[];
}

interface InputRecord {
    readonly approximateArrivalTimestamp: number; // ex) 1495072949453
    readonly recordId: string; // ex) 49546986683135544286507457936321625675700192471156785154
    readonly data: string; // base64 encoded waf log
}

interface OutputRecord {
    readonly recordId: string;
    readonly result: "Ok" | "Dropped" | "ProcessingFailed";
    readonly data: string; // base64 encoded
}

interface WafLog {
    readonly httpRequest: {
        readonly headers: Header[];
    };
}

interface Header {
    readonly name: string;
    readonly value: string;
}

interface Response {
    readonly records: OutputRecord[];
}

const maskCookieNames = ["xsrf-token", "hoge_user_name"]; // マスク対象のCookie
const re = new RegExp(`(?<=(^|;) *(${maskCookieNames.join("|")})=)([^;]+)`, "gi"); // マスク対象のCookieの値をキャプチャする正規表現
// 正規表現が難しいのでChatGPTの解説を。
// Q. この正規表現の意味を教えてください (?<=(^|;) *(xsrf-token|hoge_user_name)=)([^;]+)
// A.
// この正規表現は、xsrf-token または hoge_user_name に続く = の後の文字列をキャプチャします。具体的には以下のような動作をします。
// ・ (?<=...) は後方参照の肯定的なルックビハインドを表します。これは、... のパターンに一致する文字列の後ろにある位置にマッチします。
// ・ ( ^ |; ) は行の開始またはセミコロン(;)にマッチします。
// ・ * は前の要素が0回以上繰り返すことを表します。ここではスペースの0回以上の繰り返しにマッチします。
// ・ (xsrf-token|hoge_user_name) は xsrf-token または hoge_user_name のいずれかにマッチします。
// ・ = は等号にマッチします。
// ・ ([^;]+) はセミコロン(;)以外の1文字以上の連続にマッチします。これがキャプチャグループで、マッチした文字列を取り出すことができます。
// したがって、この正規表現は、xsrf-token または hoge_user_name に続く = の後の、セミコロンまでの文字列を取り出します。
// 例えば、xsrf-token=abc; hoge_user_name=def の場合、abc と def が取り出されます。

function maskCookieValue(cookieHeader: Header): Header {
    return {
        ...cookieHeader,
        value: cookieHeader.value.replace(re, "MASKED"),
    };
}

function transform(record: InputRecord): OutputRecord {
    const wafLog = JSON.parse(Buffer.from(record.data, "base64").toString("utf8")) as WafLog;

    const headers = wafLog.httpRequest.headers.map((header) => {
        if (header.name.toLowerCase() === "cookie") {
            return maskCookieValue(header); // ヘッダーがCookieの場合マスクする
        } else {
            return header;
        }
    });

    const newWafLog: WafLog = {
        ...wafLog,
        httpRequest: { ...wafLog.httpRequest, headers },
    };

    return {
        recordId: record.recordId,
        result: "Ok",
        data: Buffer.from(JSON.stringify(newWafLog)).toString("base64"),
    };
}

export async function handler(event: Event): Promise<Response> {
    return {
        records: event.records.map(transform),
    };
}

Lambda関数のL2 Construct

CDKのNodejsFunctionだと本当に楽で、論理IDをhandlerにしておけばファイル名のルールで対象のコードをコンパイル・デプロイしてくれます

log-transform-function.ts
import { Duration } from "aws-cdk-lib";
import { Runtime } from "aws-cdk-lib/aws-lambda";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { RetentionDays } from "aws-cdk-lib/aws-logs";
import { Construct } from "constructs";

export class WafLogTransformFunction extends NodejsFunction {
    constructor(scope: Construct) {
        super(scope, "handler", {
            runtime: Runtime.NODEJS_18_X,
            memorySize: 128,
            timeout: Duration.seconds(180),
            logRetention: RetentionDays.TWO_WEEKS,
        });
    }
}

④Kinesis Data FirehoseのL2拡張Construct

こちらも少しでも楽をするためにGA前ですがaws-kinesisfirehose-alphaのL2 Constructを利用します。
前述で作成したS3バケット、ログ変換Lambda関数、Glueデータベース、Glueテーブルを受け取って、Kinesis Data Firehose用ロールからの書き込みや呼び出し権限をgrantで設定しています。
データ変換を行うKinesis Data Firehoseを作成する際に、 glue:GetTableVersions を全リソースに許可する必要があり、盲点でした(怒られました)。

L2 ConstructのDeliveryStreamはIGrantableを実装しているのでIAMロールを作成する必要はないのですが、L2 Constructで作成できないデータ変換の設定をL1 Constructで上書きする際にロールを指定する必要があったため、ロールを作成して利用しています。

log-delivery-stream.ts
import { Database, Table } from "@aws-cdk/aws-glue-alpha";
import { DeliveryStream } from "@aws-cdk/aws-kinesisfirehose-alpha";
import { Compression, S3Bucket } from "@aws-cdk/aws-kinesisfirehose-destinations-alpha";
import { Duration, Size } from "aws-cdk-lib";
import { PolicyStatement, Role, ServicePrincipal } from "aws-cdk-lib/aws-iam";
import { CfnDeliveryStream } from "aws-cdk-lib/aws-kinesisfirehose";
import { IFunction } from "aws-cdk-lib/aws-lambda";
import { Bucket } from "aws-cdk-lib/aws-s3";
import { Construct } from "constructs";

export interface WafLogDeliveryStreamProps {
    readonly logBucket: Bucket;
    readonly logTransformFunction: IFunction;
    readonly logDatabase: Database;
    readonly logTable: Table;
}

export class WafLogDeliveryStream extends DeliveryStream {
    constructor(scope: Construct, id: string, props: WafLogDeliveryStreamProps) {
        const { prefix, logBucket, logTransformFunction, logDatabase, logTable } = props;

        const role = new Role(scope, "WafLogDeliveryStreamRole", {
            assumedBy: new ServicePrincipal("firehose.amazonaws.com"),
        });
        role.addToPolicy(new PolicyStatement({ actions: ["glue:GetTableVersions"], resources: ["*"] }));
        logBucket.grantReadWrite(role); // Kinesis Data FirehoseからS3への書き込み権限を付与
        logTransformFunction.grantInvoke(role); // Kinesis Data FirehoseからLambda関数を呼び出す権限を付与
        logTable.grantReadWrite(role); // Kinesis Data FirehoseからGlue Tableへの書き込み権限を付与

        super(scope, id, {
            // AWS WAFからログを送信するには"aws-waf-logs-"から始まる名称のKinesis Data Firehoseが必要
            deliveryStreamName: `aws-waf-logs-${id}`,
            destinations: [
                new S3Bucket(logBucket, {
                    bufferingInterval: Duration.seconds(60),
                    bufferingSize: Size.mebibytes(128),
                    compression: Compression.of("UNCOMPRESSED"),
                    dataOutputPrefix: `firehose/year=!{timestamp:yyyy}/month=!{timestamp:MM}/day=!{timestamp:dd}/hour=!{timestamp:HH}/`,
                    errorOutputPrefix:
                        "firehose-error/year=!{timestamp:yyyy}/month=!{timestamp:MM}/day=!{timestamp:dd}/hour=!{timestamp:HH}/!{firehose:error-output-type}/",
                    role: role,
                }),
            ],
            role: role,
        });

        // Kinesis Data FirehoseのLambdaによるデータ変換はL2 ConstructでサポートされていないためCfnDeliveryStreamを上書きする
        const cfnWafLogDeliveryStream = this.node.defaultChild as CfnDeliveryStream;
        const processingConfiguration: CfnDeliveryStream.ProcessingConfigurationProperty = {
            enabled: true,
            processors: [
                {
                    type: "Lambda",
                    parameters: [
                        { parameterName: "BufferIntervalInSeconds", parameterValue: "60" },
                        { parameterName: "BufferSizeInMBs", parameterValue: "3" },
                        { parameterName: "LambdaArn", parameterValue: logTransformFunction.functionArn },
                        { parameterName: "NumberOfRetries", parameterValue: "3" },
                    ],
                },
            ],
        };
        cfnWafLogDeliveryStream.addPropertyOverride(
            "ExtendedS3DestinationConfiguration.ProcessingConfiguration",
            processingConfiguration
        );
        const dataFormatConversionConfiguration: CfnDeliveryStream.DataFormatConversionConfigurationProperty = {
            enabled: true,
            inputFormatConfiguration: {
                deserializer: { openXJsonSerDe: { convertDotsInJsonKeysToUnderscores: false } },
            },
            outputFormatConfiguration: { serializer: { parquetSerDe: { compression: "SNAPPY" } } },
            schemaConfiguration: {
                databaseName: logDatabase.databaseName,
                tableName: logTable.tableName,
                roleArn: role.roleArn,
                versionId: "LATEST",
            },
        };
        cfnWafLogDeliveryStream.addPropertyOverride(
            "ExtendedS3DestinationConfiguration.DataFormatConversionConfiguration",
            dataFormatConversionConfiguration
        );
    }
}

⑤全体を作成するL3 Construct

最後にWAFのWebACLを作成しつつ関連リソースを取りまとめるL3 Constructです。
WAFルールは今回解説したいポイントではないので1つのみ入れていますが、実用ではもっと多くなるので外部化するなどメンテナンスしやすくした方がよいと思います。
CfnLoggingConfigurationでKinesis Data Firehoseへのログ連携を行いますが、ヘッダー単位でマスクしたいものはredactedFieldsに追加しています。

masked-log-web-acl.ts
import { ApplicationLoadBalancer } from "aws-cdk-lib/aws-elasticloadbalancingv2";
import { CfnLoggingConfiguration, CfnWebACL, CfnWebACLAssociation } from "aws-cdk-lib/aws-wafv2";
import { Construct } from "constructs";

import { WafLogBucket } from "./log-bucket";
import { WafLogDeliveryStream } from "./log-delivery-stream";
import { WafLogDatabase, WafLogTable } from "./log-data-catalog";
import { WafLogTransformFunction } from "./log-transform-function";

export interface MaskedLogWebAclProps {
    readonly loadBalancer: ApplicationLoadBalancer;
}

export class MaskedLogWebAcl extends Construct {
    readonly webAclArn: string;
    constructor(scope: Construct, id: string, props: MaskedLogWebAclProps) {
        super(scope, id);

        const { loadBalancer } = props;

        const webAcl = new CfnWebACL(this, "WebAcl", {
            visibilityConfig: {
                cloudWatchMetricsEnabled: true,
                metricName: "waf",
                sampledRequestsEnabled: true,
            },
            scope: "REGIONAL",
            defaultAction: {
                allow: {},
            },
            rules: [
                {
                    priority: 0,
                    name: "AWS-AWSManagedRulesAmazonIpReputationList",
                    statement: {
                        managedRuleGroupStatement: { vendorName: "AWS", name: "AWSManagedRulesAmazonIpReputationList" },
                    },
                    overrideAction: { count: {} },
                    visibilityConfig: {
                        cloudWatchMetricsEnabled: true,
                        metricName: "AWS-AWSManagedRulesAmazonIpReputationList",
                        sampledRequestsEnabled: true,
                    },
                },
            ],
        });

        this.webAclArn = webAcl.attrArn;

        const logBucket = new WafLogBucket(this, "WafLogBucket");
        const logTransformFunction = new WafLogTransformFunction(this, "WafLogTransformFunction", props);
        const logDatabase = new WafLogDatabase(this, "WafLogDatabase", { logBucket: logBucket });
        const logTable = new WafLogTable(this, "WafLogTable", { database: logDatabase, logBucket: logBucket });
        const logDeliveryStream = new WafLogDeliveryStream(this, "LogDeliveryStream", {
            logBucket,
            logTransformFunction,
            logDatabase,
            logTable,
        });

        new CfnLoggingConfiguration(this, "LoggingConfiguration", {
            resourceArn: webAcl.attrArn,
            logDestinationConfigs: [logDeliveryStream.deliveryStreamArn],
            redactedFields: [{ singleHeader: { name: "authorization" } }, { singleHeader: { name: "x-xsrf-token" } }],
        });

        new CfnWebACLAssociation(this, "WebAclAssociation", {
            resourceArn: loadBalancer.loadBalancerArn,
            webAclArn: webAcl.attrArn,
        });
    }
}

このConstructをスタックに組み込めば該当のリソース一式がデプロイされ、ALBにWAFのWebACLが統合されます!
ログがS3に保存された時点からAthenaでクエリできるテーブルがあるので、パーティションをロードしてすぐに利用が可能です!(パーティション射影にもそのうち対応したい)

まとめ

  • AWS WAFのログは重要ですが、ユーザーのプライバシー保護のためにマスクすべき情報もあります
  • AWS WAFのログ設定のredactedFieldsはヘッダー単位でしかマスクできません
  • 特定のCookieをログからマスクしたい場合はKinesis Data Firehose + Lambda関数を利用できます
  • Glueテーブルなど、一見関係なさそうなリソースも必要になりますが、CDKで一発デプロイできました
  • Kinesis Data FirehoseのL2 ConstructはGAしていないので、データ変換の設定を入れるのになかなか苦戦しました

こういったLambda関数を含むAWSサービスのソリューションを一つのConstructにしてLambdaのコードと一緒にデプロイできるCDKは素晴らしいですね。
今後複数のサービスで利用することも検討中なので、L3 Constructとして洗練させていきたいと思いました!

Enjoy CDK Life!

14
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
14
9