From c74b131ac63841ccacd0c1729e8c76ce74759d68 Mon Sep 17 00:00:00 2001 From: LightZirconite Date: Tue, 4 Nov 2025 20:34:34 +0100 Subject: [PATCH] refactor: remove BanPredictor, ConfigValidator, RiskManager, SecurityDetector, and related utility files - Deleted BanPredictor.ts, ConfigValidator.ts, RiskManager.ts, SecurityDetector.ts, and their associated interfaces and methods. - Removed all related logic for ban prediction, configuration validation, risk management, and security detection. - Added a new utility function to retrieve error messages from unknown error types. --- src/dashboard/BotController.ts | 37 ++- src/dashboard/routes.ts | 112 ++++---- src/index.ts | 112 +------- src/util/BanPredictor.ts | 394 -------------------------- src/util/ConfigValidator.ts | 490 --------------------------------- src/util/RiskManager.ts | 177 ------------ src/util/SecurityDetector.ts | 210 -------------- src/util/Utils.ts | 6 +- 8 files changed, 76 insertions(+), 1462 deletions(-) delete mode 100644 src/util/BanPredictor.ts delete mode 100644 src/util/ConfigValidator.ts delete mode 100644 src/util/RiskManager.ts delete mode 100644 src/util/SecurityDetector.ts diff --git a/src/dashboard/BotController.ts b/src/dashboard/BotController.ts index 683c77e..fdf16a5 100644 --- a/src/dashboard/BotController.ts +++ b/src/dashboard/BotController.ts @@ -1,9 +1,9 @@ import { dashboardState } from './state' import type { MicrosoftRewardsBot } from '../index' +import { getErrorMessage } from '../util/Utils' export class BotController { private botInstance: MicrosoftRewardsBot | null = null - private botPromise: Promise | null = null private startTime?: Date constructor() { @@ -37,7 +37,8 @@ export class BotController { dashboardState.setRunning(true) dashboardState.setBotInstance(this.botInstance) - this.botPromise = (async () => { + // Run bot asynchronously - don't block the API response + void (async () => { try { this.log('✓ Bot initialized, starting execution...', 'log') @@ -46,22 +47,16 @@ export class BotController { this.log('✓ Bot completed successfully', 'log') } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) - this.log(`Bot error: ${errorMsg}`, 'error') - throw error + this.log(`Bot error: ${getErrorMessage(error)}`, 'error') } finally { this.cleanup() } })() - this.botPromise.catch(error => { - this.log(`Bot execution failed: ${error instanceof Error ? error.message : String(error)}`, 'error') - }) - return { success: true, pid: process.pid } } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) + const errorMsg = getErrorMessage(error) this.log(`Failed to start bot: ${errorMsg}`, 'error') this.cleanup() return { success: false, error: errorMsg } @@ -81,7 +76,7 @@ export class BotController { return { success: true } } catch (error) { - const errorMsg = error instanceof Error ? error.message : String(error) + const errorMsg = getErrorMessage(error) this.log(`Error stopping bot: ${errorMsg}`, 'error') this.cleanup() return { success: false, error: errorMsg } @@ -90,14 +85,19 @@ export class BotController { public async restart(): Promise<{ success: boolean; error?: string; pid?: number }> { this.log('🔄 Restarting bot...', 'log') - this.stop() - return new Promise((resolve) => { - setTimeout(async () => { - const result = await this.start() - resolve(result) - }, 2000) - }) + const stopResult = this.stop() + if (!stopResult.success && stopResult.error !== 'Bot is not running') { + return { success: false, error: `Failed to stop: ${stopResult.error}` } + } + + await this.wait(2000) + + return await this.start() + } + + private async wait(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) } public getStatus(): { @@ -116,7 +116,6 @@ export class BotController { private cleanup(): void { this.botInstance = null - this.botPromise = null this.startTime = undefined dashboardState.setRunning(false) dashboardState.setBotInstance(undefined) diff --git a/src/dashboard/routes.ts b/src/dashboard/routes.ts index 16d6361..15ad03e 100644 --- a/src/dashboard/routes.ts +++ b/src/dashboard/routes.ts @@ -7,46 +7,39 @@ import { botController } from './BotController' export const apiRouter = Router() +// Helper to extract error message +const getErr = (e: unknown): string => e instanceof Error ? e.message : 'Unknown error' + +// Helper to load accounts if not already loaded +function ensureAccountsLoaded(): void { + const accounts = dashboardState.getAccounts() + if (accounts.length === 0) { + try { + const loadedAccounts = loadAccounts() + dashboardState.initializeAccounts(loadedAccounts.map(a => a.email)) + } catch (error) { + console.error('[Dashboard] Failed to load accounts:', error) + } + } +} + // GET /api/status - Bot status apiRouter.get('/status', (_req: Request, res: Response) => { try { - const accounts = dashboardState.getAccounts() - - // If no accounts loaded yet, try to load them - if (accounts.length === 0) { - try { - const loadedAccounts = loadAccounts() - dashboardState.initializeAccounts(loadedAccounts.map(a => a.email)) - } catch (error) { - console.error('[Dashboard] Failed to load accounts for status:', error) - } - } - + ensureAccountsLoaded() res.json(dashboardState.getStatus()) } catch (error) { - res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' }) + res.status(500).json({ error: getErr(error) }) } }) // GET /api/accounts - List all accounts with masked emails apiRouter.get('/accounts', (_req: Request, res: Response) => { try { - let accounts = dashboardState.getAccounts() - - // If no accounts in state, try to load from config - if (accounts.length === 0) { - try { - const loadedAccounts = loadAccounts() - dashboardState.initializeAccounts(loadedAccounts.map(a => a.email)) - accounts = dashboardState.getAccounts() - } catch (error) { - console.error('[Dashboard] Failed to load accounts:', error) - } - } - - res.json(accounts) + ensureAccountsLoaded() + res.json(dashboardState.getAccounts()) } catch (error) { - res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' }) + res.status(500).json({ error: getErr(error) }) } }) @@ -57,7 +50,7 @@ apiRouter.get('/logs', (req: Request, res: Response) => { const logs = dashboardState.getLogs(Math.min(limit, 500)) res.json(logs) } catch (error) { - res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' }) + res.status(500).json({ error: getErr(error) }) } }) @@ -67,7 +60,7 @@ apiRouter.delete('/logs', (_req: Request, res: Response) => { dashboardState.clearLogs() res.json({ success: true }) } catch (error) { - res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' }) + res.status(500).json({ error: getErr(error) }) } }) @@ -98,7 +91,7 @@ apiRouter.get('/history', (_req: Request, res: Response): void => { res.json(summaries.slice(0, 50)) } catch (error) { - res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' }) + res.status(500).json({ error: getErr(error) }) } }) @@ -115,7 +108,7 @@ apiRouter.get('/config', (_req: Request, res: Response) => { res.json(safe) } catch (error) { - res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' }) + res.status(500).json({ error: getErr(error) }) } }) @@ -139,7 +132,7 @@ apiRouter.post('/config', (req: Request, res: Response): void => { res.json({ success: true, backup: backupPath }) } catch (error) { - res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' }) + res.status(500).json({ error: getErr(error) }) } }) @@ -148,26 +141,19 @@ apiRouter.post('/start', async (_req: Request, res: Response): Promise => try { const status = botController.getStatus() if (status.running) { - res.status(400).json({ error: 'Bot already running', pid: status.pid }) + sendError(res, 400, `Bot already running (PID: ${status.pid})`) return } const result = await botController.start() if (result.success) { - res.json({ - success: true, - message: 'Bot started successfully', - pid: result.pid - }) + sendSuccess(res, { message: 'Bot started successfully', pid: result.pid }) } else { - res.status(500).json({ - success: false, - error: result.error || 'Failed to start bot' - }) + sendError(res, 500, result.error || 'Failed to start bot') } } catch (error) { - res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' }) + sendError(res, 500, getErr(error)) } }) @@ -177,12 +163,12 @@ apiRouter.post('/stop', (_req: Request, res: Response): void => { const result = botController.stop() if (result.success) { - res.json({ success: true, message: 'Bot stopped successfully' }) + sendSuccess(res, { message: 'Bot stopped successfully' }) } else { - res.status(400).json({ success: false, error: result.error || 'Failed to stop bot' }) + sendError(res, 400, result.error || 'Failed to stop bot') } } catch (error) { - res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' }) + sendError(res, 500, getErr(error)) } }) @@ -192,19 +178,12 @@ apiRouter.post('/restart', async (_req: Request, res: Response): Promise = const result = await botController.restart() if (result.success) { - res.json({ - success: true, - message: 'Bot restarted successfully', - pid: result.pid - }) + sendSuccess(res, { message: 'Bot restarted successfully', pid: result.pid }) } else { - res.status(500).json({ - success: false, - error: result.error || 'Failed to restart bot' - }) + sendError(res, 500, result.error || 'Failed to restart bot') } } catch (error) { - res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' }) + sendError(res, 500, getErr(error)) } }) @@ -230,7 +209,7 @@ apiRouter.post('/sync/:email', async (req: Request, res: Response): Promise { accountsError: accounts.filter(a => a.status === 'error').length }) } catch (error) { - res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' }) + res.status(500).json({ error: getErr(error) }) } }) @@ -275,7 +254,7 @@ apiRouter.get('/account/:email', (req: Request, res: Response): void => { res.json(account) } catch (error) { - res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' }) + res.status(500).json({ error: getErr(error) }) } }) @@ -302,10 +281,11 @@ apiRouter.post('/account/:email/reset', (req: Request, res: Response): void => { res.json({ success: true }) } catch (error) { - res.status(500).json({ error: error instanceof Error ? error.message : 'Unknown error' }) + res.status(500).json({ error: getErr(error) }) } }) +// Helper to mask sensitive URLs function maskUrl(url: string): string { try { const parsed = new URL(url) @@ -320,3 +300,13 @@ function maskUrl(url: string): string { return '***' } } + +// Helper to send error response +function sendError(res: Response, status: number, message: string): void { + res.status(status).json({ error: message }) +} + +// Helper to send success response +function sendSuccess(res: Response, data: Record): void { + res.json({ success: true, ...data }) +} diff --git a/src/index.ts b/src/index.ts index e063696..90fa1d1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,8 +15,6 @@ import { loadAccounts, loadConfig, saveSessionData } from './util/Load' import Axios from './util/Axios' import Humanizer from './util/Humanizer' import { detectBanReason } from './util/BanDetector' -import { RiskManager, RiskMetrics, RiskEvent } from './util/RiskManager' -import { BanPredictor } from './util/BanPredictor' import { QueryDiversityEngine } from './util/QueryDiversityEngine' import JobState from './util/JobState' import { StartupValidator } from './util/StartupValidator' @@ -61,10 +59,6 @@ export class MicrosoftRewardsBot { private runId: string = Math.random().toString(36).slice(2) private bannedTriggered: { email: string; reason: string } | null = null private globalStandby: { active: boolean; reason?: string } = { active: false } - private riskManager?: RiskManager - private lastRiskMetrics?: RiskMetrics - private riskThresholdTriggered: boolean = false - private banPredictor?: BanPredictor private accountJobState?: JobState private accountRunCounts: Map = new Map() @@ -95,13 +89,6 @@ export class MicrosoftRewardsBot { cacheMinutes: this.config.queryDiversity.cacheMinutes }) } - - if (this.config.riskManagement?.enabled) { - this.riskManager = new RiskManager() - if (this.config.riskManagement.banPrediction) { - this.banPredictor = new BanPredictor(this.riskManager) - } - } // Buy mode: CLI args take precedence over config const idx = process.argv.indexOf('-buy') @@ -143,36 +130,6 @@ export class MicrosoftRewardsBot { } } - private resetRiskTracking(): void { - if (this.riskManager) { - this.riskManager.reset() - } - this.lastRiskMetrics = undefined - this.riskThresholdTriggered = false - } - - private recordRiskEvent(type: RiskEvent['type'], severity: number, context?: string): void { - if (!this.riskManager || this.config.riskManagement?.enabled !== true) return - this.riskManager.recordEvent(type, severity, context) - const metrics = this.riskManager.assessRisk() - this.lastRiskMetrics = metrics - - const threshold = this.config.riskManagement?.riskThreshold - if (typeof threshold === 'number' && metrics.score >= threshold && !this.riskThresholdTriggered) { - this.riskThresholdTriggered = true - log('main', 'RISK', `Risk score ${metrics.score} exceeded threshold ${threshold} (level=${metrics.level})`, 'warn', 'yellow') - } - - if (this.config.riskManagement?.stopOnCritical && metrics.level === 'critical') { - void this.engageGlobalStandby('risk-critical', this.currentAccountEmail) - } - } - - public getRiskDelayMultiplier(): number { - if (!this.config.riskManagement?.enabled || this.config.riskManagement.autoAdjustDelays === false) return 1 - return this.lastRiskMetrics?.delayMultiplier ?? 1 - } - private shouldSkipAccount(email: string, dayKey: string): boolean { if (!this.accountJobState) return false if (this.config.jobState?.skipCompletedAccounts === false) return false @@ -525,7 +482,6 @@ export class MicrosoftRewardsBot { // Reset compromised state per account this.compromisedModeActive = false this.compromisedReason = undefined - this.resetRiskTracking() // If humanization allowed windows are configured, wait until within a window try { @@ -568,9 +524,7 @@ export class MicrosoftRewardsBot { initialTotal: 0, endTotal: 0, errors: [], - banned, - riskScore: 0, - riskLevel: 'safe' + banned } this.accountSummaries.push(summary) this.persistAccountCompletion(account.email, accountDayKey, summary) @@ -583,24 +537,20 @@ export class MicrosoftRewardsBot { // Run both and capture results with detailed logging const desktopPromise = this.Desktop(account).catch((e: unknown) => { const msg = e instanceof Error ? e.message : String(e) - this.recordRiskEvent('error', 6, `desktop:${msg}`) log(false, 'TASK', `Desktop flow failed early for ${account.email}: ${msg}`,'error') const bd = detectBanReason(e) if (bd.status) { banned.status = true; banned.reason = bd.reason.substring(0,200) - this.recordRiskEvent('ban_hint', 9, bd.reason) void this.handleImmediateBanAlert(account.email, banned.reason) } errors.push(formatFullError('desktop', e, verbose)); return null }) const mobilePromise = mobileInstance.Mobile(account).catch((e: unknown) => { const msg = e instanceof Error ? e.message : String(e) - this.recordRiskEvent('error', 6, `mobile:${msg}`) log(true, 'TASK', `Mobile flow failed early for ${account.email}: ${msg}`,'error') const bd = detectBanReason(e) if (bd.status) { banned.status = true; banned.reason = bd.reason.substring(0,200) - this.recordRiskEvent('ban_hint', 9, bd.reason) void this.handleImmediateBanAlert(account.email, banned.reason) } errors.push(formatFullError('mobile', e, verbose)); return null @@ -613,7 +563,6 @@ export class MicrosoftRewardsBot { desktopCollected = desktopResult.value.collectedPoints } else if (desktopResult.status === 'rejected') { log(false, 'TASK', `Desktop promise rejected unexpectedly: ${shortErr(desktopResult.reason)}`,'error') - this.recordRiskEvent('error', 6, `desktop-rejected:${shortErr(desktopResult.reason)}`) errors.push(formatFullError('desktop-rejected', desktopResult.reason, verbose)) } @@ -623,7 +572,6 @@ export class MicrosoftRewardsBot { mobileCollected = mobileResult.value.collectedPoints } else if (mobileResult.status === 'rejected') { log(true, 'TASK', `Mobile promise rejected unexpectedly: ${shortErr(mobileResult.reason)}`,'error') - this.recordRiskEvent('error', 6, `mobile-rejected:${shortErr(mobileResult.reason)}`) errors.push(formatFullError('mobile-rejected', mobileResult.reason, verbose)) } } else { @@ -631,12 +579,10 @@ export class MicrosoftRewardsBot { this.isMobile = false const desktopResult = await this.Desktop(account).catch(e => { const msg = e instanceof Error ? e.message : String(e) - this.recordRiskEvent('error', 6, `desktop:${msg}`) log(false, 'TASK', `Desktop flow failed early for ${account.email}: ${msg}`,'error') const bd = detectBanReason(e) if (bd.status) { banned.status = true; banned.reason = bd.reason.substring(0,200) - this.recordRiskEvent('ban_hint', 9, bd.reason) void this.handleImmediateBanAlert(account.email, banned.reason) } errors.push(formatFullError('desktop', e, verbose)); return null @@ -650,12 +596,10 @@ export class MicrosoftRewardsBot { this.isMobile = true const mobileResult = await this.Mobile(account).catch((e: unknown) => { const msg = e instanceof Error ? e.message : String(e) - this.recordRiskEvent('error', 6, `mobile:${msg}`) log(true, 'TASK', `Mobile flow failed early for ${account.email}: ${msg}`,'error') const bd = detectBanReason(e) if (bd.status) { banned.status = true; banned.reason = bd.reason.substring(0,200) - this.recordRiskEvent('ban_hint', 9, bd.reason) void this.handleImmediateBanAlert(account.email, banned.reason) } errors.push(formatFullError('mobile', e, verbose)); return null @@ -681,34 +625,6 @@ export class MicrosoftRewardsBot { : (desktopInitial || mobileInitial || 0) const endTotal = initialTotal + totalCollected - if (!banned.status) { - this.recordRiskEvent('success', 1, 'account-complete') - } - - const riskMetrics = this.lastRiskMetrics - let riskScore = riskMetrics?.score - const riskLevel = riskMetrics?.level - let banPredictionScore: number | undefined - let banLikelihood: string | undefined - - if (this.banPredictor && this.config.riskManagement?.banPrediction) { - const prediction = this.banPredictor.predictBanRisk(account.email, runNumber, runNumber) - banPredictionScore = prediction.riskScore - banLikelihood = prediction.likelihood - riskScore = prediction.riskScore - if (prediction.likelihood === 'high' || prediction.likelihood === 'critical') { - log('main', 'RISK', `Ban predictor warning for ${account.email}: likelihood=${prediction.likelihood} score=${prediction.riskScore}`, 'warn', 'yellow') - } - if (banned.status) { - this.banPredictor.recordBan(account.email, runNumber, runNumber) - } else { - this.banPredictor.recordSuccess(account.email, runNumber, runNumber) - } - } else if (banned.status && this.banPredictor) { - this.banPredictor.recordBan(account.email, runNumber, runNumber) - } else if (this.banPredictor && !banned.status) { - this.banPredictor.recordSuccess(account.email, runNumber, runNumber) - } const summary: AccountSummary = { email: account.email, @@ -719,11 +635,7 @@ export class MicrosoftRewardsBot { initialTotal, endTotal, errors, - banned, - riskScore, - riskLevel, - banPredictionScore, - banLikelihood + banned } this.accountSummaries.push(summary) @@ -733,7 +645,6 @@ export class MicrosoftRewardsBot { this.bannedTriggered = { email: account.email, reason: banned.reason } // Enter global standby: do not proceed to next accounts this.globalStandby = { active: true, reason: `banned:${banned.reason}` } - this.recordRiskEvent('ban_hint', 9, `final-ban:${banned.reason}`) await this.sendGlobalSecurityStandbyAlert(account.email, `Ban detected: ${banned.reason || 'unknown'}`) } @@ -1119,14 +1030,6 @@ export class MicrosoftRewardsBot { const extras: string[] = [] if (summary.banned?.status) extras.push(`BAN:${summary.banned.reason || 'detected'}`) if (summary.errors.length) extras.push(`ERR:${summary.errors.slice(0, 1).join(' | ')}`) - if (summary.riskLevel) { - const scoreLabel = summary.riskScore != null ? `(${summary.riskScore})` : '' - extras.push(`RISK:${summary.riskLevel}${scoreLabel}`) - } - if (summary.banLikelihood) { - const predLabel = summary.banPredictionScore != null ? `(${summary.banPredictionScore})` : '' - extras.push(`PRED:${summary.banLikelihood}${predLabel}`) - } const tail = extras.length ? ` | ${extras.join(' • ')}` : '' return `${statusIcon} ${email} ${total} ${desktop}${mobile} ${totals} ${duration}${tail}` } @@ -1151,7 +1054,6 @@ export class MicrosoftRewardsBot { const accountLines = summaries.map(buildAccountLine) const accountChunks = chunkLines(accountLines) - const riskSamples = summaries.filter(s => typeof s.riskScore === 'number' && !Number.isNaN(s.riskScore as number)) const globalLines = [ `Total points: **${formatNumber(totalInitial)}** → **${formatNumber(totalEnd)}** (${formatSigned(totalCollected)} pts)`, `Accounts: ✅ ${successes}${accountsWithErrors > 0 ? ` • ⚠️ ${accountsWithErrors}` : ''} (${totalAccounts} total)`, @@ -1159,12 +1061,6 @@ export class MicrosoftRewardsBot { `Runtime: **${formatDuration(totalDuration)}**` ] - if (riskSamples.length > 0) { - const avgRiskScore = riskSamples.reduce((sum, s) => sum + (s.riskScore ?? 0), 0) / riskSamples.length - const highestRisk = riskSamples.reduce((prev, curr) => ((curr.riskScore ?? 0) > (prev.riskScore ?? 0) ? curr : prev)) - globalLines.push(`Risk avg: **${avgRiskScore.toFixed(1)}** • Highest: ${highestRisk.email} (${highestRisk.riskScore ?? 0})`) - } - const globalStatsValue = globalLines.join('\n') const fields: { name: string; value: string; inline?: boolean }[] = [ @@ -1273,10 +1169,6 @@ interface AccountSummary { endTotal: number errors: string[] banned?: { status: boolean; reason: string } - riskScore?: number - riskLevel?: string - banPredictionScore?: number - banLikelihood?: string } function shortErr(e: unknown): string { diff --git a/src/util/BanPredictor.ts b/src/util/BanPredictor.ts deleted file mode 100644 index ac051cd..0000000 --- a/src/util/BanPredictor.ts +++ /dev/null @@ -1,394 +0,0 @@ -import { RiskManager, RiskEvent } from './RiskManager' - -export interface BanPattern { - name: string - description: string - weight: number // 0-10 - detected: boolean - evidence: string[] -} - -export interface BanPrediction { - riskScore: number // 0-100 - confidence: number // 0-1 - likelihood: 'very-low' | 'low' | 'medium' | 'high' | 'critical' - patterns: BanPattern[] - recommendation: string - preventiveActions: string[] -} - -export interface HistoricalData { - email: string - timestamp: number - banned: boolean - preBanEvents: RiskEvent[] - accountAge: number // days since first use - totalRuns: number -} - -/** - * BanPredictor uses machine-learning-style pattern analysis to predict ban risk. - * Learns from historical data and real-time signals to calculate ban probability. - */ -export class BanPredictor { - private riskManager: RiskManager - private history: HistoricalData[] = [] - private patterns: BanPattern[] = [] - - constructor(riskManager: RiskManager) { - this.riskManager = riskManager - this.initializePatterns() - } - - /** - * Analyze current state and predict ban risk - */ - predictBanRisk(accountEmail: string, accountAgeDays: number, totalRuns: number): BanPrediction { - const riskMetrics = this.riskManager.assessRisk() - const recentEvents = this.riskManager.getRecentEvents(60) - - // Detect patterns - this.detectPatterns(recentEvents, accountAgeDays, totalRuns) - - // Calculate base risk from RiskManager - const baseRisk = riskMetrics.score - - // Apply ML-style feature weights - const featureScore = this.calculateFeatureScore(recentEvents, accountAgeDays, totalRuns) - - // Pattern detection bonus - const detectedPatterns = this.patterns.filter(p => p.detected) - const patternPenalty = detectedPatterns.reduce((sum, p) => sum + p.weight, 0) - - // Historical learning adjustment - const historicalAdjustment = this.getHistoricalAdjustment(accountEmail) - - // Final risk score (capped at 100) - const finalScore = Math.min(100, baseRisk + featureScore + patternPenalty + historicalAdjustment) - - // Calculate confidence (based on data availability) - const confidence = this.calculateConfidence(recentEvents.length, this.history.length) - - // Determine likelihood tier - let likelihood: BanPrediction['likelihood'] - if (finalScore < 20) likelihood = 'very-low' - else if (finalScore < 40) likelihood = 'low' - else if (finalScore < 60) likelihood = 'medium' - else if (finalScore < 80) likelihood = 'high' - else likelihood = 'critical' - - // Generate recommendations - const recommendation = this.generateRecommendation(finalScore) - const preventiveActions = this.generatePreventiveActions(detectedPatterns) - - return { - riskScore: Math.round(finalScore), - confidence: Number(confidence.toFixed(2)), - likelihood, - patterns: detectedPatterns, - recommendation, - preventiveActions - } - } - - /** - * Record ban event for learning - */ - recordBan(email: string, accountAgeDays: number, totalRuns: number): void { - const preBanEvents = this.riskManager.getRecentEvents(120) - - this.history.push({ - email, - timestamp: Date.now(), - banned: true, - preBanEvents, - accountAge: accountAgeDays, - totalRuns - }) - - // Keep history limited (last 100 bans) - if (this.history.length > 100) { - this.history.shift() - } - } - - /** - * Record successful run (no ban) for learning - */ - recordSuccess(email: string, accountAgeDays: number, totalRuns: number): void { - this.history.push({ - email, - timestamp: Date.now(), - banned: false, - preBanEvents: [], - accountAge: accountAgeDays, - totalRuns - }) - - if (this.history.length > 100) { - this.history.shift() - } - } - - /** - * Initialize known ban patterns - */ - private initializePatterns(): void { - this.patterns = [ - { - name: 'rapid-captcha-sequence', - description: 'Multiple captchas in short timespan', - weight: 8, - detected: false, - evidence: [] - }, - { - name: 'high-error-rate', - description: 'Excessive errors (>50% in last hour)', - weight: 6, - detected: false, - evidence: [] - }, - { - name: 'timeout-storm', - description: 'Many consecutive timeouts', - weight: 7, - detected: false, - evidence: [] - }, - { - name: 'suspicious-timing', - description: 'Activity at unusual hours or too consistent', - weight: 5, - detected: false, - evidence: [] - }, - { - name: 'new-account-aggressive', - description: 'Aggressive activity on young account', - weight: 9, - detected: false, - evidence: [] - }, - { - name: 'proxy-flagged', - description: 'Proxy showing signs of blacklisting', - weight: 7, - detected: false, - evidence: [] - } - ] - } - - /** - * Detect patterns in recent events - */ - private detectPatterns(events: RiskEvent[], accountAgeDays: number, totalRuns: number): void { - // Reset detection - for (const p of this.patterns) { - p.detected = false - p.evidence = [] - } - - const captchaEvents = events.filter(e => e.type === 'captcha') - const errorEvents = events.filter(e => e.type === 'error') - const timeoutEvents = events.filter(e => e.type === 'timeout') - - // Pattern 1: Rapid captcha sequence - if (captchaEvents.length >= 3) { - const timeSpan = (events[events.length - 1]?.timestamp || 0) - (events[0]?.timestamp || 0) - if (timeSpan < 1800000) { // 30 min - const p = this.patterns.find(pat => pat.name === 'rapid-captcha-sequence') - if (p) { - p.detected = true - p.evidence.push(`${captchaEvents.length} captchas in ${Math.round(timeSpan / 60000)}min`) - } - } - } - - // Pattern 2: High error rate - const errorRate = errorEvents.length / Math.max(1, events.length) - if (errorRate > 0.5) { - const p = this.patterns.find(pat => pat.name === 'high-error-rate') - if (p) { - p.detected = true - p.evidence.push(`Error rate: ${(errorRate * 100).toFixed(1)}%`) - } - } - - // Pattern 3: Timeout storm - if (timeoutEvents.length >= 5) { - const p = this.patterns.find(pat => pat.name === 'timeout-storm') - if (p) { - p.detected = true - p.evidence.push(`${timeoutEvents.length} timeouts detected`) - } - } - - // Pattern 4: Suspicious timing (all events within same hour) - if (events.length > 5) { - const hours = new Set(events.map(e => new Date(e.timestamp).getHours())) - if (hours.size === 1) { - const p = this.patterns.find(pat => pat.name === 'suspicious-timing') - if (p) { - p.detected = true - p.evidence.push('All activity in same hour of day') - } - } - } - - // Pattern 5: New account aggressive - if (accountAgeDays < 7 && totalRuns > 10) { - const p = this.patterns.find(pat => pat.name === 'new-account-aggressive') - if (p) { - p.detected = true - p.evidence.push(`Account ${accountAgeDays} days old with ${totalRuns} runs`) - } - } - - // Pattern 6: Proxy flagged (heuristic: many ban hints) - const banHints = events.filter(e => e.type === 'ban_hint') - if (banHints.length >= 2) { - const p = this.patterns.find(pat => pat.name === 'proxy-flagged') - if (p) { - p.detected = true - p.evidence.push(`${banHints.length} ban hints detected`) - } - } - } - - /** - * Calculate feature-based risk score (ML-style) - */ - private calculateFeatureScore(events: RiskEvent[], accountAgeDays: number, totalRuns: number): number { - let score = 0 - - // Feature 1: Event density (events per minute) - const eventDensity = events.length / 60 - if (eventDensity > 0.5) score += 10 - else if (eventDensity > 0.2) score += 5 - - // Feature 2: Account age risk - if (accountAgeDays < 3) score += 15 - else if (accountAgeDays < 7) score += 10 - else if (accountAgeDays < 14) score += 5 - - // Feature 3: Run frequency risk - const runsPerDay = totalRuns / Math.max(1, accountAgeDays) - if (runsPerDay > 3) score += 12 - else if (runsPerDay > 2) score += 6 - - // Feature 4: Severity distribution - const highSeverityEvents = events.filter(e => e.severity >= 7) - if (highSeverityEvents.length > 3) score += 15 - else if (highSeverityEvents.length > 1) score += 8 - - return score - } - - /** - * Learn from historical data - */ - private getHistoricalAdjustment(email: string): number { - const accountHistory = this.history.filter(h => h.email === email) - if (accountHistory.length === 0) return 0 - - const bannedCount = accountHistory.filter(h => h.banned).length - const banRate = bannedCount / accountHistory.length - - // If this account has high ban history, increase risk - if (banRate > 0.3) return 20 - if (banRate > 0.1) return 10 - - // If clean history, slight bonus - if (accountHistory.length > 5 && banRate === 0) return -5 - - return 0 - } - - /** - * Calculate prediction confidence - */ - private calculateConfidence(eventCount: number, historyCount: number): number { - let confidence = 0.5 - - // More events = higher confidence - if (eventCount > 20) confidence += 0.2 - else if (eventCount > 10) confidence += 0.1 - - // More historical data = higher confidence - if (historyCount > 50) confidence += 0.2 - else if (historyCount > 20) confidence += 0.1 - - return Math.min(1.0, confidence) - } - - /** - * Generate human-readable recommendation - */ - private generateRecommendation(score: number): string { - if (score < 20) { - return 'Safe to proceed. Risk is minimal.' - } else if (score < 40) { - return 'Low risk detected. Monitor for issues but safe to continue.' - } else if (score < 60) { - return 'Moderate risk. Consider increasing delays and reviewing patterns.' - } else if (score < 80) { - return 'High risk! Strongly recommend pausing automation for 24-48 hours.' - } else { - return 'CRITICAL RISK! Stop all automation immediately. Manual review required.' - } - } - - /** - * Generate actionable preventive steps - */ - private generatePreventiveActions(patterns: BanPattern[]): string[] { - const actions: string[] = [] - - if (patterns.some(p => p.name === 'rapid-captcha-sequence')) { - actions.push('Increase search delays to 3-5 minutes minimum') - actions.push('Enable longer cool-down periods between activities') - } - - if (patterns.some(p => p.name === 'high-error-rate')) { - actions.push('Check proxy connectivity and health') - actions.push('Verify User-Agent and fingerprint configuration') - } - - if (patterns.some(p => p.name === 'new-account-aggressive')) { - actions.push('Slow down activity on new accounts (max 1 run per day for first week)') - actions.push('Allow account to age naturally before heavy automation') - } - - if (patterns.some(p => p.name === 'proxy-flagged')) { - actions.push('Rotate to different proxy immediately') - actions.push('Test proxy manually before resuming') - } - - if (patterns.some(p => p.name === 'suspicious-timing')) { - actions.push('Randomize execution times across different hours') - actions.push('Enable humanization.allowedWindows with varied schedules') - } - - if (actions.length === 0) { - actions.push('Continue monitoring but no immediate action needed') - } - - return actions - } - - /** - * Export historical data for analysis - */ - exportHistory(): HistoricalData[] { - return [...this.history] - } - - /** - * Import historical data (for persistence) - */ - importHistory(data: HistoricalData[]): void { - this.history = data.slice(-100) // Keep last 100 - } -} diff --git a/src/util/ConfigValidator.ts b/src/util/ConfigValidator.ts deleted file mode 100644 index b4ce86f..0000000 --- a/src/util/ConfigValidator.ts +++ /dev/null @@ -1,490 +0,0 @@ -import fs from 'fs' -import { Config } from '../interface/Config' -import { Account } from '../interface/Account' - -export interface ValidationIssue { - severity: 'error' | 'warning' | 'info' - field: string - message: string - suggestion?: string -} - -export interface ValidationResult { - valid: boolean - issues: ValidationIssue[] -} - -/** - * ConfigValidator performs intelligent validation of config.jsonc and accounts.json - * before execution to catch common mistakes, conflicts, and security issues. - */ -export class ConfigValidator { - /** - * Validate the main config file - */ - static validateConfig(config: Config): ValidationResult { - const issues: ValidationIssue[] = [] - - // Check baseURL - if (!config.baseURL || !config.baseURL.startsWith('https://')) { - issues.push({ - severity: 'error', - field: 'baseURL', - message: 'baseURL must be a valid HTTPS URL', - suggestion: 'Use https://rewards.bing.com' - }) - } - - // Check sessionPath - if (!config.sessionPath || config.sessionPath.trim() === '') { - issues.push({ - severity: 'error', - field: 'sessionPath', - message: 'sessionPath cannot be empty' - }) - } - - // Check clusters - if (config.clusters < 1) { - issues.push({ - severity: 'error', - field: 'clusters', - message: 'clusters must be at least 1' - }) - } - if (config.clusters > 10) { - issues.push({ - severity: 'warning', - field: 'clusters', - message: 'High cluster count may consume excessive resources', - suggestion: 'Consider using 2-4 clusters for optimal performance' - }) - } - - // Check globalTimeout - const timeout = this.parseTimeout(config.globalTimeout) - if (timeout < 10000) { - issues.push({ - severity: 'warning', - field: 'globalTimeout', - message: 'Very short timeout may cause frequent failures', - suggestion: 'Use at least 15s for stability' - }) - } - if (timeout > 120000) { - issues.push({ - severity: 'warning', - field: 'globalTimeout', - message: 'Very long timeout may slow down execution', - suggestion: 'Use 30-60s for optimal balance' - }) - } - - // Check search settings - if (config.searchSettings) { - const searchDelay = config.searchSettings.searchDelay - const minDelay = this.parseTimeout(searchDelay.min) - const maxDelay = this.parseTimeout(searchDelay.max) - - if (minDelay >= maxDelay) { - issues.push({ - severity: 'error', - field: 'searchSettings.searchDelay', - message: 'min delay must be less than max delay' - }) - } - - if (minDelay < 10000) { - issues.push({ - severity: 'warning', - field: 'searchSettings.searchDelay.min', - message: 'Very short search delays increase ban risk', - suggestion: 'Use at least 30s between searches' - }) - } - - if (config.searchSettings.retryMobileSearchAmount > 5) { - issues.push({ - severity: 'warning', - field: 'searchSettings.retryMobileSearchAmount', - message: 'Too many retries may waste time', - suggestion: 'Use 2-3 retries maximum' - }) - } - } - - // Check humanization - if (config.humanization) { - if (config.humanization.enabled === false && config.humanization.stopOnBan === true) { - issues.push({ - severity: 'warning', - field: 'humanization', - message: 'stopOnBan is enabled but humanization is disabled', - suggestion: 'Enable humanization for better ban protection' - }) - } - - const actionDelay = config.humanization.actionDelay - if (actionDelay) { - const minAction = this.parseTimeout(actionDelay.min) - const maxAction = this.parseTimeout(actionDelay.max) - if (minAction >= maxAction) { - issues.push({ - severity: 'error', - field: 'humanization.actionDelay', - message: 'min action delay must be less than max' - }) - } - } - - if (config.humanization.allowedWindows && config.humanization.allowedWindows.length > 0) { - for (const window of config.humanization.allowedWindows) { - if (!/^\d{2}:\d{2}-\d{2}:\d{2}$/.test(window)) { - issues.push({ - severity: 'error', - field: 'humanization.allowedWindows', - message: `Invalid time window format: ${window}`, - suggestion: 'Use format HH:mm-HH:mm (e.g., 09:00-17:00)' - }) - } - } - } - } - - // Check proxy config - if (config.proxy) { - if (config.proxy.proxyGoogleTrends === false && config.proxy.proxyBingTerms === false) { - issues.push({ - severity: 'info', - field: 'proxy', - message: 'All proxy options disabled - outbound requests will use direct connection' - }) - } - } - - // Check webhooks - if (config.webhook?.enabled && (!config.webhook.url || config.webhook.url.trim() === '')) { - issues.push({ - severity: 'error', - field: 'webhook.url', - message: 'Webhook enabled but URL is empty' - }) - } - - if (config.conclusionWebhook?.enabled && (!config.conclusionWebhook.url || config.conclusionWebhook.url.trim() === '')) { - issues.push({ - severity: 'error', - field: 'conclusionWebhook.url', - message: 'Conclusion webhook enabled but URL is empty' - }) - } - - // Check ntfy - if (config.ntfy?.enabled) { - if (!config.ntfy.url || config.ntfy.url.trim() === '') { - issues.push({ - severity: 'error', - field: 'ntfy.url', - message: 'NTFY enabled but URL is empty' - }) - } - if (!config.ntfy.topic || config.ntfy.topic.trim() === '') { - issues.push({ - severity: 'error', - field: 'ntfy.topic', - message: 'NTFY enabled but topic is empty' - }) - } - } - - const legacySchedule = (config as unknown as { schedule?: unknown }).schedule - if (legacySchedule !== undefined) { - issues.push({ - severity: 'warning', - field: 'schedule', - message: 'Legacy schedule block detected.', - suggestion: 'Remove schedule.* entries and configure OS-level scheduling (docs/schedule.md).' - }) - } - - // Check workers - if (config.workers) { - const allDisabled = !config.workers.doDailySet && - !config.workers.doMorePromotions && - !config.workers.doPunchCards && - !config.workers.doDesktopSearch && - !config.workers.doMobileSearch && - !config.workers.doDailyCheckIn && - !config.workers.doReadToEarn - - if (allDisabled) { - issues.push({ - severity: 'warning', - field: 'workers', - message: 'All workers are disabled - bot will not perform any tasks', - suggestion: 'Enable at least one worker type' - }) - } - } - - const valid = !issues.some(i => i.severity === 'error') - return { valid, issues } - } - - /** - * Validate accounts.json - */ - static validateAccounts(accounts: Account[]): ValidationResult { - const issues: ValidationIssue[] = [] - - if (accounts.length === 0) { - issues.push({ - severity: 'error', - field: 'accounts', - message: 'No accounts found in accounts.json' - }) - return { valid: false, issues } - } - - const seenEmails = new Set() - const seenProxies = new Map() // proxy -> [emails] - - for (let i = 0; i < accounts.length; i++) { - const acc = accounts[i] - const prefix = `accounts[${i}]` - - if (!acc) continue - - // Check email - if (!acc.email || acc.email.trim() === '') { - issues.push({ - severity: 'error', - field: `${prefix}.email`, - message: 'Account email is empty' - }) - } else { - if (seenEmails.has(acc.email)) { - issues.push({ - severity: 'error', - field: `${prefix}.email`, - message: `Duplicate email: ${acc.email}` - }) - } - seenEmails.add(acc.email) - - if (!/@/.test(acc.email)) { - issues.push({ - severity: 'error', - field: `${prefix}.email`, - message: 'Invalid email format' - }) - } - } - - // Check password - if (!acc.password || acc.password.trim() === '') { - issues.push({ - severity: 'error', - field: `${prefix}.password`, - message: 'Account password is empty' - }) - } else if (acc.password.length < 8) { - issues.push({ - severity: 'warning', - field: `${prefix}.password`, - message: 'Very short password - verify it\'s correct' - }) - } - - // Check proxy - if (acc.proxy) { - const proxyUrl = acc.proxy.url - if (proxyUrl && proxyUrl.trim() !== '') { - if (!acc.proxy.port) { - issues.push({ - severity: 'error', - field: `${prefix}.proxy.port`, - message: 'Proxy URL specified but port is missing' - }) - } - - // Track proxy reuse - const proxyKey = `${proxyUrl}:${acc.proxy.port}` - if (!seenProxies.has(proxyKey)) { - seenProxies.set(proxyKey, []) - } - seenProxies.get(proxyKey)?.push(acc.email) - } - } - - // Check TOTP - if (acc.totp && acc.totp.trim() !== '') { - if (acc.totp.length < 16) { - issues.push({ - severity: 'warning', - field: `${prefix}.totp`, - message: 'TOTP secret seems too short - verify it\'s correct' - }) - } - } - } - - // Warn about excessive proxy reuse - for (const [proxyKey, emails] of seenProxies) { - if (emails.length > 3) { - issues.push({ - severity: 'warning', - field: 'accounts.proxy', - message: `Proxy ${proxyKey} used by ${emails.length} accounts - may trigger rate limits`, - suggestion: 'Use different proxies per account for better safety' - }) - } - } - - const valid = !issues.some(i => i.severity === 'error') - return { valid, issues } - } - - /** - * Validate both config and accounts together (cross-checks) - */ - static validateAll(config: Config, accounts: Account[]): ValidationResult { - const configResult = this.validateConfig(config) - const accountsResult = this.validateAccounts(accounts) - - const issues = [...configResult.issues, ...accountsResult.issues] - - // Cross-validation: clusters vs accounts - if (accounts.length > 0 && config.clusters > accounts.length) { - issues.push({ - severity: 'info', - field: 'clusters', - message: `${config.clusters} clusters configured but only ${accounts.length} account(s)`, - suggestion: 'Reduce clusters to match account count for efficiency' - }) - } - - // Cross-validation: parallel mode with single account - if (config.parallel && accounts.length === 1) { - issues.push({ - severity: 'info', - field: 'parallel', - message: 'Parallel mode enabled with single account has no effect', - suggestion: 'Disable parallel mode or add more accounts' - }) - } - - const valid = !issues.some(i => i.severity === 'error') - return { valid, issues } - } - - /** - * Load and validate from file paths - */ - static validateFromFiles(configPath: string, accountsPath: string): ValidationResult { - try { - if (!fs.existsSync(configPath)) { - return { - valid: false, - issues: [{ - severity: 'error', - field: 'config', - message: `Config file not found: ${configPath}` - }] - } - } - - if (!fs.existsSync(accountsPath)) { - return { - valid: false, - issues: [{ - severity: 'error', - field: 'accounts', - message: `Accounts file not found: ${accountsPath}` - }] - } - } - - const configRaw = fs.readFileSync(configPath, 'utf-8') - const accountsRaw = fs.readFileSync(accountsPath, 'utf-8') - - // Remove JSONC comments (basic approach) - const configJson = configRaw.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, '') - const config: Config = JSON.parse(configJson) - const accounts: Account[] = JSON.parse(accountsRaw) - - return this.validateAll(config, accounts) - } catch (error) { - return { - valid: false, - issues: [{ - severity: 'error', - field: 'parse', - message: `Failed to parse files: ${error instanceof Error ? error.message : String(error)}` - }] - } - } - } - - /** - * Print validation results to console with color - * Note: This method intentionally uses console.log for CLI output formatting - */ - static printResults(result: ValidationResult): void { - if (result.valid) { - console.log('✅ Configuration validation passed\n') - } else { - console.log('❌ Configuration validation failed\n') - } - - if (result.issues.length === 0) { - console.log('No issues found.') - return - } - - const errors = result.issues.filter(i => i.severity === 'error') - const warnings = result.issues.filter(i => i.severity === 'warning') - const infos = result.issues.filter(i => i.severity === 'info') - - if (errors.length > 0) { - console.log(`\n🚫 ERRORS (${errors.length}):`) - for (const issue of errors) { - console.log(` ${issue.field}: ${issue.message}`) - if (issue.suggestion) { - console.log(` → ${issue.suggestion}`) - } - } - } - - if (warnings.length > 0) { - console.log(`\n⚠️ WARNINGS (${warnings.length}):`) - for (const issue of warnings) { - console.log(` ${issue.field}: ${issue.message}`) - if (issue.suggestion) { - console.log(` → ${issue.suggestion}`) - } - } - } - - if (infos.length > 0) { - console.log(`\nℹ️ INFO (${infos.length}):`) - for (const issue of infos) { - console.log(` ${issue.field}: ${issue.message}`) - if (issue.suggestion) { - console.log(` → ${issue.suggestion}`) - } - } - } - - console.log() - } - - private static parseTimeout(value: number | string): number { - if (typeof value === 'number') return value - const str = String(value).toLowerCase() - if (str.endsWith('ms')) return parseInt(str, 10) - if (str.endsWith('s')) return parseInt(str, 10) * 1000 - if (str.endsWith('min')) return parseInt(str, 10) * 60000 - return parseInt(str, 10) || 30000 - } -} diff --git a/src/util/RiskManager.ts b/src/util/RiskManager.ts deleted file mode 100644 index 012435f..0000000 --- a/src/util/RiskManager.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { AdaptiveThrottler } from './AdaptiveThrottler' - -export interface RiskEvent { - type: 'captcha' | 'error' | 'timeout' | 'ban_hint' | 'success' - timestamp: number - severity: number // 0-10, higher = worse - context?: string -} - -export interface RiskMetrics { - score: number // 0-100, higher = riskier - level: 'safe' | 'elevated' | 'high' | 'critical' - recommendation: string - delayMultiplier: number -} - -/** - * RiskManager monitors account activity patterns and detects early ban signals. - * Integrates with AdaptiveThrottler to dynamically adjust delays based on risk. - */ -export class RiskManager { - private events: RiskEvent[] = [] - private readonly maxEvents = 100 - private readonly timeWindowMs = 3600000 // 1 hour - private throttler: AdaptiveThrottler - private cooldownUntil: number = 0 - - constructor(throttler?: AdaptiveThrottler) { - this.throttler = throttler || new AdaptiveThrottler() - } - - /** - * Record a risk event (captcha, error, success, etc.) - */ - recordEvent(type: RiskEvent['type'], severity: number, context?: string): void { - const event: RiskEvent = { - type, - timestamp: Date.now(), - severity: Math.max(0, Math.min(10, severity)), - context - } - - this.events.push(event) - if (this.events.length > this.maxEvents) { - this.events.shift() - } - - // Feed success/error into adaptive throttler - if (type === 'success') { - this.throttler.record(true) - } else if (['error', 'captcha', 'timeout', 'ban_hint'].includes(type)) { - this.throttler.record(false) - } - - // Auto cool-down on critical events - if (severity >= 8) { - const coolMs = Math.min(300000, severity * 30000) // max 5min - this.cooldownUntil = Date.now() + coolMs - } - } - - /** - * Calculate current risk metrics based on recent events - */ - assessRisk(): RiskMetrics { - const now = Date.now() - const recentEvents = this.events.filter(e => now - e.timestamp < this.timeWindowMs) - - if (recentEvents.length === 0) { - return { - score: 0, - level: 'safe', - recommendation: 'Normal operation', - delayMultiplier: 1.0 - } - } - - // Calculate base risk score (weighted by recency and severity) - let weightedSum = 0 - let totalWeight = 0 - - for (const event of recentEvents) { - const age = now - event.timestamp - const recencyFactor = 1 - (age / this.timeWindowMs) // newer = higher weight - const weight = recencyFactor * (event.severity / 10) - - weightedSum += weight * event.severity - totalWeight += weight - } - - const baseScore = totalWeight > 0 ? (weightedSum / totalWeight) * 10 : 0 - - // Penalty for rapid event frequency - const eventRate = recentEvents.length / (this.timeWindowMs / 60000) // events per minute - const frequencyPenalty = Math.min(30, eventRate * 5) - - // Bonus penalty for specific patterns - const captchaCount = recentEvents.filter(e => e.type === 'captcha').length - const banHintCount = recentEvents.filter(e => e.type === 'ban_hint').length - const patternPenalty = (captchaCount * 15) + (banHintCount * 25) - - const finalScore = Math.min(100, baseScore + frequencyPenalty + patternPenalty) - - // Determine risk level - let level: RiskMetrics['level'] - let recommendation: string - let delayMultiplier: number - - if (finalScore < 20) { - level = 'safe' - recommendation = 'Normal operation' - delayMultiplier = 1.0 - } else if (finalScore < 40) { - level = 'elevated' - recommendation = 'Minor issues detected. Increasing delays slightly.' - delayMultiplier = 1.5 - } else if (finalScore < 70) { - level = 'high' - recommendation = 'Significant risk detected. Applying heavy throttling.' - delayMultiplier = 2.5 - } else { - level = 'critical' - recommendation = 'CRITICAL: High ban risk. Consider stopping or manual review.' - delayMultiplier = 4.0 - } - - // Apply adaptive throttler multiplier on top - const adaptiveMultiplier = this.throttler.getDelayMultiplier() - delayMultiplier *= adaptiveMultiplier - - return { - score: Math.round(finalScore), - level, - recommendation, - delayMultiplier: Number(delayMultiplier.toFixed(2)) - } - } - - /** - * Check if currently in forced cool-down period - */ - isInCooldown(): boolean { - return Date.now() < this.cooldownUntil - } - - /** - * Get remaining cool-down time in milliseconds - */ - getCooldownRemaining(): number { - const remaining = this.cooldownUntil - Date.now() - return Math.max(0, remaining) - } - - /** - * Get the adaptive throttler instance for advanced usage - */ - getThrottler(): AdaptiveThrottler { - return this.throttler - } - - /** - * Clear all events and reset state (use between accounts) - */ - reset(): void { - this.events = [] - this.cooldownUntil = 0 - // Keep throttler state across resets for learning - } - - /** - * Export events for analytics/logging - */ - getRecentEvents(limitMinutes: number = 60): RiskEvent[] { - const cutoff = Date.now() - (limitMinutes * 60000) - return this.events.filter(e => e.timestamp >= cutoff) - } -} diff --git a/src/util/SecurityDetector.ts b/src/util/SecurityDetector.ts deleted file mode 100644 index a638300..0000000 --- a/src/util/SecurityDetector.ts +++ /dev/null @@ -1,210 +0,0 @@ -import type { Page } from 'playwright' - -/** - * Security incident detected during login/authentication - */ -export interface SecurityIncident { - kind: string - account: string - details?: string[] - next?: string[] - docsUrl?: string -} - -/** - * SecurityDetector: Centralized detection of login security blocks and anomalies - * Extracted from Login.ts for testability and separation of concerns - */ -export class SecurityDetector { - // Sign-in block patterns (Microsoft security messages) - private static readonly SIGN_IN_BLOCK_PATTERNS: { re: RegExp; label: string }[] = [ - { re: /we can[''`]?t sign you in/i, label: 'cant-sign-in' }, - { re: /incorrect account or password too many times/i, label: 'too-many-incorrect' }, - { re: /used an incorrect account or password too many times/i, label: 'too-many-incorrect-variant' }, - { re: /sign-in has been blocked/i, label: 'sign-in-blocked-phrase' }, - { re: /your account has been locked/i, label: 'account-locked' }, - { re: /your account or password is incorrect too many times/i, label: 'incorrect-too-many-times' } - ] - - /** - * Check if page contains sign-in blocked message - * Returns matched pattern label or null - */ - static async detectSignInBlocked(page: Page): Promise { - try { - let text = '' - const selectors = ['[data-testid="title"]', 'h1', 'div[role="heading"]', 'div.text-title'] - - for (const sel of selectors) { - const el = await page.waitForSelector(sel, { timeout: 600 }).catch(() => null) - if (el) { - const t = (await el.textContent() || '').trim() - if (t && t.length < 300) text += ' ' + t - } - } - - const lower = text.toLowerCase() - for (const p of SecurityDetector.SIGN_IN_BLOCK_PATTERNS) { - if (p.re.test(lower)) { - return p.label - } - } - - return null - } catch { - return null - } - } - - /** - * Parse masked email from Microsoft recovery prompts - * Returns { prefix: string, domain: string } or null - * Examples: "k*****@domain.com" → { prefix: "k", domain: "domain.com" } - * "ko****@domain.com" → { prefix: "ko", domain: "domain.com" } - */ - static parseMaskedEmail(masked: string): { prefix: string; domain: string } | null { - // Pattern: 1-2 visible chars, then masked chars, then @domain - const regex = /([a-zA-Z0-9]{1,2})[a-zA-Z0-9*•._-]*@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/ - const match = regex.exec(masked) - - if (!match) { - // Fallback: try looser pattern - const loose = /([a-zA-Z0-9])[*•][a-zA-Z0-9*•._-]*@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/.exec(masked) - if (!loose) return null - return { - prefix: (loose[1] || '').toLowerCase(), - domain: (loose[2] || '').toLowerCase() - } - } - - return { - prefix: (match[1] || '').toLowerCase(), - domain: (match[2] || '').toLowerCase() - } - } - - /** - * Check if masked email matches expected recovery email - * Lenient matching: only compare visible prefix (1-2 chars) + domain - */ - static matchesMaskedEmail( - observed: { prefix: string; domain: string }, - expected: { prefix: string; domain: string } - ): boolean { - if (observed.domain !== expected.domain) return false - - // If only 1 char visible, match first char only - if (observed.prefix.length === 1) { - return expected.prefix.startsWith(observed.prefix) - } - - // If 2 chars visible, both must match - return expected.prefix === observed.prefix - } - - /** - * Extract all masked emails from page (candidates for recovery email check) - * Returns array of masked email strings found in DOM - */ - static async extractRecoveryCandidates(page: Page): Promise { - const candidates: string[] = [] - - // Priority 1: Direct selectors (Microsoft variants + French) - const directSelectors = [ - '[data-testid="recoveryEmailHint"]', - '#recoveryEmail', - '[id*="ProofEmail"]', - '[id*="EmailProof"]', - '[data-testid*="Email"]', - 'span:has(span.fui-Text)' - ] - - for (const sel of directSelectors) { - const el = await page.waitForSelector(sel, { timeout: 1000 }).catch(() => null) - if (el) { - const t = (await el.textContent() || '').trim() - if (t) candidates.push(t) - } - } - - // Priority 2: List items - const listItems = page.locator('[role="listitem"], li') - const count = await listItems.count().catch(() => 0) - for (let i = 0; i < Math.min(count, 12); i++) { - const t = (await listItems.nth(i).textContent().catch(() => ''))?.trim() || '' - if (t && /@/.test(t)) candidates.push(t) - } - - // Priority 3: XPath generic masked patterns - const xpath = page.locator('xpath=//*[contains(normalize-space(.), "@") and (contains(normalize-space(.), "*") or contains(normalize-space(.), "•"))]') - const xpCount = await xpath.count().catch(() => 0) - for (let i = 0; i < Math.min(xpCount, 12); i++) { - const t = (await xpath.nth(i).textContent().catch(() => ''))?.trim() || '' - if (t && t.length < 300) candidates.push(t) - } - - // Priority 4: Full HTML scan fallback - if (candidates.length === 0) { - try { - const html = await page.content() - const generic = /[A-Za-z0-9]{1,4}[*•]{2,}[A-Za-z0-9*•._-]*@[A-Za-z0-9.-]+/g - const frPhrase = /Nous\s+enverrons\s+un\s+code\s+à\s+([^<@]*[A-Za-z0-9]{1,4}[*•]{2,}[A-Za-z0-9*•._-]*@[A-Za-z0-9.-]+)[^.]{0,120}?Pour\s+vérifier/gi - - const found = new Set() - let m: RegExpExecArray | null - while ((m = generic.exec(html)) !== null) found.add(m[0]) - while ((m = frPhrase.exec(html)) !== null) { - const raw = m[1]?.replace(/<[^>]+>/g, '').trim() - if (raw) found.add(raw) - } - - candidates.push(...Array.from(found)) - } catch { - /* ignore HTML scan errors */ - } - } - - // Deduplicate - const seen = new Set() - return candidates - .map(s => s.replace(/\s+/g, ' ').trim()) - .filter(t => t && !seen.has(t) && seen.add(t)) - .filter(t => /@/.test(t) && /[*•]/.test(t)) // Must be masked email - } - - /** - * Check if page contains account locked indicator - */ - static async detectAccountLocked(page: Page): Promise { - return await page.waitForSelector('#serviceAbuseLandingTitle', { timeout: 1200 }) - .then(() => true) - .catch(() => false) - } - - /** - * Parse email into { prefix: first 2 chars, domain } - */ - static parseEmailReference(email: string): { prefix: string; domain: string } | null { - if (!email || !/@/.test(email)) return null - - const [local, domain] = email.split('@') - if (!local || !domain) return null - - return { - prefix: local.slice(0, 2).toLowerCase(), - domain: domain.toLowerCase() - } - } - - /** - * Get documentation URL for security incident - */ - static getDocsUrl(anchor?: string): string { - const base = process.env.DOCS_BASE?.trim() || 'https://github.com/Obsidian-wtf/Microsoft-Rewards-Bot/blob/main/docs/security.md' - const map: Record = { - 'recovery-email-mismatch': '#recovery-email-mismatch', - 'we-cant-sign-you-in': '#we-cant-sign-you-in-blocked' - } - return anchor && map[anchor] ? `${base}${map[anchor]}` : base - } -} diff --git a/src/util/Utils.ts b/src/util/Utils.ts index f862e2e..f63cc43 100644 --- a/src/util/Utils.ts +++ b/src/util/Utils.ts @@ -1,8 +1,12 @@ import ms from 'ms' +export function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + export class Util { - async wait(ms: number): Promise { + wait(ms: number): Promise { const MAX_WAIT_MS = 3600000 // 1 hour max to prevent infinite waits const MIN_WAIT_MS = 0