Files
agentjacking-attacks-trick-…/src/detector.ts
T

332 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Threat, ThreatType, ScanResult } from './types.js';
const DANGEROUS_PATTERNS = [
// npm/pip install commands with suspicious packages
{ regex: /npm\s+(?:install|i|add)\s+[@\w/-]*(?:evil|malware|backdoor|trojan|exfil|keylogger|stealer|logger|cryptominer|miner|suspicious|unknown|unverified|temp|tmp|test-malicious|test-evil|test-backdoor)[\w-]*/gi, type: 'hidden_command' as ThreatType, severity: 'critical' as const, title: 'Suspicious npm install command', description: 'An npm install command references a package with a suspicious name commonly used in supply chain attacks.' },
{ regex: /pip\s+(?:install)\s+[@\w/-]*(?:evil|malware|backdoor|trojan|exfil|keylogger|stealer|logger|cryptominer|miner|suspicious|unknown|unverified|temp|tmp)[\w-]*/gi, type: 'hidden_command' as ThreatType, severity: 'critical' as const, title: 'Suspicious pip install command', description: 'A pip install command references a package with a suspicious name.' },
// curl | bash / curl | sh patterns (classic pipe-to-shell)
{ regex: /curl\s+[^|\n]*\||wget\s+[^|\n]*\|.*\b(?:bash|sh|zsh|fish|powershell|cmd|python|ruby|node|perl)\b/gi, type: 'hidden_command' as ThreatType, severity: 'critical' as const, title: 'Pipe-to-shell command detected', description: 'A curl or wget command is piped directly to a shell interpreter. This is a common delivery mechanism for malware.' },
// eval / exec / Function constructor with dynamic content
{ regex: /\b(?:eval|exec|Function|setTimeout|setInterval)\s*\(\s*["'`]\s*(?:atob|btoa|decodeURIComponent|unescape|Buffer\.from|window\[|document\[|process\.env)/gi, type: 'hidden_command' as ThreatType, severity: 'high' as const, title: 'Dynamic code execution with encoded input', description: 'Dynamic code execution (eval, exec, Function, setTimeout) is combined with decoding functions, a common obfuscation technique.' },
// fetch to strange domains with script execution
{ regex: /fetch\s*\(\s*["'`][^"'`]*(?:pastebin|gist\.github|raw\.githubusercontent|cdn\.jsdelivr|unpkg|cloudfront|blob\.core\.windows|s3\.amazonaws|dropboxusercontent|drive\.google)[^"'`]*["'`]\s*\).*\.(?:text|json|blob)\s*\(\s*\).*\b(?:eval|Function|setTimeout|setInterval|document\.write|innerHTML|insertAdjacentHTML)/gis, type: 'hidden_command' as ThreatType, severity: 'high' as const, title: 'Fetch remote payload then execute', description: 'Code fetches content from a remote URL and then executes it dynamically. This is a common agentjacking payload delivery pattern.' },
// Fake error messages that contain code
{ regex: /(?:Error|Exception|Failed|Stack trace|Traceback|TypeError|ReferenceError|SyntaxError)\s*[:\n]\s*(?:[^\n]{0,80})\n\s*(?:npm|pip|curl|wget|git|sudo|bash|python|ruby|node|exec|eval|import|require|from|install)/gim, type: 'fake_error_injection' as ThreatType, severity: 'high' as const, title: 'Executable code inside fake error message', description: 'An error message contains executable commands or code. Agentjacking attacks often disguise malicious code as error output to trick AI agents into fixing it by running it.' },
// Base64 that decodes to dangerous keywords
{ regex: /(?:[A-Za-z0-9+/]{40,}={0,2})\s*(?:\n|\r|\s|<!--|-->|\/\/)?\s*(?:\/\/\s*decode|#\s*decode|base64|atob|btoa|Buffer\.from|from\s*["']base64["'])/gi, type: 'base64_payload' as ThreatType, severity: 'high' as const, title: 'Base64 payload with decode hint', description: 'A long base64 string is accompanied by instructions to decode it. Decoding may reveal malicious code designed to execute when processed by an AI agent.' },
];
const INVISIBLE_CHARS = [
'\u200B', '\u200C', '\u200D', '\u2060', '\uFEFF', '\u00AD',
'\u200E', '\u200F',
];
const BIDI_OVERRIDES = ['\u202A', '\u202B', '\u202D', '\u202E', '\u202C', '\u2066', '\u2067', '\u2068', '\u2069'];
const HOMOGLYPHS: Record<string, string[]> = {
'a': ['\u0430'], // Cyrillic а
'e': ['\u0435'], // Cyrillic е
'o': ['\u043E'], // Cyrillic о
'p': ['\u0440'], // Cyrillic р
'c': ['\u0441'], // Cyrillic с
'x': ['\u0445'], // Cyrillic х
'y': ['\u0443'], // Cyrillic у
'i': ['\u0456'], // Cyrillic і
'j': ['\u0458'], // Cyrillic ј
'A': ['\u0410'], // Cyrillic А
'E': ['\u0415'], // Cyrillic Е
'O': ['\u041E'], // Cyrillic О
'P': ['\u0420'], // Cyrillic Р
'C': ['\u0421'], // Cyrillic С
'X': ['\u0425'], // Cyrillic Х
'Y': ['\u0423'], // Cyrillic У
'I': ['\u0406'], // Cyrillic І
'T': ['\u0422'], // Cyrillic Т
'B': ['\u0412'], // Cyrillic В
'M': ['\u041C'], // Cyrillic М
'H': ['\u041D'], // Cyrillic Н
'K': ['\u041A'], // Cyrillic К
};
export function scanText(text: string, url: string): ScanResult {
const threats: Threat[] = [];
// Pattern-based detection
for (const pattern of DANGEROUS_PATTERNS) {
const regex = new RegExp(pattern.regex.source, pattern.regex.flags.includes('g') ? pattern.regex.flags : pattern.regex.flags + 'g');
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
threats.push({
id: `threat_${url}_${match.index}_${threats.length}`,
type: pattern.type,
severity: pattern.severity,
title: pattern.title,
description: pattern.description,
matchedText: match[0].slice(0, 200),
position: { start: match.index, end: match.index + match[0].length },
url,
timestamp: Date.now(),
});
// Prevent infinite loop on zero-width matches
if (match.index === regex.lastIndex) {
regex.lastIndex++;
}
}
}
// Invisible character detection
const invisibleMatches = findInvisibleChars(text);
for (const inv of invisibleMatches) {
threats.push({
id: `threat_${url}_invisible_${inv.start}_${threats.length}`,
type: 'invisible_character',
severity: 'medium',
title: 'Invisible characters detected',
description: 'Invisible Unicode characters (' + inv.chars.map(c => 'U+' + c.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')).join(', ') + ') were found in the text. These can be used to hide malicious code or alter how AI agents interpret commands.',
matchedText: inv.context,
position: { start: inv.start, end: inv.end },
url,
timestamp: Date.now(),
});
}
// Bidirectional override detection
const bidiMatches = findBidiOverrides(text);
for (const bidi of bidiMatches) {
threats.push({
id: `threat_${url}_bidi_${bidi.start}_${threats.length}`,
type: 'invisible_character',
severity: 'high',
title: 'Bidirectional text override detected',
description: 'Unicode bidirectional override characters (LRO, RLO, LRE, RLE, PDF, LRI, RLI, FSI, PDI) were found. These can reorder displayed text to hide malicious code from human reviewers while still executing correctly.',
matchedText: bidi.context,
position: { start: bidi.start, end: bidi.end },
url,
timestamp: Date.now(),
});
}
// Homoglyph detection
const homoglyphMatches = findHomoglyphs(text);
for (const hom of homoglyphMatches) {
threats.push({
id: `threat_${url}_homoglyph_${hom.start}_${threats.length}`,
type: 'unicode_homoglyph',
severity: 'medium',
title: 'Unicode homoglyph detected',
description: `The character "${hom.originalChar}" (U+${hom.originalChar.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}) looks like "${hom.looksLike}" but is from a different alphabet. This can trick AI agents into referencing the wrong package or API.`,
matchedText: hom.context,
position: { start: hom.start, end: hom.end },
url,
timestamp: Date.now(),
});
}
// HTML smuggling detection (iframes with javascript/data URIs, hidden scripts)
if (typeof document !== 'undefined') {
const htmlThreats = scanDomForSmuggling(url);
threats.push(...htmlThreats);
}
// Clipboard hijack detection (attempts to write to clipboard with dangerous content)
const clipboardThreats = detectClipboardHijack(text, url);
threats.push(...clipboardThreats);
return {
url,
threats: deduplicateThreats(threats),
scannedAt: Date.now(),
textLength: text.length,
};
}
function findInvisibleChars(text: string): Array<{ start: number; end: number; chars: string[]; context: string }> {
const results: Array<{ start: number; end: number; chars: string[]; context: string }> = [];
let current: { start: number; end: number; chars: string[] } | null = null;
for (let i = 0; i < text.length; i++) {
if (INVISIBLE_CHARS.includes(text[i])) {
if (!current) {
current = { start: i, end: i + 1, chars: [text[i]] };
} else {
current.end = i + 1;
current.chars.push(text[i]);
}
} else {
if (current) {
const contextStart = Math.max(0, current.start - 20);
const contextEnd = Math.min(text.length, current.end + 20);
results.push({ ...current, context: text.slice(contextStart, contextEnd) });
current = null;
}
}
}
if (current) {
const contextStart = Math.max(0, current.start - 20);
const contextEnd = Math.min(text.length, current.end + 20);
results.push({ ...current, context: text.slice(contextStart, contextEnd) });
}
return results;
}
function findBidiOverrides(text: string): Array<{ start: number; end: number; context: string }> {
const results: Array<{ start: number; end: number; context: string }> = [];
let current: { start: number; end: number } | null = null;
for (let i = 0; i < text.length; i++) {
if (BIDI_OVERRIDES.includes(text[i])) {
if (!current) {
current = { start: i, end: i + 1 };
} else {
current.end = i + 1;
}
} else {
if (current) {
const contextStart = Math.max(0, current.start - 20);
const contextEnd = Math.min(text.length, current.end + 20);
results.push({ ...current, context: text.slice(contextStart, contextEnd) });
current = null;
}
}
}
if (current) {
const contextStart = Math.max(0, current.start - 20);
const contextEnd = Math.min(text.length, current.end + 20);
results.push({ ...current, context: text.slice(contextStart, contextEnd) });
}
return results;
}
function findHomoglyphs(text: string): Array<{ start: number; end: number; originalChar: string; looksLike: string; context: string }> {
const results: Array<{ start: number; end: number; originalChar: string; looksLike: string; context: string }> = [];
for (let i = 0; i < text.length; i++) {
const ch = text[i];
for (const [latin, cyrillicList] of Object.entries(HOMOGLYPHS)) {
if (cyrillicList.includes(ch)) {
const contextStart = Math.max(0, i - 20);
const contextEnd = Math.min(text.length, i + 21);
results.push({
start: i,
end: i + 1,
originalChar: ch,
looksLike: latin,
context: text.slice(contextStart, contextEnd),
});
}
}
}
return results;
}
function scanDomForSmuggling(url: string): Threat[] {
const threats: Threat[] = [];
try {
// Check for iframes with javascript/data URIs
const iframes = document.querySelectorAll('iframe');
for (const iframe of Array.from(iframes)) {
const src = iframe.getAttribute('src') || '';
if (src.startsWith('javascript:') || src.startsWith('data:')) {
threats.push({
id: `threat_${url}_iframe_${threats.length}`,
type: 'html_smuggling',
severity: 'critical',
title: 'Dangerous iframe source detected',
description: 'An iframe uses a javascript: or data: URI, which can execute code in the context of the page. This is a common HTML smuggling technique.',
matchedText: src.slice(0, 200),
position: { start: 0, end: src.length },
url,
timestamp: Date.now(),
});
}
if (iframe.style.display === 'none' || iframe.style.visibility === 'hidden' || iframe.style.width === '0px' || iframe.style.height === '0px') {
threats.push({
id: `threat_${url}_hiddeniframe_${threats.length}`,
type: 'html_smuggling',
severity: 'high',
title: 'Hidden iframe detected',
description: 'An iframe is hidden from view. Hidden iframes are commonly used to smuggle malicious content or perform unauthorized actions.',
matchedText: src.slice(0, 200),
position: { start: 0, end: src.length },
url,
timestamp: Date.now(),
});
}
}
// Check for inline event handlers with dangerous content
const allElements = document.querySelectorAll('*');
for (const el of Array.from(allElements)) {
for (const attr of Array.from(el.attributes)) {
if (attr.name.startsWith('on') && /(?:eval|exec|Function|fetch|XMLHttpRequest|document\.write|innerHTML|atob|btoa|decodeURIComponent)/i.test(attr.value)) {
threats.push({
id: `threat_${url}_event_${threats.length}`,
type: 'html_smuggling',
severity: 'high',
title: 'Dangerous inline event handler',
description: `An inline event handler (${attr.name}) contains dynamic code execution patterns.`,
matchedText: `${attr.name}="${attr.value.slice(0, 200)}"`,
position: { start: 0, end: attr.value.length },
url,
timestamp: Date.now(),
});
}
}
}
} catch {
// DOM access may fail in restricted contexts
}
return threats;
}
function detectClipboardHijack(text: string, url: string): Threat[] {
const threats: Threat[] = [];
// Detect patterns that try to override clipboard with dangerous content
const clipboardPatterns = [
{ regex: /navigator\.clipboard\.writeText\s*\(\s*["'`][^"'`]*(?:curl|wget|npm|pip|sudo|bash|python|ruby|node|exec|eval|install|git clone)/gi, title: 'Clipboard hijack with command', description: 'Code attempts to copy a shell command or installation instruction to the clipboard. This is used to trick users into pasting malicious commands into their terminal.' },
{ regex: /document\.addEventListener\s*\(\s*["']copy["']\s*,.*\b(?:fetch|eval|exec|Function|atob|btoa|decodeURIComponent)/gis, title: 'Copy event interception with dynamic execution', description: 'A copy event listener is combined with dynamic code execution. This can intercept and modify clipboard content when the user copies text.' },
{ regex: /\.addEventListener\s*\(\s*["']paste["']\s*,.*\b(?:eval|exec|Function|document\.write|innerHTML|insertAdjacentHTML)/gis, title: 'Paste event interception with code execution', description: 'A paste event listener contains code execution patterns. This can intercept pasted content and execute it dynamically, potentially triggering agentjacking when an AI agent pastes code from the web.' },
];
for (const pattern of clipboardPatterns) {
const regex = new RegExp(pattern.regex.source, pattern.regex.flags.includes('g') ? pattern.regex.flags : pattern.regex.flags + 'g');
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
threats.push({
id: `threat_${url}_clipboard_${match.index}_${threats.length}`,
type: 'clipboard_hijack',
severity: 'high',
title: pattern.title,
description: pattern.description,
matchedText: match[0].slice(0, 200),
position: { start: match.index, end: match.index + match[0].length },
url,
timestamp: Date.now(),
});
if (match.index === regex.lastIndex) {
regex.lastIndex++;
}
}
}
return threats;
}
function deduplicateThreats(threats: Threat[]): Threat[] {
const seen = new Set<string>();
return threats.filter(t => {
const key = `${t.type}_${t.severity}_${t.matchedText}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
export function getThreatStats(threats: Threat[]) {
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
for (const t of threats) {
counts[t.severity]++;
}
return counts;
}
export function isPageSafe(threats: Threat[]): boolean {
return threats.every(t => t.severity !== 'critical' && t.severity !== 'high');
}