Repository: freedomofpress/securedrop

Issue:
Fix tor service permissions
Ensures correct mode for tor hidden service directories. Updates spectests to validate new file modes, and to test each service directory individually.

Confirmed that these playbook changes resolve the tor failure described in #1044. Playbooks finished without error on a fresh provision of staging VMs based on trusty64-14.04.2. Also spun up snapshots of a working VMs running the previous version of tor and ran the modified playbooks against them, again without errors.

File mode tasks use quotes to force stringification, which is relevant to #1006.

Resolves #1044.



Discussion:

Resolving patch:
diff --git a/securedrop/manage.py b/securedrop/manage.py
--- a/securedrop/manage.py
+++ b/securedrop/manage.py
@@ -22,13 +22,15 @@
 # We need to import config in each function because we're running the tests
 # directly, so it's important to set the environment correctly, depending on
 # development or testing, before importing config.
-#
-# TODO: do we need to store *_PIDFILE in the application config? It seems like
-# an implementation detail that is specifc to this management script.
-
 os.environ['SECUREDROP_ENV'] = 'dev'
 
-WORKER_PIDFILE = "/tmp/test_rqworker.pid"
+# TODO: the PID file for the redis worker is hard-coded below.
+# Ideally this constant would be provided by a test harness.
+# It has been intentionally omitted from `config.py.example`
+# in order to isolate the test vars from prod vars.
+# When refactoring the test suite, the TEST_WORKER_PIDFILE
+# TEST_WORKER_PIDFILE is also hard-coded in `tests/common.py`.
+TEST_WORKER_PIDFILE = "/tmp/securedrop_test_worker.pid"
 
 
 def get_pid_from_pidfile(pid_file_name):
@@ -40,7 +42,7 @@ def _start_test_rqworker(config):
     # needed to determine the directory to run the worker in
     worker_running = False
     try:
-        if psutil.pid_exists(get_pid_from_pidfile(WORKER_PIDFILE)):
+        if psutil.pid_exists(get_pid_from_pidfile(TEST_WORKER_PIDFILE)):
             worker_running = True
     except IOError:
         pass
@@ -51,14 +53,14 @@ def _start_test_rqworker(config):
             [
                 "rqworker", "test",
                 "-P", config.SECUREDROP_ROOT,
-                "--pid", WORKER_PIDFILE,
+                "--pid", TEST_WORKER_PIDFILE,
             ],
             stdout=tmp_logfile,
             stderr=subprocess.STDOUT)
 
 
 def _stop_test_rqworker():
-    os.kill(get_pid_from_pidfile(WORKER_PIDFILE), signal.SIGTERM)
+    os.kill(get_pid_from_pidfile(TEST_WORKER_PIDFILE), signal.SIGTERM)
 
 
 def test():


Test patch:
diff --git a/securedrop/tests/common.py b/securedrop/tests/common.py
--- a/securedrop/tests/common.py
+++ b/securedrop/tests/common.py
@@ -9,6 +9,14 @@
 from db import init_db, db_session, Source, Submission
 import crypto_util
 
+# TODO: the PID file for the redis worker is hard-coded below.
+# Ideally this constant would be provided by a test harness.
+# It has been intentionally omitted from `config.py.example`
+# in order to isolate the test vars from prod vars.
+# When refactoring the test suite, the TEST_WORKER_PIDFILE
+# TEST_WORKER_PIDFILE is also hard-coded in `manage.py`.
+TEST_WORKER_PIDFILE = "/tmp/securedrop_test_worker.pid"
+
 
 def clean_root():
     shutil.rmtree(config.SECUREDROP_DATA_ROOT)
@@ -70,9 +78,9 @@ def shared_setup():
     crypto_util.do_runtime_tests()
 
     # Start the Python-RQ worker if it's not already running
-    if not os.path.exists(config.WORKER_PIDFILE):
+    if not os.path.exists(TEST_WORKER_PIDFILE):
         subprocess.Popen(["rqworker", "-P", config.SECUREDROP_ROOT,
-                                      "--pid", config.WORKER_PIDFILE])
+                                      "--pid", TEST_WORKER_PIDFILE])
 
 
 def shared_teardown():
diff --git a/spec_tests/Rakefile b/spec_tests/Rakefile
--- a/spec_tests/Rakefile
+++ b/spec_tests/Rakefile
@@ -3,6 +3,7 @@ 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
@@ -12,13 +13,22 @@ def roles(host)
   roles = []
   case host
   when /^development$/
