#!/usr/bin/env python3
# Copyright (C) 2026 Photon ehf.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
#
# SPDX-License-Identifier: GPL-3.0-or-later

"""Command-line version of the php2shell.is website checker (/check/), in Python. by Photon - photon.is

Usage:
  pton-scan-php2shell example.is                one website (https, falls back to http)
  pton-scan-php2shell https://example.is/blog/  one URL
  pton-scan-php2shell 192.0.2.10                one IP address
  pton-scan-php2shell 192.0.2.0/24              a range (CIDR) ...
  pton-scan-php2shell 192.0.2.10-50             ... or a.b.c.d-e / a.b.c.d-a.b.c.e
  pton-scan-php2shell -f targets.txt            one target per line ('-' reads stdin, '#' comments)

Options:
  --json            one JSON line per target: the main results
  --full            one JSON line per target: everything found (all headers, evidence)
  --csv             CSV (for a spreadsheet)
  -j N              checks in parallel (default 16)
  --scheme S        auto (default: https, then http if that fails), https or http
  --all             also list hosts in a range that did not answer (hidden by default)
  --public-only     refuse private/internal addresses, as the website does
                    (by default all addresses are allowed: the scanner is run internally)
  --max N           refuse to scan more than N targets (default 4096)

Exit status: 0 = nothing vulnerable found, 1 = at least one vulnerable/unsupported/EOL result, 2 = usage error.
Only scan systems you are responsible for or have permission to test.
"""
import argparse
import concurrent.futures as cf
import html
import http.client
import ipaddress
import json
import os
import re
import socket
import ssl
import sys
import tempfile
import time
import zlib
from datetime import datetime, timezone
from urllib.parse import urlsplit

MAX_REDIRECTS   = 5
CONNECT_TIMEOUT = 5
TOTAL_TIMEOUT   = 10
MAX_BODY_BYTES  = 262144   # only scanned for <meta name="generator">
USER_AGENT      = 'php2shell.is-checker/1.0 (+https://php2shell.is/check/)'

# First fixed upstream release per branch (as FIXED in api.php).
FIXED = {'8.2': 35, '8.3': 36, '8.4': 27, '8.5': 12}

# Distro package builds known to contain the fixes although the upstream version is below
# the fixed release (as KNOWN_PATCHED_BUILDS in api.php), e.g. '8.2.29-1~deb12u2'.
KNOWN_PATCHED_BUILDS = []

# verdict -> (short label, colour); same labels as bin/checklog
VERDICTS = {
    'likely_vulnerable':    ('VULNERABLE', 'r'),
    'unsupported':          ('unsupported PHP', 'r'),
    'eol_os':               ('EOL OS', 'r'),
    'distro_verify_eol':    ('verify distro pkg (old PHP)', 'y'),
    'distro_verify':        ('verify distro pkg', 'y'),
    'unknown_branch':       ('unknown PHP branch', 'y'),
    'likely_patched':       ('patched', 'g'),
    'patched_distro_build': ('patched (distro)', 'g'),
    'php_hidden':           ('PHP hidden', ''),
    'no_php_detected':      ('no PHP seen', ''),
    'not_php':              ('not PHP', ''),
}
BAD = {'likely_vulnerable', 'unsupported', 'eol_os'}
# Errors meaning "nothing answered": worth retrying over http, and hidden for range scans.
NO_ANSWER = {'connect_failed', 'timeout', 'tls_failed', 'fetch_failed'}

SCHEME_RE = re.compile(r'^[a-z][a-z0-9+.-]*://', re.I)


def usage(msg=''):
    if msg:
        print(f'pton-scan-php2shell: {msg}\n', file=sys.stderr)
    print(__doc__.strip(), file=sys.stderr)
    sys.exit(2)


def now_iso():
    return datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%S+00:00')


def unique(items):
    """array_values(array_unique(...)): keep the first of each, in order."""
    return list(dict.fromkeys(items))


# ---------- URL validation and SSRF protection ----------

def normalise_url(text):
    text = text.strip()
    if not text or len(text) > 2048:
        return None
    if not SCHEME_RE.match(text):
        text = 'https://' + text
    try:
        p = urlsplit(text)
        port = p.port
    except ValueError:
        return None
    if not p.hostname or p.username is not None or p.password is not None:
        return None
    scheme = p.scheme.lower()
    if scheme not in ('http', 'https'):
        return None
    default = 443 if scheme == 'https' else 80
    port = port if port is not None else default
    if port not in (80, 443):
        return None
    host = p.hostname.rstrip('.').strip('[]').lower()
    if re.search(r'[^\x20-\x7e]', host):
        try:
            host = host.encode('idna').decode('ascii')
        except UnicodeError:
            pass
    path = (p.path or '/') + ('?' + p.query if p.query else '')
    host_for_url = f'[{host}]' if is_ip(host, 6) else host
    url = f'{scheme}://{host_for_url}' + ('' if port == default else f':{port}') + path
    return {'url': url, 'scheme': scheme, 'host': host, 'port': port}


def is_ip(text, version=None):
    try:
        ip = ipaddress.ip_address(text)
    except ValueError:
        return False
    return version is None or ip.version == version


def is_public_ip(text):
    try:
        ip = ipaddress.ip_address(text)
    except ValueError:
        return False
    if not ip.is_global:
        return False
    if ip.version == 6:
        b = ip.packed
        if (b[:12] == b'\0' * 10 + b'\xff\xff'                   # ::ffff:a.b.c.d
                or b[:12] == b'\x00\x64\xff\x9b' + b'\0' * 8     # 64:ff9b::/96
                or b[:2] == b'\x20\x02'):                        # 2002::/16 (6to4)
            return False
    return True


