From e63f6e94361d8b5496df9122a945b48551a72c7d Mon Sep 17 00:00:00 2001 From: Erik Moeller Date: Tue, 9 Feb 2021 22:35:36 -0800 Subject: [PATCH] Check for updates before running install playbook Can be skipped with --force argument. --- admin/securedrop_admin/__init__.py | 60 ++++++++++++++++++++++++- admin/tests/test_integration.py | 14 +++--- admin/tests/test_securedrop-admin.py | 66 ++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 8 deletions(-) diff --git a/admin/securedrop_admin/__init__.py b/admin/securedrop_admin/__init__.py index 8761b54810b..cc1b50c84ac 100755 --- a/admin/securedrop_admin/__init__.py +++ b/admin/securedrop_admin/__init__.py @@ -24,6 +24,7 @@ """ import argparse +import functools import ipaddress import logging import os @@ -45,6 +46,9 @@ from pkg_resources import parse_version from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import x25519 + +from typing import cast + from typing import List from typing import Set @@ -80,6 +84,11 @@ class JournalistAlertEmailException(Exception): # The type of each entry within SiteConfig.desc _T = TypeVar('_T', bound=Union[int, str, bool]) + +# The function type used for the @update_check_required decorator; see +# https://mypy.readthedocs.io/en/stable/generics.html#declaring-decorators +_FuncT = TypeVar('_FuncT', bound=Callable[..., Any]) + # (var, default, type, prompt, validator, transform, condition) _DescEntryType = Tuple[str, _T, Type[_T], str, Optional[Validator], Optional[Callable], Callable] @@ -516,7 +525,7 @@ def user_prompt_config(self) -> Dict[str, Any]: self._config_in_progress[var] = '' continue self._config_in_progress[var] = self.user_prompt_config_one(desc, - self.config.get(var)) # noqa: E501 + self.config.get(var)) # noqa: E501 return self._config_in_progress def user_prompt_config_one( @@ -690,6 +699,45 @@ def setup_logger(verbose: bool = False) -> None: sdlog.addHandler(stdout) +def update_check_required(cmd_name: str) -> Callable[[_FuncT], _FuncT]: + """ + This decorator can be added to any subcommand that is part of securedrop-admin + via `@update_check_required("name_of_subcommand")`. It forces a check for + updates, and aborts if the locally installed code is out of date. It should + be generally added to all subcommands that make modifications on the + server or on the Admin Workstation. + + The user can override this check by specifying the --force argument before + any subcommand. + """ + def decorator_update_check(func: _FuncT) -> _FuncT: + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + cli_args = args[0] + if cli_args.force: + sdlog.info("Skipping update check because --force argument was provided.") + return func(*args, **kwargs) + + update_status, latest_tag = check_for_updates(cli_args) + if update_status is True: + sdlog.error("You are not running the most recent signed SecureDrop release " + "on this workstation.") + sdlog.error("Latest available version: {}".format(latest_tag)) + sdlog.error("Running outdated or mismatched code can cause significant " + "technical issues.") + sdlog.error("If you are certain you want to proceed, run:\n\n\t" + "./securedrop-admin --force {}\n".format(cmd_name)) + sdlog.error("To apply the latest updates, run:\n\n\t" + "./securedrop-admin update\n") + sdlog.error("If this fails, see the latest upgrade guide on " + "https://docs.securedrop.org/ for instructions.") + sys.exit(1) + return func(*args, **kwargs) + return cast(_FuncT, wrapper) + return decorator_update_check + + +@update_check_required("sdconfig") def sdconfig(args: argparse.Namespace) -> int: """Configure SD site settings""" SiteConfig(args).load_and_update_config(validate=False) @@ -752,8 +800,10 @@ def find_or_generate_new_torv3_keys(args: argparse.Namespace) -> int: return 0 +@update_check_required("install") def install_securedrop(args: argparse.Namespace) -> int: """Install/Update SecureDrop""" + SiteConfig(args).load_and_update_config(prompt=False) sdlog.info("Now installing SecureDrop on remote servers.") @@ -767,6 +817,7 @@ def install_securedrop(args: argparse.Namespace) -> int: ) +@update_check_required("verify") def verify_install(args: argparse.Namespace) -> int: """Run configuration tests against SecureDrop servers""" @@ -776,6 +827,7 @@ def verify_install(args: argparse.Namespace) -> int: cwd=os.getcwd()) +@update_check_required("backup") def backup_securedrop(args: argparse.Namespace) -> int: """Perform backup of the SecureDrop Application Server. Creates a tarball of submissions and server config, and fetches @@ -789,6 +841,7 @@ def backup_securedrop(args: argparse.Namespace) -> int: return subprocess.check_call(ansible_cmd, cwd=args.ansible_path) +@update_check_required("restore") def restore_securedrop(args: argparse.Namespace) -> int: """Perform restore of the SecureDrop Application Server. Requires a tarball of submissions and server config, created via @@ -825,6 +878,7 @@ def restore_securedrop(args: argparse.Namespace) -> int: return subprocess.check_call(ansible_cmd, cwd=args.ansible_path) +@update_check_required("tailsconfig") def run_tails_config(args: argparse.Namespace) -> int: """Configure Tails environment post SD install""" sdlog.info("Configuring Tails workstation environment") @@ -972,6 +1026,7 @@ def update(args: argparse.Namespace) -> int: return 0 +@update_check_required("logs") def get_logs(args: argparse.Namespace) -> int: """Get logs for forensics and debugging purposes""" sdlog.info("Gathering logs for forensics and debugging") @@ -998,6 +1053,7 @@ def set_default_paths(args: argparse.Namespace) -> argparse.Namespace: return args +@update_check_required("reset_admin_access") def reset_admin_access(args: argparse.Namespace) -> int: """Resets SSH access to the SecureDrop servers, locking it to this Admin Workstation.""" @@ -1021,6 +1077,8 @@ class ArgParseFormatterCombo(argparse.ArgumentDefaultsHelpFormatter, help="Increase verbosity on output") parser.add_argument('-d', action='store_true', default=False, help="Developer mode. Not to be used in production.") + parser.add_argument('--force', action='store_true', required=False, + help="force command execution without update check") parser.add_argument('--root', required=True, help="path to the root of the SecureDrop repository") parser.add_argument('--site-config', diff --git a/admin/tests/test_integration.py b/admin/tests/test_integration.py index b99edf1b515..72a269b816a 100644 --- a/admin/tests/test_integration.py +++ b/admin/tests/test_integration.py @@ -359,7 +359,7 @@ def verify_install_has_valid_config(): Checks that securedrop-admin install validates the configuration. """ cmd = os.path.join(os.path.dirname(CURRENT_DIR), 'securedrop_admin/__init__.py') - child = pexpect.spawn('python {0} --root {1} install'.format(cmd, SD_DIR)) + child = pexpect.spawn('python {0} --force --root {1} install'.format(cmd, SD_DIR)) child.expect(b"SUDO password:", timeout=5) child.close() @@ -369,7 +369,7 @@ def test_install_with_no_config(): Checks that securedrop-admin install complains about a missing config file. """ cmd = os.path.join(os.path.dirname(CURRENT_DIR), 'securedrop_admin/__init__.py') - child = pexpect.spawn('python {0} --root {1} install'.format(cmd, SD_DIR)) + child = pexpect.spawn('python {0} --force --root {1} install'.format(cmd, SD_DIR)) child.expect(b'ERROR: Please run "securedrop-admin sdconfig" first.', timeout=5) child.expect(pexpect.EOF, timeout=5) child.close() @@ -380,7 +380,7 @@ def test_install_with_no_config(): def test_sdconfig_on_first_run(): cmd = os.path.join(os.path.dirname(CURRENT_DIR), 'securedrop_admin/__init__.py') - child = pexpect.spawn('python {0} --root {1} sdconfig'.format(cmd, SD_DIR)) + child = pexpect.spawn('python {0} --force --root {1} sdconfig'.format(cmd, SD_DIR)) verify_username_prompt(child) child.sendline('') verify_reboot_prompt(child) @@ -444,7 +444,7 @@ def test_sdconfig_on_first_run(): def test_sdconfig_both_v2_v3_true(): cmd = os.path.join(os.path.dirname(CURRENT_DIR), 'securedrop_admin/__init__.py') - child = pexpect.spawn('python {0} --root {1} sdconfig'.format(cmd, SD_DIR)) + child = pexpect.spawn('python {0} --force --root {1} sdconfig'.format(cmd, SD_DIR)) verify_username_prompt(child) child.sendline('') verify_reboot_prompt(child) @@ -508,7 +508,7 @@ def test_sdconfig_both_v2_v3_true(): def test_sdconfig_only_v2_true(): cmd = os.path.join(os.path.dirname(CURRENT_DIR), 'securedrop_admin/__init__.py') - child = pexpect.spawn('python {0} --root {1} sdconfig'.format(cmd, SD_DIR)) + child = pexpect.spawn('python {0} --force --root {1} sdconfig'.format(cmd, SD_DIR)) verify_username_prompt(child) child.sendline('') verify_reboot_prompt(child) @@ -572,7 +572,7 @@ def test_sdconfig_only_v2_true(): def test_sdconfig_enable_journalist_alerts(): cmd = os.path.join(os.path.dirname(CURRENT_DIR), 'securedrop_admin/__init__.py') - child = pexpect.spawn('python {0} --root {1} sdconfig'.format(cmd, SD_DIR)) + child = pexpect.spawn('python {0} --force --root {1} sdconfig'.format(cmd, SD_DIR)) verify_username_prompt(child) child.sendline('') verify_reboot_prompt(child) @@ -641,7 +641,7 @@ def test_sdconfig_enable_journalist_alerts(): def test_sdconfig_enable_https_on_source_interface(): cmd = os.path.join(os.path.dirname(CURRENT_DIR), 'securedrop_admin/__init__.py') - child = pexpect.spawn('python {0} --root {1} sdconfig'.format(cmd, SD_DIR)) + child = pexpect.spawn('python {0} --force --root {1} sdconfig'.format(cmd, SD_DIR)) verify_username_prompt(child) child.sendline('') verify_reboot_prompt(child) diff --git a/admin/tests/test_securedrop-admin.py b/admin/tests/test_securedrop-admin.py index 12661d883ee..167e05260fd 100644 --- a/admin/tests/test_securedrop-admin.py +++ b/admin/tests/test_securedrop-admin.py @@ -55,6 +55,72 @@ def test_not_verbose(self, capsys): assert 'HIDDEN' not in out assert 'VISIBLE' in out + def test_update_check_decorator_when_no_update_needed(self, caplog): + """ + When a function decorated with `@update_check_required` is run + And the `--force` argument was not given + And no update is required + Then the update check should run to completion + And no errors should be displayed + And the program should not exit + And the decorated function should be run + """ + with mock.patch( + "securedrop_admin.check_for_updates", side_effect=[[False, "1.5.0"]] + ) as mocked_check, mock.patch("sys.exit") as mocked_exit: + # The decorator itself interprets --force + args = argparse.Namespace(force=False) + rv = securedrop_admin.update_check_required("update_check_test")( + lambda _: 100 + )(args) + assert mocked_check.called + assert not mocked_exit.called + assert rv == 100 + assert caplog.text == '' + + def test_update_check_decorator_when_update_needed(self, caplog): + """ + When a function decorated with `@update_check_required` is run + And the `--force` argument was not given + And an update is required + Then the update check should run to completion + And an error referencing the command should be displayed + And the program should exit + """ + with mock.patch( + "securedrop_admin.check_for_updates", side_effect=[[True, "1.5.0"]] + ) as mocked_check, mock.patch("sys.exit") as mocked_exit: + # The decorator itself interprets --force + args = argparse.Namespace(force=False) + securedrop_admin.update_check_required("update_check_test")( + lambda _: _ + )(args) + assert mocked_check.called + assert mocked_exit.called + assert "update_check_test" in caplog.text + + def test_update_check_decorator_when_skipped(self, caplog): + """ + When a function decorated with `@update_check_required` is run + And the `--force` argument was given + Then the update check should not run + And a message should be displayed acknowledging this + And the program should not exit + And the decorated function should be run + """ + with mock.patch( + "securedrop_admin.check_for_updates", side_effect=[[True, "1.5.0"]] + ) as mocked_check, mock.patch("sys.exit") as mocked_exit: + # The decorator itself interprets --force + args = argparse.Namespace(force=True) + rv = securedrop_admin.update_check_required("update_check_test")( + lambda _: 100 + )(args) + assert not mocked_check.called + assert not mocked_exit.called + assert "--force" in caplog.text + assert rv == 100 + def test_check_for_updates_update_needed(self, tmpdir, caplog): git_repo_path = str(tmpdir) args = argparse.Namespace(root=git_repo_path)