diff --git a/CHANGELOG.d/1216-sarif-help-uri.md b/CHANGELOG.d/1216-sarif-help-uri.md new file mode 100644 index 000000000..90822215f --- /dev/null +++ b/CHANGELOG.d/1216-sarif-help-uri.md @@ -0,0 +1,7 @@ +### Fixed + +- SARIF rule metadata now emits only absolute web URLs in `helpUri`, maps the + repository's OWASP Top 10:2021 labels to canonical OWASP pages, and preserves + human-readable reference labels in `help.text`. Malformed hosts, whitespace, + control characters, and incomplete percent escapes are skipped without + aborting SARIF generation (#1216). diff --git a/appguardrail_core/sarif.py b/appguardrail_core/sarif.py index ca4a9fb6b..b4fd788e2 100644 --- a/appguardrail_core/sarif.py +++ b/appguardrail_core/sarif.py @@ -12,7 +12,9 @@ from __future__ import annotations +import re from typing import Any, Iterable +from urllib.parse import urlsplit from .findings import is_deploy_blocking, normalize_findings @@ -24,6 +26,30 @@ _LEVEL = {"CRITICAL": "error", "HIGH": "error", "WARNING": "warning", "INFO": "note"} _SECURITY_SEVERITY = {"CRITICAL": "9.0", "HIGH": "7.0", "WARNING": "4.0", "INFO": "2.0"} +_OWASP_HELP_URIS = { + "OWASP A01:2021 - Broken Access Control": ( + "https://top10.owasp.org/A01_2021-Broken_Access_Control/" + ), + "OWASP A03:2021 - Injection": "https://top10.owasp.org/A03_2021-Injection/", + "OWASP A05:2021 - Security Misconfiguration": ( + "https://top10.owasp.org/A05_2021-Security_Misconfiguration/" + ), + "OWASP A06:2021 - Vulnerable and Outdated Components": ( + "https://top10.owasp.org/A06_2021-Vulnerable_and_Outdated_Components/" + ), + "OWASP A07:2021 - Identification and Authentication Failures": ( + "https://top10.owasp.org/" + "A07_2021-Identification_and_Authentication_Failures/" + ), + "OWASP A08:2021 - Software and Data Integrity Failures": ( + "https://top10.owasp.org/" + "A08_2021-Software_and_Data_Integrity_Failures/" + ), + "OWASP A10:2021 - Server-Side Request Forgery": ( + "https://top10.owasp.org/A10_2021-Server-Side_Request_Forgery_%28SSRF%29/" + ), +} + def _tags(finding: dict[str, Any]) -> list[str]: tags = ["security", str(finding.get("category") or "misconfig")] @@ -32,6 +58,26 @@ def _tags(finding: dict[str, Any]) -> list[str]: return tags +def _help_uri(references: Iterable[Any]) -> str | None: + """Return the first absolute web URI represented by a rule reference.""" + for reference in references: + candidate = _OWASP_HELP_URIS.get(str(reference), str(reference).strip()) + if any( + ord(character) <= 0x20 or ord(character) == 0x7F + for character in candidate + ): + continue + if re.search(r"%(?![0-9A-Fa-f]{2})", candidate): + continue + try: + parsed = urlsplit(candidate) + except ValueError: + continue + if parsed.scheme in {"http", "https"} and parsed.netloc and parsed.hostname: + return candidate + return None + + def findings_to_sarif( findings: Iterable[dict[str, Any]], *, tool_version: str = "0.0.0" ) -> dict[str, Any]: @@ -52,17 +98,17 @@ def findings_to_sarif( "text": f["message"].strip().splitlines()[0][:200] }, "fullDescription": {"text": f["message"].strip()}, - "helpUri": ( - refs[0] - if refs - else "https://github.com/ContextualWisdomLab/appguardrail" - ), "defaultConfiguration": {"level": _LEVEL.get(severity, "note")}, "properties": { "tags": _tags(f), "security-severity": _SECURITY_SEVERITY.get(severity, "2.0"), }, } + if refs: + rule["help"] = {"text": "\n".join(str(ref) for ref in refs)} + help_uri = _help_uri(refs) + if help_uri is not None: + rule["helpUri"] = help_uri rules[rule_id] = rule results.append( diff --git a/tests/test_sarif.py b/tests/test_sarif.py index 742082030..4c51b6c52 100644 --- a/tests/test_sarif.py +++ b/tests/test_sarif.py @@ -1,5 +1,7 @@ """Tests for SARIF 2.1.0 output (appguardrail_core.sarif).""" +from urllib.parse import urlsplit + from appguardrail_core.sarif import findings_to_sarif FINDINGS = [ @@ -53,6 +55,7 @@ def test_levels_and_security_severity(): stripe_rule = run["tool"]["driver"]["rules"][0] assert stripe_rule["properties"]["security-severity"] == "9.0" assert "CWE-798" in stripe_rule["properties"]["tags"] + assert stripe_rule["helpUri"] == "https://stripe.com/docs/keys" def test_location_and_deploy_blocking(): @@ -72,3 +75,92 @@ def test_empty_findings_valid(): run = findings_to_sarif([])["runs"][0] assert run["results"] == [] assert run["tool"]["driver"]["rules"] == [] + + +def test_help_uri_is_a_uri_and_reference_labels_remain_readable(): + """SARIF helpUri values are URLs, while labels remain in help text.""" + findings = [ + { + "severity": "WARNING", + "rule_id": "missing-authorization", + "message": "Authorization is missing", + "file": "src/auth.py", + "line": 1, + "references": ["OWASP A01:2021 - Broken Access Control"], + }, + { + "severity": "WARNING", + "rule_id": "unsafe-default", + "message": "An unsafe default is enabled", + "file": "config.toml", + "line": 2, + "references": ["OWASP A05:2021 - Security Misconfiguration"], + }, + { + "severity": "INFO", + "rule_id": "local-guidance", + "message": "Review local guidance", + "file": "README.md", + "line": 3, + "references": ["Internal security review guide"], + }, + ] + + rules = findings_to_sarif(findings)["runs"][0]["tool"]["driver"]["rules"] + assert rules[0]["helpUri"] == ( + "https://top10.owasp.org/A01_2021-Broken_Access_Control/" + ) + assert rules[1]["helpUri"] == ( + "https://top10.owasp.org/A05_2021-Security_Misconfiguration/" + ) + assert "helpUri" not in rules[2] + assert rules[2]["help"]["text"] == "Internal security review guide" + for rule in rules[:2]: + parsed = urlsplit(rule["helpUri"]) + assert parsed.scheme == "https" + assert parsed.netloc + assert rule["help"]["text"].startswith("OWASP ") + + +def test_help_uri_skips_unparseable_reference_and_uses_later_valid_url(): + """An unparseable reference cannot abort SARIF generation.""" + findings = [ + { + "severity": "WARNING", + "rule_id": "malformed-reference", + "message": "Reference metadata is malformed", + "file": "rules/example.yml", + "line": 1, + "references": ["https://[", "https://example.com/guidance"], + } + ] + + rule = findings_to_sarif(findings)["runs"][0]["tool"]["driver"]["rules"][0] + assert rule["helpUri"] == "https://example.com/guidance" + assert rule["help"]["text"] == "https://[\nhttps://example.com/guidance" + + +def test_help_uri_omits_http_references_with_invalid_uri_characters(): + """Whitespace, controls, and incomplete escapes stay out of helpUri.""" + findings = [ + { + "severity": "INFO", + "rule_id": "invalid-uri-characters", + "message": "Reference metadata is not a URI", + "file": "rules/example.yml", + "line": 2, + "references": [ + "https://exa mple.com/path", + "https://example.com/%zz", + "https://example.com/path\x01", + ], + } + ] + + rule = findings_to_sarif(findings)["runs"][0]["tool"]["driver"]["rules"][0] + assert "helpUri" not in rule + assert rule["help"]["text"] == ( + "https://exa mple.com/path\n" + "https://example.com/%zz\n" + "https://example.com/path\x01" + )