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:
NW
2026-06-22 10:07:58 +01:00
parent a04e60d751
commit 49945d9d81
8 changed files with 155 additions and 40 deletions

View File

@@ -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,

View File

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

View File

@@ -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());
}

View 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');
}

View File

@@ -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++) {

View File

@@ -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);

View 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');
}
}