Repository: bridgecrewio/checkov

Issue:
Allow specifying the output file name
**Describe the issue**
Hello, I was looking for a way to change the output filename when using the `-o` flag.

I can specify the path to save the file, but as per [2500](https://github.com/bridgecrewio/checkov/pull/2500/files#diff-38786ae57e8435a1c2c390d07eb59c18995c91fada560575800a4f4e4ad6c6feR214-R218) it appears that the path is only configurable and not the filename itself.

**Additional context**

The below output demonstrates the issue:
```
$ checkov -d . -o json --output-file-path ./checkov-keycloak-report.json
[-snip-]
2022-08-10 16:51:48,144 [MainThread  ] [ERROR]
An error occurred while writing json results to file: ./checkov-keycloak-report.json/results_json.json
Traceback (most recent call last):
  File "/usr/local/Cellar/checkov/2.1.60/libexec/lib/python3.10/site-packages/checkov/common/runners/runner_registry.py", line 102, in save_output_to_file
    with open(file_name, 'w') as f:
FileNotFoundError: [Errno 2] No such file or directory: './checkov-keycloak-report.json/results_json.json'
```

However, If I change `--output-file-path .` then it creates the file results_json.json. I want to change the filename locally from `results_json.json` to `checkov-keycloak-report.json`


Discussion:
hey @tspearconquest thanks for reaching out. you can easily achieve this by redirecting the CLI output
```shell
checkov -d . -o json > checkov-keycloak-report.json
```
Hello,

Thank you for that. In #661 I proposed to make a wrapper script for the pre-commit hook. I can include this redirect in the wrapper to satisfy the requirement for the hook but it would be much better if checkov instead took the `--output-file-path` as a filename.

The lines highlighted on [this link](https://github.com/bridgecrewio/checkov/pull/2500/files#diff-38786ae57e8435a1c2c390d07eb59c18995c91fada560575800a4f4e4ad6c6feR213-R219) could be shortened to:

```
        if config.output_file_path:
            for output in config.output:
                self.save_output_to_file(file_name=f'{config.output_file_path}', data=data_outputs[output],
                                     data_format=output)
```

Where `config.output_file_path` can contain either a full or relative path, or just simply a filename, and that would satisfy everything this issue requests. Please advise if a PR would be accepted with this proposed change? I'm happy to submit.
this would basically be a breaking change for a flag, which I don't see the need for. Creating a new flag for it is possible, but I'm not sure why this is actually a requirement for the hook?
It's a requirement for all of checkov. I need output in json format from my pre-commit hooks to submit for government compliance requirements in my organization.

It's preferable in my case to have the hook drop the output file in the root of the repo where I can add it to my gitignore without any path on it, so it doesn't get committed.
I don't like the filename being chosen for me. I would rather name the file as checkov-keycloak-report.json to match every other file like this created from my pre-commit hooks where it is formatted as "toolname-projectname-report.json"
As you suggested, I am proceeding with the redirect, but I would still request this be added as a new flag.
> 

Hello, 

I'm afraid the redirection does not export the report for the SARIF format

```console
$ checkov --directory . --hard-fail-on MEDIUM --skip-check CKV_DOCKER_2 --output sarif > checkov.sarif
$ tail -n5 checkov.sarif
        File: /docker/Dockerfile:38-38
        Guide: https://docs.bridgecrew.io/docs/ensure-that-a-user-for-the-container-has-been-created


Wrote output in SARIF format to the file 'results.sarif'
```


Resolving patch:
diff --git a/checkov/common/output/report.py b/checkov/common/output/report.py
--- a/checkov/common/output/report.py
+++ b/checkov/common/output/report.py
@@ -377,7 +377,6 @@ def print_failed_github_md(self, use_bc_ids: bool = False) -> str:
                 showindex=True,
             )
             output_data = f"### {self.check_type} scan results:\n\n{table}\n\n---\n"
-            print(output_data)
             return output_data
         else:
             return "\n\n---\n\n"
diff --git a/checkov/common/runners/runner_registry.py b/checkov/common/runners/runner_registry.py
--- a/checkov/common/runners/runner_registry.py
+++ b/checkov/common/runners/runner_registry.py
@@ -9,7 +9,6 @@
 
 from collections import defaultdict
 from collections.abc import Iterable
-from json import dumps
 from pathlib import Path
 from typing import List, Dict, Any, Optional, cast, TYPE_CHECKING, TypeVar
 
@@ -44,6 +43,7 @@
 
 _BaseRunner = TypeVar("_BaseRunner", bound="BaseRunner[Any]")
 
+CONSOLE_OUTPUT = "console"
 CHECK_BLOCK_TYPES = frozenset(["resource", "data", "provider", "module"])
 OUTPUT_CHOICES = ["cli", "cyclonedx", "json", "junitxml", "github_failed_only", "sarif", "csv"]
 SUMMARY_POSITIONS = frozenset(['top', 'bottom'])
@@ -125,8 +125,9 @@ def _handle_report(self, scan_report: Report, repo_root_for_plan_enrichment: lis
 
     def save_output_to_file(self, file_name: str, data: str, data_format: str) -> None:
         try:
-            with open(file_name, 'w') as f:
-                f.write(data)
+            file_path = Path(file_name)
+            file_path.parent.mkdir(parents=True, exist_ok=True)
+            file_path.write_text(data)
             logging.info(f"\nWrote output in {data_format} format to the file '{file_name}')")
         except EnvironmentError:
             logging.error(f"\nAn error occurred while writing {data_format} results to file: {file_name}",
@@ -203,15 +204,21 @@ def print_reports(
             created_baseline_path: Optional[str] = None,
             baseline: Optional[Baseline] = None,
     ) -> Literal[0, 1]:
-        output_formats = set(config.output)
+        output_formats: "dict[str, str]" = {}
+
+        if config.output_file_path and "," in config.output_file_path:
+            output_paths = config.output_file_path.split(",")
+            for idx, output_format in enumerate(config.output):
+                output_formats[output_format] = output_paths[idx]
+        else:
+            output_formats = {output_format: CONSOLE_OUTPUT for output_format in config.output}
 
-        if "cli" in config.output and not config.quiet:
-            print(f"{self.banner}\n")
         exit_codes = []
         cli_reports = []
         report_jsons = []
         sarif_reports = []
         junit_reports = []
+        github_reports = []
         cyclonedx_reports = []
         csv_sbom_report = CSVSBOM()
 
@@ -231,7 +238,7 @@ def print_reports(
                 if "junitxml" in config.output:
                     junit_reports.append(report)
                 if "github_failed_only" in config.output:
-                    data_outputs["github_failed_only"] += report.print_failed_github_md(use_bc_ids=config.output_bc_ids)
+                    github_reports.append(report.print_failed_github_md(use_bc_ids=config.output_bc_ids))
                 if "sarif" in config.output:
                     sarif_reports.append(report)
                 if "cli" in config.output:
@@ -248,7 +255,20 @@ def print_reports(
             exit_code_thresholds = self.get_fail_thresholds(config, report.check_type)
             exit_codes.append(report.get_exit_code(exit_code_thresholds))
 
+        if "github_failed_only" in config.output:
+            github_output = "".join(github_reports)
+
+            self._print_to_console(
+                output_formats=output_formats,
+                output_format="github_failed_only",
+                output=github_output,
+            )
+
+            data_outputs["github_failed_only"] = github_output
         if "cli" in config.output:
+            if not config.quiet:
+                print(f"{self.banner}\n")
+
             cli_output = ''
             for report in cli_reports:
                 cli_output += report.print_console(
@@ -259,51 +279,69 @@ def print_reports(
                     use_bc_ids=config.output_bc_ids,
                     summary_position=config.summary_position
                 )
-            print(cli_output)
+
+            self._print_to_console(
+                output_formats=output_formats,
+                output_format="cli",
+                output=cli_output,
+                url=url,
+            )
+
             # Remove colors from the cli output
             ansi_escape = re.compile(r'(?:\x1B[@-_]|[\x80-\x9F])[0–9:;<=>?]*[ -/]*[@-~]')
             data_outputs['cli'] = ansi_escape.sub('', cli_output)
-            if url:
-                print("More details: {}".format(url))
-            output_formats.remove("cli")
-            if output_formats:
-                print(OUTPUT_DELIMITER)
         if "sarif" in config.output:
             master_report = Report("merged")
-            print(self.banner)
+
+            output_format = output_formats["sarif"]
+            if "cli" not in config.output and output_format == CONSOLE_OUTPUT:
+                print(self.banner)
+
             for report in sarif_reports:
-                print(report.print_console(
-                    is_quiet=config.quiet,
-                    is_compact=config.compact,
-                    created_baseline_path=created_baseline_path,
-                    baseline=baseline,
-                    use_bc_ids=config.output_bc_ids,
-                    summary_position=config.summary_position
-                ))
+                if "cli" not in config.output and output_format == CONSOLE_OUTPUT:
+                    print(report.print_console(
+                        is_quiet=config.quiet,
+                        is_compact=config.compact,
+                        created_baseline_path=created_baseline_path,
+                        baseline=baseline,
+                        use_bc_ids=config.output_bc_ids,
+                        summary_position=config.summary_position
+                    ))
                 master_report.failed_checks += report.failed_checks
                 master_report.skipped_checks += report.skipped_checks
-            if url:
-                print("More details: {}".format(url))
-            master_report.write_sarif_output(self.tool)
-            data_outputs['sarif'] = json.dumps(master_report.get_sarif_json(self.tool), cls=CustomJSONEncoder)
-            output_formats.remove("sarif")
-            if output_formats:
-                print(OUTPUT_DELIMITER)
+
+            if output_format == CONSOLE_OUTPUT:
+                # don't write to file, if an explicit file path was set
+                master_report.write_sarif_output(self.tool)
+
+            if output_format == CONSOLE_OUTPUT:
+                del output_formats["sarif"]
+
+                if "cli" not in config.output and url:
+                    print("More details: {}".format(url))
+                if CONSOLE_OUTPUT in output_formats.values():
+                    print(OUTPUT_DELIMITER)
+
+            data_outputs["sarif"] = json.dumps(master_report.get_sarif_json(self.tool), cls=CustomJSONEncoder)
         if "json" in config.output:
             if config.compact and report_jsons:
                 self.strip_code_blocks_from_json(report_jsons)
+
+            report_json_output: "list[dict[str, Any]] | dict[str, Any]" = report_jsons
             if not report_jsons:
-                print(dumps(Report("").get_summary(), indent=4, cls=CustomJSONEncoder))
-                data_outputs['json'] = json.dumps(Report("").get_summary(), cls=CustomJSONEncoder)
+                report_json_output = Report("").get_summary()
             elif len(report_jsons) == 1:
-                print(dumps(report_jsons[0], indent=4, cls=CustomJSONEncoder))
-                data_outputs['json'] = json.dumps(report_jsons[0], cls=CustomJSONEncoder)
-            else:
-                print(dumps(report_jsons, indent=4, cls=CustomJSONEncoder))
-                data_outputs['json'] = json.dumps(report_jsons, cls=CustomJSONEncoder)
-            output_formats.remove("json")
-            if output_formats:
-                print(OUTPUT_DELIMITER)
+                report_json_output = report_jsons[0]
+
+            json_output = json.dumps(report_json_output, indent=4, cls=CustomJSONEncoder)
+
+            self._print_to_console(
+                output_formats=output_formats,
+                output_format="json",
+                output=json_output,
+            )
+
+            data_outputs["json"] = json.dumps(report_json_output, cls=CustomJSONEncoder)
         if "junitxml" in config.output:
             properties = Report.create_test_suite_properties_block(config)
 
@@ -315,21 +353,26 @@ def print_reports(
             else:
                 test_suites = [Report("").get_test_suite(properties=properties)]
 
-            data_outputs['junitxml'] = Report.get_junit_xml_string(test_suites)
-            print(data_outputs['junitxml'])
+            junit_output = Report.get_junit_xml_string(test_suites)
 
-            output_formats.remove("junitxml")
-            if output_formats:
-                print(OUTPUT_DELIMITER)
+            self._print_to_console(
+                output_formats=output_formats,
+                output_format="junitxml",
+                output=junit_output,
+            )
+
+            data_outputs['junitxml'] = junit_output
         if "cyclonedx" in config.output:
             cyclonedx = CycloneDX(repo_id=metadata_integration.bc_integration.repo_id, reports=cyclonedx_reports)
             cyclonedx_output = cyclonedx.get_xml_output()
 
-            print(cyclonedx_output)
+            self._print_to_console(
+                output_formats=output_formats,
+                output_format="cyclonedx",
+                output=cyclonedx_output,
+            )
+
             data_outputs["cyclonedx"] = cyclonedx_output
-            output_formats.remove("cyclonedx")
-            if output_formats:
-                print(OUTPUT_DELIMITER)
         if "csv" in config.output:
             is_api_key = False
             if 'bc_api_key' in config and config.bc_api_key is not None:
@@ -344,14 +387,37 @@ def print_reports(
                       'junitxml': 'results_junitxml.xml',
                       'cyclonedx': 'results_cyclonedx.xml'}
         if config.output_file_path:
-            for output in config.output:
-                if output in file_names:
-                    self.save_output_to_file(file_name=f'{config.output_file_path}/{file_names[output]}',
-                                             data=data_outputs[output],
-                                             data_format=output)
+            if output_formats:
+                for output_format, output_path in output_formats.items():
+                    self.save_output_to_file(
+                        file_name=output_path,
+                        data=data_outputs[output_format],
+                        data_format=output_format,
+                    )
+            else:
+                for output in config.output:
+                    if output in file_names:
+                        self.save_output_to_file(
+                            file_name=f'{config.output_file_path}/{file_names[output]}',
+                            data=data_outputs[output],
+                            data_format=output,
+                        )
         exit_code = 1 if 1 in exit_codes else 0
         return cast(Literal[0, 1], exit_code)
 
+    def _print_to_console(self, output_formats: dict[str, str], output_format: str, output: str, url: str | None = None) -> None:
+        """Prints the output to console, if needed"""
+
+        output_dest = output_formats[output_format]
+        if output_dest == CONSOLE_OUTPUT:
+            del output_formats[output_format]
+
+            print(output)
+            if url:
+                print(f"More details: {url}")
+            if CONSOLE_OUTPUT in output_formats.values():
+                print(OUTPUT_DELIMITER)
+
     def print_iac_bom_reports(self, output_path: str,
                               scan_reports: list[Report],
                               output_types: list[str],
diff --git a/checkov/main.py b/checkov/main.py
--- a/checkov/main.py
+++ b/checkov/main.py
@@ -394,7 +394,11 @@ def add_parser_args(parser: ArgumentParser) -> None:
                default=None,
                help='Report output format. Add multiple outputs by using the flag multiple times (-o sarif -o cli)')
     parser.add('--output-file-path', default=None,
-               help='Name for output file. The first selected output via output flag will be saved to the file (default output is cli)')
+               help='Name of the output folder to save the chosen output formats. '
+                    'Advanced usage: '
+                    'By using -o cli -o junitxml --output-file-path console,results.xml the CLI output will be printed '
+                    'to the console and the JunitXML output to the file results.xml.'
+               )
     parser.add('--output-bc-ids', action='store_true',
                help='Print Bridgecrew platform IDs (BC...) instead of Checkov IDs (CKV...), if the check exists in the platform')
     parser.add('--include-all-checkov-policies', action='store_true',


Test patch:
diff --git a/tests/common/runner_registry/test_runner_registry.py b/tests/common/runner_registry/test_runner_registry.py
--- a/tests/common/runner_registry/test_runner_registry.py
+++ b/tests/common/runner_registry/test_runner_registry.py
@@ -4,7 +4,11 @@
 
 import os
 import io
+from pathlib import Path
 from unittest.mock import patch
+
+from _pytest.capture import CaptureFixture
+
 from checkov.cloudformation.runner import Runner as cfn_runner
 from checkov.common.bridgecrew.check_type import CheckType
 from checkov.common.bridgecrew.code_categories import CodeCategoryMapping
@@ -356,5 +360,43 @@ def test_extra_resources_removed_from_report(capsys):
         assert len(report.extra_resources) == 0
 
 
+def test_output_file_path_with_output_mapping(tmp_path: Path, capsys: CaptureFixture[str]):
+    # given
+    test_files_dir = Path(__file__).parent / "example_s3_tf"
+    runner_filter = RunnerFilter(framework=None, checks=None, skip_checks=None)
+    runner_registry = RunnerRegistry(
+        banner, runner_filter, tf_runner(), cfn_runner(), k8_runner()
+    )
+    reports = runner_registry.run(root_folder=str(test_files_dir))
+
+    json_file_path = tmp_path / "result.json"
+    xml_file_path = tmp_path / "sub_folder/result.xml"
+    config = argparse.Namespace(
+        file=['./example_s3_tf/main.tf'],
+        compact=False,
+        output=["json", "cli", "junitxml"],
+        quiet=False,
+        soft_fail=False,
+        soft_fail_on=None,
+        hard_fail_on=None,
+        output_file_path=f"{json_file_path},console,{xml_file_path}",
+        use_enforcement_rules=None,
+        output_bc_ids=False,
+        summary_position="top"
+    )
+
+    # when
+    runner_registry.print_reports(scan_reports=reports, config=config)
+
+    # then
+    assert 'By bridgecrew.io' in capsys.readouterr().out
+
+    assert json_file_path.exists()
+    assert '"check_type": "terraform"' in json_file_path.read_text()
+
+    assert xml_file_path.exists()
+    assert "<testcase " in xml_file_path.read_text()
+
+
 if __name__ == "__main__":
     unittest.main()