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.
This commit is contained in:
2025-11-04 20:34:34 +01:00
parent c256f5265e
commit c74b131ac6
8 changed files with 76 additions and 1462 deletions

View File

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

View File

@@ -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<string>()
const seenProxies = new Map<string, string[]>() // 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
}
}

View File

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

View File

@@ -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<string | null> {
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<string[]> {
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<string>()
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<string>()
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<boolean> {
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<string, string> = {
'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
}
}

View File

@@ -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<void> {
wait(ms: number): Promise<void> {
const MAX_WAIT_MS = 3600000 // 1 hour max to prevent infinite waits
const MIN_WAIT_MS = 0