diff --git a/docs/changelog.d/919-hofstee-scalar-control-safety.md b/docs/changelog.d/919-hofstee-scalar-control-safety.md new file mode 100644 index 000000000..4c45837a6 --- /dev/null +++ b/docs/changelog.d/919-hofstee-scalar-control-safety.md @@ -0,0 +1,5 @@ +# Harden Hofstee scalar control validation + +## Security + +- Harden Hofstee standard-setting scalar controls so rejected booleans, scalar subclasses, arbitrary conversion providers, non-finite/out-of-range percentages, overflowed trusted integers, and inverted bound pairs fail before Rust-core discovery; genuine supported NumPy scalars remain compatible and all Hofstee numerical arithmetic remains Rust-owned. diff --git a/docs/doctoring/hofstee-scalar-control-safety.md b/docs/doctoring/hofstee-scalar-control-safety.md new file mode 100644 index 000000000..850676520 --- /dev/null +++ b/docs/doctoring/hofstee-scalar-control-safety.md @@ -0,0 +1,29 @@ +# Hofstee scalar-control trust boundary + +## Scope + +This note documents the input-validation boundary added for the four public Hofstee standard-setting controls (`min_cut`, `max_cut`, `min_fail`, and `max_fail`). It does not alter the Hofstee ogive, intersection, directed-rounding fallback, or any other psychometric/statistical arithmetic, which remains owned by the Rust core. + +The Python adapter establishes a package-trusted scalar identity before compiled-core discovery. It accepts exact built-in `int`/`float` values and genuine supported NumPy integer/floating scalar identities, rejects booleans, scalar subclasses, and arbitrary conversion-protocol providers before caller callbacks, normalizes accepted values once to built-in `float`, then enforces finite `[0, 100]` domains and ordered bound pairs. + +## Security rationale + +The boundary follows an allowlisted validation strategy at the trusted layer rather than invoking caller-controlled coercion to discover whether a value is admissible. This reduces callback/re-entrancy behavior during validation and keeps malformed control inputs from reaching native dispatch. + +- CWE-1287 identifies insufficient specified-type validation as a weakness and recommends validating against known-good expected type/range contracts. +- OWASP ASVS 5.0.0 is used as the current stable application-security verification baseline; its validation guidance places input validation at a trusted service layer. +- NIST SP 800-218 SSDF 1.1 remains the final secure-development baseline used here. The later SP 800-218 Rev. 1 / SSDF 1.2 publication is still draft material and is tracked as non-normative until finalized. + +## Verification contract + +Regression tests require rejected Python/NumPy scalar subclasses and arbitrary protocol providers to execute zero conversion, representation, comparison, equality, or hashing callbacks and to cause zero Rust-core discovery. Separate cases cover booleans, non-finite values, percentage-range violations, built-in-integer overflow during trusted normalization, inverted cut/fail bounds, genuine NumPy scalar compatibility, and exact built-in-float marshalling at the Rust boundary. + +## References + +MITRE. (2026). *CWE-1287: Improper validation of specified type of input*. 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 SP 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 SP 800-218 Rev. 1, Initial Public Draft). U.S. Department of Commerce. + +OWASP Foundation. (2025). *OWASP Application Security Verification Standard 5.0.0*. https://owasp.org/www-project-application-security-verification-standard/ diff --git a/python/fast_mlsirm/standard_setting.py b/python/fast_mlsirm/standard_setting.py index 4d9b6a6bc..35b8ee25e 100644 --- a/python/fast_mlsirm/standard_setting.py +++ b/python/fast_mlsirm/standard_setting.py @@ -3,11 +3,48 @@ from __future__ import annotations +import math from dataclasses import dataclass import numpy as np +_NUMPY_REAL_SCALAR_TYPES = ( + np.int8, + np.int16, + np.int32, + np.int64, + np.intp, + np.longlong, + np.uint8, + np.uint16, + np.uint32, + np.uint64, + np.uintp, + np.ulonglong, + np.float16, + np.float32, + np.float64, + np.longdouble, +) + + +def _percentage_control(value: object, name: str) -> float: + """Normalize one trusted percentage scalar without caller callbacks.""" + value_type = type(value) + if value_type is not int and value_type is not float and not any( + value_type is trusted_type for trusted_type in _NUMPY_REAL_SCALAR_TYPES + ): + raise ValueError(f"{name} must be a real number") + try: + normalized = float(value) + except (OverflowError, ValueError): + raise ValueError(f"{name} must be finite and in [0, 100]") from None + if not math.isfinite(normalized) or not 0.0 <= normalized <= 100.0: + raise ValueError(f"{name} must be finite and in [0, 100]") + return normalized + + @dataclass class HofsteeResult: """Hofstee compromise standard-setting result. @@ -81,23 +118,21 @@ def hofstee( if s.dtype.kind not in ("i", "u", "f"): raise ValueError("scores must be an integer or float array") sf = np.ascontiguousarray(s, dtype=np.float64) - for name, p in ( - ("min_cut", min_cut), - ("max_cut", max_cut), - ("min_fail", min_fail), - ("max_fail", max_fail), - ): - if not isinstance(p, (int, float)) or isinstance(p, bool): - raise ValueError(f"{name} must be a real number") + min_cut = _percentage_control(min_cut, "min_cut") + max_cut = _percentage_control(max_cut, "max_cut") + min_fail = _percentage_control(min_fail, "min_fail") + max_fail = _percentage_control(max_fail, "max_fail") + if min_cut > max_cut: + raise ValueError("min_cut must not exceed max_cut") + if min_fail > max_fail: + raise ValueError("min_fail must not exceed max_fail") from .fitstats import _core_module core = _core_module() if core is None or not hasattr(core, "py_hofstee"): raise RuntimeError("Rust core with py_hofstee is required") - res = core.py_hofstee( - sf, float(min_cut), float(max_cut), float(min_fail), float(max_fail) - ) + res = core.py_hofstee(sf, min_cut, max_cut, min_fail, max_fail) return HofsteeResult( cut_score=float(res["cut_score"]), fail_rate=float(res["fail_rate"]), diff --git a/tests/test_standard_setting_control_safety.py b/tests/test_standard_setting_control_safety.py new file mode 100644 index 000000000..37b7cc1bd --- /dev/null +++ b/tests/test_standard_setting_control_safety.py @@ -0,0 +1,284 @@ +"""Fail-closed trust-boundary tests for Hofstee scalar controls.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import NoReturn + +import numpy as np +import pytest + +from fast_mlsirm import fitstats +from fast_mlsirm.standard_setting import hofstee + + +_SCORES = np.array([45.0, 55.0, 65.0, 75.0], dtype=np.float64) + + +@dataclass +class _CallbackCounter: + """Count every caller-controlled scalar callback attempted by validation.""" + + calls: int = 0 + + def hit(self) -> NoReturn: + """Record one callback and fail immediately.""" + self.calls += 1 + raise AssertionError("caller callback executed") + + +def _hostile_float(counter: _CallbackCounter) -> float: + """Return a float subclass whose common conversion/comparison hooks fail.""" + + class HostileFloat(float): + def __float__(self): + counter.hit() + + def __repr__(self): + counter.hit() + + def __eq__(self, other): + counter.hit() + + def __lt__(self, other): + counter.hit() + + def __le__(self, other): + counter.hit() + + def __hash__(self): + counter.hit() + + return HostileFloat(40.0) + + +def _hostile_int(counter: _CallbackCounter) -> int: + """Return an int subclass whose common conversion/comparison hooks fail.""" + + class HostileInt(int): + def __float__(self): + counter.hit() + + def __repr__(self): + counter.hit() + + def __eq__(self, other): + counter.hit() + + def __lt__(self, other): + counter.hit() + + def __le__(self, other): + counter.hit() + + def __hash__(self): + counter.hit() + + return HostileInt(40) + + +def _hostile_numpy_float(counter: _CallbackCounter) -> np.float64: + """Return a NumPy floating subclass that must not be normalized.""" + + class HostileNumpyFloat(np.float64): + def __float__(self): + counter.hit() + + def __repr__(self): + counter.hit() + + def __eq__(self, other): + counter.hit() + + def __lt__(self, other): + counter.hit() + + def __le__(self, other): + counter.hit() + + def __hash__(self): + counter.hit() + + return HostileNumpyFloat(40.0) + + +class _FloatProvider: + """Arbitrary float protocol provider that is never an accepted control type.""" + + def __init__(self, counter: _CallbackCounter) -> None: + self._counter = counter + + def __float__(self) -> float: + self._counter.hit() + + def __repr__(self) -> str: + self._counter.hit() + + +class _FakeCore: + """Minimal Rust-boundary stand-in that records normalized controls.""" + + def __init__(self) -> None: + self.calls: list[tuple[float, float, float, float]] = [] + + def py_hofstee( + self, + scores: np.ndarray, + min_cut: float, + max_cut: float, + min_fail: float, + max_fail: float, + ) -> dict[str, object]: + assert scores.dtype == np.float64 + assert all( + type(value) is float + for value in (min_cut, max_cut, min_fail, max_fail) + ) + self.calls.append((min_cut, max_cut, min_fail, max_fail)) + return { + "cut_score": 55.0, + "fail_rate": 20.0, + "failed": False, + "cum_freq_percent": np.array([0.0, 100.0]), + } + + +@pytest.mark.parametrize("factory", [_hostile_float, _hostile_int, _hostile_numpy_float]) +@pytest.mark.parametrize("field", ["min_cut", "max_cut", "min_fail", "max_fail"]) +def test_hofstee_rejects_scalar_subclasses_before_callbacks_or_core( + monkeypatch: pytest.MonkeyPatch, + factory, + field: str, +) -> None: + """Caller scalar subclasses cannot execute code or trigger core discovery.""" + counter = _CallbackCounter() + discovery_calls = 0 + + def discover_core(): + nonlocal discovery_calls + discovery_calls += 1 + raise AssertionError("Rust core discovered before control validation") + + monkeypatch.setattr(fitstats, "_core_module", discover_core) + values = { + "min_cut": 40.0, + "max_cut": 70.0, + "min_fail": 10.0, + "max_fail": 30.0, + } + values[field] = factory(counter) + + with pytest.raises(ValueError, match=rf"{field} must be a real number"): + hofstee(_SCORES, **values) + + assert counter.calls == 0 + assert discovery_calls == 0 + + +@pytest.mark.parametrize("kind", ["bool", "numpy_bool", "provider"]) +def test_hofstee_rejects_non_real_controls_before_core( + monkeypatch: pytest.MonkeyPatch, + kind: str, +) -> None: + """Boolean and protocol-only values fail at the trusted type boundary.""" + counter = _CallbackCounter() + bad_value: object + if kind == "bool": + bad_value = True + elif kind == "numpy_bool": + bad_value = np.bool_(True) + else: + bad_value = _FloatProvider(counter) + monkeypatch.setattr( + fitstats, + "_core_module", + lambda: (_ for _ in ()).throw(AssertionError("unexpected core discovery")), + ) + with pytest.raises(ValueError, match="min_cut must be a real number"): + hofstee(_SCORES, bad_value, 70.0, 10.0, 30.0) + assert counter.calls == 0 + + +@pytest.mark.parametrize( + ("field", "bad_value", "message"), + [ + ("min_cut", -0.1, "min_cut must be finite and in [0, 100]"), + ("max_cut", 100.1, "max_cut must be finite and in [0, 100]"), + ("min_fail", float("nan"), "min_fail must be finite and in [0, 100]"), + ("max_fail", float("inf"), "max_fail must be finite and in [0, 100]"), + ("max_fail", 10**1000, "max_fail must be finite and in [0, 100]"), + ], +) +def test_hofstee_rejects_scalar_domains_before_core( + monkeypatch: pytest.MonkeyPatch, + field: str, + bad_value: object, + message: str, +) -> None: + """Range, finiteness, and overflow failures occur before native discovery.""" + monkeypatch.setattr( + fitstats, + "_core_module", + lambda: (_ for _ in ()).throw(AssertionError("unexpected core discovery")), + ) + values = { + "min_cut": 40.0, + "max_cut": 70.0, + "min_fail": 10.0, + "max_fail": 30.0, + } + values[field] = bad_value + with pytest.raises( + ValueError, + match=message.replace("[", r"\[").replace("]", r"\]"), + ): + hofstee(_SCORES, **values) + + +@pytest.mark.parametrize( + ("values", "message"), + [ + ( + {"min_cut": 70.0, "max_cut": 40.0, "min_fail": 10.0, "max_fail": 30.0}, + "min_cut must not exceed max_cut", + ), + ( + {"min_cut": 40.0, "max_cut": 70.0, "min_fail": 30.0, "max_fail": 10.0}, + "min_fail must not exceed max_fail", + ), + ], +) +def test_hofstee_rejects_inverted_bounds_before_core( + monkeypatch: pytest.MonkeyPatch, + values: dict[str, float], + message: str, +) -> None: + """Cross-field ordering is established on trusted built-in floats only.""" + monkeypatch.setattr( + fitstats, + "_core_module", + lambda: (_ for _ in ()).throw(AssertionError("unexpected core discovery")), + ) + with pytest.raises(ValueError, match=message): + hofstee(_SCORES, **values) + + +def test_hofstee_accepts_genuine_numpy_scalars_and_marshals_builtin_floats( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Supported NumPy scalar identities retain compatibility at the Rust boundary.""" + core = _FakeCore() + monkeypatch.setattr(fitstats, "_core_module", lambda: core) + + result = hofstee( + _SCORES, + np.int32(40), + np.float32(70.0), + np.uint8(10), + np.float64(30.0), + ) + + assert core.calls == [(40.0, 70.0, 10.0, 30.0)] + assert result.cut_score == 55.0 + assert result.fail_rate == 20.0 + assert result.failed is False