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.
- {subcheck-0.4.1 → subcheck-0.4.2}/BACKLOG.md +22 -4
- {subcheck-0.4.1 → subcheck-0.4.2}/CHANGELOG.md +42 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/PKG-INFO +11 -2
- {subcheck-0.4.1 → subcheck-0.4.2}/README.md +10 -1
- {subcheck-0.4.1 → subcheck-0.4.2}/pyproject.toml +1 -1
- {subcheck-0.4.1 → subcheck-0.4.2}/src/subcheck/__init__.py +1 -1
- {subcheck-0.4.1 → subcheck-0.4.2}/src/subcheck/cli.py +11 -1
- {subcheck-0.4.1 → subcheck-0.4.2}/src/subcheck/report.py +3 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/src/subcheck/validator.py +7 -0
- 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.1 → subcheck-0.4.2}/tests/test_policy_shape.py +36 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/tests/test_report.py +22 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/tests/test_time_notes.py +10 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/.gitattributes +0 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/.github/dependabot.yml +0 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/.github/workflows/ci.yml +0 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/.github/workflows/fixture-drift.yml +0 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/.github/workflows/release.yml +0 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/.gitignore +0 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/ARCHITECTURE.md +0 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/CONTRIBUTING.md +0 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/LICENSE +0 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/action.yml +0 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/examples/claims-immutable.json +0 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/examples/claims-main.json +0 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/examples/claims-pull-request.json +0 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/examples/claims-reusable-workflow.json +0 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/examples/policy-immutable.json +0 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/examples/policy-reusable-workflow.json +0 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/examples/policy.json +0 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/examples/policy.yaml +0 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/scripts/check_fixture_drift.py +0 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/src/subcheck/__main__.py +0 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/src/subcheck/decoder.py +0 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/src/subcheck/policy.py +0 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/src/subcheck/py.typed +0 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/tests/conftest.py +0 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/tests/fixtures/github_subjects.json +0 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/tests/test_cli.py +0 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/tests/test_decoder.py +0 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/tests/test_decoder_vectors.py +0 -0
- {subcheck-0.4.1 → subcheck-0.4.2}/tests/test_fixture_provenance.py +0 -0
- {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
|
-
- `[
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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.
|
|
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.
|
|
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"
|
|
@@ -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
|
-
|
|
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
|
|
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
|