Repository: ansible/ansible-lint

Issue:
ansible-lint with ansible-core 2.19 can still call `lookup()`
##### Summary
While playing around with current `main`, I found some more ways to make ansible-lint call lookup plugins: add round brackets around `lookup`/`query`/`q`: `(lookup)('name.of.plugin', 'parameters')` is not caught by the regular expression.

I think this shows that the only way to properly handle this is to look at the AST.

##### Issue Type
- Bug Report



Discussion:
```
from jinja2 import Environment, meta, nodes

def find_lookup_calls(template_str):
    env = Environment()
    ast = env.parse(template_str)
    lookup_names = {'lookup', 'q', 'query'}

    found = []
    for node in ast.find_all(nodes.Call):
        func_name = getattr(node.node, 'name', None)
        if func_name in lookup_names:
            found.append({
                'name': func_name,
                'lineno': node.lineno,
                'args': [str(a) for a in node.args]
            })
    return found

# Example usage:
if __name__ == "__main__":
    templates = [
        "{{ lookup('file', '/etc/passwd') }}",
        "{{ q('env', 'HOME') }}",
        "{{ query('sequence', 'start=1 end=5') }}",
        "{{ (lookup)('pipe', 'date') }}",
        "{{ some_filter(lookup('env', 'USER')) }}",
        "{{ (q)('pipe', 'whoami') }}",
        "{% set fn = lookup %}{{ fn('file', '/etc/shadow') }}", # Indirect call, won't be detected
    ]
    for tpl in templates:
        print(f"Template: {tpl}")
        lookups = find_lookup_calls(tpl)
        if lookups:
            print("  Detected lookup calls:")
            for call in lookups:
                print(f"    Line {call['lineno']}: {call['name']} with args {call['args']}")
        else:
            print("  No direct lookup call detected.")
```

Can you think of any others?  It will never be bulletproof :)
That should be quite bullet-proof, compared to all previous solutions (except passing `disable_lookups=True` for pre-2.19).

Generally I don't think ansible-lint should ever evaluate templates, but only parse them. (Or are the values needed anywhere? I never really got a clear answer on that; see #4652.)



Patch:
diff --git a/src/ansiblelint/utils.py b/src/ansiblelint/utils.py
index a3da59513d..daf8ad64b3 100644
--- a/src/ansiblelint/utils.py
+++ b/src/ansiblelint/utils.py
@@ -63,16 +63,15 @@
 )
 from ansible.template import Templar
 from ansible.utils.collection_loader import AnsibleCollectionConfig
+from jinja2 import Environment, nodes
+from jinja2.exceptions import TemplateError, TemplateSyntaxError
 from packaging.version import Version
 from yaml.composer import Composer
 from yaml.parser import ParserError
 from yaml.representer import RepresenterError
 from yaml.scanner import ScannerError
 
