Get the Zoom link
Cloud Security Office Hours Banner

YARA-L 2.0, hands-on

YARA-L 2.0 is the language of Google Security Operations for detection rules, searches and dashboards. Its rules match events after a parser has rewritten them into Google's Unified Data Model, so writing one means knowing where each of your fields ended up (the file-scanning YARA it is named after is covered in Sigma and YARA).

Jump to the walk-through All how-to guides

· · Vendor-neutral

Time: ~90 minutes  ·  Difficulty: Intermediate  ·  You need: a text editor to read the rules against the lab file. To run them: a Google SecOps tenant you may put test data in (not production), and the secops command-line interface (CLI) from Google's Python software development kit (SDK).

How this page was checked: YARA-L runs only inside a Google SecOps tenant. Google publishes no engine you can run locally, and even checking a rule's syntax is a call to the Google SecOps application programming interface (API), so nothing on this page has been executed: not the rules, not the searches, not the secops commands. Every rule was checked against Google's YARA-L documentation, Google's field-mapping reference for its CloudTrail parser (page updated 24 September 2026) and Google's published rules in chronicle/detection-rules; where those sources disagree, the page says so. Expected results are worked out from the lab file, not captured from Google SecOps. The secops commands and flags were checked against the CLI's own --help (SDK 0.44.1).

Disclosure: the site's author works at Wiz, which Google owns (the acquisition completed on 11 March 2026). This page teaches the language; it does not compare Google SecOps with other security information and event management (SIEM) platforms.

A detection language that reads a data model, not your logs

Google Security Operations (Google SecOps, formerly Chronicle) is Google's SIEM. YARA-L 2.0 is its query language. Detection engineers write rules in it, which Google SecOps checks against incoming events, recording a detection each time a rule's pattern holds; analysts use the same language to search and to build dashboards. Google publishes several hundred example rules, with a style guide, in its chronicle/detection-rules repository.

The one idea to hold on to: a rule never reads a log as it arrived. A parser first rewrites every log into the Unified Data Model (UDM), a shared set of field names such as principal.ip and metadata.product_event_type, so that one rule can cover many log sources. The price is that you must know which UDM field each of your fields landed in, and that answer belongs to the parser, which Google updates. A rule that names a UDM field your parser leaves empty is accepted, runs, and never fires.

Despite the name, YARA-L is not YARA. It borrows YARA's rule name { meta: ... condition: } layout, but YARA matches bytes in files and YARA-L matches events in logs.

On this page

  1. Rules read UDM events, not log lines
  2. Walk-through: five detections on one lab file
  3. Hands-on exercises
  4. Common mistakes
  5. Where next

Rules read UDM events, not log lines

A rule describes the events you want to hear about. It is built from named sections in a fixed order: meta (notes for people), events (the tests an event must pass), match (how to group events, and over what time window), outcome (values to compute for each detection) and condition (which events must exist for a detection), optionally followed by options. Only meta, events and condition are required. Here is the smallest useful rule for the lab file used below:

rule lab_stop_logging_seen {
  meta:
    description = "Any StopLogging call"
  events:
    $trail.metadata.product_event_type = "StopLogging"
  condition:
    $trail
}

Read it aloud: call an event $trail; it must be a StopLogging call; one such event is enough. Names that start with $ are variables, and $trail is an event variable: it stands for any event that passes every line under events. In the condition, $trail means "at least one such event exists". In the lab file exactly one event passes: the call at 10:09:03 that switched the audit trail off.

Now look at the field name. CloudTrail calls it eventName; the Google SecOps CloudTrail parser copied it into the UDM field metadata.product_event_type before any rule ran, and it does the same with the other fields it knows. Google lists those moves in its field-mapping reference for the CloudTrail parser. Every CloudTrail event gets metadata.vendor_name = "AMAZON" and metadata.product_name = "AWS CloudTrail"; these are the other moves this page relies on:

