subcheck 0.4.0__tar.gz → 0.4.2__tar.gz

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.
Files changed (45) hide show
  1. subcheck-0.4.2/.github/workflows/ci.yml +76 -0
  2. {subcheck-0.4.0 → subcheck-0.4.2}/ARCHITECTURE.md +9 -2
  3. {subcheck-0.4.0 → subcheck-0.4.2}/BACKLOG.md +61 -13
  4. {subcheck-0.4.0 → subcheck-0.4.2}/CHANGELOG.md +93 -0
  5. {subcheck-0.4.0 → subcheck-0.4.2}/CONTRIBUTING.md +2 -2
  6. {subcheck-0.4.0 → subcheck-0.4.2}/PKG-INFO +17 -3
  7. {subcheck-0.4.0 → subcheck-0.4.2}/README.md +15 -1
  8. {subcheck-0.4.0 → subcheck-0.4.2}/pyproject.toml +2 -2
  9. {subcheck-0.4.0 → subcheck-0.4.2}/src/subcheck/__init__.py +1 -1
  10. {subcheck-0.4.0 → subcheck-0.4.2}/src/subcheck/cli.py +13 -2
  11. {subcheck-0.4.0 → subcheck-0.4.2}/src/subcheck/policy.py +62 -10
  12. {subcheck-0.4.0 → subcheck-0.4.2}/src/subcheck/report.py +3 -0
  13. {subcheck-0.4.0 → subcheck-0.4.2}/src/subcheck/validator.py +9 -1
  14. subcheck-0.4.2/tests/test_claims_shape.py +100 -0
  15. subcheck-0.4.2/tests/test_cli_inputs.py +113 -0
  16. subcheck-0.4.2/tests/test_policy_shape.py +196 -0
  17. {subcheck-0.4.0 → subcheck-0.4.2}/tests/test_report.py +22 -0
  18. {subcheck-0.4.0 → subcheck-0.4.2}/tests/test_time_notes.py +10 -0
  19. subcheck-0.4.0/.github/workflows/ci.yml +0 -34
  20. {subcheck-0.4.0 → subcheck-0.4.2}/.gitattributes +0 -0
  21. {subcheck-0.4.0 → subcheck-0.4.2}/.github/dependabot.yml +0 -0
  22. {subcheck-0.4.0 → subcheck-0.4.2}/.github/workflows/fixture-drift.yml +0 -0
  23. {subcheck-0.4.0 → subcheck-0.4.2}/.github/workflows/release.yml +0 -0
  24. {subcheck-0.4.0 → subcheck-0.4.2}/.gitignore +0 -0
  25. {subcheck-0.4.0 → subcheck-0.4.2}/LICENSE +0 -0
  26. {subcheck-0.4.0 → subcheck-0.4.2}/action.yml +0 -0
  27. {subcheck-0.4.0 → subcheck-0.4.2}/examples/claims-immutable.json +0 -0
  28. {subcheck-0.4.0 → subcheck-0.4.2}/examples/claims-main.json +0 -0
  29. {subcheck-0.4.0 → subcheck-0.4.2}/examples/claims-pull-request.json +0 -0
  30. {subcheck-0.4.0 → subcheck-0.4.2}/examples/claims-reusable-workflow.json +0 -0
  31. {subcheck-0.4.0 → subcheck-0.4.2}/examples/policy-immutable.json +0 -0
  32. {subcheck-0.4.0 → subcheck-0.4.2}/examples/policy-reusable-workflow.json +0 -0
  33. {subcheck-0.4.0 → subcheck-0.4.2}/examples/policy.json +0 -0
  34. {subcheck-0.4.0 → subcheck-0.4.2}/examples/policy.yaml +0 -0
  35. {subcheck-0.4.0 → subcheck-0.4.2}/scripts/check_fixture_drift.py +0 -0
  36. {subcheck-0.4.0 → subcheck-0.4.2}/src/subcheck/__main__.py +0 -0
  37. {subcheck-0.4.0 → subcheck-0.4.2}/src/subcheck/decoder.py +0 -0
  38. {subcheck-0.4.0 → subcheck-0.4.2}/src/subcheck/py.typed +0 -0
  39. {subcheck-0.4.0 → subcheck-0.4.2}/tests/conftest.py +0 -0
  40. {subcheck-0.4.0 → subcheck-0.4.2}/tests/fixtures/github_subjects.json +0 -0
  41. {subcheck-0.4.0 → subcheck-0.4.2}/tests/test_cli.py +0 -0
  42. {subcheck-0.4.0 → subcheck-0.4.2}/tests/test_decoder.py +0 -0
  43. {subcheck-0.4.0 → subcheck-0.4.2}/tests/test_decoder_vectors.py +0 -0
  44. {subcheck-0.4.0 → subcheck-0.4.2}/tests/test_fixture_provenance.py +0 -0
  45. {subcheck-0.4.0 → subcheck-0.4.2}/tests/test_validator.py +0 -0
@@ -0,0 +1,76 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ test:
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ fail-fast: false
16
+ matrix:
17
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
18
+ steps:
19
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
20
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
21
+ with:
22
+ python-version: ${{ matrix.python-version }}
23
+ - name: Install
24
+ run: |
25
+ python -m pip install --upgrade pip
26
+ pip install -e ".[dev]"
27
+ - name: Lint (ruff)
28
+ run: ruff check .
29
+ - name: Type check (mypy)
30
+ run: mypy
31
+ - name: Security lint (bandit)
32
+ run: bandit -q -r src
33
+ - name: Tests (pytest)
34
+ run: pytest -q --cov=subcheck --cov-report=term-missing
35
+
36
+ # The composite action is the entry point the README advertises
37
+ # (uses: Dashtid/subcheck@vX). Nothing here had ever executed it, so a break
38
+ # in action.yml would have surfaced in a user's pipeline, not in CI.
39
+ # Runs only on same-repo pushes/PRs: a fork PR gets no OIDC token, and the
40
+ # step exits 2 by design when ACTIONS_ID_TOKEN_REQUEST_TOKEN is absent.
41
+ action:
42
+ if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
43
+ runs-on: ubuntu-latest
44
+ permissions:
45
+ contents: read
46
+ id-token: write # mint the OIDC token the action inspects
47
+ steps:
48
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
49
+ - name: Write a policy matching this very job's token
50
+ # Context values go through env, never interpolated into run: - the same
51
+ # template-injection rule action.yml follows.
52
+ env:
53
+ REPO: ${{ github.repository }}
54
+ OWNER: ${{ github.repository_owner }}
55
+ run: |
56
+ {
57
+ echo "issuer: https://token.actions.githubusercontent.com"
58
+ echo "audience: sts.amazonaws.com"
59
+ echo "claims:"
60
+ echo " repository:"
61
+ echo " equals: \"$REPO\""
62
+ echo " repository_owner:"
63
+ echo " equals: \"$OWNER\""
64
+ echo " ref:"
65
+ echo " glob: \"refs/*\""
66
+ } > ci-policy.yaml
67
+ cat ci-policy.yaml
68
+ - name: Run the action against it
69
+ uses: ./
70
+ with:
71
+ policy: ci-policy.yaml
72
+ - name: JSON format still works
73
+ uses: ./
74
+ with:
75
+ policy: ci-policy.yaml
76
+ format: json
@@ -103,8 +103,15 @@ These are decisions, not gaps. See `BACKLOG.md` → Non-goals.
103
103
  - **It does not simulate cloud trust conditions.** Your policy's `glob` is not "what AWS would do."
104
104
  Grading real cloud rules is subvectors' job. Blurring that line risks being wrong in exactly the
105
105
  way subvectors exists to catch.
