diff --git a/analyzer/codechecker_analyzer/cli/parse.py b/analyzer/codechecker_analyzer/cli/parse.py index ce67b37dcf..79b3b585ed 100644 --- a/analyzer/codechecker_analyzer/cli/parse.py +++ b/analyzer/codechecker_analyzer/cli/parse.py @@ -707,7 +707,7 @@ def get_output_file_path(default_file_name: str) -> Optional[str]: data = gerrit.convert(all_reports) dump_json_output(data, get_output_file_path("reports.json")) elif export == 'sarif': - data = sarif.convert(all_reports) + data = sarif.convert(all_reports, context.checker_labels) dump_json_output(data, get_output_file_path("reports.json")) elif export == 'baseline': data = baseline.convert(all_reports) diff --git a/analyzer/tests/functional/parse_sarif/__init__.py b/analyzer/tests/functional/parse_sarif/__init__.py new file mode 100644 index 0000000000..0b0114f7e4 --- /dev/null +++ b/analyzer/tests/functional/parse_sarif/__init__.py @@ -0,0 +1,11 @@ +# coding=utf-8 +# ------------------------------------------------------------------------- +# +# Part of the CodeChecker project, under the Apache License v2.0 with +# LLVM Exceptions. See LICENSE for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# ------------------------------------------------------------------------- + +# This file is empty, and is only present so that this directory will form a +# package. diff --git a/analyzer/tests/functional/parse_sarif/test_files/Makefile b/analyzer/tests/functional/parse_sarif/test_files/Makefile new file mode 100644 index 0000000000..390ab6a1ad --- /dev/null +++ b/analyzer/tests/functional/parse_sarif/test_files/Makefile @@ -0,0 +1,7 @@ +default: a.o + +a.o: a.cpp + $(CXX) -c a.cpp -o /dev/null + +clean: + rm -f a.o diff --git a/analyzer/tests/functional/parse_sarif/test_files/a.cpp b/analyzer/tests/functional/parse_sarif/test_files/a.cpp new file mode 100644 index 0000000000..bba79de360 --- /dev/null +++ b/analyzer/tests/functional/parse_sarif/test_files/a.cpp @@ -0,0 +1,9 @@ +int main() +{ + int x; + // codechecker_false_positive [DeadStores] testing suppression via source code comment + x = 1; + + // Suppressed by config file. + return 1 / 0; +} diff --git a/analyzer/tests/functional/parse_sarif/test_files/review_status.yaml b/analyzer/tests/functional/parse_sarif/test_files/review_status.yaml new file mode 100644 index 0000000000..425ecfab5a --- /dev/null +++ b/analyzer/tests/functional/parse_sarif/test_files/review_status.yaml @@ -0,0 +1,7 @@ +$version: 1 +rules: + - filters: + checker_name: core.DivideZero + actions: + review_status: intentional + reason: testing suppression via config file diff --git a/analyzer/tests/functional/parse_sarif/test_sarif.py b/analyzer/tests/functional/parse_sarif/test_sarif.py new file mode 100644 index 0000000000..20c69c5ceb --- /dev/null +++ b/analyzer/tests/functional/parse_sarif/test_sarif.py @@ -0,0 +1,179 @@ +# +# ------------------------------------------------------------------------- +# +# Part of the CodeChecker project, under the Apache License v2.0 with +# LLVM Exceptions. See LICENSE for license information. +# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception +# +# ------------------------------------------------------------------------- + +""" +Test parse --export sarif command. +""" + +import os +import json +import shutil +import subprocess +import unittest + +from libtest import env + + +class TestParseSarif(unittest.TestCase): + _ccClient = None + + def setup_class(self): + """Setup the environment for the tests.""" + + global TEST_WORKSPACE + TEST_WORKSPACE = env.get_workspace('skip') + + report_dir = os.path.join(TEST_WORKSPACE, 'reports') + os.makedirs(report_dir) + + os.environ['TEST_WORKSPACE'] = TEST_WORKSPACE + + def teardown_class(self): + """Delete the workspace associated with this test""" + + # TODO: If environment variable is set keep the workspace + # and print out the path. + global TEST_WORKSPACE + + print("Removing: " + TEST_WORKSPACE) + shutil.rmtree(TEST_WORKSPACE) + + def setup_method(self, _): + + # TEST_WORKSPACE is automatically set by test package __init__.py . + self.test_workspace = os.environ['TEST_WORKSPACE'] + + test_class = self.__class__.__name__ + print('Running ' + test_class + ' tests in ' + self.test_workspace) + + # Get the CodeChecker cmd if needed for the tests. + self._codechecker_cmd = env.codechecker_cmd() + self._tu_collector_cmd = env.tu_collector_cmd() + self.report_dir = os.path.join(self.test_workspace, "reports") + self.test_dir = os.path.join(os.path.dirname(__file__), 'test_files') + + def __run_cmd(self, cmd): + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=self.test_dir, + encoding="utf-8", + errors="ignore") + out, err = process.communicate() + + print(out) + print(err) + + if process.returncode != (2 if "parse" in cmd else 0): + return err + + return ''.join(out) + + def __log_and_analyze(self, analyze_args=None): + """ Log and analyze the test project. """ + build_json = os.path.join(self.test_workspace, "build.json") + + clean_cmd = ["make", "clean"] + out = subprocess.check_output(clean_cmd, + cwd=self.test_dir, + encoding="utf-8", errors="ignore") + print(out) + + # Create and run log command. + log_cmd = [self._codechecker_cmd, "log", "-b", "make", + "-o", build_json] + out = subprocess.check_output(log_cmd, + cwd=self.test_dir, + encoding="utf-8", errors="ignore") + print(out) + + # Create and run analyze command. + analyze_cmd = [ + self._codechecker_cmd, "analyze", "-c", build_json, + "--analyzers", "clangsa", "-o", self.report_dir] + + analyze_cmd.extend(analyze_args or []) + + self.__run_cmd(analyze_cmd) + + def test_parse_sarif_rules(self): + self.__log_and_analyze() + + parse_sarif_cmd = [self._codechecker_cmd, "parse", self.report_dir, + "-e", "sarif"] + out = self.__run_cmd(parse_sarif_cmd) + parsed_json = json.loads(out) + + rules = parsed_json['runs'][0]['tool']['driver']['rules'] + self.assertEqual(len(rules), 1) + + self.assertEqual(rules[0], { + 'id': 'core.DivideZero', + 'fullDescription': { + 'text': 'Division by zero' + }, + # pylint: disable-next=line-too-long + 'helpUri': 'https://clang.llvm.org/docs/analyzer/checkers.html#core-dividezero-c-c-objc', # noqa + 'defaultConfiguration': { + 'level': 'error' + } + }) + + def test_parse_sarif_suppression_source(self): + self.__log_and_analyze() + + parse_sarif_cmd = [self._codechecker_cmd, "parse", self.report_dir, + "-e", "sarif", "--review-status", "false_positive"] + out = self.__run_cmd(parse_sarif_cmd) + parsed_json = json.loads(out) + + res = parsed_json['runs'][0]['results'][0] + + self.assertIn('suppressions', res) + sups = res['suppressions'] + self.assertEqual(len(sups), 1) + suppression = sups[0] + + self.assertIn('kind', suppression) + self.assertIn('status', suppression) + self.assertIn('justification', suppression) + + self.assertEqual(suppression, { + 'kind': 'inSource', + 'status': 'accepted', + 'justification': 'testing suppression via source code comment', + }) + + def test_parse_sarif_suppression_rs_config(self): + self.__log_and_analyze([ + "--review-status-config", "review_status.yaml" + ]) + + parse_sarif_cmd = [self._codechecker_cmd, "parse", self.report_dir, + "-e", "sarif", "--review-status", "intentional"] + out = self.__run_cmd(parse_sarif_cmd) + parsed_json = json.loads(out) + + res = parsed_json['runs'][0]['results'][0] + + self.assertIn('suppressions', res) + sups = res['suppressions'] + self.assertEqual(len(sups), 1) + suppression = sups[0] + + self.assertIn('kind', suppression) + self.assertIn('status', suppression) + self.assertIn('justification', suppression) + + self.assertEqual(suppression, { + 'kind': 'external', + 'status': 'accepted', + 'justification': 'testing suppression via config file', + }) diff --git a/codechecker_common/review_status_handler.py b/codechecker_common/review_status_handler.py index a189b8dd82..93168f910d 100644 --- a/codechecker_common/review_status_handler.py +++ b/codechecker_common/review_status_handler.py @@ -290,7 +290,7 @@ def get_review_status_from_config( .encode(encoding='utf-8', errors='ignore') if 'reason' in rule['actions'] else b'', bug_hash=report.report_hash or "", - in_source=True) + in_source=False) return None diff --git a/tools/report-converter/codechecker_report_converter/report/output/sarif.py b/tools/report-converter/codechecker_report_converter/report/output/sarif.py index 09480eb1c7..678ce39606 100644 --- a/tools/report-converter/codechecker_report_converter/report/output/sarif.py +++ b/tools/report-converter/codechecker_report_converter/report/output/sarif.py @@ -1,9 +1,13 @@ -from typing import Dict, List +from typing import Dict, List, Optional from codechecker_report_converter.report import Report +from codechecker_report_converter.report.checker_labels import CheckerLabels from codechecker_report_converter.report.parser import sarif -def convert(reports: List[Report]) -> Dict: - sarif_parser = sarif.Parser() +def convert( + reports: List[Report], + checker_labels: Optional[CheckerLabels] = None +) -> Dict: + sarif_parser = sarif.Parser(checker_labels=checker_labels) return sarif_parser.convert(reports) diff --git a/tools/report-converter/codechecker_report_converter/report/parser/sarif.py b/tools/report-converter/codechecker_report_converter/report/parser/sarif.py index c727affe13..a42d4ffeb8 100644 --- a/tools/report-converter/codechecker_report_converter/report/parser/sarif.py +++ b/tools/report-converter/codechecker_report_converter/report/parser/sarif.py @@ -310,17 +310,35 @@ def convert( """ Converts the given reports to sarif format. """ tool_name, tool_version = get_tool_info() + checker_labels = self._checker_labels + rules = {} results = [] for report in reports: - if report.checker_name not in rules: - rules[report.checker_name] = { - "id": report.checker_name, + if (checker_name := report.checker_name) not in rules: + rules[checker_name] = { + "id": checker_name, "fullDescription": { "text": report.message } } + if checker_labels: + doc_url = checker_labels.label_of_checker( + checker_name, "doc_url", report.analyzer_name) + if doc_url: + if isinstance(doc_url, list): + doc_url = doc_url[0] + rules[checker_name]["helpUri"] = doc_url + + severity = checker_labels.severity( + checker_name, + report.analyzer_name) # type: ignore[call-arg] + if severity: + rules[checker_name]["defaultConfiguration"] = { + "level": self._to_level(severity) + } + results.append(self._create_result(report)) return { @@ -359,6 +377,25 @@ def _create_result(self, report: Report) -> Dict: }] } + # Set `suppressions` property. + if (rs := report.review_status) and rs.status != "unreviewed": + suppression = {} + + if rs.in_source: + suppression["kind"] = "inSource" + else: + suppression["kind"] = "external" + + if rs.status in ["suppress", "false_positive", "intentional"]: + suppression["status"] = "accepted" + elif rs.status == "confirmed": + suppression["status"] = "rejected" + + suppression["justification"] = rs.message.decode('utf-8') + + result["suppressions"] = [suppression] + + # Set `codeFlows` property. locations = [] if report.bug_path_events: @@ -439,6 +476,16 @@ def _create_location( } } + def _to_level(self, severity: str) -> str: + """ Map severity label value to level (ยง3.50.3). """ + if severity in ["STYLE", "LOW"]: + return "note" + elif severity == "MEDIUM": + return "warning" + elif severity in ["HIGH", "CRITICAL"]: + return "error" + return "warning" + def write(self, data: Any, output_file_path: str): """ Creates an analyzer output file from the given data. """ try: