Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add native SARIF output support #2062

Merged
merged 6 commits into from Apr 6, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .config/dictionary.txt
Expand Up @@ -139,6 +139,7 @@ rmtree
ruamel
rulesdir
rulesdirs
sarif
sdist
sdists
setenv
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/tox.yml
Expand Up @@ -156,7 +156,7 @@ jobs:
WSLENV: FORCE_COLOR:PYTEST_REQPASS:TOXENV:TOX_PARALLEL_NO_SPINNER
# Number of expected test passes, safety measure for accidental skip of
# tests. Update value if you add/remove tests.
PYTEST_REQPASS: 599
PYTEST_REQPASS: 605

steps:
- name: Activate WSL1
Expand Down
9 changes: 7 additions & 2 deletions src/ansiblelint/app.py
Expand Up @@ -60,8 +60,11 @@ def render_matches(self, matches: List[MatchError]) -> None:
"""Display given matches (if they are not fixed)."""
matches = [match for match in matches if not match.fixed]

if isinstance(self.formatter, formatters.CodeclimateJSONFormatter):
# If formatter CodeclimateJSONFormatter is chosen,
if isinstance(
self.formatter,
(formatters.CodeclimateJSONFormatter, formatters.SarifFormatter),
):
# If formatter CodeclimateJSONFormatter or SarifFormatter is chosen,
# then print only the matches in JSON
console.print(
self.formatter.format_result(matches), markup=False, highlight=False
Expand Down Expand Up @@ -221,6 +224,8 @@ def choose_formatter_factory(
r = formatters.QuietFormatter
elif options_list.format in ("json", "codeclimate"):
r = formatters.CodeclimateJSONFormatter
elif options_list.format == "sarif":
r = formatters.SarifFormatter
elif options_list.parseable or options_list.format == "pep8":
r = formatters.ParseableFormatter
return r
Expand Down
11 changes: 10 additions & 1 deletion src/ansiblelint/cli.py
Expand Up @@ -224,7 +224,16 @@ def get_cli_parser() -> argparse.ArgumentParser:
"-f",
dest="format",
default="rich",
choices=["rich", "plain", "rst", "json", "codeclimate", "quiet", "pep8"],
choices=[
"rich",
"plain",
"rst",
"json",
"codeclimate",
"quiet",
"pep8",
"sarif",
],
help="stdout formatting, json being an alias for codeclimate. (default: %(default)s)",
)
parser.add_argument(
Expand Down
127 changes: 126 additions & 1 deletion src/ansiblelint/formatters/__init__.py
Expand Up @@ -3,11 +3,12 @@
import json
import os
from pathlib import Path
from typing import TYPE_CHECKING, Any, Dict, Generic, List, TypeVar, Union
from typing import TYPE_CHECKING, Any, Dict, Generic, List, Tuple, TypeVar, Union

import rich

from ansiblelint.config import options
from ansiblelint.version import __version__

if TYPE_CHECKING:
from ansiblelint.errors import MatchError
Expand Down Expand Up @@ -201,3 +202,127 @@ def _severity_to_level(severity: str) -> str:
return "blocker"
# VERY_LOW, INFO or anything else
return "info"


class SarifFormatter(BaseFormatter[Any]):
"""Formatter for emitting violations in SARIF report format.

The spec of SARIF can be found here:
https://docs.oasis-open.org/sarif/sarif/v2.1.0/
"""

BASE_URI_ID = "SRCROOT"
TOOL_NAME = "Ansible-lint"
TOOL_URL = "https://github.com/ansible/ansible-lint"
SARIF_SCHEMA_VERSION = "2.1.0"
RULE_DOC_URL = "https://ansible-lint.readthedocs.io/en/latest/default_rules/"
SARIF_SCHEMA = (
"https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json"
)

def format_result(self, matches: List["MatchError"]) -> str:
"""Format a list of match errors as a JSON string."""
if not isinstance(matches, list):
raise RuntimeError(
f"The {self.__class__} was expecting a list of MatchError."
)

root_path = Path(str(self._base_dir)).as_uri()
root_path = root_path + "/" if not root_path.endswith("/") else root_path
rules, results = self._extract_results(matches)

tool = {
"driver": {
"name": self.TOOL_NAME,
"version": __version__,
"informationUri": self.TOOL_URL,
"rules": rules,
}
}

runs = [
{
"tool": tool,
"columnKind": "utf16CodeUnits",
"results": results,
"originalUriBaseIds": {
self.BASE_URI_ID: {"uri": root_path},
},
}
]

report = {
"$schema": self.SARIF_SCHEMA,
"version": self.SARIF_SCHEMA_VERSION,
"runs": runs,
}

return json.dumps(
report, default=lambda o: o.__dict__, sort_keys=False, indent=2
)

def _extract_results(
self, matches: List["MatchError"]
) -> Tuple[List[Any], List[Any]]:
rules = {}
results = []
for match in matches:
if match.rule.id not in rules:
rules[match.rule.id] = self._to_sarif_rule(match)
results.append(self._to_sarif_result(match))
return list(rules.values()), results

def _to_sarif_rule(self, match: "MatchError") -> Dict[str, Any]:
rule: Dict[str, Any] = {
"id": match.rule.id,
"name": match.rule.id,
"shortDescription": {
"text": self.escape(str(match.message)),
},
"defaultConfiguration": {
"level": self._to_sarif_level(match.rule.severity),
},
"help": {
"text": str(match.rule.description),
},
"helpUri": self.RULE_DOC_URL + "#" + match.rule.id,
"properties": {"tags": match.rule.tags},
}
if match.rule.link:
rule["helpUri"] = match.rule.link
return rule

def _to_sarif_result(self, match: "MatchError") -> Dict[str, Any]:
result: Dict[str, Any] = {
"ruleId": match.rule.id,
"message": {
"text": match.details,
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": self._format_path(match.filename or ""),
"uriBaseId": self.BASE_URI_ID,
},
"region": {
"startLine": match.linenumber,
},
},
},
],
}
if match.column:
result["locations"][0]["physicalLocation"]["region"][
"startColumn"
] = match.column
return result

