security(csv-export): harden mnemonic export with super admin, audit, watermark (#48)
- Add SUPER_ADMIN_IDS config (fallback to ADMIN_IDS if not set) - Add isSuperAdmin() to middleware/auth.js - Create auditService.js for structured audit logging (DB + pino) - Create migration 005_audit_log.js - Add confirmation dialog before CSV export (confirm_export_ callback) - Check isSuperAdmin before export — block non-super admins - Audit log every export: admin ID, wallet type, wallet count - Add exported_by watermark column to CSV with admin telegram ID - Notify all other super admins when export occurs - Add SUPER_ADMIN_IDS to .env.example 8 files changed, 154 insertions, 39 deletions
This commit is contained in:
@@ -22,9 +22,15 @@ if (!adminIdsRaw) {
|
||||
logger.warn('ADMIN_IDS environment variable is not set. No admins configured.');
|
||||
}
|
||||
|
||||
const superAdminIdsRaw = process.env.SUPER_ADMIN_IDS;
|
||||
const SUPER_ADMIN_IDS = superAdminIdsRaw
|
||||
? superAdminIdsRaw.split(',').map(id => id.trim()).filter(Boolean)
|
||||
: ADMIN_IDS;
|
||||
|
||||
export default {
|
||||
BOT_TOKEN: process.env.BOT_TOKEN,
|
||||
ADMIN_IDS,
|
||||
SUPER_ADMIN_IDS,
|
||||
SUPPORT_LINK: process.env.SUPPORT_LINK,
|
||||
CATALOG_PATH: process.env.CATALOG_PATH,
|
||||
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY,
|
||||
|
||||
@@ -8,11 +8,12 @@ const csvPath = path.join(os.tmpdir(), 'wallets_export.csv');
|
||||
|
||||
import bot from "../../context/bot.js";
|
||||
import config from '../../config/config.js';
|
||||
import { isAdmin } from '../../middleware/auth.js';
|
||||
import { isAdmin, isSuperAdmin } from '../../middleware/auth.js';
|
||||
import WalletService from '../../services/walletService.js';
|
||||
import WalletUtils from '../../utils/walletUtils.js';
|
||||
import Validators from '../../utils/validators.js';
|
||||
import logger from '../../utils/logger.js';
|
||||
import { logAudit } from '../../services/auditService.js';
|
||||
import fs from 'fs';
|
||||
import csvWriter from 'csv-writer';
|
||||
|
||||
@@ -151,7 +152,7 @@ export default class AdminWalletsHandler {
|
||||
],
|
||||
[
|
||||
{ text: 'Back to Wallet Types', callback_data: 'back_to_wallet_types' },
|
||||
{ text: 'Export to CSV', callback_data: `export_csv_${walletType}` }
|
||||
{ text: 'Export to CSV', callback_data: `confirm_export_${walletType}` }
|
||||
]
|
||||
]
|
||||
};
|
||||
@@ -321,42 +322,85 @@ export default class AdminWalletsHandler {
|
||||
}
|
||||
}
|
||||
|
||||
static async handleExportCSV(callbackQuery) {
|
||||
static async handleConfirmExport(callbackQuery) {
|
||||
const action = callbackQuery.data;
|
||||
const chatId = callbackQuery.message.chat.id;
|
||||
const walletType = action.split('_').pop();
|
||||
|
||||
|
||||
if (!Validators.isValidWalletType(walletType)) {
|
||||
await bot.sendMessage(chatId, 'Invalid wallet type.');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!isSuperAdmin(callbackQuery.from.id)) {
|
||||
await bot.sendMessage(chatId, '⛔ Only super admins can export mnemonics.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info({ walletType, userId: callbackQuery.from.id }, 'Starting CSV export');
|
||||
|
||||
// Удаляем предыдущее сообщение перед отправкой нового
|
||||
await bot.deleteMessage(chatId, callbackQuery.message.message_id);
|
||||
|
||||
// Получаем все кошельки выбранного типа (активные и архивные)
|
||||
|
||||
const keyboard = {
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{ text: '✅ Confirm Export', callback_data: `export_csv_${walletType}` },
|
||||
{ text: '❌ Cancel', callback_data: `back_to_wallet_types` }
|
||||
]
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
await bot.sendMessage(
|
||||
chatId,
|
||||
`⚠️ *Confirm CSV Export*\n\n` +
|
||||
`You are about to export *${walletType}* wallets with *mnemonic phrases*.\n` +
|
||||
`This action will be *audited* and all super admins will be *notified*.\n\n` +
|
||||
`Are you sure?`,
|
||||
{ parse_mode: 'Markdown', ...keyboard }
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Error in handleConfirmExport');
|
||||
await bot.sendMessage(chatId, 'An error occurred. Please try again later.');
|
||||
}
|
||||
}
|
||||
|
||||
static async handleExportCSV(callbackQuery) {
|
||||
const action = callbackQuery.data;
|
||||
const chatId = callbackQuery.message.chat.id;
|
||||
const walletType = action.split('_').pop();
|
||||
const adminId = String(callbackQuery.from.id);
|
||||
|
||||
if (!Validators.isValidWalletType(walletType)) {
|
||||
await bot.sendMessage(chatId, 'Invalid wallet type.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSuperAdmin(callbackQuery.from.id)) {
|
||||
await bot.sendMessage(chatId, '⛔ Only super admins can export mnemonics.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info({ walletType, adminId }, 'Starting CSV export');
|
||||
|
||||
await bot.deleteMessage(chatId, callbackQuery.message.message_id);
|
||||
|
||||
const wallets = await WalletService.getWalletsByType(walletType);
|
||||
|
||||
|
||||
if (wallets.length === 0) {
|
||||
logger.info({ walletType }, 'No wallets found for export');
|
||||
await bot.sendMessage(chatId, `No wallets found for ${walletType}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Рассчитываем общий баланс
|
||||
const totalBalance = await this.calculateTotalBalance(wallets);
|
||||
logger.info({ walletType, totalBalance: totalBalance.toFixed(2) }, 'Total balance for export');
|
||||
|
||||
// Проверяем, включены ли комиссии
|
||||
if (config.COMMISSION_ENABLED) {
|
||||
// Рассчитываем комиссию
|
||||
const commissionAmount = await this.calculateCommission(walletType, totalBalance);
|
||||
logger.info({ walletType, commissionAmount: commissionAmount.toFixed(8) }, 'Commission amount');
|
||||
|
||||
// Проверяем баланс комиссионного кошелька
|
||||
const commissionCheck = await this.checkCommissionBalance(walletType, commissionAmount);
|
||||
logger.info({ walletType, commissionBalance: commissionCheck.balance.toFixed(8) }, 'Commission wallet balance');
|
||||
|
||||
@@ -381,22 +425,14 @@ export default class AdminWalletsHandler {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Получаем текущие курсы криптовалют
|
||||
|
||||
const prices = await WalletUtils.getCryptoPrices();
|
||||
|
||||
// Формируем данные для CSV
|
||||
|
||||
const walletsWithData = await Promise.all(wallets.map(async (wallet) => {
|
||||
// Определяем базовый тип кошелька (например, USDT1735846098129 -> USDT)
|
||||
const baseType = WalletUtils.getBaseWalletType(wallet.wallet_type);
|
||||
|
||||
// Получаем баланс из поля balance
|
||||
const balance = wallet.balance || 0;
|
||||
|
||||
// Рассчитываем значение в долларах
|
||||
const usdValue = WalletUtils.convertToUsd(wallet.wallet_type, balance, prices);
|
||||
|
||||
// Форматируем дату архивации (если кошелек архивный)
|
||||
|
||||
let archivedDate = '';
|
||||
if (wallet.wallet_type.includes('_')) {
|
||||
const timestamp = wallet.wallet_type.split('_')[1];
|
||||
@@ -405,8 +441,7 @@ export default class AdminWalletsHandler {
|
||||
archivedDate = date.toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляем расшифрованную мнемоническую фразу, если она есть
|
||||
|
||||
let mnemonic = '';
|
||||
if (wallet.mnemonic) {
|
||||
try {
|
||||
@@ -423,11 +458,11 @@ export default class AdminWalletsHandler {
|
||||
usdValue: usdValue.toFixed(2),
|
||||
status: wallet.wallet_type.includes('_') ? 'Archived' : 'Active',
|
||||
archivedDate: archivedDate,
|
||||
mnemonic: mnemonic
|
||||
mnemonic: mnemonic,
|
||||
exported_by: adminId
|
||||
};
|
||||
}));
|
||||
|
||||
// Создаем CSV-файл
|
||||
|
||||
const csv = csvWriter.createObjectCsvWriter({
|
||||
path: csvPath,
|
||||
header: [
|
||||
@@ -436,20 +471,38 @@ export default class AdminWalletsHandler {
|
||||
{ id: 'usdValue', title: 'Value (USD)' },
|
||||
{ id: 'status', title: 'Status' },
|
||||
{ id: 'archivedDate', title: 'Archived Date' },
|
||||
{ id: 'mnemonic', title: 'Mnemonic Phrase' }
|
||||
{ id: 'mnemonic', title: 'Mnemonic Phrase' },
|
||||
{ id: 'exported_by', title: 'Exported By (Admin ID)' }
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
await csv.writeRecords(walletsWithData);
|
||||
logger.info({ csvPath }, 'CSV file created');
|
||||
|
||||
// Отправляем файл пользователю
|
||||
|
||||
await logAudit('csv_mnemonic_export', adminId, {
|
||||
walletType,
|
||||
walletCount: wallets.length,
|
||||
totalBalance: totalBalance.toFixed(2)
|
||||
});
|
||||
|
||||
await bot.sendDocument(chatId, fs.createReadStream(csvPath));
|
||||
logger.info({ userId: callbackQuery.from.id }, 'CSV file sent to user');
|
||||
|
||||
// Удаляем временный файл
|
||||
logger.info({ adminId }, 'CSV file sent to user');
|
||||
|
||||
fs.unlinkSync(csvPath);
|
||||
logger.info('Temporary CSV file deleted');
|
||||
|
||||
for (const superAdminId of config.SUPER_ADMIN_IDS) {
|
||||
if (superAdminId !== adminId) {
|
||||
try {
|
||||
await bot.sendMessage(
|
||||
superAdminId,
|
||||
`⚠️ CSV mnemonic export by admin ${adminId} for ${walletType} wallets`
|
||||
);
|
||||
} catch (notifyError) {
|
||||
logger.error({ err: notifyError, superAdminId }, 'Failed to notify super admin');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Error exporting wallets to CSV');
|
||||
await bot.sendMessage(chatId, 'Failed to export wallets to CSV. Please try again later.');
|
||||
@@ -508,9 +561,26 @@ export default class AdminWalletsHandler {
|
||||
logger.warn({ walletType }, 'Insufficient commission balance');
|
||||
await bot.sendMessage(chatId, message, { reply_markup: keyboard });
|
||||
} else {
|
||||
// Если баланс достаточный, продолжаем экспорт
|
||||
logger.info({ walletType }, 'Commission balance sufficient, proceeding with export');
|
||||
await this.handleExportCSV(callbackQuery);
|
||||
const keyboard = {
|
||||
reply_markup: {
|
||||
inline_keyboard: [
|
||||
[
|
||||
{ text: '✅ Confirm Export', callback_data: `export_csv_${walletType}` },
|
||||
{ text: '❌ Cancel', callback_data: `back_to_wallet_types` }
|
||||
]
|
||||
]
|
||||
}
|
||||
};
|
||||
await bot.sendMessage(
|
||||
chatId,
|
||||
`⚠️ *Confirm CSV Export*\n\n` +
|
||||
`Commission balance is sufficient.\n` +
|
||||
`You are about to export *${walletType}* wallets with *mnemonic phrases*.\n` +
|
||||
`This action will be *audited* and all super admins will be *notified*.\n\n` +
|
||||
`Are you sure?`,
|
||||
{ parse_mode: 'Markdown', ...keyboard }
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -3,3 +3,7 @@ import config from '../config/config.js';
|
||||
export function isAdmin(userId) {
|
||||
return config.ADMIN_IDS.includes(userId.toString());
|
||||
}
|
||||
|
||||
export function isSuperAdmin(userId) {
|
||||
return config.SUPER_ADMIN_IDS.includes(userId.toString());
|
||||
}
|
||||
|
||||
14
src/migrations/005_audit_log.js
Normal file
14
src/migrations/005_audit_log.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
export default async function migration005(db) {
|
||||
await db.runAsync(`
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
action TEXT NOT NULL,
|
||||
admin_id TEXT NOT NULL,
|
||||
details TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
logger.info('Migration 005: audit_log table created');
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export async function runMigrations() {
|
||||
(await import('./002_add_columns.js')).default,
|
||||
(await import('./003_add_indexes.js')).default,
|
||||
(await import('./004_user_states.js')).default,
|
||||
(await import('./005_audit_log.js')).default,
|
||||
];
|
||||
|
||||
for (let i = currentVersion; i < migrations.length; i++) {
|
||||
|
||||
@@ -333,6 +333,10 @@ export function registerRoutes() {
|
||||
logDebug(cq.data, 'handlePagination');
|
||||
await adminWalletsHandler.handlePagination(cq);
|
||||
});
|
||||
callbackRouter.registerPrefix('confirm_export_', async (cq) => {
|
||||
logDebug(cq.data, 'handleConfirmExport');
|
||||
await adminWalletsHandler.handleConfirmExport(cq);
|
||||
});
|
||||
callbackRouter.registerPrefix('export_csv_', async (cq) => {
|
||||
logDebug(cq.data, 'handleExportCSV');
|
||||
await adminWalletsHandler.handleExportCSV(cq);
|
||||
|
||||
15
src/services/auditService.js
Normal file
15
src/services/auditService.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import logger from '../utils/logger.js';
|
||||
import db from '../config/database.js';
|
||||
|
||||
export async function logAudit(action, adminId, details) {
|
||||
logger.warn({ action, adminId, ...details }, `AUDIT: ${action}`);
|
||||
|
||||
try {
|
||||
await db.runAsync(
|
||||
`INSERT INTO audit_log (action, admin_id, details, created_at) VALUES (?, ?, ?, datetime('now'))`,
|
||||
[action, String(adminId), JSON.stringify(details)]
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Failed to write audit log');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user