seahaven-org-baseline/lib/cis-monitoring.ts
seahaven-openswe[bot] 142e221c47
feat: harden CIS 4.1 detection depth with M-of-N alarm tuning and CloudTrail Insights (#38)
Switch UnauthorizedApiCalls alarm from 3/3 consecutive to 3/6 M-of-N
so a single quiet 5-min window can't reset detection. The 3/3 setting
pre-dates #36 and was sized to suppress CFN/Config noise that #36 now
removes at the filter level, making a wider M-of-N evaluation window
safe from flap risk.

Enable CloudTrail Insights (ApiCallRateInsight + ApiErrorRateInsight)
on seahaven-org-trail as a compensating control for the residual risk
accepted in #36 — the CFN/Config-proxied denials intentionally excluded
from CIS 4.1 — and as a backstop for low-and-slow patterns the 5-min
alarm may miss. Cost ≈$35–$53/month at current org trail volume.

Refs: #37

Co-authored-by: amoussa1229 <166072409+amoussa1229@users.noreply.github.com>
2026-07-07 15:47:41 -04:00

269 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as cdk from "aws-cdk-lib";
import * as logs from "aws-cdk-lib/aws-logs";
import * as cloudwatch from "aws-cdk-lib/aws-cloudwatch";
import * as cwactions from "aws-cdk-lib/aws-cloudwatch-actions";
import * as sns from "aws-cdk-lib/aws-sns";
import * as subscriptions from "aws-cdk-lib/aws-sns-subscriptions";
import * as kms from "aws-cdk-lib/aws-kms";
import { Construct } from "constructs";
/**
* CIS AWS Foundations Benchmark v3.0 Section 4 — Monitoring (audit H-1).
*
* 15 metric filters on the account CloudTrail log group, each backed by a
* CloudWatch alarm that notifies a dedicated SNS topic. Closes CIS 4.1–4.15.
* (4.16 "Security Hub enabled" is not a metric filter — done Day 1, H-4.)
*
* Alarms fire on ALARM only (no OK/recovery actions) per Sea Haven preference.
*
* The trail log group is injected via props (INFRA-19). The previous approach
* imported the group by a hardcoded CDK-generated name constant — if the Trail
* or log group was ever recreated the generated suffix would change and all 15
* filters would silently detach. The caller now creates an explicit LogGroup with
* a stable name and passes the CDK object here.
*/
interface CisControl {
readonly id: string;
readonly metricName: string;
readonly pattern: string;
readonly description: string;
// Optional alarm-sensitivity override. Defaults to 1/1 (page on a single
// breaching 5-min period) which suits low-frequency security signals. Raise
// for noisy high-volume metrics where one-off breaches are expected.
readonly evaluationPeriods?: number;
readonly datapointsToAlarm?: number;
}
const CIS_CONTROLS: CisControl[] = [
{
id: "UnauthorizedApiCalls",
metricName: "UnauthorizedAPICalls",
// The previous pattern relied on `&&` binding tighter than `||`, so the
// sourceIPAddress/HeadBucket exclusions applied ONLY to the AccessDenied
// branch, and the sole IP exclusion was delivery.logs.amazonaws.com. That
// left the filter counting the dominant source of benign noise: AccessDenied
// /*UnauthorizedOperation records generated by AWS services acting on our
// behalf — CloudFormation deploy/drift describe-scans (cloudformation. and
// hooks.cloudformation.amazonaws.com), the AWS Config recorder, etc. On
// 2026-07-07 a single CFN run emitted 100+ such denials in 15 minutes and
// flapped this alarm; it transitioned OK<->ALARM 15 times in 30 days, all
// benign, drowning the CIS 4.1 signal in email noise (alert fatigue).
//
// Fix: (1) group both error codes so the exclusions apply to the whole
// filter (not just the AccessDenied branch), and (2) drop only the SPECIFIC
// benign service sources that actually flap this account — CloudFormation
// (deploy/drift describe-scans, `cloudformation.` and `hooks.cloudformation.`)
// and the Config recorder (`config.`) — plus the pre-existing delivery.logs
// exclusion.
//
// SECURITY NOTE (sh-security-review 2026-07-07): an earlier revision excluded
// ALL `*.amazonaws.com` source hosts. That was rejected — a confirmed MEDIUM
// blind spot: denials driven through OTHER services (SSM Automation, Step
// Functions, Lambda, etc.) are recorded with that service's host as the
// sourceIPAddress, so a blanket exclusion would hide service-proxied
// privesc/recon attempts. Scoping to the named benign hosts keeps every other
// service-proxied denial in scope. Residual (accepted): denials proxied
// specifically through CloudFormation/Config are still excluded — that path
// requires near-admin privilege (cloudformation:CreateStack + iam:PassRole),
// any *successful* change still trips the other CIS 4.x alarms, and GuardDuty
// provides defence-in-depth. Direct console/CLI/credential denials always
// present a routable IP and are always counted.
pattern:
'{ (($.errorCode = "*UnauthorizedOperation") || ($.errorCode = "AccessDenied*")) && ($.sourceIPAddress != "*cloudformation.amazonaws.com") && ($.sourceIPAddress != "config.amazonaws.com") && ($.sourceIPAddress != "delivery.logs.amazonaws.com") && ($.eventName != "HeadBucket") }',
description: "CIS 4.1 — unauthorized API calls",
// M-of-N with N > M so a single quiet 5-min window cannot reset detection.
// Before #36, false positives from CFN/Config-proxied denials forced a
// conservative 3/3 consecutive; now that #36 scoped the metric-filter
// exclusion to those benign sources, we can widen the evaluation window
// while keeping the same alarm threshold. An attacker pacing denied calls to
// leave every third window empty would evade a 3/3 alarm but still trips a
// 3/6 — three breaching windows in any rolling 30-min span fire the alarm.
evaluationPeriods: 6,
datapointsToAlarm: 3,
},
{
id: "ConsoleSigninNoMfa",
metricName: "ConsoleSigninWithoutMFA",
pattern:
'{ ($.eventName = "ConsoleLogin") && ($.additionalEventData.MFAUsed != "Yes") && ($.userIdentity.type = "IAMUser") && ($.responseElements.ConsoleLogin = "Success") }',
description: "CIS 4.2 — console sign-in without MFA",
},
{
id: "RootAccountUsage",
metricName: "RootAccountUsage",
pattern:
'{ $.userIdentity.type = "Root" && $.userIdentity.invokedBy NOT EXISTS && $.eventType != "AwsServiceEvent" }',
description: "CIS 4.3 — root account usage",
},
{
id: "IamPolicyChanges",
metricName: "IAMPolicyChanges",
pattern:
"{($.eventName=DeleteGroupPolicy)||($.eventName=DeleteRolePolicy)||($.eventName=DeleteUserPolicy)||($.eventName=PutGroupPolicy)||($.eventName=PutRolePolicy)||($.eventName=PutUserPolicy)||($.eventName=CreatePolicy)||($.eventName=DeletePolicy)||($.eventName=CreatePolicyVersion)||($.eventName=DeletePolicyVersion)||($.eventName=AttachRolePolicy)||($.eventName=DetachRolePolicy)||($.eventName=AttachUserPolicy)||($.eventName=DetachUserPolicy)||($.eventName=AttachGroupPolicy)||($.eventName=DetachGroupPolicy)}",
description: "CIS 4.4 — IAM policy changes",
},
{
id: "CloudTrailConfigChanges",
metricName: "CloudTrailConfigChanges",
pattern:
"{ ($.eventName = CreateTrail) || ($.eventName = UpdateTrail) || ($.eventName = DeleteTrail) || ($.eventName = StartLogging) || ($.eventName = StopLogging) }",
description: "CIS 4.5 — CloudTrail configuration changes",
},
{
id: "ConsoleAuthFailures",
metricName: "ConsoleAuthenticationFailures",
pattern:
'{ ($.eventName = ConsoleLogin) && ($.errorMessage = "Failed authentication") }',
description: "CIS 4.6 — console authentication failures",
},
{
id: "CmkDisableOrDelete",
metricName: "CMKDisableOrScheduledDelete",
pattern:
"{ ($.eventSource = kms.amazonaws.com) && (($.eventName = DisableKey) || ($.eventName = ScheduleKeyDeletion)) }",
description: "CIS 4.7 — disabling or scheduled deletion of CMKs",
},
{
id: "S3BucketPolicyChanges",
metricName: "S3BucketPolicyChanges",
pattern:
"{ ($.eventSource = s3.amazonaws.com) && (($.eventName = PutBucketAcl) || ($.eventName = PutBucketPolicy) || ($.eventName = PutBucketCors) || ($.eventName = PutBucketLifecycle) || ($.eventName = PutBucketReplication) || ($.eventName = DeleteBucketPolicy) || ($.eventName = DeleteBucketCors) || ($.eventName = DeleteBucketLifecycle) || ($.eventName = DeleteBucketReplication)) }",
description: "CIS 4.8 — S3 bucket policy changes",
},
{
id: "ConfigChanges",
metricName: "AWSConfigChanges",
pattern:
"{ ($.eventSource = config.amazonaws.com) && (($.eventName=StopConfigurationRecorder)||($.eventName=DeleteDeliveryChannel)||($.eventName=PutDeliveryChannel)||($.eventName=PutConfigurationRecorder)) }",
description: "CIS 4.9 — AWS Config configuration changes",
},
{
id: "SecurityGroupChanges",
metricName: "SecurityGroupChanges",
pattern:
"{ ($.eventName = AuthorizeSecurityGroupIngress) || ($.eventName = AuthorizeSecurityGroupEgress) || ($.eventName = RevokeSecurityGroupIngress) || ($.eventName = RevokeSecurityGroupEgress) || ($.eventName = CreateSecurityGroup) || ($.eventName = DeleteSecurityGroup) }",
description: "CIS 4.10 — security group changes",
},
{
id: "NaclChanges",
metricName: "NetworkACLChanges",
pattern:
"{ ($.eventName = CreateNetworkAcl) || ($.eventName = CreateNetworkAclEntry) || ($.eventName = DeleteNetworkAcl) || ($.eventName = DeleteNetworkAclEntry) || ($.eventName = ReplaceNetworkAclEntry) || ($.eventName = ReplaceNetworkAclAssociation) }",
description: "CIS 4.11 — network ACL changes",
},
{
id: "NetworkGatewayChanges",
metricName: "NetworkGatewayChanges",
pattern:
"{ ($.eventName = CreateCustomerGateway) || ($.eventName = DeleteCustomerGateway) || ($.eventName = AttachInternetGateway) || ($.eventName = CreateInternetGateway) || ($.eventName = DeleteInternetGateway) || ($.eventName = DetachInternetGateway) }",
description: "CIS 4.12 — network gateway changes",
},
{
id: "RouteTableChanges",
metricName: "RouteTableChanges",
pattern:
"{ ($.eventName = CreateRoute) || ($.eventName = CreateRouteTable) || ($.eventName = ReplaceRoute) || ($.eventName = ReplaceRouteTableAssociation) || ($.eventName = DeleteRouteTable) || ($.eventName = DeleteRoute) || ($.eventName = DisassociateRouteTable) }",
description: "CIS 4.13 — route table changes",
},
{
id: "VpcChanges",
metricName: "VPCChanges",
pattern:
"{ ($.eventName = CreateVpc) || ($.eventName = DeleteVpc) || ($.eventName = ModifyVpcAttribute) || ($.eventName = AcceptVpcPeeringConnection) || ($.eventName = CreateVpcPeeringConnection) || ($.eventName = DeleteVpcPeeringConnection) || ($.eventName = RejectVpcPeeringConnection) || ($.eventName = AttachClassicLinkVpc) || ($.eventName = DetachClassicLinkVpc) || ($.eventName = DisableVpcClassicLink) || ($.eventName = EnableVpcClassicLink) }",
description: "CIS 4.14 — VPC changes",
},
{
id: "OrganizationsChanges",
metricName: "OrganizationsChanges",
pattern:
'{ ($.eventSource = organizations.amazonaws.com) && (($.eventName = "AcceptHandshake") || ($.eventName = "AttachPolicy") || ($.eventName = "CreateAccount") || ($.eventName = "CreateOrganizationalUnit") || ($.eventName = "CreatePolicy") || ($.eventName = "DeclineHandshake") || ($.eventName = "DeleteOrganization") || ($.eventName = "DeleteOrganizationalUnit") || ($.eventName = "DeletePolicy") || ($.eventName = "DetachPolicy") || ($.eventName = "DisablePolicyType") || ($.eventName = "EnablePolicyType") || ($.eventName = "InviteAccountToOrganization") || ($.eventName = "LeaveOrganization") || ($.eventName = "MoveAccount") || ($.eventName = "RemoveAccountFromOrganization") || ($.eventName = "UpdatePolicy") || ($.eventName = "UpdateOrganizationalUnit")) }',
description: "CIS 4.15 — AWS Organizations changes",
},
];
export interface CisMonitoringProps {
/** Email subscribed to the CIS alarm topic. */
readonly alarmEmail: string;
/**
* The CloudTrail CloudWatch Logs group. Must be the explicit stable-named
* LogGroup created in account-baseline-stack.ts (INFRA-19) — not an imported
* reference, so the metric filters are bound to the CDK object rather than a
* hardcoded generated name.
*/
readonly trailLogGroup: logs.ILogGroup;
}
export class CisMonitoring extends Construct {
constructor(scope: Construct, id: string, props: CisMonitoringProps) {
super(scope, id);
// Customer-managed key for alarm topics (audit L-14). The AWS-managed
// alias/aws/sns key CANNOT be used here: its key policy can't grant
// cloudwatch.amazonaws.com, so CloudWatch alarms silently fail to publish
// to topics it encrypts — which is exactly what these topics receive.
// Also used by the unmanaged site-alerts topic (set via CLI; ARN output below).
const alarmTopicKey = new kms.Key(this, "AlarmTopicKey", {
alias: "seahaven-alarm-topics",
description:
"SSE for SNS alarm topics; grants CloudWatch alarms publish-side usage",
enableKeyRotation: true,
});
alarmTopicKey.addToResourcePolicy(
new cdk.aws_iam.PolicyStatement({
sid: "AllowCloudWatchAlarmsUse",
principals: [new cdk.aws_iam.ServicePrincipal("cloudwatch.amazonaws.com")],
actions: ["kms:GenerateDataKey*", "kms:Decrypt", "kms:DescribeKey"],
resources: ["*"],
conditions: {
StringEquals: { "aws:SourceAccount": cdk.Stack.of(this).account },
},
})
);
new cdk.CfnOutput(this, "AlarmTopicKeyArn", { value: alarmTopicKey.keyArn });
// Dedicated topic for security/CIS alarms (audit H-1, L-14).
const topic = new sns.Topic(this, "CisAlarmTopic", {
topicName: "seahaven-cis-alarms",
displayName: "Sea Haven CIS / security alarms",
masterKey: alarmTopicKey,
});
topic.addSubscription(new subscriptions.EmailSubscription(props.alarmEmail));
const logGroup = props.trailLogGroup;
for (const c of CIS_CONTROLS) {
const mf = new logs.MetricFilter(this, `${c.id}Filter`, {
logGroup,
filterPattern: logs.FilterPattern.literal(c.pattern),
metricNamespace: "CISBenchmark",
metricName: c.metricName,
metricValue: "1",
defaultValue: 0,
});
const alarm = mf
.metric({
statistic: "Sum",
period: cdk.Duration.minutes(5),
})
.createAlarm(this, `${c.id}Alarm`, {
alarmName: `cis-${c.metricName}`,
alarmDescription: c.description,
threshold: 1,
comparisonOperator:
cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
evaluationPeriods: c.evaluationPeriods ?? 1,
// Omitted (undefined) for default 1/1 controls so their templates are
// untouched; CloudWatch defaults datapointsToAlarm to evaluationPeriods.
datapointsToAlarm: c.datapointsToAlarm ?? c.evaluationPeriods,
treatMissingData: cloudwatch.TreatMissingData.NOT_BREACHING,
});
// ALARM-only notification (no OK/recovery action) per Sea Haven preference.
alarm.addAlarmAction(new cwactions.SnsAction(topic));
}
new cdk.CfnOutput(this, "CisAlarmTopicArn", { value: topic.topicArn });
}
}