Repository: freedomofpress/securedrop

Issue:
SI issuing a 500 error and then timing out on `develop`
## Description
The SI  is failing to start from the `develop` branch, first failing with a 500 error and the log messages below (with logs enabled, and then just timing out:
```
[Fri Feb 03 21:37:34.589966 2023] [wsgi:info] [pid 1330:tid 118957187065600] [remote 127.0.0.1:51894] mod_wsgi (pid=1330, process='source', application='localhost|'): Loading Python script file '/var/www/source.wsgi'.
[Fri Feb 03 21:37:34.591940 2023] [wsgi:error] [pid 1330:tid 118957187065600] [remote 127.0.0.1:51894] mod_wsgi (pid=1330): Failed to exec Python script file '/var/www/source.wsgi'.
[Fri Feb 03 21:37:34.592605 2023] [wsgi:error] [pid 1330:tid 118957187065600] [remote 127.0.0.1:51894] mod_wsgi (pid=1330): Exception occurred processing WSGI script '/var/www/source.wsgi'.
[Fri Feb 03 21:37:34.593310 2023] [wsgi:error] [pid 1330:tid 118957187065600] [remote 127.0.0.1:51894] Traceback (most recent call last):
[Fri Feb 03 21:37:34.593658 2023] [wsgi:error] [pid 1330:tid 118957187065600] [remote 127.0.0.1:51894]   File "/var/www/source.wsgi", line 11, in <module>
[Fri Feb 03 21:37:34.593894 2023] [wsgi:error] [pid 1330:tid 118957187065600] [remote 127.0.0.1:51894]     from source import app as application
[Fri Feb 03 21:37:34.594012 2023] [wsgi:error] [pid 1330:tid 118957187065600] [remote 127.0.0.1:51894] ImportError: cannot import name 'app' from 'source' (/var/www/securedrop/source.py)
```

## Steps to Reproduce

- set up a prod VM instance on `.2.5.1` 
- build packages from `develop` with `make build-debs`
- step through the upgrade scenario to apply said debs

## Expected Behavior
- system is upgraded cleanly and SI and JI functionality is available

## Actual Behavior
- JI is available, SI errors out with an initial 500 error as described.

## Comments

This looks to have been introduced in https://github.com/freedomofpress/securedrop/pull/6563, specifically in the change in https://github.com/freedomofpress/securedrop/blob/c9e2a7a61c486e38e4fcf06b6196b59a115ce152/securedrop/source.py#L6 . Since `app` is now defined only within the `__name__ == 'main'` block, it isn't defined when run via WSGI (as opposed to directly within the dev env).



Discussion:

Resolving patch:
diff --git a/securedrop/journalist.py b/securedrop/journalist.py
--- a/securedrop/journalist.py
+++ b/securedrop/journalist.py
@@ -5,6 +5,7 @@
 from sdconfig import SecureDropConfig
 
 config = SecureDropConfig.get_current()
+# app is imported by journalist.wsgi
 app = create_app(config)
 
 
diff --git a/securedrop/source.py b/securedrop/source.py
--- a/securedrop/source.py
+++ b/securedrop/source.py
@@ -1,9 +1,11 @@
 from sdconfig import SecureDropConfig
 from source_app import create_app
 
+config = SecureDropConfig.get_current()
+# app is imported by source.wsgi
+app = create_app(config)
+
 if __name__ == "__main__":  # pragma: no cover
-    config = SecureDropConfig.get_current()
-    app = create_app(config)
     debug = getattr(config, "env", "prod") != "prod"
     # nosemgrep: python.flask.security.audit.app-run-param-config.avoid_app_run_with_bad_host
     app.run(debug=debug, host="0.0.0.0", port=8080)  # nosec
diff --git a/securedrop/source_app/__init__.py b/securedrop/source_app/__init__.py
--- a/securedrop/source_app/__init__.py
+++ b/securedrop/source_app/__init__.py
@@ -118,12 +118,15 @@ def internal_error(error: werkzeug.exceptions.HTTPException) -> Tuple[str, int]:
     # Obscure the creation time of source private keys by touching them all
     # on startup.
     private_keys = config.GPG_KEY_DIR / "private-keys-v1.d"
-    now = time.time()
-    for entry in os.scandir(private_keys):
-        if not entry.is_file() or not entry.name.endswith(".key"):
-            continue
-        os.utime(entry.path, times=(now, now))
-        # So the ctime is also updated
-        os.chmod(entry.path, entry.stat().st_mode)
+    # The folder may not exist yet in some dev/testing setups,
+    # and if it doesn't exist there's no mtime to obscure.
+    if private_keys.is_dir():
+        now = time.time()
+        for entry in os.scandir(private_keys):
+            if not entry.is_file() or not entry.name.endswith(".key"):
+                continue
+            os.utime(entry.path, times=(now, now))
+            # So the ctime is also updated
+            os.chmod(entry.path, entry.stat().st_mode)
 
     return app


Test patch:
diff --git a/molecule/testinfra/app/test_appenv.py b/molecule/testinfra/app/test_appenv.py
--- a/molecule/testinfra/app/test_appenv.py
+++ b/molecule/testinfra/app/test_appenv.py
@@ -21,7 +21,7 @@ def test_app_wsgi(host):
     f = host.file("/var/www/source.wsgi")
     with host.sudo():
         assert f.is_file
-        assert f.mode == 0o640
+        assert f.mode == 0o644
         assert f.user == "root"
         assert f.group == "root"
         assert f.contains("^import logging$")
diff --git a/molecule/testinfra/app/test_smoke.py b/molecule/testinfra/app/test_smoke.py
new file mode 100644
--- /dev/null
+++ b/molecule/testinfra/app/test_smoke.py
@@ -0,0 +1,33 @@
+"""
+Basic smoke tests that verify the apps are functioning as expected
+"""
+import pytest
+import testutils
+
+sdvars = testutils.securedrop_test_vars
+testinfra_hosts = [sdvars.app_hostname]
+
+
+@pytest.mark.parametrize(
+    "name,url,curl_flags",
+    (
+        # We pass -L to follow the redirect from / to /login
+        ("journalist", "http://localhost:8080/", "L"),
+        ("source", "http://localhost:80/", ""),
+    ),
+)
+def test_interface_up(host, name, url, curl_flags):
+    """
+    Ensure the respective interface is up with HTTP 200 if not, we try our
+    best to grab the error log and print it via an intentionally failed
+    assertion.
+    """
+    response = host.run(f"curl -{curl_flags}i {url}").stdout
+    if "200 OK" not in response:
+        # Try to grab the log and print it via a failed assertion
+        with host.sudo():
+            f = host.file(f"/var/log/apache2/{name}-error.log")
+            if f.exists:
+                assert "nopenopenope" in f.content_string
+    assert "200 OK" in response
+    assert "Powered by" in response
diff --git a/securedrop/tests/test_wsgi.py b/securedrop/tests/test_wsgi.py
new file mode 100644
--- /dev/null
+++ b/securedrop/tests/test_wsgi.py
@@ -0,0 +1,22 @@
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+
+@pytest.mark.parametrize("filename", ("journalist.wsgi", "source.wsgi"))
+def test_wsgi(filename):
+    """
+    Verify that all setup code and imports in the wsgi files work
+
+    This is slightly hacky because it executes the wsgi files using
+    the current virtualenv, and we hack the paths so it works out.
+    """
+    sd_dir = Path(__file__).parent.parent
+    wsgi = sd_dir / "debian/app-code/var/www" / filename
+    python_path = os.getenv("PYTHONPATH", "")
+    subprocess.check_call(
+        [sys.executable, str(wsgi)], env={"PYTHONPATH": f"{python_path}:{sd_dir}"}
+    )