def nameservers():
    try:
        with open('/etc/resolv.conf') as f:
            return [p[1] for p in (line.split() for line in f) if len(p) > 1 and p[0] == 'nameserver' and is_ip(p[1])]
    except OSError:
        return []


def dns_query(name, qtype, servers):
    """Addresses of type qtype (1 = A, 28 = AAAA) for name, asked straight from DNS like PHP's
    dns_get_record() (so /etc/hosts and nss-myhostname do not count). None if no server answered."""
    qid = int.from_bytes(os.urandom(2), 'big')
    qname = b''.join(bytes([len(label)]) + label for label in name.encode('ascii').split(b'.') if label) + b'\0'
    packet = qid.to_bytes(2, 'big') + b'\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00' + qname + qtype.to_bytes(2, 'big') + b'\x00\x01'

    def skip_name(msg, pos):
        while msg[pos]:
            if msg[pos] & 0xC0 == 0xC0:
                return pos + 2
            pos += msg[pos] + 1
        return pos + 1

    for server in servers:
        family = socket.AF_INET6 if ':' in server else socket.AF_INET
        try:
            with socket.socket(family, socket.SOCK_DGRAM) as s:
                s.settimeout(2)
                s.sendto(packet, (server, 53))
                msg = s.recv(4096)
            if msg[2] & 0x02:   # truncated: ask again over TCP
                with socket.create_connection((server, 53), timeout=3) as s:
                    s.sendall(len(packet).to_bytes(2, 'big') + packet)
                    data = b''
                    while len(data) < 2 or len(data) < 2 + int.from_bytes(data[:2], 'big'):
                        chunk = s.recv(4096)
                        if not chunk:
                            break
                        data += chunk
                    msg = data[2:]
            if msg[:2] != packet[:2]:
                continue
            if msg[3] & 0x0F not in (0, 3):   # not NOERROR/NXDOMAIN: try the next server
                continue
            ancount = int.from_bytes(msg[6:8], 'big')
            pos = skip_name(msg, 12) + 4
            found = []
            for _ in range(ancount):
                pos = skip_name(msg, pos)
                rtype, rdlen = int.from_bytes(msg[pos:pos + 2], 'big'), int.from_bytes(msg[pos + 8:pos + 10], 'big')
                rdata = msg[pos + 10:pos + 10 + rdlen]
                if rtype == qtype and len(rdata) in (4, 16):
                    found.append(socket.inet_ntop(socket.AF_INET if len(rdata) == 4 else socket.AF_INET6, rdata))
                pos += 10 + rdlen
            return found
        except (OSError, IndexError, ValueError):
            continue
    return None


def lookup(host):
    """IPv4 then IPv6 addresses of host."""
    servers = nameservers()
    if servers:
        v4, v6 = dns_query(host, 1, servers), dns_query(host, 28, servers)
        if v4 is not None or v6 is not None:
            return unique((v4 or []) + (v6 or []))
    # No usable resolv.conf (e.g. Windows): use the operating system's resolver.
    found = []
    for family in (socket.AF_INET, socket.AF_INET6):
        try:
            found += [info[4][0] for info in socket.getaddrinfo(host, None, family, socket.SOCK_STREAM)]
        except (socket.gaierror, UnicodeError):
            pass
    return unique(found)


def resolve_public(host, allow_private=False):
    """Resolve host and return one IP to pin the connection to, or an error code."""
    allowed = is_ip if allow_private else is_public_ip
    if is_ip(host):
        return {'ip': host} if allowed(host) else {'error': 'blocked_address'}
    if not re.fullmatch(r'[a-z0-9.-]+', host) or '.' not in host:
        return {'error': 'invalid_url'}
    found = lookup(host)
    if not found:
        return {'error': 'dns_failed'}
    # If any address is non-public, refuse: the name could be rebound to it.
    if not all(allowed(ip) for ip in found):
        return {'error': 'blocked_address'}
    return {'ip': found[0]}


# ---------- Fetching ----------

class FetchError(Exception):
    def __init__(self, code, verify_failed=False):
        super().__init__(code)
        self.code = code
        self.verify_failed = verify_failed


def format_name(rdns):
    short = {'countryName': 'C', 'stateOrProvinceName': 'ST', 'localityName': 'L', 'organizationName': 'O',
             'organizationalUnitName': 'OU', 'commonName': 'CN', 'emailAddress': 'emailAddress'}
    return ', '.join(f'{short.get(k, k)} = {v}' for rdn in rdns for k, v in rdn)


def cert_info(sock, verified):
    """Subject, issuer and expiry in the same form curl reports them to api.php."""
    try:
        cert = sock.getpeercert() if verified else None
        if not cert:
            der = sock.getpeercert(binary_form=True)
            if not der:
                return None
            # The ssl module only decodes certificates from a file.
            with tempfile.NamedTemporaryFile('w', suffix='.pem') as f:
                f.write(ssl.DER_cert_to_PEM_cert(der))
                f.flush()
                cert = ssl._ssl._test_decode_cert(f.name)
        return {'subject': format_name(cert.get('subject', ())) or None,
                'issuer': format_name(cert.get('issuer', ())) or None,
                'expires': cert.get('notAfter')}
    except Exception:
        return None


