From 7a8fcc5f2c8d2b3d4ecea0422f339b1e3ecfc4dc Mon Sep 17 00:00:00 2001 From: corneliuhoffman Date: Tue, 31 Mar 2026 10:59:31 +0100 Subject: [PATCH 01/37] call graph: resolve constructor calls and fix object_mappings lookup Add constructor call resolution to the call graph so that new ClassName(), ClassName(), and ClassName.new() create edges to the constructor method (using Lang_config's constructor_names). Fix object_mappings lookup to use SID-aware comparison, preventing same-named variables in different scopes from resolving to the wrong class. Add type annotation fallback so that method calls on typed parameters (e.g. Python's `def f(x: ClassName)`) resolve when object_mappings has no entry. --- src/tainting/Graph_from_AST.ml | 81 ++++++++++++++++++++++++++++------ 1 file changed, 68 insertions(+), 13 deletions(-) diff --git a/src/tainting/Graph_from_AST.ml b/src/tainting/Graph_from_AST.ml index ab39972f0c..d1627a3a7a 100644 --- a/src/tainting/Graph_from_AST.ml +++ b/src/tainting/Graph_from_AST.ml @@ -139,7 +139,26 @@ let dedup_fn_ids (ids : (fn_id * Tok.t) list) : (fn_id * Tok.t) list = if cmp <> 0 then cmp else Tok.compare t1 t2) (* Helper function to identify the callee fn_id from a call expression's callee *) -let identify_callee ?(object_mappings = []) ?(all_funcs = []) +(* Resolve a type to its constructor fn_id using lang config. + e.g. Foo → Foo# (Java), Foo → Foo#__init__ (Python), Foo → Foo#initialize (Ruby) *) +let resolve_constructor_from_type ~(lang : Lang.t) ~all_funcs (ty : G.type_) : fn_id option = + let class_name = match ty.G.t with + | G.TyN (G.Id ((name, _), _)) -> Some name + | G.TyExpr { G.e = G.N (G.Id ((name, _), _)); _ } -> Some name + | _ -> None + in + match class_name with + | None -> None + | Some cls -> + List.find_opt (fun f -> + match f.fn_id with + | [Some c; Some m] -> + fst c.IL.ident = cls + && Object_initialization.is_constructor lang (fst m.IL.ident) (Some cls) + | _ -> false + ) all_funcs |> Option.map (fun f -> f.fn_id) + +let identify_callee ~(lang : Lang.t) ?(object_mappings = []) ?(all_funcs = []) ?(caller_parent_path = []) ?(call_arity : int option) (callee : G.expr) : fn_id option = (* Extract class from caller_parent_path if present *) let current_class = match caller_parent_path with @@ -203,7 +222,12 @@ let identify_callee ?(object_mappings = []) ?(all_funcs = []) | [None; Some name] when fst name.IL.ident = callee_name_str -> true | _ -> false ) all_funcs in - Option.map (fun f -> f.fn_id) free_fn_match + (match Option.map (fun f -> f.fn_id) free_fn_match with + | Some _ as r -> r + | None -> + (* Try as constructor: ClassName() → ClassName#__init__ etc. *) + let ty = G.{ t = TyN (G.Id ((callee_name_str, G.fake callee_name_str), G.empty_id_info ())); t_attrs = [] } in + resolve_constructor_from_type ~lang ~all_funcs ty) end (* Qualified call: Module.foo() *) | G.N (G.IdQualified { name_last = (id, _), _; _ }) -> @@ -248,19 +272,31 @@ let identify_callee ?(object_mappings = []) ?(all_funcs = []) | None -> None) (* Method call: obj.method() - look up obj's class *) | G.DotAccess - ( { e = G.N (G.Id ((obj_name, _), _)); _ }, + ( { e = G.N (G.Id ((obj_name, _), obj_id_info)); _ }, _, G.FN (G.Id ((id, _), _id_info)) ) -> let method_name_str = id in - (* Look up obj's class in object_mappings *) + let obj_resolved = !(obj_id_info.G.id_resolved) in let obj_class_opt = object_mappings |> List.find_opt (fun (var_name, _class_name) -> match var_name with - | G.Id ((var_str, _), _) -> var_str = obj_name + | G.Id ((var_str, _), var_id_info) -> + var_str = obj_name && + (match (obj_resolved, !(var_id_info.G.id_resolved)) with + | Some (_, sid1), Some (_, sid2) -> G.SId.equal sid1 sid2 + | _ -> true (* fallback to name-only if unresolved *)) | _ -> false) |> Option.map (fun (_var_name, class_name) -> class_name) in + (* Fallback: use the type annotation (e.g. `def f(x: ClassName)`) *) + let obj_class_opt = match obj_class_opt with + | Some _ -> obj_class_opt + | None -> (match !(obj_id_info.G.id_type) with + | Some { G.t = G.TyN (G.Id _ as n); _ } + | Some { G.t = G.TyExpr { G.e = G.N (G.Id _ as n); _ }; _ } -> Some n + | _ -> None) + in (match obj_class_opt with | Some class_name -> let class_name_str = match class_name with @@ -287,7 +323,10 @@ let identify_callee ?(object_mappings = []) ?(all_funcs = []) | [single_match] -> Some single_match.fn_id | _ -> None) (* Still 0 or multiple matches *) | None -> None)) (* No arity info, can't disambiguate *) - | None -> None) + | None -> + (* obj not in object_mappings — try as ClassName.new() constructor *) + let ty = G.{ t = TyN (G.Id ((obj_name, G.fake obj_name), G.empty_id_info ())); t_attrs = [] } in + resolve_constructor_from_type ~lang ~all_funcs ty) | _ -> Log.debug (fun m -> m "CALL_EXTRACT: Unmatched call pattern: %s" @@ -295,7 +334,7 @@ let identify_callee ?(object_mappings = []) ?(all_funcs = []) None (* Extract all calls from a function body and resolve them to fn_ids *) -let extract_calls ?(object_mappings = []) ?(all_funcs = []) ?(caller_parent_path = []) +let extract_calls ~(lang : Lang.t) ?(object_mappings = []) ?(all_funcs = []) ?(caller_parent_path = []) (fdef : G.function_definition) : (fn_id * Tok.t) list = Log.debug (fun m -> m "CALL_EXTRACT: Starting extraction for function"); let calls = ref [] in @@ -311,7 +350,7 @@ let extract_calls ?(object_mappings = []) ?(all_funcs = []) ?(caller_parent_path (match !(id_info.G.id_resolved) with | None -> (* Unresolved - try to identify it as a function *) - (match identify_callee ~object_mappings ~all_funcs ~caller_parent_path arg_exp with + (match identify_callee ~lang ~object_mappings ~all_funcs ~caller_parent_path arg_exp with | Some fn_id -> Log.debug (fun m -> m "CALL_EXTRACT: Found unresolved Id that is a function, adding as implicit call"); calls := (fn_id, tok) :: !calls @@ -329,7 +368,7 @@ let extract_calls ?(object_mappings = []) ?(all_funcs = []) ?(caller_parent_path | G.Call (callee, args) -> let (_, args_list, _) = args in let call_arity = List.length args_list in - (match identify_callee ~object_mappings ~all_funcs ~caller_parent_path ~call_arity callee with + (match identify_callee ~lang ~object_mappings ~all_funcs ~caller_parent_path ~call_arity callee with | Some fn_id -> (* Extract token from the call expression for edge label *) let tok = @@ -346,6 +385,22 @@ let extract_calls ?(object_mappings = []) ?(all_funcs = []) ?(caller_parent_path self#visit_expr env callee; (* Continue visiting arguments for nested calls *) super#visit_arguments env args + | G.New (_tok, ty, _id_info, args) -> + (* Constructor call: new ClassName(args). + Use the class name token so it matches the eorig token + in class_construction's constructor expression. *) + (match resolve_constructor_from_type ~lang ~all_funcs ty with + | Some fn_id -> + let tok = + match AST_generic_helpers.ii_of_any (G.T ty) with + | tok :: _ -> tok + | [] -> Tok.unsafe_fake_tok "" + in + calls := (fn_id, tok) :: !calls + | None -> ()); + let (_, args_list, _) = args in + List.iter check_arg_for_unresolved_function_call args_list; + super#visit_arguments env args | _ -> super#visit_expr env e end in @@ -355,7 +410,7 @@ let extract_calls ?(object_mappings = []) ?(all_funcs = []) ?(caller_parent_path (* Extract calls from top-level statements (outside any function). This returns a list of (callee_fn_id, call_tok) pairs. *) -let extract_toplevel_calls ?(object_mappings = []) ?(all_funcs = []) (ast : G.program) : (fn_id * Tok.t) list = +let extract_toplevel_calls ~(lang : Lang.t) ?(object_mappings = []) ?(all_funcs = []) (ast : G.program) : (fn_id * Tok.t) list = Log.debug (fun m -> m "CALL_EXTRACT: Starting extraction for top-level statements"); let calls = ref [] in @@ -390,7 +445,7 @@ let extract_toplevel_calls ?(object_mappings = []) ?(all_funcs = []) (ast : G.pr in if call_pos >= 0 && not (is_inside_function call_pos) then ( (* Top-level call - no class context *) - match identify_callee ~object_mappings ~all_funcs ~caller_parent_path:[] callee with + match identify_callee ~lang ~object_mappings ~all_funcs ~caller_parent_path:[] callee with | Some fn_id -> let tok = match AST_generic_helpers.ii_of_any (G.E e) with @@ -637,7 +692,7 @@ let build_call_graph ~(lang : Lang.t) ?(object_mappings = []) (ast : G.program) (* Extract calls - class context is already in fn_id *) let callee_calls = - extract_calls ~object_mappings ~all_funcs:funcs ~caller_parent_path:fn_id fdef + extract_calls ~lang ~object_mappings ~all_funcs:funcs ~caller_parent_path:fn_id fdef in (* Add labeled edges for each call - edge from callee to caller for bottom-up analysis *) @@ -677,7 +732,7 @@ let build_call_graph ~(lang : Lang.t) ?(object_mappings = []) (ast : G.program) ast; (* Extract calls from top-level code (outside any function) and add edges to *) - let toplevel_calls = extract_toplevel_calls ~object_mappings ~all_funcs:funcs ast in + let toplevel_calls = extract_toplevel_calls ~lang ~object_mappings ~all_funcs:funcs ast in List.iter (fun (callee_fn_id, call_tok) -> match fn_id_to_node callee_fn_id with From 20677f011504cf371065dbfbce7683a914bb76ba Mon Sep 17 00:00:00 2001 From: corneliuhoffman Date: Tue, 31 Mar 2026 11:03:39 +0100 Subject: [PATCH 02/37] fix signature lookup token mismatch for obj.method() calls Extract tok_of_eorig helper to recover source-level tokens from IL expressions. Call graph edges are keyed by source positions, but IL construction often creates expressions with synthetic tokens. The eorig back-pointer preserves the original source position. Apply this to get_signature_for_object and DRY the simple call path which already had the same logic inlined. Also resolve obj.method() patterns in ToSinkInCall effects. --- src/tainting/Dataflow_tainting.ml | 45 +++++++++++++++---------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/src/tainting/Dataflow_tainting.ml b/src/tainting/Dataflow_tainting.ml index 2975f5ff4c..569dbbfcb2 100644 --- a/src/tainting/Dataflow_tainting.ml +++ b/src/tainting/Dataflow_tainting.ml @@ -633,11 +633,24 @@ let effects_of_call_func_arg fun_exp fun_shape args_taints = (S.show_shape fun_shape)); [] -let get_signature_for_object graph caller_node db method_name obj arity = - (* Method call: obj.method() *) - (* Use obj's token (start of call expression) to match edge labels *) - let call_tok = snd obj.ident in - (* First try to look up via call graph to get the correct node with definition token *) +(* Call graph edges are keyed by source-level token positions, but IL + expressions often carry tokens from IL construction (e.g., fresh + variables from class_construction). The eorig back-pointer preserves + the original source position, which we need for call graph lookups. *) +let tok_of_eorig ~(default : Tok.t) (exp : IL.exp) : Tok.t = + match exp.eorig with + | SameAs orig_exp -> + (match AST_generic_helpers.ii_of_any (G.E orig_exp) with + | tok :: _ when not (Tok.is_fake tok) -> tok + | _ -> default) + | Related orig_any -> + (match AST_generic_helpers.ii_of_any orig_any with + | tok :: _ when not (Tok.is_fake tok) -> tok + | _ -> default) + | NoOrig -> default + +let get_signature_for_object graph caller_node db method_name obj (fun_exp : IL.exp) arity = + let call_tok = tok_of_eorig ~default:(snd obj.ident) fun_exp in match Call_graph.lookup_callee_from_graph graph (Option.map Function_id.of_il_name caller_node) call_tok with | Some callee_node -> @@ -671,23 +684,7 @@ let lookup_signature_with_object_context env fun_exp arity = match fun_exp.e with | Fetch { base = Var name; rev_offset = [] } -> (* Simple function call *) - (* Try to look up via call graph using the ORIGINAL AST token position. - This handles temp variables like _tmp:N which have eorig pointing to - the actual callback reference in the original AST. *) - let call_tok = - match fun_exp.eorig with - | SameAs orig_exp -> - (* Use first token from original AST expression *) - (match AST_generic_helpers.ii_of_any (G.E orig_exp) with - | tok :: _ when not (Tok.is_fake tok) -> tok - | _ -> snd name.ident) - | Related orig_any -> - (* Related contains G.any, extract tokens directly *) - (match AST_generic_helpers.ii_of_any orig_any with - | tok :: _ when not (Tok.is_fake tok) -> tok - | _ -> snd name.ident) - | NoOrig -> snd name.ident - in + let call_tok = tok_of_eorig ~default:(snd name.ident) fun_exp in (match Call_graph.lookup_callee_from_graph env.call_graph @@ -733,6 +730,7 @@ let lookup_signature_with_object_context env fun_exp arity = db (Function_id.of_il_name method_name) obj + fun_exp arity with | Some _ as result -> result @@ -1640,7 +1638,8 @@ let check_function_call env fun_exp args try let callee_name_opt = match callee.e with - | Fetch { base = Var name; rev_offset = [] } -> Some name + | Fetch { base = Var name; rev_offset = [] } + | Fetch { base = Var name; rev_offset = [{ o = Dot _; _ }] } -> Some name | _ -> None in match callee_name_opt with From 1e162e3f57e077890504c64aff1ac4590b8d67c7 Mon Sep 17 00:00:00 2001 From: corneliuhoffman Date: Tue, 31 Mar 2026 11:14:43 +0100 Subject: [PATCH 03/37] proper constructor taint analysis Remove id_resolved guard from mk_class_constructor_name so the IL New instruction always carries a constructor reference. In call_with_intrafile, detect constructor calls via the call graph and remap the callee to obj.Constructor so Sig_inst can instantiate BThis field effects onto the target variable. Prepend self arg for Python constructors (which have explicit self in params but not in call args). Read back the shape from lval_env after ToLval effects so it propagates through intermediate assignments. --- src/analyzing/AST_to_IL.ml | 17 +++- src/tainting/Dataflow_tainting.ml | 79 ++++++++++++++++++- .../test_constructor_taint_bugs_js.js | 35 ++++++++ .../test_constructor_taint_bugs_js.yaml | 10 +++ 4 files changed, 134 insertions(+), 7 deletions(-) create mode 100644 tests/rules/cross_function_tainting/test_constructor_taint_bugs_js.js create mode 100644 tests/rules/cross_function_tainting/test_constructor_taint_bugs_js.yaml diff --git a/src/analyzing/AST_to_IL.ml b/src/analyzing/AST_to_IL.ml index 2d5f31a80d..a2dcd51f72 100644 --- a/src/analyzing/AST_to_IL.ml +++ b/src/analyzing/AST_to_IL.ml @@ -237,12 +237,23 @@ let is_hcl lang : bool = | Lang.Terraform -> true | _ -> false +(* Extract the class name from a G.New type to build the constructor + reference in the IL New instruction. Only called from class_construction, + which only runs for G.New nodes (always constructor calls). + + The TyExpr case exists because the JS parser produces TyExpr where + it should produce TyN. + + Previously this had a guard `when Option.is_some !(cons_id_info.id_resolved)` + which meant it only worked when the pro engine's naming pass had run. + In OSS mode id_resolved is never set, so the IL New always got + constructor=None, falling through to all_args_taints (accidental leak). + The guard was removed because G.New is always a constructor call and + the fallback behavior is unchanged when no signature is found. *) let mk_class_constructor_name (ty : G.type_) cons_id_info : G.name option = match ty with | { t = TyN (G.Id (id, _)); _ } - | { t = TyExpr { e = G.N (G.Id (id, _)); _ }; _ } - (* FIXME: JS parser produces this ^ although it should be parsed as a 'TyN'. *) - when Option.is_some !(cons_id_info.G.id_resolved) -> + | { t = TyExpr { e = G.N (G.Id (id, _)); _ }; _ } -> Some (G.Id (id, cons_id_info)) | __else__ -> None diff --git a/src/tainting/Dataflow_tainting.ml b/src/tainting/Dataflow_tainting.ml index 569dbbfcb2..eea51cada5 100644 --- a/src/tainting/Dataflow_tainting.ml +++ b/src/tainting/Dataflow_tainting.ml @@ -1962,6 +1962,59 @@ let call_with_intrafile lval_opt e env args instr = | None -> (all_args_taints, S.Bot, lval_env))) | None -> + (* Constructor call handling for ClassName() and ClassName.new(). + * + * When taint flows through a constructor (e.g., `obj = Foo(tainted)`), + * the constructor signature may contain ToLval(BThis.field, taint) + * effects that assign taint to fields of the new object. For Sig_inst + * to correctly map BThis onto the target variable `obj`, we need the + * callee expression to be `obj.Constructor` rather than just + * `Constructor`. We check the call graph to determine if this call + * resolves to a constructor, and if so, remap the callee accordingly. *) + let resolves_to_constructor = + Option.is_some env.signature_db && + let call_tok = tok_of_eorig ~default:(Tok.unsafe_fake_tok "") e in + not (Tok.is_fake call_tok) && + match Call_graph.lookup_callee_from_graph + env.call_graph + (Option.map Function_id.of_il_name env.func.name) + call_tok with + | Some callee_node -> + Object_initialization.is_constructor env.taint_inst.lang + (Function_id.show callee_node) None + | None -> false + in + (* Remap: ClassName() → obj.ClassName(), ClassName.new() → obj.ClassName() + * This makes the callee a method-call shape so that Sig_inst maps + * BThis to obj (the assignment target) when instantiating the + * constructor's ToLval effects. *) + let e = + if resolves_to_constructor then + match (lval_opt, e.e) with + | Some lval, Fetch { base = Var name; rev_offset = ([] | [{ o = Dot _; _ }]) } -> + IL.{ e = Fetch { base = lval.base; + rev_offset = [{ o = Dot name; oorig = NoOrig }] }; + eorig = e.eorig } + | _ -> e + else e + in + (* Python's __init__ has an explicit `self` parameter but constructor + * call sites (e.g., `Foo(x)`) don't pass it. Prepend the receiver + * variable so Sig_inst maps self → obj and user_name → x correctly. + * Ruby's initialize does NOT have explicit self, so this is + * Python-specific. *) + let args, args_taints = + if resolves_to_constructor + && Lang.(env.taint_inst.lang =*= Python) then + match lval_opt with + | Some lval -> + let self_exp = IL.{ e = Fetch lval; eorig = NoOrig } in + let self_arg = IL.Unnamed self_exp in + let self_taint = IL.Unnamed (Taints.empty, S.Bot) in + (self_arg :: args, self_taint :: args_taints) + | None -> (args, args_taints) + else (args, args_taints) + in (* No implicit lambda, try unified constructor execution *) let check_function_call_wrapper env' e' args' args_taints' = check_function_call env' e' args' args_taints' () @@ -1970,11 +2023,29 @@ let call_with_intrafile lval_opt e env args instr = Object_initialization.execute_unified_constructor e args args_taints check_function_call_wrapper { env with lval_env } with - | Some (call_taints, shape, lval_env) -> (call_taints, shape, lval_env) + | Some (call_taints, shape, lval_env) -> + (* Constructor ToLval effects (e.g., this.data = tainted_arg) + * update lval_env with field-level taint on the target variable, + * but the return shape may still be Bot (constructors typically + * don't return a value). Read back the shape from lval_env so + * it propagates through intermediate assignments like + * `_tmp = Foo(x); obj = _tmp`. Without this, the shape is lost + * at the assignment boundary. *) + let shape = + if resolves_to_constructor then + match lval_opt with + | Some lval -> ( + match Lval_env.find_lval lval_env lval with + | Some (S.Cell (_, s)) when + (match s with + | S.Bot -> false + | _ -> true) -> s + | _ -> shape) + | None -> shape + else shape + in + (call_taints, shape, lval_env) | None -> ( - (* Regular function call processing *) - Log.debug (fun m -> - m "INTRAFILE: Checking function call %s" (Display_IL.string_of_exp e)); match check_function_call { env with lval_env } e args args_taints () with | Some (call_taints, shape, lval_env) -> Log.debug (fun m -> diff --git a/tests/rules/cross_function_tainting/test_constructor_taint_bugs_js.js b/tests/rules/cross_function_tainting/test_constructor_taint_bugs_js.js new file mode 100644 index 0000000000..38a5064dd5 --- /dev/null +++ b/tests/rules/cross_function_tainting/test_constructor_taint_bugs_js.js @@ -0,0 +1,35 @@ +// FALSE NEGATIVE: zero-arg constructor with internal source. +class InternalSource { + constructor() { + this.data = source(); + } + + getData() { + return this.data; + } +} + +// FALSE POSITIVE: constructor ignores its argument. +class IgnoresArg { + constructor(data) { + this.data = "safe"; + } + + getData() { + return this.data; + } +} + +function testFalseNegative() { + let obj = new InternalSource(); + let result = obj.getData(); + // ruleid: constructor-taint-bugs + sink(result); +} + +function testFalsePositive() { + let obj = new IgnoresArg(source()); + let result = obj.getData(); + // ok: constructor-taint-bugs + sink(result); +} diff --git a/tests/rules/cross_function_tainting/test_constructor_taint_bugs_js.yaml b/tests/rules/cross_function_tainting/test_constructor_taint_bugs_js.yaml new file mode 100644 index 0000000000..5a563c4759 --- /dev/null +++ b/tests/rules/cross_function_tainting/test_constructor_taint_bugs_js.yaml @@ -0,0 +1,10 @@ +rules: + - id: constructor-taint-bugs + message: Taint through constructor + languages: [javascript] + severity: ERROR + mode: taint + pattern-sources: + - pattern: source() + pattern-sinks: + - pattern: sink(...) From dc462ac3d339b694a499ca9d66f3d48ce6749981 Mon Sep 17 00:00:00 2001 From: corneliuhoffman Date: Tue, 31 Mar 2026 11:15:07 +0100 Subject: [PATCH 04/37] tests for constructor taint analysis Add tests for Java, Python, and Ruby demonstrating: - False negative fixed: zero-arg constructors with internal sources - False positive fixed: constructors ignoring tainted args - Cross-function flow via type annotations on parameters Update test_python_constructor.py with type annotation on sink_ex. Update test_ruby_zero_arg_dispatch.rb todoruleid -> ruleid. --- .../test_constructor_taint_bugs.rb | 38 +++++++++++++++++ .../test_constructor_taint_bugs.yaml | 10 +++++ .../test_constructor_taint_bugs_java.java | 41 +++++++++++++++++++ .../test_constructor_taint_bugs_java.yaml | 10 +++++ .../test_constructor_taint_bugs_python.py | 27 ++++++++++++ .../test_constructor_taint_bugs_python.yaml | 10 +++++ .../test_python_constructor.py | 2 +- .../test_ruby_zero_arg_dispatch.rb | 4 +- 8 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 tests/rules/cross_function_tainting/test_constructor_taint_bugs.rb create mode 100644 tests/rules/cross_function_tainting/test_constructor_taint_bugs.yaml create mode 100644 tests/rules/cross_function_tainting/test_constructor_taint_bugs_java.java create mode 100644 tests/rules/cross_function_tainting/test_constructor_taint_bugs_java.yaml create mode 100644 tests/rules/cross_function_tainting/test_constructor_taint_bugs_python.py create mode 100644 tests/rules/cross_function_tainting/test_constructor_taint_bugs_python.yaml diff --git a/tests/rules/cross_function_tainting/test_constructor_taint_bugs.rb b/tests/rules/cross_function_tainting/test_constructor_taint_bugs.rb new file mode 100644 index 0000000000..34ca8bbdf8 --- /dev/null +++ b/tests/rules/cross_function_tainting/test_constructor_taint_bugs.rb @@ -0,0 +1,38 @@ +# FALSE NEGATIVE: zero-arg constructor with internal source. +# The taint engine never analyzes initialize, so @data is not tainted. +class InternalSource + def initialize + @data = source() + end + + def get_data + @data + end +end + +def test_false_negative + obj = InternalSource.new() + result = obj.get_data() + # ruleid: constructor-taint-bugs + sink(result) +end + +# FALSE POSITIVE: constructor ignores its argument. +# The taint engine leaks source() through all_args_taints +# even though initialize never stores it. +class IgnoresArg + def initialize(data) + @data = "safe" + end + + def get_data + @data + end +end + +def test_false_positive + obj = IgnoresArg.new(source()) + result = obj.get_data() + # ok: constructor-taint-bugs + sink(result) +end diff --git a/tests/rules/cross_function_tainting/test_constructor_taint_bugs.yaml b/tests/rules/cross_function_tainting/test_constructor_taint_bugs.yaml new file mode 100644 index 0000000000..40ac893ec3 --- /dev/null +++ b/tests/rules/cross_function_tainting/test_constructor_taint_bugs.yaml @@ -0,0 +1,10 @@ +rules: + - id: constructor-taint-bugs + message: Taint through constructor + languages: [ruby] + severity: ERROR + mode: taint + pattern-sources: + - pattern: source() + pattern-sinks: + - pattern: sink(...) diff --git a/tests/rules/cross_function_tainting/test_constructor_taint_bugs_java.java b/tests/rules/cross_function_tainting/test_constructor_taint_bugs_java.java new file mode 100644 index 0000000000..933cbb5da6 --- /dev/null +++ b/tests/rules/cross_function_tainting/test_constructor_taint_bugs_java.java @@ -0,0 +1,41 @@ +// FALSE NEGATIVE: zero-arg constructor with internal source. +class InternalSource { + String data; + + InternalSource() { + this.data = source(); + } + + String getData() { + return this.data; + } +} + +// FALSE POSITIVE: constructor ignores its argument. +class IgnoresArg { + String data; + + IgnoresArg(String input) { + this.data = "safe"; + } + + String getData() { + return this.data; + } +} + +class Test { + void testFalseNegative() { + InternalSource obj = new InternalSource(); + String result = obj.getData(); + // ruleid: constructor-taint-bugs + sink(result); + } + + void testFalsePositive() { + IgnoresArg obj = new IgnoresArg(source()); + String result = obj.getData(); + // ok: constructor-taint-bugs + sink(result); + } +} diff --git a/tests/rules/cross_function_tainting/test_constructor_taint_bugs_java.yaml b/tests/rules/cross_function_tainting/test_constructor_taint_bugs_java.yaml new file mode 100644 index 0000000000..59e571305d --- /dev/null +++ b/tests/rules/cross_function_tainting/test_constructor_taint_bugs_java.yaml @@ -0,0 +1,10 @@ +rules: + - id: constructor-taint-bugs + message: Taint through constructor + languages: [java] + severity: ERROR + mode: taint + pattern-sources: + - pattern: source() + pattern-sinks: + - pattern: sink(...) diff --git a/tests/rules/cross_function_tainting/test_constructor_taint_bugs_python.py b/tests/rules/cross_function_tainting/test_constructor_taint_bugs_python.py new file mode 100644 index 0000000000..65010accfe --- /dev/null +++ b/tests/rules/cross_function_tainting/test_constructor_taint_bugs_python.py @@ -0,0 +1,27 @@ +# FALSE NEGATIVE: zero-arg constructor with internal source. +class InternalSource: + def __init__(self): + self.data = source() + + def get_data(self): + return self.data + +def test_false_negative(): + obj = InternalSource() + result = obj.get_data() + # ruleid: constructor-taint-bugs + sink(result) + +# FALSE POSITIVE: constructor ignores its argument. +class IgnoresArg: + def __init__(self, data): + self.data = "safe" + + def get_data(self): + return self.data + +def test_false_positive(): + obj = IgnoresArg(source()) + result = obj.get_data() + # ok: constructor-taint-bugs + sink(result) diff --git a/tests/rules/cross_function_tainting/test_constructor_taint_bugs_python.yaml b/tests/rules/cross_function_tainting/test_constructor_taint_bugs_python.yaml new file mode 100644 index 0000000000..040e7888e0 --- /dev/null +++ b/tests/rules/cross_function_tainting/test_constructor_taint_bugs_python.yaml @@ -0,0 +1,10 @@ +rules: + - id: constructor-taint-bugs + message: Taint through constructor + languages: [python] + severity: ERROR + mode: taint + pattern-sources: + - pattern: source() + pattern-sinks: + - pattern: sink(...) diff --git a/tests/rules/cross_function_tainting/test_python_constructor.py b/tests/rules/cross_function_tainting/test_python_constructor.py index 22a423397d..3b075ac8ef 100644 --- a/tests/rules/cross_function_tainting/test_python_constructor.py +++ b/tests/rules/cross_function_tainting/test_python_constructor.py @@ -20,7 +20,7 @@ def intermediateFun (): tainted_input = source() user = User(tainted_input) return user -def sink_ex(user): +def sink_ex(user : User): return user.get_profile() class FieldUser: def __init__(self): diff --git a/tests/rules/cross_function_tainting/test_ruby_zero_arg_dispatch.rb b/tests/rules/cross_function_tainting/test_ruby_zero_arg_dispatch.rb index cd54ad2c6f..013b65a925 100644 --- a/tests/rules/cross_function_tainting/test_ruby_zero_arg_dispatch.rb +++ b/tests/rules/cross_function_tainting/test_ruby_zero_arg_dispatch.rb @@ -105,13 +105,13 @@ def get_data def test_zero_arg_new_with_parens obj = TaintedService.new() result = obj.get_data() - # todoruleid: test-ruby-zero-arg-dispatch + # ruleid: test-ruby-zero-arg-dispatch sink(result) end def test_zero_arg_new_no_parens obj = TaintedService.new result = obj.get_data() - # todoruleid: test-ruby-zero-arg-dispatch + # ruleid: test-ruby-zero-arg-dispatch sink(result) end From 635eb8608ac4e28e1e6a50bc1980d7e02a614f77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Pir=C3=B3g?= Date: Tue, 31 Mar 2026 23:29:29 +0200 Subject: [PATCH 05/37] dockerfile: preprocess for better line continuations and comments --- .../Parse_dockerfile_tree_sitter.ml | 142 +++++++++++++++++- .../comment-in-continuation.dockerfile | 19 +++ .../double-backslash-continuation.dockerfile | 14 ++ .../dockerfile/escape-directive.dockerfile | 12 ++ .../trailing-ws-continuation.dockerfile | 11 ++ .../continuation-comment.dockerfile | 14 ++ .../dockerfile/continuation-comment.sgrep | 1 + .../continuation-escape-directive.dockerfile | 14 ++ .../continuation-escape-directive.sgrep | 1 + .../continuation-trailing-ws.dockerfile | 16 ++ .../dockerfile/continuation-trailing-ws.sgrep | 1 + 11 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 tests/parsing/dockerfile/comment-in-continuation.dockerfile create mode 100644 tests/parsing/dockerfile/double-backslash-continuation.dockerfile create mode 100644 tests/parsing/dockerfile/escape-directive.dockerfile create mode 100644 tests/parsing/dockerfile/trailing-ws-continuation.dockerfile create mode 100644 tests/patterns/dockerfile/continuation-comment.dockerfile create mode 100644 tests/patterns/dockerfile/continuation-comment.sgrep create mode 100644 tests/patterns/dockerfile/continuation-escape-directive.dockerfile create mode 100644 tests/patterns/dockerfile/continuation-escape-directive.sgrep create mode 100644 tests/patterns/dockerfile/continuation-trailing-ws.dockerfile create mode 100644 tests/patterns/dockerfile/continuation-trailing-ws.sgrep diff --git a/languages/dockerfile/tree-sitter/Parse_dockerfile_tree_sitter.ml b/languages/dockerfile/tree-sitter/Parse_dockerfile_tree_sitter.ml index 3089dc5beb..4b179b1615 100644 --- a/languages/dockerfile/tree-sitter/Parse_dockerfile_tree_sitter.ml +++ b/languages/dockerfile/tree-sitter/Parse_dockerfile_tree_sitter.ml @@ -1214,12 +1214,147 @@ let ensure_trailing_newline str = | _ -> str ^ "\n" else str +(* Pre-process Dockerfile content before passing to tree-sitter. + + This fixes three classes of issues that the grammar alone can't handle: + + 1. Trailing whitespace after a line-continuation backslash (e.g. "\ " + at end of line). The grammar already accepts /\\[ \t]*\n/ as + line_continuation, but combine_sparse_toks in concat_shell_fragments + fills gaps with the fixed 2-byte string "\\\n". When the original gap + is wider (e.g. 4 bytes for "\ \n") the byte-offset arithmetic goes + off, which can surface as a Tok.NoTokenLocation crash. Stripping the + trailing whitespace makes every continuation token exactly "\\\n". + + 2. The "# escape=X" parser directive lets authors use an alternative + character (commonly the backtick on Windows/PowerShell Dockerfiles) as + the line-continuation marker. tree-sitter's grammar hard-codes the + backslash, so we replace each occurrence of X at the end of a line + with '\' before handing off to tree-sitter. + + 3. A comment line inside a multi-line continuation is transparent to the + continuation: Docker removes it before execution, so + + RUN echo a \ + # comment + b + + is equivalent to "RUN echo a b". tree-sitter's extras mechanism + already handles comments between tokens, but the OCaml post-processing + code (concat_shell_fragments) relies on the gap between shell-fragment + tokens to reconstruct positions. A comment line in that gap does not + carry a synthetic continuation, so the chain can break. We replace + comment lines with spaces of the same byte count and, when inside a + continuation, append a '\' at the last position so tree-sitter sees an + unbroken chain of line_continuation extras. + + Position safety: line_col_to_pos reads the *original* file and maps + (line, col) pairs to byte offsets. Our transforms only modify characters + at or after the last real token on each line (trailing whitespace, comment + text, the escape character itself). The (line, col) of every real token + is therefore unchanged between the original file and the preprocessed + content, so the position table remains valid. line_continuation tokens + live in the tree-sitter extras bucket; the OCaml callback discards them + via the `_extras` parameter and never calls conv on their positions, so + the synthetic '\' we insert on replaced comment lines is also harmless. +*) + +(* Matches "# escape=X" (case-insensitive, spaces optional around '='). + Group 1 captures the single non-whitespace escape character. *) +let escape_directive_re = + Pcre2_.regexp ~flags:[ `CASELESS ] {|^#\s*escape\s*=\s*(\S)|} + +(* If line looks like "# escape=X", return Some X, otherwise None. *) +let parse_escape_directive (line : string) : char option = + match Pcre2_.exec_noerr ~rex:escape_directive_re line with + | None -> None + | Some substrings -> Some (Pcre2.get_substring substrings 1).[0] + +(* Return the index of the last non-whitespace character in s, or -1. *) +let last_nws_index (s : string) : int = + let is_ws c = Char.equal c ' ' || Char.equal c '\t' in + let rec go i = if i < 0 || not (is_ws s.[i]) then i else go (i - 1) in + go (String.length s - 1) + +type preprocess_state = { + escape_char : char; + in_header : bool; + in_continuation : bool; +} + + (* Process one line (without its trailing '\n') and return the updated state + together with the transformed line. *) +let process_line (st : preprocess_state) (line : string) : preprocess_state * string = + let len = String.length line in + let lnws = last_nws_index line in + + if lnws < 0 then + (* Blank / all-whitespace line: pass through verbatim, leave header zone. *) + ({ st with in_header = false }, line) + else if Char.equal line.[0] '#' then begin + (* Comment line or parser directive. *) + match if st.in_header then parse_escape_directive line else None with + | Some c -> + (* Parser directive: record new escape char and keep line as-is. *) + ({ st with escape_char = c }, line) + | None -> + (* Regular comment: replace with spaces of the same byte count. + If we are mid-continuation, put a synthetic '\' at the end so + tree-sitter sees an unbroken continuation chain. + in_continuation is left unchanged: comment lines are transparent + to continuation state, just as Docker specifies. *) + let out = + if st.in_continuation && len >= 1 then + String.make (len - 1) ' ' ^ "\\" + else + String.make len ' ' + in + (st, out) + end else begin + (* Regular instruction line. *) + let bytes = Bytes.of_string line in + + (* Replace an alternative escape char at end of line with '\'. *) + if (not (Char.equal st.escape_char '\\')) + && Char.equal (Bytes.get bytes lnws) st.escape_char + then Bytes.set bytes lnws '\\'; + + (* Break a trailing "\\" pair so tree-sitter does not greedily consume it + as a shell_fragment before recognising the final '\' as + line_continuation. *) + if lnws >= 1 + && Char.equal (Bytes.get bytes lnws) '\\' + && Char.equal (Bytes.get bytes (lnws - 1)) '\\' + then Bytes.set bytes (lnws - 1) ' '; + + (* Strip trailing whitespace: emit only up to the last non-ws char so the + continuation token is exactly "\\\n", matching combine_sparse_toks. *) + let out = Bytes.sub_string bytes 0 (lnws + 1) in + let continues = Char.equal out.[lnws] '\\' in + ({ escape_char = st.escape_char; in_header = false; in_continuation = continues }, out) + end + +let preprocess_dockerfile (content : string) : string = + let init = { escape_char = '\\'; in_header = true; in_continuation = false } in + let lines = String.split_on_char '\n' content in + let _state, rev_lines = + List.fold_left + (fun (st, acc) line -> + let st', out = process_line st line in + (st', out :: acc)) + (init, []) + lines + in + String.concat "\n" (List.rev rev_lines) + let parse file = H.wrap_parser (fun () -> let contents = - UFile.read_file file |> normalize_line_endings + UFile.read_file file + |> normalize_line_endings |> ensure_trailing_newline + |> preprocess_dockerfile in Tree_sitter_dockerfile.Parse.string ~src_file:!!file contents) (fun cst _extras -> @@ -1236,7 +1371,10 @@ let parse_pattern str = let input_kind = AST_bash.Pattern in H.wrap_parser (fun () -> - str |> ensure_trailing_newline |> Tree_sitter_dockerfile.Parse.string) + str + |> ensure_trailing_newline + |> preprocess_dockerfile + |> Tree_sitter_dockerfile.Parse.string) (fun cst _extras -> let file = Fpath.v "" in let env = diff --git a/tests/parsing/dockerfile/comment-in-continuation.dockerfile b/tests/parsing/dockerfile/comment-in-continuation.dockerfile new file mode 100644 index 0000000000..41b3b828a9 --- /dev/null +++ b/tests/parsing/dockerfile/comment-in-continuation.dockerfile @@ -0,0 +1,19 @@ +FROM alpine:3.19 + +# Comment inside a RUN continuation: the comment is transparent and the +# result is equivalent to "RUN echo hello world". +RUN echo hello \ +# comment between continuation lines + world + +# Multiple consecutive comments inside a continuation +RUN apt-get install -y \ +# first comment +# second comment + curl \ +# third comment + wget + +# Comment at the start, not inside a continuation (should parse as blank) +# regular comment here +RUN echo done diff --git a/tests/parsing/dockerfile/double-backslash-continuation.dockerfile b/tests/parsing/dockerfile/double-backslash-continuation.dockerfile new file mode 100644 index 0000000000..7d977bffe3 --- /dev/null +++ b/tests/parsing/dockerfile/double-backslash-continuation.dockerfile @@ -0,0 +1,14 @@ +FROM alpine:3.19 + +# Issue #629: double backslash at end of continuation line. +# Docker treats the last backslash as the continuation marker regardless +# of what precedes it, so "echo a \\ " still continues to the next line. +RUN echo a \\ + world + +# Same with trailing space after the double backslash +RUN echo hello \\ + world + +RUN apt-get install -y \\ + curl diff --git a/tests/parsing/dockerfile/escape-directive.dockerfile b/tests/parsing/dockerfile/escape-directive.dockerfile new file mode 100644 index 0000000000..8784e71faf --- /dev/null +++ b/tests/parsing/dockerfile/escape-directive.dockerfile @@ -0,0 +1,12 @@ +# escape=` +FROM alpine:3.19 + +# Issue #629: backtick as alternative escape/continuation character. +# The "# escape=`" directive tells Docker (and our preprocessor) to treat +# backtick as the line-continuation marker instead of backslash. +RUN echo hello ` + world + +RUN apt-get install -y ` + curl ` + wget diff --git a/tests/parsing/dockerfile/trailing-ws-continuation.dockerfile b/tests/parsing/dockerfile/trailing-ws-continuation.dockerfile new file mode 100644 index 0000000000..ed718653d3 --- /dev/null +++ b/tests/parsing/dockerfile/trailing-ws-continuation.dockerfile @@ -0,0 +1,11 @@ +FROM alpine:3.19 + +# Issue #629: trailing whitespace after line-continuation backslash +# "\ " (backslash + spaces) at end of line used to cause a +# Tok.NoTokenLocation crash in concat_shell_fragments. +RUN echo hello \ + world + +RUN apt-get install -y \ + curl \ + wget diff --git a/tests/patterns/dockerfile/continuation-comment.dockerfile b/tests/patterns/dockerfile/continuation-comment.dockerfile new file mode 100644 index 0000000000..39234843da --- /dev/null +++ b/tests/patterns/dockerfile/continuation-comment.dockerfile @@ -0,0 +1,14 @@ +FROM alpine:3.19 + +# Comment inside a continuation: the RUN instruction should still match +# and the match should be reported at the RUN line (location correctness). + +# MATCH: +RUN apt-get install -y \ +# this comment is transparent to the instruction + curl \ +# another transparent comment + wget + +# Not a match (different command) +RUN echo done diff --git a/tests/patterns/dockerfile/continuation-comment.sgrep b/tests/patterns/dockerfile/continuation-comment.sgrep new file mode 100644 index 0000000000..7d3f320572 --- /dev/null +++ b/tests/patterns/dockerfile/continuation-comment.sgrep @@ -0,0 +1 @@ +RUN apt-get install ... diff --git a/tests/patterns/dockerfile/continuation-escape-directive.dockerfile b/tests/patterns/dockerfile/continuation-escape-directive.dockerfile new file mode 100644 index 0000000000..dc795997d5 --- /dev/null +++ b/tests/patterns/dockerfile/continuation-escape-directive.dockerfile @@ -0,0 +1,14 @@ +# escape=` +FROM alpine:3.19 + +# Backtick as continuation character (Windows/PowerShell Dockerfiles). +# The match must be reported at the RUN line. + +# MATCH: +RUN echo hello ` + world + +# MATCH: +RUN echo foo ` + bar ` + baz diff --git a/tests/patterns/dockerfile/continuation-escape-directive.sgrep b/tests/patterns/dockerfile/continuation-escape-directive.sgrep new file mode 100644 index 0000000000..f52c48b9f7 --- /dev/null +++ b/tests/patterns/dockerfile/continuation-escape-directive.sgrep @@ -0,0 +1 @@ +RUN echo ... diff --git a/tests/patterns/dockerfile/continuation-trailing-ws.dockerfile b/tests/patterns/dockerfile/continuation-trailing-ws.dockerfile new file mode 100644 index 0000000000..3e8a64b2f3 --- /dev/null +++ b/tests/patterns/dockerfile/continuation-trailing-ws.dockerfile @@ -0,0 +1,16 @@ +FROM alpine:3.19 + +# Trailing whitespace after backslash continuation (issue #629). +# The match must be reported at the RUN line. + +# MATCH: +RUN echo hello \ + world + +# MATCH: +RUN echo a \\ + b + +# MATCH: +RUN echo foo \\ + bar diff --git a/tests/patterns/dockerfile/continuation-trailing-ws.sgrep b/tests/patterns/dockerfile/continuation-trailing-ws.sgrep new file mode 100644 index 0000000000..f52c48b9f7 --- /dev/null +++ b/tests/patterns/dockerfile/continuation-trailing-ws.sgrep @@ -0,0 +1 @@ +RUN echo ... From eca4656043136337acb5567a18c83b69e2b6bddf Mon Sep 17 00:00:00 2001 From: Dimitris Mostrous Date: Fri, 3 Apr 2026 14:00:58 +0100 Subject: [PATCH 06/37] elixir: use ParamPattern for map destructuring in function params Replace G.OtherParam with G.ParamPattern for OtherParamExpr and OtherParamPair in the Elixir-to-generic translation. Add Container(Dict) handling in expr_to_pattern so map patterns convert to PatList rather than falling through to opaque OtherPat. --- languages/elixir/generic/Elixir_to_generic.ml | 4 ++-- libs/ast_generic/AST_generic_helpers.ml | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/languages/elixir/generic/Elixir_to_generic.ml b/languages/elixir/generic/Elixir_to_generic.ml index c2ee1807c3..bc601b0fd6 100644 --- a/languages/elixir/generic/Elixir_to_generic.ml +++ b/languages/elixir/generic/Elixir_to_generic.ml @@ -445,12 +445,12 @@ and map_param_to_gparam env (p : parameter) : G.parameter = G.Param (G.param_of_id ?pdefault id)) | OtherParamExpr e -> let e = map_expr env e in - G.OtherParam (("OtherParamExpr", G.fake ""), [ G.E e ]) + G.ParamPattern (H.expr_to_pattern e) | OtherParamPair (kwd, e) -> let kwd = map_keyword env kwd in let e = map_expr env e in let e = keyval_of_pair (Left (kwd, e)) in - G.OtherParam (("OtherParamPair", G.fake ""), [ G.E e ]) + G.ParamPattern (H.expr_to_pattern e) and map_func_clause_to_stab env (clause : function_definition) : stab_clause_generic = diff --git a/libs/ast_generic/AST_generic_helpers.ml b/libs/ast_generic/AST_generic_helpers.ml index aaf73dce92..27ce124cc7 100644 --- a/libs/ast_generic/AST_generic_helpers.ml +++ b/libs/ast_generic/AST_generic_helpers.ml @@ -201,6 +201,8 @@ let rec expr_to_pattern e = | L l -> PatLiteral l | Container (List, (t1, xs, t2)) -> PatList (t1, xs |> List_.map expr_to_pattern, t2) + | Container (Dict, (t1, xs, t2)) -> + PatList (t1, xs |> List_.map expr_to_pattern, t2) | Ellipsis t -> PatEllipsis t | Cast (ty, _tok, expr) -> PatTyped (expr_to_pattern expr, ty) (* TODO: PatKeyVal and more *) From 0e4b47235f6dc0572be59420939fcfa3cf2123bf Mon Sep 17 00:00:00 2001 From: Dimitris Mostrous Date: Fri, 3 Apr 2026 14:02:02 +0100 Subject: [PATCH 07/37] elixir: distinguish arrow vs keyword map pairs, translate => as keyval Translate the => operator as keyval (Container(Tuple, ...)) instead of Call(Id("=>"), ...) so arrow pairs share the same structure as keyword pairs. Wrap map items in OtherExpr("MapPairArrow")/OtherExpr("MapPairKeyword") to preserve syntax distinction for pattern matching. Add OtherExpr-to-OtherPat conversion in expr_to_pattern, and unwrap the tags in AST_to_IL for Elixir. --- languages/elixir/generic/Elixir_to_generic.ml | 23 ++++++++++++++++++- libs/ast_generic/AST_generic_helpers.ml | 2 ++ src/analyzing/AST_to_IL.ml | 3 +++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/languages/elixir/generic/Elixir_to_generic.ml b/languages/elixir/generic/Elixir_to_generic.ml index bc601b0fd6..945cb9b018 100644 --- a/languages/elixir/generic/Elixir_to_generic.ml +++ b/languages/elixir/generic/Elixir_to_generic.ml @@ -369,6 +369,25 @@ and map_items env (v1, v2) : G.expr list = let v2 = map_keywords env v2 in v1 @ List_.map keyval_of_pair v2 +(* Like map_items but wraps each pair in OtherExpr to distinguish + * arrow syntax (%{"k" => v}) from keyword syntax (%{k: v}). *) +and map_map_items env (v1, v2) : G.expr list = + let v1 = (map_list map_expr) env v1 in + let v2 = map_keywords env v2 in + let wrap_arrow (e : G.expr) : G.expr = + match e.G.e with + | G.Ellipsis _ -> e + | _ -> G.OtherExpr (("MapPairArrow", G.fake "=>"), [ G.E e ]) |> G.e + in + let wrap_keyword p : G.expr = + match p with + | Right e -> e (* ellipsis or metavar, pass through unwrapped *) + | Left _ -> + let e = keyval_of_pair p in + G.OtherExpr (("MapPairKeyword", G.fake ":"), [ G.E e ]) |> G.e + in + List_.map wrap_arrow v1 @ List_.map wrap_keyword v2 + and map_keywords env v = (map_list map_pair) env v and map_pair_kw_expr env (v1, v2) = @@ -596,6 +615,8 @@ and map_binary_op env v1 v2 v3 = let e2 = map_expr env v3 in match op with | Left (op, tk) -> G.opcall (op, tk) [ e1; e2 ] + | Right (("=>", tk) as _id) -> + G.keyval e1 tk e2 | Right id -> let n = G.N (H.name_of_id id) |> G.e in G.Call (n, fb ([ e1; e2 ] |> List_.map G.arg)) |> G.e @@ -661,7 +682,7 @@ and map_expr env v : G.expr = |> G.e | Map (v1, v2, v3) -> ( let v2 = (map_option map_astruct) env v2 in - let l, xs, r = (map_bracket map_items) env v3 in + let l, xs, r = (map_bracket map_map_items) env v3 in match v2 with | None -> let l = Tok.combine_toks v1 [ l ] in diff --git a/libs/ast_generic/AST_generic_helpers.ml b/libs/ast_generic/AST_generic_helpers.ml index 27ce124cc7..d9e72a0a3f 100644 --- a/libs/ast_generic/AST_generic_helpers.ml +++ b/libs/ast_generic/AST_generic_helpers.ml @@ -204,6 +204,7 @@ let rec expr_to_pattern e = | Container (Dict, (t1, xs, t2)) -> PatList (t1, xs |> List_.map expr_to_pattern, t2) | Ellipsis t -> PatEllipsis t + | OtherExpr (tag, [ E e ]) -> OtherPat (tag, [ P (expr_to_pattern e) ]) | Cast (ty, _tok, expr) -> PatTyped (expr_to_pattern expr, ty) (* TODO: PatKeyVal and more *) | _ -> OtherPat (("ExprToPattern", fake ""), [ E e ]) @@ -220,6 +221,7 @@ let rec pattern_to_expr p = | PatList (t1, xs, t2) -> Container (List, (t1, xs |> List_.map pattern_to_expr, t2)) | OtherPat (("ExprToPattern", _), [ E e ]) -> e.e + | OtherPat (tag, [ P p ]) -> OtherExpr (tag, [ E (pattern_to_expr p) ]) | _ -> raise NotAnExpr) |> G.e diff --git a/src/analyzing/AST_to_IL.ml b/src/analyzing/AST_to_IL.ml index a2dcd51f72..f63505e928 100644 --- a/src/analyzing/AST_to_IL.ml +++ b/src/analyzing/AST_to_IL.ml @@ -436,6 +436,9 @@ and pattern env pat : stmts * lval * stmts = (* XXX: Assume same bound variables on lhs and rhs, as is imposed on most * languages. Hence we only recurse on one side. Seems good enough for now. *) pattern env pat1 + | G.OtherPat ((("MapPairArrow" | "MapPairKeyword"), _), [ G.P inner ]) + when env.lang =*= Lang.Elixir -> + pattern env inner | G.PatEllipsis _ -> sgrep_construct (G.P pat) | _ -> todo (G.P pat) From 1ccb7a5e85d0ea267346707268ad90ab14a88da6 Mon Sep 17 00:00:00 2001 From: Dimitris Mostrous Date: Fri, 3 Apr 2026 14:05:27 +0100 Subject: [PATCH 08/37] tests: arrow vs keyword map pattern distinction in Elixir Add dots_arrow_map test for arrow-syntax map patterns. Both tests include negative cases proving the two forms do not cross-match. --- tests/patterns/elixir/dots_arrow_map.ex | 20 ++++++++++++++++++++ tests/patterns/elixir/dots_arrow_map.sgrep | 1 + tests/patterns/elixir/dots_kw_map.ex | 6 ++++++ 3 files changed, 27 insertions(+) create mode 100644 tests/patterns/elixir/dots_arrow_map.ex create mode 100644 tests/patterns/elixir/dots_arrow_map.sgrep diff --git a/tests/patterns/elixir/dots_arrow_map.ex b/tests/patterns/elixir/dots_arrow_map.ex new file mode 100644 index 0000000000..2a52cea8fe --- /dev/null +++ b/tests/patterns/elixir/dots_arrow_map.ex @@ -0,0 +1,20 @@ +# MATCH: no other elements +%{"some_item" => 0} + +# MATCH: 1 other element +%{"some_item" => 0, "some_other_item" => 1} + +# MATCH: 1 other element, before the target +%{"some_other_item" => 1, "some_item" => 0} + +# MATCH: 2 other elements +%{"some_item" => 0, "some_other_item" => 1, "yet_another" => 2} + +# MATCH: 2 other elements +%{"some_other_item" => 1, "some_item" => 0, "yet_another" => 2} + +# no match: keyword syntax, not arrow +%{some_item: 0} + +# no match: keyword syntax with other elements +%{some_item: 0, some_other_item: 1} diff --git a/tests/patterns/elixir/dots_arrow_map.sgrep b/tests/patterns/elixir/dots_arrow_map.sgrep new file mode 100644 index 0000000000..a406c0232e --- /dev/null +++ b/tests/patterns/elixir/dots_arrow_map.sgrep @@ -0,0 +1 @@ +%{..., "some_item" => $V, ...} diff --git a/tests/patterns/elixir/dots_kw_map.ex b/tests/patterns/elixir/dots_kw_map.ex index 330ec4d761..f8a2422e0d 100644 --- a/tests/patterns/elixir/dots_kw_map.ex +++ b/tests/patterns/elixir/dots_kw_map.ex @@ -12,3 +12,9 @@ # MATCH: 2 other elements %{some_other_item: 1, some_item: 0, yet_another_other_item: 2} + +# no match: arrow syntax, not keyword +%{"some_item" => 0} + +# no match: arrow syntax with other elements +%{"some_item" => 0, "some_other_item" => 1} From a45302ff4b92e9b9a13ceeadf44efb2e878dff22 Mon Sep 17 00:00:00 2001 From: Dimitris Mostrous Date: Fri, 3 Apr 2026 15:07:39 +0100 Subject: [PATCH 09/37] tests: add taint test for map-destructured function params in Elixir --- .../tainting_rules/elixir/taint-map-param.ex | 48 +++++++++++++++++++ .../elixir/taint-map-param.yaml | 27 +++++++++++ 2 files changed, 75 insertions(+) create mode 100644 tests/tainting_rules/elixir/taint-map-param.ex create mode 100644 tests/tainting_rules/elixir/taint-map-param.yaml diff --git a/tests/tainting_rules/elixir/taint-map-param.ex b/tests/tainting_rules/elixir/taint-map-param.ex new file mode 100644 index 0000000000..d7fa128a2a --- /dev/null +++ b/tests/tainting_rules/elixir/taint-map-param.ex @@ -0,0 +1,48 @@ +defmodule TaintMapParam do + # keyword syntax, single clause + def keyword_single(%{user: user}) do + # ruleid: taint-map-param + sink(user) + end + + # arrow syntax, single clause + def arrow_single(%{"user" => user}) do + # ruleid: taint-map-param + sink(user) + end + + # multi-clause, keyword + def multi_kw(%{a: a}) do + # ruleid: taint-map-param + sink(a) + end + + def multi_kw(%{b: b}) do + # ruleid: taint-map-param + sink(b) + end + + # multi-clause, arrow + def multi_arrow(%{"a" => a}) do + # ruleid: taint-map-param + sink(a) + end + + def multi_arrow(%{"b" => b}) do + # ruleid: taint-map-param + sink(b) + end + + # plain param (not map), single clause + def plain(input) do + # ruleid: taint-map-param + sink(input) + end + + # safe: variable not from param + def safe(%{user: _user}) do + other = "safe" + # ok: taint-map-param + sink(other) + end +end diff --git a/tests/tainting_rules/elixir/taint-map-param.yaml b/tests/tainting_rules/elixir/taint-map-param.yaml new file mode 100644 index 0000000000..39cdc446ef --- /dev/null +++ b/tests/tainting_rules/elixir/taint-map-param.yaml @@ -0,0 +1,27 @@ +rules: +- id: taint-map-param + mode: taint + languages: [elixir] + message: "tainted via map param" + severity: INFO + + pattern-sources: + - patterns: + - pattern-either: + - pattern-inside: | + def $_(..., $X, ...) do + ... + end + - pattern-inside: | + def $_(..., %{..., $_: $X, ...}, ...) do + ... + end + - pattern-inside: | + def $_(..., %{..., $_ => $X, ...}, ...) do + ... + end + - focus-metavariable: $X + + pattern-sinks: + - pattern: | + sink($R) From 0ee52da89bd07342322da6047860625d0005ed58 Mon Sep 17 00:00:00 2001 From: Dimitris Mostrous Date: Fri, 3 Apr 2026 16:10:51 +0100 Subject: [PATCH 10/37] elixir: update submodule to fix scanner for crlf --- languages/elixir/tree-sitter/semgrep-elixir | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/languages/elixir/tree-sitter/semgrep-elixir b/languages/elixir/tree-sitter/semgrep-elixir index 227a88dc16..681f900527 160000 --- a/languages/elixir/tree-sitter/semgrep-elixir +++ b/languages/elixir/tree-sitter/semgrep-elixir @@ -1 +1 @@ -Subproject commit 227a88dc166cab25eb9554f7186c8d53d39c96d8 +Subproject commit 681f90052720f4bc1c2fd2c2547771f6029917f0 From f966f712167a1ccce053922afefd09aaa69fdde8 Mon Sep 17 00:00:00 2001 From: Dimitris Mostrous Date: Fri, 3 Apr 2026 16:48:34 +0100 Subject: [PATCH 11/37] fix: activate elixir parsing tests --- src/parsing/Unit_parsing.ml | 1 + 1 file changed, 1 insertion(+) diff --git a/src/parsing/Unit_parsing.ml b/src/parsing/Unit_parsing.ml index a553a69e94..90a055cefe 100644 --- a/src/parsing/Unit_parsing.ml +++ b/src/parsing/Unit_parsing.ml @@ -222,6 +222,7 @@ let langs_with_error_tolerance = [ (* languages with only a tree-sitter parser *) (Lang.Bash, Strict); + (Lang.Elixir, Strict); (Lang.Csharp, Strict); (Lang.Dockerfile, Strict); (Lang.Lua, Strict); From 072ee4278aabef76f96543adec12dc6dcf5d9948 Mon Sep 17 00:00:00 2001 From: Dimitris Mostrous Date: Fri, 3 Apr 2026 16:48:59 +0100 Subject: [PATCH 12/37] fix: allow empty parsing test dir, enable langs --- src/parsing/Unit_parsing.ml | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/parsing/Unit_parsing.ml b/src/parsing/Unit_parsing.ml index 90a055cefe..7ace242c93 100644 --- a/src/parsing/Unit_parsing.ml +++ b/src/parsing/Unit_parsing.ml @@ -119,13 +119,16 @@ let pack_parsing_tests_for_lang ?(error_tolerance = Strict) lang = let pattern = spf "%s/**/*" !!dir in let files = Common2.glob pattern in if files =*= [] then - failwith (spf "Empty set of parsing tests for %s at %s" slang pattern); - List.iter check_ext files; - let tests = parsing_tests_for_lang error_tolerance files lang in - (match subcategory with - | None -> tests - | Some cat -> Testo.categorize cat tests) - |> Testo.categorize slang + (* No test files found; return an empty suite rather than crashing *) + [] + else begin + List.iter check_ext files; + let tests = parsing_tests_for_lang error_tolerance files lang in + (match subcategory with + | None -> tests + | Some cat -> Testo.categorize cat tests) + |> Testo.categorize slang + end (* Note that here we also use tree-sitter to parse; certain files were not * parsing with pfff but parses here @@ -221,6 +224,7 @@ let make_tests langs_with_tolerance = let langs_with_error_tolerance = [ (* languages with only a tree-sitter parser *) + (Lang.Apex, Strict); (Lang.Bash, Strict); (Lang.Elixir, Strict); (Lang.Csharp, Strict); @@ -241,11 +245,17 @@ let langs_with_error_tolerance = (Lang.Julia, Strict); (Lang.Jsonnet, Strict); (Lang.Dart, Strict); + (Lang.Json, Strict); + (* TODO: Move_on_sui has non-.move files in its test dir (TODO/) *) + (* (Lang.Move_on_sui, Strict); *) + (Lang.Ql, Strict); (* here we have both a Pfff and tree-sitter parser *) (Lang.Java, Strict); (Lang.Go, Strict); (Lang.Ruby, Strict); (Lang.Js, Strict); + (Lang.Ts, Partial_parsing); + (Lang.Python, Strict); (Lang.C, Strict); (Lang.Cpp, Strict); (Lang.Php, Strict); From 95b43debd2ace7a28704d70a1178925e4ee04a6b Mon Sep 17 00:00:00 2001 From: Dimitris Mostrous Date: Fri, 3 Apr 2026 17:21:15 +0100 Subject: [PATCH 13/37] elixir: unwrap map pair tags in AST_to_IL dict translation The OtherExpr("MapPairArrow"/"MapPairKeyword") wrappers introduced for map items were opaque to the dict function in AST_to_IL, breaking taint propagation through map value expressions. Unwrap these tags so dict entries are processed normally. --- src/analyzing/AST_to_IL.ml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/analyzing/AST_to_IL.ml b/src/analyzing/AST_to_IL.ml index f63505e928..dc79b6e2a4 100644 --- a/src/analyzing/AST_to_IL.ml +++ b/src/analyzing/AST_to_IL.ml @@ -1465,6 +1465,14 @@ and dict env (_, orig_entries, _) orig : stmts * exp = let ss_k, ke = expr env korig in let ss_v, ve = expr env vorig in (ss_k @ ss_v, Entry (ke, ve)) + | G.OtherExpr ((("MapPairArrow" | "MapPairKeyword"), _), [ G.E inner ]) + when env.lang =*= Lang.Elixir -> + (match inner.G.e with + | G.Container (G.Tuple, (_, [ korig; vorig ], _)) -> + let ss_k, ke = expr env korig in + let ss_v, ve = expr env vorig in + (ss_k @ ss_v, Entry (ke, ve)) + | _ -> todo (G.E orig)) | __else__ -> todo (G.E orig)) orig_entries in From a540926d44a96b36cf487fdd26f24a2b62e095ae Mon Sep 17 00:00:00 2001 From: Dimitris Mostrous Date: Fri, 3 Apr 2026 17:21:37 +0100 Subject: [PATCH 14/37] tests: taint flow through Elixir map value expressions --- .../tainting_rules/elixir/taint-map-value.ex | 39 +++++++++++++++++++ .../elixir/taint-map-value.yaml | 21 ++++++++++ 2 files changed, 60 insertions(+) create mode 100644 tests/tainting_rules/elixir/taint-map-value.ex create mode 100644 tests/tainting_rules/elixir/taint-map-value.yaml diff --git a/tests/tainting_rules/elixir/taint-map-value.ex b/tests/tainting_rules/elixir/taint-map-value.ex new file mode 100644 index 0000000000..9c4e3dc7df --- /dev/null +++ b/tests/tainting_rules/elixir/taint-map-value.ex @@ -0,0 +1,39 @@ +defmodule Taint.MapValue do + # taint flows through arrow map value expression + def transform_arrow(items) do + items + # ruleid: taint-map-value + |> Enum.map(fn {k, v} -> %{sink(k) => v} end) + end + + # taint flows through complex key expression in arrow map + def transform_arrow_key(items) do + items + # ruleid: taint-map-value + |> Enum.map(fn {k, v} -> %{String.downcase(sink(k)) => v} end) + end + + # taint flows through keyword map value expression + def transform_keyword(items) do + items + # ruleid: taint-map-value + |> Enum.map(fn {k, v} -> %{result: sink(k)} end) + end + + # taint through a direct call, not inside a map + def transform_direct(items) do + items + # ruleid: taint-map-value + |> Enum.map(fn {k, v} -> sink(k) end) + end + + # safe: variable not from param + def no_taint(items) do + items + |> Enum.map(fn {_k, _v} -> + other = "safe" + # ok: taint-map-value + sink(other) + end) + end +end diff --git a/tests/tainting_rules/elixir/taint-map-value.yaml b/tests/tainting_rules/elixir/taint-map-value.yaml new file mode 100644 index 0000000000..2f9b665cd7 --- /dev/null +++ b/tests/tainting_rules/elixir/taint-map-value.yaml @@ -0,0 +1,21 @@ +rules: +- id: taint-map-value + mode: taint + languages: [elixir] + message: "tainted via map value" + severity: INFO + + pattern-sources: + - patterns: + - pattern-either: + - pattern-inside: | + fn ..., $X, ... -> ... end + - pattern-inside: | + def $_(..., $X, ...) do + ... + end + - focus-metavariable: $X + + pattern-sinks: + - pattern: | + sink($R) From 586c7b0b0d2a1921bb74a965790eb8efb801c918 Mon Sep 17 00:00:00 2001 From: corneliuhoffman Date: Fri, 3 Apr 2026 20:21:14 +0100 Subject: [PATCH 15/37] Support chained method calls on constructor results with --taint-intrafile (#638) * add tests and fix the chaining of methods in all langs * simplification of access removed tok_of_eorig --- src/call_graph/Call_graph.ml | 4 +- src/call_graph/Function_id.ml | 2 + src/call_graph/Function_id.mli | 2 + src/tainting/Dataflow_tainting.ml | 59 ++++++++-------- src/tainting/Graph_from_AST.ml | 68 +++++++++++++++++-- .../test_csharp_constructor.cs | 6 +- .../test_java_constructor.java | 4 ++ .../test_javascript_constructor.js | 4 ++ .../test_kotlin_constructor.kt | 4 ++ .../test_python_constructor.py | 4 ++ .../test_ruby_constructor.rb | 4 ++ .../test_scala_constructor.scala | 6 +- .../test_typescript_constructor.ts | 4 ++ 13 files changed, 134 insertions(+), 37 deletions(-) diff --git a/src/call_graph/Call_graph.ml b/src/call_graph/Call_graph.ml index 71b3f46277..fc46f17406 100644 --- a/src/call_graph/Call_graph.ml +++ b/src/call_graph/Call_graph.ml @@ -51,7 +51,9 @@ module Display = struct let default_vertex_attributes _ = [] let vertex_attributes _ = [] let default_edge_attributes _ = [] - let edge_attributes _ = [] + let edge_attributes (_, label, _) = + let pos = label.call_site in + [ `Label (Printf.sprintf "l:%d c:%d" pos.Pos.line pos.Pos.column) ] let get_subgraph _ = None end diff --git a/src/call_graph/Function_id.ml b/src/call_graph/Function_id.ml index 5b5dacf4a7..c4b96ba2bc 100644 --- a/src/call_graph/Function_id.ml +++ b/src/call_graph/Function_id.ml @@ -53,6 +53,8 @@ let compare (n1 : t) (n2 : t) : int = let equal (n1 : t) (n2 : t) : bool = key n1 = key n2 +let tok ((_, tok) : t) : Tok.t = tok + let show ((id, _) : t) : string = id diff --git a/src/call_graph/Function_id.mli b/src/call_graph/Function_id.mli index 30eb9f1cda..432669024a 100644 --- a/src/call_graph/Function_id.mli +++ b/src/call_graph/Function_id.mli @@ -12,4 +12,6 @@ val show_debug : t -> string val of_il_name : IL.name -> t +val tok : t -> Tok.t + val to_file_line_col : t -> string * int * int diff --git a/src/tainting/Dataflow_tainting.ml b/src/tainting/Dataflow_tainting.ml index eea51cada5..f0302660f0 100644 --- a/src/tainting/Dataflow_tainting.ml +++ b/src/tainting/Dataflow_tainting.ml @@ -633,26 +633,13 @@ let effects_of_call_func_arg fun_exp fun_shape args_taints = (S.show_shape fun_shape)); [] -(* Call graph edges are keyed by source-level token positions, but IL - expressions often carry tokens from IL construction (e.g., fresh - variables from class_construction). The eorig back-pointer preserves - the original source position, which we need for call graph lookups. *) -let tok_of_eorig ~(default : Tok.t) (exp : IL.exp) : Tok.t = - match exp.eorig with - | SameAs orig_exp -> - (match AST_generic_helpers.ii_of_any (G.E orig_exp) with - | tok :: _ when not (Tok.is_fake tok) -> tok - | _ -> default) - | Related orig_any -> - (match AST_generic_helpers.ii_of_any orig_any with - | tok :: _ when not (Tok.is_fake tok) -> tok - | _ -> default) - | NoOrig -> default - -let get_signature_for_object graph caller_node db method_name obj (fun_exp : IL.exp) arity = - let call_tok = tok_of_eorig ~default:(snd obj.ident) fun_exp in - match Call_graph.lookup_callee_from_graph - graph (Option.map Function_id.of_il_name caller_node) call_tok with + +let get_signature_for_object graph caller_node db method_name arity = + let caller = Option.map Function_id.of_il_name caller_node in + let method_tok = Function_id.tok method_name in + (* Look up via method name token — call graph edges for DotAccess calls + are stored at the method token position (see extract_calls). *) + match Call_graph.lookup_callee_from_graph graph caller method_tok with | Some callee_node -> Shape_and_sig.(lookup_signature db callee_node arity) | None -> Shape_and_sig.lookup_signature db method_name arity @@ -683,8 +670,8 @@ let lookup_signature_with_object_context env fun_exp arity = | Some db -> ( match fun_exp.e with | Fetch { base = Var name; rev_offset = [] } -> - (* Simple function call *) - let call_tok = tok_of_eorig ~default:(snd name.ident) fun_exp in + (* Simple function call — edge stored at function name token *) + let call_tok = snd name.ident in (match Call_graph.lookup_callee_from_graph env.call_graph @@ -704,19 +691,17 @@ let lookup_signature_with_object_context env fun_exp arity = try_builtin_fallback env func_name arity result) | Fetch { - base = VarSpecial ((Self | This), self_tok); + base = VarSpecial ((Self | This), _); rev_offset = [ { o = Dot method_name; _ } ]; } when Option.is_some env.class_name -> ( (* Method call on self/this: self.method() or this.method() *) - (* First try to look up via call graph to get the correct fn_id *) - (* Use self_tok (start of call expression) to match edge labels *) - let call_tok = self_tok in + let method_tok = snd method_name.IL.ident in match Call_graph.lookup_callee_from_graph env.call_graph (Option.map Function_id.of_il_name env.func.name) - call_tok + method_tok with | Some callee_node -> Shape_and_sig.(lookup_signature db callee_node arity) @@ -729,8 +714,6 @@ let lookup_signature_with_object_context env fun_exp arity = env.func.name db (Function_id.of_il_name method_name) - obj - fun_exp arity with | Some _ as result -> result @@ -1972,8 +1955,24 @@ let call_with_intrafile lval_opt e env args instr = * `Constructor`. We check the call graph to determine if this call * resolves to a constructor, and if so, remap the callee accordingly. *) let resolves_to_constructor = + (* Method calls on objects (e.g., _tmp.get_data()) should not be + remapped as constructors. Their eorig may share a token with a + constructor edge (e.g., in Passthrough(source()).get_data(), both + the constructor and the method eorig start at "Passthrough"). + Skip the constructor check for Dot accesses unless it's Ruby's + ClassName.new() pattern. *) + (match e.e with + | Fetch { rev_offset = [{ o = Dot name; _ }]; _ } + when fst name.IL.ident <> "new" + || not Lang.(env.taint_inst.lang =*= Ruby) -> false + | _ -> true) && Option.is_some env.signature_db && - let call_tok = tok_of_eorig ~default:(Tok.unsafe_fake_tok "") e in + (* The constructor edge is stored at the class name token position + (first token of the call expression). Extract it from the callee. *) + let call_tok = match e.e with + | Fetch { base = Var name; _ } -> snd name.ident + | _ -> Tok.unsafe_fake_tok "" + in not (Tok.is_fake call_tok) && match Call_graph.lookup_callee_from_graph env.call_graph diff --git a/src/tainting/Graph_from_AST.ml b/src/tainting/Graph_from_AST.ml index d1627a3a7a..9e7f80ae88 100644 --- a/src/tainting/Graph_from_AST.ml +++ b/src/tainting/Graph_from_AST.ml @@ -327,6 +327,53 @@ let identify_callee ~(lang : Lang.t) ?(object_mappings = []) ?(all_funcs = []) (* obj not in object_mappings — try as ClassName.new() constructor *) let ty = G.{ t = TyN (G.Id ((obj_name, G.fake obj_name), G.empty_id_info ())); t_attrs = [] } in resolve_constructor_from_type ~lang ~all_funcs ty) + (* Chained call: Constructor(...).method() — receiver is a constructor. + Python/Kotlin/Scala: ClassName(args).method() + Java/JS/TS/C#: new ClassName(args).method() + Ruby: ClassName.new(args).method() *) + | G.DotAccess (receiver, _, G.FN (G.Id ((method_name, _), _))) -> + let class_name_opt = match receiver.G.e with + (* Python/Kotlin/Scala: ClassName(args) *) + | G.Call ({ e = G.N (G.Id ((cn, _), _)); _ }, _) + when Lang.(lang =*= Python || lang =*= Kotlin || lang =*= Scala) -> Some cn + (* Java/JS/TS/C#: new ClassName(args) *) + | G.New (_, ty, _, _) + when Lang.(lang =*= Java || lang =*= Js || lang =*= Ts || lang =*= Csharp) -> + (match ty.G.t with + | G.TyN (G.Id ((cn, _), _)) -> Some cn + | G.TyExpr { G.e = G.N (G.Id ((cn, _), _)); _ } -> Some cn + | _ -> None) + (* Ruby: ClassName.new(args) *) + | G.Call ({ e = G.DotAccess ( + { e = G.N (G.Id ((cn, _), _)); _ }, _, + G.FN (G.Id (("new", _), _))); _ }, _) + when Lang.(lang =*= Ruby) -> Some cn + | _ -> None + in + (match class_name_opt with + | Some class_name -> + let method_matches = List.filter (fun f -> + match f.fn_id with + | [Some c; Some m] -> + fst c.IL.ident = class_name + && fst m.IL.ident = method_name + | _ -> false + ) all_funcs in + (match method_matches with + | [single_match] -> Some single_match.fn_id + | [] -> None + | _ -> + (* Multiple matches — disambiguate by arity *) + (match call_arity with + | Some arity -> + let arity_matches = List.filter (fun f -> + Int.equal (get_func_arity f.fdef) arity + ) method_matches in + (match arity_matches with + | [single_match] -> Some single_match.fn_id + | _ -> None) + | None -> None)) + | None -> None) | _ -> Log.debug (fun m -> m "CALL_EXTRACT: Unmatched call pattern: %s" @@ -370,11 +417,24 @@ let extract_calls ~(lang : Lang.t) ?(object_mappings = []) ?(all_funcs = []) ?(c let call_arity = List.length args_list in (match identify_callee ~lang ~object_mappings ~all_funcs ~caller_parent_path ~call_arity callee with | Some fn_id -> - (* Extract token from the call expression for edge label *) + (* For DotAccess calls, use the method name token so it + matches the method_tok lookup in get_signature_for_object. + Exception: Ruby's ClassName.new() — use the class name + token (top of expression) since the constructor machinery + uses tok_of_eorig which points to the class name. *) let tok = - match AST_generic_helpers.ii_of_any (G.E e) with - | tok :: _ -> tok - | [] -> Tok.unsafe_fake_tok "" + match callee.G.e with + | G.DotAccess (_, _, G.FN (G.Id (("new", _), _))) + when Lang.(lang =*= Ruby) -> + (match AST_generic_helpers.ii_of_any (G.E e) with + | tok :: _ -> tok + | [] -> Tok.unsafe_fake_tok "") + | G.DotAccess (_, _, G.FN (G.Id ((_, method_tok), _))) -> + method_tok + | _ -> + (match AST_generic_helpers.ii_of_any (G.E e) with + | tok :: _ -> tok + | [] -> Tok.unsafe_fake_tok "") in calls := (fn_id, tok) :: !calls | None -> ()); diff --git a/tests/rules/cross_function_tainting/test_csharp_constructor.cs b/tests/rules/cross_function_tainting/test_csharp_constructor.cs index 0f434da096..dca8a33b8c 100644 --- a/tests/rules/cross_function_tainting/test_csharp_constructor.cs +++ b/tests/rules/cross_function_tainting/test_csharp_constructor.cs @@ -62,7 +62,11 @@ public static void Main() FieldUser fieldUser = new FieldUser(); fieldUser.name = taintedInput2; string fieldResult = fieldUser.GetProfile(); - + + // Test chained method call: new Constructor(tainted).method() + // ruleid: csharp_constructor_sqli + string chainedResult = "SELECT * FROM users WHERE name = " + new User(source()).GetProfile(); + return; } } \ No newline at end of file diff --git a/tests/rules/cross_function_tainting/test_java_constructor.java b/tests/rules/cross_function_tainting/test_java_constructor.java index e2ee47b313..5bf24aaa7f 100644 --- a/tests/rules/cross_function_tainting/test_java_constructor.java +++ b/tests/rules/cross_function_tainting/test_java_constructor.java @@ -68,6 +68,10 @@ public static void main(String[] args) { IntermethodClass intermethodObj = new IntermethodClass(); String intermethodResult = intermethodObj.sinkMethod(); + // Test chained method call: new Constructor(tainted).method() + // ruleid: java_constructor_sqli + String chainedResult = "SELECT * FROM users WHERE name = " + new User(source()).getProfile(); + return; } } \ No newline at end of file diff --git a/tests/rules/cross_function_tainting/test_javascript_constructor.js b/tests/rules/cross_function_tainting/test_javascript_constructor.js index 8b67431318..1f672c0a27 100644 --- a/tests/rules/cross_function_tainting/test_javascript_constructor.js +++ b/tests/rules/cross_function_tainting/test_javascript_constructor.js @@ -63,5 +63,9 @@ function main() { // ruleid: javascript_constructor_sqli sink(a); + // Test chained method call: new Constructor(tainted).method() + // ruleid: javascript_constructor_sqli + sink(new User(source()).getProfile()); + return result; } diff --git a/tests/rules/cross_function_tainting/test_kotlin_constructor.kt b/tests/rules/cross_function_tainting/test_kotlin_constructor.kt index f180bebc90..a2c6071136 100644 --- a/tests/rules/cross_function_tainting/test_kotlin_constructor.kt +++ b/tests/rules/cross_function_tainting/test_kotlin_constructor.kt @@ -67,5 +67,9 @@ fun main() { val intermethodObj = IntermethodClass() val intermethodResult = intermethodObj.sinkMethod() + // Test chained method call: Constructor(tainted).method() + // ruleid: kotlin_constructor_sqli + sink(User(source()).getProfile()) + return } diff --git a/tests/rules/cross_function_tainting/test_python_constructor.py b/tests/rules/cross_function_tainting/test_python_constructor.py index 3b075ac8ef..45906d6d10 100644 --- a/tests/rules/cross_function_tainting/test_python_constructor.py +++ b/tests/rules/cross_function_tainting/test_python_constructor.py @@ -54,4 +54,8 @@ def main(): intermethod_obj = IntermethodClass() intermethod_result = intermethod_obj.sink_method() + # Test chained method call: Constructor(tainted).method() + # ruleid:python_constructor_sqli + chained_result = f"SELECT * FROM users WHERE name = {User(source()).get_profile()}" + return result diff --git a/tests/rules/cross_function_tainting/test_ruby_constructor.rb b/tests/rules/cross_function_tainting/test_ruby_constructor.rb index dc0b26ddaf..9769c3f6b7 100644 --- a/tests/rules/cross_function_tainting/test_ruby_constructor.rb +++ b/tests/rules/cross_function_tainting/test_ruby_constructor.rb @@ -31,5 +31,9 @@ def main intermethod_obj = IntermethodClass.new() intermethod_result = intermethod_obj.sink_method() + # Test chained method call: ClassName.new(tainted).method() + # ruleid: ruby_constructor_sqli + chained_result = "SELECT * FROM users WHERE name = #{User.new(taint).get_profile()}" + return result end diff --git a/tests/rules/cross_function_tainting/test_scala_constructor.scala b/tests/rules/cross_function_tainting/test_scala_constructor.scala index 0fbe3d5833..897629716d 100644 --- a/tests/rules/cross_function_tainting/test_scala_constructor.scala +++ b/tests/rules/cross_function_tainting/test_scala_constructor.scala @@ -41,7 +41,11 @@ object Main { val fieldUser = new FieldUser() fieldUser.name = taintedInput2 val fieldResult = fieldUser.getProfile() - + + // Test chained method call: new Constructor(tainted).method() + // ruleid: scala_constructor_sqli + sink(new User(source()).getProfile()) + return } } diff --git a/tests/rules/cross_function_tainting/test_typescript_constructor.ts b/tests/rules/cross_function_tainting/test_typescript_constructor.ts index bf89f4021f..750a330c0b 100644 --- a/tests/rules/cross_function_tainting/test_typescript_constructor.ts +++ b/tests/rules/cross_function_tainting/test_typescript_constructor.ts @@ -64,5 +64,9 @@ function main(): void { // ruleid: typescript_constructor_sqli const query: string = `SELECT * FROM users WHERE name = ${a}`; + // Test chained method call: new Constructor(tainted).method() + // ruleid: typescript_constructor_sqli + const chainedQuery: string = `SELECT * FROM users WHERE name = ${new User(source()).getProfile()}`; + return; } \ No newline at end of file From d357116cdfd5fe0dcbd169126390a5b3df90f7f6 Mon Sep 17 00:00:00 2001 From: Dimitris Mostrous Date: Fri, 3 Apr 2026 20:26:35 +0100 Subject: [PATCH 16/37] bump version --- cli/setup.py | 2 +- cli/src/semgrep/__init__.py | 2 +- setup.py | 2 +- src/core/Version.ml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/setup.py b/cli/setup.py index 89327e26ee..2efdf5491e 100644 --- a/cli/setup.py +++ b/cli/setup.py @@ -124,7 +124,7 @@ def find_executable(env_name, exec_name): setuptools.setup( name="opengrep", - version="1.16.5", + version="1.17.0", author="Semgrep Inc., Opengrep", author_email="support@opengrep.dev", description="Lightweight static analysis for many languages. Find bug variants with patterns that look like source code.", diff --git a/cli/src/semgrep/__init__.py b/cli/src/semgrep/__init__.py index 5aa89ca810..13e21a3312 100644 --- a/cli/src/semgrep/__init__.py +++ b/cli/src/semgrep/__init__.py @@ -1,2 +1,2 @@ -__VERSION__ = "1.16.5" +__VERSION__ = "1.17.0" __SEMGREP_VERSION__ = "1.100.0" diff --git a/setup.py b/setup.py index 623e963cbf..65946b56e9 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name="semgrep_pre_commit_package", - version="1.16.5", + version="1.17.0", # install_requires=["opengrep==1.2.0"], # Commented out since we're not on pypi. packages=[], ) diff --git a/src/core/Version.ml b/src/core/Version.ml index 57ef77ca3e..c0633ba10b 100644 --- a/src/core/Version.ml +++ b/src/core/Version.ml @@ -3,5 +3,5 @@ Automatically modified by scripts/release/bump. *) -let version = "1.16.5" +let version = "1.17.0" let version_semgrep = "1.100.0" From 9bef9456abb988794bd59e14a3d49485a6ffd371 Mon Sep 17 00:00:00 2001 From: Dimitris Mostrous Date: Fri, 3 Apr 2026 20:35:21 +0100 Subject: [PATCH 17/37] add changelog --- CHANGELOG.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 016d0723c6..9e2be38d84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,42 @@ # Changelog +## [1.17.0](https://github.com/opengrep/opengrep/releases/tag/v1.17.0) - 03-04-2026 + +### Improvements + +* Treat nested functions as lambdas by @dimitris-m in #621 +* Tainting: per-arity signature extraction for Clojure and Elixir by @dimitris-m in #582 +* Dockerfile: Preprocess for better line continuations and comments by @maciejpirog in #633 +* Elixir: fix map destructuring in function parameters by @dimitris-m in #637 +* Elixir: fix taint propagation through map value expressions by @dimitris-m in #640 +* Support chained method calls on constructor results with --taint-intrafile by @corneliuhoffman in #638 + +### Bug fixes + +* Fix(Ruby): zero argument method call syntax by @corneliuhoffman in #626 +* Fix: proper constructor taint analysis by @corneliuhoffman in #628 +* Fix: Elixir scanner CRLF handling by @dimitris-m in #639 + +### Refactoring + +* Refactoring of AST-to-IL, Step 1 by @maciejpirog in #622 + +### Developer tooling + +* Pretty-print IL in C-style syntax by @maciejpirog in #623 + +### Installation + +* Improve error reporting in install.sh by @dimitris-m in #620 + +### Security + +* [Aikido] AI Fix for Template Injection in GitHub Workflows Action by @aikido-autofix[bot] in #624 +* [Aikido] AI Fix for 3rd party Github Actions should be pinned by @aikido-autofix[bot] in #625 + +**Full Changelog**: https://github.com/opengrep/opengrep/compare/v1.16.5...v1.17.0 + + ## [1.16.5](https://github.com/opengrep/opengrep/releases/tag/v1.16.5) - 17-03-2026 ### Improvements From 59fc1f9d1a02502fa62185f1eebe7dcef3be3aa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Pir=C3=B3g?= Date: Mon, 6 Apr 2026 01:32:03 +0200 Subject: [PATCH 18/37] ast-to-il: keep expr in translation of ExprToPattern --- src/analyzing/AST_to_IL.ml | 7 +++++++ tests/tainting_rules/elixir/taint-cond-case.ex | 7 +++++++ tests/tainting_rules/elixir/taint-cond-case.yaml | 12 ++++++++++++ 3 files changed, 26 insertions(+) create mode 100644 tests/tainting_rules/elixir/taint-cond-case.ex create mode 100644 tests/tainting_rules/elixir/taint-cond-case.yaml diff --git a/src/analyzing/AST_to_IL.ml b/src/analyzing/AST_to_IL.ml index dc79b6e2a4..a9b3a15ef9 100644 --- a/src/analyzing/AST_to_IL.ml +++ b/src/analyzing/AST_to_IL.ml @@ -439,6 +439,13 @@ and pattern env pat : stmts * lval * stmts = | G.OtherPat ((("MapPairArrow" | "MapPairKeyword"), _), [ G.P inner ]) when env.lang =*= Lang.Elixir -> pattern env inner + | G.OtherPat (("ExprToPattern", tok), [ G.E e ]) -> + (* expr_to_pattern fallback: the expression couldn't be statically + * converted to a known pattern. Evaluate the expression so that + * side-effects and taint flow are captured, then bind a fresh tmp. *) + let pre_ss, _e' = expr env e in + let tmp = fresh_lval tok in + (pre_ss, tmp, []) | G.PatEllipsis _ -> sgrep_construct (G.P pat) | _ -> todo (G.P pat) diff --git a/tests/tainting_rules/elixir/taint-cond-case.ex b/tests/tainting_rules/elixir/taint-cond-case.ex new file mode 100644 index 0000000000..7fe4aba8dc --- /dev/null +++ b/tests/tainting_rules/elixir/taint-cond-case.ex @@ -0,0 +1,7 @@ +def foo() do + x = source() + cond do + # ruleid: cond_taint_rule + y = sink(x) -> 3 + end +end diff --git a/tests/tainting_rules/elixir/taint-cond-case.yaml b/tests/tainting_rules/elixir/taint-cond-case.yaml new file mode 100644 index 0000000000..0ce2b8bd9a --- /dev/null +++ b/tests/tainting_rules/elixir/taint-cond-case.yaml @@ -0,0 +1,12 @@ +rules: +- id: cond_taint_rule + languages: [elixir] + message: "tainted data reached sink" + severity: ERROR + mode: taint + pattern-sources: + - pattern: | + source() + pattern-sinks: + - pattern: | + sink(...) From 2914e44c107e530c02a976980704064a70b022a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Pir=C3=B3g?= Date: Mon, 6 Apr 2026 02:32:16 +0200 Subject: [PATCH 19/37] elixir: add rescue clause to function definition --- languages/elixir/ast/AST_elixir.ml | 3 + languages/elixir/ast/Elixir_to_elixir.ml | 19 +++-- languages/elixir/generic/Elixir_to_generic.ml | 80 +++++++++++++++++-- tests/tainting_rules/elixir/taint-rescue.ex | 66 +++++++++++++++ tests/tainting_rules/elixir/taint-rescue.yaml | 16 ++++ 5 files changed, 171 insertions(+), 13 deletions(-) create mode 100644 tests/tainting_rules/elixir/taint-rescue.ex create mode 100644 tests/tainting_rules/elixir/taint-rescue.yaml diff --git a/languages/elixir/ast/AST_elixir.ml b/languages/elixir/ast/AST_elixir.ml index af3d461f6c..ffe1529b67 100644 --- a/languages/elixir/ast/AST_elixir.ml +++ b/languages/elixir/ast/AST_elixir.ml @@ -326,6 +326,9 @@ and function_definition = { f_guard : expr option; (* bracket is do/end *) f_body : stmts bracket; + (* rescue/catch/after/else clauses from the implicit-try form: + * def foo(x) do body rescue E -> handler end *) + f_rescue : (exn_clause_kind wrap * body_or_clauses) list; f_is_private : bool; } diff --git a/languages/elixir/ast/Elixir_to_elixir.ml b/languages/elixir/ast/Elixir_to_elixir.ml index e210355f90..ad580528a5 100644 --- a/languages/elixir/ast/Elixir_to_elixir.ml +++ b/languages/elixir/ast/Elixir_to_elixir.ml @@ -28,7 +28,7 @@ open AST_elixir (*****************************************************************************) (* Visitor *) (*****************************************************************************) -let make_funcdef ~tdef ~ident ~params ~guard ~tdo ~body ~tend ~def_str = +let make_funcdef ~tdef ~ident ~params ~guard ~tdo ~body ~tend ~rescue ~def_str = let f_body = match tdo, tend with | Some tdo, Some tend -> (tdo, body, tend) @@ -41,6 +41,7 @@ let make_funcdef ~tdef ~ident ~params ~guard ~tdo ~body ~tend ~def_str = f_params = params; f_guard = guard; f_body; + f_rescue = rescue; f_is_private = String.equal def_str "defp"; } in @@ -92,15 +93,17 @@ class ['self] visitor = (* TODO? warning about unrecognized form? failwith ? *) Call (self#visit_call env x)) (* https://hexdocs.pm/elixir/Kernel.html#def/2 - * TODO: handle "implicit try" form + * The optional extras (rescue/catch/after/else) are the "implicit try" + * form; they are stored in f_rescue and translated to a Try block in + * Elixir_to_generic.ml. *) | ( I (Id (( "def" | "defp" ) as def_str, tdef)), (_, ([ Call (I ident, args, None) ], []), _), - Some (tdo, (Body body, []), tend) ) -> + Some (tdo, (Body body, extras), tend) ) -> let body = self#visit_body env body in let params = params_of_args args in make_funcdef ~tdef ~ident ~params ~guard:None ~tdo:(Some tdo) - ~body ~tend:(Some tend) ~def_str + ~body ~tend:(Some tend) ~rescue:extras ~def_str | ( I (Id (( "def" | "defp" ) as def_str, tdef)), (_, ([ Call (I ident, args, None) ], [ Kw_expr ((X1 (do_kw, _), _tok_colon), body) ]), _), @@ -108,16 +111,16 @@ class ['self] visitor = let body = self#visit_expr env body in let params = params_of_args args in make_funcdef ~tdef ~ident ~params ~guard:None ~tdo:None - ~body:([ body ]) ~tend:None ~def_str + ~body:([ body ]) ~tend:None ~rescue:[] ~def_str (* def foo(x) when guard do ... end *) | ( I (Id (( "def" | "defp" ) as def_str, tdef)), (_, ([ When (Call (I ident, args, None), _twhen, E guard) ], []), _), - Some (tdo, (Body body, []), tend) ) -> + Some (tdo, (Body body, extras), tend) ) -> let guard = self#visit_expr env guard in let body = self#visit_body env body in let params = params_of_args args in make_funcdef ~tdef ~ident ~params ~guard:(Some guard) ~tdo:(Some tdo) - ~body ~tend:(Some tend) ~def_str + ~body ~tend:(Some tend) ~rescue:extras ~def_str (* def foo(x) when guard, do: body *) | ( I (Id (( "def" | "defp" ) as def_str, tdef)), (_, ([ When (Call (I ident, args, None), _twhen, E guard) ], @@ -127,7 +130,7 @@ class ['self] visitor = let body = self#visit_expr env body in let params = params_of_args args in make_funcdef ~tdef ~ident ~params ~guard:(Some guard) ~tdo:None - ~body:([ body ]) ~tend:None ~def_str + ~body:([ body ]) ~tend:None ~rescue:[] ~def_str (* https://hexdocs.pm/elixir/Kernel.html#defmodule/2 *) | ( I (Id ("defmodule", tdefmodule)), (_, ([ Alias mname ], []), _), diff --git a/languages/elixir/generic/Elixir_to_generic.ml b/languages/elixir/generic/Elixir_to_generic.ml index 945cb9b018..5d5e27d5ec 100644 --- a/languages/elixir/generic/Elixir_to_generic.ml +++ b/languages/elixir/generic/Elixir_to_generic.ml @@ -471,6 +471,72 @@ and map_param_to_gparam env (p : parameter) : G.parameter = let e = keyval_of_pair (Left (kwd, e)) in G.ParamPattern (H.expr_to_pattern e) +(* Convert one rescue/catch stab clause to a G.catch arm. + * Each stab has a list of exception-type expressions and a handler body. *) +and map_rescue_stab_to_catch env tok (stab : stab_clause) : G.catch = + let ((args, _kwargs), _guard_opt), _tarrow, body_stmts = stab in + let catch_exn = + match args with + | [] -> G.CatchPattern (G.PatEllipsis tok) + | [arg] -> + let e = map_expr env arg in + G.CatchPattern (H.expr_to_pattern e) + | args -> + let pats = List_.map (fun a -> H.expr_to_pattern (map_expr env a)) args in + let pat = + List.fold_right (fun p acc -> G.DisjPat (p, acc)) + (List.tl pats) (List.hd pats) + in + G.CatchPattern pat + in + let body = map_stmts env body_stmts in + (tok, catch_exn, G.Block (Tok.unsafe_fake_bracket body) |> G.s) + +(* Wrap a body statement in a G.Try when there are rescue/catch/after clauses. + * `tok` is used as the try token. *) +and wrap_with_rescue env tok body_stmt rescue = + match rescue with + | [] -> body_stmt + | extras -> + let catches, try_else, finally = + List.fold_left + (fun (catches, try_else, finally) ((kind, t), boc) -> + match (kind : exn_clause_kind) with + | Rescue | Catch -> + let s = + match boc with + | Clauses stabs -> + let cs = List_.map (map_rescue_stab_to_catch env t) stabs in + catches @ cs + | Body _ -> catches + in + (s, + try_else, + finally) + | Else -> + let s = + match boc with + | Clauses stabs -> + stabs |> List.concat_map (fun (_, _, b) -> map_stmts env b) + | Body _ -> [] + in + (catches, + Some (t, G.Block (Tok.unsafe_fake_bracket s) |> G.s), + finally) + | After -> + let s = + match boc with + | Clauses stabs -> + stabs |> List.concat_map (fun (_, _, b) -> map_stmts env b) + | Body stmts -> map_stmts env stmts + in + (catches, + try_else, + Some (t, G.Block (Tok.unsafe_fake_bracket s) |> G.s))) + ([], None, None) extras + in + G.Try (tok, body_stmt, catches, try_else, finally) |> G.s + and map_func_clause_to_stab env (clause : function_definition) : stab_clause_generic = let _, params, _ = clause.f_params in @@ -485,7 +551,7 @@ and map_func_clause_to_stab env (clause : function_definition) : and map_definition env (v : definition) : G.definition = match v with - | FuncDef [ { f_def; f_name; f_params; f_guard = None; f_body; f_is_private } ] -> + | FuncDef [ { f_def; f_name; f_params; f_guard = None; f_body; f_rescue; f_is_private } ] -> (* Single clause, no guard: use direct param names (original behavior). * This preserves the simple fparams=[x,y,...] representation so that * taint analysis can match call arguments to parameters by position @@ -502,12 +568,15 @@ and map_definition env (v : definition) : G.definition = let fparams = map_bracket (map_list map_param_to_gparam) env f_params in let l, body, r = f_body in let body_stmts = map_stmts env body in + let body_stmt = + wrap_with_rescue env l (G.Block (l, body_stmts, r) |> G.s) f_rescue + in let fdef = { G.fkind = (G.Function, f_def); fparams; frettype = None; - fbody = G.FBStmt (G.Block (l, body_stmts, r) |> G.s); + fbody = G.FBStmt body_stmt; } in (ent, G.FuncDef fdef) @@ -547,9 +616,10 @@ and map_definition env (v : definition) : G.definition = let stab = map_func_clause_to_stab env c in case_and_body_of_stab_clause stab) in - let body_stmt = - G.Switch (tk, Some (G.Cond switch_cond), cases) |> G.s - in + let switch_stmt = G.Switch (tk, Some (G.Cond switch_cond), cases) |> G.s in + (* Aggregate rescue/catch/after clauses from all function clauses *) + let all_rescue = List.concat_map (fun c -> c.f_rescue) clauses in + let body_stmt = wrap_with_rescue env tk switch_stmt all_rescue in let fdef = { G.fkind = (G.Function, tk); diff --git a/tests/tainting_rules/elixir/taint-rescue.ex b/tests/tainting_rules/elixir/taint-rescue.ex new file mode 100644 index 0000000000..b9645c7801 --- /dev/null +++ b/tests/tainting_rules/elixir/taint-rescue.ex @@ -0,0 +1,66 @@ +defmodule TaintRescue do + # taint flows through main body even when rescue clause is present + def with_rescue(x) do + # ruleid: taint-rescue + sink(x) + rescue + ArgumentError -> :error + end + + # guarded function with rescue + def guarded_rescue(x) when is_binary(x) do + # ruleid: taint-rescue + sink(x) + rescue + ArgumentError -> :error + end + + # taint also flows into the rescue handler body (x is still in scope) + def rescue_handler(x) do + some_call(x) + rescue + _e -> + # ruleid: taint-rescue + sink(x) + end + + # taint flows into the else clause (x is still in scope) + def with_else(x) do + some_call(x) + else + _result -> + # ruleid: taint-rescue + sink(x) + end + + # taint flows into the after clause (x is still in scope) + def with_after(x) do + some_call(x) + rescue + _e -> :error + after + # ruleid: taint-rescue + sink(x) + end + + # taint flows through body with all clauses present + def with_all_clauses(x) do + # ruleid: taint-rescue + sink(x) + rescue + _e -> :error + else + _result -> :ok + after + some_call(x) + end + + # taint flows into catch clause body (x is still in scope) + def with_catch(x) do + some_call(x) + catch + _kind, _value -> + # ruleid: taint-rescue + sink(x) + end +end diff --git a/tests/tainting_rules/elixir/taint-rescue.yaml b/tests/tainting_rules/elixir/taint-rescue.yaml new file mode 100644 index 0000000000..895282bab6 --- /dev/null +++ b/tests/tainting_rules/elixir/taint-rescue.yaml @@ -0,0 +1,16 @@ +rules: +- id: taint-rescue + mode: taint + languages: [elixir] + message: "tainted in function with rescue clause" + severity: INFO + pattern-sources: + - patterns: + - pattern-inside: | + def $_(..., $X, ...) do + ... + end + - focus-metavariable: $X + pattern-sinks: + - pattern: | + sink($R) From d9fe8a9125db37b5d2eb7f909d49f192897e62d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Pir=C3=B3g?= Date: Mon, 6 Apr 2026 17:20:14 +0200 Subject: [PATCH 20/37] elixir: represent structs as Constructors --- languages/elixir/generic/Elixir_to_generic.ml | 21 ++++---- libs/ast_generic/AST_generic_helpers.ml | 4 ++ .../elixir/taint-struct-param.ex | 49 +++++++++++++++++++ .../elixir/taint-struct-param.yaml | 27 ++++++++++ 4 files changed, 89 insertions(+), 12 deletions(-) create mode 100644 tests/tainting_rules/elixir/taint-struct-param.ex create mode 100644 tests/tainting_rules/elixir/taint-struct-param.yaml diff --git a/languages/elixir/generic/Elixir_to_generic.ml b/languages/elixir/generic/Elixir_to_generic.ml index 5d5e27d5ec..14710e0e64 100644 --- a/languages/elixir/generic/Elixir_to_generic.ml +++ b/languages/elixir/generic/Elixir_to_generic.ml @@ -753,19 +753,16 @@ and map_expr env v : G.expr = | Map (v1, v2, v3) -> ( let v2 = (map_option map_astruct) env v2 in let l, xs, r = (map_bracket map_map_items) env v3 in + let dict_l = Tok.combine_toks v1 [ l ] in + let dict_body = G.Container (G.Dict, (dict_l, xs, r)) |> G.e in match v2 with - | None -> - let l = Tok.combine_toks v1 [ l ] in - G.Container (G.Dict, (l, xs, r)) |> G.e - | Some astruct -> - (* TODO? - | Some (Left id) -> - let n = H2.name_of_id id in - let ty = G.TyN n |> G.t in - G.New (tpercent, ty, G.empty_id_info (), (l, List_.map G.arg xs, r)) - |> G.e - *) - G.Call (astruct, (l, List_.map G.arg xs, r)) |> G.e) + | None -> dict_body + | Some name_expr -> ( + match H.name_of_dot_access name_expr with + | Some n -> G.Constructor (n, fb [ dict_body ]) |> G.e + | None -> + (* Fallback for dynamic struct names that can't be statically resolved *) + G.Call (name_expr, fb [ G.arg dict_body ]) |> G.e)) | Alias v -> (* TODO: split alias in components, and then use name_of_ids *) let v = map_alias env v in diff --git a/libs/ast_generic/AST_generic_helpers.ml b/libs/ast_generic/AST_generic_helpers.ml index d9e72a0a3f..d44ccf84e4 100644 --- a/libs/ast_generic/AST_generic_helpers.ml +++ b/libs/ast_generic/AST_generic_helpers.ml @@ -203,6 +203,8 @@ let rec expr_to_pattern e = PatList (t1, xs |> List_.map expr_to_pattern, t2) | Container (Dict, (t1, xs, t2)) -> PatList (t1, xs |> List_.map expr_to_pattern, t2) + | Constructor (n, (_, args, _)) -> + PatConstructor (n, args |> List_.map expr_to_pattern) | Ellipsis t -> PatEllipsis t | OtherExpr (tag, [ E e ]) -> OtherPat (tag, [ P (expr_to_pattern e) ]) | Cast (ty, _tok, expr) -> PatTyped (expr_to_pattern expr, ty) @@ -220,6 +222,8 @@ let rec pattern_to_expr p = | PatLiteral l -> L l | PatList (t1, xs, t2) -> Container (List, (t1, xs |> List_.map pattern_to_expr, t2)) + | PatConstructor (n, ps) -> + Constructor (n, Tok.unsafe_fake_bracket (ps |> List_.map pattern_to_expr)) | OtherPat (("ExprToPattern", _), [ E e ]) -> e.e | OtherPat (tag, [ P p ]) -> OtherExpr (tag, [ E (pattern_to_expr p) ]) | _ -> raise NotAnExpr) diff --git a/tests/tainting_rules/elixir/taint-struct-param.ex b/tests/tainting_rules/elixir/taint-struct-param.ex new file mode 100644 index 0000000000..677c00b785 --- /dev/null +++ b/tests/tainting_rules/elixir/taint-struct-param.ex @@ -0,0 +1,49 @@ + +defmodule TaintStructParam do + # keyword syntax, single clause + def keyword_single(%Some_struct{user: user}) do + # ruleid: taint-struct-param + sink(user) + end + + # arrow syntax, single clause + def arrow_single(%Some_struct{"user" => user}) do + # ruleid: taint-struct-param + sink(user) + end + + # multi-clause, keyword + def multi_kw(%Some_struct{a: a}) do + # ruleid: taint-struct-param + sink(a) + end + + def multi_kw(%Some_struct{b: b}) do + # ruleid: taint-struct-param + sink(b) + end + + # multi-clause, arrow + def multi_arrow(%Some_struct{"a" => a}) do + # ruleid: taint-struct-param + sink(a) + end + + def multi_arrow(%Some_struct{"b" => b}) do + # ruleid: taint-struct-param + sink(b) + end + + # plain param (not map), single clause + def plain(input) do + # ruleid: taint-struct-param + sink(input) + end + + # safe: variable not from param + def safe(%Some_struct{user: _user}) do + other = "safe" + # ok: taint-struct-param + sink(other) + end +end diff --git a/tests/tainting_rules/elixir/taint-struct-param.yaml b/tests/tainting_rules/elixir/taint-struct-param.yaml new file mode 100644 index 0000000000..dfd22fa535 --- /dev/null +++ b/tests/tainting_rules/elixir/taint-struct-param.yaml @@ -0,0 +1,27 @@ +rules: +- id: taint-struct-param + mode: taint + languages: [elixir] + message: "tainted via struct param" + severity: INFO + + pattern-sources: + - patterns: + - pattern-either: + - pattern-inside: | + def $_(..., $X, ...) do + ... + end + - pattern-inside: | + def $_(..., %$_{..., $_: $X, ...}, ...) do + ... + end + - pattern-inside: | + def $_(..., %$_{..., $_ => $X, ...}, ...) do + ... + end + - focus-metavariable: $X + + pattern-sinks: + - pattern: | + sink($R) From ff90bbb6987d12ae6c628a0c1c009d708ea94f34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Pir=C3=B3g?= Date: Mon, 6 Apr 2026 20:53:35 +0200 Subject: [PATCH 21/37] elixir: list join operator as Constructor --- languages/elixir/generic/Elixir_to_generic.ml | 2 +- tests/patterns/elixir/list-bar.ex | 7 +++++++ tests/patterns/elixir/list-bar.sgrep | 1 + tests/patterns/elixir/list-elements.ex | 7 +++++++ tests/patterns/elixir/list-elements.sgrep | 1 + tests/tainting_rules/elixir/taint-list.ex | 15 +++++++++++++++ tests/tainting_rules/elixir/taint-list.yaml | 17 +++++++++++++++++ 7 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 tests/patterns/elixir/list-bar.ex create mode 100644 tests/patterns/elixir/list-bar.sgrep create mode 100644 tests/patterns/elixir/list-elements.ex create mode 100644 tests/patterns/elixir/list-elements.sgrep create mode 100644 tests/tainting_rules/elixir/taint-list.ex create mode 100644 tests/tainting_rules/elixir/taint-list.yaml diff --git a/languages/elixir/generic/Elixir_to_generic.ml b/languages/elixir/generic/Elixir_to_generic.ml index 14710e0e64..d77a7a24b1 100644 --- a/languages/elixir/generic/Elixir_to_generic.ml +++ b/languages/elixir/generic/Elixir_to_generic.ml @@ -823,7 +823,7 @@ and map_expr env v : G.expr = let e1 = map_expr env v1 in let v3 = map_expr_or_kwds env v3 in let e3 = expr_of_expr_or_kwds v3 in - G.OtherExpr (("Join", tbar), [ G.E e1; G.E e3 ]) |> G.e + G.Constructor (H.name_of_id ("|", tbar), fb [ e1; e3 ]) |> G.e | Lambda (tfn, v2, _tend) -> let xs = map_clauses env v2 in let fdef = stab_clauses_to_function_definition tfn xs in diff --git a/tests/patterns/elixir/list-bar.ex b/tests/patterns/elixir/list-bar.ex new file mode 100644 index 0000000000..ab7acff26d --- /dev/null +++ b/tests/patterns/elixir/list-bar.ex @@ -0,0 +1,7 @@ +def foo() do + # ERROR: + [1,2|3] + + # OK: + [1,2,3] +end diff --git a/tests/patterns/elixir/list-bar.sgrep b/tests/patterns/elixir/list-bar.sgrep new file mode 100644 index 0000000000..37366e7b02 --- /dev/null +++ b/tests/patterns/elixir/list-bar.sgrep @@ -0,0 +1 @@ +[$X,$Y|$Z] diff --git a/tests/patterns/elixir/list-elements.ex b/tests/patterns/elixir/list-elements.ex new file mode 100644 index 0000000000..c0826c28c1 --- /dev/null +++ b/tests/patterns/elixir/list-elements.ex @@ -0,0 +1,7 @@ +def foo() do + # ERROR: + [1,2,3] + + # OK: + [1,2|3] +end diff --git a/tests/patterns/elixir/list-elements.sgrep b/tests/patterns/elixir/list-elements.sgrep new file mode 100644 index 0000000000..6222244e66 --- /dev/null +++ b/tests/patterns/elixir/list-elements.sgrep @@ -0,0 +1 @@ +[$X,$Y,$Z] diff --git a/tests/tainting_rules/elixir/taint-list.ex b/tests/tainting_rules/elixir/taint-list.ex new file mode 100644 index 0000000000..063f3f8df9 --- /dev/null +++ b/tests/tainting_rules/elixir/taint-list.ex @@ -0,0 +1,15 @@ +def foo([x,y]) do + # ruleid: list_taint_rule + sink(x) + + # ruleid: list_taint_rule + sink(y) +end + +def bar([x|y]) do + # ruleid: list_taint_rule + sink(x) + + # ruleid: list_taint_rule + sink(y) +end diff --git a/tests/tainting_rules/elixir/taint-list.yaml b/tests/tainting_rules/elixir/taint-list.yaml new file mode 100644 index 0000000000..d54d84b279 --- /dev/null +++ b/tests/tainting_rules/elixir/taint-list.yaml @@ -0,0 +1,17 @@ +rules: +- id: list_taint_rule + languages: [elixir] + message: "tainted data reached sink" + severity: ERROR + mode: taint + pattern-sources: + - patterns: + - pattern-either: + - pattern-inside: | + def $_(..., $X, ...) do + ... + end + - focus-metavariable: $X + pattern-sinks: + - pattern: | + sink(...) From 2c0667bb31fe3471801c6fccb0922ee8e3f0d22a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Pir=C3=B3g?= Date: Mon, 6 Apr 2026 21:28:09 +0200 Subject: [PATCH 22/37] elixir: support default values in function args --- languages/elixir/ast/Elixir_to_elixir.ml | 4 +++- .../elixir/taint-param-with-default.ex | 4 ++++ .../elixir/taint-param-with-default.yaml | 17 +++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 tests/tainting_rules/elixir/taint-param-with-default.ex create mode 100644 tests/tainting_rules/elixir/taint-param-with-default.yaml diff --git a/languages/elixir/ast/Elixir_to_elixir.ml b/languages/elixir/ast/Elixir_to_elixir.ml index ad580528a5..af63effa6d 100644 --- a/languages/elixir/ast/Elixir_to_elixir.ml +++ b/languages/elixir/ast/Elixir_to_elixir.ml @@ -54,7 +54,9 @@ class ['self] visitor = exprs |> List_.map (function | I id -> P { pname = id; pdefault = None } - (* TODO: recognize default value with \\ *) + (* In Elixir you can have a default value only for ident params (no pats) *) + | BinaryOp (I id, (ODefault, tok), d) -> + P { pname = id; pdefault = Some (tok, d) } | x -> OtherParamExpr x) in let ys = diff --git a/tests/tainting_rules/elixir/taint-param-with-default.ex b/tests/tainting_rules/elixir/taint-param-with-default.ex new file mode 100644 index 0000000000..98ff427e2f --- /dev/null +++ b/tests/tainting_rules/elixir/taint-param-with-default.ex @@ -0,0 +1,4 @@ +def foo(x \\ "default value") do + # ruleid: taint + sink(x) +end diff --git a/tests/tainting_rules/elixir/taint-param-with-default.yaml b/tests/tainting_rules/elixir/taint-param-with-default.yaml new file mode 100644 index 0000000000..58eb1c4c68 --- /dev/null +++ b/tests/tainting_rules/elixir/taint-param-with-default.yaml @@ -0,0 +1,17 @@ +rules: +- id: taint + languages: [elixir] + message: "tainted data reached sink" + severity: ERROR + mode: taint + pattern-sources: + - patterns: + - pattern-either: + - pattern-inside: | + def $_(..., $X, ...) do + ... + end + - focus-metavariable: $X + pattern-sinks: + - pattern: | + sink(...) From 482e7e7b235d3730cf55497b5160b322754a3239 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Pir=C3=B3g?= Date: Tue, 7 Apr 2026 13:17:12 +0200 Subject: [PATCH 23/37] elixir: support function defs with no args --- languages/elixir/ast/Elixir_to_elixir.ml | 20 +++++++++++++++++- tests/patterns/elixir/fun_forms.ex | 26 ++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/languages/elixir/ast/Elixir_to_elixir.ml b/languages/elixir/ast/Elixir_to_elixir.ml index af63effa6d..b4ea095786 100644 --- a/languages/elixir/ast/Elixir_to_elixir.ml +++ b/languages/elixir/ast/Elixir_to_elixir.ml @@ -47,6 +47,24 @@ let make_funcdef ~tdef ~ident ~params ~guard ~tdo ~body ~tend ~rescue ~def_str = in S (D (FuncDef [def])) +(* In Elixir, we can skip the arg list in function definition if it is empty. + * We preprocess these cases to avoid code duplication in the visitor. *) +let normalize_function_header (x : call) : call = + match x with + | (I (Id (( "def" | "defp" ), _)) as a), + (l, ([ I ident ], kw), r), + c -> + a, + (l, ([ Call (I ident, Tok.unsafe_fake_bracket ([], []), None) ], kw), r), + c + | (I (Id (( "def" | "defp" ), _)) as a), + (l, ([ When (I ident, wtok, wguard) ], kw), r), + c -> + a, + (l, ([ When (Call (I ident, Tok.unsafe_fake_bracket ([], []), None), wtok, wguard) ], kw), r), + c + | _ -> x + class ['self] visitor = let params_of_args (args : arguments bracket) : parameters = let l, (exprs, kwdargs), r = args in @@ -77,7 +95,7 @@ class ['self] visitor = method! visit_Call env (x : call) = - match x with + match normalize_function_header x with (* https://hexdocs.pm/elixir/Kernel.html#if/2 * TODO? recognize also the compact form 'if(cond, :do then)' ? *) diff --git a/tests/patterns/elixir/fun_forms.ex b/tests/patterns/elixir/fun_forms.ex index ba0a6d202a..0423ce5df5 100644 --- a/tests/patterns/elixir/fun_forms.ex +++ b/tests/patterns/elixir/fun_forms.ex @@ -1,9 +1,31 @@ #ERROR: -def foo() do +def foo1() do 42 end #ERROR: -def bar(), do: 42 +def foo2(), do: 42 +#ERROR: +def foo3 do + 42 +end + +#ERROR: +def foo4, do: 42 + +#ERROR: +def foo5() when x > 1 do + 42 +end + +#ERROR: +def foo6() when x > 1, do: 42 + +#ERROR: +def foo7 when x > 1 do + 42 +end +#ERROR: +def foo8 when x > 1, do: 42 From 2cb5f674593d3381b075b683e60043c7a1e1b885 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Pir=C3=B3g?= Date: Tue, 7 Apr 2026 13:55:18 +0200 Subject: [PATCH 24/37] elixir: support for as-patterns --- libs/ast_generic/AST_generic_helpers.ml | 3 ++- .../tainting_rules/elixir/taint-pattern-as.ex | 7 +++++++ .../elixir/taint-pattern-as.yaml | 20 +++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 tests/tainting_rules/elixir/taint-pattern-as.ex create mode 100644 tests/tainting_rules/elixir/taint-pattern-as.yaml diff --git a/libs/ast_generic/AST_generic_helpers.ml b/libs/ast_generic/AST_generic_helpers.ml index d44ccf84e4..ea1ac9ca0f 100644 --- a/libs/ast_generic/AST_generic_helpers.ml +++ b/libs/ast_generic/AST_generic_helpers.ml @@ -208,7 +208,8 @@ let rec expr_to_pattern e = | Ellipsis t -> PatEllipsis t | OtherExpr (tag, [ E e ]) -> OtherPat (tag, [ P (expr_to_pattern e) ]) | Cast (ty, _tok, expr) -> PatTyped (expr_to_pattern expr, ty) - (* TODO: PatKeyVal and more *) + | LetPattern (p, {e = N (Id (i, info)); _} ) -> PatAs (p, (i, info)) + (* TODO: PatKeyVal and more *) | _ -> OtherPat (("ExprToPattern", fake ""), [ E e ]) exception NotAnExpr diff --git a/tests/tainting_rules/elixir/taint-pattern-as.ex b/tests/tainting_rules/elixir/taint-pattern-as.ex new file mode 100644 index 0000000000..81a2aef666 --- /dev/null +++ b/tests/tainting_rules/elixir/taint-pattern-as.ex @@ -0,0 +1,7 @@ +def handle(%User{name: name} = user) do + #ruleid: taint-pattern-as + sink(name) + + #ruleid: taint-pattern-as + sink(user) +end diff --git a/tests/tainting_rules/elixir/taint-pattern-as.yaml b/tests/tainting_rules/elixir/taint-pattern-as.yaml new file mode 100644 index 0000000000..a7d8ce7d7b --- /dev/null +++ b/tests/tainting_rules/elixir/taint-pattern-as.yaml @@ -0,0 +1,20 @@ +rules: + - id: taint-pattern-as + message: taint + severity: WARNING + languages: + - elixir + mode: taint + pattern-sources: + - patterns: + - pattern-either: + - pattern-inside: | + def $_(..., $X, ...) do + ... + end + - focus-metavariable: $X + pattern-sinks: + - patterns: + - focus-metavariable: $X + - pattern-either: + - pattern: sink($X) From c8d9875db19aa055acd240c580f8f98fb3b093d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Pir=C3=B3g?= Date: Tue, 7 Apr 2026 15:15:45 +0200 Subject: [PATCH 25/37] elixir: keep when guards in rescue patterns --- languages/elixir/generic/Elixir_to_generic.ml | 16 +++++++++++----- tests/patterns/elixir/rescue_when.ex | 6 ++++++ tests/patterns/elixir/rescue_when.sgrep | 1 + 3 files changed, 18 insertions(+), 5 deletions(-) create mode 100644 tests/patterns/elixir/rescue_when.ex create mode 100644 tests/patterns/elixir/rescue_when.sgrep diff --git a/languages/elixir/generic/Elixir_to_generic.ml b/languages/elixir/generic/Elixir_to_generic.ml index d77a7a24b1..bc6f3a054b 100644 --- a/languages/elixir/generic/Elixir_to_generic.ml +++ b/languages/elixir/generic/Elixir_to_generic.ml @@ -474,21 +474,27 @@ and map_param_to_gparam env (p : parameter) : G.parameter = (* Convert one rescue/catch stab clause to a G.catch arm. * Each stab has a list of exception-type expressions and a handler body. *) and map_rescue_stab_to_catch env tok (stab : stab_clause) : G.catch = - let ((args, _kwargs), _guard_opt), _tarrow, body_stmts = stab in - let catch_exn = + let ((args, _kwargs), guard_opt), _tarrow, body_stmts = stab in + let catch_pat = match args with - | [] -> G.CatchPattern (G.PatEllipsis tok) + | [] -> G.PatEllipsis tok | [arg] -> let e = map_expr env arg in - G.CatchPattern (H.expr_to_pattern e) + H.expr_to_pattern e | args -> let pats = List_.map (fun a -> H.expr_to_pattern (map_expr env a)) args in let pat = List.fold_right (fun p acc -> G.DisjPat (p, acc)) (List.tl pats) (List.hd pats) in - G.CatchPattern pat + pat in + let catch_pat = + match guard_opt with + | Some (_tok, guard) -> G.PatWhen (catch_pat, map_expr env guard) + | None -> catch_pat + in + let catch_exn = G.CatchPattern catch_pat in let body = map_stmts env body_stmts in (tok, catch_exn, G.Block (Tok.unsafe_fake_bracket body) |> G.s) diff --git a/tests/patterns/elixir/rescue_when.ex b/tests/patterns/elixir/rescue_when.ex new file mode 100644 index 0000000000..06a8e00c13 --- /dev/null +++ b/tests/patterns/elixir/rescue_when.ex @@ -0,0 +1,6 @@ +def guard_sink(x) do + dangerous() +catch + # ERROR: + _kind, _value when test() -> :ok +end diff --git a/tests/patterns/elixir/rescue_when.sgrep b/tests/patterns/elixir/rescue_when.sgrep new file mode 100644 index 0000000000..8be63798f7 --- /dev/null +++ b/tests/patterns/elixir/rescue_when.sgrep @@ -0,0 +1 @@ +test() From a7da6d4d28e73d0d9a5a0be5bc0bd5466443e968 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Pir=C3=B3g?= Date: Tue, 7 Apr 2026 15:43:53 +0200 Subject: [PATCH 26/37] elixir: support stand-alone try blocks --- languages/elixir/ast/AST_elixir.ml | 1 + languages/elixir/ast/Elixir_to_elixir.ml | 4 ++ languages/elixir/generic/Elixir_to_generic.ml | 8 +++ tests/tainting_rules/elixir/taint-try.ex | 72 +++++++++++++++++++ tests/tainting_rules/elixir/taint-try.yaml | 16 +++++ 5 files changed, 101 insertions(+) create mode 100644 tests/tainting_rules/elixir/taint-try.ex create mode 100644 tests/tainting_rules/elixir/taint-try.yaml diff --git a/languages/elixir/ast/AST_elixir.ml b/languages/elixir/ast/AST_elixir.ml index ffe1529b67..8064bc190d 100644 --- a/languages/elixir/ast/AST_elixir.ml +++ b/languages/elixir/ast/AST_elixir.ml @@ -306,6 +306,7 @@ and stmt = * stmts * (tok * stmts) option * tok (* 'end' *) + | Try of tok (* 'try' *) * do_block | D of definition (* ------------------------------------------------------------------------- *) diff --git a/languages/elixir/ast/Elixir_to_elixir.ml b/languages/elixir/ast/Elixir_to_elixir.ml index b4ea095786..6953ff7f69 100644 --- a/languages/elixir/ast/Elixir_to_elixir.ml +++ b/languages/elixir/ast/Elixir_to_elixir.ml @@ -164,6 +164,10 @@ class ['self] visitor = } in S (D (ModuleDef def)) + (* https://hexdocs.pm/elixir/Kernel.SpecialForms.html#try/1 *) + | ( I (Id ("try", ttry)), (_, ([], []), _), Some do_block ) -> + let do_block = self#visit_do_block env do_block in + S (Try (ttry, do_block)) | _else_ -> let x = self#visit_call env x in Call x diff --git a/languages/elixir/generic/Elixir_to_generic.ml b/languages/elixir/generic/Elixir_to_generic.ml index bc6f3a054b..b01a34f2ed 100644 --- a/languages/elixir/generic/Elixir_to_generic.ml +++ b/languages/elixir/generic/Elixir_to_generic.ml @@ -425,6 +425,14 @@ and map_stmt env (v : stmt) : G.stmt = in let st1 = G.Block (tdo, then_, tthenend) |> G.s in G.If (tif, G.Cond e, st1, elseopt) |> G.s + | Try (ttry, (tdo, (boc, extras), tend)) -> + let body_stmts = + match boc with + | Body stmts -> map_stmts env stmts + | Clauses _ -> (* unreachable: try body is always Body stmts *) [] + in + let body_stmt = G.Block (tdo, body_stmts, tend) |> G.s in + wrap_with_rescue env ttry body_stmt extras | D def -> let d = map_definition env def in G.DefStmt d |> G.s diff --git a/tests/tainting_rules/elixir/taint-try.ex b/tests/tainting_rules/elixir/taint-try.ex new file mode 100644 index 0000000000..3f2389a581 --- /dev/null +++ b/tests/tainting_rules/elixir/taint-try.ex @@ -0,0 +1,72 @@ +defmodule TaintTry do + # taint flows through try body + def with_try(x) do + try do + # ruleid: taint-try + sink(x) + rescue + ArgumentError -> :error + end + end + + # taint flows into the rescue handler body (x is still in scope) + def try_rescue_handler(x) do + try do + some_call(x) + rescue + _e -> + # ruleid: taint-try + sink(x) + end + end + + # taint flows into the catch clause body (x is still in scope) + def try_catch_handler(x) do + try do + some_call(x) + catch + _kind, _value -> + # ruleid: taint-try + sink(x) + end + end + + # taint flows into the after clause (x is still in scope) + def try_with_after(x) do + try do + some_call(x) + rescue + _e -> :error + after + # ruleid: taint-try + sink(x) + end + end + + # taint flows into the else clause (x is still in scope) + def try_with_else(x) do + try do + some_call(x) + else + _result -> + # ruleid: taint-try + sink(x) + end + end + + # taint flows through body with all clauses present + def try_with_all_clauses(x) do + try do + # ruleid: taint-try + sink(x) + rescue + _e -> :error + catch + _kind, _value -> :caught + else + _result -> :ok + after + some_call(x) + end + end +end diff --git a/tests/tainting_rules/elixir/taint-try.yaml b/tests/tainting_rules/elixir/taint-try.yaml new file mode 100644 index 0000000000..c8f6dae994 --- /dev/null +++ b/tests/tainting_rules/elixir/taint-try.yaml @@ -0,0 +1,16 @@ +rules: +- id: taint-try + mode: taint + languages: [elixir] + message: "tainted value flows through standalone try block" + severity: INFO + pattern-sources: + - patterns: + - pattern-inside: | + def $_(..., $X, ...) do + ... + end + - focus-metavariable: $X + pattern-sinks: + - pattern: | + sink($R) From ee057451860a82e3ba1259a17e2684b15b2c4994 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Pir=C3=B3g?= Date: Tue, 7 Apr 2026 16:05:24 +0200 Subject: [PATCH 27/37] elixir: represent throw with Throw --- languages/elixir/ast/AST_elixir.ml | 1 + languages/elixir/ast/Elixir_to_elixir.ml | 4 ++++ languages/elixir/generic/Elixir_to_generic.ml | 3 +++ 3 files changed, 8 insertions(+) diff --git a/languages/elixir/ast/AST_elixir.ml b/languages/elixir/ast/AST_elixir.ml index 8064bc190d..a8d12ab942 100644 --- a/languages/elixir/ast/AST_elixir.ml +++ b/languages/elixir/ast/AST_elixir.ml @@ -307,6 +307,7 @@ and stmt = * (tok * stmts) option * tok (* 'end' *) | Try of tok (* 'try' *) * do_block + | Throw of tok (* 'throw' *) * expr | D of definition (* ------------------------------------------------------------------------- *) diff --git a/languages/elixir/ast/Elixir_to_elixir.ml b/languages/elixir/ast/Elixir_to_elixir.ml index 6953ff7f69..fc019febba 100644 --- a/languages/elixir/ast/Elixir_to_elixir.ml +++ b/languages/elixir/ast/Elixir_to_elixir.ml @@ -164,6 +164,10 @@ class ['self] visitor = } in S (D (ModuleDef def)) + (* https://hexdocs.pm/elixir/Kernel.SpecialForms.html#throw/1 *) + | ( I (Id ("throw", tthrow)), (_, ([ arg ], []), _), None ) -> + let arg = self#visit_expr env arg in + S (Throw (tthrow, arg)) (* https://hexdocs.pm/elixir/Kernel.SpecialForms.html#try/1 *) | ( I (Id ("try", ttry)), (_, ([], []), _), Some do_block ) -> let do_block = self#visit_do_block env do_block in diff --git a/languages/elixir/generic/Elixir_to_generic.ml b/languages/elixir/generic/Elixir_to_generic.ml index b01a34f2ed..6679999b06 100644 --- a/languages/elixir/generic/Elixir_to_generic.ml +++ b/languages/elixir/generic/Elixir_to_generic.ml @@ -425,6 +425,9 @@ and map_stmt env (v : stmt) : G.stmt = in let st1 = G.Block (tdo, then_, tthenend) |> G.s in G.If (tif, G.Cond e, st1, elseopt) |> G.s + | Throw (tthrow, e) -> + let e = map_expr env e in + G.Throw (tthrow, e, G.sc) |> G.s | Try (ttry, (tdo, (boc, extras), tend)) -> let body_stmts = match boc with From 4a736709f4ea3c67d87b333c4c451fb8106486f8 Mon Sep 17 00:00:00 2001 From: Dimitris Mostrous Date: Tue, 7 Apr 2026 16:56:47 +0100 Subject: [PATCH 28/37] bump version --- cli/setup.py | 2 +- cli/src/semgrep/__init__.py | 2 +- setup.py | 2 +- src/core/Version.ml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/setup.py b/cli/setup.py index 2efdf5491e..f91086b0cf 100644 --- a/cli/setup.py +++ b/cli/setup.py @@ -124,7 +124,7 @@ def find_executable(env_name, exec_name): setuptools.setup( name="opengrep", - version="1.17.0", + version="1.18.0", author="Semgrep Inc., Opengrep", author_email="support@opengrep.dev", description="Lightweight static analysis for many languages. Find bug variants with patterns that look like source code.", diff --git a/cli/src/semgrep/__init__.py b/cli/src/semgrep/__init__.py index 13e21a3312..8e821d5067 100644 --- a/cli/src/semgrep/__init__.py +++ b/cli/src/semgrep/__init__.py @@ -1,2 +1,2 @@ -__VERSION__ = "1.17.0" +__VERSION__ = "1.18.0" __SEMGREP_VERSION__ = "1.100.0" diff --git a/setup.py b/setup.py index 65946b56e9..2b10ab71cc 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name="semgrep_pre_commit_package", - version="1.17.0", + version="1.18.0", # install_requires=["opengrep==1.2.0"], # Commented out since we're not on pypi. packages=[], ) diff --git a/src/core/Version.ml b/src/core/Version.ml index c0633ba10b..2634edbac7 100644 --- a/src/core/Version.ml +++ b/src/core/Version.ml @@ -3,5 +3,5 @@ Automatically modified by scripts/release/bump. *) -let version = "1.17.0" +let version = "1.18.0" let version_semgrep = "1.100.0" From 6f5a9b709ec41f72ee5f2383512a249813beb866 Mon Sep 17 00:00:00 2001 From: Dimitris Mostrous Date: Tue, 7 Apr 2026 16:58:11 +0100 Subject: [PATCH 29/37] add changelog --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e2be38d84..c7f4f45f00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.18.0](https://github.com/opengrep/opengrep/releases/tag/v1.18.0) - 07-04-2026 + +### Improvements + +* Elixir: Updates to syntax (part 1) by @maciejpirog in #642 +* Elixir: Updates to syntax (part 2) by @maciejpirog in #646 + +**Full Changelog**: https://github.com/opengrep/opengrep/compare/v1.17.0...v1.18.0 + + ## [1.17.0](https://github.com/opengrep/opengrep/releases/tag/v1.17.0) - 03-04-2026 ### Improvements From 70673f172b0097b8f2237fa3cbeed477804767e9 Mon Sep 17 00:00:00 2001 From: Dimitris Mostrous Date: Wed, 8 Apr 2026 14:49:23 +0100 Subject: [PATCH 30/37] elixir: represent for comprehensions with G.Comprehension Elixir `for` comprehensions were encoded as plain G.Call nodes, which meant the pattern binding in `for x <- data do ... end` was invisible to the taint engine. The `<-` operator was treated as an opaque function call, so taint on `data` could not reach `x`. Add a ForGenerator/ForFilter AST to AST_elixir, recognise `Call("for", ...)` in Elixir_to_elixir (both do/end and compact keyword forms), and lower to G.Comprehension(G.List, ...) with proper CompFor/CompIf clauses in Elixir_to_generic. Remaining known limitations (marked todoruleid in tests): - Pipe operator `|>` is not desugared, so taint does not flow through pipe chains (pre-existing, affects all Elixir code). - Multiple generators in a single comprehension are not yet handled by AST_to_IL (pre-existing, affects all languages). --- languages/elixir/ast/AST_elixir.ml | 9 +++++ languages/elixir/ast/Elixir_to_elixir.ml | 39 ++++++++++++++++++- languages/elixir/generic/Elixir_to_generic.ml | 19 +++++++++ .../elixir/taint-for-comprehension.ex | 32 +++++++++++++++ .../elixir/taint-for-comprehension.yaml | 17 ++++++++ 5 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 tests/tainting_rules/elixir/taint-for-comprehension.ex create mode 100644 tests/tainting_rules/elixir/taint-for-comprehension.yaml diff --git a/languages/elixir/ast/AST_elixir.ml b/languages/elixir/ast/AST_elixir.ml index a8d12ab942..951e421ad8 100644 --- a/languages/elixir/ast/AST_elixir.ml +++ b/languages/elixir/ast/AST_elixir.ml @@ -308,8 +308,17 @@ and stmt = * tok (* 'end' *) | Try of tok (* 'try' *) * do_block | Throw of tok (* 'throw' *) * expr + (* https://hexdocs.pm/elixir/Kernel.SpecialForms.html#for/1 *) + | For of tok (* 'for' *) * for_clause list * stmts bracket (* do body *) | D of definition +(* ref: https://hexdocs.pm/elixir/Kernel.SpecialForms.html#for/1 + * A for_clause is either a generator (pattern <- collection) or a filter. + *) +and for_clause = + | ForGenerator of expr (* pattern *) * tok (* '<-' *) * expr (* collection *) + | ForFilter of expr + (* ------------------------------------------------------------------------- *) (* Definitions *) (* ------------------------------------------------------------------------- *) diff --git a/languages/elixir/ast/Elixir_to_elixir.ml b/languages/elixir/ast/Elixir_to_elixir.ml index fc019febba..bec8e17f50 100644 --- a/languages/elixir/ast/Elixir_to_elixir.ml +++ b/languages/elixir/ast/Elixir_to_elixir.ml @@ -93,7 +93,18 @@ class ['self] visitor = object (self : 'self) inherit [_] map - + method private for_clauses env (args : expr list) : for_clause list = + List_.map (fun (arg : expr) -> + match arg with + | BinaryOp (pat, (OLeftArrow, tarrow), collection) -> + let pat = self#visit_expr env pat in + let collection = self#visit_expr env collection in + ForGenerator (pat, tarrow, collection) + | e -> + let e = self#visit_expr env e in + ForFilter e + ) args + method! visit_Call env (x : call) = match normalize_function_header x with (* https://hexdocs.pm/elixir/Kernel.html#if/2 @@ -172,6 +183,32 @@ class ['self] visitor = | ( I (Id ("try", ttry)), (_, ([], []), _), Some do_block ) -> let do_block = self#visit_do_block env do_block in S (Try (ttry, do_block)) + (* https://hexdocs.pm/elixir/Kernel.SpecialForms.html#for/1 + * for pattern <- collection, filter, ... do body end *) + | ( I (Id ("for", tfor)), + (_, (args, _kwds), _), + Some (tdo, (Body body, []), tend) ) -> + let clauses = self#for_clauses env args in + let body = self#visit_body env body in + S (For (tfor, clauses, (tdo, body, tend))) + (* for pattern <- collection, do: body (compact keyword form) *) + | ( I (Id ("for", tfor)), + (_, (args, kwds), _), + None ) -> ( + match List.find_opt (fun (kwd : pair) -> + match kwd with + | Kw_expr ((X1 (do_kw, _), _), _) + when String.starts_with ~prefix:"do:" do_kw -> true + | _ -> false + ) kwds with + | Some (Kw_expr (_, body_expr)) -> + let clauses = self#for_clauses env args in + let body_expr = self#visit_expr env body_expr in + let fake = Tok.unsafe_fake_tok "" in + S (For (tfor, clauses, (fake, [ body_expr ], fake))) + | _ -> + let x = self#visit_call env x in + Call x) | _else_ -> let x = self#visit_call env x in Call x diff --git a/languages/elixir/generic/Elixir_to_generic.ml b/languages/elixir/generic/Elixir_to_generic.ml index 6679999b06..e4684bbb8b 100644 --- a/languages/elixir/generic/Elixir_to_generic.ml +++ b/languages/elixir/generic/Elixir_to_generic.ml @@ -428,6 +428,25 @@ and map_stmt env (v : stmt) : G.stmt = | Throw (tthrow, e) -> let e = map_expr env e in G.Throw (tthrow, e, G.sc) |> G.s + | For (tfor, clauses, (tdo, body, tend)) -> + let comp_clauses = List_.map (fun (clause : for_clause) -> + match clause with + | ForGenerator (pat, tarrow, collection) -> + let pat = map_expr env pat |> H.expr_to_pattern in + let collection = map_expr env collection in + G.CompFor (tfor, pat, tarrow, collection) + | ForFilter e -> + let e = map_expr env e in + G.CompIf (G.fake "if", e) + ) clauses in + let body_stmts = map_stmts env body in + let body_expr = + match body_stmts with + | [ { G.s = G.ExprStmt (e, _sc); _ } ] -> e + | _ -> G.stmt_to_expr (G.Block (tdo, body_stmts, tend) |> G.s) + in + let comp = G.Comprehension (G.List, (tdo, (body_expr, comp_clauses), tend)) in + G.ExprStmt (comp |> G.e, G.sc) |> G.s | Try (ttry, (tdo, (boc, extras), tend)) -> let body_stmts = match boc with diff --git a/tests/tainting_rules/elixir/taint-for-comprehension.ex b/tests/tainting_rules/elixir/taint-for-comprehension.ex new file mode 100644 index 0000000000..5b7501ae58 --- /dev/null +++ b/tests/tainting_rules/elixir/taint-for-comprehension.ex @@ -0,0 +1,32 @@ +def basic(data) do + for x <- data do + # ruleid: for_comp_taint + sink(x) + end +end + +def with_pipe(data) do + for line <- data do + line + |> Base.decode64!() + # todoruleid: for_comp_taint + |> sink() + end +end + +def multiple_generators(xs, ys) do + for x <- xs, y <- ys do + # todoruleid: for_comp_taint + sink(x) + # todoruleid: for_comp_taint + sink(y) + end +end + +def no_taint(data) do + other = "safe" + for x <- data do + # ok: for_comp_taint + sink(other) + end +end diff --git a/tests/tainting_rules/elixir/taint-for-comprehension.yaml b/tests/tainting_rules/elixir/taint-for-comprehension.yaml new file mode 100644 index 0000000000..22ed6eb521 --- /dev/null +++ b/tests/tainting_rules/elixir/taint-for-comprehension.yaml @@ -0,0 +1,17 @@ +rules: +- id: for_comp_taint + languages: [elixir] + message: "tainted data reached sink via for comprehension" + severity: ERROR + mode: taint + pattern-sources: + - patterns: + - pattern-either: + - pattern-inside: | + def $_(..., $X, ...) do + ... + end + - focus-metavariable: $X + pattern-sinks: + - pattern: | + sink(...) From 6b653ddd921703d9a4356f43b84be21d504385b1 Mon Sep 17 00:00:00 2001 From: Dimitris Mostrous Date: Wed, 8 Apr 2026 15:29:08 +0100 Subject: [PATCH 31/37] elixir: desugar pipe operator |> for taint propagation The pipe operator `x |> f(a)` was encoded as `Call("|>", [x; f(a)])`, an opaque function call. Taint on `x` could not flow to `f`. Desugar in Elixir_to_generic: `x |> f(a)` becomes `f(x, a)`, wrapped in `OtherExpr("PipelineCall", [E call])` to preserve the distinction for search patterns. In AST_to_IL, handle PipelineCall transparently by evaluating the inner expression, so taint flows through. The desugaring is compositional: for `x |> f() |> g()`, the inner pipe produces the desugared `f(x)` which becomes an argument in the outer call `g(f(x))`, each step independently. Also upgrades the pipe-in-comprehension test case from todoruleid to ruleid now that both features work together. --- languages/elixir/generic/Elixir_to_generic.ml | 19 ++++++++++++++++ src/analyzing/AST_to_IL.ml | 6 +++++ .../elixir/taint-for-comprehension.ex | 2 +- tests/tainting_rules/elixir/taint-pipe.ex | 22 +++++++++++++++++++ tests/tainting_rules/elixir/taint-pipe.yaml | 17 ++++++++++++++ 5 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 tests/tainting_rules/elixir/taint-pipe.ex create mode 100644 tests/tainting_rules/elixir/taint-pipe.yaml diff --git a/languages/elixir/generic/Elixir_to_generic.ml b/languages/elixir/generic/Elixir_to_generic.ml index e4684bbb8b..9be473fe15 100644 --- a/languages/elixir/generic/Elixir_to_generic.ml +++ b/languages/elixir/generic/Elixir_to_generic.ml @@ -727,6 +727,24 @@ and map_binary_op env v1 v2 v3 = let n = G.N (H.name_of_id id) |> G.e in G.Call (n, fb ([ e1; e2 ] |> List_.map G.arg)) |> G.e +(* Desugar pipe: x |> f(a, b) becomes f(x, a, b), tagged with + * OtherExpr("PipelineCall", ...) so search patterns can distinguish + * piped calls from direct calls. *) +and map_pipeline env (v1 : expr) (tk : tok) (v3 : expr) : G.expr = + let desugared = + match v3 with + | Call (fn, (l, (args, kwds), r), blk) -> + (* Insert v1 as first argument at the Elixir AST level; + * map_call will call map_expr on v1, handling nested pipes. *) + map_call env (fn, (l, (v1 :: args, kwds), r), blk) + | _ -> + (* Bare function reference: x |> f becomes f(x) *) + let e1 = map_expr env v1 in + let e2 = map_expr env v3 in + G.Call (e2, fb [ G.Arg e1 ]) |> G.e + in + G.OtherExpr (("PipelineCall", tk), [ G.E desugared ]) |> G.e + and map_match env v1 v2 = (* Single LHS names are VarDefs. * Otherwise, including ellipsis, we consider them LetPatterns. @@ -845,6 +863,7 @@ and map_expr env v : G.expr = | BinaryOp (v1, v2, v3) -> ( match v2 with | OMatch, _tk -> map_match env v1 v3 + | OPipeline, tk -> map_pipeline env v1 tk v3 | _else_ -> map_binary_op env v1 v2 v3) | OpArity (v1, tslash, pi) -> let id = map_wrap_operator_ident env v1 in diff --git a/src/analyzing/AST_to_IL.ml b/src/analyzing/AST_to_IL.ml index a9b3a15ef9..acd509bbf4 100644 --- a/src/analyzing/AST_to_IL.ml +++ b/src/analyzing/AST_to_IL.ml @@ -1090,6 +1090,12 @@ and expr_aux env ?(void = false) g_expr : stmts * exp = | G.OtherExpr (("ShortLambda", _), _) when env.lang =*= Lang.Elixir -> let lambda_expr = AST_modifications.convert_elixir_short_lambda g_expr in expr env lambda_expr + (* Elixir pipe: OtherExpr("PipelineCall", [E call]) is a desugared + * x |> f(a) => f(x, a). The tag preserves search distinction; for + * IL/taint we evaluate the inner call transparently. *) + | G.OtherExpr (("PipelineCall", _tk), [ G.E inner ]) + when env.lang =*= Lang.Elixir -> + expr env inner (* The idea here is that this is like a block, and we only * really care about the last expression. *) (* TODO: What if a statement creeps in? E.g. an If, `fn`..? diff --git a/tests/tainting_rules/elixir/taint-for-comprehension.ex b/tests/tainting_rules/elixir/taint-for-comprehension.ex index 5b7501ae58..940615a745 100644 --- a/tests/tainting_rules/elixir/taint-for-comprehension.ex +++ b/tests/tainting_rules/elixir/taint-for-comprehension.ex @@ -7,9 +7,9 @@ end def with_pipe(data) do for line <- data do + # ruleid: for_comp_taint line |> Base.decode64!() - # todoruleid: for_comp_taint |> sink() end end diff --git a/tests/tainting_rules/elixir/taint-pipe.ex b/tests/tainting_rules/elixir/taint-pipe.ex new file mode 100644 index 0000000000..17587444d3 --- /dev/null +++ b/tests/tainting_rules/elixir/taint-pipe.ex @@ -0,0 +1,22 @@ +def single_pipe(data) do + # ruleid: pipe_taint + data |> sink() +end + +def chain(data) do + # ruleid: pipe_taint + data + |> decode() + |> sink() +end + +def pipe_with_args(data) do + # ruleid: pipe_taint + data |> sink(:option) +end + +def no_taint(data) do + other = "safe" + # ok: pipe_taint + other |> sink() +end diff --git a/tests/tainting_rules/elixir/taint-pipe.yaml b/tests/tainting_rules/elixir/taint-pipe.yaml new file mode 100644 index 0000000000..f40782d2ff --- /dev/null +++ b/tests/tainting_rules/elixir/taint-pipe.yaml @@ -0,0 +1,17 @@ +rules: +- id: pipe_taint + languages: [elixir] + message: "tainted data reached sink via pipe" + severity: ERROR + mode: taint + pattern-sources: + - patterns: + - pattern-either: + - pattern-inside: | + def $_(..., $X, ...) do + ... + end + - focus-metavariable: $X + pattern-sinks: + - pattern: | + sink(...) From 39701155b56e1d818fdc1fbc8fcce4a27dc5d591 Mon Sep 17 00:00:00 2001 From: Dimitris Mostrous Date: Wed, 8 Apr 2026 16:06:49 +0100 Subject: [PATCH 32/37] il: support multiple generators and filters in comprehensions The Comprehension handler in AST_to_IL only supported a single CompFor clause; multiple generators fell through to a todo fixme. Refactor into three functions mirroring the MultiForEach pattern: - comprehension_loop: builds one foreach loop around an inner body - comprehension_clauses: recursively nests loops (CompFor) and guards (CompIf) from the clause list - comprehension: creates the accumulator, builds the innermost append body, and delegates nesting to comprehension_clauses This is language-agnostic and benefits all languages that emit Comprehension nodes (Python, Elixir, etc). Adds Python multi-generator and filter comprehension taint tests. Upgrades the Elixir multi-generator test from todoruleid to ruleid. --- src/analyzing/AST_to_IL.ml | 62 +++++++++++++------ .../elixir/taint-for-comprehension.ex | 4 +- tests/tainting_rules/python/comprehension.py | 45 ++++++++++++++ 3 files changed, 89 insertions(+), 22 deletions(-) diff --git a/src/analyzing/AST_to_IL.ml b/src/analyzing/AST_to_IL.ml index acd509bbf4..31762f98e7 100644 --- a/src/analyzing/AST_to_IL.ml +++ b/src/analyzing/AST_to_IL.ml @@ -980,9 +980,8 @@ and expr_aux env ?(void = false) g_expr : stmts * exp = let xs = List.map snd results in let kind = composite_kind ~g_expr kind in (ss, mk_e (Composite (kind, (l, xs, r))) eorig) - | G.Comprehension (_op, (_l, (er, [CompFor (tok_for, pat, tok_in, e)]), _r)) -> - comprehension_for env er tok_for pat tok_in e - | G.Comprehension _ -> todo (G.E g_expr) + | G.Comprehension (_op, (_l, (er, clauses), _r)) -> + comprehension env er clauses | G.Lambda fdef -> let lval = fresh_lval (snd fdef.fkind) in let final_fdef = @@ -1606,9 +1605,10 @@ and xml_expr env ~void eorig xml : stmts * exp = (CTuple, (tok, List.rev_append filtered_attrs filtered_body, tok))) (Related (G.Xmls xml.G.xml_body)) ) -(* Adjusted from for_each *) -and comprehension_for env result_expr tok_for pat tok_in collection_expr : stmts * exp = - let tmp = fresh_lval ~str:"_comprehension_tmp" tok_in in +(* Build a single foreach loop around [inner_body] IL stmts. + * Adjusted from for_each; used by [comprehension] below. *) +and comprehension_loop env tok_for (pat : G.pattern) (tok_in : tok) + (collection_expr : G.expr) (inner_body : stmts) : stmts = let cont_label_s, break_label_s, env = break_continue_labels env tok_for in let ss, e' = expr env collection_expr in let next_lval = fresh_lval tok_in in @@ -1633,22 +1633,44 @@ and comprehension_for env result_expr tok_for pat tok_in collection_expr : stmts (mk_e (Fetch next_lval) (related_tok tok_in)) ~eorig:(related_tok tok_in) pat in + let cond = mk_e (Fetch hasnext_lval) (related_tok tok_in) in + ss @ + [ hasnext_call; + mk_s + (Loop + ( tok_in, + cond, + next_call :: assign_st @ inner_body @ cont_label_s + @ [ hasnext_call ] )); + ] + @ break_label_s + +(* Recursively build nested loops/guards from comprehension clauses, + * wrapping [inner_body] at the innermost level. + * Mirrors the MultiForEach nesting in [stmt]. *) +and comprehension_clauses env (clauses : G.for_or_if_comp list) + (inner_body : stmts) : stmts = + match clauses with + | [] -> inner_body + | G.CompFor (tok_for, pat, tok_in, collection_expr) :: rest -> + let body = comprehension_clauses env rest inner_body in + comprehension_loop env tok_for pat tok_in collection_expr body + | G.CompIf (tok_if, guard_expr) :: rest -> + let body = comprehension_clauses env rest inner_body in + let ss, e' = expr env guard_expr in + ss @ [ mk_s (If (tok_if, e', body, [])) ] + +(* Compile a comprehension: create an accumulator, build nested + * loops from the clause list, append each result to the accumulator. *) +and comprehension env (result_expr : G.expr) + (clauses : G.for_or_if_comp list) : stmts * exp = + let tok = G.fake "comprehension" in + let tmp = fresh_lval ~str:"_comprehension_tmp" tok in let ss_res, e_eres = expr env ~void:false result_expr in let e_plus = mk_e (Operator ((G.Plus, Tok.unsafe_fake_tok "+="), [Unnamed e_eres])) NoOrig in - let st = mk_s (Instr (mk_i (Assign (tmp, e_plus)) NoOrig)) in - let cond = mk_e (Fetch hasnext_lval) (related_tok tok_in) in - let loop_stmts = - ss @ - [ hasnext_call; - mk_s - (Loop - ( tok_in, - cond, - next_call :: assign_st @ ss_res @ [st] @ cont_label_s - @ [ hasnext_call ] )); - ] - @ break_label_s - in + let append_st = mk_s (Instr (mk_i (Assign (tmp, e_plus)) NoOrig)) in + let inner_body = ss_res @ [ append_st ] in + let loop_stmts = comprehension_clauses env clauses inner_body in (loop_stmts, mk_e (Fetch tmp) NoOrig) and stmt_expr env ?g_expr st : stmts * exp = diff --git a/tests/tainting_rules/elixir/taint-for-comprehension.ex b/tests/tainting_rules/elixir/taint-for-comprehension.ex index 940615a745..5f4851cef8 100644 --- a/tests/tainting_rules/elixir/taint-for-comprehension.ex +++ b/tests/tainting_rules/elixir/taint-for-comprehension.ex @@ -16,9 +16,9 @@ end def multiple_generators(xs, ys) do for x <- xs, y <- ys do - # todoruleid: for_comp_taint + # ruleid: for_comp_taint sink(x) - # todoruleid: for_comp_taint + # ruleid: for_comp_taint sink(y) end end diff --git a/tests/tainting_rules/python/comprehension.py b/tests/tainting_rules/python/comprehension.py index a707dccc7b..291dc70027 100644 --- a/tests/tainting_rules/python/comprehension.py +++ b/tests/tainting_rules/python/comprehension.py @@ -47,3 +47,48 @@ sanitized_col = [sanitize(x) for x in s] # ok: taint sink(sanitized_col) + + +## multiple generators: taint flows from each collection + +multi = [x + y for x in s for y in other] +# ruleid: taint +sink(multi) + +multi2 = [x + y for x in safe for y in s] +# ruleid: taint +sink(multi2) + + +## multiple generators: taint flows through iteration variable + +# ruleid: taint +res_multi = [sink(x) for x in s for y in other] +do_sth(res_multi) + + +## comprehension with filter: taint flows through + +filtered = [x for x in s if x > 0] +# ruleid: taint +sink(filtered) + + +## multiple generators: taint propagated to result's elements + +res_multi2 = [x + y for x in s for y in other] +for item in res_multi2: + # ruleid: taint + sink(item) + +res_multi3 = [x + y for x in safe for y in s] +for item in res_multi3: + # ruleid: taint + sink(item) + + +## multiple generators: no taint from clean collections + +clean_multi = [x + y for x in safe1 for y in safe2] +# ok: taint +sink(clean_multi) From b6fface1c796bc20a7fa528933d9f904cc6a255a Mon Sep 17 00:00:00 2001 From: Dimitris Mostrous Date: Wed, 8 Apr 2026 19:56:59 +0100 Subject: [PATCH 33/37] ruby: remove redundant Call wrapping in expr_as_stmt Superseded by Disambiguate_ruby_calls which wraps bare identifiers post-naming, correctly skipping resolved locals and parameters. --- languages/ruby/generic/ruby_to_generic.ml | 19 ++++--------------- tests/patterns/ruby/misc_hidden_call.rb | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/languages/ruby/generic/ruby_to_generic.ml b/languages/ruby/generic/ruby_to_generic.ml index f2c9a4182f..a3f7a0083d 100644 --- a/languages/ruby/generic/ruby_to_generic.ml +++ b/languages/ruby/generic/ruby_to_generic.ml @@ -569,21 +569,10 @@ and expr_as_stmt = function | D x -> definition x | e -> ( let e = expr e in - match e.G.e with - (* targets only: a single name on its own line is probably an hidden fun call, - * unless it's a metavariable - *) - | G.N (G.Id ((s, _), _)) -> - if AST_generic.is_metavar_name s || (Domain.DLS.get Flag_parsing.sgrep_mode) then - G.exprstmt e - else - let call = G.Call (e, fb []) |> G.e in - G.exprstmt call - | _ -> ( - match expr_special_cases e with - | G.S s -> s - | G.E e -> G.exprstmt e - | _ -> raise Impossible)) + match expr_special_cases e with + | G.S s -> s + | G.E e -> G.exprstmt e + | _ -> raise Impossible) and stmt st = match st with diff --git a/tests/patterns/ruby/misc_hidden_call.rb b/tests/patterns/ruby/misc_hidden_call.rb index c910a02c9d..6c2afeef2d 100644 --- a/tests/patterns/ruby/misc_hidden_call.rb +++ b/tests/patterns/ruby/misc_hidden_call.rb @@ -1,12 +1,21 @@ #ERROR: match foo(1,2,3) -# an identifier on its own as a statement is probably an hidden funcall -# which is why ruby_to_generic.ml convert that in a Call +# A bare identifier with no prior assignment is a method call in Ruby. +# Disambiguate_ruby_calls wraps it in Call() after naming. #ERROR: match foo a = bar -# In Ruby, a bare identifier with no prior assignment is a method call. #ERROR: match b = foo + +# A bare identifier WITH a prior assignment is a local variable, +# not a method call. It should NOT match foo(...). +foo = 42 +c = foo + +# A parameter is also not a method call. +def use_param(foo) + d = foo +end From 04e36bf30c278b10cdfc66f25891431a1cd64159 Mon Sep 17 00:00:00 2001 From: corneliuhoffman Date: Wed, 8 Apr 2026 15:55:13 +0100 Subject: [PATCH 34/37] added test, modified the ast ruby, parse ruby and ruby to generic to add array access and, in the case of ruby added a match of a call and a Name --- languages/ruby/ast/ast_ruby.ml | 2 +- languages/ruby/generic/ruby_to_generic.ml | 6 +++++ .../tree-sitter/Parse_ruby_tree_sitter.ml | 12 ++++------ src/matching/Generic_vs_generic.ml | 9 +++++++ tests/patterns/ruby/array_access.rb | 10 ++++++++ tests/patterns/ruby/array_access.sgrep | 1 + tests/patterns/ruby/array_access_call.rb | 13 ++++++++++ tests/patterns/ruby/array_access_call.sgrep | 1 + .../test_ruby_array_access.rb | 24 +++++++++++++++++++ .../test_ruby_array_access.yaml | 13 ++++++++++ 10 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 tests/patterns/ruby/array_access.rb create mode 100644 tests/patterns/ruby/array_access.sgrep create mode 100644 tests/patterns/ruby/array_access_call.rb create mode 100644 tests/patterns/ruby/array_access_call.sgrep create mode 100644 tests/rules/cross_function_tainting/test_ruby_array_access.rb create mode 100644 tests/rules/cross_function_tainting/test_ruby_array_access.yaml diff --git a/languages/ruby/ast/ast_ruby.ml b/languages/ruby/ast/ast_ruby.ml index 3b3187192f..8463462836 100644 --- a/languages/ruby/ast/ast_ruby.ml +++ b/languages/ruby/ast/ast_ruby.ml @@ -213,7 +213,7 @@ type expr = | Ternary of expr * tok (* ? *) * expr * tok (* : *) * expr (* the brackets can be fake when the call is a "Command" *) | Call of expr * arguments bracket * expr option - (* TODO: ArrayAccess of expr * expr list bracket *) + | ArrayAccess of expr * arguments bracket (* old: was Binop(e1, Op_DOT, e2) before *) | DotAccess of expr * tok (* . or &. *) * method_name | DotAccessEllipsis of expr * tok (* '...' *) diff --git a/languages/ruby/generic/ruby_to_generic.ml b/languages/ruby/generic/ruby_to_generic.ml index a3f7a0083d..1c8264d698 100644 --- a/languages/ruby/generic/ruby_to_generic.ml +++ b/languages/ruby/generic/ruby_to_generic.ml @@ -126,6 +126,12 @@ let rec expr e = * and `f($X)` will match `f(x)`. *) let barg = b |> expr |> G.arg in G.Call (G.e e_call, (lb, [ barg ], rb))) + | ArrayAccess (e, (lb, args, rb)) -> + let e = expr e in + let args = list argument args in + let exprs = List.filter_map (function G.Arg e -> Some e | _ -> None) args in + let index = G.Container (G.Array, (lb, exprs, rb)) |> G.e in + G.ArrayAccess (e, (lb, index, rb)) | DotAccess (e, t, m) -> ( let e = expr e in match m with diff --git a/languages/ruby/tree-sitter/Parse_ruby_tree_sitter.ml b/languages/ruby/tree-sitter/Parse_ruby_tree_sitter.ml index 0e2301b2a5..7a40bb20ed 100644 --- a/languages/ruby/tree-sitter/Parse_ruby_tree_sitter.ml +++ b/languages/ruby/tree-sitter/Parse_ruby_tree_sitter.ml @@ -2254,13 +2254,11 @@ and lhs (env : env) (x : CST.lhs) : AST.expr = | None -> [] in let v4 = token2 env v4 in - let e = - (* THINK: Why do we need a DotAccess here rather than just `v1' ? - * And why a Call rather than an ArrayAccess ? - *) - DotAccess (v1, v2, MethodOperator (Op_AREF, v2)) - in - Call (e, (v2, v3, v4), None) + (* Ruby `obj[key]` is element/hash access. Use ArrayAccess so + the receiver is a plain sub-expression that + Disambiguate_ruby_calls can wrap in Call() when it is an + unresolved bare identifier (zero-arg method call). *) + ArrayAccess (v1, (v2, v3, v4)) | `Call_ x -> ( let e = call_ env x in match e with diff --git a/src/matching/Generic_vs_generic.ml b/src/matching/Generic_vs_generic.ml index b57800d89b..37e006cfb8 100644 --- a/src/matching/Generic_vs_generic.ml +++ b/src/matching/Generic_vs_generic.ml @@ -952,6 +952,15 @@ and m_expr ?(is_root = false) ?(arguments_have_changed = true) a b = | G.N (G.Id _ as na), B.N (B.Id _ as nb) -> m_name na nb >||> m_with_symbolic_propagation ~is_root (fun b1 -> m_expr a b1) b + (* Ruby equivalence: bare identifier `foo` matches zero-arg call `foo()`. + After disambiguation, target bare identifiers become Call(N(Id(x)), []) + but patterns keep the bare N(Id(x)) form. Exclude metavars so they + fall through to the general metavar case below. *) + | G.N (G.Id ((str, _), _) as na), B.Call ({ e = B.N (B.Id _ as nb); _ }, (_, [], _)) + when not (Mvar.is_metavar_name str) -> + with_lang (fun lang -> + if lang =*= Lang.Ruby then m_name na nb + else fail ()) | G.N (G.Id ((str, tok), _id_info)), _b when Mvar.is_metavar_name str -> envf (str, tok) (MV.E b) (* metavar: typed! *) diff --git a/tests/patterns/ruby/array_access.rb b/tests/patterns/ruby/array_access.rb new file mode 100644 index 0000000000..fa46cb9760 --- /dev/null +++ b/tests/patterns/ruby/array_access.rb @@ -0,0 +1,10 @@ +# params[...] should match hash access on bare identifiers (method calls) +#ERROR: match +params[:id] + +#ERROR: match +sink(params[:to]) + +# Also match explicit call form +#ERROR: match +params()[:name] diff --git a/tests/patterns/ruby/array_access.sgrep b/tests/patterns/ruby/array_access.sgrep new file mode 100644 index 0000000000..7d5fca4bb9 --- /dev/null +++ b/tests/patterns/ruby/array_access.sgrep @@ -0,0 +1 @@ +params[...] \ No newline at end of file diff --git a/tests/patterns/ruby/array_access_call.rb b/tests/patterns/ruby/array_access_call.rb new file mode 100644 index 0000000000..d83583c205 --- /dev/null +++ b/tests/patterns/ruby/array_access_call.rb @@ -0,0 +1,13 @@ +# params()[...] should match hash access on method calls +#ERROR: match +params[:id] + +#ERROR: match +sink(params[:to]) + +#ERROR: match +params()[:name] + +# Local variable should NOT match params()[...] +params = { id: 1 } +x = params[:id] diff --git a/tests/patterns/ruby/array_access_call.sgrep b/tests/patterns/ruby/array_access_call.sgrep new file mode 100644 index 0000000000..96858a27d4 --- /dev/null +++ b/tests/patterns/ruby/array_access_call.sgrep @@ -0,0 +1 @@ +params()[...] \ No newline at end of file diff --git a/tests/rules/cross_function_tainting/test_ruby_array_access.rb b/tests/rules/cross_function_tainting/test_ruby_array_access.rb new file mode 100644 index 0000000000..7ecc4b820f --- /dev/null +++ b/tests/rules/cross_function_tainting/test_ruby_array_access.rb @@ -0,0 +1,24 @@ +# Test that hash/array access `obj[:key]` propagates taint correctly. +# `obj[:key]` was misparsed as Call(DotAccess(obj, Op_AREF), [:key]) +# instead of ArrayAccess, which broke taint propagation because +# a Call to `[]` has no signature, while ArrayAccess preserves taint. + +class TestController + def show + if continue_params[:to] + # ruleid: test-ruby-array-access + sink(continue_params[:to]) + end + end + + def continue_params + source() + end +end + +def source() + "tainted" +end + +def sink(x) +end diff --git a/tests/rules/cross_function_tainting/test_ruby_array_access.yaml b/tests/rules/cross_function_tainting/test_ruby_array_access.yaml new file mode 100644 index 0000000000..c6b20eaccc --- /dev/null +++ b/tests/rules/cross_function_tainting/test_ruby_array_access.yaml @@ -0,0 +1,13 @@ +rules: + - id: test-ruby-array-access + message: Test hash access does not break taint + languages: + - ruby + severity: WARNING + mode: taint + options: + taint_intrafile: true + pattern-sources: + - pattern: source(...) + pattern-sinks: + - pattern: sink(...) From 07aad00a17eb1dbec7d65779e0ab36e60cd46283 Mon Sep 17 00:00:00 2001 From: Dimitris Mostrous Date: Thu, 9 Apr 2026 18:47:07 +0100 Subject: [PATCH 35/37] bump version --- cli/setup.py | 2 +- cli/src/semgrep/__init__.py | 2 +- setup.py | 2 +- src/core/Version.ml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/setup.py b/cli/setup.py index f91086b0cf..c298c33d32 100644 --- a/cli/setup.py +++ b/cli/setup.py @@ -124,7 +124,7 @@ def find_executable(env_name, exec_name): setuptools.setup( name="opengrep", - version="1.18.0", + version="1.19.0", author="Semgrep Inc., Opengrep", author_email="support@opengrep.dev", description="Lightweight static analysis for many languages. Find bug variants with patterns that look like source code.", diff --git a/cli/src/semgrep/__init__.py b/cli/src/semgrep/__init__.py index 8e821d5067..35bfc71918 100644 --- a/cli/src/semgrep/__init__.py +++ b/cli/src/semgrep/__init__.py @@ -1,2 +1,2 @@ -__VERSION__ = "1.18.0" +__VERSION__ = "1.19.0" __SEMGREP_VERSION__ = "1.100.0" diff --git a/setup.py b/setup.py index 2b10ab71cc..689e0e114a 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name="semgrep_pre_commit_package", - version="1.18.0", + version="1.19.0", # install_requires=["opengrep==1.2.0"], # Commented out since we're not on pypi. packages=[], ) diff --git a/src/core/Version.ml b/src/core/Version.ml index 2634edbac7..059760d159 100644 --- a/src/core/Version.ml +++ b/src/core/Version.ml @@ -3,5 +3,5 @@ Automatically modified by scripts/release/bump. *) -let version = "1.18.0" +let version = "1.19.0" let version_semgrep = "1.100.0" From debe11c7f15dabc22988fd99484ff28016722125 Mon Sep 17 00:00:00 2001 From: Dimitris Mostrous Date: Thu, 9 Apr 2026 18:48:45 +0100 Subject: [PATCH 36/37] add changelog --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7f4f45f00..ff13b99863 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [1.19.0](https://github.com/opengrep/opengrep/releases/tag/v1.19.0) - 09-04-2026 + +### Improvements + +* Elixir: fix taint propagation through for comprehensions and pipes by @dimitris-m in #650 +* Ruby: remove redundant Call wrapping in `expr_as_stmt` by @dimitris-m in #651 +* Fix `obj[key]` parsing in ruby by @corneliuhoffman in #649 + +**Full Changelog**: https://github.com/opengrep/opengrep/compare/v1.18.0...v1.19.0 + + ## [1.18.0](https://github.com/opengrep/opengrep/releases/tag/v1.18.0) - 07-04-2026 ### Improvements From 6e303548117fda83d6e7f6df803546688ebe7632 Mon Sep 17 00:00:00 2001 From: Michael Fowlie Date: Fri, 10 Apr 2026 19:32:38 +1000 Subject: [PATCH 37/37] Fix taint analysis regressions after upstream merge --- src/tainting/Dataflow_tainting.ml | 97 ++++++++++++++++++++-- src/tainting/Graph_from_AST.ml | 19 ++++- tests/tainting/java_collection_models.java | 48 +++++++++++ 3 files changed, 157 insertions(+), 7 deletions(-) diff --git a/src/tainting/Dataflow_tainting.ml b/src/tainting/Dataflow_tainting.ml index c2d2322214..b7b9e08b66 100644 --- a/src/tainting/Dataflow_tainting.ml +++ b/src/tainting/Dataflow_tainting.ml @@ -429,8 +429,22 @@ let type_of_lval env lval = Typing.resolved_type_of_id_info env.taint_inst.lang fld.id_info | __else__ -> Type.NoType +let java_type_of_expr_is_unreliable_for_deep_chain (eorig : G.expr) = + match eorig.G.e with + | G.Call + ( { G.e = G.DotAccess ({ G.e = (G.Call _ | G.DotAccess _); _ }, _, _); _ }, + _ ) -> + true + | G.DotAccess ({ G.e = (G.Call _ | G.DotAccess _); _ }, _, _) -> + true + | _ -> false + let type_of_expr env e = match e.eorig with + | SameAs eorig + when env.taint_inst.lang =*= Lang.Java + && java_type_of_expr_is_unreliable_for_deep_chain eorig -> + Type.NoType | SameAs eorig -> Typing.type_of_expr env.taint_inst.lang eorig |> fst | __else__ -> Type.NoType @@ -641,13 +655,23 @@ let get_signature_for_object graph caller_node db method_name ~call_tok arity = let caller = Option.map Function_id.of_il_name caller_node in let method_tok = Function_id.tok method_name in let lookup tok = Call_graph.lookup_callee_from_graph graph caller tok in - match lookup call_tok with + let lookup_signature_for_tok tok = + match lookup tok with + | Some callee_node -> + Shape_and_sig.(lookup_signature db callee_node arity) + | None -> None + in + let same_tok = Tok.equal call_tok method_tok in + match lookup_signature_for_tok method_tok with | Some callee_node -> - Shape_and_sig.(lookup_signature db callee_node arity) + Some callee_node | None -> ( - match lookup method_tok with + match + if same_tok || Tok.is_fake call_tok then None + else lookup_signature_for_tok call_tok + with | Some callee_node -> - Shape_and_sig.(lookup_signature db callee_node arity) + Some callee_node | None -> Shape_and_sig.lookup_signature db method_name arity) (* Helper to fallback to builtin signature database if regular lookup fails *) @@ -869,6 +893,9 @@ let receiver_lval_for_constructor_call env lval_opt fun_exp = Some (call_tok_of_fun_exp ~default_tok:(snd name.ident) fun_exp) | Fetch { base = VarSpecial ((Self | This), self_tok); rev_offset = _ } -> Some (call_tok_of_fun_exp ~default_tok:self_tok fun_exp) + | Fetch { base = Var obj; rev_offset = { o = Dot method_name; _ } :: _ } + when looks_like_constructor_name (fst method_name.ident) -> + Some (snd obj.ident) | Fetch { base = Var obj; rev_offset = _ } -> Some (call_tok_of_fun_exp ~default_tok:(snd obj.ident) fun_exp) | _ -> None @@ -892,6 +919,9 @@ let receiver_lval_for_constructor_call env lval_opt fun_exp = let callee_name = fst name.ident in looks_like_constructor_name callee_name || bare_call_name_looks_like_constructor callee_name + | Fetch { base = Var obj; rev_offset = { o = Dot method_name; _ } :: _ } -> + Object_initialization.is_constructor env.taint_inst.lang + (fst method_name.ident) (Some (fst obj.ident)) | _ -> false in if resolved_constructor || syntactic_fallback then Some receiver_lval @@ -1393,10 +1423,17 @@ and check_tainted_lval_aux env (lval : IL.lval) : effects_of_tainted_sinks { env with lval_env } all_taints sinks in record_effects { env with lval_env } effects; + let sub_taints = + (* Preserve freshly-computed taint on the receiver sub-lvalue, not just + * taint already materialized in the environment. Chained method calls + * such as `obj.getItems(key).get(0)` rely on the intermediate receiver + * carrying taint into the next call within the same expression. *) + Taints.union sub_new_taints (Xtaint.to_taints sub_in_env) + in ( new_taints, lval_in_env, lval_shape, - `Sub (Xtaint.to_taints sub_in_env, sub_shape), + `Sub (sub_taints, sub_shape), lval_env ) and check_tainted_lval_base env base = @@ -1914,6 +1951,49 @@ let call_with_intrafile lval_opt e env args instr = let e_obj, e_taints, e_shape, lval_env = check_function_call_callee { env with lval_env } e in + let apply_java_getter_result_fallback call_taints shape = + let getter_mode_opt = + match e.e with + | Fetch { rev_offset = { o = Dot name; _ } :: _; _ } -> + let method_name = fst name.ident in + if env.taint_inst.lang =*= Lang.Java + && String.length method_name > 3 + && String.starts_with ~prefix:"get" method_name + then + Some + (if List.length args > 0 then `Preserve_incoming else `Fallback_only) + else None + | _ -> None + in + match getter_mode_opt with + | None -> (call_taints, shape) + | Some getter_mode -> + let receiver_taints = + match e_obj with + | `Obj (obj_taints, _) -> obj_taints + | `Fun -> Taints.empty + in + let fallback_taints = receiver_taints |> Taints.union all_args_taints in + if Taints.is_empty fallback_taints then (call_taints, shape) + else + match getter_mode with + | `Preserve_incoming -> + (* Java accessor-style methods with parameters often behave like + * lookups/selectors. Even if intrafile signatures say "return a + * field", semgrep-rules expects us to conservatively preserve the + * tainted receiver/selector arguments through the result. *) + (Taints.union call_taints fallback_taints, shape) + | `Fallback_only -> + let no_precise_result = + Taints.is_empty call_taints + && + match shape with + | S.Bot -> true + | _ -> false + in + if not no_precise_result then (call_taints, shape) + else (Taints.union call_taints fallback_taints, shape) + in check_orig_if_sink { env with lval_env } instr.iorig all_args_taints Bot ~filter_sinks:(fun m -> not (m.spec.sink_exact && m.spec.sink_has_focus)); let call_taints, shape, lval_env = @@ -2204,6 +2284,10 @@ let call_with_intrafile lval_opt e env args instr = | None -> shape else shape in + let call_taints, shape = + if resolves_to_constructor then (call_taints, shape) + else apply_java_getter_result_fallback call_taints shape + in (call_taints, shape, lval_env) | None -> ( (* Regular function call processing *) @@ -2219,6 +2303,9 @@ let call_with_intrafile lval_opt e env args instr = (Display_IL.string_of_exp e) (T.show_taints call_taints) (S.show_shape shape)); + let call_taints, shape = + apply_java_getter_result_fallback call_taints shape + in (call_taints, shape, lval_env) | None -> ( Log.debug (fun m -> diff --git a/src/tainting/Graph_from_AST.ml b/src/tainting/Graph_from_AST.ml index 89c087e113..9389f7dd36 100644 --- a/src/tainting/Graph_from_AST.ml +++ b/src/tainting/Graph_from_AST.ml @@ -605,6 +605,14 @@ let dedup_fn_ids (ids : (fn_id * Tok.t) list) : (fn_id * Tok.t) list = let resolve_constructor_from_type ~(lang : Lang.t) ~all_funcs ?(imported_entity_index = CanonicalMap.empty) ?(current_file : Fpath.t option) (ty : G.type_) : fn_id option = + let is_constructor_fn_id fn_id = + match fn_id with + | [ Some cls; Some meth ] -> + let class_name = fst cls.IL.ident in + Object_initialization.is_constructor lang (fst meth.IL.ident) + (Some class_name) + | _ -> false + in let is_local_function func = match current_file with | Some file -> matches_current_file file func @@ -652,7 +660,11 @@ let resolve_constructor_from_type ~(lang : Lang.t) ~all_funcs match canonicals |> List.find_map (fun canonical -> - lookup_imported_entity ?current_file imported_entity_index canonical) + match lookup_imported_func ?current_file imported_entity_index canonical with + | Some func when is_constructor_fn_id func.fn_id -> Some func.fn_id + | Some _ + | None -> + None) with | Some _ as result -> result | None -> Option.bind class_name_opt lookup_local_constructor @@ -967,7 +979,10 @@ let identify_callee ~(lang : Lang.t) ?(object_mappings = []) ?(all_funcs = []) method_name_str) | None when Object_initialization.is_constructor lang - method_name_str (Some obj_name) -> + method_name_str (Some obj_name) + || ((not + (Object_initialization.uses_new_keyword lang)) + && String.equal method_name_str "new") -> let ty = G. { diff --git a/tests/tainting/java_collection_models.java b/tests/tainting/java_collection_models.java index 510c826981..95ba216a2e 100644 --- a/tests/tainting/java_collection_models.java +++ b/tests/tainting/java_collection_models.java @@ -3,7 +3,9 @@ import java.util.HashMap; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; public class CollectionModelsTest { @@ -51,7 +53,53 @@ public void testClean() { sink(value); } + // Test that getter-style lookups preserve taint through deep chains + public void testDeepGetterChain(String tainted, Foo foo) { + String value = foo.getBars(tainted).get(0).getX(); + // ruleid: java-collection-taint + sink(value); + } + + // Test that getter-style lookups preserve taint through chained fields + public void testDeepFieldChain(String tainted, SiteModel siteModel) { + Integer value = siteModel.getPrefixes(tainted).sites.ids.get(0); + // ruleid: java-collection-taint + sink(String.valueOf(value)); + } + private void sink(String data) { System.out.println(data); } } + +class Bar { + String x; + + String getX() { + return x; + } +} + +class Foo { + List bars; + + List getBars(String name) { + return bars; + } +} + +class SiteModel { + PrefixSiteIds prefixes; + + PrefixSiteIds getPrefixes(String name) { + return prefixes; + } +} + +class PrefixSiteIds { + SiteIds sites; +} + +class SiteIds { + Set ids = new HashSet<>(); +}