106
- - **It is not released.** No PyPI package, no git tag, no `action.yml` — on purpose. The launch is
107
- parked until the flagship and the companion article are ready.
106
+
107
+ > [i] **A third entry used to sit here and is now false on both counts.** It read: *"It is not
108
+ > released. No PyPI package, no git tag, no `action.yml` — on purpose. The launch is parked until
109
+ > the flagship and the companion article are ready."* Retired 2026-08-29. subcheck v0.2.0 shipped to
110
+ > PyPI on 2026-08-24 via OIDC trusted publishing (`pip install subcheck`), tag `v0.2.0` and the
111
+ > `action.yml` composite wrapper are live, and subvectors released the same day — so the flagship
112
+ > half of the gate fired. The article half no longer exists: the article programme was closed
113
+ > 2026-08-29 in favour of artifact-shaped proof (shipped packages, merged upstream PRs, the cited
114
+ > subvectors corpus). Release status is not a non-goal any more; it is history.
108
115
 
109
116
  ## Running it
110
117
 
@@ -12,9 +12,12 @@ expectation language and a fail-fast CI gate. It is **not** a cloud-trust-condit
12
12
  provider's job at assume-time. Keep that line sharp (see Non-goals).
13
13
 
14
14
  **Sequencing.** subvectors is the flagship; subcheck is the finished companion. The original plan
15
- was to keep subcheck parked until a subvectors v0.1 + article #1 launch burst — but as of
16
- 2026-08-16 that gate has partly fired on its own (subcheck is pinned at public slot #2) while both
17
- gate conditions remain outside your control. See the DECISION in Phase 4 before doing more here.
15
+ was to keep subcheck parked until a subvectors v0.1 + article #1 launch burst. **That gate is gone
16
+ — retired 2026-08-29.** Every leg of it dissolved on its own: the pin fired ahead of schedule
17
+ (public slot #2, 2026-08-16), both packages shipped to PyPI regardless (subcheck v0.2.0 and
18
+ subvectors v0.2.0, both 2026-08-24), and the article programme was **closed 2026-08-29** — article
19
+ #1 was never drafted and now never will be. Nothing in this repo is gated on anything: ship a slice
20
+ when it is ready. Phase 4 below is the shipping history, not a gate.
18
21
 
19
22
  Status keys: `[ ]` todo · `[~]` in progress · `[x]` done this cycle.
20
23
 
@@ -61,8 +64,9 @@ new/renamed/transferred repos on **2026-07-15**; name-based policies silently st
61
64
  `parse_github_sub` agrees with the subvectors subject grammar on all 9 (legacy, both immutable ID
62
65
  forms, case-sensitivity, nested-branch ref, tag ref, customized multi-segment sub). One-way,
63
66
  test-time, self-contained (CI-safe) — subvectors is never a runtime dependency.
64
- - `[ ]` (upstream, separate subvectors session) record subcheck as the corpus's first consumer in
65
- subvectors' BACKLOG "Consumer-adoption" item — the adoption datapoint its success metric tracks.
67
+ - `[x]` (upstream) subcheck recorded as the corpus's first consumer in subvectors' BACKLOG
68
+ "Consumer-adoption" item — **done 2026-08-25**, together with the decoder bug that consumption
69
+ caught (the combined-customization subject; fixed in 0.3.0).
66
70
 
67
71
  ## Phase 3 — ship it properly (DE-GATED 2026-08-22 — decision taken, executed)
68
72
 
@@ -86,7 +90,11 @@ new/renamed/transferred repos on **2026-07-15**; name-based policies silently st
86
90
 
87
91
  - `[x]` **Set the GitHub pin** — done, and at slot **#2** (verified 2026-08-16), ahead of its gate.
88
92
  - `[ ]` Record the demo GIF/asciinema (a PR failing on `sub=...:pull_request`).
89
- - `[ ]` Coordinated burst *with subvectors*: article -> Show HN -> one subreddit -> LinkedIn.
93
+ - `[ ]` Coordinated burst *with subvectors* — **article leg dropped 2026-08-29** (the article
94
+ programme is closed; the shipped artifact is the announcement). What remains, if it is ever worth
95
+ the evening: Show HN -> one subreddit -> LinkedIn, all pointing at `pip install subcheck` and the
96
+ subvectors corpus. Low priority by design — an installable package and a merged upstream PR are
97
+ the durable proof; a launch post decays in a week.
90
98
 
91
99
  > [x] **DECISION RESOLVED 2026-08-24 — de-gated and shipped.** v0.2.0 is live on PyPI via
92
100
  > trusted publishing; both names reserved as pending publishers. The pin at slot #2 now points
@@ -96,12 +104,17 @@ new/renamed/transferred repos on **2026-07-15**; name-based policies silently st
96
104
  ## Phase 2.5 — technical-soundness pass (done this cycle)
97
105
 
98
106
  Driven by a research fan-out (article-saturation + primary-source verification + adversarial
99
- refutation) before any article gets published. Findings that were *facts*, fixed here:
107
+ refutation) originally run ahead of an article that was never drafted — the article programme
108
+ closed 2026-08-29. [i] The pass keeps all of its value regardless: its output was never the article,
109
+ it was the corrections below, which landed in the code and the README. Findings that were *facts*,
110
+ fixed here:
100
111
 
101
112
  - `[x]` **The fork claim was wrong.** A fork's `pull_request` cannot mint a token for the upstream
102
113
  repo — `id-token: write` is downgraded and `ACTIONS_ID_TOKEN_REQUEST_TOKEN` is never injected.
103
- README now lists the real paths. This error is endemic to the published literature; do not
104
- reintroduce it, and it is worth an article of its own (see below).
114
+ README now lists the real paths. This error is endemic to the third-party literature; do not
115
+ reintroduce it. **Kept as reference material, not as an article idea (2026-08-29).** The durable
116
+ home for a correction this sharp is the README plus an upstream PR against a tool that encodes the
117
+ wrong model — an artifact someone can cite, not a post that scrolls away.
105
118
  - `[x]` Half-immutable subjects (`@id` on one segment) reported as `malformed`, not `immutable`.
106
119
  - `[x]` Migration hints suppressed for non-github.com issuers (GHES keeps mutable names).
107
120
  - `[x]` Legacy hint no longer implies format follows from repo age (org/repo opt-in exists).
@@ -122,6 +135,33 @@ Open follow-ups from the same pass:
122
135
 
123
136
  ## Correctness / quality parking lot
124
137
 
138
+ - `[x]` **Policy-shape validation — DONE 2026-08-31 (v0.4.1), and it found two silent fail-opens.**
139
+ A 22-agent audit sweep (5 lenses, each finding then adversarially verified; roughly half the raw
140
+ findings were refuted) turned up two ways a malformed policy passed every token it was written to
141
+ reject, both reproduced through the shipped CLI: claim rules written at the **top level** were
142
+ dropped without a word (only `issuer`/`audience` were read, so the gate went green having checked
143
+ the issuer alone), and **`in:` given a string** became Python substring containment, so `prod`
144
+ satisfied `in: production` and so did the empty string. Rule *keys* had been validated since the
145
+ start; rule *values* never were. Both now raise on the documented rc=2 path, along with a
146
+ non-compiling `matches`, a non-string `glob`, and a quoted `required`.
147
+ **The lesson worth keeping:** green CI, 93% coverage and a tidy backlog all held while these
148
+ shipped, because no test had ever written a *malformed policy*. Coverage measures the lines you
149
+ execute, not the inputs you never thought to send. A gate whose own config can silently weaken it
150
+ is the failure this tool exists to prevent, so policy-shape tests belong next to claim tests.
151
+
152
+ - `[x]` **Claims-shape validation — DONE 2026-09-01 (v0.4.2), found by asking 0.4.1's question of
153
+ the other input.** The tool takes two things: a policy and a set of claims. v0.4.1 hardened the
154
+ policy and never asked the same question of the claims, and that gap *was* the bug — `--claims`
155
+ went straight to `json.loads` while `decode_claims` had always required a JSON object, so the two
156
+ claim-input paths disagreed about what claims are. A list-shaped claims file made `validate()`
157
+ return **all PASS** (`rule.name not in claims` is legal on any container, so every rule read as
158
+ absent, which is PASS when optional); the only thing preventing a green gate was an unrelated
159
+ crash a few lines later in `build_report`. `--claims <non-object> --format json` exited **0** and
160
+ echoed the garbage back. Everything else exited **1** on a traceback — bad input wearing the
161
+ gate-failure exit code, the same confusion 0.4.1 fixed one input over.
162
+ **The generalisable move:** when a fix hardens one input, check the others *for the same defect*
163
+ before closing it out. Symmetry between inputs is cheap to check and, here, was the whole finding.
164
+
125
165
  - `[ ]` `--fail-on <severity>` threshold gating — today any single required-but-missing medium claim