@staticmethod
def _to_sarif_level(severity: str) -> str:
if severity in ["VERY_HIGH", "HIGH", "MEDIUM"]:
return "error"
if severity in ["LOW"]:
return "warning"
# VERY_LOW, INFO or anything else
return "note"
138 changes: 138 additions & 0 deletions test/test_formatter_sarif.py
@@ -0,0 +1,138 @@
"""Test the codeclimate JSON formatter."""
import json
import pathlib
import subprocess
import sys
from typing import List, Optional

import pytest

from ansiblelint.errors import MatchError
from ansiblelint.formatters import SarifFormatter
from ansiblelint.rules import AnsibleLintRule


class TestSarifFormatter:
"""Unit test for SarifFormatter."""

rule = AnsibleLintRule()
matches: List[MatchError] = []
formatter: Optional[SarifFormatter] = None

def setup_class(self) -> None:
"""Set up few MatchError objects."""
self.rule = AnsibleLintRule()
self.rule.id = "TCF0001"
self.rule.severity = "VERY_HIGH"
self.rule.description = "This is the rule description."
self.rule.link = "https://rules/help#TCF0001"
self.rule.tags = ["tag1", "tag2"]
self.matches = []
self.matches.append(
MatchError(
message="message",
linenumber=1,
column=10,
details="hello",
filename="filename.yml",
rule=self.rule,
)
)
self.matches.append(
MatchError(
message="message",
linenumber=2,
details="hello",
filename="filename.yml",
rule=self.rule,
)
)
self.formatter = SarifFormatter(pathlib.Path.cwd(), display_relative_path=True)

def test_format_list(self) -> None:
"""Test if the return value is a string."""
assert isinstance(self.formatter, SarifFormatter)
assert isinstance(self.formatter.format_result(self.matches), str)

def test_result_is_json(self) -> None:
"""Test if returned string value is a JSON."""
assert isinstance(self.formatter, SarifFormatter)
json.loads(self.formatter.format_result(self.matches))

def test_single_match(self) -> None:
"""Test negative case. Only lists are allowed. Otherwise a RuntimeError will be raised."""
assert isinstance(self.formatter, SarifFormatter)
with pytest.raises(RuntimeError):
self.formatter.format_result(self.matches[0]) # type: ignore

def test_result_is_list(self) -> None:
"""Test if the return SARIF object contains the results with length of 2."""
assert isinstance(self.formatter, SarifFormatter)
sarif = json.loads(self.formatter.format_result(self.matches))
assert len(sarif["runs"][0]["results"]) == 2

def test_validate_sarif_schema(self) -> None:
"""Test if the returned JSON is a valid SARIF report."""
assert isinstance(self.formatter, SarifFormatter)
sarif = json.loads(self.formatter.format_result(self.matches))
assert sarif["$schema"] == SarifFormatter.SARIF_SCHEMA
assert sarif["version"] == SarifFormatter.SARIF_SCHEMA_VERSION
driver = sarif["runs"][0]["tool"]["driver"]
assert driver["name"] == SarifFormatter.TOOL_NAME
assert driver["informationUri"] == SarifFormatter.TOOL_URL
rules = driver["rules"]
assert len(rules) == 1
assert rules[0]["id"] == self.matches[0].rule.id
assert rules[0]["name"] == self.matches[0].rule.id
assert rules[0]["shortDescription"]["text"] == self.matches[0].message
assert rules[0]["defaultConfiguration"]["level"] == "error"
assert rules[0]["help"]["text"] == self.matches[0].rule.description
assert rules[0]["properties"]["tags"] == self.matches[0].rule.tags
assert rules[0]["helpUri"] == self.rule.link
results = sarif["runs"][0]["results"]
assert len(results) == 2
for i, result in enumerate(results):
assert result["ruleId"] == self.matches[i].rule.id
assert result["message"]["text"] == self.matches[i].details
assert (
result["locations"][0]["physicalLocation"]["artifactLocation"]["uri"]
== self.matches[i].filename
)
assert (
result["locations"][0]["physicalLocation"]["artifactLocation"][
"uriBaseId"
]
== SarifFormatter.BASE_URI_ID
)
assert (
result["locations"][0]["physicalLocation"]["region"]["startLine"]
== self.matches[i].linenumber
)
if self.matches[i].column:
assert (
result["locations"][0]["physicalLocation"]["region"]["startColumn"]
== self.matches[i].column
)
else:
assert (
"startColumn"
not in result["locations"][0]["physicalLocation"]["region"]
)
assert sarif["runs"][0]["originalUriBaseIds"][SarifFormatter.BASE_URI_ID]["uri"]


def test_sarif_parsable_ignored() -> None:
"""Test that -p option does not alter SARIF format."""
cmd = [
sys.executable,
"-m",
"ansiblelint",
"-v",
"-p",
]
file = "examples/playbooks/empty_playbook.yml"
result = subprocess.run([*cmd, file], check=False)
result2 = subprocess.run([*cmd, "-p", file], check=False)

assert result.returncode == result2.returncode
assert result.stdout == result2.stdout