Ryan Scott Brown in security 9 minutes to read

Removing Excess IAM Access with AWS Config

Finding and Closing Access Loopholes

IAM is the last line of defense between compromised AWS credentials and your critical infrastructure. Following the principle of least privilege is critical. To make this more difficult, the way a credential is used might change. At the start of a project you may think SES is the way you’ll be communicating with users, but as requirements change you may find SNS, Pinpoint, or some other service is a better fit. Did you remember to revoke the SES policy on your application?

In this post, we’ll talk about a way to automatically make use of the new IAM Access Advisor to keep track of IAM permissions of users and roles in your account.

It’s common to grant access from any number of sources – AWS Managed Policies, policies you manage yourself, or inline policies attached directly to a user. Combine the number of policies that might be attached with the number of roles, users, and groups in a typical AWS account and you are bound to miss revocation of a credential or two. IAM Access Advisor looks at historical data about the services that are actually used by a user, group, or role. This helps you weed out permissions that aren’t needed.

We’ll learn how to make use of Access Advisor as part of an AWS Config rule that will search for unused access that is granted to IAM groups, users, or roles. The code is in the trek10inc/config-excess-access-exorcist repository.

Consistent Enforcement

Because everything in AWS is an API, we can use the new Access Advisor to check access on all our users, roles, and groups consistently. There’s also already a service to help find configuration problems based on AWS best practices and your own custom rules. AWS Config will watch your resources for changes and trigger rules (custom or AWS-provided) to test the new configuration.

To teach AWS Config about the IAM Access Advisor, we need to write our own little Lambda function that will check on a user, group, or role and tell whether or not it has permissions it isn’t using. Here’s a Python function we can use to get those details. There is a little bit of extra work to make sure we get all of the services, because this API could return just the first page and cause us to miss services.

def get_iam_last_access_details(iam, arn):
    '''Retrieves IAM last-accessed details for services the given user/group/role ARN'''
    job = iam.generate_service_last_accessed_details(Arn=arn)
    job_id = job['JobId']
    marker = None
    service_results = []
    while True:
        result = iam.get_service_last_accessed_details(JobId=job_id)
        if result['JobStatus'] == 'IN_PROGRESS':
            print("Awaiting job")
            continue
        elif result['JobStatus'] == 'FAILED':
            raise Exception(f"Could not get access information for {arn}")
        else:
            service_results.extend(paginate_access_details(job_id, result))
            break
        time.sleep(5)
    return service_results

This list of services has plenty of useful information, but most interesting for us is the LastAuthenticated field. This has the date of the last time a user used the service, like this:

{
    "ServiceName": "Simple Workflow Service",
    "LastAuthenticated": "2018-08-17-.....",
    "ServiceNamespace": "swf",
    "LastAuthenticatedEntity": ".......",
    "TotalAuthenticatedEntities": 123
}

But, if instead of accessing SWF on August 17th, 2018 the user in question had never used SWF at all, there won’t be a LastAuthenticated date. We can write some Python that will take the list of services and filter out all the services that have been used.

def never_accessed_services_check(iam, arn):
    # use the last_access_details function to get all the services
    service_results = get_iam_last_access_details(iam, arn)
    never_accessed = [
        x for x in service_results
        # filter out results that have an authentication date
        if 'LastAuthenticated' not in x
    ]
    if len(never_accessed) > 0:
        # Oh no! some services have never been accessed that we have permissions for
        return (
            'NON_COMPLIANT',
            "Services " + ', '.join(f"'{x['ServiceNamespace']}'" for x in never_accessed) + " have never been accessed",
        )

    return 'COMPLIANT', 'IAM entity has accessed all allowed services'

This function will take an ARN and a boto3 IAM client and return a COMPLIANT or NON_COMPLIANT status for the resource. That’s the full Python code that we need in order to take an ARN, list the user’s access history, and decide whether they have more access than they have needed historically. This is a great tool to find easy ways for us to follow the Principle of Least Privilege with our users and roles.

