subcheck 0.4.1__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 (44) hide show
  1. {subcheck-0.4.1 → subcheck-0.4.2}/BACKLOG.md +22 -4
  2. {subcheck-0.4.1 → subcheck-0.4.2}/CHANGELOG.md +42 -0
  3. {subcheck-0.4.1 → subcheck-0.4.2}/PKG-INFO +11 -2
  4. {subcheck-0.4.1 → subcheck-0.4.2}/README.md +10 -1
  5. {subcheck-0.4.1 → subcheck-0.4.2}/pyproject.toml +1 -1
  6. {subcheck-0.4.1 → subcheck-0.4.2}/src/subcheck/__init__.py +1 -1
  7. {subcheck-0.4.1 → subcheck-0.4.2}/src/subcheck/cli.py +11 -1
  8. {subcheck-0.4.1 → subcheck-0.4.2}/src/subcheck/report.py +3 -0
  9. {subcheck-0.4.1 → subcheck-0.4.2}/src/subcheck/validator.py +7 -0
  10. subcheck-0.4.2/tests/test_claims_shape.py +100 -0
  11. subcheck-0.4.2/tests/test_cli_inputs.py +113 -0
  12. {subcheck-0.4.1 → subcheck-0.4.2}/tests/test_policy_shape.py +36 -0
  13. {subcheck-0.4.1 → subcheck-0.4.2}/tests/test_report.py +22 -0
  14. {subcheck-0.4.1 → subcheck-0.4.2}/tests/test_time_notes.py +10 -0
  15. {subcheck-0.4.1 → subcheck-0.4.2}/.gitattributes +0 -0
  16. {subcheck-0.4.1 → subcheck-0.4.2}/.github/dependabot.yml +0 -0
  17. {subcheck-0.4.1 → subcheck-0.4.2}/.github/workflows/ci.yml +0 -0
  18. {subcheck-0.4.1 → subcheck-0.4.2}/.github/workflows/fixture-drift.yml +0 -0
  19. {subcheck-0.4.1 → subcheck-0.4.2}/.github/workflows/release.yml +0 -0
  20. {subcheck-0.4.1 → subcheck-0.4.2}/.gitignore +0 -0
  21. {subcheck-0.4.1 → subcheck-0.4.2}/ARCHITECTURE.md +0 -0
  22. {subcheck-0.4.1 → subcheck-0.4.2}/CONTRIBUTING.md +0 -0
  23. {subcheck-0.4.1 → subcheck-0.4.2}/LICENSE +0 -0
  24. {subcheck-0.4.1 → subcheck-0.4.2}/action.yml +0 -0
  25. {subcheck-0.4.1 → subcheck-0.4.2}/examples/claims-immutable.json +0 -0
  26. {subcheck-0.4.1 → subcheck-0.4.2}/examples/claims-main.json +0 -0
  27. {subcheck-0.4.1 → subcheck-0.4.2}/examples/claims-pull-request.json +0 -0
  28. {subcheck-0.4.1 → subcheck-0.4.2}/examples/claims-reusable-workflow.json +0 -0
  29. {subcheck-0.4.1 → subcheck-0.4.2}/examples/policy-immutable.json +0 -0
  30. {subcheck-0.4.1 → subcheck-0.4.2}/examples/policy-reusable-workflow.json +0 -0
  31. {subcheck-0.4.1 → subcheck-0.4.2}/examples/policy.json +0 -0
  32. {subcheck-0.4.1 → subcheck-0.4.2}/examples/policy.yaml +0 -0
  33. {subcheck-0.4.1 → subcheck-0.4.2}/scripts/check_fixture_drift.py +0 -0
  34. {subcheck-0.4.1 → subcheck-0.4.2}/src/subcheck/__main__.py +0 -0
  35. {subcheck-0.4.1 → subcheck-0.4.2}/src/subcheck/decoder.py +0 -0
  36. {subcheck-0.4.1 → subcheck-0.4.2}/src/subcheck/policy.py +0 -0
  37. {subcheck-0.4.1 → subcheck-0.4.2}/src/subcheck/py.typed +0 -0
  38. {subcheck-0.4.1 → subcheck-0.4.2}/tests/conftest.py +0 -0
  39. {subcheck-0.4.1 → subcheck-0.4.2}/tests/fixtures/github_subjects.json +0 -0
  40. {subcheck-0.4.1 → subcheck-0.4.2}/tests/test_cli.py +0 -0
  41. {subcheck-0.4.1 → subcheck-0.4.2}/tests/test_decoder.py +0 -0
  42. {subcheck-0.4.1 → subcheck-0.4.2}/tests/test_decoder_vectors.py +0 -0
  43. {subcheck-0.4.1 → subcheck-0.4.2}/tests/test_fixture_provenance.py +0 -0
  44. {subcheck-0.4.1 → subcheck-0.4.2}/tests/test_validator.py +0 -0
