Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

cli, dependency_source: support --no-deps #255

Merged
merged 13 commits into from
May 3, 2022
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(
woodruffw marked this conversation as resolved.
Show resolved Hide resolved
"--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())