mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 03:49:26 +00:00
feat(semantic): the hunter's semantic producer covers Java and PHP
Java parsed already, but the producer failed every Java row because no obligation facts existed for the language and that failure took roles and def_use down with it. PHP had no grammar. Now: tree-sitter-php in the semantic extra with PHP node kinds in the CST reader; obligation facts for Jenkins/Stapler, Spring Security and Servlet code (java.toml) and for Laravel, WordPress and Symfony (php.toml), each header naming the training clones and references the names were checked against; a language with an IR but no obligation facts keeps roles and def_use and records obligations as unchecked (None), which the engineered features treat as missing, never as zero. Proven on the committed pool: 6 of 6 Java rows and 33 of 33 PHP rows of one fix change each now carry semantic features. Implemented by Codex; gated here with ruff, mypy and the full suite. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
6fe46c3062
commit
4063fd1562
@@ -100,3 +100,14 @@ def test_screen_reports_the_strongest_feature_per_annotation():
|
||||
assert out.loc["hunks", "strongest_feature"] == "diff.distance" and out.loc["hunks", "auc"] == 0.0
|
||||
assert out.loc["callers", "strongest_feature"] == "source.caller_count" and out.loc["callers", "auc"] == 1.0
|
||||
assert out.loc["roles", "auc"] == 0.5 # constant: no signal
|
||||
|
||||
|
||||
def test_unchecked_obligations_are_missing_while_checked_empty_is_zero():
|
||||
frame = features.feature_frame([{**ROW, "feature.semantic.obligations": value} for value in (None, [])])
|
||||
for column in ("semantic.obligations", "semantic.obligations_undischarged"):
|
||||
assert pd.isna(frame.iloc[0][column])
|
||||
assert frame.iloc[1][column] == 0
|
||||
for row in frame.to_dict("records"):
|
||||
assert row["ok.semantic"]
|
||||
assert row["semantic.sources"] == 1
|
||||
assert row["semantic.calls"] == 2
|
||||
|
||||
@@ -198,3 +198,61 @@ def test_a_removed_guard_anchors_the_function_it_left(tmp_path):
|
||||
assert row["feature.diff.on_changed_line"] and row["feature.diff.distance"] == 0
|
||||
first = next(r for r in rows if r["feature.source.function"] == "first")
|
||||
assert first["feature.diff.distance"] == 4 and not first["feature.diff.on_changed_line"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "text", "source_name", "operation"),
|
||||
[
|
||||
(
|
||||
"App.java",
|
||||
'class App {\n void run() {\n String id = request.getParameter("id");\n'
|
||||
" checkPermission(ADMINISTER);\n getItemByFullName(id);\n }\n}\n",
|
||||
"getParameter",
|
||||
"protected_read",
|
||||
),
|
||||
(
|
||||
"app.php",
|
||||
"<?php\nfunction run($id) {\n $id = $_GET['id'];\n Gate::authorize('view');\n Product::findOrFail($id);\n}\n",
|
||||
"$_GET",
|
||||
"protected_read",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_java_php_semantic_rows_keep_all_annotations(repository, monkeypatch, path, text, source_name, operation):
|
||||
root, change = repository
|
||||
(root / path).write_text(text)
|
||||
monkeypatch.setattr(module, "_git", lambda *args: change["head"])
|
||||
row = compute_rows(root, {**change, "kind": "ordinary", "files": [{"path": path, "head_lines": [[3, 3]]}]})[0]
|
||||
assert row["provenance.semantic.ok"] is True
|
||||
assert row["feature.semantic.roles"][source_name] == "source"
|
||||
assert row["feature.semantic.def_use"]["binds"]
|
||||
assert row["feature.semantic.def_use"]["calls"]
|
||||
assert row["feature.semantic.obligations"] == [{"operation": operation, "line": 5, "discharged": True}]
|
||||
|
||||
|
||||
def test_missing_obligation_facts_preserve_roles_and_def_use(repository, monkeypatch, tmp_path):
|
||||
root, change = repository
|
||||
baseline = compute_rows(root, change)[0]
|
||||
empty = tmp_path / "empty-obligation-facts"
|
||||
empty.mkdir()
|
||||
load = module.load_obligation_facts
|
||||
monkeypatch.setattr(module, "load_obligation_facts", lambda: load(empty))
|
||||
row = compute_rows(root, change)[0]
|
||||
assert row["provenance.semantic.ok"] is True
|
||||
assert row["feature.semantic.obligations"] is None
|
||||
for name in ("roles", "def_use"):
|
||||
assert row[f"feature.semantic.{name}"] == baseline[f"feature.semantic.{name}"]
|
||||
assert row[f"feature.semantic.{name}"]
|
||||
|
||||
|
||||
def test_obligation_loader_errors_remain_type_only(repository, monkeypatch):
|
||||
root, change = repository
|
||||
|
||||
def fail():
|
||||
raise OSError("private file contents")
|
||||
|
||||
monkeypatch.setattr(module, "load_obligation_facts", fail)
|
||||
row = compute_rows(root, change)[0]
|
||||
assert row["provenance.semantic.ok"] is False
|
||||
assert row["provenance.semantic.reason"] == "OSError"
|
||||
assert "private file contents" not in json.dumps(row)
|
||||
|
||||
@@ -86,6 +86,7 @@ def test_learn_slice_parameterized_sink_and_family_routing() -> None:
|
||||
("typescript", "function f(req: any) { const x = req.query.x; eval(x); }"),
|
||||
("c", "void f(char *x) { char *y = x; system(y); }"),
|
||||
("java", "class C { void f(String x) { String y = x; exec(y); } }"),
|
||||
("php", "<?php function f($x) { $y = $x; exec($y); }"),
|
||||
],
|
||||
)
|
||||
def test_learn_slice_shared_cst_declarations(language: str, code: str) -> None:
|
||||
@@ -99,8 +100,8 @@ def test_learn_slice_same_line_assignment_order() -> None:
|
||||
|
||||
|
||||
def test_learn_slice_fixed_framing_redaction_and_unsupported() -> None:
|
||||
assert function_slice("<?php function f($x) { exec($x); }", "php", "injection") is None
|
||||
assert render_slice(None, "php") == "Data flow inside the function: not analysed (php is not supported)."
|
||||
assert function_slice("def f(x); exec(x); end", "ruby", "injection") is None
|
||||
assert render_slice(None, "ruby") == "Data flow inside the function: not analysed (ruby is not supported)."
|
||||
assert render_slice(Slice(), "anything") == "Data flow inside the function: no source reaches a sink of this family."
|
||||
assert "CVE" not in render_slice(None, "CVE-2026-1234")
|
||||
result = sliced("def f(x):\n execute(x) # CVE-2026-1234 vulnerable")
|
||||
@@ -225,7 +226,7 @@ def test_learn_slice_prompt_flow_examples_citations_and_request_identity() -> No
|
||||
def test_learn_slice_prompt_not_analysable() -> None:
|
||||
import test_learn_program as baseline
|
||||
|
||||
candidate = replace(baseline.candidate(), language="php")
|
||||
candidate = replace(baseline.candidate(), language="ruby")
|
||||
prompt, _ = baseline.program(slice=True).prepare(candidate, baseline.FOLD)
|
||||
tail = str(prompt.messages[1]["content"]).split("Candidate:\n")[1]
|
||||
assert candidate.code.rstrip() + "\nData flow inside the function: not analysed (php is not supported).\nSignals:" in tail
|
||||
assert candidate.code.rstrip() + "\nData flow inside the function: not analysed (ruby is not supported).\nSignals:" in tail
|
||||
|
||||
@@ -70,3 +70,24 @@ def test_flow_facts_loader_ignores_the_obligation_facts_directory() -> None:
|
||||
assert DEFAULT_OBLIGATION_FACTS_DIR.is_dir() and DEFAULT_OBLIGATION_FACTS_DIR.name == "obligations"
|
||||
flow = load_facts()
|
||||
assert not any(source.language == "obligations" for source in flow.sources)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("language", "text", "operation", "discharger"),
|
||||
[
|
||||
("java", "class App { void run() { checkPermission(ADMINISTER); save(); } }", "jenkins-item-write", "jenkins-permission-guard"),
|
||||
("php", "<?php function run($id) { Gate::authorize('view'); Product::findOrFail($id); }", "eloquent-read", "authorization-guard"),
|
||||
],
|
||||
)
|
||||
def test_java_php_facts_resolve_operations_and_dischargers(language, text, operation, discharger) -> None:
|
||||
from openultrasast.semantic.facts import load_facts
|
||||
from openultrasast.semantic.ir import parse_file
|
||||
from openultrasast.semantic.obligations.facts import load_obligation_facts
|
||||
from openultrasast.semantic.obligations.operations import find_discharges, find_operations
|
||||
|
||||
facts = load_obligation_facts().for_language(language)
|
||||
assert facts.operations and facts.dischargers
|
||||
ir = parse_file(f"app.{language}", text, language)
|
||||
assert ir.parse_ok
|
||||
assert operation in {op.fact_id for op in find_operations(ir, facts, text=text)}
|
||||
assert discharger in {d.fact_id for d in find_discharges(ir, facts, load_facts().for_language(language), text=text)}
|
||||
|
||||
@@ -68,3 +68,72 @@ def test_c_constant_system_demotes(tmp_path: Path) -> None:
|
||||
assert demoted
|
||||
assert demoted[0].dominating_fact == "constant"
|
||||
assert demoted[0].evidence_level == "static_corroboration"
|
||||
|
||||
|
||||
def test_php_functions_binds_calls_and_parameters(tmp_path: Path) -> None:
|
||||
_require_extra()
|
||||
from tree_sitter import Parser
|
||||
|
||||
from openultrasast.semantic.cst import _KIND, _walk
|
||||
from openultrasast.semantic.extra import grammar_for
|
||||
|
||||
text = """<p>Text mode</p>
|
||||
<?php
|
||||
class Example {
|
||||
public function run(?Thing $x, &$y, ...$rest) {
|
||||
$value = $this->g($y);
|
||||
$value .= "hello $x";
|
||||
$allowed = Gate::authorize('read', $x);
|
||||
$this?->g($y);
|
||||
}
|
||||
}
|
||||
function free_fn($x) { return consume($x); }
|
||||
$closure = function ($z) { return consume($z); };
|
||||
$arrow = fn($a) => consume($a);
|
||||
"""
|
||||
path = tmp_path / "sample.php"
|
||||
path.write_text(text)
|
||||
data = path.read_bytes()
|
||||
assert len(data) == len(text.encode()) > 0
|
||||
tree = Parser(grammar_for("php")).parse(data)
|
||||
assert not tree.root_node.has_error
|
||||
seen = {node.type for node in _walk(tree.root_node)}
|
||||
# Exercise every registered kind against the installed grammar, including fn.
|
||||
assert set().union(*_KIND["php"].values()) <= seen
|
||||
ir = parse_file(path.name, data.decode(), "php")
|
||||
assert ir.parse_ok and ir.engine == "tree-sitter"
|
||||
assert [(fn.name, fn.start_line, fn.end_line, fn.params) for fn in ir.functions] == [
|
||||
("<module>", 1, 14, ()),
|
||||
("run", 4, 9, ("$x", "$y", "$rest")),
|
||||
("free_fn", 11, 11, ("$x",)),
|
||||
("<anon>", 12, 12, ("$z",)),
|
||||
("<anon>", 13, 13, ("$a",)),
|
||||
]
|
||||
module, method, free, closure, arrow = ir.functions
|
||||
assert module.calls == ()
|
||||
assert [b.name for b in module.binds] == ["$closure", "$arrow"]
|
||||
assert [(b.name, b.line, b.names, b.call_name) for b in method.binds] == [
|
||||
("$value", 5, ("$this", "$y"), "g"),
|
||||
("$value", 6, ("$value", "$x"), None),
|
||||
("$allowed", 7, ("$x",), "authorize"),
|
||||
]
|
||||
assert [(c.name, c.line, c.arg_texts, c.arg_names) for c in method.calls] == [
|
||||
("g", 5, ("$y",), (("$y",),)),
|
||||
("authorize", 7, ("'read'", "$x"), ((), ("$x",))),
|
||||
("g", 8, ("$y",), (("$y",),)),
|
||||
]
|
||||
assert method.calls[1].arg_is_constant == (True, False)
|
||||
for fn in (free, closure, arrow):
|
||||
assert [call.name for call in fn.calls] == ["consume"]
|
||||
assert fn.calls[0].arg_names == (fn.params,)
|
||||
|
||||
|
||||
def test_php_interpolation_and_named_arguments() -> None:
|
||||
_require_extra()
|
||||
ir = parse_file("sample.php", '<?php function f($x) { $s = "hello $x"; consume(value: "hello", other: "$x"); }', "php")
|
||||
assert ir.parse_ok
|
||||
fn = ir.functions[1]
|
||||
assert fn.binds[0].names == ("$x",) and not fn.binds[0].is_constant
|
||||
assert fn.calls[0].arg_texts == ('"hello"', '"$x"')
|
||||
assert fn.calls[0].arg_names == ((), ("$x",))
|
||||
assert fn.calls[0].arg_is_constant == (True, False)
|
||||
|
||||
@@ -22,6 +22,7 @@ def test_core_dependencies_are_pyyaml_only_and_semantic_is_optional() -> None:
|
||||
assert "tree-sitter-javascript" in joined
|
||||
assert "tree-sitter-c" in joined
|
||||
assert "tree-sitter-java" in joined
|
||||
assert "tree-sitter-php>=0.23" in extra
|
||||
assert "semantic:" in " ".join(payload["tool"]["pytest"]["ini_options"]["markers"])
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user