From 75f0ea84f45e197c201b9fed166f9d74a4c9c893 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 06:01:49 +0900 Subject: [PATCH 1/3] test(sarif): reproduce non-URI help metadata --- tests/test_sarif.py | 47 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/test_sarif.py b/tests/test_sarif.py index 742082030..113ea918b 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 = [ @@ -72,3 +74,48 @@ 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 ") From e97f7b20a87f671e3ebfd4ef695c4d41f9dd2f87 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sun, 20 Sep 2026 06:02:23 +0900 Subject: [PATCH 2/3] fix(sarif): emit URI-valued rule help metadata --- CHANGELOG.d/1216-sarif-help-uri.md | 5 ++++ appguardrail_core/sarif.py | 45 ++++++++++++++++++++++++++---- tests/test_sarif.py | 1 + 3 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 CHANGELOG.d/1216-sarif-help-uri.md diff --git a/CHANGELOG.d/1216-sarif-help-uri.md b/CHANGELOG.d/1216-sarif-help-uri.md new file mode 100644 index 000000000..0bba19c5e --- /dev/null +++ b/CHANGELOG.d/1216-sarif-help-uri.md @@ -0,0 +1,5 @@ +### 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` (#1216). diff --git a/appguardrail_core/sarif.py b/appguardrail_core/sarif.py index ca4a9fb6b..8e3b5bef8 100644 --- a/appguardrail_core/sarif.py +++ b/appguardrail_core/sarif.py @@ -13,6 +13,7 @@ from __future__ import annotations from typing import Any, Iterable +from urllib.parse import urlsplit from .findings import is_deploy_blocking, normalize_findings @@ -24,6 +25,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 +57,16 @@ 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()) + parsed = urlsplit(candidate) + if parsed.scheme in {"http", "https"} and parsed.netloc: + return candidate + return None + + def findings_to_sarif( findings: Iterable[dict[str, Any]], *, tool_version: str = "0.0.0" ) -> dict[str, Any]: @@ -52,17 +87,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 113ea918b..c7f732a00 100644 --- a/tests/test_sarif.py +++ b/tests/test_sarif.py @@ -55,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(): From 4c45491dc1441c2b6fae0a3ec290a982a2de15ad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 26 Sep 2026 19:56:18 +0900 Subject: [PATCH 3/3] fix(sarif): reject malformed help URIs --- CHANGELOG.d/1216-sarif-help-uri.md | 4 ++- appguardrail_core/sarif.py | 15 ++++++++-- tests/test_sarif.py | 44 ++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.d/1216-sarif-help-uri.md b/CHANGELOG.d/1216-sarif-help-uri.md index 0bba19c5e..90822215f 100644 --- a/CHANGELOG.d/1216-sarif-help-uri.md +++ b/CHANGELOG.d/1216-sarif-help-uri.md @@ -2,4 +2,6 @@ - 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` (#1216). + 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 8e3b5bef8..b4fd788e2 100644 --- a/appguardrail_core/sarif.py +++ b/appguardrail_core/sarif.py @@ -12,6 +12,7 @@ from __future__ import annotations +import re from typing import Any, Iterable from urllib.parse import urlsplit @@ -61,8 +62,18 @@ 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()) - parsed = urlsplit(candidate) - if parsed.scheme in {"http", "https"} and parsed.netloc: + 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 diff --git a/tests/test_sarif.py b/tests/test_sarif.py index c7f732a00..4c51b6c52 100644 --- a/tests/test_sarif.py +++ b/tests/test_sarif.py @@ -120,3 +120,47 @@ def test_help_uri_is_a_uri_and_reference_labels_remain_readable(): 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" + )