subcheck 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
subcheck/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """subcheck: decode and validate GitHub Actions OIDC token claims."""
2
+
3
+ from .decoder import decode_claims, parse_github_sub
4
+ from .policy import ClaimRule, Policy, load_policy, load_policy_file
5
+ from .report import build_report, to_json, to_text
6
+ from .validator import Result, validate
7
+
8
+ __version__ = "0.2.0"
9
+
10
+ __all__ = [
11
+ "decode_claims",
12
+ "parse_github_sub",
13
+ "ClaimRule",
14
+ "Policy",
15
+ "load_policy",
16
+ "load_policy_file",
17
+ "Result",
18
+ "validate",
19
+ "build_report",
20
+ "to_json",
21
+ "to_text",
22
+ "__version__",
23
+ ]
subcheck/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Enable `python -m subcheck`."""
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
subcheck/cli.py ADDED
@@ -0,0 +1,80 @@
1
+ """Command-line interface for subcheck."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from . import __version__
11
+ from .decoder import decode_claims
12
+ from .policy import load_policy_file
13
+ from .report import build_report, to_json, to_text
14
+ from .validator import validate
15
+
16
+
17
+ def _load_claims(args: argparse.Namespace) -> dict:
18
+ if args.token:
19
+ token = sys.stdin.read() if args.token == "-" else args.token # noqa: S105 # nosec B105 - a JWT, not a secret to store
20
+ return decode_claims(token)
21
+ if args.token_file:
22
+ return decode_claims(Path(args.token_file).read_text(encoding="utf-8"))
23
+ if args.claims:
24
+ return json.loads(Path(args.claims).read_text(encoding="utf-8"))
25
+ raise ValueError("provide one of --token, --token-file, or --claims")
26
+
27
+
28
+ def _build_parser() -> argparse.ArgumentParser:
29
+ parser = argparse.ArgumentParser(
30
+ prog="subcheck",
31
+ description="Decode GitHub Actions OIDC token claims and validate them "
32
+ "against an expected-claims policy.",
33
+ )
34
+ parser.add_argument(
35
+ "--version", action="version", version=f"%(prog)s {__version__}"
36
+ )
37
+ src = parser.add_argument_group("claims input (choose one)")
38
+ src.add_argument("--token", help="the OIDC JWT ('-' reads from stdin)")
39
+ src.add_argument("--token-file", help="path to a file containing the OIDC JWT")
40
+ src.add_argument("--claims", help="path to a JSON file of already-decoded claims")
41
+ parser.add_argument(
42
+ "--policy", help="path to a policy file (.yaml/.yml/.json); omit to only decode"
43
+ )
44
+ parser.add_argument(
45
+ "--format", choices=["text", "json"], default="text",
46
+ help="output format (default: text)",
47
+ )
48
+ return parser
49
+
50
+
51
+ def main(argv: list[str] | None = None) -> int:
52
+ args = _build_parser().parse_args(argv)
53
+
54
+ try:
55
+ claims = _load_claims(args)
56
+ except (ValueError, OSError, json.JSONDecodeError) as exc:
57
+ print(f"error: {exc}", file=sys.stderr)
58
+ return 2
59
+
60
+ if not args.policy:
61
+ if args.format == "json":
62
+ print(json.dumps(claims, indent=2))
63
+ else:
64
+ for key, value in claims.items():
65
+ print(f"{key}: {value}")
66
+ return 0
67
+
68
+ try:
69
+ policy = load_policy_file(args.policy)
70
+ except (ValueError, OSError, RuntimeError) as exc:
71
+ print(f"error: {exc}", file=sys.stderr)
72
+ return 2
73
+
74
+ report = build_report(claims, validate(claims, policy))
75
+ print(to_json(report) if args.format == "json" else to_text(report))
76
+ return 0 if report["passed"] else 1
77
+
78
+
79
+ if __name__ == "__main__":
80
+ raise SystemExit(main())
subcheck/decoder.py ADDED
@@ -0,0 +1,98 @@
1
+ """Decode a GitHub Actions OIDC JSON Web Token into its claims.
2
+
3
+ This decodes the token payload for INSPECTION only. It does NOT verify the token
4
+ signature - verifying the signature against GitHub's JWKS is the cloud provider's
5
+ job at role-assumption time. Never trust these claims as an authentication control;
6
+ use them to catch a misconfigured trust policy before it ships.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import binascii
13
+ import json
14
+ import re
15
+
16
+
17
+ def _b64url_decode(segment: str) -> bytes:
18
+ padding = "=" * (-len(segment) % 4)
19
+ return base64.urlsafe_b64decode(segment + padding)
20
+
21
+
22
+ def decode_claims(token: str) -> dict:
23
+ """Decode the claims (payload) of a JWT without verifying its signature."""
24
+ parts = token.strip().split(".")
25
+ if len(parts) != 3:
26
+ raise ValueError(
27
+ f"not a JWT: expected 3 dot-separated segments, got {len(parts)}"
28
+ )
29
+ try:
30
+ payload = _b64url_decode(parts[1])
31
+ except (ValueError, binascii.Error) as exc:
32
+ raise ValueError(f"could not base64url-decode the JWT payload: {exc}") from exc
33
+ try:
34
+ claims = json.loads(payload)
35
+ except json.JSONDecodeError as exc:
36
+ raise ValueError(f"JWT payload is not valid JSON: {exc}") from exc
37
+ if not isinstance(claims, dict):
38
+ raise ValueError("JWT payload did not decode to a JSON object")
39
+ return claims
40
+
41
+
42
+ # repo:OWNER[@owner_id]/REPO[@repo_id]: ... -- the immutable format appends a numeric
43
+ # owner/repo ID (mandatory for repos created, renamed, or transferred after 2026-07-15).
44
+ # Owner/repo names exclude the '/', '@', ':' delimiters. Mirrors the subvectors subject
45
+ # grammar so the two agree on what a concrete subject decodes to.
46
+ _SUB_RE = re.compile(
47
+ r"^repo:"
48
+ r"(?P<owner>[^/@:]+)(?:@(?P<owner_id>\d+))?"
49
+ r"/"
50
+ r"(?P<repo>[^/@:]+)(?:@(?P<repo_id>\d+))?"
51
+ r"(?::(?P<context>.*))?$"
52
+ )
53
+
54
+
55
+ def parse_github_sub(sub: str) -> dict:
56
+ """Best-effort parse of the GitHub Actions ``sub`` claim into its components.
57
+
58
+ Handles both the classic name-based format and the immutable format that appends
59
+ numeric owner/repo IDs (``repo:owner@123/repo@456:...``). ``format`` is ``"immutable"``
60
+ only when BOTH IDs are present (the documented grammar always carries ``@ID`` on both
61
+ segments), ``"legacy"`` when neither is, and ``"malformed"`` when exactly one is —
62
+ a shape GitHub never mints, so it signals a hand-edited or half-migrated value.
63
+ ``repository`` is always the ``owner/repo`` names.
64
+
65
+ Examples::
66
+
67
+ repo:acme/api:ref:refs/heads/main -> repository, context=ref, ref, format=legacy
68
+ repo:acme/api:environment:production -> repository, context=environment, environment
69
+ repo:acme@1/api@2:ref:refs/heads/main -> repository, repository_id, ..., format=immutable
70
+ repo:acme/api@2:ref:refs/heads/main -> format=malformed (only one ID present)
71
+ repo:acme/api:pull_request -> repository, context=pull_request
72
+ """
73
+ out: dict = {"raw": sub}
74
+ m = _SUB_RE.match(sub)
75
+ if m is None:
76
+ return out
77
+ owner, repo = m.group("owner"), m.group("repo")
78
+ owner_id, repo_id = m.group("owner_id"), m.group("repo_id")
79
+ out["repository_owner"] = owner
80
+ out["repository"] = f"{owner}/{repo}"
81
+ if owner_id is not None:
82
+ out["repository_owner_id"] = owner_id
83
+ if repo_id is not None:
84
+ out["repository_id"] = repo_id
85
+ if owner_id and repo_id:
86
+ out["format"] = "immutable"
87
+ elif owner_id or repo_id:
88
+ out["format"] = "malformed" # GitHub always emits @ID on both segments, or neither
89
+ else:
90
+ out["format"] = "legacy"
91
+ context = m.group("context")
92
+ if not context:
93
+ return out
94
+ kind, _, value = context.partition(":")
95
+ out["context"] = kind
96
+ if value:
97
+ out[kind] = value
98
+ return out
subcheck/policy.py ADDED
@@ -0,0 +1,115 @@
1
+ """Load an expected-claims policy and represent it as claim rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ # Trust-boundary anchors are high severity; contextual claims default to medium.
10
+ CLAIM_SEVERITY = {
11
+ "iss": "high",
12
+ "aud": "high",
13
+ "sub": "high",
14
+ "repository": "high",
15
+ "repository_owner": "high",
16
+ "repository_id": "high", # immutable trust anchors (survive rename/transfer)
17
+ "repository_owner_id": "high",
18
+ # the only claim constraining WHICH workflow code minted the token; AWS accepts it as an
19
+ # alternative identity-provider control to sub.
20
+ "job_workflow_ref": "high",
21
+ "ref": "medium",
22
+ "environment": "medium",
23
+ "runner_environment": "medium",
24
+ "actor": "medium",
25
+ }
26
+ DEFAULT_SEVERITY = "medium"
27
+
28
+ _ALLOWED_KEYS = {"equals", "in", "matches", "glob", "required"}
29
+
30
+
31
+ @dataclass
32
+ class ClaimRule:
33
+ name: str
34
+ equals: str | None = None
35
+ one_of: list | None = None
36
+ matches: str | None = None # regex, applied with re.search
37
+ glob: str | None = None # fnmatch-style pattern
38
+ required: bool = True
39
+
40
+ @property
41
+ def severity(self) -> str:
42
+ return CLAIM_SEVERITY.get(self.name, DEFAULT_SEVERITY)
43
+
44
+ def describe(self) -> str:
45
+ if self.equals is not None:
46
+ return f"equals {self.equals!r}"
47
+ if self.one_of is not None:
48
+ return f"one of {self.one_of!r}"
49
+ if self.matches is not None:
50
+ return f"matches /{self.matches}/"
51
+ if self.glob is not None:
52
+ return f"glob {self.glob!r}"
53
+ return "present"
54
+
55
+
56
+ @dataclass
57
+ class Policy:
58
+ rules: list = field(default_factory=list)
59
+
60
+
61
+ def load_policy(data: dict) -> Policy:
62
+ """Build a Policy from a parsed mapping (see the README for the schema)."""
63
+ if not isinstance(data, dict):
64
+ raise ValueError("policy must be a mapping/object")
65
+ rules: list = []
66
+ if "issuer" in data:
67
+ rules.append(ClaimRule(name="iss", equals=str(data["issuer"])))
68
+ if "audience" in data:
69
+ rules.append(ClaimRule(name="aud", equals=str(data["audience"])))
70
+ claims = data.get("claims") or {}
71
+ if not isinstance(claims, dict):
72
+ raise ValueError("policy 'claims' must be a mapping of claim -> rule")
73
+ for name, spec in claims.items():
74
+ rules.append(_rule_from_spec(name, spec))
75
+ if not rules:
76
+ raise ValueError("policy is empty: define 'issuer', 'audience', or 'claims'")
77
+ return Policy(rules=rules)
78
+
79
+
80
+ def _rule_from_spec(name: str, spec) -> ClaimRule:
81
+ if isinstance(spec, str):
82
+ return ClaimRule(name=name, equals=spec)
83
+ if isinstance(spec, list):
84
+ return ClaimRule(name=name, one_of=list(spec))
85
+ if isinstance(spec, dict):
86
+ unknown = set(spec) - _ALLOWED_KEYS
87
+ if unknown:
88
+ raise ValueError(f"claim {name!r}: unknown rule keys {sorted(unknown)}")
89
+ return ClaimRule(
90
+ name=name,
91
+ equals=spec.get("equals"),
92
+ one_of=spec.get("in"),
93
+ matches=spec.get("matches"),
94
+ glob=spec.get("glob"),
95
+ required=bool(spec.get("required", True)),
96
+ )
97
+ raise ValueError(f"claim {name!r}: rule must be a string, list, or mapping")
98
+
99
+
100
+ def load_policy_file(path: str | Path) -> Policy:
101
+ """Load a policy from a .json, .yaml, or .yml file."""
102
+ p = Path(path)
103
+ text = p.read_text(encoding="utf-8")
104
+ if p.suffix.lower() in {".yaml", ".yml"}:
105
+ try:
106
+ import yaml
107
+ except ModuleNotFoundError as exc: # pragma: no cover
108
+ raise RuntimeError(
109
+ "PyYAML is required to load YAML policies (pip install PyYAML), "
110
+ "or use a .json policy instead"
111
+ ) from exc
112
+ data = yaml.safe_load(text)
113
+ else:
114
+ data = json.loads(text)
115
+ return load_policy(data)
subcheck/py.typed ADDED
File without changes
subcheck/report.py ADDED
@@ -0,0 +1,106 @@
1
+ """Assemble and format the inspection report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict
7
+
8
+ from .decoder import parse_github_sub
9
+ from .validator import FAIL, MISSING, PASS, Result
10
+
11
+
12
+ def build_report(claims: dict, results: list[Result]) -> dict:
13
+ passed = all(r.status == PASS for r in results)
14
+ return {
15
+ "passed": passed,
16
+ "summary": {
17
+ "total": len(results),
18
+ "pass": sum(r.status == PASS for r in results),
19
+ "fail": sum(r.status == FAIL for r in results),
20
+ "missing": sum(r.status == MISSING for r in results),
21
+ },
22
+ "notes": _advisories(claims, results),
23
+ "results": [asdict(r) for r in results],
24
+ "claims": claims,
25
+ }
26
+
27
+
28
+ GITHUB_ISSUER = "https://token.actions.githubusercontent.com"
29
+
30
+
31
+ def _advisories(claims: dict, results: list[Result]) -> list[str]:
32
+ """Non-gating hints, chiefly about the 2026 immutable subject-claims migration.
33
+
34
+ Immutable subject claims are a github.com-only feature, so the migration hints are
35
+ suppressed for any other issuer (notably GitHub Enterprise Server, which keeps the
36
+ mutable name-based format and uses an https://HOSTNAME/_services/token issuer).
37
+ """
38
+ sub = claims.get("sub")
39
+ if not isinstance(sub, str):
40
+ return []
41
+ iss = claims.get("iss")
42
+ if isinstance(iss, str) and iss != GITHUB_ISSUER:
43
+ return []
44
+ parsed = parse_github_sub(sub)
45
+ fmt = parsed.get("format")
46
+ if fmt is None:
47
+ return []
48
+ sub_result = next((r for r in results if r.claim == "sub"), None)
49
+ name_based_sub_pin = sub_result is not None and "@" not in str(sub_result.expected)
50
+ notes: list[str] = []
51
+ if fmt == "immutable":
52
+ oid, rid = parsed.get("repository_owner_id"), parsed.get("repository_id")
53
+ notes.append(
54
+ f"sub uses the immutable format (repository_owner_id={oid}, repository_id={rid}); "
55
+ "pin these numeric IDs in the cloud trust policy rather than mutable owner/repo names."
56
+ )
57
+ if sub_result is not None and sub_result.status == FAIL and name_based_sub_pin:
58
+ notes.append(
59
+ "the sub check failed while the token is immutable-format and the expected "
60
+ "pattern looks name-based; update the expected sub, or pin repository_id / "
61
+ "repository_owner_id instead."
62
+ )
63
+ elif fmt == "malformed":
64
+ notes.append(
65
+ "sub carries an owner/repo ID on only one segment; GitHub always emits '@id' on "
66
+ "both or neither, so this value looks hand-edited or half-migrated."
67
+ )
68
+ elif fmt == "legacy" and name_based_sub_pin:
69
+ notes.append(
70
+ "sub is pinned by name; when this repo adopts the immutable format the sub becomes "
71
+ "'owner@id/repo@id:...' and this pattern stops matching - pin repository_id / "
72
+ "repository_owner_id to stay durable. Adoption is automatic for repos created, "
73
+ "renamed, or transferred after 2026-07-15, but any repo can be switched on sooner "
74
+ "via the org-level or repo-level immutable-subject setting, so do not infer the "
75
+ "format from the repo's age."
76
+ )
77
+ return notes
78
+
79
+
80
+ def to_json(report: dict) -> str:
81
+ return json.dumps(report, indent=2)
82
+
83
+
84
+ _ICON = {PASS: "[+]", FAIL: "[-]", MISSING: "[!]"}
85
+
86
+
87
+ def to_text(report: dict) -> str:
88
+ summary = report["summary"]
89
+ verdict = "PASS" if report["passed"] else "FAIL"
90
+ lines = [
91
+ f"OIDC claim inspection: {verdict} "
92
+ f"({summary['pass']} pass, {summary['fail']} fail, {summary['missing']} missing)",
93
+ "",
94
+ ]
95
+ width = max((len(r["claim"]) for r in report["results"]), default=5)
96
+ for r in report["results"]:
97
+ icon = _ICON.get(r["status"], "[?]")
98
+ lines.append(
99
+ f" {icon} {r['claim']:<{width}} {r['severity']:<6} {r['message']}"
100
+ )
101
+ notes = report.get("notes") or []
102
+ if notes:
103
+ lines.append("")
104
+ lines.append("Notes:")
105
+ lines.extend(f" [i] {note}" for note in notes)
106
+ return "\n".join(lines)
subcheck/validator.py ADDED
@@ -0,0 +1,64 @@
1
+ """Validate decoded claims against a policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ import re
7
+ from dataclasses import dataclass
8
+
9
+ from .policy import ClaimRule, Policy
10
+
11
+ PASS = "PASS" # noqa: S105 # nosec B105 - a status constant, not a secret
12
+ FAIL = "FAIL"
13
+ MISSING = "MISSING"
14
+
15
+
16
+ @dataclass
17
+ class Result:
18
+ claim: str
19
+ status: str
20
+ severity: str
21
+ expected: str
22
+ actual: object
23
+ message: str
24
+
25
+
26
+ def _matches(rule: ClaimRule, value) -> bool:
27
+ text = str(value)
28
+ if rule.equals is not None and value != rule.equals:
29
+ return False
30
+ if rule.one_of is not None and value not in rule.one_of:
31
+ return False
32
+ if rule.matches is not None and re.search(rule.matches, text) is None:
33
+ return False
34
+ if rule.glob is not None and not fnmatch.fnmatchcase(text, rule.glob):
35
+ return False
36
+ return True
37
+
38
+
39
+ def validate(claims: dict, policy: Policy) -> list[Result]:
40
+ """Check each policy rule against the claims and return one Result per rule."""
41
+ results: list[Result] = []
42
+ for rule in policy.rules:
43
+ expected = rule.describe()
44
+ if rule.name not in claims:
45
+ status = MISSING if rule.required else PASS
46
+ note = (
47
+ f"claim {rule.name!r} is required but absent"
48
+ if rule.required
49
+ else f"claim {rule.name!r} absent (optional)"
50
+ )
51
+ results.append(Result(rule.name, status, rule.severity, expected, None, note))
52
+ continue
53
+ actual = claims[rule.name]
54
+ if _matches(rule, actual):
55
+ results.append(
56
+ Result(rule.name, PASS, rule.severity, expected, actual,
57
+ f"claim {rule.name!r} satisfies {expected}")
58
+ )
59
+ else:
60
+ results.append(
61
+ Result(rule.name, FAIL, rule.severity, expected, actual,
62
+ f"claim {rule.name!r}={actual!r} does not satisfy {expected}")
63
+ )
64
+ return results
@@ -0,0 +1,264 @@
1
+ Metadata-Version: 2.5
2
+ Name: subcheck
3
+ Version: 0.2.0
4
+ Summary: Decode GitHub Actions OIDC token claims and validate them against an expected-claims policy - a CI gate against over-broad cloud trust.
5
+ Project-URL: Homepage, https://github.com/Dashtid/subcheck
6
+ Project-URL: Related, https://github.com/Dashtid/subvectors
7
+ Author: David Dashti
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: aws-iam,cicd-security,github-actions,least-privilege,oidc,supply-chain-security
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Security
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: pyyaml>=6.0
22
+ Provides-Extra: dev
23
+ Requires-Dist: bandit>=1.7; extra == 'dev'
24
+ Requires-Dist: mypy>=1.8; extra == 'dev'
25
+ Requires-Dist: pytest-cov>=4; extra == 'dev'
26
+ Requires-Dist: pytest>=7; extra == 'dev'
27
+ Requires-Dist: ruff>=0.4; extra == 'dev'
28
+ Requires-Dist: types-pyyaml>=6; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # subcheck
32
+
33
+ Decode a GitHub Actions **OIDC token's claims** and check them against an expected-claims
34
+ policy — so a workflow fails *before* an over-broad cloud trust policy lets the wrong branch,
35
+ workflow, or trigger assume your role.
36
+
37
+ *Named for the claim that decides everything — `sub`. A focused sibling of
38
+ [subvectors](https://github.com/Dashtid/subvectors), the conformance test-vector suite (an "answer
39
+ key") that grades whether those trust conditions are well-formed, matching, and safe.*
40
+
41
+ ```text
42
+ $ subcheck --claims examples/claims-pull-request.json --policy examples/policy.json
43
+ OIDC claim inspection: FAIL (5 pass, 1 fail, 1 missing)
44
+
45
+ [+] iss high claim 'iss' satisfies equals 'https://token.actions.githubusercontent.com'
46
+ [+] aud high claim 'aud' satisfies equals 'sts.amazonaws.com'
47
+ [+] repository high claim 'repository' satisfies equals 'acme/payments-api'
48
+ [+] repository_owner high claim 'repository_owner' satisfies equals 'acme'
49
+ [-] sub high claim 'sub'='repo:acme/payments-api:pull_request' does not satisfy matches /^repo:acme/payments-api:(ref:refs/heads/main|environment:production|ref:refs/tags/v[0-9].*)$/
50
+ [!] environment medium claim 'environment' is required but absent
51
+ [+] runner_environment medium claim 'runner_environment' satisfies equals 'github-hosted'
52
+ ```
53
+
54
+ Exit code is non-zero on any finding, so the command drops straight into a CI step as a gate.
55
+
56
+ ## Why
57
+
58
+ GitHub Actions can authenticate to AWS/Azure/GCP with a short-lived **OIDC token** instead of a
59
+ long-lived secret. The cloud side (e.g. an AWS IAM role's trust policy) decides *which* tokens
60
+ may assume the role by matching claims — above all `sub`
61
+ (`repo:org/repo:ref:refs/heads/main`, `...:environment:production`, `...:pull_request`, …).
62
+
63
+ The classic mistake is a trust condition that's too loose — a wildcard `sub`, a missing
64
+ condition, or `...:sub` allowed for `repo:org/*` — so a token minted by something you never
65
+ intended can assume a privileged role. This tool pins down exactly which claims you *expect* and
66
+ flags the moment a token doesn't match.
67
+
68
+ > **Who can actually mint such a token.** Not a fork's pull request: for `pull_request` runs from
69
+ > a fork GitHub downgrades `id-token: write` and never injects
70
+ > `ACTIONS_ID_TOKEN_REQUEST_TOKEN`, so a fork cannot obtain a token for the upstream repo. The
71
+ > real paths are anyone with push/branch-create access (a wildcard `sub` then covers their
72
+ > branch), `pull_request_target` or `workflow_run` jobs that check out untrusted code, and a
73
+ > compromised third-party action running inside an already-trusted job.
74
+
75
+ It's the small, focused sibling of **[subvectors](https://github.com/Dashtid/subvectors)** — the
76
+ conformance test-vector suite that grades whether a cloud trust *condition* is well-formed,
77
+ matches, and is safe. This one works the other end: it inspects a single *token* against a policy
78
+ you write — nothing to configure, no cloud account, no network.
79
+
80
+ ## Install
81
+
82
+ ```bash
83
+ pip install subcheck # v0.2.0 — first PyPI release rolling out
84
+ # or from a clone:
85
+ pip install -e ".[dev]"
86
+ ```
87
+
88
+ Or skip installing entirely and use the GitHub Action — see [In a workflow](#in-a-workflow).
89
+
90
+ ## Usage
91
+
92
+ Decode a token (claims only):
93
+
94
+ ```bash
95
+ subcheck --token "$TOKEN" # or: --token - (read the JWT from stdin)
96
+ ```
97
+
98
+ Validate against a policy and gate on the result:
99
+
100
+ ```bash
101
+ subcheck --token "$TOKEN" --policy .github/oidc-policy.yaml
102
+ echo $? # 0 = all matched, 1 = a claim didn't match, 2 = usage/parse error
103
+ ```
104
+
105
+ Inputs (choose one): `--token <jwt>` (`-` for stdin), `--token-file <path>`, or
106
+ `--claims <decoded-claims.json>`. Output: `--format text` (default) or `json`.
107
+
108
+ > Prefer `--token -` (stdin) or `--token-file` over passing the JWT as an argument — a token on the
109
+ > command line leaks into the process list and shell history.
110
+
111
+ > **Scope (and honesty):** this **decodes** the token payload for inspection — it does **not
112
+ > verify the signature**. Verifying the signature and issuer against GitHub's JWKS is the cloud
113
+ > provider's job at role-assumption time. Use this to catch misconfigured *expectations* early,
114
+ > not as an authentication control.
115
+
116
+ ## Policy
117
+
118
+ YAML or JSON. `issuer`/`audience` are shortcuts for the `iss`/`aud` claims; everything else lives
119
+ under `claims`. Each claim takes one or more of `equals`, `in` (list), `matches` (regex), `glob`,
120
+ and `required` (default `true`).
121
+
122
+ ```yaml
123
+ issuer: https://token.actions.githubusercontent.com
124
+ audience: sts.amazonaws.com
125
+ claims:
126
+ repository:
127
+ equals: acme/payments-api
128
+ sub:
129
+ matches: '^repo:acme/payments-api:(ref:refs/heads/main|environment:production)$'
130
+ runner_environment:
131
+ equals: github-hosted # reject self-hosted runners (see note below)
132
+ environment:
133
+ equals: production
134
+ required: true
135
+ ```
136
+
137
+ Shorthand: a bare string means `equals`, a list means `in`:
138
+
139
+ ```yaml
140
+ claims:
141
+ repository_owner: acme
142
+ ref: [refs/heads/main, refs/heads/release]
143
+ ```
144
+
145
+ **Matching notes:** `matches` uses `re.search`, so it is *not* anchored — `matches: pull_request`
146
+ matches that substring anywhere in the value. Anchor with `^…$` when you mean the whole claim (the
147
+ examples do). `equals`/`in` compare against the value's real JSON type, so quote a number you expect
148
+ as a string; `matches`/`glob` always operate on the stringified value.
149
+
150
+ `glob` is `fnmatch`-based: case-sensitive, `*` spans any characters (including `/` and `:`), `?`
151
+ matches one. That lines up with AWS IAM `StringLike` on the properties that matter, with **one
152
+ divergence** — `fnmatch` also honours POSIX character classes, so `glob: 'repo:acme/[ap]*'` matches
153
+ here while IAM treats `[` and `]` as literals and matches nothing. Avoid character classes if the
154
+ pattern is meant to mirror a trust policy. This is an expectation language, not a cloud-semantics
155
+ simulator (see [subvectors](https://github.com/Dashtid/subvectors) for that).
156
+
157
+ Claims that anchor the trust boundary (`iss`, `aud`, `sub`, `repository`, `repository_owner`,
158
+ `repository_id`, `repository_owner_id`, and `job_workflow_ref`) are reported at **high** severity;
159
+ contextual claims default to **medium**.
160
+
161
+ **Why check claims the cloud already checks?** Because for several of them it can't.
162
+ `runner_environment` is the clearest case: "reject self-hosted runners" is **not expressible in an
163
+ AWS IAM trust policy at all** — nor are `event_name`, `head_ref`, `base_ref`, or `workflow_ref`. AWS
164
+ exposes only a fixed set of GitHub claims as condition keys, and note that `repository_owner` is not
165
+ among them (only `repository_owner_id` is), so a name-based owner pin here has no trust-policy
166
+ equivalent. Asserting those claims in the job is the only place they can be enforced.
167
+
168
+ ## Immutable subject claims (2026-07-15)
169
+
170
+ GitHub is migrating the `sub` claim to an **immutable format** that embeds numeric owner/repo IDs —
171
+ `repo:acme@123456/payments-api@456789:ref:refs/heads/main`. Adoption is automatic for repositories
172
+ created, renamed, or transferred after **2026-07-15**, but any repository can be switched on sooner
173
+ through the org-level or repo-level immutable-subject setting — so you cannot infer the format from
174
+ a repo's age. A trust condition (or a `sub` pattern here) written for the legacy
175
+ `repo:owner/repo:...` names silently stops matching, and deploys break with no code change.
176
+
177
+ Immutable subject claims are **github.com only**; GitHub Enterprise Server keeps the mutable
178
+ name-based format under its own `https://HOSTNAME/_services/token` issuer, and subcheck suppresses
179
+ these migration hints for any non-github.com issuer.
180
+
181
+ subcheck decodes both formats, reports which one a token uses, and flags the mismatch. Run a
182
+ post-migration token against a name-based policy and it points straight at the cause (rows trimmed):
183
+
184
+ ```text
185
+ $ subcheck --claims examples/claims-immutable.json --policy examples/policy.json
186
+ OIDC claim inspection: FAIL (6 pass, 1 fail, 0 missing)
187
+ ...
188
+ [-] sub high claim 'sub'='repo:acme@123456/payments-api@456789:ref:refs/heads/main' does not satisfy matches /^repo:acme/payments-api:(ref:refs/heads/main|environment:production|ref:refs/tags/v[0-9].*)$/
189
+ ...
190
+
191
+ Notes:
192
+ [i] sub uses the immutable format (repository_owner_id=123456, repository_id=456789); pin these numeric IDs in the cloud trust policy rather than mutable owner/repo names.
193
+ [i] the sub check failed while the token is immutable-format and the expected pattern looks name-based; update the expected sub, or pin repository_id / repository_owner_id instead.
194
+ ```
195
+
196
+ The durable fix is to pin the numeric IDs — stable across renames and transfers — as in
197
+ [`examples/policy-immutable.json`](examples/policy-immutable.json):
198
+
199
+ ```yaml
200
+ claims:
201
+ repository_owner_id: "123456"
202
+ repository_id: "456789"
203
+ ```
204
+
205
+ `repository_id` and `repository_owner_id` are **not new** — they have been separate claims in every
206
+ token since January 2023 and are present on legacy-format tokens too. You do not have to wait for
207
+ the migration to pin them; doing it now is what makes a policy survive the switch.
208
+
209
+ ## In a workflow
210
+
211
+ The one-line form — this repo ships an [`action.yml`](action.yml) that requests the job's OIDC
212
+ token and checks it against your policy:
213
+
214
+ ```yaml
215
+ permissions:
216
+ id-token: write
217
+ contents: read
218
+ steps:
219
+ - uses: actions/checkout@v4
220
+ - name: Verify the OIDC token is scoped as expected
221
+ uses: Dashtid/subcheck@v0.2.0 # or @main
222
+ with:
223
+ policy: .github/oidc-policy.yaml
224
+ audience: sts.amazonaws.com # default
225
+ ```
226
+
227
+ Or by hand, if you'd rather see every moving part:
228
+
229
+ ```yaml
230
+ permissions:
231
+ id-token: write
232
+ contents: read
233
+ steps:
234
+ - uses: actions/checkout@v4
235
+ - run: pip install subcheck
236
+ - name: Verify the OIDC token is scoped as expected
237
+ run: |
238
+ TOKEN=$(curl -sH "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
239
+ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value)
240
+ echo "$TOKEN" | subcheck --token - --policy .github/oidc-policy.yaml
241
+ ```
242
+
243
+ ## Development
244
+
245
+ ```bash
246
+ pip install -e ".[dev]"
247
+ pytest -q # tests
248
+ ruff check . # lint
249
+ bandit -r src # security lint
250
+ ```
251
+
252
+ Contributions welcome — see [CONTRIBUTING.md](CONTRIBUTING.md); good first issues are labelled.
253
+
254
+ ## Related tools
255
+
256
+ - **[subvectors](https://github.com/Dashtid/subvectors)** — the sibling project, working the other
257
+ end of the same trust boundary. subcheck checks the token a job *received* against your expected
258
+ claims; subvectors is the *cloud side* — a cited, versioned suite of conformance test vectors
259
+ answering "does subject S satisfy trust condition C, and is C safe?" across AWS IAM, Azure FIC,
260
+ and GCP WIF. subvectors grades the trust *rules*; subcheck asserts the *token*.
261
+
262
+ ## License
263
+
264
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,13 @@
1
+ subcheck/__init__.py,sha256=pZFAujfMcSfmRAldlDtIxGQ5gTuzR-y2Vr6az53fBBE,540
2
+ subcheck/__main__.py,sha256=4wiZ8LHV9WhvMBpR6zirrTCbdJMENUK2tmCLoGMYHfE,84
3
+ subcheck/cli.py,sha256=NR5HmRy7F1-MbRv52uTxBxdfzDc3G3SEQH9cYadAQ1Y,2676
4
+ subcheck/decoder.py,sha256=hcPuSNNvesm2LUYibJ8YrmGo0P7uiXhPZPdihWHWTF0,3851
5
+ subcheck/policy.py,sha256=mvxZzIDzZKD-ckfm3I8_hU6BYGiW8B7tssRTGBwWExg,3881
6
+ subcheck/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ subcheck/report.py,sha256=fTnZf3wEFWUbEpLVWLpjKkqgeoXshdP_SwPZweAEPkY,4128
8
+ subcheck/validator.py,sha256=n1BWCCK1CKOJDLIOd11jz-W2OIPadHwxR3i_8NMQmEg,1987
9
+ subcheck-0.2.0.dist-info/METADATA,sha256=fYON2N918XnZ9KMDAPST0K2iYJi3BMUcXbH87JoyXPY,12104
10
+ subcheck-0.2.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
11
+ subcheck-0.2.0.dist-info/entry_points.txt,sha256=7Dg3ZOD1DWJyJkph4ZwfLIJH2ZFpw3Oh-XwLGo_GlmA,47
12
+ subcheck-0.2.0.dist-info/licenses/LICENSE,sha256=SM21vnphyagwWbi9zY_fb64WEfpmTDjpXo0e_15iK7I,1069
13
+ subcheck-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ subcheck = subcheck.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 David Dashti
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.