AWS · Security · Alerting

AWS Alerting & Security

Step-by-step guide: clean HTML email alerts for EC2 actions (console and inside the OS), console logins, CloudTrail tampering and IAM changes — plus a new-server checklist, scheduled start/stop and GuardDuty Runtime Monitoring. Built with EventBridge, Lambda (Python) and Amazon SES.

🚀Before You Start

Read this once. Every value below is an example — replace it with your own everywhere it appears.

1

Replace these example values

Example in this guideReplace with
123456789012Your 12-digit AWS account ID (top-right menu in the console)
ap-south-1 (Mumbai)Your main AWS region — do all steps there unless told otherwise
example.comYour email domain
[email protected]Sender address (must be on a domain verified in SES)
[email protected]Mailbox that receives the alerts
[email protected]Your IAM user name (appears only in examples)
i-0aaaaaaaaaaaaaaaaYour real instance IDs
YOUR-SESSION-MANAGER-KEY-IDYour KMS key ID (only if Session Manager uses KMS)
IST / 330Your time-zone label and UTC offset in minutes
2

Turn on CloudTrail

All “who did it” details come from CloudTrail.

  1. CloudTrail → Trails. If a trail shows Logging: On, you are done.
  2. Otherwise Create trail → any name → new S3 bucket → enable multi-region (default) → Create trail.
  3. Note the trail’s Home region — needed in Part 4.
3

Set up Amazon SES (sending email)

Lambdas send email through SES from your own address.

  1. Amazon SES (in ap-south-1) → Identities → Create identity → Domain → enter example.com.
  2. Add the 3 DKIM CNAME records SES shows to your DNS. Wait until status is Verified.
  3. Account dashboard: if it says Sandbox, click Request production access (or verify the recipient address under Identities as a stop-gap).
⚠️
In sandbox, SES only delivers to verified addresses — this is the #1 reason for “no email”.
4

Servers must be in Systems Manager

Needed for Part 2 (OS hook) and Part 7 (GuardDuty).

5

How it fits together

text — flow
EC2 action (Console/CLI) ─► CloudTrail ─► EventBridge rule ─► Lambda ─► SES ─► Email
Reboot/Shutdown inside OS ─► server hook (via SSM) ─► Lambda Function URL ─► Lambda ─► SES ─► Email
Console login / IAM change (us-east-1) ─► forward rule ─► main-region bus ─► rule ─► Lambda ─► SES ─► Email
PartBuildsLambda
1EC2 start/stop/reboot/terminate alertsEC2-State-Alert
2Reboot/shutdown from inside the OS (SSH/RDP)EC2-State-Alert
3Console login alertsAWS-Login-Alert
4CloudTrail tampering + IAM change alertsAWS-Security-Alert
5New VPC / new server checklist—
6Scheduled start/stop—
7GuardDuty Runtime Monitoring—
8Alert mailbox + iPhone—
ℹ️
Do the parts in order. Part 2 needs Part 1 finished.

🖥️Part 1 — EC2 State Alerts

Emails when an instance is started, stopped, rebooted or terminated — with who did it, from where, and the final status.

1

Create the Lambda

  1. Open Lambda → Create function → Author from scratch.
  2. Function name: EC2-State-Alert · Runtime: Python 3.12 (or newer) · Execution role: Create a new role with basic Lambda permissions → Create function.
  3. Configuration → General configuration → Edit → Timeout: 5 min 0 sec → Save.
ℹ️
5 minutes because the Lambda waits for the instance to finish starting/stopping before emailing.
2

Add environment variables

Configuration → Environment variables → Edit → Add environment variable for each row, then Save.
KeyValue (example)
FROM_EMAILAWS Alerts <[email protected]>
TO_EMAIL[email protected] (comma-separate for several)
SES_REGIONap-south-1 — region where your domain is verified in SES
TZ_LABELIST — label shown in emails
TZ_OFFSET_MINUTES330 — your UTC offset in minutes (IST = 330, UTC = 0, GMT+4 = 240)
3

Add permissions

  1. Configuration → Permissions → click the Role name (opens IAM).
  2. Add permissions → Create inline policy → JSON, paste the policy below.
  3. Next → Policy name: EC2-Alert-Policy → Create policy.
json — EC2-Alert-Policy
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["ec2:DescribeInstances", "ec2:CreateTags"],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": ["ses:SendEmail", "ses:SendRawEmail"],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": "lambda:InvokeFunction",
      "Resource": "arn:aws:lambda:ap-south-1:123456789012:function:EC2-State-Alert"
    }
  ]
}
4

Paste the code

Open the Code tab → select all in lambda_function.py → paste → click Deploy.
python — EC2-State-Alert · lambda_function.py
import base64
import html as htmllib
import json
import os
import time
from datetime import datetime, timedelta, timezone

import boto3

ec2 = boto3.client('ec2')
lambda_client = boto3.client('lambda')
ses = boto3.client('ses', region_name=os.environ['SES_REGION'])
FROM_EMAIL = os.environ['FROM_EMAIL']
TO_EMAIL = [e.strip() for e in os.environ['TO_EMAIL'].split(',')]
TZ_LABEL = os.environ.get('TZ_LABEL', 'IST')
LOCAL_TZ = timezone(timedelta(minutes=int(os.environ.get('TZ_OFFSET_MINUTES', '330'))))
HOOK_TOKEN = os.environ.get('HOOK_TOKEN', '')
DEDUPE_TAG = 'OSShutdownAlert'

# From AWS Console/CLI (CloudTrail): eventName -> (label, color, waiter)
API_ACTIONS = {
    'StartInstances':     ('STARTED',    '#1e8e3e', 'instance_running'),
    'StopInstances':      ('STOPPED',    '#d93025', 'instance_stopped'),
    'RebootInstances':    ('REBOOTED',   '#f29900', None),
    'TerminateInstances': ('TERMINATED', '#5f6368', 'instance_terminated'),
}

# From inside the OS (server hook): action -> (label, color, waiter)
OS_ACTIONS = {
    'REBOOT':   ('REBOOTED', '#f29900', None),
    'SHUTDOWN': ('STOPPED',  '#d93025', 'instance_stopped'),
}


def get_user(uid):
    t = uid.get('type', 'Unknown')
    if t == 'Root':
        return 'ROOT ACCOUNT'
    if t == 'IAMUser':
        return uid.get('userName', 'Unknown')
    if t == 'AssumedRole':
        role = uid.get('sessionContext', {}).get('sessionIssuer', {}).get('userName', 'Role')
        session = uid.get('arn', '').split('/')[-1]
        return f"{session} (role: {role})"
    if t == 'AWSService':
        return uid.get('invokedBy', 'AWS Service')
    return uid.get('arn', 'Unknown')


def describe(iid):
    try:
        return ec2.describe_instances(InstanceIds=[iid])['Reservations'][0]['Instances'][0]
    except Exception as e:
        print(f"Lookup failed for {iid}: {e}")
        return {}


def get_instance(iid):
    inst = describe(iid)
    return {
        'name': next((t['Value'] for t in inst.get('Tags', []) if t['Key'] == 'Name'), 'No Name'),
        'type': inst.get('InstanceType', '-'),
        'state': inst.get('State', {}).get('Name', 'unknown'),
        'private_ip': inst.get('PrivateIpAddress', '-'),
        'public_ip': inst.get('PublicIpAddress', 'None'),
    }


def wait_for_state(waiter_name, iids):
    if not waiter_name:
        return
    try:
        ec2.get_waiter(waiter_name).wait(InstanceIds=iids, WaiterConfig={'Delay': 5, 'MaxAttempts': 50})
    except Exception as e:
        print(f"Waiter stopped: {e}")


def to_ist(time_str):
    utc = datetime.strptime(time_str, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=timezone.utc)
    return utc.astimezone(LOCAL_TZ).strftime('%d-%b-%Y %I:%M:%S %p')