def fetch_once(target, ip, verify_tls):
    """One request to a validated, pinned target. Does not follow redirects."""
    deadline = time.monotonic() + TOTAL_TIMEOUT
    try:
        sock = socket.create_connection((ip, target['port']), timeout=CONNECT_TIMEOUT)
    except socket.timeout:
        raise FetchError('timeout')
    except OSError:
        raise FetchError('connect_failed')
    cert = None
    try:
        sock.settimeout(max(0.1, deadline - time.monotonic()))
        if target['scheme'] == 'https':
            if verify_tls:
                ctx = ssl.create_default_context()
            else:
                ctx = ssl.create_default_context()
                ctx.check_hostname = False
                ctx.verify_mode = ssl.CERT_NONE
            try:
                sock = ctx.wrap_socket(sock, server_hostname=target['host'])
            except ssl.SSLCertVerificationError:
                raise FetchError('tls_failed', verify_failed=True)
            except socket.timeout:
                raise FetchError('timeout')
            except (ssl.SSLError, OSError):
                raise FetchError('tls_failed')
            cert = cert_info(sock, verify_tls)

        conn = http.client.HTTPConnection(target['host'], target['port'])
        conn.sock = sock
        u = urlsplit(target['url'])
        path = (u.path or '/') + ('?' + u.query if u.query else '')
        host_header = f"[{target['host']}]" if ':' in target['host'] else target['host']
        if target['port'] != (443 if target['scheme'] == 'https' else 80):
            host_header += f":{target['port']}"
        try:
            conn.putrequest('GET', path, skip_host=True, skip_accept_encoding=True)
            conn.putheader('Host', host_header)
            conn.putheader('User-Agent', USER_AGENT)
            conn.putheader('Accept', 'text/html,*/*;q=0.8')
            conn.putheader('Accept-Encoding', 'gzip, deflate')
            conn.endheaders()
            resp = conn.getresponse()
        except socket.timeout:
            raise FetchError('timeout')
        except (http.client.HTTPException, OSError):
            raise FetchError('fetch_failed')

        headers = {}
        for k, v in resp.msg.items():
            headers.setdefault(k.strip().lower(), []).append(v.strip())

        # Read (and decompress) at most MAX_BODY_BYTES; the cap is not an error.
        enc = (headers.get('content-encoding') or [''])[0].lower()
        dec = zlib.decompressobj(47) if enc in ('gzip', 'x-gzip', 'deflate') else None
        body = b''
        try:
            while len(body) <= MAX_BODY_BYTES:
                sock.settimeout(max(0.1, deadline - time.monotonic()))
                chunk = resp.read(16384)
                if not chunk:
                    break
                if dec:
                    try:
                        chunk = dec.decompress(chunk, MAX_BODY_BYTES + 1)
                    except zlib.error:
                        dec, chunk = None, b''
                body += chunk
                if time.monotonic() > deadline:
                    raise FetchError('timeout')
        except socket.timeout:
            raise FetchError('timeout')
        except (http.client.HTTPException, OSError):
            pass  # a broken body still leaves us the headers
        return {'status': resp.status, 'headers': headers, 'body': body, 'cert': cert}
    finally:
        try:
            sock.close()
        except OSError:
            pass


# ---------- Platform detection (OS, release, how PHP was built, containers/hosting) ----------

def first(h, name):
    v = h.get(name)
    return v[0] if v else None


def leading_float(text):
    """PHP's (float) cast: the leading number, or 0."""
    m = re.match(r'\s*[+-]?(\d+(\.\d*)?|\.\d+)', text or '')
    return float(m.group(0)) if m else 0.0


def ci(needle, haystack):
    return needle.lower() in (haystack or '').lower()


