1# Copyright (c) 2011 OpenStack Foundation.
2# All Rights Reserved.
3#
4#    Licensed under the Apache License, Version 2.0 (the "License"); you may
5#    not use this file except in compliance with the License. You may obtain
6#    a copy of the License at
7#
8#         http://www.apache.org/licenses/LICENSE-2.0
9#
10#    Unless required by applicable law or agreed to in writing, software
11#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13#    License for the specific language governing permissions and limitations
14#    under the License.
15
16import os
17import re
18import shutil
19import sys
20
21NETNS_VARS = ('net', 'netn', 'netns')
22EXEC_VARS = ('e', 'ex', 'exe', 'exec')
23
24
25if sys.platform != 'win32':
26    # NOTE(claudiub): pwd is a Linux-specific library, and currently there is
27    # no Windows support for oslo.rootwrap.
28    import pwd
29
30
31def _getuid(user):
32    """Return uid for user."""
33    return pwd.getpwnam(user).pw_uid
34
35
36def realpath(path):
37    """Return the real absolute path.
38
39    If the execution directory does not exist, os.getcwd() raises a
40    FileNotFoundError exception. In this case, unset the exception and return
41    an empty string.
42    """
43    try:
44        return os.path.realpath(path)
45    except FileNotFoundError:
46        return ''
47
48
49class CommandFilter(object):
50    """Command filter only checking that the 1st argument matches exec_path."""
51
52    def __init__(self, exec_path, run_as, *args):
53        self.name = ''
54        self.exec_path = exec_path
55        self.run_as = run_as
56        self.args = args
57        self.real_exec = None
58
59    def get_exec(self, exec_dirs=None):
60        """Returns existing executable, or empty string if none found."""
61        exec_dirs = exec_dirs or []
62        if self.real_exec is not None:
63            return self.real_exec
64        if os.path.isabs(self.exec_path):
65            if os.access(self.exec_path, os.X_OK):
66                self.real_exec = self.exec_path
67        else:
68            for binary_path in exec_dirs:
69                expanded_path = os.path.join(binary_path, self.exec_path)
70                if os.access(expanded_path, os.X_OK):
71                    self.real_exec = expanded_path
72                    break
73        return self.real_exec
74
75    def match(self, userargs):
76        """Only check that the first argument (command) matches exec_path."""
77        return userargs and os.path.basename(self.exec_path) == userargs[0]
78
79    def preexec(self):
80        """Setuid in subprocess right before command is invoked."""
81        if self.run_as != 'root':
82            os.setuid(_getuid(self.run_as))
83
84    def get_command(self, userargs, exec_dirs=None):
85        """Returns command to execute."""
86        exec_dirs = exec_dirs or []
87        to_exec = self.get_exec(exec_dirs=exec_dirs) or self.exec_path
88        return [to_exec] + userargs[1:]
89
90    def get_environment(self, userargs):
91        """Returns specific environment to set, None if none."""
92        return None
93
94
95class RegExpFilter(CommandFilter):
96    """Command filter doing regexp matching for every argument."""
97
98    def match(self, userargs):
99        # Early skip if command or number of args don't match
100        if (not userargs or len(self.args) != len(userargs)):
101            # DENY: argument numbers don't match
102            return False
103        # Compare each arg (anchoring pattern explicitly at end of string)
104        for (pattern, arg) in zip(self.args, userargs):
105            try:
106                if not re.match(pattern + '$', arg):
107                    # DENY: Some arguments did not match
108                    return False
109            except re.error:
110                # DENY: Badly-formed filter
111                return False
112        # ALLOW: All arguments matched
113        return True
114
115
116class PathFilter(CommandFilter):
117    """Command filter checking that path arguments are within given dirs
118
119        One can specify the following constraints for command arguments:
120            1) pass     - pass an argument as is to the resulting command
121            2) some_str - check if an argument is equal to the given string
122            3) abs path - check if a path argument is within the given base dir
123
124        A typical rootwrapper filter entry looks like this:
125            # cmdname: filter name, raw command, user, arg_i_constraint [, ...]
126            chown: PathFilter, /bin/chown, root, nova, /var/lib/images
127
128    """
129
130    def match(self, userargs):
131        if not userargs or len(userargs) < 2:
132            return False
133
134        arguments = userargs[1:]
135
136        equal_args_num = len(self.args) == len(arguments)
137        exec_is_valid = super(PathFilter, self).match(userargs)
138        args_equal_or_pass = all(
139            arg == 'pass' or arg == value
140            for arg, value in zip(self.args, arguments)
141            if not os.path.isabs(arg)  # arguments not specifying abs paths
142        )
143        paths_are_within_base_dirs = all(
144            os.path.commonprefix([arg, realpath(value)]) == arg
145            for arg, value in zip(self.args, arguments)
146            if os.path.isabs(arg)  # arguments specifying abs paths
147        )
148
149        return (equal_args_num and
150                exec_is_valid and
151                args_equal_or_pass and
152                paths_are_within_base_dirs)
153
154    def get_command(self, userargs, exec_dirs=None):
155        exec_dirs = exec_dirs or []
156        command, arguments = userargs[0], userargs[1:]
157
158        # convert path values to canonical ones; copy other args as is
159        args = [realpath(value) if os.path.isabs(arg) else value
160                for arg, value in zip(self.args, arguments)]
161
162        return super(PathFilter, self).get_command([command] + args,
163                                                   exec_dirs)
164
165
166class KillFilter(CommandFilter):
167    """Specific filter for the kill calls.
168
169       1st argument is the user to run /bin/kill under
170       2nd argument is the location of the affected executable
171           if the argument is not absolute, it is checked against $PATH
172       Subsequent arguments list the accepted signals (if any)
173
174       This filter relies on /proc to accurately determine affected
175       executable, so it will only work on procfs-capable systems (not OSX).
176    """
177
178    def __init__(self, *args):
179        super(KillFilter, self).__init__("/bin/kill", *args)
180
181    @staticmethod
182    def _program_path(command):
183        """Try to determine the full path for command.
184
185        Return command if the full path cannot be found.
186        """
187
188        # shutil.which() was added to Python 3.3
189        if hasattr(shutil, 'which'):
190            return shutil.which(command)
191
192        if os.path.isabs(command):
193            return command
194
195        path = os.environ.get('PATH', os.defpath).split(os.pathsep)
196        for dir in path:
197            program = os.path.join(dir, command)
198            if os.path.isfile(program):
199                return program
200
201        return command
202
203    def _program(self, pid):
204        """Determine the program associated with pid"""
205
206        try:
207            command = os.readlink("/proc/%d/exe" % int(pid))
208        except (ValueError, EnvironmentError):
209            # Incorrect PID
210            return None
211
212        # NOTE(yufang521247): /proc/PID/exe may have '\0' on the
213        # end (ex: if an executable is updated or deleted), because python
214        # doesn't stop at '\0' when read the target path.
215        command = command.partition('\0')[0]
216
217        # NOTE(dprince): /proc/PID/exe may have ' (deleted)' on
218        # the end if an executable is updated or deleted
219        if command.endswith(" (deleted)"):
220            command = command[:-len(" (deleted)")]
221
222        if os.path.isfile(command):
223            return command
224
225        # /proc/PID/exe may have been renamed with
226        # a ';......' or '.#prelink#......' suffix etc.
227        # So defer to /proc/PID/cmdline in that case.
228        try:
229            with open("/proc/%d/cmdline" % int(pid)) as pfile:
230                cmdline = pfile.read().partition('\0')[0]
231
232            cmdline = self._program_path(cmdline)
233            if os.path.isfile(cmdline):
234                command = cmdline
235
236            # Note we don't return None if cmdline doesn't exist
237            # as that will allow killing a process where the exe
238            # has been removed from the system rather than updated.
239            return command
240        except EnvironmentError:
241            return None
242
243    def match(self, userargs):
244        if not userargs or userargs[0] != "kill":
245            return False
246        args = list(userargs)
247        if len(args) == 3:
248            # A specific signal is requested
249            signal = args.pop(1)
250            if signal not in self.args[1:]:
251                # Requested signal not in accepted list
252                return False
253        else:
254            if len(args) != 2:
255                # Incorrect number of arguments
256                return False
257            if len(self.args) > 1:
258                # No signal requested, but filter requires specific signal
259                return False
260
261        command = self._program(args[1])
262        if not command:
263            return False
264
265        kill_command = self.args[0]
266
267        if os.path.isabs(kill_command):
268            return kill_command == command
269
270        return (os.path.isabs(command) and
271                kill_command == os.path.basename(command) and
272                os.path.dirname(command) in os.environ.get('PATH', ''
273                                                           ).split(':'))
274
275
276class ReadFileFilter(CommandFilter):
277    """Specific filter for the utils.read_file_as_root call."""
278
279    def __init__(self, file_path, *args):
280        self.file_path = file_path
281        super(ReadFileFilter, self).__init__("/bin/cat", "root", *args)
282
283    def match(self, userargs):
284        return (userargs == ['cat', self.file_path])
285
286
287class IpFilter(CommandFilter):
288    """Specific filter for the ip utility to that does not match exec."""
289
290    def match(self, userargs):
291        if userargs[0] == 'ip':
292            # Avoid the 'netns exec' command here
293            for a, b in zip(userargs[1:], userargs[2:]):
294                if a in NETNS_VARS:
295                    return b not in EXEC_VARS
296            else:
297                return True
298
299
300class EnvFilter(CommandFilter):
301    """Specific filter for the env utility.
302
303    Behaves like CommandFilter, except that it handles
304    leading env A=B.. strings appropriately.
305    """
306
307    def _extract_env(self, arglist):
308        """Extract all leading NAME=VALUE arguments from arglist."""
309
310        envs = set()
311        for arg in arglist:
312            if '=' not in arg:
313                break
314            envs.add(arg.partition('=')[0])
315        return envs
316
317    def __init__(self, exec_path, run_as, *args):
318        super(EnvFilter, self).__init__(exec_path, run_as, *args)
319
320        env_list = self._extract_env(self.args)
321        # Set exec_path to X when args are in the form of
322        # env A=a B=b C=c X Y Z
323        if "env" in exec_path and len(env_list) < len(self.args):
324            self.exec_path = self.args[len(env_list)]
325
326    def match(self, userargs):
327        # ignore leading 'env'
328        if userargs[0] == 'env':
329            userargs.pop(0)
330
331        # require one additional argument after configured ones
332        if len(userargs) < len(self.args):
333            return False
334
335        # extract all env args
336        user_envs = self._extract_env(userargs)
337        filter_envs = self._extract_env(self.args)
338        user_command = userargs[len(user_envs):len(user_envs) + 1]
339
340        # match first non-env argument with CommandFilter
341        return (super(EnvFilter, self).match(user_command) and
342                len(filter_envs) and user_envs == filter_envs)
343
344    def exec_args(self, userargs):
345        args = userargs[:]
346
347        # ignore leading 'env'
348        if args[0] == 'env':
349            args.pop(0)
350
351        # Throw away leading NAME=VALUE arguments
352        while args and '=' in args[0]:
353            args.pop(0)
354
355        return args
356
357    def get_command(self, userargs, exec_dirs=[]):
358        to_exec = self.get_exec(exec_dirs=exec_dirs) or self.exec_path
359        return [to_exec] + self.exec_args(userargs)[1:]
360
361    def get_environment(self, userargs):
362        env = os.environ.copy()
363
364        # ignore leading 'env'
365        if userargs[0] == 'env':
366            userargs.pop(0)
367
368        # Handle leading NAME=VALUE pairs
369        for a in userargs:
370            env_name, equals, env_value = a.partition('=')
371            if not equals:
372                break
373            if env_name and env_value:
374                env[env_name] = env_value
375
376        return env
377
378
379class ChainingFilter(CommandFilter):
380    def exec_args(self, userargs):
381        return []
382
383
384class IpNetnsExecFilter(ChainingFilter):
385    """Specific filter for the ip utility to that does match exec."""
386
387    def match(self, userargs):
388        # Network namespaces currently require root
389        # require <ns> argument
390        if self.run_as != "root" or len(userargs) < 4:
391            return False
392
393        return (userargs[0] == 'ip' and userargs[1] in NETNS_VARS and
394                userargs[2] in EXEC_VARS)
395
396    def exec_args(self, userargs):
397        args = userargs[4:]
398        if args:
399            args[0] = os.path.basename(args[0])
400        return args
401
402
403class ChainingRegExpFilter(ChainingFilter):
404    """Command filter doing regexp matching for prefix commands.
405
406    Remaining arguments are filtered again. This means that the command
407    specified as the arguments must be also allowed to execute directly.
408    """
409
410    def match(self, userargs):
411        # Early skip if number of args is smaller than the filter
412        if (not userargs or len(self.args) > len(userargs)):
413            return False
414        # Compare each arg (anchoring pattern explicitly at end of string)
415        for (pattern, arg) in zip(self.args, userargs):
416            try:
417                if not re.match(pattern + '$', arg):
418                    # DENY: Some arguments did not match
419                    return False
420            except re.error:
421                # DENY: Badly-formed filter
422                return False
423        # ALLOW: All arguments matched
424        return True
425
426    def exec_args(self, userargs):
427        args = userargs[len(self.args):]
428        if args:
429            args[0] = os.path.basename(args[0])
430        return args
431