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
43 changes: 42 additions & 1 deletion .github/workflows/lint-and-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ jobs:
run: pip install "cfn-lint==1.55.1"

- name: Run cfn-lint
run: cfn-lint 'cloudformation/**/*.yaml'
# The Terraform member-baseline module ships a CloudFormation
# StackSet template of its own, so lint it alongside the rest.
run: cfn-lint 'cloudformation/**/*.yaml' terraform/security-baseline-new-accounts/member-baseline/baseline-template.yaml

- name: Run Checkov
# The "set-output is deprecated" warning this step produces comes
Expand All @@ -50,6 +52,45 @@ jobs:
framework: cloudformation
quiet: true

python-and-policies:
name: Python, policy JSON, and Lambda copy consistency
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6
with:
persist-credentials: false # this job never pushes back to the repo

- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v6
with:
python-version: "3.12"

- name: Compile all Python sources
run: python -m compileall -q cloudformation terraform

- name: Validate policy JSON
run: |
for f in policies/*/*.json; do
python -m json.tool "$f" > /dev/null || { echo "Invalid JSON: $f"; exit 1; }
done

- name: Check CloudFormation and Terraform Lambda copies match
# Each Lambda ships as a copy under both cloudformation/ and
# terraform/ (each deployment method packages its own). They must
# stay identical, or a fix lands in one flavor and not the other.
run: |
status=0
while read -r cfn; do
tf="terraform/${cfn#cloudformation/}"
if [ -f "$tf" ] && ! cmp -s "$cfn" "$tf"; then
echo "::error file=$tf::differs from $cfn - keep the two copies identical"
status=1
fi
done < <(find cloudformation -name '*.py')
exit $status

terraform:
name: Terraform (fmt, validate, tflint, Checkov)
runs-on: ubuntu-latest
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ aws-cloud-security-toolbox/
| [`sagemaker-notebook-exposure`](#sagemaker-notebook-exposure) | Auto-remediation | Locks down SageMaker notebooks with internet or root access enabled |
| [`claude-apps-gateway`](#claude-apps-gateway) | Reference | Deployment reference for Claude apps gateway on AWS |
| [`stale-account-detector`](#stale-account-detector) | Detective | Finds accounts with no CloudTrail activity in N days |
| [`wiz-finding-bridge`](#wiz-finding-bridge) | Detective | Bridges Wiz webhook findings into SNS and this repo's own remediation Lambdas |
| [`wiz-finding-bridge`](#wiz-finding-bridge) | Detective | Bridges Wiz webhook findings into SNS and (optionally) your own remediation Lambdas |

### `auto-remediate-open-ssh-rdp`

Expand Down Expand Up @@ -276,7 +276,8 @@ about the accounts that quietly stopped being used.

Receives Wiz webhook deliveries via an API Gateway HTTP API and bridges
them into this repo's existing patterns: an SNS notification, and
optionally an invocation of one of this repo's own remediation Lambdas
optionally an invocation of a remediation Lambda you supply (an adapter
is needed; this repo's own remediators don't accept the bridge's payload)
when a finding matches a configured mapping. **Schema-tolerant by
design**: Wiz's webhook JSON shape is read via configurable dot-notation
field paths rather than hardcoded keys, and the raw payload is always
Expand All @@ -294,6 +295,9 @@ GitHub Actions on every push/PR:
- **CloudFormation**: `cfn-lint` + Checkov
- **Terraform**: `terraform fmt -check`, `terraform validate`, `tflint`,
Checkov
- **Python and policies**: Lambda sources compile, `policies/**/*.json`
parses, and each Lambda's CloudFormation and Terraform copies are
byte-identical

## Contributing

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,10 @@ def _notify(subject, message):

def _principals_from_trust_policy(trust_policy):
services = set()
for statement in trust_policy.get("Statement", []):
# "Statement" may be a single object rather than a list - both are valid IAM.
for statement in _as_list(trust_policy.get("Statement")):
if not isinstance(statement, dict):
continue
principal = statement.get("Principal", {})
if not isinstance(principal, dict):
continue
Expand Down Expand Up @@ -128,6 +131,11 @@ def _statement_is_risky(statement):
if "*" in actions:
return "full wildcard action ('*')"

# Allow + NotAction grants every action *except* the listed ones, which
# on Resource "*" is effectively near-admin access.
if statement.get("NotAction") is not None and has_wildcard_resource:
return "Allow with NotAction on Resource '*' (grants everything except the listed actions)"

if has_wildcard_resource:
for action in actions:
if ":" not in action:
Expand All @@ -140,10 +148,9 @@ def _statement_is_risky(statement):


def _evaluate_policy_document(doc, source_label, findings):
for statement in doc.get("Statement", []):
# Statement can be a single dict or (rarely) handled elsewhere as a list -
# list_role_policies/get_role_policy always returns a dict with Statement
# being a list already, but guard just in case a single-statement dict slips through.
# "Statement" may be a single object rather than a list - both are valid
# IAM, and iterating a dict here would silently skip the whole policy.
for statement in _as_list(doc.get("Statement")):
if isinstance(statement, dict):
reason = _statement_is_risky(statement)
if reason:
Expand Down
6 changes: 5 additions & 1 deletion cloudformation/ai-ml-guardrails/template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,11 @@ Resources:
"Sid": "DenyDisallowedFoundationModels",
"Effect": "Deny",
"Action": ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"],
"NotResource": ["arn:*:bedrock:*::foundation-model/${ModelArnPatterns}"]
"NotResource": [
"arn:*:bedrock:*::foundation-model/${ModelArnPatterns}",
"arn:*:bedrock:*:*:inference-profile/*",
"arn:*:bedrock:*:*:application-inference-profile/*"
]
}
]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@
RISKY_CIDR_V6 = "::/0"


def _port_range_overlaps_risky(from_port, to_port):
def _port_range_overlaps_risky(protocol, from_port, to_port):
"""Return True if the given port range includes 22 or 3389, or if the
rule has no port restriction at all (protocol -1 / from-to missing)."""
if from_port is None or to_port is None:
if str(protocol) == "-1" or from_port is None or to_port is None:
return True
for port in RISKY_PORTS:
if from_port <= port <= to_port:
Expand All @@ -67,7 +67,7 @@ def _revoke_from_permissions(group_id, ip_permissions, source):
for perm in ip_permissions:
from_port = perm.get("FromPort")
to_port = perm.get("ToPort")
if not _port_range_overlaps_risky(from_port, to_port):
if not _port_range_overlaps_risky(perm.get("IpProtocol"), from_port, to_port):
continue

bad_v4 = [r for r in perm.get("IpRanges", []) if r.get("CidrIp") == RISKY_CIDR_V4]
Expand All @@ -76,11 +76,14 @@ def _revoke_from_permissions(group_id, ip_permissions, source):
if not bad_v4 and not bad_v6:
continue

revoke_perm = {
"IpProtocol": perm.get("IpProtocol", "tcp"),
"FromPort": from_port,
"ToPort": to_port,
}
revoke_perm = {"IpProtocol": perm.get("IpProtocol", "tcp")}
# An all-traffic rule (IpProtocol "-1") has no ports; passing
# FromPort/ToPort as None would fail boto3 parameter validation.
if str(revoke_perm["IpProtocol"]) != "-1":
if from_port is not None:
revoke_perm["FromPort"] = from_port
if to_port is not None:
revoke_perm["ToPort"] = to_port
if bad_v4:
revoke_perm["IpRanges"] = bad_v4
if bad_v6:
Expand Down
31 changes: 30 additions & 1 deletion cloudformation/bedrock-cost-guardrails/template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,40 @@ Conditions:
HasNotificationEmail: !Not [!Equals [!Ref NotificationEmail, ""]]

Resources:
# Budgets and Cost Anomaly Detection publish as service principals, which
# can't use the AWS-managed aws/sns key (its key policy can't be edited to
# allow them) - without a customer-managed key, alerts would silently never
# arrive.
CostAlertsTopicKey:
Type: AWS::KMS::Key
Properties:
Description: !Sub "Encrypts the ${AWS::StackName} Bedrock cost-alerts SNS topic (customer-managed so Budgets and Cost Anomaly Detection can publish to it)."
EnableKeyRotation: true
KeyPolicy:
Version: "2012-10-17"
Statement:
- Sid: EnableIAMUserPermissions
Effect: Allow
Principal:
AWS: !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:root"
Action: "kms:*"
Resource: "*"
- Sid: AllowBudgetsAndCostAnomalyDetectionToUseKey
Effect: Allow
Principal:
Service:
- budgets.amazonaws.com
- costalerts.amazonaws.com
Action:
- kms:Decrypt
- kms:GenerateDataKey*
Resource: "*"

CostAlertsTopic:
Type: AWS::SNS::Topic
Properties:
TopicName: !Sub "${AWS::StackName}-bedrock-cost-alerts"
KmsMasterKeyId: alias/aws/sns
KmsMasterKeyId: !Ref CostAlertsTopicKey

CostAlertsTopicSubscription:
Type: AWS::SNS::Subscription
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,9 @@ def _get_current_config():
def _is_compliant(current, desired):
if not current:
return False
if current.get("textDataDeliveryEnabled") != desired["textDataDeliveryEnabled"]:
return False
for flag in ("textDataDeliveryEnabled", "imageDataDeliveryEnabled", "embeddingDataDeliveryEnabled"):
if bool(current.get(flag)) != desired[flag]:
return False
if S3_BUCKET_NAME and current.get("s3Config", {}).get("bucketName") != S3_BUCKET_NAME:
return False
if CLOUDWATCH_LOG_GROUP and current.get("cloudWatchConfig", {}).get("logGroupName") != CLOUDWATCH_LOG_GROUP:
Expand Down
2 changes: 1 addition & 1 deletion cloudformation/claude-apps-gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ managed settings file — see
| `AcmCertificateArn` | Yes | ACM certificate for the gateway hostname |
| `EcrRepositoryUri` | Yes | Output of the `ecr/` stack, after pushing an image |
| `EcrKeyArn` | Yes | `EcrKeyArn` output of the `ecr/` stack - grants the execution role decrypt access to pull the image |
| `ContainerImageTag` | No | Image tag to deploy (default `latest`) |
| `ContainerImageTag` | No | Image tag to deploy. Default `latest`, but the repository is `IMMUTABLE`, so always pass an explicit versioned tag (e.g. `v1`) |
| `OidcClientSecretValue` | Yes | Your IdP app's OAuth client secret (`NoEcho`) |
| `DesiredCount` | No | Number of gateway tasks (default `1`) |
| `DbInstanceClass` | No | RDS instance class (default `db.t4g.micro`) |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ Parameters:

CorporateCidr:
Type: String
AllowedPattern: "^(?!0[.]0[.]0[.]0/0$)([0-9]{1,3}[.]){3}[0-9]{1,3}/([0-9]|[1-2][0-9]|3[0-2])$"
ConstraintDescription: Must be an IPv4 CIDR block (e.g. 10.0.0.0/8) and must not be 0.0.0.0/0.
Description: >
CIDR range allowed to reach the gateway's ALB on 443 (your
corporate network / VPN range). Never 0.0.0.0/0 - Claude Code
Expand Down Expand Up @@ -65,7 +67,10 @@ Parameters:
Description: >
Tag of the gateway image already pushed to that repository (see
README - the image must exist before the ECS service can start,
which is why the ECR repository is a separate, earlier stack).
which is why the ECR repository is a separate, earlier stack). The
repository has IMMUTABLE tags, so an existing tag can never be
re-pushed: always set an explicit versioned tag (e.g. v1) rather
than relying on this default.

DesiredCount:
Type: Number
Expand Down
5 changes: 5 additions & 0 deletions cloudformation/ec2-isolation-runbook/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,8 @@ aws ssm start-automation-execution \
attacker in an active session isn't tipped off by the SG change first.
- This runbook doesn't touch IAM (e.g. revoking the instance's role
credentials) — pair it with your incident response process for that.
- Changing the security groups only affects the instance's **primary
network interface**, and security groups are stateful: connections that
were already established when the swap happens can stay open until they
go idle. For a hard cut-off of an active session, also stop the instance
(`StopInstance=true`) or detach/replace any secondary interfaces.
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,10 @@ def _is_exempt(user_name):
try:
tags = iam.list_user_tags(UserName=user_name).get("Tags", [])
except ClientError:
logger.exception("Failed to list tags for user %s", user_name)
return False
# Fail safe: if we can't tell whether the user is exempt, don't
# deactivate their keys - they may be a break-glass account.
logger.exception("Failed to list tags for user %s - skipping user this run", user_name)
return True
for tag in tags:
if tag.get("Key") == EXEMPT_TAG_KEY:
if EXEMPT_TAG_VALUE is None or tag.get("Value") == EXEMPT_TAG_VALUE:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,17 +88,17 @@ def _notify(subject, message):

def _paginate(method, result_key, **kwargs):
"""Manual NextToken pagination - sso-admin's list_* operations all
follow this same NextToken/MaxResults shape."""
follow this same NextToken/MaxResults shape. API errors propagate: a
detective audit that swallows AccessDenied would report a false
"no findings", so let the invocation fail visibly (Lambda Errors
metric / DLQ) instead. Callers that are genuinely best-effort catch
ClientError themselves."""
next_token = None
while True:
call_kwargs = dict(kwargs)
if next_token:
call_kwargs["NextToken"] = next_token
try:
page = method(**call_kwargs)
except ClientError:
logger.exception("Paginated call failed: %s", getattr(method, "__name__", method))
return
page = method(**call_kwargs)
for item in page.get(result_key, []):
yield item
next_token = page.get("NextToken")
Expand Down Expand Up @@ -140,6 +140,11 @@ def _statement_is_risky(statement):
if "*" in actions:
return "full wildcard action ('*')"

# Allow + NotAction grants every action *except* the listed ones, which
# on Resource "*" is effectively near-admin access.
if statement.get("NotAction") is not None and has_wildcard_resource:
return "Allow with NotAction on Resource '*' (grants everything except the listed actions)"

if has_wildcard_resource:
for action in actions:
if ":" not in action:
Expand Down Expand Up @@ -170,7 +175,8 @@ def _inline_policy_findings(instance_arn, permission_set_arn):
logger.exception("Inline policy for %s was not valid JSON", permission_set_arn)
return findings

for statement in doc.get("Statement", []):
# "Statement" may be a single object rather than a list - both are valid IAM.
for statement in _as_list(doc.get("Statement")):
if not isinstance(statement, dict):
continue
reason = _statement_is_risky(statement)
Expand Down
10 changes: 10 additions & 0 deletions cloudformation/root-activity-alarm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,13 @@ Works the same in GovCloud — no partition is hardcoded.
tune the `EventPattern` if a specific source turns out to be chatty.
- Consider subscribing a Lambda (or chat webhook via SNS→Lambda) instead
of/alongside email for lower-latency paging during an active incident.
- **Deploy in the region your root events are logged in.** EventBridge
only sees events delivered to its own region. Root console sign-ins via
the global sign-in endpoint, and global-service (IAM, STS, Organizations)
activity, are recorded in `us-east-1` (`us-gov-west-1` in GovCloud), so
deploy there at minimum. Root activity in other regions is only seen by a
copy of this stack deployed in that region.
- The SNS topic uses a customer-managed KMS key rather than the AWS-managed
`aws/sns` key: EventBridge publishes as a service principal, which can't
be granted access through the AWS-managed key's policy, so alerts on an
`aws/sns`-encrypted topic would silently never be delivered.
28 changes: 27 additions & 1 deletion cloudformation/root-activity-alarm/template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,37 @@ Conditions:
HasNotificationEmail: !Not [!Equals [!Ref NotificationEmail, ""]]

Resources:
# EventBridge publishes to this topic as a service principal, which can't
# use the AWS-managed aws/sns key (its key policy can't be edited to allow
# it) - without a customer-managed key, alerts would silently never arrive.
RootActivityTopicKey:
Type: AWS::KMS::Key
Properties:
Description: !Sub "Encrypts the ${AWS::StackName} root-activity SNS topic (customer-managed so EventBridge can publish to it)."
EnableKeyRotation: true
KeyPolicy:
Version: "2012-10-17"
Statement:
- Sid: EnableIAMUserPermissions
Effect: Allow
Principal:
AWS: !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:root"
Action: "kms:*"
Resource: "*"
- Sid: AllowEventBridgeToUseKey
Effect: Allow
Principal:
Service: events.amazonaws.com
Action:
- kms:Decrypt
- kms:GenerateDataKey*
Resource: "*"

RootActivityTopic:
Type: AWS::SNS::Topic
Properties:
TopicName: !Sub "${AWS::StackName}-root-activity-alerts"
KmsMasterKeyId: alias/aws/sns
KmsMasterKeyId: !Ref RootActivityTopicKey

RootActivityTopicSubscription:
Type: AWS::SNS::Subscription
Expand Down
Loading
Loading