Files
agent-desktop/tests/unit/security/skill_scanner/test_scan_policy.py
T

423 lines
14 KiB
Python

# -*- coding: utf-8 -*-
"""Tests for pineagents.security.skill_scanner.scan_policy.
Covers:
- _safe_compile regex helper
- Data class defaults and construction
- ScanPolicy._from_dict / _to_dict roundtrip
- ScanPolicy._deep_merge
- ScanPolicy.is_doc_path
- ScanPolicy.get_severity_override / is_rule_disabled
- ScanPolicy.from_yaml / to_yaml
- ScanPolicy.preset_names
"""
# pylint: disable=redefined-outer-name,unused-argument,protected-access
import re
import pytest
from pineagents.security.skill_scanner.scan_policy import (
AnalysisThresholdsPolicy,
CredentialPolicy,
FileClassificationPolicy,
FileLimitsPolicy,
HiddenFilePolicy,
RuleScopingPolicy,
ScanPolicy,
SeverityOverride,
_safe_compile,
)
# ---------------------------------------------------------------------------
# _safe_compile
# ---------------------------------------------------------------------------
class TestSafeCompile:
"""Tests for _safe_compile helper."""
def test_valid_pattern(self):
"""Valid regex should compile successfully."""
result = _safe_compile(r"\d+")
assert result is not None
assert result.search("123")
def test_invalid_pattern_returns_none(self):
"""Invalid regex should return None, not raise."""
result = _safe_compile(r"[invalid")
assert result is None
def test_too_long_pattern_returns_none(self):
"""Pattern exceeding max_length should return None."""
long_pattern = "a" * 1001
result = _safe_compile(long_pattern, max_length=1000)
assert result is None
def test_custom_max_length(self):
"""Custom max_length should be respected."""
pattern = "a" * 50
result = _safe_compile(pattern, max_length=49)
assert result is None
result = _safe_compile(pattern, max_length=50)
assert result is not None
def test_with_flags(self):
"""Flags should be passed through."""
result = _safe_compile(r"hello", re.IGNORECASE)
assert result is not None
assert result.search("HELLO")
# ---------------------------------------------------------------------------
# Data class defaults
# ---------------------------------------------------------------------------
class TestHiddenFilePolicy:
"""Tests for HiddenFilePolicy defaults."""
def test_defaults(self):
p = HiddenFilePolicy()
assert p.benign_dotfiles == set()
assert p.benign_dotdirs == set()
def test_custom(self):
p = HiddenFilePolicy(
benign_dotfiles={".gitignore"},
benign_dotdirs={".github"},
)
assert ".gitignore" in p.benign_dotfiles
assert ".github" in p.benign_dotdirs
class TestRuleScopingPolicy:
"""Tests for RuleScopingPolicy defaults."""
def test_defaults(self):
p = RuleScopingPolicy()
assert p.skillmd_and_scripts_only == set()
assert p.skip_in_docs == set()
assert p.code_only == set()
assert p.doc_path_indicators == set()
assert not p.doc_filename_patterns
assert p.dedupe_duplicate_findings is True
class TestCredentialPolicy:
"""Tests for CredentialPolicy defaults."""
def test_defaults(self):
p = CredentialPolicy()
assert p.known_test_values == set()
assert p.placeholder_markers == set()
class TestFileLimitsPolicy:
"""Tests for FileLimitsPolicy defaults."""
def test_defaults(self):
p = FileLimitsPolicy()
assert p.max_file_count == 100
assert p.max_file_size_bytes == 5_242_880
assert p.max_reference_depth == 5
class TestAnalysisThresholdsPolicy:
"""Tests for AnalysisThresholdsPolicy defaults."""
def test_defaults(self):
p = AnalysisThresholdsPolicy()
assert p.min_confidence_pct == 80
assert p.max_regex_pattern_length == 1000
class TestSeverityOverride:
"""Tests for SeverityOverride dataclass."""
def test_creation(self):
o = SeverityOverride(rule_id="R001", severity="LOW", reason="test")
assert o.rule_id == "R001"
assert o.severity == "LOW"
assert o.reason == "test"
def test_default_reason(self):
o = SeverityOverride(rule_id="R002", severity="INFO")
assert o.reason == ""
# ---------------------------------------------------------------------------
# ScanPolicy construction and helpers
# ---------------------------------------------------------------------------
class TestScanPolicyConstruction:
"""Tests for ScanPolicy default construction."""
def test_default_policy(self):
p = ScanPolicy()
assert p.policy_name == "default"
assert p.policy_version == "1.0"
assert p.preset_base == "balanced"
assert isinstance(p.hidden_files, HiddenFilePolicy)
assert isinstance(p.rule_scoping, RuleScopingPolicy)
assert isinstance(p.credentials, CredentialPolicy)
assert isinstance(p.file_classification, FileClassificationPolicy)
assert isinstance(p.file_limits, FileLimitsPolicy)
assert isinstance(p.analysis_thresholds, AnalysisThresholdsPolicy)
assert not p.severity_overrides
assert p.disabled_rules == set()
class TestScanPolicySeverityOverride:
"""Tests for get_severity_override."""
def test_returns_override_when_present(self):
p = ScanPolicy(
severity_overrides=[
SeverityOverride(rule_id="R001", severity="LOW"),
],
)
assert p.get_severity_override("R001") == "LOW"
def test_returns_none_when_absent(self):
p = ScanPolicy()
assert p.get_severity_override("R001") is None
def test_returns_first_match(self):
p = ScanPolicy(
severity_overrides=[
SeverityOverride(rule_id="R001", severity="LOW"),
SeverityOverride(rule_id="R001", severity="INFO"),
],
)
assert p.get_severity_override("R001") == "LOW"
class TestScanPolicyIsRuleDisabled:
"""Tests for is_rule_disabled."""
def test_disabled_rule(self):
p = ScanPolicy(disabled_rules={"R001", "R002"})
assert p.is_rule_disabled("R001") is True
def test_enabled_rule(self):
p = ScanPolicy(disabled_rules={"R001"})
assert p.is_rule_disabled("R003") is False
class TestScanPolicyIsDocPath:
"""Tests for is_doc_path."""
def test_doc_path_by_indicator(self):
p = ScanPolicy(
rule_scoping=RuleScopingPolicy(
doc_path_indicators={"docs", "examples"},
),
)
assert p.is_doc_path("docs/guide.md") is True
assert p.is_doc_path("examples/demo.py") is True
assert p.is_doc_path("src/main.py") is False
def test_doc_path_by_filename_pattern(self):
p = ScanPolicy(
rule_scoping=RuleScopingPolicy(
doc_filename_patterns=["readme", "tutorial"],
),
)
assert p.is_doc_path("readme.md") is True
assert p.is_doc_path("TUTORIAL.md") is True # case insensitive
assert p.is_doc_path("main.py") is False
def test_doc_path_no_match(self):
p = ScanPolicy()
assert p.is_doc_path("src/app.py") is False
# ---------------------------------------------------------------------------
# _from_dict / _to_dict roundtrip
# ---------------------------------------------------------------------------
class TestScanPolicyFromDict:
"""Tests for _from_dict parsing."""
def test_minimal_dict(self):
d = {}
p = ScanPolicy._from_dict(d)
assert p.policy_name == "default"
assert p.policy_version == "1.0"
def test_full_dict(self):
d = {
"policy_name": "custom",
"policy_version": "2.0",
"hidden_files": {
"benign_dotfiles": [".gitignore"],
"benign_dotdirs": [".github"],
},
"rule_scoping": {
"skillmd_and_scripts_only": ["R001"],
"skip_in_docs": ["R002"],
"code_only": ["R003"],
"doc_path_indicators": ["docs"],
"doc_filename_patterns": ["readme"],
"dedupe_duplicate_findings": False,
},
"credentials": {
"known_test_values": ["sk-test"],
"placeholder_markers": ["<YOUR_KEY>"],
},
"file_classification": {
"inert_extensions": [".png"],
"structured_extensions": [".svg"],
"archive_extensions": [".zip"],
"code_extensions": [".py"],
},
"file_limits": {
"max_file_count": 200,
"max_file_size_bytes": 10_485_760,
},
"severity_overrides": [
{"rule_id": "R001", "severity": "LOW", "reason": "safe"},
],
"disabled_rules": ["R004"],
}
p = ScanPolicy._from_dict(d)
assert p.policy_name == "custom"
assert p.hidden_files.benign_dotfiles == {".gitignore"}
assert p.rule_scoping.dedupe_duplicate_findings is False
assert p.credentials.known_test_values == {"sk-test"}
assert p.file_limits.max_file_count == 200
assert len(p.severity_overrides) == 1
assert p.severity_overrides[0].rule_id == "R001"
assert "R004" in p.disabled_rules
class TestScanPolicyToDict:
"""Tests for _to_dict serialization."""
def test_roundtrip(self):
p = ScanPolicy(
policy_name="test",
hidden_files=HiddenFilePolicy(
benign_dotfiles={".env"},
),
severity_overrides=[
SeverityOverride(rule_id="R1", severity="LOW"),
],
disabled_rules={"R2"},
)
d = p._to_dict()
assert d["policy_name"] == "test"
assert ".env" in d["hidden_files"]["benign_dotfiles"]
assert len(d["severity_overrides"]) == 1
assert "R2" in d["disabled_rules"]
def test_from_dict_to_dict_roundtrip(self):
"""_from_dict -> _to_dict should preserve key data."""
original = {
"policy_name": "roundtrip",
"hidden_files": {"benign_dotfiles": [".a"]},
"disabled_rules": ["R99"],
}
p = ScanPolicy._from_dict(original)
result = p._to_dict()
assert result["policy_name"] == "roundtrip"
assert ".a" in result["hidden_files"]["benign_dotfiles"]
assert "R99" in result["disabled_rules"]
# ---------------------------------------------------------------------------
# _deep_merge
# ---------------------------------------------------------------------------
class TestDeepMerge:
"""Tests for _deep_merge static method."""
def test_simple_merge(self):
base = {"a": 1, "b": 2}
override = {"b": 3, "c": 4}
result = ScanPolicy._deep_merge(base, override)
assert result == {"a": 1, "b": 3, "c": 4}
def test_nested_merge(self):
base = {"a": {"x": 1, "y": 2}}
override = {"a": {"y": 3, "z": 4}}
result = ScanPolicy._deep_merge(base, override)
assert result["a"] == {"x": 1, "y": 3, "z": 4}
def test_list_replaced_not_merged(self):
"""Lists in override should replace, not extend, base lists."""
base = {"items": [1, 2, 3]}
override = {"items": [4, 5]}
result = ScanPolicy._deep_merge(base, override)
assert result["items"] == [4, 5]
def test_base_unchanged(self):
"""_deep_merge should not mutate the base dict."""
base = {"a": {"x": 1}}
override = {"a": {"y": 2}}
ScanPolicy._deep_merge(base, override)
assert base == {"a": {"x": 1}}
# ---------------------------------------------------------------------------
# YAML I/O
# ---------------------------------------------------------------------------
class TestScanPolicyYamlIO:
"""Tests for from_yaml / to_yaml."""
def test_from_yaml_file_not_found(self):
"""from_yaml should raise FileNotFoundError for missing file."""
with pytest.raises(FileNotFoundError, match="not found"):
ScanPolicy.from_yaml("/nonexistent/policy.yaml")
def test_from_yaml_and_to_yaml_roundtrip(self, tmp_path):
"""Write a policy to YAML and read it back."""
p = ScanPolicy(
policy_name="test-policy",
hidden_files=HiddenFilePolicy(
benign_dotfiles={".gitignore"},
),
disabled_rules={"R001"},
)
path = tmp_path / "policy.yaml"
p.to_yaml(path)
assert path.exists()
loaded = ScanPolicy.from_yaml(path)
assert loaded.policy_name == "test-policy"
assert ".gitignore" in loaded.hidden_files.benign_dotfiles
assert "R001" in loaded.disabled_rules
def test_from_yaml_empty_file(self, tmp_path):
"""Empty YAML file should produce default policy."""
path = tmp_path / "empty.yaml"
path.write_text("")
p = ScanPolicy.from_yaml(path)
assert p.policy_name == "default"
def test_to_yaml_includes_header(self, tmp_path):
"""to_yaml should include a header comment."""
p = ScanPolicy()
path = tmp_path / "out.yaml"
p.to_yaml(path)
content = path.read_text()
assert "QwenPaw" in content or "Scan Policy" in content
class TestScanPolicyPresets:
"""Tests for preset_names and from_preset."""
def test_preset_names_returns_list(self):
names = ScanPolicy.preset_names()
assert isinstance(names, list)
def test_from_preset_unknown_raises(self):
with pytest.raises(ValueError, match="Unknown preset"):
ScanPolicy.from_preset("nonexistent")