#!/usr/bin/env python3
"""Fox DSS PHP Finder. Explicit-scope, unauthenticated HTTP PHP inventory.

Python 3.9+, stdlib only. Reads response headers only: no body download,
no redirect following, no phpinfo/config retrieval.
"""
import argparse
import concurrent.futures
import csv
import datetime
import http.client
import ipaddress
import json
import os
import re
import shutil
import socket
import ssl
import sys
import time
import urllib.parse
from pathlib import Path

VERSION = '1.4'
FIELDS = ['target', 'port', 'url', 'http_status', 'php_signal', 'php_version',
          'version_assessment', 'evidence', 'server', 'location', 'error']
PHP = re.compile(r'\bPHP(?:/|\s+)(\d+\.\d+(?:\.\d+)?(?:[a-zA-Z0-9.+_~-]*))?', re.I)
LABELS = {'php_declared': 'PHP sürümü görünen', 'possible_php': 'Olası PHP',
          'no_php_evidence': 'PHP izi yok', 'unknown': 'Web yanıtı yok'}
# PHP2Shell (CVE-2026-109618 / CVE-2026-109619) first fixed releases, php.net 21.10.2026.
FIXED = {(8, 2): (8, 2, 35), (8, 3): (8, 3, 36), (8, 4): (8, 4, 27), (8, 5): (8, 5, 12)}
ASSESS = {  # key: (rank, colour role, text)
    'eol': (5, 'bad', 'Desteklenmeyen sürüm, yükseltme planlayın'),
    'update': (4, 'warn', 'Güncelleme gerekebilir ({}+)'),
    'partial': (3, 'note', 'Sürüm eksik görünüyor, sunucuda kontrol edin'),
    'hidden': (2, 'note', 'Sürüm gizli, sunucuda kontrol edin'),
    'fixed': (1, 'ok', 'Düzeltilmiş sürüm görünüyor'),
}


def assess_versions(versions):
    """Compare declared versions with the fixed releases. Returns (key, text)."""
    worst, note = 'hidden', ASSESS['hidden'][2]
    for value in filter(None, versions.split(',')):
        match = re.match(r'(\d+)\.(\d+)(?:\.(\d+))?', value)
        if not match:
            continue
        major, minor, patch = match.groups()
        branch = (int(major), int(minor))
        if patch is None:
            key, text = 'partial', ASSESS['partial'][2]
        elif branch < (8, 2):
            key, text = 'eol', ASSESS['eol'][2]
        elif branch in FIXED and branch + (int(patch),) < FIXED[branch]:
            key, text = 'update', ASSESS['update'][2].format('.'.join(map(str, FIXED[branch])))
        else:
            key, text = 'fixed', ASSESS['fixed'][2]
        if worst == 'hidden' or ASSESS[key][0] > ASSESS[worst][0]:
            worst, note = key, text
    return worst, note


def endpoint_findings(rows):
    grouped = {}
    for row in rows:
        grouped.setdefault((row['target'], row['port']), []).append(row)
    rank = {'unknown': 0, 'no_php_evidence': 1, 'possible_php': 2, 'php_declared': 3}
    return [(max(items, key=lambda r: rank[r['php_signal']])['php_signal'], items)
            for _, items in sorted(grouped.items())]


def endpoint_versions(items):
    return ','.join(sorted({v for r in items for v in r['php_version'].split(',') if v}))


