Flatt Security Blog

株式会社Flatt Securityの公式ブログです。プロダクト開発やプロダクトセキュリティに関する技術的な知見・トレンドを伝える記事を発信しています。

株式会社Flatt Securityの公式ブログです。
プロダクト開発やプロダクトセキュリティに関する技術的な知見・トレンドを伝える記事を発信しています。

AWS Lambdaで秘密情報をセキュアに扱う - アンチパターンとTerraformも用いた推奨例の解説

はじめに

こんにちは。ソフトウェアエンジニアの@kenchan0130です。

AWS Lambdaは関数URLやAPI Gatewayのバックエンド、AWSサービスのイベントをトリガーとしたスクリプト実行など様々な用途で使用されます。 そのため、ユースケースによっては秘密情報を扱いたい場合があります。

この記事では、AWS LambdaでAPIキーなどの秘密情報を安全に扱う方法を解説します。

なお、Flatt SecurityではAWS・GCP・Azureのようなクラウドも対象に含めたセキュリティ診断サービスを提供しています。 是非下記のSmartHR様の事例をご覧ください。

推奨されない方法

秘密情報を安全に取り扱う方法を解説する前に、まずはワークロードによっては推奨されない方法があるため、その方法を2つ紹介します。

AWS Lambdaのソースコードに秘密情報をハードコード

ソースコードに秘密情報をハードコードしてしまうと、ソースコードにアクセスできてしまえば秘密情報が得られてしまうことになります。 また、ソースコードは、しばしばGitなどのバージョン管理システムで管理されることが多く、中央リポジトリが存在するリモートサーバーなどを経由して秘密情報が漏洩してしまうリスクもあります。

そのため、ソースコードに秘密情報をハードコードすることは特段理由がない限りは避けるべきです。

以下は秘密情報をハードコードした際のLambda関数の例です。

import fetch from 'node-fetch';

// 秘密情報のハードコード
const apiToken = "xxxxxxxx";

// AWS Lambda 関数ハンドラー
export const handler = async (event) => {
  const response = await fetch('https://exmaple.com', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${apiToken}`,
    }
  });

  return {
    statusCode: 200,
    body: response.ok ? "Succeeded" : "Failed",
  }
};

AWS Lambdaの環境変数に秘密情報を平文で設定

Webアプリケーションなどでは、しばしば環境変数に秘密情報を設定する手法が取られます。

AWS Lambdaにも同様に環境変数の機能があります。 この環境変数は、AWS上に保存されていますが、デフォルトではAWSマネージドなAWS Key Management Service(以下、KMS)キーで暗号化が行われます。

しかし、関数実行時には復号化された値が環境変数に設定されているため、関数の実装に環境変数が参照できるような脆弱性を作り込んでしまった場合、攻撃者はその脆弱性を突くことで秘密情報を参照できてしまいます。

また、AWS APIのGetFunction はAWS Lambdaの関数に関する情報を返しますが、その中には、復号化された環境変数も含まれています。

開発者や運用者にとっては、関数に関する情報は必要ですが、通常、秘密情報を含んだ環境変数の値を知る必要はありません。 もし、必要な場合でも、限られた人だけが閲覧できるようにすることがほとんどです。

これは、障害や不正利用による被害のリスクを最小限に抑えるために、必要最低限の権限を使用するという考え方であり、「最小権限の原則」と呼ばれ非常に大切です。

AWS Lambdaの環境変数に秘密情報を設定する例

推奨される方法

前述の推奨されない方法に関する問題の解決方法の一つは、AWS KMSキーで秘密情報を暗号化しておき、秘密情報の復号の権限を制限することです。

AWS Lambdaが暗号化した秘密情報を参照するには、何かしらに保存する必要があります。 取り回し易さの観点から、大きく以下の2つが保存場所として選択されることが多いと思います。

  1. AWS Lambdaの環境変数
  2. AWS Systems Manager Parameter Store、またはAWS Secrets Manager、Amazon S3など、AWS KMSの暗号化をサポートしているAWSのサービス

それぞれ、AWS Lambdaの関数URLを実現することを前提に、権限設定や構成がわかりやすいように、Terraformのコードも紹介します。

また、「AWS Systems Manager Parameter Store、またはAWS Secrets Manager、Amazon S3など、AWS KMSの暗号化をサポートしているAWSのサービス」に関しては、今回はAWS Systems Manager Parameter Storeを採用して確認していきます。

保存場所1. AWS Lambdaの環境変数

AWS Lambdaの環境変数にKMSで暗号化した値を設定する場合、関数内で復号する処理を記述することで秘密情報を参照できます。

関数内で復号するには、実行ロールにKMSの鍵を使用して復号するための権限を付与する必要があります。

以下は秘密情報をAWS Lambdaの環境変数に設定した際のLambda関数の例です。

import fetch from 'node-fetch';
import { KMSClient, DecryptCommand } from '@aws-sdk/client-kms'

if (!process.env.AWS_KMS_REGION) {
  throw "AWS_KMS_REGION is required.";
}
if (!process.env.ENCRYPTED_API_TOKEN) {
  throw "ENCRYPTED_API_TOKEN is required.";
}

const kmsClient = new KMSClient({ region: process.env.AWS_KMS_REGION });

const handleError = (fn) => {
  return async (event) => {
    try {
      return fn(event);
    } catch (e) {
      console.log(e);
      return {
        statusCode: 500,
      };
    }
  };
};

// 秘密情報の復号化
const getApiToken = async (encrepted) => {
  const command = new DecryptCommand({
    CiphertextBlob: Buffer.from(encrepted, 'base64'),
  });
  const data = await kmsClient.send(command);
  return new TextDecoder().decode(data.Plaintext);
};

// AWS Lambda 関数ハンドラー
export const handler = handleError(async () => {
  const apiToken = await getApiToken(process.env.ENCRYPTED_API_TOKEN);
  const response = await fetch('https://exmaple.com', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${apiToken}`,
    }
  });

  return {
    statusCode: 200,
    body: response.ok ? "Succeeded" : "Failed",
  };
});

