Add action plugins to restrict untrusted execution

There are actions undertaken by action plugins in normal ansible that
allow for executing code on the host that ansible is executing on. We do
not want to allow that for untrusted code, so add a set of action
plugins that override the upstream ones and simply return errors.

Additionally, we can trap for attempts to execute local commands in the
normal action plugin by looking at remote_addr, connection and
delegate_to.

Change-Id: I57dbe5648a9dc6ec9147c8698ad46c4fa1326e5a
This commit is contained in:
Monty Taylor 2017-02-03 09:57:15 -06:00 committed by James E. Blair
parent 5f0f514215
commit c231d939ea
43 changed files with 552 additions and 9 deletions

View File

@ -757,11 +757,12 @@ class RecordingAnsibleJob(zuul.launcher.server.AnsibleJob):
self.launcher_server.lock.release()
return result
def runAnsible(self, cmd, timeout):
def runAnsible(self, cmd, timeout, secure=False):
build = self.launcher_server.job_builds[self.job.unique]
if self.launcher_server._run_ansible:
result = super(RecordingAnsibleJob, self).runAnsible(cmd, timeout)
result = super(RecordingAnsibleJob, self).runAnsible(
cmd, timeout, secure=secure)
else:
result = build.run()
return result

View File

@ -3,3 +3,6 @@
- file:
path: "{{zuul._test.test_root}}/{{zuul.uuid}}.flag"
state: touch
- copy:
src: "{{zuul._test.test_root}}/{{zuul.uuid}}.flag"
dest: "{{zuul._test.test_root}}/{{zuul.uuid}}.copied"

View File

@ -1,6 +1,11 @@
- job:
parent: python27
name: faillocal
- project:
name: org/project
check:
jobs:
- python27
- faillocal

View File

@ -0,0 +1,5 @@
- hosts: all
tasks:
- copy:
src: "{{zuul._test.test_root}}/{{zuul.uuid}}.flag"
dest: "{{zuul._test.test_root}}/{{zuul.uuid}}.failed"

View File

@ -125,10 +125,18 @@ class TestAnsible(AnsibleZuulTestCase):
A = self.fake_gerrit.addFakeChange('org/project', 'master', 'A')
self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
self.waitUntilSettled()
build = self.getJobFromHistory('faillocal')
self.assertEqual(build.result, 'FAILURE')
build = self.getJobFromHistory('python27')
self.assertEqual(build.result, 'SUCCESS')
flag_path = os.path.join(self.test_root, build.uuid + '.flag')
self.assertTrue(os.path.exists(flag_path))
copied_path = os.path.join(self.test_root, build.uuid +
'.copied')
self.assertTrue(os.path.exists(copied_path))
failed_path = os.path.join(self.test_root, build.uuid +
'.failed')
self.assertFalse(os.path.exists(failed_path))
pre_flag_path = os.path.join(self.test_root, build.uuid +
'.pre.flag')
self.assertTrue(os.path.exists(pre_flag_path))

View File

View File

@ -0,0 +1,25 @@
# Copyright 2016 Red Hat, Inc.
#
# This module is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This software is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this software. If not, see <http://www.gnu.org/licenses/>.
from zuul.ansible.plugins.action import add_host
class ActionModule(add_host.ActionModule):
def run(self, tmp=None, task_vars=None):
return dict(
failed=True,
msg="Adding hosts to the inventory is prohibited")

View File

@ -0,0 +1 @@
network.py

View File

@ -0,0 +1 @@
network.py

View File

@ -0,0 +1,30 @@
# Copyright 2016 Red Hat, Inc.
#
# This module is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This software is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this software. If not, see <http://www.gnu.org/licenses/>.
from zuul.ansible import paths
from zuul.ansible.plugins.action import assemble
class ActionModule(assemble.ActionModule):
def run(self, tmp=None, task_vars=None):
source = self._task.args.get('src', None)
remote_src = self._task.args.get('remote_src', False)
if not remote_src and not paths._is_safe_path(source):
return paths._fail_dict(source)
return super(ActionModule, self).run(tmp, task_vars)

View File

@ -0,0 +1,30 @@
# Copyright 2016 Red Hat, Inc.
#
# This module is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This software is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this software. If not, see <http://www.gnu.org/licenses/>.
from zuul.ansible import paths
from zuul.ansible.plugins.action import copy
class ActionModule(copy.ActionModule):
def run(self, tmp=None, task_vars=None):
source = self._task.args.get('src', None)
remote_src = self._task.args.get('remote_src', False)
if not remote_src and not paths._is_safe_path(source):
return paths._fail_dict(source)
return super(ActionModule, self).run(tmp, task_vars)

View File

@ -0,0 +1 @@
network.py

