diff --git a/install_files/ansible-base/prod-specific.yml b/install_files/ansible-base/prod-specific.yml
index c0ec5f94fc7..e01b36c2fc5 100644
--- a/install_files/ansible-base/prod-specific.yml
+++ b/install_files/ansible-base/prod-specific.yml
@@ -34,6 +34,9 @@ securedrop_app_gpg_fingerprint: ""
ossec_alert_gpg_public_key: ""
ossec_gpg_fpr: ""
ossec_alert_email: ""
+journalist_alert_gpg_public_key: ""
+journalist_gpg_fpr: ""
+journalist_alert_email: ""
smtp_relay: ""
smtp_relay_port: ""
sasl_username: ""
diff --git a/install_files/ansible-base/roles/build-securedrop-app-code-deb-pkg/files/cron.daily b/install_files/ansible-base/roles/build-securedrop-app-code-deb-pkg/files/cron.daily
new file mode 100644
index 00000000000..2accb15944d
--- /dev/null
+++ b/install_files/ansible-base/roles/build-securedrop-app-code-deb-pkg/files/cron.daily
@@ -0,0 +1,4 @@
+#!/bin/bash
+
+cd /var/www/securedrop || exit 1
+./manage.py how-many-submissions-today
diff --git a/install_files/ansible-base/roles/build-securedrop-app-code-deb-pkg/tasks/build_securedrop_app_code_deb.yml b/install_files/ansible-base/roles/build-securedrop-app-code-deb-pkg/tasks/build_securedrop_app_code_deb.yml
index a2ccf472a31..19a5d061ac0 100644
--- a/install_files/ansible-base/roles/build-securedrop-app-code-deb-pkg/tasks/build_securedrop_app_code_deb.yml
+++ b/install_files/ansible-base/roles/build-securedrop-app-code-deb-pkg/tasks/build_securedrop_app_code_deb.yml
@@ -54,6 +54,17 @@
with_items: "{{ apparmor_profiles }}"
tags: apparmor
+- name: Create cron.daily directory.
+ file:
+ state: directory
+ dest: "{{ securedrop_app_code_deb_dir }}/etc/cron.daily"
+
+- name: Copy crontab.
+ copy:
+ src: cron.daily
+ dest: "{{ securedrop_app_code_deb_dir }}/etc/cron.daily/securedrop"
+ mode: 0755
+
- name: Build securedrop-app-code Debian package.
command: dpkg-deb --build {{ securedrop_app_code_deb_dir }}
diff --git a/install_files/ansible-base/roles/ossec/tasks/configure_server.yml b/install_files/ansible-base/roles/ossec/tasks/configure_server.yml
index e2df707d275..0c25558633c 100644
--- a/install_files/ansible-base/roles/ossec/tasks/configure_server.yml
+++ b/install_files/ansible-base/roles/ossec/tasks/configure_server.yml
@@ -11,8 +11,12 @@
- name: Copy the OSSEC GPG public key for sending encrypted alerts.
copy:
- src: "{{ ossec_alert_gpg_public_key }}"
+ src: "{{ item }}"
dest: /var/ossec
+ when: "'{{ item }}' != ''"
+ with_items:
+ - "{{ ossec_alert_gpg_public_key }}"
+ - "{{ journalist_alert_gpg_public_key }}"
tags:
- gpg
@@ -21,11 +25,13 @@
command: >
gpg
--homedir /var/ossec/.gnupg
- --import /var/ossec/{{ ossec_alert_gpg_public_key }}
+ --import /var/ossec/{{ item }}
become: yes
become_user: "{{ ossec_group }}"
- register: add_ossec_gpg_key_result
- changed_when: "'imported: 1' in add_ossec_gpg_key_result.stderr"
+ when: "'{{ item }}' != ''"
+ with_items:
+ - "{{ ossec_alert_gpg_public_key }}"
+ - "{{ journalist_alert_gpg_public_key }}"
tags:
- gpg
diff --git a/install_files/ansible-base/roles/ossec/templates/send_encrypted_alarm.sh b/install_files/ansible-base/roles/ossec/templates/send_encrypted_alarm.sh
index 52d6b8a9e42..d029e05b9c4 100644
--- a/install_files/ansible-base/roles/ossec/templates/send_encrypted_alarm.sh
+++ b/install_files/ansible-base/roles/ossec/templates/send_encrypted_alarm.sh
@@ -1,4 +1,5 @@
#!/bin/bash
+# shellcheck disable=SC2086
# Handler script to encrypt OSSEC alerts prior to mailing.
# Called via the `.procmailrc` for user `ossec`.
@@ -16,8 +17,26 @@ set -o pipefail
# encryption fail.
ossec_alert_text="$(< /dev/stdin)"
+# default to environment even if null
+env | grep -q JOURNALIST_EMAIL || JOURNALIST_EMAIL='{{ journalist_alert_email }}'
+env | grep -q OSSEC_EMAIL || OSSEC_EMAIL='{{ ossec_alert_email }}'
+
# Primary "send email to Admin" functionality.
function send_encrypted_alert() {
+ local recipient="$1"
+ local gpg_fpr
+ local alert_email
+ if [[ "$recipient" = "journalist" ]] ; then
+ gpg_fpr='{{ journalist_gpg_fpr }}'
+ alert_email="$JOURNALIST_EMAIL"
+ if [[ "$alert_email" = "" ]] ; then
+ echo "journalist alert email unset, no notification sent"
+ return
+ fi
+ else
+ gpg_fpr='{{ ossec_gpg_fpr }}'
+ alert_email="$OSSEC_EMAIL"
+ fi
local encrypted_alert_text
# Try to encrypt the alert message. We'll inspect the exit status of the
@@ -25,14 +44,14 @@ function send_encrypted_alert() {
# failure message.
encrypted_alert_text="$(printf "%s" "${ossec_alert_text}" | \
/usr/bin/formail -I '' | \
- /usr/bin/gpg --homedir /var/ossec/.gnupg --trust-model always -ear '{{ ossec_gpg_fpr }}')"
+ /usr/bin/gpg --homedir /var/ossec/.gnupg --trust-model always -ear $gpg_fpr)"
# Error handling.
if [[ -z "${encrypted_alert_text}" || $? -ne 0 ]]; then
- send_plaintext_fail_message
+ send_plaintext_fail_message "$alert_email"
else
echo "${encrypted_alert_text}" | \
- /usr/bin/mail -s "$(echo "${SUBJECT}" | sed -r 's/([0-9]{1,3}\.){3}[0-9]{1,3}\s?//g' )" '{{ ossec_alert_email }}'
+ /usr/bin/mail -s "$(echo "${SUBJECT}" | sed -r 's/([0-9]{1,3}\.){3}[0-9]{1,3}\s?//g' )" "$alert_email"
fi
}
@@ -40,10 +59,11 @@ function send_encrypted_alert() {
# Usually a failure is related to GPG balking on the encryption step;
# that may be due to a missing pubkey or something reason.
function send_plaintext_fail_message() {
+ local alert_email="$1"
printf "Failed to encrypt OSSEC alert. Investigate the mailing configuration on the Monitor Server." | \
/usr/bin/formail -I "" | \
- /usr/bin/mail -s "$(echo "${SUBJECT}" | sed -r 's/([0-9]{1,3}\.){3}[0-9]{1,3}\s?//g' )" '{{ ossec_alert_email }}'
+ /usr/bin/mail -s "$(echo "${SUBJECT}" | sed -r 's/([0-9]{1,3}\.){3}[0-9]{1,3}\s?//g' )" "$alert_email"
}
# Encrypt the OSSEC notification and pass to mailer for sending.
-send_encrypted_alert
+send_encrypted_alert "$@"
diff --git a/install_files/ansible-base/roles/postfix/files/aliases b/install_files/ansible-base/roles/postfix/files/aliases
index 3880678da46..1c8ccb91a69 100644
--- a/install_files/ansible-base/roles/postfix/files/aliases
+++ b/install_files/ansible-base/roles/postfix/files/aliases
@@ -1 +1,2 @@
root: ossec
+journalist: ossec
diff --git a/install_files/ansible-base/roles/postfix/files/procmailrc b/install_files/ansible-base/roles/postfix/files/procmailrc
index 729b89d5ba4..af773cc4eca 100644
--- a/install_files/ansible-base/roles/postfix/files/procmailrc
+++ b/install_files/ansible-base/roles/postfix/files/procmailrc
@@ -5,4 +5,8 @@ LOGFILE=/var/log/procmail.log
SUBJECT=`formail -xSubject:`
:0 c
*^To:.*root.*
-|/var/ossec/send_encrypted_alarm.sh
+|/var/ossec/send_encrypted_alarm.sh ossec
+
+:0 c
+*^To:.*journalist.*
+|/var/ossec/send_encrypted_alarm.sh journalist
diff --git a/install_files/securedrop-ossec-agent/var/ossec/etc/ossec.conf b/install_files/securedrop-ossec-agent/var/ossec/etc/ossec.conf
index aebbbaf27e8..995f1dd0967 100644
--- a/install_files/securedrop-ossec-agent/var/ossec/etc/ossec.conf
+++ b/install_files/securedrop-ossec-agent/var/ossec/etc/ossec.conf
@@ -34,6 +34,8 @@
/var/lib/securedrop/db.sqlite
+ /var/lib/securedrop/submissions_today.txt
+
/var/securedrop/store
/var/ossec/queue
@@ -101,6 +103,12 @@
last -n 5
+
+ full_command
+ head -1 /var/lib/securedrop/submissions_today.txt | grep '^[0-9]*$'
+ 86400
+
+
syslog
/var/log/kern.log
diff --git a/install_files/securedrop-ossec-server/var/ossec/etc/ossec.conf b/install_files/securedrop-ossec-server/var/ossec/etc/ossec.conf
index 771fb23b7e8..31b321ab17c 100644
--- a/install_files/securedrop-ossec-server/var/ossec/etc/ossec.conf
+++ b/install_files/securedrop-ossec-server/var/ossec/etc/ossec.conf
@@ -124,6 +124,12 @@
+
+ journalist@localhost
+ multiple_drops
+
+
+
root@localhost
low_diskspace
diff --git a/install_files/securedrop-ossec-server/var/ossec/rules/local_rules.xml b/install_files/securedrop-ossec-server/var/ossec/rules/local_rules.xml
index e182d5509ff..9f26db56f37 100644
--- a/install_files/securedrop-ossec-server/var/ossec/rules/local_rules.xml
+++ b/install_files/securedrop-ossec-server/var/ossec/rules/local_rules.xml
@@ -190,3 +190,11 @@
no_email_alert
+
+
+
+ 530
+ ossec: output: 'head -1 /var/lib/securedrop/submissions_today.txt
+ Number of submissions received in the past 24h.
+
+
diff --git a/testinfra/ossec/test_journalist_mail.py b/testinfra/ossec/test_journalist_mail.py
new file mode 100644
index 00000000000..628d2bb4ad6
--- /dev/null
+++ b/testinfra/ossec/test_journalist_mail.py
@@ -0,0 +1,208 @@
+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")
+ for (origin, destination) in (
+ ('journalist', 'journalist'),
+ ('root', 'ossec')):
+ assert self.run(host, "postsuper -d ALL")
+ assert self.run(
+ host,
+ "echo DEF | mail -s 'abc' {origin}@localhost".format(
+ origin=origin))
+ assert self.wait_for_command(
+ host,
+ "mailq | grep -q {destination}@ossec.test".format(
+ destination=destination))
+ self.service_stopped(host, "postfix")
+
+ 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):
+ assert self.run(
+ host, "! mailq | grep -q {who}@ossec.test".format(who=who))
+ assert self.run(
+ host,
+ """
+ ( echo 'Subject: TEST' ; echo ; echo MYGREATPAYLOAD ) | \
+ /var/ossec/send_encrypted_alarm.sh {who}
+ """.format(who=who))
+ assert self.wait_for_command(
+ host, "mailq | grep -q {who}@ossec.test".format(who=who))
+
+ #
+ # encrypted mail to journalist or ossec contact
+ #
+ for who in ('journalist', 'ossec'):
+ assert self.run(host, "postsuper -d ALL")
+ trigger(who)
+ 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 MYGREATPAYLOAD
+ """)
+ #
+ # 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)
+ 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:7: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 how-many-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")
+
+ #
+ # start ossec with frequent monitoring of
+ # submissions_today.txt
+ #
+ with app.sudo():
+ assert self.run(
+ app,
+ "sed -i -e 's/>86400>7' /var/ossec/etc/ossec.conf")
+ self.service_restarted(app, "ossec")
+
+ #
+ # wait until at least one notification is sent
+ #
+ assert self.wait_for_command(
+ mon,
+ "mailq | grep -q journalist@ossec.test")
+
+ #
+ # 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")
+ assert self.run(
+ app,
+ "sed -i -e 's/>7>86400' /var/ossec/etc/ossec.conf")
diff --git a/testinfra/test.py b/testinfra/test.py
index 8c269e02569..0ace97b963d 100755
--- a/testinfra/test.py
+++ b/testinfra/test.py
@@ -30,7 +30,7 @@ def get_target_roles(target_host):
'testinfra/app-code',
'testinfra/common',
'testinfra/development/test_xvfb.py'],
- "staging": [],
+ "staging": ['testinfra/ossec'],
"mon-staging": ['testinfra/mon',
'testinfra/common'],
"mon-prod": ['testinfra/mon']}