Python
AWS
Security
lambda
awsconfigrules

AWS Config Rules カスタムルールを作成してみよう

目的

AWS Config Rulesのカスタムルールは Lambda の作成によって行われます。この記事ではサンプルを元に、実際にカスタムルールの作成を行うことで、今後のカスタムルールを作成する際の開発をスムーズにします。

教材

awslabs の aws-config-rules を用います。

ここでは、Lambda (Python) で次のルールを作成します。
- 0.0.0.0/0のインバウンドルールを含むセキュリティグループの検出
- 実際には使用されていないIAMユーザの検出

仕組み

ドキュメントに書かれているように、AWS Config Rulesには2つのトリガータイプがあります。
今回の例では、両方とも「変更時に検出」を使います。「変更時に検出」の場合は対象のリソースがゼロではない限り、リソースの情報がLambdaファンクションにイベントとして渡されます。また、「再評価」をすることですべてのリソースを評価できます。「定期的な実行」の場合は、リソース情報は渡されませんので、リソース情報の抽出自体をコード内ですることが必要となります。

AWS Configの設定

デフォルトではIAMなどグローバルリソースは対象とならず、ルールを作成しても評価されません。
AWS Config の設定で、グローバルリソース (AWS IAM リソースなど) を含めるチェックボックスをオンにしておきます。

ソース

ソースの入手

作業用ディレクトリを作り、GitHubからCloneします。

$ mkdir Config
$ cd Config/
$ git clone https://github.com/awslabs/aws-config-rules.git
Cloning into 'aws-config-rules'...
remote: Counting objects: 575, done.
remote: Compressing objects: 100% (10/10), done.
remote: Total 575 (delta 2), reused 3 (delta 0), pack-reused 565
Receiving objects: 100% (575/575), 157.63 KiB | 140.00 KiB/s, done.
Resolving deltas: 100% (331/331), done.
$ 

渡されるイベントの構造

AWS Config ルールのイベントの例が参考になります。ただすべてのイベント仕様が記載されてはいないので、リソース毎にconfigurationItemを出力して内容を確認することをお勧めします。

今回扱うのは EC2 と IAM ユーザです。これらのリソースのinvocationEventの例を以下に記載しておきます。なお、ScheduledEventでの呼び出しの場合や、呼び出されたものの評価対象のリソースがない場合にはconfigurationItem自体が存在しません。

EC2
{
    "recordVersion": "1.3",
    "configurationItem": {
        "relationships": [],
        "configurationItemCaptureTime": "2018-07-04T01:54:02.810Z",
        "availabilityZone": null,
        "configurationStateMd5Hash": "",
        "tags": {},
        "resourceType": "AWS::EC2::Instance",
        "configurationItemVersion": "1.3",
        "configurationStateId": 1530669242810,
        "relatedEvents": [],
        "awsRegion": "ap-northeast-1",
        "ARN": "arn:aws:ec2:ap-northeast-1:xxxxxxxxxxxxxx:instance/i-05888074e384774ef",
        "supplementaryConfiguration": {},
        "resourceName": null,
        "configuration": null,
        "resourceId": "i-05888074e384774ef",
        "resourceCreationTime": null,
        "configurationItemStatus": "ResourceDeleted",
        "awsAccountId": "xxxxxxxxxxxxxxx"
    },
    "notificationCreationTime": "2018-07-05T14:40:13.752Z",
    "messageType": "ConfigurationItemChangeNotification",
    "configurationItemDiff": null
}
IAMユーザ
{
    "recordVersion": "1.3",
    "configurationItem": {
        "relationships": [
            {
                "resourceType": "AWS::IAM::Policy",
                "resourceId": "ANPAJUAZCQRMVSYFQNJPI",
                "name": "Is attached to CustomerManagedPolicy",
                "resourceName": "trainexpenser-codecommit-readonly"
            }
        ],
        "configurationItemCaptureTime": "2018-07-05T15:22:21.203Z",
        "availabilityZone": "Not Applicable",
        "configurationStateMd5Hash": "",
        "tags": {},
        "resourceType": "AWS::IAM::User",
        "configurationItemVersion": "1.3",
        "configurationStateId": 1530804141203,
        "relatedEvents": [],
        "awsRegion": "global",
        "ARN": "arn:aws:iam::xxxxxxxxxxxx:user/trainexpenser-codecommit-readonly",
        "supplementaryConfiguration": {},
        "resourceName": "trainexpenser-codecommit-readonly",
        "configuration": {
            "userName": "trainexpenser-codecommit-readonly",
            "groupList": [],
            "createDate": "2017-12-01T05:01:21.000Z",
            "userId": "AIDAIKBNZFI2LJRYEOI3G",
            "userPolicyList": null,
            "path": "/",
            "attachedManagedPolicies": [
                {
                    "policyName": "trainexpenser-codecommit-readonly",
                    "policyArn": "arn:aws:iam::xxxxxxxxxxxxx:policy/trainexpenser-codecommit-readonly"
                }
            ],
            "arn": "arn:aws:iam::xxxxxxxxxxxxx:user/trainexpenser-codecommit-readonly"
        },
        "resourceId": "AIDAIKBNZFI2LJRYEOI3G",
        "resourceCreationTime": "2017-12-01T05:01:21.000Z",
        "configurationItemStatus": "ResourceDiscovered",
        "awsAccountId": "xxxxxxxxxxxx"
    },
    "notificationCreationTime": "2018-07-05T15:22:49.724Z",
    "messageType": "ConfigurationItemChangeNotification",
    "configurationItemDiff": null
}