def send_email(subject, badge, color, rows, region, iid):
    link = f"https://{region}.console.aws.amazon.com/ec2/home?region={region}#InstanceDetails:instanceId={iid}"

    row_html = ''.join(
        f'<tr><td style="padding:8px 12px;color:#5f6368;border-bottom:1px solid #eee;">{k}</td>'
        f'<td style="padding:8px 12px;font-weight:600;border-bottom:1px solid #eee;">{htmllib.escape(str(v))}</td></tr>'
        for k, v in rows)

    html = f"""
<div style="font-family:Segoe UI,Arial,sans-serif;max-width:560px;margin:auto;border:1px solid #ddd;border-radius:8px;overflow:hidden;">
  <div style="background:#232f3e;color:#fff;padding:14px 18px;font-size:16px;font-weight:600;">EC2 Instance Status Change</div>
  <div style="padding:18px;">
    <div style="display:inline-block;background:{color};color:#fff;padding:6px 14px;border-radius:4px;font-weight:700;font-size:14px;">{badge}</div>
    <table style="width:100%;border-collapse:collapse;margin-top:16px;font-size:14px;">{row_html}</table>
    <div style="margin-top:18px;"><a href="{link}" style="background:#ff9900;color:#000;padding:8px 16px;text-decoration:none;border-radius:4px;font-weight:600;">View in AWS Console</a></div>
  </div>
  <div style="background:#f5f5f5;color:#888;font-size:12px;padding:10px 18px;">Automated alert from AWS ({region})</div>
</div>"""

    text = f"EC2 Instance Status Change\n\n{badge}\n" + \
           ''.join(f"{k}: {v}\n" for k, v in rows) + f"\nConsole: {link}\n"

    ses.send_email(
        Source=FROM_EMAIL,
        Destination={'ToAddresses': TO_EMAIL},
        Message={'Subject': {'Data': subject[:150]},
                 'Body': {'Html': {'Data': html}, 'Text': {'Data': text}}}
    )


def notify(iids, action, color, user, source_ip, action_from, event, error=None, error_msg=None):
    ist = to_ist(event['time'])
    for iid in iids:
        info = get_instance(iid)

        if error:
            base = action[:-2] if action.endswith('ED') else action
            badge, badge_color = f"{base} FAILED", '#d93025'
        else:
            badge, badge_color = action, color
        subject = f"[EC2] {info['name']} - {badge}"

        rows = [
            ('Instance Name', info['name']),
            ('Instance ID', iid),
            ('Instance Type', info['type']),
            ('Current Status', info['state'].upper()),
            ('Private IP', info['private_ip']),
            ('Public IP', info['public_ip']),
            ('Action By', user),
            ('Action From', action_from),
            ('Source IP', source_ip),
            (f'Time ({TZ_LABEL})', ist),
            ('Account', event['account']),
        ]
        if error:
            rows.append(('Error', error_msg))

        send_email(subject, badge, badge_color, rows, event['region'], iid)


def handle_api_event(event):
    """Start/Stop/Reboot/Terminate from AWS Console or CLI (CloudTrail)."""
    d = event['detail']
    name = d.get('eventName', 'UNKNOWN')
    action, color, waiter_name = API_ACTIONS.get(name, (name.upper(), '#5f6368', None))
    error = d.get('errorCode')
    error_msg = d.get('errorMessage', error)

    items = (d.get('requestParameters') or {}).get('instancesSet', {}).get('items', [])
    iids = [i['instanceId'] for i in items if 'instanceId' in i]
    if not iids:
        print("No instance IDs in event")
        return

    if not error:
        wait_for_state(waiter_name, iids)

    notify(iids, action, color,
           user=get_user(d.get('userIdentity', {})),
           source_ip=d.get('sourceIPAddress', 'Unknown'),
           action_from='AWS Console / CLI',
           event=event, error=error, error_msg=error_msg)


def handle_state_event(event):
    """Agentless fallback: OS shutdown when the server hook did not report it."""
    d = event['detail']
    iid = d.get('instance-id')
    state = d.get('state', '')
    inst = describe(iid)
    reason = inst.get('StateReason', {}).get('Code', '')

    if reason != 'Client.InstanceInitiatedShutdown':
        print(f"{iid} {state}: reason '{reason}' - not OS-initiated, skipped")
        return

    tags = {t['Key']: t['Value'] for t in inst.get('Tags', [])}
    ts = tags.get(DEDUPE_TAG, '')
    if ts.isdigit() and time.time() - int(ts) < 900:
        print(f"{iid}: already alerted by server hook, skipped")
        return

    action = 'TERMINATED' if state == 'terminated' else 'STOPPED'
    color = '#5f6368' if state == 'terminated' else '#d93025'
    notify([iid], action, color,
           user='Unknown (shutdown run inside the server)',
           source_ip='-',
           action_from='Inside OS (SSH / RDP / Terminal)',
           event=event)


def handle_os_event(event):
    """Processed asynchronously after a server hook request."""
    d = event['detail']
    iid = d.get('instance-id')
    action, color, waiter_name = OS_ACTIONS.get(d.get('action', ''), ('OS ACTION', '#5f6368', None))
    wait_for_state(waiter_name, [iid])

    notify([iid], action, color,
           user=d.get('user', 'Unknown'),
           source_ip=d.get('source-ip', 'Unknown'),
           action_from='Inside OS (SSH / RDP / Terminal)',
           event=event)


def handle_hook_request(event, context):
    """Function URL endpoint called by the server hook at reboot/shutdown."""
    headers = {k.lower(): v for k, v in (event.get('headers') or {}).items()}
    if not HOOK_TOKEN or headers.get('x-hook-token') != HOOK_TOKEN:
        return {'statusCode': 403, 'body': 'forbidden'}

    body = event.get('body') or '{}'
    if event.get('isBase64Encoded'):
        body = base64.b64decode(body).decode()
    try:
        data = json.loads(body)
    except Exception:
        return {'statusCode': 400, 'body': 'bad json'}

    iid = str(data.get('instance-id', ''))
    action = str(data.get('action', ''))
    if not iid.startswith('i-') or action not in OS_ACTIONS or not describe(iid):
        return {'statusCode': 400, 'body': 'invalid request'}

    # Mark so the agentless state-change alert does not send a duplicate
    if action == 'SHUTDOWN':
        try:
            ec2.create_tags(Resources=[iid], Tags=[{'Key': DEDUPE_TAG, 'Value': str(int(time.time()))}])
        except Exception as e:
            print(f"Tagging failed: {e}")

    os_event = {
        'source': 'custom.ec2.os',
        'detail-type': 'EC2 OS Action',
        'time': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
        'region': os.environ['AWS_REGION'],
        'account': context.invoked_function_arn.split(':')[4],
        'detail': {
            'instance-id': iid,
            'action': action,
            'user': str(data.get('user', 'Unknown'))[:120],
            'source-ip': str(data.get('source-ip', 'Unknown'))[:60],
        },
    }
    # Reply to the server immediately; send the email in the background
    lambda_client.invoke(FunctionName=context.function_name,
                         InvocationType='Event',
                         Payload=json.dumps(os_event))
    return {'statusCode': 200, 'body': 'ok'}


def lambda_handler(event, context):
    if 'http' in (event.get('requestContext') or {}):
        return handle_hook_request(event, context)

    source = event.get('source')
    detail_type = event.get('detail-type', '')

    if source == 'custom.ec2.os':
        handle_os_event(event)
    elif detail_type == 'EC2 Instance State-change Notification':
        handle_state_event(event)
    else:
        handle_api_event(event)
5

Rule A — Console / CLI actions

Catches Start/Stop/Reboot/Terminate API calls, including the user who made them.

  1. Make sure the console region (top right) is your main region (ap-south-1).
  2. EventBridge → Rules → Create rule · Name: EC2-instance-changes-states · Event bus: default · Rule type: Rule with an event pattern → Next.
  3. Creation method: Custom pattern (JSON editor) → paste the pattern → Next.
  4. Target: AWS service → Lambda function → EC2-State-Alert. Untick “Use execution role (recommended)” (if ticked, calls fail silently).
  5. Next → Next → Create rule.
json — event pattern
{
  "source": ["aws.ec2"],
  "detail-type": ["AWS API Call via CloudTrail"],
  "detail": {
    "eventSource": ["ec2.amazonaws.com"],
    "eventName": ["StartInstances", "StopInstances", "RebootInstances", "TerminateInstances"]
  }
}
6

Rule B — OS shutdown fallback

Emails when a server is shut down from inside the OS and no hook is installed. Console stops are ignored here (Rule A covers them).

  1. Make sure the console region (top right) is your main region (ap-south-1).
  2. EventBridge → Rules → Create rule · Name: EC2-OS-Shutdown · Event bus: default · Rule type: Rule with an event pattern → Next.
  3. Creation method: Custom pattern (JSON editor) → paste the pattern → Next.
  4. Target: AWS service → Lambda function → EC2-State-Alert. Untick “Use execution role (recommended)” (if ticked, calls fail silently).
  5. Next → Next → Create rule.
