未使用アクセスキーを無効化するAWS Systems Manager カスタムドキュメントを作成し、AWS Configで自動修復してみた

未使用アクセスキーを無効化するAWS Systems Manager カスタムドキュメントを作成し、AWS Configで自動修復してみた

Clock Icon2025.04.04

はじめに

AWS Configのマネージドルールには以下のルールが存在します。

「AWSConfigRemediation-RevokeUnusedIAMUserCredentials」というSSMドキュメントは、指定した日数利用されていないコンソールパスワードとアクセスキーを無効化(非アクティブ化)します。

https://docs.aws.amazon.com/ja_jp/systems-manager-automation-runbooks/latest/userguide/automation-aws-revoke-iam-user.html

このSSMドキュメントをAWS Configの修復アクションとして設定することで、非準拠リソースを自動的に修復できます。この実装方法については、以下のブログ記事で詳しく解説されています。

しかし、このマネージドSSMドキュメントには一つの課題があります。
アクセスキーの無効化だけでなく、コンソールアクセスも同時に無効化してしまう点です。

未使用のアクセスキーは無効化したいが、コンソールアクセスは維持したいというケースもあります。
この要件を満たすためには、カスタムSSMドキュメントを作成する必要があります。

本記事では、「AWSConfigRemediation-RevokeUnusedIAMUserCredentials」をベースに、アクセスキーのみを無効化し、コンソールアクセスには影響を与えないカスタムSSMドキュメントの作成方法を解説します。

IAMロール作成

Configルールの修復アクションに設定するIAMロール用のポリシーの作成を行います。

「AWSConfigRemediation-RevokeUnusedIAMUserCredentials」に必要な権限はAWSドキュメントに記載されています。以下のポリシーを作成します。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "config:ListDiscoveredResources",
                "ssm:GetAutomationExecution",
                "iam:DeleteAccessKey",
                "ssm:StartAutomationExecution",
                "iam:GetAccessKeyLastUsed",
                "iam:UpdateAccessKey",
                "iam:GetUser",
                "iam:GetLoginProfile",
                "iam:DeleteLoginProfile",
                "iam:ListAccessKeys"
            ],
            "Resource": "*"
        }
    ]
}