def detect_platform(server, powered_by, php, h, cookies):
    """Best-effort platform detection from headers only (see detect_platform() in api.php)."""
    srv = server or ''
    allh = ' '.join([srv] + powered_by)
    suffix = php['suffix'] if php else None

    p = {
        'os': None, 'os_name': None, 'release': None, 'confidence': None, 'eol': None,
        'php_source': 'unknown', 'container': None, 'hosting': None, 'hosting_kind': None,
        'evidence': [], 'guidance': 'generic',
    }

    # 1. OS named in Server / X-Powered-By. Order matters: specific before generic.
    named = [
        ('centos_stream', 'CentOS Stream',            r'\(CentOS Stream\)'),
        ('centos',        'CentOS Linux',             r'\(CentOS\)|centos linux'),
        ('rhel',          'Red Hat Enterprise Linux', r'red ?hat'),
        ('almalinux',     'AlmaLinux',                r'almalinux|\.alma\b'),
        ('rocky',         'Rocky Linux',              r'rocky'),
        ('oracle',        'Oracle Linux',             r'oracle'),
        ('amazon',        'Amazon Linux',             r'\(Amazon( Linux)?\)|amzn'),
        ('fedora',        'Fedora',                   r'fedora|\.fc\d+'),
        ('ubuntu',        'Ubuntu',                   r'ubuntu'),
        ('raspbian',      'Raspberry Pi OS',          r'raspbian'),
        ('debian',        'Debian',                   r'debian|deb\d+u\d+|\+deb\d+'),
        ('suse',          'SUSE / openSUSE',          r'suse'),
        ('gentoo',        'Gentoo',                   r'gentoo'),
        ('alpine',        'Alpine Linux',             r'alpine'),
        ('freebsd',       'FreeBSD',                  r'freebsd'),
        ('windows',       'Windows',                  r'win(32|64)|microsoft-iis'),
    ]
    # A sury.org build names the release it was built for, but the PHP itself is
    # third-party: detect the OS from the Server header first.
    for src in (srv, allh):
        hit = next(((i, n) for i, n, rx in named if src and re.search(rx, src, re.I)), None)
        if hit:
            p['os'], p['os_name'] = hit
            p['confidence'] = 'high'
            p['evidence'].append('os_in_header')
            break

    # 2. Release, where it can be inferred.
    m = re.search(r'OpenSSL/(\d+)\.(\d+)\.(\d+)', srv, re.I)
    openssl = (int(m[1]), int(m[2])) if m else None
    m = re.search(r'Apache/2\.4\.(\d+)', srv, re.I)
    apache = int(m[1]) if m else None
    rhel_like = p['os'] in ('rhel', 'almalinux', 'rocky', 'oracle', 'centos', 'centos_stream')

    if rhel_like:
        el = None
        if openssl:
            el = (7 if openssl == (1, 0) else 8 if openssl == (1, 1)
                  else 9 if openssl[0] == 3 and openssl[1] < 5 else 10 if openssl[0] == 3 else None)
        if not el and apache:
            el = 7 if apache <= 6 else 8 if apache == 37 else 9 if 51 <= apache <= 62 else 10 if apache >= 63 else None
        if el:
            p['release'] = str(el)
            p['evidence'].append('release_from_openssl' if openssl else 'release_from_apache')
        if p['os'] == 'centos' or el == 7:
            p['eol'] = 'eol'   # CentOS Linux 7/8 and all EL7 are past end of life

    if p['os'] in ('debian', 'raspbian', 'ubuntu') or (suffix and re.search(r'deb|ubuntu', suffix, re.I)):
        codenames = {'9': 'stretch', '10': 'buster', '11': 'bullseye', '12': 'bookworm', '13': 'trixie'}
        mu = re.search(r'ubuntu(?:0\.)?(\d{2}\.\d{2})', suffix or '', re.I)
        md = re.search(r'deb(\d+)u\d+|\+deb(\d+)', suffix or '', re.I)
        if suffix and mu:
            p['release'] = mu[1]
        elif suffix and md:
            n = md[1] or md[2]
            p['release'] = n + (f' ({codenames[n]})' if n in codenames else '')
        elif suffix and php:
            # Bare distro revisions such as "8.1.2-1ubuntu2.20": map from the PHP branch each release ships.
            ubuntu = {'7.2': '18.04', '7.4': '20.04', '8.1': '22.04', '8.3': '24.04'}
            debian = {'7.3': '10', '7.4': '11', '8.2': '12', '8.4': '13'}
            if ci('ubuntu', suffix) and php['branch'] in ubuntu:
                p['release'] = ubuntu[php['branch']]
            elif php['branch'] in debian and not ci('ubuntu', suffix):
                n = debian[php['branch']]
                p['release'] = f'{n} ({codenames[n]})'
        if p['release'] is not None:
            p['evidence'].append('release_from_php_build')
        num = leading_float(p['release'])
        if p['os'] == 'ubuntu' and num and num < 22.04:
            p['eol'] = 'esm'
        if p['os'] in ('debian', 'raspbian') and num and num <= 11:
            p['eol'] = 'eol'
    if p['os'] == 'fedora':
        m = re.search(r'\.fc(\d+)', allh, re.I)
        if m:
            p['release'] = m[1]

    # 3. How PHP was built.
    if php:
        if suffix and ci('sury', suffix):
            p['php_source'] = 'sury'
        elif suffix and re.search(r'deb|ubuntu|el\d|fc\d|suse|alma|-\d', suffix, re.I):
            p['php_source'] = 'distro'
        elif p['os'] in ('debian', 'raspbian', 'ubuntu'):
            # Debian/Ubuntu OS but no package revision: PHP compiled from source.
            p['php_source'] = 'upstream'
            p['container'] = 'official_php_image'
            p['evidence'].append('bare_php_on_debian')
        elif rhel_like or p['os'] in ('fedora', 'amazon'):
            p['php_source'] = 'ambiguous'
        elif re.search(r'Apache/[\d.]+ \(Unix\)', srv, re.I):
            p['php_source'] = 'upstream'
            p['container'] = 'source_build'
            p['evidence'].append('apache_unix_build')

    # 4. Managed hosting, PaaS and container platforms.
    xpb = ' '.join(h.get('x-powered-by', []))
    hosts = [
        ('WP Engine',            'managed', lambda: ci('wp engine', xpb) or 'wpe-backend' in h),
        ('Kinsta',               'managed', lambda: 'x-kinsta-cache' in h or 'ki-cache-type' in h),
        ('Pantheon',             'managed', lambda: 'x-pantheon-styx-hostname' in h),
        ('Acquia',               'managed', lambda: 'x-ah-environment' in h),
        ('WordPress.com / VIP',  'managed', lambda: 'x-hacker' in h or ci('wordpress.com', first(h, 'host-header'))),
        ('Heroku',               'paas',    lambda: ci('vegur', first(h, 'via'))),
        ('Fly.io',               'paas',    lambda: 'fly-request-id' in h),
        ('Google Cloud',         'paas',    lambda: ci('google frontend', srv) or 'x-cloud-trace-context' in h),
        ('Azure App Service',    'paas',    lambda: any(c.startswith('arraffinity') for c in cookies)),
        ('Render',               'paas',    lambda: 'rndr-id' in h),
        ('Kubernetes (ingress-nginx)', 'paas', lambda: 'ingresscookie' in cookies),
        ('Plesk',                'panel',   lambda: ci('plesk', xpb)),
    ]
    for name, kind, test in hosts:
        if test():
            p['hosting'], p['hosting_kind'] = name, kind
            p['evidence'].append('hosting_header')
            break

    # 5. Which advice to show.
    if p['hosting_kind'] == 'managed':            g = 'managed'
    elif p['container'] == 'official_php_image':  g = 'container'
    elif p['hosting_kind'] == 'paas':             g = 'paas'
    elif p['php_source'] == 'sury':               g = 'sury'
    elif p['os'] == 'centos':                     g = 'centos_eol'
    elif p['eol'] == 'eol':                       g = 'os_eol'
    elif p['eol'] == 'esm':                       g = 'ubuntu_esm'
    elif p['container'] == 'source_build':        g = 'source_build'
    elif p['os'] == 'raspbian':                   g = 'debian'
    elif p['os'] is not None:                     g = p['os']
    else:                                         g = 'generic'
    p['guidance'] = g
    p['evidence'] = unique(p['evidence'])
    return p