json — event pattern
{
  "source": ["aws.ec2"],
  "detail-type": ["EC2 Instance State-change Notification"],
  "detail": {
    "state": ["stopped", "terminated"]
  }
}
ℹ️
Check Lambda → your function → Configuration → Triggers: the rule must be listed without a red error. If missing, use Add trigger → EventBridge → Existing rules.
7

Test

  1. Stop a test instance from the EC2 console.
  2. Wait 1–3 minutes → email [EC2] name - STOPPED with Action By = your IAM user.
  3. Start it again → STARTED email.
✅
Status colours: green = started, red = stopped/failed, orange = rebooted, grey = terminated.

🪝Part 2 — Reboot / Shutdown From Inside the OS

AWS can’t see an OS-level reboot. A small hook, pushed to every Ubuntu and Windows server by Systems Manager, reports it — with the OS username and SSH/RDP source IP. No manual install on servers.

1

Create a secret token

The hook sends this token so only your servers can trigger alerts.

▶ Run on: your own PC — Windows PowerShell

powershell — generate token
-join ((48..57) + (97..122) | Get-Random -Count 40 | ForEach-Object {[char]$_})

▶ Run on: or any Linux / Mac terminal

bash — generate token
openssl rand -hex 20
⚠️
Keep this token private. Do not store it in documentation.
2

Add the token to the Lambda

Lambda → EC2-State-Alert → Configuration → Environment variables → Edit → Add: Key HOOK_TOKEN · Value = your token → Save.
3

Create a Function URL

  1. Configuration → Function URL → Create function URL.
  2. Auth type: NONE (the token protects it) → Save.
  3. Copy the URL (ends with .lambda-url.ap-south-1.on.aws/).
4

Create the SSM document

  1. Systems Manager → Documents → Create document → Command or Session.
  2. Name: EC2-OS-Action-Hook · Target type: /AWS::EC2::Instance · Document type: Command.
  3. Content: choose YAML, replace everything with the document below → Create document.
yaml — EC2-OS-Action-Hook
schemaVersion: "2.2"
description: "Install OS reboot/shutdown alert hook (Ubuntu + Windows)"
parameters:
  FunctionUrl:
    type: String
    description: "Lambda Function URL"
  HookToken:
    type: String
    description: "Shared secret token"
mainSteps:
  - action: aws:runShellScript
    name: InstallLinuxHook
    precondition:
      StringEquals: [platformType, Linux]
    inputs:
      runCommand:
        - |
          cat > /usr/local/bin/ec2-os-action-notify.sh <<'SCRIPT'
          #!/bin/bash
          URL="{{ FunctionUrl }}"
          TOKEN="{{ HookToken }}"
          LOG=/var/log/ec2-os-action-notify.log

          # Skip if AWS triggered it (console Stop = power-button event, already alerted)
          if journalctl -b 0 --since "-3 min" -u systemd-logind -o cat 2>/dev/null | grep -q "Power key pressed"; then
            echo "$(date) AWS-initiated, skipped" >> "$LOG"; exit 0
          fi

          if systemctl list-jobs 2>/dev/null | grep -q "reboot.target"; then ACTION="REBOOT"; else ACTION="SHUTDOWN"; fi

          USERNAME=$(journalctl -b 0 --since "-15 min" -t sudo -o cat 2>/dev/null \
            | grep -E "COMMAND=.*(reboot|shutdown|poweroff|halt|init [06])" | tail -1 | awk '{print $1}')
          if [ -n "$USERNAME" ]; then
            SRC_IP=$(last -i -w 2>/dev/null | awk -v u="$USERNAME" '$1==u && /still logged in/ {print $3; exit}')
          else
            LOGGED=$(last -i -w 2>/dev/null | awk '/still logged in/ {print $1"@"$3}' | sort -u | paste -sd ", " -)
            USERNAME="root shell (logged in: ${LOGGED:-none})"
          fi
          [ -z "$SRC_IP" ] && SRC_IP="Unknown"

          MD=http://169.254.169.254/latest
          T=$(curl -s -m 3 -X PUT "$MD/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 60")
          IID=$(curl -s -m 3 -H "X-aws-ec2-metadata-token: $T" "$MD/meta-data/instance-id")

          BODY=$(printf '{"instance-id":"%s","action":"%s","user":"%s","source-ip":"%s"}' "$IID" "$ACTION" "$USERNAME" "$SRC_IP")
          RESULT=$(curl -s -m 8 -X POST "$URL" -H "Content-Type: application/json" -H "x-hook-token: $TOKEN" -d "$BODY")
          echo "$(date) $ACTION by $USERNAME from $SRC_IP -> $RESULT" >> "$LOG"
          SCRIPT
          chmod 700 /usr/local/bin/ec2-os-action-notify.sh

          cat > /etc/systemd/system/ec2-os-action-notify.service <<'UNIT'
          [Unit]
          Description=Notify AWS on OS reboot/shutdown
          Wants=network-online.target
          After=network-online.target systemd-resolved.service

          [Service]
          Type=oneshot
          RemainAfterExit=yes
          ExecStart=/bin/true
          ExecStop=/usr/local/bin/ec2-os-action-notify.sh
          TimeoutStopSec=20

          [Install]
          WantedBy=multi-user.target
          UNIT

          systemctl daemon-reload
          systemctl enable ec2-os-action-notify.service
          systemctl start ec2-os-action-notify.service
          echo "Linux hook installed"
  - action: aws:runPowerShellScript
    name: InstallWindowsHook
    precondition:
      StringEquals: [platformType, Windows]
    inputs:
      runCommand:
        - |
          $dir = 'C:\ProgramData\EC2OSNotify'
          New-Item -ItemType Directory -Force -Path $dir | Out-Null
          $hook = @'
          $ErrorActionPreference = 'SilentlyContinue'
          $Url = '{{ FunctionUrl }}'
          $Token = '{{ HookToken }}'
          $Log = 'C:\ProgramData\EC2OSNotify\notify.log'

          $e = Get-WinEvent -FilterHashtable @{LogName='System'; Id=1074} -MaxEvents 1
          $type = $e.Properties[4].Value
          $user = $e.Properties[6].Value
          $action = if ($type -match 'restart') { 'REBOOT' } else { 'SHUTDOWN' }

          $short = ($user -split '\\')[-1]
          $ip = 'Unknown'
          $rdp = Get-WinEvent -LogName 'Microsoft-Windows-TerminalServices-RemoteConnectionManager/Operational' -FilterXPath '*[System[EventID=1149]]' -MaxEvents 50 |
                 Where-Object { $_.Properties[0].Value -eq $short } | Select-Object -First 1
          if ($rdp) { $ip = $rdp.Properties[2].Value }

          $t = Invoke-RestMethod -Method PUT -Uri 'http://169.254.169.254/latest/api/token' -Headers @{'X-aws-ec2-metadata-token-ttl-seconds'='60'} -TimeoutSec 3
          $iid = Invoke-RestMethod -Uri 'http://169.254.169.254/latest/meta-data/instance-id' -Headers @{'X-aws-ec2-metadata-token'=$t} -TimeoutSec 3

          $body = @{ 'instance-id' = "$iid"; 'action' = $action; 'user' = "$user"; 'source-ip' = "$ip" } | ConvertTo-Json -Compress
          $r = Invoke-RestMethod -Method POST -Uri $Url -Body $body -ContentType 'application/json' -Headers @{'x-hook-token'=$Token} -TimeoutSec 8
          Add-Content -Path $Log -Value "$(Get-Date) $action by $user from $ip -> $r"
          '@
          Set-Content -Path "$dir\notify.ps1" -Value $hook -Encoding UTF8
          icacls $dir /inheritance:r /grant:r 'SYSTEM:(OI)(CI)F' 'Administrators:(OI)(CI)F' | Out-Null

          $action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument '-NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\EC2OSNotify\notify.ps1"'
          $class = Get-CimClass -ClassName MSFT_TaskEventTrigger -Namespace Root/Microsoft/Windows/TaskScheduler
          $trigger = New-CimInstance -CimClass $class -ClientOnly
          $trigger.Enabled = $true
          $trigger.Subscription = "<QueryList><Query Id='0' Path='System'><Select Path='System'>*[System[Provider[@Name='User32'] and EventID=1074]]</Select></Query></QueryList>"
          $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
          $settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 2) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
          Register-ScheduledTask -TaskName 'EC2-OS-Action-Notify' -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
          "Windows hook installed"
5