-    roles << %w(common-development development)
+    (roles << %w(common-development development)).flatten!
   when /^build$/
-    roles << "build"
-  when /^app-staging$/
-    roles << %w(common-development common-staging app-staging)
-  when /^mon-staging$/
-    roles << %w(common-staging mon-staging)
+    (roles << "build").flatten!
+  when /^app-staging/
+    (roles << %w(common-development common-staging app-staging grsecurity)).flatten!
+  when /^mon-staging/
+    (roles << %w(common-staging mon-staging grsecurity)).flatten!
+  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
@@ -40,6 +50,10 @@ end
 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] }
+
   hosts.each do |host|
     desc "Run spectests against #{host[:name]}"
     RSpec::Core::RakeTask.new(host[:name].to_sym) do |t|
diff --git a/spec_tests/spec/app-staging/apache_spec.rb b/spec_tests/spec/app-staging/apache_spec.rb
--- a/spec_tests/spec/app-staging/apache_spec.rb
+++ b/spec_tests/spec/app-staging/apache_spec.rb
@@ -105,7 +105,7 @@
   </LimitExcept>
 </Directory>
 
-<Directory #{TEST_VARS['securedrop_code']}>
+<Directory #{property['securedrop_code']}>
   Options None
   AllowOverride None
   <Limit GET POST HEAD>
