-
Notifications
You must be signed in to change notification settings - Fork 687
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
Changes from 18 commits
47ce0d3
2fc3095
f83e647
5a43ac0
c7cc842
f8a3c2a
347cdd9
c579d89
78fe72a
cf85117
ec4a48a
85ec7c4
a595209
8a29dec
23e9955
8b841bd
a30499b
0713b76
7bec4ed
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -7,6 +7,9 @@ | |
**/venv | ||
**/.venv | ||
|
||
**/*~ | ||
**/#* | ||
|
||
**/__pycache__ | ||
**/*.pyc | ||
securedrop/.sass-cache | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 '[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 | ||
|
@@ -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) | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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'): | ||
|
@@ -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'] = '[email protected]' | ||
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 | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -60,6 +60,21 @@ continuing: | |
can add more later) | ||
- The username of the system admin | ||
|
||
You can also, optionally, configure a :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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should add a section here indicating that a single account only is possible - such that the admin knows they must set up and maintain a mailing list of journalists on the system (as well as share around the GPG key used for the alerts). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. done |
||
|
||
.. 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``: | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: remove "a " in "a daily notifications".