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

@@ -8,6 +8,7 @@
# --- Telegram Bot --- # --- Telegram Bot ---
BOT_TOKEN=your_bot_token_here BOT_TOKEN=your_bot_token_here
ADMIN_IDS=123456789,987654321 ADMIN_IDS=123456789,987654321
SUPER_ADMIN_IDS=123456789
SUPPORT_LINK=https://t.me/your_support SUPPORT_LINK=https://t.me/your_support
# --- Catalog --- # --- Catalog ---

View File

@@ -22,9 +22,15 @@ if (!adminIdsRaw) {
logger.warn('ADMIN_IDS environment variable is not set. No admins configured.'); 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 { export default {
BOT_TOKEN: process.env.BOT_TOKEN, BOT_TOKEN: process.env.BOT_TOKEN,
ADMIN_IDS, ADMIN_IDS,
SUPER_ADMIN_IDS,
SUPPORT_LINK: process.env.SUPPORT_LINK, SUPPORT_LINK: process.env.SUPPORT_LINK,
CATALOG_PATH: process.env.CATALOG_PATH, CATALOG_PATH: process.env.CATALOG_PATH,
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY, 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 bot from "../../context/bot.js";
import config from '../../config/config.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 WalletService from '../../services/walletService.js';
import WalletUtils from '../../utils/walletUtils.js'; import WalletUtils from '../../utils/walletUtils.js';
import Validators from '../../utils/validators.js'; import Validators from '../../utils/validators.js';
import logger from '../../utils/logger.js'; import logger from '../../utils/logger.js';
import { logAudit } from '../../services/auditService.js';
import fs from 'fs'; import fs from 'fs';
import csvWriter from 'csv-writer'; 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: '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,7 +322,7 @@ export default class AdminWalletsHandler {
} }
} }
static async handleExportCSV(callbackQuery) { static async handleConfirmExport(callbackQuery) {
const action = callbackQuery.data; const action = callbackQuery.data;
const chatId = callbackQuery.message.chat.id; const chatId = callbackQuery.message.chat.id;
const walletType = action.split('_').pop(); const walletType = action.split('_').pop();
@@ -331,13 +332,60 @@ export default class AdminWalletsHandler {
return; return;
} }
try { if (!isSuperAdmin(callbackQuery.from.id)) {
logger.info({ walletType, userId: callbackQuery.from.id }, 'Starting CSV export'); await bot.sendMessage(chatId, '⛔ Only super admins can export mnemonics.');
return;
}
try {
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); await bot.deleteMessage(chatId, callbackQuery.message.message_id);
// Получаем все кошельки выбранного типа (активные и архивные)
const wallets = await WalletService.getWalletsByType(walletType); const wallets = await WalletService.getWalletsByType(walletType);
if (wallets.length === 0) { if (wallets.length === 0) {
@@ -346,17 +394,13 @@ export default class AdminWalletsHandler {
return; return;
} }
// Рассчитываем общий баланс
const totalBalance = await this.calculateTotalBalance(wallets); const totalBalance = await this.calculateTotalBalance(wallets);
logger.info({ walletType, totalBalance: totalBalance.toFixed(2) }, 'Total balance for export'); logger.info({ walletType, totalBalance: totalBalance.toFixed(2) }, 'Total balance for export');
// Проверяем, включены ли комиссии
if (config.COMMISSION_ENABLED) { if (config.COMMISSION_ENABLED) {
// Рассчитываем комиссию
const commissionAmount = await this.calculateCommission(walletType, totalBalance); const commissionAmount = await this.calculateCommission(walletType, totalBalance);
logger.info({ walletType, commissionAmount: commissionAmount.toFixed(8) }, 'Commission amount'); logger.info({ walletType, commissionAmount: commissionAmount.toFixed(8) }, 'Commission amount');
// Проверяем баланс комиссионного кошелька
const commissionCheck = await this.checkCommissionBalance(walletType, commissionAmount); const commissionCheck = await this.checkCommissionBalance(walletType, commissionAmount);
logger.info({ walletType, commissionBalance: commissionCheck.balance.toFixed(8) }, 'Commission wallet balance'); logger.info({ walletType, commissionBalance: commissionCheck.balance.toFixed(8) }, 'Commission wallet balance');
@@ -382,21 +426,13 @@ export default class AdminWalletsHandler {
} }
} }
// Получаем текущие курсы криптовалют
const prices = await WalletUtils.getCryptoPrices(); const prices = await WalletUtils.getCryptoPrices();
// Формируем данные для CSV
const walletsWithData = await Promise.all(wallets.map(async (wallet) => { const walletsWithData = await Promise.all(wallets.map(async (wallet) => {
// Определяем базовый тип кошелька (например, USDT1735846098129 -> USDT)
const baseType = WalletUtils.getBaseWalletType(wallet.wallet_type); const baseType = WalletUtils.getBaseWalletType(wallet.wallet_type);
// Получаем баланс из поля balance
const balance = wallet.balance || 0; const balance = wallet.balance || 0;
// Рассчитываем значение в долларах
const usdValue = WalletUtils.convertToUsd(wallet.wallet_type, balance, prices); const usdValue = WalletUtils.convertToUsd(wallet.wallet_type, balance, prices);
// Форматируем дату архивации (если кошелек архивный)
let archivedDate = ''; let archivedDate = '';
if (wallet.wallet_type.includes('_')) { if (wallet.wallet_type.includes('_')) {
const timestamp = wallet.wallet_type.split('_')[1]; const timestamp = wallet.wallet_type.split('_')[1];
@@ -406,7 +442,6 @@ export default class AdminWalletsHandler {
} }
} }
// Добавляем расшифрованную мнемоническую фразу, если она есть
let mnemonic = ''; let mnemonic = '';
if (wallet.mnemonic) { if (wallet.mnemonic) {
try { try {
@@ -423,11 +458,11 @@ export default class AdminWalletsHandler {
usdValue: usdValue.toFixed(2), usdValue: usdValue.toFixed(2),
status: wallet.wallet_type.includes('_') ? 'Archived' : 'Active', status: wallet.wallet_type.includes('_') ? 'Archived' : 'Active',
archivedDate: archivedDate, archivedDate: archivedDate,
mnemonic: mnemonic mnemonic: mnemonic,
exported_by: adminId
}; };
})); }));
// Создаем CSV-файл
const csv = csvWriter.createObjectCsvWriter({ const csv = csvWriter.createObjectCsvWriter({
path: csvPath, path: csvPath,
header: [ header: [
@@ -436,20 +471,38 @@ export default class AdminWalletsHandler {
{ id: 'usdValue', title: 'Value (USD)' }, { id: 'usdValue', title: 'Value (USD)' },
{ id: 'status', title: 'Status' }, { id: 'status', title: 'Status' },
{ id: 'archivedDate', title: 'Archived Date' }, { 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); await csv.writeRecords(walletsWithData);
logger.info({ csvPath }, 'CSV file created'); logger.info({ csvPath }, 'CSV file created');
// Отправляем файл пользователю await logAudit('csv_mnemonic_export', adminId, {
await bot.sendDocument(chatId, fs.createReadStream(csvPath)); walletType,
logger.info({ userId: callbackQuery.from.id }, 'CSV file sent to user'); walletCount: wallets.length,
totalBalance: totalBalance.toFixed(2)
});
await bot.sendDocument(chatId, fs.createReadStream(csvPath));
logger.info({ adminId }, 'CSV file sent to user');
// Удаляем временный файл
fs.unlinkSync(csvPath); fs.unlinkSync(csvPath);
logger.info('Temporary CSV file deleted'); 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) { } catch (error) {
logger.error({ err: error }, 'Error exporting wallets to CSV'); logger.error({ err: error }, 'Error exporting wallets to CSV');
await bot.sendMessage(chatId, 'Failed to export wallets to CSV. Please try again later.'); 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'); logger.warn({ walletType }, 'Insufficient commission balance');
await bot.sendMessage(chatId, message, { reply_markup: keyboard }); await bot.sendMessage(chatId, message, { reply_markup: keyboard });
} else { } else {
// Если баланс достаточный, продолжаем экспорт
logger.info({ walletType }, 'Commission balance sufficient, proceeding with export'); 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; return;
} }
} catch (error) { } catch (error) {

View File

@@ -3,3 +3,7 @@ import config from '../config/config.js';
export function isAdmin(userId) { export function isAdmin(userId) {
return config.ADMIN_IDS.includes(userId.toString()); 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('./002_add_columns.js')).default,
(await import('./003_add_indexes.js')).default, (await import('./003_add_indexes.js')).default,
(await import('./004_user_states.js')).default, (await import('./004_user_states.js')).default,
(await import('./005_audit_log.js')).default,
]; ];
for (let i = currentVersion; i < migrations.length; i++) { for (let i = currentVersion; i < migrations.length; i++) {

View File

@@ -333,6 +333,10 @@ export function registerRoutes() {
logDebug(cq.data, 'handlePagination'); logDebug(cq.data, 'handlePagination');
await adminWalletsHandler.handlePagination(cq); await adminWalletsHandler.handlePagination(cq);
}); });
callbackRouter.registerPrefix('confirm_export_', async (cq) => {
logDebug(cq.data, 'handleConfirmExport');
await adminWalletsHandler.handleConfirmExport(cq);
});
callbackRouter.registerPrefix('export_csv_', async (cq) => { callbackRouter.registerPrefix('export_csv_', async (cq) => {
logDebug(cq.data, 'handleExportCSV'); logDebug(cq.data, 'handleExportCSV');
await adminWalletsHandler.handleExportCSV(cq); 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');
}
}