Repository: jazzband/pip-tools

Issue:
pip-sync does not verify hashes when installing packages
Describe the issue briefly here.

If a hash is incorrect, `pip` will fail with a command like:

```
THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE. If you have updated the package versions, please update the hashes. Otherwise, examine the package contents carefully; someone may have tampered with them.
    django==2.0.0 from https://pypi.python.org/packages/44/98/35b935a98a17e9a188efc2d53fc51ae0c8bf498a77bc224f9321ae5d111c/Django-2.0-py3-none-any.whl#md5=da2fdc3901e8751aa7835f49fb6246b2 (from -r requirements.txt (line 7)):
        Expected sha256 6914851d4a7ff8cbd32b73c6076441f377c45a5bbff7e771798fb02c43c31f47
        Expected     or af18618ce3291be5092893d8522fe3916961bf3a1fb60e3858ae74865a4f07c2
             Got        af18618ce3291be5092893d8522fe3919661bf3a1fb60e3858ae74865a4f07c2
```

However, `pip-sync` will not fail. It will happily install the package even if the hashes do no match. I expect `pip-sync` to also fail if it can't verify the package hashes.

##### Environment Versions

1. Linux -- Fedora 27
1. Python version: 3.6.3
1. pip version:  9.0.1
1. pip-tools version: 1.11.0

##### Steps to replicate

1. Change the hashes in `requirements.txt` so they are obviously wrong
2. Run `pip-sync requirements.txt` in a fresh virtualenv

##### Expected result

`pip-sync` fails with a loud warning that the hashes do not match (like pip)

##### Actual result

`pip-sync` installs the packages with mismatched hashes.

I have written a test script to demonstrate. In this script, `pip-sync` installs packages with mismatched hashes. At the end, the test is rerun with `pip` to demonstrate what I believe should happen.

`requirements.in`:

```
Django==2.0.0
```

`test.sh`:

```sh
#!/bin/bash

set -e
set -x

# Compile the requirements.in
rm -f requirements.txt
python3 -m venv venv
. venv/bin/activate
pip install pip-tools
venv/bin/pip-compile --generate-hashes -o requirements.txt requirements.in
deactivate

# Change the Django hash
sed -i -e 's/96/69/g' requirements.txt

# Demonstrate that pip-sync does not fail
rm -rf venv
python3 -m venv venv
. venv/bin/activate
pip install pip-tools
pip-sync requirements.txt
deactivate

# Confirm pip fails
rm -rf venv
python3 -m venv venv
. venv/bin/activate
pip install pip-tools
pip install -r requirements.txt
deactivate
```

Full script output:

```
$ ./test.sh 
+ rm -f requirements.txt
+ python3 -m venv venv
+ . venv/bin/activate
++ deactivate nondestructive
++ '[' -n '' ']'
++ '[' -n '' ']'
++ '[' -n /bin/bash -o -n '' ']'
++ hash -r
++ '[' -n '' ']'
++ unset VIRTUAL_ENV
++ '[' '!' nondestructive = nondestructive ']'
++ VIRTUAL_ENV=/home/jon/test/venv
++ export VIRTUAL_ENV
++ _OLD_VIRTUAL_PATH=/home/jon/.nvm/versions/node/v7.3.0/bin:/usr/lib64/ccache:/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/home/jon/.composer/vendor/bin:/home/jon/.local/bin:/home/jon/bin:/home/jon/devel/scripts
++ PATH=/home/jon/test/venv/bin:/home/jon/.nvm/versions/node/v7.3.0/bin:/usr/lib64/ccache:/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/home/jon/.composer/vendor/bin:/home/jon/.local/bin:/home/jon/bin:/home/jon/devel/scripts
++ export PATH
++ '[' -n '' ']'
++ '[' -z '' ']'
++ _OLD_VIRTUAL_PS1=
++ '[' 'x(venv) ' '!=' x ']'
++ PS1='(venv) '
++ export PS1
++ '[' -n /bin/bash -o -n '' ']'
++ hash -r
+ pip install pip-tools
Requirement already satisfied: pip-tools in ./venv/lib/python3.6/site-packages
Requirement already satisfied: click>=6 in ./venv/lib/python3.6/site-packages (from pip-tools)
Requirement already satisfied: first in ./venv/lib/python3.6/site-packages (from pip-tools)
Requirement already satisfied: six in ./venv/lib/python3.6/site-packages (from pip-tools)
Requirement already satisfied: setuptools in ./venv/lib/python3.6/site-packages (from pip-tools)
+ venv/bin/pip-compile --generate-hashes -o requirements.txt requirements.in
#
# This file is autogenerated by pip-compile
# To update, run:
#
#    pip-compile --generate-hashes --output-file requirements.txt requirements.in
#
django==2.0.0 \
    --hash=sha256:9614851d4a7ff8cbd32b73c6076441f377c45a5bbff7e771798fb02c43c31f47 \
    --hash=sha256:af18618ce3291be5092893d8522fe3919661bf3a1fb60e3858ae74865a4f07c2
pytz==2017.3 \
    --hash=sha256:c41c62827ce9cafacd6f2f7018e4f83a6f1986e87bfd000b8cfbd4ab5da95f1a \
    --hash=sha256:fae4cffc040921b8a2d60c6cf0b5d662c1190fe54d718271db4eb17d44a185b7 \
    # via django
+ deactivate
+ '[' -n /home/jon/.nvm/versions/node/v7.3.0/bin:/usr/lib64/ccache:/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/home/jon/.composer/vendor/bin:/home/jon/.local/bin:/home/jon/bin:/home/jon/devel/scripts ']'
+ PATH=/home/jon/.nvm/versions/node/v7.3.0/bin:/usr/lib64/ccache:/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/home/jon/.composer/vendor/bin:/home/jon/.local/bin:/home/jon/bin:/home/jon/devel/scripts
+ export PATH
+ unset _OLD_VIRTUAL_PATH
+ '[' -n '' ']'
+ '[' -n /bin/bash -o -n '' ']'
+ hash -r
+ '[' -n '' ']'
+ unset VIRTUAL_ENV
+ '[' '!' '' = nondestructive ']'
+ unset -f deactivate
+ sed -i -e s/96/69/g requirements.txt
+ rm -rf venv
+ python3 -m venv venv
+ . venv/bin/activate
++ deactivate nondestructive
++ '[' -n '' ']'
++ '[' -n '' ']'
++ '[' -n /bin/bash -o -n '' ']'
++ hash -r
++ '[' -n '' ']'
++ unset VIRTUAL_ENV
++ '[' '!' nondestructive = nondestructive ']'
++ VIRTUAL_ENV=/home/jon/test/venv
++ export VIRTUAL_ENV
++ _OLD_VIRTUAL_PATH=/home/jon/.nvm/versions/node/v7.3.0/bin:/usr/lib64/ccache:/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/home/jon/.composer/vendor/bin:/home/jon/.local/bin:/home/jon/bin:/home/jon/devel/scripts
++ PATH=/home/jon/test/venv/bin:/home/jon/.nvm/versions/node/v7.3.0/bin:/usr/lib64/ccache:/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/home/jon/.composer/vendor/bin:/home/jon/.local/bin:/home/jon/bin:/home/jon/devel/scripts
++ export PATH
++ '[' -n '' ']'
++ '[' -z '' ']'
++ _OLD_VIRTUAL_PS1='(venv) '
++ '[' 'x(venv) ' '!=' x ']'
++ PS1='(venv) (venv) '
++ export PS1
++ '[' -n /bin/bash -o -n '' ']'
++ hash -r
+ pip install pip-tools
Collecting pip-tools
  Using cached pip_tools-1.11.0-py2.py3-none-any.whl
Requirement already satisfied: setuptools in ./venv/lib/python3.6/site-packages (from pip-tools)
Collecting click>=6 (from pip-tools)
  Using cached click-6.7-py2.py3-none-any.whl
Collecting first (from pip-tools)
  Using cached first-2.0.1-py2.py3-none-any.whl
Collecting six (from pip-tools)
  Using cached six-1.11.0-py2.py3-none-any.whl
Installing collected packages: click, first, six, pip-tools
Successfully installed click-6.7 first-2.0.1 pip-tools-1.11.0 six-1.11.0
+ pip-sync requirements.txt
Collecting django==2.0.0
  Using cached Django-2.0-py3-none-any.whl
Collecting pytz==2017.3
  Using cached pytz-2017.3-py2.py3-none-any.whl
Installing collected packages: pytz, django
Successfully installed django-2.0 pytz-2017.3
+ deactivate
+ '[' -n /home/jon/.nvm/versions/node/v7.3.0/bin:/usr/lib64/ccache:/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/home/jon/.composer/vendor/bin:/home/jon/.local/bin:/home/jon/bin:/home/jon/devel/scripts ']'
+ PATH=/home/jon/.nvm/versions/node/v7.3.0/bin:/usr/lib64/ccache:/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/home/jon/.composer/vendor/bin:/home/jon/.local/bin:/home/jon/bin:/home/jon/devel/scripts
+ export PATH
+ unset _OLD_VIRTUAL_PATH
+ '[' -n '' ']'
+ '[' -n /bin/bash -o -n '' ']'
+ hash -r
+ '[' -n '(venv) ' ']'
+ PS1='(venv) '
+ export PS1
+ unset _OLD_VIRTUAL_PS1
+ unset VIRTUAL_ENV
+ '[' '!' '' = nondestructive ']'
+ unset -f deactivate
+ rm -rf venv
+ python3 -m venv venv
+ . venv/bin/activate
++ deactivate nondestructive
++ '[' -n '' ']'
++ '[' -n '' ']'
++ '[' -n /bin/bash -o -n '' ']'
++ hash -r
++ '[' -n '' ']'
++ unset VIRTUAL_ENV
++ '[' '!' nondestructive = nondestructive ']'
++ VIRTUAL_ENV=/home/jon/test/venv
++ export VIRTUAL_ENV
++ _OLD_VIRTUAL_PATH=/home/jon/.nvm/versions/node/v7.3.0/bin:/usr/lib64/ccache:/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/home/jon/.composer/vendor/bin:/home/jon/.local/bin:/home/jon/bin:/home/jon/devel/scripts
++ PATH=/home/jon/test/venv/bin:/home/jon/.nvm/versions/node/v7.3.0/bin:/usr/lib64/ccache:/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/home/jon/.composer/vendor/bin:/home/jon/.local/bin:/home/jon/bin:/home/jon/devel/scripts
++ export PATH
++ '[' -n '' ']'
++ '[' -z '' ']'
++ _OLD_VIRTUAL_PS1='(venv) '
++ '[' 'x(venv) ' '!=' x ']'
++ PS1='(venv) (venv) '
++ export PS1
++ '[' -n /bin/bash -o -n '' ']'
++ hash -r
+ pip install pip-tools
Collecting pip-tools
  Using cached pip_tools-1.11.0-py2.py3-none-any.whl
Collecting first (from pip-tools)
  Using cached first-2.0.1-py2.py3-none-any.whl
Collecting six (from pip-tools)
  Using cached six-1.11.0-py2.py3-none-any.whl
Requirement already satisfied: setuptools in ./venv/lib/python3.6/site-packages (from pip-tools)
Collecting click>=6 (from pip-tools)
  Using cached click-6.7-py2.py3-none-any.whl
Installing collected packages: first, six, click, pip-tools
Successfully installed click-6.7 first-2.0.1 pip-tools-1.11.0 six-1.11.0
+ pip install -r requirements.txt
Collecting django==2.0.0 (from -r requirements.txt (line 7))
  Using cached Django-2.0-py3-none-any.whl
Collecting pytz==2017.3 (from -r requirements.txt (line 10))
  Using cached pytz-2017.3-py2.py3-none-any.whl
THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE. If you have updated the package versions, please update the hashes. Otherwise, examine the package contents carefully; someone may have tampered with them.
    django==2.0.0 from https://pypi.python.org/packages/44/98/35b935a98a17e9a188efc2d53fc51ae0c8bf498a77bc224f9321ae5d111c/Django-2.0-py3-none-any.whl#md5=da2fdc3901e8751aa7835f49fb6246b2 (from -r requirements.txt (line 7)):
        Expected sha256 6914851d4a7ff8cbd32b73c6076441f377c45a5bbff7e771798fb02c43c31f47
        Expected     or af18618ce3291be5092893d8522fe3916961bf3a1fb60e3858ae74865a4f07c2
             Got        af18618ce3291be5092893d8522fe3919661bf3a1fb60e3858ae74865a4f07c2
```