# Fox head, 23x18 pixels, drawn with half blocks (two pixels per character row).
# O fur, c ear inner, W cheeks and muzzle, k eyes and nose.
FOX = [
    'O.....................O',
    'OO...................OO',
    'OcO.................OcO',
    'OccO...............OccO',
    'OcccO.............OcccO',
    'OccccOOOOOOOOOOOOOccccO',
    'OOOOOOOOOOOOOOOOOOOOOOO',
    'OOOOOOOOOOOOOOOOOOOOOOO',
    'OOOOOkkOOOOOOOOOkkOOOOO',
    'WOOOOOkkOOOOOOOkkOOOOOW',
    'WWWOOOOOOOOOOOOOOOOOWWW',
    '.WWWWOOOOOOOOOOOOOWWWW.',
    '..WWWWWOOOOOOOOOWWWWW..',
    '...WWWWWWOOOOOWWWWWW...',
    '.....WWWWWWOWWWWWW.....',
    '.......WWWWWWWWW.......',
    '........WWkkkWW........',
    '..........WkW..........',
]
FOX_ASCII = [
    '   /\\         /\\',
    '  /  \\_______/  \\',
    '  \\   o     o   /',
    '   \\     V     /',
    "    `-.__^__.-'",
    '',
]
PIXEL_COLORS = {'O': 166, 'c': 88, 'W': 230, 'k': 236}
# 5x5 pixel font for the wordmark.
FONT = {
    'F': ['####', '#...', '###.', '#...', '#...'],
    'O': ['.##.', '#..#', '#..#', '#..#', '.##.'],
    'X': ['#...#', '.#.#.', '..#..', '.#.#.', '#...#'],
    'D': ['###.', '#..#', '#..#', '#..#', '###.'],
    'S': ['.###', '#...', '.##.', '...#', '###.'],
    ' ': ['..', '..', '..', '..', '..'],
}
ASCII_MAP = str.maketrans({
    'ç': 'c', 'Ç': 'C', 'ğ': 'g', 'Ğ': 'G', 'ı': 'i', 'İ': 'I', 'ö': 'o', 'Ö': 'O',
    'ş': 's', 'Ş': 'S', 'ü': 'u', 'Ü': 'U', '─': '-', '│': '|', '┌': '+', '┐': '+',
    '└': '+', '┘': '+', '█': '#', '░': '.', '●': '*', '○': 'o', '»': '>', '·': '-',
    '▀': '"', '▄': '_', '…': '...'})
ANSI = re.compile(r'\033\[[0-9;?]*[A-Za-z]')