126
166
  fails the whole gate (`report.py` `passed = all(PASS)`); no way to gate on high only.
127
167
  - `[x]` `exp`/`iat`/`nbf` time checks — **DONE 2026-08-25 (v0.4.0)**, as advisory NOTES, never
@@ -145,10 +185,18 @@ Open follow-ups from the same pass:
145
185
  subject parses, not whether it parses *correctly*.
146
186
  - `[ ]` Decide `equals`/`in` type handling: coerce, or keep type-strict + documented (currently the
147
187
  latter).
148
- - `[ ]` Close test-coverage holes (92% now): `glob` branch, `--token-file`, `--token -` stdin,
149
- `load_policy_file` suffix logic, `to_json`/summary counts, and the `rc=2` bad-policy/bad-JSON paths.
150
- - `[ ]` Cosmetic: rephrase the `# nosec B105` comments so bandit stops emitting "Test in comment"
151
- warnings (prose after `# nosec` is parsed as test IDs).
188
+ - `[x]` Close test-coverage holes — **DONE 2026-08-31: 92% -> 100%, every module.** The v0.4.1 pass
189
+ took the `glob` branch, the `equals` FAIL path and the `rc=2` bad-policy paths
190
+ (`tests/test_policy_shape.py`); the follow-up took the rest (`tests/test_cli_inputs.py`):
191
+ `--token-file`, `--token -` stdin, both `python -m` entry points, the unknown-policy-suffix
192
+ branch, the JSON summary counts, malformed-JWT rejection, and the customized-sub and
193
+ days-duration report branches. **Worth noting which ones mattered**: `--token-file` and
194
+ `--token -` are how `action.yml` feeds the tool, so the shipped Action's only invocation path had
195
+ been running on untested code. 100% is not the point and is not a target to defend — it just
196
+ happens to be where the risk-driven list ran out.
197
+ - `[x]` Cosmetic: rephrase the `# nosec B105` comments — **DONE 2026-08-31**. The prose moved to
198
+ its own line above; `# nosec B105` is now bare, so bandit stops parsing "a status constant, not
199
+ a secret" as a list of test IDs and runs silent.
152
200
 
153
201
  ## Non-goals (hold the line)
154
202
 
@@ -4,6 +4,99 @@ All notable changes are documented here. Format based on
4
4
  [Keep a Changelog](https://keepachangelog.com/); this project follows
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ## [0.4.2] - 2026-09-01
10
+
11
+ ### Fixed
12
+ - **A claims file that is not a JSON object was half-processed instead of rejected.**
13
+ `decode_claims` has always required the JWT payload to decode to an object; `--claims` went
14
+ straight to `json.loads` with no such check, so the tool's two claim-input paths disagreed about
15
+ what "claims" means. Three consequences, all reproduced through the shipped CLI on 0.4.1:
16
+ - **`validate()` returned *all PASS* for list-shaped claims.** `rule.name not in claims` is legal
17
+ on any container, so every rule read as absent - MISSING for a required rule, but **PASS** for
18
+ an optional one. An all-optional policy therefore reported a clean pass over claims holding
19
+ nothing at all. Nothing downstream caught that on purpose: the report merely happened to crash
20
+ a few lines later on `claims.get()`, and that accident was the only thing standing between
21
+ `--claims '[]'` and a green gate. `validate()` and `build_report()` now reject the shape, so
22
+ the guard does not depend on a crash that a refactor could remove.
23
+ - **`--claims <non-object> --format json` exited 0**, echoing the input back as though it were a
24
+ decoded token. That branch consults no policy and calls no `.items()`, so it had no crash to
25
+ hide behind - a silent success on input that was never claims.
26
+ - **Every other combination exited 1** - the "a claim did not match" code - with a raw
27
+ `AttributeError` traceback. That is the same bad-input-indistinguishable-from-a-finding
28
+ confusion 0.4.1 fixed on the policy side; it now lands on the documented rc=2 path.
29
+
30
+ An empty object `{}` is still accepted, and a required rule still reports MISSING against it: the
31
+ guard rejects the wrong *shape*, not an empty token.
32
+
33
+ ### Added
34
+ - `tests/test_claims_shape.py` - 28 regression tests, 27 of which fail against 0.4.1. One of them
35
+ is a parity test asserting that `--token-file` and `--claims` now return the same verdict and the
36
+ same exit code for the same non-object payload, since that asymmetry is what caused this.
37
+ - Tests for the claim-input paths that had none: `--token-file`, `--token -` (stdin), and both
38
+ `python -m` entry points - the three the shipped `action.yml` actually uses, so the Action's only
39
+ invocation path had been running on untested code. Plus malformed-JWT rejection, the
40
+ unknown-policy-suffix branch, the JSON summary counts, the structurally-invalid-policy rejections
41
+ through the CLI, and the customized-sub and days-duration report branches.
42
+ Coverage 95% -> **100%**, every module (still 100% after this release's guards).
43
+
44
+ ### Changed
45
+ - README documents the exit codes explicitly (`0` / `1` / `2`) instead of "non-zero on any
46
+ finding". Both 0.4.1 and 0.4.2 turn on the 1-vs-2 distinction, and someone wiring this into a CI
47
+ gate has to know which code means "fix your config" and which means "the gate caught something".
48
+
49
+ ## [0.4.1] - 2026-08-31
50
+
51
+ ### Fixed
52
+ - **A malformed policy could pass every token it was written to reject.** Two silent fail-open
53
+ paths, both found by an audit sweep and both reproduced through the shipped CLI before fixing:
54
+ - **Claim rules written at the top level were ignored entirely.** `issuer` and `audience`
55
+ legitimately live at the top level, so putting the rest of the rules there too is the natural
56
+ slip - and unknown top-level keys were dropped without a word. A policy pinning `repository`
57
+ and `sub` that way returned `PASS (1 pass, 0 fail, 0 missing)` and exit 0 against a token from
58
+ an entirely different repository, having checked only the issuer. Unknown top-level keys are
59
+ now rejected, matching the unknown-rule-key check that already existed one level down.
60
+ - **`in:` given a string did substring matching.** `{"in": "production"}` - a one-item YAML list
61
+ missing its `- ` - became Python containment, so `prod` passed, and so did the empty string.
62
+ `in` must now be a list.
63
+ - **Rule values are validated, not just rule key names.** `matches` must be a string and must
64
+ compile; `glob` must be a string; `required` must be a real boolean (`"false"` is a non-empty
65
+ string, so it silently made an optional claim mandatory). Each of these previously escaped
66
+ `validate()` as a raw `TypeError`/`re.error` traceback and exited **1** - the "a claim did not
67
+ match" code - making a broken policy indistinguishable from a real gate failure. They now raise
68
+ a readable error on the documented rc=2 path. `equals` is deliberately unrestricted: it compares
69
+ against the claim's real JSON type, so a number is a legitimate rule.
70
+ - **An unparseable YAML policy exited 1 instead of 2.** `yaml.YAMLError` is not a `ValueError`
71
+ (`json.JSONDecodeError` is), so it slipped past the CLI's bad-input handler.
72
+ - **A claim carrying two constraints reported the wrong one on failure.** `describe()` named only
73
+ the first constraint, so a `ref` failing its regex was reported against the glob it satisfied.
74
+ All constraints are now listed, which is also what the validator actually requires.
75
+
76
+ ### Added
77
+ - CI now **executes the composite action** (`uses: ./`) against a policy pinning this repository's
78
+ own OIDC token, in both text and JSON formats. `action.yml` is the entry point the README
79
+ advertises and nothing had ever run it, so a break would have surfaced in a user's pipeline
80
+ rather than here. Skipped on fork PRs, which get no token by design.
81
+ - `tests/test_policy_shape.py` - 20 regression tests; 8 of them fail against the previous release.
82
+ Also closes three long-standing coverage holes: the `glob` matcher (which had no assertion
83
+ anywhere despite being a documented rule), the `equals` FAIL path (every prior failure case went
84
+ through `matches` or an absent claim), and the rc=2 bad-policy paths. Coverage 93% -> 95%, with
85
+ `validator.py` at 100%.
86
+
87
+ ### Changed
88
+ - The two `# nosec B105` comments no longer carry trailing prose, which bandit was parsing as a
89
+ comma-separated list of test IDs and warning about on every run.
90
+
91
+ ### Changed
92
+ - Pinned corpus bumped `subvectors==0.2.1` -> `0.3.0`, the release carrying the first six
93
+ `observed` vectors. **No fixture re-derivation was needed and that is a verified fact, not an
94
+ assumption**: `git diff v0.2.1..v0.3.0 -- vectors/` changes no `"subject"` line at all — 0.3.0
95
+ adds `observation` blocks and the schema that enforces them, so the subject grammar this
96
+ project consumes is byte-identical. All three drift directions stay green against the new pin
97
+ (9 vendored subjects, 21 upstream). Bumping still means re-deriving whenever a subject string
98
+ *does* move.
99
+
7
100
  ## [0.4.0] - 2026-08-25
8
101
 
9
102
  ### Added
@@ -10,7 +10,7 @@ sibling [subvectors](https://github.com/Dashtid/subvectors) vector suite.
10
10
 
11
11
  ```bash
12
12
  pip install -e ".[dev]"
13
- pytest -q && ruff check . && bandit -r src
13
+ pytest -q && ruff check . && mypy && bandit -r src
14
14
  ```
15
15
 
16
16
  ## Good first issues
@@ -24,5 +24,5 @@ Small, self-contained additions (labelled `good first issue`):
24
24
 
25
25
  ## Pull requests
26
26
 
27
- Keep the change focused, add a test, and make sure `pytest`, `ruff check`, and `bandit` pass.
27
+ Keep the change focused, add a test, and make sure `pytest`, `ruff check`, `mypy`, and `bandit` pass - CI runs all four.
28
28
  Not sure about scope? Open an issue first — open questions are welcome.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: subcheck
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
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
5
  Project-URL: Homepage, https://github.com/Dashtid/subcheck
6
6
  Project-URL: Related, https://github.com/Dashtid/subvectors
@@ -26,7 +26,7 @@ Requires-Dist: mypy>=1.8; extra == 'dev'
26
26
  Requires-Dist: pytest-cov>=4; extra == 'dev'
27
27
  Requires-Dist: pytest>=7; extra == 'dev'
28
28
  Requires-Dist: ruff>=0.4; extra == 'dev'
29
- Requires-Dist: subvectors==0.2.1; (python_version >= '3.11') and extra == 'dev'
29
+ Requires-Dist: subvectors==0.3.0; (python_version >= '3.11') and extra == 'dev'
30
30
  Requires-Dist: types-pyyaml>=6; extra == 'dev'
31
31
  Description-Content-Type: text/markdown
32
32
 
@@ -56,9 +56,22 @@ OIDC claim inspection: FAIL (5 pass, 1 fail, 1 missing)
56
56
  [-] 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].*)$/
57
57
  [!] environment medium claim 'environment' is required but absent
58
58
  [+] runner_environment medium claim 'runner_environment' satisfies equals 'github-hosted'
59
+
60
+ Notes:
61
+ [i] sub is pinned by name; when this repo adopts the immutable format the sub becomes 'owner@id/repo@id:...' and this pattern stops matching - pin repository_id / repository_owner_id to stay durable. Adoption is automatic for repos created, renamed, or transferred after 2026-07-15, but any repo can be switched on sooner via the org-level or repo-level immutable-subject setting, so do not infer the format from the repo's age.
59
62
  ```
60
63
 
61
- Exit code is non-zero on any finding, so the command drops straight into a CI step as a gate.
64
+ Notes are advisory and never change the verdict. Exit code is non-zero on any finding, so the
65
+ command drops straight into a CI step as a gate:
66
+
67
+ | code | meaning |
68
+ |------|---------|
69
+ | `0` | every rule passed |
70
+ | `1` | at least one claim FAILed or was required-but-MISSING - the gate caught something |
71
+ | `2` | bad input: the token, the claims file or the policy could not be read as what it claims to be |
72
+
73
+ The `1` / `2` split matters in CI. A malformed policy or a claims file that is not a JSON object
74
+ exits `2`, so it reads as "fix your config", never as "the gate caught something".
62
75
 
63
76
  ## Why
64
77
 
@@ -299,6 +312,7 @@ steps:
299
312
  pip install -e ".[dev]"
300
313
  pytest -q # tests
301
314
  ruff check . # lint
315
+ mypy # type check (CI enforces this)
302
316
  bandit -r src # security lint
303
317
  ```
304
318
 
@@ -24,9 +24,22 @@ OIDC claim inspection: FAIL (5 pass, 1 fail, 1 missing)
24
24
  [-] 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].*)$/
