aws2-11 Exercise 1: GitHub Actions with OIDC: Workflow Checks and the Trust Policy Model ======================================================================== 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. --- wf/good.yml --- name: deploy on: push: branches: [main] pull_request: branches: [main] permissions: contents: read jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: echo "run the tests here" deploy: needs: test if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest environment: production permissions: id-token: write contents: read steps: - uses: actions/checkout@v4 - uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::123456789012:role/gha-deploy aws-region: eu-west-2 - run: aws sts get-caller-identity --- mutate_wf.py --- """One-change mutations of wf/good.yml, run through four checkers: PyYAML parse, check-jsonschema (GitHub workflow schema), actionlint 1.7.12, wfcheck.py. Each mutation is (label, old, new, is_mistake).""" import pathlib, subprocess, sys, tempfile, os import yaml import wfcheck B = pathlib.Path("C:/lwaws/c11") good = (B / "wf/good.yml").read_text(encoding="utf-8") M = [ ("remove id-token: write from the deploy job", " id-token: write\n contents: read\n steps:\n - uses: actions/checkout@v4\n - uses: aws", " contents: read\n steps:\n - uses: actions/checkout@v4\n - uses: aws", True), ("id-token: write at workflow level instead of the job", "permissions:\n contents: read\n\njobs:", "permissions:\n contents: read\n id-token: write\n\njobs:", True), ("id-token: writ (typo in the value)", "id-token: write", "id-token: writ", True), ("permissions: write-all at the top", "permissions:\n contents: read\n\njobs:", "permissions: write-all\n\njobs:", True), ("permission key typo: id-tokens", "id-token: write", "id-tokens: write", True), ("contents: reads (typo in the value)", " permissions:\n id-token: write\n contents: read", " permissions:\n id-token: write\n contents: reads", True), ("remove role-to-assume", " role-to-assume: arn:aws:iam::123456789012:role/gha-deploy\n", "", True), ("input typo: role-to-asume", "role-to-assume:", "role-to-asume:", True), ("static key beside the role", " - run: aws sts get-caller-identity", " - run: aws sts get-caller-identity\n env:\n AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}", True), ("action pinned to @main", "configure-aws-credentials@v4", "configure-aws-credentials@main", True), ("action with no ref", "uses: actions/checkout@v4\n - uses: aws", "uses: actions/checkout\n - uses: aws", True), ("role ARN with 11-digit account", "arn:aws:iam::123456789012:role", "arn:aws:iam::12345678901:role", True), ("role ARN with partition typo (arn:ws)", "arn:aws:iam::123456789012:role", "arn:ws:iam::123456789012:role", True), ("needs names a job that does not exist", "needs: test", "needs: tset", True), ("runs-on missing on deploy", " needs: test\n if: github.ref == 'refs/heads/main'\n runs-on: ubuntu-latest\n", " needs: test\n if: github.ref == 'refs/heads/main'\n", True), ("expression syntax error in if", "github.ref == 'refs/heads/main'", "github.ref = 'refs/heads/main'", True), ("unclosed expression in run", "echo \"run the tests here\"", "echo \"${{ github.sha \"", True), ("script injection: PR title in run", "echo \"run the tests here\"", "echo \"${{ github.event.pull_request.title }}\"", True), ("step with uses and run together", " - uses: actions/checkout@v4\n - run: echo", " - uses: actions/checkout@v4\n run: echo hi\n - run: echo", True), ("timeout-minutes: abc", " runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n - run: echo", " runs-on: ubuntu-latest\n timeout-minutes: abc\n steps:\n - uses: actions/checkout@v4\n - run: echo", True), ("duplicate step id", " - uses: actions/checkout@v4\n - run: echo", " - id: a\n uses: actions/checkout@v4\n - id: a\n run: echo", True), ("pull_request_target checks out the PR head", "on:\n push:\n branches: [main]\n pull_request:\n branches: [main]\n", "on:\n pull_request_target:\n branches: [main]\n", True), ("unknown trigger event name", " pull_request:\n branches: [main]", " pull_requests:\n branches: [main]", True), ("environment name differs from the trust policy (production -> prod)", "environment: production", "environment: prod", True), ("aws-region eu-west-9 (not a region)", "aws-region: eu-west-2", "aws-region: eu-west-9", True), ("both: role in the wrong account (0000...)", "123456789012:role/gha-deploy", "000000000000:role/gha-deploy", True), # not mistakes: must not be flagged ("OK: region changed to us-east-1", "aws-region: eu-west-2", "aws-region: us-east-1", False), ("OK: job renamed", " deploy:\n", " ship:\n", False), ("OK: extra echo step", " - run: aws sts get-caller-identity", " - run: aws sts get-caller-identity\n - run: echo done", False), ("OK: checkout pinned to a full commit SHA", "actions/checkout@v4\n - uses: aws", "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683\n - uses: aws", False), ] # the PR-head mutation needs a second change (a checkout with ref) def apply(i): label, old, new, _ = M[i] assert good.count(old) >= 1, label t = good.replace(old, new, 1) if label.startswith("pull_request_target"): t = t.replace(" - uses: actions/checkout@v4\n - uses: aws", " - uses: actions/checkout@v4\n with:\n ref: ${{ github.event.pull_request.head.sha }}\n - uses: aws", 1) return t def run(cmd): p = subprocess.run(cmd, capture_output=True, text=True) return p.returncode, (p.stdout + p.stderr).strip() def judge(t): r = {} try: yaml.safe_load(t); r["yaml"] = False except Exception: r["yaml"] = True fn = tempfile.NamedTemporaryFile("w", suffix=".yml", delete=False, encoding="utf-8", dir=str(B / "wf")) fn.write(t); fn.close() code, out = run([str(B / "venv/Scripts/check-jsonschema.exe"), "--builtin-schema", "vendor.github-workflows", fn.name]) r["schema"] = code != 0 code, out = run([str(B / "venv/Scripts/actionlint.exe"), "-no-color", fn.name]) r["actionlint"] = code != 0 r["actionlint_msg"] = out.splitlines()[0].split(":", 3)[-1].strip()[:70] if code else "" try: r["wfcheck"] = bool(wfcheck.check(t)); r["wfrules"] = ",".join(sorted({x[0] for x in wfcheck.check(t)})) except Exception as e: r["wfcheck"] = True; r["wfrules"] = "crash:" + type(e).__name__ os.unlink(fn.name) return r print(f"{'change':72s} yaml schema alint wfchk") c = judge(good) print(f"{'(control: unchanged good.yml)':72s} {int(c['yaml']):4d} {int(c['schema']):6d} {int(c['actionlint']):5d} {int(c['wfcheck']):5d} {c['actionlint_msg']}") tot = {"yaml": 0, "schema": 0, "actionlint": 0, "wfcheck": 0}; anyc = 0; nm = 0; fp = 0; onlyw = 0; none = 0 for i, (label, _, _, mist) in enumerate(M): r = judge(apply(i)) flags = [r["yaml"], r["schema"], r["actionlint"], r["wfcheck"]] print(f"{label[:72]:72s} {int(flags[0]):4d} {int(flags[1]):6d} {int(flags[2]):5d} {int(flags[3]):5d} {r['actionlint_msg'] or r['wfrules']}") if mist: nm += 1 for k, f in zip(tot, flags): tot[k] += f anyc += any(flags[:3]); none += not any(flags) onlyw += (flags[3] and not any(flags[:3])) else: fp += any(flags) print(f"\n{nm} mistakes: caught by PyYAML {tot['yaml']}, schema {tot['schema']}, actionlint {tot['actionlint']}, wfcheck {tot['wfcheck']}") print(f"caught by at least one of the three off-the-shelf checks: {anyc}; by wfcheck only: {onlyw}; by nothing: {none}; false alarms on the {len(M)-nm} OK changes: {fp}") --- wfcheck.py --- """Cross-field checks for a GitHub Actions deploy workflow that assumes an AWS role with OIDC. Rules R1-R7 and R9-R10 were written BEFORE the mutation run, from the pages read on 2026-10-13 (GitHub OIDC reference: id-token: write is required; configure-aws-credentials README: role-to-assume, no stored keys, take care with pull_request_target, sts.amazonaws.com audience). R8 (region list) was left out on purpose: the region list was not read. Returns a list of (rule, message).""" import re import yaml ARN_ROLE = re.compile(r"^arn:aws:iam::\d{12}:role/[\w+=,.@/-]+$") INJECT = re.compile(r"\$\{\{\s*github\.(event\.(pull_request|issue|comment|head_commit|review)\.[\w.]*(title|body|ref|label|message|name)|head_ref)\b") KEY_NAMES = ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "aws-access-key-id", "aws-secret-access-key") def load(text): d = yaml.safe_load(text) if True in d and "on" not in d: # YAML 1.1 reads the key `on` as boolean True d["on"] = d.pop(True) return d def perms_of(job, wf): p = job.get("permissions", wf.get("permissions")) return p def has_id_token(p): if p == "write-all": return True return isinstance(p, dict) and p.get("id-token") == "write" def check(text): out = [] wf = load(text) triggers = wf.get("on") or {} names = triggers if isinstance(triggers, list) else list(triggers) if isinstance(triggers, dict) else [triggers] if wf.get("permissions") == "write-all": out.append(("R3", "workflow permissions: write-all")) # R2 (corrected after the first run: it was written to fire here, but the first version only looked at the deploy job) if has_id_token(wf.get("permissions")) and wf.get("permissions") != "write-all": for jn, job in (wf.get("jobs") or {}).items(): if "permissions" not in job and not any("configure-aws-credentials" in s.get("uses", "") for s in job.get("steps") or []): out.append(("R2", f"{jn}: inherits id-token: write from the workflow level but does not assume a role")) for jn, job in (wf.get("jobs") or {}).items(): steps = job.get("steps") or [] for s in steps: u = s.get("uses", "") w = s.get("with") or {} e = {**(wf.get("env") or {}), **(job.get("env") or {}), **(s.get("env") or {})} if "configure-aws-credentials" in u: if "role-to-assume" in w: if not ARN_ROLE.match(str(w["role-to-assume"])): out.append(("R7", f"{jn}: role-to-assume is not a role ARN with a 12-digit account: {w['role-to-assume']}")) if not has_id_token(perms_of(job, wf)): out.append(("R1", f"{jn}: assumes a role with OIDC but its permissions lack id-token: write")) elif "id-token" not in (job.get("permissions") or {}) and wf.get("permissions") not in (None, "write-all"): out.append(("R2", f"{jn}: id-token: write comes from the workflow level, so every job has it")) elif not any(k in w for k in KEY_NAMES): out.append(("R9", f"{jn}: configure-aws-credentials with no role-to-assume")) if u: ref = u.split("@", 1)[1] if "@" in u else "" if not ref or ref in ("main", "master"): out.append(("R5", f"{jn}: action {u} is not pinned to a tag or SHA")) if any(k in e for k in KEY_NAMES) or any(k in w for k in KEY_NAMES): out.append(("R4", f"{jn}: static AWS key set in env or with")) if INJECT.search(s.get("run", "")): out.append(("R10", f"{jn}: untrusted event text used inside run:")) if "pull_request_target" in names and "actions/checkout" in u: ref = str(w.get("ref", "")) if "pull_request.head" in ref: out.append(("R6", f"{jn}: pull_request_target checks out the pull request's head")) if "pull_request_target" in names and has_id_token(perms_of(job, wf)): out.append(("R6b", f"{jn}: pull_request_target job holds id-token: write")) return out if __name__ == "__main__": import sys for r in check(open(sys.argv[1], encoding="utf-8").read()): print(*r) --- py mutate_wf.py (saved output of the real run) --- change yaml schema alint wfchk (control: unchanged good.yml) 0 0 0 0 remove id-token: write from the deploy job 0 0 0 1 R1 id-token: write at workflow level instead of the job 0 0 0 1 R2 id-token: writ (typo in the value) 0 1 1 1 "writ" is invalid as permission of scope "id-token". available values permissions: write-all at the top 0 0 0 1 R3 permission key typo: id-tokens 0 1 1 1 unknown permission scope "id-tokens". all available permission scopes contents: reads (typo in the value) 0 1 1 0 "reads" is invalid as permission of scope "contents". available values remove role-to-assume 0 0 0 1 R9 input typo: role-to-asume 0 0 1 1 input "role-to-asume" is not defined in action "aws-actions/configure- static key beside the role 0 0 0 1 R4 action pinned to @main 0 0 0 1 R5 action with no ref 0 0 1 1 specifying action "actions/checkout" in invalid format because ref is role ARN with 11-digit account 0 0 0 1 R7 role ARN with partition typo (arn:ws) 0 0 0 1 R7 needs names a job that does not exist 0 0 1 0 job "deploy" needs job "tset" which does not exist in this workflow [j runs-on missing on deploy 0 1 1 0 "runs-on" section is missing in job "deploy" [syntax-check] expression syntax error in if 0 0 1 0 got unexpected character ' ' while lexing == operator, expecting '=' [ unclosed expression in run 0 0 1 0 got unexpected character '"' while lexing expression, expecting 'a'..' script injection: PR title in run 0 0 1 1 "github.event.pull_request.title" is potentially untrusted. avoid usin step with uses and run together 0 1 1 0 unexpected key "uses" for step to run shell command. expected one of " timeout-minutes: abc 0 1 1 0 expecting a single ${{...}} expression or float number literal, but fo duplicate step id 0 0 1 0 step ID "a" duplicates. previously defined at line:15,col:13. step ID pull_request_target checks out the PR head 0 0 0 1 R6,R6b unknown trigger event name 0 1 1 0 unknown Webhook event "pull_requests". see https://docs.github.com/en/ environment name differs from the trust policy (production -> prod) 0 0 0 0 aws-region eu-west-9 (not a region) 0 0 0 0 both: role in the wrong account (0000...) 0 0 0 0 OK: region changed to us-east-1 0 0 0 0 OK: job renamed 0 0 0 0 OK: extra echo step 0 0 0 0 OK: checkout pinned to a full commit SHA 0 0 0 0 26 mistakes: caught by PyYAML 0, schema 7, actionlint 14, wfcheck 14 caught by at least one of the three off-the-shelf checks: 14; by wfcheck only: 9; by nothing: 3; false alarms on the 4 OK changes: 0 --- oidc_trust.py --- """Model of the AWS side of GitHub OIDC trust, from the pages read on 2026-10-13: - GitHub OIDC reference: default sub formats (environment / pull_request / branch / tag), ':' in values becomes %3A, aud defaults to the repository owner's URL unless the workflow asks for another; immutable format repo:OWNER@OWNER-ID/REPO@REPO-ID:... for repositories created after 2026-07-15. - configure-aws-credentials README: StringEquals on :aud = sts.amazonaws.com and on :sub. - IAM OIDC page: the aud claim must match an audience registered on the provider. Only StringEquals and StringLike on :sub and :aud are modelled (the operators the pages show); everything else raises NotModelled. The audience the real action requests is 'sts.amazonaws.com' (README); that it is the DEFAULT of the action is my understanding, not read here. """ import fnmatch, re class NotModelled(Exception): pass def sub_for(repo, event, ref=None, environment=None, immutable_ids=None): """Default sub claim. repo is 'owner/name'; immutable_ids=(owner_id, repo_id) switches to the immutable format.""" owner, name = repo.split("/") head = f"repo:{owner}@{immutable_ids[0]}/{name}@{immutable_ids[1]}" if immutable_ids else f"repo:{repo}" esc = lambda s: s.replace(":", "%3A") if environment: return f"{head}:environment:{esc(environment)}" if event == "pull_request": return f"{head}:pull_request" if ref: return f"{head}:ref:{esc(ref)}" raise ValueError("need environment, a pull_request event or a ref") def glob_match(pattern, value): # IAM StringLike: * = any run of characters, ? = one character (case-sensitive) rx = "".join(".*" if c == "*" else "." if c == "?" else re.escape(c) for c in pattern) return re.fullmatch(rx, value, re.S) is not None def cond_ok(cond, claims): for op, kv in cond.items(): if op not in ("StringEquals", "StringLike"): raise NotModelled(op) for key, want in kv.items(): claim = key.split(":", 1)[1] vals = want if isinstance(want, list) else [want] have = claims.get(claim) if have is None: return False if op == "StringEquals": ok = have in vals else: ok = any(glob_match(v, have) for v in vals) if not ok: return False return True def can_assume(trust, token, provider_audiences=("sts.amazonaws.com",), provider_arn="arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"): """token: dict(iss, aud, sub). True if some Allow statement lets this web identity call sts:AssumeRoleWithWebIdentity.""" if token["iss"] != "https://token.actions.githubusercontent.com": return False if token["aud"] not in provider_audiences: # IAM page: aud must match a registered audience return False claims = {"aud": token["aud"], "sub": token["sub"]} for st in trust["Statement"]: if st.get("Effect") != "Allow": raise NotModelled("Effect " + str(st.get("Effect"))) if "sts:AssumeRoleWithWebIdentity" not in (st["Action"] if isinstance(st["Action"], list) else [st["Action"]]): continue if st["Principal"].get("Federated") != provider_arn: continue if cond_ok(st.get("Condition", {}), claims): return True return False def trust(sub_cond, aud="sts.amazonaws.com", sub_op="StringEquals", with_aud=True): cond = {} if with_aud: cond["StringEquals"] = {"token.actions.githubusercontent.com:aud": aud} if sub_cond is not None: cond.setdefault(sub_op, {})["token.actions.githubusercontent.com:sub"] = sub_cond return {"Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Action": "sts:AssumeRoleWithWebIdentity", "Principal": {"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"}, "Condition": cond}]} --- oidc_tests.py --- """Who can assume the role? Matrix of trust policies x tokens. Expected values come from the documented sub formats and the StringEquals/StringLike rules; they check the model against my reading of the pages, not against AWS.""" from oidc_trust import * ISS = "https://token.actions.githubusercontent.com" tok = lambda sub, aud="sts.amazonaws.com": {"iss": ISS, "aud": aud, "sub": sub} R = "acme/shop" T = { "main": tok(sub_for(R, "push", ref="refs/heads/main")), "feature": tok(sub_for(R, "push", ref="refs/heads/feature/x")), "PR": tok(sub_for(R, "pull_request")), "env production": tok(sub_for(R, "push", ref="refs/heads/main", environment="production")), "env staging": tok(sub_for(R, "push", ref="refs/heads/main", environment="staging")), "tag v1.0": tok(sub_for(R, "push", ref="refs/tags/v1.0")), "other repo acme/blog main": tok(sub_for("acme/blog", "push", ref="refs/heads/main")), "other owner evil/shop main": tok(sub_for("evil/shop", "push", ref="refs/heads/main")), "lookalike acme/shop-evil main": tok(sub_for("acme/shop-evil", "push", ref="refs/heads/main")), "main, wrong audience": tok(sub_for(R, "push", ref="refs/heads/main"), aud="https://github.com/acme"), } P = { "A aud only, no sub condition": trust(None), "B sub = main branch (StringEquals)": trust(f"repo:{R}:ref:refs/heads/main"), "C sub = environment production": trust(f"repo:{R}:environment:production"), "D sub like repo:acme/shop:*": trust(f"repo:{R}:*", sub_op="StringLike"), "E sub like repo:acme/*": trust("repo:acme/*", sub_op="StringLike"), "F sub like repo:acme/shop*": trust("repo:acme/shop*", sub_op="StringLike"), "G sub = main OR production (list)": trust([f"repo:{R}:ref:refs/heads/main", f"repo:{R}:environment:production"]), "H no aud condition, sub = main": trust(f"repo:{R}:ref:refs/heads/main", with_aud=False), } # what I expect from the documented rules: set of token names allowed all_ok = set(T) - {"main, wrong audience"} EXP = { "A aud only, no sub condition": all_ok, "B sub = main branch (StringEquals)": {"main"}, "C sub = environment production": {"env production"}, "D sub like repo:acme/shop:*": {"main", "feature", "PR", "env production", "env staging", "tag v1.0"}, "E sub like repo:acme/*": {"main", "feature", "PR", "env production", "env staging", "tag v1.0", "other repo acme/blog main", "lookalike acme/shop-evil main"}, "F sub like repo:acme/shop*": {"main", "feature", "PR", "env production", "env staging", "tag v1.0", "lookalike acme/shop-evil main"}, "G sub = main OR production (list)": {"main", "env production"}, "H no aud condition, sub = main": {"main", "main, wrong audience"}, } # H: with no aud condition the policy lets a token through if the PROVIDER accepts the audience; 'wrong audience' is rejected by the provider EXP["H no aud condition, sub = main"] = {"main"} names = list(T) print(f"{'':40s}" + " ".join(f"{i:>2d}" for i in range(len(names)))) for i, n in enumerate(names): print(f" {i:2d} = {n}") print() bad = 0 for pn, pol in P.items(): row = [] got = set() for tn in names: ok = can_assume(pol, T[tn]); row.append("Y" if ok else ".") if ok: got.add(tn) flag = "ok " if got == EXP[pn] else "DIFF" bad += got != EXP[pn] print(f"{flag} {pn:36s} " + " ".join(row) + f" {len(got)} allowed") print(f"\n{len(P)} policies x {len(names)} tokens = {len(P)*len(names)} cells; policies whose row differs from my expectation: {bad}") print("\n-- immutable format (repos created after 2026-07-15) --") imm = tok(sub_for(R, "push", ref="refs/heads/main", immutable_ids=(123456, 456789))) print("token sub:", imm["sub"]) print("policy B (old-format sub) allows it:", can_assume(P["B sub = main branch (StringEquals)"], imm)) pol_new = trust("repo:acme@123456/shop@456789:ref:refs/heads/main") print("policy with owner/repo IDs allows it:", can_assume(pol_new, imm)) print("policy with owner/repo IDs allows the OLD-format token for the same repo:", can_assume(pol_new, T["main"])) print("policy D (repo:acme/shop:*) allows it:", can_assume(P["D sub like repo:acme/shop:*"], imm)) print("\n-- ':' in a ref (documented as %3A) --") t = tok(sub_for(R, "push", ref="refs/heads/fix:thing")) print("sub:", t["sub"]) print("policy D allows it:", can_assume(P["D sub like repo:acme/shop:*"], t)) print("\n-- NotModelled --") try: can_assume({"Statement": [{"Effect": "Allow", "Action": "sts:AssumeRoleWithWebIdentity", "Principal": {"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"}, "Condition": {"ForAllValues:StringLike": {"token.actions.githubusercontent.com:sub": ["repo:acme/*"]}}}]}, T["main"]) except NotModelled as e: print("ForAllValues:StringLike ->", "NotModelled", e) --- py oidc_tests.py (saved output of the real run) --- 0 1 2 3 4 5 6 7 8 9 0 = main 1 = feature 2 = PR 3 = env production 4 = env staging 5 = tag v1.0 6 = other repo acme/blog main 7 = other owner evil/shop main 8 = lookalike acme/shop-evil main 9 = main, wrong audience ok A aud only, no sub condition Y Y Y Y Y Y Y Y Y . 9 allowed ok B sub = main branch (StringEquals) Y . . . . . . . . . 1 allowed ok C sub = environment production . . . Y . . . . . . 1 allowed ok D sub like repo:acme/shop:* Y Y Y Y Y Y . . . . 6 allowed ok E sub like repo:acme/* Y Y Y Y Y Y Y . Y . 8 allowed ok F sub like repo:acme/shop* Y Y Y Y Y Y . . Y . 7 allowed ok G sub = main OR production (list) Y . . Y . . . . . . 2 allowed ok H no aud condition, sub = main Y . . . . . . . . . 1 allowed 8 policies x 10 tokens = 80 cells; policies whose row differs from my expectation: 0 -- immutable format (repos created after 2026-07-15) -- token sub: repo:acme@123456/shop@456789:ref:refs/heads/main policy B (old-format sub) allows it: False policy with owner/repo IDs allows it: True policy with owner/repo IDs allows the OLD-format token for the same repo: False policy D (repo:acme/shop:*) allows it: False -- ':' in a ref (documented as %3A) -- sub: repo:acme/shop:ref:refs/heads/fix%3Athing policy D allows it: True -- NotModelled -- ForAllValues:StringLike -> NotModelled ForAllValues:StringLike --- cross_check.py --- """Workflow file -> the sub claim its deploy job will send -> the trust policy from pipeline.yaml. Uses wfcheck.load, oidc_trust and pipecheck. sub formats are the documented defaults (environment wins over pull_request and ref).""" import re import wfcheck, oidc_trust as O, pipecheck wf_text = open("C:/lwaws/c11/wf/good.yml", encoding="utf-8").read() pipe_text = open("C:/lwaws/c11/pipeline.yaml", encoding="utf-8").read() import yaml t = yaml.load(pipe_text, Loader=pipecheck.L) params = {k: v.get("Default") for k, v in t["Parameters"].items()} trust = pipecheck.resolve(t["Resources"]["DeployRole"]["Properties"]["AssumeRolePolicyDocument"], params) ISS = "https://token.actions.githubusercontent.com" repo = params["GitHubRepo"] def verdict(label, wf, event, ref): job = wf["jobs"]["deploy"] env = job.get("environment") env = env.get("name") if isinstance(env, dict) else env sub = O.sub_for(repo, event, ref=ref, environment=env) ok = O.can_assume(trust, {"iss": ISS, "aud": "sts.amazonaws.com", "sub": sub}) print(f"{label:62s} sub={sub:52s} {'ASSUMES' if ok else 'refused'}") wf = wfcheck.load(wf_text) verdict("good workflow, push to main", wf, "push", "refs/heads/main") verdict("good workflow, pull_request event (job has environment:)", wf, "pull_request", "refs/pull/7/merge") wf2 = wfcheck.load(wf_text.replace(" environment: production\n", "")) verdict("environment: line removed, push to main", wf2, "push", "refs/heads/main") verdict("environment: line removed, pull_request event", wf2, "pull_request", "refs/pull/7/merge") wf3 = wfcheck.load(wf_text.replace("environment: production", "environment: prod")) verdict("environment: prod (renamed), push to main", wf3, "push", "refs/heads/main") print("\nnote: the job's 'if: github.ref == refs/heads/main' is what keeps the pull_request run away from the deploy step in the good workflow;") print("the trust policy alone does not stop it, because the environment sub is the same for every trigger. (GitHub environment protection rules are a") print("separate control: not read this session.)") --- py cross_check.py (saved output of the real run) --- good workflow, push to main sub=repo:acme/shop:environment:production ASSUMES good workflow, pull_request event (job has environment:) sub=repo:acme/shop:environment:production ASSUMES environment: line removed, push to main sub=repo:acme/shop:ref:refs/heads/main refused environment: line removed, pull_request event sub=repo:acme/shop:pull_request refused environment: prod (renamed), push to main sub=repo:acme/shop:environment:prod refused note: the job's 'if: github.ref == refs/heads/main' is what keeps the pull_request run away from the deploy step in the good workflow; the trust policy alone does not stop it, because the environment sub is the same for every trigger. (GitHub environment protection rules are a separate control: not read this session.) -------------------------- Reading the result: actionlint caught 14 of 26 workflow mistakes, the workflow schema 7, PyYAML none; wfcheck.py caught 14, 9 of them missed by all three off-the-shelf tools; 3 are missed by everything (a Region that does not exist, a role in the wrong account, and an environment name that no longer matches the trust policy, which only the cross-check sees). The trust-policy matrix (8 policies x 10 tokens) agrees with my expectations; they come from the documented sub formats and StringEquals/StringLike rules, so they test the model against my reading, not against AWS. Policy A (audience only) admits every token with the right audience, including another owner's repository. Not read: GitHub environment protection rules, fork and pull_request_target behaviour, whether the audience condition adds anything when the provider only registers sts.amazonaws.com. Mistake made and fixed: rule R2 in wfcheck.py (id-token: write granted at workflow level) was meant to fire for any job that inherits it but I implemented it so that it only looked at the deploy job, so the first run missed that change (13 catches); I corrected the rule afterwards and the table shows 14. How to avoid it: for every rule in a checker, include a test where it must fire, before you trust a 'clean' result. cross_check.py needs pipecheck.py from exercise 3 (it reuses its template loader).