Skip to content

Commit

Permalink
Merge pull request #445 from pierwill/uptime-notification-squashed
Browse files Browse the repository at this point in the history
Add security notification script sdw-notify
  • Loading branch information
conorsch authored Feb 13, 2020
2 parents 5126a34 + b829b94 commit 7dbde55
Show file tree
Hide file tree
Showing 11 changed files with 661 additions and 46 deletions.
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
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():
"""
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"

# 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
96 changes: 96 additions & 0 deletions launcher/sdw_util/Util.py
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 7dbde55

Please sign in to comment.