Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.d/1216-sarif-help-uri.md
Original file line number Diff line number Diff line change
@@ -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).
56 changes: 51 additions & 5 deletions appguardrail_core/sarif.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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")]
Expand All @@ -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]:
Expand All @@ -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(
Expand Down
92 changes: 92 additions & 0 deletions tests/test_sarif.py
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down Expand Up @@ -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():
Expand All @@ -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"
)
Loading