Skip to content

Commit

Permalink
Add native SARIF output support (#2062)
Browse files Browse the repository at this point in the history
* Add SARIF output support

Fix lint and test issue

fix lint issue

Fix type issue

address feedbacks

set explicity type

Add "/" to path

use snake case

* Lint issues fixes

* Fix lint issues

* Add help uri
  • Loading branch information
yongyan-gh committed Apr 6, 2022
1 parent 5df5e50 commit c058c6d
Show file tree
Hide file tree
Showing 6 changed files with 283 additions and 5 deletions.
1 change: 1 addition & 0 deletions .config/dictionary.txt
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit c058c6d

Please sign in to comment.