|
// ============================================================ |
|
// One Lambda handles all 14 EventBridge rules. The router picks |
|
// a severity + formatter, then fans out to SES + Slack. |
|
// ============================================================ |
|
|
|
const { SESClient, SendEmailCommand } = require("@aws-sdk/client-ses"); |
|
const { SSMClient, GetParameterCommand } = require("@aws-sdk/client-ssm"); |
|
|
|
const sesClient = new SESClient({ region: process.env.AWS_REGION }); |
|
const ssmClient = new SSMClient({ region: process.env.AWS_REGION }); |
|
const FROM_EMAIL = process.env.SES_FROM_EMAIL; |
|
const TO_EMAIL = process.env.SES_TO_EMAIL; |
|
|
|
let slackWebhookUrl = null; |
|
|
|
async function getSlackWebhookUrl() { |
|
if (slackWebhookUrl) return slackWebhookUrl; |
|
const resp = await ssmClient.send( |
|
new GetParameterCommand({ Name: process.env.SLACK_WEBHOOK_SSM_PATH, WithDecryption: true }) |
|
); |
|
slackWebhookUrl = resp.Parameter.Value; |
|
return slackWebhookUrl; |
|
} |
|
|
|
exports.handler = async (event) => { |
|
try { |
|
const notification = buildNotification(event); |
|
if (!notification) { |
|
console.log("Event filtered out, no notification sent"); |
|
return; |
|
} |
|
|
|
const { subject, html, text, slack } = notification; |
|
const webhookUrl = await getSlackWebhookUrl(); |
|
|
|
await Promise.all([ |
|
sendEmail(subject, html, text), |
|
sendSlack(webhookUrl, slack), |
|
]); |
|
} catch (error) { |
|
console.error(`Error sending notification: ${error}`); |
|
throw error; |
|
} |
|
}; |
|
|
|
async function sendEmail(subject, html, text) { |
|
const response = await sesClient.send( |
|
new SendEmailCommand({ |
|
Source: FROM_EMAIL, |
|
Destination: { ToAddresses: [TO_EMAIL] }, |
|
Message: { |
|
Subject: { Data: subject }, |
|
Body: { Html: { Data: html }, Text: { Data: text } }, |
|
}, |
|
}) |
|
); |
|
console.log(`Email sent: ${response.MessageId}`); |
|
} |
|
|
|
async function sendSlack(webhookUrl, payload) { |
|
const resp = await fetch(webhookUrl, { |
|
method: "POST", |
|
headers: { "Content-Type": "application/json" }, |
|
body: JSON.stringify(payload), |
|
}); |
|
if (!resp.ok) throw new Error(`Slack webhook returned ${resp.status}: ${await resp.text()}`); |
|
console.log("Slack notification sent"); |
|
} |
|
|
|
// --------------------------------------------------------------------------- |
|
// Routing — single dispatcher per event signature |
|
// --------------------------------------------------------------------------- |
|
|
|
function buildNotification(event) { |
|
const eventName = event.detail?.eventName; |
|
const source = event.source; |
|
const userType = event.detail?.userIdentity?.type; |
|
|
|
if (eventName === "ConsoleLogin" && userType === "Root") { |
|
return render("critical", "Root User Login", formatRootLogin(event)); |
|
} |
|
if (eventName === "ConsoleLogin" && event.detail?.responseElements?.ConsoleLogin === "Failure") { |
|
return render("medium", "Console Login Failure", formatConsoleLoginFailure(event)); |
|
} |
|
if (eventName === "ConsoleLogin") { |
|
return render("medium", "Console Login Without MFA", formatNoMFALogin(event)); |
|
} |
|
if (eventName === "StopLogging") { |
|
return render("critical", "CloudTrail Logging Stopped", formatCloudTrailTampering(event)); |
|
} |
|
if (eventName === "PutEventSelectors") { |
|
return render("critical", "CloudTrail Event Selectors Modified", formatCloudTrailTampering(event)); |
|
} |
|
if (source === "aws.iam" && eventName === "CreateUser") { |
|
return render("info", "IAM User Created", formatIAMUserCreated(event)); |
|
} |
|
if (source === "aws.iam" && eventName === "CreateAccessKey") { |
|
return render("info", "IAM Access Key Created", formatIAMAccessKeyCreated(event)); |
|
} |
|
if (source === "aws.identitystore" && eventName === "CreateUser") { |
|
return render("info", "Identity Center User Created", formatIdentityCenterUserCreated(event)); |
|
} |
|
if (eventName === "DeactivateMFADevice") { |
|
return render("critical", "MFA Device Deactivated", formatMFADeactivated(event)); |
|
} |
|
if (eventName === "AuthorizeSecurityGroupIngress") { |
|
return formatSecurityGroupExposure(event); // returns null if no 0.0.0.0/0 or ::/0 |
|
} |
|
if (event["detail-type"] === "Access Analyzer Finding") { |
|
return render("high", "Access Analyzer Finding", formatAccessAnalyzerFinding(event)); |
|
} |
|
if (event["detail-type"] === "AWS Budget Notification") { |
|
return render("medium", "Budget Threshold Reached", formatBudgetAlert(event)); |
|
} |
|
if (event["detail-type"] === "AWS Cost Anomaly Detection Alert") { |
|
return render("high", "Cost Anomaly Detected", formatCostAnomaly(event)); |
|
} |
|
if (source === "aws.iam" && eventName === "CreateLoginProfile") { |
|
return render("high", "Console Access Added to IAM User", formatLoginProfileCreated(event)); |
|
} |
|
|
|
return render("info", "AWS Management Event", formatMgmtEvent(event)); |
|
} |
|
|
|
// --------------------------------------------------------------------------- |
|
// Rendering |
|
// --------------------------------------------------------------------------- |
|
|
|
const SEVERITY = { |
|
critical: { bg: "#dc2626", label: "CRITICAL" }, |
|
high: { bg: "#ea580c", label: "HIGH" }, |
|
medium: { bg: "#d97706", label: "MEDIUM" }, |
|
info: { bg: "#2563eb", label: "INFO" }, |
|
}; |
|
|
|
function render(severity, title, { fields, extraHtml = "", extraSlackBlocks = [] }) { |
|
const { bg, label } = SEVERITY[severity] ?? SEVERITY.info; |
|
const subject = `[${label}] ${title}`; |
|
|
|
const html = buildHtml(bg, label, title, fields, extraHtml); |
|
const text = buildText(label, title, fields); |
|
const slack = buildSlack(bg, label, title, fields, extraSlackBlocks); |
|
|
|
return { subject, html, text, slack }; |
|
} |
|
|
|
function buildHtml(bg, label, title, fields, extra) { |
|
const rowsHtml = fields |
|
.map( |
|
([k, v]) => |
|
`<tr> |
|
<td style="color:#6b7280;font-size:13px;padding:7px 16px 7px 0;white-space:nowrap;vertical-align:top">${k}</td> |
|
<td style="font-size:13px;color:#111827;font-family:'SF Mono',Menlo,monospace;word-break:break-all;padding:7px 0">${esc(v)}</td> |
|
</tr>` |
|
) |
|
.join(""); |
|
|
|
return `<!DOCTYPE html> |
|
<html> |
|
<head><meta charset="utf-8"></head> |
|
<body style="margin:0;padding:24px;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif"> |
|
<div style="max-width:560px;margin:0 auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.1)"> |
|
<div style="background:${bg};padding:18px 24px"> |
|
<span style="color:rgba(255,255,255,.7);font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase">${label}</span> |
|
<h1 style="margin:4px 0 0;color:#fff;font-size:17px;font-weight:600">${esc(title)}</h1> |
|
</div> |
|
<div style="padding:20px 24px"> |
|
<table style="width:100%;border-collapse:collapse">${rowsHtml}</table> |
|
${extra} |
|
</div> |
|
<div style="padding:14px 24px;background:#f9fafb;border-top:1px solid #e5e7eb;font-size:11px;color:#9ca3af"> |
|
AWS Organization Management Alert · example.com |
|
</div> |
|
</div> |
|
</body> |
|
</html>`; |
|
} |
|
|
|
function buildText(label, title, fields) { |
|
const rows = fields.map(([k, v]) => `${k.padEnd(18)} ${v ?? "N/A"}`).join("\n"); |
|
return `[${label}] ${title}\n${"=".repeat(50)}\n${rows}`; |
|
} |
|
|
|
function buildSlack(color, label, title, fields, extraBlocks = []) { |
|
const lines = fields |
|
.filter(([, v]) => v != null) |
|
.map(([k, v]) => `*${k}:* ${v}`) |
|
.join("\n"); |
|
|
|
const blocks = [ |
|
{ type: "header", text: { type: "plain_text", text: `[${label}] ${title}`, emoji: false } }, |
|
{ type: "section", text: { type: "mrkdwn", text: lines } }, |
|
...extraBlocks, |
|
{ type: "context", elements: [{ type: "mrkdwn", text: "AWS Organization · example.com" }] }, |
|
]; |
|
|
|
return { attachments: [{ color, blocks }] }; |
|
} |
|
|
|
function esc(v) { |
|
if (v == null) return '<span style="color:#9ca3af">N/A</span>'; |
|
return String(v) |
|
.replace(/&/g, "&") |
|
.replace(/</g, "<") |
|
.replace(/>/g, ">") |
|
.replace(/"/g, """); |
|
} |
|
|
|
// --------------------------------------------------------------------------- |
|
// Field extractors (a sample — same pattern for the rest) |
|
// --------------------------------------------------------------------------- |
|
|
|
function formatRootLogin(event) { |
|
return { |
|
fields: [ |
|
["Account ID", event.detail?.userIdentity?.accountId], |
|
["Source IP", event.detail?.sourceIPAddress], |
|
["Result", event.detail?.responseElements?.ConsoleLogin], |
|
["Time", event.time], |
|
], |
|
}; |
|
} |
|
|
|
function formatConsoleLoginFailure(event) { |
|
const id = event.detail?.userIdentity; |
|
return { |
|
fields: [ |
|
["Account ID", id?.accountId], |
|
["User", id?.arn ?? id?.principalId], |
|
["Source IP", event.detail?.sourceIPAddress], |
|
["Time", event.time], |
|
], |
|
}; |
|
} |
|
|
|
function formatNoMFALogin(event) { |
|
const id = event.detail?.userIdentity; |
|
return { |
|
fields: [ |
|
["Account ID", id?.accountId], |
|
["User", id?.arn ?? id?.userName ?? id?.principalId], |
|
["Source IP", event.detail?.sourceIPAddress], |
|
["Time", event.time], |
|
], |
|
}; |
|
} |
|
|
|
function formatCloudTrailTampering(event) { |
|
const id = event.detail?.userIdentity; |
|
return { |
|
fields: [ |
|
["Event", event.detail?.eventName], |
|
["Account ID", id?.accountId], |
|
["User", id?.arn ?? id?.principalId], |
|
["Source IP", event.detail?.sourceIPAddress], |
|
["Trail", event.detail?.requestParameters?.trailName], |
|
["Time", event.time], |
|
], |
|
}; |
|
} |
|
|
|
function formatIAMUserCreated(event) { |
|
const id = event.detail?.userIdentity; |
|
const newUser = event.detail?.responseElements?.user; |
|
return { |
|
fields: [ |
|
["New User", newUser?.userName], |
|
["User ARN", newUser?.arn], |
|
["Account ID", id?.accountId], |
|
["Created By", id?.arn ?? id?.principalId], |
|
["Source IP", event.detail?.sourceIPAddress], |
|
["Time", event.time], |
|
], |
|
}; |
|
} |
|
|
|
function formatIAMAccessKeyCreated(event) { |
|
const id = event.detail?.userIdentity; |
|
const key = event.detail?.responseElements?.accessKey; |
|
return { |
|
fields: [ |
|
["Key ID", key?.accessKeyId], |
|
["For User", key?.userName ?? event.detail?.requestParameters?.userName], |
|
["Account ID", id?.accountId], |
|
["Created By", id?.arn ?? id?.principalId], |
|
["Source IP", event.detail?.sourceIPAddress], |
|
["Time", event.time], |
|
], |
|
}; |
|
} |
|
|
|
function formatIdentityCenterUserCreated(event) { |
|
const id = event.detail?.userIdentity; |
|
const req = event.detail?.requestParameters; |
|
const email = req?.emails?.find((e) => e.primary)?.value ?? req?.emails?.[0]?.value; |
|
return { |
|
fields: [ |
|
["User Name", req?.userName], |
|
["Email", email], |
|
["Identity Store", req?.identityStoreId], |
|
["Created By", id?.arn ?? id?.principalId], |
|
["Time", event.time], |
|
], |
|
}; |
|
} |
|
|
|
function formatSecurityGroupExposure(event) { |
|
const items = event.detail?.requestParameters?.ipPermissions?.items ?? []; |
|
const openRules = items.filter((item) => { |
|
const ipv4Open = item.ipRanges?.items?.some((r) => r.cidrIp === "0.0.0.0/0"); |
|
const ipv6Open = item.ipv6Ranges?.items?.some((r) => r.cidrIpv6 === "::/0"); |
|
return ipv4Open || ipv6Open; |
|
}); |
|
|
|
if (openRules.length === 0) return null; |
|
|
|
const id = event.detail?.userIdentity; |
|
|
|
const ruleLines = openRules.map((rule) => { |
|
const proto = rule.ipProtocol === "-1" ? "all traffic" : rule.ipProtocol; |
|
const ports = rule.fromPort != null ? `port ${rule.fromPort}-${rule.toPort}` : "all ports"; |
|
return { proto, ports }; |
|
}); |
|
|
|
const rulesHtml = ruleLines.map(({ proto, ports }) => `<li>${esc(proto)} ${esc(ports)}</li>`).join(""); |
|
const rulesSlack = ruleLines.map(({ proto, ports }) => `• \`${proto}\` ${ports}`).join("\n"); |
|
const rulesText = ruleLines.map(({ proto, ports }) => ` • ${proto} ${ports}`).join("\n"); |
|
|
|
const base = render("high", "Security Group Opened to Internet", { |
|
fields: [ |
|
["Security Group", event.detail?.requestParameters?.groupId], |
|
["Account ID", id?.accountId], |
|
["Changed By", id?.arn ?? id?.principalId], |
|
["Source IP", event.detail?.sourceIPAddress], |
|
["Time", event.time], |
|
], |
|
extraHtml: `<p style="margin:16px 0 6px;font-size:13px;color:#6b7280">Rules opened to internet:</p><ul style="margin:0;padding-left:20px">${rulesHtml}</ul>`, |
|
extraSlackBlocks: [ |
|
{ type: "section", text: { type: "mrkdwn", text: `*Rules opened to internet:*\n${rulesSlack}` } }, |
|
], |
|
}); |
|
|
|
base.text += `\n\nRules opened to internet:\n${rulesText}`; |
|
return base; |
|
} |
|
|
|
function formatCostAnomaly(event) { |
|
const d = event.detail; |
|
return { |
|
fields: [ |
|
["Monitor", d?.monitorName], |
|
["Service", d?.anomalyDetails?.serviceName ?? d?.rootCauses?.[0]?.service], |
|
["Impact", d?.impact?.totalActualSpend != null ? `$${parseFloat(d.impact.totalActualSpend).toFixed(2)}` : null], |
|
["Expected", d?.impact?.totalExpectedSpend != null ? `$${parseFloat(d.impact.totalExpectedSpend).toFixed(2)}` : null], |
|
["Account", d?.accountId ?? event.account], |
|
["Time", event.time], |
|
], |
|
}; |
|
} |
|
|
|
function formatLoginProfileCreated(event) { |
|
const id = event.detail?.userIdentity; |
|
return { |
|
fields: [ |
|
["User", event.detail?.requestParameters?.userName], |
|
["Account", id?.accountId], |
|
["Created By", id?.arn ?? id?.principalId], |
|
["IP", event.detail?.sourceIPAddress], |
|
["Time", event.time], |
|
], |
|
}; |
|
} |
|
|
|
function formatMFADeactivated(event) { |
|
const id = event.detail?.userIdentity; |
|
return { |
|
fields: [ |
|
["User", event.detail?.requestParameters?.userName], |
|
["Account", id?.accountId], |
|
["By", id?.arn ?? id?.principalId], |
|
["IP", event.detail?.sourceIPAddress], |
|
["Time", event.time], |
|
], |
|
}; |
|
} |
|
|
|
function formatAccessAnalyzerFinding(event) { |
|
const d = event.detail; |
|
return { |
|
fields: [ |
|
["Resource", d?.resource], |
|
["Resource Type", d?.resourceType], |
|
["Finding Type", d?.findingType], |
|
["Is Public", d?.isPublic != null ? String(d.isPublic) : null], |
|
["Account", d?.accountId], |
|
["Region", d?.region], |
|
["Time", event.time], |
|
], |
|
}; |
|
} |
|
|
|
function formatBudgetAlert(event) { |
|
const d = event.detail; |
|
return { |
|
fields: [ |
|
["Budget", d?.budgetName], |
|
["Limit", d?.budgetLimit?.amount ? `$${d.budgetLimit.amount}` : null], |
|
["Actual", d?.currentSpend?.amount ? `$${d.currentSpend.amount}` : null], |
|
["Forecasted", d?.forecastedSpend?.amount ? `$${d.forecastedSpend.amount}` : null], |
|
["Alert Type", d?.notificationType], |
|
["Account", d?.accountId], |
|
["Time", event.time], |
|
], |
|
}; |
|
} |
|
|
|
function formatMgmtEvent(event) { |
|
const id = event.detail?.userIdentity; |
|
const principalId = id?.principalId ?? ""; |
|
const user = principalId.includes(":") ? principalId.split(":").pop() : (id?.userName ?? principalId); |
|
|
|
return { |
|
fields: [ |
|
["Event", event.detail?.eventName], |
|
["Account", id?.accountId], |
|
["User", user || null], |
|
["IP", event.detail?.sourceIPAddress], |
|
["Time", event.time], |
|
], |
|
}; |
|
} |