In the lab (CloudTrail)UDM field (Google's current reference)In Google's older rules
eventNamemetadata.product_event_typesame
eventTimemetadata.event_timestamp (.seconds to compare two events)same
sourceIPAddressprincipal.ip (a list: an event can carry several addresses)same
userIdentity.userName, the callerprincipal.user.user_display_name; for sign-ins, target.user.user_display_namesame
errorCodesecurity_result.summarysecurity_result.rule_id
responseElements.ConsoleLogin (Success or Failure)security_result.severity_detailstested as security_result.action = "ALLOW" or "BLOCK"
additionalEventData.MFAUsedextensions.auth.auth_detailssame field, value written "MFAUsed: No"
the user created, given a key or given a policytarget.user.user_display_namea key's user in target.user.userid
requestParameters.policyArn; the trail named in StopLoggingtarget.resource.namesame

The third column matters. Google's CloudTrail rules were last changed in January 2025; in October 2025 Google released a new version of the parser, and the reference lists what moved, including the error code. A rule copied from Google's own library can therefore compile, run and never fire. Where the current reference is silent, this page uses the form Google's rules show and says so: the only published form of the multi-factor authentication (MFA) value is "MFAUsed: No". Google updates its parsers monthly and a tenant can opt out or roll back, so the version in your tenant is the one that counts. With a tenant and the lab file from step 1, this runs your tenant's active CloudTrail parser over the 24 lab lines and prints the UDM event it makes of each:

secops parser run --log-type AWS_CLOUDTRAIL --logs-file cloudtrail-lab.jsonl

Run it before trusting any rule on this page, and again whenever a rule that used to fire goes quiet.

Walk-through: five detections on one lab file

The lab file holds 24 Amazon Web Services (AWS) CloudTrail events from one morning. A continuous integration (CI) user, build-bot, has leaked its access key. From 203.0.113.50 someone checks whose key it is, probes Identity and Access Management (IAM), Amazon Elastic Compute Cloud (EC2) and Secrets Manager and is mostly refused, lists Simple Storage Service (S3) buckets, then creates a user backup-svc, attaches AdministratorAccess to it, gives it an access key and uses that new identity to stop the CloudTrail trail. Around the attack runs ordinary work: alice signs in with MFA and creates svc-reporting, whose key comes hours later; bob signs in without MFA; someone fails to sign in as alice from 192.0.2.77; a reports-app role is refused one S3 object three times in half an hour; and a CI role lists instances. The five detections this series builds on every page:

1. Get the lab file, and a tenant to run it in

Every rule below is written against this file. Create it exactly as shown:

mkdir -p ~/yaral-lab && cd ~/yaral-lab

cat > cloudtrail-lab.jsonl <<'EOF'
{"eventTime":"2026-09-15T08:55:12Z","eventSource":"signin.amazonaws.com","eventName":"ConsoleLogin","awsRegion":"us-east-1","sourceIPAddress":"198.51.100.20","userAgent":"Mozilla/5.0","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/alice","userName":"alice"},"responseElements":{"ConsoleLogin":"Success"},"additionalEventData":{"MFAUsed":"Yes"}}
{"eventTime":"2026-09-15T09:02:40Z","eventSource":"iam.amazonaws.com","eventName":"CreateUser","awsRegion":"us-east-1","sourceIPAddress":"198.51.100.20","userAgent":"AWS Internal","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/alice","userName":"alice"},"requestParameters":{"userName":"svc-reporting"},"readOnly":false}
{"eventTime":"2026-09-15T09:03:05Z","eventSource":"s3.amazonaws.com","eventName":"ListBuckets","awsRegion":"us-east-1","sourceIPAddress":"198.51.100.20","userAgent":"AWS Internal","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/alice","userName":"alice"},"readOnly":true}
{"eventTime":"2026-09-15T09:10:22Z","eventSource":"ec2.amazonaws.com","eventName":"DescribeInstances","awsRegion":"us-east-1","sourceIPAddress":"192.0.2.10","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"AssumedRole","arn":"arn:aws:sts::111122223333:assumed-role/ci-deploy/runner-4821"},"readOnly":true}
{"eventTime":"2026-09-15T09:15:47Z","eventSource":"signin.amazonaws.com","eventName":"ConsoleLogin","awsRegion":"us-east-1","sourceIPAddress":"198.51.100.21","userAgent":"Mozilla/5.0","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/bob","userName":"bob"},"responseElements":{"ConsoleLogin":"Success"},"additionalEventData":{"MFAUsed":"No"}}
{"eventTime":"2026-09-15T09:20:03Z","eventSource":"signin.amazonaws.com","eventName":"ConsoleLogin","awsRegion":"us-east-1","sourceIPAddress":"192.0.2.77","userAgent":"Mozilla/5.0","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/alice","userName":"alice"},"errorMessage":"Failed authentication","responseElements":{"ConsoleLogin":"Failure"},"additionalEventData":{"MFAUsed":"No"}}
{"eventTime":"2026-09-15T09:31:10Z","eventSource":"s3.amazonaws.com","eventName":"GetObject","awsRegion":"us-east-1","sourceIPAddress":"192.0.2.20","userAgent":"Boto3/1.35.10","userIdentity":{"type":"AssumedRole","arn":"arn:aws:sts::111122223333:assumed-role/reports-app/i-0a12b34c56d78e90f"},"errorCode":"AccessDenied","requestParameters":{"bucketName":"finance-reports","key":"2026/09/summary.csv"},"readOnly":true}
{"eventTime":"2026-09-15T09:47:55Z","eventSource":"s3.amazonaws.com","eventName":"GetObject","awsRegion":"us-east-1","sourceIPAddress":"192.0.2.20","userAgent":"Boto3/1.35.10","userIdentity":{"type":"AssumedRole","arn":"arn:aws:sts::111122223333:assumed-role/reports-app/i-0a12b34c56d78e90f"},"errorCode":"AccessDenied","requestParameters":{"bucketName":"finance-reports","key":"2026/09/summary.csv"},"readOnly":true}
{"eventTime":"2026-09-15T10:01:30Z","eventSource":"s3.amazonaws.com","eventName":"GetObject","awsRegion":"us-east-1","sourceIPAddress":"192.0.2.20","userAgent":"Boto3/1.35.10","userIdentity":{"type":"AssumedRole","arn":"arn:aws:sts::111122223333:assumed-role/reports-app/i-0a12b34c56d78e90f"},"errorCode":"AccessDenied","requestParameters":{"bucketName":"finance-reports","key":"2026/09/summary.csv"},"readOnly":true}
{"eventTime":"2026-09-15T10:02:05Z","eventSource":"sts.amazonaws.com","eventName":"GetCallerIdentity","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"readOnly":true}
{"eventTime":"2026-09-15T10:02:31Z","eventSource":"iam.amazonaws.com","eventName":"ListUsers","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"errorCode":"AccessDenied","readOnly":true}
{"eventTime":"2026-09-15T10:02:38Z","eventSource":"iam.amazonaws.com","eventName":"ListRoles","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"errorCode":"AccessDenied","readOnly":true}
{"eventTime":"2026-09-15T10:02:52Z","eventSource":"iam.amazonaws.com","eventName":"GetAccountAuthorizationDetails","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"errorCode":"AccessDenied","readOnly":true}
{"eventTime":"2026-09-15T10:03:05Z","eventSource":"ec2.amazonaws.com","eventName":"DescribeInstances","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"errorCode":"Client.UnauthorizedOperation","readOnly":true}
{"eventTime":"2026-09-15T10:03:09Z","eventSource":"ec2.amazonaws.com","eventName":"DescribeVpcs","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"errorCode":"Client.UnauthorizedOperation","readOnly":true}
{"eventTime":"2026-09-15T10:03:14Z","eventSource":"ec2.amazonaws.com","eventName":"DescribeSecurityGroups","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"errorCode":"Client.UnauthorizedOperation","readOnly":true}
{"eventTime":"2026-09-15T10:03:22Z","eventSource":"secretsmanager.amazonaws.com","eventName":"ListSecrets","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"errorCode":"AccessDenied","readOnly":true}
{"eventTime":"2026-09-15T10:03:40Z","eventSource":"s3.amazonaws.com","eventName":"ListBuckets","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"readOnly":true}
{"eventTime":"2026-09-15T10:06:12Z","eventSource":"iam.amazonaws.com","eventName":"CreateUser","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"requestParameters":{"userName":"backup-svc"},"readOnly":false}
{"eventTime":"2026-09-15T10:06:30Z","eventSource":"iam.amazonaws.com","eventName":"AttachUserPolicy","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"requestParameters":{"userName":"backup-svc","policyArn":"arn:aws:iam::aws:policy/AdministratorAccess"},"readOnly":false}
{"eventTime":"2026-09-15T10:06:51Z","eventSource":"iam.amazonaws.com","eventName":"CreateAccessKey","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/build-bot","userName":"build-bot"},"requestParameters":{"userName":"backup-svc"},"responseElements":{"accessKey":{"userName":"backup-svc","accessKeyId":"AKIAIOSFODNN7EXAMPLE","status":"Active"}},"readOnly":false}
{"eventTime":"2026-09-15T10:09:03Z","eventSource":"cloudtrail.amazonaws.com","eventName":"StopLogging","awsRegion":"us-east-1","sourceIPAddress":"203.0.113.50","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/backup-svc","userName":"backup-svc"},"requestParameters":{"name":"arn:aws:cloudtrail:us-east-1:111122223333:trail/org-trail"},"readOnly":false}
{"eventTime":"2026-09-15T10:40:18Z","eventSource":"ec2.amazonaws.com","eventName":"DescribeInstances","awsRegion":"us-east-1","sourceIPAddress":"192.0.2.10","userAgent":"aws-cli/2.17.40","userIdentity":{"type":"AssumedRole","arn":"arn:aws:sts::111122223333:assumed-role/ci-deploy/runner-4821"},"readOnly":true}
{"eventTime":"2026-09-15T11:40:26Z","eventSource":"iam.amazonaws.com","eventName":"CreateAccessKey","awsRegion":"us-east-1","sourceIPAddress":"198.51.100.20","userAgent":"AWS Internal","userIdentity":{"type":"IAMUser","arn":"arn:aws:iam::111122223333:user/alice","userName":"alice"},"requestParameters":{"userName":"svc-reporting"},"responseElements":{"accessKey":{"userName":"svc-reporting","accessKeyId":"AKIAI44QH8DHBEXAMPLE","status":"Active"}},"readOnly":false}
EOF

Reading the rules against the file needs nothing more. Running them needs a Google SecOps tenant where test data is welcome: these events describe an attack, and a tenant with live rules could raise real alerts about them. Install the SDK, sign in with Google Cloud application default credentials, point the CLI at your tenant and send the file in as CloudTrail logs. The SDK splits the file into one log per line, and the first ingest creates a forwarder named Wrapper-SDK-Forwarder in the tenant if none exists:

python3 -m venv .venv && . .venv/bin/activate
python3 -m pip install secops
gcloud auth application-default login
secops config set --customer-id "YOUR_INSTANCE_ID" --project-id "YOUR_PROJECT_ID" --region "us"
secops log ingest --type AWS_CLOUDTRAIL --file cloudtrail-lab.jsonl
secops search --query 'metadata.product_name = "AWS CloudTrail"' \
  --start-time 2026-09-15T08:00:00Z --end-time 2026-09-15T12:00:00Z

The last command should return all 24 events. A search is written in YARA-L too, without event variables or a meta section. If it returns fewer, stop here: every result below assumes all 24 arrived.

2. A complete rule: trail tampering (D1)

The rule in the model section works, but a rule you deploy says more. Save this as lab_cloudtrail_logging_tampered.yaral:

rule lab_cloudtrail_logging_tampered {
  meta:
    author = "csoh.org lab"
    description = "CloudTrail logging stopped, deleted or reconfigured"
    severity = "High"
    priority = "High"
    tactic = "TA0112"
    technique = "T1685.002"
  events:
    $trail.metadata.vendor_name = "AMAZON"
    $trail.metadata.product_name = "AWS CloudTrail"
    (
      $trail.metadata.product_event_type = "StopLogging" or
      $trail.metadata.product_event_type = "DeleteTrail" or
      $trail.metadata.product_event_type = "UpdateTrail" or
      $trail.metadata.product_event_type = "PutEventSelectors"
    )
  outcome:
    $risk_score = 85
    $trail_name = $trail.target.resource.name
    $principal_ip = array_distinct($trail.principal.ip)
  condition:
    $trail
}

meta is free text for people and tooling. Google's rule style guide asks for an author, description, severity and priority, and a MITRE ATT&CK tactic and technique (entries in MITRE's public catalogue of attacker behaviour) where one applies: here T1685.002 Disable or Modify Cloud Log, under the Defense Impairment tactic (TA0112). Google's own version of this rule cites T1562.008 under Defense Evasion, recorded as ATT&CK v13.1; ATT&CK v19 revoked T1562.008 in favour of T1685.002 and split Defense Evasion into Stealth (which kept TA0005) and Defense Impairment, so check copied tags as you would copied fields. To save space, later rules on this page keep only a description, a severity and the ATT&CK lines.

Lines under events must all hold; the parentheses make an either-or group. outcome computes values that travel with each detection: $risk_score is the score Google SecOps shows (the style guide suggests 35, 65, 85 and 95 from low to critical), and because principal.ip is a list, array_distinct gathers its values. There is no success test: a failed attempt to stop logging deserves an alert too, and Google's current reference does not say the parser sets security_result.action, the field Google's older rules test.

Expected on the lab: one detection, for the StopLogging call at 10:09:03 by backup-svc from 203.0.113.50, naming the trail. The other 23 events are the negative case. With no match section the rule fires once per matching event; the style guide recommends one so that related events land in a single detection.

3. A nested value, and a naive rule that fires too often (D4)

MFA status sits in a nested CloudTrail field, additionalEventData.MFAUsed, which the parser moves to extensions.auth.auth_details. The obvious rule tests only that:

rule lab_mfa_no_naive {
  meta:
    description = "Console sign-in without MFA (naive)"
  events:
    $login.metadata.product_event_type = "ConsoleLogin"
    $login.extensions.auth.auth_details = "MFAUsed: No"
  condition:
    $login
}

Expected on the lab: two detections: bob at 09:15:47, and the failed attempt on alice at 09:20:03 from 192.0.2.77, a sign-in that never happened. A failed sign-in carries the MFA detail too, and Google's reference maps it whatever the result. The fix also tests the result, as AWS's own sign-in alarm, Security Hub control CloudWatch.3, does; the current parser writes the result to security_result.severity_details:

rule lab_console_login_without_mfa {
  meta:
    description = "Successful AWS console sign-in without MFA"
    severity = "Low"
    tactic = "TA0001"
    technique = "T1078.004"
  events:
    $login.metadata.product_name = "AWS CloudTrail"
    $login.metadata.event_type = "USER_LOGIN"
    $login.metadata.product_event_type = "ConsoleLogin"
    $login.extensions.auth.auth_details = "MFAUsed: No"
    $login.security_result.severity_details = "Success"
  outcome:
    $signed_in_user = $login.target.user.user_display_name
    $principal_ip = array_distinct($login.principal.ip)
  condition:
    $login
}

Expected on the lab: one detection, bob at 09:15:47 from 198.51.100.21. The negative cases are alice at 08:55:12, who used MFA, and the failed attempt. Comparisons are exact, including case; Google documents a nocase modifier for values whose case varies. Two lines rest on one source each, so find both in the parser run output: the "MFAUsed: No" form comes from Google's older rule, and severity_details from the current reference (the older rules test security_result.action = "ALLOW"). For sign-ins the user is in target.user, the account signed in to.

4. Counting, and a rule that cannot fire (D2)

"Five or more refusals from one address within 10 minutes" needs three new pieces. A placeholder variable such as $ip captures a field's value. match: $ip over 10m gathers the events that share a value into groups, one per value and time window, which makes $ip a match variable. In the condition, #denied is the number of $denied events in a group. This first version matches the one error code everyone thinks of:

rule lab_denied_burst_naive {
  meta:
    description = "Five or more AccessDenied errors from one address in 10 minutes"
  events:
    $denied.metadata.product_name = "AWS CloudTrail"
    $denied.security_result.summary = "AccessDenied"
    $denied.principal.ip = $ip
  match:
    $ip over 10m
  condition:
    #denied >= 5
}

Expected on the lab: nothing. 203.0.113.50 has four AccessDenied events and 192.0.2.20 has three. The rule compiles, runs and stays silent, exactly as it would on a quiet morning. The control drops the threshold and asks which error codes exist at all. In Google SecOps that is a statistics search, where match groups and outcome counts:

secops stats --start-time 2026-09-15T08:00:00Z --end-time 2026-09-15T12:00:00Z \
  --query 'metadata.product_name = "AWS CloudTrail"
security_result.summary != ""
match:
  security_result.summary
outcome:
  $events = count(metadata.id)
order:
  $events desc'

On the lab data it should list AccessDenied 7 and Client.UnauthorizedOperation 3: EC2 reports a refusal with its own code, and three of the attacker's seven refusals were EC2 calls. If it lists no error codes, your tenant's parser keeps them elsewhere: the earlier parser used security_result.rule_id, so change the field in the search and in the rules.

A rule that returns nothing and a rule that cannot work return the same thing. The naive rule is right about every event it sees; it never sees the EC2 refusals. Before you trust a quiet rule, remove its threshold or its value test and look at what the data contains.

The fix matches every refusal code. AWS's own alarm for unauthorized API calls, Security Hub control CloudWatch.2, matches two families of error code, those ending in UnauthorizedOperation and those starting with AccessDenied, so the rule lists two the lab does not contain (AccessDeniedException, and UnauthorizedOperation without the prefix). It also reports what it counted:

rule lab_denied_call_burst {
  meta:
    description = "Five or more denied API calls from one address in 10 minutes"
    severity = "Medium"
    tactic = "TA0007"
    technique = "T1087.004, T1580"
  events:
    $denied.metadata.product_name = "AWS CloudTrail"
    (
      $denied.security_result.summary = "AccessDenied" or
      $denied.security_result.summary = "AccessDeniedException" or
      $denied.security_result.summary = "Client.UnauthorizedOperation" or
      $denied.security_result.summary = "UnauthorizedOperation"
    )
    $denied.principal.ip = $ip
  match:
    $ip over 10m
  outcome:
    $risk_score = max(65)
    $denied_calls = count_distinct($denied.metadata.id)
    $error_codes = array_distinct($denied.security_result.summary)
  condition:
    #denied >= 5
}

Expected on the lab: one detection, for 203.0.113.50: seven denied API calls within 51 seconds, three of them EC2's, all by build-bot. 192.0.2.20 stays silent: its three refusals are spread over half an hour. With a match section, outcome works on the whole group, so every value is aggregated, even the constant score, as Google's rules do.

over 10m is not a row of clock-aligned ten-minute boxes. Google's windowing documentation calls these hop windows: they overlap, and the engine chooses their spacing and alignment, so a burst is not cut in two by a boundary. $ip by 10m asks for fixed blocks instead (a preview feature when this page was written), and over 10m after $e anchors a window to one event. Exercise 4 compares the first two.

5. Two events, one new user, ten minutes (D3)

A rule about two different events declares one event variable for each and joins them: here the placeholder $new_user must take the same value in the CreateUser event and in the CreateAccessKey event. A shared value says "same user", not "this one first", so a timestamp comparison puts them in order, and the window bounds the gap:

rule lab_new_user_given_access_key {
  meta:
    description = "IAM user created and given an access key within 10 minutes"
    severity = "Medium"
    tactic = "TA0003"
    technique = "T1136.003, T1098.001"
  events:
    $create.metadata.product_name = "AWS CloudTrail"
    $create.metadata.product_event_type = "CreateUser"
    $create.target.user.user_display_name = $new_user
    $key.metadata.product_name = "AWS CloudTrail"
    $key.metadata.product_event_type = "CreateAccessKey"
    $key.target.user.user_display_name = $new_user
    // the user has to exist before its key does
    $create.metadata.event_timestamp.seconds < $key.metadata.event_timestamp.seconds
  match:
    $new_user over 10m
  outcome:
    $risk_score = max(65)
    $created_by = array_distinct($create.principal.user.user_display_name)
    $principal_ip = array_distinct($key.principal.ip)
  condition:
    $create and $key
}

Expected on the lab: one detection, for backup-svc: created at 10:06:12 and given a key at 10:06:51, 39 seconds apart, both by build-bot from 203.0.113.50. svc-reporting is the negative case: alice created it at 09:02:40 and gave it a key at 11:40:26, 2 hours 37 minutes 46 seconds later, so no ten-minute window holds both. Widen the window to over 24h, a size Google's rules also use, and svc-reporting appears as well. The window is detection logic, not a performance setting.

Check the join field before you rely on it. Google's current reference puts the user named in both calls in target.user.user_display_name, and one of Google's older rules reports that field for them, but another reads a key's user from target.user.userid. If your parser writes either one elsewhere, the join never succeeds and the rule compiles, runs and stays silent. Look at lab lines 19 and 21 in the parser run output.

6. From rule to running detection, tested both ways

A rule file becomes a live detection in three moves: check that it compiles, create it, and switch it on. The Rules Editor in Google SecOps does the same; the CLI makes it repeatable. The response to rule create includes the rule's ID, which starts ru_; the third command needs it. Start with alerting off, so the rule records detections without paging anyone while you judge its noise:

secops rule validate --file lab_denied_call_burst.yaral
secops rule create --file lab_denied_call_burst.yaral
secops rule update-deployment --id ru_YOUR_RULE_ID --enabled true --alerting false

How soon a live rule reports depends on its shape. Google's rule scheduling documentation says rules that need only one event (D1, D4) run within about five minutes of an event arriving, while rules whose condition needs several events (D2, D3) run on a schedule, every hour by default, and run again about four hours later to catch late logs.

Validation proves only that a rule compiles, and when Google SecOps reports where it does not, the CLI prints the line and column. It cannot know that your parser leaves a field empty, or that the value you match never occurs. For that, run the rules over data: secops rule test runs rule text you have not created over a time range and prints what it matches.

for r in lab_cloudtrail_logging_tampered lab_mfa_no_naive lab_console_login_without_mfa \
         lab_denied_burst_naive lab_denied_call_burst lab_new_user_given_access_key; do
  echo "== $r"
  secops rule test --file "$r.yaral" \
    --start-time 2026-09-15T08:00:00Z --end-time 2026-09-15T12:00:00Z
done

secops rule-retrohunt create --rule-id ru_YOUR_RULE_ID \
  --start-time 2026-09-15T08:00:00Z --end-time 2026-09-15T12:00:00Z

Check each result against its "Expected on the lab" line above, which names both halves of the test: the events that must fire, and the events that must not. The naive rules are tests too: lab_mfa_no_naive must return two sign-ins and lab_denied_burst_naive nothing, and if either does otherwise, your data is not the lab's. The last command is a retrohunt: it runs a rule you have already created over past data, and Google recommends keeping alerting off while you do, since each detection would otherwise raise an alert. The strongest negative case is a near miss, and where the lab has none, make one: copy line 22, change StopLogging to GetTrailStatus, ingest it and confirm the tampering rule stays quiet. A rule that has only met its positive case has been tested in the one direction that cannot fail.

Hands-on exercises

Exercise 1

Write a rule that fires when the AWS managed policy AdministratorAccess is attached to an IAM user (D5). Which lab event must it catch, and how do you give it a negative case?

Show the answer

The policy's Amazon Resource Name (ARN) lands in target.resource.name, as Google's aws_iam_administrator_access_policy_attached rule shows and the current reference confirms:

rule lab_admin_policy_attached {
  meta:
    description = "AdministratorAccess attached to an IAM user"
    severity = "High"
    tactic = "TA0004"
    technique = "T1098.003"
  events:
    $attach.metadata.product_name = "AWS CloudTrail"
    $attach.metadata.product_event_type = "AttachUserPolicy"
    $attach.target.resource.name = "arn:aws:iam::aws:policy/AdministratorAccess"
  outcome:
    $attached_by = $attach.principal.user.user_display_name
    $attached_to_user = $attach.target.user.user_display_name
  condition:
    $attach
}

It must catch 10:06:30, when build-bot attached the policy to backup-svc, and $attached_to_user should read backup-svc, the field where the reference puts the user named in AttachUserPolicy. Google's version adds AttachGroupPolicy and AttachRolePolicy in an either-or group, which covers groups and roles as well. The lab has no other attachment, so build the negative: copy line 20 with ReadOnlyAccess in place of AdministratorAccess, ingest it and confirm silence. The tag is T1098.003 Additional Cloud Roles; Google's version tags T1078.004 Valid Accounts instead, a reminder that a tag is a judgement to review, not a fact to copy.

Exercise 2

You paste Google's aws_console_login_without_mfa rule unchanged. Its events end with $login.additional.fields["recipientAccountId"] = $account_id, and its match section is $account_id over 1h. Tested over the lab's morning it returns nothing, though bob's sign-in is right there. Why, and how would you have found out?

Show the answer

The lab events are trimmed and carry no recipientAccountId, so $account_id is empty for every event. Google's match syntax page says Google SecOps quietly filters out zero values (an empty string, a 0) for every placeholder used in match, unless the rule sets allow_zero_values = true in an options section. So bob's sign-in never reaches a group and nothing fires: no error, no warning. Real CloudTrail events carry the field, so the rule suits real data and is silent on this data, and the same happens in production when you group on a field one of your sources never fills.

To find it, take parts away until something fires: delete the match section and test again. If bob still does not appear, remove the older parser's success test, security_result.action = "ALLOW", next. The last part you removed is the one to look at.

Exercise 3

A colleague's rule should find a source address acting as more than one IAM identity within an hour. Its events set $call.principal.ip = $ip and $call.principal.user.user_display_name = $identity, its match section is $ip over 1h, and its condition is $call and #identity > 1. On the lab it reports 203.0.113.50 and 198.51.100.20. Which report is wrong, and why?

Show the answer

203.0.113.50 is right: it acted as build-bot from 10:02 and as backup-svc at 10:09. 198.51.100.20 is wrong. #identity counts the distinct values the placeholder took in the group (the form Google's aws_successful_console_authentication_from_multiple_ips uses for addresses), and one of those values is empty: alice's sign-in at 08:55:12 keeps her name in target.user, so its caller name is empty, and an empty name beside alice counts as two. Only match variables lose empty values automatically, which is why $new_user in D3 needed no test and $identity does. Add $call.principal.user.user_display_name != "" to the events and only 203.0.113.50 remains.

Exercise 4

Change D2's match section from $ip over 10m to $ip by 10m. Does the answer on the lab change? Then describe seven refusals that over 10m catches and by 10m misses.

Show the answer

On the lab, nothing changes. by 10m cuts time into fixed blocks (10:00:00 to 10:09:59, 10:10:00 to 10:19:59, and so on). All seven of the attacker's refusals fall between 10:02:31 and 10:03:22, inside one block, and 192.0.2.20's three land one per block, at 09:31, 09:47 and 10:01.

They differ at a boundary. Seven refusals between 10:09:50 and 10:10:20, four before 10:10:00 and three after, give neither block five, so the fixed-block rule stays silent; hop windows overlap, so one of them holds all seven. Fixed blocks suit counting per period (Google documents at most one detection per block for each value); hop windows suit "this many within ten minutes of each other". Choose by the question, then test a case near a boundary.

Common mistakes

Where next