class TerminalUI:
    """Dependency-free terminal output with colour, Unicode and plain fallbacks."""
    ROLES = {'brand': '38;5;166', 'copper': '38;5;130', 'cream': '38;5;223', 'dim': '38;5;245',
             'bold': '1', 'ok': '38;5;71', 'warn': '1;38;5;214', 'bad': '1;38;5;203',
             'note': '38;5;179', 'php': '1;38;5;166', 'possible': '38;5;179'}

    def __init__(self, color='auto', live=True, ascii_only=False):
        try:  # Never crash on a console code page that lacks a character.
            sys.stdout.reconfigure(errors='replace')
        except (AttributeError, ValueError):
            pass
        self.tty = sys.stdout.isatty()
        self.ansi = self.tty and os.environ.get('TERM') != 'dumb' and self.enable_windows_vt()
        self.color = color == 'always' or (color == 'auto' and self.ansi and 'NO_COLOR' not in os.environ)
        self.live = live and self.tty
        try:
            '▀▄█─│●ş'.encode(sys.stdout.encoding or 'ascii')
            self.unicode = not ascii_only
        except (LookupError, UnicodeEncodeError):
            self.unicode = False
        self.status = ''

    @staticmethod
    def enable_windows_vt():
        if os.name != 'nt':
            return True
        try:
            import ctypes
            from ctypes import wintypes
            kernel = ctypes.WinDLL('kernel32', use_last_error=True)
            kernel.GetStdHandle.argtypes = [wintypes.DWORD]
            kernel.GetStdHandle.restype = wintypes.HANDLE
            kernel.GetConsoleMode.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
            kernel.SetConsoleMode.argtypes = [wintypes.HANDLE, wintypes.DWORD]
            handle, mode = kernel.GetStdHandle(-11 & 0xffffffff), wintypes.DWORD()
            return bool(kernel.GetConsoleMode(handle, ctypes.byref(mode)) and
                        kernel.SetConsoleMode(handle, mode.value | 4))
        except (OSError, AttributeError):
            return False

    @staticmethod
    def clean(value):
        # Never let remote header/URL values inject terminal control sequences.
        return re.sub(r'[\x00-\x1f\x7f-\x9f]', ' ', str(value))

    @property
    def width(self):
        return max(40, min(shutil.get_terminal_size((90, 30)).columns, 100))

    def c(self, text, role):
        return '\033[{}m{}\033[0m'.format(self.ROLES[role], text) if self.color else text

    def out(self, text=''):
        if not self.unicode:
            text = text.translate(ASCII_MAP)
        if self.status:
            self.clear_status()
        print(text, flush=True)

    def clear_status(self):
        sys.stdout.write('\r\033[2K' if self.ansi else '\r' + ' ' * (self.width - 1) + '\r')
        self.status = ''

    def show_status(self, text):
        if not self.unicode:
            text = text.translate(ASCII_MAP)
        sys.stdout.write('\r' + ('\033[2K' if self.ansi else '') + text)
        sys.stdout.flush()
        self.status = text

    def prompt(self, text):
        return input(text if self.unicode else text.translate(ASCII_MAP))

    def rule(self, title=''):
        width = min(self.width, 78) - 4
        if title:
            line = '── ' + title + ' ' + '─' * max(2, width - len(title) - 4)
            self.out('  ' + self.c(line[:3], 'copper') + self.c(title, 'bold') + self.c(line[3 + len(title):], 'copper'))
        else:
            self.out('  ' + self.c('─' * width, 'copper'))

    # Header ---------------------------------------------------------------
    def pixel_rows(self, grid, colors):
        rows = []
        for top, bottom in zip(grid[::2], grid[1::2]):
            cells = []
            for a, b in zip(top, bottom):
                if not self.color:  # Monochrome: eyes and nose stay as gaps.
                    a, b = (x if x in 'OWc#' else '.' for x in (a, b))
                    cells.append({('.', '.'): ' '}.get((a, b)) or
                                 ('█' if a != '.' and b != '.' else '▀' if a != '.' else '▄'))
                    continue
                fa, fb = colors.get(a), colors.get(b)
                if fa is None and fb is None:
                    cells.append(' ')
                elif fb is None:
                    cells.append('\033[38;5;{}m▀\033[0m'.format(fa))
                elif fa is None:
                    cells.append('\033[38;5;{}m▄\033[0m'.format(fb))
                elif fa == fb:
                    cells.append('\033[38;5;{}m█\033[0m'.format(fa))
                else:
                    cells.append('\033[38;5;{};48;5;{}m▀\033[0m'.format(fa, fb))
            rows.append(''.join(cells))
        return rows

    def wordmark(self, text):
        grid = ['.'.join(FONT[ch][row] for ch in text) for row in range(5)] + ['']
        grid[5] = '.' * len(grid[0])
        return self.pixel_rows([r.replace('#', 'W') for r in grid], {'W': 230})

    def banner(self):
        tagline = 'Web servislerinde PHP keşfi ve PHP2Shell sürüm kontrolü'
        if self.width < 90:
            tagline = 'PHP keşfi ve sürüm kontrolü'
        if self.unicode:
            fox = self.pixel_rows(FOX, PIXEL_COLORS)
            pad = ' ' * len(FOX[0])
        else:
            fox, pad = [line.ljust(19) for line in FOX_ASCII], ' ' * 19
        right = [''] * 9
        if self.unicode and self.width >= 64:
            right[1:4] = self.wordmark('FOX DSS')
        else:
            right[2] = self.c('FOX DSS', 'bold')
        right[5] = self.c('PHP FINDER', 'php') + '  ' + self.c('v' + VERSION, 'dim')
        right[6] = self.c(tagline, 'cream')
        right[7] = self.c('Fox DSS · foxdss.com', 'dim')
        self.out()
        for left, text in zip(fox + [pad] * (9 - len(fox)), right):
            self.out('  ' + left + '   ' + text)
        self.out()
        self.rule()


