-
Notifications
You must be signed in to change notification settings - Fork 0
fix(validation): harden judge category controls #913
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7cf9eb6
db0cb58
7b6c93c
0ebcc06
3979f06
54fe33b
ee16ffa
8da4990
f1c232c
3de0533
d34d2a2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| # Judge category-count control hardening | ||
|
|
||
| ## Security | ||
|
|
||
| - Validate the public `validate_judge(..., k=...)` category count before compiled-core discovery. | ||
| - Accept exact built-in integers and genuine concrete NumPy integer scalars while rejecting booleans, subclasses, and arbitrary integer-conversion protocol providers without executing caller conversion callbacks. | ||
| - Marshal only a trusted built-in integer into the existing Rust-owned judge-validation computation; psychometric/fairness formulas, thresholds, and result schemas are unchanged. | ||
|
|
||
| Closes #912. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| # Judge category-count control safety | ||
|
|
||
| ## Scope | ||
|
|
||
| Issue #912 hardens only the Python validation/marshalling boundary for the public `validate_judge(..., k=...)` category-count control. The Rust-owned agreement, quadratic-weighted kappa, degradation, standardized-mean-difference/fairness, gate-decision arithmetic, governed thresholds, and result schema are unchanged. | ||
|
|
||
| Protected `main` previously imported the compiled core before validating `k` and repeatedly invoked `int(k)`. Because Python integer subclasses and arbitrary integer-protocol objects may supply executable conversion methods, that order allowed caller-controlled conversion code to run while establishing a security- and fairness-relevant control. | ||
|
|
||
| The bounded correction admits only an exact built-in `int` or an exact concrete NumPy integer scalar identity, normalizes a trusted NumPy scalar once, enforces the existing `2..=1000` domain, completes label/policy marshalling, and only then imports the Rust core. Booleans, Python/NumPy subclasses, and arbitrary conversion-protocol providers fail before conversion callbacks or Rust dispatch. NumPy scalar-type admission uses identity comparisons rather than set membership so a caller-controlled scalar metaclass cannot inject `__hash__` or `__eq__` execution into the trust decision. | ||
|
|
||
| ## Verification contract | ||
|
|
||
| The regression suite must prove all of the following on the exact PR head: | ||
|
|
||
| - hostile Python integer subclasses execute zero `__int__` callbacks; | ||
| - hostile NumPy integer subclasses execute zero `__int__` callbacks; | ||
| - arbitrary integer-protocol providers execute zero callbacks; | ||
| - caller-controlled NumPy scalar metaclasses execute zero hashing/equality callbacks during type admission; | ||
| - invalid exact category counts fail before compiled-core import; | ||
| - genuine concrete NumPy integer scalars remain compatible and arrive at the Rust boundary as an exact built-in integer; and | ||
| - no judge-validation numerical formula or policy threshold changes. | ||
|
|
||
| The initial test commit `7cf9eb6c2937020b5e755b5ae0a6cc2380fc068d` records the conversion/native-discovery RED contract, and `db0cb5848d317d39f197933043e289e00cdf522b` supplies its first bounded GREEN. A second RED at `3979f064b3e32fe44892cab369f8ffc1e3af4d73` demonstrates that hashed type-container membership would still execute caller-controlled metaclass hooks; `54fe33b2dd9d2a287c04635f2acba7bfc94f10fa` replaces that admission with identity-only comparisons. Hosted exact-head evidence remains authoritative over these remembered identities and must be refetched before lifecycle or integration decisions. | ||
|
|
||
| ## Standards and research basis | ||
|
|
||
| The security references below govern input-validation and development-process evidence; they are not psychometric validity authorities. OWASP ASVS 5.0.0 is the latest stable ASVS release as checked on 2026-08-16; OWASP separately labels its bleeding-edge build as preview-only. NIST SP 800-218 Rev. 1 / SSDF 1.2 remains an Initial Public Draft, so this work retains final SSDF 1.1 as the normative NIST baseline and records the draft only as a standards-watch item. | ||
|
|
||
| ### References (APA 7th) | ||
|
|
||
| MITRE. (2026). *CWE-1287: Improper validation of specified type of input* (CWE Version 4.20). Common Weakness Enumeration. https://cwe.mitre.org/data/definitions/1287.html | ||
|
|
||
| National Institute of Standards and Technology. (2022). *Secure software development framework (SSDF) version 1.1: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218). U.S. Department of Commerce. https://doi.org/10.6028/NIST.SP.800-218 | ||
|
|
||
| National Institute of Standards and Technology. (2025). *Secure software development framework (SSDF) version 1.2: Recommendations for mitigating the risk of software vulnerabilities* (NIST Special Publication 800-218 Rev. 1, Initial Public Draft). U.S. Department of Commerce. https://csrc.nist.gov/pubs/sp/800/218/r1/ipd | ||
|
|
||
| OWASP Foundation. (2025). *OWASP application security verification standard 5.0.0*. https://github.com/OWASP/ASVS/tree/v5.0.0_release | ||
|
|
||
| Williamson, D. M., Xi, X., & Breyer, F. J. (2012). A framework for evaluation and use of automated scoring. *Educational Measurement: Issues and Practice, 31*(1), 2–13. https://doi.org/10.1111/j.1745-3992.2011.00223.x | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,156 @@ | ||
| """Security regressions for judge category-count validation.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import builtins | ||
| from types import SimpleNamespace | ||
|
|
||
| import numpy as np | ||
| import pytest | ||
|
|
||
| import fast_mlsirm | ||
| from fast_mlsirm import validation | ||
|
|
||
|
|
||
| class _HostileInt(int): | ||
| """Integer subclass whose conversion callback records execution.""" | ||
|
|
||
| calls = 0 | ||
|
|
||
| def __int__(self) -> int: | ||
| type(self).calls += 1 | ||
| return 2 | ||
|
|
||
|
|
||
| class _HostileNumpyInt(np.int64): | ||
| """NumPy integer subclass whose conversion callback records execution.""" | ||
|
|
||
| calls = 0 | ||
|
|
||
| def __int__(self) -> int: | ||
| type(self).calls += 1 | ||
| return 2 | ||
|
|
||
|
|
||
| class _HostileScalarMeta(type): | ||
| """Metaclass proving scalar-type admission cannot hash caller types.""" | ||
|
|
||
| calls = 0 | ||
|
|
||
| def __hash__(cls) -> int: | ||
| type(cls).calls += 1 | ||
| return type.__hash__(np.int64) | ||
|
|
||
| def __eq__(cls, other: object) -> bool: | ||
| type(cls).calls += 1 | ||
| return type.__eq__(cls, other) | ||
|
|
||
|
|
||
| class _MetaclassHostileNumpyInt(np.int64, metaclass=_HostileScalarMeta): | ||
| """NumPy scalar subclass with caller-controlled type hash/equality hooks.""" | ||
|
|
||
|
|
||
| class _IntegerProtocolProvider: | ||
| """Arbitrary integer protocol provider that must never be invoked.""" | ||
|
|
||
| calls = 0 | ||
|
|
||
| def __int__(self) -> int: | ||
| type(self).calls += 1 | ||
| return 2 | ||
|
|
||
|
|
||
| def _fake_core(calls: list[tuple[int, type[int]]]) -> SimpleNamespace: | ||
| """Return a deterministic Rust-boundary stand-in for marshalling tests.""" | ||
|
|
||
| def validate_scoring(judge, human, k, **kwargs): | ||
| del judge, human, kwargs | ||
| calls.append((k, type(k))) | ||
| return { | ||
| "gates": [], | ||
| "exact_agreement": 1.0, | ||
| "adjacent_agreement": 1.0, | ||
| "pass": True, | ||
| } | ||
|
|
||
| return SimpleNamespace(validate_scoring=validate_scoring) | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| "control_type", | ||
| [_HostileInt, _HostileNumpyInt, _IntegerProtocolProvider], | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Important / should-fix (not a production hole): this parametrize does not match #912 items 2 and 5 or the repo integer-safety RED contract in Missing cases that production already rejects with zero callbacks (probed on this head): Hostile tests also only assert
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This parametrize covers #935 adds those RED cases and pins the fake through |
||
| ) | ||
| def test_validate_judge_rejects_executable_integer_controls_without_callbacks( | ||
| monkeypatch: pytest.MonkeyPatch, | ||
| control_type: type, | ||
| ) -> None: | ||
| """Rejected category controls cannot run conversion code or Rust dispatch.""" | ||
| control_type.calls = 0 | ||
| rust_calls: list[tuple[int, type[int]]] = [] | ||
| monkeypatch.setattr(fast_mlsirm, "_core", _fake_core(rust_calls), raising=False) | ||
|
|
||
| value = control_type(2) if control_type is not _IntegerProtocolProvider else control_type() | ||
| with pytest.raises(ValueError): | ||
| validation.validate_judge(np.array([0, 1]), np.array([0, 1]), k=value) | ||
|
|
||
| assert control_type.calls == 0 | ||
| assert rust_calls == [] | ||
|
|
||
|
|
||
| def test_validate_judge_rejects_hostile_scalar_metaclass_without_callbacks( | ||
| monkeypatch: pytest.MonkeyPatch, | ||
| ) -> None: | ||
| """Scalar-type admission cannot dispatch caller metaclass hash/equality hooks.""" | ||
| _HostileScalarMeta.calls = 0 | ||
| rust_calls: list[tuple[int, type[int]]] = [] | ||
| monkeypatch.setattr(fast_mlsirm, "_core", _fake_core(rust_calls), raising=False) | ||
|
|
||
| with pytest.raises(ValueError): | ||
| validation.validate_judge( | ||
| np.array([0, 1]), | ||
| np.array([0, 1]), | ||
| k=_MetaclassHostileNumpyInt(2), | ||
| ) | ||
|
|
||
| assert _HostileScalarMeta.calls == 0 | ||
| assert rust_calls == [] | ||
|
|
||
|
|
||
| def test_validate_judge_rejects_invalid_k_before_core_import( | ||
| monkeypatch: pytest.MonkeyPatch, | ||
| ) -> None: | ||
| """An invalid exact category count fails before compiled-core discovery.""" | ||
| real_import = builtins.__import__ | ||
| core_import_calls = 0 | ||
|
|
||
| def guarded_import(name, globals_=None, locals_=None, fromlist=(), level=0): | ||
| nonlocal core_import_calls | ||
| if "_core" in fromlist: | ||
| core_import_calls += 1 | ||
| raise AssertionError("compiled core discovered before k validation") | ||
| return real_import(name, globals_, locals_, fromlist, level) | ||
|
|
||
| monkeypatch.setattr(builtins, "__import__", guarded_import) | ||
|
|
||
| with pytest.raises(ValueError, match="must be >= 2"): | ||
| validation.validate_judge(np.array([0, 1]), np.array([0, 1]), k=1) | ||
|
|
||
| assert core_import_calls == 0 | ||
|
|
||
|
|
||
| @pytest.mark.parametrize( | ||
| "k", | ||
| [np.int8(2), np.uint16(2), np.int32(2), np.uint64(2)], | ||
| ) | ||
| def test_validate_judge_normalizes_genuine_numpy_category_counts( | ||
| monkeypatch: pytest.MonkeyPatch, | ||
| k: np.integer, | ||
| ) -> None: | ||
| """Supported concrete NumPy integer scalars marshal as exact Python ints.""" | ||
| rust_calls: list[tuple[int, type[int]]] = [] | ||
| monkeypatch.setattr(fast_mlsirm, "_core", _fake_core(rust_calls), raising=False) | ||
|
|
||
| verdict = validation.validate_judge(np.array([0, 1]), np.array([0, 1]), k=k) | ||
|
|
||
| assert verdict.passed is True | ||
| assert rust_calls == [(2, int)] | ||
Uh oh!
There was an error while loading. Please reload this page.