From acadf041092b581489b1d35d88c2044605c4c669 Mon Sep 17 00:00:00 2001 From: Marius Ciepluch <11855163+norandom@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:59:37 +0100 Subject: [PATCH] feat(access-control-prover): 11.6 migrate the stapler recogniser through the registry --- .kiro/specs/access-control-prover/tasks.md | 2 +- src/openultrasast/mapping/__init__.py | 1 + src/openultrasast/mapping/legacy.py | 41 +------- src/openultrasast/mapping/stapler.py | 68 ++++++++++++ tests/test_fact_init.py | 3 +- tests/test_mapping_registry.py | 16 ++- tests/test_mapping_stapler.py | 115 +++++++++++++++++++++ 7 files changed, 201 insertions(+), 45 deletions(-) create mode 100644 src/openultrasast/mapping/stapler.py create mode 100644 tests/test_mapping_stapler.py diff --git a/.kiro/specs/access-control-prover/tasks.md b/.kiro/specs/access-control-prover/tasks.md index e28c23d..7a8f11f 100644 --- a/.kiro/specs/access-control-prover/tasks.md +++ b/.kiro/specs/access-control-prover/tasks.md @@ -242,7 +242,7 @@ its final resolution, dominance, fact-acquisition and measurement semantics with - _Requirements: 12.1, 12.2, 12.3_ - _Boundary: Fact Initialization, Admission Guard_ - _Depends: 11.4_ -- [ ] 11.6 Migrate one legacy recogniser through the registry +- [x] 11.6 Migrate one legacy recogniser through the registry - Move one development-evidence family out of inline dispatch without changing its entries. - Done when old fixtures stay stable and inline target-specific matching is removed for that family. - _Requirements: 1.1, 1.7_ diff --git a/src/openultrasast/mapping/__init__.py b/src/openultrasast/mapping/__init__.py index 1da771d..1770db3 100644 --- a/src/openultrasast/mapping/__init__.py +++ b/src/openultrasast/mapping/__init__.py @@ -1,5 +1,6 @@ """Entry-point mapping package: legacy recognisers plus the strategy registry.""" +from . import stapler # noqa: F401 (registers the Stapler strategy at import) from .legacy import * # noqa: F401,F403 from .legacy import _php_methods as _php_methods # re-exported internal helpers (enforcement adapter) from .legacy import _php_routes as _php_routes diff --git a/src/openultrasast/mapping/legacy.py b/src/openultrasast/mapping/legacy.py index 66ea8d0..0672f22 100644 --- a/src/openultrasast/mapping/legacy.py +++ b/src/openultrasast/mapping/legacy.py @@ -186,7 +186,7 @@ def write_entry_points(entry_points: list[EntryPointRecord], path: Path) -> None def _language_entry_points(target: FileTarget, text: str) -> list[EntryPointRecord]: if target.language == "java": - return _stapler_entries(target, text) + _jetty_entries(target, text) + return _jetty_entries(target, text) if target.language == "python": return _python_entry_points(target, text) + _tornado_entries(target, text) if target.language in {"javascript", "typescript"}: @@ -522,45 +522,6 @@ def _java_methods(target: FileTarget, text: str) -> list[tuple[object, str, str] return methods -def _stapler_entries(target: FileTarget, text: str) -> list[EntryPointRecord]: - exposed = re.compile( - r"\b(?:extends|implements)\b[^\{]*\b(?:Descriptor|Action|RootAction|Plugin|UnprotectedRootAction|AbstractDescribableImpl)\b" - ) - parameters = re.compile(r"\b(?:StaplerRequest2?|StaplerResponse2?)\b|@(?:QueryParameter|AncestorInPath)\b") - records = [] - for raw_node, owner, header in _java_methods(target, text): - node = cast(Any, raw_node) - name_node = node.child_by_field_name("name") - name = name_node.text.decode("utf-8") - params = node.child_by_field_name("parameters").text.decode("utf-8") - class_match = exposed.search(owner) - param_match = parameters.search(params) - if not re.search(r"\bpublic\b", header) or not re.match(r"^do[A-Z]", name) or not (class_match or param_match): - continue - public = bool(re.search(r"\bimplements\b[^\{]*\bUnprotectedRootAction\b", owner)) - evidence = ["Stapler public do method"] - if class_match: - evidence.append(class_match.group()) - if param_match: - evidence.append("Stapler parameter: " + param_match.group()) - records.append( - _entry( - target, - node.start_point[0] + 1, - node.end_point[0] + 1, - name, - name, - "route", - "public" if public else "unknown", - "http_request", - evidence, - [], - access_declared=public, - ) - ) - return records - - def _jetty_entries(target: FileTarget, text: str) -> list[EntryPointRecord]: records = [] for raw_node, owner, header in _java_methods(target, text): diff --git a/src/openultrasast/mapping/stapler.py b/src/openultrasast/mapping/stapler.py new file mode 100644 index 0000000..5d3c6c7 --- /dev/null +++ b/src/openultrasast/mapping/stapler.py @@ -0,0 +1,68 @@ +"""Task 11.6: the Stapler entry recogniser as a registered strategy. + +This is a target-named strategy module: Stapler vocabulary is legitimate here +(the engine never imports this module; only the registry dispatches it). The +recognition body moved verbatim from ``mapping/legacy.py`` so existing entries +stay byte-identical; the legacy inline dispatch no longer matches this family. +""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING, Any, cast + +from .legacy import _entry, _java_methods +from .registry import register + +if TYPE_CHECKING: + from .legacy import EntryPointRecord + + +class StaplerStrategy: + """Recognise Jenkins Stapler ``do*`` handler methods on exposed types.""" + + identifier = "stapler" + languages: tuple[str, ...] = ("java",) + + def recognize(self, target: object, text: str) -> list[EntryPointRecord]: + exposed = re.compile( + r"\b(?:extends|implements)\b[^\{]*\b(?:Descriptor|Action|RootAction|Plugin|UnprotectedRootAction|AbstractDescribableImpl)\b" + ) + parameters = re.compile(r"\b(?:StaplerRequest2?|StaplerResponse2?)\b|@(?:QueryParameter|AncestorInPath)\b") + records: list[EntryPointRecord] = [] + for raw_node, owner, header in _java_methods(target, text): # type: ignore[arg-type] + node = cast(Any, raw_node) + name_node = node.child_by_field_name("name") + name = name_node.text.decode("utf-8") + params = node.child_by_field_name("parameters").text.decode("utf-8") + class_match = exposed.search(owner) + param_match = parameters.search(params) + if not re.search(r"\bpublic\b", header) or not re.match(r"^do[A-Z]", name) or not (class_match or param_match): + continue + public = bool(re.search(r"\bimplements\b[^\{]*\bUnprotectedRootAction\b", owner)) + evidence = ["Stapler public do method"] + if class_match: + evidence.append(class_match.group()) + if param_match: + evidence.append("Stapler parameter: " + param_match.group()) + records.append( + _entry( + target, # type: ignore[arg-type] + node.start_point[0] + 1, + node.end_point[0] + 1, + name, + name, + "route", + "public" if public else "unknown", + "http_request", + evidence, + [], + access_declared=public, + ) + ) + return records + + +register(StaplerStrategy()) + +__all__ = ["StaplerStrategy"] diff --git a/tests/test_fact_init.py b/tests/test_fact_init.py index ee0c29a..083d82a 100644 --- a/tests/test_fact_init.py +++ b/tests/test_fact_init.py @@ -175,7 +175,8 @@ def test_extraction_includes_java_operation_rows() -> None: def test_legacy_strategy_extraction_names_recognisers() -> None: records = extract_legacy_strategies(MAPPING_FILE) names = {record.name for record in records} - assert "_stapler_entries" in names + # _stapler_entries migrated to mapping/stapler.py (task 11.6); legacy keeps the rest. + assert "_stapler_entries" not in names assert "_jetty_entries" in names assert "_python_registered_routes" in names for record in records: diff --git a/tests/test_mapping_registry.py b/tests/test_mapping_registry.py index 615d65f..9daf7c3 100644 --- a/tests/test_mapping_registry.py +++ b/tests/test_mapping_registry.py @@ -2,7 +2,9 @@ from __future__ import annotations +import importlib import inspect +from collections.abc import Generator from pathlib import Path import pytest @@ -18,11 +20,17 @@ from openultrasast.preprocess import FileTarget, detect_language @pytest.fixture(autouse=True) -def _clean_registry() -> None: +def _clean_registry() -> Generator[None, None, None]: """Each test registers its own strategies into the shared module registry.""" from openultrasast.mapping import registry + saved_strategies = dict(registry._STRATEGIES) registry._STRATEGIES.clear() + stapler_module = importlib.import_module("openultrasast.mapping.stapler") + importlib.reload(stapler_module) + yield + registry._STRATEGIES.clear() + registry._STRATEGIES.update(saved_strategies) class FixtureRouteStrategy: @@ -93,7 +101,8 @@ JAVA_TEXT = "package p;\nclass H {\n // fixture marker line 3\n void fixtureHa def test_registered_strategy_is_listed_for_its_language() -> None: register(FixtureRouteStrategy()) strategies = registered_strategies("java") - assert [strategy.identifier for strategy in strategies] == ["test-fixture"] + identifiers = [strategy.identifier for strategy in strategies] + assert "test-fixture" in identifiers # built-in strategies (e.g. stapler) may coexist assert registered_strategies("php") == () @@ -115,7 +124,8 @@ def test_strategy_set_provenance_is_preserved() -> None: def test_re_registration_replaces_the_strategy() -> None: register(FixtureRouteStrategy()) register(FixtureRouteStrategy()) - assert [s.identifier for s in registered_strategies("java")] == ["test-fixture"] + identifiers = [s.identifier for s in registered_strategies("java")] + assert identifiers.count("test-fixture") == 1 # replaced, never duplicated def test_registry_entries_are_deterministic() -> None: diff --git a/tests/test_mapping_stapler.py b/tests/test_mapping_stapler.py new file mode 100644 index 0000000..bfe5747 --- /dev/null +++ b/tests/test_mapping_stapler.py @@ -0,0 +1,115 @@ +"""Tests for the migrated Stapler entry-recognition strategy (task 11.6). + +The migration moves the Stapler family out of inline legacy dispatch into a +registered strategy. The invariant pinned here (and by the untouched legacy +Stapler tests in tests/test_mapping_frameworks.py) is byte-equal entries on +the old fixtures: same fields, same provenance, same evidence. +""" + +import importlib +from collections.abc import Generator +from pathlib import Path + +import pytest + +from openultrasast.mapping import analyze_entry_points, legacy, registry +from openultrasast.preprocess import FileTarget + +JAVA_STAPLER = """package hudson.slaves; + +import hudson.model.Descriptor; +import org.kohsuke.stapler.StaplerRequest; + +public class FixtureDescriptor extends Descriptor { + public Object doTest(StaplerRequest req) { + return null; + } + + private Object helperNoEntry() { + return null; + } +} +""" + +JAVA_UNPROTECTED = """package hudson.model; + +import org.kohsuke.stapler.StaplerRequest; + +public class OpenActions implements UnprotectedRootAction { + public Object doExecute(StaplerRequest req) { + return null; + } +} +""" + + +@pytest.fixture(autouse=True) +def _fresh_stapler_strategy() -> Generator[None, None, None]: + saved_strategies = dict(registry._STRATEGIES) + registry._STRATEGIES.clear() + stapler_module = importlib.import_module("openultrasast.mapping.stapler") + importlib.reload(stapler_module) + yield + registry._STRATEGIES.clear() + registry._STRATEGIES.update(saved_strategies) + + +def _target(path: str, text: str) -> FileTarget: + return FileTarget( + path=path, + absolute_path=f"/repo/{path}", + language="java", + loc=len(text.splitlines()), + tags=[], + has_fuzz_entry_point=False, + ) + + +def test_stapler_strategy_is_registered_for_java() -> None: + identifiers = tuple(strategy.identifier for strategy in registry.registered_strategies("java")) + assert "stapler" in identifiers + + +def test_migrated_entries_match_the_legacy_shape(tmp_path: Path) -> None: + (tmp_path / "Fixture.java").write_text(JAVA_STAPLER, encoding="utf-8") + entries = analyze_entry_points(tmp_path, [_target("Fixture.java", JAVA_STAPLER)], include_manifests=False) + stapler_entries = [entry for entry in entries if entry.function_name == "doTest"] + assert len(stapler_entries) == 1 + entry = stapler_entries[0] + assert entry.kind == "route" + assert entry.access_level == "unknown" + assert entry.trust_boundary == "http_request" + assert entry.provenance == "entrypoint:route:unknown" + assert entry.access_declared is False + assert any("Stapler public do method" in line for line in entry.access_evidence) + + +def test_inline_stapler_matching_is_removed_from_legacy() -> None: + assert not hasattr(legacy, "_stapler_entries") + + +def test_jetty_family_stays_inline_in_legacy() -> None: + assert hasattr(legacy, "_jetty_entries") + + +def test_migrated_entries_are_deterministic(tmp_path: Path) -> None: + (tmp_path / "Fixture.java").write_text(JAVA_STAPLER, encoding="utf-8") + first = analyze_entry_points(tmp_path, [_target("Fixture.java", JAVA_STAPLER)], include_manifests=False) + second = analyze_entry_points(tmp_path, [_target("Fixture.java", JAVA_STAPLER)], include_manifests=False) + assert first == second + + +def test_unprotected_root_action_keeps_public_access() -> None: + import tempfile + from pathlib import Path + + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "Open.java").write_text(JAVA_UNPROTECTED, encoding="utf-8") + entries = analyze_entry_points(root, [_target("Open.java", JAVA_UNPROTECTED)], include_manifests=False) + execute_entries = [entry for entry in entries if entry.function_name == "doExecute"] + assert len(execute_entries) == 1 + entry = execute_entries[0] + assert entry.access_level == "public" + assert entry.access_declared is True + assert entry.provenance == "entrypoint:route:public"