上記の例を構成するTerraformのコード

# variables.tf
data "aws_region" "current" {}
data "aws_caller_identity" "current" {}

variable "api_token" {
  type      = string
  sensitive = true
}

locals {
  function_name = "method1-sample"
}
# kms.tf
resource "aws_kms_key" "sample" {
  description             = "KMS key for ${local.function_name} function"
  deletion_window_in_days = 7
}

resource "aws_kms_alias" "sample" {
  name          = "alias/${local.function_name}"
  target_key_id = aws_kms_key.sample.key_id
}

/**
  * tfstateにplanintextの値が平文で記録されるため、terraform外で暗号化することを推奨
  * そのためにはKMS keyとAWS Lambdaのリソース作成を分けてterraformを実行できるようにする必要がある
  *
  * 今回は簡略化のためterraform内で暗号化している
  */
data "aws_kms_ciphertext" "api_token" {
  key_id    = aws_kms_alias.sample.target_key_id
  plaintext = var.api_token
}
# iam.tf
data "aws_iam_policy_document" "trust_lambda" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
  }
}

# 最小権限の原則に基づいて、権限を絞っている
data "aws_iam_policy_document" "sample_lambda_execution" {
  statement {
    effect = "Allow"
    actions = [
      "logs:CreateLogStream",
      "logs:PutLogEvents",
    ]
    resources = [
      "${aws_cloudwatch_log_group.sample_lambda.arn}:*"
    ]
  }

  # 暗号化した秘密情報を復号化するための権限の付与
  statement {
    effect = "Allow"
    actions = [
      "kms:Decrypt"
    ]
    resources = [
      aws_kms_alias.sample.target_key_arn
    ]
  }
}

resource "aws_iam_role" "sample_lambda_execution" {
  name = "${local.function_name}-lambda-execution"
  assume_role_policy = data.aws_iam_policy_document.trust_lambda.json
}

resource "aws_iam_policy" "sample_lambda_execution" {
  name = "${local.function_name}-lambda-execution"
  policy = data.aws_iam_policy_document.sample_lambda_execution.json
}

resource "aws_iam_role_policy_attachment" "sample_lambda_execution" {
  policy_arn = aws_iam_policy.sample_lambda_execution.arn
  role = aws_iam_role.sample_lambda_execution.name
}
# cloud_watch.tf
# lambdaのリソースを作成すると自動でロググループが作成されるが、terraformの管理化に置くために事前に定義している
resource "aws_cloudwatch_log_group" "sample_lambda" {
  name = "/aws/lambda/${local.function_name}"
}
# lambda.tf
data "archive_file" "sample_lambda" {
  type        = "zip"
  source_dir  = "${path.module}/../../app/${local.function_name}"
  output_path = "${path.module}/../../dist/${local.function_name}.zip"
}

