Skip to content

Commit

Permalink
markdown: Add Markdown formatter (#312)
Browse files Browse the repository at this point in the history
* markdown: Add initial implementation of `MarkdownFormat`

* markdown: Add skipped dependencies table

* markdown: Minor formatting fixes

* test: Add basic unit test for markdown formatting

* markdown, test: Fill out remaining coverage

* test: Fix expected output

* README: Update help text

* CHANGELOG: Add entry for Markdown formatting

* format/markdown: slight tweaks

Markdown allows raw newlines as line separators, so we don't bother
with `os.linesep`. We also use f-strings, where possible.

Signed-off-by: William Woodruff <[email protected]>

* README: add markdown to feature list

Signed-off-by: William Woodruff <[email protected]>

* markdown: Remove unused import

Co-authored-by: William Woodruff <[email protected]>
  • Loading branch information
tetsuo-cpp and woodruffw authored Jun 30, 2022
1 parent 969d243 commit 8157d28
Show file tree
Hide file tree
Showing 6 changed files with 253 additions and 4 deletions.
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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ support from Google. This is not an official Google product.
[SBOMs](https://en.wikipedia.org/wiki/Software_bill_of_materials) in
[CycloneDX](https://cyclonedx.org/) XML or JSON
* Support for automatically fixing vulnerable dependencies (`--fix`)
* Human and machine-readable output formats (columnar, JSON)
* Human and machine-readable output formats (columnar, Markdown, JSON)
* Seamlessly reuses your existing local `pip` caches

## Installation
Expand Down 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",
]
151 changes: 151 additions & 0 deletions pip_audit/_format/markdown.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""
Functionality for formatting vulnerability results as a Markdown table.
"""

from textwrap import dedent
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 += "\n"
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 (
dedent(
f"""
{header}
{border}
"""
)
+ "\n".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 (
dedent(
f"""
{header}
{border}
"""
)
+ "\n".join(skipped_dep_rows)
)

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

0 comments on commit 8157d28

Please sign in to comment.