Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/lint-and-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ jobs:
framework: cloudformation
quiet: true

- name: Run Checkov on the Terraform member-baseline StackSet template
# Lives under terraform/ (so the directory scan above and the
# Terraform job's framework filter both skip it), but it's a
# CloudFormation template that gets deployed into every member account.
uses: bridgecrewio/checkov-action@a8664e3a0549367977f0cda990a34311835c87c0 # v12.3123.0
with:
file: terraform/security-baseline-new-accounts/member-baseline/baseline-template.yaml
framework: cloudformation
quiet: true

python-and-policies:
name: Python, policy JSON, and Lambda copy consistency
runs-on: ubuntu-latest
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,8 @@ or **RDP (3389)** to the entire internet (`0.0.0.0/0` / `::/0`). Ships as
two complementary paths — deploy one or both:

- **`event-driven/`** — EventBridge rule matching CloudTrail's
`AuthorizeSecurityGroupIngress` event, revokes the offending rule within
seconds of it being created.
`AuthorizeSecurityGroupIngress` and `ModifySecurityGroupRules` events,
revokes the offending rule within seconds of it being created or edited.
- **`config-rule/`** — AWS Config managed rule (`RESTRICTED_INCOMING_TRAFFIC`)
+ SSM Automation remediation, re-evaluates all security groups on a
schedule and catches rules that existed before deployment or slipped
Expand Down
17 changes: 10 additions & 7 deletions cloudformation/auto-remediate-open-ssh-rdp/event-driven/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,23 @@ to `0.0.0.0/0` / `::/0`, within seconds of the rule being created.

## How it works

1. Someone (or something) calls `AuthorizeSecurityGroupIngress` and opens
1. Someone (or something) calls `AuthorizeSecurityGroupIngress` (a new
rule) or `ModifySecurityGroupRules` (an existing rule edited) and opens
22 or 3389 to the internet.
2. That management API call is automatically delivered to EventBridge's
default event bus by CloudTrail — **no dedicated trail needs to be
created** for this to work; management events are available on the
default bus in every account.
3. An EventBridge rule matches on `eventName: AuthorizeSecurityGroupIngress`
and invokes a Lambda function.
4. The Lambda inspects exactly the rule(s) that were just added, and if
they match the risky pattern, revokes them and publishes an SNS
3. An EventBridge rule matches on those two `eventName`s and invokes a
Lambda function.
4. For a new rule, the Lambda inspects exactly the rule(s) that were just
added; for a modification (whose event only carries rule IDs, not the
resulting CIDR) it re-checks the whole group. Either way it revokes only
the rule entries that open 22/3389 to the internet and publishes an SNS
notification.

Because it only acts on the rule just created, it won't touch other,
legitimate ingress rules on the same security group.
It never touches other, legitimate ingress rules on the same security
group.

Also included for defense-in-depth / hygiene: a customer-managed KMS key
encrypting the Lambda's log group and environment variables, a dead-letter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ Resources:
detail:
eventName:
- AuthorizeSecurityGroupIngress
- ModifySecurityGroupRules
State: ENABLED
Targets:
- Arn: !GetAtt RemediationFunction.Arn
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
Automation remediation paths:

1. EventBridge rule matching CloudTrail's AuthorizeSecurityGroupIngress
management event. Revokes only the specific rule(s) just added.
management event. Revokes only the specific rule(s) just added. A
ModifySecurityGroupRules event (an existing rule edited to be open) is
also handled, but its request is rule-ID based rather than describing the
resulting CIDR, so the whole group is re-scanned instead.
2. Direct invocation with {"security_group_id": "sg-xxxxxxxx"} (used by
the SSM Automation document triggered from an AWS Config remediation).
Describes the group and revokes any matching bad rules found on it
Expand Down Expand Up @@ -136,6 +139,20 @@ def _extract_ip_permissions(container):
return normalized


def _revoke_from_group(group_id, source):
"""Describe a security group and revoke any SSH/RDP-to-the-internet
rules currently on it. Returns a small result dict."""
resp = ec2.describe_security_groups(GroupIds=[group_id])
groups = resp.get("SecurityGroups", [])
if not groups:
logger.warning("Security group %s not found", group_id)
return {"remediated": False, "reason": "security group not found"}

ip_permissions = groups[0].get("IpPermissions", [])
revoked = _revoke_from_permissions(group_id, ip_permissions, source=source)
return {"remediated": bool(revoked), "revoked_rules": revoked}


