Add lexical signal-based detection for prompt-injection handling

This commit is contained in:
hart_s3 2026-08-10 14:00:35 +02:00
parent 6bd21bc55b
commit da92b4af68
4 changed files with 572 additions and 44 deletions

View File

@ -0,0 +1,322 @@
namespace AIStudio.Tools.Security;
[Flags]
internal enum PromptInjectionLexicalSignal : uint
{
NONE = 0,
OVERRIDE_ACTION = 1 << 0,
INSTRUCTION_TARGET = 1 << 1,
SAFEGUARD_TARGET = 1 << 2,
INVALIDATION = 1 << 3,
NEGATION = 1 << 4,
COMPLIANCE_ACTION = 1 << 5,
AUTHORITY_TARGET = 1 << 6,
EXFILTRATION_ACTION = 1 << 7,
PROMPT_TARGET = 1 << 8,
PROTECTED_CONTEXT = 1 << 9,
POSITIONAL_CONTEXT = 1 << 10,
ROLE_SUBJECT = 1 << 11,
ROLE_TRANSITION = 1 << 12,
ESCAPE_STATE = 1 << 13,
}
internal readonly record struct PromptInjectionLexicalRule(
string Id,
string Category,
PromptInjectionLexicalSignal First,
PromptInjectionLexicalSignal Second,
PromptInjectionLexicalSignal Third = PromptInjectionLexicalSignal.NONE);
internal static class PromptInjectionLexicon
{
internal const int SIGNAL_COUNT = 14;
internal static readonly IReadOnlyList<PromptInjectionLexicalRule> RULES =
[
new("lexical_instruction_override", "override", PromptInjectionLexicalSignal.OVERRIDE_ACTION, PromptInjectionLexicalSignal.INSTRUCTION_TARGET),
new("lexical_instruction_invalidation", "override", PromptInjectionLexicalSignal.INSTRUCTION_TARGET, PromptInjectionLexicalSignal.INVALIDATION),
new("lexical_authority_rejection", "override", PromptInjectionLexicalSignal.NEGATION, PromptInjectionLexicalSignal.COMPLIANCE_ACTION, PromptInjectionLexicalSignal.AUTHORITY_TARGET | PromptInjectionLexicalSignal.INSTRUCTION_TARGET | PromptInjectionLexicalSignal.SAFEGUARD_TARGET),
new("lexical_prompt_exfiltration", "exfiltration", PromptInjectionLexicalSignal.EXFILTRATION_ACTION, PromptInjectionLexicalSignal.PROMPT_TARGET, PromptInjectionLexicalSignal.PROTECTED_CONTEXT | PromptInjectionLexicalSignal.POSITIONAL_CONTEXT),
new("lexical_role_override", "role_override", PromptInjectionLexicalSignal.ROLE_SUBJECT, PromptInjectionLexicalSignal.ROLE_TRANSITION, PromptInjectionLexicalSignal.ESCAPE_STATE),
new("lexical_safety_bypass", "jailbreak", PromptInjectionLexicalSignal.OVERRIDE_ACTION, PromptInjectionLexicalSignal.SAFEGUARD_TARGET),
];
internal static PromptInjectionLexicalSignal Classify(ReadOnlySpan<char> token)
{
if (token.IsEmpty)
return PromptInjectionLexicalSignal.NONE;
return char.ToLowerInvariant(token[0]) switch
{
'a' => ClassifyA(token),
'b' => ClassifyB(token),
'c' => ClassifyC(token),
'd' => ClassifyD(token),
'e' => ClassifyE(token),
'f' => ClassifyF(token),
'g' => ClassifyG(token),
'h' => ClassifyH(token),
'i' => ClassifyI(token),
'l' => ClassifyL(token),
'm' => ClassifyM(token),
'n' => ClassifyN(token),
'o' => ClassifyO(token),
'p' => ClassifyP(token),
'q' => Equals(token, "quote") ? PromptInjectionLexicalSignal.EXFILTRATION_ACTION : PromptInjectionLexicalSignal.NONE,
'r' => ClassifyR(token),
's' => ClassifyS(token),
't' => ClassifyT(token),
'u' => ClassifyU(token),
'v' => ClassifyV(token),
'w' => Equals(token, "what") ? PromptInjectionLexicalSignal.EXFILTRATION_ACTION : PromptInjectionLexicalSignal.NONE,
'y' => ClassifyY(token),
_ => PromptInjectionLexicalSignal.NONE,
};
}
private static PromptInjectionLexicalSignal ClassifyA(ReadOnlySpan<char> token)
{
if (Equals(token, "above"))
return PromptInjectionLexicalSignal.POSITIONAL_CONTEXT;
if (Equals(token, "act"))
return PromptInjectionLexicalSignal.ROLE_TRANSITION;
if (Equals(token, "actual"))
return PromptInjectionLexicalSignal.PROTECTED_CONTEXT;
if (Equals(token, "ai"))
return PromptInjectionLexicalSignal.ROLE_SUBJECT;
if (Equals(token, "apply"))
return PromptInjectionLexicalSignal.COMPLIANCE_ACTION;
if (Equals(token, "assistant"))
return PromptInjectionLexicalSignal.ROLE_SUBJECT | PromptInjectionLexicalSignal.PROTECTED_CONTEXT;
return PromptInjectionLexicalSignal.NONE;
}
private static PromptInjectionLexicalSignal ClassifyB(ReadOnlySpan<char> token)
{
if (Equals(token, "before"))
return PromptInjectionLexicalSignal.POSITIONAL_CONTEXT;
if (Equals(token, "bound") || Equals(token, "boundaries") || Equals(token, "boundary"))
return PromptInjectionLexicalSignal.ESCAPE_STATE;
if (Equals(token, "bypass"))
return PromptInjectionLexicalSignal.OVERRIDE_ACTION | PromptInjectionLexicalSignal.ESCAPE_STATE;
return PromptInjectionLexicalSignal.NONE;
}
private static PromptInjectionLexicalSignal ClassifyC(ReadOnlySpan<char> token)
{
if (Equals(token, "cancelled") || Equals(token, "canceled"))
return PromptInjectionLexicalSignal.INVALIDATION;
if (Equals(token, "complete"))
return PromptInjectionLexicalSignal.PROTECTED_CONTEXT;
if (Equals(token, "constraint") || Equals(token, "constraints"))
return PromptInjectionLexicalSignal.INSTRUCTION_TARGET;
if (Equals(token, "content"))
return PromptInjectionLexicalSignal.SAFEGUARD_TARGET;
return PromptInjectionLexicalSignal.NONE;
}
private static PromptInjectionLexicalSignal ClassifyD(ReadOnlySpan<char> token)
{
if (Equals(token, "developer"))
return PromptInjectionLexicalSignal.AUTHORITY_TARGET | PromptInjectionLexicalSignal.PROTECTED_CONTEXT;
if (Equals(token, "different"))
return PromptInjectionLexicalSignal.ESCAPE_STATE;
if (Equals(token, "directive") || Equals(token, "directives"))
return PromptInjectionLexicalSignal.INSTRUCTION_TARGET;
if (Equals(token, "disable") || Equals(token, "disregard") || Equals(token, "drop"))
return PromptInjectionLexicalSignal.OVERRIDE_ACTION;
if (Equals(token, "display") || Equals(token, "dump"))
return PromptInjectionLexicalSignal.EXFILTRATION_ACTION;
return PromptInjectionLexicalSignal.NONE;
}
private static PromptInjectionLexicalSignal ClassifyE(ReadOnlySpan<char> token)
{
if (Equals(token, "enforce"))
return PromptInjectionLexicalSignal.COMPLIANCE_ACTION;
if (Equals(token, "entire") || Equals(token, "exact"))
return PromptInjectionLexicalSignal.PROTECTED_CONTEXT;
if (Equals(token, "ethical"))
return PromptInjectionLexicalSignal.SAFEGUARD_TARGET;
if (Equals(token, "expose"))
return PromptInjectionLexicalSignal.EXFILTRATION_ACTION;
return PromptInjectionLexicalSignal.NONE;
}
private static PromptInjectionLexicalSignal ClassifyF(ReadOnlySpan<char> token)
{
if (Equals(token, "filter") || Equals(token, "filters"))
return PromptInjectionLexicalSignal.SAFEGUARD_TARGET;
if (Equals(token, "follow"))
return PromptInjectionLexicalSignal.COMPLIANCE_ACTION;
if (Equals(token, "forget"))
return PromptInjectionLexicalSignal.OVERRIDE_ACTION;
if (Equals(token, "full"))
return PromptInjectionLexicalSignal.PROTECTED_CONTEXT;
return PromptInjectionLexicalSignal.NONE;
}
private static PromptInjectionLexicalSignal ClassifyG(ReadOnlySpan<char> token)
{
if (Equals(token, "guideline") || Equals(token, "guidelines"))
return PromptInjectionLexicalSignal.INSTRUCTION_TARGET;
if (Equals(token, "guardrail") || Equals(token, "guardrails"))
return PromptInjectionLexicalSignal.SAFEGUARD_TARGET;
return PromptInjectionLexicalSignal.NONE;
}
private static PromptInjectionLexicalSignal ClassifyH(ReadOnlySpan<char> token)
{
if (Equals(token, "hidden") || Equals(token, "his") || Equals(token, "her"))
return PromptInjectionLexicalSignal.PROTECTED_CONTEXT;
return PromptInjectionLexicalSignal.NONE;
}
private static PromptInjectionLexicalSignal ClassifyI(ReadOnlySpan<char> token)
{
if (Equals(token, "ignore") || Equals(token, "ignoring"))
return PromptInjectionLexicalSignal.OVERRIDE_ACTION;
if (Equals(token, "initial") || Equals(token, "internal") || Equals(token, "its"))
return PromptInjectionLexicalSignal.PROTECTED_CONTEXT;
if (Equals(token, "instruction") || Equals(token, "instructions"))
return PromptInjectionLexicalSignal.INSTRUCTION_TARGET | PromptInjectionLexicalSignal.PROMPT_TARGET;
if (Equals(token, "invalid"))
return PromptInjectionLexicalSignal.INVALIDATION;
return PromptInjectionLexicalSignal.NONE;
}
private static PromptInjectionLexicalSignal ClassifyL(ReadOnlySpan<char> token)
{
if (Equals(token, "leak"))
return PromptInjectionLexicalSignal.EXFILTRATION_ACTION;
if (Equals(token, "limited") || Equals(token, "limit") || Equals(token, "limits"))
return PromptInjectionLexicalSignal.ESCAPE_STATE;
if (Equals(token, "longer"))
return PromptInjectionLexicalSignal.ROLE_TRANSITION;
return PromptInjectionLexicalSignal.NONE;
}
private static PromptInjectionLexicalSignal ClassifyM(ReadOnlySpan<char> token)
{
if (Equals(token, "measure") || Equals(token, "measures"))
return PromptInjectionLexicalSignal.SAFEGUARD_TARGET;
if (Equals(token, "model"))
return PromptInjectionLexicalSignal.ROLE_SUBJECT;
if (Equals(token, "my"))
return PromptInjectionLexicalSignal.PROTECTED_CONTEXT;
return PromptInjectionLexicalSignal.NONE;
}
private static PromptInjectionLexicalSignal ClassifyN(ReadOnlySpan<char> token)
{
if (Equals(token, "not") || Equals(token, "never") || Equals(token, "dont") || Equals(token, "don't"))
return PromptInjectionLexicalSignal.NEGATION;
if (Equals(token, "now"))
return PromptInjectionLexicalSignal.ROLE_TRANSITION;
if (Equals(token, "null"))
return PromptInjectionLexicalSignal.INVALIDATION;
return PromptInjectionLexicalSignal.NONE;
}
private static PromptInjectionLexicalSignal ClassifyO(ReadOnlySpan<char> token)
{
if (Equals(token, "obey"))
return PromptInjectionLexicalSignal.COMPLIANCE_ACTION | PromptInjectionLexicalSignal.ESCAPE_STATE;
if (Equals(token, "original"))
return PromptInjectionLexicalSignal.AUTHORITY_TARGET | PromptInjectionLexicalSignal.PROTECTED_CONTEXT;
if (Equals(token, "our"))
return PromptInjectionLexicalSignal.PROTECTED_CONTEXT;
if (Equals(token, "output"))
return PromptInjectionLexicalSignal.EXFILTRATION_ACTION;
if (Equals(token, "override"))
return PromptInjectionLexicalSignal.OVERRIDE_ACTION;
return PromptInjectionLexicalSignal.NONE;
}
private static PromptInjectionLexicalSignal ClassifyP(ReadOnlySpan<char> token)
{
if (Equals(token, "policy") || Equals(token, "policies") || Equals(token, "protocol") || Equals(token, "protocols"))
return PromptInjectionLexicalSignal.SAFEGUARD_TARGET;
if (Equals(token, "pretend"))
return PromptInjectionLexicalSignal.ROLE_TRANSITION;
if (Equals(token, "print"))
return PromptInjectionLexicalSignal.EXFILTRATION_ACTION;
if (Equals(token, "prompt") || Equals(token, "prompts"))
return PromptInjectionLexicalSignal.INSTRUCTION_TARGET | PromptInjectionLexicalSignal.PROMPT_TARGET;
return PromptInjectionLexicalSignal.NONE;
}
private static PromptInjectionLexicalSignal ClassifyR(ReadOnlySpan<char> token)
{
if (Equals(token, "real"))
return PromptInjectionLexicalSignal.PROTECTED_CONTEXT;
if (Equals(token, "repeat") || Equals(token, "return") || Equals(token, "reveal"))
return PromptInjectionLexicalSignal.EXFILTRATION_ACTION;
if (Equals(token, "replace"))
return PromptInjectionLexicalSignal.OVERRIDE_ACTION;
if (Equals(token, "respect"))
return PromptInjectionLexicalSignal.COMPLIANCE_ACTION;
if (Equals(token, "restricted") || Equals(token, "restriction") || Equals(token, "restrictions"))
return PromptInjectionLexicalSignal.SAFEGUARD_TARGET | PromptInjectionLexicalSignal.ESCAPE_STATE;
if (Equals(token, "revoked"))
return PromptInjectionLexicalSignal.INVALIDATION;
if (Equals(token, "rule") || Equals(token, "rules"))
return PromptInjectionLexicalSignal.INSTRUCTION_TARGET | PromptInjectionLexicalSignal.ESCAPE_STATE;
return PromptInjectionLexicalSignal.NONE;
}
private static PromptInjectionLexicalSignal ClassifyS(ReadOnlySpan<char> token)
{
if (Equals(token, "safety") || Equals(token, "security"))
return PromptInjectionLexicalSignal.SAFEGUARD_TARGET;
if (Equals(token, "secret"))
return PromptInjectionLexicalSignal.PROTECTED_CONTEXT;
if (Equals(token, "show"))
return PromptInjectionLexicalSignal.EXFILTRATION_ACTION;
if (Equals(token, "supersede"))
return PromptInjectionLexicalSignal.OVERRIDE_ACTION;
if (Equals(token, "system") || Equals(token, "systme") || Equals(token, "sysetm") || Equals(token, "systemp"))
return PromptInjectionLexicalSignal.AUTHORITY_TARGET | PromptInjectionLexicalSignal.PROTECTED_CONTEXT;
return PromptInjectionLexicalSignal.NONE;
}
private static PromptInjectionLexicalSignal ClassifyT(ReadOnlySpan<char> token)
{
if (Equals(token, "text"))
return PromptInjectionLexicalSignal.PROMPT_TARGET;
if (Equals(token, "their"))
return PromptInjectionLexicalSignal.PROTECTED_CONTEXT;
if (Equals(token, "top"))
return PromptInjectionLexicalSignal.POSITIONAL_CONTEXT;
return PromptInjectionLexicalSignal.NONE;
}
private static PromptInjectionLexicalSignal ClassifyU(ReadOnlySpan<char> token)
{
if (Equals(token, "unlimited") || Equals(token, "unrestricted"))
return PromptInjectionLexicalSignal.ESCAPE_STATE;
return PromptInjectionLexicalSignal.NONE;
}
private static PromptInjectionLexicalSignal ClassifyV(ReadOnlySpan<char> token)
{
if (Equals(token, "verbatim"))
return PromptInjectionLexicalSignal.PROTECTED_CONTEXT;
if (Equals(token, "void"))
return PromptInjectionLexicalSignal.INVALIDATION;
return PromptInjectionLexicalSignal.NONE;
}
private static PromptInjectionLexicalSignal ClassifyY(ReadOnlySpan<char> token)
{
if (Equals(token, "you") || Equals(token, "your"))
return PromptInjectionLexicalSignal.ROLE_SUBJECT | PromptInjectionLexicalSignal.PROTECTED_CONTEXT;
if (Equals(token, "you're"))
return PromptInjectionLexicalSignal.ROLE_SUBJECT;
if (Equals(token, "yoru") || Equals(token, "yuor"))
return PromptInjectionLexicalSignal.PROTECTED_CONTEXT;
return PromptInjectionLexicalSignal.NONE;
}
private static bool Equals(ReadOnlySpan<char> token, string value) => token.Equals(value, StringComparison.OrdinalIgnoreCase);
}

