diff --git a/package.json b/package.json index 27182833..4f539b64 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "lint": "eslint . --max-warnings=0 && node scripts/sort-package-json.mjs", "perf:budgets": "node scripts/check-performance-budgets.mjs", "perf:ci": "npm run build && npm run perf:budgets", - "sort-package-json": "node scripts/sort-package-json.mjs", - "start": "next start", + "security:check-globals": "node scripts/check-exposed-globals.mjs", + "validate:env": "node scripts/validate-env.js", "storybook": "storybook dev -p 6006", "test": "jest", "test:ci": "jest --coverage --watchAll=false --ci", diff --git a/scripts/check-exposed-globals.mjs b/scripts/check-exposed-globals.mjs new file mode 100644 index 00000000..aef1dfe0 --- /dev/null +++ b/scripts/check-exposed-globals.mjs @@ -0,0 +1,37 @@ +import { readFileSync, existsSync } from 'fs'; +import { glob } from 'glob'; + +const SENSITIVE_PATTERNS = [ + /__[A-Z][A-Z_]+__/g, +]; + +async function main() { + const files = await glob('src/**/*.{ts,tsx,js,jsx}', { + ignore: ['src/**/*.test.*', 'src/**/__tests__/**', 'node_modules/**'], + }); + + let hasError = false; + + for (const file of files) { + if (!existsSync(file)) continue; + const content = readFileSync(file, 'utf-8'); + const matches = content.match(SENSITIVE_PATTERNS[0]); + if (matches) { + for (const match of matches) { + console.error(`[FAIL] Found exposed global '${match}' in ${file}`); + hasError = true; + } + } + } + + if (hasError) { + process.exit(1); + } + + console.log('[PASS] No exposed globals found.'); +} + +main().catch((err) => { + console.error('Script failed:', err); + process.exit(1); +}); diff --git a/src/app/api/security/address-check/route.ts b/src/app/api/security/address-check/route.ts index cf9eea9f..e934afe4 100644 --- a/src/app/api/security/address-check/route.ts +++ b/src/app/api/security/address-check/route.ts @@ -1,8 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -const CHAINALYSIS_API_KEY = process.env.CHAINALYSIS_API_KEY || ''; -const CHAINALYSIS_API_URL = process.env.CHAINALYSIS_API_URL || 'https://api.chainalysis.com/api/v2'; - export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const address = searchParams.get('address')?.trim(); @@ -11,37 +8,46 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Address parameter required' }, { status: 400 }); } - if (address.length < 10) { - return NextResponse.json({ error: 'Invalid address' }, { status: 400 }); + if (!/^0x[a-fA-F0-9]{40}$/.test(address)) { + return NextResponse.json({ error: 'Invalid Ethereum address' }, { status: 400 }); } - if (!CHAINALYSIS_API_KEY) { + const apiKey = process.env.CHAINALYSIS_API_KEY; + + if (!apiKey) { return NextResponse.json({ + address, risk_score: 50, - risk_level: 'medium', - categories: ['unavailable'], - description: 'Chainalysis API key not configured on server', + categories: ['unknown'], + description: 'Risk check unavailable (service not configured)', }); } try { - const response = await fetch(`${CHAINALYSIS_API_URL}/address/${address}`, { - headers: { - Authorization: `Bearer ${CHAINALYSIS_API_KEY}`, - 'Content-Type': 'application/json', - }, - signal: AbortSignal.timeout(10000), - }); + const response = await fetch( + `https://api.chainalysis.com/api/v2/address/${address}`, + { + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + signal: AbortSignal.timeout(10000), + } + ); if (!response.ok) { - throw new Error(`Chainalysis API returned ${response.status}`); + const errorText = await response.text(); + return NextResponse.json( + { error: `Upstream service error: ${response.status}`, detail: errorText }, + { status: response.status } + ); } const data = await response.json(); return NextResponse.json(data); } catch (error) { return NextResponse.json( - { error: 'Failed to check address risk', risk_score: 50, risk_level: 'medium' }, + { error: error instanceof Error ? error.message : 'Unknown error' }, { status: 502 } ); } diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx index da444062..39b2215f 100644 --- a/src/components/ui/chart.tsx +++ b/src/components/ui/chart.tsx @@ -77,27 +77,27 @@ function buildChartCSS(id: string, config: ChartConfig): string { if (!colorConfig.length) return '' - // Build CSS rules safely as a string (only uses known-safe values: - // theme keys, color strings from config, and CSS variable names) - const cssText = Object.entries(THEMES) - .map( - ([theme, prefix]) => ` -${prefix} [data-chart=${id}] { -${colorConfig - .map(([key, itemConfig]) => { - const color = - itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || - itemConfig.color - return color ? ` --color-${encodeURIComponent(key)}: ${color};` : null - }) - .filter(Boolean) - .join("\n")} -} -` - ) + const cssContent = Object.entries(THEMES) + .map(([theme, prefix]) => { + const rules = colorConfig + .map(([key, itemConfig]) => { + const color = + itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || + itemConfig.color + return color ? ` --color-${key}: ${color};` : null + }) + .filter(Boolean) + .join("\n") + return rules ? `${prefix} [data-chart=${id}] {\n${rules}\n}` : "" + }) + .filter(Boolean) .join("\n") - return + if (!cssContent) { + return null + } + + return } const ChartTooltip = Tooltip diff --git a/src/utils/security/__tests__/blockchainSecurity.test.ts b/src/utils/security/__tests__/blockchainSecurity.test.ts index 54b19492..f9c8dbab 100644 --- a/src/utils/security/__tests__/blockchainSecurity.test.ts +++ b/src/utils/security/__tests__/blockchainSecurity.test.ts @@ -6,9 +6,9 @@ global.fetch = jest.fn(); describe('BlockchainSecurityService', () => { let service: BlockchainSecurityService; const mockConfig: SecurityServiceConfig = { - baseUrl: 'https://api.test.com', + baseUrl: 'http://localhost:3000', timeout: 5000, - apiKey: 'test-api-key' + apiKey: undefined }; beforeEach(() => { @@ -89,8 +89,33 @@ describe('BlockchainSecurityService', () => { expect(result.riskScore).toBeGreaterThan(0); }); + it('should call the local proxy endpoint', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ risk_score: 30, categories: ['low_risk'], labels: [], description: 'Normal' }) + }); + + await service.checkAddressRisk(testAddress); + + const fetchUrl = (global.fetch as jest.Mock).mock.calls[0][0]; + expect(fetchUrl).toContain('/api/security/address-check'); + expect(fetchUrl).toContain(encodeURIComponent(testAddress)); + }); + + it('should fall back to simulation when proxy returns non-ok', async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 502, + json: async () => ({ error: 'Bad gateway' }) + }); + + const result = await service.checkAddressRisk(testAddress); + expect(result.riskScore).toBeGreaterThanOrEqual(0); + expect(result.riskScore).toBeLessThanOrEqual(100); + }); + it('should return default risk score on API failure', async () => { - (global.fetch as jest.Mock).mockRejectedValueOnce(new Error('API Error')); + (global.fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error')); const result = await service.checkAddressRisk(testAddress); expect(result).toEqual({ diff --git a/src/utils/security/blockchainSecurity.ts b/src/utils/security/blockchainSecurity.ts index 7ab5297d..12d9bfe2 100644 --- a/src/utils/security/blockchainSecurity.ts +++ b/src/utils/security/blockchainSecurity.ts @@ -155,35 +155,42 @@ export class BlockchainSecurityService { if (cached) return cached; try { - // Try calling a remote API if available. If `fetch` returns a Promise - // (for example when tests mock it), await it and use the response. - // Otherwise, fall back to the local simulation to preserve test behavior. - const fetchResult = typeof fetch === 'function' ? fetch(`${this.config.baseUrl}/address/${address}`, { - headers: this.config.apiKey ? { Authorization: `Bearer ${this.config.apiKey}` } : {} - }) : null; - - if (fetchResult && typeof fetchResult.then === 'function') { - const response = await fetchResult; - if (response && response.ok) { - const body = await response.json(); - const score = typeof body.risk_score === 'number' ? body.risk_score : 50; - const categories = Array.isArray(body.categories) ? body.categories : []; - const result: AddressRiskScore = { - address, - riskScore: score, - riskLevel: this.getRiskLevel(score), - categories, - labels: Array.isArray(body.labels) ? body.labels : [], - description: body.description || '' - }; - this.setCache(cacheKey, result); - return result; - } - // If response not ok, throw to be caught below and return default - throw new Error('Remote service returned non-OK response'); + // Call the local API proxy route which securely holds the API key server-side. + // This avoids exposing the key in the client bundle. + const baseUrl = typeof window !== 'undefined' + ? window.location.origin + : this.config.baseUrl; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.config.timeout); + + let response; + try { + response = await fetch( + `${baseUrl}/api/security/address-check?address=${encodeURIComponent(address)}`, + { signal: controller.signal } + ); + } finally { + clearTimeout(timeoutId); } - // No remote fetch available — use the internal simulation + if (response && response.ok) { + const body = await response.json(); + const score = typeof body.risk_score === 'number' ? body.risk_score : 50; + const categories = Array.isArray(body.categories) ? body.categories : []; + const result: AddressRiskScore = { + address, + riskScore: score, + riskLevel: this.getRiskLevel(score), + categories, + labels: Array.isArray(body.labels) ? body.labels : [], + description: body.description || '' + }; + this.setCache(cacheKey, result); + return result; + } + + // If the proxy returned an error or is unavailable, fall back to simulated check const riskScore = await this.simulateAddressRiskCheck(address); const result: AddressRiskScore = { @@ -573,15 +580,14 @@ export class BlockchainSecurityService { } } -// Server-side singleton (only use this on the server; the API key stays server-side) -export function createServerSecurityService(config?: SecurityServiceConfig): BlockchainSecurityService { - const effectiveConfig = config ?? { - baseUrl: process.env.CHAINALYSIS_API_URL || 'https://api.chainalysis.com/api/v2', - timeout: 10000, - apiKey: process.env.CHAINALYSIS_API_KEY || undefined, - }; - return BlockchainSecurityService.getInstance(effectiveConfig); -} +// Default configuration for development +const defaultConfig: SecurityServiceConfig = { + baseUrl: 'http://localhost:3000', + timeout: 10000, + // API key is now configured only on the server side via CHAINALYSIS_API_KEY env var. + // The browser never has access to this key. + apiKey: undefined +}; // Client-side proxy: calls our own API endpoint so the API key never reaches the browser. export async function checkAddressRiskViaProxy(address: string): Promise { diff --git a/src/utils/security/transactionSecurity.ts b/src/utils/security/transactionSecurity.ts index bb14a4b0..b3f5c7f1 100644 --- a/src/utils/security/transactionSecurity.ts +++ b/src/utils/security/transactionSecurity.ts @@ -95,7 +95,6 @@ function getSessionSalt(): string { } return salt; } -const sessionDeviceId = typeof crypto !== 'undefined' ? crypto.randomUUID() : 'server-device'; export function getSecurityDeviceId(): string { if (typeof window === 'undefined') {