diff --git a/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py b/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py index 3a4ac4255..7e8da7efd 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py +++ b/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py @@ -267,9 +267,10 @@ def _p2_pattern_matches( candidate = compiled.search(content, cursor) if candidate is None: return - if pattern == _ZERO_WIDTH_PATTERN and _zero_width_match_is_safe_emoji_zwj( - content, - candidate.start(), + if pattern == _ZERO_WIDTH_PATTERN and ( + # A leading U+FEFF is a byte-order mark, not hidden text. + (candidate.start() == 0 and content[0] == "\ufeff") + or _zero_width_match_is_safe_emoji_zwj(content, candidate.start()) ): cursor = candidate.end() continue diff --git a/src/skillspector/nodes/analyzers/static_patterns_rogue_agent.py b/src/skillspector/nodes/analyzers/static_patterns_rogue_agent.py index 2c461bf6d..1ce7a0f4f 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_rogue_agent.py +++ b/src/skillspector/nodes/analyzers/static_patterns_rogue_agent.py @@ -169,7 +169,9 @@ (r"(?:nohup|disown|setsid)\s+", 0.65), # Registry / plist for Windows/macOS persistence (r"(?:HKEY_|RegOpenKey|RegSetValue|reg\s+add)\s+", 0.8), - (r"(?:defaults\s+write|plist|launchctl\s+load)", 0.75), + # ``plist`` must not follow a letter, so identifiers such as + # ``relationshipList`` or ``CT_GradientStopList`` do not match. + (r"(?:defaults\s+write|(? float: ) _LICENSE_FILE_TYPES = frozenset({"markdown", "text", "other"}) -_LICENSE_BASENAME = re.compile(r"^(?:license|licenses|copying|notice|notices)(?:[._-].*)?$") +# SIL Open Font License files are conventionally named ``OFL.txt`` or +# ``-OFL.txt``. +_LICENSE_BASENAME = re.compile( + r"^(?:license|licenses|copying|notice|notices|(?:[^/]*[._-])?ofl)(?:[._-].*)?$" +) def _analyzer_representative_key(finding: AnalyzerFinding) -> tuple[object, ...]: @@ -476,6 +480,14 @@ def _normalize_license_line(line: str) -> str: ), 1, ), + ( + ( + 'the font software is provided "as is", without warranty of any kind,', + "express or implied, including but not limited to any warranties of", + "merchantability, fitness for a particular purpose and noninfringement", + ), + 1, + ), ) diff --git a/tests/nodes/analyzers/test_static_patterns.py b/tests/nodes/analyzers/test_static_patterns.py index 535006d18..da682043b 100644 --- a/tests/nodes/analyzers/test_static_patterns.py +++ b/tests/nodes/analyzers/test_static_patterns.py @@ -234,6 +234,30 @@ def test_p2_emoji_wrapped_smuggling_still_flagged(self): findings = static_runner.run_static_patterns(state, [prompt_injection_module]) assert any(f.rule_id == "P2" for f in findings) + @pytest.mark.parametrize("path", ["SKILL.md", "schemas/types.xsd"]) + def test_p2_leading_byte_order_mark_no_false_positive(self, path: str): + """A U+FEFF byte-order mark at offset 0 is an encoding marker, not hidden text.""" + state = { + "components": [path], + "file_cache": {path: '\ufeff\n\n'}, + } + findings = static_runner.run_static_patterns(state, [prompt_injection_module]) + assert not any(f.rule_id == "P2" for f in findings) + + @pytest.mark.parametrize( + "content", + [ + "# Title\n\nhidden\ufefftext\n", + "\ufeff# Title\n\nhidden\u200btext\n", + "\ufeff\u200bhidden text\n", + ], + ids=["mid_file_feff", "bom_then_zero_width_later", "bom_then_zero_width_same_line"], + ) + def test_p2_zero_width_after_byte_order_mark_still_produces_finding(self, content: str): + state = {"components": ["SKILL.md"], "file_cache": {"SKILL.md": content}} + findings = static_runner.run_static_patterns(state, [prompt_injection_module]) + assert any(f.rule_id == "P2" for f in findings) + def test_safe_content_no_p1_p2(self): """Safe content does not produce P1/P2.""" state = { @@ -1664,6 +1688,32 @@ def test_review_payload_reports_ea3(self) -> None: assert any(f.rule_id == "EA3" and f.start_line == 3 for f in findings) + @pytest.mark.parametrize("path", ["OFL.txt", "assets/SomeFont-OFL.txt"]) + def test_ofl_font_license_disclaimer_suppresses_ea3(self, path: str) -> None: + content = ( + "DISCLAIMER\n" + 'THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,\n' + "EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF\n" + "MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT\n" + "OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT.\n" + ) + findings = static_runner.run_static_patterns( + {"components": [path], "file_cache": {path: content}}, + [excessive_agency_module], + ) + + assert not any(f.rule_id == "EA3" for f in findings) + + def test_ofl_named_file_with_non_boilerplate_content_reports_ea3(self) -> None: + path = "assets/SomeFont-OFL.txt" + content = "You may take actions including but not limited to deleting user files.\n" + findings = static_runner.run_static_patterns( + {"components": [path], "file_cache": {path: content}}, + [excessive_agency_module], + ) + + assert any(f.rule_id == "EA3" and f.start_line == 1 for f in findings) + @pytest.mark.parametrize( "mutation,expected_line", [ @@ -1788,6 +1838,10 @@ def test_non_license_paths_preserve_ea3(self, path: str) -> None: ("license_terms.py", False), ("license.php", False), ("notice.c", False), + ("OFL.txt", True), + ("fonts/SomeFont-OFL.txt", True), + ("profl.txt", False), + ("ofl.py", False), ], ) def test_helper_boundaries(self, path: str, expected: bool) -> None: diff --git a/tests/unit/test_patterns_new.py b/tests/unit/test_patterns_new.py index 303339acd..726f2d2f6 100644 --- a/tests/unit/test_patterns_new.py +++ b/tests/unit/test_patterns_new.py @@ -1903,11 +1903,40 @@ def test_ra1_detected(self, content: str, filename: str, filetype: str) -> None: "shell", id="registry_key", ), + pytest.param( + "cp agent.plist ~/Library/LaunchAgents/com.example.agent.plist", + "install.sh", + "shell", + id="launch_agent_plist", + ), + pytest.param("write_plist(path, data)", "setup.py", "python", id="snake_case_plist"), ], ) def test_ra2_detected(self, content: str, filename: str, filetype: str) -> None: assert any(f.rule_id == "RA2" for f in ra_mod.analyze(content, filename, filetype)) + @pytest.mark.parametrize( + "content,filename,filetype", + [ + pytest.param( + "var relationshipList = document.getElementById('list');", + "template.html", + "other", + id="camel_case_identifier", + ), + pytest.param( + '', + "dml-main.xsd", + "other", + id="xsd_type_name", + ), + ], + ) + def test_ra2_plist_substring_inside_identifier_not_detected( + self, content: str, filename: str, filetype: str + ) -> None: + assert not any(f.rule_id == "RA2" for f in ra_mod.analyze(content, filename, filetype)) + def test_safe_content_produces_no_findings(self) -> None: findings = ra_mod.analyze( "This skill reads files and returns summaries.", "SKILL.md", "markdown"