# ---------- Analysis ----------

def url_path(url):
    try:
        return urlsplit(url).path
    except ValueError:
        return ''


def analyse(hops, body, tls):
    h = hops[-1]['headers']

    # Collect evidence across all hops: X-Powered-By often appears on a redirect.
    powered_by, cookies, server = [], [], None
    for hop in hops:
        powered_by += hop['headers'].get('x-powered-by', [])
        cookies += [v.lstrip('=').split('=', 1)[0].lower() for v in hop['headers'].get('set-cookie', [])]
        server = first(hop['headers'], 'server') or server
    server = first(h, 'server') or server
    powered_by = unique(powered_by)
    cookies = unique(cookies)

    # PHP version from X-Powered-By (or occasionally Server, e.g. "Apache PHP/8.x").
    php = None
    for v in powered_by + [server or '']:
        m = re.search(r'PHP/(\d+)\.(\d+)\.(\d+)([^\s,;()]*)', v, re.I)
        if m:
            php = {
                'raw': f'PHP/{m[1]}.{m[2]}.{m[3]}{m[4]}', 'version': f'{m[1]}.{m[2]}.{m[3]}',
                'full': f'{m[1]}.{m[2]}.{m[3]}{m[4]}', 'branch': f'{m[1]}.{m[2]}',
                'patch': int(m[3]), 'suffix': m[4] or None,
            }
            break

    platform = detect_platform(server, powered_by, php, h, cookies)

    # Application fingerprints. The body is matched byte for byte, as PHP does.
    text = body.decode('latin-1')
    apps = []
    link = ' '.join(h.get('link', [])).lower()
    generator = None
    m = (re.search(r'''<meta[^>]+name=["']generator["'][^>]*content=["']([^"']{1,120})''', text, re.I)
         or re.search(r'''<meta[^>]+content=["']([^"']{1,120})["'][^>]*name=["']generator["']''', text, re.I))
    if m:
        generator = html.unescape(m[1].encode('latin-1').decode('utf-8', 'replace'))
    gen = generator or ''
    xgen = first(h, 'x-generator') or ''
    redirect_by = ' '.join(v for hop in hops for v in hop['headers'].get('x-redirect-by', []))
    if ('api.w.org' in link or 'x-pingback' in h or ci('wordpress', redirect_by)
            or any(re.match(r'(wordpress_|wp-settings)', c) for c in cookies) or ci('wordpress', gen)):
        apps.append('WordPress')
    if (ci('drupal', xgen) or 'x-drupal-cache' in h or 'x-drupal-dynamic-cache' in h
            or any(re.fullmatch(r's?sess[0-9a-f]{32}', c) for c in cookies) or ci('drupal', gen)):
        apps.append('Drupal')
    if (('x-content-encoded-by' in h and ci('joomla', first(h, 'x-content-encoded-by')))
            or ci('joomla', gen) or ci('joomla', redirect_by)):
        apps.append('Joomla')
    if ci('mediawiki', gen) or 'x-mediawiki' in h:
        apps.append('MediaWiki')
    if 'laravel_session' in cookies:
        apps.append('Laravel')
    if ci('typo3', gen):
        apps.append('TYPO3')

    # Hints that PHP is present even without a version.
    hints = []
    if php:
        hints.append('x-powered-by')
    if 'phpsessid' in cookies:
        hints.append('phpsessid')
    if 'laravel_session' in cookies:
        hints.append('laravel_session')
    if set(apps) & {'WordPress', 'Drupal', 'Joomla', 'MediaWiki', 'Laravel', 'TYPO3'}:
        hints.append('application')
    if any(re.search(r'\.php(\?|$|/)', url_path(hop['url']), re.I) for hop in hops):
        hints.append('php_url')
    if any(ci('plesk', v) for v in powered_by):
        hints.append('plesk')
    hints = unique(hints)

    # Non-PHP stacks.
    other = next((v for v in powered_by
                  if re.search(r'asp\.net|express|next\.js|servlet|jsf|nuxt|phusion passenger|django', v, re.I)), None)

    # CDN / reverse proxy in front.
    cdn = []
    sl = (server or '').lower()
    if 'cloudflare' in sl or 'cf-ray' in h:                       cdn.append('Cloudflare')
    if 'x-amz-cf-id' in h or 'cloudfront' in sl:                  cdn.append('Amazon CloudFront')
    if 'x-fastly-request-id' in h or (first(h, 'x-served-by') or '').lower().startswith('cache-'):
        cdn.append('Fastly')
    if 'x-sucuri-id' in h or 'sucuri' in sl:                      cdn.append('Sucuri')
    if 'x-akamai-transformed' in h or 'akamai' in sl:             cdn.append('Akamai')
    if 'x-azure-ref' in h:                                        cdn.append('Azure Front Door')
    if 'x-varnish' in h:                                          cdn.append('Varnish')
    if 'x-litespeed-cache' in h:                                  cdn.append('LiteSpeed Cache')
    if 'via' in h and not cdn:                                    cdn.append('Proxy (Via)')
    cdn = unique(cdn)

    # Verdict.
    fixed_in = f"{php['branch']}.{FIXED[php['branch']]}" if php and php['branch'] in FIXED else None
    maybe_backported = platform['php_source'] in ('distro', 'ambiguous')
    if php:
        maj, mnr = (int(x) for x in php['branch'].split('.'))
        if php['branch'] in FIXED and php['patch'] >= FIXED[php['branch']]:
            verdict = 'likely_patched'
        elif php['full'] in KNOWN_PATCHED_BUILDS:
            verdict = 'patched_distro_build'
        elif platform['eol'] == 'eol':
            verdict = 'eol_os'            # the OS gets no security updates at all
        elif php['branch'] in FIXED:
            verdict = 'distro_verify' if maybe_backported else 'likely_vulnerable'
        elif maj < 8 or (maj == 8 and mnr < 2):
            verdict = 'distro_verify_eol' if maybe_backported else 'unsupported'
        else:
            verdict = 'unknown_branch'
    elif other:
        verdict = 'not_php'
    elif hints:
        verdict = 'php_hidden'
    else:
        verdict = 'no_php_detected'

    # Selected headers to show the user.
    interesting = ['server', 'x-powered-by', 'x-generator', 'x-redirect-by', 'x-pingback', 'link', 'via',
                   'x-cache', 'cf-ray', 'x-varnish', 'x-litespeed-cache', 'x-drupal-cache', 'x-content-encoded-by',
                   'strict-transport-security', 'content-type']
    shown = {n: ', '.join(h[n]) for n in interesting if n in h}
    if cookies:
        shown['set-cookie (names)'] = ', '.join(cookies)

    return {
        'verdict': verdict,
        'fixed_in': fixed_in,
        'behind_cdn': bool(cdn),
        'server': server,
        'os': platform['os_name'],
        'platform': platform,
        'php': {'raw': php['raw'], 'version': php['version'], 'branch': php['branch'], 'build': php['suffix']} if php else None,
        'powered_by': powered_by,
        'php_hints': hints,
        'apps': unique(apps),
        'generator': generator,
        'other_stack': other,
        'cdn': cdn,
        'tls': tls,
        'headers': shown or [],   # PHP encodes an empty array as []
    }


