diff --git a/README.md b/README.md index ebd0b969c2..fd6a8a1994 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,14 @@ Note that you will need to obtain a copy of the vulnerability data before the to The CVE Binary Tool provides console-based output by default. If you wish to provide another format, you can specify this and a filename on the command line using `--format`. The valid formats are CSV, JSON, console, HTML and PDF. The output filename can be specified using the `--output-file` flag. +You can also specify multiple output formats by using comma (',') as separator: + +```bash +cve-bin-tool file -f csv,json,html -o report +``` + +Note: Please don't use spaces between comma (',') and the output formats. + The reported vulnerabilities can additionally be reported in the Vulnerability Exchange (VEX) format by specifying `--vex` command line option. The generated VEX file can then be used as an `--input-file` to support @@ -183,6 +191,8 @@ Usage: provide custom theme directory for HTML Report -f {csv,json,console,html,pdf}, --format {csv,json,console,html,pdf} update output format (default: console) + specify multiple output formats by using comma (',') as a separator + note: don't use spaces between comma (',') and the output formats. -c CVSS, --cvss CVSS minimum CVSS score (as integer in range 0 to 10) to report (default: 0) -S {low,medium,high,critical}, --severity {low,medium,high,critical} diff --git a/cve_bin_tool/cli.py b/cve_bin_tool/cli.py index f41ef8c712..f52bbd3e4f 100644 --- a/cve_bin_tool/cli.py +++ b/cve_bin_tool/cli.py @@ -93,7 +93,7 @@ def main(argv=None): f'Available checkers: {", ".join(VersionScanner.available_checkers())}' ) + "\n\nPlease disclose issues responsibly!", - formatter_class=argparse.RawDescriptionHelpFormatter, + formatter_class=argparse.RawTextHelpFormatter, ) nvd_database_group = parser.add_argument_group( @@ -187,8 +187,14 @@ def main(argv=None): "-f", "--format", action="store", - choices=["csv", "json", "console", "html", "pdf"], - help="update output format (default: console)", + help=textwrap.dedent( + """\ + update output format (default: console) + specify multiple output formats by using comma (',') as a separator + note: don't use spaces between comma (',') and the output formats. + """ + ), + metavar="{csv,json,console,html,pdf}", default="console", ) output_group.add_argument( @@ -509,14 +515,23 @@ def main(argv=None): cvedb_orig.remove_cache_backup() + output_formats = set(args["format"].split(",")) + output_formats = [output_format.strip() for output_format in output_formats] + extensions = ["csv", "json", "console", "html", "pdf"] + for output_format in output_formats: + if output_format not in extensions: + LOGGER.error( + f"Argument -f/--format: invalid choice: {output_format} (choose from 'csv', 'json', 'console', 'html', 'pdf')" + ) + return -1 + # Check for PDF support - output_format = args["format"] - if output_format == "pdf" and importlib.util.find_spec("reportlab") is None: - LOGGER.info("PDF output not available. Default to console.") + if "pdf" in output_formats and importlib.util.find_spec("reportlab") is None: + LOGGER.info("PDF output not available.") LOGGER.info( "If you want to produce PDF output, please install reportlab using pip install reportlab" ) - output_format = "console" + output_formats.remove("pdf") merged_reports = None if args["merge"]: @@ -710,7 +725,7 @@ def main(argv=None): ) if not args["quiet"]: - output.output_file(output_format) + output.output_file_wrapper(output_formats) if args["backport_fix"] or args["available_fix"]: distro_info = args["backport_fix"] or args["available_fix"] is_backport = True if args["backport_fix"] else False diff --git a/cve_bin_tool/output_engine/__init__.py b/cve_bin_tool/output_engine/__init__.py index 360f1a4642..9572325c4c 100644 --- a/cve_bin_tool/output_engine/__init__.py +++ b/cve_bin_tool/output_engine/__init__.py @@ -603,6 +603,13 @@ def generate_vex(self, all_cve_data: Dict[ProductInfo, CVEData], filename: str): with open(filename, "w") as outfile: json.dump(vex_output, outfile, indent=" ") + def output_file_wrapper(self, output_types=["console"]): + if "console" in output_types: + self.output_file() + output_types.remove("console") + for output_type in output_types: + self.output_file(output_type) + def output_file(self, output_type="console"): """Generate a file for list of CVE""" @@ -623,6 +630,9 @@ def output_file(self, output_type="console"): if output_type == "console": # short circuit file opening logic if we are actually # just writing to stdout + if self.filename: + self.filename = add_extension_if_not(self.filename, "txt") + self.logger.info(f"Console output stored at {self.filename}") self.output_cves(self.filename, output_type) return @@ -648,10 +658,7 @@ def output_file(self, output_type="console"): self.filename = generate_filename(output_type) # Log the filename generated - if output_type == "html" or output_type == "pdf": - self.logger.info(f"{output_type.upper()} report stored at {self.filename}") - else: - self.logger.info(f"Output stored at {self.filename}") + self.logger.info(f"{output_type.upper()} report stored at {self.filename}") # call to output_cves mode = "w" diff --git a/cve_bin_tool/output_engine/util.py b/cve_bin_tool/output_engine/util.py index 5a602e84d7..76e657cedd 100644 --- a/cve_bin_tool/output_engine/util.py +++ b/cve_bin_tool/output_engine/util.py @@ -208,20 +208,27 @@ def intermediate_output( def add_extension_if_not(filename: str, output_type: str) -> str: """ summary: Checks if the filename ends with the extension and if not - adds one. + adds one. And if the filename ends with a different extension it replaces the extension. Args: filename (str): filename from OutputEngine - output_type (str): contains a value from ["json", "csv", "html"] + output_type (str): contains a value from ["json", "csv", "html", "pdf"] Returns: str: Filename with extension according to output_type """ - if not filename.endswith(f".{output_type}"): - updated_filename = f"{filename}.{output_type}" - return updated_filename - else: + import re + + extensions = ["json", "csv", "html", "pdf", "txt"] + for extension in extensions: + if not filename.endswith(f".{extension}"): + continue + if extension == output_type: + return filename + filename = re.sub(f".{extension}$", f".{output_type}", filename) return filename + filename = f"{filename}.{output_type}" + return filename def group_cve_by_remark( diff --git a/doc/MANUAL.md b/doc/MANUAL.md index 73783d9c84..a08d0c1738 100644 --- a/doc/MANUAL.md +++ b/doc/MANUAL.md @@ -98,8 +98,10 @@ which is useful if you're trying the latest code from provide output filename (default: output to stdout) --html-theme HTML_THEME provide custom theme directory for HTML Report - -f {csv,json,console,html}, --format {csv,json,console,html} + -f {csv,json,console,html,pdf}, --format {csv,json,console,html,pdf} update output format (default: console) + specify multiple output formats by using comma (',') as a separator + note: don't use spaces between comma (',') and the output formats. -c CVSS, --cvss CVSS minimum CVSS score (as integer in range 0 to 10) to report (default: 0) -S {low,medium,high,critical}, --severity {low,medium,high,critical} @@ -670,6 +672,14 @@ associated with it, we've opted to make PDF support only available to users who have installed the library themselves. Once the library is installed, the PDF report option will function. +You can also specify multiple output formats by using comma (',') as separator: + +```console +cve-bin-tool file -f csv,json,html -o report +``` + +Note: Please don't use spaces between comma (',') and the output formats. + ### -c CVSS, --cvss CVSS This option specifies the minimum CVSS score (as integer in range 0 to 10) of the CVE to report. The default value is 0 which results in all CVEs being reported. diff --git a/test/test_cli.py b/test/test_cli.py index e4d617222f..f62416d07f 100644 --- a/test/test_cli.py +++ b/test/test_cli.py @@ -540,7 +540,7 @@ def test_console_output_depending_reportlab_existence(self, caplog): my_test_filename_pathlib.unlink() pkg_to_spoof = "reportlab" - not_installed_msg = "PDF output not available. Default to console." + not_installed_msg = "PDF output not available." execution = [ "cve-bin-tool", "-f", diff --git a/test/test_output_engine.py b/test/test_output_engine.py index cc6c6858c0..33e77cf71f 100644 --- a/test/test_output_engine.py +++ b/test/test_output_engine.py @@ -897,7 +897,7 @@ def test_output_file(self): if filename.is_file(): contains_filename = True - if "Output stored at" in cm.output[0]: + if "JSON report stored at" in cm.output[0]: contains_msg = True # reset everything back @@ -907,6 +907,38 @@ def test_output_file(self): self.assertEqual(contains_filename, True) self.assertEqual(contains_msg, True) + def test_output_file_wrapper(self): + """Test file generation logic in output_file_wrapper""" + logger = logging.getLogger() + self.output_engine.filename = "test-report" + + with self.assertLogs(logger, logging.INFO) as cm: + self.output_engine.output_file_wrapper(output_types=["json", "html"]) + + html_file = Path("test-report.html") + json_file = Path("test-report.json") + + if html_file.is_file() and json_file.is_file(): + contains_filename = True + else: + contains_filename = False + + if ( + "JSON report stored at" in cm.output[0] + and "HTML report stored at" in cm.output[1] + ): + contains_msg = True + else: + contains_msg = False + + # reset everything back + html_file.unlink() + json_file.unlink() + self.output_engine.filename = "" + + self.assertEqual(contains_filename, True) + self.assertEqual(contains_msg, True) + def test_output_file_filename_already_exists(self): """Tests output_file when filename already exist"""