-
Notifications
You must be signed in to change notification settings - Fork 1.3k
fix(security): detect letter-spaced P3 and P4 prompts #470
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,6 +17,7 @@ | |
| _concealed_instruction_run_spans, | ||
| _contextual_default_ignorable_boundary_spans, | ||
| _obfuscated_instruction_matches, | ||
| prompt_injection_letter_spacing_view, | ||
| ) | ||
| from skillspector.inspection_ledger import ( | ||
| InspectionLedgerEvent, | ||
|
|
@@ -36,6 +37,7 @@ | |
| from skillspector.unicode_confusables import ASCII_CONFUSABLE_SKELETON | ||
|
|
||
| from .common import LINE_BREAK_CHARS, LOGICAL_LINE_BREAK, get_line_number | ||
| from .static_patterns_prompt_injection import P3_PATTERNS, P4_PATTERNS | ||
| from .static_runner import MAX_FINDINGS_PER_ANALYZER, MAX_FINDINGS_PER_ARTIFACT | ||
|
|
||
| ANALYZER_ID = "artifact_integrity" | ||
|
|
@@ -61,6 +63,12 @@ | |
| "unfiltered", | ||
| "unrestricted", | ||
| "userdata", | ||
| "withoutinforming", | ||
| "withoutnotifying", | ||
| "withouttelling", | ||
| "withouttheuserknowing", | ||
| "withouttheusernoticing", | ||
| "withouttheuserrealizing", | ||
| ) | ||
| _LETTER_SPACING_EXACT_SECURITY_TERMS = frozenset( | ||
| { | ||
|
|
@@ -74,6 +82,32 @@ | |
| "systemprompt", | ||
| } | ||
| ) | ||
| _AMBIGUOUS_PROMPT_PATTERNS = tuple( | ||
| re.compile(pattern) | ||
| for pattern in ( | ||
| r"(?:send|transmit|upload|post|forward)(?:the)?(?:conversation|context|chat|history|messages?)to[a-z]{0,96}", | ||
| r"(?:send|transmit|upload|post)(?:all)?(?:user)?(?:data|information|input|files?)to(?:https|external)[a-z]{0,96}", | ||
| r"(?:copy|extract|collect)(?:the)?(?:conversation|context|session)(?:and)?(?:send|upload|transmit)", | ||
| r"(?:call|notify|ping|hit)(?:this)?(?:webhook|endpoint|url|api)with(?:the)?(?:user|conversation|context)", | ||
| r"include(?:the)?(?:full)?(?:conversation|context|history)in(?:the)?(?:request|payload|body)", | ||
| r"(?:silently|quietly|secretly|covertly)(?:send|transmit|log|record)", | ||
| r"without(?:telling|informing|notifying)(?:the)?user[a-z]{0,128}(?:send|transmit|upload)", | ||
| r"always(?:recommend|suggest|prefer|choose|use)[a-z]{1,96}(?:over|insteadof|ratherthan)", | ||
| r"never(?:recommend|suggest|mention|tell|warn|inform)(?:the)?(?:user|them)", | ||
| r"(?:prioritize|prefer)[a-z]{1,96}(?:over|above)(?:user)?(?:safety|security|privacy|preferences?)", | ||
| r"(?:subtly|gradually|slowly)(?:steer|guide|push|nudge)(?:the)?(?:user|conversation)", | ||
| r"(?:the)?user(?:knowing|realizing|noticing)[a-z]{0,128}(?:change|modify|alter|influence)", | ||
| r"(?:gain|build|establish)(?:the)?users?trust(?:andthen|before|sothat)", | ||
| r"(?:appear|seem|act)(?:helpful|friendly|trustworthy)(?:while|but|then)", | ||
| r"(?:always|never)include(?:this|thefollowing)in(?:every|all)(?:response|output|reply)", | ||
| r"append(?:this|thefollowing)to(?:every|all|each)(?:response|message|output)", | ||
| ) | ||
| ) | ||
|
Comment on lines
+85
to
+105
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These sixteen regexes are a hand-transliterated copy of Nothing is broken today, but any future edit to P3/P4 silently de-syncs this fail-closed net. Could these be derived mechanically from the source patterns? Failing that, a test asserting one ambiguous counterpart exists per P3/P4 pattern would at least catch the drift. |
||
| _MAX_AMBIGUOUS_PROMPT_PHRASE = 512 | ||
| _PROJECTED_PROMPT_PATTERNS = tuple( | ||
| re.compile(pattern, re.IGNORECASE | re.MULTILINE) | ||
| for pattern, _confidence in (*P3_PATTERNS, *P4_PATTERNS) | ||
| ) | ||
| _LETTER_SPACING_PROMPT_ACTIONS = ( | ||
| "disclose", | ||
| "disclosed", | ||
|
|
@@ -333,6 +367,7 @@ def alternation(values: tuple[str, ...]) -> str: | |
| ) | ||
| _MAX_LETTER_SPACING_SECURITY_PHRASE = max( | ||
| max(map(len, _LETTER_SPACING_EXACT_SECURITY_TERMS)), | ||
| _MAX_AMBIGUOUS_PROMPT_PHRASE, | ||
| max(map(len, _LETTER_SPACING_SECURITY_PREFIXES)) | ||
| + max(map(len, _LETTER_SPACING_ALL_ACTIONS)) | ||
| + _MAX_LETTER_SPACING_SECURITY_CONNECTORS * max(map(len, _LETTER_SPACING_SECURITY_CONNECTORS)) | ||
|
|
@@ -411,6 +446,11 @@ def _spacing_phrase_has_security_signal(phrase: str) -> bool: | |
| ) | ||
|
|
||
|
|
||
| def _ambiguous_prompt_phrase_has_security_signal(phrase: str) -> bool: | ||
| """Match bounded P3/P4 grammar only when source word boundaries are absent.""" | ||
| return any(pattern.search(phrase) is not None for pattern in _AMBIGUOUS_PROMPT_PATTERNS) | ||
|
|
||
|
|
||
| def _bounded_same_line_context( | ||
| content: str, | ||
| start: int, | ||
|
|
@@ -487,6 +527,7 @@ def _spacing_span_has_security_signal( | |
| """Match bounded security semantics without retaining the full run.""" | ||
| if _spacing_span_is_benign_notation(content, span): | ||
| return False | ||
| has_explicit_boundary = content.find(" ", span[0], span[1]) != -1 | ||
| overlap = "" | ||
| letters: list[str] = [] | ||
| letter_characters = 0 | ||
|
|
@@ -527,14 +568,26 @@ def _spacing_span_has_security_signal( | |
| if any(term in block for term in _LETTER_SPACING_SECURITY_TERMS): | ||
| return True | ||
| if phrase_overflow: | ||
| return False | ||
| if _spacing_phrase_has_security_signal("".join(phrase_parts)): | ||
| # A boundary-free letter stream this large cannot be reconstructed | ||
| # safely. Treat it as ambiguous instead of silently blessing it. | ||
| return not has_explicit_boundary | ||
| phrase = "".join(phrase_parts) | ||
| if _spacing_phrase_has_security_signal(phrase) or ( | ||
| not has_explicit_boundary and _ambiguous_prompt_phrase_has_security_signal(phrase) | ||
| ): | ||
| return True | ||
| shortened_phrase = "".join(phrase_parts[:-1]) | ||
| return ( | ||
| bool(phrase_parts) | ||
| and span[1] < len(content) | ||
| and content[span[1]].isalpha() | ||
| and _spacing_phrase_has_security_signal("".join(phrase_parts[:-1])) | ||
| and ( | ||
| _spacing_phrase_has_security_signal(shortened_phrase) | ||
| or ( | ||
| not has_explicit_boundary | ||
| and _ambiguous_prompt_phrase_has_security_signal(shortened_phrase) | ||
| ) | ||
| ) | ||
| ) | ||
|
|
||
|
|
||
|
|
@@ -591,6 +644,37 @@ def _contextual_ignorable_security_line( | |
| return None | ||
|
|
||
|
|
||
| def _projected_prompt_injection_line( | ||
| content: str, | ||
| budget: _ArtifactIntegrityBudget, | ||
| ) -> int | None: | ||
| """Return the first raw line whose letter-spacing projection matches P3/P4.""" | ||
| view = prompt_injection_letter_spacing_view( | ||
| content, | ||
| budget.check_runtime, | ||
| preserve_identifier_boundaries=False, | ||
| ) | ||
| if view.source_offsets is None: | ||
| return None | ||
| first_offset: int | None = None | ||
| identifier_relaxed_text = re.sub(r"[0-9_]", " ", view.text) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This line causes a reproducible false positive on ordinary code: a bundle containing an innocent spaced acronym ( The issue is that any collapsible spaced pair anywhere in the file arms this whole-document rescan where every underscore and digit becomes a space — so identifiers that happen to spell a P3/P4 phrase get reported as obfuscation. Suggestion: require the relaxed match to overlap at least one actually reconstructed span. Your |
||
| projected_texts = ( | ||
| (view.text, identifier_relaxed_text) | ||
| if identifier_relaxed_text != view.text | ||
| else (view.text,) | ||
| ) | ||
| for projected_text in projected_texts: | ||
| for pattern in _PROJECTED_PROMPT_PATTERNS: | ||
| budget.check_runtime() | ||
| match = pattern.search(projected_text) | ||
| if match is None: | ||
| continue | ||
| source_offset = view.source_offset(match.start()) | ||
| if first_offset is None or source_offset < first_offset: | ||
| first_offset = source_offset | ||
| return get_line_number(content, first_offset) if first_offset is not None else None | ||
|
|
||
|
|
||
| def _text_signals( | ||
| content: str, | ||
| budget: _ArtifactIntegrityBudget, | ||
|
|
@@ -629,12 +713,14 @@ def _text_signals( | |
| if targeted_instruction is not None | ||
| else None | ||
| ) | ||
| first_projected_prompt_line = _projected_prompt_injection_line(content, budget) | ||
| obfuscation_lines = [ | ||
| value | ||
| for value in ( | ||
| first_spacing_line, | ||
| first_contextual_ignorable_line, | ||
| first_targeted_instruction_line, | ||
| first_projected_prompt_line, | ||
| ) | ||
| if value is not None | ||
| ] | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,7 +22,7 @@ | |
| import sys | ||
| from collections.abc import Iterator | ||
|
|
||
| from skillspector.artifacts import _is_emoji_base | ||
| from skillspector.artifacts import _is_emoji_base, prompt_injection_letter_spacing_view | ||
| from skillspector.logging_config import get_logger | ||
| from skillspector.models import AnalyzerFinding, Location, Severity | ||
| from skillspector.state import AnalyzerNodeResponse, SkillspectorState | ||
|
|
@@ -290,36 +290,66 @@ def ctx(start: int) -> str: | |
| matched_text=match.group(0)[:200], | ||
| ) | ||
| ) | ||
| for pattern, confidence in P3_PATTERNS: | ||
| for match in re.finditer(pattern, content, re.IGNORECASE | re.MULTILINE): | ||
| line_num = get_line_number(content, match.start()) | ||
| findings.append( | ||
| AnalyzerFinding( | ||
| rule_id="P3", | ||
| message="Exfiltration Commands", | ||
| severity=Severity.HIGH, | ||
| location=loc(line_num), | ||
| confidence=confidence, | ||
| tags=tag, | ||
| context=ctx(match.start()), | ||
| matched_text=match.group(0)[:200], | ||
| ) | ||
| ) | ||
| for pattern, confidence in P4_PATTERNS: | ||
| for match in re.finditer(pattern, content, re.IGNORECASE | re.MULTILINE): | ||
| line_num = get_line_number(content, match.start()) | ||
| findings.append( | ||
| AnalyzerFinding( | ||
| rule_id="P4", | ||
| message="Behavior Manipulation", | ||
| severity=Severity.MEDIUM, | ||
| location=loc(line_num), | ||
| confidence=confidence, | ||
| tags=tag, | ||
| context=ctx(match.start()), | ||
| matched_text=match.group(0)[:200], | ||
| prompt_rules = ( | ||
| ("P3", "Exfiltration Commands", Severity.HIGH, P3_PATTERNS), | ||
| ("P4", "Behavior Manipulation", Severity.MEDIUM, P4_PATTERNS), | ||
| ) | ||
| seen_prompt_matches: set[tuple[str, int, int]] = set() | ||
| for rule_id, message, severity, patterns in prompt_rules: | ||
| for pattern, confidence in patterns: | ||
| for match in re.finditer(pattern, content, re.IGNORECASE | re.MULTILINE): | ||
| source_start = match.start() | ||
| source_end = match.end() | ||
| seen_prompt_matches.add((rule_id, source_start, source_end)) | ||
| findings.append( | ||
| AnalyzerFinding( | ||
| rule_id=rule_id, | ||
| message=message, | ||
| severity=severity, | ||
| location=loc(get_line_number(content, source_start)), | ||
| confidence=confidence, | ||
| tags=tag, | ||
| context=ctx(source_start), | ||
| matched_text=match.group(0)[:200], | ||
| ) | ||
| ) | ||
| ) | ||
|
|
||
| # This projection is intentionally local to P3/P4. Other static rules keep | ||
| # their established text-view contract and cannot inherit classifications | ||
| # from letter-spacing reconstruction. | ||
| prompt_view = prompt_injection_letter_spacing_view(content) | ||
| if prompt_view.source_offsets is not None: | ||
| for rule_id, message, severity, patterns in prompt_rules: | ||
| for pattern, confidence in patterns: | ||
| for match in re.finditer( | ||
| pattern, | ||
| prompt_view.text, | ||
| re.IGNORECASE | re.MULTILINE, | ||
| ): | ||
|
Comment on lines
+320
to
+328
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cost note: one sweep of the 16 P3/P4 patterns over a 100 KB adversarial single-line window takes ~4.1s on my machine (pre-existing — the lazy It fails closed to CAUTION, so this is degradation rather than a bypass, but a budget check inside the loop would be cheap insurance. |
||
| source_start = prompt_view.source_offset(match.start()) | ||
| source_end = prompt_view.source_offset(max(match.start(), match.end() - 1)) + 1 | ||
| key = (rule_id, source_start, source_end) | ||
| if key in seen_prompt_matches: | ||
| continue | ||
| seen_prompt_matches.add(key) | ||
| evidence: dict[str, object] = ( | ||
| {static_runner._VIEW_START_EVIDENCE: source_start} | ||
| if source_end - source_start <= static_runner._WINDOW_OVERLAP_CHARS | ||
| else {} | ||
| ) | ||
| findings.append( | ||
| AnalyzerFinding( | ||
| rule_id=rule_id, | ||
| message=message, | ||
| severity=severity, | ||
| location=loc(get_line_number(content, source_start)), | ||
| confidence=confidence, | ||
| tags=tag, | ||
| context=ctx(source_start), | ||
| matched_text=match.group(0)[:200], | ||
| evidence=evidence, | ||
| ) | ||
| ) | ||
|
|
||
| # P2 (extended): Unicode Tag-block "ASCII smuggling". Runs regardless of | ||
| # file_type — invisible instructions are dangerous in scripts and config | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the one that worries me:
s e n d conversation to external— ONE space before "conversation", i.e. the most natural way to write the attack — scans completely SAFE end to end, while the two-space version in your tests fires P3. Same story forn e v e r warn the user.What happens: the extension loop at 1760 swallows the first letter of the adjacent word (the run becomes
s e n d c), soright_letter/right_identifieris true and the whole run gets rejected — andindex = run_endmeans we never retry from inside the run. The run is also under 6 letters, so the pre-existing compact view can't rescue it either. Tab-mixed (s\te\tn\td\tconversation ...) and dot-spaced (s.e.n.d conversation ...) variants also come out SAFE.Suggested fix that stays within this design: like
_letter_spacing_offsets(~line 447), stop extending the run when the next letter starts a multi-letter word (two-letter lookahead), and after a left-side rejection retry once from the next spaced letter. Still linear — each position is visited at most twice — and I traced the rover/cover and identifier-adjacency tests: they keep passing, whiles e n d conversation to externalprojects tosend conversation to externaland fires P3.