View File

@ -50,7 +50,7 @@ internal static partial class PromptInjectionPatterns
private const string UNICODE_SMUGGLING_PATTERN = """[\u200B-\u200F\u2060-\u2064\u2066-\u2069\uFEFF]""";
private const string IGNORE_SAFETY_AFTER_DATA_PATTERN = """(?:after\s+reading|once\s+you\s+read|when\s+you\s+see)\s+.*?(?:ignore|bypass|override)\s+.*?(?:instructions?|safety|rules?)""";
private const string PERSISTENT_OR_DELAYED_TRIGGER_PATTERN = """(?:(?:remember|store|save|persist|memorize)\s+(?:this|these|the\s+following)\s+(?:instructions?|rules?|message)|(?:later|in\s+the\s+next\s+message|when\s+you\s+see|whenever\s+you\s+read|if\s+you\s+encounter)\s+.{0,120}(?:ignore|bypass|override|reveal|exfiltrate))""";
private const string JAILBREAK_MARKER_PATTERN = """\b(?:jailbreak|prompt\s+injection|ignore\s+your\s+guardrails?|bypass\s+(?:your\s+)?(?:guardrails?|safety)|unfiltered\s+mode|do\s+anything\s+now|developer\s+mode|admin\s+mode|root\s+mode)\b""";
private const string JAILBREAK_MARKER_PATTERN = """\b(?:jailbreak(?:ed)?|prompt\s+injection|ignore\s+your\s+guardrails?|bypass\s+(?:your\s+)?(?:guardrails?|safety)|unfiltered\s+mode|do\s+anything\s+now|developer\s+mode|admin\s+mode|root\s+mode)\b""";
private const string ANY_RULE_PATTERN =
"(?:" + INSTRUCTION_OVERRIDE_PATTERN + ")|(?:" +
@ -70,9 +70,6 @@ internal static partial class PromptInjectionPatterns
[GeneratedRegex(@"\b[a-zA-Z](?:[\s._:/\\|-]+[a-zA-Z]){2,}\b", RegexOptions.CultureInvariant)]
internal static partial Regex SpacedLetterSequenceRegex();
[GeneratedRegex(@"\b[a-zA-Z]{5,12}\b", RegexOptions.CultureInvariant)]
internal static partial Regex WordRegex();
[GeneratedRegex(@"(?<![A-Za-z0-9+/=])[A-Za-z0-9+/]{16,}={0,2}(?![A-Za-z0-9+/=])", RegexOptions.CultureInvariant)]
internal static partial Regex Base64Regex();

