Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add security notification script sdw-notify #445

Merged
merged 14 commits into from
Feb 13, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ jobs:
virtualenv .venv
source .venv/bin/activate
pip install --require-hashes -r test-requirements.txt
sudo apt install lsof
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lsof is available on Mac and under Linux, and therefore seemed like the best fit to perform a cross-platform test for exclusive lock access. It is, however, not installed in this particular CircleCI container by default.

make test && make bandit && make black

workflows:
Expand Down
2 changes: 2 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,6 @@ include scripts/*
include sys-firewall/*
include launcher/*.py
include launcher/sdw_updater_gui/*.py
include launcher/sdw_notify/*.py
include launcher/sdw_util/*.py
include usb-autoattach/*
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.1.2
0.1.3
10 changes: 10 additions & 0 deletions dom0/sd-clean-all.sls
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ remove-dom0-sdw-config-files:
- /home/{{ gui_user }}/Desktop/securedrop-launcher.desktop
- /home/{{ gui_user }}/.securedrop_launcher

sd-cleanup-crontab:
file.replace:
- name: /etc/crontab
- pattern: '### BEGIN securedrop-workstation ###.*### END securedrop-workstation ###\s*'
- flags:
- MULTILINE
- DOTALL
- repl: ''
- backup: no

sd-cleanup-sys-firewall:
cmd.run:
- names:
Expand Down
20 changes: 20 additions & 0 deletions dom0/sd-dom0-crontab.sls
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# vim: set syntax=yaml ts=2 sw=2 sts=2 et :
##
# Update /etc/crontab with any cron jobs we need to run regularly
##

# Identify the GUI user by group membership
{% set gui_user = salt['cmd.shell']('groupmems -l -g qubes') %}

# Add an hourly job, run as the GUI user, to display a warning if the
# SecureDrop preflight updater has not run for longer than a defined
# warning threshold.
dom0-crontab-update-notify:
file.blockreplace:
- name: /etc/crontab
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/cron.hourly will run as root, so modifying /etc/crontab appears to be the cleanest way to run as the gui_user, which is necessary in order to run with the same permissions as the preflight updater.

- append_if_not_found: True
- marker_start: "### BEGIN securedrop-workstation ###"
- marker_end: "### END securedrop-workstation ###"
- content: |
0 * * * * {{gui_user}} DISPLAY=:0 /opt/securedrop/launcher/sdw-notify.py
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DISPLAY=:0 environment varialbe is necessary to display a graphical warning from a cron job without a display.

6 changes: 4 additions & 2 deletions dom0/sd-dom0-files.sls
Original file line number Diff line number Diff line change
Expand Up @@ -180,9 +180,11 @@ dom0-securedrop-launcher-directory:
- file_mode: 644
- dir_mode: 755

dom0-securedrop-launcher-entrypoint-executable:
dom0-securedrop-launcher-executables:
file.managed:
- name: /opt/securedrop/launcher/sdw-launcher.py
- names:
- /opt/securedrop/launcher/sdw-launcher.py
- /opt/securedrop/launcher/sdw-notify.py
- user: root
- group: root
- mode: 755
Expand Down
1 change: 1 addition & 0 deletions dom0/sd-workstation.top
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ base:
dom0:
- sd-sys-vms
- sd-dom0-files
- sd-dom0-crontab
- sd-workstation-template
- sd-upgrade-templates
- sd-dom0-qvm-rpc
Expand Down
2 changes: 1 addition & 1 deletion launcher/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
Expand Down
45 changes: 11 additions & 34 deletions launcher/sdw-launcher.py
Original file line number Diff line number Diff line change
@@ -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()
62 changes: 62 additions & 0 deletions launcher/sdw-notify.py
Original file line number Diff line number Diff line change
@@ -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():
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given how straightforward the QT logic is here, I think it's fine to leave as is, but if ever we introduce further complexity/stying here, I would recommend we decouple the main from the QT code, and simply instanciate the QT app object in the main, and handle all other QT tasks in a separate file/class

"""
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()
121 changes: 121 additions & 0 deletions launcher/sdw_notify/Notify.py
Original file line number Diff line number Diff line change
@@ -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"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: using the variable from Updater.py or storing in Utils.py could help reduce duplication here


# 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
7 changes: 5 additions & 2 deletions launcher/sdw_updater_gui/Updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down
Loading