diff --git a/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py b/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py index 4bf1ea076..9108ae9a7 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py +++ b/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py @@ -86,8 +86,30 @@ def _is_p9_skipped_path(file_path: str) -> bool: # paired controls can, which is the same line GCC's -Wbidi-chars draws by # default. Do not add them here without revisiting that tradeoff. _BIDI_CONTROL_PATTERN = r"[\u202a-\u202e\u2066-\u2069]" +# 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), + ( + # "" and "" are abrupt empty comments: the HTML tokenizer + # closes them immediately, so the text after them is visible. Never + # start a hidden-comment match there. + 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) + + @pytest.mark.parametrize( + "content", + [ + " visible system docs ", + " visible system docs ", + ], + ) + def test_p2_html_comment_match_does_not_start_at_abrupt_empty_comment(self, content: str): + """```` and ```` close immediately, so the text after them is visible.""" + 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_after_abrupt_empty_comment_is_still_detected(self): + """Skipping an abrupt empty comment does not hide a real comment after it.""" + state = { + "components": ["SKILL.md"], + "file_cache": { + "SKILL.md": " visible text " + }, + } + + findings = static_runner.run_static_patterns(state, [prompt_injection_module]) + + assert 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) + + @pytest.mark.parametrize( + "content", + [ + "", + "", + "", + ], + ) + def test_p2_html_comment_detects_run_together_instructions(self, content: str): + """Low-collision keywords match inside run-together text without boundaries.""" + 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_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 db6546ab3..cd09f0008 100644 --- a/tests/nodes/test_security_remediation.py +++ b/tests/nodes/test_security_remediation.py @@ -2077,6 +2077,97 @@ 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_all_caps_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" + + +@pytest.mark.parametrize("opener", ["", ""]) +def test_abrupt_empty_comment_does_not_create_unsafe_graph_verdict( + tmp_path: Path, opener: str +) -> None: + (tmp_path / "SKILL.md").write_text( + f"""--- +name: visible-docs +description: Documents visible system behavior. +--- + +# Visible docs + +{opener} +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" + + +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( "variant", [