diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d0023be..3cf1b6ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index c3e8d169..3e2f25d6 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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) diff --git a/pip_audit/_cli.py b/pip_audit/_cli.py index 0c96810b..befab185 100644 --- a/pip_audit/_cli.py +++ b/pip_audit/_cli.py @@ -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 @@ -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: @@ -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) diff --git a/pip_audit/_format/__init__.py b/pip_audit/_format/__init__.py index 1073672d..9b1ec254 100644 --- a/pip_audit/_format/__init__.py +++ b/pip_audit/_format/__init__.py @@ -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", ] diff --git a/pip_audit/_format/markdown.py b/pip_audit/_format/markdown.py new file mode 100644 index 00000000..f0c6de4f --- /dev/null +++ b/pip_audit/_format/markdown.py @@ -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}" diff --git a/test/format/test_markdown.py b/test/format/test_markdown.py new file mode 100644 index 00000000..13324fe0 --- /dev/null +++ b/test/format/test_markdown.py @@ -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