Repository: freedomofpress/securedrop

Issue:
Implement logic to guard against checkout of branch in the case of a duplicate branch and tag
## Description

We distribute code to workstations via checkout of a signed git tag. If there is a branch with the same as the tag, then the tag will verify, but when we actually `git checkout <tag name>` then the branch, not the tag, will be checked out. 

### Short term

As a guard, implement logic to verify there is no duplicate tag and branch prior to checkout. 

### Long term

We should stop distributing code in this way and implement https://github.com/freedomofpress/securedrop/issues/3502



Discussion:
@eloquence had a very good suggestion here (idea shared privately):

> I'm curious if `git show-ref --heads --verify 0.x.0` may be an alternative to parsing stdout. It returns error code 0 if a branch with this name exists, and non-zero otherwise.

This is a good solution

Resolving patch:
diff --git a/admin/securedrop_admin/__init__.py b/admin/securedrop_admin/__init__.py
--- a/admin/securedrop_admin/__init__.py
+++ b/admin/securedrop_admin/__init__.py
@@ -686,7 +686,23 @@ def update(args):
         if RELEASE_KEY in gpg_lines[1] and \
                 sig_result.count(good_sig_text) == 1 and \
                 bad_sig_text not in sig_result:
-            sdlog.info("Signature verification successful.")
+            # Finally, we check that there is no branch of the same name
+            # prior to reporting success.
+            cmd = ['git', 'show-ref', '--heads', '--verify',
+                   'refs/heads/{}'.format(latest_tag)]
+            try:
+                # We expect this to produce a non-zero exit code, which
+                # will produce a subprocess.CalledProcessError
+                subprocess.check_output(cmd, stderr=subprocess.STDOUT)
+                sdlog.info("Signature verification failed.")
+                return 1
+            except subprocess.CalledProcessError, e:
+                if 'not a valid ref' in e.output:
+                    # Then there is no duplicate branch.
+                    sdlog.info("Signature verification successful.")
+                else:  # If any other exception occurs, we bail.
+                    sdlog.info("Signature verification failed.")
+                    return 1
         else:  # If anything else happens, fail and exit 1
             sdlog.info("Signature verification failed.")
             return 1


Test patch:
diff --git a/admin/tests/test_integration.py b/admin/tests/test_integration.py
--- a/admin/tests/test_integration.py
+++ b/admin/tests/test_integration.py
@@ -538,7 +538,10 @@ def test_update(self, securedrop_git_repo):
                                     'securedrop/install_files/ansible-base')
         child = pexpect.spawn('coverage run {0} --root {1} update'.format(
                               cmd, ansible_base))
-        child.expect('Updated to SecureDrop', timeout=100)
+
+        output = child.read()
+        assert 'Updated to SecureDrop' in output
+        assert 'Signature verification successful' in output
 
         child.expect(pexpect.EOF, timeout=10)  # Wait for CLI to exit
         child.close()
@@ -574,3 +577,35 @@ def test_update_fails_when_no_signature_present(self, securedrop_git_repo):
         # Failures should eventually exit non-zero.
         assert child.exitstatus != 0
         assert child.signalstatus != 0
