未使用アクセスキーを無効化するAWS Systems Manager カスタムドキュメントを作成し、AWS Configで自動修復してみた
はじめに
AWS Configのマネージドルールには以下のルールが存在します。
access-keys-rotated
:アクセスキーの作成日から、指定日数以内にローテーションされていない場合、非準拠とするiam-user-unused-credentials-check
:コンソールの最終ログイン日またはアクセスキーの最終利用日が指定日数を超えている場合、非準拠とする
「AWSConfigRemediation-RevokeUnusedIAMUserCredentials」というSSMドキュメントは、指定した日数利用されていないコンソールパスワードとアクセスキーを無効化(非アクティブ化)します。
このSSMドキュメントをAWS Configの修復アクションとして設定することで、非準拠リソースを自動的に修復できます。この実装方法については、以下のブログ記事で詳しく解説されています。
- iam-user-unused-credentials-checkルールを使った実装例
- access-keys-rotatedルールを使った実装例(MaxCredentialUsageAgeを0に設定)
しかし、このマネージド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"
}
]
}
カスタムSSMドキュメント作成
カスタムSSMドキュメントを作成します。
「AWSConfigRemediation-RevokeUnusedIAMUserCredentials」から[アクション]で[ドキュメントのクローン作成]をします。
もともとの「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
ルールを利用して作成します。
頻度は、最長間隔の24時間に一回評価するよう設定します。
maxCredentialUsageAgeは、1日にします。
続いて修復アクションを以下の通り設定します。
- 修復アクション:Custom-AWSConfigRemediation-DeactivateUnusedIAMAccessKeys
- リソースIDパラメータ:IAMResourceId
- AutomationAssumeRole:作成したIAMロールのARN
- MaxCredentialUsageAge:1
試してみる
Config ルールのページの[アクション]から[再評価]すると、非準拠のIAMユーザーに対して、修復アクションが実行されていることが確認できました。
非準拠のIAMユーザーを確認すると、コンソールサインインは無効化されていませんが、アクセスキーは無効化されていました。
注意点
Configルール
iam-user-unused-credentials-check ルールを使用する場合、考慮すべき点があります。
このルールは「コンソールの最終ログイン日」または「アクセスキーの最終利用日」のいずれかが指定日数を超えると非準拠と判定します。
今回作成したカスタムSSMドキュメントはアクセスキーのみを無効化し、コンソールアクセスには影響を与えないよう設計されています。そのため、以下のようなシナリオが発生します。
- IAMユーザーがコンソールにログインしていない期間が指定日数を超えている場合、非準拠となりカスタムSSMドキュメントが実行され、アクセスキーは無効化される
- しかし、コンソールアクセスは無効化されないため、Configルールでは引き続き「非準拠」と判定される
- 結果として、設定した頻度(今回は24時間ごと)で修復アクションが繰り返し実行される
非準拠リソースの数が少なければ、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ランタイムを含め、サポート終了に関する公開情報は確認されていません。そのため、当面は作成したカスタムドキュメントを問題なく利用できます。