diff --git a/VERSION b/VERSION index d917d3e2..b1e80bb2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.2 +0.1.3 diff --git a/launcher/Makefile b/launcher/Makefile index a89b1532..f5530427 100644 --- a/launcher/Makefile +++ b/launcher/Makefile @@ -8,7 +8,7 @@ bandit: .PHONY: test test: - pytest --cov-report term-missing --cov=sdw_updater_gui/ -v tests/ + pytest --cov-report term-missing --cov=sdw_notify --cov=sdw_updater_gui/ --cov=sdw_util -v tests/ black: ## Runs the black code formatter on the launcher code black --check . diff --git a/launcher/sdw-launcher.py b/launcher/sdw-launcher.py index 6831bb49..5d67ee3f 100644 --- a/launcher/sdw-launcher.py +++ b/launcher/sdw-launcher.py @@ -1,50 +1,27 @@ #!/usr/bin/env python3 -from logging.handlers import TimedRotatingFileHandler from PyQt4 import QtGui from sdw_updater_gui.UpdaterApp import UpdaterApp +from sdw_util import Util +from sdw_updater_gui import Updater + import logging -import os import sys -DEFAULT_HOME = os.path.join(os.path.expanduser("~"), ".securedrop_launcher") -logger = "" - def main(): - configure_logging() - logger = logging.getLogger(__name__) - logger.info("Starting SecureDrop Launcher") + sdlog = logging.getLogger(__name__) + Util.configure_logging(Updater.LOG_FILE) + lock_handle = Util.obtain_lock(Updater.LOCK_FILE) + if lock_handle is None: + # Preflight updater already running or problems accessing lockfile. + # Logged. + sys.exit(1) + sdlog.info("Starting SecureDrop Launcher") app = QtGui.QApplication(sys.argv) form = UpdaterApp() form.show() sys.exit(app.exec_()) -def configure_logging(): - """ - All logging related settings are set up by this function. - """ - log_folder = os.path.join(DEFAULT_HOME, "logs") - if not os.path.exists(log_folder): - os.makedirs(log_folder) - - log_file = os.path.join(DEFAULT_HOME, "logs", "launcher.log") - - # set logging format - log_fmt = ( - "%(asctime)s - %(name)s:%(lineno)d(%(funcName)s) " "%(levelname)s: %(message)s" - ) - formatter = logging.Formatter(log_fmt) - - handler = TimedRotatingFileHandler(log_file) - handler.setFormatter(formatter) - handler.setLevel(logging.INFO) - - # set up primary log - log = logging.getLogger() - log.setLevel(logging.INFO) - log.addHandler(handler) - - if __name__ == "__main__": main() diff --git a/launcher/sdw-notify.py b/launcher/sdw-notify.py new file mode 100755 index 00000000..3e0bfc62 --- /dev/null +++ b/launcher/sdw-notify.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +""" +Displays a warning to the user if the workstation has been running continuously +for too long without checking for security updates. Writes output to a logfile, +not stdout. All settings are in Notify utility module. +""" + +import sys + +from sdw_notify import Notify +from sdw_updater_gui import Updater +from sdw_util import Util +from PyQt4 import QtGui +from PyQt4.QtGui import QMessageBox + + +def main(): + """ + Show security warning, if and only if a warning is not already displayed, + the preflight updater is running, and certain checks suggest that the + system has not been updated for a specified period + """ + + Util.configure_logging(Notify.LOG_FILE) + if Util.can_obtain_lock(Updater.LOCK_FILE) is False: + # Preflight updater is already running. Logged. + sys.exit(1) + + # Hold on to lock handle during execution + lock_handle = Util.obtain_lock(Notify.LOCK_FILE) + if lock_handle is None: + # Can't write to lockfile or notifier already running. Logged. + sys.exit(1) + + warning_should_be_shown = Notify.is_update_check_necessary() + if warning_should_be_shown is None: + # Data integrity issue with update timestamp. Logged. + sys.exit(1) + elif warning_should_be_shown is True: + show_update_warning() + + +def show_update_warning(): + """ + Show a graphical warning reminding the user to check for security updates + using the preflight updater. + """ + app = QtGui.QApplication([]) # noqa: F841 + + QMessageBox.warning( + None, + "Security check recommended", + "This computer has not been checked for security updates recently. " + "We recommend that you launch or restart the SecureDrop app to " + "check for security updates.", + QMessageBox.Ok, + QMessageBox.Ok, + ) + + +if __name__ == "__main__": + main() diff --git a/launcher/sdw_notify/Notify.py b/launcher/sdw_notify/Notify.py new file mode 100644 index 00000000..22470174 --- /dev/null +++ b/launcher/sdw_notify/Notify.py @@ -0,0 +1,121 @@ +""" +Utility library for warning the user that security updates have not been applied +in some time. +""" +import logging +import os + +from datetime import datetime + +sdlog = logging.getLogger(__name__) + +# The directory where status files and logs are stored +BASE_DIRECTORY = os.path.join(os.path.expanduser("~"), ".securedrop_launcher") + +# The file and format that contains the timestamp of the last successful update +LAST_UPDATED_FILE = os.path.join(BASE_DIRECTORY, "sdw-last-updated") +LAST_UPDATED_FORMAT = "%Y-%m-%d %H:%M:%S" + +# The lockfile basename used to ensure this script can only be executed once. +# Default path for lockfiles is specified in sdw_util +LOCK_FILE = "sdw-notify.lock" + +# Log file name, base directories defined in sdw_util +LOG_FILE = "sdw-notify.log" + +# The maximum uptime this script should permit (specified in seconds) before +# showing a warning. This is to avoid situations where the user boots the +# computer after several days and immediately sees a warning. +UPTIME_GRACE_PERIOD = 1800 # 30 minutes + +# The amount of time without updates (specified in seconds) which this script +# should permit before showing a warning to the user +WARNING_THRESHOLD = 432000 # 5 days + + +def is_update_check_necessary(): + """ + Perform a series of checks to determine if a security warning should be + shown to the user, reminding them to check for available software updates + using the preflight updater. + """ + last_updated_file_exists = os.path.exists(LAST_UPDATED_FILE) + # For consistent logging + grace_period_hours = UPTIME_GRACE_PERIOD / 60 / 60 + warning_threshold_hours = WARNING_THRESHOLD / 60 / 60 + + # Get timestamp from last update (if it exists) + if last_updated_file_exists: + with open(LAST_UPDATED_FILE, "r") as f: + last_update_time = f.readline().splitlines()[0] + try: + last_update_time = datetime.strptime(last_update_time, LAST_UPDATED_FORMAT) + except ValueError: + sdlog.error( + "Data in {} not in the expected format. " + "Expecting a timestamp in format '{}'. " + "Showing security warning.".format( + LAST_UPDATED_FILE, LAST_UPDATED_FORMAT + ) + ) + return True + + now = datetime.now() + updated_seconds_ago = (now - last_update_time).total_seconds() + updated_hours_ago = updated_seconds_ago / 60 / 60 + + uptime_seconds = get_uptime_seconds() + uptime_hours = uptime_seconds / 60 / 60 + + if not last_updated_file_exists: + sdlog.warning( + "Timestamp file '{}' does not exist. " + "Updater may never have run. Showing security warning.".format( + LAST_UPDATED_FILE + ) + ) + return True + else: + if updated_seconds_ago > WARNING_THRESHOLD: + if uptime_seconds > UPTIME_GRACE_PERIOD: + sdlog.warning( + "Last successful update ({0:.1f} hours ago) is above " + "warning threshold ({1:.1f} hours). Uptime grace period of " + "{2:.1f} hours has elapsed (uptime: {3:.1f} hours). " + "Showing security warning.".format( + updated_hours_ago, + warning_threshold_hours, + grace_period_hours, + uptime_hours, + ) + ) + return True + else: + sdlog.info( + "Last successful update ({0:.1f} hours ago) is above " + "warning threshold ({1:.1f} hours). Uptime grace period " + "of {2:.1f} hours has not elapsed yet (uptime: {3:.1f} " + "hours). Exiting without warning.".format( + updated_hours_ago, + warning_threshold_hours, + grace_period_hours, + uptime_hours, + ) + ) + return False + else: + sdlog.info( + "Last successful update ({0:.1f} hours ago) " + "is below the warning threshold ({1:.1f} hours). " + "Exiting without warning.".format( + updated_hours_ago, warning_threshold_hours + ) + ) + return False + + +def get_uptime_seconds(): + # Obtain current uptime + with open("/proc/uptime", "r") as f: + uptime_seconds = float(f.readline().split()[0]) + return uptime_seconds diff --git a/launcher/sdw_updater_gui/Updater.py b/launcher/sdw_updater_gui/Updater.py index 518fb8f5..40f4f9fd 100644 --- a/launcher/sdw_updater_gui/Updater.py +++ b/launcher/sdw_updater_gui/Updater.py @@ -15,10 +15,13 @@ from enum import Enum DATE_FORMAT = "%Y-%m-%d %H:%M:%S" +DEFAULT_HOME = ".securedrop_launcher" FLAG_FILE_STATUS_SD_APP = "/home/user/.securedrop_client/sdw-update-status" FLAG_FILE_LAST_UPDATED_SD_APP = "/home/user/.securedrop_client/sdw-last-updated" -FLAG_FILE_STATUS_DOM0 = ".securedrop_launcher/sdw-update-status" -FLAG_FILE_LAST_UPDATED_DOM0 = ".securedrop_launcher/sdw-last-updated" +FLAG_FILE_STATUS_DOM0 = os.path.join(DEFAULT_HOME, "sdw-update-status") +FLAG_FILE_LAST_UPDATED_DOM0 = os.path.join(DEFAULT_HOME, "sdw-last-updated") +LOCK_FILE = "sdw-launcher.lock" +LOG_FILE = "launcher.log" sdlog = logging.getLogger(__name__) diff --git a/launcher/sdw_util/Util.py b/launcher/sdw_util/Util.py new file mode 100644 index 00000000..1aaaae82 --- /dev/null +++ b/launcher/sdw_util/Util.py @@ -0,0 +1,96 @@ +""" +Utility functions used by both the launcher and notifier scripts +""" + +import fcntl +import os +import logging + +from logging.handlers import TimedRotatingFileHandler + +# The directory where status files and logs are stored +BASE_DIRECTORY = os.path.join(os.path.expanduser("~"), ".securedrop_launcher") + +# Directory for lock files to avoid contention or multiple instantiation. +LOCK_DIRECTORY = os.path.join("/run/user", str(os.getuid())) + +# Folder where logs are stored +LOG_DIRECTORY = os.path.join(BASE_DIRECTORY, "logs") + +# Shared error string +LOCK_ERROR = "Error obtaining lock on '{}'. Process may already be running." + +# Format for those logs +LOG_FORMAT = ( + "%(asctime)s - %(name)s:%(lineno)d(%(funcName)s) " "%(levelname)s: %(message)s" +) + +sdlog = logging.getLogger(__name__) + + +def configure_logging(log_file): + """ + All logging related settings are set up by this function. + """ + + if not os.path.exists(LOG_DIRECTORY): + os.makedirs(LOG_DIRECTORY) + + formatter = logging.Formatter((LOG_FORMAT)) + + handler = TimedRotatingFileHandler(os.path.join(LOG_DIRECTORY, log_file)) + handler.setFormatter(formatter) + handler.setLevel(logging.INFO) + + log = logging.getLogger() + log.setLevel(logging.INFO) + log.addHandler(handler) + + +def obtain_lock(basename): + """ + Obtain an exclusive lock during the execution of this process. + """ + lock_file = os.path.join(LOCK_DIRECTORY, basename) + try: + lh = open(lock_file, "w") + except PermissionError: # noqa: F821 + sdlog.error( + "Error writing to lock file '{}'. User may lack the " + "required permissions.".format(lock_file) + ) + return None + + try: + # Obtain an exclusive, nonblocking lock + fcntl.lockf(lh, fcntl.LOCK_EX | fcntl.LOCK_NB) + except IOError: + sdlog.error(LOCK_ERROR.format(lock_file)) + return None + + return lh + + +def can_obtain_lock(basename): + """ + We temporarily obtain a shared, nonblocking lock to a lockfile to determine + whether the associated process is currently running. Returns True if it is + safe to continue execution (no lock conflict), False if not. + + `basename` is the basename of a lockfile situated in the LOCK_DIRECTORY. + """ + lock_file = os.path.join(LOCK_DIRECTORY, basename) + try: + lh = open(lock_file, "r") + except FileNotFoundError: # noqa: F821 + # Process may not have run during this session, safe to continue + return True + + try: + # Obtain a nonblocking, shared lock + fcntl.lockf(lh, fcntl.LOCK_SH | fcntl.LOCK_NB) + except IOError: + sdlog.error(LOCK_ERROR.format(lock_file)) + return False + + return True diff --git a/launcher/tests/test_notify.py b/launcher/tests/test_notify.py new file mode 100644 index 00000000..04a97290 --- /dev/null +++ b/launcher/tests/test_notify.py @@ -0,0 +1,173 @@ +import datetime +import os +import pytest +import re + +from unittest import mock +from importlib.machinery import SourceFileLoader +from tempfile import TemporaryDirectory + +relpath_notify = "../sdw_notify/Notify.py" +path_to_notify = os.path.join( + os.path.dirname(os.path.abspath(__file__)), relpath_notify +) +notify = SourceFileLoader("Notify", path_to_notify).load_module() + +relpath_updater = "../sdw_updater_gui/Updater.py" +path_to_updater = os.path.join( + os.path.dirname(os.path.abspath(__file__)), relpath_updater +) +updater = SourceFileLoader("Updater", path_to_updater).load_module() + + +# Regex for warning log if the last-updated timestamp does not exist (updater +# has never run) +NO_TIMESTAMP_REGEX = r"Timestamp file '.*' does not exist." + +# Regex for warning log if we've updated too long ago, and grace period has elapsed +UPDATER_WARNING_REGEX = ( + r"^Last successful update \(.* hours ago\) is above warning threshold " + r"\(.* hours\). Uptime grace period of .* hours has elapsed \(uptime: .* hours\)." +) + +# Regex for info log if we've updated too long ago, but grace period still ticking +GRACE_PERIOD_REGEX = ( + r"Last successful update \(.* hours ago\) is above " + r"warning threshold \(.* hours\). Uptime grace period of .* hours has not elapsed " + r"yet \(uptime: .* hours\)." +) + +# Regex for info log if we've updated recently enough +NO_WARNING_REGEX = ( + r"Last successful update \(.* hours ago\) is below the warning threshold " + r"\(.* hours\)." +) + +# Regex for bad contents in `sdw-last-updated` file +BAD_TIMESTAMP_REGEX = r"Data in .* not in the expected format." + + +@mock.patch("Notify.sdlog.error") +@mock.patch("Notify.sdlog.warning") +@mock.patch("Notify.sdlog.info") +def test_warning_shown_if_updater_never_ran(mocked_info, mocked_warning, mocked_error): + """ + Test whether we're correctly going to show a warning if the updater has + never run. + """ + # We're going to look for a nonexistent file in an existing tmpdir + with TemporaryDirectory() as tmpdir, mock.patch( + "Notify.LAST_UPDATED_FILE", os.path.join(tmpdir, "not-a-file") + ): + + warning_should_be_shown = notify.is_update_check_necessary() + + # No handled errors should occur + assert not mocked_error.called + + # We display a warning, because this file should always exist + assert warning_should_be_shown is True + + # A warning should also be logged + mocked_warning.assert_called_once() + + # Ensure warning matches expected output + warning_string = mocked_warning.call_args[0][0] + assert re.search(NO_TIMESTAMP_REGEX, warning_string) is not None + + +@pytest.mark.parametrize( + "uptime,warning_expected", + [(notify.UPTIME_GRACE_PERIOD + 1, True), (notify.UPTIME_GRACE_PERIOD - 1, False)], +) +@mock.patch("Notify.sdlog.error") +@mock.patch("Notify.sdlog.warning") +@mock.patch("Notify.sdlog.info") +def test_warning_shown_if_warning_threshold_exceeded( + mocked_info, mocked_warning, mocked_error, uptime, warning_expected +): + """ + Primary use case for the notifier: are we showing the warning if the + system hasn't been (successfully) updated for longer than the warning + threshold? Expected result varies based on whether system uptime exceeds + a grace period (for the user to launch the app on their own). + """ + with TemporaryDirectory() as tmpdir, mock.patch( + "Notify.LAST_UPDATED_FILE", os.path.join(tmpdir, "sdw-last-updated") + ): + # Write a "last successfully updated" date well in the past for check + historic_date = datetime.date(2013, 6, 5).strftime(updater.DATE_FORMAT) + with open(notify.LAST_UPDATED_FILE, "w") as f: + f.write(historic_date) + + with mock.patch("Notify.get_uptime_seconds") as mocked_uptime: + mocked_uptime.return_value = uptime + warning_should_be_shown = notify.is_update_check_necessary() + assert warning_should_be_shown is warning_expected + # No handled errors should occur + assert not mocked_error.called + # A warning should also be logged + if warning_expected is True: + mocked_warning.assert_called_once() + warning_string = mocked_warning.call_args[0][0] + assert re.search(UPDATER_WARNING_REGEX, warning_string) is not None + else: + assert not mocked_warning.called + mocked_info.assert_called_once() + info_string = mocked_info.call_args[0][0] + assert re.search(GRACE_PERIOD_REGEX, info_string) is not None + + +@mock.patch("Notify.sdlog.error") +@mock.patch("Notify.sdlog.warning") +@mock.patch("Notify.sdlog.info") +def test_warning_not_shown_if_warning_threshold_not_exceeded( + mocked_info, mocked_warning, mocked_error +): + """ + Another high priority case: we don't want to warn the user if they've + recently run the updater successfully. + """ + with TemporaryDirectory() as tmpdir, mock.patch( + "Notify.LAST_UPDATED_FILE", os.path.join(tmpdir, "sdw-last-updated") + ): + # Write current timestamp into the file + just_now = datetime.datetime.now().strftime(updater.DATE_FORMAT) + with open(notify.LAST_UPDATED_FILE, "w") as f: + f.write(just_now) + warning_should_be_shown = notify.is_update_check_necessary() + assert warning_should_be_shown is False + assert not mocked_error.called + assert not mocked_warning.called + info_string = mocked_info.call_args[0][0] + assert re.search(NO_WARNING_REGEX, info_string) is not None + + +@mock.patch("Notify.sdlog.error") +@mock.patch("Notify.sdlog.warning") +@mock.patch("Notify.sdlog.info") +def test_corrupt_timestamp_file_handled(mocked_info, mocked_warning, mocked_error): + """ + The LAST_UPDATED_FILE must contain a timestamp in a specified format; + if it doesn't, we show the warning and log the error. + """ + with TemporaryDirectory() as tmpdir, mock.patch( + "Notify.LAST_UPDATED_FILE", os.path.join(tmpdir, "sdw-last-updated") + ): + with open(notify.LAST_UPDATED_FILE, "w") as f: + # With apologies to HAL 9000 + f.write("daisy, daisy, give me your answer do") + warning_should_be_shown = notify.is_update_check_necessary() + assert warning_should_be_shown is True + mocked_error.assert_called_once() + error_string = mocked_error.call_args[0][0] + assert re.search(BAD_TIMESTAMP_REGEX, error_string) is not None + + +def test_uptime_is_sane(): + """ + Even in a CI container this should be greater than zero :-) + """ + seconds = notify.get_uptime_seconds() + assert isinstance(seconds, float) + assert seconds > 0 diff --git a/launcher/tests/test_updater.py b/launcher/tests/test_updater.py index f6de8793..d33324f6 100644 --- a/launcher/tests/test_updater.py +++ b/launcher/tests/test_updater.py @@ -1,9 +1,9 @@ -import imp import json import os import pytest import subprocess from datetime import datetime +from importlib.machinery import SourceFileLoader from tempfile import TemporaryDirectory from unittest import mock from unittest.mock import call @@ -12,7 +12,7 @@ path_to_script = os.path.join( os.path.dirname(os.path.abspath(__file__)), relpath_updater_script ) -updater = imp.load_source("Updater", path_to_script) +updater = SourceFileLoader("Updater", path_to_script).load_module() from Updater import UpdateStatus # noqa: E402 from Updater import current_templates # noqa: E402 diff --git a/launcher/tests/test_util.py b/launcher/tests/test_util.py new file mode 100644 index 00000000..843de9ec --- /dev/null +++ b/launcher/tests/test_util.py @@ -0,0 +1,168 @@ +import os +import re +import subprocess + +from unittest import mock +from importlib.machinery import SourceFileLoader +from tempfile import TemporaryDirectory + +# Regex for lock conflicts +BUSY_LOCK_REGEX = r"Error obtaining lock on '.*'." + +# Regex for failure to obtain lock due to permission error +LOCK_PERMISSION_REGEX = r"Error writing to lock file '.*'" + +relpath_util = "../sdw_util/Util.py" +path_to_util = os.path.join(os.path.dirname(os.path.abspath(__file__)), relpath_util) +util = SourceFileLoader("Util", path_to_util).load_module() + + +@mock.patch("Util.sdlog.error") +@mock.patch("Util.sdlog.warning") +@mock.patch("Util.sdlog.info") +def test_obtain_lock(mocked_info, mocked_warning, mocked_error): + """ + Test whether we can successfully obtain an exclusive lock + """ + with TemporaryDirectory() as tmpdir, mock.patch("Util.LOCK_DIRECTORY", tmpdir): + + basename = "test-obtain-lock.lock" + pid = os.getpid() + lh = util.obtain_lock(basename) # noqa: F841 + # No handled exception should occur + assert not mocked_error.called + # We should be getting a lock handle back + assert lh is not None + + cmd = ["lsof", "-w", os.path.join(util.LOCK_DIRECTORY, basename)] + output_lines = subprocess.check_output(cmd).decode("utf-8").strip().split("\n") + # We expect exactly one process to be accessing this file, plus output header + assert len(output_lines) == 2 + lsof_data = output_lines[1].split() + # We expect the output to have the standard number of fields + assert len(lsof_data) == 9 + # We expect the PID column to contain the ID of this process + assert lsof_data[1] == str(pid) + # We expect an exclusive write lock to be set for this process + assert lsof_data[3].find("W") != -1 + + +@mock.patch("Util.sdlog.error") +@mock.patch("Util.sdlog.warning") +@mock.patch("Util.sdlog.info") +def test_cannot_obtain_exclusive_lock_when_busy( + mocked_info, mocked_warning, mocked_error +): + """ + Test whether only a single process can obtan an exclusive lock (basic + lockfile behavior). + + This is used to prevent multiple preflight updaters or multiple notifiers + from being instantiated. + """ + with TemporaryDirectory() as tmpdir, mock.patch("Util.LOCK_DIRECTORY", tmpdir): + + basename = "test-exclusive-lock.lock" + lh1 = util.obtain_lock(basename) # noqa: F841 + + # We're running in the same process, so obtaining a lock will succeed. + # Instead we're mocking the IOError lockf would raise. + with mock.patch("fcntl.lockf", side_effect=IOError()) as mocked_lockf: + lh2 = util.obtain_lock(basename) + mocked_lockf.assert_called_once() + assert lh2 is None + error_string = mocked_error.call_args[0][0] + assert re.search(BUSY_LOCK_REGEX, error_string) is not None + + +@mock.patch("Util.sdlog.error") +@mock.patch("Util.sdlog.warning") +@mock.patch("Util.sdlog.info") +def test_cannot_obtain_shared_lock_when_busy(mocked_info, mocked_warning, mocked_error): + """ + Test whether an exlusive lock on a lock file is successfully detected + by means of attempting to obtain a shared, nonexclusive lock on the same + file. + + In the preflight updater / notifier, this is used to prevent the notification + from being displayed when the preflight updater is already open. + """ + with TemporaryDirectory() as tmpdir, mock.patch("Util.LOCK_DIRECTORY", tmpdir): + + basename = "test-conflict.lock" + lh = util.obtain_lock(basename) # noqa: F841 + + # We're running in the same process, so obtaining a lock will succeed. + # Instead we're mocking the IOError lockf would raise. + with mock.patch("fcntl.lockf", side_effect=IOError()) as mocked_lockf: + can_get_lock = util.can_obtain_lock(basename) + mocked_lockf.assert_called_once() + assert can_get_lock is False + error_string = mocked_error.call_args[0][0] + assert re.search(BUSY_LOCK_REGEX, error_string) is not None + + +@mock.patch("Util.sdlog.error") +@mock.patch("Util.sdlog.warning") +@mock.patch("Util.sdlog.info") +def test_no_lockfile_no_problems(mocked_info, mocked_warning, mocked_error): + """ + Test whether our shared lock test succeeds even when there's no lockfile + (which means the process has not run recently, or ever, and it's safe to + run the potentially conflicting process). + """ + with TemporaryDirectory() as tmpdir, mock.patch("Util.LOCK_DIRECTORY", tmpdir): + lock_result = util.can_obtain_lock("404.lock") + assert lock_result is True + + +@mock.patch("Util.sdlog.error") +@mock.patch("Util.sdlog.warning") +@mock.patch("Util.sdlog.info") +def test_permission_error_is_handled(mocked_info, mocked_warning, mocked_error): + """ + Test whether permission errors obtaining a lock are handled correctly + """ + with mock.patch( + "builtins.open", side_effect=PermissionError() + ) as mocked_open: # noqa: F821 + lock = util.obtain_lock("test-open-error.lock") + assert lock is None + mocked_open.assert_called_once() + mocked_error.assert_called_once() + error_string = mocked_error.call_args[0][0] + assert re.search(LOCK_PERMISSION_REGEX, error_string) is not None + + +@mock.patch("Util.sdlog.error") +@mock.patch("Util.sdlog.warning") +@mock.patch("Util.sdlog.info") +def test_stale_lockfile_has_no_effect(mocked_info, mocked_warning, mocked_error): + """ + Test whether we can get a shared lock when a lockfile exists, but nobody + is accessing it. + """ + with TemporaryDirectory() as tmpdir, mock.patch("Util.LOCK_DIRECTORY", tmpdir): + + # Because we're not assigning the return value, it will be immediately released + basename = "test-stale.lock" + util.obtain_lock(basename) + lock_result = util.can_obtain_lock(basename) + assert lock_result is True + + +def test_log(): + """ + Test whether we can successfully write to a log file + """ + with TemporaryDirectory() as tmpdir, mock.patch("Util.LOG_DIRECTORY", tmpdir): + basename = "test.log" + # configure_logging is expected to re-create the directory. + os.rmdir(tmpdir) + util.configure_logging(basename) + util.sdlog.info("info level log entry") + util.sdlog.warning("error level log entry") + util.sdlog.error("error level log entry") + path = os.path.join(tmpdir, basename) + count = len(open(path).readlines()) + assert count == 3 diff --git a/rpm-build/SPECS/securedrop-workstation-dom0-config.spec b/rpm-build/SPECS/securedrop-workstation-dom0-config.spec index 7b4fe412..fc28a458 100644 --- a/rpm-build/SPECS/securedrop-workstation-dom0-config.spec +++ b/rpm-build/SPECS/securedrop-workstation-dom0-config.spec @@ -1,12 +1,12 @@ Name: securedrop-workstation-dom0-config -Version: 0.1.2 +Version: 0.1.3 Release: 1%{?dist} Summary: SecureDrop Workstation Group: Library License: GPLv3+ URL: https://github.com/freedomofpress/securedrop-workstation -Source0: securedrop-workstation-dom0-config-0.1.2.tar.gz +Source0: securedrop-workstation-dom0-config-0.1.3.tar.gz BuildArch: noarch BuildRequires: python3-setuptools @@ -22,10 +22,14 @@ SecureDrop Workstation project. The package should be installed in dom0, or AdminVM, context, in order to manage updates to the VM configuration over time. +# To ensure forward-compatibility of RPMs regardless of updates to the system +# Python, we disable the creation of bytecode at build time via the build +# root policy. +%undefine py_auto_byte_compile + %prep %setup -q - %build %{__python3} setup.py build @@ -33,6 +37,8 @@ configuration over time. %{__python3} setup.py install --no-compile --skip-build --root %{buildroot} install -m 755 -d %{buildroot}/opt/securedrop/launcher install -m 755 -d %{buildroot}/opt/securedrop/launcher/sdw_updater_gui +install -m 755 -d %{buildroot}/opt/securedrop/launcher/sdw_notify +install -m 755 -d %{buildroot}/opt/securedrop/launcher/sdw_util install -m 755 -d %{buildroot}/srv install -m 755 -d %{buildroot}/srv/salt/sd install -m 755 -d %{buildroot}/srv/salt/sd/sd-app @@ -65,17 +71,21 @@ install -m 644 Makefile %{buildroot}/usr/share/%{name}/Makefile install -m 755 scripts/* %{buildroot}/usr/share/%{name}/scripts/ install -m 644 launcher/*.py %{buildroot}/opt/securedrop/launcher/ install -m 644 launcher/sdw_updater_gui/*.py %{buildroot}/opt/securedrop/launcher/sdw_updater_gui/ +install -m 644 launcher/sdw_notify/*.py %{buildroot}/opt/securedrop/launcher/sdw_notify/ +install -m 644 launcher/sdw_util/*.py %{buildroot}/opt/securedrop/launcher/sdw_util/ %files %doc README.md LICENSE +%attr(755, root, root) /opt/securedrop/launcher/sdw-launcher.py +%attr(755, root, root) /opt/securedrop/launcher/sdw-notify.py +%attr(755, root, root) %{_bindir}/securedrop-update +%attr(755, root, root) %{_bindir}/securedrop-admin %{python3_sitelib}/securedrop_workstation_dom0_config* %{_datadir}/%{name} -%{_bindir}/securedrop-update -%{_bindir}/securedrop-admin +/opt/securedrop/launcher/**/*.py /srv/salt/sd* /srv/salt/dom0-xfce-desktop-file.j2 /srv/salt/securedrop-* /srv/salt/fpf* -/opt/securedrop/* %post find /srv/salt -maxdepth 1 -type f -iname '*.top' \ @@ -84,6 +94,11 @@ find /srv/salt -maxdepth 1 -type f -iname '*.top' \ | xargs qubesctl top.enable > /dev/null %changelog +* Tue Feb 11 2020 SecureDrop Team - 0.1.3 +- Adds sdw-notify script +- Sets executable bits within package specification +- Disable build root policy for bytecode generation in package spec + * Mon Feb 03 2020 Mickael E. - 0.1.2 - Provides dev/staging/prod split logic.