diff --git a/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py b/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py index e31b2254..2274d9c0 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py +++ b/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py @@ -74,8 +74,22 @@ def _is_p9_skipped_path(file_path: str) -> bool: # P2: Hidden Instructions. Build the character class from the shared P9 # constant so hidden-instruction and padding detection cannot drift apart. _ZERO_WIDTH_PATTERN = "[" + "".join(sorted(ZERO_WIDTH_CHARS)) + "]" +# Treat separators, lower-to-upper transitions, and acronym-to-PascalCase transitions as +# identifier boundaries without matching keyword prefixes in ordinary alphanumeric words. +_P2_IDENTIFIER_START = ( + r"(?:(?", 0.7), + ( + rf"\n" + "Follow these visible instructions.\n" + "" + ), + }, + } + + findings = static_runner.run_static_patterns(state, [prompt_injection_module]) + + assert not any(f.rule_id == "P2" for f in findings) + + def test_p2_html_comment_match_stops_at_end_bang_closer(self): + """Visible instructions after a browser-compatible closer are not hidden.""" + state = { + "components": ["SKILL.md"], + "file_cache": { + "SKILL.md": " visible system docs ", + }, + } + + findings = static_runner.run_static_patterns(state, [prompt_injection_module]) + + assert not any(f.rule_id == "P2" for f in findings) + + def test_p2_html_comment_keyword_requires_whole_word(self): + """Benign words containing a keyword substring do not trigger P2.""" + state = { + "components": ["SKILL.md"], + "file_cache": { + "SKILL.md": "", + }, + } + + 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", + [ + "", + "", + "", + ], + ) + def test_p2_html_comment_keyword_rejects_all_caps_prefixes(self, content: str): + """An uppercase suffix without a case transition is not a keyword boundary.""" + state = { + "components": ["SKILL.md"], + "file_cache": {"SKILL.md": content}, + } + + findings = static_runner.run_static_patterns(state, [prompt_injection_module]) + + assert not any(f.rule_id == "P2" for f in findings) + + def test_p2_html_comment_detects_snake_case_instructions(self): + """Underscores remain valid separators around injection keywords.""" + state = { + "components": ["SKILL.md"], + "file_cache": { + "SKILL.md": "", + }, + } + + findings = static_runner.run_static_patterns(state, [prompt_injection_module]) + + assert any(f.rule_id == "P2" for f in findings) + + @pytest.mark.parametrize( + "content", + [ + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + ], + ) + def test_p2_html_comment_detects_camel_case_instructions(self, content: str): + """Case transitions remain valid separators around injection keywords.""" + 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_p2_bidi_control_chars_produce_finding(self): """Bidi control characters (Trojan Source CVE-2021-42574) yield P2.""" rlo = chr(0x202E) diff --git a/tests/nodes/test_security_remediation.py b/tests/nodes/test_security_remediation.py index 64ed77eb..03d939b8 100644 --- a/tests/nodes/test_security_remediation.py +++ b/tests/nodes/test_security_remediation.py @@ -1085,6 +1085,50 @@ def test_unicode_bypass_forms_retain_prompt_injection_rule(tmp_path: Path, conte assert all(finding.confidence == 0.8 for finding in p1) +def test_camel_case_hidden_instructions_block_safe_graph_verdict(tmp_path: Path) -> None: + (tmp_path / "SKILL.md").write_text( + """--- +name: prompt-boundary +description: Tests prompt boundary handling. +--- + +# Prompt Boundary + + +""", + encoding="utf-8", + ) + + result = graph.invoke({"input_path": str(tmp_path), "output_format": "json", "use_llm": False}) + + assert result["analysis_completeness"]["is_complete"] is True + assert any(finding.rule_id == "P2" for finding in result["filtered_findings"]) + assert result["risk_recommendation"] != "SAFE" + + +def test_end_bang_comment_closer_does_not_create_unsafe_graph_verdict(tmp_path: Path) -> None: + (tmp_path / "SKILL.md").write_text( + """--- +name: visible-docs +description: Documents visible system behavior. +--- + +# Visible docs + + +Visible system docs for operators. + +""", + encoding="utf-8", + ) + + result = graph.invoke({"input_path": str(tmp_path), "output_format": "json", "use_llm": False}) + + assert result["analysis_completeness"]["is_complete"] is True + assert not any(finding.rule_id == "P2" for finding in result["filtered_findings"]) + assert result["risk_recommendation"] == "SAFE" + + @pytest.mark.parametrize( ("ascii_content", "confusable_content", "rule_id"), [