25
25
  [!] environment medium claim 'environment' is required but absent
26
26
  [+] runner_environment medium claim 'runner_environment' satisfies equals 'github-hosted'
27
+
28
+ Notes:
29
+ [i] sub is pinned by name; when this repo adopts the immutable format the sub becomes 'owner@id/repo@id:...' and this pattern stops matching - pin repository_id / repository_owner_id to stay durable. Adoption is automatic for repos created, renamed, or transferred after 2026-07-15, but any repo can be switched on sooner via the org-level or repo-level immutable-subject setting, so do not infer the format from the repo's age.
27
30
  ```
28
31
 
29
- Exit code is non-zero on any finding, so the command drops straight into a CI step as a gate.
32
+ Notes are advisory and never change the verdict. Exit code is non-zero on any finding, so the
33
+ command drops straight into a CI step as a gate:
34
+
35
+ | code | meaning |
36
+ |------|---------|
37
+ | `0` | every rule passed |
38
+ | `1` | at least one claim FAILed or was required-but-MISSING - the gate caught something |
39
+ | `2` | bad input: the token, the claims file or the policy could not be read as what it claims to be |
40
+
41
+ The `1` / `2` split matters in CI. A malformed policy or a claims file that is not a JSON object
42
+ exits `2`, so it reads as "fix your config", never as "the gate caught something".
30
43
 
31
44
  ## Why
32
45
 
@@ -267,6 +280,7 @@ steps:
267
280
  pip install -e ".[dev]"
268
281
  pytest -q # tests
269
282
  ruff check . # lint
283
+ mypy # type check (CI enforces this)
270
284
  bandit -r src # security lint
271
285
  ```
272
286
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "subcheck"
7
- version = "0.4.0"
7
+ version = "0.4.2"
8
8
  description = "Decode GitHub Actions OIDC token claims and validate them against an expected-claims policy - a CI gate against over-broad cloud trust."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -38,7 +38,7 @@ dev = [
38
38
  "types-PyYAML>=6",
39
39
  "ruff>=0.4",
40
40
  "bandit>=1.7",
41
- 'subvectors==0.2.1; python_version >= "3.11"',
41
+ 'subvectors==0.3.0; python_version >= "3.11"',
42
42
  ]
43
43
 
44
44
  [project.urls]
@@ -5,7 +5,7 @@ from .policy import ClaimRule, Policy, load_policy, load_policy_file
5
5
  from .report import build_report, to_json, to_text
6
6
  from .validator import Result, validate
7
7
 
8
- __version__ = "0.4.0"
8
+ __version__ = "0.4.2"
9
9
 