def interactive_setup(args, ui):
    ui.out()
    ui.out('  ' + ui.c('1. Tarama ortamı', 'bold'))
    ui.out('     ' + ui.c('1', 'php') + '  İç ağ (Internal)')
    ui.out('     ' + ui.c('2', 'php') + '  Dış ağ (External)')
    ui.out('  ' + ui.c('Tarama her iki seçimde de bu bilgisayardan yapılır. Seçim yalnız rapor etiketidir.', 'dim'))
    if not args.mode:
        while True:
            choice = ui.prompt('  » Seçiminiz [1/2]: ').strip().lower()
            if choice in ('1', 'internal', '2', 'external'):
                args.mode = 'internal' if choice in ('1', 'internal') else 'external'
                break
            ui.out('  ' + ui.c('1 veya 2 yazıp Enter tuşuna basın.', 'warn'))
    ui.out()
    ui.out('  ' + ui.c('2. Hedef', 'bold'))
    ui.out('     Tek IP, ağ aralığı (CIDR) veya alan adı yazabilirsiniz. Birden fazlasını boşlukla ayırın.')
    ui.out('     Örnek  ' + ui.c('192.168.10.0/24   10.0.0.5   www.ornek.com', 'cream'))
    ui.out('  ' + ui.c('En fazla {} adres taranır (/24 = 254, /22 = 1022, /20 = 4094). Çıkmak için Ctrl+C.'.format(args.max_hosts), 'dim'))
    while True:
        values = ui.prompt('  » Hedef: ').strip().split()
        try:
            if not values:
                raise ValueError('En az bir hedef yazın.')
            targets_from(values, args.max_hosts)
        except ValueError as error:
            ui.out('  ' + ui.c(str(error), 'warn'))
            continue
        args.targets = values
        return


def output_prefix(args, values):
    # User input becomes a Windows-safe directory component, never a path.
    label = re.sub(r'[^a-zA-Z0-9._-]+', '_', values[0]).strip('._-')[:100] or 'hedef'
    if len(values) > 1:
        label += '_ve_{}_hedef'.format(len(values) - 1)
    label = 'hedef_' + label  # Also avoids Windows reserved names, e.g. CON.
    stamp = datetime.datetime.now().strftime('%Y%m%d_%H%M%S_%f')
    root = args.out.parent if args.out else Path(__file__).resolve().parent / 'sonuclar'
    name = args.out.name if args.out else 'Fox-DSS-PHP-Finder'
    folder = root / args.mode / label / stamp
    folder.mkdir(parents=True, exist_ok=False)
    return folder / name


def targets_from(values, limit):
    result = dict()
    for value in values:
        try:
            network = ipaddress.ip_network(value, strict=False)
        except ValueError:
            if '/' in value or not re.fullmatch(r'[a-zA-Z0-9_.-]+', value):
                raise ValueError('Geçersiz IP, ağ aralığı veya alan adı: ' + value)
            result[value] = None
        else:
            if network.num_addresses > limit + 2:
                count = (network.num_addresses if network.prefixlen >= network.max_prefixlen - 1
                         else network.num_addresses - (2 if network.version == 4 else 1))
                raise ValueError('{} yaklaşık {} adres içeriyor, sınır {} adres. Daha küçük bir ağ girin veya --max-hosts kullanın.'.format(value, count, limit))
            for address in network.hosts():
                result[str(address)] = None
        if len(result) > limit:
            raise ValueError('Toplam hedef sayısı {} adres sınırını aşıyor. Ağları ayrı ayrı tarayın veya --max-hosts kullanın.'.format(limit))
    return list(result)


def inspect_headers(headers, path='/'):
    evidence, versions = [], set()
    declared = False
    for name, value in headers:
        if name.lower() in ('server', 'x-powered-by'):
            # A token alone also counts as a server declaration, without a version.
            if re.search(r'\bPHP\b', value, re.I):
                declared = True
                evidence.append(name + ': ' + value)
                versions.update(m.group(1) for m in PHP.finditer(value) if m.group(1))
        if name.lower() == 'set-cookie' and re.search(r'(?:^|[;,]\s*)PHPSESSID=', value, re.I):
            # Do not persist session cookie values.
            evidence.append('Set-Cookie: PHPSESSID=<redacted>')
        if name.lower() == 'location' and re.search(r'\.php(?:[/?#]|$)', value, re.I):
            # Ignore redirects that only repeat the requested path (http -> https, canonical host).
            if urllib.parse.urlsplit(value).path != path:
                evidence.append('Location contains .php')
    signal = 'php_declared' if declared else ('possible_php' if evidence else 'no_php_evidence')
    return signal, ','.join(sorted(versions)), ' | '.join(evidence)


