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

markdown: Add Markdown formatter #312

Merged
merged 12 commits into from
Jun 30, 2022
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ All versions prior to 0.0.9 are untracked.

## [Unreleased]

### Added

* Output formats: `pip-audit` now supports a Markdown format
(`--format=markdown`) which renders results as a set of Markdown tables.
([#312](https://github.com/trailofbits/pip-audit/pull/312))

## [2.3.4]

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,8 @@ optional arguments:
used multiple times (default: None)
-f FORMAT, --format FORMAT
the format to emit audit results in (choices: columns,
json, cyclonedx-json, cyclonedx-xml) (default:
columns)
json, cyclonedx-json, cyclonedx-xml, markdown)
(default: columns)
-s SERVICE, --vulnerability-service SERVICE
the vulnerability service to audit dependencies
against (choices: osv, pypi) (default: pypi)
Expand Down
11 changes: 10 additions & 1 deletion pip_audit/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@
)
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._format import (
ColumnsFormat,
CycloneDxFormat,
JsonFormat,
MarkdownFormat,
VulnerabilityFormat,
)
from pip_audit._service import OsvService, PyPIService, VulnerabilityService
from pip_audit._service.interface import ConnectionError as VulnServiceConnectionError
from pip_audit._service.interface import ResolvedDependency, SkippedDependency
Expand All @@ -44,6 +50,7 @@ class OutputFormatChoice(str, enum.Enum):
Json = "json"
CycloneDxJson = "cyclonedx-json"
CycloneDxXml = "cyclonedx-xml"
Markdown = "markdown"

def to_format(self, output_desc: bool) -> VulnerabilityFormat:
if self is OutputFormatChoice.Columns:
Expand All @@ -54,6 +61,8 @@ def to_format(self, output_desc: bool) -> VulnerabilityFormat:
return CycloneDxFormat(inner_format=CycloneDxFormat.InnerFormat.Json)
elif self is OutputFormatChoice.CycloneDxXml:
return CycloneDxFormat(inner_format=CycloneDxFormat.InnerFormat.Xml)
elif self is OutputFormatChoice.Markdown:
return MarkdownFormat(output_desc)
else:
assert_never(self)

Expand Down
2 changes: 2 additions & 0 deletions pip_audit/_format/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
from .cyclonedx import CycloneDxFormat
from .interface import VulnerabilityFormat
from .json import JsonFormat
from .markdown import MarkdownFormat

__all__ = [
"ColumnsFormat",
"CycloneDxFormat",
"VulnerabilityFormat",
"JsonFormat",
"MarkdownFormat",
]
135 changes: 135 additions & 0 deletions pip_audit/_format/markdown.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""
Functionality for formatting vulnerability results as a Markdown table.
"""

import os
from typing import Dict, List, Optional, cast

from packaging.version import Version

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

from .interface import VulnerabilityFormat


class MarkdownFormat(VulnerabilityFormat):
"""
An implementation of `VulnerabilityFormat` that formats vulnerability results as a set of
Markdown tables.
"""

def __init__(self, output_desc: bool) -> None:
"""
Create a new `MarkdownFormat`.