From a Script to Custom Config Rules

AWS has a helpful command-line tool available for working with custom AWS Config Rules called the “Rule Development Kit” or RDK. It has some default templates for deploying Lambda functions, as well as code samples. You can find the RDK on Github and install it for yourself with pip install rdk. Once it’s installed, I can turn on AWS Config from the command line, create new rule code in Python (or NodeJS or Java), and deploy my custom code. To take the rule we’ve made and deploy it, we’ll create a rule called IAM_ALLOWS_UNUSED_SERVICES.

# rdk -r us-east-2 create IAM_ALLOWS_UNUSED_SERVICES \
    --runtime python3.6 \
    --resource-types AWS::IAM::Role,AWS::IAM::User,AWS::IAM::Group \
    --maximum-frequency TwentyFour_Hours
Running create!
Local Rule files created.

Now we want to put our own code into the template, so we’ll open IAM_ALLOWS_UNUSED_SERVICES.py and replace the default evaluate_compliance function with our own.

def evaluate_compliance(event, configuration_item, valid_rule_parameters):
    '''Put our custom code in a separate file so it's easier to pack up with our
    rule, or share between multiple rules'''
    import iam_rule_helpers

    iam = get_client('iam', event)

    compliance, annotation = iam_rule_helpers.never_accessed_services_check(iam, configuration_item['configuration']['arn'])
    return build_evaluation_from_config_item(
        configuration_item,
        compliance,
        annotation=annotation
    )

Because the IAM Access Advisor is such a new feature and iam.generate_service_last_accessed_details is so new in the boto3 SDK, we need to install a newer boto3 so we can use these features in our rule.

# pip install -t IAM_ALLOWS_UNUSED_SERVICES/ boto3
# rdk -r us-east-2 deploy IAM_ALLOWS_UNUSED_SERVICES
Running deploy!
Zipping IAM_ALLOWS_UNUSED_SERVICES
Uploading IAM_ALLOWS_UNUSED_SERVICES
Upload complete.
Creating CloudFormation Stack for IAM_ALLOWS_UNUSED_SERVICES
Waiting for CloudFormation stack operation to complete...
CloudFormation stack operation complete.
Config deploy complete.

Checking Our Work

In the AWS Config console, once we’ve deployed our rules the AWS Config service will list all the IAM Roles, IAM Groups, and IAM Users in the account and run our custom code to check whether they have access that’s never been used.

AWS Config dashboard for the IAM_ALLOWS_UNUSED_SERVICES rule

One of the semi-hidden features in AWS Config is the little “more information” tool tip on non-compliant resources. It’s a way for our Python code to communicate with auditing users about specific issues that are causing the IAM resource to fail our tests.

Config rule non-compliance results

In this case, we can see the GitlabRunnerRole hasn’t used the s3 permissions that it’s been granted to it. This is a sign that maybe GitLab doesn’t need S3 permissions at all, and would be an opportunity to tighten access on that user.

Tightening Access Controls

Removing a user’s permissions can be as simple as adding an inline policy, such as:

{
  "Effect": "Deny",
  "Action": "s3:*",
  "Resource": "*"
}

When added to our GitlabRunnerRole, that policy statement will remove the extra permissions without needing to edit other policies that might be shared between GitlabRunnerRole and other roles/users.

Making More Rules

Beyond just checking for services that have never been accessed, we could use the Access Advisor data to check whether the user has stopped using access. There could be a rule enforcing that allowed access has been used in the last 90 or 180 days, or that IAM access that has been unused for 30 days should be revoked.

The Access Advisor is a gold mine of security-relevant information, and some revocations could be automated, such as access not used in 12 months. Denials could be rolled out as inline policies on the affected roles, then rolled back manually if users filed requests to have it granted again.

Try out the IAM_ALLOWS_UNUSED_SERVICES rule in your own account from the excess-access-exorcist Github repo, or build a different check to find old or outdated policies in your AWS account.