Trial on 2 servers first

  1. Systems Manager → Run Command → Run command → search EC2-OS-Action-Hook.
  2. Fill FunctionUrl and HookToken — paste exactly, no spaces.
  3. Targets: Choose instances manually → 1 Ubuntu + 1 Windows → Run. Both must show Success.
6

Test without rebooting

▶ Run on: the Ubuntu test server (SSH or Session Manager)

bash — fire hook safely
sudo systemctl status ec2-os-action-notify.service --no-pager
sudo systemctl stop ec2-os-action-notify.service
sudo tail -1 /var/log/ec2-os-action-notify.log
sudo systemctl start ec2-os-action-notify.service

First line must show active (exited); the log line must end with ok. A STOPPED email arrives in ~4 min (status stays RUNNING — expected).

▶ Run on: the Windows test server — admin Command Prompt

cmd — schedule then cancel a restart
shutdown /r /t 120 /c "alert test"
shutdown /a
type C:\ProgramData\EC2OSNotify\notify.log

You get a REBOOTED email; the server does not restart.

7

Roll out to all servers

  1. Systems Manager → State Manager → Create association.
  2. Name: EC2-OS-Action-Hook-All · Document: EC2-OS-Action-Hook.
  3. Fill FunctionUrl and HookToken.
  4. Targets: Choose all instances · Schedule: No schedule → Create association.
  5. After a few minutes, open the association → Execution history → all servers Success.
ℹ️
New servers get the hook automatically once they appear in Fleet Manager. If one is missed, open the association → Apply association now. When editing an association, the console asks for FunctionUrl and HookToken again — copy them from the Lambda.
8

What gets installed

OSFilesLog
Ubuntu/usr/local/bin/ec2-os-action-notify.sh + systemd ec2-os-action-notify/var/log/ec2-os-action-notify.log
WindowsC:\ProgramData\EC2OSNotify\notify.ps1 + task EC2-OS-Action-Notify (Event ID 1074)C:\ProgramData\EC2OSNotify\notify.log
  • Action By = OS user (Linux username or DOMAIN\user). After sudo -i it shows root shell (logged in: …).
  • Windows Update restarts appear as NT AUTHORITY\SYSTEM.
  • Instances get an OSShutdownAlert tag to prevent duplicate emails — safe to ignore.

🔐Part 3 — Console Login Alerts

Every console sign-in (IAM user, root, SSO/Google/Microsoft, failed attempts) with location, ISP, browser and MFA status.

1

Create the Lambda

  1. Open Lambda → Create function → Author from scratch.
  2. Function name: AWS-Login-Alert · Runtime: Python 3.12 (or newer) · Execution role: Create a new role with basic Lambda permissions → Create function.
  3. Configuration → General configuration → Edit → Timeout: 30 sec → Save.
2

Add environment variables

Configuration → Environment variables → Edit → Add environment variable for each row, then Save.
KeyValue (example)
FROM_EMAILAWS Alerts <[email protected]>
TO_EMAIL[email protected] (comma-separate for several)
SES_REGIONap-south-1 — region where your domain is verified in SES
TZ_LABELIST — label shown in emails
TZ_OFFSET_MINUTES330 — your UTC offset in minutes (IST = 330, UTC = 0, GMT+4 = 240)
3

Add permissions

  1. Configuration → Permissions → click the Role name (opens IAM).
  2. Add permissions → Create inline policy → JSON, paste the policy below.
  3. Next → Policy name: Login-Alert-Policy → Create policy.
json — Login-Alert-Policy
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["ses:SendEmail", "ses:SendRawEmail"],
      "Resource": "*"
    }
  ]
}
4

Paste the code

Open the Code tab → select all in lambda_function.py → paste → click Deploy.
python — AWS-Login-Alert · lambda_function.py
import html as htmllib
import ipaddress
import json
import os
import urllib.request
from datetime import datetime, timedelta, timezone

import boto3

ses = boto3.client('ses', region_name=os.environ['SES_REGION'])
FROM_EMAIL = os.environ['FROM_EMAIL']
TO_EMAIL = [e.strip() for e in os.environ['TO_EMAIL'].split(',')]
TZ_LABEL = os.environ.get('TZ_LABEL', 'IST')
LOCAL_TZ = timezone(timedelta(minutes=int(os.environ.get('TZ_OFFSET_MINUTES', '330'))))


def get_user(uid):
    t = uid.get('type', 'Unknown')
    if t == 'Root':
        return 'ROOT ACCOUNT'
    name = uid.get('userName') or uid.get('arn', 'Unknown').split('/')[-1]
    if name == 'HIDDEN_DUE_TO_SECURITY_REASONS':
        return 'Unknown user (hidden by AWS)'
    return name


def get_browser(ua):
    if not ua:
        return 'Unknown'
    os_name = ('Windows' if 'Windows' in ua else 'Mac' if 'Mac OS' in ua
               else 'Android' if 'Android' in ua else 'iPhone/iPad' if ('iPhone' in ua or 'iPad' in ua)
               else 'Linux' if 'Linux' in ua else 'Other')
    if 'Edg/' in ua:
        b = 'Edge'
    elif 'Chrome/' in ua:
        b = 'Chrome'
    elif 'Firefox/' in ua:
        b = 'Firefox'
    elif 'Safari/' in ua:
        b = 'Safari'
    else:
        b = 'Other'
    return f"{b} on {os_name}"


def get_location(ip):
    """Returns (location, isp). Skips private/invalid IPs."""
    try:
        addr = ipaddress.ip_address(ip)
        if addr.is_private or addr.is_loopback or addr.is_reserved:
            return 'Private network', '-'
    except ValueError:
        return '-', '-'
    try:
        req = urllib.request.Request(f"https://ipapi.co/{ip}/json/", headers={'User-Agent': 'aws-login-alert'})
        with urllib.request.urlopen(req, timeout=5) as r:
            data = json.loads(r.read().decode())
        if data.get('error'):
            return 'Lookup failed', '-'
        parts = [data.get('city'), data.get('region'), data.get('country_name')]
        location = ', '.join(p for p in parts if p) or 'Unknown'
        return location, data.get('org') or '-'
    except Exception as e:
        print(f"IP lookup failed: {e}")
        return 'Lookup failed', '-'


def to_ist(time_str):
    utc = datetime.strptime(time_str, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=timezone.utc)
    return utc.astimezone(LOCAL_TZ).strftime('%d-%b-%Y %I:%M:%S %p')


def badge_html(text, color):
    return (f'<span style="display:inline-block;background:{color};color:#fff;padding:6px 14px;'
            f'border-radius:4px;font-weight:700;font-size:14px;margin-right:6px;">{text}</span>')


def send_email(subject, badges, rows, link):
    row_html = ''.join(
        f'<tr><td style="padding:8px 12px;color:#5f6368;border-bottom:1px solid #eee;">{k}</td>'
        f'<td style="padding:8px 12px;font-weight:600;border-bottom:1px solid #eee;">{htmllib.escape(str(v))}</td></tr>'
        for k, v in rows)
    badges_html = ''.join(badge_html(t, c) for t, c in badges)

    html = f"""
<div style="font-family:Segoe UI,Arial,sans-serif;max-width:560px;margin:auto;border:1px solid #ddd;border-radius:8px;overflow:hidden;">
  <div style="background:#232f3e;color:#fff;padding:14px 18px;font-size:16px;font-weight:600;">AWS Console Login</div>
  <div style="padding:18px;">
    <div>{badges_html}</div>
    <table style="width:100%;border-collapse:collapse;margin-top:16px;font-size:14px;">{row_html}</table>
    <div style="margin-top:18px;"><a href="{link}" style="background:#ff9900;color:#000;padding:8px 16px;text-decoration:none;border-radius:4px;font-weight:600;">View in CloudTrail</a></div>
  </div>
  <div style="background:#f5f5f5;color:#888;font-size:12px;padding:10px 18px;">Automated alert from AWS. If this login was not you, change the password and contact IT immediately.</div>
</div>"""

    text = "AWS Console Login\n\n" + ' | '.join(t for t, _ in badges) + "\n\n" + \
           ''.join(f"{k}: {v}\n" for k, v in rows) + f"\nCloudTrail: {link}\n"

    ses.send_email(
        Source=FROM_EMAIL,
        Destination={'ToAddresses': TO_EMAIL},
        Message={'Subject': {'Data': subject[:150]},
                 'Body': {'Html': {'Data': html}, 'Text': {'Data': text}}}
    )


