Exam Room · Advanced Security Specialist

Lab: Alarm on Root-Account Console Login

· 16 min read

Cloud Security · part of The Exam Room

This is a lab in the Security Specialty hands-on track. The reference posts argue the decisions; this stands one up. Domain 1 is about detecting what is happening in an account, and few events deserve a faster look than the account root signing in to the console. Root has full access to every service and resource in the account, including the billing information, and an identity-based IAM policy takes none of it away. In a member account an SCP can deny root almost every action; in the management account it cannot, because SCPs have no effect there. So root gets hardware MFA, no access keys, and an alarm. Here we build the log group, the metric, the alarm, and the SNS topic, and you write the filter pattern. The full lab is in lab-scs-03-root-login-alarm.zip.

Before your first lab, do the one-time, once-per-account setup: run the zip’s preflight.sh to confirm your account is ready, then deploy the lab reaper, a standing backstop that auto-deletes any lab you forget to tear down after 24 hours. Every lab tags its stack for the reaper on deploy.

The scenario

Someone signs in to the AWS console as the account root. Not an IAM user, not an assumed role, but the root identity itself. CloudTrail records the ConsoleLogin with userIdentity.type set to Root, an eventType of AwsConsoleSignIn, and an additionalEventData.MFAUsed of Yes or No. AWS publishes an average of about five minutes to deliver an event, so the record reaches the log group minutes after the sign-in, not seconds. It still reaches it. Nothing is watching the log group for that shape of record, so no one is paged.

One trap sits in front of the pattern. A root sign-in is recorded in us-east-1, us-east-2 or us-west-2, whichever the sign-in was routed through, and not in the Region you work in. A single-Region trail in ap-southeast-2 never sees it at all. The trail has to be multi-Region and delivering to CloudWatch Logs before a metric filter has anything to match.

The CIS AWS Foundations Benchmark covers the rest with its root-usage monitor, a pairing that recurs across its monitoring recommendations: a metric filter that selects the interesting record out of the trail, a custom metric that counts it, and an alarm that fires on the count. For root usage there is no burst threshold to clear, because one root console login is already worth waking someone.

What you’re given

CloudFormation builds a log group with one-day retention standing in for the CloudTrail log group, a metric filter that should count root usage into a custom metric (RootAccountUsageCount in the AcmeSecurity namespace), RootAccountUsageAlarm, and the SNS topic the alarm notifies. The alarm sums the metric over a single 60-second period against a threshold of one, and carries TreatMissingData: notBreaching so it settles at OK through the quiet periods instead of INSUFFICIENT_DATA. Nothing is subscribed to the topic: the test reads the alarm state directly, and in a real account the security channel subscribes here.

What is missing is one thing, the filter pattern that decides which records count as root usage. As shipped, the filter carries a placeholder that is valid syntax (so the stack deploys) but selects on a userIdentity.type value no CloudTrail record contains, so the metric never moves and the alarm never fires. Complete, the filter looks like this:

RootUsageMetricFilter:
  Type: AWS::Logs::MetricFilter
  Properties:
    LogGroupName: !Ref TrailLogGroup
    FilterPattern: '{ ($.userIdentity.type = "Root") && ($.userIdentity.invokedBy NOT EXISTS) && ($.eventType != "AwsServiceEvent") }'
    MetricTransformations:
      - MetricNamespace: AcmeSecurity
        MetricName: RootAccountUsageCount
        MetricValue: '1'
        DefaultValue: 0

Deploying the stack as shipped succeeds, and the stack is wrong. RootAccountUsageCount never leaves zero, so a root login raises no alarm.

Your task

Close the gap in src/template.yaml by writing the filter pattern. A CloudWatch Logs metric filter can select on structured JSON, and a CloudTrail record is structured JSON. The selector is wrapped in { }, addresses fields by a path starting $., compares with = and !=, tests absence with NOT EXISTS, and joins its terms with &&. Three terms pin down root usage. First, $.userIdentity.type = "Root" says the actor is the account root itself, not an IAM user or a role. Second, $.userIdentity.invokedBy NOT EXISTS drops the records where an AWS service made the request on root’s behalf, which is the only case that carries invokedBy. Third, $.eventType != "AwsServiceEvent" drops the events AWS raises about your account rather than ones a caller made. Put together they are the CIS pattern for root usage.

