Repository: freedomofpress/securedrop

Issue:
Apparmor denial for ntpd on Focal
## Description

`ntpd` throws grsec denial message.

## Steps to Reproduce

- [ ] Install focal on hardware (I hope the same will show up in vm too)
- [ ] check `/var/log/syslog`

## Expected Behavior

- no grsec error from ntpd

## Actual Behavior

```

Feb 17 03:43:33 app systemd[1]: Starting Network Time Service... Feb 17 03:43:33 app kernel: [  202.428911] audit: type=1400 audit(1613533413.416:46): apparmor="DENIED" operation="open" profile="/usr/sbin/ntpd" name="/snap/bin/" pid=3303 comm="ntpd" requested_mask="r" denied_mask="r" fsuid=0 ouid=0 Feb 17 03:43:33 app ntpd[3303]: ntpd 4.2.8p12@1.3728-o (1): Starting Feb 17 03:43:33 app ntpd[3303]: Command line: /usr/sbin/ntpd -p /var/run/ntpd.pid -g -u 112:117 Feb 17 03:43:33 app ntpd[3306]: proto: precision = 0.175 usec (-22)
--
```

## Comments

Suggestions to fix, any other relevant information.



Discussion:

Resolving patch:
diff --git a/molecule/testinfra/conftest.py b/molecule/testinfra/conftest.py
--- a/molecule/testinfra/conftest.py
+++ b/molecule/testinfra/conftest.py
@@ -9,8 +9,11 @@
 import io
 import os
 import yaml
+from typing import Any, Dict
+
 import testutils
 
+
 # The config tests target staging by default. It's possible to override
 # for e.g. prod, but the associated vars files are not yet ported.
 target_host = os.environ.get('SECUREDROP_TESTINFRA_TARGET_HOST', 'staging')
@@ -50,25 +53,30 @@ def securedrop_import_testinfra_vars(hostname, with_header=False):
     return hostvars
 
 
-def lookup_molecule_info():
-    """
-    Molecule automatically writes YAML files documenting dynamic host info
-    such as remote IPs. Read that file and pass back the config dict.
-    """
-    molecule_instance_config_path = os.path.abspath(
-            os.environ['MOLECULE_INSTANCE_CONFIG'])
-    with open(molecule_instance_config_path, 'r') as f:
-        molecule_instance_config = yaml.safe_load(f)
-    return molecule_instance_config
+class TestVars(dict):
+    managed_attrs = {}  # type: Dict[str, Any]
+
+    def __init__(self, initial: Dict[str, Any]) -> None:
+        self.securedrop_target_distribution = os.environ.get("SECUREDROP_TARGET_DISTRIBUTION")
+        self.managed_attrs.update(initial)
 
+    def __getattr__(self, name: str) -> Any:
+        """
+        If the requested attribute names a dict in managed_attrs and that
+        contains a key with the name of the target distribution,
+        e.g. "focal", return that. Otherwise return the entire item
+        under the requested name.
+        """
+        try:
+            attr = self.managed_attrs[name]
+            if isinstance(attr, dict) and self.securedrop_target_distribution in attr:
+                return attr[self.securedrop_target_distribution]
+            return attr
+        except KeyError:
+            raise AttributeError(name)
 
-class Myvalues:
-    def __init__(self):
-        pass
+    def __str__(self) -> str:
+        return str(self.managed_attrs)
 
 
-value = securedrop_import_testinfra_vars(target_host)
-res = Myvalues()
-for key, value in value.items():
-    setattr(res, key, value)
-testutils.securedrop_test_vars = res
+testutils.securedrop_test_vars = TestVars(securedrop_import_testinfra_vars(target_host))


Test patch:
diff --git a/molecule/testinfra/app/test_app_network.py b/molecule/testinfra/app/test_app_network.py
--- a/molecule/testinfra/app/test_app_network.py
+++ b/molecule/testinfra/app/test_app_network.py
@@ -16,12 +16,19 @@ def test_app_iptables_rules(host):
 
     local = host.get_host("local://")
 