def lambda_handler(event, context):
    d = event['detail']
    uid = d.get('userIdentity', {})
    utype = uid.get('type', 'Unknown')
    user = get_user(uid)

    result = (d.get('responseElements') or {}).get('ConsoleLogin', 'Unknown').upper()
    mfa = (d.get('additionalEventData') or {}).get('MFAUsed', 'Unknown')
    error = d.get('errorMessage', '')
    ip = d.get('sourceIPAddress', 'Unknown')
    location, isp = get_location(ip)

    # Badges: result + warnings
    badges = [(result, '#1e8e3e' if result == 'SUCCESS' else '#d93025')]
    if utype == 'Root':
        badges.append(('ROOT ACCOUNT', '#d93025'))
    if result == 'SUCCESS' and mfa != 'Yes':
        badges.append(('NO MFA', '#f29900'))

    rows = [
        ('User', user),
        ('User Type', utype),
        ('Result', result),
        ('MFA Used', mfa),
        ('Source IP', ip),
        ('Location', location),
        ('ISP', isp),
        ('Browser', get_browser(d.get('userAgent'))),
        (f'Time ({TZ_LABEL})', to_ist(event['time'])),
        ('Account', event['account']),
    ]
    if error:
        rows.append(('Error', error))

    region = event.get('region', 'us-east-1')
    event_id = d.get('eventID', '')
    link = f"https://{region}.console.aws.amazon.com/cloudtrailv2/home?region={region}#/events/{event_id}"

    subject = f"[AWS Login] {user} - {result}"
    send_email(subject, badges, rows, link)
5

Test with a sample event

  1. Test tab → Create new event → name login-test.
  2. Delete the sample JSON, paste the event below → Save → Test.
  3. You should receive [AWS Login] [email protected] - SUCCESS.
json — sample login event
{
  "version": "0",
  "id": "11111111-2222-3333-4444-555555555555",
  "detail-type": "AWS Console Sign In via CloudTrail",
  "source": "aws.signin",
  "account": "123456789012",
  "time": "2026-10-15T05:45:46Z",
  "region": "ap-south-1",
  "resources": [],
  "detail": {
    "userIdentity": {
      "type": "IAMUser",
      "arn": "arn:aws:iam::123456789012:user/[email protected]",
      "accountId": "123456789012",
      "userName": "[email protected]"
    },
    "eventTime": "2026-10-15T05:45:46Z",
    "eventSource": "signin.amazonaws.com",
    "eventName": "ConsoleLogin",
    "sourceIPAddress": "8.8.8.8",
    "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36 Edg/154.0.0.0",
    "responseElements": { "ConsoleLogin": "Success" },
    "additionalEventData": { "MFAUsed": "Yes" },
    "eventID": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
  }
}
⚠️
Error KeyError: 'detail' means the default sample event is still there — replace it.
6

Rule in your main region

  1. Make sure the console region (top right) is your main region (ap-south-1).
  2. EventBridge → Rules → Create rule · Name: Console-Login · Event bus: default · Rule type: Rule with an event pattern → Next.
  3. Creation method: Custom pattern (JSON editor) → paste the pattern → Next.
  4. Target: AWS service → Lambda function → AWS-Login-Alert. Untick “Use execution role (recommended)” (if ticked, calls fail silently).
  5. Next → Next → Create rule.
json — event pattern
{
  "source": ["aws.signin"],
  "detail-type": ["AWS Console Sign In via CloudTrail"]
}
ℹ️
Check Lambda → your function → Configuration → Triggers: the rule must be listed without a red error. If missing, use Add trigger → EventBridge → Existing rules.
7

Forward logins from N. Virginia

Global sign-in page and SSO logins are recorded in us-east-1, not your region. Without this rule you miss most logins.

  1. Make sure the console region (top right) is US East (N. Virginia) us-east-1.
  2. EventBridge → Rules → Create rule · Name: Forward-Logins-To-Your-Region · Event bus: default · Rule type: Rule with an event pattern → Next.
  3. Creation method: Custom pattern (JSON editor) → paste the pattern → Next.
  4. Target: EventBridge event bus → Event bus in a different account or Region · ARN: arn:aws:events:ap-south-1:123456789012:event-bus/default · Execution role: Create a new role for this specific resource.
  5. Next → Next → Create rule.

Use the same pattern as the previous step. Switch back to your main region afterwards.

8

Test

  1. Sign out and sign in a few ways: normal sign-in page, region URL, SSO.
  2. Each sign-in → one email within ~1 minute.
ℹ️
Location/ISP uses the free ipapi.co service. If it fails, the email still arrives with “Lookup failed”. Badges: green SUCCESS, red FAILURE / ROOT ACCOUNT, orange NO MFA.

🛡️Part 4 — Security Alerts (CloudTrail Tampering + IAM Changes)

Attackers usually switch off logging and create a backdoor user first. One Lambda alerts on both, with severity badges.

1

Create the Lambda

  1. Open Lambda → Create function → Author from scratch.
  2. Function name: AWS-Security-Alert · Runtime: Python 3.12 (or newer) · Execution role: Create a new role with basic Lambda permissions → Create function.
  3. Configuration → General configuration → Edit → Timeout: 30 sec → Save.
2

Add environment variables

Configuration → Environment variables → Edit → Add environment variable for each row, then Save.
KeyValue (example)
FROM_EMAILAWS Alerts <[email protected]>
TO_EMAIL[email protected] (comma-separate for several)
SES_REGIONap-south-1 — region where your domain is verified in SES
TZ_LABELIST — label shown in emails
TZ_OFFSET_MINUTES330 — your UTC offset in minutes (IST = 330, UTC = 0, GMT+4 = 240)
WATCH_POLICIESOptional: name(s) of critical IAM policies (e.g. an IP-restriction policy). Any change → CRITICAL
3

Add permissions

  1. Configuration → Permissions → click the Role name (opens IAM).
  2. Add permissions → Create inline policy → JSON, paste the policy below.
  3. Next → Policy name: Security-Alert-Policy → Create policy.
json — Security-Alert-Policy
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["ses:SendEmail", "ses:SendRawEmail"],
      "Resource": "*"
    }
  ]
}
4

Paste the code

Open the Code tab → select all in lambda_function.py → paste → click Deploy.
python — AWS-Security-Alert · lambda_function.py
import html as htmllib
import ipaddress
import json
import os
import urllib.request
from datetime import datetime, timedelta, timezone

import boto3

ses = boto3.client('ses', region_name=os.environ['SES_REGION'])
FROM_EMAIL = os.environ['FROM_EMAIL']
TO_EMAIL = [e.strip() for e in os.environ['TO_EMAIL'].split(',')]
TZ_LABEL = os.environ.get('TZ_LABEL', 'IST')
LOCAL_TZ = timezone(timedelta(minutes=int(os.environ.get('TZ_OFFSET_MINUTES', '330'))))
WATCH_POLICIES = [p.strip().lower() for p in os.environ.get('WATCH_POLICIES', '').split(',') if p.strip()]

