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.
- subcheck-0.4.2/.github/workflows/ci.yml +76 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/ARCHITECTURE.md +9 -2
- {subcheck-0.4.0 → subcheck-0.4.2}/BACKLOG.md +61 -13
- {subcheck-0.4.0 → subcheck-0.4.2}/CHANGELOG.md +93 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/CONTRIBUTING.md +2 -2
- {subcheck-0.4.0 → subcheck-0.4.2}/PKG-INFO +17 -3
- {subcheck-0.4.0 → subcheck-0.4.2}/README.md +15 -1
- {subcheck-0.4.0 → subcheck-0.4.2}/pyproject.toml +2 -2
- {subcheck-0.4.0 → subcheck-0.4.2}/src/subcheck/__init__.py +1 -1
- {subcheck-0.4.0 → subcheck-0.4.2}/src/subcheck/cli.py +13 -2
- {subcheck-0.4.0 → subcheck-0.4.2}/src/subcheck/policy.py +62 -10
- {subcheck-0.4.0 → subcheck-0.4.2}/src/subcheck/report.py +3 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/src/subcheck/validator.py +9 -1
- subcheck-0.4.2/tests/test_claims_shape.py +100 -0
- subcheck-0.4.2/tests/test_cli_inputs.py +113 -0
- subcheck-0.4.2/tests/test_policy_shape.py +196 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/tests/test_report.py +22 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/tests/test_time_notes.py +10 -0
- subcheck-0.4.0/.github/workflows/ci.yml +0 -34
- {subcheck-0.4.0 → subcheck-0.4.2}/.gitattributes +0 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/.github/dependabot.yml +0 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/.github/workflows/fixture-drift.yml +0 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/.github/workflows/release.yml +0 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/.gitignore +0 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/LICENSE +0 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/action.yml +0 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/examples/claims-immutable.json +0 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/examples/claims-main.json +0 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/examples/claims-pull-request.json +0 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/examples/claims-reusable-workflow.json +0 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/examples/policy-immutable.json +0 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/examples/policy-reusable-workflow.json +0 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/examples/policy.json +0 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/examples/policy.yaml +0 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/scripts/check_fixture_drift.py +0 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/src/subcheck/__main__.py +0 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/src/subcheck/decoder.py +0 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/src/subcheck/py.typed +0 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/tests/conftest.py +0 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/tests/fixtures/github_subjects.json +0 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/tests/test_cli.py +0 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/tests/test_decoder.py +0 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/tests/test_decoder_vectors.py +0 -0
- {subcheck-0.4.0 → subcheck-0.4.2}/tests/test_fixture_provenance.py +0 -0
- {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
|
-
|
|
107
|
-
|
|
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
|
|
16
|
-
2026-08-
|
|
17
|
-
|
|
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
|
-
- `[
|
|
65
|
-
|
|
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
|
|
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)
|
|
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
|
|
104
|
-
reintroduce it
|
|
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
|
-
- `[
|
|
149
|
-
`
|
|
150
|
-
|
|
151
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
41
|
+
'subvectors==0.3.0; python_version >= "3.11"',
|
|
42
42
|
]
|
|
43
43
|
|
|
44
44
|
[project.urls]
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
+
parts.append(f"equals {self.equals!r}")
|
|
47
53
|
if self.one_of is not None:
|
|
48
|
-
|
|
54
|
+
parts.append(f"one of {self.one_of!r}")
|
|
49
55
|
if self.matches is not None:
|
|
50
|
-
|
|
56
|
+
parts.append(f"matches /{self.matches}/")
|
|
51
57
|
if self.glob is not None:
|
|
52
|
-
|
|
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=
|
|
93
|
-
matches=
|
|
94
|
-
glob=
|
|
95
|
-
required=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|