sonar-ansible icon indicating copy to clipboard operation
sonar-ansible copied to clipboard

To whoever aiming to use this module in 2024

Open florck opened this issue 6 months ago • 0 comments

I hope this will save you time.

This plugin has not been updated for a very long time, and then it is not working anymore with newer ansible-lint versions.

You can try another approach with sarif exports, and generic issues import.

Note that at the date I write this message, the direct sarif import is not working for ansible-lint generated reports.

First say to ansible-lint to generate a sarif export:

ansible-lint --sarif-file ansible-lint-sarif.json

To convert the sarif to generic-issue-import-format, you may decide to use a script that I paste right below.

Once you have the json file, you simply have to add the following to you sonar-project.properties file:

sonar.externalIssuesReportPaths=converted-file-sq.json

I hope this helps.

"""Convert SARIF to Generic SonarQube issues import format.

Initially based on work made by David Fischer <[email protected]>
* https://community.sonarsource.com/t/import-sarif-results-as-security-hotspots/83223
* docs.sonarqube.org/9.8/analyzing-source-code/importing-external-issues/generic-issue-import-format
* https://gist.github.com/davidfischer-ch/cdfede27ac053a8332b2127becc07608

Authors: David Fischer <[email protected]>, Florck
"""

from __future__ import annotations

from pathlib import Path
from typing import Final
import collections
import json
import os
import sys

# https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/sarif-v2.1.0-os.html#_Toc34317648
# SonarQube severity can be one of BLOCKER, CRITICAL, MAJOR, MINOR, INFO
LEVEL_TO_SERVERITY: Final[dict[str, str]] = {
    "warning": "MEDIUM",
    "error": "HIGH",
    "note": "LOW",
    "none": "LOW",
}
DEFAULT_LEVEL: Final[str] = "warning"
DEFAULT_CLEAN_CODE_ATTRIBUTE: Final[str] = "CONVENTIONAL"
DEFAULT_SOFTWARE_QUALITY: Final[str] = "RELIABILITY"


Position = collections.namedtuple("Position", ["line", "column"])


def fix_start_end(start, end, file_path):
    """Ensure the end position makes sense or fix it"""
    lines = Path(file_path).read_text(encoding="utf-8").split(os.linesep)
    if start == end or (end.column and end.column > len(lines[end.line])):
        if end.line + 1 < len(lines):
            # Move end position to next line at column 0
            end = Position(end.line + 1, 0)
        else:
            # Move start to previous line at same column
            # Move end position to same line at column 0
            start = Position(start.line - 1, start.column)
            end = Position(end.line, 0)
    return start, end


def get_rules(sarif_rules, engine_id):
    """Get rules as formatted by sonar."""

    rules: list[dict] = []

    for rule_id in sarif_rules:
        sarif_rule = sarif_rules[rule_id]
        description = sarif_rule.get("shortDescription", {}).get("text")
        description += "<br />"
        description += sarif_rule.get("help", {}).get("text")
        description += "<br />"
        description += f'<a href="{sarif_rule.get("helpUri")}">Documentation</a>'
        description += "<br /> <h3>Tags</h3><br /> "
        description += ",".join(sarif_rule.get("properties", {}).get("tags", []))
        rule = {
            "id": rule_id,
            "name": sarif_rule["name"],
            "description": description,
            "engineId": engine_id,
            "cleanCodeAttribute": DEFAULT_CLEAN_CODE_ATTRIBUTE,
            "impacts": [
                {
                    "softwareQuality": DEFAULT_SOFTWARE_QUALITY,
                    "severity": LEVEL_TO_SERVERITY[
                        sarif_rule.get("defaultConfiguration", {}).get(
                            "level", DEFAULT_LEVEL
                        )
                    ],
                }
            ],
        }
        rules.append(rule)
    return rules


def get_issues(
    run_data, source, sarif_rules, engine_key, run_index
):  # pylint:disable=too-many-locals
    """Get the issues formatted as expected by sonar."""

    issues: list[dict] = []

    for result_index, result_data in enumerate(run_data["results"], 1):

        # Code is not programmed to handle multiple locations, because ... Its a WIP
        if (num_locations := len(result_data["locations"])) != 1:
            raise NotImplementedError(
                f"File {source} : run[{run_index}].results[{result_index}].locations[] "
                f"size expected 1, actual {num_locations}"
            )

        rule_id = result_data["ruleId"]
        rule_data = (
            sarif_rules[rule_id] if sarif_rules else {}
        )  # Only if rules is not empty
        location_data = result_data["locations"][0]["physicalLocation"]
        file_path = location_data["artifactLocation"]["uri"]

        issue = {
            "primaryLocation": {
                "filePath": file_path,
                "message": rule_data.get("help", {}).get("text"),
            },
            "ruleId": rule_id,
        }

        # Converting location data
        start = Position(
            location_data["region"]["startLine"] - 1,
            location_data["region"].get("startColumn", 1) - 1,
        )
        end = Position(
            location_data["region"].get("endLine", start.line + 1) - 1,
            location_data["region"].get("endColumn", start.column + 1) - 1,
        )

        # Fix location data for some tools (data is wrong or missing)
        if engine_key in {"ansible-lint", "robocop"}:
            start, end = fix_start_end(start, end, file_path)

        # Lines are 1-indexed both in SARIF and Sonar Generic
        # Columns are 1-indexed in SARIF 0-indexed in Sonar Generic
        issue["primaryLocation"]["textRange"] = {
            "startLine": start.line + 1,
            "startColumn": start.column,
            "endLine": end.line + 1,
            "endColumn": end.column,
        }

        issues.append(issue)
    return issues


def main(
    source: Path | str, target: Path | str
) -> None:  # pylint:disable=too-many-locals
    """Implement main logic."""

    source = Path(source).resolve()
    target = Path(target).resolve()

    if target.exists():
        raise IOError(f'Target file "{target}" already exist.')

    sarif_data: dict = json.loads(source.read_text(encoding="utf-8"))
    if "sarif" not in sarif_data["$schema"]:
        raise ValueError("Source is (probably) not a valid sarif file.")

    issues: list[dict] = []
    rules: list[dict] = []

    for run_index, run_data in enumerate(sarif_data["runs"], 1):

        driver_data = run_data["tool"]["driver"]
        engine_id = driver_data["name"]
        engine_key = engine_id.lower()

        sarif_rules: dict[str, dict] = {
            rule["id"]: rule for rule in driver_data.get("rules", {})
        }

        rules.extend(get_rules(sarif_rules, engine_id))

        issues.extend(get_issues(run_data, source, sarif_rules, engine_key, run_index))
    target.write_text(
        json.dumps({"rules": rules, "issues": issues}, indent=2), encoding="utf-8"
    )


if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2])

florck avatar Aug 07 '24 14:08 florck