diff --git a/docs/changelog.d/980-essay-report-title-trust-boundary.md b/docs/changelog.d/980-essay-report-title-trust-boundary.md new file mode 100644 index 000000000..006246611 --- /dev/null +++ b/docs/changelog.d/980-essay-report-title-trust-boundary.md @@ -0,0 +1,6 @@ +# Essay report title trust boundary + +## Fixed + +- Hardened score, validation-evidence, and facets-calibration essay HTML renderers so caller-supplied titles admit only exact built-in strings, rejecting caller-controlled `str` subclasses before overridden text callbacks such as `strip()` or HTML-escaping operations can execute. +- Added hostile-string-subclass regressions that prove all three public renderers reject before callback execution or artifact creation; scoring, calibration estimation, and psychometric arithmetic remain unchanged. diff --git a/python/fast_mlsirm/scoring/essay/calibration_report_html.py b/python/fast_mlsirm/scoring/essay/calibration_report_html.py index 252ef2167..7eb8a67ba 100644 --- a/python/fast_mlsirm/scoring/essay/calibration_report_html.py +++ b/python/fast_mlsirm/scoring/essay/calibration_report_html.py @@ -532,7 +532,7 @@ def render_essay_facets_calibration_report_html( requested_output = Path(output_path) if requested_output.suffix.lower() != ".html": raise ValueError("essay facets calibration output path must end with .html") - if title is not None and (not isinstance(title, str) or not title.strip()): + if title is not None and (type(title) is not str or not title.strip()): raise ValueError("essay facets calibration title must be a non-empty string") output, approved_root = _bounded_output_path(requested_output, output_root) resolved_title = _DEFAULT_TITLE if title is None else title diff --git a/python/fast_mlsirm/scoring/essay/report_html.py b/python/fast_mlsirm/scoring/essay/report_html.py index ed53f3b16..13ed1b162 100644 --- a/python/fast_mlsirm/scoring/essay/report_html.py +++ b/python/fast_mlsirm/scoring/essay/report_html.py @@ -374,7 +374,7 @@ def render_essay_score_report_html( output = Path(output_path) if output.suffix.lower() != ".html": raise ValueError("essay score report output path must end with .html") - if title is not None and (not isinstance(title, str) or not title.strip()): + if title is not None and (type(title) is not str or not title.strip()): raise ValueError("essay score report title must be a non-empty string") resolved_title = _DEFAULT_TITLE if title is None else title output.parent.mkdir(parents=True, exist_ok=True) diff --git a/python/fast_mlsirm/scoring/essay/validation_report_html.py b/python/fast_mlsirm/scoring/essay/validation_report_html.py index 0f46c8688..50b890f5e 100644 --- a/python/fast_mlsirm/scoring/essay/validation_report_html.py +++ b/python/fast_mlsirm/scoring/essay/validation_report_html.py @@ -237,7 +237,7 @@ def render_essay_validation_evidence_report_html( output = Path(output_path) if output.suffix.lower() != ".html": raise ValueError("essay validation evidence output path must end with .html") - if title is not None and (not isinstance(title, str) or not title.strip()): + if title is not None and (type(title) is not str or not title.strip()): raise ValueError("essay validation evidence title must be a non-empty string") resolved_title = _DEFAULT_TITLE if title is None else title output.parent.mkdir(parents=True, exist_ok=True) diff --git a/tests/test_essay_report_title_trust_boundary.py b/tests/test_essay_report_title_trust_boundary.py new file mode 100644 index 000000000..9a7548d26 --- /dev/null +++ b/tests/test_essay_report_title_trust_boundary.py @@ -0,0 +1,79 @@ +"""Regression tests for essay HTML report title trust boundaries.""" + +from __future__ import annotations + +import runpy +from collections.abc import Callable +from pathlib import Path + +import pytest + +from fast_mlsirm.scoring.essay import ( + render_essay_facets_calibration_report_html, + render_essay_score_report_html, + render_essay_validation_evidence_report_html, +) + +_SCORE_FIXTURES = runpy.run_path( + str(Path(__file__).with_name("test_scoring_essay_report_html.py")) +) +_VALIDATION_FIXTURES = runpy.run_path( + str(Path(__file__).with_name("test_scoring_essay_validation_reporting.py")) +) +_FACETS_FIXTURES = runpy.run_path( + str(Path(__file__).with_name("test_scoring_essay_facets_reporting.py")) +) +clean_score_report = _SCORE_FIXTURES["clean_report"] +build_validation_report = _VALIDATION_FIXTURES["build_report"] +build_facets_report = _FACETS_FIXTURES["build_report"] + + +class _HostileTitle(str): + """String subclass whose text callbacks must never cross a report boundary.""" + + def strip(self, *args: object, **kwargs: object) -> str: + """Fail if validation invokes caller-controlled string behavior.""" + raise AssertionError("hostile title strip callback executed") + + def replace(self, *args: object, **kwargs: object) -> str: + """Fail if HTML escaping invokes caller-controlled string behavior.""" + raise AssertionError("hostile title replace callback executed") + + +@pytest.mark.parametrize( + ("renderer", "report_factory", "message"), + ( + ( + render_essay_score_report_html, + clean_score_report, + "essay score report title must be a non-empty string", + ), + ( + render_essay_validation_evidence_report_html, + build_validation_report, + "essay validation evidence title must be a non-empty string", + ), + ( + render_essay_facets_calibration_report_html, + build_facets_report, + "essay facets calibration title must be a non-empty string", + ), + ), +) +def test_renderers_reject_string_subclasses_without_callbacks( + tmp_path: Path, + renderer: Callable[..., Path], + report_factory: Callable[[], object], + message: str, +) -> None: + """All essay HTML titles reject subclasses before caller callbacks or writes.""" + output = tmp_path / f"{report_factory.__name__}.html" + + with pytest.raises(ValueError, match=message): + renderer( + report_factory(), + output, + title=_HostileTitle("audit title"), + ) + + assert not output.exists()