0.0.0.0/0 のインバウンドを含むセキュリティグループの検出

このルールについては既にサンプルに用意されています(ec2-exposed-instance.py)。以下ソースを解説します。

ec2-exposed-instance.py
#
# This file made available under CC0 1.0 Universal (https://creativecommons.org/publicdomain/zero/1.0/legalcode)
#
# Ensure that no EC2 instances allow public access to the specified ports.
# Description: Checks that all instances block access to the specified ports.
#
# Trigger Type: Change Triggered
# Scope of Changes: EC2:Instance
# Accepted Parameters: examplePort1, exampleRange1, examplePort2, ...
# Example Values: 8080, 1-1024, 2375, ...


import json
import boto3

# ルールを適用する対象となるリソースの配列です。evaluate_compliance()で使用します。
APPLICABLE_RESOURCES = ["AWS::EC2::Instance"]

# 隠されているべきポート番号定義(forbidden ports)をrangeに変換します。1つの場合はそのポートのみです。
def expand_range(ports):
    if "-" in ports:
        return range(int(ports.split("-")[0]), int(ports.split("-")[1])+1)
    else:
        return [int(ports)]

# 0.0.0.0/0が含まれるIpRanges設定を探し、収集します。
def find_exposed_ports(ip_permissions):
    exposed_ports = []
    for permission in ip_permissions:
        if next((r for r in permission["IpRanges"]
                if "0.0.0.0/0" in r["CidrIp"]), None):
                    exposed_ports.extend(range(permission["FromPort"],
                                               permission["ToPort"]+1))
    return exposed_ports

# exporsed_portsに、forbidden_portsが含まれていた場合にルール違反を返します。最初の1つが見つかった時点で探索は終了します。
# exporsed_ports * forbidden_ports の計算量となります。
# forbidden_portsの例: {"examplePort1":"8080", "exampleRange1":"1-1024", "examplePort2":"2375"}
def find_violation(ip_permissions, forbidden_ports):
    exposed_ports = find_exposed_ports(ip_permissions)
    for forbidden in forbidden_ports:
        ports = expand_range(forbidden_ports[forbidden])
        for port in ports:
            if port in exposed_ports:
                return "A forbidden port is exposed to the internet."

    return None


