Repository: freedomofpress/securedrop

Issue:
Submission email notifications
Checking SecureDrop every day is a huge hassle. Instead, it should tell me about new submissions, but without leaking any information.

Every day at 6am, SecureDrop sends me an encrypted email noting the number of unread submissions and the number of submissions received in the last 24 hours, even if these numbers are zero. If submission categories are used, each category's “owner” is sent the same info about their category.



Discussion:
Just a thought, but it may be unfounded: Wouldn't just stating the number of recent submissions leak information about the time in which they were submitted? Especially since they are some what ordered. This would be most drastic in the case where an outlet has a small number of submissions. Let's say that an outlet has a single source that submits a trove of documents of the course of a few weeks. That source only works two days of the week and is equally likely to submit documents on either day. Doesn't the fact that emails stating when those documents were submitted (even though it is only a 24 hour window) greatly narrow down who could leak the documents since the emails would only show new documents within 24 hours after the source submitted them?

Strong encryption and a constant stream of emails may be enough to mitigate these concerns, but guaranteeing the source's anonymity should be of greater concern than journalist convenience. These are just my thoughts, so don't put too much weight into them.

I don't see how an encrypted email that gets sent out every day whether or not there's a submission reveals any information whatsoever.

@pwplus I don't understand your critique.

The goal here is to avoid leaking information about source behavior to an adversary who is capable of performing traffic analysis on the status emails. The easiest way to do that, while still being convenient for journalists, is to do what @flamsmark suggested in his initial comment, plus a little more. You need to:
1. Send the email on a regular schedule, regardless of the activity on the server. This interval could be configurable by the journalist (every day, 2 days, week, etc.), it simply must be unrelated to any source activity.
2. Encrypt the email end-to-end (obviously)

At this point, the only remaining signal to an adversary is the size of the encrypted email. Each notification email should be padded to ensure that all emails have the same size, regardless of their contents. This requires a unified and well-defined format for all possible notification emails (depending on categories, roles, etc.)

