diff --git a/package.json b/package.json index 644b53ae..d169c475 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "lint": "eslint . --max-warnings=0", "perf:budgets": "node scripts/check-performance-budgets.mjs", "perf:ci": "npm run build && npm run perf:budgets", + "security:check-globals": "node scripts/check-exposed-globals.mjs", "validate:env": "node scripts/validate-env.js", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", 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 new file mode 100644 index 00000000..e934afe4 --- /dev/null +++ b/src/app/api/security/address-check/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const address = searchParams.get('address')?.trim(); + + if (!address) { + return NextResponse.json({ error: 'Address parameter required' }, { status: 400 }); + } + + if (!/^0x[a-fA-F0-9]{40}$/.test(address)) { + return NextResponse.json({ error: 'Invalid Ethereum address' }, { status: 400 }); + } + + const apiKey = process.env.CHAINALYSIS_API_KEY; + + if (!apiKey) { + return NextResponse.json({ + address, + risk_score: 50, + categories: ['unknown'], + description: 'Risk check unavailable (service not configured)', + }); + } + + try { + 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) { + 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: error instanceof Error ? error.message : 'Unknown error' }, + { status: 502 } + ); + } +} diff --git a/src/components/ui/chart.tsx b/src/components/ui/chart.tsx index e04abd79..20b00868 100644 --- a/src/components/ui/chart.tsx +++ b/src/components/ui/chart.tsx @@ -78,28 +78,27 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { 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/__tests__/transactionSecurity.test.ts b/src/utils/security/__tests__/transactionSecurity.test.ts index da3014bb..ec162264 100644 --- a/src/utils/security/__tests__/transactionSecurity.test.ts +++ b/src/utils/security/__tests__/transactionSecurity.test.ts @@ -1,6 +1,10 @@ -import { decideStepUpSecurity, ethToWei } from '../transactionSecurity'; +import { decideStepUpSecurity, ethToWei, getSecurityDeviceId } from '../transactionSecurity'; describe('transactionSecurity helpers', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('requires step-up for values at or above the configured threshold', () => { const decision = decideStepUpSecurity({ valueWei: ethToWei(3), @@ -39,5 +43,82 @@ describe('transactionSecurity helpers', () => { expect(decision.requiresStepUp).toBe(false); expect(decision.reason).toBeNull(); }); + + describe('getSecurityDeviceId', () => { + let mockLocalStorage: Record; + let mockSessionStorage: Record; + + beforeEach(() => { + mockLocalStorage = {}; + mockSessionStorage = {}; + jest.spyOn(globalThis, 'crypto', 'get').mockReturnValue({ + randomUUID: () => '550e8400-e29b-41d4-a716-446655440000', + subtle: {} as SubtleCrypto, + getRandomValues: (arr: Uint8Array) => arr, + } as unknown as Crypto); + jest.spyOn(globalThis, 'localStorage', 'get').mockReturnValue({ + getItem: (key: string) => mockLocalStorage[key] ?? null, + setItem: (key: string, value: string) => { mockLocalStorage[key] = value; }, + removeItem: (key: string) => { delete mockLocalStorage[key]; }, + clear: () => { mockLocalStorage = {}; }, + get length() { return Object.keys(mockLocalStorage).length; }, + key: (index: number) => Object.keys(mockLocalStorage)[index] ?? null, + } as Storage); + jest.spyOn(globalThis, 'sessionStorage', 'get').mockReturnValue({ + getItem: (key: string) => mockSessionStorage[key] ?? null, + setItem: (key: string, value: string) => { mockSessionStorage[key] = value; }, + removeItem: (key: string) => { delete mockSessionStorage[key]; }, + clear: () => { mockSessionStorage = {}; }, + get length() { return Object.keys(mockSessionStorage).length; }, + key: (index: number) => Object.keys(mockSessionStorage)[index] ?? null, + } as Storage); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns "server-device" when window is undefined', () => { + const windowSpy = jest.spyOn(globalThis, 'window', 'get').mockReturnValue(undefined as any); + expect(getSecurityDeviceId()).toBe('server-device'); + windowSpy.mockRestore(); + }); + + it('generates and stores a salted hash on first call', () => { + const result = getSecurityDeviceId(); + + expect(result).toBe('550e8400-e29b-41d4-a716-446655440000'); + expect(mockSessionStorage['propchain-session-salt']).toBeDefined(); + expect(mockLocalStorage['propchain-security-device-id-hash']).toBeDefined(); + expect(mockLocalStorage['propchain-security-device-id-hash']).not.toBe(result); + }); + + it('reuses existing hash from localStorage on subsequent calls', () => { + const existingHash = 'a1b2c3d4'; + mockLocalStorage['propchain-security-device-id-hash'] = existingHash; + + const result = getSecurityDeviceId(); + + expect(result).toBe(existingHash); + expect(mockSessionStorage['propchain-session-salt']).toBeUndefined(); + }); + + it('reuses session salt across calls within the same session', () => { + const sessionSalt = 'test-session-salt'; + mockSessionStorage['propchain-session-salt'] = sessionSalt; + + getSecurityDeviceId(); + + expect(mockSessionStorage['propchain-session-salt']).toBe(sessionSalt); + }); + + it('stores a hashed value, not the raw UUID', () => { + getSecurityDeviceId(); + + const storedValue = mockLocalStorage['propchain-security-device-id-hash']; + expect(storedValue).not.toBe('550e8400-e29b-41d4-a716-446655440000'); + expect(storedValue).toMatch(/^[0-9a-f]{8,}$/); + }); + }); }); diff --git a/src/utils/security/blockchainSecurity.ts b/src/utils/security/blockchainSecurity.ts index f022d259..a1cb7490 100644 --- a/src/utils/security/blockchainSecurity.ts +++ b/src/utils/security/blockchainSecurity.ts @@ -74,35 +74,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 as any)(`${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 = { @@ -494,10 +501,11 @@ export class BlockchainSecurityService { // Default configuration for development const defaultConfig: SecurityServiceConfig = { - baseUrl: 'https://api.chainalysis.com/api/v2', + baseUrl: 'http://localhost:3000', timeout: 10000, - // In production, this would be set via environment variables - apiKey: typeof window !== 'undefined' ? (window as any).__CHAINALYSIS_API_KEY__ : undefined + // 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 }; // Export singleton instance diff --git a/src/utils/security/transactionSecurity.ts b/src/utils/security/transactionSecurity.ts index c5ce3d55..b18d1205 100644 --- a/src/utils/security/transactionSecurity.ts +++ b/src/utils/security/transactionSecurity.ts @@ -75,17 +75,40 @@ export function createTrustedDeviceId(): string { return `trusted_${crypto.randomUUID()}`; } +function simpleHash(input: string): string { + let hash = 0; + for (let i = 0; i < input.length; i++) { + const char = input.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash |= 0; + } + return Math.abs(hash).toString(16).padStart(8, '0'); +} + +const SESSION_SALT_KEY = 'propchain-session-salt'; + +function getSessionSalt(): string { + let salt = window.sessionStorage.getItem(SESSION_SALT_KEY); + if (!salt) { + salt = crypto.randomUUID(); + window.sessionStorage.setItem(SESSION_SALT_KEY, salt); + } + return salt; +} + export function getSecurityDeviceId(): string { if (typeof window === 'undefined') { return 'server-device'; } - const key = 'propchain-security-device-id'; + const key = 'propchain-security-device-id-hash'; const existing = window.localStorage.getItem(key); if (existing) return existing; const deviceId = crypto.randomUUID(); - window.localStorage.setItem(key, deviceId); + const salt = getSessionSalt(); + const hashedId = simpleHash(`${deviceId}:${salt}`); + window.localStorage.setItem(key, hashedId); return deviceId; }