# eventName -> (title, severity)
EVENTS = {
    # ---- CloudTrail tampering ----
    'StopLogging':                  ('CloudTrail logging STOPPED', 'CRITICAL'),
    'DeleteTrail':                  ('CloudTrail trail DELETED', 'CRITICAL'),
    'UpdateTrail':                  ('CloudTrail trail MODIFIED', 'HIGH'),
    'PutEventSelectors':            ('CloudTrail logged events CHANGED', 'HIGH'),
    'DeleteEventDataStore':         ('CloudTrail Lake data store DELETED', 'CRITICAL'),
    'StopEventDataStoreIngestion':  ('CloudTrail Lake ingestion STOPPED', 'CRITICAL'),

    # ---- Alert system tampering (EventBridge) ----
    'DisableRule':                  ('EventBridge rule DISABLED', 'HIGH'),
    'DeleteRule':                   ('EventBridge rule DELETED', 'HIGH'),
    'RemoveTargets':                ('EventBridge rule targets REMOVED', 'MEDIUM'),

    # ---- IAM: users & login ----
    'CreateUser':                   ('IAM user CREATED', 'HIGH'),
    'DeleteUser':                   ('IAM user DELETED', 'HIGH'),
    'CreateLoginProfile':           ('Console password CREATED for user', 'HIGH'),
    'UpdateLoginProfile':           ('Console password CHANGED for user', 'HIGH'),
    'DeleteLoginProfile':           ('Console password REMOVED for user', 'MEDIUM'),

    # ---- IAM: access keys ----
    'CreateAccessKey':              ('Access key CREATED', 'HIGH'),
    'UpdateAccessKey':              ('Access key status CHANGED', 'HIGH'),
    'DeleteAccessKey':              ('Access key DELETED', 'MEDIUM'),

    # ---- IAM: MFA ----
    'DeactivateMFADevice':          ('MFA DEACTIVATED for user', 'CRITICAL'),
    'DeleteVirtualMFADevice':       ('MFA device DELETED', 'CRITICAL'),

    # ---- IAM: permissions ----
    'AttachUserPolicy':             ('Policy ATTACHED to user', 'HIGH'),
    'DetachUserPolicy':             ('Policy DETACHED from user', 'HIGH'),
    'PutUserPolicy':                ('Inline policy ADDED/CHANGED on user', 'HIGH'),
    'DeleteUserPolicy':             ('Inline policy DELETED from user', 'HIGH'),
    'AttachGroupPolicy':            ('Policy ATTACHED to group', 'HIGH'),
    'DetachGroupPolicy':            ('Policy DETACHED from group', 'HIGH'),
    'PutGroupPolicy':               ('Inline policy ADDED/CHANGED on group', 'HIGH'),
    'DeleteGroupPolicy':            ('Inline policy DELETED from group', 'HIGH'),
    'AttachRolePolicy':             ('Policy ATTACHED to role', 'HIGH'),
    'DetachRolePolicy':             ('Policy DETACHED from role', 'HIGH'),
    'PutRolePolicy':                ('Inline policy ADDED/CHANGED on role', 'HIGH'),
    'DeleteRolePolicy':             ('Inline policy DELETED from role', 'HIGH'),
    'AddUserToGroup':               ('User ADDED to group', 'HIGH'),
    'RemoveUserFromGroup':          ('User REMOVED from group', 'MEDIUM'),
    'CreatePolicy':                 ('IAM policy CREATED', 'MEDIUM'),
    'DeletePolicy':                 ('IAM policy DELETED', 'HIGH'),
    'CreatePolicyVersion':          ('IAM policy EDITED (new version)', 'HIGH'),
    'SetDefaultPolicyVersion':      ('IAM policy version SWITCHED', 'HIGH'),
    'DeletePolicyVersion':          ('IAM policy version DELETED', 'MEDIUM'),

    # ---- IAM: roles ----
    'CreateRole':                   ('IAM role CREATED', 'HIGH'),
    'DeleteRole':                   ('IAM role DELETED', 'HIGH'),
    'UpdateAssumeRolePolicy':       ('Role trust policy CHANGED', 'HIGH'),

    # ---- IAM: account ----
    'UpdateAccountPasswordPolicy':  ('Account password policy CHANGED', 'HIGH'),
    'DeleteAccountPasswordPolicy':  ('Account password policy DELETED', 'CRITICAL'),
}

SEVERITY_COLORS = {'CRITICAL': '#d93025', 'HIGH': '#e8710a', 'MEDIUM': '#f29900', 'LOW': '#5f6368'}


def get_user(uid):
    t = uid.get('type', 'Unknown')
    if t == 'Root':
        return 'ROOT ACCOUNT'
    if t == 'IAMUser':
        return uid.get('userName', 'Unknown')
    if t == 'AssumedRole':
        role = uid.get('sessionContext', {}).get('sessionIssuer', {}).get('userName', 'Role')
        session = uid.get('arn', '').split('/')[-1]
        return f"{session} (role: {role})"
    if t == 'AWSService':
        return uid.get('invokedBy', 'AWS Service')
    return uid.get('arn', 'Unknown')


def get_via(ua):
    if not ua:
        return 'Unknown'
    if 'console' in ua.lower() or 'Mozilla' in ua:
        return 'AWS Console'
    if 'aws-cli' in ua:
        return 'AWS CLI'
    if 'Boto3' in ua or 'botocore' in ua:
        return 'Script (Python SDK)'
    if 'cloudformation' in ua.lower():
        return 'CloudFormation'
    return ua[:60]


def get_location(ip):
    try:
        addr = ipaddress.ip_address(ip)
        if addr.is_private or addr.is_loopback or addr.is_reserved:
            return 'Private network'
    except ValueError:
        return '-'
    try:
        req = urllib.request.Request(f"https://ipapi.co/{ip}/json/", headers={'User-Agent': 'aws-security-alert'})
        with urllib.request.urlopen(req, timeout=5) as r:
            data = json.loads(r.read().decode())
        if data.get('error'):
            return 'Lookup failed'
        parts = [data.get('city'), data.get('region'), data.get('country_name')]
        loc = ', '.join(p for p in parts if p) or 'Unknown'
        return f"{loc} ({data.get('org')})" if data.get('org') else loc
    except Exception as e:
        print(f"IP lookup failed: {e}")
        return 'Lookup failed'


def get_targets(d):
    """Returns list of (label, value) rows describing what was changed."""
    rp = d.get('requestParameters') or {}
    re_ = d.get('responseElements') or {}
    rows = []

    # CloudTrail / EventBridge
    for key, label in [('name', 'Resource'), ('rule', 'Rule'), ('trailName', 'Trail'),
                       ('eventDataStore', 'Data Store')]:
        if rp.get(key):
            rows.append((label, rp[key]))

    # IAM targets
    for key, label in [('userName', 'Target User'), ('roleName', 'Target Role'),
                       ('groupName', 'Target Group'), ('policyName', 'Policy Name'),
                       ('policyArn', 'Policy ARN'), ('serialNumber', 'MFA Device'),
                       ('status', 'New Status')]:
        if rp.get(key):
            rows.append((label, rp[key]))

    # New access key ID (ID only, never the secret)
    key_id = (re_.get('accessKey') or {}).get('accessKeyId') or rp.get('accessKeyId')
    if key_id:
        rows.append(('Access Key ID', key_id))

    # De-duplicate 'Resource' when it repeats another value
    seen, unique = set(), []
    for k, v in rows:
        if v not in seen:
            unique.append((k, v))
            seen.add(v)
    return unique or [('Resource', '-')]


def is_watched_policy(d):
    if not WATCH_POLICIES:
        return False
    rp = d.get('requestParameters') or {}
    text = f"{rp.get('policyArn', '')} {rp.get('policyName', '')}".lower()
    return any(p in text for p in WATCH_POLICIES)


def grants_admin(d):
    rp = d.get('requestParameters') or {}
    return 'administratoraccess' in str(rp.get('policyArn', '')).lower()


def to_ist(time_str):
    utc = datetime.strptime(time_str, '%Y-%m-%dT%H:%M:%SZ').replace(tzinfo=timezone.utc)
    return utc.astimezone(LOCAL_TZ).strftime('%d-%b-%Y %I:%M:%S %p')


def badge_html(text, color):
    return (f'<span style="display:inline-block;background:{color};color:#fff;padding:6px 14px;'
            f'border-radius:4px;font-weight:700;font-size:14px;margin-right:6px;">{text}</span>')


def send_email(subject, title, badges, rows, link):
    row_html = ''.join(
        f'<tr><td style="padding:8px 12px;color:#5f6368;border-bottom:1px solid #eee;">{k}</td>'
        f'<td style="padding:8px 12px;font-weight:600;border-bottom:1px solid #eee;word-break:break-all;">{htmllib.escape(str(v))}</td></tr>'
        for k, v in rows)
    badges_html = ''.join(badge_html(t, c) for t, c in badges)

    html = f"""
<div style="font-family:Segoe UI,Arial,sans-serif;max-width:560px;margin:auto;border:1px solid #ddd;border-radius:8px;overflow:hidden;">
  <div style="background:#232f3e;color:#fff;padding:14px 18px;font-size:16px;font-weight:600;">AWS Security Alert</div>
  <div style="padding:18px;">
    <div>{badges_html}</div>
    <div style="margin-top:12px;font-size:16px;font-weight:700;color:#202124;">{htmllib.escape(title)}</div>
    <table style="width:100%;border-collapse:collapse;margin-top:12px;font-size:14px;">{row_html}</table>
    <div style="margin-top:18px;"><a href="{link}" style="background:#ff9900;color:#000;padding:8px 16px;text-decoration:none;border-radius:4px;font-weight:600;">View in CloudTrail</a></div>
  </div>
  <div style="background:#f5f5f5;color:#888;font-size:12px;padding:10px 18px;">If this change was not expected, investigate immediately and revert it.</div>
</div>"""

    text = f"AWS Security Alert\n\n{' | '.join(t for t, _ in badges)}\n{title}\n\n" + \
           ''.join(f"{k}: {v}\n" for k, v in rows) + f"\nCloudTrail: {link}\n"

    ses.send_email(
        Source=FROM_EMAIL,
        Destination={'ToAddresses': TO_EMAIL},
        Message={'Subject': {'Data': subject[:150]},
                 'Body': {'Html': {'Data': html}, 'Text': {'Data': text}}}
    )


