Compare commits

..

6 Commits

Author SHA1 Message Date
Jon Chery 986171a165 docs(P11): complete schema-input-validation-hardening phase (v1.13.14)
---ci---
project: acdl
phase: 11
milestone: v1.14
status: complete
requirements:
  covered: [REQ-145]
  partial: []
---/ci---
2026-07-29 20:57:56 +00:00
Jon Chery 099ed015ac docs(P10): complete contract-ingestor-identity-validation phase (v1.13.13)
---ci---
project: acdl
phase: 10
milestone: v1.14
status: complete
requirements:
  covered: [REQ-144]
  partial: []
---/ci---
2026-07-29 20:52:42 +00:00
Jon Chery cc97a9308d docs(P09): complete iam-policy-least-privilege phase (v1.13.12)
---ci---
project: acdl
phase: 9
milestone: v1.14
status: complete
requirements:
  covered: [REQ-143]
  partial: []
---/ci---
2026-07-29 20:49:36 +00:00
Jon Chery c2ca0e4631 docs(P08): complete account-id-externalization phase (v1.13.11)
---ci---
project: acdl
phase: 8
milestone: v1.14
status: complete
requirements:
  covered: [REQ-142]
  partial: []
---/ci---
2026-07-29 20:46:28 +00:00
Jon Chery 225de0f613 docs(P07): complete swallowed-error-hardening phase (v1.13.10)
---ci---
project: acdl
phase: 7
milestone: v1.14
status: complete
requirements:
  covered: [REQ-141]
  partial: []
---/ci---
2026-07-29 20:43:08 +00:00
Jon Chery 69d8496107 docs(P06): complete alb-name-prefix-fix phase (v1.13.9)
---ci---
project: acdl
phase: 6
milestone: v1.14
status: complete
requirements:
  covered: [REQ-140]
  partial: []
