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