def scan_endpoint(target, port, args):
    try:
        with socket.create_connection((target, port), timeout=args.timeout):
            pass
    except OSError:
        return []
    rows = []
    authority = '[' + target + ']' if ':' in target else target
    schemes = ('https', 'http') if port in args.tls_ports else ('http', 'https')
    failures = []
    for scheme in schemes:
        responded = False
        for path in args.paths:
            conn = None
            url = '{}://{}:{}{}'.format(scheme, authority, port, path)
            try:
                if scheme == 'https':
                    # Internal self-signed certs are common; this is inventory only.
                    context = ssl._create_unverified_context()
                    conn = http.client.HTTPSConnection(target, port, timeout=args.timeout, context=context)
                else:
                    conn = http.client.HTTPConnection(target, port, timeout=args.timeout)
                conn.request('GET', path, headers={'User-Agent': 'Fox-DSS-PHP-Finder/' + VERSION,
                                                 'Connection': 'close', 'Accept': '*/*'})
                response = conn.getresponse()
                headers = response.getheaders()
                signal, version, evidence = inspect_headers(headers, path)
                rows.append(dict(zip(FIELDS, [target, port, url, response.status, signal,
                                             version, '', evidence, response.getheader('Server', ''),
                                             response.getheader('Location', ''), ''])))
                # No body download, no redirect following, no phpinfo/config retrieval.
                responded = True
            except (OSError, http.client.HTTPException, ValueError) as error:
                failures.append('{} {}: {}'.format(scheme, path, type(error).__name__))
            finally:
                if conn:
                    conn.close()
        if responded:
            break
    if not rows:
        rows.append(dict(zip(FIELDS, [target, port, '', '', 'unknown', '', '', '', '', '',
                                     'TCP open; ' + ' | '.join(failures)])))
    return rows


def csv_safe(value):
    # Web-controlled strings must not become spreadsheet formulas.
    if isinstance(value, str) and value.lstrip().startswith(('=', '+', '-', '@')):
        return "'" + value
    return value


