#!/usr/bin/env python3
"""Агрегатор паролей из стилер-логов в единый TSV (URL\tLOGIN\tPASSWORD).

Поддерживает три формата стилер-логов:
  A) Soft/Host/Login/Password  — классические крэкнутые стилеры (kir3info и др.)
  B) SOFT/URL/USER/PASS        — альтернативный формат
  C) Browser/URL/Username/Password с "------" разделителями — новые стилеры

Рекурсивно обходит Passwords/, BrowserData/ и аналогичные подкаталоги.
Фильтрует obfuscated Unicode-header (@kiʀ3infᴏ и подобные) и пустые entries.
"""

import os
import re
import sys
from pathlib import Path

# Unicode-obfuscated stealer-channel watermarks — не креды, пропускаем.
# Покрываем диапазоны combining-marks и математических Unicode-символов.
OBUFUSCATED_MARKERS = (
    "ki\u0300r3inf",          # @kiʀ3infᴏ variants (combining-grave)
    "kir3inf",                 # plain / de-obfuscated variant
    "ki\u02803inf",            # @kiʀ3infᴏ variant (Latin-Extended ʀ U+0280 replaces 'r')
    "kir3cloud",
    "kir3owner",
    "New channel:",
    "Usernames @kir3cloud",
)


def _is_obfuscated_noise(url: str, login: str) -> bool:
    """Detect Unicode-obfuscated stealer-channel watermark lines."""
    blob = (url + " " + login)
    return any(m in blob for m in OBUFUSCATED_MARKERS)


def parse_format_a(text: str) -> list[tuple[str, str, str]]:
    """Формат: Soft/Host/Login/Password блоки."""
    results = []
    blocks = re.split(r'\n(?=Soft:)', text)
    for block in blocks:
        host = login = password = ""
        for line in block.splitlines():
            if line.startswith("Host:"):
                host = line[5:].strip()
            elif line.startswith("Login:"):
                login = line[6:].strip()
            elif line.startswith("Password:"):
                password = line[9:].strip()
        if host and (login or password):
            if not _is_obfuscated_noise(host, login):
                results.append((host, login, password))
    return results


def parse_format_b(text: str) -> list[tuple[str, str, str]]:
    """Формат: SOFT/URL/USER/PASS блоки."""
    results = []
    blocks = re.split(r'\n(?=SOFT:)', text)
    for block in blocks:
        url = user = passw = ""
        for line in block.splitlines():
            if line.startswith("URL:"):
                url = line[4:].strip()
            elif line.startswith("USER:"):
                user = line[5:].strip()
            elif line.startswith("PASS:"):
                passw = line[5:].strip()
        if url and (user or passw):
            if not _is_obfuscated_noise(url, user):
                results.append((url, user, passw))
    return results


def parse_format_c(text: str) -> list[tuple[str, str, str]]:
    """Формат: Browser :/URL :/Username :/Password : с ------ разделителями."""
    results = []
    # Блоки разделяются строками из дефисов или явным "------ Passwords ------"
    blocks = re.split(r'\n-+ *\n', text)
    for block in blocks:
        url = user = passw = ""
        for line in block.splitlines():
            line = line.rstrip()
            # "Browser  : Edge", "URL      : https://..."
            m = re.match(r'^(URL|Username|Password|Browser)\s*:\s*(.*)$', line)
            if not m:
                continue
            key, val = m.group(1), m.group(2).strip()
            if key == "URL":
                url = val
            elif key == "Username":
                user = val
            elif key == "Password":
                passw = val
        if url and (user or passw):
            if not _is_obfuscated_noise(url, user):
                results.append((url, user, passw))
    return results


def parse_tsv(text: str) -> list[tuple[str, str, str]]:
    """Готовый TSV: URL\tUSER\tPASS."""
    results = []
    for line in text.splitlines():
        line = line.strip('﻿').strip()
        if not line:
            continue
        parts = line.split('\t')
        if len(parts) >= 3:
            url, user, pw = parts[0], parts[1], parts[2]
            if not _is_obfuscated_noise(url, user):
                results.append((url, user, pw))
    return results


