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
13 changes: 11 additions & 2 deletions cloudformation/scp-guardrails/template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,10 @@ Resources:
"guardduty:DisassociateFromMasterAccount",
"guardduty:DisassociateMembers",
"guardduty:DisableOrganizationAdminAccount",
"guardduty:UpdateDetector"
"guardduty:UpdateDetector",
"guardduty:DisassociateFromAdministratorAccount",
"guardduty:DeleteMembers",
"guardduty:StopMonitoringMembers"
],
"Resource": "*"
},
Expand All @@ -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": "*"
}
Expand Down
5 changes: 5 additions & 0 deletions cloudformation/wiz-finding-bridge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 12 additions & 3 deletions cloudformation/wiz-finding-bridge/lambda/wiz_webhook_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
import base64
import logging
import re
import time

import boto3
from botocore.exceptions import ClientError
Expand Down Expand Up @@ -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


Expand Down
7 changes: 7 additions & 0 deletions policies/scp-guardrails/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 11 additions & 2 deletions policies/scp-guardrails/deny-disable-security-services.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@
"guardduty:DisassociateFromMasterAccount",
"guardduty:DisassociateMembers",
"guardduty:DisableOrganizationAdminAccount",
"guardduty:UpdateDetector"
"guardduty:UpdateDetector",
"guardduty:DisassociateFromAdministratorAccount",
"guardduty:DeleteMembers",
"guardduty:StopMonitoringMembers"
],
"Resource": "*"
},
Expand All @@ -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": "*"
}
Expand Down
9 changes: 9 additions & 0 deletions terraform/scp-guardrails/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ locals {
"guardduty:DisassociateMembers",
"guardduty:DisableOrganizationAdminAccount",
"guardduty:UpdateDetector",
"guardduty:DisassociateFromAdministratorAccount",
"guardduty:DeleteMembers",
"guardduty:StopMonitoringMembers",
]
Resource = "*"
},
Expand All @@ -55,6 +58,12 @@ locals {
"securityhub:DisableSecurityHub",
"securityhub:DisableImportFindingsForProduct",
"securityhub:DeleteInsight",
"securityhub:BatchDisableStandards",
"securityhub:DisassociateFromAdministratorAccount",
"securityhub:DisassociateFromMasterAccount",
"securityhub:DisassociateMembers",
"securityhub:DeleteMembers",
"securityhub:DisableOrganizationAdminAccount",
]
Resource = "*"
},
Expand Down
5 changes: 5 additions & 0 deletions terraform/wiz-finding-bridge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 12 additions & 3 deletions terraform/wiz-finding-bridge/lambda/wiz_webhook_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
import base64
import logging
import re
import time

import boto3
from botocore.exceptions import ClientError
Expand Down Expand Up @@ -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


Expand Down
Loading