From 1c9ae04bc8c9cd9a2c35079e398b99d0d45c76b0 Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Wed, 12 Jan 2022 16:06:22 +1100 Subject: [PATCH 01/16] _cli, _fix: Implement resolving of fix versions --- pip_audit/_cli.py | 12 ++++++ pip_audit/_dependency_source/interface.py | 14 ++++++ pip_audit/_fix.py | 52 +++++++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 pip_audit/_fix.py diff --git a/pip_audit/_cli.py b/pip_audit/_cli.py index f8e3f0f5..aa59a75c 100644 --- a/pip_audit/_cli.py +++ b/pip_audit/_cli.py @@ -19,6 +19,7 @@ RequirementSource, ResolveLibResolver, ) +from pip_audit._fix import resolve_fix_versions from pip_audit._format import ColumnsFormat, CycloneDxFormat, JsonFormat, VulnerabilityFormat from pip_audit._service import OsvService, PyPIService, VulnerabilityService from pip_audit._service.interface import ResolvedDependency, SkippedDependency @@ -234,6 +235,11 @@ def audit() -> None: help="give more output; this setting overrides the `PIP_AUDIT_LOGLEVEL` variable and is " "equivalent to setting it to `debug`", ) + parser.add_argument( + "--fix", + action="store_true", + help="automatically upgrade dependencies with known vulnerabilities", + ) args = parser.parse_args() if args.verbose: @@ -288,3 +294,9 @@ def audit() -> None: sys.exit(1) else: print("No known vulnerabilities found", file=sys.stderr) + + # If the `--fix` flag has been applied, find a set of suitable fix versions and upgrade the + # dependencies at the source + if args.fix: + fix_versions = resolve_fix_versions(service, result) + source.fix_all(fix_versions) diff --git a/pip_audit/_dependency_source/interface.py b/pip_audit/_dependency_source/interface.py index 13f49c39..7edeebde 100644 --- a/pip_audit/_dependency_source/interface.py +++ b/pip_audit/_dependency_source/interface.py @@ -7,6 +7,7 @@ from typing import Iterator, List, Tuple from packaging.requirements import Requirement +from packaging.version import Version from pip_audit._service import Dependency @@ -26,6 +27,19 @@ def collect(self) -> Iterator[Dependency]: # pragma: no cover """ raise NotImplementedError + def fix(self, dep: Dependency, fix_version: Version) -> None: + """ + Upgrade a dependency to the given fix version. + """ + raise NotImplementedError + + def fix_all(self, fix_req: Iterator[Tuple[Dependency, Version]]) -> None: + """ + Upgrade a collection of dependencies to their associated fix versions. + """ + for (dep, fix_version) in fix_req: + self.fix(dep, fix_version) + class DependencySourceError(Exception): """ diff --git a/pip_audit/_fix.py b/pip_audit/_fix.py new file mode 100644 index 00000000..1a3cab6d --- /dev/null +++ b/pip_audit/_fix.py @@ -0,0 +1,52 @@ +""" +Resolving fix versions. +""" + +from typing import Dict, Iterator, List, Tuple, cast + +from packaging.version import Version + +from pip_audit._service import ( + Dependency, + ResolvedDependency, + VulnerabilityResult, + VulnerabilityService, +) + + +def resolve_fix_versions( + service: VulnerabilityService, result: Dict[Dependency, List[VulnerabilityResult]] +) -> Iterator[Tuple[ResolvedDependency, Version]]: + for (dep, vulns) in result.items(): + if dep.is_skipped(): + continue + if not vulns: + continue + dep = cast(ResolvedDependency, dep) + yield (dep, _resolve_fix_version(service, dep, vulns)) + + +def _resolve_fix_version( + service: VulnerabilityService, dep: ResolvedDependency, vulns: List[VulnerabilityResult] +) -> Version: + # We need to upgrade to a fix version that satisfies all vulnerability results + # + # However, whenever we upgrade a dependency, we run the risk of introducing new vulnerabilities + # so we need to run this in a loop and continue polling the vulnerability service on each + # prospective resolved fix version + current_version = dep.version + current_vulns = vulns + while current_vulns: + + def get_earliest_fix_version(fix_versions: List[Version]) -> Version: + for v in fix_versions: + if v > current_version: + return v + raise RuntimeError + + # We want to retrieve a version that potentially fixes all vulnerabilities + current_version = max( + [get_earliest_fix_version(v.fix_versions) for v in current_vulns if v.fix_versions] + ) + _, current_vulns = service.query(ResolvedDependency(dep.name, current_version)) + return current_version From 82d644153112ad2be0f1708e991956283b8b21d9 Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Wed, 12 Jan 2022 17:18:15 +1100 Subject: [PATCH 02/16] pip: Implement fix --- pip_audit/_dependency_source/pip.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pip_audit/_dependency_source/pip.py b/pip_audit/_dependency_source/pip.py index 61c574b9..e967dc8e 100644 --- a/pip_audit/_dependency_source/pip.py +++ b/pip_audit/_dependency_source/pip.py @@ -4,6 +4,8 @@ """ import logging +import subprocess +import sys from pathlib import Path from typing import Iterator, Sequence @@ -87,6 +89,18 @@ def collect(self) -> Iterator[Dependency]: except Exception as e: raise PipSourceError("failed to list installed distributions") from e + def fix(self, dep: Dependency, fix_version: Version) -> None: + """ + Fixes a dependency version in this `PipSource`. + """ + fix_cmd = [sys.executable, "-m", "pip", "install", "f{dep.name}=={fix_version}"] + try: + subprocess.run( + fix_cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + except subprocess.CalledProcessError as cpe: + raise RuntimeError from cpe + class PipSourceError(DependencySourceError): """A `pip` specific `DependencySourceError`.""" From 4000b2b0ef61d4a5cb1e353b4685ab3432c27b53 Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Wed, 12 Jan 2022 17:34:28 +1100 Subject: [PATCH 03/16] _cli: Minor fixes --- pip_audit/_cli.py | 12 ++++++------ pip_audit/_dependency_source/pip.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pip_audit/_cli.py b/pip_audit/_cli.py index aa59a75c..d6be453f 100644 --- a/pip_audit/_cli.py +++ b/pip_audit/_cli.py @@ -286,6 +286,12 @@ def audit() -> None: pkg_count += 1 vuln_count += len(vulns) + # If the `--fix` flag has been applied, find a set of suitable fix versions and upgrade the + # dependencies at the source + if args.fix: + fix_versions = resolve_fix_versions(service, result) + source.fix_all(fix_versions) + # TODO(ww): Refine this: we should always output if our output format is an SBOM # or other manifest format (like the default JSON format). if vuln_count > 0: @@ -294,9 +300,3 @@ def audit() -> None: sys.exit(1) else: print("No known vulnerabilities found", file=sys.stderr) - - # If the `--fix` flag has been applied, find a set of suitable fix versions and upgrade the - # dependencies at the source - if args.fix: - fix_versions = resolve_fix_versions(service, result) - source.fix_all(fix_versions) diff --git a/pip_audit/_dependency_source/pip.py b/pip_audit/_dependency_source/pip.py index e967dc8e..60d3b94e 100644 --- a/pip_audit/_dependency_source/pip.py +++ b/pip_audit/_dependency_source/pip.py @@ -93,7 +93,7 @@ def fix(self, dep: Dependency, fix_version: Version) -> None: """ Fixes a dependency version in this `PipSource`. """ - fix_cmd = [sys.executable, "-m", "pip", "install", "f{dep.name}=={fix_version}"] + fix_cmd = [sys.executable, "-m", "pip", "install", f"{dep.name}=={fix_version}"] try: subprocess.run( fix_cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL From a9bcb0cfb54ba3ca0631254f6e91f8b8ddc3e993 Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Wed, 12 Jan 2022 19:32:10 +1100 Subject: [PATCH 04/16] _dependency_source: Make `fix` an abstract method --- pip_audit/_dependency_source/interface.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pip_audit/_dependency_source/interface.py b/pip_audit/_dependency_source/interface.py index 7edeebde..aa255b9e 100644 --- a/pip_audit/_dependency_source/interface.py +++ b/pip_audit/_dependency_source/interface.py @@ -27,6 +27,7 @@ def collect(self) -> Iterator[Dependency]: # pragma: no cover """ raise NotImplementedError + @abstractmethod def fix(self, dep: Dependency, fix_version: Version) -> None: """ Upgrade a dependency to the given fix version. From 402783df39fc8cebf5c2d3203914fea5f8323966 Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Thu, 13 Jan 2022 12:19:37 +1100 Subject: [PATCH 05/16] pip, _fix: Use more appropriate exception types --- pip_audit/_dependency_source/pip.py | 4 +++- pip_audit/_dependency_source/requirement.py | 7 +++++++ pip_audit/_fix.py | 23 +++++++++++++++------ 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/pip_audit/_dependency_source/pip.py b/pip_audit/_dependency_source/pip.py index 60d3b94e..52e9ab9d 100644 --- a/pip_audit/_dependency_source/pip.py +++ b/pip_audit/_dependency_source/pip.py @@ -99,7 +99,9 @@ def fix(self, dep: Dependency, fix_version: Version) -> None: fix_cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) except subprocess.CalledProcessError as cpe: - raise RuntimeError from cpe + raise PipSourceError( + f"failed to upgrade dependency {dep.name} to fix version {fix_version}" + ) from cpe class PipSourceError(DependencySourceError): diff --git a/pip_audit/_dependency_source/requirement.py b/pip_audit/_dependency_source/requirement.py index cf3b3c28..df4ddeca 100644 --- a/pip_audit/_dependency_source/requirement.py +++ b/pip_audit/_dependency_source/requirement.py @@ -6,6 +6,7 @@ from typing import Iterator, List, Set, cast from packaging.requirements import Requirement +from packaging.version import Version from pip_api import parse_requirements from pip_api.exceptions import PipError @@ -78,6 +79,12 @@ def collect(self) -> Iterator[Dependency]: except DependencyResolverError as dre: raise RequirementSourceError("dependency resolver raised an error") from dre + def fix(self, dep: Dependency, fix_version: Version) -> None: + """ + Fixes a dependency version for this `RequirementSource`. + """ + raise NotImplementedError + class RequirementSourceError(DependencySourceError): """A requirements-parsing specific `DependencySourceError`.""" diff --git a/pip_audit/_fix.py b/pip_audit/_fix.py index 1a3cab6d..dc8086ad 100644 --- a/pip_audit/_fix.py +++ b/pip_audit/_fix.py @@ -38,15 +38,26 @@ def _resolve_fix_version( current_vulns = vulns while current_vulns: - def get_earliest_fix_version(fix_versions: List[Version]) -> Version: - for v in fix_versions: - if v > current_version: - return v - raise RuntimeError + def get_earliest_fix_version(d: ResolvedDependency, v: VulnerabilityResult) -> Version: + for fix_version in v.fix_versions: + if fix_version > current_version: + return fix_version + raise FixResolutionImpossible( + f"Failed to fix dependency {dep.name}, unable to find fix version for " + f"vulnerability {v.id}" + ) # We want to retrieve a version that potentially fixes all vulnerabilities current_version = max( - [get_earliest_fix_version(v.fix_versions) for v in current_vulns if v.fix_versions] + [get_earliest_fix_version(dep, v) for v in current_vulns if v.fix_versions] ) _, current_vulns = service.query(ResolvedDependency(dep.name, current_version)) return current_version + + +class FixResolutionImpossible(Exception): + """ + Raised when `resolve_fix_versions` fails to find a fix version without known vulnerabilities + """ + + pass From a4898d5cf94827c419eb802bed0034032dc18d02 Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Thu, 13 Jan 2022 12:20:42 +1100 Subject: [PATCH 06/16] _fix: Make error messages consistent --- pip_audit/_fix.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pip_audit/_fix.py b/pip_audit/_fix.py index dc8086ad..58979551 100644 --- a/pip_audit/_fix.py +++ b/pip_audit/_fix.py @@ -43,7 +43,7 @@ def get_earliest_fix_version(d: ResolvedDependency, v: VulnerabilityResult) -> V if fix_version > current_version: return fix_version raise FixResolutionImpossible( - f"Failed to fix dependency {dep.name}, unable to find fix version for " + f"failed to fix dependency {dep.name}, unable to find fix version for " f"vulnerability {v.id}" ) From 434f890a4286e0033e6f15e74702dd69fd5e019d Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Thu, 13 Jan 2022 14:05:59 +1100 Subject: [PATCH 07/16] pip_audit: Propagate fix information up to CLI layer --- pip_audit/_cli.py | 26 ++++++++++++--- pip_audit/_dependency_source/interface.py | 11 ++----- pip_audit/_dependency_source/pip.py | 14 +++++++-- pip_audit/_dependency_source/requirement.py | 4 +-- pip_audit/_fix.py | 35 +++++++++++++++++++-- 5 files changed, 69 insertions(+), 21 deletions(-) diff --git a/pip_audit/_cli.py b/pip_audit/_cli.py index d6be453f..a6f283c4 100644 --- a/pip_audit/_cli.py +++ b/pip_audit/_cli.py @@ -19,7 +19,8 @@ RequirementSource, ResolveLibResolver, ) -from pip_audit._fix import resolve_fix_versions +from pip_audit._dependency_source.interface import DependencySourceError +from pip_audit._fix import ResolvedFixVersion, SkippedFixVersion, resolve_fix_versions from pip_audit._format import ColumnsFormat, CycloneDxFormat, JsonFormat, VulnerabilityFormat from pip_audit._service import OsvService, PyPIService, VulnerabilityService from pip_audit._service.interface import ResolvedDependency, SkippedDependency @@ -288,15 +289,32 @@ def audit() -> None: # If the `--fix` flag has been applied, find a set of suitable fix versions and upgrade the # dependencies at the source + fixes = list() + fixed_pkg_count = 0 + fixed_vuln_count = 0 if args.fix: - fix_versions = resolve_fix_versions(service, result) - source.fix_all(fix_versions) + for fix_version in resolve_fix_versions(service, result): + if not fix_version.is_skipped(): + fix_version = cast(ResolvedFixVersion, fix_version) + try: + source.fix(fix_version) + fixed_pkg_count += 1 + fixed_vuln_count += len(result[fix_version.dep]) + except DependencySourceError as dse: + fix_version = SkippedFixVersion(fix_version.dep, str(dse)) + fixes.append(fix_version) # TODO(ww): Refine this: we should always output if our output format is an SBOM # or other manifest format (like the default JSON format). if vuln_count > 0: print(f"Found {vuln_count} known vulnerabilities in {pkg_count} packages", file=sys.stderr) + if args.fix: + print( + f" and fixed {fixed_vuln_count} vulnerabilities in {fixed_pkg_count} packages", + file=sys.stderr, + ) print(formatter.format(result)) - sys.exit(1) + if pkg_count != fixed_pkg_count: + sys.exit(1) else: print("No known vulnerabilities found", file=sys.stderr) diff --git a/pip_audit/_dependency_source/interface.py b/pip_audit/_dependency_source/interface.py index aa255b9e..3d577e98 100644 --- a/pip_audit/_dependency_source/interface.py +++ b/pip_audit/_dependency_source/interface.py @@ -7,8 +7,8 @@ from typing import Iterator, List, Tuple from packaging.requirements import Requirement -from packaging.version import Version +from pip_audit._fix import ResolvedFixVersion from pip_audit._service import Dependency @@ -28,19 +28,12 @@ def collect(self) -> Iterator[Dependency]: # pragma: no cover raise NotImplementedError @abstractmethod - def fix(self, dep: Dependency, fix_version: Version) -> None: + def fix(self, fix_version: ResolvedFixVersion) -> None: """ Upgrade a dependency to the given fix version. """ raise NotImplementedError - def fix_all(self, fix_req: Iterator[Tuple[Dependency, Version]]) -> None: - """ - Upgrade a collection of dependencies to their associated fix versions. - """ - for (dep, fix_version) in fix_req: - self.fix(dep, fix_version) - class DependencySourceError(Exception): """ diff --git a/pip_audit/_dependency_source/pip.py b/pip_audit/_dependency_source/pip.py index 52e9ab9d..9cd0654d 100644 --- a/pip_audit/_dependency_source/pip.py +++ b/pip_audit/_dependency_source/pip.py @@ -13,6 +13,7 @@ from packaging.version import InvalidVersion, Version from pip_audit._dependency_source import DependencySource, DependencySourceError +from pip_audit._fix import ResolvedFixVersion from pip_audit._service import Dependency, ResolvedDependency, SkippedDependency from pip_audit._state import AuditState @@ -89,18 +90,25 @@ def collect(self) -> Iterator[Dependency]: except Exception as e: raise PipSourceError("failed to list installed distributions") from e - def fix(self, dep: Dependency, fix_version: Version) -> None: + def fix(self, fix_version: ResolvedFixVersion) -> None: """ Fixes a dependency version in this `PipSource`. """ - fix_cmd = [sys.executable, "-m", "pip", "install", f"{dep.name}=={fix_version}"] + fix_cmd = [ + sys.executable, + "-m", + "pip", + "install", + f"{fix_version.dep.name}=={fix_version.version}", + ] try: subprocess.run( fix_cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) except subprocess.CalledProcessError as cpe: raise PipSourceError( - f"failed to upgrade dependency {dep.name} to fix version {fix_version}" + f"failed to upgrade dependency {fix_version.dep.name} to fix version " + f"{fix_version.version}" ) from cpe diff --git a/pip_audit/_dependency_source/requirement.py b/pip_audit/_dependency_source/requirement.py index df4ddeca..4e8066aa 100644 --- a/pip_audit/_dependency_source/requirement.py +++ b/pip_audit/_dependency_source/requirement.py @@ -6,7 +6,6 @@ from typing import Iterator, List, Set, cast from packaging.requirements import Requirement -from packaging.version import Version from pip_api import parse_requirements from pip_api.exceptions import PipError @@ -16,6 +15,7 @@ DependencySource, DependencySourceError, ) +from pip_audit._fix import ResolvedFixVersion from pip_audit._service import Dependency from pip_audit._service.interface import ResolvedDependency, SkippedDependency from pip_audit._state import AuditState @@ -79,7 +79,7 @@ def collect(self) -> Iterator[Dependency]: except DependencyResolverError as dre: raise RequirementSourceError("dependency resolver raised an error") from dre - def fix(self, dep: Dependency, fix_version: Version) -> None: + def fix(self, fix_version: ResolvedFixVersion) -> None: """ Fixes a dependency version for this `RequirementSource`. """ diff --git a/pip_audit/_fix.py b/pip_audit/_fix.py index 58979551..aa1f9136 100644 --- a/pip_audit/_fix.py +++ b/pip_audit/_fix.py @@ -2,7 +2,8 @@ Resolving fix versions. """ -from typing import Dict, Iterator, List, Tuple, cast +from dataclasses import dataclass +from typing import Dict, Iterator, List, cast from packaging.version import Version @@ -14,16 +15,44 @@ ) +@dataclass(frozen=True) +class FixVersion: + dep: ResolvedDependency + + def __init__(self, *_args, **_kwargs) -> None: + raise NotImplementedError + + def is_skipped(self) -> bool: + """ + Check whether the `FixVersion` was skipped + """ + return self.__class__ is SkippedFixVersion + + +@dataclass(frozen=True) +class ResolvedFixVersion(FixVersion): + version: Version + + +@dataclass(frozen=True) +class SkippedFixVersion(FixVersion): + skip_reason: str + + def resolve_fix_versions( service: VulnerabilityService, result: Dict[Dependency, List[VulnerabilityResult]] -) -> Iterator[Tuple[ResolvedDependency, Version]]: +) -> Iterator[FixVersion]: for (dep, vulns) in result.items(): if dep.is_skipped(): continue if not vulns: continue dep = cast(ResolvedDependency, dep) - yield (dep, _resolve_fix_version(service, dep, vulns)) + try: + version = _resolve_fix_version(service, dep, vulns) + yield ResolvedFixVersion(dep, version) + except FixResolutionImpossible as fri: + yield SkippedFixVersion(dep, str(fri)) def _resolve_fix_version( From 1e477064eec9a2be2770249c11e1677f6348bcc4 Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Thu, 13 Jan 2022 17:42:44 +1100 Subject: [PATCH 08/16] _fix: Add documentation --- pip_audit/_fix.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/pip_audit/_fix.py b/pip_audit/_fix.py index aa1f9136..913a91b9 100644 --- a/pip_audit/_fix.py +++ b/pip_audit/_fix.py @@ -17,31 +17,52 @@ @dataclass(frozen=True) class FixVersion: + """ + Represents an abstract dependency fix version. + + This class cannot be constructed directly. + """ + dep: ResolvedDependency def __init__(self, *_args, **_kwargs) -> None: + """ + A stub constructor that always fails. + """ raise NotImplementedError def is_skipped(self) -> bool: """ - Check whether the `FixVersion` was skipped + Check whether the `FixVersion` was unable to be resolved. """ return self.__class__ is SkippedFixVersion @dataclass(frozen=True) class ResolvedFixVersion(FixVersion): + """ + Represents a resolved fix version. + """ + version: Version @dataclass(frozen=True) class SkippedFixVersion(FixVersion): + """ + Represents a fix version that was unable to be resolved and therefore, skipped. + """ + skip_reason: str def resolve_fix_versions( service: VulnerabilityService, result: Dict[Dependency, List[VulnerabilityResult]] ) -> Iterator[FixVersion]: + """ + Resolves a mapping of dependencies to known vulnerabilities to a series of fix versions without + known vulnerabilties. + """ for (dep, vulns) in result.items(): if dep.is_skipped(): continue From 6e1a1866430e11fbc48811f5251e8b66a9bce6a3 Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Thu, 13 Jan 2022 17:47:18 +1100 Subject: [PATCH 09/16] conftest: Get tests passing --- test/conftest.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/conftest.py b/test/conftest.py index b6366be8..a0e97ea3 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -48,6 +48,9 @@ class Source(DependencySource): def collect(self): yield spec("1.0.1") + def fix(self, _) -> None: + raise NotImplementedError + return Source From 889bb0dd897885cfe2fb29c48c7ae83e069282d4 Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Thu, 13 Jan 2022 18:26:24 +1100 Subject: [PATCH 10/16] test: Fill in coverage for `PipSource` --- pip_audit/_dependency_source/interface.py | 2 +- pip_audit/_dependency_source/pip.py | 2 +- pip_audit/_dependency_source/requirement.py | 2 +- test/dependency_source/test_pip.py | 35 +++++++++++++++++++++ 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/pip_audit/_dependency_source/interface.py b/pip_audit/_dependency_source/interface.py index 3d577e98..8f4a0b26 100644 --- a/pip_audit/_dependency_source/interface.py +++ b/pip_audit/_dependency_source/interface.py @@ -28,7 +28,7 @@ def collect(self) -> Iterator[Dependency]: # pragma: no cover raise NotImplementedError @abstractmethod - def fix(self, fix_version: ResolvedFixVersion) -> None: + def fix(self, fix_version: ResolvedFixVersion) -> None: # pragma: no cover """ Upgrade a dependency to the given fix version. """ diff --git a/pip_audit/_dependency_source/pip.py b/pip_audit/_dependency_source/pip.py index 9cd0654d..5082f8c7 100644 --- a/pip_audit/_dependency_source/pip.py +++ b/pip_audit/_dependency_source/pip.py @@ -99,7 +99,7 @@ def fix(self, fix_version: ResolvedFixVersion) -> None: "-m", "pip", "install", - f"{fix_version.dep.name}=={fix_version.version}", + f"{fix_version.dep.canonical_name}=={fix_version.version}", ] try: subprocess.run( diff --git a/pip_audit/_dependency_source/requirement.py b/pip_audit/_dependency_source/requirement.py index 4e8066aa..a8804f93 100644 --- a/pip_audit/_dependency_source/requirement.py +++ b/pip_audit/_dependency_source/requirement.py @@ -79,7 +79,7 @@ def collect(self) -> Iterator[Dependency]: except DependencyResolverError as dre: raise RequirementSourceError("dependency resolver raised an error") from dre - def fix(self, fix_version: ResolvedFixVersion) -> None: + def fix(self, fix_version: ResolvedFixVersion) -> None: # pragma: no cover """ Fixes a dependency version for this `RequirementSource`. """ diff --git a/test/dependency_source/test_pip.py b/test/dependency_source/test_pip.py index 9a62aa5d..1834d18d 100644 --- a/test/dependency_source/test_pip.py +++ b/test/dependency_source/test_pip.py @@ -1,4 +1,6 @@ import os +import subprocess +import sys from dataclasses import dataclass from typing import Dict, List @@ -8,6 +10,7 @@ from packaging.version import Version from pip_audit._dependency_source import pip +from pip_audit._fix import ResolvedFixVersion from pip_audit._service.interface import ResolvedDependency, SkippedDependency @@ -82,3 +85,35 @@ def mock_installed_distributions( in specs ) assert ResolvedDependency(name="pip-api", version=Version("1.0")) in specs + + +def test_pip_source_fix(monkeypatch): + source = pip.PipSource() + + fix_version = ResolvedFixVersion( + dep=ResolvedDependency(name="pip-api", version=Version("1.0")), version=Version("1.5") + ) + + def run_mock(args, **kwargs): + assert " ".join(args) == f"{sys.executable} -m pip install pip-api==1.5" + + monkeypatch.setattr(subprocess, "run", run_mock) + + source.fix(fix_version) + + +def test_pip_source_fix_failure(monkeypatch): + source = pip.PipSource() + + fix_version = ResolvedFixVersion( + dep=ResolvedDependency(name="pip-api", version=Version("1.0")), version=Version("1.5") + ) + + def run_mock(args, **kwargs): + assert " ".join(args) == f"{sys.executable} -m pip install pip-api==1.5" + raise subprocess.CalledProcessError(-1, str()) + + monkeypatch.setattr(subprocess, "run", run_mock) + + with pytest.raises(pip.PipSourceError): + source.fix(fix_version) From 3595eb7328b03078983d1acdf909b73f80264cb0 Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Thu, 13 Jan 2022 22:25:49 +1100 Subject: [PATCH 11/16] test: Unit test fix version resolution --- pip_audit/_fix.py | 10 +++---- test/test_fix.py | 69 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 6 deletions(-) create mode 100644 test/test_fix.py diff --git a/pip_audit/_fix.py b/pip_audit/_fix.py index 913a91b9..a7ef9c2b 100644 --- a/pip_audit/_fix.py +++ b/pip_audit/_fix.py @@ -25,7 +25,7 @@ class FixVersion: dep: ResolvedDependency - def __init__(self, *_args, **_kwargs) -> None: + def __init__(self, *_args, **_kwargs) -> None: # pragma: no cover """ A stub constructor that always fails. """ @@ -93,14 +93,12 @@ def get_earliest_fix_version(d: ResolvedDependency, v: VulnerabilityResult) -> V if fix_version > current_version: return fix_version raise FixResolutionImpossible( - f"failed to fix dependency {dep.name}, unable to find fix version for " - f"vulnerability {v.id}" + f"failed to fix dependency {dep.name} ({dep.version}), unable to find fix version " + f"for vulnerability {v.id}" ) # We want to retrieve a version that potentially fixes all vulnerabilities - current_version = max( - [get_earliest_fix_version(dep, v) for v in current_vulns if v.fix_versions] - ) + current_version = max([get_earliest_fix_version(dep, v) for v in current_vulns]) _, current_vulns = service.query(ResolvedDependency(dep.name, current_version)) return current_version diff --git a/test/test_fix.py b/test/test_fix.py new file mode 100644 index 00000000..71cfe789 --- /dev/null +++ b/test/test_fix.py @@ -0,0 +1,69 @@ +from typing import Dict, List + +from packaging.version import Version + +from pip_audit._fix import ResolvedFixVersion, SkippedFixVersion, resolve_fix_versions +from pip_audit._service import ( + Dependency, + ResolvedDependency, + SkippedDependency, + VulnerabilityResult, +) + + +def test_fix(vuln_service): + dep = ResolvedDependency(name="foo", version=Version("0.5.0")) + result: Dict[Dependency, List[VulnerabilityResult]] = { + dep: [ + VulnerabilityResult( + id="fake-id", + description="this is not a real result", + fix_versions=[Version("1.0.0")], + ) + ] + } + fix_versions = list(resolve_fix_versions(vuln_service(), result)) + assert len(fix_versions) == 1 + assert fix_versions[0] == ResolvedFixVersion(dep=dep, version=Version("1.1.0")) + assert not fix_versions[0].is_skipped() + + +def test_fix_skipped_deps(vuln_service): + dep = SkippedDependency(name="foo", skip_reason="skip-reason") + result: Dict[Dependency, List[VulnerabilityResult]] = { + dep: [ + VulnerabilityResult( + id="fake-id", + description="this is not a real result", + fix_versions=[Version("1.0.0")], + ) + ] + } + fix_versions = list(resolve_fix_versions(vuln_service(), result)) + assert not fix_versions + + +def test_fix_no_vulns(vuln_service): + dep = ResolvedDependency(name="foo", version=Version("0.5.0")) + result: Dict[Dependency, List[VulnerabilityResult]] = {dep: list()} + fix_versions = list(resolve_fix_versions(vuln_service(), result)) + assert not fix_versions + + +def test_fix_resolution_impossible(vuln_service): + dep = ResolvedDependency(name="foo", version=Version("0.5.0")) + result: Dict[Dependency, List[VulnerabilityResult]] = { + dep: [ + VulnerabilityResult( + id="fake-id", description="this is not a real result", fix_versions=list() + ) + ] + } + fix_versions = list(resolve_fix_versions(vuln_service(), result)) + assert len(fix_versions) == 1 + assert fix_versions[0] == SkippedFixVersion( + dep=dep, + skip_reason="failed to fix dependency foo (0.5.0), unable to find fix version for " + "vulnerability fake-id", + ) + assert fix_versions[0].is_skipped() From 31d72ece2a3d24cb28b45ace395d074637e6e265 Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Thu, 13 Jan 2022 22:30:13 +1100 Subject: [PATCH 12/16] README: Update help text --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e3ed3005..ae06e5f3 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] + [--path PATHS] [-v] [--fix] audit the Python environment for dependencies with known vulnerabilities @@ -111,6 +111,8 @@ optional arguments: -v, --verbose give more output; this setting overrides the `PIP_AUDIT_LOGLEVEL` variable and is equivalent to setting it to `debug` (default: False) + --fix automatically upgrade dependencies with known + vulnerabilities (default: False) ``` From 682a58c61261ab6fc3be740cc97d3e584a3bed90 Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Thu, 13 Jan 2022 22:34:23 +1100 Subject: [PATCH 13/16] _cli: Fix summary msg --- pip_audit/_cli.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pip_audit/_cli.py b/pip_audit/_cli.py index a6f283c4..a5608509 100644 --- a/pip_audit/_cli.py +++ b/pip_audit/_cli.py @@ -307,12 +307,12 @@ def audit() -> None: # TODO(ww): Refine this: we should always output if our output format is an SBOM # or other manifest format (like the default JSON format). if vuln_count > 0: - print(f"Found {vuln_count} known vulnerabilities in {pkg_count} packages", file=sys.stderr) + summary_msg = f"Found {vuln_count} known vulnerabilities in {pkg_count} packages" if args.fix: - print( - f" and fixed {fixed_vuln_count} vulnerabilities in {fixed_pkg_count} packages", - file=sys.stderr, + summary_msg += ( + f" and fixed {fixed_vuln_count} vulnerabilities in {fixed_pkg_count} packages" ) + print(summary_msg, file=sys.stderr) print(formatter.format(result)) if pkg_count != fixed_pkg_count: sys.exit(1) From a9b5bf55319c25a87d529d1aa78d935795ea06b1 Mon Sep 17 00:00:00 2001 From: Alex Cameron Date: Thu, 13 Jan 2022 22:42:38 +1100 Subject: [PATCH 14/16] README: Add `--fix` example to doc --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index ae06e5f3..33754fef 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,16 @@ Found 2 known vulnerabilities in 1 packages ] ``` +Audit and attempt to automatically upgrade vulnerable dependencies: +``` +$ pip-audit --fix +Found 2 known vulnerabilities in 1 packages and fixed 2 vulnerabilities in 1 packages +Name Version ID Fix Versions +----- ------- -------------- ------------ +Flask 0.5 PYSEC-2019-179 1.0 +Flask 0.5 PYSEC-2018-66 0.12.3 +``` + ## Security Model This section exists to describe the security assumptions you **can** and **must not** From 0f6b4adbfd0624dbe1a648bdf3961a296dd5becc Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 13 Jan 2022 11:02:13 -0500 Subject: [PATCH 15/16] pip_audit, test: add a separate DependencyFixError hierarchy --- pip_audit/_dependency_source/__init__.py | 2 ++ pip_audit/_dependency_source/interface.py | 12 ++++++++++++ pip_audit/_dependency_source/pip.py | 10 ++++++++-- pip_audit/_fix.py | 2 +- test/dependency_source/test_pip.py | 2 +- 5 files changed, 24 insertions(+), 4 deletions(-) diff --git a/pip_audit/_dependency_source/__init__.py b/pip_audit/_dependency_source/__init__.py index 66c8cd05..ab19146d 100644 --- a/pip_audit/_dependency_source/__init__.py +++ b/pip_audit/_dependency_source/__init__.py @@ -3,6 +3,7 @@ """ from .interface import ( + DependencyFixError, DependencyResolver, DependencyResolverError, DependencySource, @@ -13,6 +14,7 @@ from .resolvelib import ResolveLibResolver __all__ = [ + "DependencyFixError", "DependencyResolver", "DependencyResolverError", "DependencySource", diff --git a/pip_audit/_dependency_source/interface.py b/pip_audit/_dependency_source/interface.py index 8f4a0b26..6bd5590f 100644 --- a/pip_audit/_dependency_source/interface.py +++ b/pip_audit/_dependency_source/interface.py @@ -46,6 +46,18 @@ class DependencySourceError(Exception): pass +class DependencyFixError(Exception): + """ + Raised when a `DependencySource` fails to perform a "fix" operation, i.e. + fails to upgrade a package to a different version. + + Concrete implementations are expected to subclass this exception to provide + more context. + """ + + pass + + class DependencyResolver(ABC): """ Represents an abstract resolver of Python dependencies that takes a single diff --git a/pip_audit/_dependency_source/pip.py b/pip_audit/_dependency_source/pip.py index 5082f8c7..db19559f 100644 --- a/pip_audit/_dependency_source/pip.py +++ b/pip_audit/_dependency_source/pip.py @@ -12,7 +12,7 @@ import pip_api from packaging.version import InvalidVersion, Version -from pip_audit._dependency_source import DependencySource, DependencySourceError +from pip_audit._dependency_source import DependencyFixError, DependencySource, DependencySourceError from pip_audit._fix import ResolvedFixVersion from pip_audit._service import Dependency, ResolvedDependency, SkippedDependency from pip_audit._state import AuditState @@ -106,7 +106,7 @@ def fix(self, fix_version: ResolvedFixVersion) -> None: fix_cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) except subprocess.CalledProcessError as cpe: - raise PipSourceError( + raise PipFixError( f"failed to upgrade dependency {fix_version.dep.name} to fix version " f"{fix_version.version}" ) from cpe @@ -116,3 +116,9 @@ class PipSourceError(DependencySourceError): """A `pip` specific `DependencySourceError`.""" pass + + +class PipFixError(DependencyFixError): + """A `pip` specific `DependencyFixError`.""" + + pass diff --git a/pip_audit/_fix.py b/pip_audit/_fix.py index a7ef9c2b..33cb203b 100644 --- a/pip_audit/_fix.py +++ b/pip_audit/_fix.py @@ -1,5 +1,5 @@ """ -Resolving fix versions. +Functionality for resolving fixed versions of dependencies. """ from dataclasses import dataclass diff --git a/test/dependency_source/test_pip.py b/test/dependency_source/test_pip.py index 1834d18d..c8506ab8 100644 --- a/test/dependency_source/test_pip.py +++ b/test/dependency_source/test_pip.py @@ -115,5 +115,5 @@ def run_mock(args, **kwargs): monkeypatch.setattr(subprocess, "run", run_mock) - with pytest.raises(pip.PipSourceError): + with pytest.raises(pip.PipFixError): source.fix(fix_version) From 64e3e5f6dba2399c5cb1f892980e9bd230d061d6 Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Thu, 13 Jan 2022 11:07:04 -0500 Subject: [PATCH 16/16] CHANGELOG: record changes --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f147eb6..85941e1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ All versions prior to 0.0.9 are untracked. ### Added +* CLI: The `--fix` flag has been added, allowing users to attempt to + automatically upgrade any vulnerable dependencies to the first safe version + available (#[212](https://github.com/trailofbits/pip-audit/pull/212)) + ### Changed ### Fixed