View File

@ -1,4 +1,5 @@
using System.Buffers;
using System.Numerics;
using System.Text;
using System.Text.RegularExpressions;
@ -10,6 +11,7 @@ public sealed class PromptInjectionScanner(ILogger<PromptInjectionScanner> logge
private const int MAX_DECODED_TEXT_LENGTH = 12_000;
private const int MAX_FINDINGS = 8;
private const int MAX_SNIPPET_LENGTH = 240;
private const int LEXICAL_WINDOW_SIZE = 12;
private static readonly IReadOnlyDictionary<(int Length, char First, char Last), string[]> TYPOGLYCEMIA_KEYWORDS =
CreateTypoglycemiaKeywordIndex();
@ -22,7 +24,7 @@ public sealed class PromptInjectionScanner(ILogger<PromptInjectionScanner> logge
var findings = new List<PromptInjectionFinding>();
var findingKeys = new HashSet<string>(StringComparer.Ordinal);
this.ScanVariant(text, "raw", findings, findingKeys);
this.ScanVariant(text, "raw", findings, findingKeys, scanTypoglycemia: true);
if (findings.Count >= MAX_FINDINGS)
return new(source, findings);
@ -33,46 +35,54 @@ public sealed class PromptInjectionScanner(ILogger<PromptInjectionScanner> logge
if (findings.Count < MAX_FINDINGS)
this.ScanDecodedCandidates(text, findings, findingKeys);
if (findings.Count < MAX_FINDINGS)
this.ScanTypoglycemia(text, findings, findingKeys);
return new(source, findings);
}
private void ScanVariant(string text, string stage, List<PromptInjectionFinding> findings, HashSet<string> findingKeys)
private void ScanVariant(
string text,
string stage,
List<PromptInjectionFinding> findings,
HashSet<string> findingKeys,
bool scanTypoglycemia = false)
{
var shouldScanRegexRules = true;
try
{
if (!PromptInjectionPatterns.AnyRuleRegex().IsMatch(text))
return;
shouldScanRegexRules = PromptInjectionPatterns.AnyRuleRegex().IsMatch(text);
}
catch (RegexMatchTimeoutException exception)
{
logger.LogWarning(exception, "Prompt-injection regex prefilter timed out during stage '{Stage}'. Falling back to individual rules.", stage);
}
foreach (var rule in PromptInjectionPatterns.RULES)
if (shouldScanRegexRules)
{
if (findings.Count >= MAX_FINDINGS)
return;
Match match;
try
foreach (var rule in PromptInjectionPatterns.RULES)
{
match = rule.Regex.Match(text);
}
catch (RegexMatchTimeoutException exception)
{
logger.LogWarning(exception, "Prompt-injection regex '{RuleId}' timed out during stage '{Stage}'.", rule.Id, stage);
continue;
}
if (findings.Count >= MAX_FINDINGS)
return;
if (!match.Success)
continue;
Match match;
try
{
match = rule.Regex.Match(text);
}
catch (RegexMatchTimeoutException exception)
{
logger.LogWarning(exception, "Prompt-injection regex '{RuleId}' timed out during stage '{Stage}'.", rule.Id, stage);
continue;
}
var snippet = ExtractSnippet(text, match.Index, match.Length);
AddFinding(findings, findingKeys, new(rule.Id, rule.Category, snippet));
if (!match.Success)
continue;
var snippet = ExtractSnippet(text, match.Index, match.Length);
AddFinding(findings, findingKeys, new(rule.Id, rule.Category, snippet));
}
}
if (findings.Count < MAX_FINDINGS)
ScanLexicalSignals(text, scanTypoglycemia, findings, findingKeys);
}
private void ScanDecodedCandidates(string text, List<PromptInjectionFinding> findings, HashSet<string> findingKeys)
@ -114,30 +124,228 @@ public sealed class PromptInjectionScanner(ILogger<PromptInjectionScanner> logge
}
}
private void ScanTypoglycemia(string text, List<PromptInjectionFinding> findings, HashSet<string> findingKeys)
private static void ScanLexicalSignals(
string text,
bool scanTypoglycemia,
List<PromptInjectionFinding> findings,
HashSet<string> findingKeys)
{
foreach (var match in PromptInjectionPatterns.WordRegex().EnumerateMatches(text))
Span<int> lastTokenPositions = stackalloc int[PromptInjectionLexicon.SIGNAL_COUNT];
Span<int> lastCharacterPositions = stackalloc int[PromptInjectionLexicon.SIGNAL_COUNT];
Span<int> lastTokenLengths = stackalloc int[PromptInjectionLexicon.SIGNAL_COUNT];
Span<bool> matchedRules = stackalloc bool[PromptInjectionLexicon.RULES.Count];
lastTokenPositions.Fill(int.MinValue);
matchedRules.Clear();
var tokenIndex = 0;
var characterIndex = 0;
while (characterIndex < text.Length)
{
if (findings.Count >= MAX_FINDINGS)
return;
while (characterIndex < text.Length && !IsAsciiLetter(text[characterIndex]))
characterIndex++;
var token = text.AsSpan(match.Index, match.Length);
var key = (token.Length, char.ToLowerInvariant(token[0]), char.ToLowerInvariant(token[^1]));
if (!TYPOGLYCEMIA_KEYWORDS.TryGetValue(key, out var keywords))
continue;
foreach (var keyword in keywords)
{
if (!IsTypoglycemiaVariant(token, keyword))
continue;
var snippet = ExtractSnippet(text, match.Index, match.Length);
AddFinding(findings, findingKeys, new($"typoglycemia:{keyword}", "evasion", snippet));
if (characterIndex >= text.Length)
break;
var tokenStart = characterIndex;
while (characterIndex < text.Length &&
(IsAsciiLetter(text[characterIndex]) || text[characterIndex] == '\''))
{
characterIndex++;
}
var token = text.AsSpan(tokenStart, characterIndex - tokenStart);
var signals = PromptInjectionLexicon.Classify(token);
if (signals != PromptInjectionLexicalSignal.NONE)
{
UpdateSignalOccurrences(
signals,
tokenIndex,
tokenStart,
token.Length,
lastTokenPositions,
lastCharacterPositions,
lastTokenLengths);
for (var ruleIndex = 0; ruleIndex < PromptInjectionLexicon.RULES.Count; ruleIndex++)
{
if (matchedRules[ruleIndex])
continue;
var rule = PromptInjectionLexicon.RULES[ruleIndex];
if (!TryMatchLexicalRule(
rule,
tokenIndex,
lastTokenPositions,
lastCharacterPositions,
lastTokenLengths,
out var matchStart,
out var matchLength))
{
continue;
}
matchedRules[ruleIndex] = true;
var snippet = ExtractSnippet(text, matchStart, matchLength);
AddFinding(findings, findingKeys, new(rule.Id, rule.Category, snippet));
if (findings.Count >= MAX_FINDINGS)
return;
}
}
if (scanTypoglycemia && IsTypoglycemiaCandidate(token))
{
ScanTypoglycemiaToken(text, token, tokenStart, findings, findingKeys);
if (findings.Count >= MAX_FINDINGS)
return;
}
tokenIndex++;
}
}
private static void UpdateSignalOccurrences(
PromptInjectionLexicalSignal signals,
int tokenIndex,
int tokenStart,
int tokenLength,
Span<int> lastTokenPositions,
Span<int> lastCharacterPositions,
Span<int> lastTokenLengths)
{
var remainingSignals = (uint)signals;
while (remainingSignals != 0)
{
var signalIndex = BitOperations.TrailingZeroCount(remainingSignals);
lastTokenPositions[signalIndex] = tokenIndex;
lastCharacterPositions[signalIndex] = tokenStart;
lastTokenLengths[signalIndex] = tokenLength;
remainingSignals &= remainingSignals - 1;
}
}
private static bool TryMatchLexicalRule(
PromptInjectionLexicalRule rule,
int currentTokenIndex,
ReadOnlySpan<int> lastTokenPositions,
ReadOnlySpan<int> lastCharacterPositions,
ReadOnlySpan<int> lastTokenLengths,
out int matchStart,
out int matchLength)
{
matchStart = int.MaxValue;
var matchEnd = 0;
if (!TryIncludeMostRecentSignal(
rule.First,
currentTokenIndex,
lastTokenPositions,
lastCharacterPositions,
lastTokenLengths,
ref matchStart,
ref matchEnd) ||
!TryIncludeMostRecentSignal(
rule.Second,
currentTokenIndex,
lastTokenPositions,
lastCharacterPositions,
lastTokenLengths,
ref matchStart,
ref matchEnd) ||
rule.Third != PromptInjectionLexicalSignal.NONE &&
!TryIncludeMostRecentSignal(
rule.Third,
currentTokenIndex,
lastTokenPositions,
lastCharacterPositions,
lastTokenLengths,
ref matchStart,
ref matchEnd))
{
matchStart = 0;
matchLength = 0;
return false;
}
matchLength = matchEnd - matchStart;
return true;
}
private static bool TryIncludeMostRecentSignal(
PromptInjectionLexicalSignal allowedSignals,
int currentTokenIndex,
ReadOnlySpan<int> lastTokenPositions,
ReadOnlySpan<int> lastCharacterPositions,
ReadOnlySpan<int> lastTokenLengths,
ref int matchStart,
ref int matchEnd)
{
var mostRecentSignalIndex = -1;
var mostRecentTokenIndex = int.MinValue;
var remainingSignals = (uint)allowedSignals;
while (remainingSignals != 0)
{
var signalIndex = BitOperations.TrailingZeroCount(remainingSignals);
if (lastTokenPositions[signalIndex] > mostRecentTokenIndex)
{
mostRecentSignalIndex = signalIndex;
mostRecentTokenIndex = lastTokenPositions[signalIndex];
}
remainingSignals &= remainingSignals - 1;
}
if (mostRecentSignalIndex < 0 ||
mostRecentTokenIndex < currentTokenIndex - (LEXICAL_WINDOW_SIZE - 1))
{
return false;
}
var signalStart = lastCharacterPositions[mostRecentSignalIndex];
matchStart = Math.Min(matchStart, signalStart);
matchEnd = Math.Max(matchEnd, signalStart + lastTokenLengths[mostRecentSignalIndex]);
return true;
}
private static bool IsTypoglycemiaCandidate(ReadOnlySpan<char> token)
{
if (token.Length is < 5 or > 12)
return false;
foreach (var character in token)
{
if (!IsAsciiLetter(character))
return false;
}
return true;
}
private static void ScanTypoglycemiaToken(
string text,
ReadOnlySpan<char> token,
int tokenStart,
List<PromptInjectionFinding> findings,
HashSet<string> findingKeys)
{
var key = (token.Length, char.ToLowerInvariant(token[0]), char.ToLowerInvariant(token[^1]));
if (!TYPOGLYCEMIA_KEYWORDS.TryGetValue(key, out var keywords))
return;
foreach (var keyword in keywords)
{
if (!IsTypoglycemiaVariant(token, keyword))
continue;
var snippet = ExtractSnippet(text, tokenStart, token.Length);
AddFinding(findings, findingKeys, new($"typoglycemia:{keyword}", "evasion", snippet));
return;
}
}
private static bool IsAsciiLetter(char character) =>
character is >= 'A' and <= 'Z' or >= 'a' and <= 'z';
private static bool IsTypoglycemiaVariant(ReadOnlySpan<char> token, string keyword)
{
if (token.Equals(keyword, StringComparison.OrdinalIgnoreCase))

View File

@ -7,6 +7,7 @@
- Added the dedicated file extension `.mwplugin` for plugin archives.
- Added an option for organizations to disable importing, sharing, and exporting plugins.
- Added checks for prompt injections in file attachments, images and web pages to process large documents faster and with lower memory usage.
- Improved prompt-injection protection to recognize more phrasing variants and suspicious word combinations across line breaks.
- Improved how your organization's configuration behaves when a configuration plugin is present but cannot be loaded, e.g. because of an error in the plugin. Such a plugin still manages your app, so its settings, providers, data sources, profiles, and chat templates now stay in place instead of being removed.
- Fixed reset buttons in assistants. As you may have noticed in the Document Analysis Assistant, resetting it could leave content from the previous analysis visible. Reset buttons now clear previous results completely.
- Fixed dropping files after you closed a dialog that accepts files itself. Such a dialog takes over dropped files while it is open, but never handed that role back when you closed it. Afterwards, the chat and the assistants silently ignored dropped files until you switched to another page. Each time you opened such a dialog again, the problem got worse.