From 43c4b2600db56afc0329843dfb31d44d3015744d Mon Sep 17 00:00:00 2001 From: Dusty <42273218+DustyStudy@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:39:21 -0500 Subject: [PATCH] Fix ineffective SCP, silent SNS delivery failures, and Lambda edge cases Security / correctness - deny-disable-s3-public-access-block: the SCP used condition keys (s3:PutAccountPublicAccessBlock:*) that S3 does not define, so the deny could never match. Replace with an unconditional deny of PutAccountPublicAccessBlock/PutBucketPublicAccessBlock in the JSON, CFN and Terraform copies. - restrict-bedrock-foundation-models: also allow inference-profile ARNs, otherwise enabling the allow-list blocks cross-region profile calls (including this repo's own claude-apps-gateway). - root-activity-alarm, bedrock-cost-guardrails, organization-trail: SNS topics were encrypted with the AWS-managed aws/sns key, which cannot be used by service-principal publishers (EventBridge, Budgets, Cost Anomaly Detection, CloudTrail), so alerts would never be delivered. Use customer-managed keys. - organization-trail: scope the bucket and topic policies to this trail with aws:SourceArn (confused-deputy) and deny non-TLS access to the trail bucket. - claude-apps-gateway: reject 0.0.0.0/0 for CorporateCidr, and warn that the IMMUTABLE ECR repo makes the "latest" tag default a trap. Lambda fixes (CFN and Terraform copies kept identical) - remediate_open_ssh_rdp: all-traffic (-1) rules produced a revoke call with FromPort/ToPort=None, which fails boto3 validation and crashed. - wiz_webhook_bridge: compare secrets as bytes (non-ASCII path segment raised TypeError -> 500), sanitize the SNS subject, handle base64-encoded bodies. - deactivate_stale_iam_keys: fail safe (skip user) when the exemption-tag lookup fails instead of deactivating a possibly-exempt user's keys. - ai-agent / identity-center auditors: handle a single-object Statement (previously skipped or crashed), flag Allow+NotAction on Resource "*", and stop swallowing API errors (an AccessDenied produced a false "no findings"). - sagemaker remediation: report a failed update instead of erroring out. - bedrock logging enforcement: detect drift in image/embedding delivery flags, not just text. Docs / CI - Correct the wiz-finding-bridge docs: this repo's remediators do not accept the bridge payload; an adapter is required. - Document SCP limitations (management account exempt, S3 PAB behavior), root-alarm regional coverage, and EC2 isolation caveats. - CI: lint the Terraform-side StackSet template, compile Python, validate policy JSON, and check the CFN/Terraform Lambda copies stay identical. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/lint-and-scan.yml | 43 ++++++++++- README.md | 8 +- .../lambda/audit_ai_agent_iam_roles.py | 17 +++-- cloudformation/ai-ml-guardrails/template.yaml | 6 +- .../lambda/remediate_open_ssh_rdp.py | 19 +++-- .../bedrock-cost-guardrails/template.yaml | 31 +++++++- .../lambda/enforce_bedrock_logging.py | 5 +- cloudformation/claude-apps-gateway/README.md | 2 +- .../infrastructure/template.yaml | 7 +- .../ec2-isolation-runbook/README.md | 5 ++ .../lambda/deactivate_stale_iam_keys.py | 6 +- .../lambda/audit_identity_center_access.py | 20 +++-- cloudformation/root-activity-alarm/README.md | 10 +++ .../root-activity-alarm/template.yaml | 28 ++++++- .../remediate_sagemaker_notebook_exposure.py | 28 +++++-- cloudformation/scp-guardrails/template.yaml | 61 ++------------- .../organization-trail/template.yaml | 31 +++++++- cloudformation/wiz-finding-bridge/README.md | 28 ++++--- .../lambda/wiz_webhook_bridge.py | 28 ++++++- .../wiz-finding-bridge/template.yaml | 6 +- policies/ai-ml-guardrails/README.md | 15 +++- .../restrict-bedrock-foundation-models.json | 4 +- policies/scp-guardrails/README.md | 21 +++++- .../deny-disable-s3-public-access-block.json | 75 ++----------------- .../lambda/audit_ai_agent_iam_roles.py | 17 +++-- terraform/ai-ml-guardrails/main.tf | 17 ++++- .../lambda/remediate_open_ssh_rdp.py | 19 +++-- terraform/bedrock-cost-guardrails/main.tf | 31 +++++++- .../lambda/enforce_bedrock_logging.py | 5 +- terraform/claude-apps-gateway/README.md | 2 +- terraform/claude-apps-gateway/variables.tf | 9 ++- terraform/ec2-isolation-runbook/README.md | 5 ++ .../lambda/deactivate_stale_iam_keys.py | 6 +- .../lambda/audit_identity_center_access.py | 20 +++-- terraform/root-activity-alarm/README.md | 10 +++ terraform/root-activity-alarm/main.tf | 33 +++++++- .../remediate_sagemaker_notebook_exposure.py | 28 +++++-- terraform/scp-guardrails/main.tf | 67 +++-------------- .../organization-trail/main.tf | 42 ++++++++++- terraform/wiz-finding-bridge/README.md | 28 ++++--- .../lambda/wiz_webhook_bridge.py | 28 ++++++- terraform/wiz-finding-bridge/main.tf | 2 +- 42 files changed, 575 insertions(+), 298 deletions(-) diff --git a/.github/workflows/lint-and-scan.yml b/.github/workflows/lint-and-scan.yml index 33d8b98..67b3045 100644 --- a/.github/workflows/lint-and-scan.yml +++ b/.github/workflows/lint-and-scan.yml @@ -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 @@ -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 diff --git a/README.md b/README.md index 8a35fd8..99c40dd 100644 --- a/README.md +++ b/README.md @@ -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` @@ -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 @@ -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 diff --git a/cloudformation/ai-agent-iam-auditor/lambda/audit_ai_agent_iam_roles.py b/cloudformation/ai-agent-iam-auditor/lambda/audit_ai_agent_iam_roles.py index 7515104..e248ce1 100644 --- a/cloudformation/ai-agent-iam-auditor/lambda/audit_ai_agent_iam_roles.py +++ b/cloudformation/ai-agent-iam-auditor/lambda/audit_ai_agent_iam_roles.py @@ -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 @@ -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: @@ -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: diff --git a/cloudformation/ai-ml-guardrails/template.yaml b/cloudformation/ai-ml-guardrails/template.yaml index f25c77d..11f2d49 100644 --- a/cloudformation/ai-ml-guardrails/template.yaml +++ b/cloudformation/ai-ml-guardrails/template.yaml @@ -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/*" + ] } ] } diff --git a/cloudformation/auto-remediate-open-ssh-rdp/lambda/remediate_open_ssh_rdp.py b/cloudformation/auto-remediate-open-ssh-rdp/lambda/remediate_open_ssh_rdp.py index 0c4a008..3eed306 100644 --- a/cloudformation/auto-remediate-open-ssh-rdp/lambda/remediate_open_ssh_rdp.py +++ b/cloudformation/auto-remediate-open-ssh-rdp/lambda/remediate_open_ssh_rdp.py @@ -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: @@ -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] @@ -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: diff --git a/cloudformation/bedrock-cost-guardrails/template.yaml b/cloudformation/bedrock-cost-guardrails/template.yaml index affb731..3aa93c0 100644 --- a/cloudformation/bedrock-cost-guardrails/template.yaml +++ b/cloudformation/bedrock-cost-guardrails/template.yaml @@ -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 diff --git a/cloudformation/bedrock-logging-enforcement/lambda/enforce_bedrock_logging.py b/cloudformation/bedrock-logging-enforcement/lambda/enforce_bedrock_logging.py index 80fc19a..c979d20 100644 --- a/cloudformation/bedrock-logging-enforcement/lambda/enforce_bedrock_logging.py +++ b/cloudformation/bedrock-logging-enforcement/lambda/enforce_bedrock_logging.py @@ -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: diff --git a/cloudformation/claude-apps-gateway/README.md b/cloudformation/claude-apps-gateway/README.md index 2a505f1..20836e5 100644 --- a/cloudformation/claude-apps-gateway/README.md +++ b/cloudformation/claude-apps-gateway/README.md @@ -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`) | diff --git a/cloudformation/claude-apps-gateway/infrastructure/template.yaml b/cloudformation/claude-apps-gateway/infrastructure/template.yaml index dc658d0..2e41100 100644 --- a/cloudformation/claude-apps-gateway/infrastructure/template.yaml +++ b/cloudformation/claude-apps-gateway/infrastructure/template.yaml @@ -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 @@ -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 diff --git a/cloudformation/ec2-isolation-runbook/README.md b/cloudformation/ec2-isolation-runbook/README.md index 1de5de2..90b0431 100644 --- a/cloudformation/ec2-isolation-runbook/README.md +++ b/cloudformation/ec2-isolation-runbook/README.md @@ -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. diff --git a/cloudformation/iam-credential-hygiene/lambda/deactivate_stale_iam_keys.py b/cloudformation/iam-credential-hygiene/lambda/deactivate_stale_iam_keys.py index f9340fb..3a5e41f 100644 --- a/cloudformation/iam-credential-hygiene/lambda/deactivate_stale_iam_keys.py +++ b/cloudformation/iam-credential-hygiene/lambda/deactivate_stale_iam_keys.py @@ -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: diff --git a/cloudformation/identity-center-access-auditor/lambda/audit_identity_center_access.py b/cloudformation/identity-center-access-auditor/lambda/audit_identity_center_access.py index 4a02e93..19aab6b 100644 --- a/cloudformation/identity-center-access-auditor/lambda/audit_identity_center_access.py +++ b/cloudformation/identity-center-access-auditor/lambda/audit_identity_center_access.py @@ -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") @@ -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: @@ -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) diff --git a/cloudformation/root-activity-alarm/README.md b/cloudformation/root-activity-alarm/README.md index ffa41fa..ef55b85 100644 --- a/cloudformation/root-activity-alarm/README.md +++ b/cloudformation/root-activity-alarm/README.md @@ -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. diff --git a/cloudformation/root-activity-alarm/template.yaml b/cloudformation/root-activity-alarm/template.yaml index 19313c3..f936190 100644 --- a/cloudformation/root-activity-alarm/template.yaml +++ b/cloudformation/root-activity-alarm/template.yaml @@ -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 diff --git a/cloudformation/sagemaker-notebook-exposure/lambda/remediate_sagemaker_notebook_exposure.py b/cloudformation/sagemaker-notebook-exposure/lambda/remediate_sagemaker_notebook_exposure.py index 5ccdd3e..dda5b6f 100644 --- a/cloudformation/sagemaker-notebook-exposure/lambda/remediate_sagemaker_notebook_exposure.py +++ b/cloudformation/sagemaker-notebook-exposure/lambda/remediate_sagemaker_notebook_exposure.py @@ -129,11 +129,29 @@ def _finish_remediation(name, description=None): _clear_pending_tag(arn) return - sagemaker.update_notebook_instance( - NotebookInstanceName=name, - DirectInternetAccess="Disabled", - RootAccess="Disabled", - ) + try: + sagemaker.update_notebook_instance( + NotebookInstanceName=name, + DirectInternetAccess="Disabled", + RootAccess="Disabled", + ) + except ClientError as e: + # e.g. DirectInternetAccess can't be Disabled on a notebook that has + # no subnet (no VPC) - it has to be recreated inside a VPC instead. + logger.exception("Failed to update notebook %s", name) + _notify( + subject=f"FAILED to remediate SageMaker notebook {name}", + message=( + f"Notebook {name} is stopped and still has DirectInternetAccess=" + f"{description.get('DirectInternetAccess')} / RootAccess=" + f"{description.get('RootAccess')}. The update failed: " + f"{e.response.get('Error', {}).get('Message', str(e))} " + "Manual remediation is required (a notebook with no VPC subnet " + "must be recreated inside a VPC). The pending-remediation tag " + "has been left in place." + ), + ) + return _clear_pending_tag(arn) restarted = False diff --git a/cloudformation/scp-guardrails/template.yaml b/cloudformation/scp-guardrails/template.yaml index 1b24696..75e7387 100644 --- a/cloudformation/scp-guardrails/template.yaml +++ b/cloudformation/scp-guardrails/template.yaml @@ -210,7 +210,7 @@ Resources: Condition: CreateDenyDisableS3PublicAccessBlock Properties: Name: !Sub "${AWS::StackName}-deny-disable-s3-pab" - Description: Denies disabling S3 Block Public Access at the account or bucket level. + Description: Denies changing or removing S3 Block Public Access settings at the account or bucket level. Type: SERVICE_CONTROL_POLICY TargetIds: !Ref TargetIds Content: | @@ -218,60 +218,13 @@ Resources: "Version": "2012-10-17", "Statement": [ { - "Sid": "DenyDisableAccountBlockPublicAcls", + "Sid": "DenyModifyS3BlockPublicAccess", "Effect": "Deny", - "Action": "s3:PutAccountPublicAccessBlock", - "Resource": "*", - "Condition": { "Bool": { "s3:PutAccountPublicAccessBlock:BlockPublicAcls": "false" } } - }, - { - "Sid": "DenyDisableAccountBlockPublicPolicy", - "Effect": "Deny", - "Action": "s3:PutAccountPublicAccessBlock", - "Resource": "*", - "Condition": { "Bool": { "s3:PutAccountPublicAccessBlock:BlockPublicPolicy": "false" } } - }, - { - "Sid": "DenyDisableAccountIgnorePublicAcls", - "Effect": "Deny", - "Action": "s3:PutAccountPublicAccessBlock", - "Resource": "*", - "Condition": { "Bool": { "s3:PutAccountPublicAccessBlock:IgnorePublicAcls": "false" } } - }, - { - "Sid": "DenyDisableAccountRestrictPublicBuckets", - "Effect": "Deny", - "Action": "s3:PutAccountPublicAccessBlock", - "Resource": "*", - "Condition": { "Bool": { "s3:PutAccountPublicAccessBlock:RestrictPublicBuckets": "false" } } - }, - { - "Sid": "DenyDisableBucketBlockPublicAcls", - "Effect": "Deny", - "Action": "s3:PutBucketPublicAccessBlock", - "Resource": "*", - "Condition": { "Bool": { "s3:PutBucketPublicAccessBlock:BlockPublicAcls": "false" } } - }, - { - "Sid": "DenyDisableBucketBlockPublicPolicy", - "Effect": "Deny", - "Action": "s3:PutBucketPublicAccessBlock", - "Resource": "*", - "Condition": { "Bool": { "s3:PutBucketPublicAccessBlock:BlockPublicPolicy": "false" } } - }, - { - "Sid": "DenyDisableBucketIgnorePublicAcls", - "Effect": "Deny", - "Action": "s3:PutBucketPublicAccessBlock", - "Resource": "*", - "Condition": { "Bool": { "s3:PutBucketPublicAccessBlock:IgnorePublicAcls": "false" } } - }, - { - "Sid": "DenyDisableBucketRestrictPublicBuckets", - "Effect": "Deny", - "Action": "s3:PutBucketPublicAccessBlock", - "Resource": "*", - "Condition": { "Bool": { "s3:PutBucketPublicAccessBlock:RestrictPublicBuckets": "false" } } + "Action": [ + "s3:PutAccountPublicAccessBlock", + "s3:PutBucketPublicAccessBlock" + ], + "Resource": "*" } ] } diff --git a/cloudformation/security-baseline-new-accounts/organization-trail/template.yaml b/cloudformation/security-baseline-new-accounts/organization-trail/template.yaml index 589b1f8..b262a94 100644 --- a/cloudformation/security-baseline-new-accounts/organization-trail/template.yaml +++ b/cloudformation/security-baseline-new-accounts/organization-trail/template.yaml @@ -151,6 +151,9 @@ Resources: Service: cloudtrail.amazonaws.com Action: s3:GetBucketAcl Resource: !GetAtt TrailBucket.Arn + Condition: + StringEquals: + aws:SourceArn: !Sub "arn:${AWS::Partition}:cloudtrail:${AWS::Region}:${AWS::AccountId}:trail/${TrailName}" - Sid: AWSCloudTrailWriteOrgTrail Effect: Allow Principal: @@ -160,6 +163,7 @@ Resources: Condition: StringEquals: s3:x-amz-acl: bucket-owner-full-control + aws:SourceArn: !Sub "arn:${AWS::Partition}:cloudtrail:${AWS::Region}:${AWS::AccountId}:trail/${TrailName}" - Sid: AWSCloudTrailWriteMemberAccountLogs Effect: Allow Principal: @@ -169,6 +173,17 @@ Resources: Condition: StringEquals: s3:x-amz-acl: bucket-owner-full-control + aws:SourceArn: !Sub "arn:${AWS::Partition}:cloudtrail:${AWS::Region}:${AWS::AccountId}:trail/${TrailName}" + - Sid: DenyInsecureTransport + Effect: Deny + Principal: "*" + Action: s3:* + Resource: + - !GetAtt TrailBucket.Arn + - !Sub "${TrailBucket.Arn}/*" + Condition: + Bool: + aws:SecureTransport: "false" TrailEncryptionKey: Type: AWS::KMS::Key @@ -210,6 +225,17 @@ Resources: Service: cloudtrail.amazonaws.com Action: kms:DescribeKey Resource: "*" + - Sid: AllowCloudTrailToPublishToEncryptedTopic + # SNS needs the publisher (CloudTrail) to be able to use the + # topic's key. The AWS-managed aws/sns key can't grant this, so + # the topic is encrypted with this customer-managed key instead. + Effect: Allow + Principal: + Service: cloudtrail.amazonaws.com + Action: + - kms:GenerateDataKey* + - kms:Decrypt + Resource: "*" - Sid: AllowCloudWatchLogsUseOfKey Effect: Allow Principal: @@ -273,7 +299,7 @@ Resources: Type: AWS::SNS::Topic Properties: TopicName: !Sub "${TrailName}-notifications" - KmsMasterKeyId: alias/aws/sns + KmsMasterKeyId: !Ref TrailEncryptionKey TrailTopicPolicy: Type: AWS::SNS::TopicPolicy @@ -289,6 +315,9 @@ Resources: Service: cloudtrail.amazonaws.com Action: sns:Publish Resource: !Ref TrailTopic + Condition: + StringEquals: + aws:SourceArn: !Sub "arn:${AWS::Partition}:cloudtrail:${AWS::Region}:${AWS::AccountId}:trail/${TrailName}" OrganizationTrail: Type: AWS::CloudTrail::Trail diff --git a/cloudformation/wiz-finding-bridge/README.md b/cloudformation/wiz-finding-bridge/README.md index 980a9dd..c82e2e8 100644 --- a/cloudformation/wiz-finding-bridge/README.md +++ b/cloudformation/wiz-finding-bridge/README.md @@ -2,9 +2,8 @@ Receives Wiz webhook deliveries via an API Gateway HTTP API and bridges them into this repo's existing patterns: an SNS notification matching -every other module here, and — optionally — an invocation of one of this -repo's own remediation Lambdas when a finding matches a configured -mapping. +every other module here, and — optionally — an invocation of a remediation +Lambda you supply when a finding matches a configured mapping. ## Read this before you deploy @@ -104,23 +103,30 @@ caveats, and this module uses neither. Once you know your real Wiz payload's title/rule-name values (from the raw-payload excerpt in an SNS message), you can route specific findings -straight into one of this repo's own remediation Lambdas — for example, -a Wiz finding about a security group open to the internet could invoke +into a remediation Lambda of your own. For example, a Wiz finding about +a security group open to the internet could be routed to a small adapter +Lambda that extracts the security group ID and calls [`auto-remediate-open-ssh-rdp`](../auto-remediate-open-ssh-rdp/)'s Lambda -directly: +(pointing the mapping straight at that Lambda does **not** work; see the +note after this example): ``` -RemediationLambdaMapping = {"Port 22/3389 open to 0.0.0.0/0": "arn:aws:lambda:us-east-1:123456789012:function:auto-remediate-open-ssh-rdp-...-remediate"} -RemediationLambdaArns = arn:aws:lambda:us-east-1:123456789012:function:auto-remediate-open-ssh-rdp-...-remediate +RemediationLambdaMapping = {"Port 22/3389 open to 0.0.0.0/0": "arn:aws:lambda:us-east-1:123456789012:function:your-wiz-adapter-...-remediate"} +RemediationLambdaArns = arn:aws:lambda:us-east-1:123456789012:function:your-wiz-adapter-...-remediate ``` `RemediationLambdaArns` must list every ARN used in the mapping — it's what actually grants this Lambda's execution role permission to invoke them. The mapped Lambda is invoked asynchronously with `{"source": "wiz-finding-bridge", "finding": }` as -its payload; it needs to be written to accept that shape, or you'll want -a small adapter in between rather than pointing straight at an existing -module's Lambda whose input contract wasn't designed for this. +its payload; it needs to be written to accept that shape. None of this repo's +existing remediation Lambdas do: `auto-remediate-open-ssh-rdp` reads a +top-level `security_group_id`, and `sagemaker-notebook-exposure` reads a +top-level `notebook_instance_name`, so invoked directly with this payload +they would log "no ... provided" and remediate nothing. Put a small +adapter Lambda in between that pulls the resource identifier out of +`finding` (using the field paths you've identified from a real payload) +and calls the target with the input it expects. ## Parameters diff --git a/cloudformation/wiz-finding-bridge/lambda/wiz_webhook_bridge.py b/cloudformation/wiz-finding-bridge/lambda/wiz_webhook_bridge.py index 3e1b567..4d15030 100644 --- a/cloudformation/wiz-finding-bridge/lambda/wiz_webhook_bridge.py +++ b/cloudformation/wiz-finding-bridge/lambda/wiz_webhook_bridge.py @@ -2,8 +2,9 @@ Receives Wiz webhook deliveries (Settings -> Integrations -> Webhook, fired by a Policies -> Automation Rule) via API Gateway, and bridges them into this repo's existing patterns: an SNS notification matching every other -module here, and - optionally - an invocation of one of this repo's own -remediation Lambdas when a finding matches a configured mapping. +module here, and - optionally - an invocation of a remediation Lambda you +supply (typically a small adapter; this repo's own remediators expect a +different input shape) when a finding matches a configured mapping. This module is deliberately schema-tolerant rather than schema-assuming. Wiz's outbound webhook JSON shape isn't something this repo can verify @@ -59,7 +60,9 @@ import os import json import hmac +import base64 import logging +import re import boto3 from botocore.exceptions import ClientError @@ -88,6 +91,12 @@ MAX_RAW_PAYLOAD_CHARS = 2000 +# SNS only accepts printable ASCII in a Subject (no newlines, control or +# non-ASCII characters). The title comes from an external payload, so +# collapse anything else - otherwise one odd title would make SNS reject +# the publish and the notification would be silently dropped. +_NON_SUBJECT_CHARS = re.compile(r"[^\x20-\x7e]+") + # Cached across warm Lambda invocations to avoid a Secrets Manager call # on every webhook delivery. Cleared automatically on cold start. _cached_secret = None @@ -156,11 +165,22 @@ def lambda_handler(event, context): provided_secret = path_params.get("secretToken", "") expected_secret = _get_expected_secret() - if not expected_secret or not hmac.compare_digest(provided_secret, expected_secret): + # Compare as bytes: hmac.compare_digest raises TypeError on non-ASCII + # str input, which an attacker-controlled path segment could trigger + # (turning a clean 401 into an unhandled 500). + if not expected_secret or not hmac.compare_digest( + provided_secret.encode("utf-8"), expected_secret.encode("utf-8") + ): logger.warning("Rejected webhook delivery with an invalid or missing secret token") return _http_response(401, {"message": "unauthorized"}) raw_body = event.get("body", "") or "" + if event.get("isBase64Encoded"): + try: + raw_body = base64.b64decode(raw_body).decode("utf-8") + except (ValueError, UnicodeDecodeError): + logger.warning("Webhook body was flagged base64-encoded but could not be decoded") + return _http_response(200, {"message": "received, but body could not be decoded - not processed"}) try: payload = json.loads(raw_body) if not isinstance(payload, dict): @@ -215,7 +235,7 @@ def _stringify(value): try: sns.publish( TopicArn=SNS_TOPIC_ARN, - Subject=f"Wiz finding: {title}"[:100], + Subject=_NON_SUBJECT_CHARS.sub(" ", f"Wiz finding: {title}").strip()[:100], Message="\n".join(message_lines), ) except ClientError: diff --git a/cloudformation/wiz-finding-bridge/template.yaml b/cloudformation/wiz-finding-bridge/template.yaml index 96aaa8d..0a7c200 100644 --- a/cloudformation/wiz-finding-bridge/template.yaml +++ b/cloudformation/wiz-finding-bridge/template.yaml @@ -2,8 +2,8 @@ AWSTemplateFormatVersion: "2010-09-09" Description: > Receives Wiz webhook deliveries via an API Gateway HTTP API and bridges them into this repo's patterns: an SNS notification, and optionally an - invocation of one of this repo's own remediation Lambdas for findings - that match a configured mapping. Schema-tolerant by design - see the + invocation of a remediation Lambda you supply for findings that match + a configured mapping. Schema-tolerant by design - see the module README before relying on this in production. Works in AWS commercial and GovCloud. @@ -240,7 +240,7 @@ Resources: would never receive anything to capture. Properties: FunctionName: !Sub "${AWS::StackName}-wiz-webhook-bridge" - Description: Bridges Wiz webhook findings into SNS and, optionally, this repo's own remediation Lambdas. + Description: Bridges Wiz webhook findings into SNS and, optionally, a remediation Lambda you supply. Runtime: python3.12 Handler: wiz_webhook_bridge.lambda_handler Role: !GetAtt LambdaExecutionRole.Arn diff --git a/policies/ai-ml-guardrails/README.md b/policies/ai-ml-guardrails/README.md index dbb57d3..efd1420 100644 --- a/policies/ai-ml-guardrails/README.md +++ b/policies/ai-ml-guardrails/README.md @@ -20,8 +20,13 @@ standalone JSON, or deploy/attach via CloudFormation or Terraform. - **Bedrock invocation logging** is your only audit trail of what prompts and completions actually went through your models — without it, an incident involving a leaked prompt or a jailbroken agent is nearly - unreconstructable after the fact. This SCP stops anyone from turning it - off, in either direction. + unreconstructable after the fact. This SCP denies deleting the logging + configuration; it can't stop someone *reconfiguring* it (e.g. pointing it + at another bucket or disabling text delivery), since that is the same + `PutModelInvocationLoggingConfiguration` call the enforcement Lambda + itself needs. Pair it with + [`bedrock-logging-enforcement`](../../cloudformation/bedrock-logging-enforcement/) + to detect and revert that. - **Bedrock Guardrails** (content filtering, PII redaction, topic restrictions) are easy to configure and easy to quietly delete later. This denies the delete. @@ -77,6 +82,12 @@ module "ai_ml_guardrails" { ## Notes +- The allow-list is enforced against `foundation-model` ARNs. Calls made + through a cross-region or application inference profile (e.g. + `us.anthropic.*`) are also authorized against the underlying model ARN, + so inference-profile ARNs are permitted and the model allow-list still + applies. Custom and provisioned models are *not* exempted; add their + ARNs to the `NotResource` list if you use them. - Bedrock foundation models are versioned and updated by AWS regularly - review `AllowedBedrockModelPatterns` periodically so a new model your teams need isn't silently blocked. diff --git a/policies/ai-ml-guardrails/restrict-bedrock-foundation-models.json b/policies/ai-ml-guardrails/restrict-bedrock-foundation-models.json index 4b60354..66032cd 100644 --- a/policies/ai-ml-guardrails/restrict-bedrock-foundation-models.json +++ b/policies/ai-ml-guardrails/restrict-bedrock-foundation-models.json @@ -10,7 +10,9 @@ ], "NotResource": [ "arn:*:bedrock:*::foundation-model/REPLACE_WITH_ALLOWED_MODEL_PATTERN_1", - "arn:*:bedrock:*::foundation-model/REPLACE_WITH_ALLOWED_MODEL_PATTERN_2" + "arn:*:bedrock:*::foundation-model/REPLACE_WITH_ALLOWED_MODEL_PATTERN_2", + "arn:*:bedrock:*:*:inference-profile/*", + "arn:*:bedrock:*:*:application-inference-profile/*" ] } ] diff --git a/policies/scp-guardrails/README.md b/policies/scp-guardrails/README.md index 73782d2..b973dc3 100644 --- a/policies/scp-guardrails/README.md +++ b/policies/scp-guardrails/README.md @@ -18,9 +18,17 @@ which is what makes them a guardrail rather than just another permission. | `deny-disable-security-services.json` | Disabling/stopping CloudTrail, Config, GuardDuty, or Security Hub | | `require-imdsv2.json` | Launching or modifying EC2 instances without IMDSv2 required | | `deny-leave-organization.json` | A member account leaving the Organization | -| `deny-disable-s3-public-access-block.json` | Disabling S3 Block Public Access at the account or bucket level | +| `deny-disable-s3-public-access-block.json` | Any change to S3 Block Public Access settings at the account or bucket level (see note below) | | `restrict-regions.json` | Actions outside an allow-listed set of regions (global services exempted) | +**S3 Block Public Access note:** S3 exposes no condition keys for the +individual Block Public Access settings, so this policy can't distinguish +"turn a setting off" from "turn it on". It denies `PutAccountPublicAccessBlock` +and `PutBucketPublicAccessBlock` outright (the corresponding `Delete*` +calls are authorized by those same actions). Configure Block Public Access +through your baseline automation *before* attaching it, or exempt that +automation's role. + `restrict-regions.json` has `REPLACE_WITH_ALLOWED_REGION_*` placeholders — edit those (or use the CloudFormation/Terraform, which templates the region list for you) before attaching it. It's also the one most likely @@ -86,6 +94,17 @@ Every policy has a matching `enable_*` boolean variable (see set `AllowedRegions`/`allowed_regions` to your GovCloud region(s), e.g. `us-gov-west-1,us-gov-east-1`. +## Limitations + +- **SCPs never apply to the Organization's management account**, so none + of these policies (including `deny-root-user`) constrain it. Protect the + management account's root user separately (MFA, no access keys) and see + [`root-activity-alarm`](../../cloudformation/root-activity-alarm/) for + detection. +- `deny-disable-security-services.json` also blocks `cloudtrail:UpdateTrail` + and `PutEventSelectors`, so legitimate trail changes need a break-glass + path (or an exemption for your automation role) once it's attached. + ## Before enabling in production SCPs are enforced immediately once attached — test in a non-production OU diff --git a/policies/scp-guardrails/deny-disable-s3-public-access-block.json b/policies/scp-guardrails/deny-disable-s3-public-access-block.json index fc1a994..3d81031 100644 --- a/policies/scp-guardrails/deny-disable-s3-public-access-block.json +++ b/policies/scp-guardrails/deny-disable-s3-public-access-block.json @@ -2,76 +2,13 @@ "Version": "2012-10-17", "Statement": [ { - "Sid": "DenyDisableAccountBlockPublicAcls", + "Sid": "DenyModifyS3BlockPublicAccess", "Effect": "Deny", - "Action": "s3:PutAccountPublicAccessBlock", - "Resource": "*", - "Condition": { - "Bool": { "s3:PutAccountPublicAccessBlock:BlockPublicAcls": "false" } - } - }, - { - "Sid": "DenyDisableAccountBlockPublicPolicy", - "Effect": "Deny", - "Action": "s3:PutAccountPublicAccessBlock", - "Resource": "*", - "Condition": { - "Bool": { "s3:PutAccountPublicAccessBlock:BlockPublicPolicy": "false" } - } - }, - { - "Sid": "DenyDisableAccountIgnorePublicAcls", - "Effect": "Deny", - "Action": "s3:PutAccountPublicAccessBlock", - "Resource": "*", - "Condition": { - "Bool": { "s3:PutAccountPublicAccessBlock:IgnorePublicAcls": "false" } - } - }, - { - "Sid": "DenyDisableAccountRestrictPublicBuckets", - "Effect": "Deny", - "Action": "s3:PutAccountPublicAccessBlock", - "Resource": "*", - "Condition": { - "Bool": { "s3:PutAccountPublicAccessBlock:RestrictPublicBuckets": "false" } - } - }, - { - "Sid": "DenyDisableBucketBlockPublicAcls", - "Effect": "Deny", - "Action": "s3:PutBucketPublicAccessBlock", - "Resource": "*", - "Condition": { - "Bool": { "s3:PutBucketPublicAccessBlock:BlockPublicAcls": "false" } - } - }, - { - "Sid": "DenyDisableBucketBlockPublicPolicy", - "Effect": "Deny", - "Action": "s3:PutBucketPublicAccessBlock", - "Resource": "*", - "Condition": { - "Bool": { "s3:PutBucketPublicAccessBlock:BlockPublicPolicy": "false" } - } - }, - { - "Sid": "DenyDisableBucketIgnorePublicAcls", - "Effect": "Deny", - "Action": "s3:PutBucketPublicAccessBlock", - "Resource": "*", - "Condition": { - "Bool": { "s3:PutBucketPublicAccessBlock:IgnorePublicAcls": "false" } - } - }, - { - "Sid": "DenyDisableBucketRestrictPublicBuckets", - "Effect": "Deny", - "Action": "s3:PutBucketPublicAccessBlock", - "Resource": "*", - "Condition": { - "Bool": { "s3:PutBucketPublicAccessBlock:RestrictPublicBuckets": "false" } - } + "Action": [ + "s3:PutAccountPublicAccessBlock", + "s3:PutBucketPublicAccessBlock" + ], + "Resource": "*" } ] } diff --git a/terraform/ai-agent-iam-auditor/lambda/audit_ai_agent_iam_roles.py b/terraform/ai-agent-iam-auditor/lambda/audit_ai_agent_iam_roles.py index 7515104..e248ce1 100644 --- a/terraform/ai-agent-iam-auditor/lambda/audit_ai_agent_iam_roles.py +++ b/terraform/ai-agent-iam-auditor/lambda/audit_ai_agent_iam_roles.py @@ -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 @@ -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: @@ -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: diff --git a/terraform/ai-ml-guardrails/main.tf b/terraform/ai-ml-guardrails/main.tf index e2dc796..8e79e9d 100644 --- a/terraform/ai-ml-guardrails/main.tf +++ b/terraform/ai-ml-guardrails/main.tf @@ -23,10 +23,19 @@ locals { Sid = "DenyDisallowedFoundationModels" Effect = "Deny" Action = ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"] - NotResource = [ - for pattern in var.allowed_bedrock_model_patterns : - "arn:*:bedrock:*::foundation-model/${pattern}" - ] + # Inference profiles (e.g. us.anthropic.*) are separate resource ARNs + # that Bedrock authorizes alongside the underlying foundation-model + # ARN, so they're allowed here and the model allow-list still applies. + NotResource = concat( + [ + for pattern in var.allowed_bedrock_model_patterns : + "arn:*:bedrock:*::foundation-model/${pattern}" + ], + [ + "arn:*:bedrock:*:*:inference-profile/*", + "arn:*:bedrock:*:*:application-inference-profile/*", + ] + ) }] }) diff --git a/terraform/auto-remediate-open-ssh-rdp/lambda/remediate_open_ssh_rdp.py b/terraform/auto-remediate-open-ssh-rdp/lambda/remediate_open_ssh_rdp.py index 0c4a008..3eed306 100644 --- a/terraform/auto-remediate-open-ssh-rdp/lambda/remediate_open_ssh_rdp.py +++ b/terraform/auto-remediate-open-ssh-rdp/lambda/remediate_open_ssh_rdp.py @@ -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: @@ -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] @@ -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: diff --git a/terraform/bedrock-cost-guardrails/main.tf b/terraform/bedrock-cost-guardrails/main.tf index ec2777e..12c7c5f 100644 --- a/terraform/bedrock-cost-guardrails/main.tf +++ b/terraform/bedrock-cost-guardrails/main.tf @@ -1,9 +1,38 @@ data "aws_partition" "current" {} data "aws_caller_identity" "current" {} +# 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. +resource "aws_kms_key" "topic" { + description = "Encrypts the ${var.name_prefix} Bedrock cost-alerts SNS topic (customer-managed so Budgets and Cost Anomaly Detection can publish to it)." + enable_key_rotation = true + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "EnableIAMUserPermissions" + Effect = "Allow" + Principal = { AWS = "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:root" } + Action = "kms:*" + Resource = "*" + }, + { + Sid = "AllowBudgetsAndCostAnomalyDetectionToUseKey" + Effect = "Allow" + Principal = { Service = ["budgets.amazonaws.com", "costalerts.amazonaws.com"] } + Action = ["kms:Decrypt", "kms:GenerateDataKey*"] + Resource = "*" + }, + ] + }) +} + resource "aws_sns_topic" "cost_alerts" { name = "${var.name_prefix}-bedrock-cost-alerts" - kms_master_key_id = "alias/aws/sns" + kms_master_key_id = aws_kms_key.topic.arn } resource "aws_sns_topic_subscription" "email" { diff --git a/terraform/bedrock-logging-enforcement/lambda/enforce_bedrock_logging.py b/terraform/bedrock-logging-enforcement/lambda/enforce_bedrock_logging.py index 80fc19a..c979d20 100644 --- a/terraform/bedrock-logging-enforcement/lambda/enforce_bedrock_logging.py +++ b/terraform/bedrock-logging-enforcement/lambda/enforce_bedrock_logging.py @@ -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: diff --git a/terraform/claude-apps-gateway/README.md b/terraform/claude-apps-gateway/README.md index a204094..262fc4c 100644 --- a/terraform/claude-apps-gateway/README.md +++ b/terraform/claude-apps-gateway/README.md @@ -120,7 +120,7 @@ your MDM's managed settings file — see | `corporate_cidr` | CIDR allowed to reach the ALB on 443 | — (required) | | `acm_certificate_arn` | ACM certificate for the gateway hostname | — (required) | | `oidc_client_secret_value` | Your IdP app's OAuth client secret (sensitive) | — (required) | -| `container_image_tag` | Image tag to deploy | `latest` | +| `container_image_tag` | Image tag to deploy. The repository is `IMMUTABLE`, so always pass an explicit versioned tag (e.g. `v1`) | `latest` | | `desired_count` | Number of gateway tasks | `1` | | `db_instance_class` | RDS instance class | `db.t4g.micro` | | `db_allocated_storage_gb` | RDS storage in GB | `20` | diff --git a/terraform/claude-apps-gateway/variables.tf b/terraform/claude-apps-gateway/variables.tf index 61e4a6e..eb0548c 100644 --- a/terraform/claude-apps-gateway/variables.tf +++ b/terraform/claude-apps-gateway/variables.tf @@ -16,7 +16,12 @@ variable "private_subnet_ids" { variable "corporate_cidr" { type = string - description = "CIDR range allowed to reach the gateway's ALB on 443 (your corporate network / VPN range)." + description = "CIDR range allowed to reach the gateway's ALB on 443 (your corporate network / VPN range). Must not be 0.0.0.0/0." + + validation { + condition = can(cidrhost(var.corporate_cidr, 0)) && var.corporate_cidr != "0.0.0.0/0" + error_message = "corporate_cidr must be a valid IPv4 CIDR block and must not be 0.0.0.0/0." + } } variable "acm_certificate_arn" { @@ -26,7 +31,7 @@ variable "acm_certificate_arn" { variable "container_image_tag" { type = string - description = "Tag of the gateway image already pushed to this module's ECR repository. The image must exist before the ECS service can start - see this module's README for the two-phase apply." + description = "Tag of the gateway image already pushed to this module's ECR repository. The image must exist before the ECS service can start - see this module's README for the two-phase apply. The ECR repository has IMMUTABLE tags, so an existing tag can't be re-pushed: always set an explicit versioned tag (e.g. v1) rather than relying on this default." default = "latest" } diff --git a/terraform/ec2-isolation-runbook/README.md b/terraform/ec2-isolation-runbook/README.md index 5eab715..aec5850 100644 --- a/terraform/ec2-isolation-runbook/README.md +++ b/terraform/ec2-isolation-runbook/README.md @@ -76,3 +76,8 @@ Or via the console: Systems Manager → Automation → Execute automation. attacker isn't tipped off by the SG change first. - Doesn't touch IAM (e.g. revoking the instance's role credentials) — pair 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. diff --git a/terraform/iam-credential-hygiene/lambda/deactivate_stale_iam_keys.py b/terraform/iam-credential-hygiene/lambda/deactivate_stale_iam_keys.py index f9340fb..3a5e41f 100644 --- a/terraform/iam-credential-hygiene/lambda/deactivate_stale_iam_keys.py +++ b/terraform/iam-credential-hygiene/lambda/deactivate_stale_iam_keys.py @@ -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: diff --git a/terraform/identity-center-access-auditor/lambda/audit_identity_center_access.py b/terraform/identity-center-access-auditor/lambda/audit_identity_center_access.py index 4a02e93..19aab6b 100644 --- a/terraform/identity-center-access-auditor/lambda/audit_identity_center_access.py +++ b/terraform/identity-center-access-auditor/lambda/audit_identity_center_access.py @@ -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") @@ -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: @@ -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) diff --git a/terraform/root-activity-alarm/README.md b/terraform/root-activity-alarm/README.md index 1118fa3..337fe46 100644 --- a/terraform/root-activity-alarm/README.md +++ b/terraform/root-activity-alarm/README.md @@ -52,3 +52,13 @@ Works the same in GovCloud — no partition is hardcoded. Expect a little noise right after a new account is created. - Consider subscribing a Lambda (or chat webhook via SNS→Lambda) 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. diff --git a/terraform/root-activity-alarm/main.tf b/terraform/root-activity-alarm/main.tf index 8e8e540..dab8ac2 100644 --- a/terraform/root-activity-alarm/main.tf +++ b/terraform/root-activity-alarm/main.tf @@ -1,6 +1,37 @@ +data "aws_partition" "current" {} +data "aws_caller_identity" "current" {} + +# 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. +resource "aws_kms_key" "topic" { + description = "Encrypts the ${var.name_prefix} root-activity SNS topic (customer-managed so EventBridge can publish to it)." + enable_key_rotation = true + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "EnableIAMUserPermissions" + Effect = "Allow" + Principal = { AWS = "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:root" } + Action = "kms:*" + Resource = "*" + }, + { + Sid = "AllowEventBridgeToUseKey" + Effect = "Allow" + Principal = { Service = "events.amazonaws.com" } + Action = ["kms:Decrypt", "kms:GenerateDataKey*"] + Resource = "*" + }, + ] + }) +} + resource "aws_sns_topic" "root_activity" { name = "${var.name_prefix}-root-activity-alerts" - kms_master_key_id = "alias/aws/sns" + kms_master_key_id = aws_kms_key.topic.arn } resource "aws_sns_topic_subscription" "email" { diff --git a/terraform/sagemaker-notebook-exposure/lambda/remediate_sagemaker_notebook_exposure.py b/terraform/sagemaker-notebook-exposure/lambda/remediate_sagemaker_notebook_exposure.py index 5ccdd3e..dda5b6f 100644 --- a/terraform/sagemaker-notebook-exposure/lambda/remediate_sagemaker_notebook_exposure.py +++ b/terraform/sagemaker-notebook-exposure/lambda/remediate_sagemaker_notebook_exposure.py @@ -129,11 +129,29 @@ def _finish_remediation(name, description=None): _clear_pending_tag(arn) return - sagemaker.update_notebook_instance( - NotebookInstanceName=name, - DirectInternetAccess="Disabled", - RootAccess="Disabled", - ) + try: + sagemaker.update_notebook_instance( + NotebookInstanceName=name, + DirectInternetAccess="Disabled", + RootAccess="Disabled", + ) + except ClientError as e: + # e.g. DirectInternetAccess can't be Disabled on a notebook that has + # no subnet (no VPC) - it has to be recreated inside a VPC instead. + logger.exception("Failed to update notebook %s", name) + _notify( + subject=f"FAILED to remediate SageMaker notebook {name}", + message=( + f"Notebook {name} is stopped and still has DirectInternetAccess=" + f"{description.get('DirectInternetAccess')} / RootAccess=" + f"{description.get('RootAccess')}. The update failed: " + f"{e.response.get('Error', {}).get('Message', str(e))} " + "Manual remediation is required (a notebook with no VPC subnet " + "must be recreated inside a VPC). The pending-remediation tag " + "has been left in place." + ), + ) + return _clear_pending_tag(arn) restarted = False diff --git a/terraform/scp-guardrails/main.tf b/terraform/scp-guardrails/main.tf index b33a8fd..9791e32 100644 --- a/terraform/scp-guardrails/main.tf +++ b/terraform/scp-guardrails/main.tf @@ -92,64 +92,15 @@ locals { deny_disable_s3_public_access_block_content = jsonencode({ Version = "2012-10-17" - Statement = [ - { - Sid = "DenyDisableAccountBlockPublicAcls" - Effect = "Deny" - Action = "s3:PutAccountPublicAccessBlock" - Resource = "*" - Condition = { Bool = { "s3:PutAccountPublicAccessBlock:BlockPublicAcls" = "false" } } - }, - { - Sid = "DenyDisableAccountBlockPublicPolicy" - Effect = "Deny" - Action = "s3:PutAccountPublicAccessBlock" - Resource = "*" - Condition = { Bool = { "s3:PutAccountPublicAccessBlock:BlockPublicPolicy" = "false" } } - }, - { - Sid = "DenyDisableAccountIgnorePublicAcls" - Effect = "Deny" - Action = "s3:PutAccountPublicAccessBlock" - Resource = "*" - Condition = { Bool = { "s3:PutAccountPublicAccessBlock:IgnorePublicAcls" = "false" } } - }, - { - Sid = "DenyDisableAccountRestrictPublicBuckets" - Effect = "Deny" - Action = "s3:PutAccountPublicAccessBlock" - Resource = "*" - Condition = { Bool = { "s3:PutAccountPublicAccessBlock:RestrictPublicBuckets" = "false" } } - }, - { - Sid = "DenyDisableBucketBlockPublicAcls" - Effect = "Deny" - Action = "s3:PutBucketPublicAccessBlock" - Resource = "*" - Condition = { Bool = { "s3:PutBucketPublicAccessBlock:BlockPublicAcls" = "false" } } - }, - { - Sid = "DenyDisableBucketBlockPublicPolicy" - Effect = "Deny" - Action = "s3:PutBucketPublicAccessBlock" - Resource = "*" - Condition = { Bool = { "s3:PutBucketPublicAccessBlock:BlockPublicPolicy" = "false" } } - }, - { - Sid = "DenyDisableBucketIgnorePublicAcls" - Effect = "Deny" - Action = "s3:PutBucketPublicAccessBlock" - Resource = "*" - Condition = { Bool = { "s3:PutBucketPublicAccessBlock:IgnorePublicAcls" = "false" } } - }, - { - Sid = "DenyDisableBucketRestrictPublicBuckets" - Effect = "Deny" - Action = "s3:PutBucketPublicAccessBlock" - Resource = "*" - Condition = { Bool = { "s3:PutBucketPublicAccessBlock:RestrictPublicBuckets" = "false" } } - }, - ] + Statement = [{ + Sid = "DenyModifyS3BlockPublicAccess" + Effect = "Deny" + Action = [ + "s3:PutAccountPublicAccessBlock", + "s3:PutBucketPublicAccessBlock", + ] + Resource = "*" + }] }) restrict_regions_content = jsonencode({ diff --git a/terraform/security-baseline-new-accounts/organization-trail/main.tf b/terraform/security-baseline-new-accounts/organization-trail/main.tf index 6db3342..81e7cb8 100644 --- a/terraform/security-baseline-new-accounts/organization-trail/main.tf +++ b/terraform/security-baseline-new-accounts/organization-trail/main.tf @@ -14,6 +14,10 @@ locals { # struggle with conditionally-omitted policy statements) while being # functionally inert when organization_id isn't set. effective_org_id = var.organization_id != "" ? var.organization_id : "o-00000000disabled" + + # Built from strings rather than referencing aws_cloudtrail.organization, + # which depends on the bucket/topic policies that use it (avoids a cycle). + trail_arn = "arn:${data.aws_partition.current.partition}:cloudtrail:${data.aws_region.current.region}:${data.aws_caller_identity.current.account_id}:trail/${var.trail_name}" } resource "aws_kms_key" "trail" { @@ -49,6 +53,16 @@ resource "aws_kms_key" "trail" { Action = "kms:DescribeKey" Resource = "*" }, + { + # SNS needs the publisher (CloudTrail) to be able to use the topic's + # key. The AWS-managed aws/sns key can't grant this, so the topic is + # encrypted with this customer-managed key instead. + Sid = "AllowCloudTrailToPublishToEncryptedTopic" + Effect = "Allow" + Principal = { Service = "cloudtrail.amazonaws.com" } + Action = ["kms:GenerateDataKey*", "kms:Decrypt"] + Resource = "*" + }, { Sid = "AllowCloudWatchLogsUseOfKey" Effect = "Allow" @@ -275,6 +289,9 @@ resource "aws_s3_bucket_policy" "trail" { Principal = { Service = "cloudtrail.amazonaws.com" } Action = "s3:GetBucketAcl" Resource = aws_s3_bucket.trail.arn + Condition = { + StringEquals = { "aws:SourceArn" = local.trail_arn } + } }, { Sid = "AWSCloudTrailWriteOrgTrail" @@ -283,7 +300,10 @@ resource "aws_s3_bucket_policy" "trail" { Action = "s3:PutObject" Resource = "${aws_s3_bucket.trail.arn}/AWSLogs/${data.aws_caller_identity.current.account_id}/*" Condition = { - StringEquals = { "s3:x-amz-acl" = "bucket-owner-full-control" } + StringEquals = { + "s3:x-amz-acl" = "bucket-owner-full-control" + "aws:SourceArn" = local.trail_arn + } } }, { @@ -293,7 +313,20 @@ resource "aws_s3_bucket_policy" "trail" { Action = "s3:PutObject" Resource = "${aws_s3_bucket.trail.arn}/AWSLogs/*" Condition = { - StringEquals = { "s3:x-amz-acl" = "bucket-owner-full-control" } + StringEquals = { + "s3:x-amz-acl" = "bucket-owner-full-control" + "aws:SourceArn" = local.trail_arn + } + } + }, + { + Sid = "DenyInsecureTransport" + Effect = "Deny" + Principal = "*" + Action = "s3:*" + Resource = [aws_s3_bucket.trail.arn, "${aws_s3_bucket.trail.arn}/*"] + Condition = { + Bool = { "aws:SecureTransport" = "false" } } }, ] @@ -307,7 +340,7 @@ resource "aws_s3_bucket_notification" "trail" { resource "aws_sns_topic" "trail" { name = "${var.trail_name}-notifications" - kms_master_key_id = "alias/aws/sns" + kms_master_key_id = aws_kms_key.trail.arn } resource "aws_sns_topic_policy" "trail" { @@ -321,6 +354,9 @@ resource "aws_sns_topic_policy" "trail" { Principal = { Service = "cloudtrail.amazonaws.com" } Action = "sns:Publish" Resource = aws_sns_topic.trail.arn + Condition = { + StringEquals = { "aws:SourceArn" = local.trail_arn } + } }] }) } diff --git a/terraform/wiz-finding-bridge/README.md b/terraform/wiz-finding-bridge/README.md index 48e868f..9c00217 100644 --- a/terraform/wiz-finding-bridge/README.md +++ b/terraform/wiz-finding-bridge/README.md @@ -2,9 +2,8 @@ Receives Wiz webhook deliveries via an API Gateway HTTP API and bridges them into this repo's existing patterns: an SNS notification matching -every other module here, and — optionally — an invocation of one of this -repo's own remediation Lambdas when a finding matches a configured -mapping. +every other module here, and — optionally — an invocation of a remediation +Lambda you supply when a finding matches a configured mapping. ## Read this before you deploy @@ -93,19 +92,21 @@ caveats, and this module uses neither. Once you know your real Wiz payload's title/rule-name values (from the raw-payload excerpt in an SNS message), you can route specific findings -straight into one of this repo's own remediation Lambdas — for example, -a Wiz finding about a security group open to the internet could invoke +into a remediation Lambda of your own. For example, a Wiz finding about +a security group open to the internet could be routed to a small adapter +Lambda that extracts the security group ID and calls [`auto-remediate-open-ssh-rdp`](../auto-remediate-open-ssh-rdp/)'s Lambda -directly: +(pointing the mapping straight at that Lambda does **not** work; see the +note after this example): ```hcl module "wiz_finding_bridge" { source = "github.com/DustyStudy/aws-cloud-security-toolbox//terraform/wiz-finding-bridge" remediation_lambda_mapping = { - "Port 22/3389 open to 0.0.0.0/0" = module.auto_remediate_open_ssh_rdp.event_driven_lambda_arn + "Port 22/3389 open to 0.0.0.0/0" = aws_lambda_function.wiz_adapter.arn } - remediation_lambda_arns = [module.auto_remediate_open_ssh_rdp.event_driven_lambda_arn] + remediation_lambda_arns = [aws_lambda_function.wiz_adapter.arn] } ``` @@ -113,9 +114,14 @@ module "wiz_finding_bridge" { what actually grants this module's execution role permission to invoke them. The mapped Lambda is invoked asynchronously with `{"source": "wiz-finding-bridge", "finding": }` as -its payload; it needs to be written to accept that shape, or you'll want -a small adapter in between rather than pointing straight at an existing -module's Lambda whose input contract wasn't designed for this. +its payload; it needs to be written to accept that shape. None of this repo's +existing remediation Lambdas do: `auto-remediate-open-ssh-rdp` reads a +top-level `security_group_id`, and `sagemaker-notebook-exposure` reads a +top-level `notebook_instance_name`, so invoked directly with this payload +they would log "no ... provided" and remediate nothing. Put a small +adapter Lambda in between that pulls the resource identifier out of +`finding` (using the field paths you've identified from a real payload) +and calls the target with the input it expects. ## Variables diff --git a/terraform/wiz-finding-bridge/lambda/wiz_webhook_bridge.py b/terraform/wiz-finding-bridge/lambda/wiz_webhook_bridge.py index 3e1b567..4d15030 100644 --- a/terraform/wiz-finding-bridge/lambda/wiz_webhook_bridge.py +++ b/terraform/wiz-finding-bridge/lambda/wiz_webhook_bridge.py @@ -2,8 +2,9 @@ Receives Wiz webhook deliveries (Settings -> Integrations -> Webhook, fired by a Policies -> Automation Rule) via API Gateway, and bridges them into this repo's existing patterns: an SNS notification matching every other -module here, and - optionally - an invocation of one of this repo's own -remediation Lambdas when a finding matches a configured mapping. +module here, and - optionally - an invocation of a remediation Lambda you +supply (typically a small adapter; this repo's own remediators expect a +different input shape) when a finding matches a configured mapping. This module is deliberately schema-tolerant rather than schema-assuming. Wiz's outbound webhook JSON shape isn't something this repo can verify @@ -59,7 +60,9 @@ import os import json import hmac +import base64 import logging +import re import boto3 from botocore.exceptions import ClientError @@ -88,6 +91,12 @@ MAX_RAW_PAYLOAD_CHARS = 2000 +# SNS only accepts printable ASCII in a Subject (no newlines, control or +# non-ASCII characters). The title comes from an external payload, so +# collapse anything else - otherwise one odd title would make SNS reject +# the publish and the notification would be silently dropped. +_NON_SUBJECT_CHARS = re.compile(r"[^\x20-\x7e]+") + # Cached across warm Lambda invocations to avoid a Secrets Manager call # on every webhook delivery. Cleared automatically on cold start. _cached_secret = None @@ -156,11 +165,22 @@ def lambda_handler(event, context): provided_secret = path_params.get("secretToken", "") expected_secret = _get_expected_secret() - if not expected_secret or not hmac.compare_digest(provided_secret, expected_secret): + # Compare as bytes: hmac.compare_digest raises TypeError on non-ASCII + # str input, which an attacker-controlled path segment could trigger + # (turning a clean 401 into an unhandled 500). + if not expected_secret or not hmac.compare_digest( + provided_secret.encode("utf-8"), expected_secret.encode("utf-8") + ): logger.warning("Rejected webhook delivery with an invalid or missing secret token") return _http_response(401, {"message": "unauthorized"}) raw_body = event.get("body", "") or "" + if event.get("isBase64Encoded"): + try: + raw_body = base64.b64decode(raw_body).decode("utf-8") + except (ValueError, UnicodeDecodeError): + logger.warning("Webhook body was flagged base64-encoded but could not be decoded") + return _http_response(200, {"message": "received, but body could not be decoded - not processed"}) try: payload = json.loads(raw_body) if not isinstance(payload, dict): @@ -215,7 +235,7 @@ def _stringify(value): try: sns.publish( TopicArn=SNS_TOPIC_ARN, - Subject=f"Wiz finding: {title}"[:100], + Subject=_NON_SUBJECT_CHARS.sub(" ", f"Wiz finding: {title}").strip()[:100], Message="\n".join(message_lines), ) except ClientError: diff --git a/terraform/wiz-finding-bridge/main.tf b/terraform/wiz-finding-bridge/main.tf index eb297ff..f1eba7a 100644 --- a/terraform/wiz-finding-bridge/main.tf +++ b/terraform/wiz-finding-bridge/main.tf @@ -158,7 +158,7 @@ resource "aws_lambda_function" "bridge" { # the error directly in its response - a DLQ here would never receive # anything to capture. function_name = "${var.name_prefix}-wiz-webhook-bridge" - description = "Bridges Wiz webhook findings into SNS and, optionally, this repo's own remediation Lambdas." + description = "Bridges Wiz webhook findings into SNS and, optionally, a remediation Lambda you supply." role = aws_iam_role.lambda_exec.arn handler = "wiz_webhook_bridge.lambda_handler" runtime = "python3.12"