diff --git a/appguardrail_core/rules.py b/appguardrail_core/rules.py index c06ea203..c9a890fa 100644 --- a/appguardrail_core/rules.py +++ b/appguardrail_core/rules.py @@ -106,12 +106,11 @@ def as_dict(self) -> dict[str, Any]: def extract_public_references(message: str) -> tuple[str, ...]: """Extract OWASP, CWE, and CVE references already embedded in rule copy.""" - return tuple( - dict.fromkeys( - " ".join(match.group(1).split()) - for match in REFERENCE_RE.finditer(message or "") - ) - ) + seen = {} + if message: + for match in REFERENCE_RE.finditer(message): + seen[" ".join(match.group(1).split())] = None + return tuple(seen) def _category_for_references(references: tuple[str, ...], fallback: str) -> str: @@ -174,8 +173,9 @@ def validate_rule_metadata(metadata: RuleMetadata | dict[str, Any]) -> list[str] def _merge_references(*groups: tuple[str, ...]) -> tuple[str, ...]: - return tuple( - dict.fromkeys( - reference for group in groups for reference in group if reference - ) - ) + seen = {} + for group in groups: + for reference in group: + if reference: + seen[reference] = None + return tuple(seen) diff --git a/tests/test_reference_deduplication_contract.py b/tests/test_reference_deduplication_contract.py new file mode 100644 index 00000000..2323ebf7 --- /dev/null +++ b/tests/test_reference_deduplication_contract.py @@ -0,0 +1,36 @@ +"""Regression contracts for reference extraction and ordered deduplication.""" + +from appguardrail_core.rules import _merge_references, extract_public_references + + +def test_extract_public_references_preserves_first_seen_order_and_normalizes_space() -> None: + """Repeated public references keep first-seen order after whitespace normalization.""" + message = ( + "Finding [CWE-918 - Server-Side Request Forgery] then " + "[OWASP A10:2021 - Server-Side Request Forgery], then " + "[CWE-918 - Server-Side Request Forgery] again." + ) + + assert extract_public_references(message) == ( + "CWE-918 - Server-Side Request Forgery", + "OWASP A10:2021 - Server-Side Request Forgery", + ) + + +def test_extract_public_references_handles_empty_and_unmatched_messages() -> None: + """Messages without supported taxonomy references return an empty tuple.""" + assert extract_public_references("") == () + assert extract_public_references("plain finding without a public reference") == () + + +def test_merge_references_preserves_order_while_dropping_empty_and_duplicate_values() -> None: + """Merging groups keeps the first occurrence and omits empty references.""" + assert _merge_references( + ("CWE-918 - Server-Side Request Forgery", "", "CWE-74 - Injection"), + ("CWE-918 - Server-Side Request Forgery", "OWASP A03:2021 - Injection"), + (), + ) == ( + "CWE-918 - Server-Side Request Forgery", + "CWE-74 - Injection", + "OWASP A03:2021 - Injection", + )