diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b547bf9..9f93ecb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ All versions prior to 0.0.9 are untracked. ### Added +* CLI: The `--path ` flag has been added, allowing users to limit + dependency discovery to one or more paths (specified separately) + when `pip-audit` is invoked in environment mode + ([#148](https://github.com/trailofbits/pip-audit/pull/148)) + ### Changed ### Fixed diff --git a/README.md b/README.md index 3f9c0cae..df9207da 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ python -m pip install pip-audit 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] audit the Python environment for dependencies with known vulnerabilities @@ -62,6 +63,9 @@ optional arguments: --progress-spinner {on,off} display a progress spinner (default: on) --timeout TIMEOUT set the socket timeout (default: 15) + --path PATHS restrict to the specified installation path for + auditing packages; this option can be used multiple + times (default: []) ``` diff --git a/pip_audit/_cli.py b/pip_audit/_cli.py index ec0eb555..a54b18a8 100644 --- a/pip_audit/_cli.py +++ b/pip_audit/_cli.py @@ -143,6 +143,7 @@ def audit() -> None: description="audit the Python environment for dependencies with known vulnerabilities", formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) + dep_source_args = parser.add_mutually_exclusive_group() parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}") parser.add_argument( "-l", @@ -150,7 +151,7 @@ def audit() -> None: action="store_true", help="show only results for dependencies in the local environment", ) - parser.add_argument( + dep_source_args.add_argument( "-r", "--requirement", type=argparse.FileType("r"), @@ -216,6 +217,15 @@ def audit() -> None: parser.add_argument( "--timeout", type=int, default=15, help="set the socket timeout" # Match the `pip` default ) + dep_source_args.add_argument( + "--path", + type=Path, + action="append", + dest="paths", + default=[], + help="restrict to the specified installation path for auditing packages; " + "this option can be used multiple times", + ) args = parser.parse_args() logger.debug(f"parsed arguments: {args}") @@ -232,7 +242,7 @@ def audit() -> None: req_files: List[Path] = [Path(req.name) for req in args.requirements] source = RequirementSource(req_files, ResolveLibResolver(args.timeout, state), state) else: - source = PipSource(local=args.local) + source = PipSource(local=args.local, paths=args.paths) auditor = Auditor(service, options=AuditOptions(dry_run=args.dry_run)) diff --git a/pip_audit/_dependency_source/pip.py b/pip_audit/_dependency_source/pip.py index e7aea739..051654a2 100644 --- a/pip_audit/_dependency_source/pip.py +++ b/pip_audit/_dependency_source/pip.py @@ -4,7 +4,8 @@ """ import logging -from typing import Iterator, Optional +from pathlib import Path +from typing import Iterator, Optional, Sequence import pip_api from packaging.version import InvalidVersion, Version @@ -32,16 +33,23 @@ class PipSource(DependencySource): Wraps `pip` (specifically `pip list`) as a dependency source. """ - def __init__(self, *, local: bool = False, state: Optional[AuditState] = None) -> None: + def __init__( + self, *, local: bool = False, paths: Sequence[Path] = [], state: Optional[AuditState] = None + ) -> None: """ Create a new `PipSource`. `local` determines whether to do a "local-only" list. If `True`, the `DependencySource` does not expose globally installed packages. + `paths` is a list of locations to look for installed packages. If the + list is empty, the `DependencySource` will query the current Python + environment. + `state` is an optional `AuditState` to use for state callbacks. """ self._local = local + self._paths = paths self.state = state if _PIP_VERSION < _MINIMUM_RELIABLE_PIP_VERSION: @@ -61,7 +69,9 @@ def collect(self) -> Iterator[Dependency]: # The `pip list` call that underlies `pip_api` could fail for myriad reasons. # We collect them all into a single well-defined error. try: - for (_, dist) in pip_api.installed_distributions(local=self._local).items(): + for (_, dist) in pip_api.installed_distributions( + local=self._local, paths=list(self._paths) + ).items(): dep: Dependency try: dep = ResolvedDependency(name=dist.name, version=Version(str(dist.version))) diff --git a/setup.py b/setup.py index a8944458..f1625504 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,7 @@ platforms="any", python_requires=">=3.6", install_requires=[ - "pip-api>=0.0.23", + "pip-api>=0.0.25", "packaging>=21.0.0", # TODO: Remove this once 3.7 is our minimally supported version. "dataclasses>=0.6; python_version < '3.7'", diff --git a/test/dependency_source/test_pip.py b/test/dependency_source/test_pip.py index 172c0373..5f1e12de 100644 --- a/test/dependency_source/test_pip.py +++ b/test/dependency_source/test_pip.py @@ -1,5 +1,6 @@ +import os from dataclasses import dataclass -from typing import Dict +from typing import Dict, List import pip_api import pretend # type: ignore @@ -61,7 +62,9 @@ class MockDistribution: # Return a distribution with a version that doesn't conform to PEP 440. # We should log a debug message and skip it. - def mock_installed_distributions(local: bool) -> Dict[str, MockDistribution]: + def mock_installed_distributions( + local: bool, paths: List[os.PathLike] + ) -> Dict[str, MockDistribution]: return { "pytest": MockDistribution("pytest", "0.1"), "pip-audit": MockDistribution("pip-audit", "1.0-ubuntu0.21.04.1"),