def main():
    parser = argparse.ArgumentParser(
        prog='Fox-DSS-PHP-Finder.py',
        description='Fox DSS PHP Finder ' + VERSION + '. Web servislerinin HTTP başlıklarından PHP izini ve '
                    'açıklanan sürümü toplar, sürümü PHP2Shell düzeltilmiş sürümleriyle karşılaştırır.',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog='''KOLAY KULLANIM
  Windows        py -3 Fox-DSS-PHP-Finder.py
  Linux/macOS    python3 Fox-DSS-PHP-Finder.py
  Script önce ortamı (iç/dış ağ), sonra hedefi sorar.

ÖRNEKLER
  python3 Fox-DSS-PHP-Finder.py 192.168.10.0/24
  python3 Fox-DSS-PHP-Finder.py www.ornek.com --mode external
  python3 Fox-DSS-PHP-Finder.py --targets-file hedefler.txt --ports 80,443,8080,8443
  python3 Fox-DSS-PHP-Finder.py 192.168.64.0/18 --max-hosts 16384

SONUÇLAR
  PHP sürümü görünen   Server veya X-Powered-By başlığında PHP var.
  Olası PHP            PHPSESSID çerezi veya .php yönlendirmesi görüldü.
  PHP izi yok          Web yanıtında PHP izi görünmüyor. PHP olmadığını kanıtlamaz.
  Web yanıtı yok       Port açık, HTTP/HTTPS yanıtı alınamadı.

  Sürüm değerlendirmesi PHP2Shell düzeltilmiş sürümlerine göre yapılır:
  8.2.35, 8.3.36, 8.4.27, 8.5.12. Sürüm sunucunun kendi beyanıdır. Linux
  dağıtım paketlerinde yama, sürüm numarası değişmeden gelmiş olabilir.

Raporlar: sonuclar/<internal|external>/hedef_<hedef>/<tarih-saat>/ (CSV ve JSON)
Fox DSS · foxdss.com''')
    parser.add_argument('targets', nargs='*', help='IP, CIDR veya alan adı, birden fazla olabilir')
    parser.add_argument('--targets-file', type=Path, help='Satır başına bir hedef, # ile yorum')
    parser.add_argument('--ports', default='80,443,8000,8080,8081,8443,8888,9000',
                        help='Virgülle port listesi (varsayılan 80,443,8000,8080,8081,8443,8888,9000)')
    parser.add_argument('--tls-ports', default='443,8443', help='Önce HTTPS denenecek portlar')
    parser.add_argument('--paths', nargs='+', default=['/', '/index.php'], help='İstek yolları (varsayılan / /index.php)')
    parser.add_argument('--workers', type=int, default=24, help='Paralel iş sayısı (varsayılan 24)')
    parser.add_argument('--timeout', type=float, default=2.0, help='Bağlantı zaman aşımı, saniye (varsayılan 2)')
    parser.add_argument('--max-hosts', type=int, default=4096, help='En fazla hedef adres (varsayılan 4096)')
    parser.add_argument('--mode', choices=['internal', 'external'], help='Rapor etiketi, komut satırında varsayılan internal')
    parser.add_argument('--out', type=Path, help='Rapor kök klasörü ve dosya adı')
    parser.add_argument('--verbose', action='store_true', help='PHP izi olmayan yanıtları da ekranda göster')
    parser.add_argument('--color', choices=['auto', 'always', 'never'], default='auto', help='Renkler (varsayılan auto)')
    parser.add_argument('--ascii', action='store_true', help='Yalnız ASCII karakter kullan (eski konsollar için)')
    parser.add_argument('--no-dashboard', action='store_true', help='Canlı ilerleme satırını kapat')
    parser.add_argument('--version', action='version', version='Fox DSS PHP Finder ' + VERSION)
    args = parser.parse_args()
    interactive = not args.targets and not args.targets_file
    ui = TerminalUI(args.color, not args.no_dashboard, args.ascii)
    ui.banner()
    try:
        print_results(ui, *run_scan(args, parser, ui))
    finally:
        if interactive and os.name == 'nt' and ui.tty:
            # Double-clicked scripts would otherwise close before the results are read.
            try:
                input('  Pencereyi kapatmak için Enter tuşuna basın...')
            except (EOFError, KeyboardInterrupt):
                pass


def display_path(path):
    try:
        return str(path.resolve().relative_to(Path.cwd()))
    except ValueError:
        return str(path.resolve())


