mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-18 12:39:28 +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
@@ -47,6 +47,7 @@ def _engineer(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
callers = row.get("feature.source.callers")
|
||||
roles = row.get("feature.semantic.roles") or None
|
||||
def_use = row.get("feature.semantic.def_use") or None
|
||||
# None means unchecked even when roles and def_use succeeded; [] is checked.
|
||||
obligations = row.get("feature.semantic.obligations")
|
||||
function = row.get("feature.source.function")
|
||||
out: dict[str, Any] = {key: row.get(key) for key in KEYS}
|
||||
|
||||
@@ -211,7 +211,7 @@ def _source(root: Path, change: Change, path: str, line: int, ctx: Context) -> d
|
||||
ctx.read(path)
|
||||
if path in ctx.range_errors:
|
||||
raise _Absent(ctx.range_errors[path])
|
||||
facts = source.facts_for(root, path, line, entry_points=ctx.entry_points)
|
||||
facts = source.facts_for(root, path, line, entry_points=ctx.entry_points, files=ctx.product_files, texts=ctx.texts)
|
||||
# Select the occurrence at this line when several methods share a name.
|
||||
spans = [r for r in ctx.ranges(path) if r.name == facts.function and r.start <= line <= r.end]
|
||||
span = min(spans, key=lambda r: r.end - r.start) if spans else None
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Closed authorization facts for Jenkins/Stapler, Spring Security and Servlet code.
|
||||
# Checked Git HEAD source objects in /home/mc/ousast-results/valid/train/hist/:
|
||||
# jenkinsci__analysis-core-plugin: DefaultGraphConfigurationView.checkPermission,
|
||||
# AbstractAnnotation.hasPermission and GlobalSettings.save; android-signing-plugin:
|
||||
# SignApksBuilder.lookupCredentials; ansible-plugin: AnsibleAdHocCommandBuilder.findCredentialById;
|
||||
# blueocean-plugin: GitScm.getItemByFullName; cloud-stats-plugin: CloudStatistics.getConfigFile;
|
||||
# job-import-plugin: JobImportAction.updateByXml. These are real calls, not prose matches.
|
||||
# checkAnyPermission is documented by Jenkins AccessControlled, although absent from these HEADs:
|
||||
# https://javadoc.jenkins.io/hudson/security/AccessControlled.html
|
||||
# checkReadPermission was not found in the clones or API references and is not guessed here.
|
||||
# Spring and Servlet are already named in ruleset/semantic/java.toml. Their identity/check APIs:
|
||||
# https://docs.spring.io/spring-security/site/docs/current/api/org/springframework/security/acls/model/Acl.html
|
||||
# https://docs.spring.io/spring-security/site/docs/5.4.x/reference/html5/ (Servlet integration)
|
||||
# Calls use trailing method names. Boolean predicates are ownership_check witnesses, requiring
|
||||
# an exit before the operation under OrderDominance; only throwing checks are path_guard facts.
|
||||
version = "1"
|
||||
language = "java"
|
||||
|
||||
[[operation]]
|
||||
id = "jenkins-credentials-read"
|
||||
kind = "protected_read"
|
||||
calls = ["lookupCredentials", "findCredentialById"]
|
||||
requires = ["path_guard", "ownership_check", "identity_constraint"]
|
||||
sensitivity = "high"
|
||||
|
||||
[[operation]]
|
||||
# A Stapler doXxx handler is not itself a protected operation; its item lookup is.
|
||||
id = "jenkins-item-read"
|
||||
kind = "protected_read"
|
||||
calls = ["getItemByFullName", "getConfigFile"]
|
||||
requires = ["path_guard", "ownership_check", "identity_constraint"]
|
||||
sensitivity = "high"
|
||||
|
||||
[[operation]]
|
||||
id = "jenkins-item-write"
|
||||
kind = "protected_write"
|
||||
calls = ["updateByXml", "save"]
|
||||
requires = ["path_guard", "ownership_check", "identity_constraint"]
|
||||
sensitivity = "high"
|
||||
|
||||
[[operation]]
|
||||
# JDBC access already named in semantic/java.toml; authorization is separate from SQL escaping.
|
||||
id = "sql-access"
|
||||
kind = "protected_read"
|
||||
calls = ["executeQuery", "execute", "prepareCall"]
|
||||
requires = ["path_guard", "ownership_check", "identity_constraint"]
|
||||
sensitivity = "high"
|
||||
|
||||
[[discharger]]
|
||||
id = "jenkins-permission-guard"
|
||||
kind = "path_guard"
|
||||
calls = ["checkPermission", "checkAnyPermission"]
|
||||
|
||||
[[discharger]]
|
||||
id = "permission-predicate"
|
||||
kind = "ownership_check"
|
||||
calls = ["hasPermission", "isGranted", "isUserInRole"]
|
||||
|
||||
[[discharger]]
|
||||
# Reading the security context alone is not an authorization guard.
|
||||
id = "identity-from-context"
|
||||
kind = "identity_constraint"
|
||||
identity_sources = ["SecurityContextHolder.getContext().getAuthentication()", "Jenkins.getAuthentication()", "Jenkins.getAuthentication2()", "getUserPrincipal()"]
|
||||
request_sources = ["getParameter(", "getHeader(", "getCookies(", "getQueryString("]
|
||||
@@ -0,0 +1,93 @@
|
||||
# Closed authorization facts for Laravel, WordPress and Symfony.
|
||||
# Checked shopperlabs__shopper Git HEAD source objects in /home/mc/ousast-results/valid/train/hist/:
|
||||
# CollectionProducts.authorize, SetupGuide.can, AssetController.abort_unless,
|
||||
# OrderCustomer.find, SaveProductVariantsAction.findOrFail, InitialQuantityInventory.first,
|
||||
# UpdateProductAction.update, DetachAttributesToProductAction.delete, UserCommand.save,
|
||||
# PermissionsTableSeeder.create, and Account/Devices.php's user_id constraint from auth()->user().
|
||||
# Gate predicates, abort_if and destroy are framework APIs, not observed Shopper HEAD calls:
|
||||
# https://laravel.com/framework/docs/10.x/authorization (Gate and policy dispatch)
|
||||
# https://api.laravel.com/docs/10.x/%5BGlobal_Namespace%5D.html (abort helpers)
|
||||
# https://api.laravel.com/docs/12.x/Illuminate/Database/Eloquent/Model.html
|
||||
# WordPress is already covered by semantic/php.toml (including the $wpdb calls below).
|
||||
# Post, option, capability, login and nonce APIs were checked in the official function reference:
|
||||
# https://developer.wordpress.org/reference/functions/get_post/
|
||||
# https://developer.wordpress.org/reference/functions/wp_update_post/
|
||||
# https://developer.wordpress.org/reference/functions/update_option/
|
||||
# https://developer.wordpress.org/reference/functions/current_user_can/
|
||||
# https://developer.wordpress.org/reference/functions/is_user_logged_in/
|
||||
# https://developer.wordpress.org/reference/functions/check_admin_referer/
|
||||
# https://developer.wordpress.org/reference/functions/wp_verify_nonce/
|
||||
# Symfony's controller checks: https://symfony.com/doc/current/security.html
|
||||
# Calls use trailing names: Gate::authorize and $this->authorize both become authorize;
|
||||
# variables retain their $ sigil, consistent with the CST and existing PHP source facts.
|
||||
# Common Eloquent/WordPress names are annotations, not receiver type resolution or proof.
|
||||
version = "1"
|
||||
language = "php"
|
||||
|
||||
[[operation]]
|
||||
id = "eloquent-read"
|
||||
kind = "protected_read"
|
||||
calls = ["find", "findOrFail", "first", "get"]
|
||||
requires = ["path_guard", "ownership_check", "identity_constraint"]
|
||||
sensitivity = "high"
|
||||
|
||||
[[operation]]
|
||||
id = "eloquent-write"
|
||||
kind = "protected_write"
|
||||
calls = ["update", "delete", "destroy", "save", "create"]
|
||||
requires = ["path_guard", "ownership_check", "identity_constraint"]
|
||||
sensitivity = "high"
|
||||
|
||||
[[operation]]
|
||||
id = "wordpress-read"
|
||||
kind = "protected_read"
|
||||
calls = ["get_post", "get_var", "get_results", "get_row", "get_col", "query"]
|
||||
requires = ["path_guard", "ownership_check", "identity_constraint"]
|
||||
sensitivity = "high"
|
||||
|
||||
[[operation]]
|
||||
id = "wordpress-write"
|
||||
kind = "protected_write"
|
||||
calls = ["wp_update_post", "replace"]
|
||||
requires = ["path_guard", "ownership_check", "identity_constraint"]
|
||||
sensitivity = "high"
|
||||
|
||||
[[operation]]
|
||||
id = "wordpress-option-write"
|
||||
kind = "privileged_action"
|
||||
calls = ["update_option"]
|
||||
requires = ["path_guard", "ownership_check"]
|
||||
sensitivity = "high"
|
||||
|
||||
[[discharger]]
|
||||
# Laravel policy enforcement and Symfony's throwing controller guard.
|
||||
id = "authorization-guard"
|
||||
kind = "path_guard"
|
||||
calls = ["authorize", "denyAccessUnlessGranted"]
|
||||
|
||||
[[discharger]]
|
||||
# These return booleans: a bare call is not a guard. OrderDominance requires an exit.
|
||||
id = "permission-predicate"
|
||||
kind = "ownership_check"
|
||||
calls = ["can", "cannot", "allows", "denies", "current_user_can", "isGranted"]
|
||||
|
||||
[[discharger]]
|
||||
# These throw conditionally; as with other guards, this annotation cannot establish
|
||||
# that the condition is the right permission check. It is not a proof of authorization.
|
||||
id = "conditional-abort"
|
||||
kind = "path_guard"
|
||||
calls = ["abort_if", "abort_unless"]
|
||||
|
||||
[[discharger]]
|
||||
# Nonces validate intent, not capabilities; login alone does not grant a permission.
|
||||
# None of the protected operations above accept validated_input as authorization.
|
||||
id = "wordpress-request-validation"
|
||||
kind = "validated_input"
|
||||
calls = ["check_admin_referer", "wp_verify_nonce", "is_user_logged_in"]
|
||||
|
||||
[[discharger]]
|
||||
id = "identity-from-context"
|
||||
kind = "identity_constraint"
|
||||
identity_sources = ["auth()->user()", "shopper()->auth()->user()"]
|
||||
constraint_params = ["user_id"]
|
||||
request_sources = ["$_GET", "$_POST", "$_REQUEST", "$_COOKIE", "$_FILES"]
|
||||
@@ -7,6 +7,16 @@ from typing import Any
|
||||
from .ir import Bind, CallSite, FileIR, FunctionIR
|
||||
|
||||
_KIND = {
|
||||
"php": {
|
||||
"function": frozenset({"function_definition", "method_declaration", "anonymous_function", "arrow_function"}),
|
||||
"assign": frozenset({"assignment_expression", "augmented_assignment_expression"}),
|
||||
"call": frozenset(
|
||||
{"function_call_expression", "member_call_expression", "scoped_call_expression", "nullsafe_member_call_expression"}
|
||||
),
|
||||
"args": frozenset({"arguments"}),
|
||||
"string": frozenset({"string", "encapsed_string"}),
|
||||
"params": frozenset({"formal_parameters"}),
|
||||
},
|
||||
"python": {
|
||||
"function": frozenset({"function_definition"}),
|
||||
"assign": frozenset({"assignment", "augmented_assignment"}),
|
||||
@@ -170,6 +180,8 @@ def _bind(node: Any, kinds: dict[str, frozenset[str]]) -> Bind | None:
|
||||
def _call(node: Any, kinds: dict[str, frozenset[str]]) -> CallSite:
|
||||
args_node = next((child for child in getattr(node, "named_children", ()) or () if child.type in kinds["args"]), None)
|
||||
arg_nodes = list(getattr(args_node, "named_children", ()) or ()) if args_node is not None else []
|
||||
# PHP wraps positional and named argument values in an `argument` node.
|
||||
arg_nodes = [arg.named_children[-1] if arg.type == "argument" and arg.named_children else arg for arg in arg_nodes]
|
||||
extra = len(arg_nodes) >= 2 and arg_nodes[1].type in _SEQUENCE
|
||||
return CallSite(
|
||||
order=int(node.end_byte),
|
||||
@@ -183,6 +195,10 @@ def _call(node: Any, kinds: dict[str, frozenset[str]]) -> CallSite:
|
||||
|
||||
|
||||
def _callee_name(node: Any, args_node: Any) -> str:
|
||||
if node.type in _KIND["php"]["call"]:
|
||||
callee = node.child_by_field_name("name") or node.child_by_field_name("function")
|
||||
if callee is not None:
|
||||
return _text(callee).rsplit("\\", 1)[-1]
|
||||
idents = [
|
||||
_text(child)
|
||||
for child in getattr(node, "named_children", ()) or ()
|
||||
@@ -207,7 +223,7 @@ def _call_name(node: Any, kinds: dict[str, frozenset[str]]) -> str | None:
|
||||
|
||||
def _function_name(node: Any) -> str:
|
||||
for child in getattr(node, "named_children", ()) or ():
|
||||
if child.type == "identifier":
|
||||
if child.type in {"identifier", "name"}:
|
||||
return _text(child)
|
||||
if child.type == "function_declarator":
|
||||
for grandchild in getattr(child, "named_children", ()) or ():
|
||||
@@ -222,6 +238,10 @@ def _params(node: Any, kinds: dict[str, frozenset[str]]) -> tuple[str, ...]:
|
||||
return _identifiers(parameter)
|
||||
for child in _walk(node, skip=frozenset()):
|
||||
if child.type in kinds["params"]:
|
||||
if any(
|
||||
param.type in {"simple_parameter", "variadic_parameter", "property_promotion_parameter"} for param in child.named_children
|
||||
):
|
||||
return tuple(_text(name) for param in child.named_children if (name := param.child_by_field_name("name")) is not None)
|
||||
return _identifiers(child)
|
||||
if child.type == "function_declarator":
|
||||
for grandchild in getattr(child, "named_children", ()) or ():
|
||||
@@ -231,7 +251,7 @@ def _params(node: Any, kinds: dict[str, frozenset[str]]) -> tuple[str, ...]:
|
||||
|
||||
|
||||
def _left_name(node: Any) -> str:
|
||||
if node.type in {"identifier", "property_identifier"}:
|
||||
if node.type in {"identifier", "property_identifier", "variable_name"}:
|
||||
return _text(node)
|
||||
idents = _identifiers(node)
|
||||
return idents[0] if idents else ""
|
||||
@@ -240,12 +260,16 @@ def _left_name(node: Any) -> str:
|
||||
def _identifiers(node: Any) -> tuple[str, ...]:
|
||||
names: list[str] = []
|
||||
for child in _walk(node):
|
||||
if child.type == "identifier":
|
||||
# Keep PHP's sigil, matching source facts such as $_GET and $_POST. The
|
||||
# nested `name` node is not another variable (nor are class/type names).
|
||||
if child.type in {"identifier", "variable_name"}:
|
||||
names.append(_text(child))
|
||||
return tuple(names)
|
||||
|
||||
|
||||
def _is_constant(node: Any, kinds: dict[str, frozenset[str]]) -> bool:
|
||||
if node.type == "encapsed_string":
|
||||
return all(child.type in {"string_content", "escape_sequence"} for child in node.named_children)
|
||||
if node.type in _CONST or node.type in kinds["string"]:
|
||||
return True
|
||||
if node.type not in {"binary_expression", "concatenated_string"}:
|
||||
|
||||
@@ -17,6 +17,7 @@ _GRAMMAR_MODULES = {
|
||||
"c": ("tree_sitter_c", "language"),
|
||||
"cpp": ("tree_sitter_cpp", "language"),
|
||||
"java": ("tree_sitter_java", "language"),
|
||||
"php": ("tree_sitter_php", "language_php"),
|
||||
}
|
||||
_PROBE_ENV = "OPENULTRASAST_TREE_SITTER_PROBE"
|
||||
_PROBE_OFF = frozenset({"0", "off", "false", "unavailable", "no"})
|
||||
|
||||
Reference in New Issue
Block a user