10
10
  __all__ = [
11
11
  "decode_claims",
@@ -16,12 +16,23 @@ from .validator import validate
16
16
 
17
17
  def _load_claims(args: argparse.Namespace) -> dict:
18
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
19
+ # A JWT read from stdin/argv, not a stored secret.
20
+ token = sys.stdin.read() if args.token == "-" else args.token # noqa: S105 # nosec B105
20
21
  return decode_claims(token)
21
22
  if args.token_file:
22
23
  return decode_claims(Path(args.token_file).read_text(encoding="utf-8"))
23
24
  if args.claims:
24
- return json.loads(Path(args.claims).read_text(encoding="utf-8"))
25
+ claims = json.loads(Path(args.claims).read_text(encoding="utf-8"))
26
+ # decode_claims already rejects a payload that is not a JSON object, but
27
+ # --claims went straight to json.loads, so a file holding a list or a bare
28
+ # scalar reached the report unchecked. See validate() for why that shape
29
+ # is not merely untidy.
30
+ if not isinstance(claims, dict):
31
+ raise ValueError(
32
+ f"--claims file {args.claims} did not decode to a JSON object "
33
+ f"(got {type(claims).__name__})"
34
+ )
35
+ return claims
25
36
  raise ValueError("provide one of --token, --token-file, or --claims")
26
37
 
27
38
 
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import re
6
7
  from dataclasses import dataclass, field
7
8
  from pathlib import Path
8
9
 
@@ -26,6 +27,7 @@ CLAIM_SEVERITY = {
26
27
  DEFAULT_SEVERITY = "medium"
27
28
 
28
29
  _ALLOWED_KEYS = {"equals", "in", "matches", "glob", "required"}
30
+ _ALLOWED_TOP_KEYS = {"issuer", "audience", "claims"}
29
31
 
30
32
 
31
33
  @dataclass
@@ -42,15 +44,19 @@ class ClaimRule:
42
44
  return CLAIM_SEVERITY.get(self.name, DEFAULT_SEVERITY)
43
45
 
44
46
  def describe(self) -> str:
47
+ # Every constraint set on a rule must hold (validator._matches ANDs them),
48
+ # so describe them all - reporting only the first produced a failure line
49
+ # that contradicted itself when a claim carried two constraints.
50
+ parts = []
45
51
  if self.equals is not None:
46
- return f"equals {self.equals!r}"
52
+ parts.append(f"equals {self.equals!r}")
47
53
  if self.one_of is not None:
48
- return f"one of {self.one_of!r}"
54
+ parts.append(f"one of {self.one_of!r}")
49
55
  if self.matches is not None:
50
- return f"matches /{self.matches}/"
56
+ parts.append(f"matches /{self.matches}/")
51
57
  if self.glob is not None:
52
- return f"glob {self.glob!r}"
53
- return "present"
58
+ parts.append(f"glob {self.glob!r}")
59
+ return " and ".join(parts) if parts else "present"
54
60
 
55
61
 
56
62
  @dataclass
@@ -62,6 +68,15 @@ def load_policy(data: dict) -> Policy:
62
68
  """Build a Policy from a parsed mapping (see the README for the schema)."""
63
69
  if not isinstance(data, dict):
64
70
  raise ValueError("policy must be a mapping/object")
71
+ # Claim rules belong under 'claims'. Silently ignoring anything else let a
72
+ # policy that put its rules at the top level pass every token it was written
73
+ # to reject, green and rc=0, because only 'issuer'/'audience' were read.
74
+ unknown_top = set(data) - _ALLOWED_TOP_KEYS
75
+ if unknown_top:
76
+ raise ValueError(
77
+ f"policy: unknown top-level keys {sorted(unknown_top)} - claim rules "
78
+ "belong under 'claims'; only 'issuer', 'audience' and 'claims' are read"
79
+ )
65
80
  rules: list = []
66
81
  if "issuer" in data:
67
82
  rules.append(ClaimRule(name="iss", equals=str(data["issuer"])))
@@ -86,13 +101,44 @@ def _rule_from_spec(name: str, spec) -> ClaimRule:
86
101
  unknown = set(spec) - _ALLOWED_KEYS
87
102
  if unknown:
88
103
  raise ValueError(f"claim {name!r}: unknown rule keys {sorted(unknown)}")
104
+ one_of = spec.get("in")
105
+ if one_of is not None and not isinstance(one_of, list):
106
+ # `in: production` (a one-item YAML list missing its '- ') became
107
+ # Python containment: substring matching, so 'prod' and even '' passed.
108
+ raise ValueError(
109
+ f"claim {name!r}: 'in' must be a list of values, got "
110
+ f"{type(one_of).__name__}"
111
+ )
112
+ matches = spec.get("matches")
113
+ if matches is not None:
114
+ if not isinstance(matches, str):
115
+ raise ValueError(
116
+ f"claim {name!r}: 'matches' must be a regex string, got "
117
+ f"{type(matches).__name__}"
118
+ )
119
+ try:
120
+ re.compile(matches)
121
+ except re.error as exc:
122
+ raise ValueError(f"claim {name!r}: 'matches' is not a valid regex: {exc}") from exc
123
+ glob = spec.get("glob")
124
+ if glob is not None and not isinstance(glob, str):
125
+ raise ValueError(
126
+ f"claim {name!r}: 'glob' must be a pattern string, got {type(glob).__name__}"
127
+ )
128
+ required = spec.get("required", True)
129
+ if not isinstance(required, bool):
130
+ # bool("false") is True, so a quoted boolean silently made an
131
+ # optional claim mandatory.
132
+ raise ValueError(
133
+ f"claim {name!r}: 'required' must be true or false, got {required!r}"
134
+ )
89
135
  return ClaimRule(
90
136
  name=name,
91
137
  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)),
138
+ one_of=one_of,
139
+ matches=matches,
140
+ glob=glob,
141
+ required=required,
96
142
  )
97
143
  raise ValueError(f"claim {name!r}: rule must be a string, list, or mapping")
98
144
 
@@ -109,7 +155,13 @@ def load_policy_file(path: str | Path) -> Policy:
109
155
  "PyYAML is required to load YAML policies (pip install PyYAML), "
110
156
  "or use a .json policy instead"
111
157
  ) from exc
112
- data = yaml.safe_load(text)
158
+ try:
159
+ data = yaml.safe_load(text)
160
+ except yaml.YAMLError as exc:
161
+ # YAMLError is not a ValueError (json.JSONDecodeError is), so an
162
+ # unparseable YAML policy escaped the CLI's rc=2 handler and exited 1
163
+ # - the "a claim did not match" code.
164
+ raise ValueError(f"policy file {p} is not valid YAML: {exc}") from exc
113
165
  else:
114
166
  data = json.loads(text)
115
167
  return load_policy(data)
@@ -13,6 +13,9 @@ from .validator import FAIL, MISSING, PASS, Result
13
13
 
14
14
  def build_report(claims: dict, results: list[Result], now: float | None = None) -> dict:
15
15
  """Assemble the report. ``now`` (unix seconds) is injectable for tests."""
16
+ # Same guard as validate(): both are public API and both read claims as a mapping.
17
+ if not isinstance(claims, dict):
18
+ raise ValueError(f"claims must be a mapping/object, got {type(claims).__name__}")
16
19
  passed = all(r.status == PASS for r in results)