def _handle_cloudtrail_event(event):
detail = event.get("detail", {})
request_params = detail.get("requestParameters", {}) or {}
Expand All @@ -144,6 +161,10 @@ def _handle_cloudtrail_event(event):
logger.warning("No groupId found in CloudTrail event detail, skipping")
return

if detail.get("eventName") == "ModifySecurityGroupRules":
_revoke_from_group(group_id, source="cloudtrail-eventbridge-modify")
return

response_elements = detail.get("responseElements", {}) or {}
ip_permissions = _extract_ip_permissions(response_elements) or _extract_ip_permissions(request_params)

Expand All @@ -160,22 +181,15 @@ def _handle_direct_invocation(event):
logger.warning("No security_group_id provided in direct invocation event")
return {"remediated": False, "reason": "no security group id provided"}

resp = ec2.describe_security_groups(GroupIds=[group_id])
groups = resp.get("SecurityGroups", [])
if not groups:
logger.warning("Security group %s not found", group_id)
return {"remediated": False, "reason": "security group not found"}

ip_permissions = groups[0].get("IpPermissions", [])
revoked = _revoke_from_permissions(group_id, ip_permissions, source="config-ssm-remediation")
return {"remediated": bool(revoked), "revoked_rules": revoked}
return _revoke_from_group(group_id, source="config-ssm-remediation")


def lambda_handler(event, context):
logger.info("Event: %s", json.dumps(event, default=str))

handled_events = ("AuthorizeSecurityGroupIngress", "ModifySecurityGroupRules")
is_cloudtrail_event = event.get("detail-type") == "AWS API Call via CloudTrail" or (
"detail" in event and event.get("detail", {}).get("eventName") == "AuthorizeSecurityGroupIngress"
"detail" in event and event.get("detail", {}).get("eventName") in handled_events
)

if is_cloudtrail_event:
Expand Down
12 changes: 12 additions & 0 deletions cloudformation/bedrock-logging-enforcement/template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@ Resources:
Condition:
StringEquals:
aws:SourceAccount: !Ref "AWS::AccountId"
ArnLike:
aws:SourceArn: !Sub "arn:${AWS::Partition}:bedrock:${AWS::Region}:${AWS::AccountId}:*"
Policies:
- PolicyName: BedrockToCloudWatchLogsPolicy
PolicyDocument:
Expand Down Expand Up @@ -247,6 +249,16 @@ Resources:
Resource: "*"
# Bedrock's account-level logging configuration APIs are
# not resource-scopable - "*" is required.
- Effect: Allow
# The logging configuration hands Bedrock this role for
# CloudWatch delivery. Scoped to exactly that role and only
# to Bedrock.
Action:
- iam:PassRole
Resource: !GetAtt BedrockToCloudWatchRole.Arn
Condition:
StringEquals:
iam:PassedToService: bedrock.amazonaws.com
- Effect: Allow
Action:
- sns:Publish
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,9 @@ Resources:
- bedrock:InvokeModelWithResponseStream
Resource:
- !Sub "arn:${AWS::Partition}:bedrock:${AWS::Region}:${AWS::AccountId}:inference-profile/us.anthropic.*"
# GovCloud's cross-region inference profiles use a
# us-gov. prefix instead; this never matches elsewhere.
- !Sub "arn:${AWS::Partition}:bedrock:${AWS::Region}:${AWS::AccountId}:inference-profile/us-gov.anthropic.*"
- !Sub "arn:${AWS::Partition}:bedrock:*::foundation-model/anthropic.*"
# Both ARN families are required: the built-in model
# catalog resolves every Claude model to a cross-region
Expand Down
8 changes: 8 additions & 0 deletions cloudformation/ec2-isolation-runbook/template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,16 @@ Resources:
- !Sub "arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:volume/*"
- !Sub "arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:snapshot/*"
- Effect: Allow
# Swapping security groups is authorized against the instance
# and the security group being attached (AWS lists both as
# resources of ModifyInstanceAttribute), so scope both.
Action:
- ec2:ModifyInstanceAttribute
Resource:
- !Sub "arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:instance/*"
- !Sub "arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:security-group/*"
- Effect: Allow
Action:
- ec2:StopInstances
Resource: !Sub "arn:${AWS::Partition}:ec2:${AWS::Region}:${AWS::AccountId}:instance/*"
- Effect: Allow
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,8 @@ For GovCloud:
`RetainStacksOnAccountRemoval` is left `false` and the account simply
moves elsewhere in the org — StackSets only manages what's currently in
scope.
- **One AWS Config recorder and delivery channel per region per account.**
If a targeted account already has Config enabled (for example accounts
enrolled through AWS Control Tower, or set up by hand), creating the
baseline's recorder fails in that account and region. Exclude those OUs,
or remove the existing recorder first.
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ Resources:

ConfigBucket:
Type: AWS::S3::Bucket
Metadata:
checkov:
skip:
- id: CKV_AWS_18
comment: >-
Server access logging needs a destination bucket in every
member account and region; deliberately left out of this
per-account baseline.
Properties:
BucketName: !Sub "aws-config-${AWS::AccountId}-${AWS::Region}"
BucketEncryption:
Expand All @@ -90,6 +98,16 @@ Resources:
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
VersioningConfiguration:
Status: Enabled
LifecycleConfiguration:
Rules:
- Id: ExpireNoncurrentVersions
Status: Enabled
NoncurrentVersionExpiration:
NoncurrentDays: 365
AbortIncompleteMultipartUpload:
DaysAfterInitiation: 7

ConfigBucketPolicy:
Type: AWS::S3::BucketPolicy
Expand Down Expand Up @@ -117,6 +135,16 @@ Resources:
StringEquals:
s3:x-amz-acl: bucket-owner-full-control
aws:SourceAccount: !Ref "AWS::AccountId"
- Sid: DenyInsecureTransport
Effect: Deny
Principal: "*"
Action: s3:*
Resource:
- !GetAtt ConfigBucket.Arn
- !Sub "${ConfigBucket.Arn}/*"
Condition:
Bool:
aws:SecureTransport: "false"

ConfigRole:
Type: AWS::IAM::Role
Expand Down
16 changes: 10 additions & 6 deletions terraform/auto-remediate-open-ssh-rdp/event-driven/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@ to `0.0.0.0/0` / `::/0`, within seconds of the rule being created.

## How it works

1. Someone calls `AuthorizeSecurityGroupIngress` and opens 22 or 3389 to the
internet.
1. Someone calls `AuthorizeSecurityGroupIngress` (a new rule) or
`ModifySecurityGroupRules` (an existing rule edited) and opens 22 or 3389
to the internet.
2. CloudTrail delivers that management event to EventBridge's default
event bus automatically — no dedicated trail resource required.
3. An `aws_cloudwatch_event_rule` matches on
`eventName: AuthorizeSecurityGroupIngress` and invokes a Lambda.
4. The Lambda inspects exactly the rule(s) just added and revokes any that
match the risky pattern, then publishes an SNS notification.
3. An `aws_cloudwatch_event_rule` matches on those two `eventName`s and
invokes a Lambda.
4. For a new rule the Lambda inspects exactly the rule(s) just added; for a
modification (whose event only carries rule IDs, not the resulting CIDR)
it re-checks the whole group. Either way it revokes only the rule
entries that open 22/3389 to the internet, then publishes an SNS
notification.

