aws2-11 Exercise 3: A Delivery Template, a Rule Checker, Parameter Store, CloudTrail Records and Cost ======================================================================== Run with Windows 11, Python 3.14 (py launcher) and a venv with actionlint-py 1.7.12 (actionlint 1.7.12), check-jsonschema 0.38.2, cfn-lint 1.57.2, aws-sam-translator 1.114.0, moto 5.2.4 (in-process, mock_aws), boto3 1.43.112, PyYAML 6.0.3. Scratch folder C:/lwaws/c11, nothing installed globally, Docker not used. VERIFICATION LEVEL: 1 (no AWS account, no GitHub repository wired to AWS, nothing deployed or run in a real pipeline); statements about the real services come from the documentation read on 2026-10-13 and are marked as such in the chapter. Outputs below are saved from the real runs of 2026-10-13. Set AWS_DEFAULT_REGION=eu-west-1 and AWS_EC2_METADATA_DISABLED=true before the SAM translator scripts. --- pipeline.yaml --- AWSTemplateFormatVersion: "2010-09-09" Description: Delivery for one repository - GitHub OIDC deploy role and a CodePipeline/CodeBuild path (chapter 11) Parameters: GitHubRepo: Type: String Default: acme/shop AllowedPattern: "[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+" GitHubConnectionArn: Type: String Default: arn:aws:codestar-connections:eu-west-2:123456789012:connection/11111111-2222-3333-4444-555555555555 Resources: GitHubOidc: Type: AWS::IAM::OIDCProvider Properties: Url: https://token.actions.githubusercontent.com ClientIdList: - sts.amazonaws.com DeployRole: Type: AWS::IAM::Role Properties: MaxSessionDuration: 3600 AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: sts:AssumeRoleWithWebIdentity Principal: Federated: !Ref GitHubOidc Condition: StringEquals: token.actions.githubusercontent.com:aud: sts.amazonaws.com token.actions.githubusercontent.com:sub: !Sub "repo:${GitHubRepo}:environment:production" Policies: - PolicyName: deploy PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: - s3:PutObject - s3:DeleteObject Resource: !Sub "${SiteBucket.Arn}/*" - Effect: Allow Action: s3:ListBucket Resource: !GetAtt SiteBucket.Arn SiteBucket: Type: AWS::S3::Bucket DeletionPolicy: Retain UpdateReplacePolicy: Retain Properties: PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true ArtifactBucket: Type: AWS::S3::Bucket Properties: VersioningConfiguration: Status: Enabled PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true LifecycleConfiguration: Rules: - Id: expire Status: Enabled ExpirationInDays: 30 BuildRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: sts:AssumeRole Principal: Service: codebuild.amazonaws.com Policies: - PolicyName: build PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: - logs:CreateLogGroup - logs:CreateLogStream - logs:PutLogEvents Resource: !Sub "arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/codebuild/*" - Effect: Allow Action: - s3:GetObject - s3:PutObject Resource: !Sub "${ArtifactBucket.Arn}/*" BuildProject: Type: AWS::CodeBuild::Project Properties: ServiceRole: !GetAtt BuildRole.Arn TimeoutInMinutes: 15 Artifacts: Type: CODEPIPELINE Source: Type: CODEPIPELINE BuildSpec: | version: 0.2 phases: build: commands: - echo build artifacts: files: - "**/*" Environment: Type: LINUX_CONTAINER ComputeType: BUILD_GENERAL1_SMALL Image: aws/codebuild/standard:7.0 PrivilegedMode: false PipelineRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: sts:AssumeRole Principal: Service: codepipeline.amazonaws.com Policies: - PolicyName: pipeline PolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Action: - s3:GetObject - s3:PutObject - s3:GetBucketVersioning Resource: - !GetAtt ArtifactBucket.Arn - !Sub "${ArtifactBucket.Arn}/*" - Effect: Allow Action: - codebuild:StartBuild - codebuild:BatchGetBuilds Resource: !GetAtt BuildProject.Arn - Effect: Allow Action: codestar-connections:UseConnection Resource: !Ref GitHubConnectionArn Pipeline: Type: AWS::CodePipeline::Pipeline Properties: PipelineType: V2 RoleArn: !GetAtt PipelineRole.Arn ArtifactStore: Type: S3 Location: !Ref ArtifactBucket Stages: - Name: Source Actions: - Name: GitHub ActionTypeId: Category: Source Owner: AWS Provider: CodeStarSourceConnection Version: "1" Configuration: ConnectionArn: !Ref GitHubConnectionArn FullRepositoryId: !Ref GitHubRepo BranchName: main OutputArtifacts: - Name: src - Name: Build Actions: - Name: Build ActionTypeId: Category: Build Owner: AWS Provider: CodeBuild Version: "1" Configuration: ProjectName: !Ref BuildProject InputArtifacts: - Name: src OutputArtifacts: - Name: built - Name: Approve Actions: - Name: Manual ActionTypeId: Category: Approval Owner: AWS Provider: Manual Version: "1" Outputs: DeployRoleArn: Value: !GetAtt DeployRole.Arn --- pipecheck.py --- """Checks on pipeline.yaml that cfn-lint cannot make. Written BEFORE the mutation run (rules from the pages read on 2026-10-13 and the chapter's own design intent: only the production environment of the named repository may assume the deploy role). check(text) -> list of (rule, message).""" import re, yaml import oidc_trust as O class L(yaml.SafeLoader): pass def _tag(loader, suffix, node): if isinstance(node, yaml.ScalarNode): v = loader.construct_scalar(node) elif isinstance(node, yaml.SequenceNode): v = loader.construct_sequence(node, deep=True) else: v = loader.construct_mapping(node, deep=True) return {"Fn::" + suffix if suffix != "Ref" else "Ref": v} L.add_multi_constructor("!", _tag) ISS = "https://token.actions.githubusercontent.com" def sub_text(s, params): return re.sub(r"\$\{(\w+)\}", lambda m: params.get(m[1], m[0]), s) def resolve(node, params): if isinstance(node, dict): if list(node) == ["Fn::Sub"] and isinstance(node["Fn::Sub"], str): return sub_text(node["Fn::Sub"], params) if list(node) == ["Ref"]: return "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" if node["Ref"] == "GitHubOidc" else params.get(node["Ref"], "?" + node["Ref"]) return {k: resolve(v, params) for k, v in node.items()} if isinstance(node, list): return [resolve(v, params) for v in node] return node def check(text, repo=None): out = [] t = yaml.load(text, Loader=L) R = t["Resources"] params = {k: v.get("Default") for k, v in t.get("Parameters", {}).items()} repo = repo or params["GitHubRepo"] # --- trust policy: behaviour on a fixed set of tokens, intent = only the production environment of this repo role = R["DeployRole"]["Properties"]["AssumeRolePolicyDocument"] trust = resolve(role, params) st = trust["Statement"] mk = lambda **k: {"iss": ISS, "aud": k.pop("aud", "sts.amazonaws.com"), "sub": O.sub_for(**k)} allowed_intent = {"production env": mk(repo=repo, event="push", ref="refs/heads/main", environment="production")} denied_intent = { "main branch, no environment": mk(repo=repo, event="push", ref="refs/heads/main"), "pull request": mk(repo=repo, event="pull_request"), "staging env": mk(repo=repo, event="push", ref="refs/heads/main", environment="staging"), "tag": mk(repo=repo, event="push", ref="refs/tags/v1"), "other repo, same owner": mk(repo=repo.split("/")[0] + "/other", event="push", ref="refs/heads/main", environment="production"), "lookalike repo": mk(repo=repo + "-evil", event="push", ref="refs/heads/main", environment="production"), "other owner, same name": mk(repo="evil/" + repo.split("/")[1], event="push", ref="refs/heads/main", environment="production"), "production env, wrong audience": mk(repo=repo, event="push", ref="refs/heads/main", environment="production", aud="https://github.com/acme"), } try: for n, tok in allowed_intent.items(): if not O.can_assume(trust, tok): out.append(("T1", f"trust policy refuses the intended token: {n}")) for n, tok in denied_intent.items(): if O.can_assume(trust, tok): out.append(("T2", f"trust policy lets this token in: {n}")) except O.NotModelled as e: out.append(("T0", f"trust policy uses something the model does not cover: {e}")) # --- provider: aud must be registered prov = R.get("GitHubOidc", {}).get("Properties", {}) if "sts.amazonaws.com" not in (prov.get("ClientIdList") or []): out.append(("T3", "OIDC provider has no sts.amazonaws.com audience")) # --- permissions for rn, r in R.items(): if r["Type"] != "AWS::IAM::Role": continue for pol in r["Properties"].get("Policies", []): for s in pol["PolicyDocument"]["Statement"]: acts = s["Action"] if isinstance(s["Action"], list) else [s["Action"]] res = s["Resource"] if isinstance(s["Resource"], list) else [s["Resource"]] if any(a == "*" or a.endswith(":*") for a in acts) and "*" in res: out.append(("P1", f"{rn}: wildcard action on Resource *")) elif "*" in res and any(not re.match(r"[a-z-]+:(Get|List|Describe|Batch)", a) for a in acts) and "log-group" not in str(res): out.append(("P2", f"{rn}: write action on Resource *")) # --- containers and buckets for rn, r in R.items(): p = r.get("Properties", {}) if r["Type"] == "AWS::CodeBuild::Project" and p.get("Environment", {}).get("PrivilegedMode"): out.append(("B1", f"{rn}: privileged mode on (Docker daemon access inside the build)")) if r["Type"] == "AWS::S3::Bucket" and not p.get("PublicAccessBlockConfiguration"): out.append(("B2", f"{rn}: no PublicAccessBlockConfiguration")) # --- pipeline wiring pl = R["Pipeline"]["Properties"] seen = set() for stg in pl["Stages"]: for a in stg["Actions"]: for ia in a.get("InputArtifacts", []): if ia["Name"] not in seen: out.append(("W1", f"{stg['Name']}/{a['Name']}: input artifact {ia['Name']!r} was not produced by an earlier action")) for oa in a.get("OutputArtifacts", []): seen.add(oa["Name"]) if a["ActionTypeId"]["Category"] == "Build" and not a.get("InputArtifacts"): out.append(("W2", f"{stg['Name']}/{a['Name']}: build action with no input artifact")) if pl["ArtifactStore"]["Location"] != {"Ref": "ArtifactBucket"}: out.append(("W3", "pipeline artifact store is not the artifact bucket")) if pl["RoleArn"] != {"Fn::GetAtt": "PipelineRole.Arn"} and pl["RoleArn"] != {"Fn::GetAtt": ["PipelineRole", "Arn"]}: out.append(("W4", "pipeline does not use PipelineRole")) pr = R["PipelineRole"]["Properties"]["Policies"][0]["PolicyDocument"]["Statement"] flat = {a for s in pr for a in (s["Action"] if isinstance(s["Action"], list) else [s["Action"]])} if not {"codebuild:StartBuild", "codebuild:BatchGetBuilds"} <= flat: out.append(("W5", "pipeline role cannot start or poll the build")) br = R["BuildRole"]["Properties"]["Policies"][0]["PolicyDocument"]["Statement"] flat = {a for s in br for a in (s["Action"] if isinstance(s["Action"], list) else [s["Action"]])} if not {"logs:CreateLogStream", "logs:PutLogEvents"} <= flat: out.append(("W6", "build role cannot write logs")) return out --- mutate_pipe.py --- """One change at a time in pipeline.yaml: cfn-lint 1.57.2 versus pipecheck.py (written before this run).""" import os, re, subprocess, tempfile import pipecheck B = "C:/lwaws/c11" good = open(f"{B}/pipeline.yaml", encoding="utf-8").read() SUB = ' token.actions.githubusercontent.com:sub: !Sub "repo:${GitHubRepo}:environment:production"\n' M = [ ("sub condition: StringLike repo:acme/* (whole organisation)", SUB, "", True, ("sub", "StringLike", "repo:acme/*")), ("sub condition removed", SUB, "", True, None), ("aud condition removed", " token.actions.githubusercontent.com:aud: sts.amazonaws.com\n", "", True, None), ("aud typo sts.amazon.com", "aud: sts.amazonaws.com", "aud: sts.amazon.com", True, None), ("environment production -> prod", ":environment:production", ":environment:prod", True, None), ("sub is the main branch, not the environment", ":environment:production", ":ref:refs/heads/main", True, None), ("provider ClientIdList has the wrong audience", " - sts.amazonaws.com\n\n DeployRole", " - sts.example.com\n\n DeployRole", True, None), ("trust Action sts:AssumeRole instead of AssumeRoleWithWebIdentity", "Action: sts:AssumeRoleWithWebIdentity", "Action: sts:AssumeRole", True, None), ("Principal Federated Ref to a missing resource", "Federated: !Ref GitHubOidc", "Federated: !Ref NoSuchProvider", True, None), ("deploy policy s3:* on Resource *", " Action:\n - s3:PutObject\n - s3:DeleteObject\n Resource: !Sub \"${SiteBucket.Arn}/*\"", " Action: s3:*\n Resource: \"*\"", True, None), ("CodeBuild PrivilegedMode true", "PrivilegedMode: false", "PrivilegedMode: true", True, None), ("site bucket without PublicAccessBlock", " SiteBucket:\n Type: AWS::S3::Bucket\n DeletionPolicy: Retain\n UpdateReplacePolicy: Retain\n Properties:\n PublicAccessBlockConfiguration:\n BlockPublicAcls: true\n BlockPublicPolicy: true\n IgnorePublicAcls: true\n RestrictPublicBuckets: true\n", " SiteBucket:\n Type: AWS::S3::Bucket\n DeletionPolicy: Retain\n UpdateReplacePolicy: Retain\n", True, None), ("Build input artifact renamed (src -> source)", " InputArtifacts:\n - Name: src", " InputArtifacts:\n - Name: source", True, None), ("Build action InputArtifacts removed", " InputArtifacts:\n - Name: src\n", "", True, None), ("pipeline role loses codebuild:StartBuild", " - codebuild:StartBuild\n", "", True, None), ("build role loses logs:PutLogEvents", " - logs:PutLogEvents\n", "", True, None), ("pipeline RoleArn points at BuildRole", "RoleArn: !GetAtt PipelineRole.Arn", "RoleArn: !GetAtt BuildRole.Arn", True, None), ("pipeline artifact store is the site bucket", "Location: !Ref ArtifactBucket", "Location: !Ref SiteBucket", True, None), ("BuildProject TimeoutInMinutes 15 -> 0", "TimeoutInMinutes: 15", "TimeoutInMinutes: 0", True, None), ("ComputeType typo", "BUILD_GENERAL1_SMALL", "BUILD_GENERAL1_SMAL", True, None), ("PipelineType V3 (does not exist)", "PipelineType: V2", "PipelineType: V3", True, None), ("MaxSessionDuration 100", "MaxSessionDuration: 3600", "MaxSessionDuration: 100", True, None), ("lifecycle ExpirationInDays 0", "ExpirationInDays: 30", "ExpirationInDays: 0", True, None), ("GitHubRepo default breaks AllowedPattern", "Default: acme/shop", "Default: acme-shop", True, None), ("Source BranchName removed", " BranchName: main\n", "", True, None), ("Output artifact name used twice", " - Name: built", " - Name: src", True, None), ("OK: repo default changed to acme/blog", "Default: acme/shop", "Default: acme/blog", False, None), ("OK: build timeout 30", "TimeoutInMinutes: 15", "TimeoutInMinutes: 30", False, None), ("OK: build command changed", " - echo build", " - echo build and test", False, None), ] def lint(t): f = tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False, encoding="utf-8", dir=B) f.write(t); f.close() p = subprocess.run([f"{B}/venv/Scripts/cfn-lint.exe", f.name], capture_output=True, text=True) os.unlink(f.name) return p.returncode != 0, sorted(set(re.findall(r"^([EW]\d{4})", p.stdout, re.M))) def mine(t): try: r = pipecheck.check(t) return bool(r), sorted({x[0] for x in r}) except Exception as e: return False, ["crashed:" + type(e).__name__] # a crash is NOT counted as a catch print(f"{'change':68s} lint mine") b = (lint(good)[0], mine(good)[0]) print(f"{'(control)':68s} {int(b[0]):4d} {int(b[1]):5d}") nm = a = c = both = none = only_mine = fp = 0 for label, old, new, mist, special in M: assert old in good, label t = good.replace(old, new, 1) if special: t = t.replace(" token.actions.githubusercontent.com:aud: sts.amazonaws.com\n", " token.actions.githubusercontent.com:aud: sts.amazonaws.com\n StringLike:\n token.actions.githubusercontent.com:sub: \"repo:acme/*\"\n", 1) x, xr = lint(t); y, yr = mine(t) print(f"{label[:68]:68s} {int(x):4d} {int(y):5d} {','.join(xr) if x else '':14s} {','.join(yr) if (y or (yr and yr[0].startswith('crashed'))) else ''}") if mist: nm += 1; a += x; c += y; both += (x and y); none += (not x and not y); only_mine += (y and not x) else: fp += (x or y) print(f"\n{nm} mistakes: cfn-lint {a}, pipecheck {c}, both {both}, only pipecheck {only_mine}, neither {none}; false alarms on {len(M)-nm} OK changes: {fp}") --- py mutate_pipe.py (saved output of the real run) --- change lint mine (control) 0 0 sub condition: StringLike repo:acme/* (whole organisation) 0 1 T2 sub condition removed 0 1 T2 aud condition removed 0 0 aud typo sts.amazon.com 0 1 T1 environment production -> prod 0 1 T1 sub is the main branch, not the environment 0 1 T1,T2 provider ClientIdList has the wrong audience 0 1 T3 trust Action sts:AssumeRole instead of AssumeRoleWithWebIdentity 0 1 T1 Principal Federated Ref to a missing resource 1 1 E1020 T1 deploy policy s3:* on Resource * 0 1 P1 CodeBuild PrivilegedMode true 0 1 B1 site bucket without PublicAccessBlock 0 1 B2 Build input artifact renamed (src -> source) 1 1 E3701 W1 Build action InputArtifacts removed 1 1 E3702 W2 pipeline role loses codebuild:StartBuild 0 1 W5 build role loses logs:PutLogEvents 0 1 W6 pipeline RoleArn points at BuildRole 0 1 W4 pipeline artifact store is the site bucket 0 1 W3 BuildProject TimeoutInMinutes 15 -> 0 1 0 E3034 ComputeType typo 0 0 PipelineType V3 (does not exist) 1 0 E3030 MaxSessionDuration 100 1 0 E3034 lifecycle ExpirationInDays 0 0 0 GitHubRepo default breaks AllowedPattern 1 0 E2015 crashed:ValueError Source BranchName removed 0 0 Output artifact name used twice 1 0 E3701 OK: repo default changed to acme/blog 0 0 OK: build timeout 30 0 0 OK: build command changed 0 0 26 mistakes: cfn-lint 8, pipecheck 17, both 3, only pipecheck 14, neither 4; false alarms on 3 OK changes: 0 --- ops_checks.py --- """Day-2 checks, level 1: (a) moto 5.2.4 Parameter Store against the limits read in chapter 3 (standard 4 KB, advanced 8 KB), (b) a CloudTrail record checker built from the record-contents page read 2026-10-13, run on SYNTHETIC records I wrote (not real CloudTrail output).""" import json, collections import boto3, botocore from moto import mock_aws print("== (a) moto Parameter Store vs the documented size limits ==") rows = [] with mock_aws(): ssm = boto3.client("ssm", region_name="eu-west-2", aws_access_key_id="x", aws_secret_access_key="x") def attempt(label, expect, **kw): try: ssm.put_parameter(**kw); got = "accepted" except botocore.exceptions.ParamValidationError: got = "rejected by boto3 itself" except botocore.exceptions.ClientError as e: got = "rejected: " + e.response["Error"]["Code"] mark = "ok " if (expect == "accept") == (got == "accepted") else "DIFF" if got.startswith("rejected by boto3"): mark = "n/a " rows.append(mark) print(f"{mark} {label:58s} docs say {expect:7s} moto {got}") attempt("standard parameter, 4096 bytes", "accept", Name="/a/std4096", Value="x" * 4096, Type="String") attempt("standard parameter, 4097 bytes", "reject", Name="/a/std4097", Value="x" * 4097, Type="String") attempt("advanced parameter, 8192 bytes", "accept", Name="/a/adv8192", Value="x" * 8192, Type="String", Tier="Advanced") attempt("advanced parameter, 8193 bytes", "reject", Name="/a/adv8193", Value="x" * 8193, Type="String", Tier="Advanced") attempt("standard tier, 4097 bytes, Tier given explicitly", "reject", Name="/a/std4097b", Value="x" * 4097, Type="String", Tier="Standard") attempt("Type 'Secret' (not String/StringList/SecureString)", "reject", Name="/a/badtype", Value="v", Type="Secret") ssm.put_parameter(Name="/a/s", Value="hunter2", Type="SecureString") plain = ssm.get_parameter(Name="/a/s", WithDecryption=True)["Parameter"]["Value"] enc = ssm.get_parameter(Name="/a/s", WithDecryption=False)["Parameter"]["Value"] print(f"observed: SecureString with decryption -> {plain!r}; without -> {enc!r}") try: ssm.put_parameter(Name="/a/s", Value="again", Type="SecureString"); print("observed: second put without Overwrite -> accepted") except botocore.exceptions.ClientError as e: print("observed: second put without Overwrite ->", e.response["Error"]["Code"], "(my understanding is that the service refuses this; not read)") scored = [r for r in rows if r != "n/a "] print(f"scored cases: {len(scored)}; moto agrees with the page on {scored.count('ok ')}") print("\n== (b) CloudTrail record checker (fields marked 'Optional: False' on the record-contents page) ==") REQUIRED = ["eventTime", "eventVersion", "userIdentity", "eventSource", "eventName", "awsRegion", "sourceIPAddress", "requestParameters", "responseElements", "eventID", "eventType", "eventCategory"] def ver(v): a, b = v.split("."); return int(a), int(b) def check_record(r, my_major=1, my_min_minor=11): p = [] for f in REQUIRED: if f not in r: p.append("missing " + f) if "eventVersion" in r: M, m = ver(r["eventVersion"]) if M != my_major: p.append(f"major version {M} differs from {my_major}: do not parse") elif m < my_min_minor: p.append(f"minor {m} < {my_min_minor}: newer fields (eventContext) cannot be assumed") if r.get("eventType") == "AwsApiCall" and r.get("readOnly") is True and r.get("responseElements") not in (None,): p.append("readOnly event with responseElements (page: null for readOnly APIs)") if "errorCode" in r and "errorMessage" not in r: p.append("errorCode without errorMessage (page: some services put error info in responseElements)") return p base = dict(eventVersion="1.11", userIdentity={"type": "AssumedRole", "arn": "arn:aws:sts::123456789012:assumed-role/gha-deploy/run-1"}, eventTime="2026-10-13T10:00:00Z", eventSource="s3.amazonaws.com", eventName="PutObject", awsRegion="eu-west-2", sourceIPAddress="203.0.113.7", requestParameters={}, responseElements=None, eventID="a1", eventType="AwsApiCall", eventCategory="Management", readOnly=False) recs = [ ("complete record", dict(base)), ("oidc assume, success", dict(base, eventSource="sts.amazonaws.com", eventName="AssumeRoleWithWebIdentity", eventID="a2", userIdentity={"type": "WebIdentityUser"}, requestParameters={"roleArn": "arn:aws:iam::123456789012:role/gha-deploy"}, responseElements={"credentials": "..."})), ("oidc assume, denied", dict(base, eventSource="sts.amazonaws.com", eventName="AssumeRoleWithWebIdentity", eventID="a3", userIdentity={"type": "WebIdentityUser"}, requestParameters={"roleArn": "arn:aws:iam::123456789012:role/gha-deploy"}, errorCode="AccessDenied", errorMessage="Not authorized to perform sts:AssumeRoleWithWebIdentity")), ("no eventID", {k: v for k, v in base.items() if k != "eventID"}), ("no awsRegion and no sourceIPAddress", {k: v for k, v in base.items() if k not in ("awsRegion", "sourceIPAddress")}), ("eventVersion 1.08", dict(base, eventVersion="1.08")), ("eventVersion 2.0", dict(base, eventVersion="2.0")), ("errorCode without errorMessage", dict(base, errorCode="AccessDenied")), ("readOnly but responseElements present", dict(base, eventName="ListBuckets", readOnly=True, responseElements={"x": 1})), ("no eventCategory (older record)", {k: v for k, v in base.items() if k != "eventCategory"}), ] for label, r in recs: pr = check_record(r) print(f"{label:42s} {'ok' if not pr else '; '.join(pr)}") print("\n== (c) questions answered from the synthetic records ==") log = [r for _, r in recs if not check_record(r)] by = collections.Counter(r["eventName"] for r in log) print("events by name (records that passed the check):", dict(by)) den = [r for r in log if r.get("errorCode")] print("denied assume-role events:", [(r["eventName"], r["errorCode"]) for r in den]) --- py ops_checks.py (saved output of the real run) --- == (a) moto Parameter Store vs the documented size limits == ok standard parameter, 4096 bytes docs say accept moto accepted DIFF standard parameter, 4097 bytes docs say reject moto accepted ok advanced parameter, 8192 bytes docs say accept moto accepted DIFF advanced parameter, 8193 bytes docs say reject moto accepted DIFF standard tier, 4097 bytes, Tier given explicitly docs say reject moto accepted ok Type 'Secret' (not String/StringList/SecureString) docs say reject moto rejected: ValidationException observed: SecureString with decryption -> 'hunter2'; without -> 'kms:alias/aws/ssm:hunter2' observed: second put without Overwrite -> ParameterAlreadyExists (my understanding is that the service refuses this; not read) scored cases: 6; moto agrees with the page on 3 == (b) CloudTrail record checker (fields marked 'Optional: False' on the record-contents page) == complete record ok oidc assume, success ok oidc assume, denied ok no eventID missing eventID no awsRegion and no sourceIPAddress missing awsRegion; missing sourceIPAddress eventVersion 1.08 minor 8 < 11: newer fields (eventContext) cannot be assumed eventVersion 2.0 major version 2 differs from 1: do not parse errorCode without errorMessage errorCode without errorMessage (page: some services put error info in responseElements) readOnly but responseElements present readOnly event with responseElements (page: null for readOnly APIs) no eventCategory (older record) missing eventCategory == (c) questions answered from the synthetic records == events by name (records that passed the check): {'PutObject': 1, 'AssumeRoleWithWebIdentity': 2} denied assume-role events: [('AssumeRoleWithWebIdentity', 'AccessDenied')] --- cost11.py --- """Monthly cost sketch for the chapter's pipeline, from the pricing pages fetched 2026-10-13. READ: CodePipeline V1 $1.00 per active pipeline per month (first 30 days free; one free active pipeline); V2 $0.002 per action execution minute, rounded up to the minute, 100 free minutes shared per month, manual approval and custom action types not charged; an active pipeline has existed more than 30 days and had at least one code change that month. CodeBuild: general1.small on-demand Linux $0.005 per build minute (only in a worked example: 400 minutes x $0.005 = $2), 100 free minutes a month on general1.small/arm1.small, Lambda compute per build second (lambda.arm.1GB $0.00001, free 6,000 s). No Region is named on either page. NOT READ: GitHub Actions minutes prices, CloudTrail pricing, Session Manager/Patch Manager/Config/GuardDuty/Security Hub prices, CodeDeploy price, whether a V2 source or build action's minutes are counted per action (my reading: the build action's runtime), cost of the artifact bucket.""" builds_per_day, workdays, minutes = 10, 22, 3 b = builds_per_day * workdays mins = b * minutes print(f"{b} builds a month x {minutes} minutes = {mins} build minutes") cb = max(mins - 100, 0) * 0.005 print(f"CodeBuild general1.small: ({mins} - 100 free) x $0.005 = ${cb:.2f} (free-tier minutes apply to new and existing accounts? not read: I assume every month)") v1 = 1.00 print(f"CodePipeline V1: ${v1:.2f} for one active pipeline (the first one is free: $0.00)") v2 = max(mins - 100, 0) * 0.002 print(f"CodePipeline V2: ({mins} build-action minutes - 100 free) x $0.002 = ${v2:.2f} (counts only the build action; Source and Approval not counted: unread for Source)") print(f"total, V2 pipeline + CodeBuild, assumptions above: ${cb + v2:.2f} a month") print("\nsensitivity: builds of 6 minutes instead of 3") m2 = b * 6 print(f" CodeBuild ${max(m2-100,0)*0.005:.2f}, V2 ${max(m2-100,0)*0.002:.2f}, total ${max(m2-100,0)*0.007:.2f}") print("\nnot priced: GitHub Actions minutes, CloudTrail, GuardDuty, Config, Security Hub, Session Manager, Patch Manager: pages not read this session") --- py cost11.py (saved output of the real run) --- 220 builds a month x 3 minutes = 660 build minutes CodeBuild general1.small: (660 - 100 free) x $0.005 = $2.80 (free-tier minutes apply to new and existing accounts? not read: I assume every month) CodePipeline V1: $1.00 for one active pipeline (the first one is free: $0.00) CodePipeline V2: (660 build-action minutes - 100 free) x $0.002 = $1.12 (counts only the build action; Source and Approval not counted: unread for Source) total, V2 pipeline + CodeBuild, assumptions above: $3.92 a month sensitivity: builds of 6 minutes instead of 3 CodeBuild $6.10, V2 $2.44, total $8.54 not priced: GitHub Actions minutes, CloudTrail, GuardDuty, Config, Security Hub, Session Manager, Patch Manager: pages not read this session -------------------------- Reading the result: cfn-lint 1.57.2 caught 8 of 26 pipeline-template mistakes (out-of-range values, a dangling reference, artifact names); pipecheck.py caught 17 (trust policy evaluated with the model, permissions, wiring); both 3, neither 4 (the removed audience condition, a compute type typo, a lifecycle expiry of 0, a missing branch name). pipecheck.py's rules were written before the run, but the caution from chapter 10 stands: it shows the rules can be written, not that the checker generalises. Mistake made and fixed: my first run counted a crash of pipecheck.py (the repository default that breaks the parameter pattern raised ValueError) as a catch, which made the checker look better than it is (18 catches, not 17); I changed the harness to report crashes separately and not count them. How to avoid it: a harness must distinguish 'the checker said no' from 'the checker failed to run'. moto Parameter Store: agreed with the documented size limits on 3 of 6 scored cases; SecureString read without decryption returned 'kms:alias/aws/ssm:hunter2' (plain text with a prefix). The CloudTrail checker is built from the fields listed on the record-contents page and run on 10 SYNTHETIC records that I wrote; it is not a test of CloudTrail. Cost sketch: from worked examples on the CodePipeline and CodeBuild pages, no Region named; GitHub Actions, CloudTrail, Config, GuardDuty and Security Hub prices were not read.