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') {