Guardrails that fix themselves: an open security group gets closed within a minute or two, with an email explaining what happened.
README
Format: AWS CDK (TypeScript) ยท Est. cost: ~$2โ10/month per account (AWS Config evaluations + Lambda)
Preventive + detective + corrective guardrails for an AWS Organization:
| Layer | What | Where |
|---|---|---|
| Preventive | SCPs: protect CloudTrail/Config/GuardDuty/Security Hub, deny root, restrict regions | OrgPolicies stack (management account) |
| Detective | AWS Config managed rules (public S3, open SSH/RDP/DB ports, unencrypted EBS, CloudTrail) | SelfHealingGuardrails stack (each account) |
| Corrective | EventBridge โ Lambda remediator โ SNS notice | SelfHealingGuardrails stack |
AWS Config rule โโNON_COMPLIANTโโโถ EventBridge โโโถ Lambda remediator โโโถ fix resource
โโโโโโโโโโโโโโโโถ SNS (email/Slack)
Prerequisites
- An AWS Organization (ideally with AWS Control Tower, which enables AWS Config for you)
- Node.js 18+, AWS CDK v2 (
npm i -g aws-cdk), credentials for the target account cdk bootstraprun once per account/region
Deploy
npm install
# 1) Guardrails in a workload account (start in dry-run mode!)
npx cdk deploy SelfHealingGuardrails -c alertEmail=you@example.com -c dryRun=true
# If AWS Config is NOT already on in this account:
npx cdk deploy SelfHealingGuardrails -c alertEmail=you@example.com -c createConfigRecorder=true
# 2) SCPs from the management account
npx cdk deploy OrgPolicies -c targetOuIds=ou-xxxx-aaaaaaaa -c allowedRegions=us-east-1,us-west-2
Confirm the SNS email subscription, then redeploy with -c dryRun=false once you trust the notices.
Demo (the "30-second fix")
SG=$(aws ec2 create-security-group --group-name shlz-demo --description demo --query GroupId --output text)
aws ec2 authorize-security-group-ingress --group-id $SG --protocol tcp --port 22 --cidr 0.0.0.0/0
# wait for Config to evaluate (usually < 1โ2 min), then:
aws ec2 describe-security-groups --group-ids $SG --query 'SecurityGroups[0].IpPermissions'
The ingress rule disappears, the group is tagged self-healing/remediated, and you get an email.
Rolling out to every account
Synthesize once (npx cdk synth SelfHealingGuardrails) and deploy cdk.out/SelfHealingGuardrails.template.json
with CloudFormation StackSets (service-managed, auto-deploy to new accounts in the OU).
Clean up
npx cdk destroy --all
Extend it
- Add more rules + handlers in
lambda/remediator.py(e.g.,rds-instance-public-access-check) - Send notices to Slack with AWS Chatbot subscribed to the SNS topic
- Aggregate findings org-wide with Security Hub
Files (8)
- README.md
- bin/app.ts
- lib/guardrails-stack.ts
- lib/org-policies-stack.ts
- lambda/remediator.py
- cdk.json
- package.json
- tsconfig.json
# 01 ยท Self-Healing Multi-Account Landing Zone
**Format:** AWS CDK (TypeScript) ยท **Est. cost:** ~$2โ10/month per account (AWS Config evaluations + Lambda)
Preventive + detective + corrective guardrails for an AWS Organization:
| Layer | What | Where |
|---|---|---|
| Preventive | SCPs: protect CloudTrail/Config/GuardDuty/Security Hub, deny root, restrict regions | `OrgPolicies` stack (management account) |
| Detective | AWS Config managed rules (public S3, open SSH/RDP/DB ports, unencrypted EBS, CloudTrail) | `SelfHealingGuardrails` stack (each account) |
| Corrective | EventBridge โ Lambda remediator โ SNS notice | `SelfHealingGuardrails` stack |
```
AWS Config rule โโNON_COMPLIANTโโโถ EventBridge โโโถ Lambda remediator โโโถ fix resource
โโโโโโโโโโโโโโโโถ SNS (email/Slack)
```
## Prerequisites
- An AWS Organization (ideally with AWS Control Tower, which enables AWS Config for you)
- Node.js 18+, AWS CDK v2 (`npm i -g aws-cdk`), credentials for the target account
- `cdk bootstrap` run once per account/region
## Deploy
```bash
npm install
# 1) Guardrails in a workload account (start in dry-run mode!)
npx cdk deploy SelfHealingGuardrails -c alertEmail=you@example.com -c dryRun=true
# If AWS Config is NOT already on in this account:
npx cdk deploy SelfHealingGuardrails -c alertEmail=you@example.com -c createConfigRecorder=true
# 2) SCPs from the management account
npx cdk deploy OrgPolicies -c targetOuIds=ou-xxxx-aaaaaaaa -c allowedRegions=us-east-1,us-west-2
```
Confirm the SNS email subscription, then redeploy with `-c dryRun=false` once you trust the notices.
## Demo (the "30-second fix")
```bash
SG=$(aws ec2 create-security-group --group-name shlz-demo --description demo --query GroupId --output text)
aws ec2 authorize-security-group-ingress --group-id $SG --protocol tcp --port 22 --cidr 0.0.0.0/0
# wait for Config to evaluate (usually < 1โ2 min), then:
aws ec2 describe-security-groups --group-ids $SG --query 'SecurityGroups[0].IpPermissions'
```
The ingress rule disappears, the group is tagged `self-healing/remediated`, and you get an email.
## Rolling out to every account
Synthesize once (`npx cdk synth SelfHealingGuardrails`) and deploy `cdk.out/SelfHealingGuardrails.template.json`
with **CloudFormation StackSets** (service-managed, auto-deploy to new accounts in the OU).
## Clean up
```bash
npx cdk destroy --all
```
## Extend it
- Add more rules + handlers in `lambda/remediator.py` (e.g., `rds-instance-public-access-check`)
- Send notices to Slack with AWS Chatbot subscribed to the SNS topic
- Aggregate findings org-wide with Security Hub
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { GuardrailsStack } from '../lib/guardrails-stack';
import { OrgPoliciesStack } from '../lib/org-policies-stack';
const app = new cdk.App();
const env = { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION };
// Deploy into every workload account (directly, or via CDK Pipelines / StackSets).
new GuardrailsStack(app, 'SelfHealingGuardrails', {
env,
alertEmail: app.node.tryGetContext('alertEmail'),
createConfigRecorder: app.node.tryGetContext('createConfigRecorder') === 'true',
dryRun: app.node.tryGetContext('dryRun') === 'true',
});
// Deploy ONCE in the Organizations management account.
// Example: cdk deploy OrgPolicies -c targetOuIds=ou-abcd-11111111,ou-abcd-22222222 -c allowedRegions=us-east-1,us-west-2
const targetOuIds: string | undefined = app.node.tryGetContext('targetOuIds');
if (targetOuIds) {
new OrgPoliciesStack(app, 'OrgPolicies', {
env,
targetIds: targetOuIds.split(',').map((s) => s.trim()).filter(Boolean),
allowedRegions: (app.node.tryGetContext('allowedRegions') ?? 'us-east-1,us-west-2')
.split(',')
.map((s: string) => s.trim()),
});
}
import * as path from 'path';
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as config from 'aws-cdk-lib/aws-config';
import * as events from 'aws-cdk-lib/aws-events';
import * as targets from 'aws-cdk-lib/aws-events-targets';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as kms from 'aws-cdk-lib/aws-kms';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as logs from 'aws-cdk-lib/aws-logs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as sns from 'aws-cdk-lib/aws-sns';
import * as subs from 'aws-cdk-lib/aws-sns-subscriptions';
export interface GuardrailsStackProps extends cdk.StackProps {
/** Where remediation notices go. Optional; you can subscribe Slack/Chatbot later. */
alertEmail?: string;
/** Set true only if AWS Config is NOT already enabled (Control Tower enables it for you). */
createConfigRecorder?: boolean;
/** When true, the remediator only reports what it would change. */
dryRun?: boolean;
}
/**
* Detect -> decide -> fix -> notify.
* AWS Config evaluates managed rules; EventBridge forwards NON_COMPLIANT results
* to a Lambda that remediates and publishes a human-readable notice to SNS.
*/
export class GuardrailsStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: GuardrailsStackProps) {
super(scope, id, props);
const key = new kms.Key(this, 'AlertsKey', {
enableKeyRotation: true,
description: 'Encrypts self-healing guardrail notifications',
});
key.grantEncryptDecrypt(new iam.ServicePrincipal('events.amazonaws.com'));
const topic = new sns.Topic(this, 'RemediationTopic', {
displayName: 'Self-healing guardrail remediations',
masterKey: key,
enforceSSL: true,
});
if (props.alertEmail) topic.addSubscription(new subs.EmailSubscription(props.alertEmail));
// Optional: bootstrap AWS Config in accounts that do not have it yet.
let recorderDependency: cdk.CfnResource | undefined;
if (props.createConfigRecorder) {
const bucket = new s3.Bucket(this, 'ConfigBucket', {
encryption: s3.BucketEncryption.S3_MANAGED,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
enforceSSL: true,
lifecycleRules: [{ expiration: cdk.Duration.days(365) }],
});
const role = new iam.Role(this, 'ConfigRole', {
assumedBy: new iam.ServicePrincipal('config.amazonaws.com'),
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWS_ConfigRole')],
});
bucket.grantReadWrite(role);
const recorder = new config.CfnConfigurationRecorder(this, 'Recorder', {
roleArn: role.roleArn,
recordingGroup: { allSupported: true, includeGlobalResourceTypes: true },
});
const channel = new config.CfnDeliveryChannel(this, 'DeliveryChannel', {
s3BucketName: bucket.bucketName,
});
channel.node.addDependency(recorder);
recorderDependency = recorder;
}
// Managed rules we will auto-remediate (or flag) when they go NON_COMPLIANT.
const rules: Record<string, config.ManagedRule> = {
s3Public: new config.ManagedRule(this, 'S3PublicAccess', {
configRuleName: 'shlz-s3-bucket-level-public-access-prohibited',
identifier: config.ManagedRuleIdentifiers.S3_BUCKET_LEVEL_PUBLIC_ACCESS_PROHIBITED,
}),
ssh: new config.ManagedRule(this, 'RestrictedSsh', {
configRuleName: 'shlz-restricted-ssh',
identifier: config.ManagedRuleIdentifiers.EC2_SECURITY_GROUPS_INCOMING_SSH_DISABLED,
}),
commonPorts: new config.ManagedRule(this, 'RestrictedCommonPorts', {
configRuleName: 'shlz-restricted-common-ports',
identifier: config.ManagedRuleIdentifiers.EC2_SECURITY_GROUPS_RESTRICTED_INCOMING_TRAFFIC,
inputParameters: { blockedPort1: 3389, blockedPort2: 3306, blockedPort3: 5432, blockedPort4: 1433 },
}),
ebs: new config.ManagedRule(this, 'EncryptedVolumes', {
configRuleName: 'shlz-encrypted-volumes',
identifier: config.ManagedRuleIdentifiers.EBS_ENCRYPTED_VOLUMES,
}),
trail: new config.ManagedRule(this, 'CloudTrailEnabled', {
configRuleName: 'shlz-cloudtrail-enabled',
identifier: config.ManagedRuleIdentifiers.CLOUD_TRAIL_ENABLED,
}),
};
if (recorderDependency) {
Object.values(rules).forEach((r) => r.node.addDependency(recorderDependency!));
}
const remediator = new lambda.Function(this, 'Remediator', {
runtime: lambda.Runtime.PYTHON_3_12,
handler: 'remediator.handler',
code: lambda.Code.fromAsset(path.join(__dirname, '..', 'lambda')),
timeout: cdk.Duration.seconds(60),
memorySize: 256,
logGroup: new logs.LogGroup(this, 'RemediatorLogs', {
retention: logs.RetentionDays.ONE_MONTH,
removalPolicy: cdk.RemovalPolicy.DESTROY,
}),
environment: {
TOPIC_ARN: topic.topicArn,
DRY_RUN: props.dryRun ? 'true' : 'false',
BLOCKED_PORTS: '22,3389,3306,5432,1433',
},
});
topic.grantPublish(remediator);
remediator.addToRolePolicy(
new iam.PolicyStatement({
actions: [
's3:PutBucketPublicAccessBlock',
's3:GetBucketPublicAccessBlock',
'ec2:DescribeSecurityGroups',
'ec2:RevokeSecurityGroupIngress',
'ec2:CreateTags',
'ec2:DescribeVolumes',
'cloudtrail:DescribeTrails',
'cloudtrail:StartLogging',
],
resources: ['*'],
}),
);
new events.Rule(this, 'NonCompliantRule', {
description: 'Route NON_COMPLIANT Config evaluations to the remediator',
eventPattern: {
source: ['aws.config'],
detailType: ['Config Rules Compliance Change'],
detail: {
configRuleName: Object.values(rules).map((r) => r.configRuleName),
newEvaluationResult: { complianceType: ['NON_COMPLIANT'] },
},
},
targets: [new targets.LambdaFunction(remediator, { retryAttempts: 2 })],
});
new cdk.CfnOutput(this, 'TopicArn', { value: topic.topicArn });
new cdk.CfnOutput(this, 'RemediatorName', { value: remediator.functionName });
}
}
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as organizations from 'aws-cdk-lib/aws-organizations';
export interface OrgPoliciesStackProps extends cdk.StackProps {
/** OU (or account) IDs that receive the guardrail SCPs. */
targetIds: string[];
/** Regions workloads may use. Global services are exempted. */
allowedRegions: string[];
}
/**
* Preventive guardrails (Service Control Policies). Deploy in the management account.
* The detective + corrective half lives in GuardrailsStack.
*/
export class OrgPoliciesStack extends cdk.Stack {
constructor(scope: Construct, id: string, props: OrgPoliciesStackProps) {
super(scope, id, props);
const protectSecurityServices = {
Version: '2012-10-17',
Statement: [
{
Sid: 'DenyLeavingOrganization',
Effect: 'Deny',
Action: 'organizations:LeaveOrganization',
Resource: '*',
},
{
Sid: 'ProtectAuditTrail',
Effect: 'Deny',
Action: ['cloudtrail:StopLogging', 'cloudtrail:DeleteTrail', 'cloudtrail:UpdateTrail'],
Resource: '*',
Condition: { ArnNotLike: { 'aws:PrincipalArn': 'arn:aws:iam::*:role/AWSControlTowerExecution' } },
},
{
Sid: 'ProtectConfig',
Effect: 'Deny',
Action: [
'config:DeleteConfigurationRecorder',
'config:StopConfigurationRecorder',
'config:DeleteDeliveryChannel',
],
Resource: '*',
Condition: { ArnNotLike: { 'aws:PrincipalArn': 'arn:aws:iam::*:role/AWSControlTowerExecution' } },
},
{
Sid: 'ProtectGuardDutyAndSecurityHub',
Effect: 'Deny',
Action: [
'guardduty:DeleteDetector',
'guardduty:DisassociateFromMasterAccount',
'guardduty:UpdateDetector',
'securityhub:DisableSecurityHub',
],
Resource: '*',
},
{
Sid: 'DenyRootUser',
Effect: 'Deny',
Action: '*',
Resource: '*',
Condition: { StringLike: { 'aws:PrincipalArn': 'arn:aws:iam::*:root' } },
},
],
};
const regionGuardrail = {
Version: '2012-10-17',
Statement: [
{
Sid: 'DenyUnapprovedRegions',
Effect: 'Deny',
NotAction: [
'iam:*', 'organizations:*', 'sts:*', 'support:*', 'budgets:*', 'ce:*',
'cloudfront:*', 'route53:*', 'route53domains:*', 'waf:*', 'wafv2:*', 'shield:*',
'health:*', 'trustedadvisor:*', 'globalaccelerator:*', 'kms:*', 'sso:*', 'account:*',
],
Resource: '*',
Condition: {
StringNotEquals: { 'aws:RequestedRegion': props.allowedRegions },
ArnNotLike: { 'aws:PrincipalArn': 'arn:aws:iam::*:role/AWSControlTowerExecution' },
},
},
],
};
new organizations.CfnPolicy(this, 'ProtectSecurityServicesScp', {
name: 'shlz-protect-security-services',
description: 'Prevents disabling audit and detection services and blocks root user actions',
type: 'SERVICE_CONTROL_POLICY',
content: protectSecurityServices,
targetIds: props.targetIds,
});
new organizations.CfnPolicy(this, 'RegionGuardrailScp', {
name: 'shlz-region-guardrail',
description: `Restricts regional services to ${props.allowedRegions.join(', ')}`,
type: 'SERVICE_CONTROL_POLICY',
content: regionGuardrail,
targetIds: props.targetIds,
});
}
}
"""Self-healing remediator.
Receives "Config Rules Compliance Change" events (NON_COMPLIANT only) and fixes
the offending resource, then publishes a notice to SNS. Set DRY_RUN=true to
only report what would change.
"""
import json
import os
import boto3
TOPIC_ARN = os.environ["TOPIC_ARN"]
DRY_RUN = os.environ.get("DRY_RUN", "false").lower() == "true"
BLOCKED_PORTS = {int(p) for p in os.environ.get("BLOCKED_PORTS", "22").split(",")}
OPEN_CIDRS = {"0.0.0.0/0", "::/0"}
s3 = boto3.client("s3")
ec2 = boto3.client("ec2")
cloudtrail = boto3.client("cloudtrail")
sns = boto3.client("sns")
def handler(event, _context):
detail = event.get("detail", {})
rule = detail.get("configRuleName", "")
resource_type = detail.get("resourceType", "")
resource_id = detail.get("resourceId", "")
account = detail.get("awsAccountId", event.get("account"))
region = detail.get("awsRegion", event.get("region"))
if rule.endswith("s3-bucket-level-public-access-prohibited"):
action = fix_s3_public_access(resource_id)
elif rule.endswith("restricted-ssh") or rule.endswith("restricted-common-ports"):
action = fix_open_security_group(resource_id)
elif rule.endswith("encrypted-volumes"):
action = flag_unencrypted_volume(resource_id)
elif rule.endswith("cloudtrail-enabled"):
action = restart_cloudtrail()
else:
action = f"No remediation registered for rule {rule}"
message = {
"rule": rule,
"resourceType": resource_type,
"resourceId": resource_id,
"account": account,
"region": region,
"dryRun": DRY_RUN,
"action": action,
}
print(json.dumps(message))
sns.publish(
TopicArn=TOPIC_ARN,
Subject=f"[self-healing] {rule} -> {resource_id}"[:100],
Message=json.dumps(message, indent=2),
)
return message
def fix_s3_public_access(bucket):
if DRY_RUN:
return f"Would enable all four S3 Block Public Access settings on {bucket}"
s3.put_public_access_block(
Bucket=bucket,
PublicAccessBlockConfiguration={
"BlockPublicAcls": True,
"IgnorePublicAcls": True,
"BlockPublicPolicy": True,
"RestrictPublicBuckets": True,
},
)
return f"Enabled S3 Block Public Access on {bucket}"
def fix_open_security_group(group_id):
sg = ec2.describe_security_groups(GroupIds=[group_id])["SecurityGroups"][0]
revoked = []
for perm in sg.get("IpPermissions", []):
from_port = perm.get("FromPort", 0)
to_port = perm.get("ToPort", 65535)
protocol = perm.get("IpProtocol")
all_traffic = protocol == "-1"
if not all_traffic and not any(from_port <= p <= to_port for p in BLOCKED_PORTS):
continue
open_v4 = [r for r in perm.get("IpRanges", []) if r.get("CidrIp") in OPEN_CIDRS]
open_v6 = [r for r in perm.get("Ipv6Ranges", []) if r.get("CidrIpv6") in OPEN_CIDRS]
if not open_v4 and not open_v6:
continue
bad = {"IpProtocol": protocol}
if not all_traffic:
bad.update({"FromPort": from_port, "ToPort": to_port})
if open_v4:
bad["IpRanges"] = [{"CidrIp": r["CidrIp"]} for r in open_v4]
if open_v6:
bad["Ipv6Ranges"] = [{"CidrIpv6": r["CidrIpv6"]} for r in open_v6]
revoked.append(bad)
if not revoked:
return f"{group_id}: no internet-open sensitive ports found (already fixed?)"
if DRY_RUN:
return f"Would revoke {len(revoked)} internet-open rule(s) on {group_id}: {revoked}"
ec2.revoke_security_group_ingress(GroupId=group_id, IpPermissions=revoked)
ec2.create_tags(
Resources=[group_id],
Tags=[{"Key": "self-healing/remediated", "Value": "open-ingress-revoked"}],
)
return f"Revoked {len(revoked)} internet-open rule(s) on {group_id}"
def flag_unencrypted_volume(volume_id):
# EBS volumes cannot be encrypted in place; flag for a snapshot/copy/swap workflow.
if not DRY_RUN:
ec2.create_tags(
Resources=[volume_id],
Tags=[{"Key": "self-healing/action-required", "Value": "re-create-encrypted"}],
)
return (
f"{volume_id} is unencrypted. Tagged for follow-up: snapshot -> copy with "
"encryption -> create volume -> swap. Also enable EBS encryption-by-default."
)
def restart_cloudtrail():
restarted = []
for trail in cloudtrail.describe_trails(includeShadowTrails=False)["trailList"]:
if DRY_RUN:
restarted.append(trail["Name"])
continue
cloudtrail.start_logging(Name=trail["TrailARN"])
restarted.append(trail["Name"])
verb = "Would restart" if DRY_RUN else "Restarted"
return f"{verb} logging on trails: {restarted or 'none found - create an org trail'}"
{
"app": "npx ts-node --prefer-ts-exts bin/app.ts",
"watch": { "include": ["**"], "exclude": ["README.md", "cdk*.json", "**/*.d.ts", "**/*.js", "node_modules", "cdk.out"] },
"context": {}
}
{
"name": "01-self-healing-landing-zone",
"version": "1.0.0",
"private": true,
"bin": { "app": "bin/app.js" },
"scripts": {
"build": "tsc",
"synth": "cdk synth",
"deploy": "cdk deploy --all",
"destroy": "cdk destroy --all"
},
"devDependencies": {
"@types/node": "^22.0.0",
"aws-cdk": "^2.1000.0",
"ts-node": "^10.9.2",
"typescript": "~5.6.3"
},
"dependencies": {
"aws-cdk-lib": "^2.200.0",
"constructs": "^10.4.2"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["es2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"exclude": ["node_modules", "cdk.out"]
}
Build it yourself
Download the folder, run npm install (CDK) or aws cloudformation deploy (CloudFormation), and follow the README.