@@ -140,9 +140,9 @@
 # declare source-specific apache configs
 source_apache2_config_settings = [
   '<VirtualHost 0.0.0.0:80>',
-  "DocumentRoot #{TEST_VARS['securedrop_code']}/static",
-  "Alias /static #{TEST_VARS['securedrop_code']}/static",
-  "WSGIDaemonProcess source  processes=2 threads=30 display-name=%{GROUP} python-path=#{TEST_VARS['securedrop_code']}",
+  "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',
@@ -170,9 +170,9 @@
 # declare document-specific apache configs
 document_apache2_config_settings = [
   '<VirtualHost 0.0.0.0:8080>',
-  "DocumentRoot #{TEST_VARS['securedrop_code']}/static",
-  "Alias /static #{TEST_VARS['securedrop_code']}/static",
-  "WSGIDaemonProcess document  processes=2 threads=30 display-name=%{GROUP} python-path=#{TEST_VARS['securedrop_code']}",
+  "DocumentRoot #{property['securedrop_code']}/static",
+  "Alias /static #{property['securedrop_code']}/static",
+  "WSGIDaemonProcess document  processes=2 threads=30 display-name=%{GROUP} python-path=#{property['securedrop_code']}",
   'WSGIProcessGroup document',
   'WSGIScriptAlias / /var/www/document.wsgi/',
   'AddType text/html .py',
diff --git a/spec_tests/spec/app-staging/apparmor_spec.rb b/spec_tests/spec/app-staging/apparmor_spec.rb
--- a/spec_tests/spec/app-staging/apparmor_spec.rb
+++ b/spec_tests/spec/app-staging/apparmor_spec.rb
@@ -25,6 +25,35 @@
   end
 end
 
+# declare expected app-armor capabilities for apache2
+apache2_capabilities = %w(
+  dac_override
+  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
@@ -44,36 +73,60 @@
   end
 end
 
-# aa-status does not permit explicit state checking
-# of services, so this is an ugly hack that can easily
-# report false positives. It checks the number of profiles
-# in a given state, but doesn't check which ones. Argh!
-# TODO: Consider writing a nasty perl one-liner to filter
-# the output and ensure the services are filed correctly.
-describe command("aa-status --complaining") do
-  its(:stdout) { should eq "2\n" }
+# 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 "8\n" }
+  its(:stdout) { should eq enforced_apparmor_profiles.length.to_s + "\n" }
 end
 
-describe command("aa-status --profiled") do
-  its(:stdout) { should eq "10\n" }
+# 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
 
-describe command("aa-status --profiled") do
-  its(:stdout) { should eq "10\n" }
+# 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
 
-# Check that the expected profiles are present in the aa-status command.
-#[ 'apache', 'tor', 'ntp'].each do |enforced_profile|
-#  describe command("aa-status") do
-#    it { should return_stdout /#{enforced_profile}/ }
-#  end
-#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 match /0 processes are unconfined but have a profile defined/ }
+  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
--- a/spec_tests/spec/app-staging/iptables_spec.rb
+++ b/spec_tests/spec/app-staging/iptables_spec.rb
@@ -14,7 +14,7 @@
   '-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 10.0.1.3/32 -p udp -m udp --sport 1514 -m state --state RELATED,ESTABLISHED -m comment --comment "OSSEC server agent" -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 incomming traffic" -j LOGNDROP',
@@ -22,20 +22,20 @@
   '-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',
-  '-A OUTPUT -p tcp -m owner --uid-owner 109 -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 109 -m state --state NEW -m limit --limit 3/min --limit-burst 3 -m comment --comment "SSH with rate limiting only thru tor" -j ACCEPT',
-  '-A OUTPUT -o lo -p tcp -m tcp --dport 22 -m owner --uid-owner 109 -m state --state RELATED,ESTABLISHED -m comment --comment "SSH with rate limiting only thru tor" -j ACCEPT',
-  '-A OUTPUT -m owner --uid-owner 109 -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 33 -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 33 -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 33 -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 33 -m comment --comment "Drop all other traffic by the securedrop user" -j LOGNDROP',
-  '-A OUTPUT -m owner --gid-owner 108 -m comment --comment "Drop all other outbound traffic for ssh user" -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 \"SSH with rate limiting only thru tor\" -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 \"SSH with rate limiting only thru tor\" -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 10.0.1.3/32 -p udp -m udp --dport 1514 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment "OSSEC server agent" -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',
@@ -52,8 +52,8 @@
 # from the ansible inventory should cover most use cases (except inventories
 # with just the *.onion addresses).
 unwanted_iptables_rules = [
-  '-A OUTPUT -d 10.0.1.3 -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 10.0.1.3 -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['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.
diff --git a/spec_tests/spec/app-staging/ossec_agent_spec.rb b/spec_tests/spec/app-staging/ossec_agent_spec.rb
--- a/spec_tests/spec/app-staging/ossec_agent_spec.rb
+++ b/spec_tests/spec/app-staging/ossec_agent_spec.rb
@@ -1,10 +1,11 @@
 # ensure hosts file references mon server by ip
 # TODO: replace hardcoded ip for mon-staging host
 describe file('/etc/hosts') do
-  its(:content) { should match /^127\.0\.1\.1 app-staging app-staging$/ }
+  its(:content) { should match /^127\.0\.1\.1\s+app-staging\s+app-staging$/ }
   # TODO: the "securedrop-monitor-server-alias" is an artifact of
   # using the vagrant-hostmanager plugin. it may no longer be necessary
-  its(:content) { should match /^10\.0\.1\.3  mon-staging securedrop-monitor-server-alias$/ }
+  mon_host_regex = Regexp.quote("#{property['monitor_ip']}  mon-staging securedrop-monitor-server-alias")
+  its(:content) { should match /^#{mon_host_regex}$/ }
 end
 
 # ensure custom ossec-agent package is installed
@@ -18,6 +19,7 @@
   it { should be_mode '644' }
   it { should be_owned_by 'root' }
   it { should be_grouped_into 'ossec' }
+  app_ip_regex = Regexp.quote("#{property['app_ip']}")
   # this regex checks for a hex string of 64 chars, not a specific value
-  its(:content) { should match /^1024 app-staging 10\.0\.1\.2 [0-9a-f]{64}$/ }
+  its(:content) { should match /^1024 app-staging #{app_ip_regex} [0-9a-f]{64}$/ }
 end
diff --git a/spec_tests/spec/app-staging/securedrop_app_spec.rb b/spec_tests/spec/app-staging/securedrop_app_spec.rb
--- a/spec_tests/spec/app-staging/securedrop_app_spec.rb
+++ b/spec_tests/spec/app-staging/securedrop_app_spec.rb
@@ -1,18 +1,18 @@
 # declare securedrop app directories
 securedrop_app_directories = [
-  TEST_VARS['securedrop_code'],
-  TEST_VARS['securedrop_data'],
-  "#{TEST_VARS['securedrop_data']}/store",
-  "#{TEST_VARS['securedrop_data']}/keys",
-  "#{TEST_VARS['securedrop_data']}/tmp",
+  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 TEST_VARS['securedrop_user'] }
-    it { should be_grouped_into TEST_VARS['securedrop_user'] }
+    it { should be_owned_by property['securedrop_user'] }
+    it { should be_grouped_into property['securedrop_user'] }
     it { should be_mode '700' }
   end
 end
@@ -36,17 +36,17 @@
 
 # ensure default logo header file exists
 # TODO: add check for custom logo header file
-describe file("#{TEST_VARS['securedrop_code']}/static/i/logo.png") do
+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 TEST_VARS['securedrop_user'] }
-  it { should be_grouped_into TEST_VARS['securedrop_user'] }
+  it { should be_owned_by property['securedrop_user'] }
+  it { should be_grouped_into property['securedrop_user'] }
 end
 
 # ensure cronjob for securedrop tmp dir cleanup is enabled
 describe cron do
-  it { should have_entry "@daily #{TEST_VARS['securedrop_code']}/manage.py clean_tmp" }
+  it { should have_entry "@daily #{property['securedrop_code']}/manage.py clean_tmp" }
 end
 
 # ensure directory for worker logs is present
diff --git a/spec_tests/spec/app-staging/securedrop_app_test_spec.rb b/spec_tests/spec/app-staging/securedrop_app_test_spec.rb
--- a/spec_tests/spec/app-staging/securedrop_app_test_spec.rb
+++ b/spec_tests/spec/app-staging/securedrop_app_test_spec.rb
@@ -9,7 +9,7 @@
   'Flask-Testing==0.4.2',
   'mock==1.0.1',
   'pytest==2.6.4',
-  'selenium==2.44.0',
+  'selenium==2.45.0',
 ]
 # ensure pip depdendencies are installed in staging.
 # these are required for running unit and functional tests
diff --git a/spec_tests/spec/app-staging/tor_spec.rb b/spec_tests/spec/app-staging/tor_spec.rb
--- a/spec_tests/spec/app-staging/tor_spec.rb
+++ b/spec_tests/spec/app-staging/tor_spec.rb
@@ -17,3 +17,20 @@
   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/document
+  /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/common-development/securedrop_app_spec.rb b/spec_tests/spec/common-development/securedrop_app_spec.rb
--- a/spec_tests/spec/common-development/securedrop_app_spec.rb
+++ b/spec_tests/spec/common-development/securedrop_app_spec.rb
@@ -42,7 +42,7 @@
 end
 
 # ensure the securedrop application gpg pubkey is present
-describe file("#{TEST_VARS['securedrop_data']}/test_journalist_key.pub") do
+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' }
@@ -50,34 +50,34 @@
 end
 
 # ensure config.py (settings for securedrop app) exists
-describe file("#{TEST_VARS['securedrop_code']}/config.py") do
+describe file("#{property['securedrop_code']}/config.py") do
   it { should be_file }
-  it { should be_owned_by TEST_VARS['securedrop_user'] }
-  it { should be_grouped_into TEST_VARS['securedrop_user'] }
+  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("#{TEST_VARS['securedrop_data']}/db.sqlite") do
+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 TEST_VARS['securedrop_user'] }
-  it { should be_grouped_into TEST_VARS['securedrop_user'] }
+  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=#{TEST_VARS['securedrop_code']}",
+  "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=#{TEST_VARS['securedrop_user']}",
+  "user=#{property['securedrop_user']}",
   'environment=HOME="/tmp/python-gnupg"',
 ]
 # ensure securedrop worker config for supervisor is present
diff --git a/spec_tests/spec/common-staging/cron_apt_spec.rb b/spec_tests/spec/common-staging/cron_apt_spec.rb
--- a/spec_tests/spec/common-staging/cron_apt_spec.rb
+++ b/spec_tests/spec/common-staging/cron_apt_spec.rb
@@ -1,5 +1,5 @@
 # Check for critical packages
-['cron-apt', 'ntp', 'paxctl'].each do |pkg|
+['cron-apt', 'ntp'].each do |pkg|
   describe package(pkg) do
     it { should be_installed }
   end
@@ -75,7 +75,7 @@
 end
 
 # ensure safe-upgrade has already been run
-describe command('aptitude --simulate safe-upgrade') do
+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\./ }
diff --git a/spec_tests/spec/common-staging/tor_spec.rb b/spec_tests/spec/common-staging/tor_spec.rb
--- a/spec_tests/spec/common-staging/tor_spec.rb
+++ b/spec_tests/spec/common-staging/tor_spec.rb
@@ -32,12 +32,19 @@
   end
 end
 
-# ensure parent directory for tor hidden services exists
-describe file('/var/lib/tor/services') do
-  it { should be_directory  }
-  it { should be_mode('2755')  }
-  it { should be_owned_by 'debian-tor' }
-  it { should be_grouped_into 'debian-tor' }
+# 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
diff --git a/spec_tests/spec/development/securedrop_app_dev_spec.rb b/spec_tests/spec/development/securedrop_app_dev_spec.rb
--- a/spec_tests/spec/development/securedrop_app_dev_spec.rb
+++ b/spec_tests/spec/development/securedrop_app_dev_spec.rb
@@ -18,46 +18,58 @@
 end
 
 securedrop_app_directories = [
-  TEST_VARS['securedrop_data'],
-  "#{TEST_VARS['securedrop_data']}/keys",
-  "#{TEST_VARS['securedrop_data']}/tmp",
-  "#{TEST_VARS['securedrop_data']}/store",
+  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 TEST_VARS['securedrop_user'] }
-    it { should be_grouped_into TEST_VARS['securedrop_user'] }
+    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(TEST_VARS['securedrop_code']) do
+describe file(property['securedrop_code']) do
   it { should be_directory }
-  it { should be_owned_by TEST_VARS['securedrop_user'] }
-  it { should be_grouped_into TEST_VARS['securedrop_user'] }
-  it { should be_mode '770' }
+  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 cronjob for securedrop tmp dir cleanup is enabled
 describe cron do
-  # TODO: this should be using TEST_VARS, but the ansible role
+  # 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 #{TEST_VARS['securedrop_code']}/manage.py clean_tmp" }
+  # it { should have_entry "@daily #{property['securedrop_code']}/manage.py clean_tmp" }
   it { should have_entry "@daily /var/www/securedrop/manage.py clean_tmp" }
 end
 
 
 # ensure default logo header file exists
 # TODO: add check for custom logo header file
-describe file("#{TEST_VARS['securedrop_code']}/static/i/logo.png") do
+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
   # TODO: 644 on app-staging, 664 in development
   it { should be_mode '664' }
-  it { should be_owned_by TEST_VARS['securedrop_user'] }
-  it { should be_grouped_into TEST_VARS['securedrop_user'] }
+  it { should be_owned_by property['securedrop_user'] }
+  it { should be_grouped_into property['securedrop_user'] }
 end
diff --git a/spec_tests/spec/common-staging/grsec_spec.rb b/spec_tests/spec/grsecurity/grsec_spec.rb
similarity index 72%
rename from spec_tests/spec/common-staging/grsec_spec.rb
rename to spec_tests/spec/grsecurity/grsec_spec.rb
--- a/spec_tests/spec/common-staging/grsec_spec.rb
+++ b/spec_tests/spec/grsecurity/grsec_spec.rb
@@ -46,9 +46,34 @@
 # 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
-describe command("paxtest blackhat") do
-  its(:stdout) { should_not match /vulnerable/ }
-end
+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
diff --git a/spec_tests/spec/mon-staging/iptables_spec.rb b/spec_tests/spec/mon-staging/iptables_spec.rb
--- a/spec_tests/spec/mon-staging/iptables_spec.rb
+++ b/spec_tests/spec/mon-staging/iptables_spec.rb
@@ -13,26 +13,26 @@
   '-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 "Log and drop all other incomming traffic" -j LOGNDROP',
-  '-A INPUT -s 10.0.1.2/32 -p udp -m udp --dport 1514 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment "Allow OSSEC agent to monitor" -j ACCEPT',
-  '-A OUTPUT -d 10.0.1.2/32 -p udp -m udp --sport 1514 -m state --state RELATED,ESTABLISHED -m comment --comment "Allow OSSEC agent to monitor" -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',
-  '-A OUTPUT -p tcp -m owner --uid-owner 109 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment "Allow Tor out" -j ACCEPT',
-  '-A OUTPUT -o lo -p tcp -m tcp --dport 22 -m owner --uid-owner 109 -m state --state NEW -m limit --limit 3/min --limit-burst 3 -m comment --comment "SSH with rate limiting only thur tor" -j ACCEPT',
-  '-A OUTPUT -o lo -p tcp -m tcp --dport 22 -m owner --uid-owner 109 -m state --state RELATED,ESTABLISHED -m comment --comment "SSH with rate limiting only thur tor" -j ACCEPT',
-  '-A OUTPUT -m owner --uid-owner 109 -m comment --comment "Drop all other traffic for the tor instance used for ssh" -j LOGNDROP',
-  '-A OUTPUT -m owner --gid-owner 108 -m comment --comment "Drop all other outbound traffic for ssh user" -j LOGNDROP',
+  "-A OUTPUT -p tcp -m owner --uid-owner #{property['tor_user_uid']} -m state --state NEW,RELATED,ESTABLISHED -m comment --comment \"Allow Tor out\" -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 \"SSH with rate limiting only thur tor\" -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 \"SSH with rate limiting only thur tor\" -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 8.8.8.8/32 -p tcp -m tcp --dport 53 -m owner --uid-owner 110 -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 110 -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 110 -m state --state NEW,RELATED,ESTABLISHED -m comment --comment "Allow ossec email alerts out" -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',
@@ -49,10 +49,10 @@
 # from the ansible inventory should cover most use cases (except inventories
 # with just the *.onion addresses).
 unwanted_iptables_rules = [
-  '-A INPUT -s 10.0.1.2 -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 10.0.1.2 -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 10.0.1.2 -p tcp --dport 1515 -m state --state NEW,ESTABLISHED,RELATED -j ACCEPT',
-  '-A OUTPUT -d 10.01.2 -p tcp --sport 1515 -m state --state ESTABLISHED,RELATED -j ACCEPT',
+  "-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
diff --git a/spec_tests/spec/mon-staging/ossec_server_spec.rb b/spec_tests/spec/mon-staging/ossec_server_spec.rb
--- a/spec_tests/spec/mon-staging/ossec_server_spec.rb
+++ b/spec_tests/spec/mon-staging/ossec_server_spec.rb
@@ -1,8 +1,9 @@
 # ensure hosts file references app server by ip
 # TODO: replace hardcoded ip for app-staging host
 describe file('/etc/hosts') do
-  its(:content) { should match /^127\.0\.1\.1 mon-staging mon-staging$/ }
-  its(:content) { should match /^10\.0\.1\.2  app-staging$/ }
+  its(:content) { should match /^127\.0\.1\.1\s+mon-staging\s+mon-staging$/ }
+  app_host_regex = Regexp.quote("#{property['app_ip']}  app-staging")
+  its(:content) { should match /^#{app_host_regex}$/ }
 end
 
 # ensure required packages are installed
@@ -54,7 +55,7 @@
   'smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache',
   'smtp_tls_security_level = fingerprint',
   'smtp_tls_fingerprint_digest = sha1',
-  'smtp_tls_fingerprint_cert_match = 9C:0A:CC:93:1D:E7:51:37:90:61:6B:A1:18:28:67:95:54:C5:69:A8',
+  'smtp_tls_fingerprint_cert_match = D3:7C:82:FC:D0:5F:8F:D7:DA:A2:59:8C:42:D7:B2:9F:C1:9F:7E:60',
   'smtp_tls_ciphers = high',
   'smtp_tls_protocols = TLSv1.2 TLSv1.1 TLSv1 !SSLv3 !SSLv2',
   'myhostname = monitor.securedrop',
@@ -86,7 +87,7 @@
 
 # ensure ossec considers app-staging host "available"
 describe command('/var/ossec/bin/list_agents -a') do
-  its(:stdout) { should eq "app-staging-10.0.1.2 is available.\n" }
+  its(:stdout) { should eq "app-staging-#{property['app_ip']} is available.\n" }
 end
 
 # ensure ossec gpg homedir exists
diff --git a/spec_tests/spec/spec_helper.rb b/spec_tests/spec/spec_helper.rb
--- a/spec_tests/spec/spec_helper.rb
+++ b/spec_tests/spec/spec_helper.rb
@@ -18,41 +18,89 @@
 
 host = ENV['TARGET_HOST']
 
-`vagrant up #{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
 config = Tempfile.new('', Dir.tmpdir)
 config.write(`vagrant ssh-config #{host}`)
 config.close
-
 options = Net::SSH::Config.for(host, [config.path])
-
-options[:user] ||= Etc.getlogin
-
+options[:user]
 set :host,        options[:host_name] || host
 set :ssh_options, options
 
-# accept basename for sought vars file,
-# then return a hash based on those settings
-def retrieve_vars(file_basename)
-  fullpath = File.expand_path(File.join(File.dirname(__FILE__), 'vars', "#{file_basename}.yml"))
-  vars_file = YAML.load_file(fullpath)
-  return vars_file
-end
 
-# load custom vars for host
-case host
-when /^development$/
-  TEST_VARS = retrieve_vars('development')
-when /^app-staging$/
-  TEST_VARS = retrieve_vars('staging')
-end
+# retrieve dynamic vars for given hostname
+def retrieve_vars(hostname)
+  # accept basename for sought vars file,
+  # then return a hash based on 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
 
-# Disable sudo
-# set :disable_sudo, true
+  # crude case statement for determining var lookup
+  case hostname
+  when /^development$/
+    vars = read_vars_file('development')
+  when /-staging$/
+    # Both staging hosts need a similar list of vars.
+    vars = read_vars_file('staging')
+    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")
+    # 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 staging host.
+    vars['app_ip'] = retrieve_ip_addr('app-staging')
+    vars['monitor_ip'] = retrieve_ip_addr('mon-staging')
+    # These vars are host-specific, so check hostname before querying.
+    if hostname.match(/^app/)
+      vars['apache_user_uid'] = vagrant_ssh_cmd(hostname, "id -u www-data")
+    elsif hostname.match(/^mon/)
+      vars['postfix_user_uid'] = vagrant_ssh_cmd(hostname, "id -u postfix")
+    end
+  end
+  return vars
+end
 
+# ssh into vagrant machine, run command, return output
+def vagrant_ssh_cmd(hostname, command)
+  # Every ssh connection will end with a "Connection closed" message.
+  # Since dynamic variable fetching makes several ssh calls,
+  # let's filter to remove that noisy output from stderr.
+  filter_stderr = "2> >( grep -vP '^Connection to [\d.]+ closed\.' )"
+  # Unfortunately it appears that all of stderr is being filtered,
+  # not just the grep pattern. Perhaps the popen4 gem would facilitate
+  # smarter filtering, but that doesn't seem worthwhile right now.
+  vagrant_cmd = "vagrant ssh #{hostname} --command '#{command}'"
+  # Ruby backticks use /bin/sh as shell, and /bin/sh doesn't support
+  # process redirection, so force use of /bin/bash
+  return `/bin/bash -c "#{vagrant_cmd} #{filter_stderr}"`
+end
 
-# Set environment variables
-# set :env, :LANG => 'C', :LC_MESSAGES => 'C'
+# look up ip address for given hostname,
+# so spectests are relevant regardless of provider
+def retrieve_ip_addr(hostname)
+  ip_output = vagrant_ssh_cmd(hostname, "hostname -I")
+  # Vagrant VirtualBox images will always have eth0 as the NAT device,
+  # but spectests need the private_network device instead.
+  iface1, iface2 = ip_output.split()
+  # If we have two devices, assume first is NAT and return second.
+  # Otherwise, assume eth0 is the primary address.
+  return iface2 ? iface2 : iface1
+end
 
-# Set PATH
-# set :path, '/sbin:/usr/local/sbin:$PATH'
+# load dynamic vars for host
+set_property retrieve_vars(host)
diff --git a/spec_tests/spec/vars/development.yml b/spec_tests/spec/vars/development.yml
--- a/spec_tests/spec/vars/development.yml
+++ b/spec_tests/spec/vars/development.yml
@@ -1,7 +1,4 @@
 ---
-securedrop_repo: /vagrant
-non_default_securedrop_user: vagrant
 securedrop_user: vagrant
-ssh_users: vagrant
 securedrop_code: /vagrant/securedrop
 securedrop_data: /var/lib/securedrop
diff --git a/spec_tests/spec/vars/staging.yml b/spec_tests/spec/vars/staging.yml
--- a/spec_tests/spec/vars/staging.yml
+++ b/spec_tests/spec/vars/staging.yml
@@ -1,21 +1,6 @@
 ---
 ### General (used by more than one role) ###
-securedrop_repo: /vagrant
 securedrop_code: /var/www/securedrop
 securedrop_data: /var/lib/securedrop
 securedrop_user: www-data
-dns_server: 8.8.8.8
-ssh_users: vagrant
-
-monitor_ip: 10.0.1.3
-monitor_hostname: mon-staging
-app_ip: 10.0.1.2
-app_hostname: app-staging
-
-# To allow direct connections to source document interfaces for ease of testing
-apache_listening_address: 0.0.0.0
-apache_allow_from: all
-source_apache_log_location: /var/log/apache2/source-error.log
-apache_logging_level: info
-