+
+    def test_update_with_duplicate_branch_and_tag(self,
+                                                  securedrop_git_repo):
+        gpgdir = os.path.join(os.path.expanduser('~'), '.gnupg')
+        set_reliable_keyserver(gpgdir)
+
+        github_url = 'https://api.github.com/repos/freedomofpress/securedrop/releases/latest'  # noqa: E501
+        latest_release = requests.get(github_url).json()
+        latest_tag = str(latest_release["tag_name"])
+
+        # Create a branch with the same name as a tag.
+        subprocess.check_call(['git', 'checkout', '-b', latest_tag])
+        # Checkout the older tag again in preparation for the update.
+        subprocess.check_call('git checkout 0.6'.split())
+
+        cmd = os.path.join(os.path.dirname(CURRENT_DIR),
+                           'securedrop_admin/__init__.py')
+        ansible_base = os.path.join(str(securedrop_git_repo),
+                                    'securedrop/install_files/ansible-base')
+
+        child = pexpect.spawn('coverage run {0} --root {1} update'.format(
+                              cmd, ansible_base))
+        output = child.read()
+        # Verify that we do not falsely check out a branch instead of a tag.
+        assert 'Switched to branch' not in output
+        assert 'Updated to SecureDrop' not in output
+        assert 'Signature verification failed' in output
+
+        child.expect(pexpect.EOF, timeout=10)  # Wait for CLI to exit
+        child.close()
+        assert child.exitstatus != 0
+        assert child.signalstatus != 0
diff --git a/admin/tests/test_securedrop-admin.py b/admin/tests/test_securedrop-admin.py
--- a/admin/tests/test_securedrop-admin.py
+++ b/admin/tests/test_securedrop-admin.py
@@ -140,7 +140,10 @@ def test_update_gpg_recv_primary_key_failure(self, tmpdir, caplog):
                        return_value=(True, "0.6.1")),
             mock.patch('subprocess.check_call'),
             mock.patch('subprocess.check_output',
-                       return_value=git_output),
+                       side_effect=[
+                           git_output,
+                           subprocess.CalledProcessError(1, 'cmd',
+                                                         'not a valid ref')]),
             mock.patch('securedrop_admin.get_release_key_from_keyserver',
                        side_effect=[
                            subprocess.CalledProcessError(1, 'cmd', 'BANG'),
@@ -181,16 +184,63 @@ def test_update_signature_verifies(self, tmpdir, caplog):
                       'gpg: Good signature from "SecureDrop Release '
                       'Signing Key" [unknown]\n')
 
-        with mock.patch('securedrop_admin.check_for_updates',
-                        return_value=(True, "0.6.1")):
-            with mock.patch('subprocess.check_call'):
-                with mock.patch('subprocess.check_output',
-                                return_value=git_output):
-                    ret_code = securedrop_admin.update(args)
-                    assert "Applying SecureDrop updates..." in caplog.text
-                    assert "Signature verification successful." in caplog.text
-                    assert "Updated to SecureDrop" in caplog.text
-                    assert ret_code == 0
+        patchers = [
+            mock.patch('securedrop_admin.check_for_updates',
+                       return_value=(True, "0.6.1")),
+            mock.patch('subprocess.check_call'),
+            mock.patch('subprocess.check_output',
+                       side_effect=[
+                           git_output,
+                           subprocess.CalledProcessError(1, 'cmd',
+                                                         'not a valid ref')]),
+            ]
+
+        for patcher in patchers:
+            patcher.start()
+
+        try:
+            ret_code = securedrop_admin.update(args)
+            assert "Applying SecureDrop updates..." in caplog.text
+            assert "Signature verification successful." in caplog.text
+            assert "Updated to SecureDrop" in caplog.text
+            assert ret_code == 0
+        finally:
+            for patcher in patchers:
+                patcher.stop()
+
+    def test_update_unexpected_exception_git_refs(self, tmpdir, caplog):
+        git_repo_path = str(tmpdir)
+        args = argparse.Namespace(root=git_repo_path)
+
+        git_output = ('gpg: Signature made Tue 13 Mar 2018 01:14:11 AM UTC\n'
+                      'gpg:                using RSA key '
+                      '22245C81E3BAEB4138B36061310F561200F4AD77\n'
+                      'gpg: Good signature from "SecureDrop Release '
+                      'Signing Key" [unknown]\n')
+
+        patchers = [
+            mock.patch('securedrop_admin.check_for_updates',
+                       return_value=(True, "0.6.1")),
+            mock.patch('subprocess.check_call'),
+            mock.patch('subprocess.check_output',
+                       side_effect=[
+                           git_output,
+                           subprocess.CalledProcessError(1, 'cmd',
+                                                         'a random error')]),
+            ]
+
+        for patcher in patchers:
+            patcher.start()
+
+        try:
+            ret_code = securedrop_admin.update(args)
+            assert "Applying SecureDrop updates..." in caplog.text
+            assert "Signature verification successful." not in caplog.text
+            assert "Updated to SecureDrop" not in caplog.text
+            assert ret_code == 1
+        finally:
+            for patcher in patchers:
+                patcher.stop()
 
     def test_update_signature_does_not_verify(self, tmpdir, caplog):
         git_repo_path = str(tmpdir)