From 0d222c0e639839fee0a6ded63db866729ebf0c11 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 07:14:54 +0900 Subject: [PATCH 1/9] test(report): reject hostile essay title subclasses --- .../test_essay_report_title_trust_boundary.py | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 tests/test_essay_report_title_trust_boundary.py 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..1ffbd13cf --- /dev/null +++ b/tests/test_essay_report_title_trust_boundary.py @@ -0,0 +1,41 @@ +"""Regression tests for the essay HTML report title trust boundary.""" + +from __future__ import annotations + +import runpy +from pathlib import Path + +import pytest + +from fast_mlsirm.scoring.essay import render_essay_score_report_html + +_REPORT_FIXTURES = runpy.run_path( + str(Path(__file__).with_name("test_scoring_essay_report_html.py")) +) +clean_report = _REPORT_FIXTURES["clean_report"] + + +class _HostileTitle(str): + """String subclass whose text callbacks must never cross the public 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") + + +def test_renderer_rejects_string_subclass_without_callbacks(tmp_path: Path) -> None: + """Custom titles reject string subclasses before any caller callback or write.""" + output = tmp_path / "hostile-title.html" + + with pytest.raises(ValueError, match="title must be a non-empty string"): + render_essay_score_report_html( + clean_report(), + output, + title=_HostileTitle("audit title"), + ) + + assert not output.exists() From 4724b43761b9ffd2b478784a15ac842fbee45a73 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 07:16:08 +0900 Subject: [PATCH 2/9] fix(report): fail closed on essay title subclasses --- python/fast_mlsirm/scoring/essay/report_html.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/fast_mlsirm/scoring/essay/report_html.py b/python/fast_mlsirm/scoring/essay/report_html.py index ed53f3b16..ed1801480 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) @@ -382,4 +382,4 @@ def render_essay_score_report_html( return output -__all__ = ["render_essay_score_report_html"] +__all__ = ["render_essay_score_report_html"] \ No newline at end of file From 032e133e80fe45569f880864147c900e42d75cf5 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 07:17:59 +0900 Subject: [PATCH 3/9] docs(changelog): record essay title trust boundary --- docs/changelog.d/980-essay-report-title-trust-boundary.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 docs/changelog.d/980-essay-report-title-trust-boundary.md 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..cc86bc89c --- /dev/null +++ b/docs/changelog.d/980-essay-report-title-trust-boundary.md @@ -0,0 +1,6 @@ +# Essay report title trust boundary + +## Fixed + +- Hardened `render_essay_score_report_html()` 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 a hostile-string-subclass regression that proves rejection occurs before callback execution or artifact creation; scoring, calibration, and psychometric arithmetic remain unchanged. From 2ec8aa36b7b37a9d07039430f00e2401213f0c9d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 07:20:26 +0900 Subject: [PATCH 4/9] test(report): cover all essay title subclass boundaries --- .../test_essay_report_title_trust_boundary.py | 60 +++++++++++++++---- 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/tests/test_essay_report_title_trust_boundary.py b/tests/test_essay_report_title_trust_boundary.py index 1ffbd13cf..9a7548d26 100644 --- a/tests/test_essay_report_title_trust_boundary.py +++ b/tests/test_essay_report_title_trust_boundary.py @@ -1,22 +1,35 @@ -"""Regression tests for the essay HTML report title trust boundary.""" +"""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_score_report_html +from fast_mlsirm.scoring.essay import ( + render_essay_facets_calibration_report_html, + render_essay_score_report_html, + render_essay_validation_evidence_report_html, +) -_REPORT_FIXTURES = runpy.run_path( +_SCORE_FIXTURES = runpy.run_path( str(Path(__file__).with_name("test_scoring_essay_report_html.py")) ) -clean_report = _REPORT_FIXTURES["clean_report"] +_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 the public boundary.""" + """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.""" @@ -27,13 +40,38 @@ def replace(self, *args: object, **kwargs: object) -> str: raise AssertionError("hostile title replace callback executed") -def test_renderer_rejects_string_subclass_without_callbacks(tmp_path: Path) -> None: - """Custom titles reject string subclasses before any caller callback or write.""" - output = tmp_path / "hostile-title.html" +@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="title must be a non-empty string"): - render_essay_score_report_html( - clean_report(), + with pytest.raises(ValueError, match=message): + renderer( + report_factory(), output, title=_HostileTitle("audit title"), ) From 096345838fae8822961a4b9af7d8c5b14a2f26bf Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 07:21:37 +0900 Subject: [PATCH 5/9] fix(report): harden essay validation title boundary --- python/fast_mlsirm/scoring/essay/validation_report_html.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From d992e775eaf58c85a66f88e81c69590aebe53007 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 07:23:10 +0900 Subject: [PATCH 6/9] fix(report): harden facets calibration title boundary --- python/fast_mlsirm/scoring/essay/calibration_report_html.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/fast_mlsirm/scoring/essay/calibration_report_html.py b/python/fast_mlsirm/scoring/essay/calibration_report_html.py index 252ef2167..6d05a7603 100644 --- a/python/fast_mlsirm/scoring/essay/calibration_report_html.py +++ b/python/fast_mlsirm/scoring/essay/calibration_report_html.py @@ -206,7 +206,7 @@ def _validated_report( "essay_facets_connectedness_mismatch", "$.report.fit_connected", "fit connectedness does not match the source design", - ) + ) from None replayed = EssayFacetsCalibrationReport( report_id=report.report_id, @@ -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 @@ -542,4 +542,4 @@ def render_essay_facets_calibration_report_html( return output -__all__ = ["render_essay_facets_calibration_report_html"] +__all__ = ["render_essay_facets_calibration_report_html"] \ No newline at end of file From 304bb9ae4806be1dd11ce6107a12aa8d5a74fe43 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 07:24:35 +0900 Subject: [PATCH 7/9] fix(report): remove unintended calibration diff --- python/fast_mlsirm/scoring/essay/calibration_report_html.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/fast_mlsirm/scoring/essay/calibration_report_html.py b/python/fast_mlsirm/scoring/essay/calibration_report_html.py index 6d05a7603..7eb8a67ba 100644 --- a/python/fast_mlsirm/scoring/essay/calibration_report_html.py +++ b/python/fast_mlsirm/scoring/essay/calibration_report_html.py @@ -206,7 +206,7 @@ def _validated_report( "essay_facets_connectedness_mismatch", "$.report.fit_connected", "fit connectedness does not match the source design", - ) from None + ) replayed = EssayFacetsCalibrationReport( report_id=report.report_id, @@ -542,4 +542,4 @@ def render_essay_facets_calibration_report_html( return output -__all__ = ["render_essay_facets_calibration_report_html"] \ No newline at end of file +__all__ = ["render_essay_facets_calibration_report_html"] From b7dd14dd1e24e1157d330589fe3e8f868c20f5cb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 07:25:05 +0900 Subject: [PATCH 8/9] docs(changelog): cover all essay title boundaries --- docs/changelog.d/980-essay-report-title-trust-boundary.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changelog.d/980-essay-report-title-trust-boundary.md b/docs/changelog.d/980-essay-report-title-trust-boundary.md index cc86bc89c..006246611 100644 --- a/docs/changelog.d/980-essay-report-title-trust-boundary.md +++ b/docs/changelog.d/980-essay-report-title-trust-boundary.md @@ -2,5 +2,5 @@ ## Fixed -- Hardened `render_essay_score_report_html()` 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 a hostile-string-subclass regression that proves rejection occurs before callback execution or artifact creation; scoring, calibration, and psychometric arithmetic remain unchanged. +- 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. From e2d5e0ebfe11d9b842a88f4773dbad3d4c577ccb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 18 Aug 2026 07:27:14 +0900 Subject: [PATCH 9/9] fix(report): restore score renderer trailing newline --- python/fast_mlsirm/scoring/essay/report_html.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/fast_mlsirm/scoring/essay/report_html.py b/python/fast_mlsirm/scoring/essay/report_html.py index ed1801480..13ed1b162 100644 --- a/python/fast_mlsirm/scoring/essay/report_html.py +++ b/python/fast_mlsirm/scoring/essay/report_html.py @@ -382,4 +382,4 @@ def render_essay_score_report_html( return output -__all__ = ["render_essay_score_report_html"] \ No newline at end of file +__all__ = ["render_essay_score_report_html"]