# 評価の主ファンクションです。
def evaluate_compliance(configuration_item, rule_parameters):
    # 対象のリソースタイプではない場合は NOT_APPLICABLE として返します。
    if configuration_item["resourceType"] not in APPLICABLE_RESOURCES:
        return {
            "compliance_type": "NOT_APPLICABLE",
            "annotation": "The rule doesn't apply to resources of type " +
            configuration_item["resourceType"] + "."
        }

    # リソースが既に削除されている場合は NOT_APPLICABLE として返します。
    if configuration_item['configurationItemStatus'] == "ResourceDeleted":
        return {
            "compliance_type": "NOT_APPLICABLE",
            "annotation": "The configurationItem was deleted and therefore cannot be validated"
        }

    security_groups = configuration_item["configuration"].get("securityGroups")

    # そもそもセキュリティグループがアタッチされていない場合は NON_COMPLIANT として返します。
    if security_groups is None:
        return {
            "compliance_type": "NON_COMPLIANT",
            "annotation": "The instance doesn't pertain to any security groups."
        }

    ec2 = boto3.resource("ec2")
    # すべてのセキュリティグループについて検査します。
    for security_group in security_groups:
        # IPパーミッションを取り出します。
        ip_permissions = ec2.SecurityGroup(
                                           security_group["groupId"]
                                          ).ip_permissions
        # 違反がないかを検査します。
        violation = find_violation(
            ip_permissions,
            rule_parameters
        )

        # None以外が返却された場合は違反として NON_COMPLIANT とその内容を返します。
        if violation:
            return {
                "compliance_type": "NON_COMPLIANT",
                "annotation": violation
            }

    # 検査に合格したので COMPLIANT を返します。
    return {
        "compliance_type": "COMPLIANT",
        "annotation": "This resource is compliant with the rule."
    }


# Lambdaの主関数です。検査を行う対象のリソース毎に呼び出されます。
# eventの以下3つを使用します。
# invokingEvent/configurationItem: リソースの設定内容
# ruleParameters: AWS Config Rules におけるルールのパラメータ  -> (key, value)の配列
# resultToken: 結果をAWS Configにputする時に使用するトークン
def lambda_handler(event, context):

    # 各データ/パラメータ/トークンの取り出し
    invoking_event = json.loads(event["invokingEvent"])
    configuration_item = invoking_event["configurationItem"]
    rule_parameters = json.loads(event["ruleParameters"])

    result_token = "No token found."
    if "resultToken" in event:
        result_token = event["resultToken"]

    # 評価
    evaluation = evaluate_compliance(configuration_item, rule_parameters)

    # boto3を使用して AWS Config に結果を put
    config = boto3.client("config")
    config.put_evaluations(
        Evaluations=[
            {
                # 通常はinputのリソースタイプ/リソースIDを使用する
                "ComplianceResourceType":
                    configuration_item["resourceType"],
                "ComplianceResourceId":
                    configuration_item["resourceId"],

                # 以下2つが評価結果
                # COMPLIANT, NON_COMPLIANT, NOT_APPLICABLE
                "ComplianceType":
                    evaluation["compliance_type"],
                # 評価結果の文字列
                "Annotation":
                    evaluation["annotation"],

                # 並べ替えに使うタイムスタンプ
                # キャプチャ時のタイムスタンプを用いれば良い
                "OrderingTimestamp":
                    configuration_item["configurationItemCaptureTime"]
            },
        ],
        ResultToken=result_token
    )

実際には使用されていないIAMユーザの検出

このルールについても既にサンプルに用意されています(iam-inactive-user.py)。以下ソースを解説します。前節と重複する部分については省略しています。

iam-inactive-user.py
#
# This file made available under CC0 1.0 Universal (https://creativecommons.org/publicdomain/zero/1.0/legalcode)
#
# Ensure that no users have been inactive for a period longer than specified.
# Description: Checks that all users have been active for earlier than specified.
#
# Trigger Type: Change Triggered
# Scope of Changes: IAM:User
# Required Parameters: maxInactiveDays
# Example Value: 90


import json
import boto3
import datetime


APPLICABLE_RESOURCES = ["AWS::IAM::User"]

# 与えられた日付と現在の差分の日数を計算します
def calculate_age(date):
    now = datetime.datetime.utcnow().date()
    then = date.date()
    age = now - then

    return age.days