resource "aws_lambda_function" "sample" {
  depends_on = [
    aws_cloudwatch_log_group.sample_lambda
  ]

  filename      = data.archive_file.sample_lambda.output_path
  function_name = local.function_name
  role          = aws_iam_role.sample_lambda_execution.arn
  handler       = "index.handler"

  source_code_hash = data.archive_file.sample_lambda.output_base64sha256

  runtime = "nodejs16.x"

  environment {
    variables = {
      ENCRYPTED_API_TOKEN = data.aws_kms_ciphertext.api_token.ciphertext_blob
      AWS_KMS_REGION      = data.aws_region.current.name
    }
  }
}

resource "aws_lambda_function_url" "sample" {
  function_name      = aws_lambda_function.sample.function_name
  authorization_type = "NONE"
}

注意点 - 環境変数のサービスクオータ

AWS Lambdaの環境変数は4KBのサービスクォータがあります。 そのため、4KBを超える場合は、「AWS Systems Manager Parameter Store、またはAWS Secrets Manager、Amazon S3など、AWS KMSの暗号化をサポートしているAWSのサービス」に秘密情報を保存する方法を選択する必要があります。

注意点 - IaCの差分確認

IaCを実現するツールであるTerraformのapplyと同時にAWS Lambdaの環境変数に暗号化した秘密情報を設定する場合、値は都度暗号化することになります。

この値が差分として検知されてしまうため、差分確認時のノイズになる可能性があります。 そのためAWS Lambdaの環境変数は、インフラ構成処理が終わった後にAWS CLIなどを経由して設定、つまり、インフラ構成とは異なるライフサイクルにすることをお勧めします。

保存場所2. AWS Systems Manager Parameter Store

AWS Systems Manager Parameter Storeに値を設定する場合、関数内でパラメータを取得する処理を記述することで秘密情報を参照できます。

また、関数内で値を取得するには、実行ロールにKMSの鍵を使用して復号するための権限とAWS Systems Manager Parameter Storeの値を取得する権限を付与する必要があります。

以下は秘密情報をAWS Systems Manager Parameter Storeに設定した際のLambda関数の例です。

import fetch from "node-fetch";
import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";

if (!process.env.AWS_SSM_REGION) {
  throw "AWS_SSM_REGION is required.";
}
if (!process.env.API_TOKEN_PARAMETER_NAME) {
  throw "API_TOKEN_PARAMETER_NAME is required.";
}

const ssmClient = new SSMClient({ region: process.env.AWS_SSM_REGION });

const handleError = (fn) => {
  return async (event) => {
    try {
      return fn(event);
    } catch (e) {
      console.log(e);
      return {
        statusCode: 500,
      };
    }
  };
};

// 秘密情報の取得
const getApiToken = async (parameterName) => {
  const command = new GetParameterCommand({
    Name: parameterName,
    WithDecryption: true,
  });

  const response = await ssmClient.send(command);

  if (!response.Parameter?.Value) {
    throw `${parameterName} parameter value could not be obtained.`;
  }

  return response.Parameter.Value;
};

