diff --git a/cloudformation/scp-guardrails/template.yaml b/cloudformation/scp-guardrails/template.yaml index 98ce710..fc8b721 100644 --- a/cloudformation/scp-guardrails/template.yaml +++ b/cloudformation/scp-guardrails/template.yaml @@ -135,7 +135,10 @@ Resources: "guardduty:DisassociateFromMasterAccount", "guardduty:DisassociateMembers", "guardduty:DisableOrganizationAdminAccount", - "guardduty:UpdateDetector" + "guardduty:UpdateDetector", + "guardduty:DisassociateFromAdministratorAccount", + "guardduty:DeleteMembers", + "guardduty:StopMonitoringMembers" ], "Resource": "*" }, @@ -145,7 +148,13 @@ Resources: "Action": [ "securityhub:DisableSecurityHub", "securityhub:DisableImportFindingsForProduct", - "securityhub:DeleteInsight" + "securityhub:DeleteInsight", + "securityhub:BatchDisableStandards", + "securityhub:DisassociateFromAdministratorAccount", + "securityhub:DisassociateFromMasterAccount", + "securityhub:DisassociateMembers", + "securityhub:DeleteMembers", + "securityhub:DisableOrganizationAdminAccount" ], "Resource": "*" } diff --git a/cloudformation/wiz-finding-bridge/README.md b/cloudformation/wiz-finding-bridge/README.md index c82e2e8..096cf9e 100644 --- a/cloudformation/wiz-finding-bridge/README.md +++ b/cloudformation/wiz-finding-bridge/README.md @@ -95,6 +95,11 @@ Created* (or *Detection Created*, if you have Wiz Defend), an optional severity "If" filter, and set the action to send to the webhook integration you just created. +**Rotating the token:** if the URL may have leaked, replace the secret +value in Secrets Manager and re-paste the new URL into Wiz. The Lambda +caches the secret for `SECRET_CACHE_TTL_SECONDS` (default 300), so the old +token stops being accepted within about five minutes without redeploying. + Works the same in GovCloud — HTTP APIs are fully supported there; only edge-optimized endpoints and private VPC-link integrations have GovCloud caveats, and this module uses neither. diff --git a/cloudformation/wiz-finding-bridge/lambda/wiz_webhook_bridge.py b/cloudformation/wiz-finding-bridge/lambda/wiz_webhook_bridge.py index 4d15030..461852c 100644 --- a/cloudformation/wiz-finding-bridge/lambda/wiz_webhook_bridge.py +++ b/cloudformation/wiz-finding-bridge/lambda/wiz_webhook_bridge.py @@ -63,6 +63,7 @@ import base64 import logging import re +import time import boto3 from botocore.exceptions import ClientError @@ -98,20 +99,28 @@ _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. +# on every webhook delivery. The cache expires after a short TTL so a +# rotated secret (e.g. after a suspected URL leak) stops being accepted +# within minutes, not whenever the execution environment happens to be +# recycled. +SECRET_CACHE_TTL_SECONDS = int(os.environ.get("SECRET_CACHE_TTL_SECONDS", "300")) _cached_secret = None +_cached_secret_at = 0.0 def _get_expected_secret(): - global _cached_secret - if _cached_secret is not None: + global _cached_secret, _cached_secret_at + if _cached_secret is not None and time.monotonic() - _cached_secret_at < SECRET_CACHE_TTL_SECONDS: return _cached_secret try: response = secretsmanager.get_secret_value(SecretId=WEBHOOK_SECRET_ARN) _cached_secret = response["SecretString"] + _cached_secret_at = time.monotonic() return _cached_secret except ClientError: logger.exception("Failed to retrieve webhook secret from Secrets Manager") + # Don't keep honoring a stale secret indefinitely if the refresh fails. + _cached_secret = None return None diff --git a/policies/scp-guardrails/README.md b/policies/scp-guardrails/README.md index 1e5d34f..01e4e58 100644 --- a/policies/scp-guardrails/README.md +++ b/policies/scp-guardrails/README.md @@ -109,6 +109,13 @@ Every policy has a matching `enable_*` boolean variable (see changes (finding publishing frequency, feature toggles), and CloudFormation issues it when a `AWS::GuardDuty::Detector` is updated - so change those settings from an exempted role, or before attaching the policy. +- It also blocks the current-name GuardDuty and Security Hub calls that + detach an account from its delegated administrator + (`DisassociateFromAdministratorAccount`; the older + `...FromMasterAccount` names are separate IAM actions and are listed + too), plus `guardduty:DeleteMembers` / `StopMonitoringMembers` and + `securityhub:BatchDisableStandards`. Managing membership or standards + therefore has to run from an exempted role in the administrator account. ## Before enabling in production diff --git a/policies/scp-guardrails/deny-disable-security-services.json b/policies/scp-guardrails/deny-disable-security-services.json index dc6bd85..120c215 100644 --- a/policies/scp-guardrails/deny-disable-security-services.json +++ b/policies/scp-guardrails/deny-disable-security-services.json @@ -32,7 +32,10 @@ "guardduty:DisassociateFromMasterAccount", "guardduty:DisassociateMembers", "guardduty:DisableOrganizationAdminAccount", - "guardduty:UpdateDetector" + "guardduty:UpdateDetector", + "guardduty:DisassociateFromAdministratorAccount", + "guardduty:DeleteMembers", + "guardduty:StopMonitoringMembers" ], "Resource": "*" }, @@ -42,7 +45,13 @@ "Action": [ "securityhub:DisableSecurityHub", "securityhub:DisableImportFindingsForProduct", - "securityhub:DeleteInsight" + "securityhub:DeleteInsight", + "securityhub:BatchDisableStandards", + "securityhub:DisassociateFromAdministratorAccount", + "securityhub:DisassociateFromMasterAccount", + "securityhub:DisassociateMembers", + "securityhub:DeleteMembers", + "securityhub:DisableOrganizationAdminAccount" ], "Resource": "*" } diff --git a/terraform/scp-guardrails/main.tf b/terraform/scp-guardrails/main.tf index 5af04db..0151c7b 100644 --- a/terraform/scp-guardrails/main.tf +++ b/terraform/scp-guardrails/main.tf @@ -45,6 +45,9 @@ locals { "guardduty:DisassociateMembers", "guardduty:DisableOrganizationAdminAccount", "guardduty:UpdateDetector", + "guardduty:DisassociateFromAdministratorAccount", + "guardduty:DeleteMembers", + "guardduty:StopMonitoringMembers", ] Resource = "*" }, @@ -55,6 +58,12 @@ locals { "securityhub:DisableSecurityHub", "securityhub:DisableImportFindingsForProduct", "securityhub:DeleteInsight", + "securityhub:BatchDisableStandards", + "securityhub:DisassociateFromAdministratorAccount", + "securityhub:DisassociateFromMasterAccount", + "securityhub:DisassociateMembers", + "securityhub:DeleteMembers", + "securityhub:DisableOrganizationAdminAccount", ] Resource = "*" }, diff --git a/terraform/wiz-finding-bridge/README.md b/terraform/wiz-finding-bridge/README.md index 9c00217..d39df71 100644 --- a/terraform/wiz-finding-bridge/README.md +++ b/terraform/wiz-finding-bridge/README.md @@ -84,6 +84,11 @@ Created* (or *Detection Created*, if you have Wiz Defend), an optional severity "If" filter, and set the action to send to the webhook integration you just created. +**Rotating the token:** if the URL may have leaked, replace the secret +value in Secrets Manager and re-paste the new URL into Wiz. The Lambda +caches the secret for `SECRET_CACHE_TTL_SECONDS` (default 300), so the old +token stops being accepted within about five minutes without redeploying. + Works the same in GovCloud — HTTP APIs are fully supported there; only edge-optimized endpoints and private VPC-link integrations have GovCloud caveats, and this module uses neither. diff --git a/terraform/wiz-finding-bridge/lambda/wiz_webhook_bridge.py b/terraform/wiz-finding-bridge/lambda/wiz_webhook_bridge.py index 4d15030..461852c 100644 --- a/terraform/wiz-finding-bridge/lambda/wiz_webhook_bridge.py +++ b/terraform/wiz-finding-bridge/lambda/wiz_webhook_bridge.py @@ -63,6 +63,7 @@ import base64 import logging import re +import time import boto3 from botocore.exceptions import ClientError @@ -98,20 +99,28 @@ _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. +# on every webhook delivery. The cache expires after a short TTL so a +# rotated secret (e.g. after a suspected URL leak) stops being accepted +# within minutes, not whenever the execution environment happens to be +# recycled. +SECRET_CACHE_TTL_SECONDS = int(os.environ.get("SECRET_CACHE_TTL_SECONDS", "300")) _cached_secret = None +_cached_secret_at = 0.0 def _get_expected_secret(): - global _cached_secret - if _cached_secret is not None: + global _cached_secret, _cached_secret_at + if _cached_secret is not None and time.monotonic() - _cached_secret_at < SECRET_CACHE_TTL_SECONDS: return _cached_secret try: response = secretsmanager.get_secret_value(SecretId=WEBHOOK_SECRET_ARN) _cached_secret = response["SecretString"] + _cached_secret_at = time.monotonic() return _cached_secret except ClientError: logger.exception("Failed to retrieve webhook secret from Secrets Manager") + # Don't keep honoring a stale secret indefinitely if the refresh fails. + _cached_secret = None return None