diff --git a/src/zenzic/cli/_governance.py b/src/zenzic/cli/_governance.py index 211ad3a..1c58df9 100644 --- a/src/zenzic/cli/_governance.py +++ b/src/zenzic/cli/_governance.py @@ -113,9 +113,9 @@ def count_per_file_ignores(config: ZenzicConfig) -> int: if not isinstance(codes, list): continue normalized_codes = { - str(code).upper().strip() + str(code).strip().upper() for code in codes - if isinstance(code, str) and str(code).upper().startswith("Z") + if isinstance(code, str) and str(code).strip().upper().startswith("Z") } total += len(normalized_codes) return total diff --git a/src/zenzic/core/suppressions.py b/src/zenzic/core/suppressions.py index 85e2b95..9dc1559 100644 --- a/src/zenzic/core/suppressions.py +++ b/src/zenzic/core/suppressions.py @@ -102,12 +102,11 @@ def is_suppressed(self, line_no: int, code: str) -> bool: self.global_tracker.mark_directory_policy_used(pattern, code) return True - suppressed = False for d in self.directives: - if d.line_no == line_no and d.code == code: + if d.line_no == line_no and d.code == code and not d.consumed: d.consumed = True - suppressed = True - return suppressed + return True + return False def get_dead_suppressions(self) -> list["RuleFinding"]: """Yield Z603 findings for all directives that were never consumed.""" @@ -146,7 +145,7 @@ def __init__(self, config: "ZenzicConfig"): if getattr(config, "governance", None) and config.governance.directory_policies: for pattern, codes in config.governance.directory_policies.items(): for code in codes: - self.unused_dir_policies.add((pattern, str(code).upper())) + self.unused_dir_policies.add((pattern, str(code).strip().upper())) if getattr(config, "excluded_file_patterns", None): for pattern in config.excluded_file_patterns: diff --git a/src/zenzic/models/config.py b/src/zenzic/models/config.py index 4d10424..90416aa 100644 --- a/src/zenzic/models/config.py +++ b/src/zenzic/models/config.py @@ -704,11 +704,12 @@ def _validate_no_swallowed_root_keys(data: dict[str, Any]) -> None: tables_to_check = [] if isinstance(value, dict): tables_to_check.append(value) - elif isinstance(value, list) and value and isinstance(value[0], dict): - tables_to_check.extend(value) + elif isinstance(value, list) and value: + tables_to_check.extend(item for item in value if isinstance(item, dict)) for table in tables_to_check: - swallowed = set(table.keys()) & root_keys + if isinstance(table, dict): + swallowed = set(table.keys()) & root_keys if swallowed: from rich.markup import escape diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 0000000..4cb7532 --- /dev/null +++ b/tests/test_security.py @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: 2026 PythonWoods +# SPDX-License-Identifier: Apache-2.0 +"""Security and robustness tests for Zenzic core.""" + +from __future__ import annotations + +from pathlib import Path +import pytest + +from zenzic.models.config import ZenzicConfig +from zenzic.cli._governance import count_per_file_ignores +from zenzic.core.suppressions import SuppressionTracker + + +def test_ghost_policy_leading_space_remediation() -> None: + """Vulnerability 1: Ensure leading/trailing spaces in per_file_ignores are counted in DQS.""" + config_data = { + "governance": { + "per_file_ignores": { + "docs/index.md": [" Z101"] + } + } + } + config = ZenzicConfig(**config_data) + + # Verify the space-prefixed code is properly counted as a suppression + per_file_count = count_per_file_ignores(config) + assert per_file_count == 1, "The leading-space ignore must be counted as a suppression to avoid DQS bypass" + + +def test_toml_bomb_mixed_type_array_remediation() -> None: + """Vulnerability 2: Ensure mixed-type arrays in configuration tables do not crash the swallowed root validator.""" + data = { + "custom_rules": [ + {"id": "ZZ-TEST", "pattern": "test", "message": "test"}, + 42 + ] + } + + # This should not raise an AttributeError crash + try: + ZenzicConfig._validate_no_swallowed_root_keys(data) + except AttributeError: + pytest.fail("Swallowed root validator crashed on mixed-type array item.") + + +def test_duplicate_inline_suppressions_remediation() -> None: + """Vulnerability 3: Ensure duplicate inline suppressions are not fully consumed by a single finding.""" + # We have two ignore comments for Z101 on line 1, but only one Z101 finding + text = " \n" + tracker = SuppressionTracker(Path("dummy.md"), text) + + assert len(tracker.directives) == 2 + + # First Z101 finding is suppressed and consumes one directive + assert tracker.is_suppressed(1, "Z101") + + # One of the two Z101 directives should be dead (unconsumed) + dead = tracker.get_dead_suppressions() + assert len(dead) == 1, "The duplicate inline ignore must remain unconsumed and trigger Z603" + assert dead[0].rule_id == "Z603"