---/ci---
2026-07-29 20:38:14 +00:00
15 changed files with 235 additions and 44 deletions
+4 -2
View File
@@ -112,6 +112,8 @@ def adapt(stack_instance, out_dir):
stack_name = stack.get("name", "spike") stack_name = stack.get("name", "spike")
environment = stack.get("environment", "dev") environment = stack.get("environment", "dev")
account_id = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
state_bucket = f"acdl-tfstate-{account_id}-us-east-1"
terraform_tf = ( terraform_tf = (
'terraform {\n' 'terraform {\n'
' required_version = ">= 1.9, < 1.10"\n' ' required_version = ">= 1.9, < 1.10"\n'
@@ -122,7 +124,7 @@ def adapt(stack_instance, out_dir):
' }\n' ' }\n'
' }\n' ' }\n'
' backend "s3" {\n' ' backend "s3" {\n'
' bucket = "acdl-tfstate-581513795199-us-east-1"\n' f' bucket = "{state_bucket}"\n'
f' key = "spike/{stack_name}/{environment}/terraform.tfstate"\n' f' key = "spike/{stack_name}/{environment}/terraform.tfstate"\n'
' region = "us-east-1"\n' ' region = "us-east-1"\n'
' }\n' ' }\n'
@@ -137,7 +139,7 @@ def adapt(stack_instance, out_dir):
'data "terraform_remote_state" "platform" {\n' 'data "terraform_remote_state" "platform" {\n'
' backend = "s3"\n' ' backend = "s3"\n'
' config = {\n' ' config = {\n'
' bucket = "acdl-tfstate-581513795199-us-east-1"\n' f' bucket = "{state_bucket}"\n'
f' key = "{remote_state_key}"\n' f' key = "{remote_state_key}"\n'
' region = "us-east-1"\n' ' region = "us-east-1"\n'
' }\n' ' }\n'
+42 -11
View File
@@ -17,6 +17,7 @@ requests. The invoke policy is scoped via ABAC (consumer repo identity).
import datetime import datetime
import json import json
import os import os
import urllib.error
import urllib.parse import urllib.parse
import boto3 import boto3
@@ -154,7 +155,16 @@ def _report_error(payload):
with urllib.request.urlopen(req, timeout=10) as resp: with urllib.request.urlopen(req, timeout=10) as resp:
search_result = json.loads(resp.read()) search_result = json.loads(resp.read())
existing = search_result.get("items", []) existing = search_result.get("items", [])
except Exception: except urllib.error.HTTPError as e:
if e.code == 404:
existing = []
else:
import sys
print(f"WARNING: GitHub issue search failed (HTTP {e.code}): {e}", file=sys.stderr)
existing = []
except urllib.error.URLError as e:
import sys
print(f"WARNING: GitHub issue search network error: {e}", file=sys.stderr)
existing = [] existing = []
body = f"""## Deploy Failure Report body = f"""## Deploy Failure Report
@@ -228,21 +238,42 @@ def _validate_caller_identity(event, payload):
If the identity is not available (e.g. local testing or non-IAM auth), the If the identity is not available (e.g. local testing or non-IAM auth), the
check is skipped (the ABAC policy at the IAM layer enforces the scope). check is skipped (the ABAC policy at the IAM layer enforces the scope).
v1.14 (REQ-144): also validates contractId format, environment enum, and
error length. The ABAC reliance is documented here: the Function URL IAM
identity does not expose principal tags in the event, so full enforcement
of consumerRepo ownership is at the IAM layer (ABAC via
aws:PrincipalTag/acdl:owner). This function validates format only, not
ownership.
""" """
identity = event.get("requestContext", {}).get("identity", {}) identity = event.get("requestContext", {}).get("identity", {})
caller_arn = identity.get("userArn", "") caller_arn = identity.get("userArn", "")
if not caller_arn: if not caller_arn:
return # no identity available — rely on IAM ABAC enforcement pass # no identity available — rely on IAM ABAC enforcement
payload_repo = payload.get("consumerRepo", "") payload_repo = payload.get("consumerRepo", "")
if not payload_repo: if payload_repo:
return # consumerRepo must be org/repo format, <=128 chars
# Extract the session name or principal tag from the ARN. The ABAC policy if "/" not in payload_repo or len(payload_repo) > 128:
# scopes via aws:PrincipalTag/acdl:owner = <consumerRepo>. The Function URL raise ValueError(f"invalid consumerRepo format: {payload_repo!r}")
# IAM identity does not expose principal tags in the event, so we do a
# best-effort check: the consumerRepo must not be empty and must be a valid # v1.14 (REQ-144): contractId format validation
# repo identifier (org/repo format). Full enforcement is at the IAM layer. contract_id = payload.get("contractId", "")
if "/" not in payload_repo or len(payload_repo) > 128: if contract_id:
raise ValueError(f"invalid consumerRepo format: {payload_repo!r}") import re
if not re.match(r'^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$', contract_id):
raise ValueError(f"invalid contractId format: {contract_id!r} (alphanumeric, hyphen, underscore; max 64 chars)")
# v1.14 (REQ-144): environment enum validation
environment = payload.get("environment", "")
if environment:
valid_envs = {"dev", "qa", "prod", "dr"}
if environment not in valid_envs:
raise ValueError(f"invalid environment: {environment!r} (must be one of {valid_envs})")
# v1.14 (REQ-144): error length cap (for report_error action)
error_msg = payload.get("error", "")
if error_msg and len(str(error_msg)) > 10000:
payload["error"] = str(error_msg)[:10000]
def _validate_change_request(payload): def _validate_change_request(payload):
+3 -2
View File
@@ -371,8 +371,9 @@ class LocalLambdaStub:
return _FakeResponse( return _FakeResponse(
json.dumps([{"number": 1, "title": "stub"}]).encode()) json.dumps([{"number": 1, "title": "stub"}]).encode())
urllib.request.urlopen = _fake_urlopen urllib.request.urlopen = _fake_urlopen
except Exception: except (AttributeError, TypeError) as e:
pass import sys
print(f"WARNING: could not patch urlopen for local Lambda stub: {e}", file=sys.stderr)
try: try:
event = { event = {
+7 -3
View File
@@ -97,8 +97,10 @@ def publish_to_ssm(outputs, environment, contract_id):
Overwrite=True, Overwrite=True,
) )
results[name] = param_name results[name] = param_name
except Exception: except Exception as e:
# Don't fail the pipeline if one output fails to publish # Don't fail the pipeline if one output fails to publish, but log it
import sys
print(f"WARNING: SSM put_parameter failed for {name}: {e}", file=sys.stderr)
results[name] = None results[name] = None
return results return results
@@ -165,7 +167,9 @@ def post_github_comment(comment_text, token=None, repo=None, pr_number=None):
req.add_header("Accept", "application/vnd.github+json") req.add_header("Accept", "application/vnd.github+json")
urllib.request.urlopen(req, timeout=10) urllib.request.urlopen(req, timeout=10)
return True return True
except Exception: except Exception as e:
import sys
print(f"WARNING: GitHub PR comment failed: {e}", file=sys.stderr)
return False return False
+4 -2
View File
@@ -421,8 +421,10 @@ def _check_s3_state_bucket() -> Tuple[Status, str]:
s3 = boto3.client("s3", region_name=env.get("AWS_DEFAULT_REGION", "us-east-1"), s3 = boto3.client("s3", region_name=env.get("AWS_DEFAULT_REGION", "us-east-1"),
aws_access_key_id=env.get("AWS_ACCESS_KEY_ID"), aws_access_key_id=env.get("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=env.get("AWS_SECRET_ACCESS_KEY")) aws_secret_access_key=env.get("AWS_SECRET_ACCESS_KEY"))
s3.head_bucket(Bucket="acdl-tfstate-581513795199-us-east-1") account_id = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
r = s3.list_objects_v2(Bucket="acdl-tfstate-581513795199-us-east-1", MaxKeys=5) state_bucket = f"acdl-tfstate-{account_id}-us-east-1"
s3.head_bucket(Bucket=state_bucket)
r = s3.list_objects_v2(Bucket=state_bucket, MaxKeys=5)
keys = [o["Key"] for o in r.get("Contents", [])] keys = [o["Key"] for o in r.get("Contents", [])]
return "Verified", f"state bucket exists, keys={keys}" return "Verified", f"state bucket exists, keys={keys}"
except Exception as e: except Exception as e:
+1 -1
View File
@@ -6,7 +6,7 @@ resource "aws_lb" "this" {
} }
resource "aws_lb_target_group" "this" { resource "aws_lb_target_group" "this" {
name_prefix = "tg-ci-" name_prefix = "${var.name}-"
port = var.port port = var.port
protocol = var.protocol protocol = var.protocol
vpc_id = var.vpc_id vpc_id = var.vpc_id
+1 -1
View File
@@ -18,7 +18,7 @@
"wires": [ "wires": [
{"from": "contract.inputs.name", "to": "alb.inputs.name", "default": "app"}, {"from": "contract.inputs.name", "to": "alb.inputs.name", "default": "app"},
{"from": "contract.inputs.name", "to": "ecr.inputs.name", "default": "app-repo"}, {"from": "contract.inputs.name", "to": "ecr.inputs.name", "default": "app-repo"},
{"from": "contract.inputs.name", "to": "roles.inputs.role_name", "default": "app-role"}, {"from": "contract.inputs.name", "to": "roles.inputs.role_name", "default": "acdl-app-role"},
{"from": "contract.inputs.region", "to": "cluster.inputs.region"}, {"from": "contract.inputs.region", "to": "cluster.inputs.region"},
{"from": "contract.inputs.region", "to": "ecr.inputs.region"}, {"from": "contract.inputs.region", "to": "ecr.inputs.region"},
{"from": "contract.inputs.region", "to": "roles.inputs.region"}, {"from": "contract.inputs.region", "to": "roles.inputs.region"},
+10 -6
View File
@@ -27,20 +27,23 @@
"type": "object", "type": "object",
"required": ["bucket", "lock_table"], "required": ["bucket", "lock_table"],
"properties": { "properties": {
"bucket": {"type": "string", "description": "S3 state bucket name."}, "bucket": {"type": "string", "pattern": "^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", "description": "S3 state bucket name (lowercase, 3-63 chars, dots/hyphens)."},
"lock_table": {"type": "string", "description": "DynamoDB lock table name."} "lock_table": {"type": "string", "description": "DynamoDB lock table name."}
} },
"additionalProperties": false
}, },
"network": { "network": {
"type": "object", "type": "object",
"required": ["vpc_cidr", "azs"], "required": ["vpc_cidr", "azs"],
"properties": { "properties": {
"vpc_cidr": {"type": "string", "description": "VPC CIDR block."}, "vpc_cidr": {"type": "string", "pattern": "^[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}/[0-9]{1,2}$", "description": "VPC CIDR block (e.g. 10.0.0.0/16)."},
"azs": {"type": "array", "items": {"type": "string"}, "description": "Availability zones."} "azs": {"type": "array", "items": {"type": "string"}, "maxItems": 6, "description": "Availability zones (max 6)."}
} },
"additionalProperties": false
}, },
"runner_role_arn": { "runner_role_arn": {
"type": "string", "type": "string",
"pattern": "^arn:aws:iam::[0-9]{12}:role/.+$",
"description": "The IAM role ARN surfaced to the consumer's repo via ABAC." "description": "The IAM role ARN surfaced to the consumer's repo via ABAC."
}, },
"autonomy": { "autonomy": {
@@ -54,5 +57,6 @@
"maximum": 1, "maximum": 1,
"description": "The confidence gate threshold for this environment (dev 0.50, qa 0.75, prod 0.90, dr 0.95)." "description": "The confidence gate threshold for this environment (dev 0.50, qa 0.75, prod 0.90, dr 0.95)."
} }
} },
"additionalProperties": false
} }
+1 -1
View File
@@ -29,7 +29,7 @@ import boto3
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
ENV_FILE = REPO_ROOT / ".env.secrets" ENV_FILE = REPO_ROOT / ".env.secrets"
AWS_ACCOUNT_ID = "581513795199" AWS_ACCOUNT_ID = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
AWS_REGION = "us-east-1" AWS_REGION = "us-east-1"
ECR_REPO_NAME = "acdl-microservice" ECR_REPO_NAME = "acdl-microservice"
IMAGE_TAG = "latest" IMAGE_TAG = "latest"
+4 -2
View File
@@ -30,7 +30,7 @@ import boto3
ROOT = Path(__file__).resolve().parent.parent.parent ROOT = Path(__file__).resolve().parent.parent.parent
POLICY_PATH = ROOT / "terraform" / "bootstrap" / "spike_runner_policy.json" POLICY_PATH = ROOT / "terraform" / "bootstrap" / "spike_runner_policy.json"
ACCOUNT = "581513795199" ACCOUNT = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
USER = "acdl-spike-runner" USER = "acdl-spike-runner"
POLICY_NAME = "acdl-spike-runner-policy" POLICY_NAME = "acdl-spike-runner-policy"
POLICY_ARN = f"arn:aws:iam::{ACCOUNT}:policy/{POLICY_NAME}" POLICY_ARN = f"arn:aws:iam::{ACCOUNT}:policy/{POLICY_NAME}"
@@ -75,8 +75,10 @@ def apply_managed_policy(iam, policy_doc: str) -> str:
try: try:
iam.delete_policy_version(PolicyArn=POLICY_ARN, VersionId=default) iam.delete_policy_version(PolicyArn=POLICY_ARN, VersionId=default)
print(f"deleted old default version {default}") print(f"deleted old default version {default}")
except iam.exceptions.NoSuchEntityException:
pass # already deleted
except Exception as e: except Exception as e:
print(f"could not delete old version {default}: {e}") print(f"WARNING: could not delete old version {default}: {e}")
return POLICY_ARN return POLICY_ARN
except iam.exceptions.NoSuchEntityException: except iam.exceptions.NoSuchEntityException:
print(f"creating managed policy {POLICY_NAME}...") print(f"creating managed policy {POLICY_NAME}...")
+12 -8
View File
@@ -30,9 +30,9 @@ import boto3
REGION = os.environ.get("AWS_DEFAULT_REGION", "us-east-1") REGION = os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
STATE_BUCKET = "acdl-tfstate-581513795199-us-east-1" ACCOUNT_ID = os.environ.get("ACDL_AWS_ACCOUNT_ID", "581513795199")
STATE_BUCKET = f"acdl-tfstate-{ACCOUNT_ID}-us-east-1"
OUTBOX_TABLE = "acdl-outbox" OUTBOX_TABLE = "acdl-outbox"
ACCOUNT_ID = "581513795199"
def main(): def main():
@@ -48,12 +48,16 @@ def main():
try: try:
s3.head_bucket(Bucket=STATE_BUCKET) s3.head_bucket(Bucket=STATE_BUCKET)
print(f"s3: bucket {STATE_BUCKET} already exists") print(f"s3: bucket {STATE_BUCKET} already exists")
except Exception: except s3.exceptions.ClientError as e:
kwargs = {"Bucket": STATE_BUCKET} error_code = e.response.get("Error", {}).get("Code", "")
if REGION != "us-east-1": if error_code in ("404", "NoSuchBucket", "NotFound"):
kwargs["CreateBucketConfiguration"] = {"LocationConstraint": REGION} kwargs = {"Bucket": STATE_BUCKET}
s3.create_bucket(**kwargs) if REGION != "us-east-1":
print(f"s3: created bucket {STATE_BUCKET}") kwargs["CreateBucketConfiguration"] = {"LocationConstraint": REGION}
s3.create_bucket(**kwargs)
print(f"s3: created bucket {STATE_BUCKET}")
else:
raise
# Enable versioning (idempotent) # Enable versioning (idempotent)
s3.put_bucket_versioning( s3.put_bucket_versioning(
Bucket=STATE_BUCKET, Bucket=STATE_BUCKET,
+5 -2
View File
@@ -215,7 +215,10 @@
"kms:TagResource", "kms:TagResource",
"kms:UntagResource" "kms:UntagResource"
], ],
"Resource": "*" "Resource": [
"arn:aws:kms:*:*:key/*",
"arn:aws:kms:*:*:alias/acdl-*"
]
}, },
{ {
"Effect": "Allow", "Effect": "Allow",
@@ -233,7 +236,7 @@
"iam:TagRole", "iam:TagRole",
"iam:UntagRole" "iam:UntagRole"
], ],
"Resource": "*" "Resource": "arn:aws:iam::*:role/acdl-*"
} }
] ]
} }
+40 -1
View File
@@ -500,4 +500,43 @@ class TestValidateChangeRequest:
resp = ingestor.lambda_handler(event, None) resp = ingestor.lambda_handler(event, None)
assert resp["statusCode"] == 200 assert resp["statusCode"] == 200
body = json.loads(resp["body"]) body = json.loads(resp["body"])
assert body["action"] == "validate_change_request" assert body["action"] == "validate_change_request"
class TestV14IdentityValidation:
"""v1.14 (REQ-144): contractId format, environment enum, error length
validation + spoofing resistance."""
def test_invalid_contract_id_rejected(self, moto_contracts_table, sample_payload):
sample_payload["contractId"] = "bad contract!@#"
event = {"body": json.dumps(sample_payload), "requestContext": {}}
resp = ingestor.lambda_handler(event, None)
assert resp["statusCode"] == 400
assert "invalid contractId" in resp["body"]
def test_contract_id_too_long_rejected(self, moto_contracts_table, sample_payload):
sample_payload["contractId"] = "a" * 65
event = {"body": json.dumps(sample_payload), "requestContext": {}}
resp = ingestor.lambda_handler(event, None)
assert resp["statusCode"] == 400
assert "invalid contractId" in resp["body"]
def test_invalid_environment_rejected(self, moto_contracts_table, sample_payload):
sample_payload["environment"] = "staging"
event = {"body": json.dumps(sample_payload), "requestContext": {}}
resp = ingestor.lambda_handler(event, None)
assert resp["statusCode"] == 400
assert "invalid environment" in resp["body"]
def test_valid_environments_accepted(self, moto_contracts_table, sample_payload):
for env in ["dev", "qa", "prod", "dr"]:
sample_payload["environment"] = env
event = {"body": json.dumps(sample_payload), "requestContext": {}}
resp = ingestor.lambda_handler(event, None)
assert resp["statusCode"] == 200
def test_abac_reliance_documented(self):
"""The _validate_caller_identity docstring documents the ABAC reliance."""
docstring = ingestor._validate_caller_identity.__doc__
assert "ABAC" in docstring
assert "PrincipalTag" in docstring
+61 -1
View File
@@ -96,4 +96,64 @@ def test_account_id_is_12_digits():
for env_file in ENV_FILES: for env_file in ENV_FILES:
env = json.loads((ENV_DIR / env_file).read_text()) env = json.loads((ENV_DIR / env_file).read_text())
assert len(env["account_id"]) == 12 assert len(env["account_id"]) == 12
assert env["account_id"].isdigit() assert env["account_id"].isdigit()
def test_v14_schema_rejects_undocumented_fields():
"""v1.14 (REQ-145): additionalProperties: false rejects unknown fields."""
schema = json.loads(SCHEMA.read_text())
bad_env = {
"name": "dev",
"account_id": "123456789012",
"region": "us-east-1",
"state_backend": {"bucket": "test", "lock_table": "test"},
"network": {"vpc_cidr": "10.0.0.0/16", "azs": ["us-east-1a"]},
"runner_role_arn": "arn:aws:iam::123456789012:role/test",
"autonomy": "full",
"confidence_threshold": 0.5,
"rogue_field": "should be rejected"
}
with pytest.raises(jsonschema.ValidationError, match="Additional properties are not allowed"):
jsonschema.validate(bad_env, schema)
def test_v14_schema_validates_bucket_name_format():
"""v1.14 (REQ-145): state_backend.bucket must match S3 naming rules."""
schema = json.loads(SCHEMA.read_text())
bad_env = {
"name": "dev", "account_id": "123456789012", "region": "us-east-1",
"state_backend": {"bucket": "Invalid_Bucket!", "lock_table": "test"},
"network": {"vpc_cidr": "10.0.0.0/16", "azs": ["us-east-1a"]},
"runner_role_arn": "arn:aws:iam::123456789012:role/test",
"autonomy": "full", "confidence_threshold": 0.5
}
with pytest.raises(jsonschema.ValidationError, match="does not match"):
jsonschema.validate(bad_env, schema)
def test_v14_schema_validates_arn_format():
"""v1.14 (REQ-145): runner_role_arn must match ARN format."""
schema = json.loads(SCHEMA.read_text())
bad_env = {
"name": "dev", "account_id": "123456789012", "region": "us-east-1",
"state_backend": {"bucket": "test", "lock_table": "test"},
"network": {"vpc_cidr": "10.0.0.0/16", "azs": ["us-east-1a"]},
"runner_role_arn": "not-an-arn",
"autonomy": "full", "confidence_threshold": 0.5
}
with pytest.raises(jsonschema.ValidationError, match="does not match"):
jsonschema.validate(bad_env, schema)
def test_v14_schema_validates_cidr_format():
"""v1.14 (REQ-145): vpc_cidr must match CIDR format."""
schema = json.loads(SCHEMA.read_text())
bad_env = {
"name": "dev", "account_id": "123456789012", "region": "us-east-1",
"state_backend": {"bucket": "test", "lock_table": "test"},
"network": {"vpc_cidr": "not-a-cidr", "azs": ["us-east-1a"]},
"runner_role_arn": "arn:aws:iam::123456789012:role/test",
"autonomy": "full", "confidence_threshold": 0.5
}
with pytest.raises(jsonschema.ValidationError, match="does not match"):
jsonschema.validate(bad_env, schema)
+40 -1
View File
@@ -176,4 +176,43 @@ class TestIAMPolicyBaseline:
res = s.get("Resource", "") res = s.get("Resource", "")
if isinstance(res, list): if isinstance(res, list):
res = " ".join(res) res = " ".join(res)
assert res != "*", "iam:PassRole must not be granted to Resource: *" assert res != "*", "iam:PassRole must not be granted to Resource: *"
def test_iam_role_creation_scoped_to_acdl_prefix(self, policy):
"""G-104: iam:CreateRole must be scoped to role/acdl-* (not Resource: *)."""
for s in policy["Statement"]:
acts = s.get("Action", [])
if isinstance(acts, str):
acts = [acts]
if "iam:CreateRole" in acts:
res = s.get("Resource", "")
if isinstance(res, list):
res = " ".join(res)
assert "acdl-*" in res, f"iam:CreateRole must be scoped to acdl-* (got: {res})"
def test_kms_scoped_to_acdl_alias(self, policy):
"""G-104: kms:CreateKey etc. must be scoped to alias/acdl-* (not Resource: *)."""
for s in policy["Statement"]:
acts = s.get("Action", [])
if isinstance(acts, str):
acts = [acts]
if any(a.startswith("kms:") for a in acts):
res = s.get("Resource", "")
if isinstance(res, list):
res = " ".join(res)
assert "acdl-*" in res, f"kms actions must be scoped to acdl-* (got: {res})"
def test_cloudfront_waf_remain_global(self, policy):
"""G-104: CloudFront + WAFv2 (CloudFront scope) ARNs are global;
Resource: * is acceptable here (documented constraint, not a defect)."""
global_actions = {"cloudfront:", "wafv2:"}
for s in policy["Statement"]:
acts = s.get("Action", [])
if isinstance(acts, str):
acts = [acts]
if any(any(a.startswith(g) for g in global_actions) for a in acts):
res = s.get("Resource", "")
if isinstance(res, list):
res = res[0] if res else ""
# CloudFront/WAFv2 are allowed to be * (global ARNs)
assert res == "*" or "acdl" in res