AWS Config + Lambdaで特定ユーザーを除外しつつ未使用IAMユーザーを自動無効化してみた
はじめに
みなさんこんにちは、クラウド事業本部コンサルティング部の浅野です。
AWS Config のマネージドルール 「iam-user-unused-credentials-check」 を使うと、一定期間パスワードやアクセスキーを利用していない IAM ユーザーを「非準拠」として検出し、自動的に SSM Document を使って無効化してくれます。
ただし、実際の運用では 利用頻度は少ないけれど重要な管理者アカウントや運用用アカウント など、無効化の対象から外したいユーザーが存在する場合があります。
そこで今回は、特定の IAM ユーザーを除外できる仕組みも含めて、CloudFormation で AWS Config のカスタム Lambda ルールを構築し、未使用の IAM ユーザーを自動的に無効化するシステムを一括でデプロイしてみました。
マネージドルール 「iam-user-unused-credentials-check」 を使って、一定期間未使用の IAM ユーザーを無効化する方法については、以下のブログが参考になります!
CloudFormation
ソースコード
GitHub - haruki-0408/aws-iam-unused-user-cleanup
以下のリソースタイプを内包しています。
- AWS::IAM::Role: カスタムLambda Config規則用の実行ロール
- AWS::Lambda::Function: 未使用IAMユーザーを検知するカスタムLambda関数
- AWS::Lambda::Permission: LambdaにConfig権限を付与
- AWS::Config::ConfigRule: カスタムConfig規則(24時間間隔で定期実行)
- AWS::IAM::Role: Config自動修復用のサービスロール
- AWS::Config::RemediationConfiguration: 自動修復設定(SSM Documentによる無効化実行)
AWSTemplateFormatVersion: '2010-09-09'
Description: Detect unused IAM users with custom Lambda Config rule and auto-remediate excluding specific IAM users
Parameters:
ExcludedUsersParameterName:
Type: String
Default: "/iam-unused-user-cleanup/excluded-users"
Description: SSM Parameter Store path for excluded IAM users list
MaxCredentialUsageAge:
Type: String
Default: "30"
Description: Maximum number of days a credential can be unused before being revoked
Resources:
# カスタムLambda Config規則用の実行ロール
ConfigRuleLambdaRole:
Type: AWS::IAM::Role
Properties:
RoleName: ConfigRuleLambdaRole
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
- arn:aws:iam::aws:policy/service-role/AWSConfigRulesExecutionRole
Policies:
- PolicyName: IAMReadAndParameterAccess
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- iam:ListUsers
- iam:GetUser
- iam:GetLoginProfile
- iam:ListAccessKeys
- iam:GetAccessKeyLastUsed
- ssm:GetParameter
Resource: "*"
# カスタムLambda Config規則
ConfigRuleLambda:
Type: AWS::Lambda::Function
Properties:
FunctionName: custom-unused-iam-user-credentials-check-function
Runtime: python3.13
Timeout: 300
MemorySize: 256
Handler: index.lambda_handler
Role: !GetAtt ConfigRuleLambdaRole.Arn
Environment:
Variables:
EXCLUDED_USERS_PARAMETER: !Ref ExcludedUsersParameterName
MAX_CREDENTIAL_AGE: !Ref MaxCredentialUsageAge
Code:
ZipFile: |
import boto3
import os
from datetime import datetime, timedelta, timezone
iam = boto3.client('iam')
ssm = boto3.client('ssm')
config_client = boto3.client('config')
def lambda_handler(event, context):
evaluations = []
excluded_users = get_excluded_users()
max_days = int(os.environ.get('MAX_CREDENTIAL_AGE', '90'))
# 全IAMユーザーを取得して評価
paginator = iam.get_paginator('list_users')
for page in paginator.paginate():
for user in page['Users']:
user_name = user['UserName']
print(f"評価中: {user_name}")
if user_name in excluded_users:
compliance = 'COMPLIANT'
print(f"除外ユーザー: {user_name}")
elif is_credentials_unused(user_name, max_days):
compliance = 'NON_COMPLIANT'
print(f"未使用ユーザー: {user_name}")
else:
compliance = 'COMPLIANT'
print(f"使用中ユーザー: {user_name}")
evaluations.append({
'ComplianceResourceType': 'AWS::IAM::User',
'ComplianceResourceId': user['UserId'],
'ComplianceType': compliance,
'OrderingTimestamp': datetime.now(timezone.utc)
})
# Config に評価結果を送信
if evaluations:
config_client.put_evaluations(
Evaluations=evaluations,
ResultToken=event['resultToken']
)
print(f"評価完了: {len(evaluations)}ユーザー")
return {'evaluations_count': len(evaluations)}
def get_excluded_users():
parameter_name = os.environ.get('EXCLUDED_USERS_PARAMETER', '')
if not parameter_name:
return []
try:
response = ssm.get_parameter(Name=parameter_name, WithDecryption=False)
return [u.strip() for u in response['Parameter']['Value'].split(',') if u.strip()]
except ssm.exceptions.ParameterNotFound:
print(f"除外リストパラメータが見つかりません: {parameter_name}")
return []
def is_credentials_unused(user_name, max_days):
cutoff_date = datetime.now(timezone.utc) - timedelta(days=max_days)
print(f"カットオフ日: {cutoff_date}")
# パスワード使用チェック(修復ロジックに合わせて)
user_info = iam.get_user(UserName=user_name)
password_valid = True # デフォルトは有効
try:
login_profile = iam.get_login_profile(UserName=user_name)
password_needs_remediation = False
if 'PasswordLastUsed' in user_info['User']:
last_used = user_info['User']['PasswordLastUsed']
last_used_days = (datetime.now(timezone.utc) - last_used).days
print(f"パスワード最終使用: {last_used} ({last_used_days}日前)")
if last_used_days >= max_days:
password_needs_remediation = True
else:
# パスワード未使用の場合、作成日をチェック
create_date = login_profile['LoginProfile']['CreateDate'].replace(tzinfo=timezone.utc)
create_days = (datetime.now(timezone.utc) - create_date).days
print(f"パスワード作成日: {create_date} ({create_days}日前)")
if create_days >= max_days:
password_needs_remediation = True
if password_needs_remediation:
print(f"期間超過パスワード検出: {user_name}")
password_valid = False
except iam.exceptions.NoSuchEntityException:
print(f"ログインプロファイルなし: {user_name}")
# パスワードがない場合は有効とみなす(修復対象外)
# アクセスキー使用チェック(修復ロジックに合わせて個別評価)
access_keys = iam.list_access_keys(UserName=user_name)
access_key_valid = True # デフォルトは有効
if not access_keys['AccessKeyMetadata']:
print(f"アクセスキーなし: {user_name}")
else:
for key in access_keys['AccessKeyMetadata']:
if key['Status'] == 'Active': # Activeキーのみチェック
key_usage = iam.get_access_key_last_used(AccessKeyId=key['AccessKeyId'])
key_needs_remediation = False
if 'LastUsedDate' in key_usage['AccessKeyLastUsed']:
last_used = key_usage['AccessKeyLastUsed']['LastUsedDate']
last_used_days = (datetime.now(timezone.utc) - last_used).days
print(f"アクセスキー最終使用: {last_used} ({last_used_days}日前)")
if last_used_days >= max_days:
key_needs_remediation = True
else:
# アクセスキー未使用の場合、作成日をチェック
create_date = key['CreateDate']
create_days = (datetime.now(timezone.utc) - create_date).days
print(f"アクセスキー作成日: {create_date} ({create_days}日前)")
if create_days >= max_days:
key_needs_remediation = True
if key_needs_remediation:
print(f"期間超過アクセスキー検出: {key['AccessKeyId']}")
access_key_valid = False # 一つでも期間超過があれば無効
# OR条件: どちらか一方でも無効(修復が必要)な場合はNON_COMPLIANT
result = not password_valid or not access_key_valid
print(f"パスワード有効: {password_valid}, アクセスキー有効: {access_key_valid}, 未使用判定: {result}")
return result
# Lambdaに対するConfig権限
ConfigRuleLambdaPermission:
Type: AWS::Lambda::Permission
Properties:
FunctionName: !Ref ConfigRuleLambda
Action: lambda:InvokeFunction
Principal: config.amazonaws.com
SourceAccount: !Ref AWS::AccountId
# カスタムConfig規則
ConfigRule:
Type: AWS::Config::ConfigRule
DependsOn: ConfigRuleLambdaPermission
Properties:
ConfigRuleName: custom-unused-iam-user-credentials-check
Description: "Detect IAM users with unused credentials (excluding specified users)"
Source:
Owner: CUSTOM_LAMBDA
SourceIdentifier: !GetAtt ConfigRuleLambda.Arn
SourceDetails:
- EventSource: aws.config
MessageType: ScheduledNotification
MaximumExecutionFrequency: TwentyFour_Hours
Scope:
ComplianceResourceTypes:
- "AWS::IAM::User"
# Config Remediation用のサービスロール
RemediationServiceRole:
Type: AWS::IAM::Role
Properties:
RoleName: ConfigRemediationServiceRole
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: ssm.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: IAMUserManagementPolicy
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- config:ListDiscoveredResources
- ssm:GetAutomationExecution
- iam:DeleteAccessKey
- ssm:StartAutomationExecution
- iam:GetAccessKeyLastUsed
- iam:UpdateAccessKey
- iam:GetUser
- iam:GetLoginProfile
- iam:DeleteLoginProfile
- iam:ListAccessKeys
Resource: "*"
# Config Remediation設定
RemediationConfiguration:
Type: AWS::Config::RemediationConfiguration
Properties:
ConfigRuleName: !Ref ConfigRule
TargetType: SSM_DOCUMENT
TargetId: AWSConfigRemediation-RevokeUnusedIAMUserCredentials
TargetVersion: "5"
Parameters:
AutomationAssumeRole:
StaticValue:
Values:
- !GetAtt RemediationServiceRole.Arn
IAMResourceId:
ResourceValue:
Value: RESOURCE_ID
MaxCredentialUsageAge:
StaticValue:
Values:
- !Ref MaxCredentialUsageAge
Automatic: true
MaximumAutomaticAttempts: 5
RetryAttemptSeconds: 60
以下の項目はCloudFormation外で管理しています。
- 除外ユーザーリスト: SSM Parameter Storeで管理
- キー:
/iam-unused-user-cleanup/excluded-users
- 値の例:
admin-user,emergency-user,service-account
- 形式: カンマ区切りの文字列(StringList)で複数のIAMユーザー名を指定
- キー:
また、以下の箇所は自由に設定可能です。
- Configルール評価の実行頻度: 3時間毎、6時間毎、12時間毎、24時間毎、週1回から選択可能(CloudFormationテンプレートで設定)
- 非準拠リソース自動修復のリトライ回数: デフォルト5回(修復失敗時の最大リトライ回数)
- リトライ間隔: デフォルト60秒(修復リトライの実行間隔)
- IAMユーザーを「未使用」と定義する対象日数: デフォルト90日(CloudFormationパラメータ
MaxCredentialUsageAge
で変更可能)
処理フロー
今回作成したシステムの全体フローは以下に従います。
IAMユーザーの未使用判定を、後のSSM修復ドキュメント「AWSConfigRemediation-RevokeUnusedIAMUserCredentials」のスクリプトにおける判定内容と揃えなければ誤検知や未修復につながる可能性があるので注意が必要でした。
パスワードとアクセスキーの有効/無効検知の箇所はSSM修復ドキュメントのバージョン5
内のスクリプトとロジックを合わせており、修復ドキュメントのバージョンをCloudFormationで変更する場合はカスタムLambdaルールとロジックがずれないか注意が必要です。
動作確認
今回は「未使用」と定義する日数(MaxCredentialUsageAge)を30
に設定し、CloudFormationをデプロイしました。
これにより30日以上パスワードまたはアクセスキーが未使用のIAMユーザーが「非準拠」の対象となります。
では実際に動作を確認していきましょう。
1. 検証用IAMユーザー
今回は検証用として以下IAMユーザーを3つ作成しています。
ユーザー名 | 最後のアクティビティ | パスワードが作成されてから経過した期間 | コンソールの最終サインイン | アクセスキー ID | アクティブなキーが作成されてから経過した期間 |
---|---|---|---|---|---|
test-active-user | 48 日前 | 49 日 | July 10, 2025, 11:21 (UTC+09:00) | - | - |
test-active-user2 | - | 48 日 | - | Active - AKIA*********** | 57 分 |
test-active-user3 | - | 17 時間 | - | - | - |
各ユーザーはConfigルールにより以下のように評価されるはずです。
- test-active-user : コンソールにサインイン可能かつアクセスキーを持ちません。最後にログインしたのが現在から48日前 → 「非準拠」対象
- test-active-user2 : コンソールにサインイン可能かつアクセスキーを1時間前に作成、パスワードが作成されてから48日以上未ログイン → 「非準拠」対象
- test-active-user2 : コンソールにサインイン可能かつアクセスキーを持ちません。未ログイン → 「準拠」対象
2. 除外リストへの登録
動作を検証する前に、今回機能の目玉であるSSM Parameter Storeにこのルールから除外したいIAMユーザーリストを作成します。
以下のようにtest-active-user
をConfigカスタムルールの対象外として登録しました。
本来であればtest-active-user
は最終的なパスワードの使用(コンソールの最終サインイン)から30日以上経過しているため、「非準拠」と判定されるのですが、この除外リストに登録することで検知対象外とさせます。
3. 検知内容
デプロイすると以下のConfigカスタムルールが作成されました。
修復ドキュメント上の設定値として無事にMaxCredentialUsageAge
が30
に設定されています。
カスタムルールの実態はLambdaコンソール画面ににあるこの関数ですね。
Lambdaの環境変数として、除外リストの場所と、こちらにも「未使用」と判定する日数を定義しています。この値と先ほどの修復ドキュメントの上の値がズレると動作がおかしくなるので合わせてください。
今回のプロジェクトではCloudFormationのパラメータとして統一しているのでその部分だけ変更すればよいです。
続いてこのルールによって検知されたリソースを確認していきます。
仕様通りtest-active-user
, test-active-user3
は評価により「準拠」と判断されました。
こちらも予想通りきちんとtest-active-user2
が「非準拠」と判定されています!
カスタムLambdaルールのログ内容を確認してみましょう。それぞれどのようなロジックで評価されたかが確認できます。
適切に評価ロジックが通っていることが確認できました。素晴らしい〜
また「非準拠」となったリソースを自動で修復する設定にしているので、数分後以下のように「アクションが正常に実行されたました」と出てtest-active-user2
の修復が終えたことがわかります。
AWS CLIでも修復実行に関する記録を詳しくみてみましょう。
# 修復実行ステータスを確認するコマンド
aws configservice describe-remediation-execution-status --config-rule-name custom-unused-iam-user-credentials-check \
{
"RemediationExecutionStatuses": [
{
"ResourceKey": {
"resourceType": "AWS::IAM::User",
"resourceId": "******************"
},
"State": "SUCCEEDED",
"StepDetails": [
{
"Name": "RevokeUnusedIAMUserCredentialsAndVerify",
"State": "SUCCEEDED",
"StartTime": "2025-08-28T11:27:52.846000+09:00",
"StopTime": "2025-08-28T11:28:06.138000+09:00"
}
],
"InvocationTime": "2025-08-28T11:27:52.515000+09:00",
"LastUpdatedTime": "2025-08-28T11:28:06.514000+09:00"
}
]
}
設定した修復アクションが実行され、成功ステータスに変わっています。
SSM ドキュメントの実行ログも見てます。
aws ssm describe-automation-executions \
--filters "Key=DocumentNamePrefix,Values=AWSConfigRemediation-RevokeUnusedIAMUserCredentials" \
--max-results 1
{
{
"AutomationExecutionMetadataList": [
{
"AutomationExecutionId": "******************",
"DocumentName": "AWSConfigRemediation-RevokeUnusedIAMUserCredentials",
"DocumentVersion": "5",
"AutomationExecutionStatus": "Success",
"ExecutionStartTime": "2025-08-28T11:27:52.370000+09:00",
"ExecutionEndTime": "2025-08-28T11:28:06.197000+09:00",
"ExecutedBy": "arn:aws:sts::**********:assumed-role/AWSServiceRoleForConfigRemediation/AwsConfigRemediation",
"LogFile": "",
"Outputs": {
"RevokeUnusedIAMUserCredentialsAndVerify.Output": [
"{\"output\":\"Verification of unused IAM User credentials is successful.\",\"http_responses\":{\"DeactivateUnusedKeysResponse\":[],\"DeleteUnusedPasswordResponse\":{\"ResponseMetadata\":{\"RequestId\":\"22bce8dc-9371-46f3-a366-abda08b72b9d\",\"HTTPStatusCode\":200,\"HTTPHeaders\":{\"date\":\"Thu, 28 Aug 2025 02:28:01 GMT\",\"x-amzn-requestid\":\"22bce8dc-9371-46f3-a366-abda08b72b9d\",\"content-type\":\"text/xml\",\"content-length\":\"216\"},\"RetryAttempts\":0}}}}"
]
},
"Mode": "Auto",
"Targets": [],
"ResolvedTargets": {
"ParameterValues": [],
"Truncated": false
},
"AutomationType": "Local"
}
],
"NextToken": "**********"
}
きちんとドキュメントバージョン5
を使用しておりDeleteUnusedPasswordResponse
の記載があるので未使用のIAMユーザーからパスワードが削除されたことがわかります。
実際にコンソール上で確認してみましょう。
無事test-active-user2
の「コンソールを通じたアクセス」が「無効」に切り替わっておりパスワードが削除されたことが確認できました!
さらに修復後、Configルールの再評価を行うと「非準拠」リソースが修復されて全て「準拠」に変わったことも確認できました。
最後に
今回はカスタムLambdaルールを用いてAWS Configのルール評価により一定期間「パスワード」 or 「アクセスキー」が未使用のユーザーを検知して無効化するシステムを作成してみました。
SSM Parameter Storeに除外リストを作成することで、より柔軟な設定が可能なシステムを目指しました。
このシステムとブログが誰かの役に立てば幸いです。