The only subtle point is, once padded, we need to avoid compressing the emails before encryption. Otherwise, an adversary could perform a traffic analysis attack on the size of the emails, similar to the [CRIME](https://en.wikipedia.org/wiki/CRIME) attack on HTTPS. Similar to CRIME, allowing compression in this context would enable a chosen plaintext attack because:
1. The email format will be well-defined (open source)
2. An adversary can perform actions on the Source Interface that will deterministically influence the contents of the emails.

Once encrypted, any further compression (e.g. applied by the mail server or transport) does not lead to a potential attack. I don't think they'll ever be so large that compression makes a meaningful impact on their storage or transmission.

Note that GPG compresses plaintext by default (this is actually a terrible idea, but it's GPG so not a big surprise there). We'd need to pass `-z 0` to gpg to avoid compressing the plaintext in this context.

If the notification is really simple, the size shouldn't indicate much. “There are XX unread submissions of which XX where submitted in the last 24 hours.” doesn't leave a lot of room for adventure.

Or pad notifications with poetry and/or gifs.

Agree that this would be a nice feature and @flamsmark's proposal would be a good way to implement it to mitigate leaking sensitive info. However, before going forward we should keep in mind that this potentially could diminish one of the features of SecureDrop which we talk about when describing the benefits to others: newsrooms often have compromised networks, and that right now, nothing about SD submissions ever touches that network. Right on our front page, we say SecureDrop is designed to be used in high-risk environments like that.

The current workflow requires the journalist log onto their regular workstation using Tails. If we implement this feature, the email would come to their normal workstation, and even if the alert is sent using PGP, if their computer or network is compromised, it is possible for the attacker to either get the PGP key off that computer or wait for the journalist to decrypt it and read it then. 

Of course one could say the same if their firmware if compromised and an attacker can somehow see what they're doing on Tails. However that is at least a much more sophisticated attack. 

In @flamsmark's proposal, the info would be less specific because it only sends it once per day and does not specify the source. Personally I do think the chances are low that this information would be useful to an attacker, and we might decide that the trade off is worth it, but we should at least be conscious of it. 

@trevortimm Good point, thanks for bringing it up. It depends on the content of the notification emails. If they are, as @flamsmark has suggested, simply a summary of meta-activity, e.g.

```
7 new sources
15 new submissions
```

then it's not a concern. If the emails are more detailed (which would make them more useful/helpful), e.g.

```
Since you last logged in, there are 7 new sources.

You have replies from:
    * source name 1
    * source name 2
    * ...

You have new submissions from:
    * source name 1
    * source name 2
    * ...
```

I think the 2nd form would be much more useful, and the source names are designed to be random pseudonyms so it _might_ be acceptable to expose them to a less trusted security domain (the journalist's computer). It does leak some information (metadata) to an adversary who can compromise the journalist's computer.

What I was trying to say is that if the emails were some how compromised (court order or a hacked email account, etc), having a 24 hour creation time could greatly narrow down the possible list of sources if a company or agency were able to get those emails. My example was just to show that if there were only a single source, then a company with those emails would have a pretty short list of who to look at as possible sources. I can see how that is not so clear from my earlier comment. 

Like I said, encryption and now the things Garrett mentioned are possibly enough to protect from this.

Right now, the biggest hurdle is just getting people to check SecureDrop — they're busy and (especially during pre-launch testing) the probability of seeing an empty document interface day after day is demoralizing. Adding more info might be more useful someday I guess, but what I really want is a push notification that says whether or not to do the work of logging in to check for submissions. Minimal information is fine. Even if it just said “check SecureDrop" or “don't check SecureDrop", that'd be great.

@flamsmark Thanks, that's helpful feedback! Perfect is the enemy of the good, and all that :grin: 

I seem to have duplicated Issue #544 .

Assuming you have some journos that will check in daily regardless, and others that do so more randomly due to their other work load, another possible way of addressing this would be if SD only sent notifications if a journo had not checked in for say x days after a submission (lets say for example sake, 2 days).

I think it preferable for notification behavior to be completely undetermined by either submissions or journalists logging in.

Going to work on a very simple implementation of this for the hackathon.

Per conversation with @fowlslegs and @redshiftzero, I'm going to implement this as a daily cronjob that runs a python script that parses through the OSSEC logs, looks for changes related to new submissions, and sends an encrypted email either saying "There are no new submissions" and "There are new submissions" .

@fowlslegs @redshiftzero This may not actually work as we originally thought.

From: http://ossec-docs.readthedocs.io/en/latest/faq/ossec.html

```
OSSEC does not store the logs sent to it by default. If a log does
not trigger an alert it is discarded, and logs that do trigger alerts
are stored with the alerts in /var/ossec/logs/alerts.
```

This means that for this too work from the monitoring server, we would need to configure the changes to trigger alerts. At that point, you would lose the ability to hide when the notification would come in + you would have the problem discussed this morning of mixing in application-level functionality with ops-specific functionality.

@ajvb OSSEC does not seem the right tool for this job. What about a POC Python script that:
1. checks the sqlite db for any "unread" submissions
2. outputs that number as an integer, even if it's zero

Bonus points if it returns a non-zero exit code for zero unread submissions, and a zero exit code if unread submissions were found. Then we can rather trivially hook up an alerting mechanism, such as piping to encrypted email to the instance Admin.

To be clear, we have no method of contacting registered Journalists outside of the Document Interface. These alerts at present would apply only to the Admin, who already receives OSSEC alerts via encrypted email.

@conorsch 

So if this could use `db.py`, then:

``` python
#!/usr/bin/python
from sys import exit
from db import Submission

if __name__ == "__main__":
    n = Submission.query.filter_by(downloaded=False).count()
    print n
    if n > 0:
        exit(0)
    else:
        exit(1)
```

> Then we can rather trivially hook up an alerting mechanism, such as piping to encrypted email to the instance Admin.

Would you mind explaining how this could be done within the current infrastructure? Wouldn't this require sending the email from the application server or am I missing something?

We'd want all DI users to receive these notifications, not just admins. How does the admin manage access to email notifications in a way that doesn't threaten the separation of the mon and app servers? It'd be nice if when a journalist was deleted from the DI, they stopped receiving emails, but we can't just allow the journalist app running on the app server to set values on the mon server. We also shouldn't install a MTA on the app server.

> We'd want all DI users to receive these notifications, not just admins.

Agreed, but may be harder. Might be easier to do just admins for the MVP?

> It'd be nice if when a journalist was deleted from the DI, they stopped receiving emails, but we can't just allow the journalist app running on the app server to set values on the mon server. We also shouldn't install a MTA on the app server.

Not sure how we can get away with both of these:
- App server can't set values on the mon server
- shouldn't install a MTA on the app server

If OSSEC is not the right tool for this (which I agree that it is not), then there needs to be someway for the monitoring server to pull data from the app server.

What is the safest way that the monitoring server can get the boolean response from the application server on whether or not there is new data?
Or, what is the safest way to send emails from the app server?

I feel like the safer route currently is to have the monitoring server get the data from the application server as it already is getting data from it. Can we mimic the safety measures of ossec within the application/API we provide for getting this data?

I say this often, but this is yet another problem that would be much easier to solve via the reading room client. The problem is that we want to provide a unified management interface to the admin, while storing state on two backends that must remain compartmentalized. We can't use code provided by one of the backends to do this (i.e., the DI) because this would break the compartmentalization, so we must provide a native application via a signed package. Anyway, back to addressing this for the current SD landscape....

How about we build a little tool for the admin workstation that lets the admin add and remove users from the email notifications list, `manage-notifications`? We could of course make a desktop icon to launch it. The script downloads a plaintext configuration file from the monitor server over SSH, and opens it up in some friendly, simple GUI editor (maybe even Zenity). The format is very simple:

```
email1 fingerprint1
email2 fingerprint2
```

After modification, the contents are copied back to the monitor server. We could do some sanity checking to make sure each field at least appears to be a valid email address or PGP fingerprint.

When journalists are added or deleted via the admin interface, we should change the message flashed to inform the admin they might want to modify this list.

As to the email notifications themselves. I think the app server should create a new, simple log that gets rotated daily, and provides a boolean response to whether there are unread messages. I think we should use OSSEC to parse this log, and a new command (via a Python script) to send email notifications.

> How about we build a little tool for the admin workstation that lets the admin add and remove users from the email notifications list, manage-notifications? We could of course make a desktop icon to launch it. The script downloads a plaintext configuration file from the monitor server over SSH, and opens it up in some friendly, simple GUI editor (maybe even Zenity).

I like this, but it seems frail and prone to weird bugs. Is it not possible to have the MVP require you to SSH into a the machine and edit the file using `vim` or similar?

> As to the email notifications themselves. I think the app server should create a new, simple log that gets rotated daily, and provides a boolean response to whether there are unread messages.

👍 

> I think we should use OSSEC to parse this log

I don't believe OSSEC is the right tool for this, per my former comment: https://github.com/freedomofpress/securedrop/issues/1195#issuecomment-258708226

This probably breaks the compartmentalization mentioned in your previous comment, but would it be possible have the mon server simply `scp`/download the file, read it, and then delete it?

> a new command (via a Python script) to send email notifications.

👍 

> I like this, but it seems frail and prone to weird bugs. Is it not possible to have the MVP require you to SSH into a the machine and edit the file using vim or similar?

Yeah, I think that's probably the way to do this for now, lest we invest two much time into it, when it's important we begin to focus our energy on more long-term solutions.

I think we can have OSSEC call a [customized active response](https://ossec-docs.readthedocs.io/en/latest/manual/ar/ar-custom.html), that is not really active. What I mean by this is that it calls a Python script that perhaps maintains a simple binary state (e.g., a text file that should only contain a 0 or 1 at any time). Then we create a second script that sends one of two email messages, based on this binary state, to an admin-maintained list of users. This second script is run as a cron job at a set time. This could be maintained/ provisioned by Ansible--the list of users to email, the time to email them, and whether email notifications are wanted at all--by adding new vars.

I still think this is a hacky solution, but at least it adds minimal code and doesn't introduce new communication processes between the app and mon servers. I need to get back to this when I have more time to play with OSSEC + dig through the documentation. There's gotta be a way to do this safe and easy.


Resolving patch:
diff --git a/admin/securedrop_admin/__init__.py b/admin/securedrop_admin/__init__.py
--- a/admin/securedrop_admin/__init__.py
+++ b/admin/securedrop_admin/__init__.py
@@ -43,6 +43,10 @@ class FingerprintException(Exception):
     pass
 
 
+class JournalistAlertEmailException(Exception):
+    pass
+
+
 class SiteConfig(object):
 
     class ValidateNotEmpty(Validator):
@@ -122,6 +126,13 @@ def validate(self, document):
             raise ValidationError(
                 message=path + ' file does not exist')
 
+    class ValidateOptionalPath(ValidatePath):
+        def validate(self, document):
+            if document.text == '':
+                return True
+            return super(SiteConfig.ValidateOptionalPath, self).validate(
+                document)
+
     class ValidateYesNo(Validator):
         def validate(self, document):
             text = document.text.lower()
@@ -143,6 +154,13 @@ def validate(self, document):
                     message='fingerprints must be 40 hexadecimal characters')
             return True
 
+    class ValidateOptionalFingerprint(ValidateFingerprint):
+        def validate(self, document):
+            if document.text == '':
+                return True
+            return super(SiteConfig.ValidateOptionalFingerprint,
+                         self).validate(document)
+
     class ValidateInt(Validator):
         def validate(self, document):
             if re.match('\d+$', document.text):
@@ -192,14 +210,33 @@ def validate(self, document):
             raise ValidationError(
                 message="Password for OSSEC email account must be strong")
 
-    class ValidateOSSECEmail(Validator):
+    class ValidateEmail(Validator):
         def validate(self, document):
             text = document.text
-            if text and '@' in text and 'ossec@ossec.test' != text:
+            if text == '':
+                raise ValidationError(
+                    message=("Must not be empty"))
+            if '@' not in text:
+                raise ValidationError(
+                    message=("Must contain a @"))
+            return True
+
+    class ValidateOSSECEmail(ValidateEmail):
+        def validate(self, document):
+            super(SiteConfig.ValidateOSSECEmail, self).validate(document)
+            text = document.text
+            if 'ossec@ossec.test' != text:
                 return True
             raise ValidationError(
-                message=("Must contain a @ and be set to "
-                         "something other than ossec@ossec.test"))
+                message=("Must be set to something other than "
+                         "ossec@ossec.test"))
+
+    class ValidateOptionalEmail(ValidateEmail):
+        def validate(self, document):
+            if document.text == '':
+                return True
+            return super(SiteConfig.ValidateOptionalEmail, self).validate(
+                document)
 
     def __init__(self, args):
         self.args = args
@@ -257,6 +294,19 @@ def __init__(self, args):
              u'Admin email address for receiving OSSEC alerts',
              SiteConfig.ValidateOSSECEmail(),
              None],
+            ['journalist_alert_gpg_public_key', '', str,
+             u'Local filepath to journalist alerts GPG public key (optional)',
+             SiteConfig.ValidateOptionalPath(self.args.ansible_path),
+             None],
+            ['journalist_gpg_fpr', '', str,
+             u'Full fingerprint for the journalist alerts '
+             u'GPG public key (optional)',
+             SiteConfig.ValidateOptionalFingerprint(),
+             self.sanitize_fingerprint],
+            ['journalist_alert_email', '', str,
+             u'Email address for receiving journalist alerts (optional)',
+             SiteConfig.ValidateOptionalEmail(),
+             None],
             ['smtp_relay', "smtp.gmail.com", str,
              u'SMTP relay for sending OSSEC alerts',
              SiteConfig.ValidateNotEmpty(),
@@ -295,6 +345,7 @@ def update_config(self):
         self.config.update(self.user_prompt_config())
         self.save()
         self.validate_gpg_keys()
+        self.validate_journalist_alert_email()
         return True
 
     def user_prompt_config(self):
@@ -340,11 +391,17 @@ def validate_gpg_keys(self):
                  'securedrop_app_gpg_fingerprint'),
 
                 ('ossec_alert_gpg_public_key',
-                 'ossec_gpg_fpr'))
+                 'ossec_gpg_fpr'),
+
+                ('journalist_alert_gpg_public_key',
+                 'journalist_gpg_fpr'))
+        validate = os.path.join(
+            os.path.dirname(__file__), '..', 'bin',
+            'validate-gpg-key.sh')
         for (public_key, fingerprint) in keys:
-            validate = os.path.join(
-                os.path.dirname(__file__), '..', 'bin',
-                'validate-gpg-key.sh')
+            if (self.config[public_key] == '' and
+                    self.config[fingerprint] == ''):
+                continue
             public_key = os.path.join(self.args.ansible_path,
                                       self.config[public_key])
             fingerprint = self.config[fingerprint]
@@ -360,6 +417,23 @@ def validate_gpg_keys(self):
                     "the public key {}".format(public_key))
         return True
 
+    def validate_journalist_alert_email(self):
+        if (self.config['journalist_alert_gpg_public_key'] == '' and
+                self.config['journalist_gpg_fpr'] == ''):
+            return True
+
+        class Document(object):
+            def __init__(self, text):
+                self.text = text
+
+        try:
+            SiteConfig.ValidateEmail().validate(Document(
+                self.config['journalist_alert_email']))
+        except ValidationError as e:
+            raise JournalistAlertEmailException(
+                "journalist alerts email: " + e.message)
+        return True
+
     def exists(self):
         return os.path.exists(self.args.site_config)
 
diff --git a/securedrop/manage.py b/securedrop/manage.py
--- a/securedrop/manage.py
+++ b/securedrop/manage.py
@@ -3,6 +3,7 @@
 
 import argparse
 import codecs
+import datetime
 import logging
 import os
 import pwd
@@ -14,15 +15,16 @@
 import traceback
 
 from flask import current_app
-from sqlalchemy import text
+from sqlalchemy import text, create_engine
 from sqlalchemy.orm.exc import NoResultFound
+from sqlalchemy.orm import sessionmaker
 
 os.environ['SECUREDROP_ENV'] = 'dev'  # noqa
 from sdconfig import config
 import journalist_app
 
 from db import db
-from models import Journalist, PasswordError, InvalidUsernameException
+from models import Source, Journalist, PasswordError, InvalidUsernameException
 from management.run import run
 
 logging.basicConfig(format='%(asctime)s %(levelname)s %(message)s')
@@ -51,15 +53,15 @@ def reset(args):
 
     # Clear submission/reply storage
     try:
-        os.stat(config.STORE_DIR)
+        os.stat(args.store_dir)
     except OSError:
         pass
     else:
-        for source_dir in os.listdir(config.STORE_DIR):
+        for source_dir in os.listdir(args.store_dir):
             try:
                 # Each entry in STORE_DIR is a directory corresponding
                 # to a source
-                shutil.rmtree(os.path.join(config.STORE_DIR, source_dir))
+                shutil.rmtree(os.path.join(args.store_dir, source_dir))
             except OSError:
                 pass
     return 0
@@ -250,10 +252,38 @@ def init_db(args):
     os.chown('/var/lib/securedrop/db.sqlite', user.pw_uid, user.pw_gid)
 
 
+def were_there_submissions_today(args):
+    if config.DATABASE_ENGINE == "sqlite":
+        db_uri = (config.DATABASE_ENGINE + ":///" +
+                  config.DATABASE_FILE)
+    else:
+        db_uri = (
+            config.DATABASE_ENGINE + '://' +
+            config.DATABASE_USERNAME + ':' +
+            config.DATABASE_PASSWORD + '@' +
+            config.DATABASE_HOST + '/' +
+            config.DATABASE_NAME
+        )
+    session = sessionmaker(bind=create_engine(db_uri))()
+    something = session.query(Source).filter(
+        Source.last_updated >
+        datetime.datetime.utcnow() - datetime.timedelta(hours=24)
+    ).count() > 0
+    count_file = os.path.join(args.data_root, 'submissions_today.txt')
+    open(count_file, 'w').write(something and '1' or '0')
+
+
 def get_args():
     parser = argparse.ArgumentParser(prog=__file__, description='Management '
                                      'and testing utility for SecureDrop.')
     parser.add_argument('-v', '--verbose', action='store_true')
+    parser.add_argument('--data-root',
+                        default=config.SECUREDROP_DATA_ROOT,
+                        help=('directory in which the securedrop '
+                              'data is stored'))
+    parser.add_argument('--store-dir',
+                        default=config.STORE_DIR,
+                        help=('directory in which the documents are stored'))
     subps = parser.add_subparsers()
     # Run WSGI app
     run_subp = subps.add_parser('run', help='Run the Werkzeug source & '
@@ -286,6 +316,8 @@ def get_args():
     set_clean_tmp_parser(subps, 'clean-tmp')
     set_clean_tmp_parser(subps, 'clean_tmp')
 
+    set_were_there_submissions_today(subps)
+
     init_db_subp = subps.add_parser('init-db', help='initialize the DB')
     init_db_subp.add_argument('-u', '--user',
                               help='Unix user for the DB',
@@ -295,6 +327,14 @@ def get_args():
     return parser
 
 
+def set_were_there_submissions_today(subps):
+    parser = subps.add_parser(
+        'were-there-submissions-today',
+        help=('Update the file indicating '
+              'whether submissions were received in the past 24h'))
+    parser.set_defaults(func=were_there_submissions_today)
+
+
 def set_clean_tmp_parser(subps, name):
     parser = subps.add_parser(name, help='Cleanup the '
                               'SecureDrop temp directory.')


Test patch:
diff --git a/admin/tests/test_securedrop-admin.py b/admin/tests/test_securedrop-admin.py
--- a/admin/tests/test_securedrop-admin.py
+++ b/admin/tests/test_securedrop-admin.py
@@ -178,18 +178,29 @@ def test_validate_ossec_password(self):
         with pytest.raises(ValidationError):
             validator.validate(Document('short'))
 
-    def test_validate_ossec_email(self):
-        validator = securedrop_admin.SiteConfig.ValidateOSSECEmail()
+    def test_validate_email(self):
+        validator = securedrop_admin.SiteConfig.ValidateEmail()
 
         assert validator.validate(Document('good@mail.com'))
         with pytest.raises(ValidationError):
             validator.validate(Document('badmail'))
         with pytest.raises(ValidationError):
             validator.validate(Document(''))
+
+    def test_validate_ossec_email(self):
+        validator = securedrop_admin.SiteConfig.ValidateOSSECEmail()
+
+        assert validator.validate(Document('good@mail.com'))
         with pytest.raises(ValidationError) as e:
             validator.validate(Document('ossec@ossec.test'))
         assert 'something other than ossec@ossec.test' in e.value.message
 
+    def test_validate_optional_email(self):
+        validator = securedrop_admin.SiteConfig.ValidateOptionalEmail()
+
+        assert validator.validate(Document('good@mail.com'))
+        assert validator.validate(Document(''))
+
     def test_is_tails(self):
         validator = securedrop_admin.SiteConfig.ValidateDNS()
         with mock.patch('subprocess.check_output', return_value='Tails'):
@@ -273,6 +284,13 @@ def test_validate_path(self):
         with pytest.raises(ValidationError):
             validator.validate(Document(""))
 
+    def test_validate_optional_path(self):
+        mydir = dirname(__file__)
+        myfile = basename(__file__)
+        validator = securedrop_admin.SiteConfig.ValidateOptionalPath(mydir)
+        assert validator.validate(Document(myfile))
+        assert validator.validate(Document(""))
+
     def test_validate_yes_no(self):
         validator = securedrop_admin.SiteConfig.ValidateYesNo()
         with pytest.raises(ValidationError):
@@ -309,6 +327,12 @@ def test_validate_fingerprint(self):
                 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"))
         assert '40 hexadecimal' in e.value.message
 
+    def test_validate_optional_fingerprint(self):
+        validator = securedrop_admin.SiteConfig.ValidateOptionalFingerprint()
+        assert validator.validate(Document(
+            "012345678901234567890123456789ABCDEFABCD"))
+        assert validator.validate(Document(""))
+
     def test_sanitize_fingerprint(self):
         args = argparse.Namespace(site_config='DOES_NOT_EXIST',
                                   ansible_path='.',
@@ -354,8 +378,7 @@ def test_validate_gpg_key(self, caplog):
         args = argparse.Namespace(site_config='INVALID',
                                   ansible_path='tests/files',
                                   app_path=dirname(__file__))
-        site_config = securedrop_admin.SiteConfig(args)
-        site_config.config = {
+        good_config = {
             'securedrop_app_gpg_public_key':
             'test_journalist_key.pub',
 
@@ -367,12 +390,61 @@ def test_validate_gpg_key(self, caplog):
 
             'ossec_gpg_fpr':
             '65A1B5FF195B56353CC63DFFCC40EF1228271441',
+
+            'journalist_alert_gpg_public_key':
+            'test_journalist_key.pub',
+
+            'journalist_gpg_fpr':
+            '65A1B5FF195B56353CC63DFFCC40EF1228271441',
         }
+        site_config = securedrop_admin.SiteConfig(args)
+        site_config.config = good_config
         assert site_config.validate_gpg_keys()
-        site_config.config['ossec_gpg_fpr'] = 'FAIL'
-        with pytest.raises(securedrop_admin.FingerprintException) as e:
-            site_config.validate_gpg_keys()
-        assert 'FAIL does not match' in e.value.message
+
+        for key in ('securedrop_app_gpg_fingerprint',
+                    'ossec_gpg_fpr',
+                    'journalist_gpg_fpr'):
+            bad_config = good_config.copy()
+            bad_config[key] = 'FAIL'
+            site_config.config = bad_config
+            with pytest.raises(securedrop_admin.FingerprintException) as e:
+                site_config.validate_gpg_keys()
+            assert 'FAIL does not match' in e.value.message
+
+    def test_journalist_alert_email(self):
+        args = argparse.Namespace(site_config='INVALID',
+                                  ansible_path='tests/files',
+                                  app_path=dirname(__file__))
+        site_config = securedrop_admin.SiteConfig(args)
+        site_config.config = {
+            'journalist_alert_gpg_public_key':
+            '',
+
+            'journalist_gpg_fpr':
+            '',
+        }
+        assert site_config.validate_journalist_alert_email()
+        site_config.config = {
+            'journalist_alert_gpg_public_key':
+            'test_journalist_key.pub',
+
+            'journalist_gpg_fpr':
+            '65A1B5FF195B56353CC63DFFCC40EF1228271441',
+        }
+        site_config.config['journalist_alert_email'] = ''
+        with pytest.raises(
+                securedrop_admin.JournalistAlertEmailException) as e:
+            site_config.validate_journalist_alert_email()
+        assert 'not be empty' in e.value.message
+
+        site_config.config['journalist_alert_email'] = 'bademail'
+        with pytest.raises(
+                securedrop_admin.JournalistAlertEmailException) as e:
+            site_config.validate_journalist_alert_email()
+        assert 'Must contain a @' in e.value.message
+
+        site_config.config['journalist_alert_email'] = 'good@email.com'
+        assert site_config.validate_journalist_alert_email()
 
     @mock.patch('securedrop_admin.SiteConfig.validated_input',
                 side_effect=lambda p, d, v, t: d)
@@ -437,11 +509,15 @@ def get_desc(self, site_config, var):
             if desc[0] == var:
                 return desc
 
-    def verify_desc_consistency(self, site_config, desc):
+    def verify_desc_consistency_optional(self, site_config, desc):
         (var, default, etype, prompt, validator, transform) = desc
         # verify the default passes validation
         assert site_config.user_prompt_config_one(desc, None) == default
         assert type(default) == etype
+
+    def verify_desc_consistency(self, site_config, desc):
+        self.verify_desc_consistency_optional(site_config, desc)
+        (var, default, etype, prompt, validator, transform) = desc
         with pytest.raises(ValidationError):
             site_config.user_prompt_config_one(desc, '')
 
@@ -467,8 +543,7 @@ def verify_prompt_not_empty(self, site_config, desc):
         with pytest.raises(ValidationError):
             site_config.user_prompt_config_one(desc, '')
 
-    def verify_prompt_fingerprint(self, site_config, desc):
-        self.verify_prompt_not_empty(site_config, desc)
+    def verify_prompt_fingerprint_optional(self, site_config, desc):
         fpr = "0123456 789012 34567890123456789ABCDEFABCD"
         clean_fpr = site_config.sanitize_fingerprint(fpr)
         assert site_config.user_prompt_config_one(desc, fpr) == clean_fpr
@@ -479,10 +554,18 @@ def verify_desc_consistency_allow_empty(self, site_config, desc):
         assert site_config.user_prompt_config_one(desc, None) == default
         assert type(default) == etype
 
+    def verify_prompt_fingerprint(self, site_config, desc):
+        self.verify_prompt_not_empty(site_config, desc)
+        self.verify_prompt_fingerprint_optional(site_config, desc)
+
     verify_prompt_securedrop_app_gpg_fingerprint = verify_prompt_fingerprint
     verify_prompt_ossec_alert_gpg_public_key = verify_desc_consistency
     verify_prompt_ossec_gpg_fpr = verify_prompt_fingerprint
     verify_prompt_ossec_alert_email = verify_prompt_not_empty
+    verify_prompt_journalist_alert_gpg_public_key = (
+        verify_desc_consistency_optional)
+    verify_prompt_journalist_gpg_fpr = verify_prompt_fingerprint_optional
+    verify_prompt_journalist_alert_email = verify_desc_consistency_optional
     verify_prompt_smtp_relay = verify_prompt_not_empty
     verify_prompt_smtp_relay_port = verify_desc_consistency
     verify_prompt_sasl_domain = verify_desc_consistency_allow_empty
diff --git a/securedrop/tests/test_manage.py b/securedrop/tests/test_manage.py
--- a/securedrop/tests/test_manage.py
+++ b/securedrop/tests/test_manage.py
@@ -2,6 +2,7 @@
 
 import argparse
 import io
+import datetime
 import logging
 import os
 import manage
@@ -13,7 +14,8 @@
 
 os.environ['SECUREDROP_ENV'] = 'test'  # noqa
 
-from models import Journalist
+from models import Journalist, db
+from utils import db_helper
 
 
 YUBIKEY_HOTP = ['cb a0 5f ad 41 a2 ff 4e eb 53 56 3a 1b f7 23 2e ce fc dc',
@@ -170,7 +172,8 @@ def test_reset(journalist_app, test_journo, config):
         # We need to override the config to point at the per-test DB
         manage.config = config
 
-        return_value = manage.reset(args=None)
+        args = argparse.Namespace(store_dir=config.STORE_DIR)
+        return_value = manage.reset(args=args)
         assert return_value == 0
         assert os.path.exists(config.DATABASE_FILE)
         assert os.path.exists(config.STORE_DIR)
@@ -221,3 +224,28 @@ def test_clean_tmp_removed(config, caplog):
     manage.setup_verbosity(args)
     manage.clean_tmp(args)
     assert 'FILE removed' in caplog.text
+
+
+def test_were_there_submissions_today(source_app, config):
+    original_config = manage.config
+    try:
+        # We need to override the config to point at the per-test DB
+        manage.config = config
+        data_root = config.SECUREDROP_DATA_ROOT
+        args = argparse.Namespace(data_root=data_root,
+                                  verbose=logging.DEBUG)
+
+        with source_app.app_context():
+            count_file = os.path.join(data_root, 'submissions_today.txt')
+            source, codename = db_helper.init_source_without_keypair()
+            source.last_updated = (datetime.datetime.utcnow() -
+                                   datetime.timedelta(hours=24*2))
+            db.session.commit()
+            manage.were_there_submissions_today(args)
+            assert io.open(count_file).read() == "0"
+            source.last_updated = datetime.datetime.utcnow()
+            db.session.commit()
+            manage.were_there_submissions_today(args)
+            assert io.open(count_file).read() == "1"
+    finally:
+        manage.config = original_config
diff --git a/testinfra/mon/test_ossec.py b/testinfra/mon/test_ossec.py
--- a/testinfra/mon/test_ossec.py
+++ b/testinfra/mon/test_ossec.py
@@ -5,21 +5,6 @@
 securedrop_test_vars = pytest.securedrop_test_vars
 
 
-@pytest.mark.parametrize('package', [
-    'mailutils',
-    'ossec-server',
-    'postfix',
-    'procmail',
-    'securedrop-ossec-server',
-])
-def test_ossec_package(Package, package):
-    """
-    Ensure required packages for OSSEC are installed.
-    Includes mail utilities and the FPF-maintained metapackage.
-    """
-    assert Package(package).is_installed
-
-
 def test_ossec_connectivity(Command, Sudo):
     """
     Ensure ossec-server machine has active connection to the ossec-agent.
@@ -34,44 +19,6 @@ def test_ossec_connectivity(Command, Sudo):
         assert c == desired_output
 
 
-def test_ossec_gnupg_homedir(File, Sudo):
-    """ ensure ossec gpg homedir exists """
-    with Sudo():
-        f = File("/var/ossec/.gnupg")
-        assert f.is_directory
-        assert f.user == "ossec"
-        assert oct(f.mode) == "0700"
-
-
-# Permissions don't match between Ansible and OSSEC deb packages postinst.
-@pytest.mark.xfail
-def test_ossec_gnupg(File, Sudo):
-    """
-    Ensures the test Admin GPG public key is present as file.
-    Does not check that it's added to the keyring for the ossec user;
-    that's handled by a separate test.
-    """
-    with Sudo():
-        f = File("/var/ossec/test_admin_key.pub")
-        assert f.is_file
-        assert oct(f.mode) == "0644"
-
-
-def test_ossec_pubkey_in_keyring(Command, Sudo):
-    """
-    Ensure the test Admin GPG public key exists in the keyring
-    within the ossec home directory.
-    """
-    ossec_gpg_pubkey_info = """pub   2048R/B5E53711 2018-01-25
-uid                  SecureDrop admin key for tests (do not use in production)
-sub   2048R/EC1DF5D0 2018-01-25"""  # noqa
-    with Sudo("ossec"):
-        c = Command.check_output(
-            "gpg --homedir /var/ossec/.gnupg "
-            "--list-keys 53E1113AC1F25027BA5D475B1141E2BBB5E53711")
-        assert c == ossec_gpg_pubkey_info
-
-
 # Permissions don't match between Ansible and OSSEC deb packages postinst.
 @pytest.mark.xfail
 @pytest.mark.parametrize('keyfile', [
@@ -96,40 +43,6 @@ def test_ossec_keyfiles(File, Sudo, keyfile):
         assert f.group == "ossec"
 
 
-@pytest.mark.parametrize('setting', [
-    'VERBOSE=yes',
-    'MAILDIR=/var/mail/',
-    'DEFAULT=$MAILDIR',
-    'LOGFILE=/var/log/procmail.log',
-    'SUBJECT=`formail -xSubject:`',
-    ':0 c',
-    '*^To:.*root.*',
-    '|/var/ossec/send_encrypted_alarm.sh',
-])
-def test_procmail_settings(File, Sudo, setting):
-    """
-    Ensure procmail settings are correct. These config lines determine
-    how the OSSEC email alerts are encrypted and then passed off for sending.
-    """
-    # Sudo is required to traverse the /var/ossec directory.
-    with Sudo():
-        f = File("/var/ossec/.procmailrc")
-        assert f.contains('^{}$'.format(setting))
-
-
-# Permissions don't match between Ansible and OSSEC deb packages postinst.
-@pytest.mark.xfail
-def test_procmail_attrs(File, Sudo):
-    """
-    Ensure procmail file attributes are specified correctly.
-    """
-    with Sudo():
-        f = File("/var/ossec/.procmailrc")
-        assert f.is_file
-        assert f.user == "ossec"
-        assert oct(f.mode) == "0440"
-
-
 # Permissions don't match between Ansible and OSSEC deb packages postinst.
 @pytest.mark.xfail
 def test_procmail_log(File, Sudo):
diff --git a/testinfra/mon/test_postfix.py b/testinfra/mon/test_postfix.py
--- a/testinfra/mon/test_postfix.py
+++ b/testinfra/mon/test_postfix.py
@@ -25,48 +25,6 @@ def test_postfix_headers(File, header):
     assert re.search(regex, f.content, re.M)
 
 
-@pytest.mark.parametrize('setting', [
-    'relayhost = [smtp.gmail.com]:587',
-    'smtp_sasl_auth_enable = yes',
-    'smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd',
-    'smtp_sasl_security_options = noanonymous',
-    'smtp_use_tls = yes',
-    'smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache',
-    'smtp_tls_security_level = secure',
-    'smtp_tls_CApath = /etc/ssl/certs',
-    'smtp_tls_ciphers = high',
-    'smtp_tls_protocols = TLSv1.2 TLSv1.1 TLSv1 !SSLv3 !SSLv2',
-    'myhostname = ossec.server',
-    'myorigin = $myhostname',
-    'smtpd_banner = $myhostname ESMTP $mail_name (Ubuntu)',
-    'biff = no',
-    'append_dot_mydomain = no',
-    'readme_directory = no',
-    'smtp_header_checks = regexp:/etc/postfix/header_checks',
-    'mailbox_command = /usr/bin/procmail',
-    'inet_interfaces = loopback-only',
-    'alias_maps = hash:/etc/aliases',
-    'alias_database = hash:/etc/aliases',
-    'mydestination = $myhostname, localhost.localdomain , localhost',
-    'mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128',
-    'mailbox_size_limit = 0',
-    'recipient_delimiter = +',
-])
-def test_postfix_settings(File, setting):
-    """
-    Check all postfix configuration lines. There are technically multiple
-    configuration paths regarding the TLS settings, particularly the
-    fingerprint verification logic, but only the base default config is tested
-    currently.
-    """
-    f = File("/etc/postfix/main.cf")
-    assert f.is_file
-    assert f.user == 'root'
-    assert oct(f.mode) == "0644"
-    regex = '^{}$'.format(re.escape(setting))
-    assert re.search(regex, f.content, re.M)
-
-
 def test_postfix_generic_maps(File):
     """
     Regression test to check that generic Postfix maps are not configured
diff --git a/testinfra/ossec/test_journalist_mail.py b/testinfra/ossec/test_journalist_mail.py
new file mode 100644
--- /dev/null
+++ b/testinfra/ossec/test_journalist_mail.py
@@ -0,0 +1,212 @@
+import pytest
+import testinfra
+import time
+
+
+class TestBase(object):
+
+    @pytest.fixture(autouse=True)
+    def only_mon_staging_sudo(self, host):
+        if host.backend.host != 'mon-staging':
+            pytest.skip()
+
+        with host.sudo():
+            yield
+
+    def ansible(self, host, module, parameters):
+        r = host.ansible(module, parameters, check=False)
+        assert 'exception' not in r
+
+    def run(self, host, cmd):
+        print(host.backend.host + " running: " + cmd)
+        r = host.run(cmd)
+        print(r.stdout)
+        print(r.stderr)
+        return r.rc == 0
+
+    def wait_for(self, fun):
+        success = False
+        for d in (1, 2, 4, 8, 16, 32, 64):
+            if fun():
+                success = True
+                break
+            time.sleep(d)
+        return success
+
+    def wait_for_command(self, host, cmd):
+        return self.wait_for(lambda: self.run(host, cmd))
+
+    #
+    # implementation note: we do not use host.ansible("service", ...
+    # because it only works for services in /etc/init and not those
+    # legacy only found in /etc/init.d such as postfix
+    #
+    def service_started(self, host, name):
+        assert self.run(host, "service {name} start".format(name=name))
+        assert self.wait_for_command(
+            host,
+            "service {name} status | grep -q 'is running'".format(name=name))
+
+    def service_restarted(self, host, name):
+        assert self.run(host, "service {name} restart".format(name=name))
+        assert self.wait_for_command(
+            host,
+            "service {name} status | grep -q 'is running'".format(name=name))
+
+    def service_stopped(self, host, name):
+        assert self.run(host, "service {name} stop".format(name=name))
+        assert self.wait_for_command(
+            host,
+            "service {name} status | grep -q 'not running'".format(name=name))
+
+
+class TestJournalistMail(TestBase):
+
+    def test_procmail(self, host):
+        self.service_started(host, "postfix")
+        today_payload = (
+            'ossec: output: head -1 /var/lib/securedrop/submissions_today.txt'
+            '\n1234')
+        for (destination, payload) in (
+                ('journalist', today_payload),
+                ('ossec', 'MYGREATPAYLOAD')):
+            assert self.run(host, "postsuper -d ALL")
+            assert self.run(
+                host,
+                "echo -e '{payload}' | "
+                "mail -s 'abc' root@localhost".format(payload=payload))
+            assert self.wait_for_command(
+                host,
+                "mailq | grep -q {destination}@ossec.test".format(
+                    destination=destination))
+        self.service_stopped(host, "postfix")
+
+    def test_process_submissions_today(self, host):
+        self.run(host, "/var/ossec/process_submissions_today.sh test_main")
+
+    def test_send_encrypted_alert(self, host):
+        self.service_started(host, "postfix")
+        src = "install_files/ansible-base/roles/ossec/files/test_admin_key.sec"
+        self.ansible(host, "copy",
+                     "dest=/tmp/test_admin_key.sec src={src}".format(src=src))
+
+        self.run(host, "gpg  --homedir /var/ossec/.gnupg"
+                 " --import /tmp/test_admin_key.sec")
+
+        def trigger(who, payload):
+            assert self.run(
+                host, "! mailq | grep -q {who}@ossec.test".format(who=who))
+            assert self.run(
+                host,
+                """
+                ( echo 'Subject: TEST' ; echo ; echo -e '{payload}' ) | \
+                /var/ossec/send_encrypted_alarm.sh {who}
+                """.format(who=who, payload=payload))
+            assert self.wait_for_command(
+                host, "mailq | grep -q {who}@ossec.test".format(who=who))
+
+        #
+        # encrypted mail to journalist or ossec contact
+        #
+        for (who, payload, expected) in (
+                ('journalist', 'ossec: output\n1', '1'),
+                ('ossec', 'MYGREATPAYLOAD', 'MYGREATPAYLOAD')):
+            assert self.run(host, "postsuper -d ALL")
+            trigger(who, payload)
+            assert self.run(
+                host,
+                """
+                job=$(mailq | sed -n -e '2p' | cut -f1 -d ' ')
+                postcat -q $job | tee /dev/stderr | \
+                   gpg --homedir /var/ossec/.gnupg --decrypt 2>&1 | \
+                   grep -q {expected}
+                """.format(expected=expected))
+        #
+        # failure to encrypt must trigger an emergency mail to ossec contact
+        #
+        try:
+            assert self.run(host, "postsuper -d ALL")
+            assert self.run(host, "mv /usr/bin/gpg /usr/bin/gpg.save")
+            trigger(who, 'MYGREATPAYLOAD')
+            assert self.run(
+                host,
+                """
+                job=$(mailq | sed -n -e '2p' | cut -f1 -d ' ')
+                postcat -q $job | grep -q 'Failed to encrypt OSSEC alert'
+                """)
+        finally:
+            assert self.run(host, "mv /usr/bin/gpg.save /usr/bin/gpg")
+        self.service_stopped(host, "postfix")
+
+    def test_missing_journalist_alert(self, host):
+        #
+        # missing journalist mail does nothing
+        #
+        assert self.run(
+            host,
+            """
+            JOURNALIST_EMAIL= \
+               bash -x /var/ossec/send_encrypted_alarm.sh journalist | \
+               tee /dev/stderr | \
+               grep -q 'no notification sent'
+            """)
+
+    # https://ossec-docs.readthedocs.io/en/latest/manual/rules-decoders/testing.html
+    def test_ossec_rule_journalist(self, host):
+        assert self.run(host, """
+        set -ex
+        l="ossec: output: 'head -1 /var/lib/securedrop/submissions_today.txt"
+        echo "$l" | /var/ossec/bin/ossec-logtest
+        echo "$l" | /var/ossec/bin/ossec-logtest -U '400600:1:ossec'
+        """)
+
+    def test_journalist_mail_notification(self, host):
+        mon = host
+        app = testinfra.host.Host.get_host(
+            'ansible://app-staging',
+            ansible_inventory=host.backend.ansible_inventory)
+        #
+        # run ossec & postfix on mon
+        #
+        self.service_started(mon, "postfix")
+        self.service_started(mon, "ossec")
+
+        #
+        # ensure the submission_today.txt file exists
+        #
+        with app.sudo():
+            assert self.run(app, """
+            cd /var/www/securedrop
+            ./manage.py were-there-submissions-today
+            test -f /var/lib/securedrop/submissions_today.txt
+            """)
+
+        #
+        # empty the mailq on mon in case there were leftovers
+        #
+        assert self.run(mon, "postsuper -d ALL")
+
+        #
+        # the command fires every time ossec starts,
+        # regardless of the frequency
+        #
+        with app.sudo():
+            self.service_restarted(app, "ossec")
+
+        #
+        # wait until at exactly one notification is sent
+        #
+        assert self.wait_for_command(
+            mon,
+            "mailq | grep -q journalist@ossec.test")
+        assert self.run(
+            mon,
+            "test 1 = $(mailq | grep journalist@ossec.test | wc -l)")
+
+        #
+        # teardown the ossec and postfix on mon and app
+        #
+        self.service_stopped(mon, "postfix")
+        self.service_stopped(mon, "ossec")
+        with app.sudo():
+            self.service_stopped(app, "ossec")
diff --git a/testinfra/test.py b/testinfra/test.py
--- a/testinfra/test.py
+++ b/testinfra/test.py
@@ -24,6 +24,7 @@ def get_target_roles(target_host):
                                     'testinfra/app-code',
                                     'testinfra/common',
                                     'testinfra/development/test_xvfb.py'],
+                    "staging": ['testinfra/ossec'],
                     "mon-staging": ['testinfra/mon',
                                     'testinfra/common'],
                     "mon-prod":    ['testinfra/mon']}
@@ -92,6 +93,37 @@ def run_testinfra(target_host, verbose=True):
     {target_roles}
 """.lstrip().rstrip()
 
+    elif target_host == 'staging':
+        if "CI_SSH_CONFIG" in os.environ:
+            ssh_config_path = '--ssh-config ' + os.environ["CI_SSH_CONFIG"]
+            inventory = ""
+            junit = """
+            --junit-xml=./{target_host}-results.xml \
+            --junit-prefix={target_host} \
+            """
+        else:
+            ssh_config_path = ""
+            inventory = """
+            --ansible-inventory \
+            .vagrant/provisioners/ansible/inventory/vagrant_ansible_inventory \
+            """
+            junit = ""
+        testinfra_command_template = """
+testinfra \
+    -vv \
+    --connection ansible \
+    {testinfra_args} \
+    {ssh_config_path} \
+    {inventory} \
+    {junit} \
+    --hosts app-staging,mon-staging \
+    {target_roles}
+""".format(ssh_config_path=ssh_config_path,
+           inventory=inventory,
+           target_roles=" ".join(target_roles),
+           junit=junit,
+           testinfra_args=os.environ.get('TESTINFRA_ARGS', ''))
+
     else:
         ssh_config_path = ""
         testinfra_command_template = """