def run_scan(args, parser, ui):
    try:
        args.ports = sorted(set(int(p) for p in args.ports.split(',')))
        args.tls_ports = set(int(p) for p in args.tls_ports.split(','))
        if not args.ports or any(p < 1 or p > 65535 for p in args.ports + list(args.tls_ports)):
            raise ValueError('Portlar 1-65535 aralığında olmalı.')
        if not 1 <= args.workers <= 256 or args.timeout <= 0 or args.max_hosts < 1:
            raise ValueError('workers 1-256 arasında, timeout ve max-hosts pozitif olmalı.')
        if any(not p.startswith('/') or p.startswith('//') or '\r' in p or '\n' in p for p in args.paths):
            raise ValueError('Yollar tek / ile başlamalı ve satır sonu içermemeli.')
        if not args.targets and not args.targets_file:
            interactive_setup(args, ui)
        args.mode = args.mode or 'internal'
        values = list(args.targets)
        if args.targets_file:
            values.extend(line.split('#', 1)[0].strip() for line in
                          args.targets_file.read_text(encoding='utf-8-sig').splitlines()
                          if line.split('#', 1)[0].strip())
        targets = targets_from(values, args.max_hosts)
        if not targets:
            raise ValueError('En az bir hedef veya --targets-file gerekli.')
        args.out = output_prefix(args, values)
    except (OSError, ValueError) as error:
        parser.error(str(error))
    total = len(targets) * len(args.ports)
    ui.out()
    ui.out('  ' + ui.c('3. Tarama', 'bold'))
    plan = [('Ortam', 'İç ağ (Internal)' if args.mode == 'internal' else 'Dış ağ (External)'),
            ('Hedef', '{}  ({} adres)'.format(' '.join(values)[:60], len(targets))),
            ('Port', ', '.join(map(str, args.ports))),
            ('Rapor', display_path(args.out.parent))]
    for key, value in plan:
        ui.out('     {}  {}'.format(ui.c(key.ljust(6), 'dim'), ui.clean(value)))
    ui.out()
    started = time.monotonic()
    rows = []
    with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as executor:
        # Keep only workers*2 jobs in flight, including for larger explicit scopes.
        jobs = iter((target, port) for target in targets for port in args.ports)
        pending = set()
        finished = 0
        last = 0
        while True:
            while len(pending) < args.workers * 2:
                job = next(jobs, None)
                if job is None:
                    break
                pending.add(executor.submit(scan_endpoint, *job, args))
            if not pending:
                break
            done, pending = concurrent.futures.wait(pending, timeout=.25, return_when=concurrent.futures.FIRST_COMPLETED)
            for future in done:
                items = future.result()
                rows.extend(items)
                print_finding(ui, items, args.verbose)
            finished += len(done)
            now = time.monotonic()
            if now - last > .2 or finished == total:
                last = now
                progress(ui, finished, total, rows, now - started)
    if ui.status:
        ui.clear_status()
    rows.sort(key=lambda r: (r['target'], r['port'], r['url']))
    for row in rows:
        row['version_assessment'] = assess_versions(row['php_version'])[1] if row['php_signal'] != 'no_php_evidence' and row['php_signal'] != 'unknown' else ''
    args.out.parent.mkdir(parents=True, exist_ok=True)
    csv_path, json_path = Path(str(args.out) + '.csv'), Path(str(args.out) + '.json')
    with csv_path.open('w', encoding='utf-8-sig', newline='') as stream:
        writer = csv.DictWriter(stream, fieldnames=FIELDS)
        writer.writeheader()
        writer.writerows({key: csv_safe(value) for key, value in row.items()} for row in rows)
    summary = {signal: len({(r['target'], r['port']) for r in rows if r['php_signal'] == signal})
               for signal in ('php_declared', 'possible_php', 'no_php_evidence', 'unknown')}
    json_path.write_text(json.dumps({'scanned_at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
                                    'tool_version': VERSION, 'fixed_versions': {'.'.join(map(str, k)): '.'.join(map(str, v)) for k, v in FIXED.items()},
                                    'mode': args.mode, 'max_hosts': args.max_hosts, 'input_targets': values,
                                    'targets': targets, 'ports': args.ports, 'paths': args.paths,
                                    'summary_endpoint_counts': summary, 'results': rows},
                                   ensure_ascii=False, indent=2), encoding='utf-8')
    return rows, len(targets), len(args.ports), csv_path, json_path, time.monotonic() - started


def progress(ui, finished, total, rows, elapsed):
    findings = endpoint_findings(rows)
    php = sum(s == 'php_declared' for s, _ in findings)
    possible = sum(s == 'possible_php' for s, _ in findings)
    if ui.live:
        bar = 22
        filled = int(bar * finished / total)
        ui.show_status('  {} {}{} {:3.0f}%  {}/{}  {} {}  {} {}  {:02.0f}:{:02.0f}'.format(
            ui.c('Taranıyor', 'bold'), ui.c('█' * filled, 'brand'), ui.c('░' * (bar - filled), 'dim'),
            100 * finished / total, finished, total, ui.c('●', 'php'), php, ui.c('●', 'possible'), possible,
            *divmod(elapsed, 60)))
    elif finished == total or finished % 200 == 0:
        ui.out('  Taranıyor {}/{}  PHP {}  Olası {}'.format(finished, total, php, possible))


