From d610082c25f2dc812344544d991ed856e381cb0e Mon Sep 17 00:00:00 2001 From: AnsibleGuy Date: Sun, 22 Sep 2024 15:20:14 +0200 Subject: [PATCH] refactor runtime secret handling (#66) --- CHANGELOG.md | 1 + docs/source/usage/security.rst | 14 +------ src/ansibleguy-webui/aw/execute/play.py | 2 +- .../aw/execute/play_credentials.py | 30 +++++++------ src/ansibleguy-webui/aw/execute/play_util.py | 42 ++++++++++--------- src/ansibleguy-webui/aw/execute/repository.py | 7 +--- src/ansibleguy-webui/aw/utils/util.py | 19 ++++++++- src/ansibleguy-webui/aw/views/job.py | 4 +- test/roles/test1/tasks/main.yml | 2 +- 9 files changed, 64 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21a7e32..51a516f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ * Enable SSH host-key checking by default (*ansible-runner seem to disable it by default*) * Enhanced Bottons of Log Live-View * Git-Repository Cleanup-Hook (Post Job-Run) +* Refactored runtime secret-handling (`ansible-runner `_) ---- diff --git a/docs/source/usage/security.rst b/docs/source/usage/security.rst index d15f5c5..1f8b00a 100644 --- a/docs/source/usage/security.rst +++ b/docs/source/usage/security.rst @@ -51,19 +51,7 @@ Security considerations this project does take into account: * Job secrets like passwords are never returned to the user/Web-UI -* Job secrets are not passed as commandline-arguments but written to files: - - Example: - - .. code-block:: bash - - [INFO] [play] Running job 'test': 'ansible-playbook --become-password-file /tmp/ansible-webui/2024-01-26_21-14-0066101/.aw_become_pass --vault-password-file /tmp/ansible-webui/2024-01-26_21-14-0066101/.aw_vault_pass -i inventory/hosts.yml --limit myHost playbook1.yml' - - These files are: - - * created with mode 0600 - - * overwritten and deleted at execution-cleanup +* Runtime handling of secrets is done by the official `ansible-runner `_ module (using :code:`pexpect` and :code:`ssh-agent`) * Usage of GitHub's `dependabot `_ and `CodeQL `_ diff --git a/src/ansibleguy-webui/aw/execute/play.py b/src/ansibleguy-webui/aw/execute/play.py index 2bb87d8..9fa9f30 100644 --- a/src/ansibleguy-webui/aw/execute/play.py +++ b/src/ansibleguy-webui/aw/execute/play.py @@ -50,7 +50,7 @@ def _cancel_job() -> bool: runner_cfg.prepare() command = ' '.join(runner_cfg.command) log(msg=f"Running job '{job.name}': '{command}'", level=5) - execution.command = command + execution.command = command[command.find('ansible-playbook'):] execution.save() runner = Runner(config=runner_cfg, cancel_callback=_cancel_job) diff --git a/src/ansibleguy-webui/aw/execute/play_credentials.py b/src/ansibleguy-webui/aw/execute/play_credentials.py index 603dd86..cd61160 100644 --- a/src/ansibleguy-webui/aw/execute/play_credentials.py +++ b/src/ansibleguy-webui/aw/execute/play_credentials.py @@ -7,8 +7,8 @@ from aw.utils.permission import has_credentials_permission, CHOICE_PERMISSION_READ from aw.base import USERS from aw.utils.debug import log # log_warn -from aw.utils.util import is_set, is_null -from aw.execute.util import config_error, write_file_0600 +from aw.utils.util import is_set, is_null, write_file_0600 +from aw.execute.util import config_error def get_pwd_file(path_run: (str, Path), attr: str) -> str: @@ -95,16 +95,22 @@ def get_credentials_to_use(job: Job, execution: JobExecution) -> (BaseJobCredent return credentials -def commandline_arguments_credentials(credentials: BaseJobCredentials, path_run: Path) -> list: - cmd_arguments = [] +def get_runner_credential_args(creds: BaseJobCredentials) -> dict: + args = {} - for attr, flag in BaseJobCredentials.PUBLIC_ATTRS_ARGS.items(): - if is_set(getattr(credentials, attr)): - cmd_arguments.append(f'{flag} {getattr(credentials, attr)}') + if is_set(creds.ssh_key): + args['ssh_key'] = creds.ssh_key - for attr in BaseJobCredentials.SECRET_ATTRS: - pwd_arg = get_pwd_file_arg(credentials, attr=attr, path_run=path_run) - if pwd_arg is not None: - cmd_arguments.append(pwd_arg) + if is_set(creds.connect_pass) or is_set(creds.become_pass) or is_set(creds.vault_pass): + args['passwords'] = {} - return cmd_arguments + if is_set(creds.connect_pass): + args['passwords']['SSH password:'] = creds.connect_pass + + if is_set(creds.become_pass): + args['passwords']['BECOME password:'] = creds.become_pass + + if is_set(creds.vault_pass): + args['passwords']['Vault password:'] = creds.vault_pass + + return args diff --git a/src/ansibleguy-webui/aw/execute/play_util.py b/src/ansibleguy-webui/aw/execute/play_util.py index 1ce3f6d..821f83c 100644 --- a/src/ansibleguy-webui/aw/execute/play_util.py +++ b/src/ansibleguy-webui/aw/execute/play_util.py @@ -14,14 +14,13 @@ from aw.config.main import config from aw.utils.util import is_set, datetime_w_tz, write_file_0640 -from aw.model.job import Job, JobExecution, JobExecutionResult, JobExecutionResultHost, JobError from aw.model.job_credential import BaseJobCredentials +from aw.model.job import Job, JobExecution, JobExecutionResult, JobExecutionResultHost, JobError from aw.execute.util import update_status, overwrite_and_delete_file, decode_job_env_vars, \ create_dirs, is_execution_status, config_error from aw.utils.debug import log -from aw.execute.play_credentials import get_credentials_to_use, commandline_arguments_credentials, \ - write_pwd_file, get_pwd_file from aw.execute.repository import ExecuteRepository +from aw.execute.play_credentials import get_runner_credential_args, get_credentials_to_use # see: https://ansible.readthedocs.io/projects/runner/en/latest/intro/ @@ -34,7 +33,7 @@ def _exec_log(execution: JobExecution, msg: str, level: int = 3): ) -def _commandline_arguments(job: Job, execution: JobExecution, path_run: Path) -> str: +def _commandline_arguments(job: Job, execution: JobExecution, creds: BaseJobCredentials) -> str: cmd_arguments = [] if is_set(job.cmd_args): cmd_arguments.append(job.cmd_args) @@ -48,12 +47,6 @@ def _commandline_arguments(job: Job, execution: JobExecution, path_run: Path) -> if execution.mode_diff or job.mode_diff: cmd_arguments.append('--diff') - credentials = get_credentials_to_use(job=job, execution=execution) - if credentials is not None: - cmd_arguments.extend( - commandline_arguments_credentials(credentials=credentials, path_run=path_run) - ) - if is_set(config['path_ssh_known_hosts']) and \ ' '.join(cmd_arguments).find('ansible_ssh_extra_args') == -1: if Path(config['path_ssh_known_hosts']).is_file(): @@ -64,6 +57,15 @@ def _commandline_arguments(job: Job, execution: JobExecution, path_run: Path) -> else: _exec_log(execution=execution, msg='Ignoring known_hosts file because it does not exist', level=5) + if is_set(creds.become_pass): + cmd_arguments.append('--ask-become-pass') + + if is_set(creds.connect_pass): + cmd_arguments.append('--ask-pass') + + if is_set(creds.vault_pass): + cmd_arguments.append('--ask-vault-pass') + return ' '.join(cmd_arguments) @@ -120,7 +122,9 @@ def _execution_or_job(job: Job, execution: JobExecution, attr: str): return None -def _runner_options(job: Job, execution: JobExecution, path_run: Path, project_dir: str) -> dict: +def _runner_options( + job: Job, execution: JobExecution, path_run: Path, project_dir: str, creds: BaseJobCredentials, +) -> dict: verbosity = None if execution.verbosity != 0: verbosity = execution.verbosity @@ -128,7 +132,7 @@ def _runner_options(job: Job, execution: JobExecution, path_run: Path, project_d elif job.verbosity != 0: verbosity = job.verbosity - cmdline_args = _commandline_arguments(job=job, execution=execution, path_run=path_run) + cmdline_args = _commandline_arguments(job=job, execution=execution, creds=creds) opts = { 'project_dir': project_dir, @@ -147,7 +151,8 @@ def _runner_options(job: Job, execution: JobExecution, path_run: Path, project_d def runner_prep(job: Job, execution: JobExecution, path_run: Path, project_dir: str) -> dict: update_status(execution, status='Starting') - opts = _runner_options(job=job, execution=execution, path_run=path_run, project_dir=project_dir) + creds = get_credentials_to_use(job=job, execution=execution) + opts = _runner_options(job=job, execution=execution, path_run=path_run, project_dir=project_dir, creds=creds) opts['playbook'] = job.playbook_file if is_set(job.inventory_file): opts['inventory'] = job.inventory_file.split(',') @@ -166,12 +171,11 @@ def runner_prep(job: Job, execution: JobExecution, path_run: Path, project_dir: create_dirs(path=path_run, desc='run') create_dirs(path=config['path_log'], desc='log') - credentials = get_credentials_to_use(job=job, execution=execution) - for secret_attr in BaseJobCredentials.SECRET_ATTRS: - write_pwd_file(credentials, attr=secret_attr, path_run=path_run) - update_status(execution, status='Running') - return opts + return { + **opts, + **get_runner_credential_args(creds=creds), + } def runner_logs(cfg: RunnerConfig, log_files: dict): @@ -196,8 +200,6 @@ def runner_logs(cfg: RunnerConfig, log_files: dict): def runner_cleanup(execution: JobExecution, path_run: Path, exec_repo: ExecuteRepository): overwrite_and_delete_file(f"{path_run}/env/passwords") overwrite_and_delete_file(f"{path_run}/env/ssh_key") - for attr in BaseJobCredentials.SECRET_ATTRS: - overwrite_and_delete_file(get_pwd_file(path_run=path_run, attr=attr)) try: exec_repo.cleanup_repository() diff --git a/src/ansibleguy-webui/aw/execute/repository.py b/src/ansibleguy-webui/aw/execute/repository.py index bc9128a..f4b89d4 100644 --- a/src/ansibleguy-webui/aw/execute/repository.py +++ b/src/ansibleguy-webui/aw/execute/repository.py @@ -10,8 +10,7 @@ from aw.utils.util import is_null, is_set, write_file_0640 from aw.utils.subps import process from aw.execute.play_credentials import write_pwd_file, get_pwd_file -from aw.execute.util import overwrite_and_delete_file, update_status, get_path_run, job_logs, create_dirs -from aw.model.job_credential import BaseJobCredentials +from aw.execute.util import update_status, get_path_run, job_logs, create_dirs from aw.utils.handlers import AnsibleRepositoryError from aw.model.repository import Repository from aw.base import USERS @@ -158,10 +157,6 @@ def cleanup_repository(self): if is_null(self.repository) or self.repository.rtype_name == 'Static': return - path_run_repo = self.get_path_run_repo() - for attr in BaseJobCredentials.SECRET_ATTRS: - overwrite_and_delete_file(get_pwd_file(path_run=path_run_repo, attr=attr)) - self._run_repo_config_cmds(cmds=self.repository.git_hook_cleanup, env=self._git_env()) if self.repository.git_isolate: rmtree(self.get_path_repo(), ignore_errors=True) diff --git a/src/ansibleguy-webui/aw/utils/util.py b/src/ansibleguy-webui/aw/utils/util.py index f19f40a..a55a41f 100644 --- a/src/ansibleguy-webui/aw/utils/util.py +++ b/src/ansibleguy-webui/aw/utils/util.py @@ -1,3 +1,6 @@ +import os +import unicodedata +import re as regex from platform import python_version from datetime import datetime, timedelta from time import time @@ -5,9 +8,8 @@ from pathlib import Path from functools import lru_cache, wraps from math import ceil -import re as regex from sys import maxunicode -import unicodedata +from threading import Thread from pkg_resources import get_distribution from crontab import CronTab @@ -73,6 +75,19 @@ def write_file_0600(file: (str, Path), content: str): _file.write(content) +def write_pipe_0600(file: (str, Path), content: str): + os.mkfifo(file, mode=0o600) + + def pipe_writer(f: (str, Path), c: str): + # will be blocked until ansible connects to the other end of the pipe + with open(f, 'wb') as fh: + fh.write(c.encode('utf-8')) + + os.remove(file) + + Thread(target=pipe_writer, args=(file, content)).start() + + def _open_file_0640(path: (str, Path), flags): return open_file(path, flags, 0o640) diff --git a/src/ansibleguy-webui/aw/views/job.py b/src/ansibleguy-webui/aw/views/job.py index 21319e0..a5e2ff1 100644 --- a/src/ansibleguy-webui/aw/views/job.py +++ b/src/ansibleguy-webui/aw/views/job.py @@ -7,7 +7,7 @@ from aw.model.job import JobExecution, JobExecutionResultHost from aw.model.job_credential import JobGlobalCredentials, JobUserCredentials from aw.api_endpoints.job_util import get_viewable_jobs -from aw.utils.util import get_next_cron_execution_str +from aw.utils.util import get_next_cron_execution_str, is_set from aw.utils.permission import has_credentials_permission, CHOICE_PERMISSION_READ from aw.views.forms.job import job_edit, job_clone, job_credentials_edit, job_repository_static_edit, \ job_repository_git_edit @@ -34,7 +34,7 @@ def manage(request) -> HttpResponse: cron = '-' try: - if job.schedule is not None: + if is_set(job.schedule): cron = get_next_cron_execution_str(job.schedule) except ValueError: diff --git a/test/roles/test1/tasks/main.yml b/test/roles/test1/tasks/main.yml index 0d6c70e..7101738 100644 --- a/test/roles/test1/tasks/main.yml +++ b/test/roles/test1/tasks/main.yml @@ -13,7 +13,7 @@ - name: TEST 1 | Sleeping ansible.builtin.pause: - seconds: 30 + seconds: 60 when: test == 'run3' - name: TEST 1 | Fail