IAMロールの信頼ポリシーは以下の通りです。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "",
            "Effect": "Allow",
            "Principal": {
                "Service": "ssm.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}

https://docs.aws.amazon.com/ja_jp/systems-manager-automation-runbooks/latest/userguide/automation-aws-revoke-iam-user.html

カスタムSSMドキュメント作成

カスタムSSMドキュメントを作成します。

「AWSConfigRemediation-RevokeUnusedIAMUserCredentials」から[アクション]で[ドキュメントのクローン作成]をします。

cm-hirai-screenshot 2025-03-21 16.01.38

もともとの「AWSConfigRemediation-RevokeUnusedIAMUserCredentials」コード(最新バージョン5)は以下の通りです。

(クリックで展開)
schemaVersion: "0.3"
description: |
   ### Document Name - AWSConfigRemediation-RevokeUnusedIAMUserCredentials

   ## What does this document do?
   This document revokes unused IAM passwords and active access keys. This document will deactivate expired access keys by using the [UpdateAccessKey API](https://docs.aws.amazon.com/IAM/latest/APIReference/API_UpdateAccessKey.html) and delete expired login profiles by using the [DeleteLoginProfile API](https://docs.aws.amazon.com/IAM/latest/APIReference/API_DeleteLoginProfile.html). Please note, this automation document requires AWS Config to be enabled.

   ## Input Parameters
   * AutomationAssumeRole: (Required) The ARN of the role that allows Automation to perform the actions on your behalf.
   * IAMResourceId: (Required) IAM resource unique identifier.
   * MaxCredentialUsageAge: (Required) Maximum number of days within which a credential must be used. The default value is 90 days.

   ## Output Parameters
   * RevokeUnusedIAMUserCredentialsAndVerify.Output - Success message or failure Exception.

assumeRole: "{{ AutomationAssumeRole }}"
parameters:
  AutomationAssumeRole:
    type: AWS::IAM::Role::Arn
    description: (Required) The ARN of the role that allows Automation to perform the actions on your behalf.
  IAMResourceId:
    type: String
    description: (Required) IAM resource unique identifier.
    allowedPattern: ^[\w+=,.@_-]{1,128}$
  MaxCredentialUsageAge:
    type: String
    description: (Required) Maximum number of days within which a credential must be used. The default value is 90 days.
    allowedPattern: ^(\d|[1-9]\d{1,3}|10000)$
    default: "90"
outputs:
  - RevokeUnusedIAMUserCredentialsAndVerify.Output
mainSteps:
  - name: RevokeUnusedIAMUserCredentialsAndVerify
    action: aws:executeScript
    timeoutSeconds: 600
    isEnd: true
    description: |
      ## RevokeUnusedIAMUserCredentialsAndVerify
      This step deactivates expired IAM User access keys, deletes expired login profiles and verifies credentials were revoked
      ## Outputs
      * Output: Success message or failure Exception.
    inputs:
      Runtime: python3.11
      Handler: unused_iam_credentials_handler
      InputPayload:
        IAMResourceId: "{{ IAMResourceId }}"
        MaxCredentialUsageAge: "{{ MaxCredentialUsageAge }}"
      Script: |-
        import boto3
        from datetime import datetime
        from datetime import timedelta

        iam_client = boto3.client("iam")
        config_client = boto3.client("config")

        responses = {}
        responses["DeactivateUnusedKeysResponse"] = []

        def list_access_keys(user_name):
          return iam_client.list_access_keys(UserName=user_name).get("AccessKeyMetadata")

        def deactivate_key(user_name, access_key):
          responses["DeactivateUnusedKeysResponse"].append({"AccessKeyId": access_key, "Response": iam_client.update_access_key(UserName=user_name, AccessKeyId=access_key, Status="Inactive")})

        def deactivate_unused_keys(access_keys, max_credential_usage_age, user_name):
          for key in access_keys:
            last_used = iam_client.get_access_key_last_used(AccessKeyId=key.get("AccessKeyId")).get("AccessKeyLastUsed")
            if last_used.get("LastUsedDate"):
              last_used_date = last_used.get("LastUsedDate").replace(tzinfo=None)
              last_used_days = (datetime.now() - last_used_date).days
              if last_used_days >= max_credential_usage_age:
                deactivate_key(user_name, key.get("AccessKeyId"))
            else:
              create_date = key.get("CreateDate").replace(tzinfo=None)
              days_since_creation = (datetime.now() - create_date).days
              if days_since_creation >= max_credential_usage_age:
                deactivate_key(user_name, key.get("AccessKeyId"))

        def get_login_profile(user_name):
          try:
            return iam_client.get_login_profile(UserName=user_name)["LoginProfile"]
          except iam_client.exceptions.NoSuchEntityException:
            return False

        def delete_unused_password(user_name, max_credential_usage_age):
          user = iam_client.get_user(UserName=user_name).get("User")
          password_last_used_days = 0
          login_profile = get_login_profile(user_name)
          if login_profile and user.get("PasswordLastUsed"):
            password_last_used = user.get("PasswordLastUsed").replace(tzinfo=None)
            password_last_used_days = (datetime.now() - password_last_used).days
          elif login_profile and not user.get("PasswordLastUsed"):
            password_creation_date = login_profile.get("CreateDate").replace(tzinfo=None)
            password_last_used_days = (datetime.now() - password_creation_date).days
          if password_last_used_days >= max_credential_usage_age:
            responses["DeleteUnusedPasswordResponse"] = iam_client.delete_login_profile(UserName=user_name)

        def verify_expired_credentials_revoked(responses, user_name):
          if responses.get("DeactivateUnusedKeysResponse"):
            for key in responses.get("DeactivateUnusedKeysResponse"):
              key_data = next(filter(lambda x: x.get("AccessKeyId") == key.get("AccessKeyId"), list_access_keys(user_name)))
              if key_data.get("Status") != "Inactive":
                error_message = "VERIFICATION FAILED. ACCESS KEY {} NOT DEACTIVATED".format(key_data.get("AccessKeyId"))
                raise Exception(error_message)
          if responses.get("DeleteUnusedPasswordResponse"):
            try:
              iam_client.get_login_profile(UserName=user_name)
              error_message = "VERIFICATION FAILED. IAM USER {} LOGIN PROFILE NOT DELETED".format(user_name)
              raise Exception(error_message)
            except iam_client.exceptions.NoSuchEntityException:
              pass
          return {
              "output": "Verification of unused IAM User credentials is successful.",
              "http_responses": responses
          }

        def get_user_name(resource_id):
          list_discovered_resources_response = config_client.list_discovered_resources(
              resourceType='AWS::IAM::User',
              resourceIds=[resource_id]
          )
          resource_name = list_discovered_resources_response.get("resourceIdentifiers")[0].get("resourceName")
          return resource_name

        def unused_iam_credentials_handler(event, context):
          iam_resource_id = event.get("IAMResourceId")
          user_name = get_user_name(iam_resource_id)

          max_credential_usage_age = int(event.get("MaxCredentialUsageAge"))

          access_keys = list_access_keys(user_name)
          unused_keys = deactivate_unused_keys(access_keys, max_credential_usage_age, user_name)

          delete_unused_password(user_name, max_credential_usage_age)

          return verify_expired_credentials_revoked(responses, user_name)
    outputs:
      - Name: Output
        Selector: $.Payload
        Type: StringMap

そのコードからdescriptionとpythonコード部分を修正したのが、以下のコードです。

schemaVersion: '0.3'
description: |
  ### Document Name - AWSConfigRemediation-RevokeUnusedIAMUserCredentials

  ## What does this document do?
  This document revokes unused IAM active access keys only. This document will deactivate expired access keys by using the [UpdateAccessKey API](https://docs.aws.amazon.com/IAM/latest/APIReference/API_UpdateAccessKey.html). Console access (login profiles) will not be affected. Please note, this automation document requires AWS Config to be enabled.

  ## Input Parameters
  * AutomationAssumeRole: (Required) The ARN of the role that allows Automation to perform the actions on your behalf.
  * IAMResourceId: (Required) IAM resource unique identifier.
  * MaxCredentialUsageAge: (Required) Maximum number of days within which a credential must be used. The default value is 90 days.

  ## Output Parameters
  * RevokeUnusedIAMUserCredentialsAndVerify.Output - Success message or failure Exception.
assumeRole: '{{ AutomationAssumeRole }}'
parameters:
  AutomationAssumeRole:
    type: AWS::IAM::Role::Arn
    description: (Required) The ARN of the role that allows Automation to perform the actions on your behalf.
  IAMResourceId:
    type: String
    description: (Required) IAM resource unique identifier.
    allowedPattern: ^[\w+=,.@_-]{1,128}$
  MaxCredentialUsageAge:
    type: String
    description: (Required) Maximum number of days within which a credential must be used. The default value is 90 days.
    allowedPattern: ^(\d|[1-9]\d{1,3}|10000)$
    default: '90'
mainSteps:
  - description: |
      ## RevokeUnusedIAMUserCredentialsAndVerify
      This step deactivates expired IAM User access keys and verifies credentials were revoked
      ## Outputs
      * Output: Success message or failure Exception.
    name: RevokeUnusedIAMUserCredentialsAndVerify
    action: aws:executeScript
    timeoutSeconds: 600
    isEnd: true
    inputs:
      Runtime: python3.11
      Handler: unused_iam_credentials_handler
      InputPayload:
        IAMResourceId: '{{ IAMResourceId }}'
        MaxCredentialUsageAge: '{{ MaxCredentialUsageAge }}'
      Script: |-
        import boto3
        from datetime import datetime
        from datetime import timedelta

        iam_client = boto3.client("iam")
        config_client = boto3.client("config")

        responses = {}
        responses["DeactivateUnusedKeysResponse"] = []

        def list_access_keys(user_name):
          return iam_client.list_access_keys(UserName=user_name).get("AccessKeyMetadata")

        def deactivate_key(user_name, access_key):
          responses["DeactivateUnusedKeysResponse"].append({"AccessKeyId": access_key, "Response": iam_client.update_access_key(UserName=user_name, AccessKeyId=access_key, Status="Inactive")})

        def deactivate_unused_keys(access_keys, max_credential_usage_age, user_name):
          for key in access_keys:
            last_used = iam_client.get_access_key_last_used(AccessKeyId=key.get("AccessKeyId")).get("AccessKeyLastUsed")
            if last_used.get("LastUsedDate"):
              last_used_date = last_used.get("LastUsedDate").replace(tzinfo=None)
              last_used_days = (datetime.now() - last_used_date).days
              if last_used_days >= max_credential_usage_age:
                deactivate_key(user_name, key.get("AccessKeyId"))
            else:
              create_date = key.get("CreateDate").replace(tzinfo=None)
              days_since_creation = (datetime.now() - create_date).days
              if days_since_creation >= max_credential_usage_age:
                deactivate_key(user_name, key.get("AccessKeyId"))

        def get_login_profile(user_name):
          try:
            return iam_client.get_login_profile(UserName=user_name)["LoginProfile"]
          except iam_client.exceptions.NoSuchEntityException:
            return False

        def delete_unused_password(user_name, max_credential_usage_age):
          user = iam_client.get_user(UserName=user_name).get("User")
          password_last_used_days = 0
          login_profile = get_login_profile(user_name)
          if login_profile and user.get("PasswordLastUsed"):
            password_last_used = user.get("PasswordLastUsed").replace(tzinfo=None)
            password_last_used_days = (datetime.now() - password_last_used).days
          elif login_profile and not user.get("PasswordLastUsed"):
            password_creation_date = login_profile.get("CreateDate").replace(tzinfo=None)
            password_last_used_days = (datetime.now() - password_creation_date).days
          if password_last_used_days >= max_credential_usage_age:
            responses["DeleteUnusedPasswordResponse"] = iam_client.delete_login_profile(UserName=user_name)

        def verify_expired_credentials_revoked(responses, user_name):
          if responses.get("DeactivateUnusedKeysResponse"):
            for key in responses.get("DeactivateUnusedKeysResponse"):
              key_data = next(filter(lambda x: x.get("AccessKeyId") == key.get("AccessKeyId"), list_access_keys(user_name)))
              if key_data.get("Status") != "Inactive":
                error_message = "VERIFICATION FAILED. ACCESS KEY {} NOT DEACTIVATED".format(key_data.get("AccessKeyId"))
                raise Exception(error_message)
          if responses.get("DeleteUnusedPasswordResponse"):
            try:
              iam_client.get_login_profile(UserName=user_name)
              error_message = "VERIFICATION FAILED. IAM USER {} LOGIN PROFILE NOT DELETED".format(user_name)
              raise Exception(error_message)
            except iam_client.exceptions.NoSuchEntityException:
              pass
          return {
              "output": "Verification of unused IAM User credentials is successful.",
              "http_responses": responses
          }

        def get_user_name(resource_id):
          list_discovered_resources_response = config_client.list_discovered_resources(
              resourceType='AWS::IAM::User',
              resourceIds=[resource_id]
          )
          resource_name = list_discovered_resources_response.get("resourceIdentifiers")[0].get("resourceName")
          return resource_name

        def unused_iam_credentials_handler(event, context):
          iam_resource_id = event.get("IAMResourceId")
          user_name = get_user_name(iam_resource_id)

          max_credential_usage_age = int(event.get("MaxCredentialUsageAge"))

          access_keys = list_access_keys(user_name)
          unused_keys = deactivate_unused_keys(access_keys, max_credential_usage_age, user_name)

          # delete_unused_password(user_name, max_credential_usage_age)

          return verify_expired_credentials_revoked(responses, user_name)
    outputs:
      - Name: Output
        Selector: $.Payload
        Type: StringMap
outputs:
  - RevokeUnusedIAMUserCredentialsAndVerify.Output

Pythonのコードでは、コンソールを無効化する以下の関数をコメントアウトした修正のみです。

          # delete_unused_password(user_name, max_credential_usage_age)

これによって、今回の目的である、非準拠となったIAMユーザーに対して、アクセスキーは無効化したいが、コンソールは無効化しないことが可能です。

ドキュメント名は、Custom-AWSConfigRemediation-DeactivateUnusedIAMAccessKeysとしてカスタムSSMドキュメントの作成は完了です。

Configルール作成

Configルールは、今回iam-user-unused-credentials-checkルールを利用して作成します。

cm-hirai-screenshot 2025-03-21 15.55.45

頻度は、最長間隔の24時間に一回評価するよう設定します。

cm-hirai-screenshot 2025-03-21 15.57.11
maxCredentialUsageAgeは、1日にします。

続いて修復アクションを以下の通り設定します。

  • 修復アクション:Custom-AWSConfigRemediation-DeactivateUnusedIAMAccessKeys
  • リソースIDパラメータ:IAMResourceId
  • AutomationAssumeRole:作成したIAMロールのARN
  • MaxCredentialUsageAge:1

cm-hirai-screenshot 2025-03-21 16.14.18

試してみる

Config ルールのページの[アクション]から[再評価]すると、非準拠のIAMユーザーに対して、修復アクションが実行されていることが確認できました。
cm-hirai-screenshot 2025-03-21 16.43.58

非準拠のIAMユーザーを確認すると、コンソールサインインは無効化されていませんが、アクセスキーは無効化されていました。

cm-hirai-screenshot 2025-03-21 16.45.55

注意点

Configルール

iam-user-unused-credentials-check ルールを使用する場合、考慮すべき点があります。

このルールは「コンソールの最終ログイン日」または「アクセスキーの最終利用日」のいずれかが指定日数を超えると非準拠と判定します。

今回作成したカスタムSSMドキュメントはアクセスキーのみを無効化し、コンソールアクセスには影響を与えないよう設計されています。そのため、以下のようなシナリオが発生します。

  • IAMユーザーがコンソールにログインしていない期間が指定日数を超えている場合、非準拠となりカスタムSSMドキュメントが実行され、アクセスキーは無効化される
  • しかし、コンソールアクセスは無効化されないため、Configルールでは引き続き「非準拠」と判定される
  • 結果として、設定した頻度(今回は24時間ごと)で修復アクションが繰り返し実行される
    cm-hirai-screenshot 2025-03-21 16.00.02

非準拠リソースの数が少なければ、AWS Config関連の料金への影響は限定的ですが、大量のIAMユーザーが存在する環境では考慮すべき点です。

一方、access-keys-rotated ルールはアクセスキーの作成日のみを評価基準としており、コンソールの最終ログイン日は考慮しないため、このような問題は発生しません。用途に応じて適切なConfigルールを選択することをお勧めします。

SSMカスタムドキュメント

元のAWSマネージドドキュメント「AWSConfigRemediation-RevokeUnusedIAMUserCredentials」を確認すると、Pythonランタイムのバージョンが毎年更新されていることがわかります。

一方、今回作成したカスタムSSMドキュメントについては、定期的なメンテナンスは基本的に不要です。

ただし、長期運用においては以下の点に注意が必要です。

  • 特定のPythonランタイムバージョンがAWSによってサポート終了となる可能性
  • aws:executeScriptアクションの仕様変更の可能性

重要な変更が発生する場合、AWSは通常、AWS Health ダッシュボード等を通じて事前に通知を行います。
AWSのサービス契約には以下のように明記されています。

1.5 本サービスの変更通知 アマゾンは、本サービスのいずれについても、随時、変更または終了することができるものとする。アマゾンは、アマゾンがお客様に対して一般的に提供しており、サービス利用が利用している本サービスの重要な機能を終了する場合、遅くとも 12 ヶ月前までにサービス利用者に通知する。
https://aws.amazon.com/agreement/

現時点では、aws:executeScriptアクションで使用されるPythonランタイムを含め、サポート終了に関する公開情報は確認されていません。そのため、当面は作成したカスタムドキュメントを問題なく利用できます。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.