def evaluate_compliance(configuration_item, rule_parameters):
    if configuration_item["resourceType"] not in APPLICABLE_RESOURCES:
        return "NOT_APPLICABLE"

    config = boto3.client("config")
    # 与えられているイベントから変更があるかもしれないので、念のためAWS Configの最新情報からリソースIDをキーとしてユーザ名を取り出します
    resource_information = config.get_resource_config_history(
        resourceType=configuration_item["resourceType"],
        resourceId=configuration_item["resourceId"]
    )
    user_name = resource_information["configurationItems"][0]["resourceName"]

    # IAM の API を用いて当該IAMユーザの PasswordLastUsed を取り出します
    iam = boto3.client("iam")
    user = iam.get_user(UserName=user_name)
    last_used = user["User"].get("PasswordLastUsed")

    # パラメータから未使用期間の閾値を取り出します
    max_inactive_days = int(rule_parameters["maxInactiveDays"])

    # 未使用期間がパラメータで与えられた閾値を超えていた場合は NON_COMPIANT として返します
    if last_used is not None and calculate_age(last_used) > max_inactive_days:
        return "NON_COMPLIANT"

    return "COMPLIANT"


def lambda_handler(event, context):
    invoking_event = json.loads(event["invokingEvent"])
    configuration_item = invoking_event["configurationItem"]
    rule_parameters = json.loads(event["ruleParameters"])

    result_token = "No token found."
    if "resultToken" in event:
        result_token = event["resultToken"]

    config = boto3.client("config")
    config.put_evaluations(
        Evaluations=[
            {
                "ComplianceResourceType":
                    configuration_item["resourceType"],
                "ComplianceResourceId":
                    configuration_item["resourceId"],
                "ComplianceType":
                    evaluate_compliance(configuration_item, rule_parameters),
                "Annotation":
                    "The user has never logged in.", # COMPIANTの場合も同じ Annotation となっています
                "OrderingTimestamp":
                    configuration_item["configurationItemCaptureTime"]
            },
        ],
        ResultToken=result_token
    )

Lambdaファンクションの作成

AWS Config Rules のための Lambda ファンクションは以下で構成されます。
- AWS Config Rules にアクセス可能な Lambda ファンクション用のロール
- ソースコード・パッケージ (zip)

AWS Config Rules のカスタムルール用ロールの作成

まずは、ロールを作成します。ロールは1つ作っておけば複数のルールで再利用できます。このロールは Lambda ファンクション用のサービスロールであり、ログ出力のために CloudWatch Logs への権限と、AWS Config へのアクセス権限が必要となります。次のように作成します。

  1. Lamdda ファンクション用のサービスロール作成に必要なポリシーファイルを作成します。
lambda-exec-role-policy.json
{
  "Version": "2012-10-17",
  "Statement": [
     {
       "Action": "sts:AssumeRole",
       "Principal": {
         "Service": "lambda.amazonaws.com"
        },
        "Effect": "Allow",
        "Sid": ""
     }
  ]
}
  1. create-roleでロールを作成します。ロール名は lambda-config-rules-role とします。作成後 ARN を得ておきます。get-roleを実行すればいつでも得られます。
$ ls
aws-config-rules        lambda-exec-role-policy.json
※git cloneを実行したディレクトリ
$ aws iam create-role --role-name lambda-config-rules-role --assume-role-policy-document file://lambda-exec-role-policy.json
{
    "Role": {
        "AssumeRolePolicyDocument": {
            "Version": "2012-10-17", 
            "Statement": [
                {
                    "Action": "sts:AssumeRole", 
                    "Sid": "", 
                    "Effect": "Allow", 
                    "Principal": {
                        "Service": "lambda.amazonaws.com"
                    }
                }
            ]
        }, 
        "RoleId": "AROAJSIB5RAT7Y2JOAEUA", 
        "CreateDate": "2018-07-05T12:38:22.114Z", 
        "RoleName": "lambda-config-rules-role", 
        "Path": "/", 
        "Arn": "arn:aws:iam::xxxxxxxxxxxxxx:role/lambda-config-rules-role"
    }
}