def lambda_handler(event, context):
    d = event['detail']
    name = d.get('eventName', 'Unknown')
    title, severity = EVENTS.get(name, (f"{name} detected", 'MEDIUM'))
    error = d.get('errorCode')

    # Escalate special cases
    extra_badges = []
    if is_watched_policy(d):
        severity = 'CRITICAL'
        extra_badges.append(('IP RESTRICTION POLICY', '#d93025'))
    if grants_admin(d) and name.startswith('Attach'):
        severity = 'CRITICAL'
        extra_badges.append(('ADMIN ACCESS', '#d93025'))

    user = get_user(d.get('userIdentity', {}))
    ip = d.get('sourceIPAddress', 'Unknown')

    badges = [(severity, SEVERITY_COLORS.get(severity, '#5f6368'))] + extra_badges
    if error:
        badges.append(('ATTEMPT FAILED', '#5f6368'))
        title = f"{title} (attempt blocked)"

    rows = [('Action', name)] + get_targets(d) + [
        ('Action By', user),
        ('Via', get_via(d.get('userAgent', ''))),
        ('Source IP', ip),
        ('Location', get_location(ip)),
        (f'Time ({TZ_LABEL})', to_ist(event['time'])),
        ('Region', event.get('region', '-')),
        ('Account', event['account']),
    ]
    if error:
        rows.append(('Error', d.get('errorMessage', error)))

    region = event.get('region', 'ap-south-1')
    link = f"https://{region}.console.aws.amazon.com/cloudtrailv2/home?region={region}#/events/{d.get('eventID', '')}"

    subject = f"[SECURITY] {title} by {user}"
    send_email(subject, title, badges, rows, link)
5

Rule — CloudTrail & alert tampering

Stop/delete/modify of trails, and disabling/deleting EventBridge rules (your alerts).

  1. Make sure the console region (top right) is your main region (ap-south-1).
  2. EventBridge → Rules → Create rule · Name: Alert-System-Tampering · Event bus: default · Rule type: Rule with an event pattern → Next.
  3. Creation method: Custom pattern (JSON editor) → paste the pattern → Next.
  4. Target: AWS service → Lambda function → AWS-Security-Alert. Untick “Use execution role (recommended)” (if ticked, calls fail silently).
  5. Next → Next → Create rule.
json — event pattern
{
  "source": ["aws.cloudtrail", "aws.events"],
  "detail-type": ["AWS API Call via CloudTrail"],
  "detail": {
    "eventSource": ["cloudtrail.amazonaws.com", "events.amazonaws.com"],
    "eventName": [
      "StopLogging", "DeleteTrail", "UpdateTrail", "PutEventSelectors",
      "DeleteEventDataStore", "StopEventDataStoreIngestion",
      "DisableRule", "DeleteRule", "RemoveTargets"
    ]
  }
}
ℹ️
If your CloudTrail trail’s home region is not your main region, create the same rule in the trail’s home region with the forward target (as in the next step).
6

Rule — IAM changes

  1. Make sure the console region (top right) is your main region (ap-south-1).
  2. EventBridge → Rules → Create rule · Name: IAM-Changes · Event bus: default · Rule type: Rule with an event pattern → Next.
  3. Creation method: Custom pattern (JSON editor) → paste the pattern → Next.
  4. Target: AWS service → Lambda function → AWS-Security-Alert. Untick “Use execution role (recommended)” (if ticked, calls fail silently).
  5. Next → Next → Create rule.
json — event pattern
{
  "source": ["aws.iam"],
  "detail-type": ["AWS API Call via CloudTrail"],
  "detail": {
    "eventSource": ["iam.amazonaws.com"],
    "eventName": [
      "CreateUser", "DeleteUser", "CreateLoginProfile", "UpdateLoginProfile", "DeleteLoginProfile",
      "CreateAccessKey", "UpdateAccessKey", "DeleteAccessKey",
      "DeactivateMFADevice", "DeleteVirtualMFADevice",
      "AttachUserPolicy", "DetachUserPolicy", "PutUserPolicy", "DeleteUserPolicy",
      "AttachGroupPolicy", "DetachGroupPolicy", "PutGroupPolicy", "DeleteGroupPolicy",
      "AttachRolePolicy", "DetachRolePolicy", "PutRolePolicy", "DeleteRolePolicy",
      "AddUserToGroup", "RemoveUserFromGroup",
      "CreatePolicy", "DeletePolicy", "CreatePolicyVersion", "SetDefaultPolicyVersion", "DeletePolicyVersion",
      "CreateRole", "DeleteRole", "UpdateAssumeRolePolicy",
      "UpdateAccountPasswordPolicy", "DeleteAccountPasswordPolicy"
    ]
  }
}
ℹ️
Check Lambda → your function → Configuration → Triggers: the rule must be listed without a red error. If missing, use Add trigger → EventBridge → Existing rules.
7

Forward IAM events from N. Virginia

IAM is a global service — its events land in us-east-1. This step is required.

  1. Make sure the console region (top right) is US East (N. Virginia) us-east-1.
  2. EventBridge → Rules → Create rule · Name: Forward-IAM-To-Your-Region · Event bus: default · Rule type: Rule with an event pattern → Next.
  3. Creation method: Custom pattern (JSON editor) → paste the pattern → Next.
  4. Target: EventBridge event bus → Event bus in a different account or Region · ARN: arn:aws:events:ap-south-1:123456789012:event-bus/default · Execution role: Create a new role for this specific resource.
  5. Next → Next → Create rule.

Use the same IAM pattern. Switch back to your main region afterwards.

8

Test safely

  1. IAM → Users → Create user test-alert-user (no permissions) → email [SECURITY] IAM user CREATED.
  2. Delete the user → IAM user DELETED.
  3. EventBridge → Rules → Create rule test-dummy (any pattern, no target) → Disable → Delete → two HIGH alerts.
9

Severity guide

SeverityExamples
CRITICALCloudTrail stopped/deleted, MFA removed, password policy deleted, watched policy changed, AdministratorAccess attached
HIGHUser/role/access key created, policy attached/detached, rule disabled/deleted
MEDIUMRule targets removed, access key deleted, policy created
ℹ️
Normal admin work also creates alerts (including roles auto-created when you make new Lambdas). Access key secrets are never shown — only the Key ID. Blocked attempts show ATTEMPT FAILED.
10

Optional — prevent logging from being switched off

Attach as an inline policy to every IAM user/group except a break-glass admin. Attempts are then blocked and still alerted.

json — Deny-CloudTrail-Stop
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Deny",
      "Action": ["cloudtrail:StopLogging", "cloudtrail:DeleteTrail"],
      "Resource": "*"
    }
  ]
}

🧱Part 5 — New VPC / New Server Checklist

Use for every new VPC or server. Without these, servers don’t appear in Fleet Manager, don’t get the hook, and Session Manager fails.

1

Checklist

#CheckWhere
1Instance role has AmazonSSMManagedInstanceCore and CloudWatchAgentServerPolicyIAM → Roles
2Role is attached to the instanceEC2 → instance → Actions → Security → Modify IAM role
3Subnet route table has 0.0.0.0/0 → NAT Gateway (private) or Internet Gateway (public)VPC → Subnets → Route table
4Security group outbound allows HTTPS 443EC2 → instance → Security
5Custom network ACL allows outbound 443 + inbound 1024–65535VPC → Network ACLs
6DNS resolution + DNS hostnames enabledVPC → Edit VPC settings
7Not Online in Fleet Manager after fixes → reboot instance, wait 5–10 minEC2 / Fleet Manager
8Hook installed → association “Apply association now”State Manager
9If Session Manager uses KMS → role has kms:Decrypt (next step)IAM
ℹ️
Private subnet: use a NAT Gateway. VPC endpoints for SSM alone are not enough — the hook must reach the Lambda Function URL on the internet.
2

Session Manager KMS permission

