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

_format: Show vulnerabilities removed via --fix in JSON and columnar formats #222

Merged
merged 12 commits into from
Jan 14, 2022
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ All versions prior to 0.0.9 are untracked.

* 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))
available ([#212](https://github.com/trailofbits/pip-audit/pull/212),
[#222](https://github.com/trailofbits/pip-audit/pull/222))

### Changed

Expand Down
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ No known vulnerabilities found
Audit dependencies when there are vulnerabilities present:
```
$ pip-audit
Found 2 known vulnerabilities in 1 packages
Found 2 known vulnerabilities in 1 package
Name Version ID Fix Versions
---- ------- -------------- ------------
Flask 0.5 PYSEC-2019-179 1.0
Expand All @@ -158,7 +158,7 @@ Flask 0.5 PYSEC-2018-66 0.12.3
Audit dependencies including descriptions:
```
$ pip-audit --desc
Found 2 known vulnerabilities in 1 packages
Found 2 known vulnerabilities in 1 package
Name Version ID Fix Versions Description
---- ------- -------------- ------------ --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Flask 0.5 PYSEC-2019-179 1.0 The Pallets Project Flask before 1.0 is affected by: unexpected memory usage. The impact is: denial of service. The attack vector is: crafted encoded JSON data. The fixed version is: 1. NOTE: this may overlap CVE-2018-1000656.
Expand All @@ -168,7 +168,7 @@ Flask 0.5 PYSEC-2018-66 0.12.3 The Pallets Project flask version Befo
Audit dependencies in JSON format:
```
$ pip-audit -f json | jq
Found 2 known vulnerabilities in 1 packages
Found 2 known vulnerabilities in 1 package
[
{
"name": "flask",
Expand Down Expand Up @@ -221,11 +221,11 @@ 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
Found 2 known vulnerabilities in 1 package and fixed 2 vulnerabilities in 1 package
Name Version ID Fix Versions Applied Fix
----- ------- -------------- ------------ ----------------------------------------
flask 0.5 PYSEC-2019-179 1.0 Successfully upgraded flask (0.5 => 1.0)
flask 0.5 PYSEC-2018-66 0.12.3 Successfully upgraded flask (0.5 => 1.0)
```

## Security Model
Expand Down
2 changes: 1 addition & 1 deletion pip_audit/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ def audit() -> None:
f"{'package' if fixed_pkg_count == 1 else 'packages'}"
)
print(summary_msg, file=sys.stderr)
print(formatter.format(result))
print(formatter.format(result, fixes))
if pkg_count != fixed_pkg_count:
sys.exit(1)
else:
Expand Down
34 changes: 30 additions & 4 deletions pip_audit/_format/columns.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
"""

from itertools import zip_longest
from typing import Any, Dict, Iterable, List, Tuple, cast
from typing import Any, Dict, Iterable, List, Optional, Tuple, cast

from packaging.version import Version

import pip_audit._fix as fix
import pip_audit._service as service

from .interface import VulnerabilityFormat
Expand Down Expand Up @@ -39,7 +40,11 @@ def __init__(self, output_desc: bool):
"""
self.output_desc = output_desc

def format(self, result: Dict[service.Dependency, List[service.VulnerabilityResult]]) -> str:
def format(
self,
result: Dict[service.Dependency, List[service.VulnerabilityResult]],
fixes: List[fix.FixVersion],
) -> str:
"""
Returns a column formatted string for a given mapping of dependencies to vulnerability
results.
Expand All @@ -48,15 +53,18 @@ def format(self, result: Dict[service.Dependency, List[service.VulnerabilityResu
"""
vuln_data: List[List[Any]] = []
header = ["Name", "Version", "ID", "Fix Versions"]
if fixes:
header.append("Applied Fix")
if self.output_desc:
header.append("Description")
vuln_data.append(header)
for dep, vulns in result.items():
if dep.is_skipped():
continue
dep = cast(service.ResolvedDependency, dep)
applied_fix = next((f for f in fixes if f.dep == dep), None)
for vuln in vulns:
vuln_data.append(self._format_vuln(dep, vuln))
vuln_data.append(self._format_vuln(dep, vuln, applied_fix))

vuln_strings, sizes = tabulate(vuln_data)

Expand Down Expand Up @@ -96,14 +104,19 @@ def format(self, result: Dict[service.Dependency, List[service.VulnerabilityResu
return columns_string

def _format_vuln(
self, dep: service.ResolvedDependency, vuln: service.VulnerabilityResult
self,
dep: service.ResolvedDependency,
vuln: service.VulnerabilityResult,
applied_fix: Optional[fix.FixVersion],
) -> List[Any]:
vuln_data = [
dep.canonical_name,
dep.version,
vuln.id,
self._format_fix_versions(vuln.fix_versions),
]
if applied_fix is not None:
vuln_data.append(self._format_applied_fix(applied_fix))
if self.output_desc:
vuln_data.append(vuln.description)
return vuln_data
Expand All @@ -116,3 +129,16 @@ def _format_skipped_dep(self, dep: service.SkippedDependency) -> List[Any]:
dep.canonical_name,
dep.skip_reason,
]

def _format_applied_fix(self, applied_fix: fix.FixVersion) -> str:
if applied_fix.is_skipped():
applied_fix = cast(fix.SkippedFixVersion, applied_fix)
return (
f"Failed to fix {applied_fix.dep.canonical_name} ({applied_fix.dep.version}): "
f"{applied_fix.skip_reason}"
)
applied_fix = cast(fix.ResolvedFixVersion, applied_fix)
return (
f"Successfully upgraded {applied_fix.dep.canonical_name} ({applied_fix.dep.version} "
f"=> {applied_fix.version})"
)
13 changes: 12 additions & 1 deletion pip_audit/_format/cyclonedx.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

import enum
import logging
from typing import Dict, List, cast

from cyclonedx import output
Expand All @@ -11,10 +12,13 @@
from cyclonedx.model.vulnerability import Vulnerability
from cyclonedx.parser import BaseParser

import pip_audit._fix as fix
import pip_audit._service as service

from .interface import VulnerabilityFormat

logger = logging.getLogger(__name__)


class _PipAuditResultParser(BaseParser):
def __init__(self, result: Dict[service.Dependency, List[service.VulnerabilityResult]]):
Expand Down Expand Up @@ -64,13 +68,20 @@ def __init__(self, inner_format: "CycloneDxFormat.InnerFormat"):

self._inner_format = inner_format

def format(self, result: Dict[service.Dependency, List[service.VulnerabilityResult]]) -> str:
def format(
self,
result: Dict[service.Dependency, List[service.VulnerabilityResult]],
fixes: List[fix.FixVersion],
) -> str:
"""
Returns a CycloneDX formatted string for a given mapping of dependencies to vulnerability
results.

See `VulnerabilityFormat.format`.
"""
if fixes:
logger.warning("--fix output is unsupported by CycloneDX formats")

parser = _PipAuditResultParser(result)
bom = Bom.from_parser(parser)

Expand Down
5 changes: 4 additions & 1 deletion pip_audit/_format/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from abc import ABC
from typing import Dict, List

import pip_audit._fix as fix
import pip_audit._service as service


Expand All @@ -13,7 +14,9 @@ class VulnerabilityFormat(ABC):
"""

def format(
self, result: Dict[service.Dependency, List[service.VulnerabilityResult]]
self,
result: Dict[service.Dependency, List[service.VulnerabilityResult]],
fixes: List[fix.FixVersion],
) -> str: # pragma: no cover
"""
Convert a mapping of dependencies to vulnerabilities into a string.
Expand Down
32 changes: 29 additions & 3 deletions pip_audit/_format/json.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import json
from typing import Any, Dict, List, cast

import pip_audit._fix as fix
import pip_audit._service as service

from .interface import VulnerabilityFormat
Expand All @@ -25,16 +26,26 @@ def __init__(self, output_desc: bool):
"""
self.output_desc = output_desc

def format(self, result: Dict[service.Dependency, List[service.VulnerabilityResult]]) -> str:
def format(
self,
result: Dict[service.Dependency, List[service.VulnerabilityResult]],
fixes: List[fix.FixVersion],
) -> str:
"""
Returns a JSON formatted string for a given mapping of dependencies to vulnerability
results.

See `VulnerabilityFormat.format`.
"""
output_json = []
output_json = {}
dep_json = []
for dep, vulns in result.items():
output_json.append(self._format_dep(dep, vulns))
dep_json.append(self._format_dep(dep, vulns))
output_json["dependencies"] = dep_json
fix_json = []
for f in fixes:
fix_json.append(self._format_fix(f))
output_json["fixes"] = fix_json
return json.dumps(output_json)

def _format_dep(
Expand Down Expand Up @@ -62,3 +73,18 @@ def _format_vuln(self, vuln: service.VulnerabilityResult) -> Dict[str, Any]:
if self.output_desc:
vuln_json["description"] = vuln.description
return vuln_json

def _format_fix(self, fix_version: fix.FixVersion) -> Dict[str, Any]:
if fix_version.is_skipped():
fix_version = cast(fix.SkippedFixVersion, fix_version)
return {
"name": fix_version.dep.canonical_name,
"version": str(fix_version.dep.version),
"skip_reason": fix_version.skip_reason,
}
fix_version = cast(fix.ResolvedFixVersion, fix_version)
return {
"name": fix_version.dep.canonical_name,
"old_version": str(fix_version.dep.version),
"new_version": str(fix_version.version),
}
33 changes: 29 additions & 4 deletions test/format/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@
import pytest
from packaging.version import Version

import pip_audit._fix as fix
import pip_audit._service as service

_RESOLVED_DEP_FOO = service.ResolvedDependency(name="foo", version=Version("1.0"))
_RESOLVED_DEP_BAR = service.ResolvedDependency(name="bar", version=Version("0.1"))
_SKIPPED_DEP = service.SkippedDependency(name="bar", skip_reason="skip-reason")

_TEST_VULN_DATA: Dict[service.Dependency, List[service.VulnerabilityResult]] = {
service.ResolvedDependency(name="foo", version=Version("1.0")): [
_RESOLVED_DEP_FOO: [
service.VulnerabilityResult(
id="VULN-0",
description="The first vulnerability",
Expand All @@ -21,7 +26,7 @@
fix_versions=[Version("1.0")],
),
],
service.ResolvedDependency(name="bar", version=Version("0.1")): [
_RESOLVED_DEP_BAR: [
service.VulnerabilityResult(
id="VULN-2",
description="The third vulnerability",
Expand All @@ -31,7 +36,7 @@
}

_TEST_VULN_DATA_SKIPPED_DEP: Dict[service.Dependency, List[service.VulnerabilityResult]] = {
service.ResolvedDependency(name="foo", version=Version("1.0")): [
_RESOLVED_DEP_FOO: [
service.VulnerabilityResult(
id="VULN-0",
description="The first vulnerability",
Expand All @@ -41,9 +46,19 @@
],
),
],
service.SkippedDependency(name="bar", skip_reason="skip-reason"): [],
_SKIPPED_DEP: [],
}

_TEST_FIX_DATA: List[fix.FixVersion] = [
fix.ResolvedFixVersion(dep=_RESOLVED_DEP_FOO, version=Version("1.8")),
fix.ResolvedFixVersion(dep=_RESOLVED_DEP_BAR, version=Version("0.3")),
]

_TEST_SKIPPED_FIX_DATA: List[fix.FixVersion] = [
fix.ResolvedFixVersion(dep=_RESOLVED_DEP_FOO, version=Version("1.8")),
fix.SkippedFixVersion(dep=_RESOLVED_DEP_BAR, skip_reason="skip-reason"),
]


@pytest.fixture(autouse=True)
def vuln_data():
Expand All @@ -53,3 +68,13 @@ def vuln_data():
@pytest.fixture(autouse=True)
def vuln_data_skipped_dep():
return _TEST_VULN_DATA_SKIPPED_DEP


@pytest.fixture(autouse=True)
def fix_data():
return _TEST_FIX_DATA


@pytest.fixture(autouse=True)
def skipped_fix_data():
return _TEST_SKIPPED_FIX_DATA
27 changes: 23 additions & 4 deletions test/format/test_columns.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ def test_columns(vuln_data):
foo 1.0 VULN-0 1.1,1.4 The first vulnerability
foo 1.0 VULN-1 1.0 The second vulnerability
bar 0.1 VULN-2 The third vulnerability"""
assert columns_format.format(vuln_data) == expected_columns
assert columns_format.format(vuln_data, list()) == expected_columns


def test_columns_no_desc(vuln_data):
Expand All @@ -18,7 +18,7 @@ def test_columns_no_desc(vuln_data):
foo 1.0 VULN-0 1.1,1.4
foo 1.0 VULN-1 1.0
bar 0.1 VULN-2"""
assert columns_format.format(vuln_data) == expected_columns
assert columns_format.format(vuln_data, list()) == expected_columns


def test_columns_skipped_dep(vuln_data_skipped_dep):
Expand All @@ -29,5 +29,24 @@ def test_columns_skipped_dep(vuln_data_skipped_dep):
Name Skip Reason
---- -----------
bar skip-reason"""
print(columns_format.format(vuln_data_skipped_dep))
assert columns_format.format(vuln_data_skipped_dep) == expected_columns
assert columns_format.format(vuln_data_skipped_dep, list()) == expected_columns


def test_columns_fix(vuln_data, fix_data):
columns_format = format.ColumnsFormat(False)
expected_columns = """Name Version ID Fix Versions Applied Fix
---- ------- ------ ------------ --------------------------------------
foo 1.0 VULN-0 1.1,1.4 Successfully upgraded foo (1.0 => 1.8)
foo 1.0 VULN-1 1.0 Successfully upgraded foo (1.0 => 1.8)
bar 0.1 VULN-2 Successfully upgraded bar (0.1 => 0.3)"""
assert columns_format.format(vuln_data, fix_data) == expected_columns


def test_columns_skipped_fix(vuln_data, skipped_fix_data):
columns_format = format.ColumnsFormat(False)
expected_columns = """Name Version ID Fix Versions Applied Fix
---- ------- ------ ------------ --------------------------------------
foo 1.0 VULN-0 1.1,1.4 Successfully upgraded foo (1.0 => 1.8)
foo 1.0 VULN-1 1.0 Successfully upgraded foo (1.0 => 1.8)
bar 0.1 VULN-2 Failed to fix bar (0.1): skip-reason"""
assert columns_format.format(vuln_data, skipped_fix_data) == expected_columns
Loading