Skip to content

Commit

Permalink
Merge pull request #2803 from freedomofpress/wip-dachary-1195-journal…
Browse files Browse the repository at this point in the history
…ist-notification

daily email alert to journalists about submissions received in the past 24h
  • Loading branch information
redshiftzero authored Apr 12, 2018
2 parents bf4307b + 7bec4ed commit bca891b
Show file tree
Hide file tree
Showing 23 changed files with 712 additions and 166 deletions.
3 changes: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
**/venv
**/.venv

**/*~
**/#*

**/__pycache__
**/*.pyc
securedrop/.sass-cache
Expand Down
90 changes: 82 additions & 8 deletions admin/securedrop_admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ class FingerprintException(Exception):
pass


class JournalistAlertEmailException(Exception):
pass


class SiteConfig(object):

class ValidateNotEmpty(Validator):
Expand Down Expand Up @@ -129,6 +133,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()
Expand All @@ -150,6 +161,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):
Expand Down Expand Up @@ -199,14 +217,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 '[email protected]' != 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 '[email protected]' != text:
return True
raise ValidationError(
message=("Must contain a @ and be set to "
"something other than [email protected]"))
message=("Must be set to something other than "
"[email protected]"))

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
Expand Down Expand Up @@ -268,6 +305,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(),
Expand Down Expand Up @@ -306,6 +356,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):
Expand Down Expand Up @@ -351,11 +402,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]
Expand All @@ -371,6 +428,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)

Expand Down
105 changes: 94 additions & 11 deletions admin/tests/test_securedrop-admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,18 +193,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('[email protected]'))
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('[email protected]'))
with pytest.raises(ValidationError) as e:
validator.validate(Document('[email protected]'))
assert 'something other than [email protected]' in e.value.message

def test_validate_optional_email(self):
validator = securedrop_admin.SiteConfig.ValidateOptionalEmail()

assert validator.validate(Document('[email protected]'))
assert validator.validate(Document(''))

def test_is_tails(self):
validator = securedrop_admin.SiteConfig.ValidateDNS()
with mock.patch('subprocess.check_output', return_value='Tails'):
Expand Down Expand Up @@ -288,6 +299,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):
Expand Down Expand Up @@ -324,6 +342,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='.',
Expand Down Expand Up @@ -369,8 +393,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',

Expand All @@ -382,12 +405,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'] = '[email protected]'
assert site_config.validate_journalist_alert_email()

@mock.patch('securedrop_admin.SiteConfig.validated_input',
side_effect=lambda p, d, v, t: d)
Expand Down Expand Up @@ -452,11 +524,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, '')

Expand All @@ -482,8 +558,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
Expand All @@ -494,10 +569,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_daily_reboot_time = verify_desc_consistency
Expand Down
15 changes: 15 additions & 0 deletions docs/install.rst
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,21 @@ continuing:
can add more later)
- The username of the system admin

You can also, optionally, configure :doc:`daily notifications
<journalist>` about whether or not submission activity occurred in the
past 24 hours. They are sent via email so journalists know if it is
worth checking the *Journalist Interface*. For this you will need:

- The journalist alerts GPG key
- The journalist alerts GPG key fingerprint
- The email address that will receive the journalist alerts

.. note:: It is not possible to specify multiple email addresses. If
there are more than one recipient, an alias or a mailing
list must be created. All journalist subscribed must share
the GPG private key, it is not possible to specify multiple
GPG keys.

You will have to copy the following required files to
``install_files/ansible-base``:

Expand Down
19 changes: 19 additions & 0 deletions docs/journalist.rst
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,25 @@ Journalist Interface <yubikey_setup>`.)

|Journalist Interface Login|

Daily journalist alerts about submissions
-----------------------------------------

When a SecureDrop has little activity and receives only a few
submissions every other week, checking the *Journalist Interface*
daily only to find there is nothing is a burden. It is more convenient
for journalists to be notified daily via encrypted email about whether
or not there has been submission activity in the past 24 hours.

If the email shows submissions were received, the journalist can
connect to the *Journalist Interface* to get them.

This is an optional feature that must be activated :doc:`by the
administrator <admin>`. In the simplest case a journalist provides
her/his email and GPG public key to the admin. If a team of journalist
wants to receive these daily alerts, they should share a GPG key and
ask the admin to setup a mail alias (SecureDrop does not provide that
service) so they all receive the alerts and are able to decrypt them.

Interacting With Sources
------------------------

Expand Down
Loading

0 comments on commit bca891b

Please sign in to comment.