mirror of
https://github.com/Sea-Haven-Industries/seahaven-org-baseline.git
synced 2026-09-30 05:43:17 +00:00
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>
269 lines
14 KiB
TypeScript
269 lines
14 KiB
TypeScript
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 });
|
||
}
|
||
}
|