Read what that matches, because it is wider than the title. Every API call signed with root credentials carries userIdentity.type of Root, so the pattern counts a root sign-in and a root DeleteBucketPolicy the same way. The console login is the case the test writes, and the one you are most likely to see first.

Leave the metric transformations alone. DefaultValue: 0 reports a zero for each period in which logs are ingested and none of them match, which stops the metric going spotty; AWS is explicit that a period with no log events at all reports nothing, which is why the alarm needs TreatMissingData: notBreaching as well. One more thing before you redeploy: a metric filter is not retroactive. It publishes data points only for events ingested after the filter was created, so each attempt needs a fresh event written after the deploy, which is what test.sh does.

Run it

./scripts/deploy.sh          # deploys src/template.yaml
./scripts/test.sh            # writes CloudTrail-shaped events and watches the alarm
./scripts/teardown.sh        # deletes everything

test.sh never launches compute, which keeps it cheap; the only running charge is the metric filter’s metric, billed as a custom metric while the stack stands. It writes CloudTrail-shaped log events with put-log-events and polls describe-alarms. First it writes an ordinary IAM-user ConsoleLogin and confirms the alarm ignores it, because an everyday login is not a page; then it writes a root ConsoleLogin, shaped like a real CloudTrail record with userIdentity.type of Root, no invokedBy, and an eventType of AwsConsoleSignIn, and confirms the alarm fires. Metric filters and alarms take a minute or three to react, so the early alarm=OK readings are normal. With the pattern in place it prints:

  t+90s  alarm=ALARM
PASS: the ordinary login was ignored and the root console login paged.

If it fails

  • The alarm fired on the ordinary IAM-user login. The pattern is matching too broadly. It has to require $.userIdentity.type = "Root", which an IAM-user record does not satisfy, rather than any ConsoleLogin.
  • The alarm never fired, even after the root login. The FilterPattern is not matching the root sign-in, so RootAccountUsageCount never moves. Check that the pattern is wrapped in { }, that each field path starts with $., that NOT EXISTS sits on $.userIdentity.invokedBy, and that the event was written after the corrected filter was deployed.
  • The alarm sits in INSUFFICIENT_DATA. No data point has reached the metric and the alarm is not treating the gap as OK. Confirm the alarm kept TreatMissingData: notBreaching; DefaultValue: 0 does not cover this, since it reports only in periods where some log event arrived.

Reveal the solution

SRC=solution ./scripts/deploy.sh && ./scripts/test.sh

What you just learned

  • CloudWatch Logs metric filters read structured JSON, not just plain text. A pattern wrapped in { } addresses fields by path, compares with = and !=, tests absence with NOT EXISTS, and joins terms with &&. That syntax turns a CloudTrail log group into an alarm source.
  • This is one alarm from the CIS AWS Foundations Benchmark’s monitoring set, which pairs a metric filter with an alarm for root usage, console sign-in without MFA, IAM policy changes, unauthorised API calls and ten more, fourteen in all under CIS v1.2.0. The shape repeats: filter the trail, count the metric, alarm on the count. Security Hub CSPM automated most of them under v1.2.0 and some under v1.4.0; from v3.0.0 every one is a manual check, so the filter is yours to build and yours to prove.
  • Root usage gets a threshold of one. A noisy application metric needs a burst threshold so a single blip does not page, but a root console login is a red flag on its own.
  • Two separate settings keep the alarm quiet rather than stuck. DefaultValue: 0 reports a zero in periods where logs arrive and none match. The alarm’s TreatMissingData: notBreaching covers the periods where no log event arrives at all, which is what an idle trail looks like.
  • Detection only: nothing in this stack stops root from being used. Prevention is hardware MFA on root, no root access keys, an SCP denying root actions in member accounts, and centralised root access management, which deletes member-account root credentials so there is no password left to sign in with.
  • Know what the pattern misses. When the management account or the delegated administrator for IAM calls sts:AssumeRoot, that event’s userIdentity.type is AssumedRole with sessionContext.assumedRoot true, so this filter does not match it, though the privileged actions in the target member account do carry type Root. GuardDuty raises Policy:IAMUser/RootCredentialUsage and Policy:IAMUser/ShortTermRootCredentialUsage over the same ground, both at default severity Low, which lands in a findings queue rather than on a phone.

Next

The rest of the hands-on lab tracks are listed in the labs README.

These posts are LLM-aided. Backbone, original writing, and structure by Craig. Research and editing by Craig + LLM. Proof-reading by Craig.