View File

@ -0,0 +1 @@
network.py

View File

@ -0,0 +1 @@
network.py

View File

@ -0,0 +1 @@
network.py

View File

@ -0,0 +1 @@
network.py

View File

@ -0,0 +1,29 @@
# Copyright 2016 Red Hat, Inc.
#
# This module is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This software is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this software. If not, see <http://www.gnu.org/licenses/>.
from zuul.ansible import paths
from zuul.ansible.plugins.action import fetch
class ActionModule(fetch.ActionModule):
def run(self, tmp=None, task_vars=None):
dest = self._task.args.get('dest', None)
if dest and not paths._is_safe_path(dest):
return paths._fail_dict(dest)
return super(ActionModule, self).run(tmp, task_vars)

View File

@ -0,0 +1,31 @@
# Copyright 2016 Red Hat, Inc.
#
# This module is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This software is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this software. If not, see <http://www.gnu.org/licenses/>.
from zuul.ansible import paths
from zuul.ansible.plugins.action import include_vars
class ActionModule(include_vars.ActionModule):
def run(self, tmp=None, task_vars=None):
source_dir = self._task.args.get('dir', None)
source_file = self._task.args.get('file', None)
for fileloc in (source_dir, source_file):
if fileloc and not paths._is_safe_path(fileloc):
return paths._fail_dict(fileloc)
return super(ActionModule, self).run(tmp, task_vars)

View File

@ -0,0 +1 @@
network.py

View File

@ -0,0 +1 @@
network.py

View File

@ -0,0 +1 @@
network.py

View File

@ -0,0 +1 @@
network.py

View File

@ -0,0 +1 @@
network.py

View File

@ -0,0 +1 @@
network.py

View File

@ -0,0 +1 @@
network.py

View File

@ -0,0 +1 @@
network.py

View File

@ -0,0 +1,24 @@
# Copyright 2016 Red Hat, Inc.
#
# This module is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This software is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this software. If not, see <http://www.gnu.org/licenses/>.
from zuul.ansible.plugins.action import network
class ActionModule(network.ActionModule):
def run(self, tmp=None, task_vars=None):
return dict(failed=True, msg='Use of network modules is prohibited')

View File

@ -0,0 +1,33 @@
# Copyright 2016 Red Hat, Inc.
#
# This module is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This software is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this software. If not, see <http://www.gnu.org/licenses/>.
from zuul.ansible.plugins.action import normal
class ActionModule(normal.ActionModule):
def run(self, tmp=None, task_vars=None):
if (self._play_context.connection == 'local'
or self._play_context.remote_addr == 'localhost'
or self._play_context.remote_addr.startswith('127.')
or self._task.delegate_to == 'localhost'
or (self._task.delegate_to
and self._task.delegate_to.startswtih('127.'))):
return dict(
failed=True,
msg="Executing local code is prohibited")
return super(ActionModule, self).run(tmp, task_vars)

View File

@ -0,0 +1 @@
network.py

View File

@ -0,0 +1 @@
network.py

View File

@ -0,0 +1 @@
network.py

View File

@ -0,0 +1 @@
network.py

View File

@ -0,0 +1,30 @@
# Copyright 2016 Red Hat, Inc.
#
# This module is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This software is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this software. If not, see <http://www.gnu.org/licenses/>.
from zuul.ansible import paths
from zuul.ansible.plugins.action import patch
class ActionModule(patch.ActionModule):
def run(self, tmp=None, task_vars=None):
source = self._task.args.get('src', None)
remote_src = self._task.args.get('remote_src', False)
if not remote_src and not paths._is_safe_path(source):
return paths._fail_dict(source)
return super(ActionModule, self).run(tmp, task_vars)

View File

@ -0,0 +1,34 @@
# Copyright 2016 Red Hat, Inc.
#
# This module is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This software is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this software. If not, see <http://www.gnu.org/licenses/>.
from zuul.ansible import paths
from zuul.ansible.plugins.action import copy
class ActionModule(copy.ActionModule):
def run(self, tmp=None, task_vars=None):
# the script name is the first item in the raw params, so we split it
# out now so we know the file name we need to transfer to the remote,
# and everything else is an argument to the script which we need later
# to append to the remote command
parts = self._task.args.get('_raw_params', '').strip().split()
source = parts[0]
if not paths._is_safe_path(source):
return paths._fail_dict(source)
return super(ActionModule, self).run(tmp, task_vars)

View File

@ -0,0 +1 @@
network.py

View File