Discussion:
@jdufresne Thanks for the report, and good catch!

I took a quick look at the code: `pip-sync` does a `pip install pkg_a pkg_b ...`, not a `pip install -r requirements.txt`, which is the cause of the issue.

I feel like this could be changed, which could simplify `pip-sync` and align it more with `pip`. That would deserve a look.
That makes sense to me. So, IIUC, `pip-sync`'s job would be to:

1. Remove all extraneous packages installed to `site-packages`
2. Rerun `pip install -r requirements.txt` to install all missing packages

Yeah, I like that.

Resolving patch:
diff --git a/piptools/sync.py b/piptools/sync.py
--- a/piptools/sync.py
+++ b/piptools/sync.py
@@ -1,11 +1,12 @@
 import collections
 import os
 import sys
+import tempfile
 from subprocess import check_call
 
 from . import click
 from .exceptions import IncompatibleRequirements, UnsupportedConstraint
-from .utils import flat_map, format_requirement, key_from_ireq, key_from_req
+from .utils import flat_map, format_requirement, key_from_ireq, key_from_req, get_hashes_from_ireq
 
 PACKAGES_TO_IGNORE = [
     '-markerlib',
@@ -156,11 +157,16 @@ def sync(to_install, to_uninstall, verbose=False, dry_run=False, pip_flags=None,
             for ireq in to_install:
                 click.echo("  {}".format(format_requirement(ireq)))
         else:
-            package_args = []
+            # prepare requirement lines
+            req_lines = []
             for ireq in sorted(to_install, key=key_from_ireq):
-                if ireq.editable:
-                    package_args.extend(['-e', str(ireq.link or ireq.req)])
-                else:
-                    package_args.append(str(ireq.req))
-            check_call([pip, 'install'] + pip_flags + install_flags + package_args)
+                ireq_hashes = get_hashes_from_ireq(ireq)
+                req_lines.append(format_requirement(ireq, hashes=ireq_hashes))
+
+            # save requirement lines to a temporary file
+            with tempfile.NamedTemporaryFile(mode='wt') as tmp_req_file:
+                tmp_req_file.write('\n'.join(req_lines))
+                tmp_req_file.flush()
+
+                check_call([pip, 'install', '-r', tmp_req_file.name] + pip_flags + install_flags)
     return 0
diff --git a/piptools/utils.py b/piptools/utils.py
--- a/piptools/utils.py
+++ b/piptools/utils.py
@@ -53,7 +53,7 @@ def make_install_requirement(name, version, extras, constraint=False):
         constraint=constraint)
 
 
-def format_requirement(ireq, marker=None):
+def format_requirement(ireq, marker=None, hashes=None):
     """
     Generic formatter for pretty printing InstallRequirements to the terminal
     in a less verbose way than using its `__str__` method.
@@ -63,6 +63,10 @@ def format_requirement(ireq, marker=None):
     else:
         line = str(ireq.req).lower()
 
+    if hashes:
+        for hash_ in sorted(hashes):
+            line += " \\\n    --hash={}".format(hash_)
+
     if marker:
         line = '{} ; {}'.format(line, marker)
 
@@ -249,3 +253,16 @@ def temp_environ():
     finally:
         os.environ.clear()
         os.environ.update(environ)
+
+
+def get_hashes_from_ireq(ireq):
+    """
+    Given an InstallRequirement, return a list of string hashes in the format "{algorithm}:{hash}".
+    Return an empty list if there are no hashes in the requirement options.
+    """
+    result = []
+    ireq_hashes = ireq.options.get('hashes', {})
+    for algorithm, hexdigests in ireq_hashes.items():
+        for hash_ in hexdigests:
+            result.append("{}:{}".format(algorithm, hash_))
+    return result
diff --git a/piptools/writer.py b/piptools/writer.py
--- a/piptools/writer.py
+++ b/piptools/writer.py
@@ -130,12 +130,9 @@ def write(self, results, unsafe_requirements, reverse_dependencies,
                     f.write(os.linesep.encode('utf-8'))
 
     def _format_requirement(self, ireq, reverse_dependencies, primary_packages, marker=None, hashes=None):
-        line = format_requirement(ireq, marker=marker)
-
         ireq_hashes = (hashes if hashes is not None else {}).get(ireq)
-        if ireq_hashes:
-            for hash_ in sorted(ireq_hashes):
-                line += " \\\n    --hash={}".format(hash_)
+
+        line = format_requirement(ireq, marker=marker, hashes=ireq_hashes)
 
         if not self.annotate or key_from_req(ireq.req) in primary_packages:
             return line


Test patch:
diff --git a/tests/test_sync.py b/tests/test_sync.py
--- a/tests/test_sync.py
+++ b/tests/test_sync.py
@@ -2,6 +2,7 @@
 import os
 import platform
 import sys
+import tempfile
 
 import mock
 import pytest
@@ -10,6 +11,13 @@
 from piptools.sync import dependency_tree, diff, merge, sync
 
 
+@pytest.fixture
+def mocked_tmp_req_file():
+    with mock.patch.object(tempfile, 'NamedTemporaryFile') as m:
+        m.return_value.__enter__.return_value.name = 'requirements.txt'
+        yield m.return_value.__enter__.return_value
+
+
 @pytest.mark.parametrize(
     ('installed', 'root', 'expected'),
 
@@ -211,25 +219,71 @@ def test_diff_with_editable(fake_dist, from_editable):
     assert str(package.link) == _get_file_url(path_to_package)
 
 
-@pytest.mark.parametrize(
-    'lines',
-    [
-        ['django==1.8'],
-        ['django==1.8', 'click==4.0'],
-    ]
-)
-def test_sync_install(from_line, lines):
+def test_sync_install_temporary_requirement_file(from_line, from_editable, mocked_tmp_req_file):
     with mock.patch('piptools.sync.check_call') as check_call:
-        to_install = {from_line(line) for line in lines}
-
+        to_install = {from_line('django==1.8')}
         sync(to_install, set())
-        check_call.assert_called_once_with(['pip', 'install', '-q'] + sorted(lines))
+        check_call.assert_called_once_with(['pip', 'install', '-r', mocked_tmp_req_file.name, '-q'])
 
 
-def test_sync_with_editable(from_editable):
-    with mock.patch('piptools.sync.check_call') as check_call:
-        path_to_package = os.path.join(os.path.dirname(__file__), 'test_data', 'small_fake_package')
-        to_install = {from_editable(path_to_package)}
+def test_sync_requirement_file(from_line, from_editable, mocked_tmp_req_file):
+    with mock.patch('piptools.sync.check_call'):
+        to_install = {
+            from_line('django==1.8'),
+            from_editable('git+git://fake.org/x/y.git#egg=y'),
+            from_line('click==4.0'),
+            from_editable('git+git://fake.org/i/j.git#egg=j'),
+            from_line('pytz==2017.2'),
+        }
+
+        sync(to_install, set())
+
+        expected = (
+            'click==4.0\n'
+            'django==1.8\n'
+            '-e git+git://fake.org/i/j.git#egg=j\n'
+            'pytz==2017.2\n'
+            '-e git+git://fake.org/x/y.git#egg=y'
+        )
+        mocked_tmp_req_file.write.assert_called_once_with(expected)
+
+
+def test_sync_requirement_file_with_hashes(from_line, from_editable, mocked_tmp_req_file):
+    with mock.patch('piptools.sync.check_call'):
+        to_install = {
+            from_line('django==1.8', options={
+                'hashes': {
+                    'sha256': [
+                        '6a03ce2feafdd193a0ba8a26dbd9773e757d2e5d5e7933a62eac129813bd381a',
+                    ]
+                }
+            }),
+            from_line('click==4.0', options={
+                'hashes': {
+                    'sha256': [
+                        '9ab1d313f99b209f8f71a629f36833030c8d7c72282cf7756834baf567dca662',
+                    ]
+                }
+            }),
+            from_line('pytz==2017.2', options={
+                'hashes': {
+                    'sha256': [
+                        'd1d6729c85acea5423671382868627129432fba9a89ecbb248d8d1c7a9f01c67',
+                        'f5c056e8f62d45ba8215e5cb8f50dfccb198b4b9fbea8500674f3443e4689589'
+                    ]
+                }
+            })
+        }
 
         sync(to_install, set())
-        check_call.assert_called_once_with(['pip', 'install', '-q', '-e', _get_file_url(path_to_package)])
+
+        expected = (
+            'click==4.0 \\\n'
+            '    --hash=sha256:9ab1d313f99b209f8f71a629f36833030c8d7c72282cf7756834baf567dca662\n'
+            'django==1.8 \\\n'
+            '    --hash=sha256:6a03ce2feafdd193a0ba8a26dbd9773e757d2e5d5e7933a62eac129813bd381a\n'
+            'pytz==2017.2 \\\n'
+            '    --hash=sha256:d1d6729c85acea5423671382868627129432fba9a89ecbb248d8d1c7a9f01c67 \\\n'
+            '    --hash=sha256:f5c056e8f62d45ba8215e5cb8f50dfccb198b4b9fbea8500674f3443e4689589'
+        )
+        mocked_tmp_req_file.write.assert_called_once_with(expected)
diff --git a/tests/test_utils.py b/tests/test_utils.py
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,7 +1,7 @@
 from pytest import raises
 
 from piptools.utils import (
-    as_tuple, format_requirement, format_specifier, flat_map, dedup)
+    as_tuple, format_requirement, format_specifier, flat_map, dedup, get_hashes_from_ireq)
 
 
 def test_format_requirement(from_line):
@@ -14,6 +14,21 @@ def test_format_requirement_editable(from_editable):
     assert format_requirement(ireq) == '-e git+git://fake.org/x/y.git#egg=y'
 
 
+def test_format_requirement_ireq_with_hashes(from_line):
+    ireq = from_line('pytz==2017.2')
+    ireq_hashes = [
+        'sha256:d1d6729c85acea5423671382868627129432fba9a89ecbb248d8d1c7a9f01c67',
+        'sha256:f5c056e8f62d45ba8215e5cb8f50dfccb198b4b9fbea8500674f3443e4689589',
+    ]
+
+    expected = (
+        'pytz==2017.2 \\\n'
+        '    --hash=sha256:d1d6729c85acea5423671382868627129432fba9a89ecbb248d8d1c7a9f01c67 \\\n'
+        '    --hash=sha256:f5c056e8f62d45ba8215e5cb8f50dfccb198b4b9fbea8500674f3443e4689589'
+    )
+    assert format_requirement(ireq, hashes=ireq_hashes) == expected
+
+
 def test_format_specifier(from_line):
     ireq = from_line('foo')
     assert format_specifier(ireq) == '<any>'
@@ -58,3 +73,19 @@ def test_flat_map():
 
 def test_dedup():
     assert list(dedup([3, 1, 2, 4, 3, 5])) == [3, 1, 2, 4, 5]
+
+
+def test_get_hashes_from_ireq(from_line):
+    ireq = from_line('pytz==2017.2', options={
+        'hashes': {
+            'sha256': [
+                'd1d6729c85acea5423671382868627129432fba9a89ecbb248d8d1c7a9f01c67',
+                'f5c056e8f62d45ba8215e5cb8f50dfccb198b4b9fbea8500674f3443e4689589'
+            ]
+        }
+    })
+    expected = [
+        'sha256:d1d6729c85acea5423671382868627129432fba9a89ecbb248d8d1c7a9f01c67',
+        'sha256:f5c056e8f62d45ba8215e5cb8f50dfccb198b4b9fbea8500674f3443e4689589',
+    ]
+    assert get_hashes_from_ireq(ireq) == expected