aws2-10 Exercise 3: Emulator Check, Template Mutations, a Cross-Property Checker and a Cost Sketch ======================================================================== Run with Windows 11, Python 3.14 (py launcher) and a venv with moto 5.2.4 (server mode on port 5000, dummy credentials test/test), boto3 1.43.112, cfn-lint 1.57.2, PyYAML, dnspython 2.9.0; iam_eval.py is the chapter 2 model copied with one fix (see exercise 1). Scratch folder C:/lwaws/c10, nothing installed globally, no Docker. VERIFICATION LEVEL: 1 (no AWS account, no domain, nothing deployed); statements about the real services come from the AWS 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. --- moto server --- venv\Scripts\python.exe -m moto.server -p 5000 (stop it when done) --- common.py --- import boto3, os, time os.environ.setdefault("AWS_ACCESS_KEY_ID", "test"); os.environ.setdefault("AWS_SECRET_ACCESS_KEY", "test") EP = "http://127.0.0.1:5000" def client(name, region="us-east-1"): return boto3.client(name, endpoint_url=EP, region_name=region) def uniq(p): return f"{p}-{int(time.time()*1000) % 10**9}" --- edge_moto_tests.py --- """Does the moto emulator (5.2.4) enforce documented rules of Route 53, WAF, CloudFront and ACM? Each case: what the page I read says, what the emulator did. 'basis' marks the source of each expectation.""" import json from botocore.exceptions import ClientError from common import client, uniq r53, waf, cf, acm = client("route53"), client("wafv2"), client("cloudfront"), client("acm", "eu-west-1") acm_us = client("acm", "us-east-1") rows = [] def attempt(fn): try: fn(); return "accepted" except ClientError as e: if e.response["ResponseMetadata"]["HTTPStatusCode"] == 404 and e.response["Error"]["Code"] == "404": return "NOT IMPLEMENTED (404)" return "rejected (" + e.response["Error"]["Code"] + ")" except Exception as e: return "rejected by boto3 (" + type(e).__name__ + ")" def case(label, basis, expect, fn): got = attempt(fn) unscored = got.startswith("NOT IMPLEMENTED") or "boto3" in got ok = (got == "accepted") == (expect == "accepted") if not unscored: rows.append(ok) tag = "n/a " if unscored else ("ok " if ok else "DIFF") print(f"{tag} {label:64s} expect {expect:9s} emulator {got:34s} [{basis}]") # ---------------- Route 53 ---------------- zone = r53.create_hosted_zone(Name=f"{uniq('zone')}.example.", CallerReference=uniq("ref"))["HostedZone"] ZID, ZNAME = zone["Id"].split("/")[-1], zone["Name"] def change(rec): return lambda: r53.change_resource_record_sets(HostedZoneId=ZID, ChangeBatch={"Changes": [{"Action": "CREATE", "ResourceRecordSet": rec}]}) case("CNAME record at the zone apex", "alias page: you can't create a CNAME at the apex", "rejected", change({"Name": ZNAME, "Type": "CNAME", "TTL": 300, "ResourceRecords": [{"Value": "www." + ZNAME}]})) case("CNAME record at a subdomain", "alias page", "accepted", change({"Name": "blog." + ZNAME, "Type": "CNAME", "TTL": 300, "ResourceRecords": [{"Value": "www." + ZNAME}]})) case("alias A record at the apex, CloudFront target", "alias page: allowed at the apex", "accepted", change({"Name": ZNAME, "Type": "A", "AliasTarget": {"HostedZoneId": "Z2FDTNDATAQYW2", "DNSName": "d111111abcdef8.cloudfront.net", "EvaluateTargetHealth": False}})) case("alias record that also sets a TTL", "alias page: for an AWS resource you can't set the TTL", "rejected", change({"Name": "alias2." + ZNAME, "Type": "A", "TTL": 60, "AliasTarget": {"HostedZoneId": "Z2FDTNDATAQYW2", "DNSName": "d111111abcdef8.cloudfront.net", "EvaluateTargetHealth": False}})) case("health check with a 20 s request interval", "health check page: every 10 or every 30 seconds", "rejected", lambda: r53.create_health_check(CallerReference=uniq("hc"), HealthCheckConfig={"Type": "HTTP", "FullyQualifiedDomainName": "example.com", "Port": 80, "ResourcePath": "/", "RequestInterval": 20, "FailureThreshold": 3})) case("health check with a 30 s request interval", "health check page", "accepted", lambda: r53.create_health_check(CallerReference=uniq("hc"), HealthCheckConfig={"Type": "HTTP", "FullyQualifiedDomainName": "example.com", "Port": 80, "ResourcePath": "/", "RequestInterval": 30, "FailureThreshold": 3})) case("weighted record without a SetIdentifier", "my understanding (weighted pages: records share a name and type)", "rejected", change({"Name": "w." + ZNAME, "Type": "A", "Weight": 10, "TTL": 60, "ResourceRecords": [{"Value": "192.0.2.1"}]})) case("weighted record with a SetIdentifier", "weighted page", "accepted", change({"Name": "w2." + ZNAME, "Type": "A", "SetIdentifier": "a", "Weight": 1, "TTL": 60, "ResourceRecords": [{"Value": "192.0.2.1"}]})) # ---------------- WAF ---------------- def acl(rules, scope="CLOUDFRONT"): return lambda: waf.create_web_acl(Name=uniq("acl"), Scope=scope, DefaultAction={"Allow": {}}, VisibilityConfig={"SampledRequestsEnabled": True, "CloudWatchMetricsEnabled": True, "MetricName": "m"}, Rules=rules) def rule(name, pri, stmt, action=None): return {"Name": name, "Priority": pri, "Statement": stmt, "Action": action or {"Block": {}}, "VisibilityConfig": {"SampledRequestsEnabled": True, "CloudWatchMetricsEnabled": True, "MetricName": name}} RATE = lambda limit, window=300: {"RateBasedStatement": {"Limit": limit, "AggregateKeyType": "IP", "EvaluationWindowSec": window}} GEO = {"GeoMatchStatement": {"CountryCodes": ["KP"]}} case("web ACL with a rate-based rule, limit 100, window 300", "rate-based page", "accepted", acl([rule("rl", 1, RATE(100))])) case("rate-based limit 5", "rate-based page: lowest limit is 10", "rejected", acl([rule("rl", 1, RATE(5))])) case("rate-based window 90", "rate-based page: 60, 120, 300 or 600", "rejected", acl([rule("rl", 1, RATE(100, 90))])) case("rate-based rule with the Allow action", "rate-based page: any action except Allow", "rejected", acl([rule("rl", 1, RATE(100), {"Allow": {}})])) case("two rules with the same priority", "priority page: unique within the web ACL", "rejected", acl([rule("a", 1, GEO), rule("b", 1, GEO)])) case("two rules with different priorities", "priority page", "accepted", acl([rule("a", 1, GEO), rule("b", 2, GEO)])) # ---------------- ACM + CloudFront ---------------- cert_eu = acm.request_certificate(DomainName="www.example.com")["CertificateArn"] cert_us = acm_us.request_certificate(DomainName="www.example.com")["CertificateArn"] def dist(cert, aliases=("www.example.com",)): def f(): cfg = {"CallerReference": uniq("d"), "Comment": "t", "Enabled": True, "Origins": {"Quantity": 1, "Items": [{"Id": "o", "DomainName": "bucket.s3.amazonaws.com", "S3OriginConfig": {"OriginAccessIdentity": ""}}]}, "DefaultCacheBehavior": {"TargetOriginId": "o", "ViewerProtocolPolicy": "redirect-to-https", "CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6"}, "Aliases": {"Quantity": len(aliases), "Items": list(aliases)}, "ViewerCertificate": {"ACMCertificateArn": cert, "SSLSupportMethod": "sni-only", "MinimumProtocolVersion": "TLSv1.2_2021"}} cf.create_distribution(DistributionConfig=cfg) return f case("distribution with a certificate requested in us-east-1", "certificate page: request it in us-east-1", "accepted", dist(cert_us)) case("distribution with a certificate requested in eu-west-1", "certificate page: must be us-east-1", "rejected", dist(cert_eu, ("a.example.com",))) case("distribution with 101 alternate domain names", "quotas page: 100 per distribution", "rejected", dist(cert_us, tuple(f"h{i}.example.com" for i in range(101)))) def cache_policy(headers=0, name=None): def f(): params = {"MinTTL": 0, "DefaultTTL": 60, "MaxTTL": 600, "ParametersInCacheKeyAndForwardedToOrigin": {"EnableAcceptEncodingGzip": False, "EnableAcceptEncodingBrotli": False, "HeadersConfig": {"HeaderBehavior": "whitelist" if headers else "none", **({"Headers": {"Quantity": headers, "Items": [f"X-H{i}" for i in range(headers)]}} if headers else {})}, "CookiesConfig": {"CookieBehavior": "none"}, "QueryStringsConfig": {"QueryStringBehavior": "none"}}} cf.create_cache_policy(CachePolicyConfig={"Name": name or uniq("cp"), **params}) return f case("cache policy with 10 headers in the cache key", "quotas page: 10 headers per cache policy", "accepted", cache_policy(10)) case("cache policy with 11 headers in the cache key", "quotas page: 10 headers per cache policy", "rejected", cache_policy(11)) print() print(f"{sum(rows)} of {len(rows)} scored cases agree with the documentation (n/a = not scored: the emulator does not implement the call, or boto3 itself rejected the input before it was sent)") --- py edge_moto_tests.py (saved output of the real run) --- DIFF CNAME record at the zone apex expect rejected emulator accepted [alias page: you can't create a CNAME at the apex] ok CNAME record at a subdomain expect accepted emulator accepted [alias page] ok alias A record at the apex, CloudFront target expect accepted emulator accepted [alias page: allowed at the apex] DIFF alias record that also sets a TTL expect rejected emulator accepted [alias page: for an AWS resource you can't set the TTL] DIFF health check with a 20 s request interval expect rejected emulator accepted [health check page: every 10 or every 30 seconds] ok health check with a 30 s request interval expect accepted emulator accepted [health check page] DIFF weighted record without a SetIdentifier expect rejected emulator accepted [my understanding (weighted pages: records share a name and type)] ok weighted record with a SetIdentifier expect accepted emulator accepted [weighted page] ok web ACL with a rate-based rule, limit 100, window 300 expect accepted emulator accepted [rate-based page] n/a rate-based limit 5 expect rejected emulator rejected by boto3 (ParamValidationError) [rate-based page: lowest limit is 10] DIFF rate-based window 90 expect rejected emulator accepted [rate-based page: 60, 120, 300 or 600] DIFF rate-based rule with the Allow action expect rejected emulator accepted [rate-based page: any action except Allow] DIFF two rules with the same priority expect rejected emulator accepted [priority page: unique within the web ACL] ok two rules with different priorities expect accepted emulator accepted [priority page] ok distribution with a certificate requested in us-east-1 expect accepted emulator accepted [certificate page: request it in us-east-1] DIFF distribution with a certificate requested in eu-west-1 expect rejected emulator accepted [certificate page: must be us-east-1] DIFF distribution with 101 alternate domain names expect rejected emulator accepted [quotas page: 100 per distribution] n/a cache policy with 10 headers in the cache key expect accepted emulator NOT IMPLEMENTED (404) [quotas page: 10 headers per cache policy] n/a cache policy with 11 headers in the cache key expect rejected emulator NOT IMPLEMENTED (404) [quotas page: 10 headers per cache policy] 7 of 16 scored cases agree with the documentation (n/a = not scored: the emulator does not implement the call, or boto3 itself rejected the input before it was sent) --- edge.yaml --- AWSTemplateFormatVersion: "2010-09-09" Description: Chapter 10 edge design - private S3 site behind CloudFront (OAC, cache policy, WAF) with Route 53 records Parameters: DomainName: Type: String Default: www.example.com CertificateArn: Type: String Default: arn:aws:acm:us-east-1:111122223333:certificate/00000000-0000-0000-0000-000000000000 HostedZoneId: Type: String Default: Z0000000EXAMPLE Resources: SiteBucket: Type: AWS::S3::Bucket Properties: PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true SiteBucketPolicy: Type: AWS::S3::BucketPolicy Properties: Bucket: !Ref SiteBucket PolicyDocument: Version: "2012-10-17" Statement: - Sid: AllowCloudFrontServicePrincipalReadOnly Effect: Allow Principal: {Service: cloudfront.amazonaws.com} Action: s3:GetObject Resource: !Sub "${SiteBucket.Arn}/*" Condition: StringEquals: "AWS:SourceArn": !Sub "arn:aws:cloudfront::${AWS::AccountId}:distribution/${Distribution}" OriginAccess: Type: AWS::CloudFront::OriginAccessControl Properties: OriginAccessControlConfig: Name: site-oac OriginAccessControlOriginType: s3 SigningBehavior: always SigningProtocol: sigv4 SiteCachePolicy: Type: AWS::CloudFront::CachePolicy Properties: CachePolicyConfig: Name: site-cache-policy MinTTL: 1 DefaultTTL: 86400 MaxTTL: 31536000 ParametersInCacheKeyAndForwardedToOrigin: EnableAcceptEncodingGzip: true EnableAcceptEncodingBrotli: true HeadersConfig: {HeaderBehavior: none} CookiesConfig: {CookieBehavior: none} QueryStringsConfig: {QueryStringBehavior: none} WebAcl: Type: AWS::WAFv2::WebACL Properties: Name: site-acl Scope: CLOUDFRONT DefaultAction: {Allow: {}} VisibilityConfig: {SampledRequestsEnabled: true, CloudWatchMetricsEnabled: true, MetricName: site-acl} Rules: - Name: rate-limit Priority: 1 Action: {Block: {}} Statement: RateBasedStatement: {Limit: 1000, AggregateKeyType: IP, EvaluationWindowSec: 300} VisibilityConfig: {SampledRequestsEnabled: true, CloudWatchMetricsEnabled: true, MetricName: rate-limit} - Name: common-rules Priority: 2 OverrideAction: {None: {}} Statement: ManagedRuleGroupStatement: {VendorName: AWS, Name: AWSManagedRulesCommonRuleSet} VisibilityConfig: {SampledRequestsEnabled: true, CloudWatchMetricsEnabled: true, MetricName: common-rules} Distribution: Type: AWS::CloudFront::Distribution Properties: DistributionConfig: Enabled: true Comment: chapter 10 site Aliases: [!Ref DomainName] DefaultRootObject: index.html HttpVersion: http2and3 PriceClass: PriceClass_100 WebACLId: !GetAtt WebAcl.Arn Origins: - Id: site DomainName: !GetAtt SiteBucket.RegionalDomainName OriginAccessControlId: !GetAtt OriginAccess.Id S3OriginConfig: {OriginAccessIdentity: ""} DefaultCacheBehavior: TargetOriginId: site ViewerProtocolPolicy: redirect-to-https Compress: true CachePolicyId: !Ref SiteCachePolicy CustomErrorResponses: - ErrorCode: 404 ResponseCode: 404 ResponsePagePath: /404.html ErrorCachingMinTTL: 30 ViewerCertificate: AcmCertificateArn: !Ref CertificateArn SslSupportMethod: sni-only MinimumProtocolVersion: TLSv1.2_2021 SiteRecordA: Type: AWS::Route53::RecordSet Properties: HostedZoneId: !Ref HostedZoneId Name: !Ref DomainName Type: A AliasTarget: DNSName: !GetAtt Distribution.DomainName HostedZoneId: Z2FDTNDATAQYW2 SiteRecordAAAA: Type: AWS::Route53::RecordSet Properties: HostedZoneId: !Ref HostedZoneId Name: !Ref DomainName Type: AAAA AliasTarget: DNSName: !GetAtt Distribution.DomainName HostedZoneId: Z2FDTNDATAQYW2 ApiHealthCheck: Type: AWS::Route53::HealthCheck Properties: HealthCheckConfig: Type: HTTPS FullyQualifiedDomainName: stable.api.example.com ResourcePath: /health RequestInterval: 30 FailureThreshold: 3 ApiStable: Type: AWS::Route53::RecordSet Properties: HostedZoneId: !Ref HostedZoneId Name: api.example.com Type: CNAME TTL: "60" SetIdentifier: stable Weight: 255 HealthCheckId: !Ref ApiHealthCheck ResourceRecords: [stable.api.example.com] ApiCanary: Type: AWS::Route53::RecordSet Properties: HostedZoneId: !Ref HostedZoneId Name: api.example.com Type: CNAME TTL: "60" SetIdentifier: canary Weight: 1 ResourceRecords: [canary.api.example.com] --- mutate_edge.py --- """One-change mutations of edge.yaml linted with cfn-lint 1.57.2. 'basis' says which page (read 2026-10-13) the expectation rests on; 'semantic' = well-formed but wrong for a reason that needs two properties or resources together, or knowledge of AWS.""" import os, re, subprocess LINT = "C:/lwaws/c10/venv/Scripts/cfn-lint.exe" SRC = open("C:/lwaws/c10/edge.yaml", encoding="utf-8").read() M = [ ("cache policy MinTTL 1 -> -1", "cache policy: TTL values are seconds, not negative", " MinTTL: 1\n", " MinTTL: -1\n"), ("cache policy MinTTL above MaxTTL", "expiration page: min <= max (table cases)", " MinTTL: 1\n", " MinTTL: 99999999\n"), ("cache policy DefaultTTL 'a day'", "managed policies page: a number of seconds", "DefaultTTL: 86400", "DefaultTTL: a day"), ("headers 'whitelist' with no header list", "cache policy: allowlist needs names (my understanding)", "HeadersConfig: {HeaderBehavior: none}", "HeadersConfig: {HeaderBehavior: whitelist}"), ("ViewerProtocolPolicy 'https'", "distribution settings: allow-all, https-only, redirect-to-https", "ViewerProtocolPolicy: redirect-to-https", "ViewerProtocolPolicy: https"), ("PriceClass 'PriceClass_50'", "distribution settings: 100, 200, All", "PriceClass: PriceClass_100", "PriceClass: PriceClass_50"), ("HttpVersion 'http4'", "distribution settings", "HttpVersion: http2and3", "HttpVersion: http4"), ("OAC SigningBehavior 'sometimes'", "OAC page: always, never, no-override", "SigningBehavior: always", "SigningBehavior: sometimes"), ("OAC origin type 'bucket'", "OAC page: s3 (and others)", "OriginAccessControlOriginType: s3", "OriginAccessControlOriginType: bucket"), ("OAC with signing 'never' on a private bucket", "OAC page: the bucket must then be public", "SigningBehavior: always", "SigningBehavior: never"), ("bucket policy without the SourceArn Condition", "OAC page: use a Condition so only your distribution is allowed", " Condition:\n StringEquals:\n \"AWS:SourceArn\": !Sub \"arn:aws:cloudfront::${AWS::AccountId}:distribution/${Distribution}\"\n", ""), ("bucket policy Resource is the bucket, not bucket/*", "OAC page: arn:aws:s3:::bucket/*", "Resource: !Sub \"${SiteBucket.Arn}/*\"", "Resource: !GetAtt SiteBucket.Arn"), ("bucket policy trusts the wrong service", "OAC page: cloudfront.amazonaws.com", "Principal: {Service: cloudfront.amazonaws.com}", "Principal: {Service: s3.amazonaws.com}"), ("certificate in eu-west-1 instead of us-east-1", "certificate page: request it in us-east-1", "arn:aws:acm:us-east-1:111122223333", "arn:aws:acm:eu-west-1:111122223333"), ("alias domain with no certificate", "alternate names page: the certificate must cover the name", " ViewerCertificate:\n AcmCertificateArn: !Ref CertificateArn\n SslSupportMethod: sni-only\n MinimumProtocolVersion: TLSv1.2_2021\n", ""), ("custom error ErrorCachingMinTTL -5", "error page: seconds (default 10)", "ErrorCachingMinTTL: 30", "ErrorCachingMinTTL: -5"), ("WebACL Scope REGIONAL while attached to CloudFront", "semantic (CloudFront needs the CLOUDFRONT scope)", "Scope: CLOUDFRONT", "Scope: REGIONAL"), ("WebACL without a DefaultAction", "web ACL pages: the default action applies when no rule terminates", " DefaultAction: {Allow: {}}\n", ""), ("rate-based Limit 1000 -> 5", "rate-based page: lowest limit 10", "Limit: 1000", "Limit: 5"), ("rate-based EvaluationWindowSec 300 -> 90", "rate-based page: 60, 120, 300 or 600", "EvaluationWindowSec: 300", "EvaluationWindowSec: 90"), ("rate-based rule with Action Allow", "rate-based page: any action except Allow", " Action: {Block: {}}\n", " Action: {Allow: {}}\n"), ("two rules with the same Priority", "priority page: unique within the web ACL", " Priority: 2\n", " Priority: 1\n"), ("a rule without VisibilityConfig", "(required property)", " VisibilityConfig: {SampledRequestsEnabled: true, CloudWatchMetricsEnabled: true, MetricName: common-rules}\n", ""), ("managed rule group with a vendor that does not exist", "semantic", "VendorName: AWS,", "VendorName: AWSS,"), ("apex record becomes a CNAME", "alias page: no CNAME at the apex", " Name: !Ref DomainName\n Type: A\n AliasTarget:\n DNSName: !GetAtt Distribution.DomainName\n HostedZoneId: Z2FDTNDATAQYW2\n", " Name: !Ref DomainName\n Type: CNAME\n TTL: \"300\"\n ResourceRecords: [d111111abcdef8.cloudfront.net]\n"), ("alias record that also sets a TTL", "alias page: can't set the TTL", " Type: A\n AliasTarget:", " Type: A\n TTL: \"60\"\n AliasTarget:"), ("alias hosted zone ID is not CloudFront's", "semantic (the alias zone ID of the target service)", "HostedZoneId: Z2FDTNDATAQYW2\n\n SiteRecordAAAA", "HostedZoneId: Z0000000WRONG\n\n SiteRecordAAAA"), ("health check interval 20 s", "health check page: 10 or 30 s", "RequestInterval: 30", "RequestInterval: 20"), ("health check FailureThreshold 'three'", "health check page: a number", "FailureThreshold: 3", "FailureThreshold: three"), ("health check type 'HTTP3'", "health check page: HTTP, HTTPS, TCP, ...", "Type: HTTPS\n FullyQualifiedDomainName", "Type: HTTP3\n FullyQualifiedDomainName"), ("weighted record without a SetIdentifier", "weighted page (records share a name and type)", " SetIdentifier: canary\n", ""), ("weighted Weight 'heavy'", "weighted page: a number", "Weight: 255", "Weight: heavy"), ] def lint(text, tag): p = f"C:/lwaws/c10/_m{tag}.yaml" open(p, "w", encoding="utf-8").write(text) r = subprocess.run([LINT, p], capture_output=True, text=True) os.remove(p) return r.returncode, sorted(set(re.findall(r"^([EW]\d{4})", r.stdout, re.M))) rc, rules = lint(SRC, 0) print(f"{'change':58s} {'cfn-lint 1.57.2':22s} basis") print(f"{'(control) template as written':58s} {'exit ' + str(rc)}") rows = [] for i, (label, basis, old, new) in enumerate(M, 1): assert SRC.count(old) == 1, (label, SRC.count(old)) rc, rules = lint(SRC.replace(old, new), i) verdict = "caught " + ",".join(r for r in rules if r.startswith("E")) if rc in (2, 6) else ("warning " + ",".join(rules) if rc == 4 else "missed") rows.append((label, basis, verdict)) print(f"{label:58s} {verdict:22s} {basis}") caught = sum(r[2].startswith("caught") for r in rows) print(f"\n{len(rows)} one-change mutations: cfn-lint reported an error for {caught}; {len(rows) - caught} passed or only warned") print("missed:", [r[0] for r in rows if not r[2].startswith("caught")]) --- py mutate_edge.py (saved output of the real run) --- change cfn-lint 1.57.2 basis (control) template as written exit 0 cache policy MinTTL 1 -> -1 caught E3034 cache policy: TTL values are seconds, not negative cache policy MinTTL above MaxTTL missed expiration page: min <= max (table cases) cache policy DefaultTTL 'a day' caught E3012 managed policies page: a number of seconds headers 'whitelist' with no header list missed cache policy: allowlist needs names (my understanding) ViewerProtocolPolicy 'https' caught E3030 distribution settings: allow-all, https-only, redirect-to-https PriceClass 'PriceClass_50' caught E3030 distribution settings: 100, 200, All HttpVersion 'http4' caught E3030 distribution settings OAC SigningBehavior 'sometimes' caught E3031 OAC page: always, never, no-override OAC origin type 'bucket' caught E3031 OAC page: s3 (and others) OAC with signing 'never' on a private bucket missed OAC page: the bucket must then be public bucket policy without the SourceArn Condition missed OAC page: use a Condition so only your distribution is allowed bucket policy Resource is the bucket, not bucket/* missed OAC page: arn:aws:s3:::bucket/* bucket policy trusts the wrong service missed OAC page: cloudfront.amazonaws.com certificate in eu-west-1 instead of us-east-1 missed certificate page: request it in us-east-1 alias domain with no certificate warning W2001 alternate names page: the certificate must cover the name custom error ErrorCachingMinTTL -5 missed error page: seconds (default 10) WebACL Scope REGIONAL while attached to CloudFront missed semantic (CloudFront needs the CLOUDFRONT scope) WebACL without a DefaultAction caught E3003 web ACL pages: the default action applies when no rule terminates rate-based Limit 1000 -> 5 caught E3034 rate-based page: lowest limit 10 rate-based EvaluationWindowSec 300 -> 90 caught E3030 rate-based page: 60, 120, 300 or 600 rate-based rule with Action Allow missed rate-based page: any action except Allow two rules with the same Priority missed priority page: unique within the web ACL a rule without VisibilityConfig caught E3003 (required property) managed rule group with a vendor that does not exist missed semantic apex record becomes a CNAME missed alias page: no CNAME at the apex alias record that also sets a TTL caught E3029 alias page: can't set the TTL alias hosted zone ID is not CloudFront's missed semantic (the alias zone ID of the target service) health check interval 20 s missed health check page: 10 or 30 s health check FailureThreshold 'three' caught E3012 health check page: a number health check type 'HTTP3' caught E3030 health check page: HTTP, HTTPS, TCP, ... weighted record without a SetIdentifier missed weighted page (records share a name and type) weighted Weight 'heavy' caught E3012 weighted page: a number 32 one-change mutations: cfn-lint reported an error for 15; 17 passed or only warned missed: ['cache policy MinTTL above MaxTTL', "headers 'whitelist' with no header list", "OAC with signing 'never' on a private bucket", 'bucket policy without the SourceArn Condition', 'bucket policy Resource is the bucket, not bucket/*', 'bucket policy trusts the wrong service', 'certificate in eu-west-1 instead of us-east-1', 'alias domain with no certificate', 'custom error ErrorCachingMinTTL -5', 'WebACL Scope REGIONAL while attached to CloudFront', 'rate-based rule with Action Allow', 'two rules with the same Priority', 'managed rule group with a vendor that does not exist', 'apex record becomes a CNAME', "alias hosted zone ID is not CloudFront's", 'health check interval 20 s', 'weighted record without a SetIdentifier'] --- edgecheck.py --- """Cross-property checks for edge.yaml-shaped templates, from the pages read on 2026-10-13 (CloudFront OAC, certificates, expiration, error caching; Route 53 alias, weighted and health-check pages; WAF rate-based and priority pages). A sketch for this template's shape. Two rules are my understanding rather than something on a page I read; they are tagged (understanding).""" import re import yaml class Loader(yaml.SafeLoader): pass def tag(loader, suffix, node): if isinstance(node, yaml.ScalarNode): return {suffix: loader.construct_scalar(node)} if isinstance(node, yaml.SequenceNode): return {suffix: loader.construct_sequence(node)} return {suffix: loader.construct_mapping(node)} Loader.add_multi_constructor("!", tag) def flat(v): """text of a value that may be a Sub/Ref/GetAtt wrapper""" return v if isinstance(v, str) else str(v) def check(text): t = yaml.load(text, Loader=Loader) R, P = t["Resources"], t.get("Parameters", {}) out = [] by_type = lambda ty: [(k, v) for k, v in R.items() if v["Type"] == ty] # cache policies for k, r in by_type("AWS::CloudFront::CachePolicy"): c = r["Properties"]["CachePolicyConfig"] if isinstance(c.get("MinTTL"), (int, float)) and isinstance(c.get("MaxTTL"), (int, float)) and c["MinTTL"] > c["MaxTTL"]: out.append(("ERROR", k, f"MinTTL {c['MinTTL']} is above MaxTTL {c['MaxTTL']} (the expiration table assumes min < max)")) h = c.get("ParametersInCacheKeyAndForwardedToOrigin", {}).get("HeadersConfig", {}) if h.get("HeaderBehavior") == "whitelist" and not h.get("Headers"): out.append(("ERROR", k, "HeaderBehavior whitelist with no Headers list (understanding)")) # OAC and the bucket policy for k, r in by_type("AWS::CloudFront::OriginAccessControl"): sb = r["Properties"]["OriginAccessControlConfig"].get("SigningBehavior") if sb in ("never", "no-override"): out.append(("WARN", k, f"SigningBehavior {sb}: with 'never' the bucket must be publicly readable (OAC page)")) for k, r in by_type("AWS::S3::BucketPolicy"): for s in r["Properties"]["PolicyDocument"]["Statement"]: p = s.get("Principal", {}) if p.get("Service") == "cloudfront.amazonaws.com": if not s.get("Condition") or "AWS:SourceArn" not in str(s["Condition"]): out.append(("ERROR", k, "CloudFront service principal without an AWS:SourceArn condition: any distribution could read the bucket (OAC page)")) if not flat(s.get("Resource")).endswith("/*") and "/*" not in flat(s.get("Resource")): out.append(("ERROR", k, "Resource is not bucket/* so GetObject on objects never matches (OAC page)")) elif p.get("Service") and p.get("Service") != "cloudfront.amazonaws.com" and "SiteBucket" in flat(s.get("Resource")): out.append(("ERROR", k, f"principal {p['Service']} is not cloudfront.amazonaws.com")) # distributions for k, r in by_type("AWS::CloudFront::Distribution"): c = r["Properties"]["DistributionConfig"] if c.get("Aliases") and not c.get("ViewerCertificate"): out.append(("ERROR", k, "alternate domain names without a ViewerCertificate (certificate page: it must cover the names)")) vc = c.get("ViewerCertificate", {}) arn = vc.get("AcmCertificateArn") names = [arn] if isinstance(arn, str) else [] if isinstance(arn, dict) and "Ref" in arn and arn["Ref"] in P: names = [P[arn["Ref"]].get("Default", "")] for n in names: m = re.match(r"arn:aws:acm:([a-z0-9-]+):", n) if m and m.group(1) != "us-east-1": out.append(("ERROR", k, f"ACM certificate in {m.group(1)}; for CloudFront it must be requested in us-east-1 (certificate page)")) for e in c.get("CustomErrorResponses", []): if isinstance(e.get("ErrorCachingMinTTL"), (int, float)) and e["ErrorCachingMinTTL"] < 0: out.append(("ERROR", k, "ErrorCachingMinTTL below 0")) w = c.get("WebACLId") wk = w["GetAtt"].split(".")[0] if isinstance(w, dict) and "GetAtt" in w else None if wk and wk in R and R[wk]["Properties"].get("Scope") != "CLOUDFRONT": out.append(("ERROR", k, f"web ACL {wk} has scope {R[wk]['Properties'].get('Scope')}; one used by CloudFront needs CLOUDFRONT (understanding)")) # web ACLs for k, r in by_type("AWS::WAFv2::WebACL"): pr = [x.get("Priority") for x in r["Properties"].get("Rules", [])] if len(set(pr)) != len(pr): out.append(("ERROR", k, "duplicate rule priorities (priority page: unique within the web ACL)")) for x in r["Properties"].get("Rules", []): st = x.get("Statement", {}) if "RateBasedStatement" in st: rb = st["RateBasedStatement"] if "Allow" in x.get("Action", {}): out.append(("ERROR", k, f"{x['Name']}: a rate-based rule cannot use Allow (rate-based page)")) if rb.get("EvaluationWindowSec", 300) not in (60, 120, 300, 600): out.append(("ERROR", k, f"{x['Name']}: window must be 60, 120, 300 or 600")) if isinstance(rb.get("Limit"), int) and rb["Limit"] < 10: out.append(("ERROR", k, f"{x['Name']}: lowest rate limit is 10")) mg = st.get("ManagedRuleGroupStatement") if mg and mg.get("VendorName") not in ("AWS", "AWSMarketplace") and not str(mg.get("VendorName")).startswith("!"): out.append(("WARN", k, f"{x['Name']}: vendor {mg.get('VendorName')} is not AWS (check the name)")) # Route 53 zone_apex = None for k, r in by_type("AWS::Route53::RecordSet"): p = r["Properties"] if p.get("Type") == "CNAME" and p.get("Name") == {"Ref": "DomainName"}: out.append(("ERROR", k, "CNAME at the apex of the domain being served (alias page: not allowed; use an alias A/AAAA record)")) if "AliasTarget" in p and "TTL" in p: out.append(("ERROR", k, "alias record with a TTL (alias page)")) if "Weight" in p and not p.get("SetIdentifier"): out.append(("ERROR", k, "weighted record without a SetIdentifier (understanding)")) at = p.get("AliasTarget", {}) if isinstance(at.get("DNSName"), dict) and "GetAtt" in at["DNSName"] and "Distribution" in at["DNSName"]["GetAtt"] and at.get("HostedZoneId") != "Z2FDTNDATAQYW2": out.append(("ERROR", k, "alias to a CloudFront distribution must use CloudFront's alias hosted zone ID Z2FDTNDATAQYW2 (understanding: the Route 53 alias-values page was not read)")) for k, r in by_type("AWS::Route53::HealthCheck"): iv = r["Properties"]["HealthCheckConfig"].get("RequestInterval") if iv not in (10, 30): out.append(("ERROR", k, f"RequestInterval {iv}: the page says every 10 seconds or every 30 seconds")) return out --- mutate_edge2.py --- """The same 32 mutations as mutate_edge.py, run through cfn-lint and edgecheck.py; shows what each tool adds.""" import os, re, subprocess import edgecheck src = open("C:/lwaws/c10/mutate_edge.py", encoding="utf-8").read() exec(src.split("def lint")[0]) # defines LINT, SRC and M def lint(text, tag): p = f"C:/lwaws/c10/_k{tag}.yaml" open(p, "w", encoding="utf-8").write(text) r = subprocess.run([LINT, p], capture_output=True, text=True) os.remove(p) return r.returncode in (2, 6) base = edgecheck.check(SRC) print("edgecheck on the unchanged template:", base or "no findings") print(f"{'change':58s} {'cfn-lint':10s} {'edgecheck':10s}") both = cl_only = my_only = neither = 0 missed = [] for i, (label, basis, old, new) in enumerate(M, 1): t = SRC.replace(old, new) a = lint(t, i) b = any(f[0] == "ERROR" for f in edgecheck.check(t)) or bool(edgecheck.check(t)) both += a and b; cl_only += a and not b; my_only += b and not a; neither += (not a) and (not b) if not a and not b: missed.append(label) print(f"{label:58s} {'error' if a else '-':10s} {'finding' if b else '-':10s}") print(f"\n{len(M)} changes: both tools {both}; cfn-lint only {cl_only}; edgecheck only {my_only}; neither {neither}") print("neither:", missed) --- py mutate_edge2.py (saved output of the real run) --- edgecheck on the unchanged template: no findings change cfn-lint edgecheck cache policy MinTTL 1 -> -1 error - cache policy MinTTL above MaxTTL - finding cache policy DefaultTTL 'a day' error - headers 'whitelist' with no header list - finding ViewerProtocolPolicy 'https' error - PriceClass 'PriceClass_50' error - HttpVersion 'http4' error - OAC SigningBehavior 'sometimes' error - OAC origin type 'bucket' error - OAC with signing 'never' on a private bucket - finding bucket policy without the SourceArn Condition - finding bucket policy Resource is the bucket, not bucket/* - finding bucket policy trusts the wrong service - finding certificate in eu-west-1 instead of us-east-1 - finding alias domain with no certificate - finding custom error ErrorCachingMinTTL -5 - finding WebACL Scope REGIONAL while attached to CloudFront - finding WebACL without a DefaultAction error - rate-based Limit 1000 -> 5 error finding rate-based EvaluationWindowSec 300 -> 90 error finding rate-based rule with Action Allow - finding two rules with the same Priority - finding a rule without VisibilityConfig error - managed rule group with a vendor that does not exist - finding apex record becomes a CNAME - finding alias record that also sets a TTL error finding alias hosted zone ID is not CloudFront's - finding health check interval 20 s - finding health check FailureThreshold 'three' error - health check type 'HTTP3' error - weighted record without a SetIdentifier - finding weighted Weight 'heavy' error - 32 changes: both tools 3; cfn-lint only 12; edgecheck only 17; neither 0 neither: [] --- cost10.py --- """Monthly cost lines for the chapter's design, from the pricing pages read on 2026-10-13 (fetch tool). READ: Route 53 hosted zone $0.50 (first 25), standard queries $0.40 per million (first billion), alias queries to CloudFront free, health checks $0.50 AWS endpoints / $0.75 other endpoints plus $1.00 / $2.00 per optional feature (HTTPS, string matching, fast interval), WAF $5.00 per web ACL + $1.00 per rule (each rule group counts) + $0.60 per million requests (rates from the page's examples; no Region table), CloudFront flat-rate plans. NOT READ / NOT ON THE PAGE TEXT: CloudFront pay-as-you-go data transfer and request prices, the price of invalidation paths above the free 1,000, Route 53 prices by Region, SQS-style details. Those lines are left out, not guessed.""" PLANS = {"Free": (0, "1M requests", "100 GB"), "Pro": (15, "10M requests", "50 TB"), "Business": (200, "125M requests", "50 TB"), "Premium": (1000, "500M requests", "50 TB")} print("== lines I can price (per month) ==") zone = 0.50 dns_queries_m = 20 # million queries a month to api.example.com (not alias), assumed dns = dns_queries_m * 0.40 alias_q = 0.0 # alias queries to CloudFront are free hc_aws, hc_other = 0.50, 0.75 hc_https_extra_other = 2.00 waf_acl, waf_rules, waf_req_m = 5.00, 2, 10 # 2 rules: the rate-based rule and the managed group; 10 million requests waf = waf_acl + waf_rules * 1.00 + waf_req_m * 0.60 print(f"Route 53 hosted zone ${zone:7.2f}") print(f"Route 53 standard queries, {dns_queries_m} million (api records) ${dns:7.2f}") print(f"Route 53 alias queries for the site (A and AAAA) ${alias_q:7.2f} (free for CloudFront targets)") print(f"Route 53 health check, HTTPS, endpoint outside AWS ${hc_other + hc_https_extra_other:7.2f} (0.75 basic + 2.00 HTTPS feature)") print(f"Route 53 health check, HTTPS, endpoint inside AWS ${hc_aws + 1.00:7.2f} (0.50 basic + 1.00 HTTPS feature)") print(f"WAF: 1 web ACL + {waf_rules} rules + {waf_req_m}M requests ${waf:7.2f} (5.00 + 2.00 + 6.00)") tot = zone + dns + hc_other + hc_https_extra_other + waf print(f"{'':54s}-------") print(f"sum of the priced lines (CloudFront delivery not included) ${tot:7.2f}") print("\n== CloudFront flat-rate plans (pricing page; one price per distribution, no overage charges) ==") for n, (p, req, dt) in PLANS.items(): print(f" {n:9s} ${p:>6,.2f} a month {req:>14s} {dt:>6s} data transfer") print("The WAF page lists what the plans include: CloudFront, AWS WAF and DDoS protection, bot management and analytics, Route 53 DNS,") print("CloudWatch Logs ingestion, a TLS certificate, serverless edge compute and S3 storage credits; a web ACL must stay associated.") print("I did not read the plan page, so I cannot say whether the WAF and Route 53 lines above would disappear under a plan, or how a") print("plan treats a web ACL's own charges, health checks or weighted records. Check that before choosing between pay-as-you-go and a plan.") print("\n== invalidations ==") print("first 1,000 paths a month are free across the account; a /* wildcard is one path; the price per path above that is not on the page text") --- py cost10.py (saved output of the real run) --- == lines I can price (per month) == Route 53 hosted zone $ 0.50 Route 53 standard queries, 20 million (api records) $ 8.00 Route 53 alias queries for the site (A and AAAA) $ 0.00 (free for CloudFront targets) Route 53 health check, HTTPS, endpoint outside AWS $ 2.75 (0.75 basic + 2.00 HTTPS feature) Route 53 health check, HTTPS, endpoint inside AWS $ 1.50 (0.50 basic + 1.00 HTTPS feature) WAF: 1 web ACL + 2 rules + 10M requests $ 13.00 (5.00 + 2.00 + 6.00) ------- sum of the priced lines (CloudFront delivery not included) $ 24.25 == CloudFront flat-rate plans (pricing page; one price per distribution, no overage charges) == Free $ 0.00 a month 1M requests 100 GB data transfer Pro $ 15.00 a month 10M requests 50 TB data transfer Business $200.00 a month 125M requests 50 TB data transfer Premium $1,000.00 a month 500M requests 50 TB data transfer The WAF page lists what the plans include: CloudFront, AWS WAF and DDoS protection, bot management and analytics, Route 53 DNS, CloudWatch Logs ingestion, a TLS certificate, serverless edge compute and S3 storage credits; a web ACL must stay associated. I did not read the plan page, so I cannot say whether the WAF and Route 53 lines above would disappear under a plan, or how a plan treats a web ACL's own charges, health checks or weighted records. Check that before choosing between pay-as-you-go and a plan. == invalidations == first 1,000 paths a month are free across the account; a /* wildcard is one path; the price per path above that is not on the page text -------------------------- Reading the result: moto 5.2.4 accepted all 9 invalid inputs that could be scored (CNAME at the apex, alias with a TTL, health check interval 20, weighted record without SetIdentifier [my understanding], rate-based window 90, rate-based Allow, duplicate rule priorities, an eu-west-1 certificate on a distribution, 101 alternate domain names) and all valid ones; it does not implement CloudFront cache policies (HTTP 404) and its rate-limit rejection came from boto3. cfn-lint caught 15 of 32 template mutations (types, ranges, enumerations); edgecheck.py caught 20, 17 of them ones cfn-lint missed. IMPORTANT: edgecheck.py's rules were written after I saw which mutations cfn-lint missed, so 'neither: 0' shows the rules can be written, not that the checker generalises. Two rules are my understanding (CLOUDFRONT scope for a CloudFront web ACL; CloudFront's alias hosted zone ID Z2FDTNDATAQYW2) and are tagged in the code. Mistakes made and fixed: (1) my first emulator run counted a boto3 client-side rejection and a 404 from an unimplemented route as emulator rejections, which made moto look better than it is; I separated them as 'not scored'. (2) A leftover counter line from an earlier draft of the script printed misleading text and had to be removed. (3) A first draft of the cost sketch said the WAF and DNS lines would disappear under a flat-rate plan; I had not read the plan page, so it now says it cannot tell. How to avoid them: classify every failure by who raised it (emulator, client library, missing route) before scoring, and re-read your own conclusions against what you actually read.