From 61c5284119c791f4aabb5414d3e208ef916e1636 Mon Sep 17 00:00:00 2001 From: PatchLedger Research Date: Thu, 14 May 2026 10:19:32 -0400 Subject: [PATCH] fix(scanner): reduce HTTP client false positives --- scanner/package.json | 1 + scanner/src/analyzer.ts | 43 ++++++++++++++-- scanner/src/http-client-allowlist.test.ts | 63 +++++++++++++++++++++++ 3 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 scanner/src/http-client-allowlist.test.ts diff --git a/scanner/package.json b/scanner/package.json index a71b58fe..1f307804 100644 --- a/scanner/package.json +++ b/scanner/package.json @@ -6,6 +6,7 @@ "type": "module", "scripts": { "build": "tsc", + "test": "npm run build && node --test dist/http-client-allowlist.test.js", "start": "node dist/index.js", "dev": "tsx watch src/index.ts", "scan": "tsx src/cli.ts" diff --git a/scanner/src/analyzer.ts b/scanner/src/analyzer.ts index 7ea15a93..9b94059c 100644 --- a/scanner/src/analyzer.ts +++ b/scanner/src/analyzer.ts @@ -48,6 +48,45 @@ const RISK_THRESHOLDS = { low: 10 }; +const SENSITIVE_CONTEXT_PATTERN = + /process\.env(?:\[[^\]]*(?:TOKEN|SECRET|KEY|PASSWORD|PWD|CREDENTIAL|PRIVATE)[^\]]*\]|\.(?:[A-Z0-9_]*(?:TOKEN|SECRET|KEY|PASSWORD|PWD|CREDENTIAL|PRIVATE)[A-Z0-9_]*))|document\.cookie|localStorage|sessionStorage|\.env|id_rsa|id_ed25519|credentials|privateKey|api[_-]?key|password/i; + +const BENIGN_ENV_NAME_PATTERN = + /^(?:NODE_ENV|PORT|HOST|HOSTNAME|CI|DEBUG|LOG_LEVEL|TZ|LANG|PATH|HOME|PWD|TMPDIR|TEMP|PUBLIC_[A-Z0-9_]+|NEXT_PUBLIC_[A-Z0-9_]+|VITE_[A-Z0-9_]+|[A-Z0-9_]+_(?:URL|URI|HOST|PORT|ORIGIN|BASE_URL|ENDPOINT)|(?:API|APP|SITE|BASE)_(?:URL|URI|HOST|PORT|ORIGIN|ENDPOINT))$/; + +function isBenignDynamicFetch(contextLine: string): boolean { + if (SENSITIVE_CONTEXT_PATTERN.test(contextLine)) { + return false; + } + return /fetch\s*\(\s*(?:url|uri|endpoint|apiUrl|apiEndpoint|requestUrl|resource|input)\b/i.test(contextLine); +} + +function getEnvNames(contextLine: string): string[] { + const names: string[] = []; + for (const match of contextLine.matchAll(/process\.env(?:\.([A-Z0-9_]+)|\[\s*['"`]([^'"`]+)['"`]\s*\])/gi)) { + const envName = match[1] ?? match[2]; + if (envName) { + names.push(envName.toUpperCase()); + } + } + return names; +} + +function isBenignEnvConfigAccess(contextLine: string): boolean { + const envNames = getEnvNames(contextLine); + return envNames.length > 0 && envNames.every((envName) => BENIGN_ENV_NAME_PATTERN.test(envName)); +} + +function isContextAllowlisted(pattern: Pattern, contextLine: string): boolean { + if (pattern.id === 'EXFIL_FETCH_DYNAMIC' && isBenignDynamicFetch(contextLine)) { + return true; + } + if (pattern.id === 'CRED_ENV_ACCESS' && isBenignEnvConfigAccess(contextLine)) { + return true; + } + return ALLOWLIST_PATTERNS.some(allowPattern => allowPattern.test(contextLine)); +} + /** * Analyze content for security issues */ @@ -72,9 +111,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 = isContextAllowlisted(pattern, contextLine); if (!isAllowlisted) { findings.push({ diff --git a/scanner/src/http-client-allowlist.test.ts b/scanner/src/http-client-allowlist.test.ts new file mode 100644 index 00000000..35e4b9a7 --- /dev/null +++ b/scanner/src/http-client-allowlist.test.ts @@ -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('does not flag benign dynamic fetch wrapper parameters', () => { + const ids = patternIdsFor(` + export async function getJson(url: string) { + const response = await fetch(url, { method: "GET" }); + return response.json(); + } + `); + + assert.equal(ids.includes('EXFIL_FETCH_DYNAMIC'), false); +}); + +test('does not flag benign API URL configuration from environment', () => { + const ids = patternIdsFor(` + const baseUrl = process.env.API_URL; + export const client = { baseUrl }; + `); + + assert.equal(ids.includes('CRED_ENV_ACCESS'), false); +}); + +test('does not flag common public frontend environment configuration', () => { + const ids = patternIdsFor(` + const endpoint = process.env.NEXT_PUBLIC_API_URL || process.env.VITE_API_ENDPOINT; + fetch(endpoint); + `); + + assert.equal(ids.includes('CRED_ENV_ACCESS'), false); + assert.equal(ids.includes('EXFIL_FETCH_DYNAMIC'), false); +}); + +test('still flags dynamic fetches that include environment secrets', () => { + const ids = patternIdsFor(` + const target = getCollectorUrl(); + fetch(target + "?token=" + process.env.NPM_TOKEN); + `); + + assert.ok(ids.includes('EXFIL_FETCH_DYNAMIC')); + assert.ok(ids.includes('CRED_ENV_ACCESS')); +}); + +test('still flags dynamic fetches that send browser credentials', () => { + const ids = patternIdsFor(` + fetch(endpoint, { method: "POST", body: document.cookie }); + `); + + assert.ok(ids.includes('EXFIL_FETCH_DYNAMIC')); +}); + +test('still flags token-like environment variable access', () => { + const ids = patternIdsFor(` + const token = process.env.GITHUB_TOKEN; + `); + + assert.ok(ids.includes('CRED_ENV_ACCESS')); +});