// AWS Lambda 関数ハンドラー
export const handler = handleError(async () => {
  const apiToken = await getApiToken(process.env.API_TOKEN_PARAMETER_NAME);
  const response = await fetch("https://exmaple.com", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${apiToken}`,
    },
  });

  return {
    statusCode: 200,
    body: response.ok ? "Succeeded" : "Failed",
  };
});

上記の例を構成するTerraformのコード

# variables.tf
data "aws_region" "current" {}
data "aws_caller_identity" "current" {}

variable "api_token" {
  type      = string
  sensitive = true
}

locals {
  function_name = "method2-sample"
}
# kms.tf
resource "aws_kms_key" "sample" {
  description             = "KMS key for ${local.function_name} function"
  deletion_window_in_days = 7
}

resource "aws_kms_alias" "sample" {
  name          = "alias/${local.function_name}"
  target_key_id = aws_kms_key.sample.key_id
}
# ssm.tf
/**
  * tfstateにvalueの値が平文で記録されるため、terraform外で値を設定することを推奨
  *
  * 今回は簡略化のためterraform内でvalueを定義している
  */
resource "aws_ssm_parameter" "sample_api_token" {
  name = "/sample-app/lambda/sample-api-token"
  type = "SecureString"
  key_id = aws_kms_alias.sample.target_key_id
  value = var.api_token
}
# iam.tf
data "aws_iam_policy_document" "trust_lambda" {
  statement {
    effect  = "Allow"
    actions = ["sts:AssumeRole"]
    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
  }
}

# 最小権限の原則に基づいて、権限を絞っている
data "aws_iam_policy_document" "sample_lambda_execution" {
  statement {
    effect = "Allow"
    actions = [
      "logs:CreateLogStream",
      "logs:PutLogEvents",
    ]
    resources = [
      "${aws_cloudwatch_log_group.sample_lambda.arn}:*"
    ]
  }

  statement {
    effect = "Allow"
    actions = [
      "ssm:GetParameter*"
    ]
    resources = [
      aws_ssm_parameter.sample_api_token.arn
    ]
  }

  # 暗号化した秘密情報を復号化するための権限の付与
  statement {
    effect = "Allow"
    actions = [
      "kms:Decrypt"
    ]
    resources = [
      aws_kms_alias.sample.target_key_arn
    ]
  }
}

resource "aws_iam_role" "sample_lambda_execution" {
  name               = "${local.function_name}-lambda-execution"
  assume_role_policy = data.aws_iam_policy_document.trust_lambda.json
}

resource "aws_iam_policy" "sample_lambda_execution" {
  name   = "${local.function_name}-lambda-execution"
  policy = data.aws_iam_policy_document.sample_lambda_execution.json
}

resource "aws_iam_role_policy_attachment" "sample_lambda_execution" {
  policy_arn = aws_iam_policy.sample_lambda_execution.arn
  role       = aws_iam_role.sample_lambda_execution.name
}
# cloud_watch.tf
# lambdaのリソースを作成すると自動でロググループが作成されるが、terraformの管理化に置くために事前に定義している
resource "aws_cloudwatch_log_group" "sample_lambda" {
  name = "/aws/lambda/${local.function_name}"
}
# lambda.tf
data "archive_file" "sample_lambda" {
  type        = "zip"
  source_dir  = "${path.module}/../../app/${local.function_name}"
  output_path = "${path.module}/../../dist/${local.function_name}.zip"
}

resource "aws_lambda_function" "sample" {
  depends_on = [
    aws_cloudwatch_log_group.sample_lambda
  ]

  filename      = data.archive_file.sample_lambda.output_path
  function_name = local.function_name
  role          = aws_iam_role.sample_lambda_execution.arn
  handler       = "index.handler"

  source_code_hash = data.archive_file.sample_lambda.output_base64sha256

  runtime = "nodejs16.x"

  environment {
    variables = {
      API_TOKEN_PARAMETER_NAME = aws_ssm_parameter.sample_api_token.name
      AWS_SSM_REGION           = data.aws_region.current.name
    }
  }
}

resource "aws_lambda_function_url" "sample" {
  function_name      = aws_lambda_function.sample.function_name
  authorization_type = "NONE"
}

注意点 - IaCのstateファイル

IaCを実現するツールであるTerraformのapplyと同時にAWS Systems Manager Parameter Storeに秘密情報を設定すると、現在の構成を記録しているtfstateファイルに秘密情報が含まれてしまいます。

そのため、tfstateを安全に取り扱う、または、Terraformのapplyとは異なるライフサイクルでAWS Systems Manager Parameter Storeに値を設定する機構を用意するなどの対応が必要です。

まとめ

この記事では、AWS Lambdaで秘密情報を安全に扱う方法として、

  • AWS Lambdaの環境変数に暗号化した値を設定し、関数内で値を復号化する手法
  • AWS Lambdaの環境変数にAWS Systems Manager Parameter Storeのパラメータ名を設定し、関数内で値を取得する手法

を紹介しました。

また、「AWS Lambdaの環境変数にAWS Systems Manager Parameter Storeのパラメータ名を設定し、関数内で値を取得する手法」の実装例では、直接AWS Systems Manager Parameter StoreのAPIを呼び出していましたが、先日リリース されたLambda Extensionを使うことで、API呼び出しのレイテンシーとコストを削減できるようになりました。 実装される際はLambda Extensionの利用も検討してみてください。

Flatt Securityのセキュリティ診断について

冒頭で紹介したSmartHR様の事例のように、Flatt Security では AWS・GCP・Azure診断Firebase診断というサービスにおいて、クラウドサービスの設定不備のみを確認する一般的なクラウドセキュリティ診断だけでなく、アプリケーションの仕様やお客様のクラウドセキュリティに対する懸念事項などを踏まえ、クラウドとアプリケーションを総合的に診断するメニューも提供しています。

セキュリティ診断にご興味のある方は是非「料金を自分で計算できる資料」を公開しているので、ダウンロードしてみてください。

また、Flatt Securityはセキュリティに関する様々な発信を行っています。 最新情報を見逃さないよう、公式Twitterのフォローをぜひお願いします!

twitter.com

ここまでお読みいただきありがとうございました。