# ---------- Check ----------

def php_dirname(path):
    """PHP's dirname() for URL paths ('/a/b' -> '/a', '/a/' -> '/', '/' -> '/')."""
    stripped = path.rstrip('/')
    if not stripped:
        return '/' if path.startswith('/') else '.'
    if '/' not in stripped:
        return '.'
    return stripped[:stripped.rindex('/')].rstrip('/') or '/'


def run_check(text, allow_private=False):
    """Follow up to MAX_REDIRECTS redirects, then analyse. Returns (HTTP status, response data)."""
    target = normalise_url(text)
    if not target:
        return 400, {'ok': False, 'error': 'invalid_url'}

    hops, tls, body = [], None, b''
    for i in range(MAX_REDIRECTS + 1):
        res = resolve_public(target['host'], allow_private)
        if 'error' in res:
            return (400, {'ok': False, 'error': res['error']}) if i == 0 else (502, {'ok': False, 'error': 'redirect_' + res['error']})
        ip = res['ip']

        tls_valid = True
        try:
            try:
                r = fetch_once(target, ip, True)
            except FetchError as e:
                if not (e.verify_failed and target['scheme'] == 'https'):
                    raise
                # Still read the headers, but report the certificate as invalid.
                r = fetch_once(target, ip, False)
                tls_valid = False
        except FetchError as e:
            return 502, {'ok': False, 'error': e.code}
        hops.append({'url': target['url'], 'ip': ip, 'status': r['status'], 'headers': r['headers']})
        tls = {'valid': tls_valid, **(r['cert'] or {})} if target['scheme'] == 'https' else None
        body = r['body']

        location = first(r['headers'], 'location')
        if 300 <= r['status'] < 400 and location:
            if i == MAX_REDIRECTS:
                break
            # Resolve relative redirects against the current URL.
            if not SCHEME_RE.match(location):
                host = f"[{target['host']}]" if ':' in target['host'] else target['host']
                base = f"{target['scheme']}://{host}" + ('' if target['port'] in (80, 443) else f":{target['port']}")
                if location.startswith('//'):
                    location = target['scheme'] + ':' + location
                elif location.startswith('/'):
                    location = base + location
                else:
                    location = base + php_dirname(url_path(target['url']) or '/').rstrip('/') + '/' + location
            nxt = normalise_url(location)
            if not nxt:
                break   # redirect leaves the allowed scope (other scheme/port); report what we have
            target = nxt
            continue
        break

    result = analyse(hops, body, tls)
    return 200, {
        'ok': True,
        'input': text,
        'final_url': hops[-1]['url'],
        'hops': [{'url': x['url'], 'status': x['status'], 'ip': x['ip']} for x in hops],
        'checked_at': now_iso(),
        **result,
    }


def log_record(text, status, data, start):
    """The checker log's record for one check (bin/checklog reads the same shape), without client details."""
    rec = {
        'time': now_iso(),
        'duration_ms': round((time.monotonic() - start) * 1000),
        'input': text,
        'status': status,
        'error': data.get('error'),
    }
    if data.get('ok'):
        rec['result'] = {
            'final_url': data['final_url'],
            'hops': data['hops'],
            'verdict': data['verdict'],
            'fixed_in': data['fixed_in'],
            'server': data['server'],
            'os': data['os'],
            'release': data['platform']['release'],
            'php_source': data['platform']['php_source'],
            'container': data['platform']['container'],
            'hosting': data['platform']['hosting'],
            'guidance': data['platform']['guidance'],
            'php': data['php']['raw'] if data['php'] else None,
            'php_hints': data['php_hints'],
            'apps': data['apps'],
            'cdn': data['cdn'],
            'tls_valid': (data['tls'] or {}).get('valid'),
        }
    return rec