`output_desc` is a flag to determine whether descriptions for each vulnerability should be
included in the output as they can be quite long and make the output difficult to read.
"""
self.output_desc = output_desc

@property
def is_manifest(self) -> bool:
"""
See `VulnerabilityFormat.is_manifest`.
"""
return False

def format(
self,
result: Dict[service.Dependency, List[service.VulnerabilityResult]],
fixes: List[fix.FixVersion],
) -> str:
"""
Returns a Markdown formatted string representing a set of vulnerability results and applied
fixes.
"""
output = self._format_vuln_results(result, fixes)
skipped_deps_output = self._format_skipped_deps(result)
if skipped_deps_output:
# If we wrote the results table already, we need to add some line breaks to ensure that
# the skipped dependency table renders correctly.
if output:
output += os.linesep + os.linesep
output += skipped_deps_output
return output

def _format_vuln_results(
self,
result: Dict[service.Dependency, List[service.VulnerabilityResult]],
fixes: List[fix.FixVersion],
) -> str:
header = "Name | Version | ID | Fix Versions"
border = "--- | --- | --- | ---"
if fixes:
header += " | Applied Fix"
border += " | ---"
if self.output_desc:
header += " | Description"
border += " | ---"

vuln_rows: List[str] = []
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_rows.append(self._format_vuln(dep, vuln, applied_fix))

if not vuln_rows:
return str()

return header + os.linesep + border + os.linesep + os.linesep.join(vuln_rows)

def _format_vuln(
self,
dep: service.ResolvedDependency,
vuln: service.VulnerabilityResult,
applied_fix: Optional[fix.FixVersion],
) -> str:
vuln_text = (
f"{dep.canonical_name} | {dep.version} | {vuln.id} | "
f"{self._format_fix_versions(vuln.fix_versions)}"
)
if applied_fix is not None:
vuln_text += f" | {self._format_applied_fix(applied_fix)}"
if self.output_desc:
vuln_text += f" | {vuln.description}"
return vuln_text

def _format_fix_versions(self, fix_versions: List[Version]) -> str:
return ",".join([str(version) for version in fix_versions])

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})"
)

def _format_skipped_deps(
self, result: Dict[service.Dependency, List[service.VulnerabilityResult]]
) -> str:
header = "Name | Skip Reason"
border = "--- | ---"

skipped_dep_rows: List[str] = []
for dep, _ in result.items():
if dep.is_skipped():
dep = cast(service.SkippedDependency, dep)
skipped_dep_rows.append(self._format_skipped_dep(dep))

if not skipped_dep_rows:
return str()

return header + os.linesep + border + os.linesep + os.linesep.join(skipped_dep_rows)
woodruffw marked this conversation as resolved.
Show resolved Hide resolved

def _format_skipped_dep(self, dep: service.SkippedDependency) -> str:
return f"{dep.name} | {dep.skip_reason}"
75 changes: 75 additions & 0 deletions test/format/test_markdown.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import pytest

import pip_audit._format as format


@pytest.mark.parametrize("output_desc", [True, False])
def test_columns_not_manifest(output_desc):
fmt = format.MarkdownFormat(output_desc)
assert not fmt.is_manifest


def test_markdown(vuln_data):
markdown_format = format.MarkdownFormat(True)
expected_markdown = """Name | Version | ID | Fix Versions | Description
--- | --- | --- | --- | ---
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 markdown_format.format(vuln_data, list()) == expected_markdown


def test_markdown_no_desc(vuln_data):
markdown_format = format.MarkdownFormat(False)
expected_markdown = """Name | Version | ID | Fix Versions
--- | --- | --- | ---
foo | 1.0 | VULN-0 | 1.1,1.4
foo | 1.0 | VULN-1 | 1.0
bar | 0.1 | VULN-2 | """
assert markdown_format.format(vuln_data, list()) == expected_markdown


def test_markdown_skipped_dep(vuln_data_skipped_dep):
markdown_format = format.MarkdownFormat(False)
expected_markdown = """Name | Version | ID | Fix Versions
--- | --- | --- | ---
foo | 1.0 | VULN-0 | 1.1,1.4

Name | Skip Reason
--- | ---
bar | skip-reason"""
assert markdown_format.format(vuln_data_skipped_dep, list()) == expected_markdown


def test_markdown_no_vuln_data(no_vuln_data):
markdown_format = format.MarkdownFormat(False)
expected_markdown = str()
assert markdown_format.format(no_vuln_data, list()) == expected_markdown


def test_markdown_no_vuln_data_skipped_dep(no_vuln_data_skipped_dep):
markdown_format = format.MarkdownFormat(False)
expected_markdown = """Name | Skip Reason
--- | ---
bar | skip-reason"""
assert markdown_format.format(no_vuln_data_skipped_dep, list()) == expected_markdown


def test_markdown_fix(vuln_data, fix_data):
markdown_format = format.MarkdownFormat(False)
expected_markdown = """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 markdown_format.format(vuln_data, fix_data) == expected_markdown


def test_markdown_skipped_fix(vuln_data, skipped_fix_data):
markdown_format = format.MarkdownFormat(False)
expected_markdown = """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 markdown_format.format(vuln_data, skipped_fix_data) == expected_markdown