@ -0,0 +1,33 @@
# Copyright 2016 Red Hat, Inc.
#
# This module is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This software is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this software. If not, see <http://www.gnu.org/licenses/>.
from zuul.ansible import paths
from zuul.ansible.plugins.action import synchronize
class ActionModule(synchronize.ActionModule):
def run(self, tmp=None, task_vars=None):
source = self._task.args.get('src', None)
dest = self._task.args.get('dest', None)
pull = self._task.args.get('pull', False)
if not pull and not paths._is_safe_path(source):
return paths._fail_dict(source, prefix='Syncing files from')
if pull and not paths._is_safe_path(dest):
return paths._fail_dict(dest, prefix='Syncing files to')
return super(ActionModule, self).run(tmp, task_vars)

View File

@ -0,0 +1,29 @@
# Copyright 2016 Red Hat, Inc.
#
# This module is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This software is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this software. If not, see <http://www.gnu.org/licenses/>.
from zuul.ansible import paths
from zuul.ansible.plugins.action import template
class ActionModule(template.ActionModule):
def run(self, tmp=None, task_vars=None):
source = self._task.args.get('src', None)
if not paths._is_safe_path(source):
return paths._fail_dict(source)
return super(ActionModule, self).run(tmp, task_vars)

View File

@ -0,0 +1,30 @@
# Copyright 2016 Red Hat, Inc.
#
# This module is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This software is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this software. If not, see <http://www.gnu.org/licenses/>.
from zuul.ansible import paths
from zuul.ansible.plugins.action import unarchive
class ActionModule(unarchive.ActionModule):
def run(self, tmp=None, task_vars=None):
source = self._task.args.get('src', None)
remote_src = self._task.args.get('remote_src', False)
if not remote_src and not paths._is_safe_path(source):
return paths._fail_dict(source)
return super(ActionModule, self).run(tmp, task_vars)

View File

@ -0,0 +1 @@
network.py

View File

@ -0,0 +1,30 @@
# Copyright 2016 Red Hat, Inc.
#
# This module is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This software is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this software. If not, see <http://www.gnu.org/licenses/>.
from zuul.ansible import paths
from zuul.ansible.plugins.action import win_copy
class ActionModule(win_copy.ActionModule):
def run(self, tmp=None, task_vars=None):
source = self._task.args.get('src', None)
remote_src = self._task.args.get('remote_src', False)
if not remote_src and not paths._is_safe_path(source):
return paths._fail_dict(source)
return super(ActionModule, self).run(tmp, task_vars)

View File

@ -0,0 +1,30 @@
# Copyright 2016 Red Hat, Inc.
#
# This module is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This software is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this software. If not, see <http://www.gnu.org/licenses/>.
from zuul.ansible import paths
from zuul.ansible.plugins.action import win_template
class ActionModule(win_template.ActionModule):
def run(self, tmp=None, task_vars=None):
source = self._task.args.get('src', None)
remote_src = self._task.args.get('remote_src', False)
if not remote_src and not paths._is_safe_path(source):
return paths._fail_dict(source)
return super(ActionModule, self).run(tmp, task_vars)

33
zuul/ansible/paths.py Normal file
View File

@ -0,0 +1,33 @@
# Copyright 2016 Red Hat, Inc.
#
# This module is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This software is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this software. If not, see <http://www.gnu.org/licenses/>.
import os
def _is_safe_path(path):
if os.path.isabs(path):
return False
if not os.path.abspath(os.path.expanduser(path)).startswith(
os.path.abspath(os.path.curdir)):
return False
return True
def _fail_dict(path, prefix='Accessing files from'):
return dict(
failed=True,
path=path,
msg="{prefix} outside the working dir is prohibited".format(
prefix=prefix))

View File