Only needed if Systems Manager → Session Manager → Preferences shows a KMS key. Fixes “Error when decrypting data key … AccessDeniedException”.

  1. Copy the key ARN from Session Manager Preferences.
  2. IAM → Roles → instance role → Add permissions → Create inline policy → JSON.
  3. Paste below with your key ARN → name SessionManager-KMS-Decrypt → Create.
json — SessionManager-KMS-Decrypt
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "kms:Decrypt",
      "Resource": "arn:aws:kms:ap-south-1:123456789012:key/YOUR-SESSION-MANAGER-KEY-ID"
    }
  ]
}
3

Diagnose a server that won’t register

First command: HTTP/1.1 400 = network OK (timeout = blocked). Log should show “Set up control channel successfully”.

▶ Run on: the affected EC2 server (as root / sudo)

bash — network + restart agent
curl -sI https://ssm.ap-south-1.amazonaws.com | head -1
sudo snap restart amazon-ssm-agent
sudo tail -30 /var/log/amazon/ssm/amazon-ssm-agent.log

▶ Run on: the same server

bash — is a role attached?
TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 60")
curl -s -H "X-aws-ec2-metadata-token: $TOKEN" http://169.254.169.254/latest/meta-data/iam/security-credentials/; echo
ResultFix
Role name printedRole OK — check Fleet Manager in 2–3 min
Empty / 404Attach the role (checklist #2), then sudo snap restart amazon-ssm-agent
Log: AccessDeniedRole trust must be ec2.amazonaws.com
4

Connecting to private servers

  • No public IP → EC2 Instance Connect won’t work.
  • Use EC2 → Connect → Session Manager — no SSH keys, no open ports.

⏰Part 6 — Scheduled Start / Stop

Start servers every morning (example 08:30) without any code, using EventBridge Scheduler. Alerts from Part 1 still fire, showing the scheduler role as “Action By”.

1

Create the scheduler role

The console cannot auto-create a role for this target, so create it once.

  1. IAM → Roles → Create role → Trusted entity: Custom trust policy → paste the trust policy → Next.
  2. Skip permissions → Next → Name EC2-Scheduler-Role → Create role.
  3. Open the role → Add permissions → Create inline policy → JSON → paste the permission policy → name EC2-StartStop-Policy → Create.
json — trust policy
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "Service": "scheduler.amazonaws.com" },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": { "aws:SourceAccount": "123456789012" }
      }
    }
  ]
}
json — permission policy
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["ec2:StartInstances", "ec2:StopInstances"],
      "Resource": "arn:aws:ec2:ap-south-1:123456789012:instance/*"
    }
  ]
}
2

Create the start schedule

  1. EventBridge → Scheduler → Schedules → Create schedule · Name Start-Instances-0830.
  2. Occurrence: Recurring · Time zone: yours (e.g. Asia/Calcutta) · Type: Cron-based.
  3. Fill the cron boxes as below, check the “Next 10 trigger dates” list, Flexible time window: Off → Next.
  4. Target: All APIs → Amazon EC2 → StartInstances · Input: the JSON below with your instance IDs → Next.
  5. Retry: On · Permissions: Use existing role → EC2-Scheduler-Role → Next → Create schedule.
MinutesHoursDay of monthMonthDay of weekYear
308?*MON-SAT*
Run onDay of monthDay of week
Mon–Sat?MON-SAT
Mon–Fri?MON-FRI
Every day*?
json — target input
{
  "InstanceIds": [
    "i-0aaaaaaaaaaaaaaaa",
    "i-0bbbbbbbbbbbbbbbb"
  ]
}
⚠️
? goes in Day of month, not Month. One of Day-of-month / Day-of-week must be ?.
3

Optional — evening stop schedule

Same steps, name Stop-Instances-2030, hour 20, target StopInstances, same input and role.

4

Test & notes

  • Temporarily set the cron to 5 minutes from now, confirm the STARTED email, then set it back.
  • EBS encrypted with a custom KMS key? Add kms:CreateGrant + kms:Decrypt on that key to the role, or instances fall back to stopped.
  • Disable any old start/stop Lambda or rule so servers aren’t started twice.

🦠Part 7 — GuardDuty Runtime Monitoring

Detects malware, crypto-mining, reverse shells and other suspicious activity inside Linux servers. GuardDuty installs its own agent through Systems Manager.

1

Check requirements

  • GuardDuty enabled in your region (GuardDuty → Get started if not).
  • Servers Online in Fleet Manager.
  • Linux only — Ubuntu 20.04/22.04/24.04, Amazon Linux, Debian, RHEL. Windows servers are skipped.
  • VPC DNS resolution + hostnames on (GuardDuty creates a VPC endpoint per VPC).
2

Optional — exclude servers

Tag servers to skip with GuardDutyManaged = false before enabling.

3

Enable

  1. GuardDuty → Runtime Monitoring → Configuration → Edit.
  2. Runtime Monitoring: Enabled.
  3. Automated agent configuration → Amazon EC2: Enabled → Save.
4

Verify coverage

After 15–30 minutes: Runtime Monitoring → Runtime coverage → EC2 instance → each Linux server Healthy.

▶ Run on: an Unhealthy Ubuntu server

bash — kernel version
uname -r
  • Unhealthy: server not in Fleet Manager, or VPC DNS settings off.
  • Ubuntu 24.04 with kernel 6.13/6.14 needs agent 1.9.2+ (installed automatically).
5

Cost & impact

  • Billed per vCPU-hour of covered servers — check GuardDuty → Usage after a day (new plans usually have a 30-day trial).
  • Agent is capped at 10% of vCPU and 128 MB RAM on servers under 8 GB.
  • Findings appear in GuardDuty → Findings. Emailing them is a planned next step.

📱Part 8 — Alert Mailbox & iPhone Notifications

Make critical alerts arrive instantly and reach you at night.

1

Use a dedicated mailbox

Microsoft 365 (Exchange)Google Workspace
Push in iPhone Mail✅ instant❌ fetch every 15+ min
Suggested planExchange Online Plan 11 user licence
  • Create e.g. [email protected].
  • iPhone: Settings → Apps → Mail → Mail Accounts → Add Account → Microsoft Exchange.
  • Put it in TO_EMAIL of all three Lambdas. In SES sandbox, verify it first.
2

Make alerts stand out (VIP)

  1. Open an alert email → tap sender [email protected] → Add to VIP.
  2. Mail → Mailboxes → ⓘ next to VIP → VIP Alerts → Lock Screen on, Banners Persistent, distinct sound.
3

Get alerts in Sleep mode

  1. Settings → Focus → Sleep → Apps → add Mail.
  2. Same Focus → Focus Filters → Add Filter → Mail → select only the alerts account.
⚠️
A Focus allowance does not override the Silent switch. To ring through silent mode you need an app with Critical Alerts (e.g. PagerDuty, Opsgenie).

🚑Troubleshooting

ProblemFix
No email at allLambda → Monitor → View CloudWatch logs; check SES sandbox / verified sender
“Log group does not exist”Lambda never ran → wrong rule, rule disabled, or trigger missing (Configuration → Triggers)
Red error on triggerRule has no target for this Lambda → delete trigger, add again from the correct rule
Rule fires, Lambda never invokedTarget had “Use execution role” ticked → untick and save
AuthorizationError / AccessDenied in logsInline policy missing on the Lambda role
MessageRejected (SES)Sender domain not verified, or recipient not verified while in sandbox
KeyError: 'detail' in TestReplace the default test event with a real event JSON
Only some logins / IAM changes alertForward rules in us-east-1 missing or disabled
Duplicate emailsOld SNS target still on a rule, or overlapping rules → remove extras
OS reboot not alerting (Ubuntu)systemctl status ec2-os-action-notify and its log
OS reboot not alerting (Windows)Task Scheduler → EC2-OS-Action-Notify; C:\ProgramData\EC2OSNotify\notify.log
Hook log shows 403 forbiddenHookToken in the association ≠ Lambda HOOK_TOKEN
Server missing in Fleet ManagerPart 5 checklist + diagnose commands
Session Manager KMS errorPart 5 — SessionManager-KMS-Decrypt
Scheduled start did nothingScheduler role/permissions, instance IDs, KMS grant for encrypted EBS
1

Maintenance

  • Rotate HOOK_TOKEN: change it in the Lambda, then edit the State Manager association with the new value.
  • Adding recipients: edit TO_EMAIL in each Lambda.
  • Public IP shows None when stopped and changes on each start unless you use an Elastic IP.
  • Remove old SNS targets / old start-stop Lambdas once the new setup is confirmed.