aws2-1 Exercise 1: Check SCP Files Against the Documented Syntax, Then Lint the Template That Deploys One ======================================================================================================= Run with Python 3.14.0 and cfn-lint 1.57.2 on Windows 11. VERIFICATION LEVEL: 1 (run here, no AWS account, no AWS CLI). What the scripts check is MY reading of AWS documentation pages read on 2026-10-12 (through a page-fetch tool that summarises pages); it is not AWS's own behaviour, and AWS may change its limits and rules. --- mk_scps.py (writes the 17 sample SCP files into scp/) --- import json, pathlib D = pathlib.Path(r"C:\lwaws\c1\scp"); D.mkdir(exist_ok=True) def w(name, obj, pretty=True): (D / name).write_text(json.dumps(obj, indent=4) if pretty else json.dumps(obj, separators=(",", ":")), encoding="utf-8") def raw(name, text): (D / name).write_text(text, encoding="utf-8") V = "2012-10-17" # ---- should pass: taken from, or modelled on, the AWS documentation pages w("good_default_controls.json", {"Version": V, "Statement": [{"Sid": "DenyLeaveOrganizationAndCloseAccount", "Effect": "Deny", "Action": ["organizations:LeaveOrganization", "account:CloseAccount"], "Resource": "*"}]}) w("good_region_lock.json", {"Version": V, "Statement": [{"Sid": "DenyAllOutsideEU", "Effect": "Deny", "NotAction": ["cloudfront:*", "iam:*", "route53:*", "support:*"], "Resource": "*", "Condition": {"StringNotEquals": {"aws:RequestedRegion": ["eu-central-1", "eu-west-1"]}}}]}) w("good_allow_list.json", {"Version": V, "Statement": [{"Effect": "Allow", "Action": ["ec2:*", "cloudwatch:*", "organizations:*"], "Resource": "*"}, {"Effect": "Deny", "Action": "organizations:LeaveOrganization", "Resource": "*"}]}) w("good_protect_role.json", {"Version": V, "Statement": [{"Sid": "DenyAccessToAdminRole", "Effect": "Deny", "Action": ["iam:AttachRolePolicy", "iam:DeleteRole", "iam:DeleteRolePolicy", "iam:DetachRolePolicy", "iam:PutRolePolicy", "iam:UpdateAssumeRolePolicy"], "Resource": ["arn:aws:iam::*:role/OrgAdmin"]}]}) w("good_single_statement_object.json", {"Version": V, "Statement": {"Effect": "Deny", "Action": ["iam:*", "ec2:*", "rds:*"], "Resource": "*"}}, pretty=False) # ---- should fail: one mistake each w("bad_principal.json", {"Version": V, "Statement": [{"Effect": "Deny", "Principal": "*", "Action": "s3:*", "Resource": "*"}]}) w("bad_allow_with_arn.json", {"Version": V, "Statement": [{"Effect": "Allow", "Action": "s3:*", "Resource": "arn:aws:s3:::my-bucket/*"}]}) w("bad_no_version.json", {"Statement": [{"Effect": "Deny", "Action": "s3:*", "Resource": "*"}]}) w("bad_old_version.json", {"Version": "2008-10-17", "Statement": [{"Effect": "Deny", "Action": "s3:*", "Resource": "*"}]}) w("bad_effect_typo.json", {"Version": V, "Statement": [{"Effect": "Denied", "Action": "s3:*", "Resource": "*"}]}) w("bad_no_resource.json", {"Version": V, "Statement": [{"Effect": "Deny", "Action": "s3:*"}]}) w("bad_action_format.json", {"Version": V, "Statement": [{"Effect": "Deny", "Action": ["s3", "ec2:Describe Instances"], "Resource": "*"}]}) w("bad_empty_statement.json", {"Version": V, "Statement": []}) raw("bad_trailing_comma.json", '{"Version": "2012-10-17", "Statement": [{"Effect": "Deny", "Action": "s3:*", "Resource": "*"},]}') raw("warn_duplicate_key.json", '{"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Effect": "Deny", "Action": "s3:*", "Resource": "*"}]}') # ---- size: find a list length whose compact form fits but whose indented form does not LIMIT = 10240 def stmt(n): return {"Version": V, "Statement": [{"Effect": "Deny", "Action": [f"s3:FakeAction{i:04d}" for i in range(n)], "Resource": "*"}]} n = 1 while len(json.dumps(stmt(n), separators=(",", ":"))) <= LIMIT: n += 1 n -= 1 # largest n whose compact form still fits assert len(json.dumps(stmt(n), indent=4)) > LIMIT w("size_fits_only_compact.json", stmt(n)) w("size_too_big.json", stmt(n + 40), pretty=False) print("n =", n, "compact", len(json.dumps(stmt(n), separators=(",", ":"))), "indented", len(json.dumps(stmt(n), indent=4))) --- scp_lint.py --- """scp_lint.py - check SCP files against the rules written on AWS's SCP syntax page. This is MY reading of the documentation, not AWS's validator. It cannot know whether an action name exists, and AWS may reject things this script accepts. Rules and the page that states each one: R1 valid JSON (duplicate keys are only warned about: the docs do not say what AWS does) R2 Version must be "2012-10-17" (SCP syntax: Version) R3 Statement is an object or a non-empty array (SCP syntax: Statement) R4 Effect is Allow or Deny (SCP syntax: Effect) R5 Action or NotAction is present (SCP syntax: Action and NotAction) R6 Resource or NotResource is present (SCP syntax: Resource and NotResource) R7 Principal and NotPrincipal are not allowed (SCP syntax: Unsupported elements) R8 an Allow statement may use only "*" as Resource (SCP syntax: Resource and NotResource) R9 action strings look like service:Action (or *) (SCP syntax: Action; format only) R10 size in characters, as written and with whitespace removed, against the quoted limit """ import json, re, sys, pathlib LIMIT = 10240 # "Service control policies: 10,240 characters" (Organizations quotas page) ACTION_RE = re.compile(r"^(\*|[A-Za-z0-9*?-]+:[A-Za-z0-9*?_]+)$") def dup_hook(pairs): seen, out = set(), {} for k, v in pairs: if k in seen: DUPS.append(k) seen.add(k) out[k] = v return out def as_list(x): return x if isinstance(x, list) else [x] def lint(text): """return (errors, warnings, info)""" global DUPS DUPS = [] errs, warns = [], [] try: doc = json.loads(text, object_pairs_hook=dup_hook) except json.JSONDecodeError as e: return [f"R1 not valid JSON: {e.msg} (line {e.lineno} column {e.colno})"], [], "" if DUPS: warns.append(f"R1 duplicate key(s) {sorted(set(DUPS))}: docs do not say how AWS treats them") compact = json.dumps(doc, separators=(",", ":"), ensure_ascii=False) info = f"{len(text)} chars as written, {len(compact)} compact" if len(text) > LIMIT and len(compact) <= LIMIT: warns.append(f"R10 {len(text)} chars as written is over {LIMIT}, but {len(compact)} without whitespace fits: " "console strips whitespace, CLI/SDK saves it as given") elif len(compact) > LIMIT: errs.append(f"R10 {len(compact)} chars even without whitespace is over the {LIMIT} limit") if not isinstance(doc, dict): return errs + ["R3 top level is not an object"], warns, info if doc.get("Version") != "2012-10-17": errs.append(f"R2 Version is {doc.get('Version')!r}, must be '2012-10-17'") st = doc.get("Statement") if isinstance(st, dict): sts = [st] elif isinstance(st, list) and st: sts = st else: return errs + ["R3 Statement missing, empty or not an object/array"], warns, info for i, s in enumerate(sts): who = f"statement {i + 1}" + (f" ({s['Sid']})" if isinstance(s, dict) and 'Sid' in s else "") if not isinstance(s, dict): errs.append(f"R3 {who} is not an object") continue eff = s.get("Effect") if eff not in ("Allow", "Deny"): errs.append(f"R4 {who}: Effect is {eff!r}") if "Action" not in s and "NotAction" not in s: errs.append(f"R5 {who}: no Action or NotAction") if "Resource" not in s and "NotResource" not in s: errs.append(f"R6 {who}: no Resource or NotResource") for bad in ("Principal", "NotPrincipal"): if bad in s: errs.append(f"R7 {who}: {bad} is not supported in SCPs") if eff == "Allow" and "Resource" in s and as_list(s["Resource"]) != ["*"]: errs.append(f"R8 {who}: Allow with Resource {s['Resource']!r}; only '*' is allowed") for key in ("Action", "NotAction"): for a in as_list(s.get(key, [])): if not isinstance(a, str) or not ACTION_RE.match(a): errs.append(f"R9 {who}: {key} value {a!r} is not service:Action") return errs, warns, info if __name__ == "__main__": for p in sys.argv[1:]: errs, warns, info = lint(pathlib.Path(p).read_text(encoding="utf-8")) print(f"{'FAIL' if errs else 'PASS'} {pathlib.Path(p).name:<28} {info}") for m in errs: print(" error:", m) for m in warns: print(" warn: ", m) --- py -X utf8 scp_lint.py scp/*.json --- FAIL bad_action_format.json 237 chars as written, 112 compact error: R9 statement 1: Action value 's3' is not service:Action error: R9 statement 1: Action value 'ec2:Describe Instances' is not service:Action FAIL bad_allow_with_arn.json 189 chars as written, 111 compact error: R8 statement 1: Allow with Resource 'arn:aws:s3:::my-bucket/*'; only '*' is allowed FAIL bad_effect_typo.json 167 chars as written, 89 compact error: R4 statement 1: Effect is 'Denied' FAIL bad_empty_statement.json 52 chars as written, 39 compact error: R3 Statement missing, empty or not an object/array FAIL bad_no_resource.json 136 chars as written, 72 compact error: R6 statement 1: no Resource or NotResource FAIL bad_no_version.json 136 chars as written, 64 compact error: R2 Version is None, must be '2012-10-17' FAIL bad_old_version.json 165 chars as written, 87 compact error: R2 Version is '2008-10-17', must be '2012-10-17' FAIL bad_principal.json 195 chars as written, 103 compact error: R7 statement 1: Principal is not supported in SCPs FAIL bad_trailing_comma.json error: R1 not valid JSON: Illegal trailing comma before end of array (line 1 column 94) PASS good_allow_list.json 402 chars as written, 200 compact PASS good_default_controls.json 323 chars as written, 184 compact PASS good_protect_role.json 513 chars as written, 276 compact PASS good_region_lock.json 570 chars as written, 242 compact PASS good_single_statement_object.json 104 chars as written, 104 compact PASS size_fits_only_compact.json 18932 chars as written, 10222 compact warn: R10 18932 chars as written is over 10240, but 10222 without whitespace fits: console strips whitespace, CLI/SDK saves it as given FAIL size_too_big.json 11022 chars as written, 11022 compact error: R10 11022 chars even without whitespace is over the 10240 limit PASS warn_duplicate_key.json 114 chars as written, 87 compact warn: R1 duplicate key(s) ['Effect']: docs do not say how AWS treats them --- scp_stack.yaml (the template, passes cfn-lint) --- AWSTemplateFormatVersion: "2010-09-09" Description: Attach the default-controls SCP (deny LeaveOrganization and CloseAccount) to a target. Parameters: TargetId: Type: String Description: Root, OU or account ID the SCP is attached to AllowedPattern: "^(r-[0-9a-z]{4,32}|ou-[0-9a-z]{4,32}-[0-9a-z]{8,32}|[0-9]{12})$" Resources: DenyLeaveAndClose: Type: AWS::Organizations::Policy Properties: Name: deny-leave-and-close Description: Member accounts cannot leave the organization or close themselves Type: SERVICE_CONTROL_POLICY TargetIds: - !Ref TargetId Content: Version: "2012-10-17" Statement: - Sid: DenyLeaveOrganizationAndCloseAccount Effect: Deny Action: - organizations:LeaveOrganization - account:CloseAccount Resource: "*" --- lint_templates.py (lints the template and six broken copies) --- """Lint the SCP template, then six deliberately broken copies, with cfn-lint. Prints cfn-lint's own words.""" import pathlib, subprocess, sys B = pathlib.Path(r"C:\lwaws\c1") base = (B / "scp_stack.yaml").read_text(encoding="utf-8") variants = { "OK (unchanged)": base, "policy Type misspelt": base.replace("Type: SERVICE_CONTROL_POLICY", "Type: SERVICE_CONTROL_POLICIES"), "Name removed": base.replace(" Name: deny-leave-and-close\n", ""), "TargetIds removed": base.replace(" TargetIds:\n - !Ref TargetId\n", ""), "Content is a string": base.split(" Content:")[0] + " Content: 'not a policy'\n", "Principal inside Content": base.replace(" - Sid: Deny", " - Principal: '*'\n Sid: Deny"), "Effect misspelt in Content": base.replace("Effect: Deny", "Effect: Denied"), } for label, text in variants.items(): f = B / "tmpl_variant.yaml" f.write_text(text, encoding="utf-8") r = subprocess.run([str(B / ".venv" / "Scripts" / "cfn-lint.exe"), str(f)], capture_output=True, text=True, encoding="utf-8") out = (r.stdout + r.stderr).strip().replace(str(f), "tmpl_variant.yaml") print(f"=== {label}: exit status {r.returncode}") print(out if out else "(no findings)") --- py -X utf8 lint_templates.py --- === OK (unchanged): exit status 0 (no findings) === policy Type misspelt: exit status 2 E3030 'SERVICE_CONTROL_POLICIES' is not one of ['AISERVICES_OPT_OUT_POLICY', 'BACKUP_POLICY', 'BEDROCK_POLICY', 'CHATBOT_POLICY', 'DECLARATIVE_POLICY_EC2', 'INSPECTOR_POLICY', 'NETWORK_SECURITY_DIRECTOR_POLICY', 'RESOURCE_CONTROL_POLICY', 'S3_POLICY', 'SECURITYHUB_POLICY', 'SERVICE_CONTROL_POLICY', 'TAG_POLICY', 'UPGRADE_ROLLOUT_POLICY'] tmpl_variant.yaml:14:7 === Name removed: exit status 2 E3003 'Name' is a required property tmpl_variant.yaml:11:5 === TargetIds removed: exit status 4 W2001 Parameter TargetId not used. tmpl_variant.yaml:4:3 === Content is a string: exit status 0 (no findings) === Principal inside Content: exit status 0 (no findings) === Effect misspelt in Content: exit status 0 (no findings) WHY THIS WORKS AS AN ANSWER --------------------------- Each rule in the checker is tied to a sentence on an AWS page (the list is at the top of scp_lint.py), so a failure points at a documented rule rather than at a guess. The 17 files cover one mistake per file, so a failure names its cause; the five good files, three from the documentation's own examples, show it does not reject valid policy. Two edge cases matter in practice: a policy of 18,932 characters as written that is 10,222 without whitespace (the console strips whitespace, the CLI does not, per the quotas page), and a duplicate key, which the checker only warns about because the documentation does not say how AWS treats it. The second half is a finding rather than a success: cfn-lint 1.57.2 caught the misspelt policy type and the missing Name, hinted at the missing TargetIds only through an unused-parameter warning, and passed (exit status 0) a Content that was a string, one with a Principal and one with Effect: Denied. So the template linter and the policy checker do different jobs and you need both. Not run: AWS's own validation (aws organizations create-policy), other cfn-lint versions, checking that action names exist, Condition keys and their operators, and deployment.