mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-11-13 17:58:18 +00:00
feat(access-control-prover): 9.1 local function summaries
This commit is contained in:
@@ -138,7 +138,7 @@ its final resolution, dominance, fact-acquisition and measurement semantics with
|
|||||||
- _Depends: 8.2, 8.3_
|
- _Depends: 8.2, 8.3_
|
||||||
|
|
||||||
- [ ] 9. Interprocedural summaries and concrete paths
|
- [ ] 9. Interprocedural summaries and concrete paths
|
||||||
- [ ] 9.1 Extract local function summaries
|
- [x] 9.1 Extract local function summaries
|
||||||
- Collect each function's operations, witnesses, resolved calls and local completeness without traversing callers.
|
- Collect each function's operations, witnesses, resolved calls and local completeness without traversing callers.
|
||||||
- Done when summaries are deterministic and contain no target names outside fact identifiers.
|
- Done when summaries are deterministic and contain no target names outside fact identifiers.
|
||||||
- _Requirements: 3.1, 3.6, 11.7_
|
- _Requirements: 3.1, 3.6, 11.7_
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Local function summaries for the obligations abstract interpretation.
|
||||||
|
|
||||||
|
Task 9.1 (Reqs 3.1, 3.6, 11.7). Each summary collects a function's protected
|
||||||
|
operations, local witnesses, resolved calls and local completeness WITHOUT
|
||||||
|
traversing callers: the monotone fixpoint over strongly connected call
|
||||||
|
components is task 9.2. Summaries are deterministic and carry no target names
|
||||||
|
beyond fact identifiers (Req 11.7).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from ..ir import FunctionIR
|
||||||
|
from .abstract_domain import Completeness, join_completeness
|
||||||
|
from .operations import Discharge, Operation
|
||||||
|
from .resolution import CallTargetSet, SymbolId, SymbolIndex, resolve_call
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FunctionSummary:
|
||||||
|
"""Local summary of one function: what it holds, whom it calls."""
|
||||||
|
|
||||||
|
symbol: SymbolId
|
||||||
|
operations: tuple[Operation, ...] = ()
|
||||||
|
witnesses: tuple[Discharge, ...] = ()
|
||||||
|
calls: tuple[CallTargetSet, ...] = ()
|
||||||
|
completeness: Completeness = field(default_factory=lambda: Completeness(is_complete=True))
|
||||||
|
|
||||||
|
|
||||||
|
def _enclosing_type(index: SymbolIndex, module: str, name: str) -> str | None:
|
||||||
|
"""Return the enclosing type of the function ``name`` declared in ``module``."""
|
||||||
|
for symbol in index.by_name.get(name, ()):
|
||||||
|
if symbol.module == module:
|
||||||
|
return symbol.enclosing_type
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _function_symbol(index: SymbolIndex, module: str, function_ir: FunctionIR) -> SymbolId:
|
||||||
|
"""Build the stable identity of ``function_ir`` within ``module``."""
|
||||||
|
enclosing_type = _enclosing_type(index, module, function_ir.name)
|
||||||
|
signature = tuple(function_ir.params) if function_ir.params else None
|
||||||
|
return SymbolId(module, enclosing_type, function_ir.name, signature)
|
||||||
|
|
||||||
|
|
||||||
|
def _joined_call_completeness(calls: tuple[CallTargetSet, ...]) -> Completeness:
|
||||||
|
"""Join the completeness of every resolved call into one local value."""
|
||||||
|
completeness = Completeness(is_complete=True)
|
||||||
|
for call in calls:
|
||||||
|
completeness = join_completeness(completeness, call.completeness)
|
||||||
|
return completeness
|
||||||
|
|
||||||
|
|
||||||
|
def _sorted_functions(functions: Iterable[FunctionIR]) -> tuple[FunctionIR, ...]:
|
||||||
|
"""Deterministic function order (name, start line)."""
|
||||||
|
return tuple(sorted(functions, key=lambda fn: (fn.name, fn.start_line)))
|
||||||
|
|
||||||
|
|
||||||
|
def local_summaries(
|
||||||
|
functions_by_module: Mapping[str, Iterable[FunctionIR]],
|
||||||
|
index: SymbolIndex,
|
||||||
|
*,
|
||||||
|
operations_by_symbol: Mapping[SymbolId, tuple[Operation, ...]] | None = None,
|
||||||
|
witnesses_by_symbol: Mapping[SymbolId, tuple[Discharge, ...]] | None = None,
|
||||||
|
) -> tuple[FunctionSummary, ...]:
|
||||||
|
"""Summarise every function locally: operations, witnesses, resolved calls.
|
||||||
|
|
||||||
|
No caller traversal happens here (task 9.2 owns the monotone fixpoint);
|
||||||
|
completeness joins only the resolution completeness of the function's own
|
||||||
|
calls plus the injected operation/witness completeness, so an unresolved or
|
||||||
|
ambiguous call leaves the summary incomplete with its reason preserved.
|
||||||
|
"""
|
||||||
|
operations_by_symbol = operations_by_symbol or {}
|
||||||
|
witnesses_by_symbol = witnesses_by_symbol or {}
|
||||||
|
summaries: list[FunctionSummary] = []
|
||||||
|
for module in sorted(functions_by_module):
|
||||||
|
for function_ir in _sorted_functions(functions_by_module[module]):
|
||||||
|
symbol = _function_symbol(index, module, function_ir)
|
||||||
|
calls = tuple(resolve_call(call, module=module, index=index) for call in function_ir.calls)
|
||||||
|
completeness = _joined_call_completeness(calls)
|
||||||
|
summaries.append(
|
||||||
|
FunctionSummary(
|
||||||
|
symbol=symbol,
|
||||||
|
operations=operations_by_symbol.get(symbol, ()),
|
||||||
|
witnesses=witnesses_by_symbol.get(symbol, ()),
|
||||||
|
calls=calls,
|
||||||
|
completeness=completeness,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(summaries)
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Task 9.1 -- local function summaries: determinism, no caller traversal."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from openultrasast.semantic.cst import declaration_scopes
|
||||||
|
from openultrasast.semantic.ir import CallSite, FunctionIR
|
||||||
|
from openultrasast.semantic.obligations import summaries
|
||||||
|
from openultrasast.semantic.obligations.abstract_domain import Completeness
|
||||||
|
from openultrasast.semantic.obligations.operations import Discharge, Operation
|
||||||
|
from openultrasast.semantic.obligations.resolution import SymbolIndex
|
||||||
|
from openultrasast.semantic.obligations.summaries import FunctionSummary, local_summaries
|
||||||
|
|
||||||
|
PHP_CLASS = """<?php
|
||||||
|
class InvoiceController {
|
||||||
|
public function preview($fields) {
|
||||||
|
return $this->render($fields);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
JAVA_ONE_CLASS = """public class Handler {
|
||||||
|
public void handle() {
|
||||||
|
this.run();
|
||||||
|
}
|
||||||
|
void run() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
JAVA_TWO_CLASSES = """public class Auditor {
|
||||||
|
public boolean check() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class Gate {
|
||||||
|
public boolean check() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_functions(fixture: str, path: str, language: str) -> tuple[FunctionIR, ...]:
|
||||||
|
scopes = declaration_scopes(path, fixture, language)
|
||||||
|
assert scopes, f"fixture produced no declaration scopes: {path}"
|
||||||
|
functions = []
|
||||||
|
for scope in scopes:
|
||||||
|
calls = (CallSite("render", 4, (), (), (), False, 0),) if scope.function_name == "preview" else ()
|
||||||
|
functions.append(
|
||||||
|
FunctionIR(
|
||||||
|
name=scope.function_name,
|
||||||
|
params=scope.params,
|
||||||
|
start_line=scope.start_line,
|
||||||
|
end_line=scope.start_line + 3,
|
||||||
|
binds=(),
|
||||||
|
calls=calls,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(functions)
|
||||||
|
|
||||||
|
|
||||||
|
def _index(fixtures: tuple[tuple[str, str, str], ...]) -> SymbolIndex:
|
||||||
|
return SymbolIndex.build(fixtures)
|
||||||
|
|
||||||
|
|
||||||
|
def _operation(function_name: str) -> Operation:
|
||||||
|
return Operation(
|
||||||
|
path="src/InvoiceController.php",
|
||||||
|
line=3,
|
||||||
|
function=function_name,
|
||||||
|
kind="protected_read",
|
||||||
|
fact_id="php:protected-read",
|
||||||
|
resource="invoice",
|
||||||
|
requires=("path_guard",),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _witness(function_name: str) -> Discharge:
|
||||||
|
return Discharge(
|
||||||
|
path="src/InvoiceController.php",
|
||||||
|
line=2,
|
||||||
|
function=function_name,
|
||||||
|
kind="path_guard",
|
||||||
|
fact_id="php:authorize",
|
||||||
|
provenance="call",
|
||||||
|
scope="statement",
|
||||||
|
detail="authorize",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_function_produces_a_complete_empty_summary() -> None:
|
||||||
|
index = _index((("src/Handler.java", JAVA_ONE_CLASS, "java"),))
|
||||||
|
functions = {"src/Handler.java": (FunctionIR("run", (), 7, 8, (), ()),)}
|
||||||
|
(summary,) = local_summaries(functions, index)
|
||||||
|
assert summary.symbol.name == "run"
|
||||||
|
assert summary.operations == ()
|
||||||
|
assert summary.witnesses == ()
|
||||||
|
assert summary.calls == ()
|
||||||
|
assert summary.completeness == Completeness(is_complete=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unresolved_call_leaves_local_completeness_incomplete() -> None:
|
||||||
|
index = _index((("src/Handler.java", JAVA_ONE_CLASS, "java"),))
|
||||||
|
calls = (CallSite("missing", 3, (), (), (), False, 0),)
|
||||||
|
functions = {"src/Handler.java": (FunctionIR("handle", (), 2, 5, (), calls),)}
|
||||||
|
(summary,) = local_summaries(functions, index)
|
||||||
|
assert summary.completeness.is_complete is False
|
||||||
|
assert "resolution:no_candidate" in summary.completeness.reasons
|
||||||
|
|
||||||
|
|
||||||
|
def test_variable_receiver_call_stays_incomplete() -> None:
|
||||||
|
index = _index((("src/Auditor.java", JAVA_TWO_CLASSES, "java"),))
|
||||||
|
calls = (CallSite("check", 2, (), (), (), False, 0, receiver="auditor", arity=0),)
|
||||||
|
functions = {"src/Auditor.java": (FunctionIR("review", (), 2, 4, (), calls),)}
|
||||||
|
(summary,) = local_summaries(functions, index)
|
||||||
|
assert len(summary.calls[0].targets) == 2
|
||||||
|
assert summary.completeness.is_complete is False
|
||||||
|
assert "resolution:receiver_type_unknown" in summary.completeness.reasons
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_self_call_is_complete() -> None:
|
||||||
|
index = _index((("src/Handler.java", JAVA_ONE_CLASS, "java"),))
|
||||||
|
calls = (CallSite("run", 3, (), (), (), False, 0, receiver="this", arity=0),)
|
||||||
|
functions = {"src/Handler.java": (FunctionIR("handle", (), 2, 5, (), calls),)}
|
||||||
|
(summary,) = local_summaries(functions, index)
|
||||||
|
assert len(summary.calls[0].targets) == 1
|
||||||
|
assert summary.completeness == Completeness(is_complete=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_injected_operations_and_witnesses_are_preserved() -> None:
|
||||||
|
index = _index((("src/InvoiceController.php", PHP_CLASS, "php"),))
|
||||||
|
scopes = declaration_scopes("src/InvoiceController.php", PHP_CLASS, "php")
|
||||||
|
preview_scope = scopes[0]
|
||||||
|
functions = {"src/InvoiceController.php": (FunctionIR(preview_scope.function_name, preview_scope.params, 3, 5, (), ()),)}
|
||||||
|
symbol = local_summaries(functions, index)[0].symbol
|
||||||
|
operations = {symbol: (_operation(preview_scope.function_name),)}
|
||||||
|
witnesses = {symbol: (_witness(preview_scope.function_name),)}
|
||||||
|
(summary,) = local_summaries(functions, index, operations_by_symbol=operations, witnesses_by_symbol=witnesses)
|
||||||
|
assert summary.operations == (_operation(preview_scope.function_name),)
|
||||||
|
assert summary.witnesses == (_witness(preview_scope.function_name),)
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_symbol_carries_enclosing_type_and_signature() -> None:
|
||||||
|
index = _index((("src/InvoiceController.php", PHP_CLASS, "php"),))
|
||||||
|
functions = {"src/InvoiceController.php": (FunctionIR("preview", ("$fields",), 3, 5, (), ()),)}
|
||||||
|
(summary,) = local_summaries(functions, index)
|
||||||
|
assert summary.symbol.enclosing_type == "InvoiceController"
|
||||||
|
assert summary.symbol.signature == ("$fields",)
|
||||||
|
|
||||||
|
|
||||||
|
def test_summaries_are_deterministic_across_builds() -> None:
|
||||||
|
index = _index(
|
||||||
|
(
|
||||||
|
("src/InvoiceController.php", PHP_CLASS, "php"),
|
||||||
|
("src/Handler.java", JAVA_ONE_CLASS, "java"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
functions = {
|
||||||
|
"src/Handler.java": (FunctionIR("handle", (), 2, 5, (), ()),),
|
||||||
|
"src/InvoiceController.php": (FunctionIR("preview", ("$fields",), 3, 5, (), ()),),
|
||||||
|
}
|
||||||
|
assert local_summaries(functions, index) == local_summaries(functions, index)
|
||||||
|
|
||||||
|
|
||||||
|
def test_module_text_contains_no_target_names() -> None:
|
||||||
|
module_source = inspect.getsource(summaries)
|
||||||
|
for target_name in ("symfony", "laravel", "tornado", "wordpress", "stapler", "jetty", "rails", "spring"):
|
||||||
|
assert target_name not in module_source.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_function_summary_is_frozen() -> None:
|
||||||
|
summary = FunctionSummary.__new__(FunctionSummary)
|
||||||
|
with pytest.raises(Exception, match="cannot assign|frozen"):
|
||||||
|
summary.symbol = None # type: ignore[misc,assignment]
|
||||||
Reference in New Issue
Block a user