|
1 | | -"""Tests for SARIF output.""" |
| 1 | +"""Tests for SARIF 2.1.0 output formatting.""" |
2 | 2 |
|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
5 | 5 | import json |
6 | 6 |
|
| 7 | +import pytest |
| 8 | + |
| 9 | +from ai_sec_scan import __version__ |
7 | 10 | from ai_sec_scan.models import Finding, ScanResult, Severity |
8 | | -from ai_sec_scan.sarif import to_sarif, to_sarif_json |
| 11 | +from ai_sec_scan.sarif import finding_to_sarif_result, to_sarif, to_sarif_json, _build_rules |
| 12 | + |
| 13 | + |
| 14 | +def _finding(**overrides) -> Finding: |
| 15 | + defaults = dict( |
| 16 | + file_path="src/app.py", |
| 17 | + line_start=42, |
| 18 | + severity=Severity.HIGH, |
| 19 | + title="Hardcoded Secret", |
| 20 | + description="API key found in source", |
| 21 | + recommendation="Store secrets in environment variables", |
| 22 | + ) |
| 23 | + defaults.update(overrides) |
| 24 | + return Finding(**defaults) |
9 | 25 |
|
10 | 26 |
|
11 | | -def _make_result(findings: list[Finding] | None = None) -> ScanResult: |
| 27 | +def _result(findings: list[Finding] | None = None) -> ScanResult: |
12 | 28 | return ScanResult( |
13 | 29 | findings=findings or [], |
14 | | - files_scanned=1, |
| 30 | + files_scanned=2, |
15 | 31 | scan_duration=0.5, |
16 | | - provider="test", |
17 | | - model="test-model", |
| 32 | + provider="anthropic", |
| 33 | + model="claude-3.5-sonnet", |
18 | 34 | ) |
19 | 35 |
|
20 | 36 |
|
21 | | -def _make_finding(**kwargs) -> Finding: # type: ignore[no-untyped-def] |
22 | | - defaults = { |
23 | | - "file_path": "app.py", |
24 | | - "line_start": 1, |
25 | | - "severity": Severity.HIGH, |
26 | | - "title": "Test Issue", |
27 | | - "description": "Test description", |
28 | | - "recommendation": "Test fix", |
29 | | - } |
30 | | - defaults.update(kwargs) |
31 | | - return Finding(**defaults) |
| 37 | +class TestFindingToSarifResult: |
| 38 | + def test_basic_finding_maps_correctly(self) -> None: |
| 39 | + f = _finding(cwe_id="CWE-798") |
| 40 | + result = finding_to_sarif_result(f) |
32 | 41 |
|
| 42 | + assert result["ruleId"] == "CWE-798" |
| 43 | + assert result["level"] == "error" |
| 44 | + assert "Hardcoded Secret" in result["message"]["text"] |
| 45 | + loc = result["locations"][0]["physicalLocation"] |
| 46 | + assert loc["artifactLocation"]["uri"] == "src/app.py" |
| 47 | + assert loc["region"]["startLine"] == 42 |
33 | 48 |
|
34 | | -class TestSarif: |
35 | | - def test_empty_sarif_structure(self) -> None: |
36 | | - sarif = to_sarif(_make_result()) |
37 | | - assert sarif["version"] == "2.1.0" |
38 | | - assert "$schema" in sarif |
39 | | - assert len(sarif["runs"]) == 1 |
40 | | - assert sarif["runs"][0]["results"] == [] |
| 49 | + def test_line_end_included_when_set(self) -> None: |
| 50 | + f = _finding(line_end=50) |
| 51 | + result = finding_to_sarif_result(f) |
| 52 | + region = result["locations"][0]["physicalLocation"]["region"] |
| 53 | + assert region["endLine"] == 50 |
41 | 54 |
|
42 | | - def test_sarif_tool_info(self) -> None: |
43 | | - sarif = to_sarif(_make_result()) |
44 | | - driver = sarif["runs"][0]["tool"]["driver"] |
45 | | - assert driver["name"] == "ai-sec-scan" |
46 | | - assert "frankentini" in driver["informationUri"] |
47 | | - |
48 | | - def test_sarif_with_finding(self) -> None: |
49 | | - finding = _make_finding( |
50 | | - line_start=42, |
51 | | - cwe_id="CWE-798", |
52 | | - owasp_category="A07:2021", |
53 | | - ) |
54 | | - sarif = to_sarif(_make_result([finding])) |
55 | | - results = sarif["runs"][0]["results"] |
56 | | - assert len(results) == 1 |
57 | | - assert results[0]["ruleId"] == "CWE-798" |
58 | | - assert results[0]["level"] == "error" |
59 | | - loc = results[0]["locations"][0]["physicalLocation"] |
60 | | - assert loc["region"]["startLine"] == 42 |
61 | | - assert loc["artifactLocation"]["uri"] == "app.py" |
62 | | - |
63 | | - def test_sarif_properties(self) -> None: |
64 | | - finding = _make_finding(cwe_id="CWE-89", owasp_category="A03:2021") |
65 | | - sarif = to_sarif(_make_result([finding])) |
66 | | - props = sarif["runs"][0]["results"][0]["properties"] |
67 | | - assert props["cwe"] == "CWE-89" |
68 | | - assert props["owasp"] == "A03:2021" |
69 | | - |
70 | | - def test_sarif_no_properties_without_metadata(self) -> None: |
71 | | - finding = _make_finding(cwe_id=None, owasp_category=None) |
72 | | - sarif = to_sarif(_make_result([finding])) |
73 | | - assert "properties" not in sarif["runs"][0]["results"][0] |
74 | | - |
75 | | - def test_sarif_level_mapping(self) -> None: |
76 | | - for sev, expected in [ |
| 55 | + def test_line_end_absent_when_none(self) -> None: |
| 56 | + f = _finding(line_end=None) |
| 57 | + result = finding_to_sarif_result(f) |
| 58 | + region = result["locations"][0]["physicalLocation"]["region"] |
| 59 | + assert "endLine" not in region |
| 60 | + |
| 61 | + def test_rule_id_falls_back_to_title(self) -> None: |
| 62 | + f = _finding(cwe_id=None) |
| 63 | + result = finding_to_sarif_result(f) |
| 64 | + assert result["ruleId"] == "hardcoded-secret" |
| 65 | + |
| 66 | + def test_severity_level_mapping(self) -> None: |
| 67 | + for sev, expected_level in [ |
77 | 68 | (Severity.CRITICAL, "error"), |
78 | 69 | (Severity.HIGH, "error"), |
79 | 70 | (Severity.MEDIUM, "warning"), |
80 | 71 | (Severity.LOW, "warning"), |
81 | 72 | (Severity.INFO, "note"), |
82 | 73 | ]: |
83 | | - finding = _make_finding(severity=sev) |
84 | | - sarif = to_sarif(_make_result([finding])) |
85 | | - assert sarif["runs"][0]["results"][0]["level"] == expected |
| 74 | + f = _finding(severity=sev) |
| 75 | + result = finding_to_sarif_result(f) |
| 76 | + assert result["level"] == expected_level |
| 77 | + |
| 78 | + def test_properties_include_cwe_and_owasp(self) -> None: |
| 79 | + f = _finding(cwe_id="CWE-89", owasp_category="A03:2021") |
| 80 | + result = finding_to_sarif_result(f) |
| 81 | + assert result["properties"]["cwe"] == "CWE-89" |
| 82 | + assert result["properties"]["owasp"] == "A03:2021" |
| 83 | + |
| 84 | + def test_properties_omitted_when_no_extras(self) -> None: |
| 85 | + f = _finding(cwe_id=None, owasp_category=None) |
| 86 | + result = finding_to_sarif_result(f) |
| 87 | + assert "properties" not in result |
| 88 | + |
| 89 | + def test_recommendation_in_message(self) -> None: |
| 90 | + f = _finding(recommendation="Use parameterized queries") |
| 91 | + result = finding_to_sarif_result(f) |
| 92 | + assert "Use parameterized queries" in result["message"]["text"] |
| 93 | + |
| 94 | + |
| 95 | +class TestToSarif: |
| 96 | + def test_schema_and_version(self) -> None: |
| 97 | + doc = to_sarif(_result()) |
| 98 | + assert doc["version"] == "2.1.0" |
| 99 | + assert "$schema" in doc |
| 100 | + |
| 101 | + def test_tool_info(self) -> None: |
| 102 | + doc = to_sarif(_result()) |
| 103 | + driver = doc["runs"][0]["tool"]["driver"] |
| 104 | + assert driver["name"] == "ai-sec-scan" |
| 105 | + assert driver["version"] == __version__ |
| 106 | + assert "github.com" in driver["informationUri"] |
| 107 | + |
| 108 | + def test_empty_findings_produce_empty_results(self) -> None: |
| 109 | + doc = to_sarif(_result([])) |
| 110 | + assert doc["runs"][0]["results"] == [] |
| 111 | + assert doc["runs"][0]["tool"]["driver"]["rules"] == [] |
86 | 112 |
|
87 | | - def test_sarif_json_is_valid(self) -> None: |
88 | | - finding = _make_finding() |
89 | | - output = to_sarif_json(_make_result([finding])) |
| 113 | + def test_findings_ordered_by_severity(self) -> None: |
| 114 | + findings = [ |
| 115 | + _finding(severity=Severity.LOW, title="Low thing"), |
| 116 | + _finding(severity=Severity.CRITICAL, title="Critical thing"), |
| 117 | + ] |
| 118 | + doc = to_sarif(_result(findings)) |
| 119 | + results = doc["runs"][0]["results"] |
| 120 | + assert results[0]["level"] == "error" |
| 121 | + assert results[1]["level"] == "warning" |
| 122 | + |
| 123 | + def test_invocation_marked_successful(self) -> None: |
| 124 | + doc = to_sarif(_result()) |
| 125 | + invocations = doc["runs"][0]["invocations"] |
| 126 | + assert len(invocations) == 1 |
| 127 | + assert invocations[0]["executionSuccessful"] is True |
| 128 | + |
| 129 | + |
| 130 | +class TestToSarifJson: |
| 131 | + def test_output_is_valid_json(self) -> None: |
| 132 | + findings = [_finding(cwe_id="CWE-79")] |
| 133 | + output = to_sarif_json(_result(findings)) |
90 | 134 | parsed = json.loads(output) |
91 | 135 | assert parsed["version"] == "2.1.0" |
92 | 136 |
|
93 | | - def test_sarif_line_range(self) -> None: |
94 | | - finding = _make_finding(line_start=10, line_end=20) |
95 | | - sarif = to_sarif(_make_result([finding])) |
96 | | - region = sarif["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["region"] |
97 | | - assert region["startLine"] == 10 |
98 | | - assert region["endLine"] == 20 |
99 | | - |
100 | | - def test_sarif_no_end_line(self) -> None: |
101 | | - finding = _make_finding(line_start=5) |
102 | | - sarif = to_sarif(_make_result([finding])) |
103 | | - region = sarif["runs"][0]["results"][0]["locations"][0]["physicalLocation"]["region"] |
104 | | - assert region["startLine"] == 5 |
105 | | - assert "endLine" not in region |
| 137 | + def test_custom_indent(self) -> None: |
| 138 | + output_2 = to_sarif_json(_result(), indent=2) |
| 139 | + output_4 = to_sarif_json(_result(), indent=4) |
| 140 | + # 4-space indent produces longer output |
| 141 | + assert len(output_4) > len(output_2) |
| 142 | + |
106 | 143 |
|
107 | | - def test_sarif_rule_dedup(self) -> None: |
| 144 | +class TestBuildRules: |
| 145 | + def test_deduplicates_by_rule_id(self) -> None: |
108 | 146 | findings = [ |
109 | | - _make_finding(file_path="a.py", line_start=1, cwe_id="CWE-89"), |
110 | | - _make_finding(file_path="b.py", line_start=5, cwe_id="CWE-89"), |
| 147 | + _finding(cwe_id="CWE-89", title="SQL Injection A"), |
| 148 | + _finding(cwe_id="CWE-89", title="SQL Injection B"), |
111 | 149 | ] |
112 | | - sarif = to_sarif(_make_result(findings)) |
113 | | - rules = sarif["runs"][0]["tool"]["driver"]["rules"] |
| 150 | + rules = _build_rules(findings) |
114 | 151 | assert len(rules) == 1 |
115 | 152 | assert rules[0]["id"] == "CWE-89" |
116 | | - # But still two results |
117 | | - assert len(sarif["runs"][0]["results"]) == 2 |
118 | | - |
119 | | - def test_sarif_rule_help_uri(self) -> None: |
120 | | - finding = _make_finding(cwe_id="CWE-89") |
121 | | - sarif = to_sarif(_make_result([finding])) |
122 | | - rule = sarif["runs"][0]["tool"]["driver"]["rules"][0] |
123 | | - assert "cwe.mitre.org" in rule["helpUri"] |
124 | | - assert "89" in rule["helpUri"] |
125 | | - |
126 | | - def test_sarif_invocations(self) -> None: |
127 | | - sarif = to_sarif(_make_result()) |
128 | | - invocations = sarif["runs"][0]["invocations"] |
129 | | - assert len(invocations) == 1 |
130 | | - assert invocations[0]["executionSuccessful"] is True |
| 153 | + |
| 154 | + def test_different_cwe_ids_produce_separate_rules(self) -> None: |
| 155 | + findings = [ |
| 156 | + _finding(cwe_id="CWE-89", title="SQLi"), |
| 157 | + _finding(cwe_id="CWE-79", title="XSS"), |
| 158 | + ] |
| 159 | + rules = _build_rules(findings) |
| 160 | + ids = {r["id"] for r in rules} |
| 161 | + assert ids == {"CWE-89", "CWE-79"} |
| 162 | + |
| 163 | + def test_help_uri_generated_for_cwe(self) -> None: |
| 164 | + findings = [_finding(cwe_id="CWE-89")] |
| 165 | + rules = _build_rules(findings) |
| 166 | + assert rules[0]["helpUri"] == "https://cwe.mitre.org/data/definitions/89.html" |
| 167 | + |
| 168 | + def test_no_help_uri_without_cwe(self) -> None: |
| 169 | + findings = [_finding(cwe_id=None)] |
| 170 | + rules = _build_rules(findings) |
| 171 | + assert "helpUri" not in rules[0] |
| 172 | + |
| 173 | + def test_rule_descriptions(self) -> None: |
| 174 | + findings = [_finding(title="XSS", description="Cross-site scripting found")] |
| 175 | + rules = _build_rules(findings) |
| 176 | + assert rules[0]["shortDescription"]["text"] == "XSS" |
| 177 | + assert rules[0]["fullDescription"]["text"] == "Cross-site scripting found" |
0 commit comments