🚀Before You Start
Read this once. Every value below is an example — replace it with your own everywhere it appears.
Replace these example values
| Example in this guide | Replace with |
|---|---|
123456789012 | Your 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.com | Your 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-0aaaaaaaaaaaaaaaa | Your real instance IDs |
YOUR-SESSION-MANAGER-KEY-ID | Your KMS key ID (only if Session Manager uses KMS) |
IST / 330 | Your time-zone label and UTC offset in minutes |
Turn on CloudTrail
All “who did it” details come from CloudTrail.
- CloudTrail → Trails. If a trail shows Logging: On, you are done.
- Otherwise Create trail → any name → new S3 bucket → enable multi-region (default) → Create trail.
- Note the trail’s Home region — needed in Part 4.
Set up Amazon SES (sending email)
Lambdas send email through SES from your own address.
- Amazon SES (in
ap-south-1) → Identities → Create identity → Domain → enterexample.com. - Add the 3 DKIM CNAME records SES shows to your DNS. Wait until status is Verified.
- Account dashboard: if it says Sandbox, click Request production access (or verify the recipient address under Identities as a stop-gap).
Servers must be in Systems Manager
Needed for Part 2 (OS hook) and Part 7 (GuardDuty).
- Systems Manager → Fleet Manager should list every server as Online.
- Missing servers? Follow Part 5 — New Server Checklist first.
How it fits together
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| Part | Builds | Lambda |
|---|---|---|
| 1 | EC2 start/stop/reboot/terminate alerts | EC2-State-Alert |
| 2 | Reboot/shutdown from inside the OS (SSH/RDP) | EC2-State-Alert |
| 3 | Console login alerts | AWS-Login-Alert |
| 4 | CloudTrail tampering + IAM change alerts | AWS-Security-Alert |
| 5 | New VPC / new server checklist | — |
| 6 | Scheduled start/stop | — |
| 7 | GuardDuty Runtime Monitoring | — |
| 8 | Alert mailbox + iPhone | — |
🖥️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.
Create the Lambda
- Open Lambda → Create function → Author from scratch.
- Function name:
EC2-State-Alert· Runtime: Python 3.12 (or newer) · Execution role: Create a new role with basic Lambda permissions → Create function. - Configuration → General configuration → Edit → Timeout: 5 min 0 sec → Save.
Add environment variables
Configuration → Environment variables → Edit → Add environment variable for each row, then Save.| Key | Value (example) |
|---|---|
FROM_EMAIL | AWS Alerts <[email protected]> |
TO_EMAIL | [email protected] (comma-separate for several) |
SES_REGION | ap-south-1 — region where your domain is verified in SES |
TZ_LABEL | IST — label shown in emails |
TZ_OFFSET_MINUTES | 330 — your UTC offset in minutes (IST = 330, UTC = 0, GMT+4 = 240) |
Add permissions
- Configuration → Permissions → click the Role name (opens IAM).
- Add permissions → Create inline policy → JSON, paste the policy below.
- Next → Policy name:
EC2-Alert-Policy→ Create 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"
}
]
}Paste the code
Open the Code tab → select all inlambda_function.py → paste → click Deploy.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)Rule A — Console / CLI actions
Catches Start/Stop/Reboot/Terminate API calls, including the user who made them.
- Make sure the console region (top right) is your main region (ap-south-1).
- EventBridge → Rules → Create rule · Name:
EC2-instance-changes-states· Event bus: default · Rule type: Rule with an event pattern → Next. - Creation method: Custom pattern (JSON editor) → paste the pattern → Next.
- Target: AWS service → Lambda function →
EC2-State-Alert. Untick “Use execution role (recommended)” (if ticked, calls fail silently). - Next → Next → Create rule.
{
"source": ["aws.ec2"],
"detail-type": ["AWS API Call via CloudTrail"],
"detail": {
"eventSource": ["ec2.amazonaws.com"],
"eventName": ["StartInstances", "StopInstances", "RebootInstances", "TerminateInstances"]
}
}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).
- Make sure the console region (top right) is your main region (ap-south-1).
- EventBridge → Rules → Create rule · Name:
EC2-OS-Shutdown· Event bus: default · Rule type: Rule with an event pattern → Next. - Creation method: Custom pattern (JSON editor) → paste the pattern → Next.
- Target: AWS service → Lambda function →
EC2-State-Alert. Untick “Use execution role (recommended)” (if ticked, calls fail silently). - Next → Next → Create rule.
{
"source": ["aws.ec2"],
"detail-type": ["EC2 Instance State-change Notification"],
"detail": {
"state": ["stopped", "terminated"]
}
}Test
- Stop a test instance from the EC2 console.
- Wait 1–3 minutes → email
[EC2] name - STOPPEDwith Action By = your IAM user. - Start it again →
STARTEDemail.
🪝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.
Create a secret token
The hook sends this token so only your servers can trigger alerts.
▶ Run on: your own PC — Windows PowerShell
-join ((48..57) + (97..122) | Get-Random -Count 40 | ForEach-Object {[char]$_})▶ Run on: or any Linux / Mac terminal
openssl rand -hex 20Add the token to the Lambda
Lambda → EC2-State-Alert → Configuration → Environment variables → Edit → Add: KeyHOOK_TOKEN · Value = your token → Save.Create a Function URL
- Configuration → Function URL → Create function URL.
- Auth type: NONE (the token protects it) → Save.
- Copy the URL (ends with
.lambda-url.ap-south-1.on.aws/).
Create the SSM document
- Systems Manager → Documents → Create document → Command or Session.
- Name:
EC2-OS-Action-Hook· Target type:/AWS::EC2::Instance· Document type: Command. - Content: choose YAML, replace everything with the document below → Create document.
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"Trial on 2 servers first
- Systems Manager → Run Command → Run command → search
EC2-OS-Action-Hook. - Fill FunctionUrl and HookToken — paste exactly, no spaces.
- Targets: Choose instances manually → 1 Ubuntu + 1 Windows → Run. Both must show Success.
Test without rebooting
▶ Run on: the Ubuntu test server (SSH or Session Manager)
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.serviceFirst 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
shutdown /r /t 120 /c "alert test"
shutdown /a
type C:\ProgramData\EC2OSNotify\notify.logYou get a REBOOTED email; the server does not restart.
Roll out to all servers
- Systems Manager → State Manager → Create association.
- Name:
EC2-OS-Action-Hook-All· Document:EC2-OS-Action-Hook. - Fill FunctionUrl and HookToken.
- Targets: Choose all instances · Schedule: No schedule → Create association.
- After a few minutes, open the association → Execution history → all servers Success.
What gets installed
| OS | Files | Log |
|---|---|---|
| Ubuntu | /usr/local/bin/ec2-os-action-notify.sh + systemd ec2-os-action-notify | /var/log/ec2-os-action-notify.log |
| Windows | C:\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 -iit showsroot shell (logged in: …). - Windows Update restarts appear as
NT AUTHORITY\SYSTEM. - Instances get an
OSShutdownAlerttag 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.
Create the Lambda
- Open Lambda → Create function → Author from scratch.
- Function name:
AWS-Login-Alert· Runtime: Python 3.12 (or newer) · Execution role: Create a new role with basic Lambda permissions → Create function. - Configuration → General configuration → Edit → Timeout: 30 sec → Save.
Add environment variables
Configuration → Environment variables → Edit → Add environment variable for each row, then Save.| Key | Value (example) |
|---|---|
FROM_EMAIL | AWS Alerts <[email protected]> |
TO_EMAIL | [email protected] (comma-separate for several) |
SES_REGION | ap-south-1 — region where your domain is verified in SES |
TZ_LABEL | IST — label shown in emails |
TZ_OFFSET_MINUTES | 330 — your UTC offset in minutes (IST = 330, UTC = 0, GMT+4 = 240) |
Add permissions
- Configuration → Permissions → click the Role name (opens IAM).
- Add permissions → Create inline policy → JSON, paste the policy below.
- Next → Policy name:
Login-Alert-Policy→ Create policy.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["ses:SendEmail", "ses:SendRawEmail"],
"Resource": "*"
}
]
}Paste the code
Open the Code tab → select all inlambda_function.py → paste → click Deploy.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)Test with a sample event
- Test tab → Create new event → name
login-test. - Delete the sample JSON, paste the event below → Save → Test.
- You should receive
[AWS Login] [email protected] - SUCCESS.
{
"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"
}
}KeyError: 'detail' means the default sample event is still there — replace it.Rule in your main region
- Make sure the console region (top right) is your main region (ap-south-1).
- EventBridge → Rules → Create rule · Name:
Console-Login· Event bus: default · Rule type: Rule with an event pattern → Next. - Creation method: Custom pattern (JSON editor) → paste the pattern → Next.
- Target: AWS service → Lambda function →
AWS-Login-Alert. Untick “Use execution role (recommended)” (if ticked, calls fail silently). - Next → Next → Create rule.
{
"source": ["aws.signin"],
"detail-type": ["AWS Console Sign In via CloudTrail"]
}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.
- Make sure the console region (top right) is US East (N. Virginia) us-east-1.
- EventBridge → Rules → Create rule · Name:
Forward-Logins-To-Your-Region· Event bus: default · Rule type: Rule with an event pattern → Next. - Creation method: Custom pattern (JSON editor) → paste the pattern → Next.
- 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. - Next → Next → Create rule.
Use the same pattern as the previous step. Switch back to your main region afterwards.
Test
- Sign out and sign in a few ways: normal sign-in page, region URL, SSO.
- Each sign-in → one email within ~1 minute.
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.
Create the Lambda
- Open Lambda → Create function → Author from scratch.
- Function name:
AWS-Security-Alert· Runtime: Python 3.12 (or newer) · Execution role: Create a new role with basic Lambda permissions → Create function. - Configuration → General configuration → Edit → Timeout: 30 sec → Save.
Add environment variables
Configuration → Environment variables → Edit → Add environment variable for each row, then Save.| Key | Value (example) |
|---|---|
FROM_EMAIL | AWS Alerts <[email protected]> |
TO_EMAIL | [email protected] (comma-separate for several) |
SES_REGION | ap-south-1 — region where your domain is verified in SES |
TZ_LABEL | IST — label shown in emails |
TZ_OFFSET_MINUTES | 330 — your UTC offset in minutes (IST = 330, UTC = 0, GMT+4 = 240) |
WATCH_POLICIES | Optional: name(s) of critical IAM policies (e.g. an IP-restriction policy). Any change → CRITICAL |
Add permissions
- Configuration → Permissions → click the Role name (opens IAM).
- Add permissions → Create inline policy → JSON, paste the policy below.
- Next → Policy name:
Security-Alert-Policy→ Create policy.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["ses:SendEmail", "ses:SendRawEmail"],
"Resource": "*"
}
]
}Paste the code
Open the Code tab → select all inlambda_function.py → paste → click Deploy.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)Rule — CloudTrail & alert tampering
Stop/delete/modify of trails, and disabling/deleting EventBridge rules (your alerts).
- Make sure the console region (top right) is your main region (ap-south-1).
- EventBridge → Rules → Create rule · Name:
Alert-System-Tampering· Event bus: default · Rule type: Rule with an event pattern → Next. - Creation method: Custom pattern (JSON editor) → paste the pattern → Next.
- Target: AWS service → Lambda function →
AWS-Security-Alert. Untick “Use execution role (recommended)” (if ticked, calls fail silently). - Next → Next → Create rule.
{
"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"
]
}
}Rule — IAM changes
- Make sure the console region (top right) is your main region (ap-south-1).
- EventBridge → Rules → Create rule · Name:
IAM-Changes· Event bus: default · Rule type: Rule with an event pattern → Next. - Creation method: Custom pattern (JSON editor) → paste the pattern → Next.
- Target: AWS service → Lambda function →
AWS-Security-Alert. Untick “Use execution role (recommended)” (if ticked, calls fail silently). - Next → Next → Create rule.
{
"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"
]
}
}Forward IAM events from N. Virginia
IAM is a global service — its events land in us-east-1. This step is required.
- Make sure the console region (top right) is US East (N. Virginia) us-east-1.
- EventBridge → Rules → Create rule · Name:
Forward-IAM-To-Your-Region· Event bus: default · Rule type: Rule with an event pattern → Next. - Creation method: Custom pattern (JSON editor) → paste the pattern → Next.
- 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. - Next → Next → Create rule.
Use the same IAM pattern. Switch back to your main region afterwards.
Test safely
- IAM → Users → Create user
test-alert-user(no permissions) → email[SECURITY] IAM user CREATED. - Delete the user →
IAM user DELETED. - EventBridge → Rules → Create rule
test-dummy(any pattern, no target) → Disable → Delete → two HIGH alerts.
Severity guide
| Severity | Examples |
|---|---|
| CRITICAL | CloudTrail stopped/deleted, MFA removed, password policy deleted, watched policy changed, AdministratorAccess attached |
| HIGH | User/role/access key created, policy attached/detached, rule disabled/deleted |
| MEDIUM | Rule targets removed, access key deleted, policy created |
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.
{
"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.
Checklist
| # | Check | Where |
|---|---|---|
| 1 | Instance role has AmazonSSMManagedInstanceCore and CloudWatchAgentServerPolicy | IAM → Roles |
| 2 | Role is attached to the instance | EC2 → instance → Actions → Security → Modify IAM role |
| 3 | Subnet route table has 0.0.0.0/0 → NAT Gateway (private) or Internet Gateway (public) | VPC → Subnets → Route table |
| 4 | Security group outbound allows HTTPS 443 | EC2 → instance → Security |
| 5 | Custom network ACL allows outbound 443 + inbound 1024–65535 | VPC → Network ACLs |
| 6 | DNS resolution + DNS hostnames enabled | VPC → Edit VPC settings |
| 7 | Not Online in Fleet Manager after fixes → reboot instance, wait 5–10 min | EC2 / Fleet Manager |
| 8 | Hook installed → association “Apply association now” | State Manager |
| 9 | If Session Manager uses KMS → role has kms:Decrypt (next step) | IAM |
Session Manager KMS permission
Only needed if Systems Manager → Session Manager → Preferences shows a KMS key. Fixes “Error when decrypting data key … AccessDeniedException”.
- Copy the key ARN from Session Manager Preferences.
- IAM → Roles → instance role → Add permissions → Create inline policy → JSON.
- Paste below with your key ARN → name
SessionManager-KMS-Decrypt→ Create.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "kms:Decrypt",
"Resource": "arn:aws:kms:ap-south-1:123456789012:key/YOUR-SESSION-MANAGER-KEY-ID"
}
]
}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)
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
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| Result | Fix |
|---|---|
| Role name printed | Role OK — check Fleet Manager in 2–3 min |
| Empty / 404 | Attach the role (checklist #2), then sudo snap restart amazon-ssm-agent |
| Log: AccessDenied | Role trust must be ec2.amazonaws.com |
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”.
Create the scheduler role
The console cannot auto-create a role for this target, so create it once.
- IAM → Roles → Create role → Trusted entity: Custom trust policy → paste the trust policy → Next.
- Skip permissions → Next → Name
EC2-Scheduler-Role→ Create role. - Open the role → Add permissions → Create inline policy → JSON → paste the permission policy → name
EC2-StartStop-Policy→ Create.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "scheduler.amazonaws.com" },
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": { "aws:SourceAccount": "123456789012" }
}
}
]
}{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["ec2:StartInstances", "ec2:StopInstances"],
"Resource": "arn:aws:ec2:ap-south-1:123456789012:instance/*"
}
]
}Create the start schedule
- EventBridge → Scheduler → Schedules → Create schedule · Name
Start-Instances-0830. - Occurrence: Recurring · Time zone: yours (e.g. Asia/Calcutta) · Type: Cron-based.
- Fill the cron boxes as below, check the “Next 10 trigger dates” list, Flexible time window: Off → Next.
- Target: All APIs → Amazon EC2 → StartInstances · Input: the JSON below with your instance IDs → Next.
- Retry: On · Permissions: Use existing role → EC2-Scheduler-Role → Next → Create schedule.
| Minutes | Hours | Day of month | Month | Day of week | Year |
|---|---|---|---|---|---|
30 | 8 | ? | * | MON-SAT | * |
| Run on | Day of month | Day of week |
|---|---|---|
| Mon–Sat | ? | MON-SAT |
| Mon–Fri | ? | MON-FRI |
| Every day | * | ? |
{
"InstanceIds": [
"i-0aaaaaaaaaaaaaaaa",
"i-0bbbbbbbbbbbbbbbb"
]
}? goes in Day of month, not Month. One of Day-of-month / Day-of-week must be ?.Optional — evening stop schedule
Same steps, name Stop-Instances-2030, hour 20, target StopInstances, same input and role.
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:Decrypton 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.
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).
Optional — exclude servers
Tag servers to skip with GuardDutyManaged = false before enabling.
Enable
- GuardDuty → Runtime Monitoring → Configuration → Edit.
- Runtime Monitoring: Enabled.
- Automated agent configuration → Amazon EC2: Enabled → Save.
Verify coverage
After 15–30 minutes: Runtime Monitoring → Runtime coverage → EC2 instance → each Linux server Healthy.
▶ Run on: an Unhealthy Ubuntu server
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).
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.
Use a dedicated mailbox
| Microsoft 365 (Exchange) | Google Workspace | |
|---|---|---|
| Push in iPhone Mail | ✅ instant | ❌ fetch every 15+ min |
| Suggested plan | Exchange Online Plan 1 | 1 user licence |
- Create e.g.
[email protected]. - iPhone: Settings → Apps → Mail → Mail Accounts → Add Account → Microsoft Exchange.
- Put it in
TO_EMAILof all three Lambdas. In SES sandbox, verify it first.
Make alerts stand out (VIP)
- Open an alert email → tap sender
[email protected]→ Add to VIP. - Mail → Mailboxes → ⓘ next to VIP → VIP Alerts → Lock Screen on, Banners Persistent, distinct sound.
Get alerts in Sleep mode
- Settings → Focus → Sleep → Apps → add Mail.
- Same Focus → Focus Filters → Add Filter → Mail → select only the alerts account.
🚑Troubleshooting
| Problem | Fix |
|---|---|
| No email at all | Lambda → 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 trigger | Rule has no target for this Lambda → delete trigger, add again from the correct rule |
| Rule fires, Lambda never invoked | Target had “Use execution role” ticked → untick and save |
| AuthorizationError / AccessDenied in logs | Inline policy missing on the Lambda role |
| MessageRejected (SES) | Sender domain not verified, or recipient not verified while in sandbox |
| KeyError: 'detail' in Test | Replace the default test event with a real event JSON |
| Only some logins / IAM changes alert | Forward rules in us-east-1 missing or disabled |
| Duplicate emails | Old 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 forbidden | HookToken in the association ≠ Lambda HOOK_TOKEN |
| Server missing in Fleet Manager | Part 5 checklist + diagnose commands |
| Session Manager KMS error | Part 5 — SessionManager-KMS-Decrypt |
| Scheduled start did nothing | Scheduler role/permissions, instance IDs, KMS grant for encrypted EBS |
Maintenance
- Rotate HOOK_TOKEN: change it in the Lambda, then edit the State Manager association with the new value.
- Adding recipients: edit
TO_EMAILin 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.