+    time_service_user = (
+        host.check_output("id -u systemd-timesync")
+        if securedrop_test_vars.securedrop_target_distribution == "focal"
+        else 0
+    )
+
     # Build a dict of variables to pass to jinja for iptables comparison
     kwargs = dict(
         mon_ip=os.environ.get('MON_IP', securedrop_test_vars.mon_ip),
         default_interface=host.check_output("ip r | head -n 1 | "
                                             "awk '{ print $5 }'"),
         tor_user_id=host.check_output("id -u debian-tor"),
+        time_service_user=time_service_user,
         securedrop_user_id=host.check_output("id -u www-data"),
         ssh_group_gid=host.check_output("getent group ssh | cut -d: -f3"),
         dns_server=securedrop_test_vars.dns_server)
diff --git a/molecule/testinfra/common/test_automatic_updates.py b/molecule/testinfra/common/test_automatic_updates.py
--- a/molecule/testinfra/common/test_automatic_updates.py
+++ b/molecule/testinfra/common/test_automatic_updates.py
@@ -19,7 +19,7 @@ def test_automatic_updates_dependencies(host):
     """
     apt_dependencies = {
         'xenial': ['cron-apt', 'ntp'],
-        'focal': ['unattended-upgrades', 'ntp']
+        'focal': ['unattended-upgrades']
     }
 
     for package in apt_dependencies[host.system_info.codename]:
diff --git a/molecule/testinfra/common/test_basic_configuration.py b/molecule/testinfra/common/test_basic_configuration.py
new file mode 100644
--- /dev/null
+++ b/molecule/testinfra/common/test_basic_configuration.py
@@ -0,0 +1,39 @@
+from testinfra.host import Host
+
+import testutils
+
+
+test_vars = testutils.securedrop_test_vars
+testinfra_hosts = [test_vars.app_hostname, test_vars.monitor_hostname]
+
+
+def test_system_time(host: Host) -> None:
+    if host.system_info.codename == "xenial":
+        assert host.package("ntp").is_installed
+        assert host.package("ntpdate").is_installed
+
+        # TODO: The staging setup timing is too erratic for the
+        # following check. If we do want to reinstate it before
+        # dropping Xenial support, it should be done in a loop to give
+        # ntpd time to sync after the machines are created.
+
+        # c = host.run("ntpq -c rv")
+        # assert "leap_none" in c.stdout
+        # assert "sync_ntp" in c.stdout
+        # assert "refid" in c.stdout
+    else:
+        assert not host.package("ntp").is_installed
+        assert not host.package("ntpdate").is_installed
+
+        s = host.service("systemd-timesyncd")
+        assert s.is_running
+        assert s.is_enabled
+        assert not s.is_masked
+
+        # File will be touched on every successful synchronization,
+        # see 'man systemd-timesyncd'`
+        assert host.file("/run/systemd/timesync/synchronized").exists
+
+        c = host.run("timedatectl show")
+        assert "NTP=yes" in c.stdout
+        assert "NTPSynchronized=yes" in c.stdout
diff --git a/molecule/testinfra/mon/test_mon_network.py b/molecule/testinfra/mon/test_mon_network.py
--- a/molecule/testinfra/mon/test_mon_network.py
+++ b/molecule/testinfra/mon/test_mon_network.py
@@ -15,12 +15,19 @@ def test_mon_iptables_rules(host):
 
     local = host.get_host("local://")
 
+    time_service_user = (
+        host.check_output("id -u systemd-timesync")
+        if securedrop_test_vars.securedrop_target_distribution == "focal"
+        else 0
+    )
+
     # Build a dict of variables to pass to jinja for iptables comparison
     kwargs = dict(
         app_ip=os.environ.get('APP_IP', securedrop_test_vars.app_ip),
         default_interface=host.check_output(
             "ip r | head -n 1 | awk '{ print $5 }'"),
         tor_user_id=host.check_output("id -u debian-tor"),
+        time_service_user=time_service_user,
         ssh_group_gid=host.check_output("getent group ssh | cut -d: -f3"),
         postfix_user_id=host.check_output("id -u postfix"),
         dns_server=securedrop_test_vars.dns_server)