feat(access-control-prover): 11.6 migrate the stapler recogniser through the registry

This commit is contained in:
Marius Ciepluch
2026-10-29 20:59:37 +01:00
parent 3f241bc595
commit acadf04109
7 changed files with 201 additions and 45 deletions
+1 -1
View File
@@ -242,7 +242,7 @@ its final resolution, dominance, fact-acquisition and measurement semantics with
- _Requirements: 12.1, 12.2, 12.3_ - _Requirements: 12.1, 12.2, 12.3_
- _Boundary: Fact Initialization, Admission Guard_ - _Boundary: Fact Initialization, Admission Guard_
- _Depends: 11.4_ - _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. - 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. - Done when old fixtures stay stable and inline target-specific matching is removed for that family.
- _Requirements: 1.1, 1.7_ - _Requirements: 1.1, 1.7_
+1
View File
@@ -1,5 +1,6 @@
"""Entry-point mapping package: legacy recognisers plus the strategy registry.""" """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 * # noqa: F401,F403
from .legacy import _php_methods as _php_methods # re-exported internal helpers (enforcement adapter) from .legacy import _php_methods as _php_methods # re-exported internal helpers (enforcement adapter)
from .legacy import _php_routes as _php_routes from .legacy import _php_routes as _php_routes
+1 -40
View File
@@ -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]: def _language_entry_points(target: FileTarget, text: str) -> list[EntryPointRecord]:
if target.language == "java": if target.language == "java":
return _stapler_entries(target, text) + _jetty_entries(target, text) return _jetty_entries(target, text)
if target.language == "python": if target.language == "python":
return _python_entry_points(target, text) + _tornado_entries(target, text) return _python_entry_points(target, text) + _tornado_entries(target, text)
if target.language in {"javascript", "typescript"}: if target.language in {"javascript", "typescript"}:
@@ -522,45 +522,6 @@ def _java_methods(target: FileTarget, text: str) -> list[tuple[object, str, str]
return methods 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]: def _jetty_entries(target: FileTarget, text: str) -> list[EntryPointRecord]:
records = [] records = []
for raw_node, owner, header in _java_methods(target, text): for raw_node, owner, header in _java_methods(target, text):
+68
View File
@@ -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"]
+2 -1
View File
@@ -175,7 +175,8 @@ def test_extraction_includes_java_operation_rows() -> None:
def test_legacy_strategy_extraction_names_recognisers() -> None: def test_legacy_strategy_extraction_names_recognisers() -> None:
records = extract_legacy_strategies(MAPPING_FILE) records = extract_legacy_strategies(MAPPING_FILE)
names = {record.name for record in records} 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 "_jetty_entries" in names
assert "_python_registered_routes" in names assert "_python_registered_routes" in names
for record in records: for record in records:
+13 -3
View File
@@ -2,7 +2,9 @@
from __future__ import annotations from __future__ import annotations
import importlib
import inspect import inspect
from collections.abc import Generator
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -18,11 +20,17 @@ from openultrasast.preprocess import FileTarget, detect_language
@pytest.fixture(autouse=True) @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.""" """Each test registers its own strategies into the shared module registry."""
from openultrasast.mapping import registry from openultrasast.mapping import registry
saved_strategies = dict(registry._STRATEGIES)
registry._STRATEGIES.clear() 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: 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: def test_registered_strategy_is_listed_for_its_language() -> None:
register(FixtureRouteStrategy()) register(FixtureRouteStrategy())
strategies = registered_strategies("java") 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") == () assert registered_strategies("php") == ()
@@ -115,7 +124,8 @@ def test_strategy_set_provenance_is_preserved() -> None:
def test_re_registration_replaces_the_strategy() -> None: def test_re_registration_replaces_the_strategy() -> None:
register(FixtureRouteStrategy()) register(FixtureRouteStrategy())
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: def test_registry_entries_are_deterministic() -> None:
+115
View File
@@ -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<Thing> {
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"