Skip to content

Commit

Permalink
cli, dependency_source: support --no-deps (#255)
Browse files Browse the repository at this point in the history
* cli, dependency_source: support `--no-deps`

* pip_audit, test: update tests, coverage

* README: update `--help`

* CHANGELOG: record changes

* CHANGELOG: add link

* cli: `make lint`

* cli: add some nudges

Co-authored-by: Alex Cameron <[email protected]>
  • Loading branch information
woodruffw and tetsuo-cpp authored May 3, 2022
1 parent b2695ba commit 5275228
Show file tree
Hide file tree
Showing 5 changed files with 101 additions and 25 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ All versions prior to 0.0.9 are untracked.
* CLI: The `--output` option has been added, allowing users to specify
a file to write output to. The default behavior of writing to `stdout`
is unchanged ([#262](https://github.com/trailofbits/pip-audit/pull/262))

* CLI: The `--no-deps` flag has been added, allowing users to skip dependency
resolution entirely when `pip-audit` is used in requirements mode
([#255](https://github.com/trailofbits/pip-audit/pull/255))

### Fixed

Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ usage: pip-audit [-h] [-V] [-l] [-r REQUIREMENTS] [-f FORMAT] [-s SERVICE]
[--progress-spinner {on,off}] [--timeout TIMEOUT]
[--path PATHS] [-v] [--fix] [--require-hashes]
[--index-url INDEX_URL] [--extra-index-url EXTRA_INDEX_URLS]
[--skip-editable] [-o FILE]
[--skip-editable] [--no-deps] [-o FILE]
[project_path]
audit the Python environment for dependencies with known vulnerabilities
Expand Down Expand Up @@ -137,6 +137,9 @@ optional arguments:
`--index-url` (default: [])
--skip-editable don't audit packages that are marked as editable
(default: False)
--no-deps don't perform any dependency resolution; requires all
requirements are pinned to an exact version (default:
False)
-o FILE, --output FILE
output results to the given file (default: None)
```
Expand Down
23 changes: 23 additions & 0 deletions pip_audit/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,12 @@ def _parser() -> argparse.ArgumentParser:
action="store_true",
help="don't audit packages that are marked as editable",
)
parser.add_argument(
"--no-deps",
action="store_true",
help="don't perform any dependency resolution; requires all requirements are pinned "
"to an exact version",
)
parser.add_argument(
"-o",
"--output",
Expand Down Expand Up @@ -326,6 +332,22 @@ def audit() -> None:
parser.error("The --index-url flag can only be used with --requirement (-r)")
elif args.extra_index_urls:
parser.error("The --extra-index-url flag can only be used with --requirement (-r)")
elif args.no_deps:
parser.error("The --no-deps flag can only be used with --requirement (-r)")

# Nudge users to consider alternate workflows.
if args.require_hashes and args.no_deps:
logger.warning("The --no-deps flag is redundant when used with --require-hashes")

if args.no_deps:
logger.warning(
"--no-deps is supported, but users are encouraged to fully hash their "
"pinned dependencies"
)
logger.warning(
"Consider using a tool like `pip-compile`: "
"https://pip-tools.readthedocs.io/en/latest/#using-hashes"
)

with ExitStack() as stack:
actors = []
Expand All @@ -345,6 +367,7 @@ def audit() -> None:
index_urls, args.timeout, args.cache_dir, args.skip_editable, state
),
require_hashes=args.require_hashes,
no_deps=args.no_deps,
state=state,
)
elif args.project_path is not None:
Expand Down
63 changes: 39 additions & 24 deletions pip_audit/_dependency_source/requirement.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def __init__(
resolver: DependencyResolver,
*,
require_hashes: bool = False,
no_deps: bool = False,
state: AuditState = AuditState(),
) -> None:
"""
Expand All @@ -59,11 +60,16 @@ def __init__(
`require_hashes` controls the hash policy: if `True`, dependency collection
will fail unless all requirements include hashes.
`no_deps` controls the dependency resolution policy: if `True`,
dependency resolution is not performed and the inputs are checked
and treated as "frozen".
`state` is an `AuditState` to use for state callbacks.
"""
self._filenames = filenames
self._resolver = resolver
self._require_hashes = require_hashes
self._no_deps = no_deps
self.state = state

def collect(self) -> Iterator[Dependency]:
Expand All @@ -79,17 +85,20 @@ 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. 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.
# There are three cases where we skip dependency resolution:
#
# If at least one requirement has a hash, it implies that we require hashes for all
# requirements
if self._require_hashes or any(
# 1. The user has explicitly specified `--require-hashes`.
# 2. One or more parsed requirements has hashes specified, enabling
# hash checking for all requirements.
# 3. The user has explicitly specified `--no-deps`.
require_hashes = 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()))
)
skip_deps = require_hashes or self._no_deps
if skip_deps:
yield from self._collect_preresolved_deps(
iter(reqs.values()), require_hashes=require_hashes
)
continue

# Invoke the dependency resolver to turn requirements into dependencies
Expand Down Expand Up @@ -178,26 +187,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]]
def _collect_preresolved_deps(
self,
reqs: Iterator[Union[ParsedRequirement, UnparsedRequirement]],
require_hashes: bool = False,
) -> Iterator[Dependency]:
# NOTE: Editable and hashed requirements are incompatible by definition, so
# we don't bother checking whether the user has asked us to skip editable requirements
# when we're doing hashed requirement collection.
"""
Collect pre-resolved (pinned) dependencies, optionally enforcing a
hash requirement policy.
"""
for req in reqs:
req = cast(ParsedRequirement, req)
if not req.hashes:
if require_hashes and not req.hashes:
raise RequirementSourceError(
f"requirement {req.name} does not contain a hash: {str(req)}"
f"requirement {req.name} does not contain a hash {str(req)}"
)
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.hashes)
continue
raise RequirementSourceError(f"requirement {req.name} is not pinned: {str(req)}")

if not req.specifier:
raise RequirementSourceError(f"requirement {req.name} is not pinned: {str(req)}")

pinned_specifier = PINNED_SPECIFIER_RE.match(str(req.specifier))
if pinned_specifier is None:
raise RequirementSourceError(f"requirement {req.name} is not pinned: {str(req)}")

yield ResolvedDependency(
req.name, Version(pinned_specifier.group("version")), req.hashes
)


class RequirementSourceError(DependencySourceError):
Expand Down
31 changes: 31 additions & 0 deletions test/dependency_source/test_requirement.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,3 +353,34 @@ def test_requirement_source_require_hashes_unpinned(monkeypatch):
# version number
with pytest.raises(DependencySourceError):
list(source.collect())


def test_requirement_source_no_deps(monkeypatch):
source = requirement.RequirementSource(
[Path("requirements.txt")], ResolveLibResolver(), no_deps=True
)

monkeypatch.setattr(
_parse_requirements,
"_read_file",
lambda _: ["flask==2.0.1"],
)

specs = list(source.collect())
assert specs == [ResolvedDependency("flask", Version("2.0.1"), hashes={})]


def test_requirement_source_no_deps_unpinned(monkeypatch):
source = requirement.RequirementSource(
[Path("requirements.txt")], ResolveLibResolver(), no_deps=True
)

monkeypatch.setattr(
_parse_requirements,
"_read_file",
lambda _: ["flask\nrequests>=1.0"],
)

# When dependency resolution is disabled, all requirements must be pinned.
with pytest.raises(DependencySourceError):
list(source.collect())

0 comments on commit 5275228

Please sign in to comment.