def check(text, scheme, allow_private, full):
    """One target, as bin/scan's worker: https first, then http if nothing answered."""
    start = time.monotonic()
    has_scheme = bool(SCHEME_RE.match(text))
    host = f'[{text}]' if is_ip(text, 6) else text
    url = text if has_scheme else (f'https://{host}' if scheme == 'auto' else f'{scheme}://{host}')
    status, data = run_check(url, allow_private)
    if not has_scheme and scheme == 'auto' and data.get('error') in NO_ANSWER:
        status, data = run_check(f'http://{host}', allow_private)
    data['input'] = text
    return {'status': status, **data} if full else log_record(text, status, data, start)


# ---------- Targets ----------

def ip2long(text):
    try:
        return int(ipaddress.IPv4Address(text))
    except ValueError:
        return None


def long2ip(n):
    return str(ipaddress.IPv4Address(n))


def expand(arg, limit):
    """Expand one argument to a list of targets, and whether it was a range."""
    m = re.fullmatch(r'(\d+\.\d+\.\d+\.\d+)/(\d+)', arg)
    if m and ip2long(m[1]) is not None:
        bits = int(m[2])
        if bits > 32:
            usage(f'bad prefix length in {arg}')
        size = 2 ** (32 - bits)
        if size > limit:
            usage(f'{arg} is {size} addresses; the limit is {limit} (use --max)')
        net = ip2long(m[1]) & ((0xFFFFFFFF << (32 - bits)) & 0xFFFFFFFF)
        lo, hi = (net, net + size - 1) if bits >= 31 else (net + 1, net + size - 2)   # skip network/broadcast
        return [long2ip(n) for n in range(lo, hi + 1)], True
    m = re.fullmatch(r'(\d+\.\d+\.\d+\.(\d+))-(\d+(?:\.\d+\.\d+\.\d+)?)', arg)
    if m and ip2long(m[1]) is not None:
        lo = ip2long(m[1])
        if '.' in m[3]:
            hi = ip2long(m[3])
        else:
            hi = None if int(m[3]) > 255 else (lo & ~0xFF) | int(m[3])
        if hi is None or hi < lo:
            usage(f'bad range {arg}')
        if hi - lo + 1 > limit:
            usage(f'{arg} is {hi - lo + 1} addresses; the limit is {limit} (use --max)')
        return [long2ip(n) for n in range(lo, hi + 1)], True
    if '/' in arg and is_ip(arg.split('/', 1)[0], 6):
        usage(f'IPv6 ranges are not supported ({arg}); give single addresses')
    return [arg], False


# ---------- Output ----------

USE_COLOUR = sys.stdout.isatty() and not os.environ.get('NO_COLOR')
COLOURS = {'r': '\033[31;1m', 'y': '\033[33m', 'g': '\033[32m', 'd': '\033[2m'}


def paint(text, colour):
    return f'{COLOURS[colour]}{text}\033[0m' if USE_COLOUR and colour else text


def col(s, w):
    """Cut to w characters and pad to w + 1, so columns always have a space between them."""
    s = s[:w - 1] + '…' if len(s) > w else s
    return s + ' ' * (w + 1 - len(s))


def fields(rec):
    r = rec.get('result')
    if not r:
        return {'label': 'error: ' + (rec.get('error') or 'unknown'), 'colour': 'd',
                'php': '', 'os': '', 'server': '', 'extra': '', 'final': ''}
    label, colour = VERDICTS.get(r['verdict'], (r['verdict'], ''))
    if r['verdict'] in ('likely_vulnerable', 'distro_verify') and r['fixed_in']:
        label += f" (fix {r['fixed_in']})"
    extra = (r['apps'] + ([f"via {'+'.join(r['cdn'])}"] if r['cdn'] else []) + ([r['hosting']] if r['hosting'] else [])
             + (['bad TLS cert'] if r['tls_valid'] is False else []))
    return {
        'label': label,
        'colour': colour,
        'php': re.sub(r'^PHP/', '', r['php'] or ''),
        'os': f"{r['os'] or ''} {r['release'] or ''}".strip(),
        'server': r['server'] or '',
        'extra': ', '.join(extra),
        'final': r['final_url'],
    }


def print_row(rec):
    f = fields(rec)
    print(col(rec['input'], 28) + paint(col(f['label'], 30), f['colour']) + col(f['php'], 22) + col(f['os'], 26)
          + col(f['server'], 30) + f['extra'], flush=True)


def csv_line(values):
    """PHP fputcsv(): quote fields containing a delimiter, quote, space or line break."""
    out = []
    for v in values:
        v = str(v)
        out.append('"' + v.replace('"', '""') + '"' if re.search(r'[,"\s]', v) else v)
    print(','.join(out), flush=True)


def json_line(rec):
    print(json.dumps(rec, ensure_ascii=False, separators=(',', ':')), flush=True)


# ---------- Main ----------