17
20
  return {
18
21
  "passed": passed,
@@ -8,7 +8,8 @@ from dataclasses import dataclass
8
8
 
9
9
  from .policy import ClaimRule, Policy
10
10
 
11
- PASS = "PASS" # noqa: S105 # nosec B105 - a status constant, not a secret
11
+ # A status constant, not a secret - the linters flag the literal name only.
12
+ PASS = "PASS" # noqa: S105 # nosec B105
12
13
  FAIL = "FAIL"
13
14
  MISSING = "MISSING"
14
15
 
@@ -38,6 +39,13 @@ def _matches(rule: ClaimRule, value) -> bool:
38
39
 
39
40
  def validate(claims: dict, policy: Policy) -> list[Result]:
40
41
  """Check each policy rule against the claims and return one Result per rule."""
42
+ # `rule.name not in claims` is legal on any container, so a list or tuple read
43
+ # as "every claim absent" - MISSING for a required rule, but PASS for an
44
+ # optional one, so an all-optional policy passed claims that held nothing at
45
+ # all. The only thing that stopped a green report was an unrelated crash further
46
+ # down, on claims.get(). Reject the shape here rather than lean on that accident.
47
+ if not isinstance(claims, dict):
48
+ raise ValueError(f"claims must be a mapping/object, got {type(claims).__name__}")
41
49
  results: list[Result] = []
42
50
  for rule in policy.rules:
43
51
  expected = rule.describe()
@@ -0,0 +1,100 @@
1
+ """A claims file that is not a JSON object must be rejected, not half-processed.
2
+
3
+ `decode_claims` has always rejected a JWT payload that is not an object. `--claims`
4
+ went straight to `json.loads` with no such check, so the two claim-input paths
5
+ disagreed about what "claims" means. Three things followed from that asymmetry,
6
+ all reproduced on 0.4.1 and all pinned below:
7
+
8
+ * `validate()` returned **all PASS** for list-shaped claims - `rule.name not in
9
+ claims` is legal on any container, so every rule read as absent, which is PASS
10
+ for an optional rule. Only an unrelated crash further down stopped a green report.
11
+ * `--claims <non-object> --format json` with no policy printed the garbage and
12
+ **exited 0**.
13
+ * Every other combination raised an AttributeError traceback and exited **1** - the
14
+ "a claim did not match" code, so bad input was indistinguishable from a real
15
+ gate failure.
16
+ """
17
+
18
+ import json
19
+
20
+ import pytest
21
+
22
+ from subcheck import build_report, load_policy, validate
23
+ from subcheck.cli import main
24
+ from subcheck.validator import MISSING, PASS
25
+
26
+ NOT_OBJECTS = [[], [1, 2, 3], "hello", 42, None, True]
27
+
28
+ OPTIONAL_POLICY = {
29
+ "claims": {
30
+ "repository": {"equals": "acme/api", "required": False},
31
+ "sub": {"matches": "^repo:acme/api:", "required": False},
32
+ }
33
+ }
34
+
35
+
36
+ # --- the library API ---------------------------------------------------------
37
+
38
+ @pytest.mark.parametrize("claims", NOT_OBJECTS + [()])
39
+ def test_validate_rejects_claims_that_are_not_a_mapping(claims):
40
+ # Was: [] and (1,2,3) returned one PASS per rule, so an all-optional policy
41
+ # reported a clean pass over claims holding nothing at all.
42
+ with pytest.raises(ValueError, match="mapping/object"):
43
+ validate(claims, load_policy(OPTIONAL_POLICY))
44
+
45
+
46
+ @pytest.mark.parametrize("claims", NOT_OBJECTS)
47
+ def test_build_report_rejects_claims_that_are_not_a_mapping(claims):
48
+ with pytest.raises(ValueError, match="mapping/object"):
49
+ build_report(claims, [])
50
+
51
+
52
+ def test_an_empty_claims_object_is_still_accepted():
53
+ # The guard must reject the wrong SHAPE, not an empty token: {} is a legitimate
54
+ # (if useless) claim set, and a required rule should report MISSING as before.
55
+ policy = load_policy({"claims": {"repository": {"equals": "acme/api"}}})
56
+ assert [r.status for r in validate({}, policy)] == [MISSING]
57
+ assert [r.status for r in validate({}, load_policy(OPTIONAL_POLICY))] == [PASS, PASS]
58
+
59
+
60
+ # --- through the CLI ---------------------------------------------------------
61
+
62
+ def _claims_file(tmp_path, value):
63
+ f = tmp_path / "claims.json"
64
+ f.write_text(json.dumps(value), encoding="utf-8")
65
+ return str(f)
66
+
67
+
68
+ @pytest.mark.parametrize("claims", NOT_OBJECTS)
69
+ def test_cli_rejects_a_non_object_claims_file_on_rc2(tmp_path, capsys, claims):
70
+ # Was: rc=1 with an AttributeError traceback - the gate-failure exit code.
71
+ assert main(["--claims", _claims_file(tmp_path, claims)]) == 2
72
+ assert "did not decode to a JSON object" in capsys.readouterr().err
73
+
74
+
75
+ @pytest.mark.parametrize("claims", NOT_OBJECTS)
76
+ def test_cli_json_format_no_longer_exits_zero_on_a_non_object(tmp_path, capsys, claims):
77
+ # Was: rc=0, echoing the garbage back as if it were a decoded token. This is
78
+ # the worst of the three - a silent success on input that is not claims.
79
+ assert main(["--claims", _claims_file(tmp_path, claims), "--format", "json"]) == 2
80
+ assert "did not decode to a JSON object" in capsys.readouterr().err
81
+
82
+
83
+ def test_cli_rejects_a_non_object_claims_file_with_a_policy(tmp_path, capsys):
84
+ policy = tmp_path / "policy.json"
85
+ policy.write_text(json.dumps(OPTIONAL_POLICY), encoding="utf-8")
86
+ rc = main(["--claims", _claims_file(tmp_path, []), "--policy", str(policy)])
87
+ assert rc == 2
88
+ assert "did not decode to a JSON object" in capsys.readouterr().err
89
+
90
+
91
+ def test_both_claim_input_paths_agree_on_what_claims_are(tmp_path, capsys, make_jwt):
92
+ # Parity: the token path has always rejected a non-object payload. The point of
93
+ # the fix is that --claims now gives the same verdict and the same exit code.
94
+ token = tmp_path / "token.jwt"
95
+ token.write_text(make_jwt(["not", "an", "object"]), encoding="utf-8")
96
+ assert main(["--token-file", str(token)]) == 2
97
+ assert "did not decode to a JSON object" in capsys.readouterr().err
98
+
99
+ assert main(["--claims", _claims_file(tmp_path, ["not", "an", "object"])]) == 2
100
+ assert "did not decode to a JSON object" in capsys.readouterr().err
@@ -0,0 +1,113 @@
1
+ """The claim-input paths and the failure exits, which had no coverage.
2
+
3
+ `--token-file` and `--token -` are how the shipped GitHub Action feeds subcheck
4
+ (action.yml pipes the token into `--token -`), so the tool's own advertised entry
5
+ point ran on code no test executed.
6
+ """
7
+
8
+ import json
9
+ import subprocess
10
+ import sys
11
+
12
+ import pytest
13
+
14
+ from subcheck.cli import main
15
+
16
+
17
+ def test_token_file(make_jwt, tmp_path, capsys):
18
+ jwt = make_jwt({"iss": "https://token.actions.githubusercontent.com", "repository": "acme/api"})
19
+ f = tmp_path / "token.jwt"
20
+ f.write_text(jwt, encoding="utf-8")
21
+ assert main(["--token-file", str(f)]) == 0
22
+ assert "repository: acme/api" in capsys.readouterr().out
23
+
24
+
25
+ def test_token_stdin(make_jwt, monkeypatch, capsys):
26
+ # The action.yml path: `... | python3 -m subcheck --token - ...`
27
+ jwt = make_jwt({"iss": "https://token.actions.githubusercontent.com", "repository": "acme/api"})
28
+ monkeypatch.setattr("sys.stdin", __import__("io").StringIO(jwt + "\n"))
29
+ assert main(["--token", "-"]) == 0
30
+ assert "repository: acme/api" in capsys.readouterr().out
31
+
32
+
33
+ def test_token_stdin_then_policy(make_jwt, monkeypatch, tmp_path, capsys):
34
+ jwt = make_jwt({"iss": "https://x", "repository": "acme/api"})
35
+ policy = tmp_path / "p.json"
36
+ policy.write_text(
37
+ json.dumps({"claims": {"repository": {"equals": "acme/api"}}}), encoding="utf-8"
38
+ )
39
+ monkeypatch.setattr("sys.stdin", __import__("io").StringIO(jwt))
40
+ assert main(["--token", "-", "--policy", str(policy)]) == 0
41
+
42
+
43
+ @pytest.mark.parametrize("token", ["not-a-jwt", "a.b", "a.!!!!.c"])
44
+ def test_malformed_token_exits_2(token, capsys):
45
+ assert main(["--token", token]) == 2
46
+ assert "error:" in capsys.readouterr().err
47
+
48
+
49
+ def test_token_payload_that_is_not_an_object_exits_2(capsys):
50
+ import base64
51
+
52
+ def seg(obj):
53
+ return base64.urlsafe_b64encode(json.dumps(obj).encode()).rstrip(b"=").decode()
54
+
55
+ jwt = f"{seg({'alg': 'none'})}.{seg(['not', 'an', 'object'])}.xxxxxxxx"
56
+ assert main(["--token", jwt]) == 2
57
+
58
+
59
+ def test_missing_claims_file_exits_2(capsys):
60
+ assert main(["--claims", "no-such-file.json"]) == 2
61
+ assert "error:" in capsys.readouterr().err
62
+
63
+
64
+ def test_policy_file_with_unknown_suffix_is_read_as_json(tmp_path, make_jwt, monkeypatch):
65
+ # load_policy_file only special-cases .yaml/.yml; everything else is JSON.
66
+ policy = tmp_path / "policy.txt"
67
+ policy.write_text(
68
+ json.dumps({"claims": {"repository": {"equals": "acme/api"}}}), encoding="utf-8"
69
+ )
70
+ claims = tmp_path / "c.json"
71
+ claims.write_text(json.dumps({"repository": "acme/api"}), encoding="utf-8")
72
+ assert main(["--claims", str(claims), "--policy", str(policy)]) == 0
73
+
74
+
75
+ def test_json_format_report_shape(examples_dir, capsys):
76
+ rc = main(["--claims", str(examples_dir / "claims-pull-request.json"),
77
+ "--policy", str(examples_dir / "policy.json"), "--format", "json"])
78
+ assert rc == 1
79
+ report = json.loads(capsys.readouterr().out)
80
+ assert report["passed"] is False
81
+ assert report["summary"]["fail"] >= 1
82
+ counted = sum(report["summary"][k] for k in ("pass", "fail", "missing"))
83
+ assert counted == len(report["results"])
84
+ assert isinstance(report["notes"], list)
85
+
86
+
87
+ def test_python_dash_m_entry_point():
88
+ # `python -m subcheck` is what action.yml invokes; __main__.py had 0% coverage.
89
+ # Run out-of-process against this checkout, so it works whether or not the
90
+ # package happens to be installed in the environment running the tests.
91
+ import os
92
+ from pathlib import Path
93
+
94
+ src = Path(__file__).resolve().parent.parent / "src"
95
+ env = {**os.environ, "PYTHONPATH": str(src)}
96
+ r = subprocess.run([sys.executable, "-m", "subcheck", "--version"],
97
+ capture_output=True, text=True, env=env)
98
+ assert r.returncode == 0, r.stderr
99
+ assert "subcheck" in r.stdout
100
+
101
+
102
+ def test_python_dash_m_subcheck_cli_entry_point():
103
+ # cli.py carries its own `if __name__ == "__main__"` guard, reachable as
104
+ # `python -m subcheck.cli`. Both entry points are real; exercise both.
105
+ import os
106
+ from pathlib import Path
107
+
108
+ src = Path(__file__).resolve().parent.parent / "src"
109
+ env = {**os.environ, "PYTHONPATH": str(src)}
110
+ r = subprocess.run([sys.executable, "-m", "subcheck.cli", "--version"],
111
+ capture_output=True, text=True, env=env)
112
+ assert r.returncode == 0, r.stderr
113
+ assert "subcheck" in r.stdout
@@ -0,0 +1,196 @@
1
+ """A malformed policy must be rejected, never silently weakened.
2
+
3
+ Every case here PASSED with rc=0 before the guards landed - the gate went green
4
+ while checking less than the author wrote. That is the one failure mode this
5
+ tool exists to prevent, so each case is pinned as a regression test.
6
+ """
7
+
8
+ import json
9
+
10
+ import pytest
11
+
12
+ from subcheck import load_policy, validate
13
+ from subcheck.policy import load_policy_file
14
+ from subcheck.validator import FAIL, PASS
15
+
16
+ # --- claim rules written at the top level instead of under 'claims' ----------
17
+
18
+ def test_unknown_top_level_keys_are_rejected():
19
+ # Was: silently ignored, so the policy checked only 'issuer' and passed a
20
+ # token from an entirely different repository.
21
+ with pytest.raises(ValueError, match="unknown top-level keys"):
22
+ load_policy(
23
+ {
24
+ "issuer": "https://token.actions.githubusercontent.com",
25
+ "repository": {"equals": "acme/payments-api"},
26
+ "sub": {"matches": "^repo:acme/payments-api:"},
27
+ }
28
+ )
29
+
30
+
31
+ def test_the_documented_top_level_keys_are_still_accepted():
32
+ policy = load_policy(
33
+ {
34
+ "issuer": "https://token.actions.githubusercontent.com",
35
+ "audience": "sts.amazonaws.com",
36
+ "claims": {"repository": {"equals": "acme/api"}},
37
+ }
38
+ )
39
+ assert len(policy.rules) == 3
40
+
41
+
42
+ # --- rule value shapes -------------------------------------------------------
43
+
44
+ def test_in_must_be_a_list_not_a_string():
45
+ # Was: `value not in "production"` - substring containment, so 'prod' and
46
+ # even '' satisfied it. In YAML this is a one-item list missing its '- '.
47
+ with pytest.raises(ValueError, match="'in' must be a list"):
48
+ load_policy({"claims": {"environment": {"in": "production"}}})
49
+
50
+
51
+ def test_in_as_a_list_still_works():
52
+ policy = load_policy({"claims": {"environment": {"in": ["production", "prod"]}}})
53
+ assert validate({"environment": "prod"}, policy)[0].status == PASS
54
+ assert validate({"environment": "staging"}, policy)[0].status == FAIL
55
+
56
+
57
+ @pytest.mark.parametrize(
58
+ ("spec", "expected"),
59
+ [
60
+ ({"matches": 123}, "'matches' must be a regex string"),
61
+ ({"matches": "refs/heads/(main"}, "not a valid regex"),
62
+ ({"glob": ["a", "b"]}, "'glob' must be a pattern string"),
63
+ ({"required": "false"}, "'required' must be true or false"),
64
+ ],
65
+ )
66
+ def test_malformed_rule_values_are_rejected(spec, expected):
67
+ # Each of these previously escaped as a raw TypeError/re.error traceback
68
+ # from validate(), exiting 1 - the "a claim did not match" code.
69
+ with pytest.raises(ValueError, match=expected):
70
+ load_policy({"claims": {"ref": spec}})
71
+
72
+
73
+ def test_equals_still_accepts_any_json_scalar():
74
+ # equals compares with != against the claim's real JSON type, so a number
75
+ # is a legitimate rule - the shape guards must not break it.
76
+ policy = load_policy({"claims": {"run_attempt": {"equals": 1}}})
77
+ assert validate({"run_attempt": 1}, policy)[0].status == PASS
78
+
79
+
80
+ # --- file-level parse failures ----------------------------------------------
81
+
82
+ def test_unparseable_yaml_policy_raises_valueerror(tmp_path):
83
+ # yaml.YAMLError is not a ValueError (json.JSONDecodeError is), so this
84
+ # escaped the CLI's rc=2 handler and exited 1.
85
+ p = tmp_path / "policy.yaml"
86
+ p.write_text("claims:\n ref: {matches: '['\n", encoding="utf-8")
87
+ with pytest.raises(ValueError, match="not valid YAML"):
88
+ load_policy_file(p)
89
+
90
+
91
+ def test_unparseable_json_policy_raises_valueerror(tmp_path):
92
+ p = tmp_path / "policy.json"
93
+ p.write_text("{not json", encoding="utf-8")
94
+ with pytest.raises(ValueError):
95
+ load_policy_file(p)
96
+
97
+
98
+ def test_every_shipped_example_policy_still_loads():
99
+ from pathlib import Path
100
+
101
+ examples = sorted(Path(__file__).resolve().parent.parent.glob("examples/policy*"))
102
+ assert examples, "no example policies found"
103
+ for path in examples:
104
+ load_policy_file(path)
105
+
106
+
107
+ # --- describe() reports every constraint ------------------------------------
108
+
109
+ def test_describe_reports_all_constraints_not_just_the_first():
110
+ # Was: only the first constraint was named, so a claim failing on the
111
+ # regex was reported against the glob it actually satisfied.
112
+ policy = load_policy({"claims": {"ref": {"glob": "refs/heads/*", "matches": "main$"}}})
113
+ result = validate({"ref": "refs/tags/v1"}, policy)[0]
114
+ assert result.status == FAIL
115
+ assert "matches /main$/" in result.expected
116
+ assert "glob 'refs/heads/*'" in result.expected
117
+
118
+
119
+ def test_json_policy_round_trips(tmp_path):
120
+ p = tmp_path / "policy.json"
121
+ p.write_text(json.dumps({"claims": {"repository": {"equals": "acme/api"}}}), encoding="utf-8")
122
+ assert len(load_policy_file(p).rules) == 1
123
+
124
+
125
+ # --- the glob matcher, previously untested anywhere ---------------------------
126
+
127
+ @pytest.mark.parametrize(
128
+ ("pattern", "value", "expected"),
129
+ [
130
+ ("refs/heads/*", "refs/heads/main", PASS),
131
+ ("refs/heads/*", "refs/tags/v1", FAIL),
132
+ # fnmatch's '*' spans '/', exactly like AWS StringLike - a nested branch
133
+ # still matches, which is the footgun the README documents.
134
+ ("refs/heads/*", "refs/heads/feature/a", PASS),
135
+ # fnmatchcase, so matching is case-sensitive.
136
+ ("refs/heads/Main", "refs/heads/main", FAIL),
137
+ ],
138
+ )
139
+ def test_glob_matching(pattern, value, expected):
140
+ policy = load_policy({"claims": {"ref": {"glob": pattern}}})
141
+ assert validate({"ref": value}, policy)[0].status == expected
142
+
143
+
144
+ # --- the equals FAIL path, previously never exercised -------------------------
145
+
146
+ def test_equals_mismatch_fails():
147
+ # `equals` is the most common rule type and no test had ever made one FAIL:
148
+ # every existing failure case went through `matches` or an absent claim.
149
+ policy = load_policy({"claims": {"repository": {"equals": "acme/api"}}})
150
+ ok, bad = (validate({"repository": v}, policy)[0] for v in ("acme/api", "attacker/evil"))
151
+ assert ok.status == PASS
152
+ assert bad.status == FAIL
153
+ assert bad.severity == "high"
154
+ assert "attacker/evil" in bad.message
155
+
156
+
157
+ def test_equals_shorthand_string_form_fails_too():
158
+ # The bare-string shorthand builds the same rule as {"equals": ...}.
159
+ policy = load_policy({"claims": {"repository": "acme/api"}})
160
+ assert validate({"repository": "attacker/evil"}, policy)[0].status == FAIL
161
+
162
+
163
+ # --- the remaining rejection paths -------------------------------------------
164
+
165
+ @pytest.mark.parametrize(
166
+ ("data", "expected"),
167
+ [
168
+ (["not", "a", "mapping"], "policy must be a mapping"),
169
+ ({"claims": ["not", "a", "mapping"]}, "'claims' must be a mapping"),
170
+ ({}, "policy is empty"),
171
+ ({"claims": {"ref": 42}}, "rule must be a string, list, or mapping"),
172
+ ],
173
+ )
174
+ def test_structurally_invalid_policies_are_rejected(data, expected):
175
+ with pytest.raises(ValueError, match=expected):
176
+ load_policy(data)
177
+
178
+
179
+ def test_bad_policy_file_exits_2_through_the_cli(tmp_path, capsys):
180
+ from subcheck.cli import main
181
+
182
+ claims = tmp_path / "c.json"
183
+ claims.write_text('{"repository": "acme/api"}', encoding="utf-8")
184
+ policy = tmp_path / "p.json"
185
+ policy.write_text('{"claims": {"environment": {"in": "production"}}}', encoding="utf-8")
186
+ assert main(["--claims", str(claims), "--policy", str(policy)]) == 2
187
+ assert "must be a list" in capsys.readouterr().err
188
+
189
+
190
+ def test_missing_policy_file_exits_2_through_the_cli(tmp_path, capsys):
191
+ from subcheck.cli import main
192
+
193
+ claims = tmp_path / "c.json"
194
+ claims.write_text('{"repository": "acme/api"}', encoding="utf-8")
195
+ assert main(["--claims", str(claims), "--policy", str(tmp_path / "nope.json")]) == 2
196
+ assert "error:" in capsys.readouterr().err
@@ -64,3 +64,25 @@ def test_malformed_half_immutable_sub_is_flagged():
64
64
  policy = {"claims": {"sub": {"matches": r"^repo:.*$"}}}
65
65
  note = " ".join(_report(claims, policy)["notes"])
66
66
  assert "only one segment" in note
67
+
68
+
69
+ def test_customized_sub_warns_that_default_format_conditions_stop_matching():
70
+ # A customized sub REPLACES the default format rather than varying it, so a
71
+ # trust condition written for 'repo:ORG/REPO:...' stops matching entirely -
72
+ # wildcards included. That advisory had no test.
73
+ claims = {
74
+ "sub": "repo:acme/payments-api:environment:production"
75
+ ":job_workflow_ref:acme/shared/.github/workflows/deploy.yml@refs/tags/v1.2.3"
76
+ }
77
+ note = " ".join(_report(claims, {"claims": {"sub": {"matches": "^repo:acme/"}}})["notes"])
78
+ assert "CUSTOMIZED" in note
79
+ assert "wildcards included" in note
80
+
81
+
82
+ def test_unparseable_sub_yields_no_format_notes():
83
+ # A sub that is not a GitHub subject at all (another issuer's format) must
84
+ # not produce migration advice about a format it does not have.
85
+ claims = {"sub": "project_path:acme/api:ref_type:branch:ref:main"}
86
+ report = _report(claims, {"claims": {"sub": {"matches": "^project_path:"}}})
87
+ assert report["passed"]
88
+ assert not any("immutable" in n for n in report["notes"])
@@ -53,3 +53,13 @@ def test_non_numeric_time_claims_are_ignored():
53
53
 
54
54
  def test_absent_time_claims_produce_no_notes():
55
55
  assert _notes({}) == ""
56
+
57
+
58
+ def test_long_expiry_is_reported_in_days():
59
+ # _duration's coarsest branch: anything past 48h reads in days, not hours.
60
+ from subcheck.report import _duration
61
+
62
+ assert _duration(30) == "30s"
63
+ assert _duration(600) == "10m"
64
+ assert _duration(7200) == "2h"
65
+ assert _duration(3 * 86400) == "3d"
@@ -1,34 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- push:
5
- branches: [main]
6
- pull_request:
7
-
8
- permissions:
9
- contents: read
10
-
11
- jobs:
12
- test:
13
- runs-on: ubuntu-latest
14
- strategy:
15
- fail-fast: false
16
- matrix:
17
- python-version: ["3.10", "3.11", "3.12", "3.13"]
18
- steps:
19
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
20
- - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
21
- with:
22
- python-version: ${{ matrix.python-version }}
23
- - name: Install
24
- run: |
25
- python -m pip install --upgrade pip
26
- pip install -e ".[dev]"
27
- - name: Lint (ruff)
28
- run: ruff check .
29
- - name: Type check (mypy)
30
- run: mypy
31
- - name: Security lint (bandit)
32
- run: bandit -q -r src
33
- - name: Tests (pytest)
34
- run: pytest -q --cov=subcheck --cov-report=term-missing
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes