Skip to content
5 changes: 5 additions & 0 deletions docs/changelog.d/919-hofstee-scalar-control-safety.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 29 additions & 0 deletions docs/doctoring/hofstee-scalar-control-safety.md
Original file line number Diff line number Diff line change
@@ -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/
57 changes: 46 additions & 11 deletions python/fast_mlsirm/standard_setting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"]),
Expand Down
Loading
Loading