From 2ba64e6fe9664bb42948f5acb95a9685d0c7cac6 Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Wed, 2 Feb 2022 00:24:13 +1100 Subject: [PATCH 01/10] requirement: Implement `--require-hashes` dependency collection --- pip_audit/_cli.py | 11 +++++- pip_audit/_dependency_source/requirement.py | 42 ++++++++++++++++++++- pip_audit/_service/interface.py | 3 +- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/pip_audit/_cli.py b/pip_audit/_cli.py index f45c0cba..b3e3c9a9 100644 --- a/pip_audit/_cli.py +++ b/pip_audit/_cli.py @@ -239,6 +239,12 @@ def _parser() -> argparse.ArgumentParser: action="store_true", help="automatically upgrade dependencies with known vulnerabilities", ) + parser.add_argument( + "--require-hashes", + action="store_true", + help="require a hash to check each requirement against, for repeatable audits; this option " + "is implied when any package in a requirements file has a --hash option.", + ) return parser @@ -272,7 +278,10 @@ def audit() -> None: if args.requirements is not None: req_files: List[Path] = [Path(req.name) for req in args.requirements] source = RequirementSource( - req_files, ResolveLibResolver(args.timeout, args.cache_dir, state), state + req_files, + ResolveLibResolver(args.timeout, args.cache_dir, state), + args.require_hashes, + state, ) else: source = PipSource(local=args.local, paths=args.paths, state=state) diff --git a/pip_audit/_dependency_source/requirement.py b/pip_audit/_dependency_source/requirement.py index 0417d347..4600a0ce 100644 --- a/pip_audit/_dependency_source/requirement.py +++ b/pip_audit/_dependency_source/requirement.py @@ -4,15 +4,17 @@ import logging import os +import re import shutil from contextlib import ExitStack from pathlib import Path from tempfile import NamedTemporaryFile -from typing import IO, Iterator, List, Set, cast +from typing import IO, Iterator, List, Set, Union, cast from packaging.requirements import Requirement from packaging.specifiers import SpecifierSet -from pip_api import parse_requirements +from packaging.version import Version +from pip_api import ParsedRequirement, UnparsedRequirement, parse_requirements from pip_api.exceptions import PipError from pip_audit._dependency_source import ( @@ -29,6 +31,8 @@ logger = logging.getLogger(__name__) +PINNED_SPECIFIER_RE = re.compile(r"==(?P.+?)$", re.VERBOSE) + class RequirementSource(DependencySource): """ @@ -39,6 +43,7 @@ def __init__( self, filenames: List[Path], resolver: DependencyResolver, + require_hashes: bool = False, state: AuditState = AuditState(), ) -> None: """ @@ -52,6 +57,7 @@ def __init__( """ self.filenames = filenames self.resolver = resolver + self.require_hashes = require_hashes self.state = state def collect(self) -> Iterator[Dependency]: @@ -67,6 +73,12 @@ def collect(self) -> Iterator[Dependency]: except PipError as pe: raise RequirementSourceError("requirement parsing raised an error") from pe + # If we're requiring hashes, we skip dependency resolution and check that each + # requirement is accompanied by a hash and is pinned + if self.require_hashes: + yield from self._collect_hashed_deps(iter(reqs.values())) + continue + # Invoke the dependency resolver to turn requirements into dependencies req_values: List[Requirement] = [Requirement(str(req)) for req in reqs.values()] try: @@ -153,6 +165,32 @@ def _recover_files(self, tmp_files: List[IO[str]]) -> None: logger.warning(f"encountered an exception during file recovery: {e}") continue + def _collect_hashed_deps( + self, reqs: Iterator[Union[ParsedRequirement, UnparsedRequirement]] + ) -> Iterator[Dependency]: + for req in reqs: + req = cast(ParsedRequirement, req) + if req.hash is None: + skip_reason = ( + f"requirement {req.name} does not contain a hash with " + f"`--require-hashes`: {str(req)}" + ) + logger.debug(skip_reason) + yield SkippedDependency(req.name, skip_reason) + continue + if req.specifier is not None: + pinned_specifier_info = PINNED_SPECIFIER_RE.match(str(req.specifier)) + if pinned_specifier_info is not None: + # Yield a dependency with the hash + pinned_version = pinned_specifier_info.group("version") + yield ResolvedDependency(req.name, Version(pinned_version), req.hash) + continue + skip_reason = ( + f"requirement {req.name} is not pinned with `--require-hashes`: {str(req)}" + ) + logger.debug(skip_reason) + yield SkippedDependency(req.name, skip_reason) + class RequirementSourceError(DependencySourceError): """A requirements-parsing specific `DependencySourceError`.""" diff --git a/pip_audit/_service/interface.py b/pip_audit/_service/interface.py index 8b4543b4..d2344add 100644 --- a/pip_audit/_service/interface.py +++ b/pip_audit/_service/interface.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Iterator, List, Tuple +from typing import Iterator, List, Optional, Tuple from packaging.utils import canonicalize_name from packaging.version import Version @@ -54,6 +54,7 @@ class ResolvedDependency(Dependency): """ version: Version + hash: Optional[str] = None @dataclass(frozen=True) From a9e58d9c631c26ac7ba46294962a83c9a69dd960 Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Wed, 2 Feb 2022 00:46:02 +1100 Subject: [PATCH 02/10] pypi: Check hashes against PyPI releases --- pip_audit/_service/pypi.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/pip_audit/_service/pypi.py b/pip_audit/_service/pypi.py index aae765f8..e16caeaf 100644 --- a/pip_audit/_service/pypi.py +++ b/pip_audit/_service/pypi.py @@ -70,6 +70,31 @@ def query(self, spec: Dependency) -> Tuple[Dependency, List[VulnerabilityResult] response_json = response.json() results: List[VulnerabilityResult] = [] + # If the dependency has a hash explicitly listed, check it against the PyPI data + if spec.hash is not None: + releases = response_json["releases"] + release = releases.get(str(spec.version)) + if release is None: + raise ServiceError( + "Could not find release to compare hashes: " + f"{spec.canonical_name} ({spec.version})" + ) + found = False + hash_type, hash_value = spec.hash.split(":", 1) + for dist in release: + digests = dist.get("digests") + if digests is None: + continue + pypi_hash = digests.get(hash_type) + if pypi_hash is not None and pypi_hash == hash_value: + found = True + break + if not found: + raise ServiceError( + f"Mismatched hash for {spec.canonical_name} ({spec.version}): listed " + f"{hash_value} of type {hash_type} could not be found in PyPI releases" + ) + vulns = response_json.get("vulnerabilities") # No `vulnerabilities` key means that there are no vulnerabilities for any version From 7f67914a7a757a179c3f2cdc077fa214df96bd49 Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Wed, 2 Feb 2022 12:35:37 +1100 Subject: [PATCH 03/10] requirement, pypi: Adapt solution to work with multiple hashes --- pip_audit/_dependency_source/requirement.py | 8 +++-- pip_audit/_service/interface.py | 6 ++-- pip_audit/_service/pypi.py | 33 +++++++++++---------- 3 files changed, 25 insertions(+), 22 deletions(-) diff --git a/pip_audit/_dependency_source/requirement.py b/pip_audit/_dependency_source/requirement.py index 4600a0ce..c9d99100 100644 --- a/pip_audit/_dependency_source/requirement.py +++ b/pip_audit/_dependency_source/requirement.py @@ -14,7 +14,9 @@ from packaging.requirements import Requirement from packaging.specifiers import SpecifierSet from packaging.version import Version -from pip_api import ParsedRequirement, UnparsedRequirement, parse_requirements +from pip_api import parse_requirements +from pip_api._parse_requirements import Requirement as ParsedRequirement +from pip_api._parse_requirements import UnparsedRequirement from pip_api.exceptions import PipError from pip_audit._dependency_source import ( @@ -170,7 +172,7 @@ def _collect_hashed_deps( ) -> Iterator[Dependency]: for req in reqs: req = cast(ParsedRequirement, req) - if req.hash is None: + if req.hashes is None: skip_reason = ( f"requirement {req.name} does not contain a hash with " f"`--require-hashes`: {str(req)}" @@ -183,7 +185,7 @@ def _collect_hashed_deps( if pinned_specifier_info is not None: # Yield a dependency with the hash pinned_version = pinned_specifier_info.group("version") - yield ResolvedDependency(req.name, Version(pinned_version), req.hash) + yield ResolvedDependency(req.name, Version(pinned_version), req.hashes) continue skip_reason = ( f"requirement {req.name} is not pinned with `--require-hashes`: {str(req)}" diff --git a/pip_audit/_service/interface.py b/pip_audit/_service/interface.py index d2344add..b34b5ca5 100644 --- a/pip_audit/_service/interface.py +++ b/pip_audit/_service/interface.py @@ -4,8 +4,8 @@ """ from abc import ABC, abstractmethod -from dataclasses import dataclass -from typing import Iterator, List, Optional, Tuple +from dataclasses import dataclass, field +from typing import Dict, Iterator, List, Tuple from packaging.utils import canonicalize_name from packaging.version import Version @@ -54,7 +54,7 @@ class ResolvedDependency(Dependency): """ version: Version - hash: Optional[str] = None + hashes: Dict[str, List[str]] = field(default_factory=dict, hash=False) @dataclass(frozen=True) diff --git a/pip_audit/_service/pypi.py b/pip_audit/_service/pypi.py index e16caeaf..67159590 100644 --- a/pip_audit/_service/pypi.py +++ b/pip_audit/_service/pypi.py @@ -71,7 +71,7 @@ def query(self, spec: Dependency) -> Tuple[Dependency, List[VulnerabilityResult] results: List[VulnerabilityResult] = [] # If the dependency has a hash explicitly listed, check it against the PyPI data - if spec.hash is not None: + if spec.hashes: releases = response_json["releases"] release = releases.get(str(spec.version)) if release is None: @@ -79,21 +79,22 @@ def query(self, spec: Dependency) -> Tuple[Dependency, List[VulnerabilityResult] "Could not find release to compare hashes: " f"{spec.canonical_name} ({spec.version})" ) - found = False - hash_type, hash_value = spec.hash.split(":", 1) - for dist in release: - digests = dist.get("digests") - if digests is None: - continue - pypi_hash = digests.get(hash_type) - if pypi_hash is not None and pypi_hash == hash_value: - found = True - break - if not found: - raise ServiceError( - f"Mismatched hash for {spec.canonical_name} ({spec.version}): listed " - f"{hash_value} of type {hash_type} could not be found in PyPI releases" - ) + for hash_type, hash_values in spec.hashes.items(): + for hash_value in hash_values: + found = False + for dist in release: + digests = dist.get("digests") + if digests is None: + continue + pypi_hash = digests.get(hash_type) + if pypi_hash is not None and pypi_hash == hash_value: + found = True + break + if not found: + raise ServiceError( + f"Mismatched hash for {spec.canonical_name} ({spec.version}): listed " + f"{hash_value} of type {hash_type} could not be found in PyPI releases" + ) vulns = response_json.get("vulnerabilities") From 0eb8e7867482ef594a67a0198a99d308b50bcdd8 Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Wed, 2 Feb 2022 12:45:31 +1100 Subject: [PATCH 04/10] setup: Temporarily pin to pip-api branch with hash support --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 5e6817be..94aa5f84 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,7 @@ platforms="any", python_requires=">=3.7", install_requires=[ - "pip-api>=0.0.26", + "pip-api @ git+ssh://git@github.com/woodruffw-forks/pip-api@ww/hash-support", "packaging>=21.0.0", "progress>=1.6", "resolvelib>=0.8.0", From 3eefd95b30f215076d43f635fe8c86885ec6de27 Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Wed, 2 Feb 2022 15:58:37 +1100 Subject: [PATCH 05/10] test: Basic PyPI tests for hash checking --- pip_audit/_dependency_source/requirement.py | 7 +++++- pip_audit/_service/pypi.py | 4 +--- test/service/test_pypi.py | 25 +++++++++++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/pip_audit/_dependency_source/requirement.py b/pip_audit/_dependency_source/requirement.py index c9d99100..054a12e3 100644 --- a/pip_audit/_dependency_source/requirement.py +++ b/pip_audit/_dependency_source/requirement.py @@ -77,7 +77,12 @@ def collect(self) -> Iterator[Dependency]: # If we're requiring hashes, we skip dependency resolution and check that each # requirement is accompanied by a hash and is pinned - if self.require_hashes: + # + # If at least one requirement has a hash, it implies that we require hashes for all + # requirements + if self.require_hashes or any( + isinstance(req, ParsedRequirement) and req.hashes for req in reqs.values() + ): yield from self._collect_hashed_deps(iter(reqs.values())) continue diff --git a/pip_audit/_service/pypi.py b/pip_audit/_service/pypi.py index 67159590..0b69c80b 100644 --- a/pip_audit/_service/pypi.py +++ b/pip_audit/_service/pypi.py @@ -83,9 +83,7 @@ def query(self, spec: Dependency) -> Tuple[Dependency, List[VulnerabilityResult] for hash_value in hash_values: found = False for dist in release: - digests = dist.get("digests") - if digests is None: - continue + digests = dist["digests"] pypi_hash = digests.get(hash_type) if pypi_hash is not None and pypi_hash == hash_value: found = True diff --git a/test/service/test_pypi.py b/test/service/test_pypi.py index f3fadccd..8cf482ea 100644 --- a/test/service/test_pypi.py +++ b/test/service/test_pypi.py @@ -206,3 +206,28 @@ def test_pypi_skipped_dep(cache_dir): assert dep in results vulns = results[dep] assert len(vulns) == 0 + + +def test_pypi_hashed_dep(cache_dir): + pypi = service.PyPIService(cache_dir) + dep = service.ResolvedDependency( + "flask", + Version("2.0.1"), + hashes={"sha256": ["a6209ca15eb63fc9385f38e452704113d679511d9574d09b2cf9183ae7d20dc9"]}, + ) + results = dict(pypi.query_all(iter([dep]))) + assert len(results) == 1 + assert dep in results + vulns = results[dep] + assert len(vulns) == 0 + + +def test_pypi_hashed_dep_mismatch(cache_dir): + pypi = service.PyPIService(cache_dir) + dep = service.ResolvedDependency( + "flask", + Version("2.0.1"), + hashes={"sha256": ["mismatched-hash"]}, + ) + with pytest.raises(service.ServiceError): + dict(pypi.query_all(iter([dep]))) From 955c07f7d0d75addece2029305a908d654867f28 Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Wed, 2 Feb 2022 17:25:54 +1100 Subject: [PATCH 06/10] test: Test requirements source with `require-hashes` --- pip_audit/_dependency_source/requirement.py | 20 +++--- test/dependency_source/test_requirement.py | 67 +++++++++++++++++++++ test/service/test_pypi.py | 30 +++++++++ 3 files changed, 104 insertions(+), 13 deletions(-) diff --git a/pip_audit/_dependency_source/requirement.py b/pip_audit/_dependency_source/requirement.py index 054a12e3..4387ab2a 100644 --- a/pip_audit/_dependency_source/requirement.py +++ b/pip_audit/_dependency_source/requirement.py @@ -76,7 +76,9 @@ def collect(self) -> Iterator[Dependency]: raise RequirementSourceError("requirement parsing raised an error") from pe # If we're requiring hashes, we skip dependency resolution and check that each - # requirement is accompanied by a hash and is pinned + # requirement is accompanied by a hash and is pinned. Files that include hashes must + # explicitly list all transitive dependencies so assuming that the requirements file is + # valid and able to be installed with `-r`, we can skip dependency resolution. # # If at least one requirement has a hash, it implies that we require hashes for all # requirements @@ -177,14 +179,10 @@ def _collect_hashed_deps( ) -> Iterator[Dependency]: for req in reqs: req = cast(ParsedRequirement, req) - if req.hashes is None: - skip_reason = ( - f"requirement {req.name} does not contain a hash with " - f"`--require-hashes`: {str(req)}" + if not req.hashes: + raise RequirementSourceError( + f"requirement {req.name} does not contain a hash: {str(req)}" ) - logger.debug(skip_reason) - yield SkippedDependency(req.name, skip_reason) - continue if req.specifier is not None: pinned_specifier_info = PINNED_SPECIFIER_RE.match(str(req.specifier)) if pinned_specifier_info is not None: @@ -192,11 +190,7 @@ def _collect_hashed_deps( pinned_version = pinned_specifier_info.group("version") yield ResolvedDependency(req.name, Version(pinned_version), req.hashes) continue - skip_reason = ( - f"requirement {req.name} is not pinned with `--require-hashes`: {str(req)}" - ) - logger.debug(skip_reason) - yield SkippedDependency(req.name, skip_reason) + raise RequirementSourceError(f"requirement {req.name} is not pinned: {str(req)}") class RequirementSourceError(DependencySourceError): diff --git a/test/dependency_source/test_requirement.py b/test/dependency_source/test_requirement.py index 9fdce726..7d074341 100644 --- a/test/dependency_source/test_requirement.py +++ b/test/dependency_source/test_requirement.py @@ -283,3 +283,70 @@ def mock_replace(*_args, **_kwargs): for (expected_req, req_path) in zip(expected_reqs, req_paths): with open(req_path, "r") as f: assert expected_req == f.read().strip() + + +def test_requirement_source_require_hashes(monkeypatch): + source = requirement.RequirementSource( + [Path("requirements.txt")], ResolveLibResolver(), require_hashes=True + ) + + monkeypatch.setattr( + _parse_requirements, "_read_file", lambda _: ["flask==2.0.1 --hash=sha256:flask-hash"] + ) + + # The hash should be populated in the resolved dependency. Additionally, the source should not + # calculate and resolve transitive dependencies since requirements files with hashes must + # explicitly list all dependencies. + specs = list(source.collect()) + assert specs == [ + ResolvedDependency("flask", Version("2.0.1"), hashes={"sha256": ["flask-hash"]}) + ] + + +def test_requirement_source_require_hashes_missing(monkeypatch): + source = requirement.RequirementSource( + [Path("requirements.txt")], ResolveLibResolver(), require_hashes=True + ) + + monkeypatch.setattr( + _parse_requirements, + "_read_file", + lambda _: ["flask==2.0.1"], + ) + + # All requirements must be hashed when collecting with `require-hashes` + with pytest.raises(DependencySourceError): + list(source.collect()) + + +def test_requirement_source_require_hashes_inferred(monkeypatch): + source = requirement.RequirementSource([Path("requirements.txt")], ResolveLibResolver()) + + monkeypatch.setattr( + _parse_requirements, + "_read_file", + lambda _: ["flask==2.0.1 --hash=sha256:flask-hash\nrequests==1.0"], + ) + + # If at least one requirement is hashed, this infers `require-hashes` + with pytest.raises(DependencySourceError): + list(source.collect()) + + +def test_requirement_source_require_hashes_unpinned(monkeypatch): + source = requirement.RequirementSource( + [Path("requirements.txt")], ResolveLibResolver(), require_hashes=True + ) + + monkeypatch.setattr( + _parse_requirements, + "_read_file", + lambda _: [ + "flask==2.0.1 --hash=sha256:flask-hash\nrequests>=1.0 --hash=sha256:requests-hash" + ], + ) + + # When hashed dependencies are provided, all dependencies must be explicitly pinned to an exact + # version number + with pytest.raises(DependencySourceError): + list(source.collect()) diff --git a/test/service/test_pypi.py b/test/service/test_pypi.py index 8cf482ea..5eca0e9d 100644 --- a/test/service/test_pypi.py +++ b/test/service/test_pypi.py @@ -231,3 +231,33 @@ def test_pypi_hashed_dep_mismatch(cache_dir): ) with pytest.raises(service.ServiceError): dict(pypi.query_all(iter([dep]))) + + +def test_pypi_hashed_dep_no_release_data(cache_dir, monkeypatch): + def get_mock_response(): + class MockResponse: + def raise_for_status(self): + pass + + def json(self): + return { + "releases": {}, + "vulnerabilities": [ + { + "id": "VULN-0", + "details": "The first vulnerability", + "fixed_in": ["1.1"], + } + ], + } + + return MockResponse() + + monkeypatch.setattr( + service.pypi, "caching_session", lambda _: get_mock_session(get_mock_response) + ) + + pypi = service.PyPIService(cache_dir) + dep = service.ResolvedDependency("foo", Version("1.0"), hashes={"sha256": ["package-hash"]}) + with pytest.raises(service.ServiceError): + dict(pypi.query_all(iter([dep]))) From 9c23b70dc222070e590d76e186a483504dc02396 Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Wed, 2 Feb 2022 18:47:13 +1100 Subject: [PATCH 07/10] README: Update help text --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1e3c9227..a5c6eeb3 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ python -m pip_audit --help usage: pip-audit [-h] [-V] [-l] [-r REQUIREMENTS] [-f FORMAT] [-s SERVICE] [-d] [-S] [--desc [{on,off,auto}]] [--cache-dir CACHE_DIR] [--progress-spinner {on,off}] [--timeout TIMEOUT] - [--path PATHS] [-v] [--fix] + [--path PATHS] [-v] [--fix] [--require-hashes] audit the Python environment for dependencies with known vulnerabilities @@ -115,6 +115,10 @@ optional arguments: setting it to `debug` (default: False) --fix automatically upgrade dependencies with known vulnerabilities (default: False) + --require-hashes require a hash to check each requirement against, for + repeatable audits; this option is implied when any + package in a requirements file has a --hash option. + (default: False) ``` From 194d211bd04db76223b9f952abddf070933a8ed0 Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Thu, 3 Feb 2022 12:21:29 +1100 Subject: [PATCH 08/10] README, _cli: Tweak help text Co-authored-by: William Woodruff --- README.md | 2 +- pip_audit/_cli.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a5c6eeb3..cd0f73ea 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ optional arguments: vulnerabilities (default: False) --require-hashes require a hash to check each requirement against, for repeatable audits; this option is implied when any - package in a requirements file has a --hash option. + package in a requirements file has a `--hash` option. (default: False) ``` diff --git a/pip_audit/_cli.py b/pip_audit/_cli.py index b3e3c9a9..0da2b1ab 100644 --- a/pip_audit/_cli.py +++ b/pip_audit/_cli.py @@ -243,7 +243,7 @@ def _parser() -> argparse.ArgumentParser: "--require-hashes", action="store_true", help="require a hash to check each requirement against, for repeatable audits; this option " - "is implied when any package in a requirements file has a --hash option.", + "is implied when any package in a requirements file has a `--hash` option.", ) return parser From f667aea6939823e9c9b5a741d9642b9bd245cc15 Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Thu, 3 Feb 2022 12:49:04 +1100 Subject: [PATCH 09/10] _cli: Ensure that `--require-hashes` is used with `-r` --- pip_audit/_cli.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pip_audit/_cli.py b/pip_audit/_cli.py index 0da2b1ab..4891ae4a 100644 --- a/pip_audit/_cli.py +++ b/pip_audit/_cli.py @@ -268,6 +268,10 @@ def audit() -> None: output_desc = args.desc.to_bool(args.format) formatter = args.format.to_format(output_desc) + # The `--require-hashes` flag is only valid with requirements files + if args.require_hashes and args.requirements is None: + parser.error("The --require-hashes flag can only be used with --requirement (-r)") + with ExitStack() as stack: actors = [] if args.progress_spinner: From 8cfbb4b9d94b5bc1aa555852177f69b920f6e2fd Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Fri, 4 Feb 2022 10:31:04 +1100 Subject: [PATCH 10/10] setup: Pin `pip-api` to new version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 94aa5f84..b97fc0a6 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,7 @@ platforms="any", python_requires=">=3.7", install_requires=[ - "pip-api @ git+ssh://git@github.com/woodruffw-forks/pip-api@ww/hash-support", + "pip-api>=0.0.27", "packaging>=21.0.0", "progress>=1.6", "resolvelib>=0.8.0",