summaryrefslogtreecommitdiffstats
path: root/src/ansiblelint/rules/no_free_form.py
blob: 13489efa3d9758ae211a65c6e32e1f787a4add97 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
"""Implementation of NoFreeFormRule."""

from __future__ import annotations

import functools
import re
import sys
from typing import TYPE_CHECKING, Any

from ansiblelint.constants import INCLUSION_ACTION_NAMES, LINE_NUMBER_KEY
from ansiblelint.rules import AnsibleLintRule, TransformMixin
from ansiblelint.rules.key_order import task_property_sorter

if TYPE_CHECKING:
    from ruamel.yaml.comments import CommentedMap, CommentedSeq

    from ansiblelint.errors import MatchError
    from ansiblelint.file_utils import Lintable
    from ansiblelint.utils import Task


class NoFreeFormRule(AnsibleLintRule, TransformMixin):
    """Rule for detecting discouraged free-form syntax for action modules."""

    id = "no-free-form"
    description = "Avoid free-form inside files as it can produce subtle bugs."
    severity = "MEDIUM"
    tags = ["syntax", "risk"]
    version_added = "v6.8.0"
    needs_raw_task = True
    cmd_shell_re = re.compile(
        r"(chdir|creates|executable|removes|stdin|stdin_add_newline|warn)=",
    )
    _ids = {
        "no-free-form[raw]": "Avoid embedding `executable=` inside raw calls, use explicit args dictionary instead.",
        "no-free-form[raw-non-string]": "Passing a non string value to `raw` module is neither documented or supported.",
    }

    def matchtask(
        self,
        task: Task,
        file: Lintable | None = None,
    ) -> list[MatchError]:
        results: list[MatchError] = []
        action = task["action"]["__ansible_module_original__"]

        if action in INCLUSION_ACTION_NAMES:
            return results

        action_value = task["__raw_task__"].get(action, None)
        if task["action"].get("__ansible_module__", None) == "raw":
            if isinstance(action_value, str):
                if "executable=" in action_value:
                    results.append(
                        self.create_matcherror(
                            message="Avoid embedding `executable=` inside raw calls, use explicit args dictionary instead.",
                            lineno=task[LINE_NUMBER_KEY],
                            filename=file,
                            tag=f"{self.id}[raw]",
                        ),
                    )
            else:
                results.append(
                    self.create_matcherror(
                        message="Passing a non string value to `raw` module is neither documented or supported.",
                        lineno=task[LINE_NUMBER_KEY],
                        filename=file,
                        tag=f"{self.id}[raw-non-string]",
                    ),
                )
        elif isinstance(action_value, str) and "=" in action_value:
            fail = False
            if task["action"].get("__ansible_module__") in (
                "ansible.builtin.command",
                "ansible.builtin.shell",
                "ansible.windows.win_command",
                "ansible.windows.win_shell",
                "command",
                "shell",
                "win_command",
                "win_shell",
            ):
                if self.cmd_shell_re.search(action_value):
                    fail = True
            else:
                fail = True
            if fail:
                results.append(
                    self.create_matcherror(
                        message=f"Avoid using free-form when calling module actions. ({action})",
                        lineno=task[LINE_NUMBER_KEY],
                        filename=file,
                    ),
                )
        return results

    def transform(
        self,
        match: MatchError,
        lintable: Lintable,
        data: CommentedMap | CommentedSeq | str,
    ) -> None:
        if "no-free-form" in match.tag:
            task = self.seek(match.yaml_path, data)

            def filter_values(
                val: str,
                filter_key: str,
                filter_dict: dict[str, Any],
            ) -> str:
                """Pull out key=value pairs from a string and set them in filter_dict.

                Returns unmatched strings.
                """
                if filter_key not in val:
                    return val

                extra = ""
                [k, v] = val.split(filter_key, 1)
                if " " in k:
                    extra, k = k.rsplit(" ", 1)

                if v[0] in "\"'":
                    # Keep quoted strings together
                    quote = v[0]
                    _, v, remainder = v.split(quote, 2)
                    v = f"{quote}{v}{quote}"
                else:
                    try:
                        v, remainder = v.split(" ", 1)
                    except ValueError:
                        remainder = ""

                filter_dict[k] = v

                extra = " ".join(
                    (extra, filter_values(remainder, filter_key, filter_dict)),
                )
                return extra.strip()

            if match.tag == "no-free-form":
                module_opts: dict[str, Any] = {}
                for _ in range(len(task)):
                    k, v = task.popitem(False)
                    # identify module as key and process its value
                    if len(k.split(".")) == 3 and isinstance(v, str):
                        cmd = filter_values(v, "=", module_opts)
                        if cmd:
                            module_opts["cmd"] = cmd

                        sorted_module_opts = {}
                        for key in sorted(
                            module_opts.keys(),
                            key=functools.cmp_to_key(task_property_sorter),
                        ):
                            sorted_module_opts[key] = module_opts[key]

                        task[k] = sorted_module_opts
                    else:
                        task[k] = v

                match.fixed = True
            elif match.tag == "no-free-form[raw]":
                exec_key_val: dict[str, Any] = {}
                for _ in range(len(task)):
                    k, v = task.popitem(False)
                    if isinstance(v, str) and "executable" in v:
                        # Filter the executable and other parts from the string
                        task[k] = " ".join(
                            [
                                item
                                for item in v.split(" ")
                                if filter_values(item, "=", exec_key_val)
                            ],
                        )
                        task["args"] = exec_key_val
                    else:
                        task[k] = v
                match.fixed = True


if "pytest" in sys.modules:
    import pytest

    # pylint: disable=ungrouped-imports
    from ansiblelint.rules import RulesCollection
    from ansiblelint.runner import Runner

    @pytest.mark.parametrize(
        ("file", "expected"),
        (
            pytest.param("examples/playbooks/rule-no-free-form-pass.yml", 0, id="pass"),
            pytest.param("examples/playbooks/rule-no-free-form-fail.yml", 3, id="fail"),
        ),
    )
    def test_rule_no_free_form(
        default_rules_collection: RulesCollection,
        file: str,
        expected: int,
    ) -> None:
        """Validate that rule works as intended."""
        results = Runner(file, rules=default_rules_collection).run()

        for result in results:
            assert result.rule.id == NoFreeFormRule.id, result
        assert len(results) == expected