@@ -149,6 +149,19 @@ Open follow-ups from the same pass:
149
149
  execute, not the inputs you never thought to send. A gate whose own config can silently weaken it
150
150
  is the failure this tool exists to prevent, so policy-shape tests belong next to claim tests.
151
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
+
152
165
  - `[ ]` `--fail-on <severity>` threshold gating — today any single required-but-missing medium claim
153
166
  fails the whole gate (`report.py` `passed = all(PASS)`); no way to gate on high only.
154
167
  - `[x]` `exp`/`iat`/`nbf` time checks — **DONE 2026-08-25 (v0.4.0)**, as advisory NOTES, never
@@ -172,10 +185,15 @@ Open follow-ups from the same pass:
172
185
  subject parses, not whether it parses *correctly*.
173
186
  - `[ ]` Decide `equals`/`in` type handling: coerce, or keep type-strict + documented (currently the
174
187
  latter).
175
- - `[~]` Close test-coverage holes (**95% now, up from 92%**; `validator.py` reached 100%). Closed
176
- 2026-08-31 (v0.4.1): the `glob` branch, the `equals` FAIL path, and the `rc=2` bad-policy paths
177
- — see `tests/test_policy_shape.py`. Still open: `--token-file`, `--token -` stdin,
178
- `load_policy_file` suffix logic, `to_json`/summary counts, and `__main__.py`.
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.
179
197
  - `[x]` Cosmetic: rephrase the `# nosec B105` comments — **DONE 2026-08-31**. The prose moved to
180
198
  its own line above; `# nosec B105` is now bare, so bandit stops parsing "a status constant, not
181
199
  a secret" as a list of test IDs and runs silent.
@@ -4,6 +4,48 @@ 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
+
7
49
  ## [0.4.1] - 2026-08-31
8
50
 
9
51
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: subcheck
3
- Version: 0.4.1
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
@@ -62,7 +62,16 @@ Notes:
62
62
  ```
63
63
 
64
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.
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".
66
75
 
67
76
  ## Why
68
77
 
@@ -30,7 +30,16 @@ Notes:
30
30
  ```
31
31
 
32
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.
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".
34
43
 
35
44
  ## Why
36
45
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "subcheck"
7
- version = "0.4.1"
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"
@@ -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.1"
8
+ __version__ = "0.4.2"
9
9
 
10
10
  __all__ = [
11
11
  "decode_claims",
@@ -22,7 +22,17 @@ def _load_claims(args: argparse.Namespace) -> dict:
22
22
  if args.token_file:
23
23
  return decode_claims(Path(args.token_file).read_text(encoding="utf-8"))
24
24
  if args.claims:
25
- 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
26
36
  raise ValueError("provide one of --token, --token-file, or --claims")
27
37
 
28
38
 
@@ -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,
@@ -39,6 +39,13 @@ def _matches(rule: ClaimRule, value) -> bool:
39
39
 
40
40
  def validate(claims: dict, policy: Policy) -> list[Result]:
41
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__}")
42
49
  results: list[Result] = []
43
50
  for rule in policy.rules:
44
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
@@ -158,3 +158,39 @@ def test_equals_shorthand_string_form_fails_too():
158
158
  # The bare-string shorthand builds the same rule as {"equals": ...}.
159
159
  policy = load_policy({"claims": {"repository": "acme/api"}})
160
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"
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