$ aws iam get-role --role-name lambda-config-rules-role
※create-role時と同じ結果が返される
  1. ロールに必要なポリシーを追加します。既存のポリシーの利用をする場合、CloudWatchLogsFullAccess と AWSConfigRulesExecutionRole となります。今回はEC2とIAMのリソース情報を取得しますので、AmazonEC2ReadOnlyAccessとIAMReadOnlyAccessも追加しています。
$ aws iam attach-role-policy --role-name lambda-config-rules-role --policy-arn "arn:aws:iam::aws:policy/service-role/AWSConfigRulesExecutionRole"
$ aws iam attach-role-policy --role-name lambda-config-rules-role --policy-arn "arn:aws:iam::aws:policy/CloudWatchLogsFullAccess"
$ aws iam attach-role-policy --role-name lambda-config-rules-role --policy-arn "arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess"
$ aws iam attach-role-policy --role-name lambda-config-rules-role --policy-arn "arn:aws:iam::aws:policy/IAMReadOnlyAccess"
$ aws iam list-attached-role-policies --role-name lambda-config-rules-role
{
    "AttachedPolicies": [
        {
            "PolicyName": "AmazonEC2ReadOnlyAccess", 
            "PolicyArn": "arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess"
        }, 
        {
            "PolicyName": "CloudWatchLogsFullAccess", 
            "PolicyArn": "arn:aws:iam::aws:policy/CloudWatchLogsFullAccess"
        }, 
        {
            "PolicyName": "IAMReadOnlyAccess", 
            "PolicyArn": "arn:aws:iam::aws:policy/IAMReadOnlyAccess"
        }, 
        {
            "PolicyName": "AWSConfigRulesExecutionRole", 
            "PolicyArn": "arn:aws:iam::aws:policy/service-role/AWSConfigRulesExecutionRole"
        }
    ]
}

Lambda ファンクションの作成

ソースコードを zip にして前節のロールと共にファンクションを作成します。

  1. Zip ファイルを作成します。
$ zip -j ec2-exposed-instance.zip aws-config-rules/python/ec2-exposed-instance.py 
  adding: ec2-exposed-instance.py (deflated 68%)
$ zip -j iam-inactive-user.zip aws-config-rules/python/iam-inactive-user.py
  adding: iam-inactive-user.py (deflated 61%) 
  1. Lambdaファンクションを作成します。ロールのARNは実際のものに変更して下さい。
$ aws lambda create-function \
 --function-name awsconfig-ec2-exposed-instance \
 --runtime python2.7 \
 --role arn:aws:iam::xxxxxxxxxx:role/lambda-config-rules-role \
 --handler ec2-exposed-instance.lambda_handler \
 --timeout 300 \
 --zip-file fileb://ec2-exposed-instance.zip
{
    "TracingConfig": {
        "Mode": "PassThrough"
    }, 
    "CodeSha256": "f+ZThR3wLwsK9AHcupb7MNFLtKl3H5tnAxA77vg2ILo=", 
    "FunctionName": "awsconfig-ec2-exposed-instance", 
    "CodeSize": 1473, 
    "RevisionId": "ba1b0512-2646-4571-92e3-728fe627d8a3", 
    "MemorySize": 128, 
    "FunctionArn": "arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxxx:function:awsconfig-ec2-exposed-instance", 
    "Version": "$LATEST", 
    "Role": "arn:aws:iam::xxxxxxxxxxxxxxx:role/lambda-config-rules-role", 
    "Timeout": 300, 
    "LastModified": "2018-07-05T13:18:28.119+0000", 
    "Handler": "lambda_handler", 
    "Runtime": "python2.7", 
    "Description": ""
}
$ aws lambda create-function \
 --function-name awsconfig-iam-inactive-user \
 --runtime python2.7 \
 --role arn:aws:iam::xxxxxxxxxx:role/lambda-config-rules-role \
 --handler iam-inactive-user.lambda_handler \
 --timeout 300 \
 --zip-file fileb://iam-inactive-user.zip
{
    "TracingConfig": {
        "Mode": "PassThrough"
    }, 
    "CodeSha256": "yRuXariCEPmjSqZ0GKOHcVhzk2/1B14+C+9BpMQzkXs=", 
    "FunctionName": "awsconfig-iam-inactive-user", 
    "CodeSize": 1115, 
    "RevisionId": "ba7be6ef-5148-4790-80c2-73f232e5536d", 
    "MemorySize": 128, 
    "FunctionArn": "arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxx:function:awsconfig-iam-inactive-user", 
    "Version": "$LATEST", 
    "Role": "arn:aws:iam::xxxxxxxxxxxx:role/lambda-config-rules-role", 
    "Timeout": 300, 
    "LastModified": "2018-07-05T13:19:29.453+0000", 
    "Handler": "lambda_handler", 
    "Runtime": "python2.7", 
    "Description": ""
}
  1. AWS Config から Lambda ファンクションを呼び出す許可を与えます。source-accountについては自分のアカウント番号に変更して下さい。
