From da92b4af68d63bebffba9f93be91ed58fe467cc6 Mon Sep 17 00:00:00 2001 From: hart_s3 Date: Mon, 10 Aug 2026 14:00:35 +0200 Subject: [PATCH] Add lexical signal-based detection for prompt-injection handling --- .../Tools/Security/PromptInjectionLexicon.cs | 322 ++++++++++++++++++ .../Tools/Security/PromptInjectionPatterns.cs | 5 +- .../Tools/Security/PromptInjectionScanner.cs | 288 +++++++++++++--- .../wwwroot/changelog/v26.8.1.md | 1 + 4 files changed, 572 insertions(+), 44 deletions(-) create mode 100644 app/MindWork AI Studio/Tools/Security/PromptInjectionLexicon.cs diff --git a/app/MindWork AI Studio/Tools/Security/PromptInjectionLexicon.cs b/app/MindWork AI Studio/Tools/Security/PromptInjectionLexicon.cs new file mode 100644 index 00000000..11ebb9ce --- /dev/null +++ b/app/MindWork AI Studio/Tools/Security/PromptInjectionLexicon.cs @@ -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 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 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 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 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 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 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 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 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 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 token) + { + if (Equals(token, "hidden") || Equals(token, "his") || Equals(token, "her")) + return PromptInjectionLexicalSignal.PROTECTED_CONTEXT; + return PromptInjectionLexicalSignal.NONE; + } + + private static PromptInjectionLexicalSignal ClassifyI(ReadOnlySpan 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 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 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 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 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 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 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 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 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 token) + { + if (Equals(token, "unlimited") || Equals(token, "unrestricted")) + return PromptInjectionLexicalSignal.ESCAPE_STATE; + return PromptInjectionLexicalSignal.NONE; + } + + private static PromptInjectionLexicalSignal ClassifyV(ReadOnlySpan token) + { + if (Equals(token, "verbatim")) + return PromptInjectionLexicalSignal.PROTECTED_CONTEXT; + if (Equals(token, "void")) + return PromptInjectionLexicalSignal.INVALIDATION; + return PromptInjectionLexicalSignal.NONE; + } + + private static PromptInjectionLexicalSignal ClassifyY(ReadOnlySpan 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 token, string value) => token.Equals(value, StringComparison.OrdinalIgnoreCase); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Security/PromptInjectionPatterns.cs b/app/MindWork AI Studio/Tools/Security/PromptInjectionPatterns.cs index 6c92c3a2..2781839b 100644 --- a/app/MindWork AI Studio/Tools/Security/PromptInjectionPatterns.cs +++ b/app/MindWork AI Studio/Tools/Security/PromptInjectionPatterns.cs @@ -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(@"(? 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 logge var findings = new List(); var findingKeys = new HashSet(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 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 findings, HashSet findingKeys) + private void ScanVariant( + string text, + string stage, + List findings, + HashSet 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 findings, HashSet findingKeys) @@ -114,30 +124,228 @@ public sealed class PromptInjectionScanner(ILogger logge } } - private void ScanTypoglycemia(string text, List findings, HashSet findingKeys) + private static void ScanLexicalSignals( + string text, + bool scanTypoglycemia, + List findings, + HashSet findingKeys) { - foreach (var match in PromptInjectionPatterns.WordRegex().EnumerateMatches(text)) + Span lastTokenPositions = stackalloc int[PromptInjectionLexicon.SIGNAL_COUNT]; + Span lastCharacterPositions = stackalloc int[PromptInjectionLexicon.SIGNAL_COUNT]; + Span lastTokenLengths = stackalloc int[PromptInjectionLexicon.SIGNAL_COUNT]; + Span 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 lastTokenPositions, + Span lastCharacterPositions, + Span 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 lastTokenPositions, + ReadOnlySpan lastCharacterPositions, + ReadOnlySpan 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 lastTokenPositions, + ReadOnlySpan lastCharacterPositions, + ReadOnlySpan 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 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 token, + int tokenStart, + List findings, + HashSet 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 token, string keyword) { if (token.Equals(keyword, StringComparison.OrdinalIgnoreCase)) diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.8.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.8.1.md index 846c55ab..80e0e227 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.8.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.8.1.md @@ -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.