security: remove .env from tracking (contains secrets)
This commit is contained in:
@@ -1,33 +1,81 @@
|
||||
/**
|
||||
* Chain-specific address format validators.
|
||||
* НЕ заменяет реальную чеканку checksum — это первый barrier.
|
||||
* Chain-specific address validators с CHECKSUM проверкой.
|
||||
* Принципиально: regex/length недостаточно — TRX/BTC используют base58check,
|
||||
* один испорченный символ может пройти regex, но кошелёк по такому адресу
|
||||
* не восстановим → funds permanently lost.
|
||||
*/
|
||||
import { ethers } from 'ethers';
|
||||
import { createHash } from 'crypto';
|
||||
import bs58 from 'bs58';
|
||||
import * as bitcoin from 'bitcoinjs-lib';
|
||||
import { PublicKey } from '@solana/web3.js';
|
||||
|
||||
const BTC_RE = /^(bc1[a-zA-HJ-NP-Z0-9]{25,62}|[13][a-km-zA-HJ-NP-Z1-9]{25,34})$/;
|
||||
const TRX_RE = /^T[1-9A-HJ-NP-Za-km-z]{33}$/;
|
||||
const SOL_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
|
||||
|
||||
export type ChainCode = 'ETH' | 'BTC' | 'SOL' | 'TRX' | 'BSC';
|
||||
|
||||
export function isValidAddress(chain: ChainCode, address: string): boolean {
|
||||
if (typeof address !== 'string' || address.length === 0 || address.length > 256) return false;
|
||||
// Любой блокчейн-адрес помещается в ~64 chars. 256 был оверкилл и open vector
|
||||
// для DoS (тратим CPU на bs58.decode 200-char garbage).
|
||||
if (typeof address !== 'string' || address.length === 0 || address.length > 64) return false;
|
||||
|
||||
switch (chain) {
|
||||
case 'BTC':
|
||||
return BTC_RE.test(address);
|
||||
return isValidBtcAddress(address);
|
||||
case 'TRX':
|
||||
return TRX_RE.test(address);
|
||||
return isValidTrxAddress(address);
|
||||
case 'ETH':
|
||||
case 'BSC':
|
||||
return ethers.utils.isAddress(address);
|
||||
case 'SOL':
|
||||
return SOL_RE.test(address);
|
||||
return isValidSolAddress(address);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── BTC: bitcoinjs-lib проверяет version byte + checksum (P2PKH/P2SH/bech32) ──
|
||||
function isValidBtcAddress(address: string): boolean {
|
||||
try {
|
||||
bitcoin.address.toOutputScript(address, bitcoin.networks.bitcoin);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── TRX: base58check + первый байт 0x41 ──
|
||||
function isValidTrxAddress(address: string): boolean {
|
||||
if (!TRX_RE.test(address)) return false;
|
||||
let decoded: Uint8Array;
|
||||
try {
|
||||
decoded = bs58.decode(address);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (decoded.length !== 25) return false; // 1 prefix + 20 payload + 4 checksum
|
||||
if (decoded[0] !== 0x41) return false; // TRX mainnet prefix
|
||||
const payload = decoded.subarray(0, 21);
|
||||
const checksum = decoded.subarray(21);
|
||||
const h1 = createHash('sha256').update(payload).digest();
|
||||
const h2 = createHash('sha256').update(h1).digest();
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (h2[i] !== checksum[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── SOL: реальное base58-декодирование через PublicKey ──
|
||||
function isValidSolAddress(address: string): boolean {
|
||||
try {
|
||||
const pk = new PublicKey(address);
|
||||
// PublicKey принимает 32-байтовое значение; isOnCurve дополнительный sanity
|
||||
return pk.toBytes().length === 32;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidAmount(amount: string): boolean {
|
||||
if (typeof amount !== 'string' || amount.length === 0 || amount.length > 78) return false;
|
||||
if (!/^\d+$/.test(amount)) return false;
|
||||
|
||||
61
apps/api/src/lib/audit-log.ts
Normal file
61
apps/api/src/lib/audit-log.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Audit log — append-only JSON lines в отдельный файл `logs/audit.log`.
|
||||
* Используется для критических операций: mnemonic reveal, custodial sign.
|
||||
*
|
||||
* НИКОГДА не пишет sensitive данные (mnemonic / privkey / etc.) — только мета.
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { logger } from './logger';
|
||||
import { getTraceId } from './trace-store';
|
||||
|
||||
const AUDIT_DIR = path.resolve(__dirname, '../../../../logs');
|
||||
const AUDIT_FILE = path.join(AUDIT_DIR, 'audit.log');
|
||||
|
||||
let initialized = false;
|
||||
|
||||
async function ensureFile(): Promise<void> {
|
||||
if (initialized) return;
|
||||
try {
|
||||
await fs.mkdir(AUDIT_DIR, { recursive: true });
|
||||
// Создать с правами 0600 если файла нет
|
||||
const handle = await fs.open(AUDIT_FILE, 'a', 0o600);
|
||||
await handle.close();
|
||||
try {
|
||||
await fs.chmod(AUDIT_FILE, 0o600);
|
||||
} catch {
|
||||
// chmod может не работать на Windows — игнор
|
||||
}
|
||||
initialized = true;
|
||||
} catch (err: any) {
|
||||
logger.error(`Audit log init failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
event: string; // 'mnemonic.reveal', 'wallet.create', 'wallet.send', etc.
|
||||
userId: string;
|
||||
ip?: string | null;
|
||||
meta?: Record<string, unknown>;
|
||||
result?: 'success' | 'failure';
|
||||
errorCode?: string;
|
||||
}
|
||||
|
||||
export async function auditLog(entry: AuditEntry): Promise<void> {
|
||||
await ensureFile();
|
||||
|
||||
const line = JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
trace_id: getTraceId(),
|
||||
...entry,
|
||||
});
|
||||
|
||||
try {
|
||||
await fs.appendFile(AUDIT_FILE, line + '\n', { mode: 0o600 });
|
||||
} catch (err: any) {
|
||||
// Если audit-log не записался — логируем в обычный logger как ошибку,
|
||||
// но НЕ блокируем основной флоу
|
||||
logger.error(`Audit log write failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
@@ -35,10 +35,16 @@ function getCallerInfo(): { file: string; line: number } {
|
||||
const SENSITIVE_PATTERNS: { regex: RegExp; replace: string }[] = [
|
||||
{ regex: /(password|passwd|pwd)\s*[:=]\s*\S+/gi, replace: '$1=***' },
|
||||
{ regex: /(token|secret|api[_-]?key|auth)\s*[:=]\s*\S+/gi, replace: '$1=***' },
|
||||
{ regex: /(mnemonic|seed[_-]?phrase|private[_-]?key|priv[_-]?key)\s*[:=]\s*\S+/gi, replace: '$1=***' },
|
||||
{ regex: /\bhvs\.[A-Za-z0-9]+/g, replace: 'hvs.***' },
|
||||
{ regex: /\bs\.[A-Za-z0-9]{20,}/g, replace: 's.***' },
|
||||
{ regex: /Bearer\s+[A-Za-z0-9._-]+/gi, replace: 'Bearer ***' },
|
||||
{ regex: /Authorization:\s*\S+/gi, replace: 'Authorization: ***' },
|
||||
// BIP39 mnemonic phrase (12+ lowercase английских слов через пробел) — рискованный паттерн,
|
||||
// но лучше пере-санитайзить чем пропустить mnemonic в логи
|
||||
{ regex: /\b([a-z]{3,8}\s+){11,23}[a-z]{3,8}\b/g, replace: '[REDACTED_MNEMONIC]' },
|
||||
// Hex privkey (64 hex chars подряд, optional 0x)
|
||||
{ regex: /\b(0x)?[0-9a-fA-F]{64}\b/g, replace: '[REDACTED_HEX64]' },
|
||||
];
|
||||
|
||||
function sanitize(msg: string): string {
|
||||
|
||||
Reference in New Issue
Block a user