$ aws lambda add-permission \
> --function-name awsconfig-ec2-exposed-instance \
> --statement-id 1 \
> --principal config.amazonaws.com \
> --action lambda:InvokeFunction \
> --source-account xxxxxxxxxxxxxx
{
    "Statement": "{\"Sid\":\"1\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"config.amazonaws.com\"},\"Action\":\"lambda:InvokeFunction\",\"Resource\":\"arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxxx:function:awsconfig-ec2-exposed-instance\",\"Condition\":{\"StringEquals\":{\"AWS:SourceAccount\":\"xxxxxxxxxxxxx\"}}}"
} 
$ aws lambda add-permission \
> --function-name awsconfig-iam-inactive-user \
> --statement-id 1 \
> --principal config.amazonaws.com \
> --action lambda:InvokeFunction \
> --source-account xxxxxxxxxxxxx
{
    "Statement": "{\"Sid\":\"1\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"config.amazonaws.com\"},\"Action\":\"lambda:InvokeFunction\",\"Resource\":\"arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxxxxx:function:awsconfig-iam-inactive-user\",\"Condition\":{\"StringEquals\":{\"AWS:SourceAccount\":\"xxxxxxxxxxxxx\"}}}"
}
$ 

AWS Config Rules の作成

ルール定義のための json ファイルを作成し、それを用いてルールを作成します。SourceIdentifierには。実際の Lambda ファンクションのARNを指定して下さい。

ec2-exposed-instance-rule.json
{
    "ConfigRuleName": "EC2-Exposed-Instance",
    "Description": "Evaluates EC2 instances which expose port(s).",
    "Scope": {
        "ComplianceResourceTypes": [
            "AWS::EC2::Instance"
        ]
    },
    "Source": {
        "Owner": "CUSTOM_LAMBDA",
        "SourceIdentifier": "arn:aws:lambda:ap-northeast-1:xxxxxxxxxx:function:awsconfig-ec2-exposed-instance",
        "SourceDetails": [{
            "EventSource": "aws.config",
            "MessageType": "ConfigurationItemChangeNotification"
        }]
    },
    "InputParameters": "{\"examplePort1\":\"8080\", \"exampleRange1\":\"1-1024\", \"examplePort2\":\"2375\"}"
}
iam-inactive-user-rule.json
{
    "ConfigRuleName": "IAM-Inactive-Users",
    "Description": "Evaluates inactive IAM users.",
    "Scope": {
        "ComplianceResourceTypes": [
            "AWS::IAM::User"
        ]
    },
    "Source": {
        "Owner": "CUSTOM_LAMBDA",
        "SourceIdentifier": "arn:aws:lambda:ap-northeast-1:xxxxxxxxxxx:function:awsconfig-iam-inactive-user",
        "SourceDetails": [{
            "EventSource": "aws.config",
            "MessageType": "ConfigurationItemChangeNotification"
        }]
    },
    "InputParameters": "{\"maxInactiveDays\":\"90\"}"
}
$ aws configservice put-config-rule --config-rule file://ec2-exposed-instance-rule.json 
$ aws configservice put-config-rule --config-rule file://iam-inactive-user-rule.json 

ここまでできたら、AWS Configのコンソールから「再評価」を押すことにより全リソースの評価を実行できます。評価結果やLambdaのログを確認してみてください。