diff --git a/src/trace_tests/modules/tr_pol.py b/src/trace_tests/modules/tr_pol.py index beb932c..354b868 100644 --- a/src/trace_tests/modules/tr_pol.py +++ b/src/trace_tests/modules/tr_pol.py @@ -155,7 +155,7 @@ def check( )) enforcement = policy.get("enforcement_mode") - if enforcement in _VALID_ENFORCEMENT: + if isinstance(enforcement, str) and enforcement in _VALID_ENFORCEMENT: findings.append(Finding("TR-POL-002", Status.PASS, f"policy.enforcement_mode is valid ({enforcement!r})")) else: findings.append(Finding( diff --git a/src/trace_tests/modules/tr_rte.py b/src/trace_tests/modules/tr_rte.py index 97aa93f..b5d73dc 100644 --- a/src/trace_tests/modules/tr_rte.py +++ b/src/trace_tests/modules/tr_rte.py @@ -48,7 +48,7 @@ def check( ] platform = runtime.get("platform") - if platform in _DEV_PLATFORMS: + if isinstance(platform, str) and platform in _DEV_PLATFORMS: if level == 0: findings.append( Finding("TR-RTE-001", Status.PASS, f"runtime.platform is registered ({platform!r})") @@ -63,7 +63,7 @@ def check( f"hardware-attested levels (Level {level} requires a hardware TEE platform)", ) ) - elif platform in _VALID_PLATFORMS: + elif isinstance(platform, str) and platform in _VALID_PLATFORMS: findings.append( Finding("TR-RTE-001", Status.PASS, f"runtime.platform is registered ({platform!r})") ) diff --git a/src/trace_tests/modules/tr_sca.py b/src/trace_tests/modules/tr_sca.py index d500fe1..8bbe03c 100644 --- a/src/trace_tests/modules/tr_sca.py +++ b/src/trace_tests/modules/tr_sca.py @@ -23,7 +23,14 @@ def check(trace: dict[str, Any]) -> list[Finding]: return [Finding("TR-SCA-001", Status.FAIL, "TR-SCA-001: build_provenance must be an object")] slsa_level = prov.get("slsa_level") - if slsa_level in _SLSA_LEVELS: + # JSON booleans are not integers, although Python makes bool an int subclass. + # JSON Schema does admit 1.0 as an integer, so numeric membership remains valid + # after booleans are excluded explicitly. + if ( + isinstance(slsa_level, (int, float)) + and not isinstance(slsa_level, bool) + and slsa_level in _SLSA_LEVELS + ): findings.append(Finding("TR-SCA-001", Status.PASS, f"build_provenance.slsa_level is valid ({slsa_level})")) else: findings.append(Finding( diff --git a/src/trace_tests/modules/tr_sig.py b/src/trace_tests/modules/tr_sig.py index 071d776..6d80b03 100644 --- a/src/trace_tests/modules/tr_sig.py +++ b/src/trace_tests/modules/tr_sig.py @@ -167,7 +167,7 @@ def check(trace: dict[str, Any], record: dict[str, Any], fmt: str, level: int = )) return findings - if kty in _SUPPORTED_KTY: + if isinstance(kty, str) and kty in _SUPPORTED_KTY: label = f"kty={kty!r}" + (f", crv={crv!r}" if crv else "") findings.append(Finding("TR-SIG-004", Status.PASS, f"cnf.jwk key type is supported ({label})")) elif kty is None: diff --git a/tests/test_modules_never_raise.py b/tests/test_modules_never_raise.py index f03b830..81ddd6b 100644 --- a/tests/test_modules_never_raise.py +++ b/tests/test_modules_never_raise.py @@ -4,12 +4,11 @@ a record it does not understand ends the whole run: the caller gets a traceback where a verdict belongs, and the record is neither passed nor failed. -``tr_sig`` did exactly that in five ways. ``Finding(rule=...)`` raised ``TypeError`` on -the one check meant to catch a record that embeds its own private key, and reading -``cnf`` or ``cnf.jwk`` raised ``AttributeError`` whenever either was not an object. The -packaged schema does not forbid a ``d`` member in ``cnf.jwk``, so nothing rejected such -a record before the module saw it. The other six modules already guarded their inputs -with ``isinstance``; this pins that for all seven. +The original regression covered malformed top-level fields but not nested discriminator +fields. Four modules then performed set membership directly on values from the record: +an array or object at ``policy.enforcement_mode``, ``runtime.platform``, +``build_provenance.slsa_level`` or ``cnf.jwk.kty`` raised ``TypeError`` instead of +returning a finding. This matrix pins both levels of the record shape. Scope: the record is a dict throughout and its *fields* are malformed. The cmcp case below hands ``check`` an envelope whose ``trace`` is junk, but it does so directly. @@ -51,12 +50,27 @@ "transparency", "appraisal", "signature", "model", "subject", "iat", ) +NESTED_DISCRIMINANTS = ( + (("policy", "enforcement_mode"), "TR-POL-002"), + (("runtime", "platform"), "TR-RTE-001"), + (("build_provenance", "slsa_level"), "TR-SCA-001"), + (("cnf", "jwk", "kty"), "TR-SIG-004"), +) + def _record() -> dict[str, Any]: raw = json.loads((VECTORS / "signed_root.json").read_text(encoding="utf-8")) return dict(raw.get("record", raw)) +def _replace(record: dict[str, Any], path: tuple[str, ...], value: Any) -> None: + """Replace the value at *path* in a copied fixture.""" + node = record + for key in path[:-1]: + node = node[key] + node[path[-1]] = value + + def _call(module: Any, record: dict[str, Any]) -> list[Finding]: """Invoke a module's ``check`` whatever its parameter list happens to be. @@ -83,6 +97,11 @@ def _mutations() -> list[tuple[str, dict[str, Any]]]: if isinstance(record.get("cnf"), dict): record["cnf"]["jwk"] = junk cases.append((f"cnf.jwk={junk!r}", record)) + for path, _ in NESTED_DISCRIMINANTS: + for junk in JUNK: + record = _record() + _replace(record, path, junk) + cases.append((f"{'.'.join(path)}={junk!r}", record)) record = _record() record["cnf"]["jwk"]["d"] = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" cases.append(("cnf.jwk carries d", record)) @@ -119,6 +138,40 @@ def test_no_module_raises_on_a_record_whose_fields_are_malformed(name: str) -> N ) +@pytest.mark.parametrize( + ("path", "expected_code"), + NESTED_DISCRIMINANTS, + ids=["policy", "runtime", "provenance", "signing-key"], +) +@pytest.mark.parametrize("junk", (["unexpected"], {"unexpected": True}), ids=("array", "object")) +def test_runner_fails_nested_discriminators_instead_of_raising( + path: tuple[str, ...], expected_code: str, junk: Any +) -> None: + record = _record() + _replace(record, path, junk) + + results = run(record, "trace", level=2) + + findings = [finding for module in results.values() for finding in module] + assert any(finding.code == expected_code and finding.failed() for finding in findings), ( + f"{'.'.join(path)}={junk!r} produced no {expected_code} failure: {findings}" + ) + + +@pytest.mark.parametrize("slsa_level", [True, False]) +def test_runner_rejects_boolean_slsa_levels(slsa_level: bool) -> None: + """JSON booleans must not inherit Python's integer membership semantics.""" + record = _record() + record["build_provenance"]["slsa_level"] = slsa_level + + results = run(record, "trace", level=1) + + assert any( + finding.code == "TR-SCA-001" and finding.failed() + for finding in results["TR-SCA"] + ), results["TR-SCA"] + + def test_a_record_embedding_its_own_private_key_fails_rather_than_raising() -> None: """The condition the check exists for, named as its own case. @@ -243,4 +296,3 @@ def test_the_cmcp_path_does_not_raise_on_a_malformed_envelope() -> None: assert not raised, ( "tr_sig.check on a cmcp envelope raised or returned nothing:\n " + "\n ".join(raised) ) - diff --git a/tests/unit/test_tr_sca.py b/tests/unit/test_tr_sca.py index 127acd5..d24f6ee 100644 --- a/tests/unit/test_tr_sca.py +++ b/tests/unit/test_tr_sca.py @@ -4,6 +4,8 @@ margin 0, meaning it could have been deleted silently. """ +import pytest + from trace_tests.modules.tr_sca import check VALID_DIGEST = "sha256:" + "a" * 64 @@ -20,6 +22,17 @@ def test_valid_provenance_passes(): assert not failed, failed +@pytest.mark.parametrize("slsa_level", [0, 1, 2, 3, 0.0, 1.0, 2.0, 3.0]) +def test_every_schema_integer_slsa_level_passes(slsa_level): + """JSON Schema treats numbers with a zero fractional part as integers.""" + failed = [ + finding + for finding in check(_prov(slsa_level=slsa_level)) + if finding.code == "TR-SCA-001" and finding.failed() + ] + assert not failed, failed + + def test_missing_provenance_fails(): failed = [f for f in check({}) if f.failed()] assert any(f.code == "TR-SCA-001" for f in failed)