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 anyConsoleLogin. - The alarm never fired, even after the root login. The
FilterPatternis not matching the root sign-in, soRootAccountUsageCountnever moves. Check that the pattern is wrapped in{ }, that each field path starts with$., thatNOT EXISTSsits 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 keptTreatMissingData: notBreaching;DefaultValue: 0does 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 withNOT 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: 0reports a zero in periods where logs arrive and none match. The alarm’sTreatMissingData: notBreachingcovers 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’suserIdentity.typeisAssumedRolewithsessionContext.assumedRoottrue, so this filter does not match it, though the privileged actions in the target member account do carry typeRoot. GuardDuty raisesPolicy:IAMUser/RootCredentialUsageandPolicy:IAMUser/ShortTermRootCredentialUsageover 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.