Pair this with the sibling `../config-rule/` module to also catch
pre-existing open rules and drift on a schedule.
Expand Down
4 changes: 2 additions & 2 deletions terraform/auto-remediate-open-ssh-rdp/event-driven/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -183,13 +183,13 @@ resource "aws_cloudwatch_log_group" "remediate" {

resource "aws_cloudwatch_event_rule" "authorize_sg_ingress" {
name = "${var.name_prefix}-authorize-sg-ingress"
description = "Matches AuthorizeSecurityGroupIngress API calls captured by CloudTrail."
description = "Matches AuthorizeSecurityGroupIngress and ModifySecurityGroupRules API calls captured by CloudTrail."

event_pattern = jsonencode({
source = ["aws.ec2"]
detail-type = ["AWS API Call via CloudTrail"]
detail = {
eventName = ["AuthorizeSecurityGroupIngress"]
eventName = ["AuthorizeSecurityGroupIngress", "ModifySecurityGroupRules"]
}
})
}
Expand Down
4 changes: 2 additions & 2 deletions terraform/auto-remediate-open-ssh-rdp/lambda/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ Shared Lambda source used by both remediation paths in
`auto-remediate-open-ssh-rdp/`:

- **event-driven**: invoked directly by EventBridge with a CloudTrail
`AuthorizeSecurityGroupIngress` event. Revokes only the rule(s) just
added.
`AuthorizeSecurityGroupIngress` event (revokes only the rule(s) just
added) or `ModifySecurityGroupRules` event (re-checks the whole group).
- **config-rule**: invoked by an SSM Automation document with
`{"security_group_id": "sg-xxxxxxxx"}`. Describes the group and revokes
any matching rule found.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
Automation remediation paths:

1. EventBridge rule matching CloudTrail's AuthorizeSecurityGroupIngress
management event. Revokes only the specific rule(s) just added.
management event. Revokes only the specific rule(s) just added. A
ModifySecurityGroupRules event (an existing rule edited to be open) is
also handled, but its request is rule-ID based rather than describing the
resulting CIDR, so the whole group is re-scanned instead.
2. Direct invocation with {"security_group_id": "sg-xxxxxxxx"} (used by
the SSM Automation document triggered from an AWS Config remediation).
Describes the group and revokes any matching bad rules found on it
Expand Down Expand Up @@ -136,6 +139,20 @@ def _extract_ip_permissions(container):
return normalized


def _revoke_from_group(group_id, source):
"""Describe a security group and revoke any SSH/RDP-to-the-internet
rules currently on it. Returns a small result dict."""
resp = ec2.describe_security_groups(GroupIds=[group_id])
groups = resp.get("SecurityGroups", [])
if not groups:
logger.warning("Security group %s not found", group_id)
return {"remediated": False, "reason": "security group not found"}

ip_permissions = groups[0].get("IpPermissions", [])
revoked = _revoke_from_permissions(group_id, ip_permissions, source=source)
return {"remediated": bool(revoked), "revoked_rules": revoked}


def _handle_cloudtrail_event(event):
detail = event.get("detail", {})
request_params = detail.get("requestParameters", {}) or {}
Expand All @@ -144,6 +161,10 @@ def _handle_cloudtrail_event(event):
logger.warning("No groupId found in CloudTrail event detail, skipping")
return

if detail.get("eventName") == "ModifySecurityGroupRules":
_revoke_from_group(group_id, source="cloudtrail-eventbridge-modify")
return

response_elements = detail.get("responseElements", {}) or {}
ip_permissions = _extract_ip_permissions(response_elements) or _extract_ip_permissions(request_params)

Expand All @@ -160,22 +181,15 @@ def _handle_direct_invocation(event):
logger.warning("No security_group_id provided in direct invocation event")
return {"remediated": False, "reason": "no security group id provided"}

resp = ec2.describe_security_groups(GroupIds=[group_id])
groups = resp.get("SecurityGroups", [])
if not groups:
logger.warning("Security group %s not found", group_id)
return {"remediated": False, "reason": "security group not found"}

ip_permissions = groups[0].get("IpPermissions", [])
revoked = _revoke_from_permissions(group_id, ip_permissions, source="config-ssm-remediation")
return {"remediated": bool(revoked), "revoked_rules": revoked}
return _revoke_from_group(group_id, source="config-ssm-remediation")


def lambda_handler(event, context):
logger.info("Event: %s", json.dumps(event, default=str))

handled_events = ("AuthorizeSecurityGroupIngress", "ModifySecurityGroupRules")
is_cloudtrail_event = event.get("detail-type") == "AWS API Call via CloudTrail" or (
"detail" in event and event.get("detail", {}).get("eventName") == "AuthorizeSecurityGroupIngress"
"detail" in event and event.get("detail", {}).get("eventName") in handled_events
)

if is_cloudtrail_event:
Expand Down
11 changes: 11 additions & 0 deletions terraform/bedrock-logging-enforcement/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ resource "aws_iam_role" "bedrock_to_cloudwatch" {
Action = "sts:AssumeRole"
Condition = {
StringEquals = { "aws:SourceAccount" = data.aws_caller_identity.current.account_id }
ArnLike = { "aws:SourceArn" = "arn:${data.aws_partition.current.partition}:bedrock:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:*" }
}
}]
})
Expand Down Expand Up @@ -239,6 +240,16 @@ resource "aws_iam_role_policy" "lambda_exec" {
]
Resource = "*"
},
{
# The logging configuration hands Bedrock this role for CloudWatch
# delivery. Scoped to exactly that role and only to Bedrock.
Effect = "Allow"
Action = ["iam:PassRole"]
Resource = aws_iam_role.bedrock_to_cloudwatch.arn
Condition = {
StringEquals = { "iam:PassedToService" = "bedrock.amazonaws.com" }
}
},
{
Effect = "Allow"
Action = ["sns:Publish"]
Expand Down
Loading
Loading