def detect_and_parse(filepath: Path) -> list[tuple[str, str, str]]:
    """Определяет формат и парсит файл."""
    try:
        text = filepath.read_text(encoding='utf-8', errors='ignore')
    except Exception:
        return []

    if not text.strip():
        return []

    if filepath.suffix == '.tsv':
        return parse_tsv(text)

    # Format C: новые стилеры (Browser :/URL :/Username :/Password :)
    if re.search(r'^URL\s*:', text, re.M) and re.search(r'^Username\s*:', text, re.M):
        return parse_format_c(text)
    # Format B
    if 'SOFT:' in text and 'URL:' in text:
        return parse_format_b(text)
    # Format A
    if 'Soft:' in text and 'Host:' in text:
        return parse_format_a(text)
    # Fallback: raw TSV
    return parse_tsv(text)


# Файлы, из которых имеет смысл тянуть креды, в порядке предпочтения.
# Поддерживаются как корневые, так и вложенные (Passwords/, BrowserData/) пути.
CRED_FILE_NAMES = (
    "passwords.tsv",
    "passwords.txt",
    "passwords_unique.txt",
    "BrowserPasswords.txt",
    "Google Chrome_Default_passwords.txt",
    "Microsoft Edge_Default_passwords.txt",
)


def _candidate_files(dump_dir: Path) -> list[Path]:
    """Собирает все файлы с кредами в дампе (корень + подкаталоги)."""
    candidates: list[Path] = []
    seen_names: set[str] = set()
    # Сначала корень
    for name in CRED_FILE_NAMES:
        p = dump_dir / name
        if p.exists() and p.is_file():
            candidates.append(p)
            seen_names.add(name)
    # Потом подкаталоги Passwords/, BrowserData/, и любые *.txt с "password" в имени
    for p in dump_dir.rglob("*.txt"):
        if not p.is_file():
            continue
        name = p.name
        # Дедуп по имени: если уже брали из корня — пропускаем
        if name in seen_names:
            continue
        lower = name.lower()
        if "password" in lower or lower in ("browserpasswords.txt",):
            candidates.append(p)
            seen_names.add(name)
    # TSV из подкаталогов
    for p in dump_dir.rglob("*.tsv"):
        if p.is_file():
            candidates.append(p)
    return candidates


def aggregate(breach_dir: Path, output: Path):
    """Обходит все дампы, собирает пароли в единый файл."""
    all_creds: list[tuple[str, str, str]] = []
    seen = set()
    processed = 0
    skipped = 0

    for dump_dir in sorted(breach_dir.iterdir()):
        if not dump_dir.is_dir():
            continue

        parsed = False
        for cred_file in _candidate_files(dump_dir):
            creds = detect_and_parse(cred_file)
            if not creds:
                continue
            parsed = True
            for c in creds:
                key = (c[0], c[1], c[2])
                if key not in seen:
                    seen.add(key)
                    all_creds.append(c)

        if parsed:
            processed += 1
        else:
            skipped += 1

    with open(output, 'w', encoding='utf-8') as f:
        for url, login, password in all_creds:
            f.write(f"{url}\t{login}\t{password}\n")

    print(f"Дампов обработано: {processed}")
    print(f"Дампов без паролей: {skipped}")
    print(f"Уникальных кредов: {len(all_creds)}")
    print(f"Результат: {output}")


if __name__ == "__main__":
    if len(sys.argv) < 3:
        print(f"Usage: {sys.argv[0]} <breach_dir> <output.tsv>")
        sys.exit(1)

    breach_dir = Path(sys.argv[1])
    output = Path(sys.argv[2])

    if not breach_dir.is_dir():
        print(f"Error: {breach_dir} is not a directory")
        sys.exit(1)

    aggregate(breach_dir, output)
