mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-11-13 01:48:17 +00:00
feat(access-control-prover): 11.6 migrate the stapler recogniser through the registry
This commit is contained in:
@@ -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,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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user