def main():
    ap = argparse.ArgumentParser(add_help=False, usage=argparse.SUPPRESS)
    ap.add_argument('targets', nargs='*')
    ap.add_argument('-h', '--help', action='store_true')
    ap.add_argument('--json', dest='format', action='store_const', const='json')
    ap.add_argument('--full', dest='format', action='store_const', const='full')
    ap.add_argument('--csv', dest='format', action='store_const', const='csv')
    ap.add_argument('--all', action='store_true')
    ap.add_argument('--public-only', action='store_true')
    ap.add_argument('-j', type=int, default=16)
    ap.add_argument('--max', type=int, default=4096)
    ap.add_argument('--scheme', default='auto', choices=['auto', 'http', 'https'])
    ap.add_argument('-f', action='append', default=[])
    ap.error = usage
    opts = ap.parse_args()
    if opts.help:
        usage()
    fmt = opts.format or 'table'
    jobs, limit = max(1, opts.j), max(1, opts.max)

    args = list(opts.targets)
    for name in opts.f:
        try:
            text = sys.stdin.read() if name == '-' else open(name, encoding='utf-8', errors='replace').read()
        except OSError:
            usage(f'cannot read {name}')
        args += [line for line in (re.sub('#.*', '', raw).strip() for raw in text.splitlines()) if line]
    if not args:
        usage('no targets given')

    targets = []   # (input, from_range)
    for arg in args:
        items, is_range = expand(arg, limit)
        targets += [(t, is_range) for t in items]
    targets = unique(targets)
    if len(targets) > limit:
        usage(f'{len(targets)} targets; the limit is {limit} (use --max)')

    progress = sys.stderr.isatty() and len(targets) > 1
    if fmt == 'table':
        print(paint(col('TARGET', 28) + col('RESULT', 30) + col('PHP', 22) + col('OS', 26) + col('SERVER', 30)
                    + 'APPS / CDN / NOTES', 'd'), flush=True)
    elif fmt == 'csv':
        csv_line(['target', 'verdict', 'result', 'fixed_in', 'php', 'os', 'release', 'php_source', 'server', 'apps',
                  'cdn', 'hosting', 'tls_valid', 'final_url', 'error'])

    counts, hidden, bad = {}, 0, False

    def emit(i, rec):
        nonlocal hidden, bad
        err = rec.get('error')
        verdict = rec.get('verdict') if fmt == 'full' else (rec.get('result') or {}).get('verdict')
        key = verdict or f"error: {err or 'unknown'}"
        counts[key] = counts.get(key, 0) + 1
        bad = bad or verdict in BAD
        if not verdict and targets[i][1] and not opts.all and err in NO_ANSWER | {'blocked_address'}:
            hidden += 1
            return
        if fmt in ('json', 'full'):
            json_line(rec)
        elif fmt == 'csv':
            r = rec.get('result') or {}
            csv_line([rec['input'], r.get('verdict', ''), fields(rec)['label'], r.get('fixed_in') or '', r.get('php') or '',
                      r.get('os') or '', r.get('release') or '', r.get('php_source') or '', r.get('server') or '',
                      ' '.join(r.get('apps', [])), ' '.join(r.get('cdn', [])), r.get('hosting') or '',
                      json.dumps(r['tls_valid']) if r.get('tls_valid') is not None else '',
                      r.get('final_url') or '', rec.get('error') or ''])
        else:
            print_row(rec)

    def safe_check(text):
        try:
            return check(text, opts.scheme, not opts.public_only, fmt == 'full')
        except Exception as e:  # never let one target stop the scan
            return {'input': text, 'status': 0, 'error': f'scanner_failed: {e}'}

    # Run the checks in threads, -j at a time; print results in input order.
    pool = cf.ThreadPoolExecutor(max_workers=jobs)
    try:
        futures = [pool.submit(safe_check, t) for t, _ in targets]
        nxt = 0
        while nxt < len(futures):
            cf.wait(futures[nxt:], timeout=0.2, return_when=cf.FIRST_COMPLETED)
            while nxt < len(futures) and futures[nxt].done():
                if progress:
                    sys.stderr.write('\r\033[K')
                emit(nxt, futures[nxt].result())
                nxt += 1
            if progress:
                finished = sum(f.done() for f in futures)
                sys.stderr.write(f'\r\033[K{finished}/{len(targets)} checked …')
                sys.stderr.flush()
    except KeyboardInterrupt:
        pool.shutdown(wait=False, cancel_futures=True)
        if progress:
            sys.stderr.write('\r\033[K')
        print('pton-scan-php2shell: interrupted', file=sys.stderr)
        os._exit(130)
    pool.shutdown()
    if progress:
        sys.stderr.write('\r\033[K')

    # Summary (stderr for machine-readable formats so stdout stays clean).
    if len(targets) > 1:
        out = sys.stdout if fmt == 'table' else sys.stderr
        parts = [f'{n} {VERDICTS.get(k, (k,))[0]}' for k, n in sorted(counts.items(), key=lambda kv: -kv[1])]
        print(f"\n{len(targets)} targets: {', '.join(parts)}"
              + (f' ({hidden} addresses in ranges gave no answer or were refused; --all lists them)' if hidden else ''),
              file=out)

    # Disclaimer when PHP was not seen: absence of evidence is not evidence of absence.
    if counts.keys() & {'no_php_detected', 'php_hidden', 'not_php'}:
        def is_ip_target(t):
            return is_ip(urlsplit(t if SCHEME_RE.match(t) else f'http://{t}').hostname or '')

        note = [
            'Note: "no PHP seen", "PHP hidden" and "not PHP" do not mean the server is safe. Many servers',
            'hide the PHP version (expose_php = Off) or sit behind a proxy or CDN, so PHP can still be there.',
        ]
        if any(rng or is_ip_target(t) for t, rng in targets):
            note.append("An IP address only reaches the server's default website, not the other sites hosted on it.")
        note += ['For the best result scan each website by its URL (e.g. https://example.is/), and confirm the PHP',
                 'version on the server itself (php -v, or the package manager).']
        print('\n' + '\n'.join(note), file=sys.stdout if fmt == 'table' else sys.stderr)
    sys.exit(1 if bad else 0)


if __name__ == '__main__':
    try:
        main()
    except BrokenPipeError:   # output piped into e.g. head: stop quietly
        os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
        sys.exit(1)
