Repository: freedomofpress/securedrop

Issue:
Convert ServerSpec tests to TestInfra
Substantial work has been done on config tests, currently implemented in [ServerSpec](http://serverspec.org/) (#1006). The config tests are not currently run automatically via a CI pipeline (#1067), but should be. Given recent efforts to improve the application tests (#1445, #1457, #1466, #1474, #1516), we should make a similar push to test the system hardening specifications. 

The core developers already have strong familiarity with Python and [pytest](http://docs.pytest.org/en/latest/contents.html), so we should convert the existing config tests to [TestInfra](https://testinfra.readthedocs.io/en/latest/#), which is pytest-based. Doing so would allow us to remove Ruby-based tooling from the repo and focus on a Python-only development environment for the foreseeable future.

The config tests must be updated before we make a concerted effort to improve the Ansible versioning situation on Admin Workstations (#1146, #1531). We'll also need a strong config testing baseline in order to evaluate the upgrade from Ubuntu Trusty to Xenial (#1530). 


Discussion:

Resolving patch:
diff --git a/testinfra/conftest.py b/testinfra/conftest.py
new file mode 100644
--- /dev/null
+++ b/testinfra/conftest.py
@@ -0,0 +1,38 @@
+"""
+Configuration for TestInfra test suite for SecureDrop.
+Handles importing host-specific test vars, so test functions
+can be reused across multiple hosts, with varied targets.
+
+Vars should be placed in `testinfra/vars/<hostname>.yml`.
+"""
+
+import os
+import sys
+import yaml
+import pytest
+
+
+target_host = os.environ['SECUREDROP_TESTINFRA_TARGET_HOST']
+assert target_host != ""
+
+
+def securedrop_import_testinfra_vars(hostname, with_header=False):
+    """
+    Import vars from a YAML file to populate tests with host-specific
+    values used in checks. For instance, the SecureDrop docroot will
+    be under /vagrant in development, but /var/www/securedrop in staging.
+
+    Vars must be stored in `testinfra/vars/<hostname>.yml`.
+    """
+    filepath = os.path.join(os.path.dirname(__file__), "vars", hostname+".yml")
+    hostvars = yaml.load(open(filepath, 'r'))
+    if with_header:
+        hostvars = dict(securedrop_test_vars=hostvars)
+    return hostvars
+
+
+
+
+
+def pytest_namespace():
+    return securedrop_import_testinfra_vars(target_host, with_header=True)


Test patch:
diff --git a/docs/development/config_tests.rst b/docs/development/config_tests.rst
new file mode 100644
--- /dev/null
+++ b/docs/development/config_tests.rst
@@ -0,0 +1,115 @@
+Configuration Tests
+===================
+
+testinfra_ tests verify the end state of the vagrant machines. Any
+changes to the Ansible configuration should have a corresponding
+spectest.
+
+.. _testinfra: https://testinfra.readthedocs.io/en/latest/
+
+Installation
+------------
+
+.. code:: sh
+
+    pip install -r testinfra/requirements.txt
+
+Running the tests
+-----------------
+
+In order to run the tests, first create and provision the VM you intend
+to test:
+
+.. code:: sh
+
+    vagrant up development
+    vagrant up /staging/
+
+.. note:: The staging machines must be rebooted via in order to finalize
+          the iptables config. You must manually reboot the machines via
+          ``vagrant reload /staging/`` prior to running the config tests
+          to ensure the config is valid.
+
+Running all VMs concurrently may cause performance
+problems if you have less than 8GB of RAM. You can isolate specific
+machines for faster testing:
+
+.. code:: sh
+
+    $ ./testinfra/test.py development
+    $ ./testinfra/test.py app-staging
+    $ ./testinfra/test.py mon-staging
+
+.. note:: The config tests for the ``app-prod`` and ``mon-prod`` hosts are
+          incomplete. Further changes are necessary to run the tests via
+          SSH over Authenticated Tor Hidden Service (ATHS), for both local
+          testing via Vagrant and automated testing via CI.
+
+Test failure against any host will generate a report with informative output
+about the specific test that triggered the error. The wrapper script
+will also exit with a non-zero status code.
+
+Updating the tests
+------------------
+
+Changes to the Ansible config should result in failing config tests, but
+only if an existing task was modified. If you add a new task, make
+sure to add a corresponding spectest to validate that state after a
+new provisioning run. Tests import variables from separate YAML files
+than the Ansible playbooks: ::
+
+    testinfra/vars/
+    ├── app-prod.yml
+    ├── app-staging.yml
+    ├── build.yml
+    ├── development.yml
+    ├── mon-prod.yml
+    └── mon-staging.yml
+
+Any variable changes in the Ansible config should have a corresponding
+entry in these vars files. These vars are dynamically loaded for each
+host via the ``testinfra/conftest.py`` file. Make sure to add your tests to
+relevant location for the host you plan to test: ::
+
+    testinfra/app/
+    ├── apache
+    │   ├── test_apache_journalist_interface.py
+    │   ├── test_apache_service.py
+    │   ├── test_apache_source_interface.py
+    │   └── test_apache_system_config.py
+    ├── test_apparmor.py
+    ├── test_appenv.py
+    └── test_ossec.py
+
+In the example above, to add a new test for the ``app-staging`` host,
+add a new file to the ``testinfra/spec/app-staging`` directory.
+
+Config test layout
+------------------
+
+The config tests are mostly broken up according to machines in the
+Vagrantfile: ::
+
+    testinfra/
+    ├── app
+    ├── app-code
+    ├── build
+    ├── common
+    ├── development
+    └── mon
+
+Ideally the config tests would be broken up according to roles,
+mirroring the Ansible configuration. Prior to the reorganization of
+the Ansible layout, the tests are rather tightly coupled to hosts. The
+layout of config tests is therefore subject to change.
+
+Testing strategy
+----------------
+
+The config tests currently emphasize testing implementation rather than
+functionality. This is a temporary measure to increase the current testing
+baseline for validating the Ansible provisioning flow, to aid in migrating
+to a current version of Ansible (v2+). After the Ansible version is current,
+the config tests can be improved to validate behavior, such as confirming
+ports are blocked via external network calls, rather than simply checking
+that the iptables rules are formatted as expected.
diff --git a/docs/development/spec_tests.rst b/docs/development/spec_tests.rst
deleted file mode 100644
--- a/docs/development/spec_tests.rst
+++ /dev/null
@@ -1,118 +0,0 @@
-Serverspec Tests
-================
-
-serverspec_ tests verify the end state of the vagrant machines. Any
-changes to the Ansible configuration should have a corresponding
-spectest.
-
-.. _serverspec: http://serverspec.org
-
-Install directions (Ubuntu/Debian)
----------------------------
-
-.. code:: sh
-
-    apt-get install bundler
-    cd spec_tests/
-    bundle update
-
-.. note:: If you run into an error regarding the version of
-          ``bundler`` such as "Bundler could not find compatible versions",
-          then you can install and run a particular version of ``bundler`` via:
-
-          .. code:: sh
-
-              gem install bundler -v 1.12.5
-              bundle _1.12.5_ install
-              bundle _1.12.5_ exec rake spec:development
-
-
-Running the tests
------------------
-
-In order to run the tests, each VM will be created and provisioned, if
-necessary.  Running all VMs concurrently may cause performance
-problems if you have less than 8GB of RAM. You can isolate specific
-machines for faster testing:
-
-.. code:: sh
-
-    $ cd spec_tests
-    $ bundle exec rake --tasks # check output for desired machine
-    rake spec:app-prod     # Run spectests against app-prod
-    rake spec:app-staging  # Run spectests against app-staging
-    rake spec:build        # Run spectests against build
-    rake spec:development  # Run spectests against development
-    rake spec:mon-prod     # Run spectests against mon-prod
-    rake spec:mon-staging  # Run spectests against mon-staging
-
-    $ bundle exec rake spec:staging
-
-The invocation above will run tests only against the ``staging`` VMs.
-You can run against multiple environments in a single invocation:
-
-.. code:: sh
-
-    $ cd spec_tests
-    $ bundle exec rake spec:development spec:staging
-
-Test failure against any host will cause Serverspec to exit, and stop
-executing tests against subsequent hosts. In order to run the ``spec:prod``
-task, you will need to configure :ref:`SSH access over Tor <ssh_over_tor>`.
-
-Updating the tests
-------------------
-
-Changes to the ansible config should result in failing spectests, but
-only if an existing task was modified. If you add a new task, make
-sure to add a corresponding spectest to validate that state after a
-new provisioning run. Tests import variables from separate YAML files
-than the Ansible playbooks: ::
-
-    spec_tests/spec/vars
-    ├── development.yml
-    └── staging.yml
-
-Any variable changes in the Ansible config should have a corresponding
-entry in these vars files. These vars are dynamically loaded for each
-host via the ``spec_helper.rb`` file. Make sure to add your tests to
-relevant location for the host you plan to test: ::
-
-    spec_tests/spec/app-staging
-    ├── apache_spec.rb
-    ├── apparmor_spec.rb
-    ├── iptables_spec.rb
-    ├── ossec_agent_spec.rb
-    ├── securedrop_app_spec.rb
-    ├── securedrop_app_test_spec.rb
-    └── tor_spec.rb
-
-In the example above, to add a new test for the ``app-staging`` host,
-add a new file to the ``spec_tests/spec/app-staging`` directory.
-
-Spectest layout
----------------
-
-The serverspec tests are mostly broken up according to machines in the
-Vagrantfile: ::
-
-    spec_tests/spec
-    ├── app-staging
-    ├── build
-    ├── common-development
-    ├── common-staging
-    ├── development
-    ├── mon-staging
-    └── vars
-
-There are a few exceptions:
-
--  ``common-development`` shares tests between ``development`` and
-   ``app-staging``
--  ``common-staging`` shares tests between ``app-staging`` and
-   ``mon-staging``
-
-Ideally the serverspec tests would be broken up according to roles,
-mirroring the Ansible configuration. Prior to the reorganization of
-the Ansible layout, the tests are rather tightly coupled to hosts. The
-layout of spectests is therefore subject to change.
diff --git a/spec_tests/.bundle/config b/spec_tests/.bundle/config
deleted file mode 100644
--- a/spec_tests/.bundle/config
+++ /dev/null
@@ -1,3 +0,0 @@
----
-BUNDLE_PATH: ./gems/
-BUNDLE_DISABLE_SHARED_GEMS: '1'
diff --git a/spec_tests/.gemrc b/spec_tests/.gemrc
deleted file mode 100644
--- a/spec_tests/.gemrc
+++ /dev/null
@@ -1,8 +0,0 @@
-:backtrace: false
-:benchmark: false
-:bulk_threshold: 1000
-:sources:
-- http://rubygems.org/
-:update_sources: true
-:verbose: true
-gem: --no-ri --no-rdoc
diff --git a/spec_tests/.rspec b/spec_tests/.rspec
deleted file mode 100644
--- a/spec_tests/.rspec
+++ /dev/null
@@ -1,4 +0,0 @@
---color
-#--format documentation
---require spec_helper
---order random
diff --git a/spec_tests/Gemfile b/spec_tests/Gemfile
deleted file mode 100644
--- a/spec_tests/Gemfile
+++ /dev/null
@@ -1,5 +0,0 @@
-source 'https://rubygems.org'
-
-gem 'serverspec'
-gem 'rake'
-
diff --git a/spec_tests/Rakefile b/spec_tests/Rakefile
deleted file mode 100644
--- a/spec_tests/Rakefile
+++ /dev/null
@@ -1,78 +0,0 @@
-require 'rake'
-require 'rspec/core/rake_task'
-
-
-task :spec    => 'spec:all'
-task :staging => 'spec:staging'
-task :default => :spec
-
-# this function accepts a hostname and returns a hash
-# that includes roles assigned to that host. changes made
-# in ansible config must be manually duplicated here.
-def roles(host)
-  roles = []
-  case host
-  when /^development$/
-    (roles << %w(app-test common-development development)).flatten!
-  when /^build$/
-    (roles << "build").flatten!
-  when /^app-staging/
-    (roles << %w(app-test app-general common-development common app-staging grsecurity ip6tables)).flatten!
-  when /^mon-staging/
-    (roles << %w(mon-general mon-staging common grsecurity ip6tables)).flatten!
-  when /^app-prod/
-    (roles << %w(app-prod app-general common common-development grsecurity ip6tables)).flatten!
-  when /^mon-prod/
-    (roles << %w(mon-general mon-prod common grsecurity ip6tables)).flatten!
-  end
-
-  # SSH access over Tor required for prod config,
-  # so assume machines have already been provisioned
-  # and ATHS values fetched, then force SSH over Tor.
-  if host.match(/-prod$/)
-    ENV['SECUREDROP_SSH_OVER_TOR'] = "1"
-  end
-
-  # DigitalOcean droplets don't support custom kernels, so remove
-  # any planned spectests for grsecurity. This is only relevant
-  # for the app and mon hosts, so don't delay other tests with check.
-  if host.match(/^(app|mon)/)
-    if `vagrant status #{host} --machine-readable`.match(/#{host},provider-name,digital_ocean/m)
-      roles.delete('grsecurity')
-    end
-  end
-  roles
-end
-
-
-# manually declare hosts, and assign them roles for testing
-hosts = %w(development build app-staging mon-staging app-prod mon-prod).map do |host|
-  {
-    :name => host,
-    :roles => roles(host)
-  }
-end
-
-
-# dynamically determine roles per host
-# this brilliant pragma conceived by @vincentbernat, see
-# https://github.com/vincentbernat/serverspec-example
-# http://vincent.bernat.im/en/blog/2014-serverspec-test-infrastructure.html
-namespace :spec do
-  # assign all hosts to serverspec task run
-  task :all => hosts.map { |h| h[:name] }
-
-  # assign only /staging/ hosts to serverspec task run
-  task :staging => hosts.select{ |h| h[:name].match(/staging$/) }.map{ |h| h[:name] }
-  task :prod => hosts.select{ |h| h[:name].match(/prod$/) }.map{ |h| h[:name] }
-
-  hosts.each do |host|
-    desc "Run spectests against #{host[:name]}"
-    RSpec::Core::RakeTask.new(host[:name].to_sym) do |t|
-      ENV['TARGET_HOST'] = host[:name]
-      t.pattern = './spec/{' + host[:roles].join(",") + '}/*_spec.rb'
-      puts "Running spectests for host '#{host[:name]}'..."
-      t.verbose = false
-    end
-  end
-end
diff --git a/spec_tests/spec/app-general/apache_spec.rb b/spec_tests/spec/app-general/apache_spec.rb
deleted file mode 100644
--- a/spec_tests/spec/app-general/apache_spec.rb
+++ /dev/null
@@ -1,279 +0,0 @@
-# declare required apache packages
-apache_packages = [
-  'apache2-mpm-worker',
-  'libapache2-mod-wsgi',
-  'libapache2-mod-xsendfile',
-]
-# ensure required apache packages are installed
-apache_packages.each do |apache_package|
-  describe package(apache_package) do
-    it { should be_installed }
-  end
-end
-
-# ensure required apache2 security config file is present
-# TODO: /etc/apache2/security is superfluous, see issue #643
-# once ansible playbook is updated to remove it, this check
-# should be removed as well.
-describe file('/etc/apache2/security') do
-  it { should be_file }
-  it { should be_owned_by  'root' }
-  it { should be_mode '644' }
-  its(:content) { should match "ServerTokens Prod" }
-  its(:content) { should match "ServerSignature Off" }
-  its(:content) { should match "TraceEnable Off" }
-end
-
-# declare required apache2 config settings
-apache2_config_settings = [
-  'Mutex file:${APACHE_LOCK_DIR} default',
-  'PidFile ${APACHE_PID_FILE}',
-  'Timeout 60',
-  'KeepAlive On',
-  'MaxKeepAliveRequests 100',
-  'KeepAliveTimeout 5',
-  'User www-data',
-  'Group www-data',
-  'AddDefaultCharset UTF-8',
-  'DefaultType None',
-  'HostnameLookups Off',
-  'ErrorLog /dev/null',
-  'LogLevel crit',
-  'IncludeOptional mods-enabled/*.load',
-  'IncludeOptional mods-enabled/*.conf',
-  'Include ports.conf',
-  'IncludeOptional sites-enabled/*.conf',
-  'ServerTokens Prod',
-  'ServerSignature Off',
-  'TraceEnable Off',
-]
-# ensure required apache2 config settings are present
-describe file('/etc/apache2/apache2.conf') do
-  it { should be_file }
-  it { should be_owned_by  'root' }
-  it { should be_mode '644' }
-  apache2_config_settings.each do |apache2_config_setting|
-    apache2_config_setting_regex = Regexp.quote(apache2_config_setting)
-    its(:content) { should match /^#{apache2_config_setting_regex}$/ }
-  end
-end
-
-# ensure apache2 ports conf is present
-describe file('/etc/apache2/ports.conf') do
-  it { should be_file }
-  it { should be_owned_by  'root' }
-  it { should be_mode '644' }
-  ["80", "8080"].each do |listening_port|
-    listening_regex = Regexp.quote("Listen #{property['apache_listening_address']}:#{listening_port}")
-    its(:content) { should match /^#{listening_regex}$/ }
-  end
-end
-
-# declare desired apache headers for vhost configs
-apache2_common_headers = [
-  'Header edit Set-Cookie ^(.*)$ $1;HttpOnly',
-  'Header always append X-Frame-Options: DENY',
-  'Header set X-XSS-Protection: "1; mode=block"',
-  'Header set X-Content-Type-Options: nosniff',
-  'Header set X-Download-Options: noopen',
-  # using string literal syntax here (%{}) to avoid manual quote escaping
-  %{Header set X-Content-Security-Policy: "default-src 'self'"},
-  %{Header set Content-Security-Policy: "default-src 'self'"},
-  'Header unset Etag',
-]
-# declare block of directory declarations common to both
-# source and journalist interfaces.
-common_apache2_directory_declarations = <<eos
-<Directory />
-  Options None
-  AllowOverride None
-  Order deny,allow
-  Deny from all
-</Directory>
-
-<Directory /var/www/>
-  Options None
-  AllowOverride None
-  <Limit GET POST HEAD>
-    Order allow,deny
-    allow from #{property['apache_allow_from']}
-  </Limit>
-  <LimitExcept GET POST HEAD>
-    Order deny,allow
-    Deny from all
-  </LimitExcept>
-</Directory>
-
-<Directory #{property['securedrop_code']}>
-  Options None
-  AllowOverride None
-  <Limit GET POST HEAD>
-    Order allow,deny
-    allow from #{property['apache_allow_from']}
-  </Limit>
-  <LimitExcept GET POST HEAD>
-    Order deny,allow
-    Deny from all
-  </LimitExcept>
-</Directory>
-eos
-# declare desired apache2 available sites
-apache2_available_sites = [
-  '/etc/apache2/sites-available/journalist.conf',
-  '/etc/apache2/sites-available/source.conf',
-]
-# check desired apache2 available sites for common headers
-apache2_available_sites.each do |apache2_available_site|
-  describe file(apache2_available_site) do
-    it { should be_file }
-    it { should be_owned_by 'root' }
-    it { should be_mode '644' }
-    apache2_common_headers.each do |apache2_common_header|
-      apache2_common_header_regex = Regexp.quote(apache2_common_header)
-      its(:content) { should match /^#{apache2_common_header_regex}$/ }
-    end
-    its(:content) { should contain(common_apache2_directory_declarations) }
-  end
-end
-
-# declare source-specific apache configs
-source_apache2_config_settings = [
-  'Header set Cache-Control "max-age=1800, must-revalidate"',
-  "<VirtualHost #{property['apache_listening_address']}:80>",
-  "DocumentRoot #{property['securedrop_code']}/static",
-  "Alias /static #{property['securedrop_code']}/static",
-  "WSGIDaemonProcess source  processes=2 threads=30 display-name=%{GROUP} python-path=#{property['securedrop_code']}",
-  'WSGIProcessGroup source',
-  'WSGIScriptAlias / /var/www/source.wsgi/',
-  'AddType text/html .py',
-  'XSendFile        Off',
-  'LimitRequestBody 524288000',
-  'ErrorDocument 400 /notfound',
-  'ErrorDocument 401 /notfound',
-  'ErrorDocument 403 /notfound',
-  'ErrorDocument 404 /notfound',
-  'ErrorDocument 500 /notfound',
-  "ErrorLog #{property['apache_source_log']}",
-]
-# check source-specific apache2 config
-describe file('/etc/apache2/sites-available/source.conf') do
-  it { should be_file }
-  it { should be_owned_by  'root' }
-  it { should be_grouped_into  'root' }
-  it { should be_mode '644' }
-  source_apache2_config_settings.each do |source_apache2_config_setting|
-    source_apache2_config_setting_regex = Regexp.quote(source_apache2_config_setting)
-    its(:content) { should match /^#{source_apache2_config_setting_regex}$/ }
-  end
-end
-
-# declare journalist-specific apache configs
-journalist_apache2_config_settings = [
-  'Header set Cache-Control "max-age=1800"',
-  "<VirtualHost #{property['apache_listening_address']}:8080>",
-  "DocumentRoot #{property['securedrop_code']}/static",
-  "Alias /static #{property['securedrop_code']}/static",
-  "WSGIDaemonProcess journalist  processes=2 threads=30 display-name=%{GROUP} python-path=#{property['securedrop_code']}",
-  'WSGIProcessGroup journalist',
-  'WSGIScriptAlias / /var/www/journalist.wsgi/',
-  'AddType text/html .py',
-  'XSendFile        On',
-  'XSendFilePath    /var/lib/securedrop/store/',
-  'XSendFilePath    /var/lib/securedrop/tmp/',
-  'ErrorLog /var/log/apache2/journalist-error.log',
-  'CustomLog /var/log/apache2/journalist-access.log combined',
-]
-# check journalist-specific apache2 config
-describe file('/etc/apache2/sites-available/journalist.conf') do
-  it { should be_file }
-  it { should be_owned_by  'root' }
-  it { should be_grouped_into  'root' }
-  it { should be_mode '644' }
-  journalist_apache2_config_settings.each do |journalist_apache2_config_setting|
-    journalist_apache2_config_setting_regex = Regexp.quote(journalist_apache2_config_setting)
-    its(:content) { should match /^#{journalist_apache2_config_setting_regex}$/ }
-  end
-end
-
-# declare apache2 enabled modules
-apache2_enabled_modules = [
-  'access_compat',
-  'authn_core',
-  'alias',
-  'authz_core',
-  'authz_host',
-  'authz_user',
-  'deflate',
-  'filter',
-  'dir',
-  'headers',
-  'mime',
-  'mpm_event',
-  'negotiation',
-  'reqtimeout',
-  'rewrite',
-  'wsgi',
-  'xsendfile',
-]
-# ensure required apache2 modules are enabled
-apache2_enabled_modules.each do |enabled_module|
-  describe command("a2query -m #{enabled_module}") do
-    its(:stdout) { should match /^#{enabled_module} \(enabled/ }
-  end
-end
-
-# declare apache2 disabled modules
-apache2_disabled_modules = [
-  'auth_basic',
-  'authn_file',
-  'autoindex',
-  'env',
-  'setenvif',
-  'status',
-]
-# ensure unwanted apache2 modules are disabled
-apache2_disabled_modules.each do |disabled_module|
-  describe command("a2query -m #{disabled_module}") do
-    its(:stderr) { should match /^No module matches #{disabled_module}/ }
-  end
-end
-
-# Are source and journalist interface sites enabled?
-['source', 'journalist'].each do |enabled_site|
-  describe command("a2query -s #{enabled_site}") do
-    its(:stdout) { should match /^#{enabled_site} \(enabled/ }
-  end
-end
-
-# Are default sites disabled?
-['000-default'].each do |disabled_site|
-  describe command("a2query -s #{disabled_site}") do
-    its(:stderr) { should match /^No site matches #{disabled_site}/ }
-  end
-end
-
-# Is apache running as user X
-describe service('apache2') do
- it { should be_enabled }
- it { should be_running }
-end
-
-# ensure securedrop user account is configured properly
-describe user('www-data') do
-  it { should exist }
-  it { should have_home_directory '/var/www' }
-  it { should have_login_shell '/usr/sbin/nologin' }
-end
-
-# Apache should be listening on 80 and 8080.
-# In staging, expect the service to be bound to 0.0.0.0,
-# but in prod, it should be restricted to 127.0.0.1.
-listening_ports = ['80', '8080']
-listening_ports.each do |listening_port|
-  describe port(listening_port) do
-    it { should be_listening.with('tcp') }
-    it { should be_listening.on(property['apache_listening_address']).with('tcp') }
-  end
-end
-
-# Check firewall rule
diff --git a/spec_tests/spec/app-general/ossec_agent_spec.rb b/spec_tests/spec/app-general/ossec_agent_spec.rb
deleted file mode 100644
--- a/spec_tests/spec/app-general/ossec_agent_spec.rb
+++ /dev/null
@@ -1,30 +0,0 @@
-# ensure hosts file references mon server by ip
-describe file('/etc/hosts') do
-  localhost_regex = /^#{Regexp.quote('127.0.0.1')}(\s+#{property['app_hostname']}){2}$/
-  its(:content) { should match localhost_regex }
-  # TODO: the "securedrop-monitor-server-alias" is an artifact of
-  # using the vagrant-hostmanager plugin. it may no longer be necessary
-  mon_host_regex = Regexp.quote("#{property['monitor_ip']}  #{property['monitor_hostname']} securedrop-monitor-server-alias")
-  its(:content) { should match /^#{mon_host_regex}$/ }
-end
-
-# Regression test to check for duplicate entries.
-describe command('uniq --repeated /etc/hosts') do
-  its(:stdout) { should eq "" }
-end
-
-# ensure custom ossec-agent package is installed
-describe package('securedrop-ossec-agent') do
-  it { should be_installed }
-end
-
-# ensure client keyfile for ossec-agent is present
-describe file('/var/ossec/etc/client.keys') do
-  it { should be_file }
-  it { should be_mode '644' }
-  it { should be_owned_by 'root' }
-  it { should be_grouped_into 'ossec' }
-  app_ip_regex = Regexp.quote("#{property['app_hostname']} #{property['app_ip']}")
-  # this regex checks for a hex string of 64 chars, not a specific value
-  its(:content) { should match /^1024 #{app_ip_regex} [0-9a-f]{64}$/ }
-end
diff --git a/spec_tests/spec/app-general/securedrop_app_spec.rb b/spec_tests/spec/app-general/securedrop_app_spec.rb
deleted file mode 100644
--- a/spec_tests/spec/app-general/securedrop_app_spec.rb
+++ /dev/null
@@ -1,58 +0,0 @@
-# declare securedrop app directories
-securedrop_app_directories = [
-  property['securedrop_code'],
-  property['securedrop_data'],
-  "#{property['securedrop_data']}/store",
-  "#{property['securedrop_data']}/keys",
-  "#{property['securedrop_data']}/tmp",
-]
-
-# ensure securedrop app directories exist with correct permissions
-securedrop_app_directories.each do |securedrop_app_directory|
-  describe file(securedrop_app_directory) do
-    it { should be_directory }
-    it { should be_owned_by property['securedrop_user'] }
-    it { should be_grouped_into property['securedrop_user'] }
-    it { should be_mode '700' }
-  end
-end
-
-# ensure securedrop-app-code package is installed
-describe package('securedrop-app-code') do
-  it { should be_installed }
-end
-
-# ensure test gpg key is present in app keyring
-describe command('su -s /bin/bash -c "gpg --homedir /var/lib/securedrop/keys --list-keys 28271441" www-data') do
-  its(:exit_status) { should eq 0 }
-  expected_output = <<-eos
-pub   4096R/28271441 2013-10-12
-uid                  SecureDrop Test/Development (DO NOT USE IN PRODUCTION)
-sub   4096R/A2201B2A 2013-10-12
-
-eos
-  its(:stdout) { should eq expected_output }
-end
-
-# ensure default logo header file exists
-# TODO: add check for custom logo header file
-describe file("#{property['securedrop_code']}/static/i/logo.png") do
-  it { should be_file }
-  # TODO: ansible task declares mode 400 but the file ends up as 644 on host
-  it { should be_mode '644' }
-  it { should be_owned_by property['securedrop_user'] }
-  it { should be_grouped_into property['securedrop_user'] }
-end
-
-# ensure cron job for securedrop tmp dir cleanup is enabled
-describe cron do
-  it { should have_entry "@daily #{property['securedrop_code']}/manage.py clean-tmp" }
-end
-
-# ensure directory for worker logs is present
-describe file('/var/log/securedrop_worker') do
-  it { should be_directory }
-  it { should be_mode '644' }
-  it { should be_owned_by 'root' }
-  it { should be_grouped_into 'root' }
-end
diff --git a/spec_tests/spec/app-general/tor_spec.rb b/spec_tests/spec/app-general/tor_spec.rb
deleted file mode 100644
--- a/spec_tests/spec/app-general/tor_spec.rb
+++ /dev/null
@@ -1,36 +0,0 @@
-# declare app-staging torrc settings
-torrc_settings = [
-  'HiddenServiceDir /var/lib/tor/services/source',
-  'HiddenServicePort 80 127.0.0.1:80',
-  'HiddenServiceDir /var/lib/tor/services/journalist',
-  'HiddenServicePort 80 127.0.0.1:8080',
-  'HiddenServiceAuthorizeClient stealth journalist',
-]
-# ensure torrc for app-staging host contains entries
-# for both journalist and source ATHSes. the admin
-# ATHS and other settings are already checked as part of the
-# common-staging serverspec tests
-describe file('/etc/tor/torrc') do
-  torrc_settings.each do |torrc_setting|
-    torrc_setting_regex = Regexp.quote(torrc_setting)
-    its(:content) { should match /^#{torrc_setting_regex}$/ }
-  end
-end
-
-# declare app-specific tor service directories,
-# for mode and ownership checks. the parent dir
-# and the "ssh" service are validated in the
-# common-staging spectests.
-tor_service_directories = %w(
-  /var/lib/tor/services/journalist
-  /var/lib/tor/services/source
-)
-# ensure tor service dirs are owned by tor user and mode 0700
-tor_service_directories.each do |tor_service_directory|
-  describe file(tor_service_directory) do
-    it { should be_directory  }
-    it { should be_mode('700')  }
-    it { should be_owned_by 'debian-tor' }
-    it { should be_grouped_into 'debian-tor' }
-  end
-end
diff --git a/spec_tests/spec/app-prod/apparmor_spec.rb b/spec_tests/spec/app-prod/apparmor_spec.rb
deleted file mode 100644
--- a/spec_tests/spec/app-prod/apparmor_spec.rb
+++ /dev/null
@@ -1,130 +0,0 @@
-# Apparmor package dependencies
-['apparmor', 'apparmor-utils' ].each do |pkg|
-  describe package(pkg) do
-    it { should be_installed }
-  end
-end
-
-# Check that apparmor is enabled.
-# The command returns error code if AppArmor not enabled
-describe command("aa-status --enabled") do
-  its(:exit_status) { should eq 0 }
-end
-
-# Staging role has two profiles in complain mode:
-# tor and apache2. Make sure the config file does NOT include
-# that flag, since restarting apparmor will load whatever's on disk
-['tor', 'apache2'].each do |complaining_process|
-  describe file("/etc/apparmor.d/usr.sbin.#{complaining_process}") do
-    it { should be_file }
-    it { should be_owned_by 'root' }
-    it { should be_mode '644' }
-    its(:content) { should_not match /^\/usr\/sbin\/#{complaining_process} flags=\(complain\) \{/ }
-  end
-end
-
-# declare expected app-armor capabilities for apache2
-apache2_capabilities = %w(
-  kill
-  net_bind_service
-  sys_ptrace
-)
-# check for exact list of expected app-armor capabilities for apache2
-describe command('perl -nE \'/^\s+capability\s+(\w+),$/ && say $1\' /etc/apparmor.d/usr.sbin.apache2') do
-  apache2_capabilities.each do |apache2_capability|
-    its(:stdout) { should contain(apache2_capability) }
-  end
-end
-
-# ensure no extra capabilities are defined for apache2
-describe command('grep -ic capability /etc/apparmor.d/usr.sbin.apache2') do
-  its(:stdout) { should eq apache2_capabilities.length.to_s + "\n" }
-end
-
-# check for exact list of expected app-armor capabilities for tor
-describe command('perl -nE \'/^\s+capability\s+(\w+),$/ && say $1\' /etc/apparmor.d/usr.sbin.tor') do
-  its(:stdout) { should contain("setgid") }
-end
-
-# ensure no extra capabilities are defined for tor
-describe command('grep -ic capability /etc/apparmor.d/usr.sbin.tor') do
-  its(:stdout) { should eq "1\n" }
-end
-
-# Explicitly check that enforced profiles are NOT
-# present in /etc/apparmor.d/disable. Polling aa-status
-# only checks the last config that was loaded, whereas
-# checking for symlinks in the `disabled` dir checks
-# the config to be loaded when the apparmor service is bounced.
-enforced_profiles = [
-  'ntpd',
-  'apache2',
-  'tcpdump',
-  'tor',
-]
-enforced_profiles.each do |enforced_profile|
-  describe file("/etc/apparmor.d/disabled/usr.sbin.#{enforced_profile}") do
-    it { should_not be_file }
-    it { should_not be_directory }
-    it { should_not be_symlink }
-  end
-end
-
-# declare app-armor profiles expected to be enforced
-enforced_apparmor_profiles = %w(
-  /sbin/dhclient
-  /usr/lib/NetworkManager/nm-dhcp-client.action
-  /usr/lib/connman/scripts/dhclient-script
-  /usr/sbin/apache2
-  /usr/sbin/apache2//DEFAULT_URI
-  /usr/sbin/apache2//HANDLING_UNTRUSTED_INPUT
-  /usr/sbin/ntpd
-  /usr/sbin/tcpdump
-  /usr/sbin/tor
-  system_tor
-)
-# check for enforced app-armor profiles
-# this klunky one-liner uses bash, because serverspec defaults to sh,
-# then provides START and STOP patterns to sed, filters by profile
-# names according to leading whitespace, then trims leading whitespace
-describe command("aa-status") do
-  enforced_apparmor_profiles.each do |enforced_apparmor_profile|
-    its(:stdout) { should contain(enforced_apparmor_profile).from(/profiles are in enforce mode/).to(/profiles are in complain mode/) }
-  end
-end
-
-# ensure number of expected enforced profiles matches number checked
-describe command("aa-status --enforced") do
-  its(:stdout) { should eq enforced_apparmor_profiles.length.to_s + "\n" }
-end
-
-# declare app-armor profiles expected to be complaining
-# the staging hosts enabled "complain" mode for more verbose
-# logging during development and testing; production hosts
-# should not have any complain mode.
-complaining_apparmor_profiles = %w(
-  /usr/sbin/apache2
-  /usr/sbin/tor
-)
-
-# check for complaining app-armor profiles; should NOT exist in prod
-describe command("aa-status") do
-  complaining_apparmor_profiles.each do |complaining_apparmor_profile|
-    its(:stdout) { should_not contain(complaining_apparmor_profile).from(/profiles are in complain mode/).to(/\d+ processes have profiles defined/) }
-  end
-end
-
-# ensure number of expected complaining profiles matches number checked
-describe command("aa-status --complaining") do
-  its(:stdout) { should eq "0\n" }
-end
-
-# ensure number of total profiles matches enforced profiles
-describe command("aa-status --profiled") do
-  its(:stdout) { should eq enforced_apparmor_profiles.length.to_s + "\n" }
-end
-
-# Ensure that there are no processes that are unconfined but have a profile
-describe command("aa-status") do
-  its(:stdout) { should contain("0 processes are unconfined but have a profile defined") }
-end
diff --git a/spec_tests/spec/app-prod/iptables_spec.rb b/spec_tests/spec/app-prod/iptables_spec.rb
deleted file mode 100644
--- a/spec_tests/spec/app-prod/iptables_spec.rb
+++ /dev/null
@@ -1,79 +0,0 @@
-# declare desired iptables rules
-# These rules should be present in prod and staging
-# TODO: There are also hardcoded IP addresses in this section.
-desired_iptables_rules = [
-  '-P INPUT DROP',
-  '-P FORWARD DROP',
-  '-P OUTPUT DROP',
-  '-N LOGNDROP',
-  '-A INPUT -p tcp -m state --state RELATED,ESTABLISHED -m comment --comment "Allow traffic back for tor" -j ACCEPT',
-  '-A INPUT -i lo -p tcp -m tcp --dport 80 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment "Allow tor connection from local loopback to connect to source int" -j ACCEPT',
-  '-A INPUT -i lo -p tcp -m tcp --dport 8080 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment "Allow tor connection from local loopback to connect to document int" -j ACCEPT',
-  '-A INPUT -s 127.0.0.1/32 -d 127.0.0.1/32 -i lo -p tcp -m state --state RELATED,ESTABLISHED -m comment --comment "for redis worker all application user local loopback user" -j ACCEPT',
-  '-A INPUT -s 8.8.8.8/32 -p tcp -m tcp --sport 53 -m state --state RELATED,ESTABLISHED -m comment --comment "tcp/udp dns" -j ACCEPT',
-  '-A INPUT -s 8.8.8.8/32 -p udp -m udp --sport 53 -m state --state RELATED,ESTABLISHED -m comment --comment "tcp/udp dns" -j ACCEPT',
-  '-A INPUT -p udp -m udp --sport 123 --dport 123 -m state --state RELATED,ESTABLISHED -m comment --comment ntp -j ACCEPT',
-  '-A INPUT -p tcp -m multiport --sports 80,8080,443 -m state --state RELATED,ESTABLISHED -m comment --comment "apt updates" -j ACCEPT',
-  "-A INPUT -s #{property['monitor_ip']}/32 -p udp -m udp --sport 1514 -m state --state RELATED,ESTABLISHED -m comment --comment \"OSSEC server agent\" -j ACCEPT",
-  '-A INPUT -i lo -m comment --comment "Allow lo to lo traffic all protocols" -j ACCEPT',
-  '-A INPUT -p tcp -m state --state INVALID -m comment --comment "drop but do not log inbound invalid state packets" -j DROP',
-  '-A INPUT -m comment --comment "Drop and log all other incoming traffic" -j LOGNDROP',
-  "-A OUTPUT -p tcp -m owner --uid-owner #{property['tor_user_uid']} -m state --state NEW,RELATED,ESTABLISHED -m comment --comment \"tor instance that provides ssh access\" -j ACCEPT",
-  "-A OUTPUT -o lo -p tcp -m tcp --dport 22 -m owner --uid-owner #{property['tor_user_uid']} -m state --state NEW -m limit --limit 3/min --limit-burst 3 -m comment --comment \"Rate limit traffic from tor to the ssh dameon\" -j ACCEPT",
-  "-A OUTPUT -o lo -p tcp -m tcp --dport 22 -m owner --uid-owner #{property['tor_user_uid']} -m state --state RELATED,ESTABLISHED -m comment --comment \"Allow the established traffic from tor to the ssh dameon\" -j ACCEPT",
-  "-A OUTPUT -o lo -p tcp -m tcp --dport 22 -m owner --uid-owner #{property['tor_user_uid']} -m state --state NEW -m comment --comment \"Drop all other new connections from tor to the ssh dameon\" -j LOGNDROP",
-  "-A OUTPUT -o lo -p tcp -m tcp --sport 80 -m owner --uid-owner #{property['apache_user_uid']} -m state --state RELATED,ESTABLISHED -m comment --comment \"Restrict the apache user outbound connections\" -j ACCEPT",
-  "-A OUTPUT -o lo -p tcp -m tcp --sport 8080 -m owner --uid-owner #{property['apache_user_uid']} -m state --state RELATED,ESTABLISHED -m comment --comment \"Restrict the apache user outbound connections\" -j ACCEPT",
-  "-A OUTPUT -s 127.0.0.1/32 -d 127.0.0.1/32 -o lo -p tcp -m owner --uid-owner #{property['apache_user_uid']} -m state --state NEW,RELATED,ESTABLISHED -m comment --comment \"for redis worker all application user local loopback user\" -j ACCEPT",
-  "-A OUTPUT -m owner --uid-owner #{property['apache_user_uid']} -m comment --comment \"Drop all other traffic by the securedrop user\" -j LOGNDROP",
-  "-A OUTPUT -m owner --gid-owner #{property['ssh_group_gid']} -m comment --comment \"Drop all other outbound traffic for ssh user\" -j LOGNDROP",
-  '-A OUTPUT -d 8.8.8.8/32 -p tcp -m tcp --dport 53 -m owner --uid-owner 0 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment "tcp/udp dns" -j ACCEPT',
-  '-A OUTPUT -d 8.8.8.8/32 -p udp -m udp --dport 53 -m owner --uid-owner 0 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment "tcp/udp dns" -j ACCEPT',
-  '-A OUTPUT -p udp -m udp --sport 123 --dport 123 -m owner --uid-owner 0 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment ntp -j ACCEPT',
-  '-A OUTPUT -p tcp -m multiport --dports 80,8080,443 -m owner --uid-owner 0 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment "apt updates" -j ACCEPT',
-  "-A OUTPUT -d #{property['monitor_ip']}/32 -p udp -m udp --dport 1514 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment \"OSSEC server agent\" -j ACCEPT",
-  '-A OUTPUT -o lo -m comment --comment "Allow lo to lo traffic all protocols" -j ACCEPT',
-  '-A OUTPUT -m comment --comment "Drop all other outgoing traffic" -j DROP',
-  '-A LOGNDROP -p tcp -m limit --limit 5/min -j LOG --log-tcp-options --log-ip-options --log-uid',
-  '-A LOGNDROP -p udp -m limit --limit 5/min -j LOG --log-ip-options --log-uid',
-  '-A LOGNDROP -p icmp -m limit --limit 5/min -j LOG --log-ip-options --log-uid',
-  '-A LOGNDROP -j DROP',
-]
-# declare unwanted iptables rules
-# These rules should have been removed by the `remove_authd_exceptions` role
-# TODO: The Vagrantfile virtualbox static IP was hardcoded into the two rules
-# below. This will need to be fixed. Possibly with using something like
-# https://github.com/volanja/ansible_spec Using the values for IP addresses
-# from the ansible inventory should cover most use cases (except inventories
-# with just the *.onion addresses).
-unwanted_iptables_rules = [
-  "-A OUTPUT -d #{property['monitor_ip']} -p tcp --dport 1515 -m state --state NEW,ESTABLISHED,RELATED -j ACCEPT -m comment --comment \"ossec authd rule only required for initial agent registration\"",
-  "-A INPUT -s #{property['monitor_ip']} -p tcp --sport 1515 -m state --state ESTABLISHED,RELATED -v ACCEPT -m comment --comment \"ossec authd rule only required for initial agent registration\"",
-
-  # These rules have the wrong interface for the vagrant mon-staging machine.
-  # Adding them in here to make sure ansible config changes don't introduce regressions.
-  '-A INPUT -s 10.0.2.15/32 -p udp -m udp --sport 1514 -m state --state RELATED,ESTABLISHED -m comment --comment "OSSEC server agent" -j ACCEPT',
-  '-A OUTPUT -d 10.0.2.15/32 -p udp -m udp --dport 1514 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment "OSSEC server agent" -j ACCEPT',
-
-  # These rules belong in the staging environment, not production.
-  '-A OUTPUT -p tcp -m tcp --sport 8080 -m state --state NEW,RELATED,ESTABLISHED -j ACCEPT',
-  '-A INPUT -p tcp -m tcp --dport 8080 -m state --state NEW,RELATED,ESTABLISHED -j ACCEPT',
-  '-A INPUT -p tcp -m tcp --dport 80 -m state --state NEW,RELATED,ESTABLISHED -j ACCEPT',
-
-  '-A INPUT -p udp -m udp --sport 53 -m state --state NEW,RELATED,ESTABLISHED -j ACCEPT',
-  '-A INPUT -p tcp -m tcp --dport 22 -m state --state NEW,RELATED,ESTABLISHED -j ACCEPT',
-
-  '-A OUTPUT -p tcp -m tcp --sport 80 -m state --state NEW,RELATED,ESTABLISHED -j ACCEPT',
-  '-A OUTPUT -p udp -m udp --dport 53 -m state --state NEW,RELATED,ESTABLISHED -j ACCEPT',
-  '-A OUTPUT -p tcp -m tcp --sport 22 -m state --state NEW,RELATED,ESTABLISHED -j ACCEPT',
-]
-
-# check for wanted and unwanted iptables rules
-describe iptables do
-  unwanted_iptables_rules.each do |unwanted_iptables_rule|
-    it { should_not have_rule(unwanted_iptables_rule) }
-  end
-  desired_iptables_rules.each do |desired_iptables_rule|
-    it { should have_rule(desired_iptables_rule) }
-  end
-end
diff --git a/spec_tests/spec/app-staging/apparmor_spec.rb b/spec_tests/spec/app-staging/apparmor_spec.rb
deleted file mode 100644
--- a/spec_tests/spec/app-staging/apparmor_spec.rb
+++ /dev/null
@@ -1,131 +0,0 @@
-# Apparmor package dependencies
-['apparmor', 'apparmor-utils' ].each do |pkg|
-  describe package(pkg) do
-    it { should be_installed }
-  end
-end
-
-# Check that apparmor is enabled.
-# The command returns error code if AppArmor not enabled
-describe command("aa-status --enabled") do
-  its(:exit_status) { should eq 0 }
-end
-
-# SecureDrop apache apparmor profile
-# Staging role has two profiles in complain mode:
-# tor and apache2. Make sure the config file includes
-# that flag, since restarting apparmor will load
-# whatever's on disk
-['tor', 'apache2'].each do |complaining_process|
-  describe file("/etc/apparmor.d/usr.sbin.#{complaining_process}") do
-    it { should be_file }
-    it { should be_owned_by 'root' }
-    it { should be_mode '644' }
-    its(:content) { should match /^\/usr\/sbin\/#{complaining_process} flags=\(complain\) \{/ }
-  end
-end
-
-# declare expected app-armor capabilities for apache2
-apache2_capabilities = %w(
-  kill
-  net_bind_service
-  sys_ptrace
-)
-# check for exact list of expected app-armor capabilities for apache2
-describe command('perl -nE \'/^\s+capability\s+(\w+),$/ && say $1\' /etc/apparmor.d/usr.sbin.apache2') do
-  apache2_capabilities.each do |apache2_capability|
-    its(:stdout) { should contain(apache2_capability) }
-  end
-end
-
-# ensure no extra capabilities are defined for apache2
-describe command('grep -ic capability /etc/apparmor.d/usr.sbin.apache2') do
-  its(:stdout) { should eq apache2_capabilities.length.to_s + "\n" }
-end
-
-# check for exact list of expected app-armor capabilities for tor
-describe command('perl -nE \'/^\s+capability\s+(\w+),$/ && say $1\' /etc/apparmor.d/usr.sbin.tor') do
-  its(:stdout) { should contain("setgid") }
-end
-
-# ensure no extra capabilities are defined for tor
-describe command('grep -ic capability /etc/apparmor.d/usr.sbin.tor') do
-  its(:stdout) { should eq "1\n" }
-end
-
-# Explicitly check that enforced profiles are NOT
-# present in /etc/apparmor.d/disable. Polling aa-status
-# only checks the last config that was loaded, whereas
-# checking for symlinks in the `disabled` dir checks
-# the config to be loaded when the apparmor service is bounced.
-enforced_profiles = [
-  'ntpd',
-  'apache2',
-  'tcpdump',
-  'tor',
-]
-enforced_profiles.each do |enforced_profile|
-  describe file("/etc/apparmor.d/disabled/usr.sbin.#{enforced_profile}") do
-    it { should_not be_file }
-    it { should_not be_directory }
-    it { should_not be_symlink }
-  end
-end
-
-# declare app-armor profiles expected to be enforced
-enforced_apparmor_profiles = %w(
-  /sbin/dhclient
-  /usr/lib/NetworkManager/nm-dhcp-client.action
-  /usr/lib/connman/scripts/dhclient-script
-  /usr/sbin/apache2//DEFAULT_URI
-  /usr/sbin/apache2//HANDLING_UNTRUSTED_INPUT
-  /usr/sbin/ntpd
-  /usr/sbin/tcpdump
-  system_tor
-)
-# check for enforced app-armor profiles
-# this klunky one-liner uses bash, because serverspec defaults to sh,
-# then provides START and STOP patterns to sed, filters by profile
-# names according to leading whitespace, then trims leading whitespace
-describe command("aa-status") do
-  enforced_apparmor_profiles.each do |enforced_apparmor_profile|
-    its(:stdout) { should contain(enforced_apparmor_profile).from(/profiles are in enforce mode/).to(/profiles are in complain mode/) }
-  end
-end
-
-# ensure number of expected enforced profiles matches number checked
-describe command("aa-status --enforced") do
-  its(:stdout) { should eq enforced_apparmor_profiles.length.to_s + "\n" }
-end
-
-# declare app-armor profiles expected to be complaining
-# the staging hosts enabled "complain" mode for more verbose
-# logging during development and testing; production hosts
-# should not have any complain mode.
-complaining_apparmor_profiles = %w(
-  /usr/sbin/apache2
-  /usr/sbin/tor
-)
-
-# check for complaining app-armor profiles
-describe command("aa-status") do
-  complaining_apparmor_profiles.each do |complaining_apparmor_profile|
-    its(:stdout) { should contain(complaining_apparmor_profile).from(/profiles are in complain mode/).to(/\d+ processes have profiles defined/) }
-  end
-end
-
-# ensure number of expected complaining profiles matches number checked
-describe command("aa-status --complaining") do
-  its(:stdout) { should eq complaining_apparmor_profiles.length.to_s + "\n" }
-end
-
-# ensure number of total profiles is sum of enforced and complaining profiles
-describe command("aa-status --profiled") do
-  total_profiles = enforced_apparmor_profiles.length + complaining_apparmor_profiles.length
-  its(:stdout) { should eq total_profiles.to_s + "\n" }
-end
-
-# Ensure that there are no processes that are unconfined but have a profile
-describe command("aa-status") do
-  its(:stdout) { should contain("0 processes are unconfined but have a profile defined") }
-end
diff --git a/spec_tests/spec/app-staging/iptables_spec.rb b/spec_tests/spec/app-staging/iptables_spec.rb
deleted file mode 100644
--- a/spec_tests/spec/app-staging/iptables_spec.rb
+++ /dev/null
@@ -1,107 +0,0 @@
-# declare desired iptables rules
-# These rules should be present in prod and staging
-# TODO: There are also hardcoded IP addresses in this section.
-desired_iptables_rules = [
-  '-P INPUT DROP',
-  '-P FORWARD DROP',
-  '-P OUTPUT DROP',
-  '-N LOGNDROP',
-  "-A INPUT -i #{property['staging_iface']} -p tcp -m tcp --dport 8080 -m state --state NEW,RELATED,ESTABLISHED -j ACCEPT",
-  "-A INPUT -i #{property['staging_iface']} -p tcp -m tcp --dport 80 -m state --state NEW,RELATED,ESTABLISHED -j ACCEPT",
-  '-A INPUT -p udp -m udp --sport 53 -m state --state NEW,RELATED,ESTABLISHED -j ACCEPT',
-  "-A INPUT -i #{property['staging_iface']} -p tcp -m tcp --dport 22 -m state --state NEW,RELATED,ESTABLISHED -j ACCEPT",
-  '-A INPUT -p tcp -m state --state RELATED,ESTABLISHED -m comment --comment "Allow traffic back for tor" -j ACCEPT',
-  '-A INPUT -i lo -p tcp -m tcp --dport 80 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment "Allow tor connection from local loopback to connect to source int" -j ACCEPT',
-  '-A INPUT -i lo -p tcp -m tcp --dport 8080 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment "Allow tor connection from local loopback to connect to document int" -j ACCEPT',
-  '-A INPUT -s 127.0.0.1/32 -d 127.0.0.1/32 -i lo -p tcp -m state --state RELATED,ESTABLISHED -m comment --comment "for redis worker all application user local loopback user" -j ACCEPT',
-  '-A INPUT -s 8.8.8.8/32 -p tcp -m tcp --sport 53 -m state --state RELATED,ESTABLISHED -m comment --comment "tcp/udp dns" -j ACCEPT',
-  '-A INPUT -s 8.8.8.8/32 -p udp -m udp --sport 53 -m state --state RELATED,ESTABLISHED -m comment --comment "tcp/udp dns" -j ACCEPT',
-  '-A INPUT -p udp -m udp --sport 123 --dport 123 -m state --state RELATED,ESTABLISHED -m comment --comment ntp -j ACCEPT',
-  '-A INPUT -p tcp -m multiport --sports 80,8080,443 -m state --state RELATED,ESTABLISHED -m comment --comment "apt updates" -j ACCEPT',
-  "-A INPUT -s #{property['monitor_ip']}/32 -p udp -m udp --sport 1514 -m state --state RELATED,ESTABLISHED -m comment --comment \"OSSEC server agent\" -j ACCEPT",
-  '-A INPUT -i lo -m comment --comment "Allow lo to lo traffic all protocols" -j ACCEPT',
-  '-A INPUT -p tcp -m state --state INVALID -m comment --comment "drop but do not log inbound invalid state packets" -j DROP',
-  '-A INPUT -m comment --comment "Drop and log all other incoming traffic" -j LOGNDROP',
-  "-A OUTPUT -o #{property['staging_iface']} -p tcp -m tcp --sport 8080 -m state --state NEW,RELATED,ESTABLISHED -j ACCEPT",
-  "-A OUTPUT -o #{property['staging_iface']} -p tcp -m tcp --sport 80 -m state --state NEW,RELATED,ESTABLISHED -j ACCEPT",
-  '-A OUTPUT -p udp -m udp --dport 53 -m state --state NEW,RELATED,ESTABLISHED -j ACCEPT',
-  "-A OUTPUT -o #{property['staging_iface']} -p tcp -m owner --uid-owner 0 -m tcp --sport 22 -m state --state NEW,RELATED,ESTABLISHED -j ACCEPT",
-  "-A OUTPUT -o lo -p tcp -m tcp --dport 22 -m owner --uid-owner #{property['tor_user_uid']} -m state --state NEW -m limit --limit 3/min --limit-burst 3 -m comment --comment \"Rate limit traffic from tor to the ssh dameon\" -j ACCEPT",
-  "-A OUTPUT -o lo -p tcp -m tcp --dport 22 -m owner --uid-owner #{property['tor_user_uid']} -m state --state NEW -m comment --comment \"Drop all other new connections from tor to the ssh dameon\" -j LOGNDROP",
-  "-A OUTPUT -o lo -p tcp -m tcp --dport 22 -m owner --uid-owner #{property['tor_user_uid']} -m state --state RELATED,ESTABLISHED -m comment --comment \"Allow the established traffic from tor to the ssh dameon\" -j ACCEPT",
-  "-A OUTPUT -m owner --uid-owner #{property['tor_user_uid']} -m comment --comment \"Drop all other traffic for the tor instance used for ssh\" -j LOGNDROP",
-  "-A OUTPUT -o lo -p tcp -m tcp --sport 80 -m owner --uid-owner #{property['apache_user_uid']} -m state --state RELATED,ESTABLISHED -m comment --comment \"Restrict the apache user outbound connections\" -j ACCEPT",
-  "-A OUTPUT -o lo -p tcp -m tcp --sport 8080 -m owner --uid-owner #{property['apache_user_uid']} -m state --state RELATED,ESTABLISHED -m comment --comment \"Restrict the apache user outbound connections\" -j ACCEPT",
-  "-A OUTPUT -s 127.0.0.1/32 -d 127.0.0.1/32 -o lo -p tcp -m owner --uid-owner #{property['apache_user_uid']} -m state --state NEW,RELATED,ESTABLISHED -m comment --comment \"for redis worker all application user local loopback user\" -j ACCEPT",
-  "-A OUTPUT -m owner --uid-owner #{property['apache_user_uid']} -m comment --comment \"Drop all other traffic by the securedrop user\" -j LOGNDROP",
-  "-A OUTPUT -m owner --gid-owner #{property['ssh_group_gid']} -m comment --comment \"Drop all other outbound traffic for ssh user\" -j LOGNDROP",
-  '-A OUTPUT -d 8.8.8.8/32 -p tcp -m tcp --dport 53 -m owner --uid-owner 0 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment "tcp/udp dns" -j ACCEPT',
-  '-A OUTPUT -d 8.8.8.8/32 -p udp -m udp --dport 53 -m owner --uid-owner 0 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment "tcp/udp dns" -j ACCEPT',
-  '-A OUTPUT -p udp -m udp --sport 123 --dport 123 -m owner --uid-owner 0 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment ntp -j ACCEPT',
-  '-A OUTPUT -p tcp -m multiport --dports 80,8080,443 -m owner --uid-owner 0 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment "apt updates" -j ACCEPT',
-  "-A OUTPUT -d #{property['monitor_ip']}/32 -p udp -m udp --dport 1514 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment \"OSSEC server agent\" -j ACCEPT",
-  '-A OUTPUT -o lo -m comment --comment "Allow lo to lo traffic all protocols" -j ACCEPT',
-  '-A OUTPUT -m comment --comment "Drop all other outgoing traffic" -j DROP',
-  '-A LOGNDROP -p tcp -m limit --limit 5/min -j LOG --log-tcp-options --log-ip-options --log-uid',
-  '-A LOGNDROP -p udp -m limit --limit 5/min -j LOG --log-ip-options --log-uid',
-  '-A LOGNDROP -p icmp -m limit --limit 5/min -j LOG --log-ip-options --log-uid',
-  '-A LOGNDROP -j DROP',
-]
-
-# declare unwanted iptables rules
-# These rules should have been removed by the `remove_authd_exceptions` role
-# TODO: The Vagrantfile virtualbox static IP was hardcoded into the two rules
-# below. This will need to be fixed. Possibly with using something like
-# https://github.com/volanja/ansible_spec Using the values for IP addresses
-# from the ansible inventory should cover most use cases (except inventories
-# with just the *.onion addresses).
-unwanted_iptables_rules = [
-  "-A OUTPUT -d #{property['monitor_ip']} -p tcp --dport 1515 -m state --state NEW,ESTABLISHED,RELATED -j ACCEPT -m comment --comment \"ossec authd rule only required for initial agent registration\"",
-  "-A INPUT -s #{property['monitor_ip']} -p tcp --sport 1515 -m state --state ESTABLISHED,RELATED -v ACCEPT -m comment --comment \"ossec authd rule only required for initial agent registration\"",
-
-  # These rules have the wrong interface for the vagrant mon-staging machine.
-  # Adding them in here to make sure ansible config changes don't introduce regressions.
-  '-A INPUT -s 10.0.2.15/32 -p udp -m udp --sport 1514 -m state --state RELATED,ESTABLISHED -m comment --comment "OSSEC server agent" -j ACCEPT',
-  '-A OUTPUT -d 10.0.2.15/32 -p udp -m udp --dport 1514 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment "OSSEC server agent" -j ACCEPT',
-]
-
-# check for wanted and unwanted iptables rules
-describe iptables do
-  unwanted_iptables_rules.each do |unwanted_iptables_rule|
-    it { should_not have_rule(unwanted_iptables_rule) }
-  end
-  desired_iptables_rules.each do |desired_iptables_rule|
-    it { should have_rule(desired_iptables_rule) }
-  end
-end
-
-
-# try to validate local networking config
-describe host(property['monitor_hostname']) do
-  monitor_ip_regex = Regexp.quote(property['monitor_ip'])
-  its(:ipaddress) { should match /^#{monitor_ip_regex}$/ }
-  it { should be_resolvable.by('hosts')  }
-  it { should_not be_reachable  }
-  # in staging, direct access allows ssh.
-  # prod hosts should NOT have access on 22.
-  it { should_not be_reachable.with( :port => 22, :proto => 'tcp') }
-end
-
-# declare ports expected to be listening
-listening_ports = [
-  22,     # ssh
-  80,     # source interface
-  8080,   # journalist interface
-  6001,   # Xvfb
-]
-# ensure ports are listening
-listening_ports.each do |listening_port|
-  describe port(listening_port) do
-    it { should be_listening.on('0.0.0.0').with('tcp') }
-  end
-end
-
-# check redis worker listening port
-describe port(6379) do
-  it { should be_listening.on('127.0.0.1').with('tcp') }
-end
diff --git a/spec_tests/spec/app-staging/securedrop_app_test_spec.rb b/spec_tests/spec/app-staging/securedrop_app_test_spec.rb
deleted file mode 100644
--- a/spec_tests/spec/app-staging/securedrop_app_test_spec.rb
+++ /dev/null
@@ -1,40 +0,0 @@
-# ensure logging is enabled for source interface in staging
-describe file('/var/www/source.wsgi') do
-  it { should be_file }
-  it { should be_owned_by 'www-data' }
-  it { should be_grouped_into 'www-data' }
-  it { should be_mode '640' }
-  its(:content) { should match /^import logging$/ }
-  its(:content) { should match /^logging\.basicConfig\(stream=sys\.stderr\)$/ }
-end
-
-# ensure app-armor profiles are in complain mode for staging
-# there are two profiles that should be in complain mode:
-# - usr.sbin.apache2
-# - usr.sbin.tor
-describe command('aa-status') do
-  expected_output = <<-eos
-2 profiles are in complain mode.
-   /usr/sbin/apache2
-   /usr/sbin/tor
-eos
-  its(:stdout) { should contain(expected_output) }
-end
-
-# These pip dependencies are staging-specific; they may NOT match
-# what's specified in `securedrop/requirements/test-requirements.txt`,
-# since they're pulled in via the prod packages on apt.freedom.press.
-pip_dependencies = [
-  'Flask-Testing==0.5.0',
-  'mock==2.0.0',
-  'pytest==3.0.1',
-  'selenium==2.53.6',
-]
-# ensure pip depdendencies are installed in staging.
-# these are required for running unit and functional tests
-describe command('pip freeze') do
-  pip_dependencies.each do |pip_dependency|
-    its(:stdout) { should contain(pip_dependency) }
-  end
-end
-
diff --git a/spec_tests/spec/app-test/xvfb_spec.rb b/spec_tests/spec/app-test/xvfb_spec.rb
deleted file mode 100644
--- a/spec_tests/spec/app-test/xvfb_spec.rb
+++ /dev/null
@@ -1,89 +0,0 @@
-# declare apt package dependencies for running tests
-apt_dependencies = [
-  'firefox',
-  'xvfb',
-]
-# ensure apt package dependencies are installed
-apt_dependencies.each do |apt_dependency|
-  describe package(apt_dependency) do
-    it { should be_installed }
-  end
-end
-
-# ensure xvfb service config is present
-describe file('/etc/init.d/xvfb') do
-  it { should be_file }
-  it { should be_mode '700' }
-  it { should be_owned_by 'root' }
-  it { should be_grouped_into 'root' }
-  xvfb_init_content = <<-eos
-# This is the /etc/init.d/xvfb script. We use it to launch xvfb at boot in the
-# development environment so we can easily run the functional tests.
-
-XVFB=/usr/bin/Xvfb
-XVFBARGS=":1 -screen 0 1024x768x24 -ac +extension GLX +render -noreset"
-PIDFILE=/var/run/xvfb.pid
-case "$1" in
-  start)
-    echo -n "Starting virtual X frame buffer: Xvfb"
-    start-stop-daemon --start --quiet --pidfile $PIDFILE --make-pidfile --background --exec $XVFB -- $XVFBARGS
-    echo "."
-    ;;
-  stop)
-    echo -n "Stopping virtual X frame buffer: Xvfb"
-    start-stop-daemon --stop --quiet --pidfile $PIDFILE
-    echo "."
-    ;;
-  restart)
-    $0 stop
-    $0 start
-    ;;
-  *)
-        echo "Usage: /etc/init.d/xvfb {start|stop|restart}"
-        exit 1
-esac
-
-exit 0
-eos
-  its(:content) { should eq xvfb_init_content }
-end
-
-# ensure xvfb is configured to start on boot via update-rc.d
-# the `-n` option to update-rc.d is dry-run
-describe command('update-rc.d -n xvfb defaults') do
-  its(:exit_status) { should eq 0 }
-  expected_output_regex = Regexp.quote('System start/stop links for /etc/init.d/xvfb already exist.')
-  its(:stdout) { should match /^\s{1}#{expected_output_regex}$/ }
-end
-
-# ensure DISPLAY environment variable is set on boot
-describe file('/etc/profile.d/xvfb_display.sh') do
-  it { should be_file }
-  it { should be_mode '444' }
-  it { should be_owned_by 'root' }
-  it { should be_grouped_into 'root' }
-  its(:content) { should eq "export DISPLAY=:1\n" }
-end
-
-# ensure that xvfb service is running
-describe service('Xvfb') do
-  # TODO: `enabled` check in serverspec uses a case-sensitive grep,
-  # so the all-lowercase filename /etc/init.d/xvfb fails an enabled
-  # check as a result. modify this in ansible config, then update test.
-  #  it { should be_enabled }
-  # TODO: ansible config does not enforce service=started for xvfb, but should.
-  # if app-staging has be rebooted/reloaded, then the service will be running.
-  it { should be_running }
-end
-describe service('xvfb') do
-  # TODO: (duplicate of above). rename /etc/init.d/{x,X}vfb in ansible config
-  it { should be_enabled }
-end
-
-# TODO: confirm that DISPLAY environment variable is currently set
-# will likely need to leverage a spec_helper for this, since
-# env vars are ignored by serverspec's default ssh config
-#describe command('echo $DISPLAY') do
-#  its(:stdout) { should eq ":1\n" }
-#end
-
diff --git a/spec_tests/spec/build/build_securedrop_deb_spec.rb b/spec_tests/spec/build/build_securedrop_deb_spec.rb
deleted file mode 100644
--- a/spec_tests/spec/build/build_securedrop_deb_spec.rb
+++ /dev/null
@@ -1,73 +0,0 @@
-# TODO: dynamically read the securedrop_app_code_version var
-securedrop_app_code_version = "0.3.2"
-# declare development apt dependencies for building
-development_apt_dependencies = [
-  'libssl-dev',
-  'python-dev',
-  'python-pip',
-]
-# ensure development apt dependencies are installed
-development_apt_dependencies.each do |development_apt_dependency|
-  describe package(development_apt_dependency) do
-    it { should be_installed }
-  end
-end
-
-# ensure "wheel" is installed via pip
-describe command('pip freeze') do
-  its(:stdout) { should contain('wheel==0.24.0') }
-end
-
-# declare required directories
-required_directories = [
-#  "/tmp/build/securedrop-ossec-agent-2.8.1+#{securedrop_app_code_version}-amd64/",
-#  "/tmp/build/securedrop-ossec-server-2.8.1+#{securedrop_app_code_version}-amd64/",
-  '/vagrant/build',
-]
-# ensure required directories exist
-required_directories.each do |required_directory|
-  describe file(required_directory) do
-    it { should be_directory }
-  end
-end
-
-# declare filenames for built debs
-wanted_debs = [
-  "/vagrant/build/securedrop-app-code-#{securedrop_app_code_version}-amd64.deb",
-  "/vagrant/build/securedrop-ossec-agent-2.8.1+#{securedrop_app_code_version}-amd64.deb",
-  "/vagrant/build/securedrop-ossec-server-2.8.1+#{securedrop_app_code_version}-amd64.deb",
-]
-wanted_debs.each do |wanted_deb|
-  # ensure required debs exist
-  describe file(wanted_deb) do
-    it { should be_file }
-  end
-
-  # get file basename of package, stripping leading dirs
-  deb_basename = File.basename(wanted_deb)
-
-  # cut up filename to extract package name
-  # this garish regex finds just the package name and strips the version info, e.g.
-  # from 'securedrop-ossec-agent-2.8.1+0.3.1-amd64.deb' it will return
-  # 'securedrop-ossec-agent'
-  package_name = deb_basename.scan(/^([a-z\-]+(?!\d))/)[0][0].to_s
-
-  # ensure required debs appear installable
-  describe command("dpkg --install --dry-run #{wanted_deb}") do
-    its(:exit_status) { should eq 0 }
-    its(:stdout) { should contain("Selecting previously unselected package #{package_name}.") }
-#    its(:stdout) { should contain("Preparing to unpack #{deb_basename} ...")}
-  end
-
-  # ensure control fields are populated as expected
-  # TODO: these checks are rather superficial, and don't actually confirm that the
-  # .deb files are not broken. at a later date, consider integration tests
-  # that actually use these built files during an ansible provisioning run.
-  describe command("dpkg-deb --field #{wanted_deb}") do
-    its(:exit_status) { should eq 0 }
-    its(:stdout) { should contain("Maintainer: SecureDrop Team <securedrop@freedom.press>") }
-    its(:stdout) { should contain("Homepage: https://securedrop.org") }
-    its(:stdout) { should contain("Package: #{package_name}")}
-    its(:stdout) { should contain("Architecture: amd64") }
-  end
-end
diff --git a/spec_tests/spec/common-development/securedrop_app_spec.rb b/spec_tests/spec/common-development/securedrop_app_spec.rb
deleted file mode 100644
--- a/spec_tests/spec/common-development/securedrop_app_spec.rb
+++ /dev/null
@@ -1,98 +0,0 @@
-# this file tests for app-related config
-# common to "development" and "app-staging"
-
-# ensure default apache html directory is absent
-describe file('/var/www/html') do
-  it { should_not exist }
-end
-
-# declare securedrop-app package dependencies
-securedrop_package_dependencies = [
-  'apparmor-utils',
-  'gnupg2',
-  'haveged',
-  'python',
-  'python-pip',
-  'redis-server',
-  'secure-delete',
-  'sqlite',
-  'supervisor',
-]
-# ensure securedrop-app dependencies are installed
-securedrop_package_dependencies.each do |securedrop_package_dependency|
-  describe package(securedrop_package_dependency) do
-    it { should be_installed }
-  end
-end
-
-# ensure haveged's low entrop watermark is sufficiently high
-describe file('/etc/default/haveged') do
-  it { should be_file }
-  it { should be_mode '644' }
-  it { should be_owned_by 'root' }
-  it { should be_grouped_into 'root' }
-  its(:content) { should match /^DAEMON_ARGS="-w 2400"$/ }
-end
-
-# Regression test to check for duplicate entries.
-describe command('uniq --repeated /etc/default/haveged') do
-  its(:stdout) { should eq "" }
-end
-
-# ensure haveged is running
-describe service('haveged') do
-  it { should be_enabled }
-  it { should be_running }
-end
-
-# ensure the securedrop application gpg pubkey is present
-describe file("#{property['securedrop_data']}/test_journalist_key.pub") do
-  it { should be_file }
-  it { should be_owned_by 'root' }
-  it { should be_grouped_into 'root' }
-  it { should be_mode '644' }
-end
-
-# ensure config.py (settings for securedrop app) exists
-describe file("#{property['securedrop_code']}/config.py") do
-  it { should be_file }
-  it { should be_owned_by property['securedrop_user'] }
-  it { should be_grouped_into property['securedrop_user'] }
-  it { should be_mode '600' }
-  its(:content) { should match /^JOURNALIST_KEY = '65A1B5FF195B56353CC63DFFCC40EF1228271441'$/ }
-end
-
-# ensure sqlite database exists for application
-describe file("#{property['securedrop_data']}/db.sqlite") do
-  it { should be_file }
-  # TODO: perhaps 640 perms would work here
-  it { should be_mode '644' }
-  it { should be_owned_by property['securedrop_user'] }
-  it { should be_grouped_into property['securedrop_user'] }
-end
-
-# declare config options for securedrop worker
-securedrop_worker_config_options = [
-  '[program:securedrop_worker]',
-  'command=/usr/local/bin/rqworker',
-  "directory=#{property['securedrop_code']}",
-  'autostart=true',
-  'autorestart=true',
-  'startretries=3',
-  'stderr_logfile=/var/log/securedrop_worker/err.log',
-  'stdout_logfile=/var/log/securedrop_worker/out.log',
-  "user=#{property['securedrop_user']}",
-  'environment=HOME="/tmp/python-gnupg"',
-]
-# ensure securedrop worker config for supervisor is present
-describe file('/etc/supervisor/conf.d/securedrop_worker.conf') do
-  it { should be_file }
-  it { should be_mode '644' }
-  it { should be_owned_by 'root' }
-  it { should be_grouped_into 'root' }
-  securedrop_worker_config_options.each do |securedrop_worker_config_option|
-    securedrop_worker_config_option_regex = Regexp.quote(securedrop_worker_config_option)
-    its(:content) { should match /^#{securedrop_worker_config_option_regex}$/ }
-  end
-end
-
diff --git a/spec_tests/spec/common/cron_apt_spec.rb b/spec_tests/spec/common/cron_apt_spec.rb
deleted file mode 100644
--- a/spec_tests/spec/common/cron_apt_spec.rb
+++ /dev/null
@@ -1,96 +0,0 @@
-# Check for critical packages
-['cron-apt', 'ntp'].each do |pkg|
-  describe package(pkg) do
-    it { should be_installed }
-  end
-end
-
-# ensure custom cron-apt config file is present
-describe file('/etc/cron-apt/config') do
-  it { should be_file }
-  it { should be_owned_by  'root' }
-  it { should be_mode '644' }
-  its(:content) { should match /^SYSLOGON="always"$/ }
-  its(:content) { should match /^EXITON=error$/ }
-end
-
-# list desired security repositories
-security_repositories = [
-  'deb http://security.ubuntu.com/ubuntu trusty-security main',
-  'deb-src http://security.ubuntu.com/ubuntu trusty-security main',
-  'deb http://security.ubuntu.com/ubuntu trusty-security universe',
-  'deb-src http://security.ubuntu.com/ubuntu trusty-security universe',
-  'deb [arch=amd64] https://apt.freedom.press trusty main',
-  'deb http://deb.torproject.org/torproject.org trusty main',
-]
-# ensure custom security.list file is present
-describe file('/etc/apt/security.list') do
-  it { should be_file }
-  it { should be_owned_by  'root' }
-  it { should be_mode '644' }
-  security_repositories.each do |repo|
-    repo_regex = Regexp.quote(repo)
-    its(:content) { should match /^#{repo_regex}$/ }
-  end
-end
-
-# ensure cron-apt updates the security.list packages
-describe file('/etc/cron-apt/action.d/0-update') do
-  it { should be_file }
-  it { should be_owned_by  'root' }
-  it { should be_mode '644' }
-  repo_regex = Regexp.quote('update -o quiet=2 -o Dir::Etc::SourceList=/etc/apt/security.list -o Dir::Etc::SourceParts=""')
-  its(:content) { should match /^#{repo_regex}$/ }
-end
-
-# ensure cron-apt upgrades the security.list packages
-describe file('/etc/cron-apt/action.d/5-security') do
-  it { should be_file }
-  it { should be_owned_by  'root' }
-  it { should be_mode '644' }
-  its(:content) { should match /^autoclean -y$/ }
-  config_regex = Regexp.quote('dist-upgrade -y -o APT::Get::Show-Upgraded=true -o Dir::Etc::SourceList=/etc/apt/security.list -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold')
-  its(:content) { should match /^#{config_regex}$/ }
-  its(:content) { should match /^autoremove -y$/ }
-end
-
-# ensure default cron-apt file to download all updates does not exist
-describe file('/etc/cron-apt/action.d/3-download') do
-  it { should_not exist }
-end
-
-desired_cronjobs = [
-  '0 4 * * * root    /usr/bin/test -x /usr/sbin/cron-apt && /usr/sbin/cron-apt && /sbin/reboot'
-]
-# Checking for old cron jobs to guard against regressions.
-unwanted_cronjobs = [
-  '0 4 * * * root    /usr/bin/test -x /usr/sbin/cron-apt && /usr/sbin/cron-apt',
-  '0 5 * * * root    /sbin/reboot',
-]
-# ensure the cron.d config for cron-apt exists
-describe file('/etc/cron.d/cron-apt') do
-  it { should be_file }
-  it { should be_owned_by  'root' }
-  it { should be_mode '644' }
-  desired_cronjobs.each do |cronjob|
-    cronjob_regex = Regexp.quote(cronjob)
-    its(:content) { should match /^#{cronjob_regex}$/ }
-  end
-  unwanted_cronjobs.each do |cronjob|
-    cronjob_regex = Regexp.quote(cronjob)
-    its(:content) { should_not match /^#{cronjob_regex}$/ }
-  end
-end
-
-# ensure safe-upgrade has already been run
-describe command('aptitude --simulate -y safe-upgrade') do
-  its(:exit_status) { should eq 0 }
-  its(:stdout) { should match /^No packages will be installed, upgraded, or removed\.$/ }
-  its(:stdout) { should match /0 packages upgraded, 0 newly installed, 0 to remove and 0 not upgraded\./ }
-end
-
-# TODO: In order to validate the intended system state post-provisioning,
-# may be simplest to compare output of `dpkg --get-selections`
-# from a clean box versus a post-provisioned one. However,
-# there will be environment-specific items in this list (e.g. vbox)
-# that must be pruned.
diff --git a/spec_tests/spec/common/fpf_repo_spec.rb b/spec_tests/spec/common/fpf_repo_spec.rb
deleted file mode 100644
--- a/spec_tests/spec/common/fpf_repo_spec.rb
+++ /dev/null
@@ -1,25 +0,0 @@
-# ensure FPF repo is present
-describe file('/etc/apt/sources.list.d/apt_freedom_press.list') do
-  its(:content) { should match /^deb \[arch=amd64\] https:\/\/apt\.freedom\.press trusty main$/ }
-end
-
-# ensure FPF has correct fingerprint
-describe command('apt-key finger') do
-  fpf_gpg_pub_key_info = '/etc/apt/trusted.gpg.d/securedrop-keyring.gpg
----------------------------------------------
-pub   4096R/00F4AD77 2016-10-20 [expires: 2017-10-20]
-      Key fingerprint = 2224 5C81 E3BA EB41 38B3  6061 310F 5612 00F4 AD77
-uid                  SecureDrop Release Signing Key'
-
-  # Using Regexp.quote() to escape regex special characters such as [].
-  its(:stdout) { should contain(Regexp.quote(fpf_gpg_pub_key_info)) }
-
-  fpf_gpg_pub_key_fingerprint_expired = 'B89A 29DB 2128 160B 8E4B  1B4C BADD E0C7 FC9F 6818'
-  fpf_gpg_pub_key_info_expired = "pub   4096R/FC9F6818 2014-10-26 [expired: 2016-10-27]
-      Key fingerprint = #{fpf_gpg_pub_key_fingerprint_expired}
-uid                  Freedom of the Press Foundation Master Signing Key"
-
-  its(:stdout) { should_not contain(Regexp.quote(fpf_gpg_pub_key_info_expired)) }
-  # Extra check to for just the old fingerprint; more durable in case formatting is off.
-  its(:stdout) { should_not contain(fpf_gpg_pub_key_fingerprint_expired) }
-end
diff --git a/spec_tests/spec/common/server_hardening_spec.rb b/spec_tests/spec/common/server_hardening_spec.rb
deleted file mode 100644
--- a/spec_tests/spec/common/server_hardening_spec.rb
+++ /dev/null
@@ -1,60 +0,0 @@
-# declare desired sysctl flags
-sysctl_flags = {
-  'net.ipv4.tcp_max_syn_backlog' => 4096,
-  'net.ipv4.tcp_syncookies' => 1,
-  'net.ipv4.conf.all.rp_filter' => 1,
-  'net.ipv4.conf.all.accept_source_route' => 0,
-  'net.ipv4.conf.all.accept_redirects' => 0,
-  'net.ipv4.conf.all.secure_redirects' => 0,
-  'net.ipv4.conf.default.rp_filter' => 1,
-  'net.ipv4.conf.default.accept_source_route' => 0,
-  'net.ipv4.conf.default.accept_redirects' => 0,
-  'net.ipv4.conf.default.secure_redirects' => 0,
-  'net.ipv4.icmp_echo_ignore_broadcasts' => 1,
-  'net.ipv4.ip_forward' => 0,
-  'net.ipv4.conf.all.send_redirects' => 0,
-  'net.ipv4.conf.default.send_redirects' => 0,
-  'net.ipv6.conf.all.disable_ipv6' => 1,
-  'net.ipv6.conf.default.disable_ipv6' => 1,
-  'net.ipv6.conf.lo.disable_ipv6' => 1,
-}
-# ensure sysctl flags are set correctly
-sysctl_flags.each do |sysctl_flag, value|
-  describe linux_kernel_parameter(sysctl_flag) do
-    its(:value) { should eq value }
-  end
-end
-
-# ensure DNS server is named
-# TODO: nameserver var is hard-coded below. consider
-# dynamically populated this var via spec_helper.
-describe file('/etc/resolvconf/resolv.conf.d/base') do |resolvconf|
-  it { should be_mode '644' }
-  its(:content) { should match /^nameserver 8\.8\.8\.8$/ }
-end
-
-disabled_kernel_modules = [
-  'bluetooth',
-  'iwlwifi',
-]
-disabled_kernel_modules.each do |disabled_kernel_module|
-  describe kernel_module(disabled_kernel_module) do
-    it { should_not be_loaded }
-  end
-  describe file('/etc/modprobe.d/blacklist.conf') do
-    its(:content) { should match /^blacklist #{disabled_kernel_module}$/ }
-  end
-end
-
-describe package('ntp') do
-  it { should be_installed }
-end
-
-# ensure swap space is disabled
-describe command('swapon --summary') do
-  # by using the `eq` operator here, we're ensuring the entirety of stdout is checked
-  its(:stdout) { should eq "Filename\t\t\t\tType\t\tSize\tUsed\tPriority\n" }
-  # a leading slash will indicate a fullpath to a swapfile
-  its(:stdout) { should_not match /^\// }
-end
-
diff --git a/spec_tests/spec/common/tor_spec.rb b/spec_tests/spec/common/tor_spec.rb
deleted file mode 100644
--- a/spec_tests/spec/common/tor_spec.rb
+++ /dev/null
@@ -1,74 +0,0 @@
-# ensure tor repo is present
-describe file('/etc/apt/sources.list.d/deb_torproject_org_torproject_org.list') do
-  repo_regex = Regexp.quote('deb http://deb.torproject.org/torproject.org trusty main')
-  its(:content) { should match /^#{repo_regex}$/ }
-end
-
-# ensure packages from tor repo are installed
-['deb.torproject.org-keyring', 'tor'].each do |pkg|
-  describe package(pkg) do
-    it { should be_installed }
-  end
-end
-
-# declare common settings for torrc
-torrc_settings = [
-  'SocksPort 0',
-  'SafeLogging 1',
-  'RunAsDaemon 1',
-  'Sandbox 1',
-  'HiddenServiceDir /var/lib/tor/services/ssh',
-  'HiddenServicePort 22 127.0.0.1:22',
-  'HiddenServiceAuthorizeClient stealth admin',
-]
-# ensure common settings are present in torrc
-# these settings should exist in both app-staging and mon-staging
-describe file('/etc/tor/torrc') do
-  it { should be_file }
-  it { should be_mode '644' }
-  it { should be_owned_by 'debian-tor' }
-  torrc_settings.each do |torrc_setting|
-    torrc_setting_regex = Regexp.quote(torrc_setting)
-    its(:content) { should match /^#{torrc_setting_regex}$/ }
-  end
-end
-
-# declare tor service directories, for mode and ownership checks
-tor_service_directories = %w(
-  /var/lib/tor/services
-  /var/lib/tor/services/ssh
-)
-# ensure tor service dirs are owned by tor user and mode 0700
-tor_service_directories.each do |tor_service_directory|
-  describe file(tor_service_directory) do
-    it { should be_directory  }
-    it { should be_mode('700')  }
-    it { should be_owned_by 'debian-tor' }
-    it { should be_grouped_into 'debian-tor' }
-  end
-end
-
-# ensure tor service is running
-describe service('tor') do
-  it { should be_running }
-  it { should be_enabled }
-end
-
-# Likely overkill
-describe command('service tor status') do
-  its(:exit_status) { should eq 0 }
-  its(:stdout) { should match /tor is running/ }
-end
-
-# ensure tor repo gpg key matches
-describe command('apt-key finger') do
-  tor_gpg_pub_key_info = '/etc/apt/trusted.gpg.d/deb.torproject.org-keyring.gpg
------------------------------------------------------
-pub   2048R/886DDD89 2009-09-04 [expires: 2020-08-29]
-      Key fingerprint = A3C4 F0F9 79CA A22C DBA8  F512 EE8C BC9E 886D DD89
-uid                  deb.torproject.org archive signing key
-sub   2048R/219EC810 2009-09-04 [expires: 2018-08-30]'
-
-  # Using Regexp.quote() to escape regex special chars such as [].
-  its(:stdout) { should contain(Regexp.quote(tor_gpg_pub_key_info)) }
-end
diff --git a/spec_tests/spec/common/user_config_spec.rb b/spec_tests/spec/common/user_config_spec.rb
deleted file mode 100644
--- a/spec_tests/spec/common/user_config_spec.rb
+++ /dev/null
@@ -1,31 +0,0 @@
-# ensure sudoers file is present
-['/etc/sudoers'].each do |sudoers|
-  describe file(sudoers) do
-    it { should be_mode '440' }
-    it { should be_readable.by_user('root') }
-    it { should_not be_readable.by('others') }
-    its(:content) { should match /^Defaults\s+env_reset$/ }
-    its(:content) { should match /^Defaults\s+mail_badpass$/ }
-    its(:content) { should contain('Defaults\s+secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"') }
-    its(:content) { should match /^%sudo\s+ALL=\(ALL\)\s+NOPASSWD:\s+ALL$/ }
-    its(:content) { should contain('Defaults:%sudo\s+!requiretty') }
-  end
-end
-
-# ensure securedrop-specific bashrc additions are present
-describe file('/etc/profile.d/securedrop_additions.sh') do
-  non_interactive_str = Regexp.quote('[[ $- != *i* ]] && return')
-  its(:content) { should match /^#{non_interactive_str}$/ }
-  its(:content) { should match /^if which tmux >\/dev\/null 2>&1; then$/ }
-  tmux_check = Regexp.quote('test -z "$TMUX" && (tmux attach || tmux new-session)')
-  its(:content) { should match /^\s+#{tmux_check}$/ }
-end
-
-# TODO: 'vagrant' user only valid in local vbox environment.
-# find some way to read this variable dynamically.
-# probably best to parse the YAML vars file via spec_helper.rb
-describe file('/home/vagrant/.bashrc') do |bashrc|
-  # Regression test: SecureDrop bashrc additions were previously added to local
-  # ~/.bashrc files in admin accounts, so now we're checking that the line does NOT exist.
-  its(:content) { should_not match /^. \/etc\/bashrc\.securedrop_additions$/  }
-end
diff --git a/spec_tests/spec/development/development_spec.rb b/spec_tests/spec/development/development_spec.rb
deleted file mode 100644
--- a/spec_tests/spec/development/development_spec.rb
+++ /dev/null
@@ -1,56 +0,0 @@
-# ensure development apt dependencies are installed
-development_apt_dependencies = [
-  'libssl-dev',
-  'python-dev',
-  'python-pip',
-]
-# ensure development apt dependencies are installed
-development_apt_dependencies.each do |development_apt_dependency|
-  describe package(development_apt_dependency) do
-    it { should be_installed }
-  end
-end
-
-# declare securedrop app pip requirements
-# since this is the development role, the pip dependencies
-# should be installed directly, rather than relying on the
-# apt packages, which would clobber local changes
-# versions here are intentionally hardcoded
-pip_requirements = [
-  'Flask-Testing==0.6.1',
-  'Flask==0.11.1',
-  'Jinja2==2.8',
-  'MarkupSafe==0.23',
-  'Werkzeug==0.11.11',
-  'beautifulsoup4==4.5.1',
-  'click==6.6',
-  'coverage==4.2',
-  'first==2.0.1',
-  'funcsigs==1.0.2',
-  'itsdangerous==0.24',
-  'mock==2.0.0',
-  'pbr==1.10.0',
-  'pip-tools==1.7.0',
-  'py==1.4.31',
-  'pytest-cov==2.3.1',
-  'pytest==3.0.2',
-  'selenium==2.53.6',
-  'six==1.10.0',
-]
-# ensure securedrop app pip requirements are installed
-describe command('pip freeze') do
-  pip_requirements.each do |pip_requirement|
-    its(:stdout) { should contain(pip_requirement) }
-  end
-end
-
-# ensure that the SECUREDROP_ENV var is set to "dev"
-# TODO: this isn't really checking that the env var is set,
-# just that it's declared in the bashrc. spec_helper ignores
-# env vars via ssh by default, so start there.
-describe file('/home/vagrant/.bashrc') do
-  it { should be_file }
-  it { should be_owned_by 'vagrant' }
-  its(:content) { should match /^export SECUREDROP_ENV=dev$/ }
-end
-
diff --git a/spec_tests/spec/development/iptables_spec.rb b/spec_tests/spec/development/iptables_spec.rb
deleted file mode 100644
--- a/spec_tests/spec/development/iptables_spec.rb
+++ /dev/null
@@ -1,50 +0,0 @@
-# Declare desired iptables rules
-# The 'development' machine doesn't have any custom
-# iptables rules, so just check for the default chains.
-desired_iptables_rules = [
-  '-P INPUT ACCEPT',
-  '-P FORWARD ACCEPT',
-  '-P OUTPUT ACCEPT',
-]
-
-# check for wanted and unwanted iptables rules
-describe iptables do
-  desired_iptables_rules.each do |desired_iptables_rule|
-    it { should have_rule(desired_iptables_rule) }
-  end
-end
-
-# if any iptables rules are ever added, this test will
-# fail, so tests can be written for the new rules.
-describe command('iptables -S | wc -l') do
-  its(:stdout) { should eq desired_iptables_rules.length.to_s + "\n" }
-end
-
-# check for ssh listening (this really shouldn't ever fail)
-describe port(22) do
-  it { should be_listening.on('0.0.0.0').with('tcp') }
-end
-
-# check for ssh listening (this really shouldn't ever fail)
-describe port(22) do
-  it { should be_listening.on('0.0.0.0').with('tcp') }
-end
-
-# check for redis worker port listening
-describe port(6379) do
-  it { should be_listening.on('127.0.0.1').with('tcp') }
-end
-
-# The Flask runners for the source and journalist interfaces
-# aren't configured to run by default, e.g. on boot. Nor
-# do the app tests cause them to be run. So, we shouldn't
-# really expected them to be running.
-## check for source interface flask port listening
-#describe port(8080) do
-#  it { should be_listening.on('0.0.0.0').with('tcp') }
-#end
-#
-## check for journalist interface flask port listening
-#describe port(8081) do
-#  it { should be_listening.on('0.0.0.0').with('tcp') }
-#end
diff --git a/spec_tests/spec/development/securedrop_app_dev_spec.rb b/spec_tests/spec/development/securedrop_app_dev_spec.rb
deleted file mode 100644
--- a/spec_tests/spec/development/securedrop_app_dev_spec.rb
+++ /dev/null
@@ -1,80 +0,0 @@
-# development role excludes apache, in favor of flask runner,
-# so ensure that apache is not installed.
-unwanted_packages = %w(
-  securedrop-app-code
-  apache2-mpm-worker
-  libapache2-mod-wsgi
-  libapache2-mod-xsendfile
-)
-unwanted_packages.each do |unwanted_package|
-  describe package(unwanted_package) do
-    it { should_not be_installed }
-  end
-end
-
-# ensure default html dir is absent
-describe file("/var/www/html") do
-  it { should_not exist }
-end
-
-securedrop_app_directories = [
-  property['securedrop_data'],
-  "#{property['securedrop_data']}/keys",
-  "#{property['securedrop_data']}/tmp",
-  "#{property['securedrop_data']}/store",
-]
-# ensure securedrop app directories exist with correct permissions
-securedrop_app_directories.each do |securedrop_app_directory|
-  describe file(securedrop_app_directory) do
-    it { should be_directory }
-    it { should be_owned_by property['securedrop_user'] }
-    it { should be_grouped_into property['securedrop_user'] }
-    it { should be_mode '700' }
-  end
-end
-
-# /vagrant has 770 permissions, so test
-# separately from the 700 permissions above
-describe file(property['securedrop_code']) do
-  it { should be_directory }
-  it { should be_owned_by property['securedrop_user'] }
-  it { should be_grouped_into property['securedrop_user'] }
-  # Vagrant VirtualBox environments show /vagrant as 770,
-  # but the Vagrant DigitalOcean droplet shows /vagrant as 775.
-  # This appears to be a side-effect of the default umask
-  # in the snapci instances. (The rsync provisioner for the
-  # vagrant-digitalocean plugin preserves permissions from the host.)
-  # The spectests for 'staging' still check for an explicit mode,
-  # so it's OK to relax this test for now.
-  #it { should be_mode '700' }
-  # TODO: should be 700 in all environments; ansible task is
-  # straightforward about this.
-  it { should be_readable.by('owner') }
-  it { should be_writable.by('owner') }
-  it { should be_executable.by('owner') }
-end
-
-# ensure cron job for securedrop tmp dir cleanup is enabled
-describe cron do
-  # TODO: this should be using property, but the ansible role
-  # doesn't use a var, it's hard-coded. update ansible, then fix test.
-  # it { should have_entry "@daily #{property['securedrop_code']}/manage.py clean-tmp" }
-  it { should have_entry "@daily /vagrant/securedrop/manage.py clean-tmp" }
-end
-
-
-# ensure default logo header file exists
-# TODO: add check for custom logo header file
-describe file("#{property['securedrop_code']}/static/i/logo.png") do
-  it { should be_file }
-  # TODO: Ansible task declares mode 400 but not as string, needs to be fixed
-  # and tests updated. Also, not using "mode" in tests below because umask
-  # on snapci machines differs from the /vagrant folder in dev VM.
-  # Fixing Ansible task may fix differing perms.
-  it { should be_writable.by('owner') }
-  it { should be_readable.by('owner') }
-  it { should be_readable.by('group') }
-  it { should be_readable.by('others') }
-  it { should be_owned_by property['securedrop_user'] }
-  it { should be_grouped_into property['securedrop_user'] }
-end
diff --git a/spec_tests/spec/development/securedrop_app_test_spec.rb b/spec_tests/spec/development/securedrop_app_test_spec.rb
deleted file mode 100644
--- a/spec_tests/spec/development/securedrop_app_test_spec.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# These checks are for the role `app-test`,
-# which is different from simply `app`, which
-# is tested in `securedrop_app_spec.rb`.
-# TODO: the Ansible role filters tasks from the
-# app-test role via the "non-development" tag.
-# There should be better separation between hosts
-# and roles, e.g. "development" and "app-test",
-# perhaps leveraging host_vars. Fix that, then
-# come back here and refactor the various versions
-# of `securedrop_app_test_spec.rb`.
-
-# ensure wsgi file is absent in development
-describe file("/var/www/source.wsgi") do
-  it { should_not exist }
-end
-
-# ensure app-armor profiles are NOT in complain mode
-# that's only for staging. in staging, these profiles complain:
-# - usr.sbin.apache2
-# - usr.sbin.tor
-describe command('aa-status') do
-  its(:stdout) { should contain('0 profiles are in complain mode.') }
-end
diff --git a/spec_tests/spec/grsecurity/grsec_spec.rb b/spec_tests/spec/grsecurity/grsec_spec.rb
deleted file mode 100644
--- a/spec_tests/spec/grsecurity/grsec_spec.rb
+++ /dev/null
@@ -1,107 +0,0 @@
-# ensure ssh motd is disabled (grsec balks at ubuntu's default motd)
-describe file('/etc/pam.d/sshd') do
-  its(:content) { should_not match /pam\.motd/ }
-end
-
-['securedrop-grsec', 'paxctl'].each do |pkg|
-  describe package(pkg) do
-    it { should be_installed }
-  end
-end
-
-unwanted_kernel_metapackages = [
-  'linux-signed-image-generic-lts-utopic',
-  'linux-signed-image-generic',
-  'linux-signed-generic-lts-utopic',
-  'linux-signed-generic',
-]
-unwanted_kernel_metapackages.each do |metapkg|
-  describe package(metapkg) do
-    it { should_not be_installed }
-  end
-end
-
-# ensure that system is running grsec kernel
-describe file('/proc/sys/kernel/grsecurity/grsec_lock') do
-  it { should be_mode '600' }
-  it { should be_owned_by('root') }
-  its(:size) { should eq 0 }
-end
-
-# ensure that system reports it's running grsec kernel (lazy)
-describe command("uname -r") do
-  its(:stdout) { should match /grsec$/ }
-end
-
-# Check that the grsec sysctl settings are correct
-describe 'Grsecurity kernel parameters' do
-  context linux_kernel_parameter('kernel.grsecurity.grsec_lock') do
-    its(:value) { should eq 1 }
-  end
-  context linux_kernel_parameter('kernel.grsecurity.rwxmap_logging') do
-    its(:value) { should eq 0 }
-  end
-end
-
-# Check that paxtest does not report anything vulnerable
-# Requires the package paxtest to be installed
-# The paxtest package is currently being installed in the app-test role
-paxtest_check_killed = [
-  "Executable anonymous mapping",
-  "Executable bss",
-  "Executable data",
-  "Executable heap",
-  "Executable stack",
-  "Executable shared library bss",
-  "Executable shared library data",
-  "Executable anonymous mapping (mprotect)",
-  "Executable bss (mprotect)",
-  "Executable data (mprotect)",
-  "Executable heap (mprotect)",
-  "Executable stack (mprotect)",
-  "Executable shared library bss (mprotect)",
-  "Executable shared library data (mprotect)",
-  "Writable text segments",
-  "Return to function (memcpy)",
-  "Return to function (memcpy, PIE)",
-]
-# TODO: enable the paxtest checks below once the "paxtest"
-# package is included via the grsecurity role.
-#describe command("paxtest blackhat") do
-#  paxtest_check_killed.each do |killed|
-#    its(:stdout) { should match /^#{Regexp.escape(killed)}\s*:\sKilled/ }
-#  end
-#  its(:stdout) { should_not match /Vulnerable/i }
-#  its(:exit_status) { should eq 0 }
-#end
-
-# ensure generic linux kernels have been removed
-describe command("dpkg --get-selections '^linux-image-.*generic$'") do
-  its(:stdout) { should_not match /^linux-image-.*generic$/ }
-  its(:stderr) { should match /^dpkg: no packages found matching / }
-  its(:exit_status) { should eq 0 }
-end
-
-# ensure linux kernel headers have been removed
-describe command("dpkg --get-selections '^linux-headers-.*'") do
-  its(:stdout) { should_not match /^linux-headers-.*/ }
-  its(:stderr) { should match /^dpkg: no packages found matching / }
-  its(:exit_status) { should eq 0 }
-end
-
-# ensure grub-pc is marked as manually installed (necessary vagrant/vbox)
-describe command('apt-mark showmanual grub-pc') do
-  its(:stdout) { should match /^grub-pc$/ }
-end
-
-# ensure old packages have been autoremoved
-describe command('apt-get --dry-run autoremove') do
-  its(:stdout) { should match /^0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded\.$/ }
-  its(:exit_status) { should eq 0 }
-end
-
-# Check pax flags for apache tor
-# paxctl -v /usr/sbin/apache2
-# paxctl -v /usr/sbin/tor
-# paxctl -v /usr/sbin/ntp
-# paxctl -v /usr/sbin/apt
diff --git a/spec_tests/spec/ip6tables/ipt6tables_spec.rb b/spec_tests/spec/ip6tables/ipt6tables_spec.rb
deleted file mode 100644
--- a/spec_tests/spec/ip6tables/ipt6tables_spec.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# All servers should have default DROP chains
-# for IPv6 iptables rules. Therefore the ip6tables
-# spec tests are isolated in a separate directory,
-# for easy reuse among hosts.
-
-# Declare exact output for ip6tables-save
-desired_ip6tables_output = <<END_IPV6_TABLE_RULES
-*filter
-:INPUT DROP [0:0]
-:FORWARD DROP [0:0]
-:OUTPUT DROP [0:0]
-COMMIT
-END_IPV6_TABLE_RULES
-# Check for DROP rules on ip6tables.
-# serverspec does have support for the 'ip6tables' type,
-# but that doesn't ensure rule order. Since the expected IPv6 rules
-# are simply default DROP rules for default chains,
-# check for the exact output. Make sure to filter out first and
-# last lines, which are just comments with the date.
-describe command("ip6tables-save | sed '1d;$d'") do
-  its(:stdout) { should eq desired_ip6tables_output }
-end
-
diff --git a/spec_tests/spec/mon-general/ossec_server_spec.rb b/spec_tests/spec/mon-general/ossec_server_spec.rb
deleted file mode 100644
--- a/spec_tests/spec/mon-general/ossec_server_spec.rb
+++ /dev/null
@@ -1,186 +0,0 @@
-# ensure hosts file references app server by ip
-describe file('/etc/hosts') do
-  localhost_regex = /^#{Regexp.quote('127.0.0.1')}(\s+#{property['monitor_hostname']}){2}$/
-  its(:content) { should match localhost_regex }
-  app_host_regex = Regexp.quote("#{property['app_ip']}  #{property['app_hostname']}")
-  its(:content) { should match /^#{app_host_regex}$/ }
-end
-
-# Regression test to check for duplicate entries.
-describe command('uniq --repeated /etc/hosts') do
-  its(:stdout) { should eq "" }
-end
-
-# ensure required packages are installed
-['postfix', 'procmail', 'mailutils', 'securedrop-ossec-server'].each do |pkg|
-  describe package(pkg) do
-    it { should be_installed }
-  end
-end
-
-# ensure custom /etc/aliases is present
-describe file('/etc/aliases') do
-  it { should be_file }
-  it { should be_mode '644' }
-  its(:content) { should match /^root: ossec$/ }
-end
-
-# ensure sasl password for smtp relay is configured
-# TODO: values below are hardcoded. for staging,
-# this is probably ok.
-describe file('/etc/postfix/sasl_passwd') do
-  sasl_passwd_regex = Regexp.quote('[smtp.gmail.com]:587   test@ossec.test:password123')
-  its(:content) { should match /^#{sasl_passwd_regex}$/ }
-  it { should be_mode '400' }
-end
-
-# declare desired regex checks for stripping smtp headers
-header_checks = [
-  '/^X-Originating-IP:/    IGNORE',
-  '/^X-Mailer:/    IGNORE',
-  '/^Mime-Version:/        IGNORE',
-  '/^User-Agent:/  IGNORE',
-  '/^Received:/    IGNORE',
-]
-# ensure header_checks regex to strip smtl headers are present
-describe file ('/etc/postfix/header_checks') do
-  it { should be_file }
-  it { should be_mode '644' }
-  header_checks.each do |header_check|
-    header_check_regex = Regexp.quote(header_check)
-    its(:content) { should match /^#{header_check_regex}$/ }
-  end
-end
-
-postfix_settings = [
-  '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',
-
-  # The security settings below are the default values
-  # set by the Ansible config as of #1100, but the playbooks
-  # still support TLS fingerprint verification. If you're testing
-  # the fingerprint verification functionality, you'll need to
-  # swap the strings below so the tests check for the appropriate config lines.
-  # 'smtp_tls_fingerprint_digest = sha1',
-  # 'smtp_tls_fingerprint_cert_match = 6D:87:EE:CB:D0:37:2F:88:B8:29:06:FB:35:F4:65:00:7F:FD:84:29',
-  '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 = +',
-
-  # This line was merged into develop via #1102,
-  # but hasn't made it into a release yet, so don't check for it.
-  # 'maximal_queue_lifetime = 14d',
-]
-# ensure all desired postfix settings are declared
-describe file('/etc/postfix/main.cf') do
-  it { should be_file }
-  it { should be_owned_by 'root' }
-  it { should be_mode '644' }
-  postfix_settings.each do |postfix_setting|
-    postfix_setting_regex = Regexp.quote(postfix_setting)
-    its(:content) { should match /^#{postfix_setting_regex}$/ }
-  end
-end
-
-# ensure ossec considers app-staging host "available"
-describe command('/var/ossec/bin/list_agents -a') do
-  its(:stdout) { should eq "#{property['app_hostname']}-#{property['app_ip']} is available.\n" }
-end
-
-# ensure ossec gpg homedir exists
-describe file("/var/ossec/.gnupg") do
-  it { should be_directory }
-  it { should be_owned_by "ossec" }
-  it { should be_mode '700' }
-end
-
-# ensure test admin gpg pubkey is present
-describe file('/var/ossec/test_admin_key.pub') do
-  it { should be_file }
-  it { should be_mode '644' }
-end
-
-# ensure test admin gpg pubkey is in ossec keyring
-describe command('su -s /bin/bash -c "gpg --homedir /var/ossec/.gnupg --list-keys EDDDC102" ossec') do
-  its(:exit_status) { should eq 0 }
-  # gpg dumps a lot of output to stderr, rather than stdout
-  expected_output = <<-eos
-pub   4096R/EDDDC102 2014-10-15
-uid                  Test/Development (DO NOT USE IN PRODUCTION) (Admin's OSSEC Alert GPG key) <securedrop@freedom.press>
-sub   4096R/97D2EB39 2014-10-15
-
-eos
-  its(:stdout) { should eq expected_output }
-end
-
-# ensure key files for ossec-server exist
-['/var/ossec/etc/sslmanager.key', '/var/ossec/etc/sslmanager.cert'].each do |keyfile|
-  describe file(keyfile) do
-    it { should be_file }
-    it { should be_mode '644' }
-    it { should be_owned_by 'root' }
-   end
-end
-
-# declare ossec procmail settings
-ossec_procmail_settings = [
-  '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',
-]
-# ensure ossec procmailrc has desired settings
-describe file("/var/ossec/.procmailrc") do
-  it { should be_file }
-  it { should be_mode '644' }
-  it { should be_owned_by 'ossec' }
-  ossec_procmail_settings.each do |ossec_procmail_setting|
-    ossec_procmail_setting_regex = Regexp.quote(ossec_procmail_setting)
-    its(:content) { should match /^#{ossec_procmail_setting_regex}$/ }
-  end
-end
-
-describe file('/var/ossec/send_encrypted_alarm.sh') do
-  it { should be_file }
-  it { should be_owned_by 'ossec' }
-  it { should be_mode '750' }
-end
-
-describe file('/var/log/procmail.log') do
-  it { should be_file }
-  it { should be_mode '660' }
-  it { should be_owned_by 'ossec' }
-end
-
-# ensure that authd is no longer running,
-# as stipulated in the `remove_authd_exemptions` role
-describe command('pgrep ossec-authd') do
-  # pgrep returns 1 if it finds no matching process
-  its(:exit_status) { should eq 1 }
-  its(:stdout) { should eq '' }
-end
diff --git a/spec_tests/spec/mon-prod/iptables_spec.rb b/spec_tests/spec/mon-prod/iptables_spec.rb
deleted file mode 100644
--- a/spec_tests/spec/mon-prod/iptables_spec.rb
+++ /dev/null
@@ -1,71 +0,0 @@
-# declare desired iptables rules
-# These rules should be present in prod.
-desired_iptables_rules = [
-  '-P INPUT DROP',
-  '-P FORWARD DROP',
-  '-P OUTPUT DROP',
-  '-N LOGNDROP',
-  '-A INPUT -p tcp -m state --state RELATED,ESTABLISHED -m comment --comment "Allow traffic back for tor" -j ACCEPT',
-  '-A INPUT -s 8.8.8.8/32 -p tcp -m tcp --sport 53 -m state --state RELATED,ESTABLISHED -m comment --comment "tcp/udp dns" -j ACCEPT',
-  '-A INPUT -s 8.8.8.8/32 -p udp -m udp --sport 53 -m state --state RELATED,ESTABLISHED -m comment --comment "tcp/udp dns" -j ACCEPT',
-  '-A INPUT -p udp -m udp --sport 123 --dport 123 -m state --state RELATED,ESTABLISHED -m comment --comment ntp -j ACCEPT',
-  '-A INPUT -p tcp -m multiport --sports 80,8080,443 -m state --state RELATED,ESTABLISHED -m comment --comment "apt updates" -j ACCEPT',
-  "-A INPUT -s #{property['app_ip']}/32 -p udp -m udp --dport 1514 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment \"Allow OSSEC agent to monitor\" -j ACCEPT",
-  '-A INPUT -p tcp -m tcp --sport 587 -m state --state RELATED,ESTABLISHED -m comment --comment "Allow ossec email alerts out" -j ACCEPT',
-  '-A INPUT -i lo -m comment --comment "Allow lo to lo traffic all protocols" -j ACCEPT',
-  '-A INPUT -p tcp -m state --state INVALID -m comment --comment "drop but do not log inbound invalid state packets" -j DROP',
-  '-A INPUT -m comment --comment "Drop and log all other incoming traffic" -j LOGNDROP',
-  "-A OUTPUT -p tcp -m owner --uid-owner #{property['tor_user_uid']} -m state --state NEW,RELATED,ESTABLISHED -m comment --comment \"tor instance that provides ssh access\" -j ACCEPT",
-  "-A OUTPUT -m owner --uid-owner #{property['tor_user_uid']} -m comment --comment \"Drop all other traffic for the tor instance used for ssh\" -j LOGNDROP",
-  "-A OUTPUT -o lo -p tcp -m tcp --dport 22 -m owner --uid-owner #{property['tor_user_uid']} -m state --state NEW -m limit --limit 3/min --limit-burst 3 -m comment --comment \"Rate limit traffic from tor to the ssh dameon\" -j ACCEPT",
-  "-A OUTPUT -o lo -p tcp -m tcp --dport 22 -m owner --uid-owner #{property['tor_user_uid']} -m state --state RELATED,ESTABLISHED -m comment --comment \"Allow the established traffic from tor to the ssh dameon\" -j ACCEPT",
-  "-A OUTPUT -o lo -p tcp -m tcp --dport 22 -m owner --uid-owner #{property['tor_user_uid']} -m state --state NEW -m comment --comment \"Drop all other new connections from tor to the ssh dameon\" -j LOGNDROP",
-  "-A OUTPUT -m owner --uid-owner #{property['tor_user_uid']} -m comment --comment \"Drop all other traffic for the tor instance used for ssh\" -j LOGNDROP",
-  "-A OUTPUT -m owner --gid-owner #{property['ssh_group_gid']} -m comment --comment \"Drop all other outbound traffic for ssh user\" -j LOGNDROP",
-  '-A OUTPUT -d 8.8.8.8/32 -p tcp -m tcp --dport 53 -m owner --uid-owner 0 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment "tcp/udp dns" -j ACCEPT',
-  '-A OUTPUT -d 8.8.8.8/32 -p udp -m udp --dport 53 -m owner --uid-owner 0 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment "tcp/udp dns" -j ACCEPT',
-  '-A OUTPUT -p udp -m udp --sport 123 --dport 123 -m owner --uid-owner 0 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment ntp -j ACCEPT',
-  '-A OUTPUT -p tcp -m multiport --dports 80,8080,443 -m owner --uid-owner 0 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment "apt updates" -j ACCEPT',
-  "-A OUTPUT -d #{property['app_ip']}/32 -p udp -m udp --sport 1514 -m state --state RELATED,ESTABLISHED -m comment --comment \"Allow OSSEC agent to monitor\" -j ACCEPT",
-  "-A OUTPUT -d 8.8.8.8/32 -p tcp -m tcp --dport 53 -m owner --uid-owner #{property['postfix_user_uid']} -m state --state NEW,RELATED,ESTABLISHED -m comment --comment \"postfix dns rule\" -j ACCEPT",
-  "-A OUTPUT -d 8.8.8.8/32 -p udp -m udp --dport 53 -m owner --uid-owner #{property['postfix_user_uid']} -m state --state NEW,RELATED,ESTABLISHED -m comment --comment \"postfix dns rule\" -j ACCEPT",
-  "-A OUTPUT -p tcp -m tcp --dport 587 -m owner --uid-owner #{property['postfix_user_uid']} -m state --state NEW,RELATED,ESTABLISHED -m comment --comment \"Allow ossec email alerts out\" -j ACCEPT",
-  '-A OUTPUT -o lo -m comment --comment "Allow lo to lo traffic all protocols" -j ACCEPT',
-  '-A OUTPUT -m comment --comment "Drop all other outgoing traffic" -j DROP',
-  '-A LOGNDROP -p tcp -m limit --limit 5/min -j LOG --log-tcp-options --log-ip-options --log-uid',
-  '-A LOGNDROP -p udp -m limit --limit 5/min -j LOG --log-ip-options --log-uid',
-  '-A LOGNDROP -p icmp -m limit --limit 5/min -j LOG --log-ip-options --log-uid',
-  '-A LOGNDROP -j DROP',
-]
-
-# declare unwanted iptables rules
-# These rules should have been removed by the `remove_authd_exceptions` role
-# TODO: The Vagrantfile virtualbox static IP was hardcoded into the two rules
-# below. This will need to be fixed. Possibly with using something like
-# https://github.com/volanja/ansible_spec Using the values for IP addresses
-# from the ansible inventory should cover most use cases (except inventories
-# with just the *.onion addresses).
-unwanted_iptables_rules = [
-  "-A INPUT -s #{property['app_ip']} -p tcp --sport 1515 -m state --state ESTABLISHED,RELATED -v ACCEPT -m comment --comment \"ossec authd rule only required for initial agent registration\"",
-  "-A OUTPUT -d #{property['app_ip']} -p tcp --dport 1515 -m state --state NEW,ESTABLISHED,RELATED -j ACCEPT -m comment --comment \"ossec authd rule only required for initial agent registration\"",
-  "-A INPUT -s #{property['app_ip']} -p tcp --dport 1515 -m state --state NEW,ESTABLISHED,RELATED -j ACCEPT",
-  "-A OUTPUT -d #{property['app_ip']} -p tcp --sport 1515 -m state --state ESTABLISHED,RELATED -j ACCEPT",
-
-
-  # These rules are from the staging environment
-  '-A INPUT -p udp -m udp --sport 53 -m state --state NEW,RELATED,ESTABLISHED -j ACCEPT',
-  '-A OUTPUT -p udp -m udp --dport 53 -m state --state NEW,RELATED,ESTABLISHED -j ACCEPT',
-  '-A INPUT -p tcp -m tcp --dport 22 -m state --state NEW,RELATED,ESTABLISHED -j ACCEPT',
-  '-A OUTPUT -p tcp -m tcp --sport 22 -m state --state NEW,RELATED,ESTABLISHED -j ACCEPT',
-]
-
-# check for wanted and unwanted iptables rules
-describe iptables do
-  unwanted_iptables_rules.each do |unwanted_iptables_rule|
-    it { should_not have_rule(unwanted_iptables_rule) }
-  end
-
-  desired_iptables_rules.each do |desired_iptables_rule|
-    it { should have_rule(desired_iptables_rule) }
-  end
-end
diff --git a/spec_tests/spec/mon-staging/iptables_spec.rb b/spec_tests/spec/mon-staging/iptables_spec.rb
deleted file mode 100644
--- a/spec_tests/spec/mon-staging/iptables_spec.rb
+++ /dev/null
@@ -1,100 +0,0 @@
-# declare desired iptables rules
-# These rules should be present in prod and staging
-# TODO: There are also hardcoded IP addresses in this section.
-# These rules were exported from a fully provisioned
-# mon-staging host running the develop branch around 2015-04-10.
-# That means they are post-0.3.2 and therefore may need to be tweaked
-# to test older versions.
-desired_iptables_rules = [
-  '-P INPUT DROP',
-  '-P FORWARD DROP',
-  '-P OUTPUT DROP',
-  '-N LOGNDROP',
-  '-A INPUT -p udp -m udp --sport 53 -m state --state NEW,RELATED,ESTABLISHED -j ACCEPT',
-  "-A INPUT -i #{property['staging_iface']} -p tcp -m tcp --dport 22 -m state --state NEW,RELATED,ESTABLISHED -j ACCEPT",
-  '-A INPUT -p tcp -m state --state RELATED,ESTABLISHED -m comment --comment "Allow traffic back for tor" -j ACCEPT',
-  '-A INPUT -s 8.8.8.8/32 -p tcp -m tcp --sport 53 -m state --state RELATED,ESTABLISHED -m comment --comment "tcp/udp dns" -j ACCEPT',
-  '-A INPUT -s 8.8.8.8/32 -p udp -m udp --sport 53 -m state --state RELATED,ESTABLISHED -m comment --comment "tcp/udp dns" -j ACCEPT',
-  '-A INPUT -p udp -m udp --sport 123 --dport 123 -m state --state RELATED,ESTABLISHED -m comment --comment ntp -j ACCEPT',
-  '-A INPUT -p tcp -m multiport --sports 80,8080,443 -m state --state RELATED,ESTABLISHED -m comment --comment "apt updates" -j ACCEPT',
-  "-A INPUT -s #{property['app_ip']}/32 -p udp -m udp --dport 1514 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment \"Allow OSSEC agent to monitor\" -j ACCEPT",
-  '-A INPUT -p tcp -m tcp --sport 587 -m state --state RELATED,ESTABLISHED -m comment --comment "Allow ossec email alerts out" -j ACCEPT',
-  '-A INPUT -i lo -m comment --comment "Allow lo to lo traffic all protocols" -j ACCEPT',
-  '-A INPUT -p tcp -m state --state INVALID -m comment --comment "drop but do not log inbound invalid state packets" -j DROP',
-  '-A INPUT -m comment --comment "Drop and log all other incoming traffic" -j LOGNDROP',
-  '-A OUTPUT -p udp -m udp --dport 53 -m state --state NEW,RELATED,ESTABLISHED -j ACCEPT',
-  "-A OUTPUT -o #{property['staging_iface']} -p tcp -m tcp --sport 22 -m state --state NEW,RELATED,ESTABLISHED -j ACCEPT",
-  "-A OUTPUT -p tcp -m owner --uid-owner #{property['tor_user_uid']} -m state --state NEW,RELATED,ESTABLISHED -m comment --comment \"tor instance that provides ssh access\" -j ACCEPT",
-  "-A OUTPUT -o lo -p tcp -m tcp --dport 22 -m owner --uid-owner #{property['tor_user_uid']} -m state --state NEW -m limit --limit 3/min --limit-burst 3 -m comment --comment \"Rate limit traffic from tor to the ssh dameon\" -j ACCEPT",
-  "-A OUTPUT -o lo -p tcp -m tcp --dport 22 -m owner --uid-owner #{property['tor_user_uid']} -m state --state RELATED,ESTABLISHED -m comment --comment \"Allow the established traffic from tor to the ssh dameon\" -j ACCEPT",
-  "-A OUTPUT -m owner --uid-owner #{property['tor_user_uid']} -m comment --comment \"Drop all other traffic for the tor instance used for ssh\" -j LOGNDROP",
-  "-A OUTPUT -m owner --gid-owner #{property['ssh_group_gid']} -m comment --comment \"Drop all other outbound traffic for ssh user\" -j LOGNDROP",
-  '-A OUTPUT -d 8.8.8.8/32 -p tcp -m tcp --dport 53 -m owner --uid-owner 0 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment "tcp/udp dns" -j ACCEPT',
-  '-A OUTPUT -d 8.8.8.8/32 -p udp -m udp --dport 53 -m owner --uid-owner 0 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment "tcp/udp dns" -j ACCEPT',
-  '-A OUTPUT -p udp -m udp --sport 123 --dport 123 -m owner --uid-owner 0 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment ntp -j ACCEPT',
-  '-A OUTPUT -p tcp -m multiport --dports 80,8080,443 -m owner --uid-owner 0 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment "apt updates" -j ACCEPT',
-  "-A OUTPUT -d #{property['app_ip']}/32 -p udp -m udp --sport 1514 -m state --state RELATED,ESTABLISHED -m comment --comment \"Allow OSSEC agent to monitor\" -j ACCEPT",
-  "-A OUTPUT -d 8.8.8.8/32 -p tcp -m tcp --dport 53 -m owner --uid-owner #{property['postfix_user_uid']} -m state --state NEW,RELATED,ESTABLISHED -m comment --comment \"postfix dns rule\" -j ACCEPT",
-  "-A OUTPUT -d 8.8.8.8/32 -p udp -m udp --dport 53 -m owner --uid-owner #{property['postfix_user_uid']} -m state --state NEW,RELATED,ESTABLISHED -m comment --comment \"postfix dns rule\" -j ACCEPT",
-  "-A OUTPUT -p tcp -m tcp --dport 587 -m owner --uid-owner #{property['postfix_user_uid']} -m state --state NEW,RELATED,ESTABLISHED -m comment --comment \"Allow ossec email alerts out\" -j ACCEPT",
-  '-A OUTPUT -o lo -m comment --comment "Allow lo to lo traffic all protocols" -j ACCEPT',
-  '-A OUTPUT -m comment --comment "Drop all other outgoing traffic" -j DROP',
-  '-A LOGNDROP -p tcp -m limit --limit 5/min -j LOG --log-tcp-options --log-ip-options --log-uid',
-  '-A LOGNDROP -p udp -m limit --limit 5/min -j LOG --log-ip-options --log-uid',
-  '-A LOGNDROP -p icmp -m limit --limit 5/min -j LOG --log-ip-options --log-uid',
-  '-A LOGNDROP -j DROP',
-]
-
-# declare unwanted iptables rules
-# These rules should have been removed by the `remove_authd_exceptions` role
-# TODO: The Vagrantfile virtualbox static IP was hardcoded into the two rules
-# below. This will need to be fixed. Possibly with using something like
-# https://github.com/volanja/ansible_spec Using the values for IP addresses
-# from the ansible inventory should cover most use cases (except inventories
-# with just the *.onion addresses).
-unwanted_iptables_rules = [
-  "-A INPUT -s #{property['app_ip']} -p tcp --sport 1515 -m state --state ESTABLISHED,RELATED -v ACCEPT -m comment --comment \"ossec authd rule only required for initial agent registration\"",
-  "-A OUTPUT -d #{property['app_ip']} -p tcp --dport 1515 -m state --state NEW,ESTABLISHED,RELATED -j ACCEPT -m comment --comment \"ossec authd rule only required for initial agent registration\"",
-  "-A INPUT -s #{property['app_ip']} -p tcp --dport 1515 -m state --state NEW,ESTABLISHED,RELATED -j ACCEPT",
-  "-A OUTPUT -d #{property['app_ip']} -p tcp --sport 1515 -m state --state ESTABLISHED,RELATED -j ACCEPT",
-]
-
-# check for wanted and unwanted iptables rules
-describe iptables do
-  unwanted_iptables_rules.each do |unwanted_iptables_rule|
-    it { should_not have_rule(unwanted_iptables_rule) }
-  end
-
-  desired_iptables_rules.each do |desired_iptables_rule|
-    it { should have_rule(desired_iptables_rule) }
-  end
-end
-
-# TODO: the iptables rules aren't checked in order,
-# just by grepping individually for each rule.
-# Rule order matters, so work on a before/after chain
-# that validates order.
-
-
-# try to validate local networking config
-describe host(property['app_hostname']) do
-  app_ip_regex = Regexp.quote(property['app_ip'])
-  its(:ipaddress) { should match /^#{app_ip_regex}$/ }
-  it { should be_resolvable.by('hosts')  }
-  # TODO: consider adding checks for service absence here.
-end
-
-# check for ssh listening (direct access in staging)
-describe port(22) do
-  it{ should be_listening.on('0.0.0.0').with('tcp') }
-end
-
-# check for postfix listening for sending ossec email alerts
-describe port(25) do
-  it{ should be_listening.on('127.0.0.1').with('tcp') }
-end
-
-# check for ossec-server listening
-describe port(1514) do
-  it{ should be_listening.on('0.0.0.0').with('udp') }
-end
diff --git a/spec_tests/spec/spec_helper.rb b/spec_tests/spec/spec_helper.rb
deleted file mode 100644
--- a/spec_tests/spec/spec_helper.rb
+++ /dev/null
@@ -1,118 +0,0 @@
-require 'serverspec'
-require 'net/ssh'
-require 'tempfile'
-require 'yaml'
-
-set :backend, :ssh
-
-if ENV['ASK_SUDO_PASSWORD']
-  begin
-    require 'highline/import'
-  rescue LoadError
-    fail "highline is not available. Try installing it."
-  end
-  set :sudo_password, ask("Enter sudo password: ") { |q| q.echo = false }
-else
-  set :sudo_password, ENV['SUDO_PASSWORD']
-end
-
-host = ENV['TARGET_HOST']
-
-# Using backticks for a subprocess call means
-# STDOUT will be masked, which blocks silently for
-# a long time if the host isn't up. Using IO.popen
-# instead allows for a tee-like interface
-#`vagrant up #{host}`
-IO.popen("vagrant up #{host}") do |output|
-  while line = output.gets do
-    # simply echo it back
-    puts line
-  end
-end
-
-# Determine SSH config for this host.
-config = Tempfile.new('', Dir.tmpdir)
-config.write(`vagrant ssh-config #{host}`)
-config.close
-options = Net::SSH::Config.for(host, [config.path])
-options[:user]
-set :host,        options[:host_name] || host
-set :ssh_options, options
-
-
-# Given a hostname, return dynamic variables for spectests.
-# Variables include entries hard-coded in a YAML file specifically
-# for spectests, as well as values retrieved from VMs over SSH.
-def retrieve_vars(hostname)
-
-  # Accept basename for vars YAML file,
-  # then return a hash of those settings.
-  def read_vars_file(file_basename)
-    vars_filepath = File.expand_path(File.join(
-      File.dirname(__FILE__), 'vars', "#{file_basename}.yml"
-    ))
-    return YAML.load_file(vars_filepath)
-  end
-  # This clunky if statement ain't pretty, but it gets the job done.
-  # A case statement assumes only one permissible match, whereas many
-  # matches should be able to add variables before this function returns.
-  if hostname.match(/^development$/)
-    vars = read_vars_file('development')
-  end
-  if hostname.match(/-staging$/)
-    vars = read_vars_file('staging')
-    # Ideally these IP addresses would be cached, since they don't
-    # change during a test run. Right now, both values are looked up twice,
-    # once for each app/mon host.
-    vars['app_ip'] = retrieve_ip_addr('app-staging')
-    vars['monitor_ip'] = retrieve_ip_addr('mon-staging')
-  end
-  if hostname.match(/-prod$/)
-    vars = read_vars_file('prod')
-    vars['app_ip'] = retrieve_ip_addr('app-prod')
-    vars['monitor_ip'] = retrieve_ip_addr('mon-prod')
-  end
-  if hostname.match(/^(app|mon)/)
-    vars['tor_user_uid'] = vagrant_ssh_cmd(hostname, "id -u debian-tor")
-    vars['ssh_group_gid'] = vagrant_ssh_cmd(hostname, "getent group ssh | cut -d: -f3")
-  end
-  if hostname.match(/^app/)
-    vars['apache_user_uid'] = vagrant_ssh_cmd(hostname, "id -u www-data")
-  end
-  if hostname.match(/^mon/)
-    vars['postfix_user_uid'] = vagrant_ssh_cmd(hostname, "id -u postfix")
-  end
-  return vars
-end
-
-# SSH into Vagrant machine, run command, return output.
-def vagrant_ssh_cmd(hostname, command)
-  # Dump STDERR, to avoid noisy "Connection closed" messages.
-  # Error code is checked below.
-  filter_stderr = "2> /dev/null"
-  vagrant_cmd = "vagrant ssh #{hostname} --command '#{command}'"
-  result = `#{vagrant_cmd} #{filter_stderr}`.rstrip
-  if $? != 0
-    puts "Command failed: #{vagrant_cmd}"
-    exit(1)
-  end
-  return result
-end
-
-# Look up IP address for given hostname, so spectests
-# have accurate dynamic vars regardless of provider.
-def retrieve_ip_addr(hostname)
-  ip_output = vagrant_ssh_cmd(hostname, "hostname -I")
-  iface1, iface2 = ip_output.split()
-  # Vagrant VirtualBox images will always have eth0 as the NAT device,
-  # so we actually what the IP address from the eth1 device on VirtualBox hosts.
-  # In other testing environments, we can assume the first address is correct.
-  if `vagrant status #{hostname} --machine-readable`.match(/#{hostname},provider-name,virtualbox/m)
-    return iface2
-  else
-    return iface1
-  end
-end
-
-# Load dynamic variables for current host.
-set_property retrieve_vars(host)
diff --git a/spec_tests/spec/vars/prod.yml b/spec_tests/spec/vars/prod.yml
deleted file mode 100644
--- a/spec_tests/spec/vars/prod.yml
+++ /dev/null
@@ -1,12 +0,0 @@
----
-### General (used by more than one role) ###
-securedrop_code: /var/www/securedrop
-securedrop_data: /var/lib/securedrop
-securedrop_user: www-data
-
-app_hostname: app-prod
-monitor_hostname: mon-prod
-
-apache_listening_address: 127.0.0.1
-apache_source_log: /dev/null
-apache_allow_from: 127.0.0.1
diff --git a/spec_tests/spec/vars/staging.yml b/spec_tests/spec/vars/staging.yml
deleted file mode 100644
--- a/spec_tests/spec/vars/staging.yml
+++ /dev/null
@@ -1,21 +0,0 @@
----
-### General (used by more than one role) ###
-securedrop_code: /var/www/securedrop
-securedrop_data: /var/lib/securedrop
-securedrop_user: www-data
-
-app_hostname: app-staging
-monitor_hostname: mon-staging
-
-apache_listening_address: 0.0.0.0
-apache_source_log: /var/log/apache2/source-error.log
-apache_allow_from: all
-
-# Hard-coding the default interface used in the
-# staging rules for now. In VirtualBox, eth0 is a
-# NAT device used for SSH port forwards. In DigitalOcean,
-# eth0 is the only device, so still the correct device
-# to allow staging access to. TODO: virtualize firewall
-# so these iface values are more flexible. Also consider
-# enabling private_networking in DO.
-staging_iface: eth0
diff --git a/testinfra/app-code/test_haveged.py b/testinfra/app-code/test_haveged.py
new file mode 100644
--- /dev/null
+++ b/testinfra/app-code/test_haveged.py
@@ -0,0 +1,34 @@
+def test_haveged_config(File):
+    """
+    Ensure haveged's low entrop watermark is sufficiently high.
+    """
+    f = File('/etc/default/haveged')
+    assert f.is_file
+    assert f.user == 'root'
+    assert f.group == 'root'
+    assert oct(f.mode) == '0644'
+    assert f.contains('^DAEMON_ARGS="-w 2400"$')
+
+
+def test_haveged_no_duplicate_lines(Command):
+    """
+    Regression test to check for duplicate entries. Earlier playbooks
+    for configuring the SD instances needlessly appended the `DAEMON_ARGS`
+    line everytime the playbook was run. Fortunately the duplicate lines don't
+    break the service, but it's still poor form.
+    """
+    c = Command("uniq --repeated /etc/default/haveged")
+    assert c.rc == 0
+    assert c.stdout == ""
+
+
+def test_haveged_is_running(Service, Sudo):
+    """
+    Ensure haveged service is running, to provide additional entropy.
+    """
+    # Sudo is necessary to read /proc when running under grsecurity,
+    # which the App hosts do. Not technically necessary under development.
+    with Sudo():
+        s = Service("haveged")
+        assert s.is_running
+        assert s.is_enabled
diff --git a/testinfra/app-code/test_redis_worker.py b/testinfra/app-code/test_redis_worker.py
new file mode 100644
--- /dev/null
+++ b/testinfra/app-code/test_redis_worker.py
@@ -0,0 +1,44 @@
+import pytest
+import re
+
+
+securedrop_test_vars = pytest.securedrop_test_vars
+
+
+@pytest.mark.parametrize('config_line', [
+  '[program:securedrop_worker]',
+  'command=/usr/local/bin/rqworker',
+  "directory={}".format(securedrop_test_vars.securedrop_code),
+  'autostart=true',
+  'autorestart=true',
+  'startretries=3',
+  'stderr_logfile=/var/log/securedrop_worker/err.log',
+  'stdout_logfile=/var/log/securedrop_worker/out.log',
+  "user={}".format(securedrop_test_vars.securedrop_user),
+  'environment=HOME="/tmp/python-gnupg"',
+])
+def test_redis_worker_configuration(File, config_line):
+  """
+  Ensure SecureDrop Redis worker config for supervisor service
+  management is configured correctly.
+  """
+  f = File('/etc/supervisor/conf.d/securedrop_worker.conf')
+  # Config lines may have special characters such as [] which will
+  # throw off the regex matching, so let's escape those chars.
+  regex = re.escape(config_line)
+  assert f.contains('^{}$'.format(regex))
+
+
+def test_redis_worker_config_file(File):
+  """
+  Ensure SecureDrop Redis worker config for supervisor service
+  management has proper ownership and mode.
+
+  Using separate test so that the parametrization doesn't rerun
+  the file mode checks, which would be useless.
+  """
+  f = File('/etc/supervisor/conf.d/securedrop_worker.conf')
+  assert f.is_file
+  assert oct(f.mode) == '0644'
+  assert f.user == "root"
+  assert f.group == "root"
diff --git a/testinfra/app-code/test_securedrop_app_code.py b/testinfra/app-code/test_securedrop_app_code.py
new file mode 100644
--- /dev/null
+++ b/testinfra/app-code/test_securedrop_app_code.py
@@ -0,0 +1,77 @@
+import pytest
+
+
+securedrop_test_vars = pytest.securedrop_test_vars
+
+
+def test_apache_default_docroot_is_absent(File):
+    """
+    Ensure that the default docroot for Apache, containing static HTML
+    under Debian, has been removed. Leaving it in place can be a privacy
+    leak, as it displays version information by default.
+    """
+    assert not File('/var/www/html').exists
+
+
+@pytest.mark.parametrize('package', [
+  'apparmor-utils',
+  'gnupg2',
+  'haveged',
+  'python',
+  'python-pip',
+  'redis-server',
+  'secure-delete',
+  'sqlite',
+  'supervisor',
+])
+def test_securedrop_application_apt_dependencies(Package, package):
+    """
+    Ensure apt dependencies required to install `securedrop-app-code`
+    are present. These should be pulled in automatically via apt,
+    due to specification in Depends in package control file.
+    """
+    assert Package(package).is_installed
+
+
+def test_securedrop_application_test_journalist_key(File, Sudo):
+    """
+    Ensure the SecureDrop Application GPG public key file is present.
+    This is a test-only pubkey provided in the repository strictly for testing.
+    """
+    pubkey_file = File("{}/test_journalist_key.pub".format(
+        securedrop_test_vars.securedrop_data))
+    # Sudo is only necessary when testing against app hosts, since the
+    # permissions are tighter. Let's elevate privileges so we're sure
+    # we can read the correct file attributes and test them.
+    with Sudo():
+        assert pubkey_file.is_file
+        assert pubkey_file.user == "root"
+        assert pubkey_file.group == "root"
+        assert oct(pubkey_file.mode) == "0644"
+
+    # Let's make sure the corresponding fingerprint is specified
+    # in the SecureDrop app configuration.
+    securedrop_config = File("{}/config.py".format(
+        securedrop_test_vars.securedrop_code))
+    with Sudo():
+        assert securedrop_config.is_file
+        assert securedrop_config.user == securedrop_test_vars.securedrop_user
+        assert securedrop_config.group == securedrop_test_vars.securedrop_user
+        assert oct(securedrop_config.mode) == "0600"
+        assert securedrop_config.contains(
+                "^JOURNALIST_KEY = '65A1B5FF195B56353CC63DFFCC40EF1228271441'$")
+
+
+def test_securedrop_application_sqlite_db(File, Sudo):
+    """
+    Ensure sqlite database exists for application. The database file should be
+    created by Ansible on first run.
+    """
+    # Sudo is necessary under the App hosts, which have restrictive file
+    # permissions on the doc root. Not technically necessary under dev host.
+    with Sudo():
+        f = File("{}/db.sqlite".format(securedrop_test_vars.securedrop_data))
+        assert f.is_file
+        assert f.user == securedrop_test_vars.securedrop_user
+        assert f.group == securedrop_test_vars.securedrop_user
+        assert oct(f.mode) == "0644"
diff --git a/testinfra/app/apache/test_apache_journalist_interface.py b/testinfra/app/apache/test_apache_journalist_interface.py
new file mode 100644
--- /dev/null
+++ b/testinfra/app/apache/test_apache_journalist_interface.py
@@ -0,0 +1,142 @@
+import pytest
+import re
+
+
+# Hard-coding test vars for development during
+# transition from ServerSpec to TestInfra. Test vars
+# should be imported based on hostname.
+securedrop_test_vars = dict(
+    securedrop_user="vagrant",
+    securedrop_code="/var/www/securedrop",
+    securedrop_data="/var/lib/securedrop",
+    apache_allow_from="all",
+    apache_listening_address="0.0.0.0",
+    apache_source_log="/var/log/apache2/source-error.log",
+)
+
+
+# Setting once so it can be reused in multiple tests.
+wanted_apache_headers = [
+  'Header edit Set-Cookie ^(.*)$ $1;HttpOnly',
+  'Header always append X-Frame-Options: DENY',
+  'Header set X-XSS-Protection: "1; mode=block"',
+  'Header set X-Content-Type-Options: nosniff',
+  'Header set X-Download-Options: noopen',
+  "Header set X-Content-Security-Policy: \"default-src 'self'\"",
+  "Header set Content-Security-Policy: \"default-src 'self'\"",
+  'Header unset Etag',
+]
+
+# Test is not DRY; haven't figured out how to parametrize on
+# multiple inputs, so explicitly redeclaring test logic.
+@pytest.mark.parametrize("header", wanted_apache_headers)
+def test_apache_headers_journalist_interface(File, header):
+    """
+    Test for expected headers in Document Interface vhost config.
+    """
+    f = File("/etc/apache2/sites-available/journalist.conf")
+    assert f.is_file
+    assert f.user == "root"
+    assert f.group == "root"
+    assert oct(f.mode) == "0644"
+    header_regex = "^{}$".format(re.escape(header))
+    assert re.search(header_regex, f.content, re.M)
+
+# Block of directory declarations for Apache vhost is common
+# to both Source and Journalist interfaces. Hardcoding these values
+# across multiple test files to speed up development; they should be
+# written once and imported in a DRY manner.
+common_apache2_directory_declarations = """
+<Directory />
+  Options None
+  AllowOverride None
+  Order deny,allow
+  Deny from all
+</Directory>
+
+<Directory /var/www/>
+  Options None
+  AllowOverride None
+  <Limit GET POST HEAD>
+    Order allow,deny
+    allow from {apache_allow_from}
+  </Limit>
+  <LimitExcept GET POST HEAD>
+    Order deny,allow
+    Deny from all
+  </LimitExcept>
+</Directory>
+
+<Directory {securedrop_code}>
+  Options None
+  AllowOverride None
+  <Limit GET POST HEAD>
+    Order allow,deny
+    allow from {apache_allow_from}
+  </Limit>
+  <LimitExcept GET POST HEAD>
+    Order deny,allow
+    Deny from all
+  </LimitExcept>
+</Directory>
+""".lstrip().rstrip().format(**securedrop_test_vars)
+
+
+# declare journalist-specific apache configs
+@pytest.mark.parametrize("apache_opt", [
+  'Header set Cache-Control "max-age=1800"',
+  "<VirtualHost {}:8080>".format(securedrop_test_vars['apache_listening_address']),
+  "DocumentRoot {}/static".format(securedrop_test_vars['securedrop_code']),
+  "Alias /static {}/static".format(securedrop_test_vars['securedrop_code']),
+  "WSGIDaemonProcess journalist processes=2 threads=30 display-name=%{GROUP}"+" python-path={}".format(securedrop_test_vars['securedrop_code']),
+  'WSGIProcessGroup journalist',
+  'WSGIScriptAlias / /var/www/journalist.wsgi/',
+  'AddType text/html .py',
+  'XSendFile        On',
+  'XSendFilePath    /var/lib/securedrop/store/',
+  'XSendFilePath    /var/lib/securedrop/tmp/',
+  'ErrorLog /var/log/apache2/journalist-error.log',
+  'CustomLog /var/log/apache2/journalist-access.log combined',
+])
+def test_apache_config_journalist_interface(File, apache_opt):
+    """
+    Ensure the necessary Apache settings for serving the application
+    are in place. Some values will change according to the host,
+    e.g. app-staging versus app-prod will have different listening
+    addresses, depending on whether Tor connections are forced.
+
+    These checks apply only to the Document Interface, used by Journalists.
+    """
+    f = File("/etc/apache2/sites-available/journalist.conf")
+    assert f.is_file
+    assert f.user == "root"
+    assert f.group == "root"
+    assert oct(f.mode) == "0644"
+    regex = "^{}$".format(re.escape(apache_opt))
+    assert re.search(regex, f.content, re.M)
+
+
+def test_apache_journalist_interface_vhost(File):
+    """
+    Ensure the document root is configured with correct access restrictions
+    for serving Journalist Interface application code.
+    """
+    f = File("/etc/apache2/sites-available/journalist.conf")
+    assert common_apache2_directory_declarations in f.content
+
+
+# Expect to fail pending fix for LogFormat declaration.
+@pytest.mark.xfail
+def test_apache_logging_journalist_interface(File):
+    """
+    Check that logging is configured correctly for the Journalist Interface.
+    The actions of Journalists are logged by the system, so that an Admin can
+    investigate incidents and track access.
+
+    Logs were broken for some period of time, logging only "combined" to the logfile,
+    rather than the combined LogFormat intended.
+    """
+    f = File("/var/log/apache2/journalist-access.log")
+    assert f.is_file
+    assert f.size > 0 # will fail if no journalist account used
+    assert not f.contains("^combined$")
diff --git a/testinfra/app/apache/test_apache_service.py b/testinfra/app/apache/test_apache_service.py
new file mode 100644
--- /dev/null
+++ b/testinfra/app/apache/test_apache_service.py
@@ -0,0 +1,79 @@
+import pytest
+import re
+
+
+# Hard-coding test vars for development during
+# transition from ServerSpec to TestInfra. Test vars
+# should be imported based on hostname.
+securedrop_test_vars = dict(
+    securedrop_user="vagrant",
+    securedrop_code="/var/www/securedrop",
+    securedrop_data="/var/lib/securedrop",
+    apache_allow_from="all",
+    apache_listening_address="0.0.0.0",
+    apache_source_log="/var/log/apache2/source-error.log",
+)
+
+
+@pytest.mark.parametrize("apache_site", [
+    "source",
+    "journalist",
+])
+def test_apache_enabled_sites(Command, Sudo, apache_site):
+    """
+    Ensure the Source and Journalist interfaces are enabled.
+    """
+    with Sudo():
+        c = Command("/usr/sbin/a2query -s {}".format(apache_site))
+        assert "{} (enabled".format(apache_site) in c.stdout
+        assert c.rc == 0
+
+
+@pytest.mark.parametrize("apache_site", [
+    "000-default",
+])
+def test_apache_disabled_sites(Command, apache_site):
+    """
+    Ensure the default HTML document root is disabled.
+    """
+    c = Command("a2query -s {}".format(apache_site))
+    assert "No site matches {} (disabled".format(apache_site) in c.stderr
+    assert c.rc == 32
+
+
+def test_apache_service(Service, Sudo):
+    """
+    Ensure Apache service is running.
+    """
+    # Sudo is necessary to run `service apache2 status`, otherwise
+    # the service is falsely reported as not running.
+    with Sudo():
+        s = Service("apache2")
+        assert s.is_running
+        assert s.is_enabled
+
+
+def test_apache_user(User):
+    """
+    Ensure user account for running application code is configured correctly.
+    """
+    u = User("www-data")
+    assert u.exists
+    assert u.home == "/var/www"
+    assert u.shell == "/usr/sbin/nologin"
+
+
+@pytest.mark.parametrize("port", [
+    "80",
+    "8080",
+])
+def test_apache_listening(Socket, Sudo, port):
+    """
+    Ensure Apache is listening on proper ports and interfaces.
+    In staging, expect the service to be bound to 0.0.0.0,
+    but in prod, it should be restricted to 127.0.0.1.
+    """
+    # Sudo is necessary to read from /proc/net/tcp.
+    with Sudo():
+        s = Socket("tcp://{}:{}".format(securedrop_test_vars['apache_listening_address'], port))
+        assert s.is_listening
diff --git a/testinfra/app/apache/test_apache_source_interface.py b/testinfra/app/apache/test_apache_source_interface.py
new file mode 100644
--- /dev/null
+++ b/testinfra/app/apache/test_apache_source_interface.py
@@ -0,0 +1,56 @@
+import pytest
+import re
+
+
+securedrop_test_vars = pytest.securedrop_test_vars
+
+
+@pytest.mark.parametrize("header", securedrop_test_vars.wanted_apache_headers)
+def test_apache_headers_source_interface(File, header):
+    """
+    Test for expected headers in Source Interface vhost config.
+    """
+    f = File("/etc/apache2/sites-available/source.conf")
+    assert f.is_file
+    assert f.user == "root"
+    assert f.group == "root"
+    assert oct(f.mode) == "0644"
+    header_regex = "^{}$".format(re.escape(header))
+    assert re.search(header_regex, f.content, re.M)
+
+
+@pytest.mark.parametrize("apache_opt", [
+    'Header set Cache-Control "max-age=1800, must-revalidate"',
+    "<VirtualHost {}:80>".format(securedrop_test_vars.apache_listening_address),
+    "DocumentRoot {}/static".format(securedrop_test_vars.securedrop_code),
+    "Alias /static {}/static".format(securedrop_test_vars.securedrop_code),
+    "WSGIDaemonProcess source  processes=2 threads=30 display-name=%{GROUP}"+" python-path={}".format(securedrop_test_vars.securedrop_code),
+    'WSGIProcessGroup source',
+    'WSGIScriptAlias / /var/www/source.wsgi/',
+    'AddType text/html .py',
+    'XSendFile        Off',
+    'LimitRequestBody 524288000',
+    'ErrorDocument 400 /notfound',
+    'ErrorDocument 401 /notfound',
+    'ErrorDocument 403 /notfound',
+    'ErrorDocument 404 /notfound',
+    'ErrorDocument 500 /notfound',
+    "ErrorLog {}".format(securedrop_test_vars.apache_source_log),
+])
+def test_apache_config_source_interface(File, apache_opt):
+    """
+    Ensure the necessary Apache settings for serving the application
+    are in place. Some values will change according to the host,
+    e.g. app-staging versus app-prod will have different listening
+    addresses, depending on whether Tor connections are forced.
+
+    These checks apply only to the Source Interface, used by Sources.
+    """
+    f = File("/etc/apache2/sites-available/source.conf")
+    assert f.is_file
+    assert f.user == "root"
+    assert f.group == "root"
+    assert oct(f.mode) == "0644"
+    regex = "^{}$".format(re.escape(apache_opt))
+    assert re.search(regex, f.content, re.M)
+
diff --git a/testinfra/app/apache/test_apache_system_config.py b/testinfra/app/apache/test_apache_system_config.py
new file mode 100644
--- /dev/null
+++ b/testinfra/app/apache/test_apache_system_config.py
@@ -0,0 +1,173 @@
+import pytest
+import re
+
+
+# Hard-coding test vars for development during
+# transition from ServerSpec to TestInfra. Test vars
+# should be imported based on hostname.
+securedrop_test_vars = dict(
+    securedrop_user="vagrant",
+    securedrop_code="/var/www/securedrop",
+    securedrop_data="/var/lib/securedrop",
+    apache_allow_from="all",
+    apache_listening_address="0.0.0.0",
+    apache_source_log="/var/log/apache2/source-error.log",
+)
+
+
+@pytest.mark.parametrize("package", [
+    "apache2-mpm-worker",
+    "libapache2-mod-wsgi",
+    "libapache2-mod-xsendfile",
+])
+def test_apache_apt_packages(Package, package):
+    """
+    Ensure required Apache packages are installed.
+    """
+    assert Package(package).is_installed
+
+
+@pytest.mark.parametrize("apache_opt", [
+    "ServerTokens Prod",
+    "ServerSignature Off",
+    "TraceEnable Off",
+])
+def test_apache_security_config(File, apache_opt):
+    """
+    Ensure required apache2 security config file is present.
+
+    Refer to #643, which states that /etc/apache2/security
+    is superfluous, and not even used in our config right now.
+    We should update the Ansible config to move the file
+    to /etc/apache2/conf-available/security.conf.
+    """
+    f = File("/etc/apache2/security")
+    assert f.is_file
+    assert f.user == "root"
+    assert f.group == "root"
+    assert oct(f.mode) == "0644"
+
+    assert f.contains("^{}$".format(apache_opt))
+
+
+# OK to fail here, pending updates to Ansible config.
+@pytest.mark.xfail
+def test_apache_security_config_deprecated(File):
+    """
+    Ensure that /etc/apache2/security is absent. See #643 for discussion.
+    Tokens set in that file should be moved to
+    /etc/apache2/conf-available/security.conf.
+    """
+    assert not File("/etc/apache2/security").exists
+    assert File("/etc/apache2/config-available/security.conf").exists
+
+
+@pytest.mark.parametrize("apache_opt", [
+    'Mutex file:${APACHE_LOCK_DIR} default',
+    'PidFile ${APACHE_PID_FILE}',
+    'Timeout 60',
+    'KeepAlive On',
+    'MaxKeepAliveRequests 100',
+    'KeepAliveTimeout 5',
+    'User www-data',
+    'Group www-data',
+    'AddDefaultCharset UTF-8',
+    'DefaultType None',
+    'HostnameLookups Off',
+    'ErrorLog /dev/null',
+    'LogLevel crit',
+    'IncludeOptional mods-enabled/*.load',
+    'IncludeOptional mods-enabled/*.conf',
+    'Include ports.conf',
+    'IncludeOptional sites-enabled/*.conf',
+    'ServerTokens Prod',
+    'ServerSignature Off',
+    'TraceEnable Off',
+])
+def test_apache_config_settings(File, apache_opt):
+    """
+    Check required Apache config settings for general server.
+    These checks do not target individual interfaces, e.g.
+    Source versus Document Interface, and instead apply to
+    Apache more generally.
+    """
+    f = File("/etc/apache2/apache2.conf")
+    assert f.is_file
+    assert f.user == "root"
+    assert f.group == "root"
+    assert oct(f.mode) == "0644"
+    assert re.search("^{}$".format(re.escape(apache_opt)), f.content, re.M)
+
+
+@pytest.mark.parametrize("port", [
+    "80",
+    "8080",
+])
+def test_apache_ports_config(File, SystemInfo, port):
+    """
+    Ensure Apache ports config items, which specify how the
+    Source and Document Interfaces are configured to be served
+    over Tor. On staging hosts, they will listen on any interface,
+    to permit port forwarding for local testing, but in production,
+    they're restricted to localhost, for use over Tor.
+    """
+    f = File("/etc/apache2/ports.conf")
+    assert f.is_file
+    assert f.user == "root"
+    assert f.group == "root"
+    assert oct(f.mode) == "0644"
+
+    listening_regex = "^Listen {}:{}$".format(re.escape(
+            securedrop_test_vars['apache_listening_address']), port)
+    assert f.contains(listening_regex)
+
+
+@pytest.mark.parametrize("apache_module", [
+  'access_compat',
+  'authn_core',
+  'alias',
+  'authz_core',
+  'authz_host',
+  'authz_user',
+  'deflate',
+  'filter',
+  'dir',
+  'headers',
+  'mime',
+  'mpm_event',
+  'negotiation',
+  'reqtimeout',
+  'rewrite',
+  'wsgi',
+  'xsendfile',
+])
+def test_apache_modules_present(Command, Sudo, apache_module):
+    """
+    Ensure presence of required Apache modules. Application will not work
+    correctly if these are missing. A separate test will check for
+    disabled modules.
+    """
+    with Sudo():
+        c = Command("/usr/sbin/a2query -m {}".format(apache_module))
+        assert "{} (enabled".format(apache_module) in c.stdout
+        assert c.rc == 0
+
+
+@pytest.mark.parametrize("apache_module", [
+  'auth_basic',
+  'authn_file',
+  'autoindex',
+  'env',
+  'setenvif',
+  'status',
+])
+def test_apache_modules_absent(Command, Sudo, apache_module):
+    """
+    Ensure absence of unwanted Apache modules. Application does not require
+    these modules, so they should be disabled to reduce attack surface.
+    A separate test will check for disabled modules.
+    """
+    with Sudo():
+        c = Command("/usr/sbin/a2query -m {}".format(apache_module))
+        assert "No module matches {} (disabled".format(apache_module) in c.stderr
+        assert c.rc == 32
diff --git a/testinfra/app/test_apparmor.py b/testinfra/app/test_apparmor.py
new file mode 100644
--- /dev/null
+++ b/testinfra/app/test_apparmor.py
@@ -0,0 +1,97 @@
+import os
+import pytest
+
+
+sdvars = pytest.securedrop_test_vars
+
+
+@pytest.mark.parametrize('pkg', ['apparmor', 'apparmor-utils'])
+def test_apparmor_pkg(Package, pkg):
+    """ Apparmor package dependencies """
+    assert Package(pkg).is_installed
+
+def test_apparmor_enabled(Command, Sudo):
+    """ Check that apparmor is enabled """
+    with Sudo():
+        assert Command("aa-status --enabled").rc == 0
+
+apache2_capabilities = [
+        'kill',
+        'net_bind_service',
+        'sys_ptrace'
+        ]
+@pytest.mark.parametrize('cap', apache2_capabilities)
+def test_apparmor_apache_capabilities(Command, cap):
+    """ check for exact list of expected app-armor capabilities for apache2 """
+    c = Command("perl -nE \'/^\s+capability\s+(\w+),$/ && say $1\' /etc/apparmor.d/usr.sbin.apache2")
+    assert cap in c.stdout
+
+def test_apparmor_apache_exact_capabilities(Command):
+    """ ensure no extra capabilities are defined for apache2 """
+    c = Command("grep -ic capability /etc/apparmor.d/usr.sbin.apache2")
+    assert str(len(apache2_capabilities)) == c.stdout
+
+tor_capabilities = ['setgid']
+@pytest.mark.parametrize('cap', tor_capabilities)
+def test_apparmor_tor_capabilities(Command, cap):
+    """ check for exact list of expected app-armor capabilities for tor """
+    c = Command("perl -nE \'/^\s+capability\s+(\w+),$/ && say $1\' /etc/apparmor.d/usr.sbin.tor")
+    assert cap in c.stdout
+
+def test_apparmor_apache_exact_capabilities(Command):
+    """ ensure no extra capabilities are defined for tor """
+    c = Command("grep -ic capability /etc/apparmor.d/usr.sbin.tor")
+    assert str(len(tor_capabilities)) == c.stdout
+
+enforced_profiles = [
+        'ntpd',
+        'apache2',
+        'tcpdump',
+        'tor']
+@pytest.mark.parametrize('profile', enforced_profiles)
+def test_apparmor_ensure_not_disabled(File, Sudo, profile):
+    """ Explicitly check that enforced profiles are NOT in /etc/apparmor.d/disable 
+        Polling aa-status only checks the last config that was loaded, this ensures
+        it wont be disabled on reboot.
+    """
+    f = File("/etc/apparmor.d/disabled/usr.sbin.{}".format(profile))
+    with Sudo():
+        assert not f.exists
+
+@pytest.mark.skipif(os.environ['SECUREDROP_TESTINFRA_TARGET_HOST'] != 'app-staging',
+                    reason='only to be run on app-staging')
+@pytest.mark.parametrize('complain_pkg', sdvars.apparmor_complain)
+def test_app_apparmor_complain(Command, Sudo, complain_pkg):
+    """ Ensure app-armor profiles are in complain mode for staging """
+    with Sudo():
+        awk = "awk '/[0-9]+ profiles.*complain./{flag=1;next}/^[0-9]+.*/{flag=0}flag'"
+        c = Command.check_output("aa-status | {}".format(awk))
+        assert complain_pkg in c
+
+@pytest.mark.skipif(os.environ['SECUREDROP_TESTINFRA_TARGET_HOST'] != 'app-staging',
+                    reason='only to be run on app-staging')
+def test_app_apparmor_complain_count(Command, Sudo):
+    """ Ensure right number of app-armor profiles are in complain mode """
+    with Sudo():
+        c = Command.check_output("aa-status --complaining")
+        assert c == str(len(sdvars.apparmor_complain))
+
+@pytest.mark.parametrize('aa_enforced', sdvars.apparmor_enforce)
+def test_apparmor_enforced(Command, Sudo, aa_enforced):
+    awk = "awk '/[0-9]+ profiles.*enforce./{flag=1;next}/^[0-9]+.*/{flag=0}flag'"
+    with Sudo():
+        c = Command.check_output("aa-status | {}".format(awk))
+        assert aa_enforced in c
+
+def test_apparmor_total_profiles(Command, Sudo):
+    """ ensure number of total profiles is sum of enforced and complaining profiles """
+    with Sudo():
+        total_expected = str((len(sdvars.apparmor_enforce) 
+                        + len(sdvars.apparmor_complain)))
+        assert Command.check_output("aa-status --profiled") == total_expected
+
+def test_aastatus_unconfined(Command, Sudo):
+    """ Ensure that there are no processes that are unconfined but have a profile """
+    unconfined_chk = "0 processes are unconfined but have a profile defined"
+    with Sudo():
+        assert unconfined_chk in Command("aa-status").stdout
diff --git a/testinfra/app/test_appenv.py b/testinfra/app/test_appenv.py
new file mode 100644
--- /dev/null
+++ b/testinfra/app/test_appenv.py
@@ -0,0 +1,66 @@
+import pytest
+import os
+
+sdvars = pytest.securedrop_test_vars
+
+@pytest.mark.parametrize('exp_pip_pkg', sdvars.pip_deps)
+def test_app_pip_deps(PipPackage, exp_pip_pkg):
+    """ Ensure pip dependencies are installed """
+    pip = PipPackage.get_packages()
+    assert pip[exp_pip_pkg['name']]['version'] == exp_pip_pkg['version']
+
+
+def test_app_wsgi(File, Sudo):
+    """ ensure logging is enabled for source interface in staging """
+    f = File("/var/www/source.wsgi")
+    with Sudo():
+        assert f.is_file
+        assert oct(f.mode) == "0640"
+        assert f.user == 'www-data'
+        assert f.group == 'www-data'
+        assert f.contains("^import logging$")
+        assert f.contains("^logging\.basicConfig(stream=sys\.stderr)$")
+
+@pytest.mark.parametrize('app_dir', sdvars.app_directories)
+def test_app_directories(File, Sudo, app_dir):
+    """ ensure securedrop app directories exist with correct permissions """
+    f = File(app_dir)
+    with Sudo():
+        assert f.is_directory
+        assert f.user == sdvars.securedrop_user
+        assert f.group == sdvars.securedrop_user
+        assert oct(f.mode) == "0700"
+
+def test_app_code_pkg(Package):
+    """ ensure securedrop-app-code package is installed """
+    assert Package("securedrop-app-code").is_installed
+
+def test_gpg_key_in_keyring(Command, Sudo):
+    """ ensure test gpg key is present in app keyring """
+    with Sudo(sdvars.securedrop_user):
+        c = Command("gpg --homedir /var/lib/securedrop/keys --list-keys 28271441")
+        assert "pub   4096R/28271441 2013-10-12" in c.stdout 
+
+def test_ensure_logo(File, Sudo):
+    """ ensure default logo header file exists """
+    f = File("{}/static/i/logo.png".format(sdvars.securedrop_code))
+    with Sudo():
+        assert oct(f.mode) == "0644"
+        assert f.user == sdvars.securedrop_user
+        assert f.group == sdvars.securedrop_user
+
+def test_securedrop_tmp_clean_cron(Command, Sudo):
+    """ Ensure securedrop tmp clean cron job in place """
+    with Sudo():
+        cronlist = Command("crontab -l").stdout
+        cronjob = "@daily {}/manage.py clean-tmp".format(sdvars.securedrop_code)
+        assert cronjob in cronlist
+
+def test_app_workerlog_dir(File, Sudo):
+    """ ensure directory for worker logs is present """
+    f = File('/var/log/securedrop_worker')
+    with Sudo():
+        assert f.is_directory
+        assert f.user == "root"
+        assert f.group == "root"
+        assert oct(f.mode) == "0644"
diff --git a/testinfra/app/test_network.py b/testinfra/app/test_network.py
new file mode 100644
--- /dev/null
+++ b/testinfra/app/test_network.py
@@ -0,0 +1,41 @@
+import os
+import difflib
+import pytest
+from jinja2 import Template
+
+
+securedrop_test_vars = pytest.securedrop_test_vars
+
+
+def test_app_iptables_rules(SystemInfo, Command, Sudo, Ansible):
+
+    # Build a dict of variables to pass to jinja for iptables comparison
+    kwargs = dict(
+        mon_ip=securedrop_test_vars.mon_ip,
+        default_interface = Ansible("setup")["ansible_facts"]["ansible_default_ipv4"]["interface"],
+        tor_user_id = Command.check_output("id -u debian-tor"),
+        securedrop_user_id = Command.check_output("id -u www-data"),
+        ssh_group_gid = Command.check_output("getent group ssh | cut -d: -f3"),
+        dns_server = securedrop_test_vars.dns_server)
+
+    # Build iptables scrape cmd, purge comments + counters
+    iptables = "iptables-save | sed 's/ \[[0-9]*\:[0-9]*\]//g' | egrep -v '^#'"
+    iptables_file = "{}/iptables-{}.j2".format(
+                                      os.path.dirname(os.path.abspath(__file__)),
+                                      SystemInfo.hostname)
+
+    # template out a local iptables jinja file
+    jinja_iptables = Template(open(iptables_file,'r').read())
+    iptables_expected = jinja_iptables.render(**kwargs)
+
+    with Sudo():
+        # Actually run the iptables scrape command
+        iptables = Command.check_output(iptables)
+        # print diff comparison (only shows up in pytests if test fails or
+        # verbosity turned way up)
+        for iptablesdiff in difflib.context_diff(iptables_expected.split('\n'),
+                                             iptables.split('\n')):
+            print(iptablesdiff)
+        # Conduct the string comparison of the expected and actual iptables
+        # ruleset
+        assert iptables_expected == iptables
diff --git a/testinfra/app/test_ossec.py b/testinfra/app/test_ossec.py
new file mode 100644
--- /dev/null
+++ b/testinfra/app/test_ossec.py
@@ -0,0 +1,43 @@
+import re
+import pytest
+
+sdvars = pytest.securedrop_test_vars
+
+def test_hosts_files(File, SystemInfo):
+    """ Ensure host files mapping are in place """
+    f = File('/etc/hosts')
+
+    hostname = SystemInfo.hostname
+    mon_ip = sdvars.mon_ip
+    app_host = sdvars.app_hostname
+    mon_host = sdvars.monitor_hostname
+
+    assert f.contains('^127.0.0.1')
+    assert f.contains('^127.0.0.1\t*{0}\t*{0}$'.format(app_host))
+    assert f.contains('^{}\s*{}\s*securedrop-monitor-server-alias$'.format(
+                                                                    mon_ip,
+                                                                    mon_host))
+
+def test_hosts_duplicate(Command):
+    """ Regression test for duplicate entries """
+    assert Command.check_output("uniq --repeated /etc/hosts") == ""
+
+def test_ossec_agent_installed(Package):
+    """ Check that ossec-agent package is present """
+    assert Package("securedrop-ossec-agent").is_installed
+
+def test_ossec_keyfile_present(File, Command, Sudo, SystemInfo):
+    """ ensure client keyfile for ossec-agent is present """
+    pattern = "^1024 {} {} [0-9a-f]{{64}}$".format(
+                                        sdvars.app_hostname,
+                                        sdvars.app_ip)
+    regex = re.compile(pattern)
+
+    with Sudo():
+        f = File("/var/ossec/etc/client.keys")
+        assert f.exists
+        assert oct(f.mode) == "0644"
+        assert f.user == "root"
+        assert f.group == "ossec"
+        assert f.content_string
+        assert bool(re.search(regex, f.content))
diff --git a/testinfra/build/test_build_dependencies.py b/testinfra/build/test_build_dependencies.py
new file mode 100644
--- /dev/null
+++ b/testinfra/build/test_build_dependencies.py
@@ -0,0 +1,72 @@
+import pytest
+
+
+securedrop_test_vars = pytest.securedrop_test_vars
+
+
+def get_build_directories():
+    """
+    Helper function to retrieve module-namespace test vars and format
+    the strings to interpolate version info. Keeps the test vars DRY
+    in terms of version info, and required since we can't rely on
+    Jinja-based evaluation of the YAML files (so we can't trivially
+    reuse vars in other var values, as is the case with Ansible).
+    """
+    substitutions = dict(
+            securedrop_version=securedrop_test_vars.securedrop_version,
+            ossec_version=securedrop_test_vars.ossec_version,
+            keyring_version=securedrop_test_vars.keyring_version,
+            )
+    build_directories = [d.format(**substitutions) for d in securedrop_test_vars.build_directories]
+    return build_directories
+
+
+build_directories = get_build_directories()
+
+
+@pytest.mark.parametrize("package", [
+    "libssl-dev",
+    "python-dev",
+    "python-pip",
+])
+def test_build_dependencies(Package, package):
+    """
+    Ensure development apt dependencies are installed.
+    """
+    assert Package(package).is_installed
+
+
+def test_pip_wheel_installed(Command):
+    """
+    Ensure `wheel` is installed via pip, for packaging Python
+    dependencies into a Debian package.
+    """
+    c = Command("pip freeze")
+    assert "wheel==0.24.0" in c.stdout
+    assert c.rc == 0
+
+
+def test_sass_gem_installed(Command):
+    """
+    Ensure the `sass` Ruby gem is installed, for compiling SASS to CSS.
+    """
+    # The Ansible config uses state=latest, so the version may change;
+    # intentionally tracking an explicit version here so we can track
+    # a specific version down the road.
+    c = Command("gem list")
+    assert "sass (3.4.23)" in c.stdout
+    assert c.rc == 0
+
+
+@pytest.mark.parametrize("directory", get_build_directories())
+def test_build_directories(File, directory):
+    """
+    Ensure the build directories are present. These directories are
+    the top-level of the Debian packages being created. They contain
+    nested subdirs of varying complexity, depending on package.
+    """
+    if '{}' in directory:
+        directory = directory.format(securedrop_test_vars.securedrop_version)
+    assert File(directory).is_directory
+
+
diff --git a/testinfra/build/test_securedrop_deb_package.py b/testinfra/build/test_securedrop_deb_package.py
new file mode 100644
--- /dev/null
+++ b/testinfra/build/test_securedrop_deb_package.py
@@ -0,0 +1,134 @@
+import pytest
+import os
+import re
+
+
+securedrop_test_vars = pytest.securedrop_test_vars
+
+
+def extract_package_name_from_filepath(filepath):
+    """
+    Helper function to infer intended package name from
+    the absolute filepath, using a rather garish regex.
+    E.g., given:
+       securedrop-ossec-agent-2.8.2+0.3.10-amd64.deb
+
+    retuns:
+
+       securedrop-ossec-agent
+
+    which can then be used for comparisons in dpkg output.
+    """
+    deb_basename = os.path.basename(filepath)
+    package_name = re.search('^([a-z\-]+(?!\d))', deb_basename).groups()[0]
+    assert deb_basename.startswith(package_name)
+    return package_name
+
+
+def get_deb_packages():
+    """
+    Helper function to retrieve module-namespace test vars and format
+    the strings to interpolate version info. Keeps the test vars DRY
+    in terms of version info, and required since we can't rely on
+    Jinja-based evaluation of the YAML files (so we can't trivially
+    reuse vars in other var values, as is the case with Ansible).
+    """
+    substitutions = dict(
+            securedrop_version=securedrop_test_vars.securedrop_version,
+            ossec_version=securedrop_test_vars.ossec_version,
+            keyring_version=securedrop_test_vars.keyring_version,
+            )
+
+    deb_packages = [d.format(**substitutions) for d in securedrop_test_vars.build_deb_packages]
+    return deb_packages
+
+
+deb_packages = get_deb_packages()
+
+@pytest.mark.parametrize("deb", deb_packages)
+def test_build_deb_packages(File, deb):
+    """
+    Sanity check the built Debian packages for Control field
+    values and general package structure.
+    """
+    deb_package = File(deb.format(
+        securedrop_test_vars.securedrop_version))
+    assert deb_package.is_file
+
+
+@pytest.mark.parametrize("deb", deb_packages)
+def test_deb_packages_appear_installable(File, Command, Sudo, deb):
+    """
+    Confirms that a dry-run of installation reports no errors.
+    Simple check for valid Debian package structure, but not thorough.
+    When run on a malformed package, `dpkg` will report:
+
+       dpkg-deb: error: `foo.deb' is not a debian format archive
+
+    Testing application behavior is left to the functional tests.
+    """
+
+    deb_package = File(deb.format(
+        securedrop_test_vars.securedrop_version))
+
+    deb_basename = os.path.basename(deb_package.path)
+    package_name = extract_package_name_from_filepath(deb_package.path)
+    assert deb_basename.startswith(package_name)
+
+    # Sudo is required to call `dpkg --install`, even as dry-run.
+    with Sudo():
+        c = Command("dpkg --install --dry-run {}".format(deb_package.path))
+        assert "Selecting previously unselected package {}".format(package_name) in c.stdout
+        regex = "Preparing to unpack [./]+{} ...".format(re.escape(deb_basename))
+        assert re.search(regex, c.stdout, re.M)
+        assert c.rc == 0
+
+
+@pytest.mark.parametrize("deb", deb_packages)
+def test_deb_package_control_fields(File, Command, deb):
+    """
+    Ensure Debian Control fields are populated as expected in the package.
+    These checks are rather superficial, and don't actually confirm that the
+    .deb files are not broken. At a later date, consider integration tests
+    that actually use these built files during an Ansible provisioning run.
+    """
+    deb_package = File(deb.format(
+        securedrop_test_vars.securedrop_version))
+    package_name = extract_package_name_from_filepath(deb_package.path)
+    # The `--field` option will display all fields if none are specified.
+    c = Command("dpkg-deb --field {}".format(deb_package.path))
+
+    assert "Maintainer: SecureDrop Team <securedrop@freedom.press>" in c.stdout
+    assert "Architecture: amd64" in c.stdout
+    assert "Package: {}".format(package_name) in c.stdout
+    assert c.rc == 0
+
+# Marking as expected failure because the securedrop-keyring package
+# uses the old "freedom.press/securedrop" URL as the Homepage. That should
+# be changed to securedrop.org, and the check folded into the other control
+# fields logic, above.
+@pytest.mark.xfail
+@pytest.mark.parametrize("deb", deb_packages)
+def test_deb_package_control_fields_homepage(File, Command, deb):
+    deb_package = File(deb.format(
+        securedrop_test_vars.securedrop_version))
+    # The `--field` option will display all fields if none are specified.
+    c = Command("dpkg-deb --field {}".format(deb_package.path))
+    assert "Homepage: https://securedrop.org" in c.stdout
+
+
+# Marking as expected failure because the build process does not currently
+# programmatically enforce absence of these files; but it definitely should.
+# Right now, package building requires a manual step to clean up .pyc files.
+@pytest.mark.xfail
+@pytest.mark.parametrize("deb", deb_packages)
+def test_deb_package_contains_no_pyc_files(File, Command, deb):
+    """
+    Ensures no .pyc files are shipped via the Debian packages.
+    """
+    deb_package = File(deb.format(
+        securedrop_test_vars.securedrop_version))
+    # Using `dpkg-deb` but `lintian --tag package-installs-python-bytecode`
+    # would be cleaner. Will defer to adding lintian tests later.
+    c = Command("dpkg-deb --contents {}".format(deb_package.path))
+    assert not re.search("^.*\.pyc$", c.stdout, re.M)
diff --git a/testinfra/common/test_cron_apt.py b/testinfra/common/test_cron_apt.py
new file mode 100644
--- /dev/null
+++ b/testinfra/common/test_cron_apt.py
@@ -0,0 +1,142 @@
+import pytest
+import re
+
+
+@pytest.mark.parametrize('dependency', [
+    'cron-apt',
+    'ntp'
+])
+def test_cron_apt_dependencies(Package, dependency):
+    """
+    Ensure critical packages are installed. If any of these are missing,
+    the system will fail to receive automatic updates.
+
+    The current apt config uses cron-apt, rather than unattended-upgrades,
+    but this may change in the future. Previously the apt.freedom.press repo
+    was not reporting any "Origin" field, making use of unattended-upgrades
+    problematic. With better procedures in place regarding apt repo
+    maintenance, we can ensure the field is populated going forward.
+    """
+    assert Package(dependency).is_installed
+
+
+def test_cron_apt_config(File):
+    """
+    Ensure custom cron-apt config file is present.
+    """
+    f = File('/etc/cron-apt/config')
+    assert f.is_file
+    assert f.user == "root"
+    assert oct(f.mode) == "0644"
+    assert f.contains('^SYSLOGON="always"$')
+    assert f.contains('^EXITON=error$')
+
+
+
+@pytest.mark.parametrize('repo', [
+  'deb http://security.ubuntu.com/ubuntu trusty-security main',
+  'deb-src http://security.ubuntu.com/ubuntu trusty-security main',
+  'deb http://security.ubuntu.com/ubuntu trusty-security universe',
+  'deb-src http://security.ubuntu.com/ubuntu trusty-security universe',
+  'deb [arch=amd64] https://apt.freedom.press trusty main',
+  'deb http://deb.torproject.org/torproject.org trusty main',
+])
+def test_cron_apt_repo_list(File, repo):
+    """
+    Ensure the correct apt repositories are specified
+    in the security list for apt.
+    """
+    f = File('/etc/apt/security.list')
+    assert f.is_file
+    assert f.user == "root"
+    assert oct(f.mode) == "0644"
+    repo_regex = '^{}$'.format(re.escape(repo))
+    assert f.contains(repo_regex)
+
+
+
+def test_cron_apt_repo_config_update(File):
+    """
+    Ensure cron-apt updates repos from the security.list config.
+    """
+
+    f = File('/etc/cron-apt/action.d/0-update')
+    assert f.is_file
+    assert f.user == "root"
+    assert oct(f.mode) == "0644"
+    repo_config = str('update -o quiet=2'
+                     ' -o Dir::Etc::SourceList=/etc/apt/security.list'
+                     ' -o Dir::Etc::SourceParts=""')
+    assert f.contains('^{}$'.format(repo_config))
+
+
+def test_cron_apt_repo_config_upgrade(File):
+    """
+    Ensure cron-apt upgrades packages from the security.list config.
+    """
+    f = File('/etc/cron-apt/action.d/5-security')
+    assert f.is_file
+    assert f.user == "root"
+    assert oct(f.mode) == "0644"
+    assert f.contains('^autoclean -y$')
+    repo_config = str('dist-upgrade -y -o APT::Get::Show-Upgraded=true'
+                       ' -o Dir::Etc::SourceList=/etc/apt/security.list'
+                       ' -o Dpkg::Options::=--force-confdef'
+                       ' -o Dpkg::Options::=--force-confold')
+    assert f.contains(re.escape(repo_config))
+
+
+def test_cron_apt_config_deprecated(File):
+    """
+    Ensure default cron-apt file to download all updates does not exist.
+    """
+    f = File('/etc/cron-apt/action.d/3-download')
+    assert not f.exists
+
+
+@pytest.mark.parametrize('cron_job', [
+    { 'job': '0 4 * * * root    /usr/bin/test -x /usr/sbin/cron-apt && /usr/sbin/cron-apt && /sbin/reboot',
+      'state': 'present',
+    },
+    { 'job': '0 4 * * * root    /usr/bin/test -x /usr/sbin/cron-apt && /usr/sbin/cron-apt',
+      'state': 'absent',
+    },
+    { 'job': '0 5 * * * root    /sbin/reboot',
+      'state': 'absent',
+    },
+])
+def test_cron_apt_cron_jobs(File, cron_job):
+    """
+    Check for correct cron job for upgrading all packages and rebooting.
+    We'll also check for absence of previous versions of the cron job,
+    to make sure those have been cleaned up via the playbooks.
+    """
+    f = File('/etc/cron.d/cron-apt')
+    assert f.is_file
+    assert f.user == "root"
+    assert oct(f.mode) == "0644"
+
+    regex_job = '^{}$'.format(re.escape(cron_job['job']))
+    if cron_job['state'] == 'present':
+        assert f.contains(regex_job)
+    else:
+        assert not f.contains(regex_job)
+
+
+def test_cron_apt_all_packages_updated(Command):
+    """
+    Ensure a safe-upgrade has already been run, by checking that no
+    packages are eligible for upgrade currently.
+
+    The Ansible config installs a specific, out-of-date version of Firefox
+    for use with Selenium. Therefore apt will report it's possible to upgrade
+    Firefox, which we'll need to mark as "OK" in terms of the tests.
+    """
+    c = Command('aptitude --simulate -y safe-upgrade')
+    assert c.rc == 0
+    # If planning to upgrade anything, make sure it's ONLY firefox.
+    if "1 packages upgraded, 0 newly installed, 0 to remove and 0 not upgraded" in c.stdout:
+        assert "firefox" in c.stdout
+    else:
+        assert "No packages will be installed, upgraded, or removed." in c.stdout
+        assert "0 packages upgraded, 0 newly installed, 0 to remove and 0 not upgraded" in c.stdout
diff --git a/testinfra/common/test_fpf_apt_repo.py b/testinfra/common/test_fpf_apt_repo.py
new file mode 100644
--- /dev/null
+++ b/testinfra/common/test_fpf_apt_repo.py
@@ -0,0 +1,45 @@
+def test_fpf_apt_repo_present(File):
+    """
+    Ensure the FPF apt repo, apt.freedom.press, is configured.
+    This repository is necessary for the SecureDrop Debian packages,
+    including:
+
+      * securedrop-app-code
+      * securedrop-keyring
+      * securedrop-grsec
+
+    Depending on the host, additional FPF-maintained packages will be
+    installed, e.g. for OSSEC. Install state for those packages
+    is tested separately.
+    """
+    f = File('/etc/apt/sources.list.d/apt_freedom_press.list')
+    assert f.contains('^deb \[arch=amd64\] https:\/\/apt\.freedom\.press trusty main$')
+
+
+def test_fpf_apt_repo_fingerprint(Command):
+    """
+    Ensure the FPF apt repo has the correct fingerprint on the associated
+    signing pubkey. The key changed in October 2016, so test for the 
+    newest fingerprint, which is installed on systems via the 
+    `securedrop-keyring` package.
+    """
+
+
+    c = Command('apt-key finger')
+
+    fpf_gpg_pub_key_info = """/etc/apt/trusted.gpg.d/securedrop-keyring.gpg
+---------------------------------------------
+pub   4096R/00F4AD77 2016-10-20 [expires: 2017-10-20]
+      Key fingerprint = 2224 5C81 E3BA EB41 38B3  6061 310F 5612 00F4 AD77
+uid                  SecureDrop Release Signing Key"""
+
+    assert c.rc == 0
+    assert fpf_gpg_pub_key_info in c.stdout
+
+    fpf_gpg_pub_key_fingerprint_expired = 'B89A 29DB 2128 160B 8E4B  1B4C BADD E0C7 FC9F 6818'
+    fpf_gpg_pub_key_info_expired = """pub   4096R/FC9F6818 2014-10-26 [expired: 2016-10-27]
+      Key fingerprint = #{fpf_gpg_pub_key_fingerprint_expired}
+uid                  Freedom of the Press Foundation Master Signing Key"""
+
+    assert fpf_gpg_pub_key_fingerprint_expired not in c.stdout
+    assert fpf_gpg_pub_key_info_expired not in c.stdout
diff --git a/testinfra/common/test_grsecurity.py b/testinfra/common/test_grsecurity.py
new file mode 100644
--- /dev/null
+++ b/testinfra/common/test_grsecurity.py
@@ -0,0 +1,172 @@
+import pytest
+import os
+import re
+
+
+def test_ssh_motd_disabled(File):
+    """
+    Ensure the SSH MOTD (Message of the Day) is disabled.
+    Grsecurity balks at Ubuntu's default MOTD.
+    """
+    f = File("/etc/pam.d/sshd")
+    assert f.is_file
+    assert not f.contains("pam\.motd")
+
+
+@pytest.mark.parametrize("package", [
+    'paxctl',
+    'securedrop-grsec',
+])
+def test_grsecurity_apt_packages(Package, package):
+    """
+    Ensure the grsecurity-related apt packages are present on the system.
+    Includes the FPF-maintained metapackage, as well as paxctl, for managing
+    PaX flags on binaries.
+    """
+    assert Package(package).is_installed
+
+
+@pytest.mark.parametrize("package", [
+    'linux-signed-image-generic-lts-utopic',
+    'linux-signed-image-generic',
+    'linux-signed-generic-lts-utopic',
+    'linux-signed-generic',
+    '^linux-image-.*generic$',
+    '^linux-headers-.*',
+])
+def test_generic_kernels_absent(Command, package):
+    """
+    Ensure the default Ubuntu-provided kernel packages are absent.
+    In the past, conflicting version numbers have caused machines
+    to reboot into a non-grsec kernel due to poor handling of 
+    GRUB_DEFAULT logic. Removing the vendor-provided kernel packages
+    prevents accidental boots into non-grsec kernels.
+    """
+    # Can't use the TestInfra Package module to check state=absent,
+    # so let's check by shelling out to `dpkg -l`. Dpkg will automatically
+    # honor simple regex in package names.
+    c = Command("dpkg -l {}".format(package))
+    assert c.rc == 1
+    error_text = "dpkg-query: no packages found matching {}".format(package)
+    assert c.stderr == error_text
+
+
+def test_grsecurity_lock_file(File):
+    """
+    Ensure system is rerunning a grsecurity kernel by testing for the
+    `grsec_lock` file, which is automatically created by grsecurity.
+    """
+    f = File("/proc/sys/kernel/grsecurity/grsec_lock")
+    assert oct(f.mode) == "0600"
+    assert f.user == "root"
+    assert f.size == 0
+
+
+def test_grsecurity_kernel_is_running(Command):
+    """
+    Make sure the currently running kernel is specific grsec kernel.
+    """
+    c = Command('uname -r')
+    assert c.stdout.endswith('-grsec')
+    assert c.stdout == '3.14.79-grsec'
+
+
+@pytest.mark.parametrize('sysctl_opt', [
+  ('kernel.grsecurity.grsec_lock', 1),
+  ('kernel.grsecurity.rwxmap_logging', 0),
+])
+def test_grsecurity_sysctl_options(Sysctl, Sudo, sysctl_opt):
+    """
+    Check that the grsecurity-related sysctl options are set correctly.
+    In production the RWX logging is disabled, to reduce log noise.
+    """
+    with Sudo():
+        assert Sysctl(sysctl_opt[0]) == sysctl_opt[1]
+
+@pytest.mark.parametrize('paxtest_check', [
+  "Executable anonymous mapping",
+  "Executable bss",
+  "Executable data",
+  "Executable heap",
+  "Executable stack",
+  "Executable shared library bss",
+  "Executable shared library data",
+  "Executable anonymous mapping (mprotect)",
+  "Executable bss (mprotect)",
+  "Executable data (mprotect)",
+  "Executable heap (mprotect)",
+  "Executable stack (mprotect)",
+  "Executable shared library bss (mprotect)",
+  "Executable shared library data (mprotect)",
+  "Writable text segments",
+  "Return to function (memcpy)",
+  "Return to function (memcpy, PIE)",
+])
+def test_grsecurity_paxtest(Command, Sudo, paxtest_check):
+    """
+    Check that paxtest does not report anything vulnerable
+    Requires the package paxtest to be installed.
+    The paxtest package is currently being installed in the app-test role.
+    """
+    if Command.exists("/usr/bin/paxtest"):
+        with Sudo():
+            c = Command("paxtest blackhat")
+            assert c.rc == 0
+            assert "Vulnerable" not in c.stdout
+            regex = "^{}\s*:\sKilled$".format(re.escape(paxtest_check))
+
+
+
+def test_grub_pc_marked_manual(Command):
+    """
+    Ensure the `grub-pc` packaged is marked as manually installed.
+    This is necessary for VirtualBox with Vagrant.
+    """
+    c = Command('apt-mark showmanual grub-pc')
+    assert c.rc == 0
+    assert c.stdout == "grub-pc"
+
+
+# The app-staging host has an outdated version of Firefox installed manually,
+# for compatibility with Selenium. Let's skip only on that host.
+@pytest.mark.skipif(
+        os.environ['SECUREDROP_TESTINFRA_TARGET_HOST'] == "app-staging",
+        reason="app-staging uses old version of Firefox for Selenium")
+def test_apt_autoremove(Command):
+    """
+    Ensure old packages have been autoremoved.
+    """
+    c = Command('apt-get --dry-run autoremove')
+    assert c.rc == 0
+    assert "0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded" in c.stdout
+
+
+# Expecting failure here, since the Ansible config doesn't set the same
+# flags in via the playbook as were recently declared in the securedrop-grsec
+# metapackage. The playbook in the SecureDrop install process should be updated
+# to match the PaX flags enforced via the metapackage.
+@pytest.mark.xfail
+@pytest.mark.parametrize("binary", [
+    "/usr/sbin/grub-probe",
+    "/usr/sbin/grub-mkdevicemap",
+    "/usr/bin/grub-script-check",
+])
+def test_pax_flags(Command, File, binary):
+    """
+    Ensure PaX flags are set correctly on critical Grub binaries.
+    These flags are maintained as part of a post-install kernel hook
+    in the `securedrop-grsec` metapackage. If they aren't set correctly,
+    the machine may fail to boot into a new kernel.
+    """
+
+    f = File("/etc/kernel/postinst.d/paxctl-grub")
+    assert f.is_file
+    assert f.contains("^paxctl -zCE {}".format(binary))
+
+    c = Command("paxctl -v {}".format(binary))
+    assert c.rc == 0
+
+    assert "- PaX flags: -p---m--E--- [{}]".format(binary) in c.stdout
+    assert "PAGEEXEC is disabled" in c.stdout
+    assert "MPROTECT is disabled" in c.stdout
+    assert "EMUTRAMP is enabled" in c.stdout
diff --git a/testinfra/common/test_ip6tables.py b/testinfra/common/test_ip6tables.py
new file mode 100644
--- /dev/null
+++ b/testinfra/common/test_ip6tables.py
@@ -0,0 +1,15 @@
+def test_ip6tables_drop_everything(Command, Sudo):
+    """
+    Ensure that all IPv6 packets are dropped by default.
+    The IPv4 rules are more complicated, and tested separately.
+    """
+    desired_ip6tables_output = """
+-P INPUT DROP
+-P FORWARD DROP
+-P OUTPUT DROP
+""".lstrip().rstrip()
+
+    with Sudo():
+        c = Command("ip6tables -S")
+        assert c.stdout == desired_ip6tables_output
+        assert c.rc == 0
diff --git a/testinfra/common/test_platform.py b/testinfra/common/test_platform.py
new file mode 100644
--- /dev/null
+++ b/testinfra/common/test_platform.py
@@ -0,0 +1,28 @@
+import pytest
+
+
+def test_ansible_version(LocalCommand):
+    """
+    Check that a supported version of Ansible is being used.
+
+    The project has long used the Ansible 1.x series, but aims
+    to upgrade to 2.x during the 0.4 release. Developers commonly
+    install a recent version of Ansible when developing SecureDrop,
+    which is not a good baseline for testing.
+    """
+    c = LocalCommand("ansible --version")
+    assert c.stdout.startswith("ansible 1.")
+    assert "ansible 2" not in c.stdout
+
+
+def test_platform(SystemInfo):
+    """
+    SecureDrop requires Ubuntu Trusty 14.04 LTS. The shelf life
+    of that release means we'll need to migrate to Xenial LTS
+    at some point; until then, require hosts to be running
+    Ubuntu.
+    """
+    assert SystemInfo.type == "linux"
+    assert SystemInfo.distribution == "ubuntu"
+    assert SystemInfo.codename == "trusty"
+    assert SystemInfo.release == "14.04"
diff --git a/testinfra/common/test_system_hardening.py b/testinfra/common/test_system_hardening.py
new file mode 100644
--- /dev/null
+++ b/testinfra/common/test_system_hardening.py
@@ -0,0 +1,76 @@
+import pytest
+import re
+
+
+@pytest.mark.parametrize('sysctl_opt', [
+  ('net.ipv4.conf.all.accept_redirects', 0),
+  ('net.ipv4.conf.all.accept_source_route', 0),
+  ('net.ipv4.conf.all.rp_filter', 1),
+  ('net.ipv4.conf.all.secure_redirects', 0),
+  ('net.ipv4.conf.all.send_redirects', 0),
+  ('net.ipv4.conf.default.accept_redirects', 0),
+  ('net.ipv4.conf.default.accept_source_route', 0),
+  ('net.ipv4.conf.default.rp_filter', 1),
+  ('net.ipv4.conf.default.secure_redirects', 0),
+  ('net.ipv4.conf.default.send_redirects', 0),
+  ('net.ipv4.icmp_echo_ignore_broadcasts', 1),
+  ('net.ipv4.ip_forward', 0),
+  ('net.ipv4.tcp_max_syn_backlog', 4096),
+  ('net.ipv4.tcp_syncookies', 1),
+  ('net.ipv6.conf.all.disable_ipv6', 1),
+  ('net.ipv6.conf.default.disable_ipv6', 1),
+  ('net.ipv6.conf.lo.disable_ipv6', 1),
+])
+def test_sysctl_options(Sysctl, Sudo, sysctl_opt):
+    """
+    Ensure sysctl flags are set correctly. Most of these checks
+    are disabling IPv6 and hardening IPv4, which is appropriate
+    due to the heavy use of Tor.
+    """
+    with Sudo():
+        assert Sysctl(sysctl_opt[0]) == sysctl_opt[1]
+
+
+def test_dns_setting(File):
+    """
+    Ensure DNS service is hard-coded in resolv.conf config.
+    """
+    f = File('/etc/resolvconf/resolv.conf.d/base')
+    assert f.is_file
+    assert f.user == "root"
+    assert f.group == "root"
+    assert oct(f.mode) == "0644"
+    assert f.contains('^nameserver 8\.8\.8\.8$')
+
+
+@pytest.mark.parametrize('kernel_module', [
+    'bluetooth',
+    'iwlwifi',
+])
+def test_blacklisted_kernel_modules(Command, File, Sudo, kernel_module):
+    """
+    Test that unwanted kernel modules are blacklisted on the system.
+    Mostly these checks are defense-in-depth approaches to ensuring
+    that wireless interfaces will not work.
+    """
+    with Sudo():
+        assert kernel_module not in Command("lsmod").stdout
+
+    f = File("/etc/modprobe.d/blacklist.conf")
+    assert f.contains("^blacklist {}$".format(kernel_module))
+
+
+# Expecting failure here, since the Ansible config doesn't actually
+# disable swap, as intended. (It doesn't manage /etc/fstab.)
+@pytest.mark.xfail
+def test_swap_disabled(Command):
+    """
+    Ensure swap space is disabled. Prohibit writing memory to swapfiles
+    to reduce the threat of forensic analysis leaking any sensitive info.
+    """
+    c = Command('swapon --summary')
+    assert c.rc == 0
+    # A leading slash will indicate full path to a swapfile.
+    assert not re.search("^/", c.stdout, re.M)
+    # Expect that ONLY the headers will be present in the output.
+    assert c.stdout == "Filename\t\t\t\tType\t\tSize\tUsed\tPriority\n"
diff --git a/testinfra/common/test_tor_config.py b/testinfra/common/test_tor_config.py
new file mode 100644
--- /dev/null
+++ b/testinfra/common/test_tor_config.py
@@ -0,0 +1,93 @@
+import pytest
+import re
+
+sdvars = pytest.securedrop_test_vars
+
+def test_tor_apt_repo(File):
+    """
+    Ensure the Tor Project apt repository is configured.
+    The version of Tor in the Trusty repos is not up to date.
+    """
+    f = File('/etc/apt/sources.list.d/deb_torproject_org_torproject_org.list')
+    repo_regex = re.escape('deb http://deb.torproject.org/torproject.org trusty main')
+    assert f.contains(repo_regex)
+
+
+@pytest.mark.parametrize('package', [
+    'deb.torproject.org-keyring',
+    'tor',
+])
+def test_tor_packages(Package, package):
+    """
+    Ensure Tor packages are installed. Includes a check for the keyring,
+    so that automatic updates can handle rotating the signing key if necessary.
+    """
+    assert Package(package).is_installed
+
+
+def test_tor_service_running(Command, File, Sudo):
+    """
+    Ensure tor is running and enabled. Tor is required for SSH access,
+    so it must be enabled to start on boot.
+    """
+    # TestInfra tries determine the service manager intelligently, and
+    # inappropriately assumes Upstart on Trusty, due to presence of the
+    # `initctl` command. The tor service is handled via a SysV-style init
+    # script, so let's just shell out and verify the running and enabled
+    # states explicitly.
+    with Sudo():
+        tor_status = Command("service tor status")
+    assert tor_status.rc == 0
+    assert tor_status.stdout == " * tor is running"
+
+    with Sudo():
+        tor_enabled = Command("find /etc/rc?.d -name S??tor")
+    assert tor_enabled.rc == 0
+    assert tor_enabled.stdout != ""
+
+    tor_targets = tor_enabled.stdout.split("\n")
+    assert len(tor_targets) == 4
+    for target in tor_targets:
+        t = File(target)
+        assert t.is_symlink
+        assert t.linked_to == "/etc/init.d/tor"
+
+
+@pytest.mark.parametrize('torrc_option', [
+    'SocksPort 0',
+    'SafeLogging 1',
+    'RunAsDaemon 1',
+    'Sandbox 1',
+])
+def test_tor_torrc_options(File, torrc_option):
+    """
+    Check for required options in the system Tor config file.
+    These options should be present regardless of machine role,
+    meaning both Application and Monitor server will have them.
+
+    Separate tests will check for specific hidden services.
+    """
+    f = File("/etc/tor/torrc")
+    assert f.is_file
+    assert f.user == "debian-tor"
+    assert oct(f.mode) == "0644"
+    assert f.contains("^{}$".format(torrc_option))
+
+
+def test_tor_signing_key_fingerprint(Command):
+    """
+    The `deb.torproject.org-keyring` package manages the repo signing pubkey
+    for tor-related packages, so make sure that fingerprint matches
+    expectations.
+    """
+
+    c = Command("apt-key finger")
+    tor_gpg_pub_key_info = """/etc/apt/trusted.gpg.d/deb.torproject.org-keyring.gpg
+-----------------------------------------------------
+pub   2048R/886DDD89 2009-09-04 [expires: 2020-08-29]
+      Key fingerprint = A3C4 F0F9 79CA A22C DBA8  F512 EE8C BC9E 886D DD89
+uid                  deb.torproject.org archive signing key
+sub   2048R/219EC810 2009-09-04 [expires: 2018-08-30]"""
+
+    assert c.rc == 0
+    assert tor_gpg_pub_key_info in c.stdout
diff --git a/testinfra/common/test_tor_hidden_services.py b/testinfra/common/test_tor_hidden_services.py
new file mode 100644
--- /dev/null
+++ b/testinfra/common/test_tor_hidden_services.py
@@ -0,0 +1,89 @@
+import pytest
+import re
+
+
+sdvars = pytest.securedrop_test_vars
+
+
+@pytest.mark.parametrize('tor_service', sdvars.tor_services)
+def test_tor_service_directories(File, Sudo, tor_service):
+    """
+    Check mode and ownership on Tor service directories.
+    """
+    with Sudo():
+        f = File("/var/lib/tor/services/{}".format(tor_service['name']))
+        assert f.is_directory
+        # TODO: tor might mark these dirs as setgid
+        assert oct(f.mode) == "0700"
+        assert f.user == "debian-tor"
+        assert f.group == "debian-tor"
+
+
+@pytest.mark.parametrize('tor_service', sdvars.tor_services)
+def test_tor_service_hostnames(File, Sudo, tor_service):
+    """
+    Check contents of tor service hostname file. For normal Hidden Services,
+    the file should contain only hostname (.onion URL). For Authenticated
+    Hidden Services, it should also contain the HidServAuth cookie.
+    """
+
+    # Declare regex only for THS; we'll build regex for ATHS only if
+    # necessary, since we won't have the required values otherwise.
+    ths_hostname_regex = "[a-z0-9]{16}\.onion"
+
+    with Sudo():
+        f = File("/var/lib/tor/services/{}/hostname".format(tor_service['name']))
+        assert f.is_file
+        assert oct(f.mode) == "0600"
+        assert f.user == "debian-tor"
+        assert f.group == "debian-tor"
+
+        # All hostnames should contain at *least* the hostname.
+        assert re.search(ths_hostname_regex, f.content)
+
+        if tor_service['authenticated']:
+            # HidServAuth regex is approximately [a-zA-Z0-9/+], but validating
+            # the entire entry is sane, and we don't need to nitpick the charset.
+            aths_hostname_regex = ths_hostname_regex+" .{22} # client: "+tor_service['client']
+            assert re.search("^{}$".format(aths_hostname_regex), f.content)
+        else:
+            assert re.search("^{}$".format(ths_hostname_regex), f.content)
+
+
+@pytest.mark.parametrize('tor_service', sdvars.tor_services)
+def test_tor_services_config(File, tor_service):
+    """
+    Ensure torrc file contains relevant lines for Hidden Service declarations.
+    All hidden services must include:
+
+      * HiddenServiceDir
+      * HiddenServicePort
+
+    Only authenticated hidden services must also include:
+
+      * HiddenServiceAuthorizeClient
+
+    Check for each as appropriate.
+    """
+    f = File("/etc/tor/torrc")
+    dir_regex = "HiddenServiceDir /var/lib/tor/services/{}".format(
+                                                            tor_service['name'])
+    # We need at least one port, but it may be used for both config values.
+    # On the Journalist Interface, we reuse the "80" remote port but map it to
+    # a different local port, so Apache can listen on several sockets.
+    remote_port = tor_service['ports'][0]
+    try:
+        local_port = tor_service['ports'][1]
+    except IndexError:
+        local_port = remote_port
+
+    port_regex = "HiddenServicePort {} 127.0.0.1:{}".format(
+            remote_port, local_port)
+
+    assert f.contains("^{}$".format(dir_regex))
+    assert f.contains("^{}$".format(port_regex))
+
+    if tor_service['authenticated']:
+        auth_regex = "HiddenServiceAuthorizeClient stealth {}".format(
+                tor_service['client'])
+        assert f.contains("^{}$".format(auth_regex))
diff --git a/testinfra/common/test_user_config.py b/testinfra/common/test_user_config.py
new file mode 100644
--- /dev/null
+++ b/testinfra/common/test_user_config.py
@@ -0,0 +1,69 @@
+import re
+
+
+def test_sudoers_config(File, Sudo):
+    """
+    Check sudoers config for passwordless sudo via group membership,
+    as well as environment-related hardening.
+    """
+    f = File("/etc/sudoers")
+    assert f.is_file
+    assert f.user == "root"
+    assert f.group == "root"
+    assert oct(f.mode) == "0440"
+
+    # Restrictive file mode requires sudo for reading, so let's
+    # read once and store the content in a var.
+    with Sudo():
+        sudoers_config = f.content
+
+    # Using re.search rather than `f.contains` since the basic grep
+    # matching doesn't support PCRE, so `\s` won't work.
+    assert re.search('^Defaults\s+env_reset$', sudoers_config, re.M)
+    assert re.search('^Defaults\s+env_reset$', sudoers_config, re.M)
+    assert re.search('^Defaults\s+mail_badpass$', sudoers_config, re.M)
+    assert re.search('Defaults\s+secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"', sudoers_config, re.M)
+    assert re.search('^%sudo\s+ALL=\(ALL\)\s+NOPASSWD:\s+ALL$', sudoers_config, re.M)
+    assert re.search('Defaults:%sudo\s+!requiretty', sudoers_config, re.M)
+
+
+def test_sudoers_tmux_env(File):
+    """
+    Ensure SecureDrop-specific bashrc additions are present.
+    This checks for automatic tmux start on interactive shells.
+    If we switch to byobu, we can set `byobu-enabled` and check
+    the corresponding settings there.
+    """
+
+    f = File('/etc/profile.d/securedrop_additions.sh')
+    non_interactive_str = re.escape('[[ $- != *i* ]] && return')
+    tmux_check = re.escape('test -z "$TMUX" && (tmux attach || tmux new-session)')
+
+    assert f.contains("^{}$".format(non_interactive_str))
+    assert f.contains("^if which tmux >\/dev\/null 2>&1; then$")
+
+    assert 'test -z "$TMUX" && (tmux attach || tmux new-session)' in f.content
+    assert f.contains(tmux_check)
+
+
+def test_tmux_installed(Package):
+    """
+    Ensure the `tmux` package is present, since it's required for the user env.
+    When running an interactive SSH session over Tor, tmux should be started
+    automatically, to prevent problems if the connection is broken unexpectedly,
+    as sometimes happens over Tor. The Admin will be able to reconnect to the
+    running tmux session and review command output.
+    """
+    assert Package("tmux").is_installed
+
+
+def test_sudoers_tmux_env_deprecated(File):
+    """
+    Previous version of the Ansible config set the tmux config
+    in per-user ~/.bashrc, which was redundant. The config has
+    since moved to /etc/profile.d, to provide a single point of
+    update that applies to all users. Let's make sure that the
+    old setting isn't still active.
+    """
+    f = File("/home/vagrant/.bashrc")
+    assert not f.contains("^. \/etc\/bashrc\.securedrop_additions$")
diff --git a/testinfra/development/test_development_application_settings.py b/testinfra/development/test_development_application_settings.py
new file mode 100644
--- /dev/null
+++ b/testinfra/development/test_development_application_settings.py
@@ -0,0 +1,131 @@
+import pytest
+
+
+@pytest.mark.parametrize('package', [
+    "securedrop-app-code",
+    "apache2-mpm-worker",
+    "libapache2-mod-wsgi",
+    "libapache2-mod-xsendfile",
+])
+def test_development_lacks_deb_packages(Command, package):
+    """
+    The development machine does not use Apache, but rather the Flask runner,
+    for standing up dev-friendly servers inside the VM. Therefore the
+    app-code-related deb packages should be absent.
+    """
+    # The TestInfra `Package` module doesn't offer state=absent checks,
+    # so let's call `dpkg -l` and inspect that output.
+    c = Command("dpkg -l {}".format(package))
+    assert c.rc == 1
+    assert c.stdout == ""
+    assert c.stderr == "dpkg-query: no packages found matching {}".format(
+            package)
+
+
+def test_development_apparmor_no_complain_mode(Command, Sudo):
+    """
+    Ensure that AppArmor profiles are not set to complain mode in development.
+    The app-staging host sets profiles to complain, viz.
+
+      * usr.sbin.apache2
+      * usr.sbin.tor
+
+    but those changes should not land on the development machine.
+    """
+
+    with Sudo():
+        c = Command("aa-status")
+        assert c.rc == 0
+        assert '0 profiles are in complain mode.' in c.stdout
+
+
+@pytest.mark.parametrize('unwanted_file', [
+    "/var/www/html",
+    "/var/www/source.wsgi",
+    "/var/www/document.wsgi",
+])
+def test_development_apache_docroot_absent(File, unwanted_file):
+    """
+    Ensure the default HTML document root is missing.
+    Development environment does not serve out of /var/www,
+    since it uses the Flask dev server, not Apache.
+    """
+    f = File(unwanted_file)
+    assert not f.exists
+
+
+@pytest.mark.parametrize('data_dir', [
+    "/var/lib/securedrop",
+    "/var/lib/securedrop/keys",
+    "/var/lib/securedrop/tmp",
+    "/var/lib/securedrop/store",
+])
+def test_development_data_directories_exist(File, data_dir):
+    """
+    Ensure that application code directories are created
+    under /vagrant for the development environment, rather than
+    /var/www as in staging and prod.
+    """
+    f = File(data_dir)
+    assert f.is_directory
+    assert f.user == "vagrant"
+    assert f.group == "vagrant"
+    assert oct(f.mode) == "0700"
+
+
+def test_development_app_directories_exist(File):
+    """
+    Ensure that application code directories are created
+    under /vagrant for the development environment, rather than
+    /var/www as in staging and prod.
+
+    Using a separate check from the data directories because /vagrant
+    will be mounted with different mode.
+    """
+    f = File("/vagrant/securedrop")
+    assert f.is_directory
+    assert f.user == "vagrant"
+    assert f.group == "vagrant"
+
+  # Vagrant VirtualBox environments show /vagrant as 770,
+  # but the Vagrant DigitalOcean droplet shows /vagrant as 775.
+  # This appears to be a side-effect of the default umask
+  # in the snapci instances. (The rsync provisioner for the
+  # vagrant-digitalocean plugin preserves permissions from the host.)
+  # The spectests for 'staging' still check for an explicit mode,
+  # so it's OK to relax this test for now.
+  #it { should be_mode '700' }
+  # TODO: should be 700 in all environments; ansible task is
+  # straightforward about this.
+
+
+def test_development_clean_tmp_cron_job(Command, Sudo):
+    """
+    Ensure cron job for cleaning the temporary directory for the app code exists.
+    """
+
+    with Sudo():
+        c = Command('crontab -l')
+    assert c.rc == 0
+    # TODO: this should be using property, but the ansible role
+    # doesn't use a var, it's hard-coded. update ansible, then fix test.
+    # it { should have_entry "@daily #{property['securedrop_code']}/manage.py clean-tmp" }
+    assert "@daily /vagrant/securedrop/manage.py clean-tmp" in c.stdout
+
+
+def test_development_default_logo_exists(File):
+    """
+    Checks for default SecureDrop logo file.
+
+    TODO: Add check for custom logo file.
+    """
+
+    f = File("/vagrant/securedrop/static/i/logo.png")
+    assert f.is_file
+    assert f.user == "vagrant"
+    assert f.group == "vagrant"
+    assert oct(f.mode) == "0644"
+    # TODO: Ansible task declares mode 400 but not as string, needs to be fixed
+    # and tests updated. Also, not using "mode" in tests below because umask
+    # on snapci machines differs from the /vagrant folder in dev VM.
+    # Fixing Ansible task may fix differing perms.
diff --git a/testinfra/development/test_development_environment.py b/testinfra/development/test_development_environment.py
new file mode 100644
--- /dev/null
+++ b/testinfra/development/test_development_environment.py
@@ -0,0 +1,63 @@
+import pytest
+
+
+def test_development_app_dependencies(Package):
+    """
+    Ensure development apt dependencies are installed.
+    """
+    development_apt_dependencies = [
+      'libssl-dev',
+      'python-dev',
+      'python-pip',
+    ]
+    for dependency in development_apt_dependencies:
+        p = Package(dependency)
+        assert p.is_installed
+
+
+@pytest.mark.parametrize('pip_package,version', [
+    ('Flask-Testing', '0.6.1'),
+    ('Flask', '0.11.1'),
+    ('Jinja2', '2.8'),
+    ('MarkupSafe', '0.23'),
+    ('Werkzeug', '0.11.11'),
+    ('beautifulsoup4', '4.5.1'),
+    ('click', '6.6'),
+    ('coverage', '4.2'),
+    ('first', '2.0.1'),
+    ('funcsigs', '1.0.2'),
+    ('itsdangerous', '0.24'),
+    ('mock', '2.0.0'),
+    ('pbr', '1.10.0'),
+    ('pip-tools', '1.7.0'),
+    ('py', '1.4.31'),
+    ('pytest-cov', '2.4.0'),
+    ('pytest', '3.0.3'),
+    ('selenium', '2.53.6'),
+    ('six', '1.10.0'),
+])
+def test_development_pip_dependencies(Command, pip_package, version):
+    """
+    Declare SecureDrop app pip requirements. On the development VM,
+    the pip dependencies should be installed directly via pip, rather
+    than relying on the deb packages with pip-wheel inclusions.
+    Versions here are intentionally hardcoded to track changes.
+    """
+    c = Command('pip freeze')
+    assert "{}=={}".format(pip_package, version) in c.stdout
+
+
+def test_development_securedrop_env_var(File):
+    """
+    Ensure that the SECUREDROP_ENV var is set to "dev".
+
+
+    TODO: this isn't really checking that the env var is set,
+    just that it's declared in the bashrc. spec_helper ignores
+    env vars via ssh by default, so start there.
+    """
+
+    f = File('/home/vagrant/.bashrc')
+    assert f.is_file
+    assert f.user == 'vagrant'
+    assert f.contains('^export SECUREDROP_ENV=dev$')
diff --git a/testinfra/development/test_development_networking.py b/testinfra/development/test_development_networking.py
new file mode 100644
--- /dev/null
+++ b/testinfra/development/test_development_networking.py
@@ -0,0 +1,55 @@
+def test_development_iptables_rules(Command, Sudo):
+    """
+    Declare desired iptables rules
+    The 'development' machine doesn't have any custom
+    iptables rules, so just check for the default chains.
+    """
+    desired_iptables_rules = [
+      '-P INPUT ACCEPT',
+      '-P FORWARD ACCEPT',
+      '-P OUTPUT ACCEPT',
+    ]
+    with Sudo():
+        c = Command('iptables -S')
+    assert c.rc == 0
+    for rule in desired_iptables_rules:
+        assert rule in c.stdout
+
+    # If any iptables rules are ever added, this test will
+    # fail, so tests can be written for the new rules.
+    # Counting newlines in the output simply to avoid calling
+    # `iptables -S` again and piping to `wc -l`.
+    assert c.stdout.count("\n") == len(desired_iptables_rules) - 1
+
+
+def test_development_ssh_listening(Socket):
+    """
+    Check for ssh listening on all interfaces. In prod environment,
+    SSH will be listening only on localhost, i.e. SSH over ATHS.
+    """
+    s = Socket("tcp://0.0.0.0:22")
+    assert s.is_listening
+
+
+def test_development_redis_worker(Socket):
+    """
+    Ensure that Redis worker is listening on localhost.
+    This worker is used to handle incoming submissions.
+    """
+
+    s = Socket("tcp://127.0.0.1:6379")
+    assert s.is_listening
+
+# The Flask runners for the source and journalist interfaces
+# aren't configured to run by default, e.g. on boot. Nor
+# do the app tests cause them to be run. So, we shouldn't
+# really expected them to be running.
+## check for source interface flask port listening
+#describe port(8080) do
+#  it { should be_listening.on('0.0.0.0').with('tcp') }
+#end
+#
+## check for journalist interface flask port listening
+#describe port(8081) do
+#  it { should be_listening.on('0.0.0.0').with('tcp') }
+#end
diff --git a/testinfra/development/test_xvfb.py b/testinfra/development/test_xvfb.py
new file mode 100644
--- /dev/null
+++ b/testinfra/development/test_xvfb.py
@@ -0,0 +1,113 @@
+import pytest
+
+
+@pytest.mark.parametrize('dependency', [
+  'firefox',
+  'xvfb',
+])
+def test_xvfb_apt_dependencies(Package, dependency):
+    """
+    Ensure apt requirements for Xvfb are present.
+    """
+    assert Package(dependency).is_installed
+
+
+def test_xvfb_service_config(File, Sudo):
+    """
+    Ensure xvfb service configuration file is present.
+    Using Sudo context manager because the expected mode is 700.
+    Not sure it's really necessary to have this script by 700; 755
+    sounds sufficient.
+    """
+    with Sudo():
+        f = File("/etc/init.d/xvfb")
+    assert f.is_file
+    assert oct(f.mode) == "0700"
+    assert f.user == "root"
+    assert f.group == "root"
+    # Let's hardcode the entire init script and check for exact match.
+    # The pytest output will display a diff if anything is missing.
+    xvfb_init_content = """
+# This is the /etc/init.d/xvfb script. We use it to launch xvfb at boot in the
+# development environment so we can easily run the functional tests.
+
+XVFB=/usr/bin/Xvfb
+XVFBARGS=":1 -screen 0 1024x768x24 -ac +extension GLX +render -noreset"
+PIDFILE=/var/run/xvfb.pid
+case "$1" in
+  start)
+    echo -n "Starting virtual X frame buffer: Xvfb"
+    start-stop-daemon --start --quiet --pidfile $PIDFILE --make-pidfile --background --exec $XVFB -- $XVFBARGS
+    echo "."
+    ;;
+  stop)
+    echo -n "Stopping virtual X frame buffer: Xvfb"
+    start-stop-daemon --stop --quiet --pidfile $PIDFILE
+    echo "."
+    ;;
+  restart)
+    $0 stop
+    $0 start
+    ;;
+  *)
+        echo "Usage: /etc/init.d/xvfb {start|stop|restart}"
+        exit 1
+esac
+
+exit 0
+""".lstrip().rstrip()
+    with Sudo():
+        assert f.contains('^XVFB=/usr/bin/Xvfb$')
+        assert f.contains('^XVFBARGS=":1 -screen 0 1024x768x24 '
+                          '-ac +extension GLX +render -noreset"$')
+        assert f.content == xvfb_init_content
+
+
+def test_xvfb_service_enabled(Command, Sudo):
+    """
+    Ensure xvfb is configured to start on boot via update-rc.d.
+    The `-n` option to update-rc.d is dry-run.
+
+    Using Sudo context manager because the service file is mode 700.
+    Not sure it's really necessary to have this script by 700; 755
+    sounds sufficient.
+    """
+    with Sudo():
+        c = Command('update-rc.d -n xvfb defaults')
+    assert c.rc == 0
+    wanted_text = 'System start/stop links for /etc/init.d/xvfb already exist.'
+    assert wanted_text in c.stdout
+
+
+def test_xvfb_display_config(File):
+    """
+    Ensure DISPLAY environment variable is set on boot, for running
+    headless tests via Xvfb.
+    """
+    f = File('/etc/profile.d/xvfb_display.sh')
+    assert f.is_file
+    assert oct(f.mode) == "0444"
+    assert f.user == "root"
+    assert f.group == "root"
+    assert f.contains("export DISPLAY=:1\n")
+
+
+def test_xvfb_service_running(Process, Sudo):
+    """
+    Ensure that xvfb service is running.
+
+    We can't use the Service module because it expects a "status"
+    subcommand for the init script, and our custom version doesn't have
+    one. So let's make sure the process is running.
+    """
+    # Sudo isn't necessary to read out of /proc on development, but is
+    # required when running under Grsecurity, which app-staging does.
+    # So let's escalate privileges to ensure we can determine service state.
+    with Sudo():
+        p = Process.get(user="root", comm="Xvfb")
+        wanted_args = str('/usr/bin/Xvfb :1 -screen 0 1024x768x24 '
+                      '-ac +extension GLX +render -noreset')
+        assert p.args == wanted_args
+        # We only expect a single process, no children.
+        workers = Process.filter(ppid=p.pid)
+        assert len(workers) == 0
diff --git a/testinfra/mon/test_network.py b/testinfra/mon/test_network.py
new file mode 100644
--- /dev/null
+++ b/testinfra/mon/test_network.py
@@ -0,0 +1,69 @@
+import os
+import difflib
+import pytest
+from jinja2 import Template
+
+
+securedrop_test_vars = pytest.securedrop_test_vars
+
+
+def determine_app_ip(SystemInfo, Command):
+    """
+    Dumb logic to determine environment and lookup relevant app IP address
+    """
+    app_hostname = "app-prod"
+    hostname = SystemInfo.hostname
+    if "staging" in hostname:
+        app_hostname = "app-staging"
+    app_ip = Command.check_output("getent hosts "+app_hostname+" | awk '{ print $1 }'")
+    return app_ip
+
+
+def test_mon_iptables_rules(SystemInfo, Command, Sudo, Ansible):
+    app_ip = determine_app_ip(SystemInfo, Command)
+
+    # Build a dict of variables to pass to jinja for iptables comparison
+    kwargs = dict(
+        app_ip=app_ip,
+        default_interface = Ansible("setup")["ansible_facts"]["ansible_default_ipv4"]["interface"],
+        tor_user_id = Command.check_output("id -u debian-tor"),
+        ssh_group_gid = Command.check_output("getent group ssh | cut -d: -f3"),
+        postfix_user_id = Command.check_output("id -u postfix"),
+        dns_server = securedrop_test_vars.dns_server)
+
+    # Build iptables scrape cmd, purge comments + counters
+    iptables = "iptables-save | sed 's/ \[[0-9]*\:[0-9]*\]//g' | egrep -v '^#'"
+    iptables_file = "{}/iptables-{}.j2".format(
+                                      os.path.dirname(os.path.abspath(__file__)),
+                                      SystemInfo.hostname)
+
+    # template out a local iptables jinja file
+    jinja_iptables = Template(open(iptables_file,'r').read())
+    iptables_expected = jinja_iptables.render(**kwargs)
+
+    with Sudo():
+        # Actually run the iptables scrape command
+        iptables = Command.check_output(iptables)
+        # print diff comparison (only shows up in pytests if test fails or
+        # verbosity turned way up)
+        for iptablesdiff in difflib.context_diff(iptables_expected.split('\n'),
+                                             iptables.split('\n')):
+            print(iptablesdiff)
+        # Conduct the string comparison of the expected and actual iptables
+        # ruleset
+        assert iptables_expected == iptables
+
+
+@pytest.mark.parametrize('ossec_service', [
+    dict(host="0.0.0.0", proto="tcp", port=22),
+    dict(host="127.0.0.1", proto="tcp", port=25),
+    dict(host="0.0.0.0", proto="udp", port=1514),
+])
+def test_listening_ports(Socket, Sudo, ossec_service):
+    """
+    Ensure the OSSEC-related services are listening on the
+    expected sockets. Services to check include ossec, mail, and ssh.
+    """
+    socket = "{proto}://{host}:{port}".format(**ossec_service)
+    with Sudo():
+        assert Socket(socket).is_listening
diff --git a/testinfra/mon/test_ossec.py b/testinfra/mon/test_ossec.py
new file mode 100644
--- /dev/null
+++ b/testinfra/mon/test_ossec.py
@@ -0,0 +1,235 @@
+import re
+import pytest
+
+
+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
+
+
+@pytest.mark.parametrize('header', [
+    '/^X-Originating-IP:/    IGNORE',
+    '/^X-Mailer:/    IGNORE',
+    '/^Mime-Version:/        IGNORE',
+    '/^User-Agent:/  IGNORE',
+    '/^Received:/    IGNORE',
+])
+def test_postfix_headers(File, header):
+    """
+    Ensure postfix header filters are set correctly. Common mail headers
+    are stripped by default to avoid leaking metadata about the instance.
+    Message body is always encrypted prior to sending.
+    """
+    f = File("/etc/postfix/header_checks")
+    assert f.is_file
+    assert oct(f.mode) == "0644"
+    regex = '^{}$'.format(re.escape(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_ossec_connectivity(Command, Sudo):
+    """
+    Ensure ossec-server machine has active connection to the ossec-agent.
+    The ossec service will report all available agents, and we can inspect
+    that list to make sure it's the host we expect.
+    """
+    desired_output = "{}-{} is available.".format(securedrop_test_vars.app_hostname,
+            securedrop_test_vars.app_ip)
+    with Sudo():
+        c = Command("/var/ossec/bin/list_agents -a")
+        assert c.stdout == desired_output
+        assert c.rc == 0
+
+def test_ossec_gnupg(File, Sudo):
+    """ ensure ossec gpg homedir exists """
+    with Sudo():
+        f = File(OSSEC_GNUPG)
+        assert f.is_directory
+        assert f.user == "ossec"
+        assert oct(f.mode) == "0700"
+
+
+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   4096R/EDDDC102 2014-10-15
+uid                  Test/Development (DO NOT USE IN PRODUCTION) (Admin's OSSEC Alert GPG key) <securedrop@freedom.press>
+sub   4096R/97D2EB39 2014-10-15"""
+    with Sudo("ossec"):
+        c = Command("gpg --homedir /var/ossec/.gnupg --list-keys EDDDC102")
+        assert c.stdout ==  ossec_gpg_pubkey_info
+
+
+@pytest.mark.parametrize('keyfile', [
+    '/var/ossec/etc/sslmanager.key',
+    '/var/ossec/etc/sslmanager.cert',
+])
+def test_ossec_keyfiles(File, Sudo, keyfile):
+    """
+    Ensure that the OSSEC transport key pair exists. These keys are used
+    to protect the connection between the ossec-server and ossec-agent.
+
+    All this check does in confirm they're present, it doesn't perform any
+    matching checks to validate the configuration.
+    """
+    with Sudo():
+        f = File(keyfile)
+        assert f.is_file
+        assert oct(f.mode) == "0644"
+        assert f.user == "root"
+
+
+@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))
+
+
+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) == "0644"
+
+
+def test_procmail_log(File, Sudo):
+    """
+    Ensure procmail log file exist with proper ownership.
+    Only the ossec user should have read/write permissions.
+    """
+    with Sudo():
+        f = File("/var/log/procmail.log")
+        assert f.is_file
+        assert f.user == "ossec"
+        assert f.group == "root"
+        assert oct(f.mode) == "0660"
+
+
+def test_ossec_authd(Command, Sudo):
+    """ Ensure that authd is not running """
+    with Sudo():
+        c = Command("pgrep ossec-authd")
+        assert c.stdout == ""
+        assert c.rc != 0
+
+def test_hosts_files(File, SystemInfo):
+    """ Ensure host files mapping are in place """
+    f = File('/etc/hosts')
+
+    hostname = SystemInfo.hostname
+    env = "prod"
+    app_ip = "10.0.1.4"
+    if "staging" in hostname:
+        env = "staging"
+        app_ip = "10.0.1.2"
+
+    assert f.contains('^127.0.0.1')
+    assert f.contains('^127.0.0.1\t*mon-{0}\t*mon-{0}$'.format(env))
+    assert f.contains('^{}\s*app-{}$'.format(app_ip, env))
+
+
+def test_ossec_log_contains_no_malformed_events(File, Sudo):
+    """
+    Ensure the OSSEC log reports no errors for incorrectly formatted
+    messages. These events indicate that the OSSEC server failed to decrypt
+    the event sent by the OSSEC agent, which implies a misconfiguration,
+    likely the IPv4 address or keypair differing from what's declared.
+
+    Documentation regarding this error message can be found at:
+    http://ossec-docs.readthedocs.io/en/latest/faq/unexpected.html#id4
+    """
+    with Sudo():
+        f = File("/var/ossec/logs/ossec.log")
+        assert not f.contains("ERROR: Incorrectly formated message from")
+
+
+def test_regression_hosts(Command):
+    """ Regression test to check for duplicate entries. """
+    assert Command.check_output("uniq --repeated /etc/hosts") == ""
diff --git a/testinfra/test.py b/testinfra/test.py
new file mode 100755
--- /dev/null
+++ b/testinfra/test.py
@@ -0,0 +1,125 @@
+#!/usr/bin/env python
+"""
+Wrapper script for running Testinfra against SecureDrop VMs.
+Accepts a single argument: the hostname to run the tests against.
+Script will handle building the list of tests to run, based on hostname.
+"""
+import os
+import subprocess
+import sys
+import tempfile
+
+# By default let's assume we're testing against the development VM.
+try:
+    target_host = sys.argv[1]
+except IndexError:
+    target_host = "development"
+
+# Set env var so that `testinfra/conftest.py` can read in a YAML vars file
+# specific to the host being tested.
+os.environ['SECUREDROP_TESTINFRA_TARGET_HOST'] = target_host
+
+
+def get_target_roles(target_host):
+    """
+    Assemble list of role tests to run. Hard-coded per host.
+    """
+    if target_host == "development":
+        target_roles = [
+                'testinfra/app-code',
+                'testinfra/development',
+                ]
+
+    elif target_host == "app-staging":
+        target_roles = [
+                'testinfra/app',
+                'testinfra/app-code',
+                'testinfra/common',
+                'testinfra/development/test_xvfb.py',
+                ]
+
+    elif target_host == "mon-staging":
+        target_roles = [
+                'testinfra/mon',
+                'testinfra/common',
+                ]
+
+    elif target_host == "mon-prod":
+        target_roles = [
+                'testinfra/mon',
+                ]
+
+    elif target_host == "build":
+        target_roles = [
+                'testinfra/build',
+                ]
+    else:
+        print("Unknown host '{}'! Exiting.".format(target_host))
+        sys.exit(1)
+
+    return target_roles
+
+
+def run_testinfra(target_host, verbose=True):
+    """
+    Handler for executing testinfra against `target_host`.
+    Queries list of roles via helper def `get_target_roles`.
+    """
+    target_roles = get_target_roles(target_host)
+    if verbose:
+        # Print informative output prior to test run.
+        print("Running Testinfra suite against '{}'...".format(target_host))
+        print("Target roles:")
+        for role in target_roles:
+            print("    - {}".format(role))
+
+    # Prod hosts host have SSH access over Tor. Let's use the SSH backend
+    # for Testinfra, rather than Ansible. When we write a dynamic inventory
+    # script for Ansible SSH-over-Tor, we can use the Ansible backend
+    # everywhere.
+    if target_host.endswith("-prod"):
+        os.environ['SECUREDROP_SSH_OVER_TOR'] = '1'
+        # Dump SSH config to tempfile so it can be passed as arg to testinfra.
+        ssh_config_output = subprocess.check_output(["vagrant", "ssh-config", target_host])
+        # Create temporary file to store ssh-config. Not deleting it automatically
+        # because there's no sensitive info (HidServAuth is required to connect),
+        # and we'll need it outside of the context-manager block that writes to it.
+        ssh_config_tmpfile = tempfile.NamedTemporaryFile(delete=False)
+        with ssh_config_tmpfile.file as f:
+            f.write(ssh_config_output)
+        ssh_config_path = ssh_config_tmpfile.name
+        testinfra_command_template = """
+testinfra \
+    -vv \
+    -n auto \
+    --connection ssh \
+    --ssh-config \
+    {ssh_config_path}\
+    --hosts {target_host} \
+    {target_roles}
+""".lstrip().rstrip()
+
+    else:
+        ssh_config_path = ""
+        testinfra_command_template = """
+testinfra \
+    -vv \
+    -n auto \
+    --connection ansible \
+    --ansible-inventory \
+    .vagrant/provisioners/ansible/inventory/vagrant_ansible_inventory \
+    --hosts {target_host} \
+    {target_roles}
+""".lstrip().rstrip()
+
+    testinfra_command = testinfra_command_template.format(
+            target_host=target_host,
+            ssh_config_path=ssh_config_path,
+            target_roles=" ".join(target_roles),
+            ).split()
+
+    # Execute config tests.
+    subprocess.check_call(testinfra_command)
+
+if __name__ == "__main__":
+    run_testinfra(target_host)
diff --git a/spec_tests/spec/vars/development.yml b/testinfra/vars/development.yml
similarity index 100%
rename from spec_tests/spec/vars/development.yml
rename to testinfra/vars/development.yml