def print_finding(ui, items, verbose):
    if not items or not verbose:  # Default: findings appear once, in the final table.
        return
    signal, _ = endpoint_findings(items)[0]
    address = '{}:{}'.format(items[0]['target'], items[0]['port'])
    if signal == 'php_declared' or signal == 'possible_php':
        useful = [r for r in items if r['php_signal'] == signal]
        versions = endpoint_versions(useful)
        key, note = assess_versions(versions)
        shown = 'PHP ' + versions.replace(',', ', ') if versions else ('PHP, sürüm gizli' if signal == 'php_declared' else 'Olası PHP')
        ui.out('  {} {}  {}  {}'.format(ui.c('●', 'php' if signal == 'php_declared' else 'possible'),
                                        ui.clean(address).ljust(22), ui.c(ui.clean(shown).ljust(18), 'bold'),
                                        ui.c(note, ASSESS[key][1])))
    elif verbose:
        ui.out('  {} {}  {}'.format(ui.c('○', 'dim'), ui.clean(address).ljust(24), ui.c(LABELS[signal], 'dim')))


def print_results(ui, rows, hosts, ports, csv_path, json_path, elapsed):
    findings = endpoint_findings(rows)
    php = [(s, i) for s, i in findings if s in ('php_declared', 'possible_php')]
    keys = [assess_versions(endpoint_versions(i))[0] for s, i in php]
    count = {s: sum(x == s for x, _ in findings) for s in LABELS}
    ui.out()
    ui.rule('SONUÇ')
    ui.out('  {} adres · {} port · {:.1f} sn'.format(hosts, ports, elapsed))
    ui.out()
    lines = [
        ('php', count['php_declared'], 'PHP sürümü görünen servis'),
        ('warn', sum(k in ('update', 'eol') for k in keys), 'güncelleme gerekebilir'),
        ('possible', count['possible_php'], 'olası PHP, sürüm görünmüyor'),
        ('dim', count['no_php_evidence'], 'web servisi, PHP izi yok'),
        ('dim', count['unknown'], 'açık port, web yanıtı yok'),
    ]
    for role, value, text in lines:
        ui.out('    {} {}  {}'.format(ui.c('●', role), ui.c(str(value).rjust(4), 'bold'), text))
    ui.out()
    if php:
        ui.rule('BULUNAN PHP SERVİSLERİ')
        ui.out('  ' + ui.c('{:<24}  {:<14}  {}'.format('Adres', 'PHP sürümü', 'Ne yapılmalı'), 'dim'))
        order = {'eol': 0, 'update': 1, 'partial': 2, 'hidden': 3, 'fixed': 4}
        for (signal, items), key in sorted(zip(php, keys), key=lambda x: (order[x[1]], x[0][1][0]['target'])):
            versions = endpoint_versions(items)
            address = '{}:{}'.format(items[0]['target'], items[0]['port'])
            shown = versions.replace(',', ', ') if versions else ('gizli' if signal == 'php_declared' else 'gizli')
            ui.out('  {}  {}  {}'.format(ui.clean(address).ljust(24), ui.c(ui.clean(shown)[:14].ljust(14), 'bold'),
                                         ui.c(assess_versions(versions)[1], ASSESS[key][1])))
        ui.out()
    else:
        ui.out('  ' + ui.c('Bu kapsamda PHP izi görülmedi.', 'ok') + ' Web yanıtında görünmemesi PHP olmadığı anlamına gelmez.')
        ui.out()
    ui.rule('RAPORLAR')
    ui.out('    {}  {}'.format(ui.c('Klasör  ', 'dim'), display_path(csv_path.parent)))
    ui.out('    {}  {}  {}'.format(ui.c('Dosyalar', 'dim'), csv_path.name + ' (Excel)', json_path.name))
    ui.out()
    ui.out('  ' + ui.c('Sürümler sunucunun kendi beyanıdır. Linux paketlerinde yama sürüm numarası değişmeden', 'dim'))
    ui.out('  ' + ui.c('gelmiş olabilir. Kesin sonuç için sunucuda web PHP sürümünü ve paket yamasını kontrol edin.', 'dim'))
    ui.out('  ' + ui.c('Düzeltilmiş sürümler 8.2.35 · 8.3.36 · 8.4.27 · 8.5.12   Fox DSS · foxdss.com', 'dim'))
    ui.out()


if __name__ == '__main__':
    try:
        main()
    except (EOFError, KeyboardInterrupt):
        print('\n  İşlem durduruldu.')
        sys.exit(130)