@ -29,6 +29,7 @@ import yaml
import gear
import zuul.merger.merger
import zuul.ansible.action
import zuul.ansible.library
from zuul.lib import commandsocket
@ -85,6 +86,8 @@ class JobDir(object):
os.makedirs(self.git_root)
self.ansible_root = os.path.join(self.root, 'ansible')
os.makedirs(self.ansible_root)
self.secure_ansible_root = os.path.join(self.ansible_root, 'secure')
os.makedirs(self.secure_ansible_root)
self.known_hosts = os.path.join(self.ansible_root, 'known_hosts')
self.inventory = os.path.join(self.ansible_root, 'inventory')
self.vars = os.path.join(self.ansible_root, 'vars.yaml')
@ -93,6 +96,8 @@ class JobDir(object):
self.pre_playbooks = []
self.post_playbooks = []
self.config = os.path.join(self.ansible_root, 'ansible.cfg')
self.secure_config = os.path.join(
self.secure_ansible_root, 'ansible.cfg')
self.ansible_log = os.path.join(self.ansible_root, 'ansible_log.txt')
def addPrePlaybook(self):
@ -238,11 +243,18 @@ class LaunchServer(object):
self.library_dir = os.path.join(ansible_dir, 'library')
if not os.path.exists(self.library_dir):
os.makedirs(self.library_dir)
self.action_dir = os.path.join(ansible_dir, 'action')
if not os.path.exists(self.action_dir):
os.makedirs(self.action_dir)
library_path = os.path.dirname(os.path.abspath(
zuul.ansible.library.__file__))
for fn in os.listdir(library_path):
shutil.copy(os.path.join(library_path, fn), self.library_dir)
action_path = os.path.dirname(os.path.abspath(
zuul.ansible.action.__file__))
for fn in os.listdir(action_path):
shutil.copy(os.path.join(action_path, fn), self.action_dir)
self.job_workers = {}
@ -580,10 +592,28 @@ class AnsibleJob(object):
hosts.append((node['name'], dict(ansible_connection='local')))
return hosts
def findPlaybook(self, path, required=False):
def _blockPluginDirs(self, fn):
'''Prevent execution of playbooks with plugins
Plugins are loaded from roles and also if there is a plugin dir
adjacent to the playbook. Role exclusion will be handled elsewhere,
but while we're looking for playbooks, throw an error if the playbook
exists in a location that would cause a plugin to get loaded if the
playbook is not in a secure repository.
'''
playbook_dir = os.path.dirname(os.path.abspath(fn))
for entry in os.listdir(playbook_dir):
if os.path.isdir(entry) and entry.endswith('_plugins'):
raise Exception(
"Ansible plugin dir %s found adjacent to playbook %s in"
" non-secure repo." % (entry, fn))
def findPlaybook(self, path, required=False, secure=False):
for ext in ['.yaml', '.yml']:
fn = path + ext
if os.path.exists(fn):
if not secure:
self._blockPluginDirs(fn)
return fn
if required:
raise Exception("Unable to find playbook %s" % path)
@ -631,7 +661,10 @@ class AnsibleJob(object):
path = os.path.join(self.jobdir.git_root,
project.name,
playbook['path'])
jobdir_playbook.path = self.findPlaybook(path, main)
jobdir_playbook.path = self.findPlaybook(
path,
required=main,
secure=playbook['secure'])
return
# The playbook repo is either a config repo, or it isn't in
# the stack of changes we are testing, so check out the branch
@ -643,7 +676,10 @@ class AnsibleJob(object):
path = os.path.join(jobdir_playbook.root,
project.name,
playbook['path'])
jobdir_playbook.path = self.findPlaybook(path, main)
jobdir_playbook.path = self.findPlaybook(
path,
required=main,
secure=playbook['secure'])
def prepareAnsibleFiles(self, args):
with open(self.jobdir.inventory, 'w') as inventory:
@ -657,7 +693,11 @@ class AnsibleJob(object):
zuul_vars = dict(zuul=args['zuul'])
vars_yaml.write(
yaml.safe_dump(zuul_vars, default_flow_style=False))
with open(self.jobdir.config, 'w') as config:
self.writeAnsibleConfig(self.jobdir.config)
self.writeAnsibleConfig(self.jobdir.secure_config, secure=True)
def writeAnsibleConfig(self, config_path, secure=False):
with open(config_path, 'w') as config:
config.write('[defaults]\n')
config.write('hostfile = %s\n' % self.jobdir.inventory)
config.write('local_tmp = %s/.ansible/local_tmp\n' %
@ -673,6 +713,9 @@ class AnsibleJob(object):
# bump the timeout because busy nodes may take more than
# 10s to respond
config.write('timeout = 30\n')
if not secure:
config.write('action_plugins = %s\n'
% self.launcher_server.action_dir)
config.write('[ssh_connection]\n')
# NB: when setting pipelining = True, keep_remote_files
@ -706,17 +749,22 @@ class AnsibleJob(object):
self.log.exception("Exception while killing "
"ansible process:")
def runAnsible(self, cmd, timeout):
def runAnsible(self, cmd, timeout, secure=False):
env_copy = os.environ.copy()
env_copy['LOGNAME'] = 'zuul'
if secure:
cwd = self.jobdir.secure_ansible_root
else:
cwd = self.jobdir.ansible_root
with self.proc_lock:
if self.aborted:
return (self.RESULT_ABORTED, None)
self.log.debug("Ansible command: %s" % (cmd,))
self.proc = subprocess.Popen(
cmd,
cwd=self.jobdir.ansible_root,
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
preexec_fn=os.setsid,
@ -771,4 +819,5 @@ class AnsibleJob(object):
# TODOv3: get this from the job
timeout = 60
return self.runAnsible(cmd, timeout)
return self.runAnsible(
cmd=cmd, timeout=timeout, secure=playbook.secure)