-from ansiblelint._internal.rules import (
-    AnsibleParserErrorRule,
-    RuntimeErrorRule,
-)
+from ansiblelint._internal.rules import AnsibleParserErrorRule, RuntimeErrorRule
 from ansiblelint.app import App, get_app
 from ansiblelint.config import Options, get_deps_versions, options
 from ansiblelint.constants import (
@@ -169,6 +168,32 @@ def mock_filter(left: Any, *args: Any, **kwargs: Any) -> Any:  # noqa: ARG001
     return left
 
 
+def has_lookup_function_calls(varname: str) -> bool:
+    """Check if a template string contains lookup, query, or q function calls using AST parsing.
+
+    This function parses Jinja2 templates and looks for function calls to
+    'lookup', 'query', or 'q' by examining the AST).
+
+    :param varname: The template string to analyze
+    :return: True if lookup functions are found, False otherwise
+    """
+    lookup_names = {"lookup", "query", "q"}
+
+    try:
+        env = Environment(autoescape=True)
+        ast_tree = env.parse(varname)
+
+        for node in ast_tree.find_all(nodes.Call):
+            if isinstance(node.node, nodes.Name) and node.node.name in lookup_names:
+                return True
+    except (TemplateSyntaxError, TemplateError, AttributeError):
+        # Fallback to regex for edge cases where Jinja2 parsing fails
+        fallback_pattern = re.compile(r"\(?(lookup|query|q)\)?\s*\(")
+        return bool(fallback_pattern.search(varname))
+    else:
+        return False
+
+
 def ansible_template(
     basedir: Path,
     varname: Any,
@@ -200,13 +225,13 @@ def ansible_template(
     re_filter_fqcn = re.compile(r"\w+\.\w+\.\w+")
     re_filter_in_err = re.compile(r"Could not load \"(\w+)\"")
     re_valid_filter = re.compile(r"^\w+(\.\w+\.\w+)?$")
-    re_lookup_functions = re.compile(r"\b(lookup|query|q)\s*\(")
     templar = ansible_templar(basedir=basedir, templatevars=templatevars)
+    ansible_core_2_19 = Version("2.19")
+    deps = get_deps_versions()
 
     # Skip lookups for ansible-core >= 2.19; use disable_lookups for older versions
-    if re_lookup_functions.search(str(varname)):
-        deps = get_deps_versions()
-        if deps["ansible-core"] and deps["ansible-core"] >= Version("2.19"):
+    if has_lookup_function_calls(str(varname)):
+        if deps["ansible-core"] and deps["ansible-core"] >= ansible_core_2_19:
             return varname
         kwargs["disable_lookups"] = True
 


Test patch:
diff --git a/test/test_utils.py b/test/test_utils.py
index c16b6b921e..a721c2a7cc 100644
--- a/test/test_utils.py
+++ b/test/test_utils.py
@@ -300,7 +300,7 @@ def test_template(template: str, output: str) -> None:
             id="query_function_call",
         ),
         pytest.param(
-            "{{ q('file', 'config.txt') }}",
+            "{{ q('env', 'HOME') }}",
             True,
             id="q_function_call",
         ),
@@ -310,10 +310,48 @@ def test_template(template: str, output: str) -> None:
             True,
             id="query_function_with_whitespace",
         ),
+        pytest.param(
+            "{{ some_function(lookup('env', 'USER')) }}",
+            True,
+            id="nested_with_function",
+        ),
+        pytest.param(
+            "{{ (query)('env', 'HOME') }}",
+            True,
+            id="query_with_parentheses",
+        ),
+        pytest.param(
+            "{{ (q)('env', 'HOME') }}",
+            True,
+            id="q_with_parentheses",
+        ),
+        pytest.param(
+            "{{ 'This string contains lookup but not a call' }}",
+            False,
+            id="lookup_in_string",
+        ),
+        pytest.param(
+            "{{ query_result }}",
+            False,
+            id="query_variable_name",
+        ),
+        pytest.param(
+            "{{ my_dict.lookup }}",
+            False,
+            id="lookup_as_attribute",
+        ),
     ),
 )
 def test_template_lookup_behavior(template: str, has_lookup: bool) -> None:
     """Test template behavior for both ansible-core >= 2.19 and < 2.19."""
+    # Use the lookup detection function directly
+    detected_lookup = utils.has_lookup_function_calls(template)
+    assert detected_lookup == has_lookup, (
+        f"Expected has_lookup_function_calls({template!r}) to return {has_lookup}, "
+        f"but got {detected_lookup}"
+    )
+
+    # Then test template behavior
     result = utils.template(
         basedir=Path("/base/dir"),
         value=template,
@@ -331,11 +369,6 @@ def test_template_lookup_behavior(template: str, has_lookup: bool) -> None:
         assert result == template, (
             f"Expected lookup to be skipped for ansible-core >= 2.19, but got: {result}"
         )
-    elif not has_lookup:
-        # Normal templates should always be processed
-        assert result != template, (
-            f"Expected normal template to be processed, but got unchanged: {result}"
-        )
 
 
 def test_task_to_str_unicode() -> None: