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

daily email alert to journalists about submissions received in the past 24h #2803

Merged
merged 19 commits into from
Apr 12, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
47ce0d3
staging: set smtp_replay to a fake domain
Jan 27, 2018
2fc3095
lint: ignore emacs backup files
Jan 27, 2018
f83e647
manage: use args.store_dir instead of config.STORE_DIR
Jan 27, 2018
5a43ac0
manage: implement the how_many_submissions_today sub-command
Jan 27, 2018
c7cc842
testinfra: define staging as a multi-machine context
Jan 27, 2018
f8a3c2a
ossec: notify the journalist about submissions in the past 24h
Jan 27, 2018
347cdd9
testinfra: remove some OSSEC tests duplicating Ansible
Jan 27, 2018
c579d89
testinfra: remove some postfix tests duplicating Ansible
Jan 27, 2018
78fe72a
docs: admin and journalist guide regarding daily notifications
Jan 28, 2018
cf85117
securedrop-admin: split ValidateEmail out of ValidateOSSECEmail
Jan 31, 2018
ec4a48a
securedrop-admin: test_validate_gpg_key must verify all fingerprints
Jan 31, 2018
85ec7c4
securedrop-admin: implement verify_desc_consistency_optional
Jan 31, 2018
a595209
securedrop-admin: cosmetic cleanup moving a constant out of a loop
Jan 31, 2018
8a29dec
securedrop-admin: add sdconfig journalist_alert_gpg_public_key input
Jan 31, 2018
23e9955
securedrop-admin: add sdconfig journalist_gpg_fpr input
Jan 31, 2018
8b841bd
securedrop-admin: add sdconfig journalist_alert_email input
Jan 31, 2018
a30499b
securedrop-admin: when present the journalist alert key must be verified
Jan 31, 2018
0713b76
securedrop-admin: when a journalist key is present, the email is requ…
Jan 31, 2018
7bec4ed
Small typo fixes in journalist notification docs
Apr 11, 2018
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
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 @@ -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()
Expand All @@ -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):
Expand Down Expand Up @@ -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 '[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 @@ -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(),
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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]
Expand All @@ -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)

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 @@ -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('[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 @@ -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):
Expand Down Expand Up @@ -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='.',
Expand Down Expand Up @@ -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',

Expand All @@ -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'] = '[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 @@ -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, '')

Expand All @@ -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
Expand All @@ -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
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