NK nekonic
2026-10-20-보안컴퓨터네트워크.md
posts20262026-10-20-보안컴퓨터네트워크.md

보안컴퓨터네트워크

Nekonic 1 min read
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
pcap_analyzer.py  -  보안컴퓨터네트워크 패킷 분석 도우미
=====================================================================
 * 외부 라이브러리 필요 없음 (파이썬 3.7 이상, 표준 라이브러리만 사용)
 * pcap / pcapng 지원
 * 링크 계층: Ethernet, Cisco HDLC(GNS3 시리얼 링크 캡처), Raw IP,
              Linux cooked(SLL/SLL2), Loopback, PPP

사용법 (이것 하나뿐, 옵션 없음)
  python pcap_analyzer.py 폴더

  → 폴더 안 pcap 전부 분석 → 목록 화면. 키 하나로 동작, 화면 맨 아래에 쓸 수 있는 키가 항상 표시됨
    ↑↓ 고르기   Enter 열기   ←→ 탭/이전·다음 패킷   q 뒤로(첫 화면에서는 종료)   ? 도움말
    목록 화면   숫자 = 그 파일 열기, d 전체 답안, c 계정·비번, f Wireshark 필터, s 글자 검색, w txt 저장
    파일 화면   탭 1 답안 2 중요 패킷 3 전체 패킷 4 TCP 대화 5 통계 6 파일 추출,  [ ] 이전/다음 파일
    패킷 해부   바이트를 계층·필드별 색으로 칠하고, 옆에 색의 의미와 필드 해석을 보여줌

판별하는 것
  [침투 테스트 도구]  ARTEX(AI 자율 침투 테스트: artex-enrich/norma UA, 8787 포트, LLM API 접속),
    nmap(-sS/-sT/-sU/-sX/-sF/-sN/-sA, -sn 호스트 탐색, -O OS 탐지, -sV 버전 탐지, -sC/-A NSE),
    Hydra / Medusa / Ncrack (FTP·SSH·HTTP·Telnet 무차별 대입 + 성공 여부, SSH는 패킷 크기로 성공 판정),
    Nikto, sqlmap, dirb, Gobuster, ffuf, wfuzz, WPScan, Nuclei, Acunetix 등 웹 스캐너(User-Agent),
    hping3(SYN Flood/Land/스캔), Metasploit(4444, Meterpreter), 리버스/바인드 셸,
    vsftpd 2.3.4·UnrealIRCd·Samba usermap·distcc 백도어(Metasploitable)
  [웹 공격]  SQL 인젝션, XSS, 경로 탐색/LFI, RFI, 명령 주입, Shellshock, Log4Shell, 웹셸, SSTI
  [기본]  FTP/Telnet/POP3/IMAP/SMTP/HTTP 계정·비밀번호, SSH, ping, traceroute/tracert,
    nslookup(DNS), TLS 접속 도메인(SNI), SYN/ICMP/UDP Flood, Land, Ping of Death, Smurf,
    Teardrop, ARP 스푸핑, DNS 스푸핑, DHCP 고갈, MAC 플러딩
  ※ 실제 Kali 도구 트래픽으로 지문을 검증했고, Wireshark 필터 결과는 tshark와 대조 검증함
"""

import base64
import collections
import copy
import datetime
import glob
import gzip
import os
import re
import struct
import sys
import unicodedata
import urllib.parse
import zlib

# ════════════════════════════════════════════════════════════════════
# 1. 캡처 파일 읽기 (pcap / pcapng)
# ════════════════════════════════════════════════════════════════════
LINKTYPES = {0: 'Loopback(BSD)', 1: 'Ethernet', 9: 'PPP', 12: 'Raw IP', 14: 'Raw IP',
             50: 'PPP-HDLC', 101: 'Raw IP', 104: 'Cisco HDLC (GNS3 시리얼)',
             108: 'Loopback', 113: 'Linux SLL', 228: 'Raw IPv4', 229: 'Raw IPv6',
             276: 'Linux SLL2'}


def read_capture(path):
    """[(타임스탬프, 링크타입, 바이트), ...] 반환"""
    with open(path, 'rb') as f:
        data = f.read()
    if len(data) < 24:
        raise ValueError('파일이 너무 작음')
    magic = data[:4]
    if magic == b'\x0a\x0d\x0d\x0a':
        return read_pcapng(data)
    if magic in (b'\xd4\xc3\xb2\xa1', b'\x4d\x3c\xb2\xa1'):
        e = '<'
    elif magic in (b'\xa1\xb2\xc3\xd4', b'\xa1\xb2\x3c\x4d'):
        e = '>'
    else:
        raise ValueError('pcap/pcapng 파일이 아님')
    nano = magic in (b'\x4d\x3c\xb2\xa1', b'\xa1\xb2\x3c\x4d')
    linktype = struct.unpack(e + 'I', data[20:24])[0] & 0x0FFFFFFF
    out, off, n = [], 24, len(data)
    while off + 16 <= n:
        ts, frac, incl, _orig = struct.unpack(e + 'IIII', data[off:off + 16])
        off += 16
        incl = min(incl, n - off)
        out.append((ts + frac / (1e9 if nano else 1e6), linktype, data[off:off + incl], _orig))
        off += incl
    return out


def read_pcapng(data):
    out, off, n, e, ifaces = [], 0, len(data), '<', []
    while off + 12 <= n:
        try:
            if data[off:off + 4] == b'\x0a\x0d\x0d\x0a':          # Section Header
                e = '<' if data[off + 8:off + 12] == b'\x4d\x3c\x2b\x1a' else '>'
                ifaces = []
            btype, blen = struct.unpack(e + 'II', data[off:off + 8])
            if blen < 12 or off + blen > n:
                break
            body = data[off + 8:off + blen - 4]
            if btype == 1:                                         # Interface Description
                lt = struct.unpack(e + 'H', body[:2])[0]
                res, o = 1e-6, 8
                while o + 4 <= len(body):
                    code, ln = struct.unpack(e + 'HH', body[o:o + 4])
                    o += 4
                    if code == 0:
                        break
                    if code == 9 and ln >= 1 and o < len(body):
                        v = body[o]
                        res = 2.0 ** -(v & 0x7f) if v & 0x80 else 10.0 ** -v
                    o += (ln + 3) // 4 * 4
                ifaces.append((lt, res))
            elif btype == 6:                                       # Enhanced Packet
                iid, th, tl, cap, orig = struct.unpack(e + 'IIIII', body[:20])
                lt, res = ifaces[iid] if iid < len(ifaces) else (1, 1e-6)
                out.append((((th << 32) | tl) * res, lt, body[20:20 + cap], orig))
            elif btype == 3:                                       # Simple Packet
                orig = struct.unpack(e + 'I', body[:4])[0]
                lt, res = ifaces[0] if ifaces else (1, 1e-6)
                out.append((0.0, lt, body[4:4 + min(orig, len(body) - 4)], orig))
        except (struct.error, IndexError):
            break                       # 깨진 블록 → 거기까지 읽은 패킷은 살림
        off += blen
    return out


# ════════════════════════════════════════════════════════════════════
# 2. 패킷 디코딩
# ════════════════════════════════════════════════════════════════════
def ip4(b):
    return '.'.join(str(x) for x in b[:4])


def ip6(b):
    h = b[:16].hex()
    return ':'.join(h[i:i + 4] for i in range(0, 32, 4))


def mac(b):
    return ':'.join('%02x' % x for x in b[:6])


class Pkt:
    """디코딩된 패킷. 없는 필드는 None"""
    ts = 0.0; idx = 0; l2 = None; src = None; dst = None; proto = None
    ttl = None; ipid = None; frag_off = 0; mf = False; iplen = 0
    sport = None; dport = None; flags = None; seq = None; ack = None
    win = None; opts = b''; data = b''; itype = None; icode = None
    icmp_id = None; icmp_seq = None; inner = None; arp = None; eth_src = None
    flen = 0; rawb = b''; http = None; svc = None; wscale = None
    wlen = 0; rseq = None; rack = None; stream = None; eth_dst = None; ipdf = False
    retrans = False; reasm = False; arp_dup = False


def annotate_reasm(pkts):
    """IP 조각이 다 모이는 프레임 = Wireshark가 ICMP/UDP를 보여주는 프레임 → 필터용으로 합친 모습을 붙여 둠"""
    groups = {}
    for p in pkts:
        if p.l2 != 'ipv4' or not (p.mf or p.frag_off):
            continue
        k = (p.src, p.dst, p.ipid, p.proto)
        g = groups.setdefault(k, {'parts': {}, 'first': None, 'done': False})
        if g['done']:
            continue
        g['parts'][p.frag_off] = p
        if p.frag_off == 0:
            g['first'] = p
        ends = sorted(g['parts'])
        last = max(g['parts'].values(), key=lambda x: x.frag_off)
        if g['first'] is None or last.mf:
            continue
        pos, ok = 0, True
        for o in ends:
            x = g['parts'][o]
            if o > pos:
                ok = False
                break
            pos = max(pos, o + len(x.data) + (8 if o == 0 and x.proto in (1, 17) else 0) + (len(x.opts) + 20 if o == 0 and x.proto == 6 else 0))
        if not ok:
            continue
        g['done'] = True
        f = g['first']
        v = copy.copy(p)
        for a in ('sport', 'dport', 'itype', 'icode', 'icmp_id', 'icmp_seq', 'flags', 'seq', 'ack', 'win', 'opts', 'inner'):
            setattr(v, a, getattr(f, a))
        v.data = b''.join(g['parts'][o].data for o in ends)
        v.reasm = True
        p.rview = v


def annotate_arp(pkts):
    seen = {}
    for p in pkts:
        if p.arp and p.arp[2] != '0.0.0.0':
            m = seen.setdefault(p.arp[2], p.arp[1])
            if m != p.arp[1]:
                p.arp_dup = True


def annotate_tcp(pkts):
    """Wireshark처럼 tcp.stream 번호와 상대 시퀀스/ACK 번호(SYN = 0) 계산"""
    streams, nid = {}, 0
    for p in pkts:
        if p.proto != 6 or p.sport is None or p.mf or p.frag_off:
            continue
        a, b = (p.src, p.sport), (p.dst, p.dport)
        key = (a, b) if a <= b else (b, a)
        f = p.flags or 0
        st = streams.get(key)
        if st is None or (f & 0x12 == 0x02 and st['syn'] is not None and st['syn'] != p.seq):
            st = {'id': nid, 'syn': None, 'isn': {}}
            nid += 1
            streams[key] = st
        if f & 0x12 == 0x02:
            st['syn'] = p.seq
        if f & 0x02 or (a, b) not in st['isn']:
            st['isn'][(a, b)] = p.seq if f & 0x02 else (p.seq - 1) & 0xffffffff
        p.stream = st['id']
        p.rseq = (p.seq - st['isn'][(a, b)]) & 0xffffffff
        if f & 0x10:
            base = st['isn'].get((b, a))
            p.rack = ((p.ack - base) & 0xffffffff) if base is not None else 1
        else:
            p.rack = p.ack                     # ACK 플래그가 없으면 Wireshark도 원래 값 그대로
        if f & 0x02 and not p.data:          # 같은 SYN(시퀀스 같음)을 다시 보냄 = 재전송
            sk = (st['id'], a, p.seq, f & 0x12)
            if sk in st.setdefault('syns', set()):
                p.retrans = True
            st['syns'].add(sk)


ETHERTYPES = {0x0800: 'ipv4', 0x86dd: 'ipv6', 0x0806: 'arp', 0x88cc: 'LLDP'}


def l2_decode(lt, b):
    """링크 계층을 벗겨서 (종류, 나머지 바이트, 출발 MAC) 반환"""
    try:
        if lt == 1:                                    # Ethernet
            et = struct.unpack('>H', b[12:14])[0]
            off = 14
            while et in (0x8100, 0x88a8) and len(b) >= off + 4:   # VLAN
                et = struct.unpack('>H', b[off + 2:off + 4])[0]
                off += 4
            if et <= 1500:
                if b[:6] == b'\x01\x80\xc2\x00\x00\x00':
                    return 'STP', b, mac(b[6:12])
                if b[:6] == b'\x01\x00\x0c\xcc\xcc\xcc':
                    return 'CDP', b, mac(b[6:12])
                return 'L2기타', b, mac(b[6:12])
            return ETHERTYPES.get(et, 'L2(0x%04x)' % et), b[off:], mac(b[6:12])
        if lt == 104:                                  # Cisco HDLC
            et = struct.unpack('>H', b[2:4])[0]
            name = {0x8035: 'SLARP', 0x2000: 'CDP'}.get(et)
            return name or ETHERTYPES.get(et, 'HDLC(0x%04x)' % et), b[4:], None
        if lt in (101, 12, 14, 228, 229):              # Raw IP
            return ('ipv4' if b[0] >> 4 == 4 else 'ipv6'), b, None
        if lt == 113:                                  # Linux SLL
            et = struct.unpack('>H', b[14:16])[0]
            return ETHERTYPES.get(et, 'L2기타'), b[16:], None
        if lt == 276:                                  # Linux SLL2
            et = struct.unpack('>H', b[0:2])[0]
            return ETHERTYPES.get(et, 'L2기타'), b[20:], None
        if lt in (0, 108):                             # Loopback
            fam = struct.unpack('<I', b[:4])[0]
            if fam > 0xffff:
                fam = struct.unpack('>I', b[:4])[0]
            return ('ipv4' if fam == 2 else 'ipv6'), b[4:], None
        if lt in (9, 50):                              # PPP
            if b[:2] == b'\xff\x03':
                b = b[2:]
            pp = struct.unpack('>H', b[:2])[0]
            return {0x21: 'ipv4', 0x57: 'ipv6'}.get(pp, 'PPP기타'), b[2:], None
    except (struct.error, IndexError):
        pass
    return 'L2기타', b, None


def parse_inner(b):
    """ICMP 오류 메시지 안의 '원래 패킷' (IP 헤더 + 8바이트)"""
    if len(b) < 20 or b[0] >> 4 != 4:
        return None
    ihl = (b[0] & 15) * 4
    d = {'proto': b[9], 'src': ip4(b[12:16]), 'dst': ip4(b[16:20]), 'ttl': b[8],
         'sport': None, 'dport': None, 'icmp_seq': None, 'id': struct.unpack('>H', b[4:6])[0],
         'df': int(bool(b[6] & 0x40)), 'len': struct.unpack('>H', b[2:4])[0]}
    l4 = b[ihl:ihl + 8]
    if d['proto'] in (6, 17) and len(l4) >= 4:
        d['sport'], d['dport'] = struct.unpack('>HH', l4[:4])
        if d['proto'] == 17 and len(l4) >= 6:
            d['ulen'] = struct.unpack('>H', l4[4:6])[0]
            d['udata'] = b[ihl + 8:]
    elif d['proto'] == 1 and len(l4) >= 8:
        d['icmp_id'], d['icmp_seq'] = struct.unpack('>HH', l4[4:8])
        d['itype'], d['icode'] = l4[0], l4[1]
    return d


def decode_l4(p, proto, b):
    p.proto = proto
    if proto == 6 and len(b) >= 20:
        p.sport, p.dport, p.seq, p.ack, off, p.flags, p.win = struct.unpack('>HHIIBBH', b[:16])
        h = max(20, (off >> 4) * 4)
        p.opts, p.data = b[20:h], b[h:]
    elif proto == 17 and len(b) >= 8:
        p.sport, p.dport = struct.unpack('>HH', b[:4])
        p.data = b[8:]
    elif proto in (1, 58) and len(b) >= 4:
        t, c = b[0], b[1]
        if proto == 58:                       # ICMPv6 → ICMPv4 의미로 맞춤
            t = {128: 8, 129: 0, 1: 3, 3: 11}.get(t, 100 + t)
            p.proto = 1
        p.itype, p.icode = t, c
        if t in (0, 8, 13, 14) and len(b) >= 8:
            p.icmp_id, p.icmp_seq = struct.unpack('>HH', b[4:8])
            p.data = b[8:]
        elif t in (3, 4, 5, 11, 12):
            p.inner = parse_inner(b[8:])
            p.data = b[8:]
        else:
            p.data = b[4:]
    else:
        p.data = b


def decode(ts, idx, lt, raw):
    p = Pkt()
    p.ts, p.idx = ts, idx
    p.flen, p.rawb = len(raw), raw
    kind, b, p.eth_src = l2_decode(lt, raw)
    if lt == 1 and len(raw) >= 6:
        p.eth_dst = mac(raw[:6])
    p.l2 = kind
    try:
        if kind == 'arp' and len(b) >= 28:
            op = struct.unpack('>H', b[6:8])[0]
            p.arp = (op, mac(b[8:14]), ip4(b[14:18]), mac(b[18:24]), ip4(b[24:28]))
        elif kind == 'ipv4' and len(b) >= 20:
            ihl = (b[0] & 15) * 4
            tot = struct.unpack('>H', b[2:4])[0]
            if tot < ihl or tot > len(b):
                tot = len(b)
            p.ipid = struct.unpack('>H', b[4:6])[0]
            fl = struct.unpack('>H', b[6:8])[0]
            p.mf, p.frag_off = bool(fl & 0x2000), (fl & 0x1fff) * 8
            p.ipdf = bool(fl & 0x4000)
            p.ttl, p.src, p.dst, p.iplen = b[8], ip4(b[12:16]), ip4(b[16:20]), tot
            if p.frag_off > 0:            # 첫 조각이 아닌 IP 조각 → L4 헤더 없음
                p.proto, p.data = b[9], b[ihl:tot]
            else:
                decode_l4(p, b[9], b[ihl:tot])
        elif kind == 'ipv6' and len(b) >= 40:
            nh, p.ttl = b[6], b[7]
            p.src, p.dst = ip6(b[8:24]), ip6(b[24:40])
            p.iplen = 40 + struct.unpack('>H', b[4:6])[0]
            off = 40
            while nh in (0, 43, 60, 44) and off + 8 <= len(b):
                if nh == 44:
                    nh, off = b[off], off + 8
                else:
                    nh, off = b[off], off + (b[off + 1] + 1) * 8
            decode_l4(p, nh, b[off:])
    except (struct.error, IndexError):
        pass
    return p


# 공격/실습과 상관없는 배경 트래픽 (라우팅 프로토콜 등)
NOISE_PROTO = {88: 'EIGRP', 89: 'OSPF', 112: 'VRRP', 2: 'IGMP', 103: 'PIM'}
NOISE_UDP = {520: 'RIP', 1985: 'HSRP', 5353: 'mDNS', 1900: 'SSDP', 137: 'NetBIOS',
             138: 'NetBIOS', 67: 'DHCP', 68: 'DHCP', 5355: 'LLMNR', 546: 'DHCPv6',
             547: 'DHCPv6', 123: 'NTP', 3222: 'GLBP'}


def noise_name(p):
    if p.l2 not in ('ipv4', 'ipv6', 'arp'):
        return p.l2
    if p.l2 == 'arp':
        return None
    if p.proto in NOISE_PROTO:
        return NOISE_PROTO[p.proto]
    if p.proto == 17 and (p.sport in NOISE_UDP or p.dport in NOISE_UDP):
        return NOISE_UDP.get(p.dport) or NOISE_UDP.get(p.sport)
    if p.proto == 1 and p.itype is not None and p.itype >= 100:
        return 'ICMPv6(ND/RA 등)'
    return None


# ════════════════════════════════════════════════════════════════════
# 3. TCP 연결(세션) 재구성
# ════════════════════════════════════════════════════════════════════
KNOWN_PORTS = {20: 'FTP-DATA', 21: 'FTP', 22: 'SSH', 23: 'Telnet', 25: 'SMTP', 53: 'DNS',
               80: 'HTTP', 110: 'POP3', 111: 'RPC', 135: 'MSRPC', 139: 'NetBIOS',
               143: 'IMAP', 443: 'HTTPS', 445: 'SMB', 465: 'SMTPS', 587: 'SMTP',
               993: 'IMAPS', 995: 'POP3S', 1433: 'MSSQL', 1521: 'Oracle', 3306: 'MySQL',
               3389: 'RDP', 5432: 'PostgreSQL', 5601: 'Kibana', 5900: 'VNC',
               6379: 'Redis', 8080: 'HTTP-Alt', 8443: 'HTTPS-Alt', 9200: 'Elasticsearch'}


class Conn:
    def __init__(self, cli, srv, ts):
        self.cli, self.srv = cli, srv            # (ip, port)
        self.start = self.end = ts
        self.syn = self.synack = self.handshake = False
        self.client_rst_after_synack = False
        self.rst = [False, False]
        self.fin = [False, False]
        self.pkts = [0, 0]
        self.bytes = [0, 0]
        self.segs = [[], []]
        self.tl = []
        self.idxs = []                           # 이 세션의 모든 프레임 번호
        self._seen = set()
        self._stream = {}
        self.isn = None
        self.syn_win = self.syn_opts = None
        self.service = None

    def add(self, p, d):                         # d: 0=클라이언트→서버, 1=서버→클라이언트
        self.end = p.ts
        self.pkts[d] += 1
        self.idxs.append(p.idx)
        f = p.flags or 0
        if f & 0x02 and not f & 0x10 and d == 0:
            self.syn, self.isn, self.syn_win, self.syn_opts = True, p.seq, p.win, p.opts
        if f & 0x12 == 0x12 and d == 1:
            self.synack = True
        if d == 0 and f & 0x10 and not f & 0x06 and self.synack:
            self.handshake = True
        if f & 0x04:
            self.rst[d] = True
            if d == 0 and self.synack and not self.handshake:
                self.client_rst_after_synack = True
        if f & 0x01:
            self.fin[d] = True
        if p.data:
            k = (d, p.seq, len(p.data))
            if k in self._seen:
                p.retrans = True
            if k not in self._seen:
                self._seen.add(k)
                self.bytes[d] += len(p.data)
                self.segs[d].append((p.seq, p.data, p.idx))
                new = self._fresh(d, p.seq, p.data)       # 재전송으로 겹치는 부분은 시간순 목록에서 뺌
                if new:
                    self.tl.append((p.ts, d, new, p.idx))
                else:
                    p.retrans = True

    def _fresh(self, d, seq, data):
        """이미 받은 바이트 범위를 빼고 새 바이트만 돌려줌"""
        cov = self.__dict__.setdefault('_cov', [None, None])
        if cov[d] is None:
            cov[d] = (seq, [])
        base, iv = cov[d]
        r = (seq - base) & 0xffffffff
        if r > (1 << 31):
            r -= 1 << 32
        e = r + len(data)
        out, pos = bytearray(), r
        for a, b in iv:
            if b <= pos or a >= e:
                continue
            if a > pos:
                out += data[pos - r:a - r]
            pos = max(pos, b)
        if pos < e:
            out += data[pos - r:]
        # 범위 합치기
        iv.append((r, e))
        iv.sort()
        merged = []
        for a, b in iv:
            if merged and a <= merged[-1][1]:
                merged[-1] = (merged[-1][0], max(merged[-1][1], b))
            else:
                merged.append((a, b))
        cov[d] = (base, merged)
        return bytes(out)

    @property
    def closed(self):
        return any(self.rst) or all(self.fin)

    def stream(self, d):
        """한 방향의 데이터를 시퀀스 번호 순서로 이어붙임 (재전송 제거)"""
        if d in self._stream:
            return self._stream[d]
        segs = self.segs[d]
        if not segs:
            self._stream[d] = b''
            return b''
        base = segs[0][0]

        def rel(s):
            r = (s - base) & 0xffffffff
            return r - (1 << 32) if r > (1 << 31) else r
        out, nxt, offs = bytearray(), None, []
        for r, data, ix in sorted(((rel(s), dd, ix) for s, dd, ix in segs), key=lambda x: x[0]):
            before = len(out)
            if nxt is None or r >= nxt:
                out += data
                nxt = r + len(data)
            elif r + len(data) > nxt:
                out += data[nxt - r:]
                nxt = r + len(data)
            if len(out) > before:
                offs.append((before, len(out), ix))
        self._stream[d] = bytes(out)
        self._offs = getattr(self, '_offs', {})
        self._offs[d] = offs
        return self._stream[d]

    def frame_at(self, d, off):
        """스트림 위치 off 바이트가 들어있는 프레임 번호"""
        self.stream(d)
        for a, b, ix in self._offs.get(d, []):
            if a <= off < b:
                return ix
        return self.idxs[-1] if self.idxs else 0

    def timeline(self):
        return self.tl


def build_conns(pkts):
    conns, active = [], {}
    for p in pkts:
        if p.proto != 6 or p.sport is None:
            continue
        a, b = (p.src, p.sport), (p.dst, p.dport)
        key = (a, b) if a < b else (b, a)
        c = active.get(key)
        f = p.flags or 0
        syn_only = f & 0x02 and not f & 0x10
        if c is not None and syn_only and (p.src, p.sport) == c.cli:
            if c.closed or (c.isn is not None and p.seq != c.isn and c.handshake):
                c = None                       # 같은 포트쌍의 새 연결
        if c is None:
            if syn_only:
                cli, srv = a, b
            elif f & 0x12 == 0x12:
                cli, srv = b, a
            elif b[1] in KNOWN_PORTS and a[1] not in KNOWN_PORTS:
                cli, srv = a, b
            elif a[1] in KNOWN_PORTS and b[1] not in KNOWN_PORTS:
                cli, srv = b, a
            else:
                cli, srv = (a, b) if a[1] > b[1] else (b, a)
            c = Conn(cli, srv, p.ts)
            active[key] = c
            conns.append(c)
        c.add(p, 0 if (p.src, p.sport) == c.cli else 1)
    return conns


def _ws_opt(opts):
    i = 0
    while i < len(opts):
        k = opts[i]
        if k == 0:
            break
        if k == 1:
            i += 1
            continue
        if i + 1 >= len(opts) or opts[i + 1] < 2:
            break
        if k == 3 and opts[i + 1] == 3 and i + 2 < len(opts):
            return min(opts[i + 2], 14)
        i += opts[i + 1]
    return None


def set_window_scale(conns, pkts):
    """Wireshark의 tcp.window_size(스케일 적용값)를 맞추기 위해 SYN/SYN-ACK의 Window Scale 옵션 반영"""
    for c in conns:
        if not (c.syn and c.synack):
            continue
        syn = next((pkts[i - 1] for i in c.idxs if pkts[i - 1].flags is not None and pkts[i - 1].flags & 0x12 == 0x02), None)
        sa = next((pkts[i - 1] for i in c.idxs if pkts[i - 1].flags is not None and pkts[i - 1].flags & 0x12 == 0x12), None)
        if not syn or not sa:
            continue
        cw, sw = _ws_opt(syn.opts), _ws_opt(sa.opts)
        if cw is None or sw is None:
            continue
        for i in c.idxs:
            p = pkts[i - 1]
            if p.flags is not None and not p.flags & 0x02:
                p.wscale = cw if (p.src, p.sport) == c.cli else sw


def max_concurrency(conns):
    ev = []
    for c in conns:
        ev.append((c.start, 1))
        ev.append((c.end, -1))
    cur = best = 0
    for _, v in sorted(ev, key=lambda x: (x[0], -x[1])):
        cur += v
        best = max(best, cur)
    return best


def identify_service(c):
    cs, ss, port = c.stream(0)[:300], c.stream(1)[:300], c.srv[1]
    if cs.startswith(b'SSH-') or ss.startswith(b'SSH-'):
        return 'SSH'
    if REQ_RE.match(cs) or ss.startswith(b'HTTP/1.'):
        return 'HTTP'
    if cs[:1] == b'\x16' and cs[1:2] == b'\x03':
        return 'TLS(HTTPS 등)'
    if ss.startswith(b'+OK') or port == 110:
        return 'POP3'
    if ss.startswith(b'* OK') or port == 143:
        return 'IMAP'
    if ss.startswith(b'220') and (re.search(rb'(?i)smtp|esmtp|postfix|sendmail|exim', ss)
                                  or re.match(rb'(?i)(EHLO|HELO)', cs)):
        return 'SMTP'
    if (ss.startswith(b'220') and re.search(rb'(?i)ftp', ss)) or re.match(rb'(?i)USER ', cs) or port == 21:
        return 'FTP'
    if port == 23 or cs[:1] == b'\xff' or ss[:1] == b'\xff':
        return 'Telnet'
    return KNOWN_PORTS.get(port, 'TCP/%d' % port)


# ════════════════════════════════════════════════════════════════════
# 4. 응용 프로토콜 분석기
# ════════════════════════════════════════════════════════════════════
def lines_in_time(c):
    """(시간, 방향, 한 줄, 프레임번호) 를 시간순으로 생성"""
    buf = [b'', b'']
    for ts, d, data, ix in c.timeline():
        buf[d] += data
        while b'\n' in buf[d]:
            raw, buf[d] = buf[d].split(b'\n', 1)
            yield ts, d, raw.rstrip(b'\r').decode('utf-8', 'replace'), ix
    for d in (0, 1):
        if buf[d].strip():
            yield c.end, d, buf[d].rstrip(b'\r\n').decode('utf-8', 'replace'), (c.idxs[-1] if c.idxs else 0)


def new_attempt(user, ts, c):
    return {'user': user, 'pass': None, 'result': None, 'msg': '', 'ts': ts, 'conn': c, 'ix': []}


def analyze_ftp(c):
    r = {'banner': '', 'attempts': [], 'cmds': [], 'data_ports': set()}
    cur, last_user = None, None
    for ts, d, line, ix in lines_in_time(c):
        if d == 0:
            verb, _, arg = line.partition(' ')
            verb = verb.upper()
            if verb == 'USER':
                last_user = arg
                cur = new_attempt(arg, ts, c)
                r['attempts'].append(cur)
                cur['ix'].append(ix)
            elif verb == 'PASS':
                if cur is None or cur['pass'] is not None:
                    cur = new_attempt(last_user, ts, c)
                    r['attempts'].append(cur)
                cur['pass'] = arg
                cur['ix'].append(ix)
            else:
                r['cmds'].append([ts, line, ''])
                if verb == 'PORT':
                    n = arg.split(',')
                    if len(n) == 6 and all(x.strip().isdigit() for x in n):
                        r['data_ports'].add(int(n[4]) * 256 + int(n[5]))
                elif verb == 'EPRT' and arg:
                    parts = arg.split(arg[0])
                    if len(parts) > 3 and parts[3].isdigit():
                        r['data_ports'].add(int(parts[3]))
        else:
            m = re.match(r'(\d{3})([ -]?)(.*)', line)
            if not m:
                continue
            code, cont, text = m.groups()
            if code == '220' and not r['banner']:
                r['banner'] = text.strip()
            if cont == '-':
                continue
            if code == '227':
                mm = re.search(r'(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)', line)
                if mm:
                    r['data_ports'].add(int(mm.group(5)) * 256 + int(mm.group(6)))
            if code == '229':
                mm = re.search(r'\(\|\|\|(\d+)\|\)', line)
                if mm:
                    r['data_ports'].add(int(mm.group(1)))
            if cur is not None and cur['result'] is None and code in ('230', '530', '430', '421', '500', '501', '503', '331', '332'):
                cur['ix'].append(ix)
                if code == '230':
                    cur['result'], cur['msg'] = '성공', line
                elif code not in ('331', '332'):
                    cur['result'], cur['msg'] = '실패', line
            elif r['cmds'] and not r['cmds'][-1][2]:
                r['cmds'][-1][2] = line
    return r


def strip_iac(b):
    out, i, n = bytearray(), 0, len(b)
    while i < n:
        x = b[i]
        if x == 255 and i + 1 < n:
            cmd = b[i + 1]
            if cmd == 255:
                out.append(255)
                i += 2
            elif cmd == 250:
                j = b.find(b'\xff\xf0', i + 2)
                i = n if j < 0 else j + 2
            elif cmd in (251, 252, 253, 254):
                i += 3
            else:
                i += 2
        else:
            out.append(x)
            i += 1
    return bytes(out)


def analyze_telnet(c):
    r = {'attempts': [], 'commands': [], 'banner': '', 'server_text': ''}
    mode, field, tail, cur, after, logged_in, cmd = None, '', '', None, '', False, ''
    for ts, d, data, ix in c.timeline():
        t = strip_iac(data).decode('utf-8', 'replace')
        if not t:
            continue
        if d == 1:
            r['server_text'] += t
            if not r['banner'] and t.strip():
                r['banner'] = t.strip().splitlines()[0][:80]
            tail = (tail + t)[-200:]
            if cur is not None and cur['result'] is None and cur['pass'] is not None:
                after += t
                low = after.lower()
                head = low.lstrip()[:200]
                if re.search(r'login incorrect|incorrect (login|password)|authentication fail|access denied|login invalid|'
                             r'bad password|invalid password|wrong password|password incorrect|permission denied|틀렸|잘못', head) \
                        or re.search(r'(login|username|user name)\s*:\s*$', low):
                    cur['result'], cur['msg'] = '실패', ' '.join(after.split())[:60]
                    cur['ix'].append(ix)
                elif re.search(r'last login|welcome|[$#%>]\s*$', after.rstrip('\r\n\x00 ') + ' ', re.I) or \
                        re.search(r'[$#%>]\s*$', after.rstrip('\r\n\x00')):
                    cur['result'], cur['msg'] = '성공', ' '.join(after.split())[:60]
                    cur['ix'].append(ix)
                    logged_in = True
            lt = tail.lower().rstrip()
            if re.search(r'(login|username|user name|user)\s*:\s*$', lt):
                mode, field = 'user', ''
            elif re.search(r'password\s*:\s*$', lt):
                mode, field = 'pass', ''
        else:
            for ch in t:
                if ch in '\r\n\x00':
                    if mode == 'user' and field:
                        cur = new_attempt(field, ts, c)
                        cur['ix'].append(ix)
                        r['attempts'].append(cur)
                        mode, field = None, ''
                    elif mode == 'pass':
                        if cur is None or cur['pass'] is not None:
                            cur = new_attempt(None, ts, c)
                            r['attempts'].append(cur)
                        cur['ix'].append(ix)
                        cur['pass'], after, mode, field = field, '', None, ''
                    elif logged_in and cmd.strip():
                        r['commands'].append(cmd.strip())
                        cmd = ''
                elif ch in '\x7f\x08':
                    if mode:
                        field = field[:-1]
                    else:
                        cmd = cmd[:-1]
                elif mode:
                    field += ch
                elif logged_in:
                    cmd += ch
    for a in r['attempts']:
        if a['result'] is None and a['pass'] is not None:
            a['result'] = '판단불가'
    return r


def sasl_plain(v):
    """AUTH PLAIN base64(authzid NUL user NUL pass) → (user, pass)"""
    try:
        z = base64.b64decode(v.strip() + '===')
    except Exception:
        return None
    z = z.split(b'\x00')
    if len(z) >= 3:
        return z[1].decode('utf-8', 'replace'), z[2].decode('utf-8', 'replace')
    return None


_IMAP_ARG = re.compile(r'\s*(?:"((?:[^"\\]|\\.)*)"|(\S+))')


def imap_args(t):
    out, pos = [], 0
    while pos < len(t):
        m = _IMAP_ARG.match(t, pos)
        if not m or m.end() == pos:
            break
        out.append(m.group(1).replace('\\"', '"').replace('\\\\', '\\') if m.group(1) is not None else m.group(2))
        pos = m.end()
    return out


def analyze_mail(c, svc):
    """POP3 / IMAP / SMTP 로그인 시도 (USER/PASS, LOGIN, AUTH PLAIN, AUTH LOGIN)"""
    r = {'attempts': [], 'cmds': [], 'banner': ''}
    cur, last_user, state, imap_tags, tag = None, None, None, {}, None

    def start(user, pw, ts, ix):
        a = new_attempt(user, ts, c)
        a['pass'] = pw
        a['ix'].append(ix)
        r['attempts'].append(a)
        return a
    for ts, d, line, ix in lines_in_time(c):
        if d == 1 and not r['banner']:
            r['banner'] = line[:80]
        if d == 0:
            up = line.upper()
            if svc == 'IMAP':
                parts = line.split(None, 2)
                t0 = parts[0] if parts else ''
                verb = parts[1].upper() if len(parts) > 1 else ''
                rest = parts[2] if len(parts) > 2 else ''
            else:
                t0, verb, rest = None, (line.split() or [''])[0].upper(), line.partition(' ')[2]
            # SASL 이어서 오는 줄
            if state == 'plain':
                up_ = sasl_plain(line)
                state = None
                if up_:
                    cur = start(up_[0], up_[1], ts, ix)
                    if tag:
                        imap_tags[tag] = cur
                continue
            if state == 'login_user':
                cur = start(b64(line), None, ts, ix)
                if tag:
                    imap_tags[tag] = cur
                state = 'login_pass'
                continue
            if state == 'login_pass':
                if cur is not None:
                    cur['pass'] = b64(line)
                    cur['ix'].append(ix)
                state = None
                continue
            if verb in ('AUTH', 'AUTHENTICATE'):
                mech, _, init = rest.strip().partition(' ')
                tag = t0
                if mech.upper() == 'PLAIN':
                    if init.strip() and init.strip() != '=':
                        up_ = sasl_plain(init)
                        if up_:
                            cur = start(up_[0], up_[1], ts, ix)
                            if tag:
                                imap_tags[tag] = cur
                    else:
                        state = 'plain'
                elif mech.upper() == 'LOGIN':
                    if init.strip():
                        cur = start(b64(init), None, ts, ix)
                        if tag:
                            imap_tags[tag] = cur
                        state = 'login_pass'
                    else:
                        state = 'login_user'
                continue
            if svc == 'POP3' and verb == 'USER':
                last_user = rest
                cur = start(rest, None, ts, ix)
                continue
            if svc == 'POP3' and verb == 'PASS':
                if cur is None or cur['pass'] is not None:
                    cur = start(last_user, None, ts, ix)
                cur['pass'] = rest
                cur['ix'].append(ix)
                continue
            if svc == 'IMAP' and verb == 'LOGIN':
                args = imap_args(rest)
                if len(args) >= 2:
                    cur = start(args[0], args[1], ts, ix)
                    imap_tags[t0] = cur
                    continue
            r['cmds'].append(line)
            continue
        # 서버 응답
        if svc == 'IMAP':
            m = re.match(r'(\S+)\s+(OK|NO|BAD)', line, re.I)
            if m and m.group(1) in imap_tags and imap_tags[m.group(1)]['result'] is None:
                a = imap_tags[m.group(1)]
                a['result'] = '성공' if m.group(2).upper() == 'OK' else '실패'
                a['msg'] = line
                a['ix'].append(ix)
            continue
        if cur is None or cur['pass'] is None or cur['result'] is not None:
            continue
        if svc == 'POP3':
            if line.startswith('+OK'):
                cur['result'], cur['msg'] = '성공', line
                cur['ix'].append(ix)
            elif line.startswith('-ERR'):
                cur['result'], cur['msg'] = '실패', line
                cur['ix'].append(ix)
        else:
            if line.startswith('235'):
                cur['result'], cur['msg'] = '성공', line
                cur['ix'].append(ix)
            elif line[:3] in ('535', '534', '454', '501', '500'):
                cur['result'], cur['msg'] = '실패', line
                cur['ix'].append(ix)
    for a in r['attempts']:
        if a['result'] is None and a['pass'] is not None:
            a['result'] = '응답없음'
    return r


def b64(s):
    try:
        return base64.b64decode(s.strip()).decode('utf-8', 'replace')
    except Exception:
        return s


# ── HTTP ──────────────────────────────────────────────────────────
HTTP_METHODS = rb'GET|POST|HEAD|PUT|DELETE|OPTIONS|PATCH|CONNECT|TRACE|PROPFIND|PROPPATCH|MKCOL|COPY|MOVE|LOCK|UNLOCK|SEARCH|REPORT|CHECKOUT|CHECKIN|MERGE|MKACTIVITY|MKWORKSPACE|VERSION-CONTROL|BASELINE-CONTROL|LABEL|UPDATE|ACL|SUBSCRIBE|UNSUBSCRIBE|NOTIFY|POLL|M-SEARCH|MKCALENDAR|BPROPFIND|BMOVE|BDELETE|BCOPY|RPC_IN_DATA|RPC_OUT_DATA'
REQ_RE = re.compile(rb'(' + HTTP_METHODS + rb'|[A-Z][A-Z_\-]{2,15}) ([^\r\n]+?) HTTP/1\.[01]\r?\n')
RESP_RE = re.compile(rb'HTTP/1\.[01] (\d{3})([^\r\n]*)\r?\n')
WS_METHODS = set(HTTP_METHODS.decode().split('|'))


def dechunk(s, i):
    out = bytearray()
    while i < len(s):
        j = s.find(b'\r\n', i)
        if j < 0:
            break
        try:
            n = int(s[i:j].split(b';')[0].strip() or b'0', 16)
        except ValueError:
            break
        i = j + 2
        if n == 0:
            k = s.find(b'\r\n\r\n', i - 2)
            return bytes(out), (k + 4 if k >= 0 else i)
        out += s[i:i + n]
        i += n + 2
    return bytes(out), i


def decode_body(body, hdr):
    enc = hdr.get('content-encoding', '').lower()
    try:
        if 'gzip' in enc or body[:2] == b'\x1f\x8b':
            return zlib.decompressobj(16 + zlib.MAX_WBITS).decompress(body)
        if 'deflate' in enc:
            try:
                return zlib.decompress(body)
            except zlib.error:
                return zlib.decompressobj(-zlib.MAX_WBITS).decompress(body)
    except Exception:
        pass
    return body


def parse_http(stream, is_req):
    rx = REQ_RE if is_req else RESP_RE
    msgs, pos = [], 0
    while True:
        m = rx.search(stream, pos)
        if not m:
            break
        he = stream.find(b'\r\n\r\n', m.start())
        head = stream[m.start():he if he >= 0 else len(stream)]
        bstart = he + 4 if he >= 0 else len(stream)
        lines = head.decode('latin-1').split('\r\n')
        hdr = {}
        for l in lines[1:]:
            k, _, v = l.partition(':')
            hdr[k.strip().lower()] = v.strip()
        nxt = bstart
        mstart = m.start()
        if 'chunked' in hdr.get('transfer-encoding', '').lower():
            body, nxt = dechunk(stream, bstart)
        elif hdr.get('content-length', '').isdigit():
            cl = int(hdr['content-length'])
            body, nxt = stream[bstart:bstart + cl], bstart + cl
        elif not is_req:
            n2 = rx.search(stream, bstart)
            nxt = n2.start() if n2 else len(stream)
            body = stream[bstart:nxt]
        else:
            body = b''
        msg = {'first': lines[0], 'hdr': hdr, 'raw_len': len(body), 'body': decode_body(body, hdr),
               'off': mstart, 'end': max(nxt, bstart), 'hdr_end': bstart}
        if is_req:
            msg['method'], msg['uri'] = m.group(1).decode(), m.group(2).decode('latin-1')
        else:
            msg['code'], msg['reason'] = int(m.group(1)), m.group(2).decode('latin-1').strip()
        msgs.append(msg)
        pos = max(nxt, m.end())
    return msgs


def analyze_http(c):
    if getattr(c, '_http', None) is not None:
        return c._http
    reqs, allr = parse_http(c.stream(0), True), parse_http(c.stream(1), False)
    resps = [x for x in allr if not 100 <= x['code'] < 200]      # 100 Continue 등은 짝짓기에서 뺌
    for i, q in enumerate(reqs):
        if i < len(resps) and (q['method'] == 'HEAD' or resps[i]['code'] in (204, 304)):
            resps[i]['end'], resps[i]['body'] = resps[i]['hdr_end'], b''
    for d, msgs in ((0, reqs), (1, allr)):
        for m in msgs:                      # Wireshark처럼 메시지가 끝나는 프레임에 HTTP 표시
            m['frame'] = c.frame_at(d, max(m['off'], m['end'] - 1))
    pairs = []
    for i, q in enumerate(reqs):
        pairs.append((q, resps[i] if i < len(resps) else None))
    c._http = pairs
    c._http_all = reqs + allr
    return pairs


def http_title(body):
    m = re.search(rb'(?is)<title[^>]*>(.*?)</title>', body or b'')
    if m:
        return re.sub(r'\s+', ' ', m.group(1).decode('utf-8', 'replace')).strip()[:80]
    return ''


LOGIN_KEYS = re.compile(r'(?i)^(user(name)?|id|uid|login|userid|user_id|email|account|name|log|usr|mb_id)$')
PASS_KEYS = re.compile(r'(?i)(pass|pwd|pw|password|passwd|mb_password)')


def http_login_result(r, q=None):
    """HTTP 로그인 응답으로 성공/실패 추정"""
    if not r:
        return '응답없음'
    code = r['code']
    body = (r['body'] or b'')[:8000].decode('utf-8', 'replace').lower()
    t = http_title(r['body']).lower()
    text = body + ' ' + t
    loc = r['hdr'].get('location', '')
    if code in (401, 403, 407):
        return '실패(%d)' % code
    if code in (301, 302, 303, 307, 308):
        if re.search(r'(?i)log_?in|sign_?in|logon|auth|fail|error|denied|invalid|retry|again', loc):
            return '실패 추정(로그인 페이지로 되돌아감 → %s)' % loc
        return '성공 추정(리다이렉트 → %s)' % loc
    if re.search(r'welcome|dashboard|logout|log out|sign out|signout|login success|logged in|환영|로그아웃|로그인 성공|관리자 페이지|마이페이지', text):
        return '성공 추정(응답에 로그인 후 문구)'
    if re.search(r'login fail|fail|incorrect|invalid|wrong|denied|not match|try again|실패|틀렸|잘못|일치하지|없는 계정|다시 시도', text):
        return '실패(응답에 실패 문구)'
    if q is not None and q['hdr'].get('authorization', '').lower().startswith('basic ') and 200 <= code < 300:
        return '성공(Basic 인증 통과 %d)' % code
    return '판단불가(%d)' % code


def http_creds(q):
    """요청에서 계정/비밀번호 추출 (Basic 인증, POST 폼, GET 파라미터)"""
    out = []
    auth = q['hdr'].get('authorization', '')
    if auth.lower().startswith('basic '):
        dec = b64(auth[6:])
        u, _, pw = dec.partition(':')
        out.append(('Basic 인증', u, pw))
    params = {}
    body = q['body'] or b''
    if body:
        try:
            params.update(urllib.parse.parse_qs(body.decode('utf-8', 'replace'), keep_blank_values=True))
        except Exception:
            pass
    if '?' in q['uri']:
        params.update(urllib.parse.parse_qs(q['uri'].split('?', 1)[1], keep_blank_values=True))
    u = next((v[0] for k, v in params.items() if LOGIN_KEYS.match(k)), None)
    pw = next((v[0] for k, v in params.items() if PASS_KEYS.search(k)), None)
    if pw is not None:
        out.append(('폼 파라미터', u, pw))
    return out


# ── DNS ───────────────────────────────────────────────────────────
QTYPES = {1: 'A', 2: 'NS', 5: 'CNAME', 6: 'SOA', 12: 'PTR', 15: 'MX', 16: 'TXT',
          28: 'AAAA', 33: 'SRV', 255: 'ANY', 65: 'HTTPS', 64: 'SVCB'}
RCODES = {0: '정상', 1: '형식오류', 2: '서버실패', 3: '없는 도메인(NXDOMAIN)', 4: '미구현', 5: '거부'}


def dns_name(b, off):
    labels, end, hops = [], None, 0
    while off < len(b):
        l = b[off]
        if l == 0:
            off += 1
            break
        if l & 0xC0 == 0xC0:
            if off + 1 >= len(b) or hops > 15:
                break
            if end is None:
                end = off + 2
            off, hops = ((l & 0x3f) << 8) | b[off + 1], hops + 1
            continue
        labels.append(b[off + 1:off + 1 + l].decode('latin-1'))
        off += 1 + l
    return ('.'.join(labels) or '.'), (end if end is not None else off)


def parse_dns(b):
    if len(b) < 12:
        return None
    tid, fl, qd, an = struct.unpack('>HHHH', b[:8])
    msg = {'id': tid, 'qr': fl >> 15, 'rcode': fl & 15, 'q': [], 'a': []}
    off = 12
    try:
        for _ in range(min(qd, 20)):
            name, off = dns_name(b, off)
            qt = struct.unpack('>H', b[off:off + 2])[0]
            off += 4
            msg['q'].append((name, QTYPES.get(qt, str(qt))))
        for _ in range(min(an, 50)):
            name, off = dns_name(b, off)
            t, _c, _ttl, rl = struct.unpack('>HHIH', b[off:off + 10])
            off += 10
            rd = b[off:off + rl]
            if t == 1 and rl == 4:
                v = ip4(rd)
            elif t == 28 and rl == 16:
                v = ip6(rd)
            elif t in (2, 5, 12):
                v = dns_name(b, off)[0]
            elif t == 15:
                v = dns_name(b, off + 2)[0]
            elif t == 16:
                v = rd[1:].decode('latin-1')
            else:
                v = rd.hex()[:40]
            off += rl
            msg['a'].append((name, QTYPES.get(t, str(t)), v))
    except (struct.error, IndexError):
        pass
    return msg


BROWSER_DOMAINS = re.compile(r'(?i)(mozilla|firefox|google|gstatic|googleapis|msftconnecttest|msftncsi|'
                             r'microsoft|windowsupdate|bing\.com|apple\.com|icloud|openh264|'
                             r'digicert|ocsp|cloudflare|akamai|doubleclick)')

ICMP_NAMES = {0: 'Echo Reply', 3: 'Destination Unreachable', 4: 'Source Quench', 5: 'Redirect',
              8: 'Echo Request', 11: 'Time Exceeded', 12: 'Parameter Problem',
              13: 'Timestamp Request', 14: 'Timestamp Reply'}
UNREACH = {0: 'net', 1: 'host', 2: 'protocol', 3: 'port', 4: 'frag needed', 9: 'net prohibited',
           10: 'host prohibited', 13: 'admin prohibited'}


# ════════════════════════════════════════════════════════════════════
# 5. 파일 하나 분석 → 판정(finding) 목록 만들기
# ════════════════════════════════════════════════════════════════════
class Finding:
    def __init__(self, prio, title, summary, lines=None, ev=None, wf=None, groups=None):
        self.prio, self.title, self.summary, self.lines = prio, title, summary, lines or []
        self.ev = list(ev or [])     # 근거 패킷 프레임 번호 (Wireshark No. 와 같음)
        self.wf = list(wf or [])     # Wireshark 디스플레이 필터 [(필터, 설명)]
        self.groups = groups         # [(소제목, [프레임번호...]), ...] — 있으면 묶어서 표시


WF_AUTH = {'FTP': 'ftp.request.command == "USER" || ftp.request.command == "PASS" || ftp.response.code == 230 || ftp.response.code == 530',
           'Telnet': 'telnet', 'POP3': 'pop', 'IMAP': 'imap', 'SMTP': 'smtp'}
WF_PROTO = {'FTP': 'ftp', 'Telnet': 'telnet', 'POP3': 'pop', 'IMAP': 'imap', 'SMTP': 'smtp'}
STD_PORT = {'FTP': 21, 'Telnet': 23, 'POP3': 110, 'IMAP': 143, 'SMTP': 25, 'HTTP': 80, 'SSH': 22}


def fmt_ts(ts, full=True):
    try:
        return datetime.datetime.fromtimestamp(ts).strftime('%Y-%m-%d %H:%M:%S' if full else '%H:%M:%S')
    except (OverflowError, OSError, ValueError):
        return '%.3f' % ts


def ep(x):
    return '%s:%s' % x


def describe_targets(ips):
    ips = sorted(set(ips), key=lambda s: [int(x) if x.isdigit() else 0 for x in s.split('.')])
    nets = collections.Counter(i.rsplit('.', 1)[0] for i in ips if i.count('.') == 3)
    if len(nets) == 1 and len(ips) > 3:
        net = next(iter(nets))
        return '%s.0/24 대역 %d개 IP (%s ~ %s)' % (net, len(ips), ips[0], ips[-1])
    return ', '.join(ips[:8]) + (' 외 %d개' % (len(ips) - 8) if len(ips) > 8 else '')


def short_list(items, n=12):
    items = list(items)
    s = ', '.join(str(x) for x in items[:n])
    return s + (' … (총 %d개)' % len(items) if len(items) > n else '')


def ports_str(ports):
    return ', '.join('%d(%s)' % (p, KNOWN_PORTS[p]) if p in KNOWN_PORTS else str(p) for p in sorted(ports))


def guess_os_by_ttl(ttl):
    if ttl is None:
        return ''
    if ttl <= 64:
        return 'Linux/Unix 계열(초기 TTL 64)'
    if ttl <= 128:
        return 'Windows 계열(초기 TTL 128)'
    return '네트워크 장비/Solaris 등(초기 TTL 255)'


def _safe(fn, *a):
    """탐지기 하나가 실패해도 나머지 분석은 살림"""
    try:
        return fn(*a)
    except Exception as e:
        return [Finding(97, '일부 분석 실패', '%s: %s: %s' % (fn.__name__, type(e).__name__, e),
                        ['손상된 패킷 등으로 이 항목만 분석하지 못했습니다. 다른 결과는 정상입니다.'])]


def analyze_file(path, verbose=False):
    raw = read_capture(path)
    pkts = [decode(ts, i + 1, lt, b) for i, (ts, lt, b, _o) in enumerate(raw)]
    for p, x in zip(pkts, raw):
        p.wlen = max(x[3] or 0, p.flen)
    annotate_tcp(pkts)
    annotate_reasm(pkts)
    annotate_arp(pkts)
    info = {'path': path, 'count': len(pkts), 'linktypes': sorted({x[1] for x in raw}),
            'start': min((p.ts for p in pkts), default=0), 'end': max((p.ts for p in pkts), default=0)}
    noise, main = collections.Counter(), []
    for p in pkts:
        n = noise_name(p)
        if n:
            noise[n] += 1
        else:
            main.append(p)
    info['noise'] = noise
    findings = []
    conns = build_conns(main)
    set_window_scale(conns, pkts)
    for c in conns:
        if c.bytes[0] or c.bytes[1]:
            c.service = identify_service(c)
            for ix in c.idxs:
                pkts[ix - 1].svc = c.service
            if c.service == 'HTTP':
                analyze_http(c)
                for m in c._http_all:
                    if 'method' in m and m['method'] not in WS_METHODS:
                        continue                 # Wireshark가 HTTP로 안 보는 이상한 메서드(nmap 프로브 등)
                    if 1 <= m['frame'] <= len(pkts):
                        pkts[m['frame'] - 1].http = m

    # ── (A) 응용 계층 세션 (FTP/Telnet/HTTP/SSH/메일) + 무차별 대입 판단 ─────────
    groups = collections.OrderedDict()
    for c in conns:
        if c.service:
            groups.setdefault((c.cli[0], c.srv[0], c.srv[1], c.service), []).append(c)
    ftp_data_ports = set()
    used_conns = set()
    creds = []
    for (cip, sip, sport, svc), cs in groups.items():
        if svc in ('FTP', 'Telnet', 'POP3', 'IMAP', 'SMTP'):
            if svc == 'FTP':
                res = [analyze_ftp(c) for c in cs]
                for r in res:
                    ftp_data_ports |= r['data_ports']
            elif svc == 'Telnet':
                res = [analyze_telnet(c) for c in cs]
            else:
                res = [analyze_mail(c, svc) for c in cs]
            fg = _safe(auth_findings, svc, cip, sip, sport, cs, res, verbose)
            findings += fg
            creds += collect_auth_creds(svc, cip, sip, sport, res, any('무차별' in x.title for x in fg))
        elif svc == 'HTTP':
            fg = _safe(http_findings, cip, sip, sport, cs, verbose)
            findings += fg
            creds += collect_http_creds(cip, sip, sport, cs, any('무차별' in x.title for x in fg))
        elif svc == 'SSH':
            findings += _safe(ssh_findings, cip, sip, sport, cs, verbose)
        else:
            continue
        used_conns.update(id(c) for c in cs)

    # FTP 데이터 채널 (파일 내용/목록)
    for c in conns:
        if (c.bytes[0] or c.bytes[1]) and (c.srv[1] in ftp_data_ports or c.cli[1] in ftp_data_ports or c.srv[1] == 20 or c.cli[1] == 20):
            used_conns.add(id(c))
            for ix in c.idxs:
                pkts[ix - 1].svc = 'FTP-DATA'
            d = 1 if c.bytes[1] >= c.bytes[0] else 0
            data = c.stream(d)
            findings.append(Finding(52, 'FTP 데이터 전송', 'FTP 데이터 채널 %d바이트' % len(data),
                                    ['%s → %s  %d바이트' % (ep(c.cli), ep(c.srv), len(data)),
                                     '내용 미리보기: ' + preview(data, 300 if verbose else 150)],
            ev=c.idxs, wf=[('ftp-data', 'FTP 데이터 채널(파일 내용/목록)')]))

    # 기타 TCP 데이터 세션
    other = [c for c in conns if c.service and id(c) not in used_conns and c.service != 'TLS(HTTPS 등)'
             and 4444 not in (c.srv[1], c.cli[1]) and not SHELL_RX.search(c.stream(0)[:20000] + c.stream(1)[:20000])]
    if other:
        lines = []
        for c in other[:30]:
            first = c.stream(0)[:60] or c.stream(1)[:60]
            lines.append('%s → %s [%s] 송신 %dB / 수신 %dB  %s' % (ep(c.cli), ep(c.srv), c.service,
                                                                 c.bytes[0], c.bytes[1], preview(first, 60)))
        findings.append(Finding(58, '기타 TCP 통신', '%d개 세션 (%s)' % (len(other), ', '.join(sorted({c.service for c in other}))), lines,
            ev=[i for c in other[:10] for i in c.idxs[:6]], wf=[('tcp.port == %d' % other[0].srv[1], '첫 세션 서버 포트')]))

    # ── (B) 포트 스캔 ────────────────────────────────────────────────
    scan_pairs = collections.defaultdict(list)
    for c in conns:
        if c.syn and not (c.bytes[0] or c.bytes[1]) or (c.syn and c.service is None):
            scan_pairs[(c.cli[0], c.srv[0])].append(c)
    nmap_probe_srcs = nmap_discovery_signature(main)
    for (a, v), cs in scan_pairs.items():
        ports = {c.srv[1] for c in cs}
        is_nmap = any(c.syn_win in (1024, 2048, 3072, 4096) and c.syn_opts == b'\x02\x04\x05\xb4' for c in cs)
        is_hping = sum(1 for c in cs if c.syn_win == 512 and not c.syn_opts) >= len(cs) * 0.5
        if len(ports) < 6 and not (len(ports) >= 2 and (is_nmap or is_hping)):
            continue
        opened = sorted({c.srv[1] for c in cs if c.synack})
        closed = sorted({c.srv[1] for c in cs if not c.synack and c.rst[1]} - set(opened))
        filtered = sorted(ports - set(opened) - set(closed))
        half = sum(1 for c in cs if c.client_rst_after_synack)
        full = sum(1 for c in cs if c.handshake)
        os_opts = sum(1 for c in cs if c.syn_opts and len(c.syn_opts) >= 12) >= len(cs) * 0.5
        if half == full == 0 and not is_nmap and os_opts:
            tech = 'TCP Connect 스캔(nmap -sT 추정) — 열린 포트 없음, SYN 옵션이 OS 기본값(connect() 사용)'
        elif half >= full:
            tech = 'TCP SYN 스캔(하프오픈, nmap -sS) — 열린 포트의 SYN/ACK에 공격자가 RST로 끊음'
        else:
            tech = 'TCP Connect 스캔(nmap -sT) — 3-way 핸드셰이크 완료 후 종료'
        lines = ['공격자 %s → 대상 %s' % (a, v),
                 '기간 %s ~ %s' % (fmt_ts(min(c.start for c in cs)), fmt_ts(max(c.end for c in cs), False)),
                 '스캔 방식: ' + tech,
                 '검사한 포트 %d개: %s' % (len(ports), ports_str(ports) if len(ports) <= 40 else short_list(sorted(ports), 40)),
                 '▶ 열린 포트(SYN/ACK 응답): ' + (ports_str(opened) or '없음'),
                 '  닫힌 포트(RST 응답): %d개 %s' % (len(closed), ports_str(closed) if len(closed) <= 30 else ''),
                 '  필터링/무응답: %d개 %s' % (len(filtered), ports_str(filtered) if len(filtered) <= 30 else '')]
        if is_nmap:
            wins = sorted({c.syn_win for c in cs if c.syn_opts == b'\x02\x04\x05\xb4'})
            lines.append('nmap 특징: SYN 윈도우 크기 %s + TCP 옵션이 MSS 하나뿐, 출발 포트 고정' % ','.join(str(x) for x in wins[:4]))
        if a in nmap_probe_srcs:
            lines.append('nmap 특징: 스캔 직전 호스트 확인 프로브(ICMP Echo, ICMP Timestamp, TCP 443 SYN, TCP 80 ACK)')
        if is_hping:
            lines.append('hping3 특징: SYN 윈도우 512 + TCP 옵션 없음 (hping3 --scan / -S)')
        if not is_nmap and not is_hping and full > half:
            lines.append('일반 OS의 connect() 사용(윈도우·옵션이 정상값) → nmap -sT 또는 nc -z 등')
        NMAP_PROBES = (b'GET / HTTP/1.0\r\n\r\n', b'OPTIONS / HTTP/1.0\r\n\r\n', b'OPTIONS / RTSP/1.0\r\n\r\n',
                       b'HELP\r\n', b'\r\n\r\n', b'HEAD / HTTP/1.0\r\n\r\n')
        svc_conns = [c for c in conns if c.cli[0] == a and c.srv[0] == v and c.service and c.bytes[1]
                     and (c.stream(0) in NMAP_PROBES or not c.bytes[0])]
        if svc_conns:
            seen_p = collections.OrderedDict()
            for c in svc_conns:
                seen_p.setdefault(c.srv[1], c)
            lines.append('▶ 서비스/버전 탐지(-sV/-A) 결과: ' + ', '.join(
                '%d:%s' % (port, (banner_of(c) or preview(c.stream(1)[:50], 50))) for port, c in list(seen_p.items())[:8]))
        tool = 'nmap ' if (is_nmap or a in nmap_probe_srcs) else 'hping3 ' if is_hping else ''
        findings.append(Finding(30, '%s포트 스캔' % tool,
                                '열린 포트 %s (%d개 검사) | %s → %s' % (','.join(map(str, opened)) or '없음', len(ports), a, v),
                                lines,
            ev=[i for c in cs if c.synack for i in c.idxs] + [i for c in [x for x in cs if not x.synack][:2] for i in c.idxs],
            groups=[('열린 포트: SYN → SYN/ACK → (공격자)RST', [i for c in cs if c.synack for i in c.idxs]),
                    ('닫힌 포트 예시: SYN → RST/ACK', [i for c in [x for x in cs if not x.synack][:3] for i in c.idxs])], wf=[('ip.src == %s && tcp.flags.syn == 1 && tcp.flags.ack == 0' % a, '공격자가 보낸 스캔 SYN'), ('ip.src == %s && tcp.flags.syn == 1 && tcp.flags.ack == 1' % v, '열린 포트의 SYN/ACK 응답')]))

    findings += _safe(stealth_scan_findings, main, conns)
    findings += _safe(udp_scan_findings, [p for p in pkts if p.src])

    # ── (C) 호스트 탐색(핑 스윕) ─────────────────────────────────────
    findings += _safe(sweep_findings, main, nmap_probe_srcs)

    # ── (D) traceroute / ping / 기타 ICMP ───────────────────────────
    trace = _safe(traceroute_findings, main)
    findings += trace
    trace_pairs = {(f.src, f.dst) for f in trace}
    findings += _safe(ping_findings, main, trace_pairs, {f_src for f_src in sweep_sources(main)})

    # ── (E) DoS / 기타 공격 ─────────────────────────────────────────
    findings += _safe(dos_findings, main, conns, sweep_sources(main))
    findings += _safe(tool_findings, main, conns, pkts)
    findings += _safe(l2_attack_findings, pkts)
    tool_titles = ' '.join(f.title for f in findings)
    if re.search(r'Nikto|sqlmap|디렉터리/파일 무차별|웹 취약점 스캔|nmap 스크립트|nmap 서비스 버전|ARTEX|웹 공격 페이로드|무차별 대입', tool_titles):
        for f in findings:
            if f.title == 'HTTP 웹 접속' and '로그인 정보' not in ' '.join(f.lines):
                f.prio = 82
    if re.search(r'nmap (서비스 버전|스크립트|OS 탐지)', tool_titles):
        for f in findings:
            if f.title in ('FTP 접속', 'SSH 접속', 'Telnet 접속', '기타 TCP 통신') and '로그인:' not in ' '.join(f.lines):
                f.prio = 83
    hp = [f for f in findings if f.title.startswith('hping3 사용 흔적')]
    if hp:
        hosts = [f for f in findings if re.search('SYN Flooding|Land 공격|hping3 포트 스캔', f.title)]
        for f in hosts:
            f.title = f.title + ' — hping3'
            f.lines += [l for l in hp[0].lines if l.lstrip().startswith(('▶ 도구', '근거'))]
        if hosts:
            findings = [f for f in findings if f not in hp]
    findings += _safe(arp_findings, main)

    # ── (F) DNS ─────────────────────────────────────────────────────
    findings += _safe(dns_findings, main, bool(findings), verbose)

    if not findings:
        findings.append(Finding(99, '분류 불가', '알려진 패턴 없음 — 3 전체 패킷 탭에서 Enter로 패킷 해부해서 확인',
                                flow_summary(main),
            ev=[p.idx for p in main[:40]]))
    findings.sort(key=lambda f: f.prio)
    info['flows'] = flow_summary(main)
    info['pkts'], info['raw'], info['conns'], info['creds'] = pkts, raw, conns, creds
    return info, findings


def preview(b, n=80):
    s = b[:n].decode('utf-8', 'replace')
    s = ''.join(ch if (ch.isprintable() or ch == ' ') else ('↵' if ch == '\n' else '.') for ch in s.replace('\r', ''))
    return s + ('…' if len(b) > n else '')


# ════════════════════════════════════════════════════════════════════
# 5-2. 침투 테스트 도구 / 공격 기법 탐지 (실제 Kali 도구 트래픽으로 검증한 지문)
# ════════════════════════════════════════════════════════════════════
# (정규식, 도구 이름, 설명)
UA_SIGS = [
    (r'artex-enrich', 'ARTEX', 'AI 자율 침투 테스트 도구 ARTEX의 자산 정보 수집(enrich) 프로브'),
    (r'artex-selfupdate', 'ARTEX', 'ARTEX 자체 업데이트 요청'),
    (r'^norma/', 'ARTEX(norma SDK)', 'ARTEX 에이전트가 쓰는 norma SDK의 WebFetch'),
    (r'Nikto', 'Nikto', '웹 서버 취약점 스캐너'),
    (r'sqlmap', 'sqlmap', 'SQL 인젝션 자동화 도구'),
    (r'Nmap Scripting Engine', 'nmap NSE', 'nmap 스크립트 스캔(-sC / -A / -sV / --script)'),
    (r'\(Hydra\)', 'Hydra', '로그인 무차별 대입 도구'),
    (r'gobuster', 'Gobuster', '디렉터리/파일 무차별 대입'),
    (r'DirBuster', 'DirBuster', '디렉터리/파일 무차별 대입'),
    (r'feroxbuster', 'feroxbuster', '디렉터리/파일 무차별 대입'),
    (r'Fuzz Faster U Fool|ffuf', 'ffuf', '웹 퍼저(디렉터리/파라미터 무차별 대입)'),
    (r'Wfuzz', 'wfuzz', '웹 퍼저'),
    (r'WPScan', 'WPScan', '워드프레스 취약점 스캐너'),
    (r'WhatWeb', 'WhatWeb', '웹 기술 식별'),
    (r'[Nn]uclei', 'Nuclei', '템플릿 기반 취약점 스캐너'),
    (r'masscan', 'masscan', '고속 포트 스캐너'),
    (r'zgrab', 'ZGrab', '배너 수집 스캐너'),
    (r'[Aa]cunetix', 'Acunetix', '상용 웹 취약점 스캐너'),
    (r'Arachni', 'Arachni', '웹 취약점 스캐너'),
    (r'OpenVAS|Greenbone', 'OpenVAS', '취약점 스캐너'),
    (r'Nessus', 'Nessus', '취약점 스캐너'),
    (r'w3af', 'w3af', '웹 취약점 스캐너'),
    (r'commix', 'commix', '명령 주입 자동화 도구'),
    (r'skipfish', 'skipfish', '웹 취약점 스캐너'),
    (r'XSStrike', 'XSStrike', 'XSS 자동화 도구'),
    (r'Metasploit|msf', 'Metasploit', '익스플로잇 프레임워크'),
    (r'MSIE 6\.0; Windows NT 5\.1\)$', 'dirb', 'dirb 기본 User-Agent(IE6/WinXP) — 오래된 UA를 쓰는 스캐너'),
    (r'python-requests|python-urllib|aiohttp|Python/', 'Python 스크립트', '자작 스크립트/자동화'),
    (r'Go-http-client', 'Go 프로그램', 'Go로 만든 도구(nuclei/httpx/ffuf 등) 가능성'),
    (r'^curl/', 'curl', '명령줄 HTTP 요청 (AI 에이전트·스크립트 자동화에서 자주 사용)'),
    (r'^Wget', 'wget', '명령줄 다운로드'),
]
SCANNER_TOOLS = {'Nikto', 'Acunetix', 'Arachni', 'OpenVAS', 'Nessus', 'w3af', 'skipfish', 'Nuclei', 'WPScan', 'WhatWeb', 'ZGrab'}
DIRBUST_TOOLS = {'Gobuster', 'DirBuster', 'feroxbuster', 'ffuf', 'wfuzz', 'dirb'}

WEB_ATTACKS = [
    ('SQL 인젝션', r"('|\")\s*(or|and)\s+['\"]?\w+['\"]?\s*=|union\s+(all\s+)?select|sleep\s*\(\s*\d|benchmark\s*\(|"
                  r"information_schema|waitfor\s+delay|extractvalue\s*\(|updatexml\s*\(|order\s+by\s+\d+\s*(--|#)|"
                  r"'\s*(--|#)|\bor\s+1\s*=\s*1|and\s+\d+\s*=\s*\d+"),
    ('XSS(크로스사이트 스크립팅)', r'<\s*script|onerror\s*=|onload\s*=|javascript:|alert\s*\(|document\.cookie|<\s*svg|<\s*img[^>]+src'),
    ('경로 탐색 / 파일 포함(LFI)', r'\.\./|\.\.\\|/etc/passwd|/etc/shadow|win\.ini|boot\.ini|php://|file://'),
    ('원격 파일 포함(RFI)', r'=\s*(https?|ftp)://[^&\s]+\.(txt|php|sh)'),
    ('OS 명령 주입', r'(;|\||&&|`|\$\()\s*(id|whoami|cat\s|ls\b|uname|wget\s|curl\s|nc\s|bash|sh\s|ping\s|ifconfig|net\s+user)'),
    ('Shellshock (CVE-2014-6271)', r'\(\)\s*\{\s*:\s*;\s*\}\s*;'),
    ('Log4Shell (CVE-2021-44228)', r'\$\{jndi:'),
    ('웹셸/PHP 코드 실행', r'<\?php|system\s*\(|passthru\s*\(|shell_exec\s*\(|eval\s*\(|base64_decode\s*\(|\bcmd=\w'),
    ('PHP-CGI 인자 주입 (CVE-2012-1823)', r'\?-d\s*allow_url_include|-d\+allow_url_include'),
    ('SSTI(템플릿 주입)', r'\{\{\s*\d+\s*\*\s*\d+\s*\}\}|\$\{\s*\d+\s*\*\s*\d+\s*\}'),
]
AI_API = [
    (r'(^|\.)openai\.com$', 'OpenAI(ChatGPT)'), (r'(^|\.)anthropic\.com$', 'Anthropic(Claude)'),
    (r'(^|\.)deepseek\.com$', 'DeepSeek'), (r'bigmodel\.cn$|zhipuai', 'Zhipu AI(GLM)'), (r'(^|\.)x\.ai$', 'xAI(Grok)'),
    (r'generativelanguage\.googleapis\.com|aiplatform\.googleapis\.com', 'Google Gemini'),
    (r'openrouter\.ai$', 'OpenRouter'), (r'moonshot\.cn$', 'Moonshot(Kimi)'), (r'dashscope\.aliyuncs\.com$', 'Alibaba Qwen'),
    (r'(^|\.)mistral\.ai$', 'Mistral'), (r'(^|\.)groq\.com$', 'Groq'), (r'together\.(xyz|ai)$', 'Together AI'),
    (r'(^|\.)siliconflow\.cn$', 'SiliconFlow'), (r'(^|\.)volces\.com$|ark\.cn-', 'ByteDance(Doubao)'),
]
SSH_CLIENTS = [
    (r'libssh_', 'Hydra', 'Hydra는 SSH 공격에 libssh 사용 → 배너 SSH-2.0-libssh_x'),
    (r'MEDUSA', 'Medusa', '배너 SSH-2.0-MEDUSA_x'),
    (r'libssh2', 'libssh2 기반 도구', 'Medusa 구버전/Patator 등'),
    (r'paramiko', 'Python(paramiko)', 'Patator, 자작 스크립트 등'),
    (r'Net::SSH|Ruby', 'Metasploit', 'Metasploit ssh_login 모듈(Ruby Net::SSH)'),
    (r'Nmap-SSH', 'nmap NSE', 'nmap ssh-hostkey/ssh-auth-methods 스크립트'),
    (r'^SSH-2\.0-Go', 'Go 프로그램', 'Go로 만든 도구'),
    (r'PuTTY', 'PuTTY', '일반 SSH 클라이언트'),
]
NORMAL_SSH = ('OpenSSH', 'PuTTY', 'WinSCP', 'Dropbear')


def _http_reqs(conns):
    out = []
    for c in conns:
        if c.service == 'HTTP':
            out += [(c, q, r) for q, r in analyze_http(c)]
    return out


def req_url(c, q):
    if q['uri'].startswith(('http://', 'https://')):
        return q['uri']
    return 'http://%s%s' % (q['hdr'].get('host', c.srv[0]), q['uri'])


def _unq(s):
    try:
        return urllib.parse.unquote_plus(urllib.parse.unquote_plus(s))
    except Exception:
        return s


def ssh_auth(c):
    """암호화된 SSH 세션에서 인증 결과 추정.
    키 교환 후 서버의 첫 암호화 패킷 = SERVICE_ACCEPT(크기 A).
    인증 실패(USERAUTH_FAILURE)는 A보다 크고, 성공(USERAUTH_SUCCESS)은 A보다 작다 (실측 검증)."""
    s = c.stream(1)
    nl = s.find(b'\n')
    if not s.startswith(b'SSH-') or nl < 0:
        return None
    pos, enc = nl + 1, None
    while pos + 6 <= len(s):
        plen = struct.unpack('>I', s[pos:pos + 4])[0]
        if plen < 5 or plen > 70000:
            break
        msg = s[pos + 5]
        pos += 4 + plen
        if msg == 21:            # SSH_MSG_NEWKEYS
            enc = pos
            break
    if enc is None:
        return {'stage': '키 교환 미완료', 'success': None}
    off, segs, prev_dir = 0, [], None
    for ts, d, data, ix in c.timeline():
        if d == 1:
            if off >= enc:
                segs.append((len(data), ix, prev_dir))
            off += len(data)
        prev_dir = d
    if not segs:
        return {'stage': '인증 전 종료', 'success': None}
    a = segs[0][0]
    later = segs[1:]
    succ = next(((n, ix) for n, ix, pd in later if n < a and pd == 0), None)
    fails = []
    for n, ix, pd in later:
        if succ and ix >= succ[1]:
            break
        if n > a and pd == 0 and n < 400:
            fails.append(n)
    return {'stage': '인증', 'success': bool(succ), 'succ_frame': succ[1] if succ else None, 'succ_size': succ[0] if succ else None,
            'accept': a, 'fail_size': collections.Counter(fails).most_common(1)[0][0] if fails else None, 'fails': len(fails)}


def tls_sni(c):
    b = c.stream(0)
    if len(b) < 50 or b[0] != 0x16 or b[5] != 0x01:
        return None
    try:
        p = 9 + 2 + 32
        p += 1 + b[p]
        p += 2 + struct.unpack('>H', b[p:p + 2])[0]
        p += 1 + b[p]
        end = p + 2 + struct.unpack('>H', b[p:p + 2])[0]
        p += 2
        while p + 4 <= end:
            t, l = struct.unpack('>HH', b[p:p + 4])
            if t == 0:
                return b[p + 9:p + 9 + struct.unpack('>H', b[p + 7:p + 9])[0]].decode('latin-1')
            p += 4 + l
    except (IndexError, struct.error):
        pass
    return None


SHELL_RX = re.compile(rb'(?m)uid=\d+\([\w.-]+\)\s+gid=\d+|^[\w.-]+@[\w.-]+:[^\n]{0,60}[#$] ?$|/bin/(ba)?sh: |'
                      rb"can't access tty|Microsoft Windows \[Version|[A-Z]:\\[^\n>]{0,60}>|^# $")
CMD_RX = re.compile(r'^(id|whoami|uname|ls|cat|pwd|cd|ifconfig|ip a|wget|curl|nc|python|bash|sh|echo|ps|netstat|hostname|'
                    r'dir|ipconfig|net |type |chmod|rm |cp |mv |find|sudo|su )')


def tool_findings(main, conns, pkts):
    out = []
    reqs = _http_reqs(conns)
    # ── 1) User-Agent 로 도구 식별 ──────────────────────────────
    ua_hits = collections.OrderedDict()
    for c, q, r in reqs:
        ua = q['hdr'].get('user-agent', '')
        for rx, tool, desc in UA_SIGS:
            if ua and re.search(rx, ua):
                e = ua_hits.setdefault(tool, {'desc': desc, 'ua': ua, 'n': 0, 'cli': set(), 'srv': set(), 'ev': [], 'reqs': []})
                e['n'] += 1
                e['cli'].add(c.cli[0])
                e['srv'].add(c.srv[0])
                e['reqs'].append((c, q, r))
                if len(e['ev']) < 20:
                    e['ev'].append(q.get('frame') or (c.tl[0][3] if c.tl else 0))
                break
    # ── 2) 웹 공격 페이로드 ──────────────────────────────────────
    atk = collections.OrderedDict()
    tool_atk = collections.defaultdict(collections.Counter)
    for c, q, r in reqs:
        ua = q['hdr'].get('user-agent', '')
        owner = next((t for rx, t, d in UA_SIGS if ua and re.search(rx, ua)), None)
        blob = _unq(q['uri']) + ' ' + _unq((q['body'] or b'')[:4000].decode('latin-1')) + ' ' + \
            ' '.join(v for k, v in q['hdr'].items() if k in ('user-agent', 'referer', 'cookie', 'x-api-version', 'x-forwarded-for'))
        for name, rx in WEB_ATTACKS:
            if re.search(rx, blob, re.I):
                if owner in SCANNER_TOOLS or owner in ('sqlmap', 'nmap NSE', 'Hydra') or owner in DIRBUST_TOOLS:
                    tool_atk[owner][name] += 1
                    continue
                e = atk.setdefault(name, [])
                e.append((c, q, r))
    # ── 3) 디렉터리 무차별 대입 / 스캐너 행동 ────────────────────
    by_pair = collections.defaultdict(list)
    for c, q, r in reqs:
        by_pair[(c.cli[0], c.srv[0], c.srv[1])].append((c, q, r))
    tools_by_pair = {}
    for tool, e in ua_hits.items():
        for c, q, r in e['reqs']:
            tools_by_pair.setdefault((c.cli[0], c.srv[0], c.srv[1]), set()).add(tool)

    for tool, e in ua_hits.items():
        if tool in ('Hydra', 'nmap NSE'):
            continue                                     # 무차별 대입 / nmap 세부기능 판정에서 따로 처리
        if tool in ('curl', 'wget', 'Python 스크립트', 'Go 프로그램') and not any(t.startswith('ARTEX') for t in ua_hits):
            if e['n'] < 20:
                continue                                 # 소량의 일반 도구 요청은 생략
        codes = collections.Counter(r['code'] for c, q, r in e['reqs'] if r)
        found = collections.OrderedDict()
        if tool in DIRBUST_TOOLS | SCANNER_TOOLS:
            for c, q, r in e['reqs']:
                if r and r['code'] not in (404, 400, 405, 501) and len(found) < 25:
                    found.setdefault(q['uri'].split('?')[0] if not q['uri'].startswith('http') else req_url(c, q), r['code'])
        lines = ['도구: %s — %s' % (tool, e['desc']),
                 'User-Agent: ' + e['ua'],
                 '출발지 %s → 대상 %s, 요청 %d개' % (', '.join(sorted(e['cli'])), ', '.join(sorted(e['srv'])), e['n']),
                 '응답 코드: ' + ', '.join('%s×%d' % kv for kv in codes.most_common(6))]
        prio, title = 21, '%s 사용 흔적' % tool
        if tool in SCANNER_TOOLS:
            title, prio = '%s 웹 취약점 스캔' % tool, 21
        elif tool in DIRBUST_TOOLS:
            title, prio = '%s 디렉터리/파일 무차별 대입' % tool, 21
        elif tool == 'sqlmap':
            title, prio = 'sqlmap SQL 인젝션 공격', 19
            params = collections.Counter()
            tech = set()
            vuln = False
            for c, q, r in e['reqs']:
                qs = q['uri'].split('?', 1)[1] if '?' in q['uri'] else (q['body'] or b'').decode('latin-1')
                for k in urllib.parse.parse_qs(qs, keep_blank_values=True):
                    params[k] += 1
                u = _unq(q['uri']).upper()
                if 'SLEEP(' in u or 'BENCHMARK(' in u or 'WAITFOR' in u:
                    tech.add('시간 기반(Time-based)')
                if 'UNION' in u and 'SELECT' in u:
                    tech.add('UNION 기반')
                if re.search(r'AND \d+=\d+|OR \d+=\d+', u):
                    tech.add('불리언 기반(Boolean-based)')
                if 'EXTRACTVALUE' in u or 'UPDATEXML' in u or 'FLOOR(RAND' in u:
                    tech.add('에러 기반(Error-based)')
                if r and re.search(rb'(?i)sql syntax|mysql_fetch|ORA-\d|SQLSTATE|syntax error', r['body'] or b''):
                    vuln = True
            lines.append('▶ 공격 대상 파라미터: ' + ', '.join('%s(%d회)' % kv for kv in params.most_common(4)))
            if tech:
                lines.append('  사용 기법: ' + ', '.join(sorted(tech)))
            lines.append('▶ 결과: ' + ('서버가 DB 에러 메시지를 응답 → SQL 인젝션 취약 가능성 높음' if vuln else 'DB 에러 응답은 없음'))
        elif tool.startswith('ARTEX'):
            continue                                     # ARTEX 종합 판정에서 처리
        elif tool == 'nmap NSE':
            title, prio = 'nmap 스크립트 스캔(NSE: -sC / -A)', 31
        if tool_atk.get(tool):
            lines.append('▶ 보낸 공격 테스트: ' + ', '.join('%s %d건' % kv for kv in tool_atk[tool].most_common()))
        if found:
            lines.append('▶ 존재 확인된 경로(404 아님): ' + ', '.join('%s(%s)' % (u, s) for u, s in list(found.items())[:15]))
        summ = '%s → %s 요청 %d개' % (', '.join(sorted(e['cli'])), ', '.join(sorted(e['srv'])), e['n'])
        if tool in DIRBUST_TOOLS | SCANNER_TOOLS:
            summ += ' (404 %d개), 찾은 경로: %s' % (codes.get(404, 0), ', '.join(list(found)[:5]) or '없음')
        if tool == 'sqlmap':
            summ += ', 파라미터 %s, %s' % (params.most_common(1)[0][0] if params else '?', 'DB 에러 응답 → 취약 가능성 높음' if vuln else 'DB 에러 없음')
        out.append(Finding(prio, title, summ,
                           lines, ev=e['ev'], wf=[('http.user_agent contains "%s"' % ua_filter_text(e['ua']), '%s 요청' % tool),
                                                  ('http.response.code != 404 && http.response', '404가 아닌 응답(찾아낸 경로)')]))

    # UA 없이도 404 비율로 디렉터리 무차별 대입 판단
    for key, rs in by_pair.items():
        if key in tools_by_pair and tools_by_pair[key] & (DIRBUST_TOOLS | SCANNER_TOOLS | {'sqlmap', 'Hydra'}):
            continue
        n404 = sum(1 for c, q, r in rs if r and r['code'] == 404)
        if len(rs) >= 30 and n404 >= len(rs) * 0.5:
            found = [(req_url(c, q), r['code']) for c, q, r in rs if r and r['code'] != 404][:15]
            out.append(Finding(21, '디렉터리/파일 무차별 대입(dirb/gobuster 류)', '%s → %s 요청 %d개 중 404 %d개' % (key[0], key[1], len(rs), n404),
                               ['짧은 시간에 존재하지 않는 경로를 대량 요청 → 숨은 페이지 찾기',
                                '▶ 존재 확인된 경로: ' + (', '.join('%s(%s)' % (u, s) for u, s in found) or '없음')],
                               ev=[c.tl[0][3] for c, q, r in rs[:15] if c.tl], wf=[('http.response.code == 404', '없는 경로 응답')]))

    if atk:
        lines, ev = [], []
        for name, items in atk.items():
            c, q, r = items[0]
            lines.append('▶ %s: %d건  예) %s %s → %s' % (name, len(items), q['method'], cut(_unq(req_url(c, q)), 90),
                                                      ('%d' % r['code']) if r else '응답없음'))
            ev += [q.get('frame') or c.tl[0][3] for c, q, r in items[:5] if c.tl]
        srcs = sorted({c.cli[0] for items in atk.values() for c, q, r in items})
        dsts = sorted({c.srv[0] for items in atk.values() for c, q, r in items})
        out.append(Finding(18, '웹 공격 페이로드 (%s)' % ', '.join(list(atk)[:3]), '%s → %s: %s' % (
            ', '.join(srcs), ', '.join(dsts), ', '.join('%s %d건' % (k, len(v)) for k, v in atk.items())),
            ['공격자 %s → 웹 서버 %s' % (', '.join(srcs), ', '.join(dsts))] + lines, ev=ev,
            wf=[('http.request.uri contains "%27" || http.request.uri contains "script" || http.request.uri contains ".."', '대표적인 공격 요청')]))

    # ── 4) nmap 세부 기능 / hping3 ───────────────────────────────
    modes, mev = collections.OrderedDict(), collections.defaultdict(list)
    for p in main:
        if p.proto == 1 and p.itype == 8 and p.icode == 9:
            modes['OS 탐지(-O): ICMP Echo code=9 프로브'] = p.src; mev['os'].append(p.idx)
        elif p.proto == 6 and p.flags is not None and p.flags & 0x02 and b'\x08\x0a\xff\xff\xff\xff' in p.opts:
            modes['OS 탐지(-O): TCP 타임스탬프 0xFFFFFFFF 프로브'] = p.src; mev['os'].append(p.idx)
        elif p.proto == 6 and p.win == 31337:
            modes['OS 탐지(-O): 윈도우 31337 프로브'] = p.src; mev['os'].append(p.idx)
        elif p.proto == 17 and len(p.data) == 300 and p.data == b'C' * 300:
            modes['OS 탐지(-O): UDP "C"×300 프로브'] = p.src; mev['os'].append(p.idx)
        elif p.proto == 6 and p.data in (b'GET / HTTP/1.0\r\n\r\n', b'OPTIONS / HTTP/1.0\r\n\r\n', b'OPTIONS / RTSP/1.0\r\n\r\n',
                                         b'HELP\r\n', b'\r\n\r\n', b'HEAD / HTTP/1.0\r\n\r\n'):
            modes['서비스 버전 탐지(-sV): nmap 기본 프로브 "%s"' % p.data.strip().decode()[:20]] = p.src; mev['sv'].append(p.idx)
        elif p.proto == 6 and p.data.startswith(b'SSH-') and b'Nmap' in p.data[:40]:
            modes['스크립트 스캔(NSE): SSH 배너 Nmap-SSH'] = p.src; mev['sv'].append(p.idx)
    if 'nmap NSE' in ua_hits:
        modes['스크립트 스캔(NSE): User-Agent "Nmap Scripting Engine"'] = next(iter(ua_hits['nmap NSE']['cli']))
        mev['sv'] += ua_hits['nmap NSE']['ev'][:5]
    if modes:
        kinds = sorted({m.split(':')[0] for m in modes})
        out.append(Finding(31, 'nmap ' + ' + '.join(kinds), '%s 가 nmap %s 수행' % (', '.join(sorted(set(modes.values()))), ', '.join(kinds)),
                           ['▶ ' + m for m in modes] +
                           ['  -O: 응답 패킷의 TTL/윈도우/옵션으로 대상 OS를 맞히는 기능',
                            '  -sV: 열린 포트에 프로브를 보내 서비스 이름·버전 확인 / -sC, -A: NSE 스크립트 실행'],
                           ev=mev['os'][:15] + mev['sv'][:15],
                           wf=[('icmp.code == 9 || tcp.window_size == 31337 || tcp.options.timestamp.tsval == 4294967295', 'OS 탐지 프로브'),
                               ('http.user_agent contains "Nmap"', 'NSE 요청')]))

    hp = [p for p in main if p.proto == 6 and p.flags == 0x02 and p.win == 512 and not p.opts]
    if len(hp) >= 3:
        srcs = collections.Counter(p.src for p in hp)
        dports = {p.dport for p in hp}
        kind = 'SYN Flood' if len(srcs) > 20 or len(dports) <= 2 and len(hp) > 50 else '포트 스캔' if len(dports) >= 5 else 'SYN 전송'
        out.append(Finding(23, 'hping3 사용 흔적 (%s)' % kind, 'SYN 패킷 %d개 (윈도우 512, TCP 옵션 없음 = hping3 기본값)' % len(hp),
                           ['▶ 도구: hping3 — 패킷을 직접 조립해 보내는 도구 (DoS, 방화벽 테스트, 포트 스캔)',
                            '  근거: SYN 윈도우 크기 512 + TCP 옵션 없음 (일반 OS는 64240/65535 + MSS 등 옵션 포함)',
                            '  출발지 %d개 %s, 목적지 포트 %s' % (len(srcs), '(--rand-source 로 IP 위조)' if len(srcs) > 20 else short_list(srcs, 3),
                                                     short_list(sorted(dports), 10))],
                           ev=[p.idx for p in hp[:20]], wf=[('tcp.flags == 0x002 && tcp.window_size == 512', 'hping3 기본 SYN')]))

    # ── 5) 셸 세션(리버스/바인드 셸), Metasploit, 백도어 ───────────
    for c in conns:
        if not (c.bytes[0] or c.bytes[1]) or c.service in ('SSH', 'TLS(HTTPS 등)', 'Telnet', 'FTP', 'HTTP', 'SMTP', 'POP3', 'IMAP'):
            continue                      # 정상 원격 접속 프로토콜은 셸 탐지 대상에서 제외 (Telnet 로그인 등)
        s0, s1 = c.stream(0), c.stream(1)
        m0, m1 = SHELL_RX.search(s0), SHELL_RX.search(s1)
        if not (m0 or m1):
            if c.srv[1] == 4444 or b'metsrv' in s1 or b'stdapi_' in s0 + s1 or b'core_channel' in s0 + s1:
                out.append(Finding(9, 'Metasploit 페이로드/Meterpreter 의심', '%s ↔ %s (%s바이트)' % (ep(c.cli), ep(c.srv), format(c.bytes[0] + c.bytes[1], ',')),
                                   ['포트 4444 = Metasploit 기본 리스너 포트' if 4444 in (c.srv[1], c.cli[1]) else '',
                                    'Meterpreter 문자열(metsrv/stdapi/core_channel) 발견' if (b'metsrv' in s1 or b'stdapi_' in s0 + s1) else ''],
                                   ev=c.idxs[:20], wf=[('tcp.port == %d' % c.srv[1], '이 세션')]))
            continue
        # 셸 출력이 나오는 쪽 = 피해자, 명령을 보내는 쪽 = 공격자
        out_dir = 0 if m0 else 1
        victim = c.cli if out_dir == 0 else c.srv
        attacker = c.srv if out_dir == 0 else c.cli
        reverse = out_dir == 0          # 피해자가 먼저 접속(클라이언트) = 리버스 셸
        cmds = []
        for line in (s1 if out_dir == 0 else s0).decode('utf-8', 'replace').splitlines():
            l = line.strip()
            if l and (CMD_RX.match(l) or len(l) < 60) and len(cmds) < 20:
                cmds.append(l)
        outtxt = (s0 if out_dir == 0 else s1).decode('utf-8', 'replace')
        user = re.search(r'uid=\d+\(([\w.-]+)\)', outtxt)
        lines = ['▶ %s: 피해자 %s 가 공격자 %s 에게 셸을 넘겨줌' % ('리버스 셸' if reverse else '바인드 셸(접속형 셸)', ep(victim), ep(attacker)),
                 '  %s' % ('피해자가 공격자의 리스닝 포트(%d)로 먼저 접속 → 방화벽 우회용 리버스 셸 (nc -lvnp %d 등으로 대기)' % (attacker[1], attacker[1])
                          if reverse else '공격자가 피해자의 열린 포트(%d)에 접속해 셸을 얻음' % victim[1]),
                 '▶ 공격자가 실행한 명령: ' + (', '.join(cmds[:12]) or '확인 안 됨')]
        if user:
            lines.append('▶ 획득 권한: %s%s' % (user.group(1), ' (관리자! 최고 권한)' if user.group(1) == 'root' else ''))
        if 4444 in (c.srv[1], c.cli[1]):
            lines.append('  포트 4444 = Metasploit/netcat 리버스 셸 기본 포트')
        if c.srv[1] == 1524:
            lines.append('  포트 1524 = Metasploitable ingreslock 백도어(루트 셸)')
        out.append(Finding(9, '%s 획득 (원격 명령 실행)' % ('리버스 셸' if reverse else '바인드 셸'),
                           '%s → %s, 명령: %s%s' % (ep(c.cli), ep(c.srv), ', '.join(cmds[:5]), ' / 권한 %s' % user.group(1) if user else ''),
                           lines, ev=[t[3] for t in c.tl[:30]], wf=[('tcp.port == %d' % c.srv[1], '셸 세션 → Follow TCP Stream')]))

    ftp_user_bd = [(c, ix) for c in conns if c.service == 'FTP' for ts, d, line, ix in lines_in_time(c)
                   if d == 0 and re.match(r'(?i)USER \S*:\)', line)]
    if ftp_user_bd:
        c0 = ftp_user_bd[0][0]
        hit6200 = [c for c in conns if c.srv[0] == c0.srv[0] and c.srv[1] == 6200]
        opened = any(c.synack for c in hit6200)
        out.append(Finding(8, 'vsftpd 2.3.4 백도어 공격 (CVE-2011-2523)', 'USER 이름 끝에 ":)" 전송%s' % (
            ' → 6200 포트 %s' % ('열림(백도어 셸 획득)' if opened else '접속 시도(닫힘)') if hit6200 else ''),
            ['▶ FTP USER 명령의 사용자 이름이 ":)" 로 끝나면 vsftpd 2.3.4 백도어가 6200 포트에 루트 셸을 연다',
             '  Metasploit: exploit/unix/ftp/vsftpd_234_backdoor',
             '▶ 결과: ' + ('6200 포트 연결 성공 → 백도어 셸 획득' if opened else '6200 포트 닫힘(RST) → 실패' if hit6200 else '6200 접속 흔적 없음')],
            ev=[ix for c, ix in ftp_user_bd[:3]] + [i for c in hit6200 for i in c.idxs[:4]],
            wf=[('ftp.request.arg contains ":)" || tcp.port == 6200', '백도어 트리거와 6200 포트')]))
    for c in conns:
        s0 = c.stream(0)[:2000]
        if c.srv[1] in (6667, 6697) and s0.startswith(b'AB;'):
            out.append(Finding(8, 'UnrealIRCd 3.2.8.1 백도어 공격', '%s 에 "AB;" 명령 주입' % ep(c.srv),
                               ['▶ 클라이언트가 "AB;" 로 시작하는 데이터 전송 → 백도어로 OS 명령 실행', '  내용: ' + preview(s0, 120)],
                               ev=c.idxs[:10], wf=[('tcp.port == %d' % c.srv[1], '')]))
        if b'/=`nohup' in s0 or b'/=`' in s0 and c.srv[1] in (139, 445):
            out.append(Finding(8, 'Samba usermap_script 공격 (CVE-2007-2447)', '%s 로 셸 메타문자 사용자명 전송' % ep(c.srv),
                               ['▶ 사용자 이름에 "/=`nohup ...`" → Samba가 명령 실행', '  Metasploit: exploit/multi/samba/usermap_script'],
                               ev=c.idxs[:10], wf=[('smb || tcp.port == 139 || tcp.port == 445', '')]))
        if c.srv[1] == 3632 and b'DIST' in s0[:20]:
            out.append(Finding(8, 'distcc 원격 명령 실행 (CVE-2004-2687)', '%s 에 DIST 명령' % ep(c.srv),
                               ['▶ distccd에 컴파일 명령 대신 셸 명령 전달', '  내용: ' + preview(s0, 120)], ev=c.idxs[:10],
                               wf=[('tcp.port == 3632', '')]))

    # ── 6) TLS 접속 대상(SNI) / AI API ───────────────────────────
    snis = collections.OrderedDict()
    for c in conns:
        if c.service == 'TLS(HTTPS 등)' or (c.stream(0)[:1] == b'\x16'):
            n = tls_sni(c)
            if n:
                snis.setdefault(n, []).append(c)
    ai = [(n, name) for n in snis for rx, name in AI_API if re.search(rx, n)]
    if snis:
        lines = ['%s  ← %s (%d회, 첫 프레임 #%d)' % (n, ', '.join(sorted({ep(x.cli).rsplit(':', 1)[0] for x in cs})), len(cs), cs[0].idxs[0])
                 for n, cs in list(snis.items())[:20]]
        if ai:
            lines.insert(0, '▶ AI(LLM) API 접속: ' + ', '.join('%s(%s)' % a for a in ai) +
                         ' → ARTEX 같은 AI 에이전트 공격 도구가 LLM을 호출한 흔적일 수 있음')
        out.append(Finding(45 if ai else 75, 'AI(LLM) API 접속' if ai else 'TLS(HTTPS) 접속 대상',
                           ('AI API: ' + ', '.join(a[1] for a in ai)) if ai else ', '.join(list(snis)[:4]), lines,
                           ev=[x.idxs[3] if len(x.idxs) > 3 else x.idxs[0] for cs in snis.values() for x in cs][:20],
                           wf=[('tls.handshake.extensions_server_name', 'TLS 접속 도메인(SNI)')]))

    # ── 7) ARTEX 종합 판정 ───────────────────────────────────────
    art = []
    for tool in ('ARTEX', 'ARTEX(norma SDK)'):
        if tool in ua_hits:
            e = ua_hits[tool]
            art.append(('▶ User-Agent "%s" %d회 → %s' % (e['ua'], e['n'], e['desc']), e['ev']))
            if tool == 'ARTEX':
                art.append(('  enrich 프로브 대상 %d개 호스트: %s' % (len(e['srv']), ', '.join(sorted(e['srv']))), []))
    panel = [c for c in conns if 8787 in (c.srv[1],) and c.bytes[1]]
    if panel:
        art.append(('▶ 8787 포트 웹 접속 %d건 → ARTEX 관리 화면(기본 포트 8787) 가능성' % len(panel), [c.idxs[0] for c in panel[:5]]))
    if art and ai:
        art.append(('▶ AI API 접속 동반: ' + ', '.join(a[1] for a in ai), []))
    if art:
        others = [t for t in ua_hits if not t.startswith('ARTEX')]
        modes_s = ', '.join(sorted({m.split(':')[0] for m in modes})) if modes else ''
        lines = [a for a, _ in art] + [
            '  ARTEX = LLM 기반 자율 침투 테스트 도구: Planner 에이전트가 계획 → Worker 에이전트가 Kali 도구(nmap, curl 등) 실행',
            '  따라서 같은 출발지에서 nmap 스캔, curl 요청, 웹 공격 페이로드가 짧은 간격으로 이어지는 것이 특징',
            '▶ 같이 관찰된 도구/공격: ' + (', '.join(others + ([modes_s] if modes_s else []) + list(atk)) or '없음')]
        out.append(Finding(7, 'ARTEX (AI 자율 침투 테스트 도구) 사용 흔적', 'ARTEX 지문 발견: ' + ', '.join(
            t for t in ('artex-enrich', 'norma SDK', '8787 관리화면') if
            (t == 'artex-enrich' and 'ARTEX' in ua_hits) or (t == 'norma SDK' and 'ARTEX(norma SDK)' in ua_hits) or (t == '8787 관리화면' and panel)),
            lines, ev=[i for _, ev in art for i in ev][:30],
            wf=[('http.user_agent contains "artex" || http.user_agent contains "norma"', 'ARTEX 요청'), ('tcp.port == 8787', 'ARTEX 관리 화면')]))
    return out


def l2_attack_findings(pkts):
    """DNS 스푸핑, DHCP 고갈, MAC 플러딩 (배경 트래픽까지 포함해서 봐야 하는 것)"""
    out = []
    resp = collections.defaultdict(list)
    for p in pkts:
        if p.proto == 17 and p.sport == 53 and p.data:
            m = parse_dns(p.data)
            if m and m['qr'] and m['q']:
                resp[(m['id'], m['q'][0][0])].append((p, tuple(sorted(v for _, t, v in m['a'] if t in ('A', 'AAAA')))))
    for (tid, name), lst in resp.items():
        ans = {a for _, a in lst if a}
        if len(ans) >= 2:
            lines = ['같은 질의(ID 0x%04x, %s)에 서로 다른 응답 %d개 → 먼저 도착한 가짜 응답으로 속이는 DNS 스푸핑' % (tid, name, len(ans))]
            for p, a in lst:
                lines.append('  #%d %s → %s : %s' % (p.idx, p.src, p.dst, ', '.join(a) or '(답 없음)'))
            out.append(Finding(10, 'DNS 스푸핑 (ettercap dns_spoof 등)', '%s 에 대한 응답이 %s 로 엇갈림' % (name, ' / '.join(', '.join(a) for a in ans)),
                               lines, ev=[p.idx for p, _ in lst], wf=[('dns.id == 0x%04x' % tid, '같은 ID의 질의/응답')]))
    disc = [p for p in pkts if p.proto == 17 and p.dport == 67 and len(p.data) > 240 and p.data[0] == 1]
    macs = {p.data[28:34] for p in disc}
    if len(disc) >= 30 and len(macs) >= 20:
        out.append(Finding(12, 'DHCP 고갈(Starvation) 공격', 'DHCP 요청 %d개, 서로 다른 MAC %d개' % (len(disc), len(macs)),
                           ['▶ 가짜 MAC으로 DHCP 주소를 모두 받아가 정상 사용자가 IP를 못 받게 함 (yersinia, dhcpstarv 등)'],
                           ev=[p.idx for p in disc[:20]], wf=[('dhcp.option.dhcp == 1', 'DHCP Discover')]))
    srcm = {p.eth_src for p in pkts if p.eth_src}
    if len(srcm) >= 500:
        out.append(Finding(12, 'MAC 플러딩 (macof 등)', '서로 다른 출발지 MAC %d개' % len(srcm),
                           ['▶ 스위치의 MAC 테이블을 가득 채워 허브처럼 동작하게 만들어 스니핑하는 공격'],
                           ev=[p.idx for p in pkts[:20]], wf=[('eth.src', '')]))
    return out


# ── 인증 기반 서비스(FTP/Telnet/POP3/IMAP/SMTP) 판정 ─────────────────
def auth_findings(svc, cip, sip, sport, cs, results, verbose):
    attempts = sorted((a for r in results for a in r['attempts']), key=lambda a: a['ts'])
    banner = next((r['banner'] for r in results if r.get('banner')), '')
    conc = max_concurrency(cs)
    users = list(collections.OrderedDict.fromkeys(str(a['user']) for a in attempts))
    pwds = list(collections.OrderedDict.fromkeys(str(a['pass']) for a in attempts if a['pass'] is not None))
    succ = [a for a in attempts if a['result'] == '성공']
    fail = [a for a in attempts if a['result'] == '실패']
    head = ['%s %s → %s:%d  (세션 %d개, %s ~ %s)' % (svc, cip, sip, sport, len(cs),
                                                   fmt_ts(min(c.start for c in cs)), fmt_ts(max(c.end for c in cs), False))]
    if banner:
        head.append('서버 배너: ' + banner)
    brute = len(attempts) >= 5 or (len(cs) >= 5 and len(attempts) >= 3) or len(pwds) >= 4
    if brute:
        reasons = []
        multi_user = any(len({str(a['user']) for a in r['attempts']}) >= 2 for r in results)
        per_conn = len(attempts) / float(max(1, len(cs)))
        if conc >= 4 and multi_user:
            tool = 'Ncrack'
            reasons.append('동시 %d개 세션 + 한 세션 안에서 여러 계정을 바꿔가며 시도(Ncrack 특징)' % conc)
        elif conc >= 4:
            tool = 'Hydra'
            reasons.append('동시에 %d개 세션을 병렬로 열고 세션마다 1회씩 시도(Hydra 기본 16 task 특징)' % conc)
        elif len(cs) >= 5 and conc <= 2:
            tool = 'Medusa'
            reasons.append('동시 접속 %d개 이하로 순차 시도(Medusa 기본 동작, Hydra는 16개 병렬) — Metasploit 모듈도 비슷' % conc)
        else:
            tool = '자동화 도구(Hydra/Medusa 등)'
        reasons.append('짧은 시간에 %d회 로그인 시도(세션당 %.1f회)' % (len(attempts), per_conn))
        dur = max(c.end for c in cs) - min(c.start for c in cs)
        lines = head + [
            '▶ 공격: %s를 이용한 %s 무차별 대입(Brute Force / 사전 대입) 공격' % (tool, svc),
            '  근거: ' + '; '.join(reasons) + ' (공격 시간 약 %.1f초)' % dur,
            '  시도 %d회 = 성공 %d / 실패 %d / 응답없음 %d' % (len(attempts), len(succ), len(fail),
                                                     len(attempts) - len(succ) - len(fail)),
            '  시도한 계정: ' + short_list(users),
            '  시도한 비밀번호: ' + short_list(pwds, 20)]
        if succ:
            for a in succ:
                lines.append('▶ 결과: 공격 성공!  계정 %s / 비밀번호 %s  (%s, %s, 출발포트 %d)' % (
                    a['user'], a['pass'], a['msg'], fmt_ts(a['ts'], False), a['conn'].cli[1]))
            summ = '★성공 %s / %s  (%s %d회 시도)' % (succ[0]['user'], succ[0]['pass'], svc, len(attempts))
        else:
            lines.append('▶ 결과: 공격 실패 (성공 응답 없음)')
            summ = '실패 (%s %d회 시도, 계정 %s)' % (svc, len(attempts), short_list(users, 4))
        lines.append('  [계정별 결과]')
        for u in users:
            mine = [a for a in attempts if str(a['user']) == u]
            ok = [a for a in mine if a['result'] == '성공']
            pw_list = list(collections.OrderedDict.fromkeys(str(a['pass']) for a in mine))
            lines.append('   %-12s %d회 (비밀번호 %s) → %s' % (u, len(mine), short_list(pw_list, 8),
                         ('★성공: %s  #%d' % (ok[0]['pass'], ok[0]['ix'][-1] if ok[0]['ix'] else 0)) if ok else '전부 실패'))
        if verbose:
            lines.append('  [시도 목록 전체]')
            for a in attempts:
                lines.append('   %s  %-12s / %-15s → %s  %s' % (fmt_ts(a['ts'], False), a['user'], a['pass'],
                                                              a['result'] or '응답없음', a['msg'][:40]))
        return [Finding(20, '%s %s 무차별 대입 공격' % (tool, svc), summ, lines,
            ev=[i for a in succ for i in a['conn'].idxs] + [i for a in fail[:3] for i in a['ix']],
            groups=[('성공한 세션 전체 (출발포트 %d) — %s / %s' % (a['conn'].cli[1], a['user'], a['pass']), a['conn'].idxs) for a in succ[:2]]
                   + [('실패한 시도 예시 %d건 (USER → 331 → PASS → 530)' % len(fail[:3]), [i for a in fail[:3] for i in a['ix']])], wf=[(WF_AUTH.get(svc, svc.lower()), '로그인 시도/결과 전체')] + ([('tcp.port == %d' % succ[0]['conn'].cli[1], '성공한 세션만 → 우클릭 Follow > TCP Stream')] if succ else []))]

    # 일반 로그인 세션
    lines = list(head)
    for a in attempts:
        lines.append('▶ 로그인: 계정 %s / 비밀번호 %s → %s  %s' % (a['user'], a['pass'], a['result'] or '응답없음', a['msg']))
    for r in results:
        for item in r.get('cmds', []):
            if isinstance(item, list):
                lines.append('  명령: %-30s → %s' % (item[1], item[2]))
            else:
                lines.append('  명령: ' + item)
        for cmd in r.get('commands', []):
            lines.append('  입력한 명령: ' + cmd)
    if attempts:
        a = succ[0] if succ else attempts[-1]
        summ = '계정 %s / 비밀번호 %s → 로그인 %s' % (a['user'], a['pass'], a['result'] or '응답없음')
    else:
        summ = '%s 접속 (로그인 정보 없음)' % svc
    return [Finding(50, '%s 접속' % svc, summ, lines,
            ev=[i for c in cs for i in c.idxs], wf=[(WF_PROTO.get(svc, svc.lower()) if sport == STD_PORT.get(svc) else 'tcp.port == %d' % sport, '%s 패킷' % svc),
                                                   ('tcp.port == %d' % cs[0].cli[1], '이 세션만 → Follow TCP Stream')])]


def http_findings(cip, sip, sport, cs, verbose):
    pairs = []
    for c in cs:
        pairs += [(c, q, r) for q, r in analyze_http(c)]
    if not pairs:
        return []
    uas = collections.Counter(q['hdr'].get('user-agent', '') for _, q, _ in pairs)
    hydra_ua = any('hydra' in ua.lower() for ua in uas)
    tool_ua = [ua for ua in uas if re.search(r'(?i)hydra|nikto|sqlmap|dirb|gobuster|wfuzz|nmap|curl|wget|python|burp|zgrab|masscan|ffuf', ua)]
    creds = [(c, q, r, cr) for c, q, r in pairs for cr in http_creds(q)]
    lines = ['HTTP %s → %s:%d  (세션 %d개, 요청 %d개)' % (cip, sip, sport, len(cs), len(pairs))]
    for ua, n in uas.most_common(3):
        lines.append('User-Agent: %s%s' % (ua or '(없음)', ' (×%d)' % n if n > 1 else ''))
    servers = {r['hdr'].get('server') for _, _, r in pairs if r and r['hdr'].get('server')}
    if servers:
        lines.append('웹 서버: ' + ', '.join(servers))
    paths = collections.Counter(q['uri'].split('?')[0] for _, q, _, _ in creds)
    same_path = bool(paths) and paths.most_common(1)[0][1] >= len(creds) * 0.8
    brute = hydra_ua or (len(creds) >= 5 and same_path) or sum(1 for _, _, r in pairs if r and r['code'] == 401) >= 5
    if brute:
        def rsig(r):
            return (r['code'], r['raw_len'], http_title(r['body']), r['hdr'].get('location', '')) if r else None
        sig = collections.Counter(rsig(r) for _, _, r, _ in creds)
        common = sig.most_common(1)[0][0] if sig else None
        outliers = [(c, q, r, cr) for c, q, r, cr in creds if r and rsig(r) != common]
        if not creds:   # Basic 인증 없이 401만 반복되는 경우
            outliers = [(c, q, r, ('?', '?', '?')) for c, q, r in pairs if r and r['code'] not in (401, 403)]
        lines.append('▶ 공격: %s HTTP 로그인 무차별 대입 공격 (시도 %d회)' % ('Hydra를 이용한' if hydra_ua else '자동화 도구로', len(creds) or len(pairs)))
        if hydra_ua:
            lines.append('  근거: User-Agent에 "Hydra" 문자열')
        lines.append('  시도한 계정: ' + short_list(collections.OrderedDict.fromkeys(str(cr[1]) for *_, cr in creds)))
        lines.append('  시도한 비밀번호: ' + short_list(collections.OrderedDict.fromkeys(str(cr[2]) for *_, cr in creds), 20))
        okset = {id(q) for c, q, r, cr in outliers} if outliers and len(outliers) < len(creds or pairs) else set()
        for c, q, r, cr in creds:
            c.__dict__.setdefault('_brute_res', {})[id(q)] = (
                '성공 추정(혼자 다른 응답 %d)' % r['code'] if id(q) in okset else
                ('실패(다른 시도와 같은 응답 %d)' % r['code'] if r else '응답없음'))
        if outliers and len(outliers) < len(creds or pairs):
            for c, q, r, cr in outliers[:5]:
                lines.append('▶ 결과(추정): 혼자 다른 응답을 받은 시도 → 계정 %s / 비밀번호 %s  응답 %d %s, 본문 %dB%s, 프레임 #%d (성공 가능성 높음)' % (
                    cr[1], cr[2], r['code'], r['reason'], r['raw_len'], (', 제목 "%s"' % http_title(r['body'])) if http_title(r['body']) else '', r['frame']))
            summ = '★성공 추정 %s / %s  (HTTP %d회 시도)' % (outliers[0][3][1], outliers[0][3][2], len(creds) or len(pairs))
        else:
            lines.append('▶ 결과(추정): 모든 응답이 동일 → 실패로 보임')
            summ = '실패 추정 (HTTP %d회 시도)' % (len(creds) or len(pairs))
        if verbose:
            for c, q, r, cr in creds:
                lines.append('   %s / %s → %s' % (cr[1], cr[2], '%d %dB' % (r['code'], r['raw_len']) if r else '응답없음'))
        return [Finding(20, ('Hydra ' if hydra_ua else '') + 'HTTP 무차별 대입 공격', summ, lines,
            ev=[i for x in (outliers[:3] or creds[:3]) for i in x[0].idxs], wf=[('http.request.method == "POST" || http.response', 'POST 요청과 응답'), ('http.user_agent contains "Hydra"', 'Hydra가 보낸 요청')])]

    for c, q, r in pairs[: (len(pairs) if verbose else 15)]:
        host = q['hdr'].get('host', sip)
        resp = '%d %s' % (r['code'], r['reason']) if r else '응답 없음'
        lines.append('▶ %s %s → %s  #%d' % (q['method'], req_url(c, q), resp, q['frame']))
        for kind, u, pw in http_creds(q):
            lines.append('    로그인 정보(%s): 계정 %s / 비밀번호 %s → %s' % (kind, u, pw, http_login_result(r, q)))
        if q['method'] in ('POST', 'PUT') and q['body']:
            lines.append('    요청 본문: ' + preview(q['body'], 150))
        if q['hdr'].get('cookie'):
            lines.append('    Cookie: ' + q['hdr']['cookie'][:100])
        if r:
            t = http_title(r['body'])
            ct = r['hdr'].get('content-type', '')
            extra = []
            if t:
                extra.append('제목 "%s"' % t)
            if ct:
                extra.append(ct.split(';')[0])
            extra.append('본문 %dB%s' % (len(r['body']), '(압축해제)' if len(r['body']) != r['raw_len'] else ''))
            if r['hdr'].get('location'):
                extra.append('이동 → ' + r['hdr']['location'])
            lines.append('    응답: ' + ', '.join(extra))
    if len(pairs) > 15 and not verbose:
        lines.append('  … 요청 %d개 더 ([v] 자세히 켜면 전체)' % (len(pairs) - 15))
    if tool_ua:
        lines.append('※ 도구 User-Agent 감지: ' + short_list(sorted({re.sub(r'\(Test:[^)]*\)', '', u).strip() for u in tool_ua}), 3))
    q0, r0 = pairs[0][1], pairs[0][2]
    summ = '%s %s → %s' % (q0['method'], req_url(pairs[0][0], q0),
                                    ('%d %s' % (r0['code'], r0['reason'])) if r0 else '응답없음')
    if servers:
        summ += ' (%s)' % ', '.join(servers)
    if len(pairs) > 1:
        summ += ' 외 %d건' % (len(pairs) - 1)
    if creds:
        best = next((x for x in creds if '성공' in http_login_result(x[2], x[1])), creds[-1])
        summ += ' / 로그인: %s / %s → %s' % (best[3][1], best[3][2], http_login_result(best[2], best[1]))
    return [Finding(55, 'HTTP 웹 접속', summ, lines,
            ev=[t[3] for c in cs for t in c.tl], wf=[('http', 'HTTP 요청/응답'), ('http.request', '요청만')])]


def ssh_findings(cip, sip, sport, cs, verbose):
    def banner(c, d):
        s = c.stream(d)
        return s.split(b'\n', 1)[0].strip().decode('latin-1') if s.startswith(b'SSH-') else ''
    cb = next((banner(c, 0) for c in cs if banner(c, 0)), '')
    sb = next((banner(c, 1) for c in cs if banner(c, 1)), '')
    lines = ['SSH %s → %s:%d  (세션 %d개, %s ~ %s)' % (cip, sip, sport, len(cs), fmt_ts(min(c.start for c in cs)),
                                                    fmt_ts(max(c.end for c in cs), False)),
             '클라이언트: %s' % (cb or '?'), '서버: %s' % (sb or '?')]
    conc = max_concurrency(cs)
    rawb = next((c.stream(0).split(b'\n', 1)[0] for c in cs if c.stream(0).startswith(b'SSH-')), b'')
    tool = next(((t, why) for rx, t, why in SSH_CLIENTS if re.search(rx, cb)), None)
    if not tool and cb.startswith('SSH-2.0-OpenSSH_7.1') and not rawb.endswith(b'\r') and len(cs) >= 3:
        tool = ('Ncrack', '배너가 고정값 SSH-2.0-OpenSSH_7.1 이고 줄끝에 \\r 이 없음(Ncrack 특징)')
    auth = {id(c): ssh_auth(c) for c in cs}
    tried = [c for c in cs if auth[id(c)] and auth[id(c)]['stage'] == '인증']
    succ = [c for c in tried if auth[id(c)]['success']]
    n_fail = sum(auth[id(c)]['fails'] for c in tried)
    attacky = tool and tool[0] not in ('PuTTY',)
    if len(cs) >= 5 or attacky or n_fail >= 4:
        tname = tool[0] if tool else ('Hydra' if conc >= 4 else '자동화 도구')
        lines.append('▶ 공격: %s를 이용한 SSH 무차별 대입 공격 (세션 %d개, 동시 최대 %d개)' % (tname, len(cs), conc))
        if tool:
            lines.append('  근거: ' + tool[1])
        lines.append('  인증까지 간 세션 %d개, 인증 실패 응답 약 %d회' % (len(tried), n_fail))
        lines.append('  SSH는 암호화되어 시도한 계정/비밀번호 자체는 패킷으로 확인 불가')
        if succ:
            for c in succ[:3]:
                a = auth[id(c)]
                lines.append('▶ 결과: 로그인 성공! 출발포트 %d 세션 (프레임 #%d)' % (c.cli[1], a['succ_frame']))
                lines.append('  근거: 인증 실패 응답은 %s바이트인데 이 세션에서만 %d바이트짜리 작은 응답'
                             '(= SSH_MSG_USERAUTH_SUCCESS, SERVICE_ACCEPT %d바이트보다 작음)' % (a['fail_size'] or '?', a['succ_size'], a['accept']))
            summ = '★성공 (SSH %d세션, 출발포트 %d, #%d) — 비번은 암호화' % (len(cs), succ[0].cli[1], auth[id(succ[0])]['succ_frame'])
        else:
            lines.append('▶ 결과: 실패로 보임 — 모든 인증 응답이 실패 크기')
            summ = '실패 (SSH %d세션)' % len(cs)
        return [Finding(20, '%s SSH 무차별 대입 공격' % tname, summ, lines,
            ev=[i for c in (succ[:1] or cs[:1]) for i in c.idxs],
            groups=[('성공 세션 (출발포트 %d)' % c.cli[1], c.idxs) for c in succ[:2]] or [(None, cs[0].idxs)],
            wf=[('tcp.dstport == %d && tcp.flags.syn == 1 && tcp.flags.ack == 0' % sport, 'SYN 개수 = 접속 세션 수'),
                ('ssh.protocol', '클라이언트/서버 버전 배너')] + ([('tcp.port == %d' % succ[0].cli[1], '성공 세션 → 마지막 작은 패킷 확인')] if succ else []))]
    res_s = ''
    for c in cs:
        a = auth[id(c)]
        lines.append('세션 %s:%d  %.1f초, 클라→서버 %dB/%d패킷, 서버→클라 %dB/%d패킷%s' % (
            c.cli[0], c.cli[1], c.end - c.start, c.bytes[0], c.pkts[0], c.bytes[1], c.pkts[1],
            ', 정상종료(FIN)' if all(c.fin) else ''))
        if a and a['stage'] == '인증':
            if a['success']:
                res_s = '로그인 성공'
                lines.append('▶ 로그인 성공 (프레임 #%d: SERVICE_ACCEPT %d바이트보다 작은 %d바이트 응답 = USERAUTH_SUCCESS)%s' % (
                    a['succ_frame'], a['accept'], a['succ_size'], ', 실패 %d회 후' % a['fails'] if a['fails'] else ''))
            else:
                res_s = res_s or '로그인 실패 추정'
                lines.append('▶ 로그인 실패로 보임 (인증 실패 응답 %d회, 성공 크기 응답 없음)' % a['fails'])
        elif a:
            lines.append('  %s' % a['stage'])
    lines.append('※ SSH는 암호화되어 계정/비밀번호/명령은 패킷에서 볼 수 없음 (성공 여부는 패킷 크기로 추정)')
    return [Finding(56, 'SSH 접속', 'SSH %s → %s (클라 %s / 서버 %s)%s' % (cip, sip, cb.replace('SSH-2.0-', ''),
                                                                     sb.replace('SSH-2.0-', ''), ' → ' + res_s if res_s else ''), lines,
            ev=[i for c in cs for i in c.idxs[:14]], wf=[('ssh', 'SSH 패킷'), ('ssh.protocol', '버전 배너')])]


# ── 스캔 / 스윕 ───────────────────────────────────────────────────
def nmap_discovery_signature(pk):
    """nmap 호스트 탐색 기본 프로브 4종을 보낸 출발지 집합"""
    kinds = collections.defaultdict(set)
    for p in pk:
        if p.proto == 1 and p.itype == 8 and len(p.data) == 0:
            kinds[p.src].add('echo')
        elif p.proto == 1 and p.itype == 13:
            kinds[p.src].add('ts')
        elif p.proto == 6 and p.dport == 443 and p.flags == 0x02:
            kinds[p.src].add('syn443')
        elif p.proto == 6 and p.dport == 80 and p.flags == 0x10 and not p.data:
            kinds[p.src].add('ack80')
    return {s for s, k in kinds.items() if len(k) >= 3}


def sweep_sources(pk):
    tg = collections.defaultdict(set)
    for p in pk:
        if p.proto == 1 and p.itype in (8, 13):
            tg[p.src].add(p.dst)
        elif p.arp and p.arp[0] == 1:
            tg[p.arp[2]].add(p.arp[4])
    return {s for s, t in tg.items() if len(t) >= 10}


def sweep_findings(pk, nmap_srcs):
    out = []
    targets = collections.defaultdict(set)
    methods = collections.defaultdict(set)
    for p in pk:
        if p.proto == 1 and p.itype == 8:
            targets[p.src].add(p.dst); methods[p.src].add('ICMP Echo')
        elif p.proto == 1 and p.itype == 13:
            targets[p.src].add(p.dst); methods[p.src].add('ICMP Timestamp')
        elif p.proto == 6 and p.flags == 0x02 and p.dport == 443:
            targets[p.src].add(p.dst); methods[p.src].add('TCP SYN→443')
        elif p.proto == 6 and p.flags == 0x10 and p.dport == 80 and not p.data:
            targets[p.src].add(p.dst); methods[p.src].add('TCP ACK→80')
        elif p.arp and p.arp[0] == 1:
            targets[p.arp[2]].add(p.arp[4]); methods[p.arp[2]].add('ARP 요청')
    for s, tg in targets.items():
        if len(tg) < 10:
            if not (s in nmap_srcs and len(methods[s]) >= 3):
                continue
            scan_ports = {p.dport for p in pk if p.proto == 6 and p.src == s and p.flags == 0x02}
            if len(scan_ports - {80, 443}) >= 2:
                continue                       # 포트 스캔 앞에 nmap이 하는 호스트 확인 → 포트 스캔 판정에 포함
        alive, others, rep_ev = set(), set(), []
        for p in pk:
            if p.dst == s and p.src and p.src != s:
                if p.proto == 1 and p.itype in (0, 14) or p.proto == 6:
                    (alive if p.src in tg else others).add(p.src)
                    if p.src in tg:
                        rep_ev.append(p.idx)
            if p.arp and p.arp[0] == 2 and p.arp[4] == s:
                alive.add(p.arp[2])
        nm = s in nmap_srcs or 'ARP 요청' in methods[s] and len(tg) > 20
        sk = lambda x: [int(y) if y.isdigit() else 0 for y in x.split('.')]
        lines = ['탐색자 %s → %s' % (s, describe_targets(tg)),
                 '사용한 프로브: ' + ', '.join(sorted(methods[s]))]
        if s in nmap_srcs:
            lines.append('nmap 특징: ICMP Echo(데이터 0바이트) + ICMP Timestamp + TCP 443 SYN + TCP 80 ACK = nmap -sn 기본 프로브')
        lines.append('▶ 살아있는 호스트(응답): %s' % (', '.join(sorted(alive, key=sk)) or '없음'))
        if others:
            lines.append('  (대상 목록 밖에서 응답: %s)' % ', '.join(sorted(others, key=sk)))
        out.append(Finding(35, ('nmap ' if nm else '') + '호스트 탐색(핑 스윕, -sn)',
                           '%s → %s 탐색, 응답 호스트: %s' % (s, describe_targets(tg), ', '.join(sorted(alive, key=sk)) or '없음'),
                           lines,
            ev=[p.idx for p in pk if p.src == s][:8] + rep_ev, wf=[('ip.dst == %s && (icmp.type == 0 || icmp.type == 14 || tcp)' % s, '살아있는 호스트의 응답'), ('ip.src == %s && icmp.type == 8' % s, '핑 스윕 Echo Request')]))
    return out


def stealth_scan_findings(pk, conns):
    live = set()
    for c in conns:
        if c.synack or c.handshake or c.bytes[0] or c.bytes[1]:
            live.add((c.cli, c.srv))
            live.add((c.srv, c.cli))
    names = {0x01: 'FIN 스캔(-sF)', 0x00: 'NULL 스캔(-sN)', 0x29: 'Xmas 스캔(-sX)', 0x10: 'ACK 스캔(-sA)',
             0x11: 'Maimon 스캔(-sM)'}
    by = collections.defaultdict(set)
    byi = collections.defaultdict(list)
    for p in pk:
        if p.proto == 6 and p.flags in names and not p.data and not (p.flags == 0x10 and p.dport == 80) \
                and ((p.src, p.sport), (p.dst, p.dport)) not in live:
            by[(p.src, p.dst, p.flags)].add(p.dport)
            byi[(p.src, p.dst, p.flags)].append(p.idx)
    out = []
    for (a, v, fl), ports in by.items():
        if len(ports) < 6 or (fl == 0x10 and len(ports) < 20):
            continue
        rst = {p.sport for p in pk if p.proto == 6 and p.src == v and p.dst == a and (p.flags or 0) & 0x04}
        if fl == 0x10:
            res = '응답 RST=필터 안 됨(unfiltered): %d개, 무응답=필터됨: %d개' % (len(ports & rst), len(ports - rst))
        else:
            res = '▶ 열림|필터(무응답): %s / 닫힘(RST): %d개' % (ports_str(ports - rst) or '없음', len(ports & rst))
        out.append(Finding(30, 'nmap ' + names[fl], '%s → %s 포트 %d개, %s' % (a, v, len(ports), res),
                           ['공격자 %s → 대상 %s, TCP 플래그 %s 패킷으로 %d개 포트 검사' % (a, v, fl_str(fl), len(ports)), res],
            ev=byi[(a, v, fl)][:30] + [p.idx for p in pk if p.proto == 6 and p.src == v and p.dst == a][:30], wf=[('ip.src == %s && tcp.flags == 0x%03x' % (a, fl), '스캔 패킷'), ('ip.src == %s && tcp.flags.reset == 1' % v, 'RST 응답 = 닫힌 포트')]))
    return out


def ua_filter_text(ua):
    """User-Agent 에서 필터에 넣기 좋은 부분 (괄호·따옴표 없는 첫 덩어리)"""
    parts = [x.strip() for x in re.split(r'[()"\\]', ua) if len(x.strip()) >= 6]
    return (parts[0] if parts else ua.replace('"', ''))[:40]


def banner_of(c):
    """서버가 보낸 첫 응답에서 제품/버전이 보이는 부분"""
    t = c.stream(1)[:400].decode('latin-1', 'replace')
    m = re.search(r'(?im)^server:\s*([^\r\n]+)', t)
    if m:
        return m.group(1).strip()[:50]
    first = t.split('\n', 1)[0].strip()
    return first[:50]


def fl_str(f):
    return ''.join(c for bit, c in [(1, 'F'), (2, 'S'), (4, 'R'), (8, 'P'), (16, 'A'), (32, 'U')] if f & bit) or 'NULL(없음)'


def is_trace_port(port):
    return 33434 <= port <= 33600


def udp_scan_findings(pk):
    by = collections.defaultdict(set)
    for p in pk:
        if p.proto == 17 and p.dport is not None and not is_trace_port(p.dport) and p.sport != 53 and p.dport < 49152:
            by[(p.src, p.dst)].add(p.dport)
    out = []
    unr_n = collections.Counter()
    unr_ports = collections.defaultdict(set)
    udp_resp = collections.defaultdict(set)
    for p in pk:
        if p.proto == 1 and p.itype == 3 and p.icode == 3 and p.inner:
            unr_n[(p.dst, p.src)] += 1
            unr_ports[p.dst].add(p.inner['dport'])
        elif p.proto == 17 and p.sport is not None:
            udp_resp[(p.src, p.dst)].add(p.sport)
    for (a, v), ports in by.items():
        if len(ports) < 4:
            continue
        n_unr = unr_n[(a, v)]
        if len(ports) < 6 and n_unr == 0:
            continue
        unreach = unr_ports[a]
        resp = udp_resp[(v, a)]
        out.append(Finding(31, 'UDP 포트 스캔(nmap -sU)', '%s → %s UDP %d개 포트, 열림: %s' % (a, v, len(ports), ports_str(resp) or '없음'),
                           ['공격자 %s → 대상 %s UDP 포트 %d개' % (a, v, len(ports)),
                            '▶ 열린 포트(UDP 응답): ' + (ports_str(resp) or '없음'),
                            '  닫힌 포트(ICMP Port Unreachable): %d개' % len(unreach & ports),
                            '  열림|필터(무응답): %d개' % len(ports - unreach - resp)],
            ev=[p.idx for p in pk if (p.src == a and p.dst == v and p.proto == 17) or (p.src == v and p.dst == a)][:80], wf=[('ip.addr == %s && (udp || icmp.type == 3)' % v, 'UDP 프로브와 응답')]))
    return out


def traceroute_findings(pk):
    out = []
    udp = collections.defaultdict(list)
    icmp = collections.defaultdict(list)
    for p in pk:
        if p.proto == 17 and p.dport and is_trace_port(p.dport):
            udp[(p.src, p.dst)].append(p)
        elif p.proto == 1 and p.itype == 8:
            icmp[(p.src, p.dst)].append(p)
    cands = []
    for k, ps in udp.items():
        if len(ps) >= 3:
            cands.append((k, ps, 'UDP', 'traceroute (Linux 기본, UDP 33434~ 포트로 TTL을 늘려가며 전송)'))
    for k, ps in icmp.items():
        ttls = {p.ttl for p in ps}
        if len(ttls) >= 3 and min(ttls) <= 5:
            cands.append((k, ps, 'ICMP', 'tracert (Windows) / traceroute -I (ICMP Echo로 TTL을 늘려가며 전송)'))
    for (s, d), ps, mode, desc in cands:
        probe_ttl = {}
        for p in ps:
            probe_ttl[p.dport if mode == 'UDP' else p.icmp_seq] = p.ttl
        # 캡처 지점 앞에 있는 라우터 수(k): Linux traceroute는 TTL마다 포트 3개씩(33434~) → 원래 TTL = (포트-33434)//3+1
        k = 0
        if mode == 'UDP':
            ks = collections.Counter((p.dport - 33434) // 3 + 1 - p.ttl for p in ps)
            kk, n = ks.most_common(1)[0]
            if kk > 0 and n >= 0.8 * len(ps):
                k = kk
        hops, reached, reached_hop = {}, None, None
        for p in pk:
            if p.proto != 1 or p.dst != s or not p.inner or p.inner['dst'] != d:
                if mode == 'ICMP' and p.proto == 1 and p.itype == 0 and p.src == d and p.dst == s:
                    reached = reached or d
                    t = probe_ttl.get(p.icmp_seq)
                    if t is not None:
                        reached_hop = min(reached_hop or 999, t + k)
                continue
            key = p.inner['dport'] if mode == 'UDP' else p.inner['icmp_seq']
            t = probe_ttl.get(key, p.inner['ttl'])
            if p.itype == 11:
                hops.setdefault(t + k, p.src)
            elif p.itype == 3:
                reached = p.src
                # 목적지에 도착했을 때 남은 TTL(안쪽 헤더) → 목적지까지 실제 홉 수 = 원래 TTL - 남은 TTL + 1
                h = t + k - max(1, p.inner['ttl']) + 1
                reached_hop = min(reached_hop or 999, max(1, h))
        last = max([h for h in hops] + [reached_hop or 0, k])
        seq = []
        for n in range(1, last + 1):
            if n <= k:
                seq.append((n, None, '캡처 지점 이전(안 보임)'))
            elif n in hops:
                seq.append((n, hops[n], ''))
            elif reached_hop == n:
                seq.append((n, reached, '도착'))
            else:
                seq.append((n, '*', '응답 없음'))
        if reached_hop and reached_hop in hops and hops[reached_hop] != reached:
            pass
        elif reached and not reached_hop:
            seq.append((last + 1, reached, '도착'))
        for i, (n, ip, note) in enumerate(seq):
            if ip == reached and n == reached_hop:
                seq[i] = (n, ip, '도착')
        visible = [ip for n, ip, note in seq if ip and ip != '*']
        lines = ['%s → 목적지 %s, %s' % (s, d, desc),
                 '프로브 %d개, 캡처 지점에서 본 TTL 값: %s' % (len(ps), sorted({p.ttl for p in ps}))]
        if k:
            lines.append('※ 프로브 포트로 계산한 원래 TTL이 캡처된 TTL보다 %d 큼 → 캡처 지점 앞에 라우터 %d개가 있어 그 홉은 패킷에 안 보임' % (k, k))
        for n, ip, note in seq:
            lines.append('  %d번째 홉: %s%s' % (n, ip or '(보이지 않음)', ('  (%s)' % note) if note else '  (ICMP Time Exceeded 응답)'))
        lines.append('▶ 목적지 도달: %s' % (('예 — %s 가 응답(%s)' % (reached, 'Port Unreachable' if mode == 'UDP' else 'Echo Reply'))
                                          if reached else '확인 안 됨'))
        lines.insert(1, '▶ 홉 %d개: %s' % (len(seq), '  '.join('%d) %s%s' % (n, ip or '(캡처 전)', '(도착)' if note == '도착' else '')
                                                              for n, ip, note in seq)))
        f = Finding(40, 'Traceroute (경로 추적)', '홉 %d개: %s | %s → %s%s' % (
            len(seq), ' → '.join(visible) or '(없음)', s, d, (' (앞 %d홉은 캡처 전이라 안 보임)' % k) if k else ''), lines,
            ev=[p.idx for p in ps] + [p.idx for p in pk if p.proto == 1 and p.dst == s and ((p.inner and p.inner['dst'] == d) or (p.itype == 0 and p.src == d))], wf=[(('icmp.type == 11 || icmp.type == 3 || (ip.src == %s && udp.dstport >= 33434 && udp.dstport <= 33534)' % s) if mode == 'UDP' else ('icmp && ip.addr == %s' % s), '프로브와 Time Exceeded/도착 응답')])
        f.src, f.dst = s, d
        out.append(f)
    return out


def ping_findings(pk, trace_pairs, sweepers):
    reqs = collections.defaultdict(list)
    reps = collections.defaultdict(list)
    for p in pk:
        if p.proto == 1 and p.itype == 8 and not p.frag_off:
            reqs[(p.src, p.dst)].append(p)
        elif p.proto == 1 and p.itype == 0:
            reps[(p.dst, p.src)].append(p)
    out = []
    per_dst = collections.Counter()
    for (s, d), rq in reqs.items():
        per_dst[d] += len(rq)
    unr_by = collections.defaultdict(list)
    for p in pk:
        if p.proto == 1 and p.itype == 3:
            unr_by[p.dst].append(p)
    for (s, d), rq in reqs.items():
        if (s, d) in trace_pairs or s in sweepers or any(p.icode for p in rq) or d.endswith('.255') or d == '255.255.255.255':
            continue
        if per_dst[d] >= 200 and len(rq) < 50:
            continue                                   # 출발지를 위조한 ICMP Flood의 조각 → DoS 쪽에서 처리
        if len(rq) <= 2 and all(len(p.data) == 0 for p in rq):
            continue                                   # nmap 호스트 확인용 단발 프로브
        rp = reps.get((s, d), [])
        if len(rq) >= 200:
            continue                                   # 플러딩은 DoS 쪽에서 처리
        size = len(rq[0].data) + 8
        if rq[0].mf:                                   # 큰 ping → IP 조각으로 나뉨: 조각을 합친 크기
            size = sum(len(x.data) for x in pk if x.src == s and x.dst == d and x.ipid == rq[0].ipid and x.proto == 1 and
                       (x.mf or x.frag_off)) + 8
            if size + 20 > 65535:
                continue                               # Ping of Death → DoS 쪽에서 처리
        payload = rq[0].data
        if b'abcdefghijklmnopqrstuvw' in payload:
            tool = 'Windows ping (데이터 abcdefg… 패턴)'
        elif bytes(range(0x10, 0x30)) in payload:
            tool = 'Linux/Unix ping (데이터 0x10~0x37 패턴)'
        elif not payload:
            tool = '데이터 없는 Echo (nmap/hping 등 도구 가능성)'
        else:
            tool = '알 수 없음'
        loss = 100.0 * (len(rq) - len(rp)) / len(rq)
        lines = ['%s → %s  ICMP Echo Request %d회 / Echo Reply %d회 (손실 %.0f%%)' % (s, d, len(rq), len(rp), max(loss, 0)),
                 'ICMP 크기 %d바이트 (데이터 %d바이트%s), 보낸 도구: %s' % (size, size - 8, ', IP 조각으로 나뉘어 전송 → ping -s 큰 값' if rq[0].mf else '', tool),
                 '기간 %s ~ %s' % (fmt_ts(rq[0].ts), fmt_ts(rq[-1].ts, False))]
        if rp:
            lines.append('응답 TTL %d → 대상 OS 추정: %s' % (rp[0].ttl, guess_os_by_ttl(rp[0].ttl)))
            lines.append('▶ 결과: 대상 %s 살아있음(통신 가능)' % d)
        else:
            unr = unr_by.get(s, [])
            lines.append('▶ 결과: 응답 없음' + (' (ICMP Unreachable from %s)' % unr[0].src if unr else ''))
        out.append(Finding(60, 'Ping (ICMP Echo)', '응답 %d/%d %s | %s → %s' % (len(rp), len(rq), '(살아있음)' if rp else '(응답 없음)', s, d), lines,
            ev=[p.idx for p in rq] + [p.idx for p in rp], wf=[('icmp && ip.addr == %s' % d, 'Echo Request/Reply')]))
    return out


def dos_findings(pk, conns, sweepers=()):
    out = []
    # Land 공격
    land = [p for p in pk if p.proto == 6 and p.src == p.dst and p.sport == p.dport and p.flags and p.flags & 0x02]
    if land:
        out.append(Finding(10, 'Land 공격', '출발지=목적지(%s:%d) SYN %d개' % (land[0].src, land[0].sport, len(land)),
                           ['출발지 IP/포트와 목적지 IP/포트가 같은 SYN 패킷 %d개 → Land Attack' % len(land)],
            ev=[p.idx for p in land], wf=[('ip.src == ip.dst', '출발지 = 목적지')]))
    # SYN Flood
    syn = collections.defaultdict(list)
    for c in conns:
        if c.syn:
            syn[c.srv].append(c)
    for srv, cs in syn.items():
        if len(cs) < 100:
            continue
        done = sum(1 for c in cs if c.handshake)
        srcs = {c.cli[0] for c in cs}
        dur = max(c.start for c in cs) - min(c.start for c in cs) or 1
        if done / len(cs) < 0.3:
            out.append(Finding(10, 'SYN Flooding (DoS)', '%s 대상 SYN %d개, 출발지 %d개%s' % (
                ep(srv), len(cs), len(srcs), '(IP 위조)' if len(srcs) > 20 else ''),
                ['대상 %s 에 SYN %d개 (%.0f개/초), 핸드셰이크 완료 %d개' % (ep(srv), len(cs), len(cs) / dur, done),
                 '출발지 IP %d개: %s' % (len(srcs), short_list(sorted(srcs), 8)),
                 '출발지가 많고 무작위면 IP 스푸핑(위조) 사용'],
            ev=[c.idxs[0] for c in cs[:30]], wf=[('tcp.flags.syn == 1 && tcp.flags.ack == 0 && ip.dst == %s' % srv[0], 'SYN 패킷')]))
    # ICMP / UDP flood, Smurf
    ic = collections.defaultdict(list)
    ud = collections.defaultdict(list)
    for p in pk:
        if p.proto == 1 and p.itype == 8:
            ic[p.dst].append(p)
        elif p.proto == 17 and p.dport != 53:
            ud[p.dst].append(p)
    for dst, ps in ic.items():
        if (dst.endswith('.255') or dst == '255.255.255.255') and ps[0].src not in sweepers and len(ps) >= 3:
            out.append(Finding(10, 'Smurf 공격(의심)', '브로드캐스트 %s 로 Echo Request %d개 (위조 출발지 %s)' % (dst, len(ps), ps[0].src),
                               ['브로드캐스트 주소로 ICMP Echo Request → 응답이 출발지(피해자 %s)로 몰림' % ps[0].src],
            ev=[p.idx for p in ps[:30]], wf=[('icmp.type == 8 && ip.dst == %s' % dst, '브로드캐스트로 보낸 Echo')]))
        elif len(ps) >= 200:
            dur = (ps[-1].ts - ps[0].ts) or 1
            if len(ps) / dur > 50:
                srcs = {p.src for p in ps}
                out.append(Finding(12, 'ICMP Flooding (DoS)', '%s 대상 Echo Request %d개 (%.0f개/초)' % (dst, len(ps), len(ps) / dur),
                                   ['출발지 %d개: %s' % (len(srcs), short_list(sorted(srcs), 6)),
                                    '평균 크기 %d바이트' % (sum(p.iplen for p in ps) // len(ps))],
            ev=[p.idx for p in ps[:30]], wf=[('icmp.type == 8 && ip.dst == %s' % dst, 'Echo Request')]))
    for dst, ps in ud.items():
        if len(ps) >= 300:
            dur = (ps[-1].ts - ps[0].ts) or 1
            if len(ps) / dur > 50:
                out.append(Finding(12, 'UDP Flooding (DoS)', '%s 대상 UDP %d개 (%.0f개/초)' % (dst, len(ps), len(ps) / dur),
                                   ['목적지 포트: ' + short_list(collections.Counter(p.dport for p in ps).most_common(5))],
            ev=[p.idx for p in ps[:30]], wf=[('udp && ip.dst == %s' % dst, 'UDP 패킷')]))
    # IP 조각 공격 (Ping of Death / Teardrop)
    frags = collections.defaultdict(list)
    for p in pk:
        if p.l2 == 'ipv4' and (p.mf or p.frag_off):
            ihl_len = len(p.data) + (8 if p.frag_off == 0 and p.proto in (1, 17) else 0) + (20 if p.frag_off == 0 and p.proto == 6 else 0)
            frags[(p.src, p.dst, p.ipid, p.proto)].append((p.frag_off, ihl_len))
    pod, tear = [], []
    for k, fs in frags.items():
        total = max(o + l for o, l in fs) + 20
        if k[3] == 1 and total > 65535:
            pod.append((k, total))
        fs2 = sorted(set(fs))                        # 같은 조각이 두 번 잡힌 것은 겹침 아님
        if any(fs2[i + 1][0] < fs2[i][0] + fs2[i][1] and fs2[i + 1][0] != fs2[i][0] for i in range(len(fs2) - 1)):
            tear.append(k)
    if pod:
        k, t = pod[0]
        out.append(Finding(10, 'Ping of Death', '%s → %s 재조립 크기 %d바이트 (>65535) ICMP %d건' % (k[0], k[1], t, len(pod)),
                           ['조각난 ICMP 패킷을 합치면 IP 최대 크기 65,535바이트 초과 → Ping of Death'],
            ev=[p.idx for p in pk if (p.mf or p.frag_off) and (p.src, p.dst, p.ipid, p.proto) == k][:40], wf=[('ip.flags.mf == 1 || ip.frag_offset > 0', 'IP 조각')]))
    elif frags:
        big = [(k, max(o + l for o, l in fs)) for k, fs in frags.items() if k[3] == 1]
        if big and len(big) >= 10:
            out.append(Finding(15, '대용량 조각 ICMP (Ping of Death/Flood 의심)',
                               '조각난 ICMP 묶음 %d개, 최대 %d바이트' % (len(big), max(b for _, b in big)),
                               ['ping -s 65500 류의 큰 ICMP를 반복 전송한 흔적'],
            ev=[p.idx for p in pk if p.proto == 1 and (p.mf or p.frag_off)][:40], wf=[('ip.flags.mf == 1 || ip.frag_offset > 0', 'IP 조각')]))
    if tear:
        out.append(Finding(10, 'Teardrop 공격', 'IP 조각 오프셋 겹침 %d건 (%s → %s)' % (len(tear), tear[0][0], tear[0][1]),
                           ['IP 조각의 오프셋이 서로 겹치게 조작됨 → Teardrop'],
            ev=[p.idx for p in pk if (p.mf or p.frag_off) and (p.src, p.dst, p.ipid, p.proto) == tear[0]][:40], wf=[('ip.flags.mf == 1 || ip.frag_offset > 0', 'IP 조각')]))
    return out


def arp_findings(pk):
    ip_macs = collections.defaultdict(set)
    replies = collections.Counter()
    mac_ips = collections.defaultdict(set)
    asked = set()
    unsol = collections.Counter()
    for p in pk:
        if p.arp:
            op, sha, spa, tha, tpa = p.arp
            if spa != '0.0.0.0':
                ip_macs[spa].add(sha)
            if op == 1:
                asked.add((spa, tpa))
            if op == 2:
                replies[(sha, spa)] += 1
                mac_ips[sha].add(spa)
                if (tpa, spa) not in asked:
                    unsol[(sha, spa)] += 1
    out, done = [], set()

    def ev_of(pred):
        return [p.idx for p in pk if p.arp and pred(p.arp)][:40]
    for ipa, macs in ip_macs.items():
        if len(macs) >= 2:
            # 공격자 MAC = 다른 IP까지 주장하거나, 요청 없는 Reply를 더 많이 보낸 쪽
            att = max(macs, key=lambda m: (len(mac_ips[m]) > 1, unsol[(m, ipa)], replies[(m, ipa)]))
            lines = ['IP %s 에 대해 MAC %d개가 응답: %s' % (ipa, len(macs), ', '.join(sorted(macs)))]
            for (sha, spa), n in replies.most_common():
                if spa == ipa:
                    lines.append('  %s 가 "%s 는 나다" ARP Reply %d회 (요청 없이 보낸 것 %d회)' % (sha, spa, n, unsol[(sha, spa)]))
            lines.append('▶ 공격자 MAC(추정): %s%s' % (att, ('  — 이 MAC은 %s 도 자기라고 주장' % ', '.join(sorted(mac_ips[att] - {ipa})))
                                                   if len(mac_ips[att]) > 1 else ''))
            done.add(att)
            out.append(Finding(10, 'ARP 스푸핑(캐시 포이즈닝)', 'IP %s ↔ MAC %d개, 공격자 MAC %s' % (ipa, len(macs), att), lines,
                ev=ev_of(lambda a: a[2] == ipa), wf=[('arp.src.proto_ipv4 == %s' % ipa, '이 IP를 주장하는 ARP'),
                                                    ('arp.opcode == 2 && arp.src.hw_mac == %s' % att, '공격자 MAC이 보낸 ARP Reply')]))
    for m, ips in mac_ips.items():
        if m in done:
            continue
        n_un = sum(unsol[(m, i)] for i in ips)
        if (len(ips) >= 2 and sum(replies[(m, i)] for i in ips) >= 4) or n_un >= 10:
            lines = ['MAC %s 가 ARP Reply로 IP %s 를 자기 것이라고 주장 (Reply %d회, 요청 없이 보낸 것 %d회)' % (
                m, ', '.join(sorted(ips)), sum(replies[(m, i)] for i in ips), n_un),
                '▶ 공격자 MAC(추정): %s — 피해자 쪽 캡처라 진짜 MAC의 응답은 안 보여도 반복된 가짜 Reply로 판단' % m]
            out.append(Finding(10, 'ARP 스푸핑(캐시 포이즈닝)', '공격자 MAC %s 가 %s 를 사칭 (가짜 ARP Reply %d회)' % (
                m, ', '.join(sorted(ips)), sum(replies[(m, i)] for i in ips)), lines,
                ev=ev_of(lambda a: a[1] == m), wf=[('arp.opcode == 2 && arp.src.hw_mac == %s' % m, '공격자가 보낸 ARP Reply')]))
    return out


def dns_findings(pk, has_other, verbose):
    qs = collections.OrderedDict()
    for p in pk:
        if p.proto == 17 and 53 in (p.sport, p.dport):
            m = parse_dns(p.data)
            if not m or not m['q']:
                continue
            name, qt = m['q'][0]
            e = qs.setdefault((name, qt), {'n': 0, 'resp': None, 'cli': set(), 'srv': set(), 'ts': p.ts, 'ix': []})
            e['ix'].append(p.idx)
            if m['qr'] == 0:
                e['n'] += 1
                e['cli'].add(p.src)
                e['srv'].add(p.dst)
            else:
                e['resp'] = m
    if not qs:
        return []
    user_q = [(k, v) for k, v in qs.items() if not k[0].endswith('in-addr.arpa') and not BROWSER_DOMAINS.search(k[0])]
    browser_q = [(k, v) for k, v in qs.items() if BROWSER_DOMAINS.search(k[0])]
    ptr_q = [(k, v) for k, v in qs.items() if k[0].endswith('in-addr.arpa')]

    def line(k, v):
        if v['resp'] is None:
            r = '응답 없음'
        elif v['resp']['rcode']:
            r = RCODES.get(v['resp']['rcode'], str(v['resp']['rcode']))
        else:
            r = '응답: ' + (', '.join('%s %s' % (t, val) for _, t, val in v['resp']['a']) or '(답 없음)')
        return '%-35s %-5s ×%d  → %s' % (k[0], k[1], v['n'], r)
    lines = ['DNS 서버: %s, 질의한 호스트: %s' % (', '.join(sorted({s for v in qs.values() for s in v['srv']})),
                                          ', '.join(sorted({s for v in qs.values() for s in v['cli']})))]
    for k, v in user_q:
        lines.append('▶ ' + line(k, v))
    if browser_q:
        lines.append('브라우저 자동 질의(Firefox/Chrome 등) %d종%s' % (len(browser_q), ':' if verbose else ' ([v] 자세히 켜면 보임)'))
        if verbose:
            lines += ['   ' + line(k, v) for k, v in browser_q]
    if ptr_q:
        lines.append('역방향(PTR) 조회 %d건 — nmap/traceroute가 IP의 이름을 자동 조회' % len(ptr_q))
        if verbose:
            lines += ['   ' + line(k, v) for k, v in ptr_q]
    names = ', '.join(list(collections.OrderedDict.fromkeys(k[0] for k, _ in user_q))[:4])
    any_resp = any(v['resp'] for _, v in user_q)
    if user_q and not has_other:
        if browser_q:
            title = 'DNS 질의 — 웹 브라우저로 도메인 접속 시도'
            summ = '%s 조회%s (브라우저 질의 동반)' % (names, '' if any_resp else ', DNS 응답 없음 → 접속 실패')
        else:
            title = 'DNS 질의 (nslookup/dig)'
            summ = '%s 조회 → %s' % (names, '응답 있음' if any_resp else '응답 없음')
            lines.append('브라우저/다른 통신 없이 DNS 질의만 존재 → nslookup 등으로 직접 조회')
        return [Finding(65, title, summ, lines,
            ev=[i for _, v in user_q for i in v['ix']], wf=[('dns.qry.name == "%s"' % user_q[0][0][0], '이 도메인 질의'), ('dns', 'DNS 전체')])]
    if user_q or browser_q:
        return [Finding(80, 'DNS 질의', (names or '브라우저 자동 질의') + ' 등 %d종' % len(qs), lines,
            ev=[i for _, v in (user_q or browser_q[:3]) for i in v['ix']], wf=[('dns', 'DNS 전체')])]
    return [Finding(85, 'DNS 역방향 조회', 'PTR %d건 (도구 자동 조회)' % len(ptr_q), lines,
            ev=[i for _, v in ptr_q for i in v['ix']], wf=[('dns.qry.type == 12', 'PTR(역방향) 질의')])]


def flow_summary(pk):
    c = collections.Counter()
    for p in pk:
        if p.src is None:
            continue
        name = {6: 'TCP', 17: 'UDP', 1: 'ICMP'}.get(p.proto, 'IP/%s' % p.proto)
        port = ''
        if p.proto in (6, 17):
            if p.sport is not None and p.dport is not None:
                port = ':%d' % (p.dport if p.dport < p.sport else p.sport)
        c[(p.src, p.dst, name + port)] += 1
    return ['%s → %s  %s  %d개' % (s, d, pr, n) for (s, d, pr), n in c.most_common(8)]


# ════════════════════════════════════════════════════════════════════
# 6. 출력
# ════════════════════════════════════════════════════════════════════
def dispw(s):
    return sum(2 if unicodedata.east_asian_width(ch) in 'WF' else 1 for ch in s)


def pad(s, w):
    return s + ' ' * max(0, w - dispw(s))


def cut(s, w):
    out, cur = '', 0
    for ch in s:
        cw = 2 if unicodedata.east_asian_width(ch) in 'WF' else 1
        if cur + cw > w - 1:
            return out + '…'
        out, cur = out + ch, cur + cw
    return out


# ════════════════════════════════════════════════════════════════════
# 7. 화면 출력 (색상, 보고서, 패킷 한 줄 표시)
# ════════════════════════════════════════════════════════════════════
COLOR = False
_CODES = {'red': '31', 'green': '32', 'yellow': '33', 'blue': '34', 'magenta': '35',
          'cyan': '36', 'bold': '1', 'dim': '2', 'rev': '7'}


def col(s, *codes):
    if not COLOR or not codes:
        return s
    return '\033[%sm%s\033[0m' % (';'.join(_CODES[c] for c in codes), s)


def setup_color(enable):
    global COLOR
    COLOR = bool(enable) and not os.environ.get('NO_COLOR') and sys.stdout.isatty()
    if COLOR and os.name == 'nt':
        os.system('')                       # Windows 콘솔 색상(ANSI) 켜기


def title_color(prio):
    if prio <= 20:
        return ('red', 'bold')
    if prio <= 40:
        return ('yellow', 'bold')
    if prio < 80:
        return ('green', 'bold')
    return ('dim',)


def clear():
    if COLOR:
        print('\033[2J\033[H', end='')
    else:
        print('\n' * 2)


def term_size():
    try:
        import shutil
        s = shutil.get_terminal_size((100, 30))
        return max(30, s.columns), max(10, s.lines)
    except Exception:
        return 100, 30


def main_findings(fs):
    return [x for x in fs if x.prio < 80] or fs[:1]


def render_report(info, findings, verbose=False):
    o = []
    name = os.path.basename(info['path'])
    o.append(col('═' * 78, 'cyan'))
    o.append(col('■ ' + name, 'bold') + '   (패킷 %d개, %s ~ %s, %s)' % (
        info['count'], fmt_ts(info['start']), fmt_ts(info['end'], False),
        '/'.join(LINKTYPES.get(l, str(l)) for l in info['linktypes'])))
    o.append('  판정: ' + ' + '.join(col(f.title, *title_color(f.prio)) for f in main_findings(findings)))
    o.append('─' * 78)
    for f in findings:
        o.append(col('[%s]' % f.title, *title_color(f.prio)) + ' ' + f.summary)
        for l in f.lines:
            o.append('    ' + (col(l, 'bold') if l.lstrip().startswith('▶') else l))
        o.append('')
    if info['noise']:
        o.append(col('  ※ 배경 트래픽(라우터 간 프로토콜 등, 무시 가능): ' +
                     ', '.join('%s %d' % kv for kv in info['noise'].most_common()), 'dim'))
    if verbose:
        o.append('  [통신 요약]')
        o += ['    ' + l for l in info['flows']]
    return o


def row_of(r):
    if r['err']:
        return '오류', r['err']
    mf = main_findings(r['fs'])
    return ' + '.join(x.title for x in mf[:2]), mf[0].summary


def render_table(results, numbered=False, width=None):
    width = width or 400
    names = [os.path.basename(r['path']) for r in results]
    w1 = max([dispw('파일')] + [dispw(n) for n in names]) + 2
    rows = [row_of(r) for r in results]
    w2 = min(30 if width < 120 else 34, max([dispw('판정')] + [dispw(t) for t, _ in rows]) + 2)
    num = '번호 ' if numbered else ''
    o = [num + pad('파일', w1) + pad('판정', w2) + '핵심 내용', '─' * min(width - 1, 110)]
    for i, (r, n, (t, s)) in enumerate(zip(results, names, rows), 1):
        prio = main_findings(r['fs'])[0].prio if r['fs'] else 0
        rest = max(10, width - w1 - w2 - (5 if numbered else 0) - 2)
        o.append(('%3d  ' % i if numbered else '') + pad(n, w1) + col(pad(cut(t, w2 - 1), w2), *title_color(prio)) + cut(s, rest))
    return o


FLAGN = [(0x02, 'SYN'), (0x01, 'FIN'), (0x04, 'RST'), (0x08, 'PSH'), (0x10, 'ACK'), (0x20, 'URG')]
PKT_HEADER = '%6s %11s  %-15s %-15s %-7s %s' % ('No.', 'Time', 'Source', 'Destination', 'Proto', 'Info')


def flag_names(f):
    n = [name for bit, name in FLAGN if f & bit]
    return '[%s]' % ', '.join(n) if n else '[없음]'


def first_line(b, n=70):
    t = b.decode('utf-8', 'replace')
    parts = [x.strip() for x in t.replace('\r', '\n').split('\n') if x.strip()]
    s = ' ↵ '.join(parts[:2]) if parts else ('↵(Enter)' if ('\n' in t or '\r' in t) else '')
    s = ''.join(ch if ch.isprintable() else '.' for ch in s)
    return s[:n] + ('…' if len(s) > n else '')


def proto_label(p):
    if p.arp:
        return 'ARP'
    if p.src is None:
        return p.l2
    if p.proto == 6:
        if p.svc == 'HTTP':
            return 'HTTP' if p.http else 'TCP'
        if p.data:
            for port in (p.sport, p.dport):
                if port in KNOWN_PORTS:
                    return KNOWN_PORTS[port]
        return 'TCP'
    if p.proto == 17:
        if 53 in (p.sport, p.dport):
            return 'DNS'
        return NOISE_UDP.get(p.dport) or NOISE_UDP.get(p.sport) or 'UDP'
    if p.proto == 1:
        return 'ICMP'
    return NOISE_PROTO.get(p.proto, 'IP/%s' % p.proto)


def pkt_info(p):
    if p.arp:
        op, sha, spa, tha, tpa = p.arp
        return 'Who has %s? Tell %s' % (tpa, spa) if op == 1 else '%s is at %s' % (spa, sha)
    if p.src is None:
        return p.l2
    s = ''
    if p.proto == 6 and p.sport is not None:
        s = '%s → %s %s' % (p.sport, p.dport, flag_names(p.flags or 0))
        if p.http:
            m = p.http
            s += ' ' + (('%s %s' % (m['method'], m['uri'])) if 'method' in m else
                        ('HTTP %d %s%s' % (m['code'], m['reason'], ' (%s)' % m['hdr']['content-type'].split(';')[0] if m['hdr'].get('content-type') else '')))
        elif p.data and p.svc == 'HTTP':
            s += ' [재조립되는 TCP 조각] Len=%d %s' % (len(p.data), first_line(p.data, 40) if p.data[:4] in (b'HTTP', b'GET ', b'POST', b'HEAD') else '')
        elif p.data:
            if p.data[:1] == b'\xff':
                rest = strip_iac(p.data)
                s += ' Telnet 옵션 협상(IAC)' + (' ' + first_line(rest) if rest.strip() else '')
            elif p.data[:1].isascii():
                s += ' ' + first_line(p.data)
            else:
                s += ' Len=%d (바이너리/암호화)' % len(p.data)
        else:
            s += ' Win=%s Len=0' % p.win
    elif p.proto == 17 and p.sport is not None:
        if 53 in (p.sport, p.dport):
            m = parse_dns(p.data)
            if m and m['q']:
                s = 'DNS %s %s %s' % ('응답' if m['qr'] else '질의', m['q'][0][1], m['q'][0][0])
                if m['qr']:
                    s += ' → ' + (', '.join(v for _, _, v in m['a']) or RCODES.get(m['rcode'], ''))
        if not s:
            s = '%s → %s Len=%d %s' % (p.sport, p.dport, len(p.data), first_line(p.data, 30) if p.data[:1].isascii() else '')
    elif p.proto == 1 and p.itype is not None:
        s = ICMP_NAMES.get(p.itype, 'type %s' % p.itype)
        if p.itype == 3:
            s += ' (%s)' % UNREACH.get(p.icode, p.icode)
        if p.icmp_seq is not None:
            s += ' id=%d seq=%d' % (p.icmp_id, p.icmp_seq)
        s += ' ttl=%s' % p.ttl
        if p.inner:
            s += ' [원래 패킷: %s → %s:%s]' % (p.inner['src'], p.inner['dst'], p.inner['dport'] or '')
    else:
        s = noise_name(p) or 'IP proto %s' % p.proto
    if p.frag_off or p.mf:
        s += ' [IP 조각 off=%d MF=%d]' % (p.frag_off, p.mf)
    return s


def pkt_line(p, base):
    if p.arp:
        src, dst = p.arp[1], 'Broadcast' if p.arp[0] == 1 else p.arp[3]
    else:
        src, dst = p.src or '', p.dst or ''
    return '%6d %11.6f  %-15s %-15s %-7s %s' % (p.idx, p.ts - base, src, dst, proto_label(p), pkt_info(p))


KEY_RX = re.compile(r'230 |530 |USER |PASS |SYN, ACK|Echo Reply|Time Exceeded|Unreachable|HTTP/1\.|GET |POST |HTTP \d{3}|'
                    r'login|Login|password|Password|DNS 응답|SSH-|is at|uid=|whoami|artex|norma|sqlmap|Nikto|Hydra|'
                    r'%27|UNION|<script|\.\./|/etc/passwd')


def hl(line):
    return col(line, 'bold') if KEY_RX.search(line) else line


# ════════════════════════════════════════════════════════════════════
# 8. 보기 화면용 줄 만들기
# ════════════════════════════════════════════════════════════════════
def key_packet_lines(r, limit=60):
    info, fs = r['info'], r['fs']
    pk, base = info['pkts'], info['start']
    o = [col('■ %s — 중요 패킷' % os.path.basename(r['path']), 'bold'),
         col('  No. = Wireshark 프레임 번호, Time = 첫 패킷 기준 초 (Wireshark 기본 표시와 같음)', 'dim'), '']
    for f in fs:
        o.append(col('▣ ' + f.title, *title_color(f.prio)) + '  ' + f.summary)
        for flt, desc in f.wf:
            o.append('  Wireshark 필터: ' + col(flt, 'cyan') + (col('   ← ' + desc, 'dim') if desc else ''))
        groups = f.groups or [(None, f.ev)]
        if not any(g for _, g in groups):
            o.append(col('   (표시할 패킷 없음)', 'dim'))
        for label, idxs in groups:
            ev = sorted(set(i for i in idxs if 1 <= i <= len(pk)))
            if not ev:
                continue
            if label:
                o.append(col('  ▸ ' + label, 'magenta'))
            o.append(col(PKT_HEADER, 'dim'))
            for i in ev[:limit]:
                o.append(hl(pkt_line(pk[i - 1], base)))
            if len(ev) > limit:
                o.append(col('   … %d개 더 있음 ([2] 전체 목록 또는 위 필터로 확인)' % (len(ev) - limit), 'dim'))
        o.append('')
    return o


def all_packet_lines(r, show_noise=False):
    pk, base = r['info']['pkts'], r['info']['start']
    o = [col('■ %s — 전체 패킷 목록' % os.path.basename(r['path']), 'bold'), col(PKT_HEADER, 'dim')]
    hidden = 0
    for p in pk:
        n = noise_name(p)
        if n and not show_noise:
            hidden += 1
            continue
        line = pkt_line(p, base)
        o.append(col(line + '  (배경)', 'dim') if n else hl(line))
    if hidden:
        o.append(col('  (배경 트래픽 %d개 숨김 — OSPF/CDP/SLARP 등)' % hidden, 'dim'))
    return o


def follow_lines(r, maxbytes=4000):
    conns = [c for c in r['info']['conns'] if c.bytes[0] or c.bytes[1]]
    o = [col('■ %s — TCP 대화 내용 (Wireshark Follow TCP Stream과 같음)' % os.path.basename(r['path']), 'bold'),
         col('  C> 클라이언트가 보낸 것,  S< 서버가 보낸 것,  [#번호] 프레임', 'dim'), '']
    if not conns:
        return o + ['(데이터가 있는 TCP 세션 없음)']
    for n, c in enumerate(conns, 1):
        ok = (c.service == 'FTP' and re.search(rb'(^|\n)230 ', c.stream(1))) or \
             (c.service in ('POP3', 'IMAP', 'SMTP', 'Telnet') and re.search(rb'(?i)\+OK.*logged|OK LOGIN|235 |last login', c.stream(1)))
        o.append(col('━━ 세션 %d: %s → %s [%s] %s  (클라 %dB / 서버 %dB) ━━%s' % (
            n, ep(c.cli), ep(c.srv), c.service or identify_service(c), fmt_ts(c.start, False), c.bytes[0], c.bytes[1],
            '  ★ 로그인 성공' if ok else ''), 'green' if ok else 'cyan', *(('bold',) if ok else ())))
        shown = 0
        for ts, d, data, ix in c.timeline():
            if shown >= maxbytes:
                o.append(col('   … (생략)', 'dim'))
                break
            if c.service in ('SSH', 'TLS(HTTPS 등)') and not data.startswith(b'SSH-'):
                txt = '(암호화 데이터 %dB)' % len(data)
            else:
                txt = data.decode('utf-8', 'replace').replace('\r', '')
                txt = ''.join(ch if ch.isprintable() or ch in '\n\t' else '.' for ch in txt)
            for k, l in enumerate(txt.rstrip('\n').split('\n')):
                tag = ('C> ' if d == 0 else 'S< ')
                o.append(col('  %s' % tag, 'blue' if d == 0 else 'red') + l + (col('  [#%d]' % ix, 'dim') if k == 0 else ''))
            shown += len(data)
        o.append('')
    return o


def frame_detail_lines(r, n):
    info = r['info']
    if not 1 <= n <= len(info['pkts']):
        return ['프레임 번호는 1 ~ %d 사이여야 합니다.' % len(info['pkts'])]
    p = info['pkts'][n - 1]
    raw = info['raw'][n - 1][2]
    o = [col('■ %s — 프레임 #%d 상세' % (os.path.basename(r['path']), n), 'bold'),
         col(PKT_HEADER, 'dim'), pkt_line(p, info['start']), '',
         '  시간: %s (첫 패킷부터 %.6f초)   길이: %d바이트   링크: %s' % (
             fmt_ts(p.ts), p.ts - info['start'], len(raw), LINKTYPES.get(info['raw'][n - 1][1], '?'))]
    if p.src:
        o.append('  IP: %s → %s   TTL=%s  ID=%s  프로토콜=%s  전체길이=%s' % (p.src, p.dst, p.ttl, p.ipid, p.proto, p.iplen))
    if p.proto == 6 and p.sport is not None:
        o.append('  TCP: %s → %s  %s  Seq=%s Ack=%s Win=%s  옵션=%s  데이터 %d바이트' % (
            p.sport, p.dport, flag_names(p.flags or 0), p.seq, p.ack, p.win, p.opts.hex() or '없음', len(p.data)))
    elif p.proto == 17 and p.sport is not None:
        o.append('  UDP: %s → %s  데이터 %d바이트' % (p.sport, p.dport, len(p.data)))
    elif p.proto == 1 and p.itype is not None:
        o.append('  ICMP: type=%s(%s) code=%s' % (p.itype, ICMP_NAMES.get(p.itype, '?'), p.icode))
    if p.data:
        o.append('  데이터(텍스트): ' + preview(p.data, 200))
    o.append('')
    o.append(col('  [16진수 덤프 — 링크 계층 헤더 포함 원본]', 'dim'))
    for off in range(0, len(raw), 16):
        ch = raw[off:off + 16]
        hx = ' '.join('%02x' % x for x in ch)
        asc = ''.join(chr(x) if 32 <= x < 127 else '.' for x in ch)
        o.append('  %04x  %-48s  %s' % (off, hx, asc))
    return o


def grep_lines(results, pattern):
    try:
        rx = re.compile(pattern.encode('utf-8'), re.I)
    except re.error as e:
        return ['정규식 오류: %s' % e]
    o = [col('■ 검색: "%s"' % pattern, 'bold'), col('  파일  ' + PKT_HEADER, 'dim')]
    hits = 0
    for r in results:
        if r['err']:
            continue
        for p in r['info']['pkts']:
            raw = r['info']['raw'][p.idx - 1][2]
            blob = p.data if p.src else raw
            if blob and rx.search(blob):
                hits += 1
                o.append('  %-8s ' % os.path.basename(r['path']) + hl(pkt_line(p, r['info']['start'])))
    o.append(col('  → %d개 패킷 일치' % hits, 'bold'))
    return o


# ════════════════════════════════════════════════════════════════════
# 8-2. 추가 기능: 계정 모음 / Wireshark 필터 / 통계 / 파일 추출 / 답안
# ════════════════════════════════════════════════════════════════════
def collect_auth_creds(svc, cip, sip, sport, results, brute):
    out = []
    for r in results:
        for a in r['attempts']:
            out.append({'svc': svc, 'cli': cip, 'srv': '%s:%d' % (sip, sport), 'user': a['user'],
                        'pass': a['pass'], 'result': a['result'] or '응답없음', 'frames': a['ix'],
                        'brute': brute, 'ts': a['ts']})
    return out


def collect_http_creds(cip, sip, sport, cs, brute):
    out = []
    for c in cs:
        for q, rsp in analyze_http(c):
            ua = q['hdr'].get('user-agent', '')
            owner = next((t for rx, t, d in UA_SIGS if ua and re.search(rx, ua)), None)
            if owner in SCANNER_TOOLS or owner in DIRBUST_TOOLS or owner in ('sqlmap', 'nmap NSE'):
                continue                      # 스캐너가 시험 삼아 넣은 값은 제외
            for kind, u, pw in http_creds(q):
                if not pw:
                    continue
                res = getattr(c, '_brute_res', {}).get(id(q)) if brute else None
                out.append({'svc': 'HTTP(%s)' % kind, 'cli': cip, 'srv': '%s:%d' % (sip, sport), 'user': u, 'pass': pw,
                            'result': res or http_login_result(rsp, q),
                            'frames': [x['frame'] for x in (q, rsp) if x], 'brute': brute, 'ts': c.start})
    return out


def creds_lines(results, show_all=False):
    o = [col('■ 계정 / 비밀번호 모아보기 (모든 파일)', 'bold'),
         col('  평문 프로토콜(FTP/Telnet/HTTP/POP3/IMAP/SMTP)에서 찾은 로그인 정보. SSH/HTTPS는 암호화라 안 보임', 'dim'), '']
    any_found = False
    for r in results:
        if r['err'] or not r['info'].get('creds'):
            continue
        cr = r['info']['creds']
        any_found = True
        o.append(col('▣ ' + os.path.basename(r['path']), 'bold'))
        normal = [c for c in cr if not c['brute']]
        brute = [c for c in cr if c['brute']]
        for c in normal:
            o.append('  %-10s %s → %-21s 계정 %-12s 비밀번호 %-14s → %s  %s' % (
                c['svc'], c['cli'], c['srv'], c['user'], c['pass'], res_col(c['result']),
                col('#' + ','.join(map(str, c['frames'][:3])), 'dim') if c['frames'] else ''))
        if brute:
            ok = [c for c in brute if '성공' in str(c['result'])]
            o.append('  %s 무차별 대입 %d회 시도 (계정 %d개 × 비밀번호 %d개)' % (
                brute[0]['svc'], len(brute), len({c['user'] for c in brute}), len({c['pass'] for c in brute})))
            for c in (brute if show_all else ok):
                o.append('    %s 계정 %-12s 비밀번호 %-14s → %s  %s' % (
                    '★' if '성공' in str(c['result']) else ' ', c['user'], c['pass'], res_col(c['result']),
                    col('#' + ','.join(map(str, c['frames'][:3])), 'dim') if c['frames'] else ''))
            if not show_all:
                o.append(col('    (전체 시도 목록은 파일을 열고 [v] 자세히 → 보고서에서 확인)', 'dim'))
        o.append('')
    if not any_found:
        o.append('  평문 로그인 정보가 있는 파일이 없습니다.')
    return o


def res_col(s):
    s = str(s)
    if s == '성공' or s.startswith('2'):
        return col(s, 'green', 'bold')
    if s == '실패' or s.startswith(('4', '5')):
        return col(s, 'red')
    return s


# ── 미니 Wireshark 디스플레이 필터 ───────────────────────────────────
class FilterError(Exception):
    pass


def _ftp_parts(p):
    if p.proto == 6 and p.data and 21 in (p.sport, p.dport):
        line = p.data.split(b'\n', 1)[0].decode('utf-8', 'replace').strip('\r ')
        if p.dport == 21:
            cmd, _, arg = line.partition(' ')
            return ('req', cmd.upper(), arg)
        m = re.match(r'(\d{3})[ -]?(.*)', line)
        if m:
            return ('resp', int(m.group(1)), m.group(2))
    return None


def _http_parts(p):
    m = p.http
    if not m:
        return None
    if 'method' in m:
        return {'method': m['method'], 'uri': m['uri'], 'hdr': m['hdr']}
    return {'code': m['code'], 'hdr': m['hdr']}


def _l4ok(p):
    return p.reasm or not (p.mf or p.frag_off)


def _inn(p, key, proto=None):
    i = p.inner
    if not i or (proto is not None and i['proto'] != proto) or i.get(key) is None:
        return []
    return [i[key]]


def _dns(p):
    if p.proto == 17 and 53 in (p.sport, p.dport) and len(p.data) >= 12 and _l4ok(p):
        return parse_dns(p.data)
    i = p.inner
    if i and i['proto'] == 17 and 53 in (i['sport'], i['dport']) and len(i.get('udata') or b'') >= 12:
        try:
            return parse_dns(i['udata'])
        except Exception:
            return None
    return None


_QT_NUM = {v: k for k, v in QTYPES.items()}


def _tcp(p, v):
    return [v] if p.proto == 6 and p.sport is not None and _l4ok(p) else []


def _udp(p, v):
    return [v] if p.proto == 17 and p.sport is not None and _l4ok(p) else []


def _flag(bit):
    return lambda p: [int(bool((p.flags or 0) & bit))] if p.proto == 6 and p.sport is not None and _l4ok(p) else []


def _icmp(p, v):
    return [v] if p.proto == 1 and v is not None and _l4ok(p) else []


def _protocols(p):
    """Wireshark frame.protocols 와 비슷한 'eth:ethertype:ip:tcp:ftp' 문자열"""
    out = [{'Ethernet': 'eth:ethertype'}.get(p.l2, '')] if p.eth_dst else []
    if p.l2 in ('ipv4', 'ipv6'):
        out.append('ip' if p.l2 == 'ipv4' else 'ipv6')
        l4 = {6: 'tcp', 17: 'udp', 1: 'icmp'}.get(p.proto)
        if l4 and _l4ok(p):
            out.append(l4)
            for name in ('http', 'ftp', 'ftp-data', 'ssh', 'telnet', 'smtp', 'pop', 'imap', 'dns', 'tls'):
                try:
                    if PROTOS[name](p):
                        out.append(name)
                        break
                except Exception:
                    pass
    elif p.arp:
        out.append('arp')
    else:
        out.append(str(p.l2 or '').lower())
    if _is_data(p):
        out.append('data')
    return ':'.join(x for x in out if x)


def _tcp_opt(p, kind, conv):
    if p.proto != 6 or not p.opts or not _l4ok(p):
        return []
    o, b = 0, p.opts
    while o < len(b):
        k = b[o]
        if k == 0:
            break
        if k == 1:
            o += 1
            continue
        if o + 1 >= len(b):
            break
        ln = b[o + 1]
        if ln < 2:
            break
        if k == kind:
            try:
                return [conv(b[o + 2:o + ln])]
            except (struct.error, IndexError):
                return []
        o += ln
    return []


_TLS_PORTS = {443, 8443, 993, 995, 465, 636, 989, 990, 5061, 853, 4433}


def _tls_hs(p):
    return p.proto == 6 and len(p.data) >= 6 and p.data[0] == 0x16 and p.data[1] == 3 and _l4ok(p) and bool({p.sport, p.dport} & _TLS_PORTS)


def _sni_of(p):
    if not _tls_hs(p) or p.data[5] != 1:
        return None
    b = p.data
    try:
        q = 5 + 4 + 2 + 32
        q += 1 + b[q]
        q += 2 + struct.unpack('>H', b[q:q + 2])[0]
        q += 1 + b[q]
        end = q + 2 + struct.unpack('>H', b[q:q + 2])[0]
        q += 2
        while q + 4 <= min(end, len(b)):
            t, ln = struct.unpack('>HH', b[q:q + 4])
            if t == 0:
                n = struct.unpack('>H', b[q + 7:q + 9])[0]
                return b[q + 9:q + 9 + n].decode('ascii', 'replace')
            q += 4 + ln
    except (struct.error, IndexError):
        return None
    return None


_APP_PORTS = {21, 22, 23, 25, 53, 80, 110, 143, 443, 587, 8080}


def _is_data(p):
    """Wireshark 'data' = 어떤 프로토콜로도 해석 못 한 페이로드"""
    if (p.mf or p.frag_off) and not p.reasm and p.data:
        return True                                    # 아직 다 모이지 않은 IP 조각
    if p.proto == 1 and p.itype in (0, 8) and p.data and _l4ok(p):
        return True
    if p.inner and p.inner.get('udata') and min(p.inner['sport'], p.inner['dport']) >= 1024:
        return True                                    # ICMP 오류 안에 담긴 원래 UDP 데이터
    if not p.data or p.src is None or not _l4ok(p) or p.proto not in (6, 17):
        return False
    if p.proto == 17:
        return min(p.sport, p.dport) >= 1024 and not ({p.sport, p.dport} & {1900, 5353, 5355, 3478, 4789, 5060})
    if p.http is not None or p.sport in _APP_PORTS or p.dport in _APP_PORTS or p.svc == 'FTP-DATA' or p.retrans:
        return False
    return not p.data[:4] in (b'SSH-', b'HTTP')


FIELDS = {
    'frame.number': lambda p: [p.idx],
    'frame.len': lambda p: [p.wlen or p.flen],
    'frame.cap_len': lambda p: [p.flen],
    'eth.src': lambda p: [p.eth_src] if p.eth_dst else [],
    'eth.dst': lambda p: [p.eth_dst] if p.eth_dst else [],
    'eth.addr': lambda p: [p.eth_src, p.eth_dst] if p.eth_dst else [],
    'ip.flags.df': lambda p: ([int(bool(p.ipdf))] + _inn(p, 'df')) if p.src and p.l2 == 'ipv4' else [],
    'tcp.stream': lambda p: [p.stream] if p.stream is not None else [],
    'tcp.options': lambda p: [p.opts] if p.proto == 6 and p.opts and _l4ok(p) else [],
    'frame.protocols': lambda p: [_protocols(p)],
    'http.request.full_uri': lambda p: ['http://%s%s' % (x['hdr'].get('host', p.dst), x['uri']) for x in [_http_parts(p)] if x and 'uri' in x],
    'tcp.analysis.retransmission': lambda p: [1] if p.retrans and p.proto == 6 else [],
    'tcp.options.timestamp.tsval': lambda p: _tcp_opt(p, 8, lambda v: struct.unpack('>I', v[:4])[0]),
    'tcp.options.timestamp.tsecr': lambda p: _tcp_opt(p, 8, lambda v: struct.unpack('>I', v[4:8])[0]),
    'tcp.options.mss_val': lambda p: _tcp_opt(p, 2, lambda v: struct.unpack('>H', v[:2])[0]),
    'tcp.options.wscale.shift': lambda p: _tcp_opt(p, 3, lambda v: v[0]),
    'tls.handshake.type': lambda p: [p.data[5]] if _tls_hs(p) else [],
    'tls.handshake.extensions_server_name': lambda p: [x for x in [_sni_of(p)] if x],
    'tls.record.content_type': lambda p: [p.data[0]] if PROTOS['tls'](p) else [],
    'tcp.hdr_len': lambda p: _tcp(p, 20 + len(p.opts)),
    'tcp.seq_raw': lambda p: _tcp(p, p.seq),
    'tcp.ack_raw': lambda p: _tcp(p, p.ack),
    'tcp.nxtseq': lambda p: _tcp(p, None if p.rseq is None else p.rseq + len(p.data) + (1 if (p.flags or 0) & 3 else 0)),
    'frame': lambda p: [p.rawb],
    'ip.src': lambda p: ([p.src] + _inn(p, 'src')) if p.src else [],
    'ip.dst': lambda p: ([p.dst] + _inn(p, 'dst')) if p.src else [],
    'ip.addr': lambda p: ([p.src, p.dst] + _inn(p, 'src') + _inn(p, 'dst')) if p.src else [],
    'ip.ttl': lambda p: ([p.ttl] + _inn(p, 'ttl')) if p.src else [],
    'ip.proto': lambda p: ([p.proto] + _inn(p, 'proto')) if p.src else [],
    'ip.id': lambda p: ([p.ipid] + _inn(p, 'id')) if p.ipid is not None else [],
    'ip.len': lambda p: ([p.iplen] + _inn(p, 'len')) if p.src else [],
    'ip.flags.mf': lambda p: [int(p.mf)] if p.src else [],
    'ip.frag_offset': lambda p: [p.frag_off] if p.src else [],
    'tcp': lambda p: [p.data] if p.proto == 6 and p.sport is not None and _l4ok(p) else [],
    'tcp.port': lambda p: ([p.sport, p.dport] if p.proto == 6 and p.sport is not None and _l4ok(p) else [])
                          + _inn(p, 'sport', 6) + _inn(p, 'dport', 6),
    'tcp.srcport': lambda p: _tcp(p, p.sport) + _inn(p, 'sport', 6),
    'tcp.dstport': lambda p: _tcp(p, p.dport) + _inn(p, 'dport', 6),
    'tcp.flags': lambda p: _tcp(p, p.flags),
    'tcp.flags.syn': _flag(0x02), 'tcp.flags.ack': _flag(0x10), 'tcp.flags.fin': _flag(0x01),
    'tcp.flags.reset': _flag(0x04), 'tcp.flags.push': _flag(0x08), 'tcp.flags.urg': _flag(0x20),
    'tcp.len': lambda p: _tcp(p, len(p.data)),
    'tcp.seq': lambda p: _tcp(p, p.rseq),
    'tcp.ack': lambda p: _tcp(p, p.rack),
    'tcp.window_size': lambda p: _tcp(p, p.win if p.wscale is None else p.win << p.wscale),
    'tcp.window_size_value': lambda p: _tcp(p, p.win),
    'tcp.payload': lambda p: [p.data] if p.proto == 6 and p.data else [],
    'udp': lambda p: ([p.data] if p.proto == 17 and p.sport is not None and _l4ok(p) else []) + _inn(p, 'udata', 17),
    'udp.port': lambda p: ([p.sport, p.dport] if p.proto == 17 and p.sport is not None and _l4ok(p) else [])
                          + _inn(p, 'sport', 17) + _inn(p, 'dport', 17),
    'udp.srcport': lambda p: _udp(p, p.sport) + _inn(p, 'sport', 17),
    'udp.dstport': lambda p: _udp(p, p.dport) + _inn(p, 'dport', 17),
    'udp.length': lambda p: _udp(p, len(p.data) + 8) + _inn(p, 'ulen', 17),
    'icmp': lambda p: [p.data] if p.proto == 1 and p.itype is not None and _l4ok(p) else [],
    'icmp.type': lambda p: ([p.itype] if p.proto == 1 and p.itype is not None and _l4ok(p) else []) + _inn(p, 'itype', 1),
    'icmp.code': lambda p: _icmp(p, p.icode if p.itype is not None else None) + _inn(p, 'icode', 1),
    'icmp.seq': lambda p: _icmp(p, p.icmp_seq) + _inn(p, 'icmp_seq', 1),
    'icmp.ident': lambda p: _icmp(p, p.icmp_id) + _inn(p, 'icmp_id', 1),
    'arp.opcode': lambda p: [p.arp[0]] if p.arp else [],
    'arp.src.hw_mac': lambda p: [p.arp[1]] if p.arp else [],
    'arp.src.proto_ipv4': lambda p: [p.arp[2]] if p.arp else [],
    'arp.dst.hw_mac': lambda p: [p.arp[3]] if p.arp else [],
    'arp.dst.proto_ipv4': lambda p: [p.arp[4]] if p.arp else [],
    'dns.qry.name': lambda p: [q[0] for q in (_dns(p) or {}).get('q', [])],
    'dns.qry.type': lambda p: [_QT_NUM.get(q[1], q[1]) for q in (_dns(p) or {}).get('q', [])],
    'dns.flags.response': lambda p: [(_dns(p) or {}).get('qr')] if _dns(p) else [],
    'dns.flags.rcode': lambda p: [(_dns(p) or {}).get('rcode')] if _dns(p) else [],
    'dns.a': lambda p: [v for _, t, v in (_dns(p) or {}).get('a', []) if t == 'A'],
    'ftp.request.command': lambda p: [x[1] for x in [_ftp_parts(p)] if x and x[0] == 'req'],
    'ftp.request.arg': lambda p: [x[2] for x in [_ftp_parts(p)] if x and x[0] == 'req'],
    'ftp.response.code': lambda p: [x[1] for x in [_ftp_parts(p)] if x and x[0] == 'resp'],
    'ftp.response.arg': lambda p: [x[2] for x in [_ftp_parts(p)] if x and x[0] == 'resp'],
    'http.request.method': lambda p: [x['method'] for x in [_http_parts(p)] if x and 'method' in x],
    'http.request.uri': lambda p: [x['uri'] for x in [_http_parts(p)] if x and 'uri' in x],
    'http.host': lambda p: [x['hdr'].get('host') for x in [_http_parts(p)] if x and 'host' in x['hdr']],
    'http.user_agent': lambda p: [x['hdr'].get('user-agent') for x in [_http_parts(p)] if x and 'user-agent' in x['hdr']],
    'http.response.code': lambda p: [x['code'] for x in [_http_parts(p)] if x and 'code' in x],
    'http.server': lambda p: [x['hdr'].get('server') for x in [_http_parts(p)] if x and 'server' in x['hdr']],
    'http.cookie': lambda p: [x['hdr'].get('cookie') for x in [_http_parts(p)] if x and 'cookie' in x['hdr']],
    'http.authorization': lambda p: [x['hdr'].get('authorization') for x in [_http_parts(p)] if x and 'authorization' in x['hdr']],
}
FIELDS['http.request'] = lambda p: [1] if (_http_parts(p) or {}).get('method') else []
FIELDS['http.response'] = lambda p: [1] if 'code' in (_http_parts(p) or {}) else []
FIELDS['ssh.protocol'] = lambda p: [first_line(p.data)] if p.proto == 6 and p.data.startswith(b'SSH-') else []


def _port_proto(*ports):
    return lambda p: p.proto == 6 and bool(p.data) and not p.retrans and (p.sport in ports or p.dport in ports)


PROTOS = {
    'ip': lambda p: p.l2 == 'ipv4', 'ipv6': lambda p: p.l2 == 'ipv6',
    'tcp': lambda p: (p.proto == 6 and _l4ok(p)) or bool(_inn(p, 'proto', 6)),
    'udp': lambda p: (p.proto == 17 and _l4ok(p)) or bool(_inn(p, 'proto', 17)),
    'icmp': lambda p: p.proto == 1 and p.itype is not None and _l4ok(p), 'arp': lambda p: bool(p.arp),
    'dns': lambda p: _dns(p) is not None or (bool(p.inner) and p.inner['proto'] == 17 and 53 in (p.inner['sport'], p.inner['dport'])),
    'ftp': _port_proto(21), 'ssh': _port_proto(22), 'telnet': _port_proto(23),
    'smtp': _port_proto(25, 587), 'pop': _port_proto(110), 'imap': _port_proto(143),
    'http': lambda p: p.http is not None,
    'tls': lambda p: p.proto == 6 and len(p.data) >= 5 and 0x14 <= p.data[0] <= 0x17 and p.data[1] == 3 and _l4ok(p)
    and bool({p.sport, p.dport} & _TLS_PORTS),
    'ftp-data': lambda p: p.proto == 6 and bool(p.data) and p.svc == 'FTP-DATA',
    'ospf': lambda p: p.proto == 89, 'eigrp': lambda p: p.proto == 88,
    'cdp': lambda p: p.l2 == 'CDP', 'slarp': lambda p: p.l2 == 'SLARP',
    'data': _is_data,
}

_TOK = re.compile(r'\s*(?:(\(|\)|\{|\}|,)|(&&|\|\||==|!=|>=|<=|>|<|!|~|===|!==)|("(?:[^"\\]|\\.)*")|([A-Za-z0-9_.:/\-]+))')
_WORDOPS = {'and': '&&', 'or': '||', 'not': '!', 'eq': '==', 'ne': '!=', 'gt': '>', 'lt': '<', 'ge': '>=',
            'le': '<=', 'contains': 'contains', 'matches': 'matches', 'in': 'in'}


def _unescape(t):
    """필터 문자열의 escape 처리 → bytes (백슬래시 x 두자리, 8진수 세자리, n t r). 모르는 escape 는 글자 그대로 (C:\\Users 등)"""
    out, i = bytearray(), 0
    while i < len(t):
        ch = t[i]
        if ch == '\\' and i + 1 < len(t):
            n = t[i + 1]
            if n in '\\"\'':
                out += n.encode()
                i += 2
                continue
            if n in 'ntr':
                out += {'n': b'\n', 't': b'\t', 'r': b'\r'}[n]
                i += 2
                continue
            if n == 'x' and re.match(r'[0-9a-fA-F]{2}', t[i + 2:i + 4]):
                out.append(int(t[i + 2:i + 4], 16))
                i += 4
                continue
            if re.match(r'[0-7]{3}', t[i + 1:i + 4]):
                out.append(int(t[i + 1:i + 4], 8) & 0xff)
                i += 4
                continue
        out += ch.encode('utf-8')
        i += 1
    return bytes(out)


def _tokens(s):
    pos, out = 0, []
    s = s.strip()
    while pos < len(s):
        m = _TOK.match(s, pos)
        if not m or m.end() == pos:
            raise FilterError('이해할 수 없는 부분: "%s"' % s[pos:pos + 15])
        pos = m.end()
        if m.group(1):
            out.append(('p', m.group(1)))
        elif m.group(2):
            out.append(('op', m.group(2)))
        elif m.group(3):
            out.append(('str', _unescape(m.group(3)[1:-1])))
        else:
            w = m.group(4)
            out.append(('op', _WORDOPS[w.lower()]) if w.lower() in _WORDOPS else ('word', w))
    return out


_HEXBYTES = re.compile(r'^[0-9a-fA-F]{2}([:.\-][0-9a-fA-F]{2})+$')


def _conv(v, raw):
    """비교값을 필드값 형식에 맞게 변환 (값은 compile 때 이미 검사됨)"""
    if isinstance(raw, (bytes, bytearray)):
        if isinstance(v, str):
            return bytes.fromhex(re.sub(r'[:.\-]', '', v)) if _HEXBYTES.match(v) else v.encode('utf-8')
        return v
    if isinstance(v, (bytes, bytearray)):
        v = v.decode('utf-8', 'replace')
    if isinstance(raw, int) and not isinstance(raw, bool):
        try:
            return _num(v)
        except ValueError:
            return v
    return v


def _num(v):
    t = str(v).strip().lower()
    if t in ('true', 'false'):
        return int(t == 'true')
    if re.match(r'^0[0-7]+$', t):
        return int(t, 8)
    return int(t, 0)


def _ipval(a):
    import ipaddress
    try:
        return ipaddress.ip_address(a)
    except ValueError:
        return None


def _eq(a, b):
    if isinstance(a, str) and isinstance(b, str) and '/' in b and a.count('.') == 3:
        try:
            import ipaddress
            return ipaddress.ip_address(a) in ipaddress.ip_network(b, strict=False)
        except ValueError:
            pass
    if isinstance(a, str) and isinstance(b, str):
        return a.lower() == b.lower() if ':' in a else a == b
    return a == b


def _cmp(op, a, b):
    try:
        if op == '==':
            return _eq(a, b)
        if op in ('>', '<', '>=', '<=') and isinstance(a, str) and isinstance(b, str):
            ia, ib = _ipval(a), _ipval(b)
            if ia is not None and ib is not None:
                a, b = ia, ib
        if op == '>':
            return a > b
        if op == '<':
            return a < b
        if op == '>=':
            return a >= b
        if op == '<=':
            return a <= b
        if op == 'contains':
            if isinstance(a, (bytes, bytearray)):
                return b in a
            return str(b) in str(a)
    except Exception:
        return False
    return False


# 숫자 필드가 아닌 것들 (나머지는 숫자로 비교)
_STR_FIELDS = {'ip.src', 'ip.dst', 'ip.addr', 'arp.src.hw_mac', 'arp.dst.hw_mac', 'arp.src.proto_ipv4', 'arp.dst.proto_ipv4',
               'eth.src', 'eth.dst', 'eth.addr', 'dns.qry.name', 'dns.a', 'ftp.request.command', 'ftp.request.arg',
               'ftp.response.arg', 'http.request.method', 'http.request.uri', 'http.host', 'http.user_agent', 'http.server',
               'http.cookie', 'http.authorization', 'ssh.protocol', 'frame', 'tcp', 'udp', 'icmp', 'tcp.payload'}
_IP_FIELDS = {'ip.src', 'ip.dst', 'ip.addr', 'arp.src.proto_ipv4', 'arp.dst.proto_ipv4', 'dns.a'}


def _check_value(name, op, v):
    """Wireshark처럼 잘못된 값은 바로 오류로 알려줌"""
    if isinstance(v, (bytes, bytearray)):
        v = v.decode('utf-8', 'replace')
    if op in ('contains', 'matches', '~'):
        return
    if name in _IP_FIELDS:
        import ipaddress
        try:
            if '/' in v:
                ipaddress.ip_network(v, strict=False)
            else:
                ipaddress.ip_address(v)
        except ValueError:
            raise FilterError('"%s" 는 올바른 IP 주소가 아닙니다' % v)
        return
    if name not in _STR_FIELDS:
        try:
            _num(v)
        except ValueError:
            raise FilterError('%s 에는 숫자가 와야 합니다 ("%s" 는 숫자가 아님)' % (name, v))


def compile_filter(text):
    try:
        e = _compile_filter(text)
        return lambda p: e(getattr(p, 'rview', None) or p)
    except FilterError:
        raise
    except RecursionError:
        raise FilterError('괄호나 ! 가 너무 많이 겹쳐 있습니다')
    except Exception as e:
        raise FilterError('필터를 이해할 수 없습니다 (%s)' % e)


def _compile_filter(text):
    toks = _tokens(text)
    if not toks:
        raise FilterError('필터가 비어 있습니다')
    pos = [0]

    def peek():
        return toks[pos[0]] if pos[0] < len(toks) else (None, None)

    def take():
        t = peek()
        pos[0] += 1
        return t

    def p_or():
        left = p_and()
        while peek() == ('op', '||'):
            take()
            right = p_and()
            left = (lambda l, r: lambda p: l(p) or r(p))(left, right)
        return left

    def p_and():
        left = p_not()
        while peek() == ('op', '&&'):
            take()
            right = p_not()
            left = (lambda l, r: lambda p: l(p) and r(p))(left, right)
        return left

    def p_not():
        if peek() == ('op', '!'):
            take()
            inner = p_not()
            return lambda p: not inner(p)
        return p_primary()

    def value():
        k, v = take()
        if k in ('word', 'str'):
            return v
        raise FilterError('비교할 값이 와야 합니다')

    def p_primary():
        k, v = take()
        if (k, v) == ('p', '('):
            e = p_or()
            if take() != ('p', ')'):
                raise FilterError('괄호 ")" 가 빠졌습니다')
            return e
        if k != 'word':
            raise FilterError('필드 이름이 와야 할 자리에 "%s"' % v)
        name = v.lower()
        nk, nv = peek()
        is_cmp = nk == 'op' and nv not in ('&&', '||', '!')
        get = None
        if name in PROTOS and (name not in FIELDS or not is_cmp):
            if is_cmp and nv in ('contains', 'matches', '~'):
                if name not in FIELDS:
                    pr = PROTOS[name]
                    get = (lambda pr: lambda p: [p.data] if pr(p) else [])(pr)
            else:
                return PROTOS[name]
        if get is None and name not in FIELDS:
            raise FilterError('지원하지 않는 필드: %s  (필터 입력 칸에서 ? 를 입력하면 지원 필드 목록)' % v)
        get = get or FIELDS[name]
        if not is_cmp:
            return lambda p: bool(get(p))
        op = take()[1]
        if op == 'in':
            if take() != ('p', '{'):
                raise FilterError('in 뒤에는 { 값 값 ... } 형식')
            vals, ranges = [], []
            while peek() != ('p', '}'):
                if peek() == (None, None):
                    raise FilterError('} 가 빠졌습니다')
                if peek() == ('p', ','):
                    take()
                    continue
                x = value()
                if isinstance(x, str) and '..' in x and name not in _STR_FIELDS:
                    lo, _, hi = x.partition('..')
                    _check_value(name, '==', lo)
                    _check_value(name, '==', hi)
                    ranges.append((_num(lo), _num(hi)))
                else:
                    _check_value(name, '==', x)
                    vals.append(x)
            take()
            if not vals and not ranges:
                raise FilterError('{ } 안에 값이 없습니다')
            return lambda p: any(any(_eq(f, _conv(x, f)) for x in vals) or
                                 any(isinstance(f, int) and lo <= f <= hi for lo, hi in ranges)
                                 for f in get(p) if f is not None)
        val = value()
        if isinstance(val, str) and val.lower() in FIELDS and op in ('==', '!=', '>', '<', '>=', '<='):
            other = FIELDS[val.lower()]          # 필드끼리 비교 (예: ip.src == ip.dst)
            if op == '!=':
                return lambda p: bool(get(p)) and not any(_eq(f, g) for f in get(p) for g in other(p))
            return lambda p: any(_cmp(op, f, g) for f in get(p) for g in other(p) if f is not None and g is not None)
        _check_value(name, op, val)
        if op in ('matches', '~'):
            pat = val.decode('utf-8', 'replace') if isinstance(val, (bytes, bytearray)) else str(val)
            try:
                rx = re.compile(pat, re.I | re.S)
            except (re.error, OverflowError, RecursionError) as e:
                raise FilterError('정규식 오류: %s' % e)
            return lambda p: any(rx.search(f.decode('utf-8', 'replace') if isinstance(f, (bytes, bytearray)) else str(f))
                                 for f in get(p) if f is not None)
        if op == '===':
            op = '=='
        if op == '!==':
            op = '!='
        if op == '!=':
            return lambda p: bool(get(p)) and not any(_cmp('==', f, _conv(val, f)) for f in get(p) if f is not None)
        return lambda p: any(_cmp(op, f, _conv(val, f)) for f in get(p) if f is not None)

    e = p_or()
    if pos[0] != len(toks):
        raise FilterError('남는 부분: %s' % ' '.join(str(t[1]) for t in toks[pos[0]:]))
    return e


FILTER_HELP = [
    '지원하는 필터 (Wireshark 문법과 같음):',
    '  프로토콜: tcp udp icmp arp dns ftp http ssh telnet smtp pop imap tls ospf data',
    '  eth.src eth.dst eth.addr  ip.src ip.dst ip.addr ip.ttl ip.proto ip.id ip.flags.df ip.flags.mf ip.frag_offset',
    '  frame.number frame.len frame  tcp.stream(= Follow TCP Stream 번호)',
    '  tcp.port tcp.srcport tcp.dstport tcp.flags tcp.flags.syn/ack/fin/reset/push tcp.len tcp.seq tcp.ack tcp.window_size',
    '  udp.port udp.srcport udp.dstport  icmp.type icmp.code icmp.seq',
    '  dns.qry.name dns.qry.type dns.flags.response dns.a   arp.opcode arp.src.proto_ipv4 arp.src.hw_mac',
    '  ftp.request.command ftp.request.arg ftp.response.code ftp.response.arg',
    '  http.request http.response http.request.method http.request.uri http.host http.user_agent http.response.code',
    '  연산자: == != > < >= <= contains matches in{..}  &&(and) ||(or) !(not) ( )',
    '  범위: tcp.port in {20..25}   바이트: frame contains 50:41:53:53   프로토콜 안 글자: ftp contains "PASS"',
    '예: ftp.response.code == 230    tcp.port == 40072    ip.addr == 210.93.6.0/24',
    '    tcp.flags.syn == 1 && tcp.flags.ack == 0    frame contains "PASS"    tcp.port in {21, 22, 23}',
    '※ Wireshark처럼 ICMP 오류 안의 원래 헤더도 검사하고, HTTP는 메시지가 끝나는 프레임에 표시됩니다',
]


def filter_lines(r, text, limit=2000):
    f = compile_filter(text)
    pk, base = r['info']['pkts'], r['info']['start']
    hits = [p for p in pk if f(p)]
    o = [col('■ %s — 필터: %s  → %d개 일치' % (os.path.basename(r['path']), text, len(hits)), 'bold'), col(PKT_HEADER, 'dim')]
    o += [hl(pkt_line(p, base)) for p in hits[:limit]]
    if len(hits) > limit:
        o.append(col('  … %d개 더' % (len(hits) - limit), 'dim'))
    return o


def filter_all_lines(results, text, per_file=30):
    f = compile_filter(text)
    o = [col('■ 모든 파일에 필터 적용: %s' % text, 'bold'), '']
    summary = []
    for r in results:
        if r['err']:
            continue
        hits = [p for p in r['info']['pkts'] if f(p)]
        summary.append((os.path.basename(r['path']), len(hits)))
        if hits:
            o.append(col('▣ %s  (%d개)' % (os.path.basename(r['path']), len(hits)), 'bold'))
            o.append(col(PKT_HEADER, 'dim'))
            o += [hl(pkt_line(p, r['info']['start'])) for p in hits[:per_file]]
            if len(hits) > per_file:
                o.append(col('  … %d개 더 (파일을 열고 [6] 필터로 전체 보기)' % (len(hits) - per_file), 'dim'))
            o.append('')
    o.append(col('일치한 파일: ', 'bold') + (', '.join('%s(%d)' % s for s in summary if s[1]) or '없음'))
    return o


# ── 통계 / 호스트 정보 / 세션 목록 ──────────────────────────────────
def initial_ttl(ttl):
    for t in (32, 64, 128, 255):
        if ttl <= t:
            return t
    return 255


def stats_lines(r):
    info = r['info']
    pk, base, conns = info['pkts'], info['start'], info['conns']
    main = [p for p in pk if p.src and not noise_name(p)]
    o = [col('■ %s — 통계 (Wireshark Statistics 메뉴와 비슷)' % os.path.basename(r['path']), 'bold'), '']

    # 1) 프로토콜 비율
    pc = collections.Counter(proto_label(p) for p in pk)
    o.append(col('▣ 프로토콜 비율 (Protocol Hierarchy)', 'cyan', 'bold'))
    tot = len(pk) or 1
    for name, n in pc.most_common(12):
        o.append('  %-10s %5d개 %5.1f%%  %s' % (name, n, 100.0 * n / tot, '█' * max(1, int(30 * n / tot))))
    o.append('')

    # 2) 호스트(Endpoints)
    sent, recv, ttls = collections.Counter(), collections.Counter(), collections.defaultdict(set)
    for p in main:
        sent[p.src] += 1
        recv[p.dst] += 1
        ttls[p.src].add(p.ttl)
    open_ports = collections.defaultdict(set)
    banners = collections.defaultdict(list)
    initiated = collections.Counter()
    for c in conns:
        if c.synack:
            open_ports[c.srv[0]].add(c.srv[1])
        if c.syn:
            initiated[c.cli[0]] += 1
        if c.bytes[1] and c.service:
            s1 = c.stream(1)
            b = ''
            if c.service in ('FTP', 'SMTP', 'POP3', 'IMAP', 'SSH'):
                b = first_line(s1, 60)
            elif c.service == 'HTTP':
                m = re.search(rb'(?im)^Server:\s*([^\r\n]+)', s1)
                b = 'Server: ' + m.group(1).decode('latin-1') if m else ''
            if b and '%s/%d %s' % (c.service, c.srv[1], b) not in banners[c.srv[0]]:
                banners[c.srv[0]].append('%s/%d %s' % (c.service, c.srv[1], b))
        if c.bytes[0] and c.service == 'SSH':
            b = 'SSH 클라이언트 ' + first_line(c.stream(0), 50)
            if b not in banners[c.cli[0]]:
                banners[c.cli[0]].append(b)
        if c.service == 'HTTP':
            m = re.search(rb'(?im)^User-Agent:\s*([^\r\n]+)', c.stream(0))
            if m:
                b = 'HTTP 클라이언트 UA: ' + m.group(1).decode('latin-1')[:60]
                if b not in banners[c.cli[0]]:
                    banners[c.cli[0]].append(b)
    routers = {p.src for p in main if p.proto == 1 and p.itype == 11}
    icmp_req = collections.Counter(p.src for p in main if p.proto == 1 and p.itype in (8, 13))
    o.append(col('▣ 호스트별 정보 (Endpoints) — 역할 / OS 추정 / 열린 포트 / 배너', 'cyan', 'bold'))
    o.append(col('  ※ 캡처된 TTL = 초기값 - 거친 라우터 수. 초기값 64=Linux/Unix, 128=Windows, 255=네트워크 장비', 'dim'))
    sk = lambda x: [int(y) if y.isdigit() else 0 for y in x.replace(':', '.').split('.')]
    hosts = sorted(set(sent) | set(recv), key=lambda h: -(sent[h] + recv[h]))
    for h in hosts[:20]:
        role = []
        if open_ports.get(h):
            role.append('서버')
        if initiated[h] >= 6 or icmp_req[h] >= 10:
            role.append('스캔/공격 출발지 의심')
        elif initiated[h] or icmp_req[h]:
            role.append('클라이언트')
        if h in routers:
            role.append('라우터(경유지)')
        tt = sorted(t for t in ttls.get(h, ()) if t is not None)
        os_s = ''
        if tt:
            t = max(tt)
            it = initial_ttl(t)
            os_s = 'TTL %s → 초기 %d (%d홉) %s' % ('/'.join(map(str, tt[:4])), it, it - t,
                                                  {64: 'Linux/Unix', 128: 'Windows', 255: '네트워크장비/Unix', 32: '구형 Windows'}.get(it, ''))
        o.append('  %s  보냄 %d / 받음 %d  [%s]' % (col('%-15s' % h, 'bold'), sent[h], recv[h], ', '.join(role) or '-'))
        if os_s and len(tt) <= 4:
            o.append('      OS 추정: ' + os_s)
        elif os_s:
            o.append('      TTL 값이 여러 개(%d종) → 도구가 TTL을 조작(nmap 등) 또는 여러 OS' % len(tt))
        if open_ports.get(h):
            o.append('      열린 포트: ' + ports_str(open_ports[h]))
        for b in banners.get(h, [])[:4]:
            o.append('      ' + b)
    if len(hosts) > 20:
        o.append(col('  … 호스트 %d개 더' % (len(hosts) - 20), 'dim'))
    o.append('')

    # 3) 대화 (Conversations)
    conv = {}
    for p in main:
        a, b = sorted([p.src, p.dst], key=sk)
        e = conv.setdefault((a, b), [0, 0, 0, p.ts, p.ts])
        e[0 if p.src == a else 1] += 1
        e[2] += p.flen
        e[4] = p.ts
    o.append(col('▣ IP 대화 (Conversations)', 'cyan', 'bold'))
    o.append(col('  %-15s %-15s %8s %8s %9s %10s %8s' % ('주소 A', '주소 B', 'A→B', 'B→A', '바이트', '시작(초)', '지속(초)'), 'dim'))
    for (a, b), e in sorted(conv.items(), key=lambda x: -(x[1][0] + x[1][1]))[:15]:
        o.append('  %-15s %-15s %8d %8d %9d %10.3f %8.3f' % (a, b, e[0], e[1], e[2], e[3] - base, e[4] - e[3]))
    if len(conv) > 15:
        o.append(col('  … 대화 %d개 더' % (len(conv) - 15), 'dim'))
    o.append('')

    # 4) TCP 세션 목록
    o.append(col('▣ TCP 세션 목록 (%d개)' % len(conns), 'cyan', 'bold'))
    if conns:
        o.append(col('  %4s %-22s %-22s %-10s %-22s %8s %7s %7s' % ('#', '클라이언트', '서버', '서비스', '상태', '데이터B', '지속초', '첫프레임'), 'dim'))
    for n, c in enumerate(conns[:60], 1):
        if c.syn and not c.synack:
            st = '닫힘(RST)' if c.rst[1] else '응답없음'
        elif c.client_rst_after_synack and not (c.bytes[0] or c.bytes[1]):
            st = '하프오픈(SYN 스캔)'
        elif all(c.fin):
            st = '정상 종료(FIN)'
        elif any(c.rst):
            st = '리셋(RST)'
        elif c.handshake:
            st = '연결됨(종료 미확인)'
        else:
            st = '중간부터 캡처됨'
        o.append(('  %4d %-22s %-22s %-10s %s %8d %7.2f %7d' % (
            n, ep(c.cli), ep(c.srv), (c.service or KNOWN_PORTS.get(c.srv[1], '-'))[:10], pad(st, 22),
            c.bytes[0] + c.bytes[1], c.end - c.start, c.idxs[0] if c.idxs else 0), c.idxs[0] if c.idxs else 0))
    if len(conns) > 60:
        o.append(col('  … 세션 %d개 더' % (len(conns) - 60), 'dim'))
    return o


# ── 파일 추출 (Export Objects) ──────────────────────────────────────
def _safe_name(s):
    s = re.sub(r'[\\/:*?"<>|\x00-\x1f]', '_', s).strip(' .') or 'file'
    return s[:80]


def guess_type(b):
    sigs = [(b'\x89PNG', 'PNG 이미지'), (b'\xff\xd8\xff', 'JPEG 이미지'), (b'GIF8', 'GIF 이미지'), (b'%PDF', 'PDF'),
            (b'PK\x03\x04', 'ZIP/Office 문서'), (b'\x7fELF', 'ELF 실행파일'), (b'MZ', 'Windows 실행파일'),
            (b'\x1f\x8b', 'gzip'), (b'Rar!', 'RAR'), (b'7z\xbc\xaf', '7z')]
    for sig, name in sigs:
        if b.startswith(sig):
            return name
    sample = b[:512]
    if sample and sum(1 for x in sample if 32 <= x < 127 or x in (9, 10, 13)) > len(sample) * 0.85:
        return 'HTML' if re.search(rb'(?i)<html|<!doctype', sample) else '텍스트'
    try:
        sample.decode('utf-8')
        return '텍스트'
    except UnicodeDecodeError:
        return '바이너리'


def extract_objects(r):
    objs = []
    conns = r['info']['conns']
    for c in conns:
        if c.service != 'HTTP':
            continue
        for q, rsp in analyze_http(c):
            host = q['hdr'].get('host', c.srv[0])
            base = os.path.basename(q['uri'].split('?')[0]) or 'index.html'
            if q['method'] in ('POST', 'PUT') and q['body']:
                objs.append({'name': _safe_name('upload_' + base), 'data': q['body'], 'frame': q.get('frame', 0),
                             'desc': 'HTTP %s http://%s%s (클라이언트가 보낸 본문)' % (q['method'], host, q['uri'])})
            if rsp and rsp['body']:
                objs.append({'name': _safe_name(base), 'data': rsp['body'],
                             'frame': rsp.get('frame', 0),
                             'desc': 'HTTP %s http://%s%s → %d %s' % (q['method'], host, q['uri'], rsp['code'], rsp['reason'])})
    cmds, dports, portmap = [], set(), {}
    for c in conns:
        if c.service == 'FTP':
            fr = analyze_ftp(c)
            dports |= fr['data_ports']
            for ts, line, _resp in fr['cmds']:
                verb, _, arg = line.partition(' ')
                if verb.upper() in ('RETR', 'STOR', 'APPE', 'STOU', 'LIST', 'NLST', 'MLSD'):
                    cmds.append((ts, verb.upper(), arg.strip()))
            # PASV(227)/EPSV(229)/PORT/EPRT 로 알려준 포트 → 그 다음 전송 명령과 짝짓기
            pend = None
            for ts, d, line, ix in lines_in_time(c):
                m = (re.match(r'227 .*?(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)', line) if d == 1 else
                     re.match(r'(?i)PORT (\d+),(\d+),(\d+),(\d+),(\d+),(\d+)', line))
                if m:
                    pend = int(m.group(5)) * 256 + int(m.group(6))
                    continue
                m = re.match(r'229 .*\(\|\|\|(\d+)\|\)', line) if d == 1 else re.match(r'(?i)EPRT \|\d\|[^|]*\|(\d+)\|', line)
                if m:
                    pend = int(m.group(1))
                    continue
                if d == 0 and pend is not None:
                    verb, _, arg = line.partition(' ')
                    if verb.upper() in ('RETR', 'STOR', 'APPE', 'STOU', 'LIST', 'NLST', 'MLSD'):
                        portmap[(c.cli[0], c.srv[0], pend)] = (verb.upper(), arg.strip())
                        pend = None
    for c in conns:
        if not (c.bytes[0] or c.bytes[1]):
            continue
        if not (c.srv[1] in dports or c.cli[1] in dports or 20 in (c.srv[1], c.cli[1])):
            continue
        d = 1 if c.bytes[1] >= c.bytes[0] else 0
        hit = (portmap.get((c.cli[0], c.srv[0], c.srv[1])) or portmap.get((c.srv[0], c.cli[0], c.cli[1])) or
               portmap.get((c.cli[0], c.srv[0], c.cli[1])) or portmap.get((c.srv[0], c.cli[0], c.srv[1])))
        if hit:
            verb, arg = hit
        else:
            prev = [x for x in cmds if x[0] <= c.start + 1.0]
            verb, arg = (prev[-1][1], prev[-1][2]) if prev else ('?', '')
        if verb in ('RETR', 'STOR', 'APPE', 'STOU') and arg:
            name, kind = os.path.basename(arg), {'RETR': '다운로드'}.get(verb, '업로드')
        elif verb in ('LIST', 'NLST', 'MLSD'):
            name, kind = 'ftp_LIST_%d.txt' % c.cli[1], '디렉터리 목록'
        else:
            name, kind = 'ftp_data_%d.bin' % c.cli[1], '데이터'
        objs.append({'name': _safe_name(name), 'data': c.stream(d), 'frame': c.idxs[-1] if c.idxs else 0,
                     'desc': 'FTP %s %s (%s, 데이터 채널 %s → %s)' % (verb, arg, kind, ep(c.cli), ep(c.srv))})
    for c in conns:                                           # SMTP 메일 본문
        if c.service == 'SMTP':
            m = re.search(rb'(?is)\r?\nDATA\r?\n(.*?)\r?\n\.\r?\n', b'\n' + c.stream(0))
            if m:
                objs.append({'name': 'mail_%d.eml' % c.cli[1], 'data': m.group(1), 'frame': c.idxs[0],
                             'desc': 'SMTP 메일 본문 %s → %s' % (ep(c.cli), ep(c.srv))})
    return objs


def objects_lines(r, objs):
    o = [col('■ %s — 파일 추출 (Wireshark: File > Export Objects)' % os.path.basename(r['path']), 'bold'), '']
    if not objs:
        return o + ['  추출할 파일이 없습니다 (HTTP 응답 본문 / FTP 데이터 / SMTP 메일 본문 없음).']
    for n, ob in enumerate(objs, 1):
        o.append('  %2d) %s  %s바이트  [%s]  #%d' % (n, col(ob['name'], 'bold'), format(len(ob['data']), ','),
                                                   guess_type(ob['data']), ob['frame']))
        o.append('      ' + ob['desc'])
        o.append(col('      미리보기: ' + preview(ob['data'], 100), 'dim'))
    return o


def object_content_lines(ob, maxlen=6000):
    o = [col('■ %s  (%s바이트, %s)' % (ob['name'], format(len(ob['data']), ','), guess_type(ob['data'])), 'bold'),
         '  ' + ob['desc'], '']
    t = guess_type(ob['data'])
    if t in ('텍스트', 'HTML'):
        txt = ob['data'][:maxlen].decode('utf-8', 'replace').replace('\r', '')
        o += [''.join(ch if ch.isprintable() or ch == '\t' else '.' for ch in l) for l in txt.split('\n')]
    else:
        for off in range(0, min(len(ob['data']), 1024), 16):
            ch = ob['data'][off:off + 16]
            o.append('  %04x  %-48s  %s' % (off, ' '.join('%02x' % x for x in ch),
                                            ''.join(chr(x) if 32 <= x < 127 else '.' for x in ch)))
    if len(ob['data']) > maxlen:
        o.append(col('  … (나머지 생략 — [e]로 저장 후 확인)', 'dim'))
    return o


def save_objects(r, objs):
    stem = os.path.splitext(os.path.basename(r['path']))[0]
    outdir = os.path.join(os.path.dirname(os.path.abspath(r['path'])), '추출_' + _safe_name(stem))
    try:
        os.makedirs(outdir, exist_ok=True)
    except OSError:
        outdir = os.path.abspath('추출_' + _safe_name(stem))
        os.makedirs(outdir, exist_ok=True)
    fails = 0
    for n, ob in enumerate(objs, 1):
        # 번호를 앞에 붙여 Windows 예약 이름(con, nul, aux…)과 대소문자 충돌을 피함
        name = '%04d_%s' % (n, ob['name'])
        try:
            with open(os.path.join(outdir, name), 'wb') as fp:
                fp.write(ob['data'])
        except OSError:
            fails += 1
    return outdir + (' (실패 %d개)' % fails if fails else '')


# ── 답안 카드 ───────────────────────────────────────────────────────
def best_wf(r, f):
    """보여줄 확인 필터: 이 파일에서 실제로 패킷이 걸리는 것 우선"""
    if not f.wf:
        return None
    order = sorted(f.wf, key=lambda w: 0 if re.search('성공|열린|살아있는', w[1]) else 1)
    for w in order:
        try:
            fn = compile_filter(w[0])
        except FilterError:
            continue
        if any(fn(p) for p in r['info']['pkts']):
            return w[0]
    return order[0][0]


def answer_lines(r):
    name = os.path.basename(r['path'])
    o = [col('■ %s — 답안' % name, 'bold')]
    if r['err']:
        return o + ['  분석 실패: ' + r['err'], '']
    pk, base = r['info']['pkts'], r['info']['start']
    for f in main_findings(r['fs']):
        o.append(col('  ▣ ' + f.title, *title_color(f.prio)))
        o.append('    답: ' + f.summary)
        for l in f.lines:
            s = l.strip()
            if s.startswith('▶'):
                o.append('    - ' + s.lstrip('▶ ').strip())
        src = f.groups[0][1] if f.groups and f.groups[0][1] else f.ev
        frames = sorted(set(i for i in src if 1 <= i <= len(pk)))
        key = [i for i in frames if KEY_RX.search(pkt_info(pk[i - 1]))] or frames[:4]
        if key:
            o.append('    근거 패킷 (Wireshark No.):')
            for i in key[:6]:
                p = pk[i - 1]
                o.append(('      #%-5d %s → %s  %s' % (i, p.src or (p.arp[1] if p.arp else ''), p.dst or '', cut(pkt_info(p), 60)), i))
            if len(key) > 6:
                o.append(col('      … 외 %d개' % (len(key) - 6), 'dim'))
        flt = best_wf(r, f)
        if flt:
            o.append('    확인 필터: ' + col(flt, 'cyan'))
    o.append('')
    return o


# ════════════════════════════════════════════════════════════════════
# 8-3. 패킷 해부 — 바이트 하나하나를 계층/필드별 색으로 구분해서 설명
# ════════════════════════════════════════════════════════════════════
# 계층별 색 (보통, 밝게) — 같은 계층 안의 필드는 보통/밝게를 번갈아 칠해 경계를 보이게 함
LAYER_COLOR = {
    'link': ('37', '97'), 'net': ('34', '94'), 'tcp': ('32', '92'), 'udp': ('36', '96'),
    'icmp': ('35', '95'), 'arp': ('35', '95'), 'app': ('33', '93'), 'inner': ('31', '91'), 'pad': ('90', '90'),
}
LAYER_LABEL = {
    'link': '링크 계층(L2) 헤더', 'net': 'IP 헤더(L3)', 'tcp': 'TCP 헤더(L4)', 'udp': 'UDP 헤더(L4)',
    'icmp': 'ICMP', 'arp': 'ARP', 'app': '응용 데이터(L7, 페이로드)', 'inner': 'ICMP 안에 담긴 원래 패킷', 'pad': '패딩/트레일러(의미 없음)',
}
LAYER_LETTER = {'link': 'L', 'net': 'I', 'tcp': 'T', 'udp': 'U', 'icmp': 'C', 'arp': 'A', 'app': 'D', 'inner': 'R', 'pad': '-'}
IP_PROTO_NAMES = {1: 'ICMP', 2: 'IGMP', 6: 'TCP', 17: 'UDP', 47: 'GRE', 50: 'ESP', 58: 'ICMPv6', 88: 'EIGRP', 89: 'OSPF', 112: 'VRRP'}
ETHER_NAMES = {0x0800: 'IPv4', 0x0806: 'ARP', 0x86dd: 'IPv6', 0x8100: 'VLAN', 0x8035: 'SLARP(Cisco 시리얼 keepalive)',
               0x2000: 'CDP(Cisco 장비 정보)', 0x88cc: 'LLDP'}
FTP_CODES = {'110': '재시작 마커', '120': '잠시 후 서비스 준비', '125': '데이터 연결 열림, 전송 시작', '150': '파일 상태 OK, 데이터 연결 연다',
             '200': '명령 성공', '211': '시스템 상태/기능 목록', '213': '파일 상태', '214': '도움말', '215': '시스템 종류',
             '220': '서비스 준비됨(접속 환영 배너)', '221': '접속 종료', '226': '전송 완료, 데이터 연결 닫음', '227': '패시브 모드 진입(데이터 포트 알려줌)',
             '229': '확장 패시브 모드', '230': '★ 로그인 성공', '250': '파일 작업 완료', '257': '경로 생성/현재 경로', '331': '사용자 이름 OK, 비밀번호 요구',
             '332': '계정 필요', '350': '추가 정보 필요', '421': '서비스 불가, 연결 종료', '425': '데이터 연결 실패', '426': '전송 중단',
             '450': '파일 사용 불가', '500': '알 수 없는 명령', '501': '인자 오류', '502': '구현 안 된 명령', '503': '명령 순서 오류',
             '530': '★ 로그인 실패', '550': '파일 없음/권한 없음', '553': '파일 이름 허용 안 됨'}
FTP_CMDS = {'USER': '사용자 이름 전송', 'PASS': '비밀번호 전송(평문!)', 'SYST': '서버 OS 종류 질문', 'FEAT': '지원 기능 질문',
            'PWD': '현재 디렉터리', 'CWD': '디렉터리 이동', 'LIST': '파일 목록 요청', 'NLST': '파일 이름 목록', 'RETR': '파일 다운로드',
            'STOR': '파일 업로드', 'DELE': '파일 삭제', 'MKD': '디렉터리 생성', 'RMD': '디렉터리 삭제', 'PASV': '패시브 모드 요청',
            'EPSV': '확장 패시브 모드', 'PORT': '액티브 모드(데이터 포트 지정)', 'TYPE': '전송 형식(A=텍스트, I=바이너리)', 'QUIT': '접속 종료',
            'SIZE': '파일 크기', 'MDTM': '수정 시각', 'OPTS': '옵션 설정', 'AUTH': 'TLS 암호화 요청'}
HTTP_STATUS = {200: '성공', 201: '생성됨', 204: '내용 없음', 301: '영구 이동', 302: '임시 이동(로그인 후 리다이렉트에 자주 쓰임)',
               304: '변경 없음(캐시 사용)', 400: '잘못된 요청', 401: '인증 필요', 403: '접근 금지', 404: '없는 페이지',
               405: '허용 안 된 메서드', 500: '서버 내부 오류(SQL 인젝션 시 자주 보임)', 502: '게이트웨이 오류', 503: '서비스 불가'}
TCP_OPT = {0: 'End of List', 1: 'NOP(정렬용 빈칸)', 2: 'MSS(최대 세그먼트 크기)', 3: 'Window Scale(윈도우 배수)', 4: 'SACK 허용',
           5: 'SACK 블록', 8: 'Timestamp(시간 기록)', 30: 'MPTCP', 34: 'TCP Fast Open'}
ICMP_CODES = {3: {0: '네트워크 도달 불가', 1: '호스트 도달 불가', 2: '프로토콜 도달 불가', 3: '포트 도달 불가(UDP 포트 닫힘 / traceroute 도착)',
                  4: '조각화 필요(DF 설정됨)', 9: '네트워크 관리적 차단', 10: '호스트 관리적 차단', 13: '방화벽 등으로 차단'},
              11: {0: 'TTL 만료(traceroute 경유 라우터가 응답)', 1: '조각 재조립 시간 초과'},
              5: {0: '네트워크 리다이렉트', 1: '호스트 리다이렉트'}}
DNS_RCODE = {0: '정상(NOERROR)', 1: '형식 오류', 2: '서버 실패', 3: '없는 도메인(NXDOMAIN)', 4: '미구현', 5: '거부'}


def _port_note(port):
    return KNOWN_PORTS.get(port, '')


def _flags_meaning(f):
    if f & 0x04:
        return '연결 강제 종료(RST)' + (' — 닫힌 포트의 응답이거나 스캔 후 끊기' if f & 0x10 else '')
    if f & 0x02 and f & 0x10:
        return '연결 수락(3-way 핸드셰이크 2단계) — 포트 열림'
    if f & 0x02:
        return '연결 요청(3-way 핸드셰이크 1단계)'
    if f & 0x01:
        return '연결 종료 요청(FIN)'
    if f & 0x08:
        return '데이터 전달(PSH: 바로 응용으로 올려라)'
    if f == 0x10:
        return '확인 응답(ACK)'
    if f == 0x29:
        return 'Xmas 스캔 패킷(FIN+PSH+URG)'
    if f == 0:
        return 'NULL 스캔 패킷(플래그 없음)'
    return ''


def dissect(raw, lt):
    """[(시작, 끝, 계층, 필드이름, 값, 설명), ...]"""
    F = []

    def add(s, e, layer, name, val, note=''):
        if e > s and s < len(raw):
            F.append((s, min(e, len(raw)), layer, name, val, note))

    b = raw
    try:
        off, et = 0, None
        if lt == 104:                                      # Cisco HDLC
            add(0, 1, 'link', 'HDLC 주소', '0x%02x' % b[0], '0x0f=유니캐스트, 0x8f=브로드캐스트 (GNS3 시리얼 링크)')
            add(1, 2, 'link', 'HDLC 제어', '0x%02x' % b[1])
            et = struct.unpack('>H', b[2:4])[0]
            add(2, 4, 'link', '상위 프로토콜', '0x%04x' % et, ETHER_NAMES.get(et, ''))
            off = 4
        elif lt == 1:                                      # Ethernet
            add(0, 6, 'link', '목적지 MAC', mac(b[0:6]), '브로드캐스트' if b[0:6] == b'\xff' * 6 else '')
            add(6, 12, 'link', '출발지 MAC', mac(b[6:12]))
            et = struct.unpack('>H', b[12:14])[0]
            off = 14
            while et in (0x8100, 0x88a8):
                add(off - 2, off, 'link', '타입', '0x%04x' % et, 'VLAN 태그 있음')
                add(off, off + 2, 'link', 'VLAN ID', str(struct.unpack('>H', b[off:off + 2])[0] & 0xfff))
                et = struct.unpack('>H', b[off + 2:off + 4])[0]
                off += 4
            add(off - 2, off, 'link', '이더타입', '0x%04x' % et, ETHER_NAMES.get(et, '') if et > 1500 else '길이 필드(802.3)')
        elif lt == 113:
            add(0, 2, 'link', '패킷 종류', str(struct.unpack('>H', b[0:2])[0]), '0=나에게, 4=내가 보냄')
            add(2, 4, 'link', 'ARPHRD 타입', str(struct.unpack('>H', b[2:4])[0]))
            add(4, 6, 'link', '주소 길이', str(struct.unpack('>H', b[4:6])[0]))
            add(6, 14, 'link', '출발지 주소', b[6:14].hex())
            et = struct.unpack('>H', b[14:16])[0]
            add(14, 16, 'link', '프로토콜', '0x%04x' % et, ETHER_NAMES.get(et, ''))
            off = 16
        elif lt == 276:
            et = struct.unpack('>H', b[0:2])[0]
            add(0, 2, 'link', '프로토콜', '0x%04x' % et, ETHER_NAMES.get(et, ''))
            add(2, 20, 'link', 'Linux cooked v2 헤더 나머지', '')
            off = 20
        elif lt in (0, 108):
            add(0, 4, 'link', '주소 패밀리', b[0:4].hex(), '2=IPv4, 24/28/30=IPv6 (루프백 캡처)')
            et, off = (0x0800 if 2 in (b[0], b[3]) else 0x86dd), 4
        elif lt in (9, 50):
            if b[:2] == b'\xff\x03':
                add(0, 2, 'link', 'PPP 주소/제어', 'ff 03')
                off = 2
            pp = struct.unpack('>H', b[off:off + 2])[0]
            add(off, off + 2, 'link', 'PPP 프로토콜', '0x%04x' % pp, {0x21: 'IPv4', 0x57: 'IPv6'}.get(pp, ''))
            et = {0x21: 0x0800, 0x57: 0x86dd}.get(pp)
            off += 2
        else:                                              # Raw IP
            et = 0x0800 if b[0] >> 4 == 4 else 0x86dd

        if et == 0x0800:
            _d_ipv4(b, off, len(b), add, 'net')
        elif et == 0x86dd:
            _d_ipv6(b, off, add)
        elif et == 0x0806:
            _d_arp(b, off, add)
        elif et == 0x8035:
            code = struct.unpack('>I', b[off:off + 4])[0]
            add(off, off + 4, 'app', 'SLARP 코드', str(code), {0: '주소 요청', 1: '주소 응답', 2: 'keepalive(살아있음 신호)'}.get(code, ''))
            add(off + 4, len(b), 'app', 'SLARP 내용', '', '라우터끼리 시리얼 링크가 살아있는지 확인 — 공격과 무관한 배경 트래픽')
        elif et == 0x2000:
            add(off, len(b), 'app', 'CDP 내용', '', '시스코 장비가 자기 정보(이름·IOS 버전)를 알림 — 배경 트래픽')
        elif off < len(b):
            add(off, len(b), 'app', '데이터', '%d바이트' % (len(b) - off))
    except (IndexError, struct.error):
        pass
    return F


def _d_ipv4(b, off, limit, add, layer):
    vihl = b[off]
    ihl = (vihl & 15) * 4
    tot = struct.unpack('>H', b[off + 2:off + 4])[0]
    end = min(off + tot, limit) if tot >= ihl else limit
    add(off, off + 1, layer, '버전 / 헤더 길이', 'IPv%d / %d바이트' % (vihl >> 4, ihl), '0x%02x: 앞 4비트=버전, 뒤 4비트×4=헤더 길이' % vihl)
    add(off + 1, off + 2, layer, 'TOS(DSCP/ECN)', '0x%02x' % b[off + 1], '서비스 품질 표시' + (' — 0xc0=라우팅 프로토콜(네트워크 제어)' if b[off + 1] == 0xc0 else ''))
    add(off + 2, off + 4, layer, '전체 길이', '%d바이트' % tot, 'IP 헤더 + 데이터 합계' + (' (ICMP 오류에는 앞 28바이트만 담김)' if layer == 'inner' else ''))
    ipid = struct.unpack('>H', b[off + 4:off + 6])[0]
    add(off + 4, off + 6, layer, '식별자(ID)', '0x%04x (%d)' % (ipid, ipid), '조각난 패킷을 다시 합칠 때 쓰는 번호')
    fl = struct.unpack('>H', b[off + 6:off + 8])[0]
    fs = []
    if fl & 0x4000:
        fs.append('DF(조각화 금지)')
    if fl & 0x2000:
        fs.append('MF(뒤에 조각 더 있음)')
    add(off + 6, off + 8, layer, '플래그 / 조각 오프셋', '%s / %d' % (', '.join(fs) or '없음', (fl & 0x1fff) * 8),
        '조각 오프셋>0 이거나 MF=1이면 IP 조각' if fl & 0x3fff else '')
    ttl = b[off + 8]
    init = 64 if ttl <= 64 else 128 if ttl <= 128 else 255
    if layer == 'inner':
        add(off + 8, off + 9, layer, 'TTL', str(ttl), '오류를 낸 라우터에 도착했을 때 남은 TTL' + (' — 1이면 여기서 만료(traceroute)' if ttl <= 1 else ''))
    else:
        add(off + 8, off + 9, layer, 'TTL', str(ttl), '라우터를 지날 때마다 1씩 감소 → 초기값 %d(%s)에서 %d홉 지남' % (
            init, {64: 'Linux/Unix', 128: 'Windows', 255: '네트워크 장비'}[init], init - ttl))
    proto = b[off + 9]
    add(off + 9, off + 10, layer, '프로토콜', '%d (%s)' % (proto, IP_PROTO_NAMES.get(proto, '?')), '다음 헤더가 무엇인지')
    add(off + 10, off + 12, layer, '헤더 체크섬', '0x%s' % b[off + 10:off + 12].hex(), '헤더 오류 검사용')
    add(off + 12, off + 16, layer, '출발지 IP', ip4(b[off + 12:off + 16]), '보낸 쪽')
    add(off + 16, off + 20, layer, '목적지 IP', ip4(b[off + 16:off + 20]), '받는 쪽' + (' (브로드캐스트)' if b[off + 19] == 255 else ''
                                                                               ) + (' (멀티캐스트 — 라우팅 프로토콜)' if 224 <= b[off + 16] <= 239 else ''))
    if ihl > 20:
        add(off + 20, off + ihl, layer, 'IP 옵션', b[off + 20:off + ihl].hex())
    p = off + ihl
    if (fl & 0x1fff) and layer == 'net':
        add(p, end, 'app', 'IP 조각 데이터', '%d바이트' % (end - p), '첫 조각이 아니라 TCP/UDP/ICMP 헤더가 없음')
    elif proto == 6:
        _d_tcp(b, p, end, add, 'tcp' if layer == 'net' else 'inner')
    elif proto == 17:
        _d_udp(b, p, end, add, 'udp' if layer == 'net' else 'inner')
    elif proto == 1:
        _d_icmp(b, p, end, add, 'icmp' if layer == 'net' else 'inner')
    elif proto in (88, 89):
        name = 'OSPF' if proto == 89 else 'EIGRP'
        if proto == 89 and end - p >= 8:
            add(p, p + 1, 'app', 'OSPF 버전', str(b[p]))
            add(p + 1, p + 2, 'app', 'OSPF 타입', str(b[p + 1]), {1: 'Hello(이웃 확인)', 2: 'DB 설명', 3: 'LS 요청', 4: 'LS 업데이트', 5: 'LS 확인'}.get(b[p + 1], ''))
            add(p + 2, p + 4, 'app', '길이', str(struct.unpack('>H', b[p + 2:p + 4])[0]))
            add(p + 4, p + 8, 'app', '라우터 ID', ip4(b[p + 4:p + 8]))
            add(p + 8, end, 'app', 'OSPF 나머지', '', '라우터끼리 경로 정보 교환 — 배경 트래픽(무시)')
        else:
            add(p, end, 'app', '%s 내용' % name, '', '라우터끼리 경로 정보 교환 — 배경 트래픽(무시)')
    elif end > p:
        add(p, end, 'app', '데이터', '%d바이트' % (end - p))
    if layer == 'net' and end < len(b):
        add(end, len(b), 'pad', '이더넷 패딩/트레일러', '%d바이트' % (len(b) - end), '최소 길이 맞추기용, 의미 없음')


def _d_ipv6(b, off, add):
    add(off, off + 4, 'net', '버전/트래픽클래스/플로우', b[off:off + 4].hex(), '앞 4비트=6')
    pl = struct.unpack('>H', b[off + 4:off + 6])[0]
    add(off + 4, off + 6, 'net', '페이로드 길이', str(pl))
    nh = b[off + 6]
    add(off + 6, off + 7, 'net', '다음 헤더', '%d (%s)' % (nh, IP_PROTO_NAMES.get(nh, '?')))
    add(off + 7, off + 8, 'net', '홉 제한(TTL)', str(b[off + 7]))
    add(off + 8, off + 24, 'net', '출발지 IPv6', ip6(b[off + 8:off + 24]))
    add(off + 24, off + 40, 'net', '목적지 IPv6', ip6(b[off + 24:off + 40]))
    p, end = off + 40, min(len(b), off + 40 + pl)
    if nh == 6:
        _d_tcp(b, p, end, add, 'tcp')
    elif nh == 17:
        _d_udp(b, p, end, add, 'udp')
    elif nh == 58:
        add(p, p + 1, 'icmp', 'ICMPv6 타입', str(b[p]), {128: 'Echo Request', 129: 'Echo Reply', 135: '이웃 요청', 136: '이웃 광고', 133: '라우터 요청', 134: '라우터 광고'}.get(b[p], ''))
        add(p + 1, end, 'icmp', 'ICMPv6 나머지', '')
    elif end > p:
        add(p, end, 'app', '데이터', '%d바이트' % (end - p))


def _d_arp(b, off, add):
    op = struct.unpack('>H', b[off + 6:off + 8])[0]
    add(off, off + 2, 'arp', '하드웨어 타입', str(struct.unpack('>H', b[off:off + 2])[0]), '1=이더넷')
    add(off + 2, off + 4, 'arp', '프로토콜 타입', '0x%04x' % struct.unpack('>H', b[off + 2:off + 4])[0], '0x0800=IPv4')
    add(off + 4, off + 5, 'arp', 'MAC 길이', str(b[off + 4]))
    add(off + 5, off + 6, 'arp', 'IP 길이', str(b[off + 5]))
    add(off + 6, off + 8, 'arp', '동작(opcode)', str(op), {1: '요청: 이 IP 누구야?', 2: '응답: 이 IP는 내 MAC이야 (스푸핑은 이걸 위조)'}.get(op, ''))
    add(off + 8, off + 14, 'arp', '보낸 쪽 MAC', mac(b[off + 8:off + 14]))
    add(off + 14, off + 18, 'arp', '보낸 쪽 IP', ip4(b[off + 14:off + 18]))
    add(off + 18, off + 24, 'arp', '대상 MAC', mac(b[off + 18:off + 24]), '요청일 때는 00:00:00:00:00:00(모름)')
    add(off + 24, off + 28, 'arp', '대상 IP', ip4(b[off + 24:off + 28]))
    if off + 28 < len(b):
        add(off + 28, len(b), 'pad', '패딩', '%d바이트' % (len(b) - off - 28), '이더넷 최소 길이 맞추기')


def _d_tcp(b, p, end, add, layer):
    sp, dp = struct.unpack('>HH', b[p:p + 4])
    add(p, p + 2, layer, '출발지 포트', str(sp), _port_note(sp) or ('임시(클라이언트) 포트' if sp > 1023 else ''))
    add(p + 2, p + 4, layer, '목적지 포트', str(dp), _port_note(dp) or ('임시(클라이언트) 포트' if dp > 1023 else ''))
    if end - p < 20:
        add(p + 4, end, layer, 'TCP 헤더 앞부분(ICMP 오류엔 8바이트만 담김)', b[p + 4:end].hex())
        return
    seq, ack = struct.unpack('>II', b[p + 4:p + 12])
    add(p + 4, p + 8, layer, '시퀀스 번호', str(seq), '이 세그먼트 첫 바이트의 번호')
    add(p + 8, p + 12, layer, '확인 번호(ACK)', str(ack), '다음에 받을 바이트 번호')
    hl = (b[p + 12] >> 4) * 4
    fl = b[p + 13]
    add(p + 12, p + 14, layer, '헤더 길이 / 플래그', '%d바이트 / %s' % (hl, flag_names(fl)), _flags_meaning(fl))
    win = struct.unpack('>H', b[p + 14:p + 16])[0]
    add(p + 14, p + 16, layer, '윈도우 크기', str(win), {1024: 'nmap SYN 스캔 기본값', 512: 'hping3 기본값', 31337: 'nmap OS 탐지 프로브'}.get(win, '받을 수 있는 버퍼 크기'))
    add(p + 16, p + 18, layer, '체크섬', '0x%s' % b[p + 16:p + 18].hex())
    add(p + 18, p + 20, layer, '긴급 포인터', str(struct.unpack('>H', b[p + 18:p + 20])[0]))
    o = p + 20
    while o < p + hl:
        k = b[o]
        if k in (0, 1):
            add(o, o + 1, layer, '옵션: ' + TCP_OPT[k], '')
            o += 1
            if k == 0:
                break
            continue
        ln = b[o + 1] if o + 1 < p + hl else 2
        if ln < 2:
            break
        v, note = b[o + 2:o + ln].hex(), ''
        if k == 2 and ln == 4:
            v = str(struct.unpack('>H', b[o + 2:o + 4])[0])
        elif k == 3 and ln == 3:
            v, note = 'shift %d' % b[o + 2], '윈도우 × 2^%d' % b[o + 2]
        elif k == 8 and ln == 10:
            tv, te = struct.unpack('>II', b[o + 2:o + 10])
            v = 'TSval=%d TSecr=%d' % (tv, te)
            note = 'TSval 0xFFFFFFFF = nmap OS 탐지(-O) 프로브' if tv == 0xffffffff else ''
        add(o, o + ln, layer, '옵션: ' + TCP_OPT.get(k, 'kind %d' % k), v, note)
        o += ln
    if p + hl < end:
        _d_app(b, p + hl, end, add, sp, dp, 6)


def _d_udp(b, p, end, add, layer):
    sp, dp = struct.unpack('>HH', b[p:p + 4])
    add(p, p + 2, layer, '출발지 포트', str(sp), _port_note(sp) or NOISE_UDP.get(sp, ''))
    add(p + 2, p + 4, layer, '목적지 포트', str(dp), _port_note(dp) or NOISE_UDP.get(dp, '') or
        ('traceroute 프로브 포트(33434~)' if 33434 <= dp <= 33600 else ''))
    if end - p < 8:
        return
    add(p + 4, p + 6, layer, '길이', str(struct.unpack('>H', b[p + 4:p + 6])[0]), 'UDP 헤더 8 + 데이터')
    add(p + 6, p + 8, layer, '체크섬', '0x%s' % b[p + 6:p + 8].hex())
    if p + 8 < end:
        _d_app(b, p + 8, end, add, sp, dp, 17)


def _d_icmp(b, p, end, add, layer):
    t, c = b[p], b[p + 1]
    add(p, p + 1, layer, '타입', '%d (%s)' % (t, ICMP_NAMES.get(t, '?')), {8: 'ping 요청', 0: 'ping 응답', 3: '도달 불가 오류', 11: '시간 초과 오류', 13: '타임스탬프 요청(nmap 호스트 탐색에 사용)'}.get(t, ''))
    add(p + 1, p + 2, layer, '코드', str(c), ICMP_CODES.get(t, {}).get(c, '') or ('code 9 = nmap OS 탐지 프로브' if t == 8 and c == 9 else ''))
    add(p + 2, p + 4, layer, '체크섬', '0x%s' % b[p + 2:p + 4].hex())
    if end - p < 8:
        return
    if t in (0, 8, 13, 14):
        add(p + 4, p + 6, layer, '식별자(ID)', str(struct.unpack('>H', b[p + 4:p + 6])[0]), '요청/응답 짝 맞추기용')
        add(p + 6, p + 8, layer, '순번(seq)', str(struct.unpack('>H', b[p + 6:p + 8])[0]), '보낸 순서')
        data = b[p + 8:end]
        if data:
            note = ('Windows ping 데이터(abcd…)' if b'abcdefghijklmnopqrstuvw' in data else
                    'Linux ping 데이터(타임스탬프 + 0x10~0x37)' if bytes(range(0x10, 0x20)) in data else
                    'traceroute -I 데이터' if data.startswith(b'@ABCDEFG') else '')
            add(p + 8, end, 'app', 'ICMP 데이터', '%d바이트' % len(data), note)
    elif t in (3, 11, 4, 5, 12):
        add(p + 4, p + 8, layer, '사용 안 함/추가정보', b[p + 4:p + 8].hex())
        if p + 8 + 20 <= end:
            _d_ipv4(b, p + 8, end, add, 'inner')
    else:
        add(p + 4, end, layer, 'ICMP 나머지', b[p + 4:end].hex()[:40])


def _d_app(b, s, e, add, sp, dp, proto):
    data = b[s:e]
    ports = (sp, dp)
    if proto == 17 and 53 in ports and len(data) >= 12:
        return _d_dns(b, s, e, add)
    if data[:1] in (b'\x14', b'\x15', b'\x16', b'\x17') and data[1:2] == b'\x03' and len(data) >= 5:
        return _d_tls(b, s, e, add)
    if data.startswith(b'SSH-'):
        nl = data.find(b'\n')
        nl = len(data) if nl < 0 else nl + 1
        add(s, s + nl, 'app', 'SSH 버전 배너', first_line(data[:nl], 60),
            '클라이언트/서버 프로그램 이름이 그대로 노출 (libssh=Hydra, MEDUSA=Medusa)')
        if s + nl < e:
            add(s + nl, e, 'app', 'SSH 바이너리 패킷', '%d바이트' % (e - s - nl), '키 교환/암호화 데이터')
        return
    if 22 in ports:
        if len(data) >= 6:
            plen = struct.unpack('>I', data[:4])[0]
            if plen + 4 == len(data) and data[5] in (20, 21, 30, 31, 32, 33, 34):
                add(s, s + 4, 'app', 'SSH 패킷 길이', str(plen))
                add(s + 4, s + 5, 'app', '패딩 길이', str(data[4]))
                add(s + 5, s + 6, 'app', '메시지 코드', str(data[5]), {20: 'KEXINIT(알고리즘 협상)', 21: 'NEWKEYS(이후 암호화 시작)', 30: '키 교환 시작',
                                                                    31: '키 교환 응답', 32: 'DH 그룹 요청', 33: 'DH 그룹', 34: 'DH 초기값'}.get(data[5], ''))
                add(s + 6, e, 'app', 'SSH 메시지 내용', '%d바이트' % (e - s - 6), '평문(키 교환 단계)')
                return
        add(s, e, 'app', '암호화된 SSH 데이터', '%d바이트' % len(data), '내용은 볼 수 없음. 크기만으로 인증 성공/실패 추정 가능')
        return
    if data[:1] == b'\xff' and 23 in ports:
        add(s, e, 'app', 'Telnet 옵션 협상(IAC)', data.hex()[:30], '0xff로 시작 = 터미널 설정 주고받기')
        return
    printable = sum(1 for x in data[:300] if 32 <= x < 127 or x in (9, 10, 13))
    if data and printable >= min(len(data), 300) * 0.85:
        _d_text(b, s, e, add, ports)
        return
    label = 'FTP 데이터 채널' if 20 in ports else '데이터'
    add(s, e, 'app', label, '%d바이트' % len(data), {4444: 'Metasploit 기본 포트'}.get(dp, '') or {4444: 'Metasploit 기본 포트'}.get(sp, ''))


def _d_text(b, s, e, add, ports):
    data = b[s:e]
    pos = 0
    http = data[:5] == b'HTTP/' or REQ_RE.match(data) is not None
    in_body = False
    n = 0
    while pos < len(data):
        if in_body:
            add(s + pos, e, 'app', 'HTTP 본문', '%d바이트' % (len(data) - pos), first_line(data[pos:], 50))
            break
        nl = data.find(b'\n', pos)
        nl = len(data) if nl < 0 else nl + 1
        line = data[pos:nl].decode('utf-8', 'replace').rstrip('\r\n')
        name, note = '텍스트 %d번째 줄' % (n + 1), ''
        if http:
            if n == 0:
                if line.startswith('HTTP/'):
                    m = re.match(r'HTTP/\S+ (\d{3})', line)
                    code = int(m.group(1)) if m else 0
                    name, note = 'HTTP 상태 줄', '%d = %s' % (code, HTTP_STATUS.get(code, ''))
                else:
                    name, note = 'HTTP 요청 줄', '메서드 경로 버전'
            elif line == '':
                name, note = '빈 줄', '헤더 끝 — 다음부터 본문'
                in_body = True
            else:
                k = line.split(':', 1)[0]
                name = '%s 헤더' % k
                note = {'Host': '접속한 사이트 주소', 'User-Agent': '클라이언트 프로그램(공격 도구 이름이 자주 보임)', 'Cookie': '세션 정보',
                        'Authorization': '인증 정보(Basic은 base64 디코딩하면 계정:비번)', 'Content-Length': '본문 길이', 'Server': '웹 서버 프로그램/버전',
                        'Content-Type': '본문 종류', 'Location': '이동할 주소(리다이렉트)', 'Set-Cookie': '서버가 준 세션'}.get(k, '')
        elif 21 in ports or 20 in ports:
            m = re.match(r'(\d{3})[ -]', line)
            if m:
                name, note = 'FTP 응답 %s' % m.group(1), FTP_CODES.get(m.group(1), '')
            else:
                cmd = line.split(' ', 1)[0].upper()
                name, note = 'FTP 명령 %s' % cmd, FTP_CMDS.get(cmd, '')
        elif 25 in ports or 587 in ports:
            name = 'SMTP ' + ('응답' if line[:3].isdigit() else '명령')
        elif 110 in ports:
            name = 'POP3 ' + ('응답' if line.startswith(('+OK', '-ERR')) else '명령')
        add(s + pos, s + nl, 'app', name, line[:70] + ('…' if len(line) > 70 else ''), note)
        pos = nl
        n += 1


def _d_dns(b, s, e, add):
    tid, fl, qd, an, ns, ar = struct.unpack('>HHHHHH', b[s:s + 12])
    add(s, s + 2, 'app', 'DNS 트랜잭션 ID', '0x%04x' % tid, '질의와 응답 짝 맞추기 (스푸핑은 이 ID를 맞혀야 함)')
    qr = fl >> 15
    add(s + 2, s + 4, 'app', 'DNS 플래그', '0x%04x' % fl, '%s, 재귀요청=%d%s' % ('응답' if qr else '질의', (fl >> 8) & 1,
                                                                        ', 결과=' + DNS_RCODE.get(fl & 15, str(fl & 15)) if qr else ''))
    add(s + 4, s + 6, 'app', '질문 수', str(qd))
    add(s + 6, s + 8, 'app', '답변 수', str(an))
    add(s + 8, s + 10, 'app', '권한 레코드 수', str(ns))
    add(s + 10, s + 12, 'app', '추가 레코드 수', str(ar))
    p = s + 12
    msg = b[s:e]
    for _ in range(min(qd, 5)):
        name, np = dns_name(msg, p - s)
        add(p, s + np, 'app', '질의 이름', name, '조회하려는 도메인' + (' (역방향 조회: IP→이름)' if name.endswith('in-addr.arpa') else ''))
        p = s + np
        qt = struct.unpack('>H', b[p:p + 2])[0]
        add(p, p + 2, 'app', '질의 타입', '%d (%s)' % (qt, QTYPES.get(qt, '?')), {1: 'IPv4 주소', 28: 'IPv6 주소', 12: '이름 역조회', 15: '메일 서버', 65: 'HTTPS 정보'}.get(qt, ''))
        add(p + 2, p + 4, 'app', '질의 클래스', str(struct.unpack('>H', b[p + 2:p + 4])[0]), '1=IN(인터넷), 3=CHAOS(version.bind 등)')
        p += 4
    for _ in range(min(an, 10)):
        name, np = dns_name(msg, p - s)
        add(p, s + np, 'app', '답변 이름', name)
        p = s + np
        t, c, ttl, rl = struct.unpack('>HHIH', b[p:p + 10])
        add(p, p + 2, 'app', '답변 타입', '%d (%s)' % (t, QTYPES.get(t, '?')))
        add(p + 2, p + 4, 'app', '답변 클래스', str(c))
        add(p + 4, p + 8, 'app', 'TTL(캐시 시간)', '%d초' % ttl)
        add(p + 8, p + 10, 'app', '데이터 길이', str(rl))
        v = ip4(b[p + 10:p + 14]) if t == 1 and rl == 4 else b[p + 10:p + 10 + rl].hex()[:40]
        add(p + 10, p + 10 + rl, 'app', '답변 데이터', v, 'A 레코드 = 도메인의 IP 주소' if t == 1 else '')
        p += 10 + rl
    if p < e:
        add(p, e, 'app', 'DNS 나머지 레코드', '%d바이트' % (e - p))


def _d_tls(b, s, e, add):
    ct = b[s]
    add(s, s + 1, 'app', 'TLS 레코드 타입', str(ct), {20: 'ChangeCipherSpec', 21: 'Alert(경고)', 22: 'Handshake(협상)', 23: 'Application Data(암호화된 내용)'}.get(ct, ''))
    add(s + 1, s + 3, 'app', 'TLS 버전', b[s + 1:s + 3].hex(), '0301=TLS1.0, 0303=TLS1.2(1.3도 이 값 사용)')
    ln = struct.unpack('>H', b[s + 3:s + 5])[0]
    add(s + 3, s + 5, 'app', 'TLS 레코드 길이', str(ln))
    p = s + 5
    if ct == 22 and p + 4 <= e:
        ht = b[p]
        add(p, p + 1, 'app', '핸드셰이크 타입', str(ht), {1: 'ClientHello(클라이언트 인사 — 접속할 도메인 SNI 포함)', 2: 'ServerHello', 11: '인증서', 16: '키 교환'}.get(ht, ''))
        add(p + 1, p + 4, 'app', '핸드셰이크 길이', str(int.from_bytes(b[p + 1:p + 4], 'big')))
        if ht == 1:
            sni = tls_sni_pos(b, s, e)
            if sni:
                a, z, name = sni
                add(p + 4, a, 'app', 'ClientHello 내용', '버전·난수·세션·암호목록 등')
                ai = next((nm for rx, nm in AI_API if re.search(rx, name)), '')
                add(a, z, 'app', 'SNI(접속 도메인)', name, ('★ AI API: ' + ai) if ai else '암호화돼도 접속하는 도메인은 보임')
                if z < e:
                    add(z, e, 'app', 'ClientHello 나머지', '')
                return
        add(p + 4, min(e, p + 4 + int.from_bytes(b[p + 1:p + 4], 'big')), 'app', '핸드셰이크 내용', '')
    else:
        add(p, min(e, p + ln), 'app', '암호화된 데이터', '%d바이트' % min(ln, e - p), '내용 확인 불가')
    if p + ln < e and ct != 22:
        add(p + ln, e, 'app', '다음 TLS 레코드', '%d바이트' % (e - p - ln))


def tls_sni_pos(b, s, e):
    try:
        p = s + 5 + 4 + 2 + 32
        p += 1 + b[p]
        p += 2 + struct.unpack('>H', b[p:p + 2])[0]
        p += 1 + b[p]
        end = p + 2 + struct.unpack('>H', b[p:p + 2])[0]
        p += 2
        while p + 4 <= min(end, e):
            t, l = struct.unpack('>HH', b[p:p + 4])
            if t == 0:
                nl = struct.unpack('>H', b[p + 7:p + 9])[0]
                return p + 9, p + 9 + nl, b[p + 9:p + 9 + nl].decode('latin-1')
            p += 4 + l
    except (IndexError, struct.error):
        pass
    return None


def dissect_lines(r, n, parts=False, full_hex=True):
    info = r['info']
    pk = info['pkts']
    if not 1 <= n <= len(pk):
        return ['프레임 번호는 1 ~ %d 사이여야 합니다.' % len(pk)]
    ts, lt, raw = info['raw'][n - 1][:3]
    fields = dissect(raw, lt)
    pp = pk[n - 1]
    extra = []
    app = [i for i, f in enumerate(fields) if f[2] == 'app']
    if pp.svc == 'HTTP' and app and pp.data and not (pp.data[:5] == b'HTTP/' or REQ_RE.match(pp.data)):
        s0, e0 = fields[app[0]][0], fields[app[-1]][1]
        fields = fields[:app[0]] + [(s0, e0, 'app', '데이터', '', '')] + fields[app[-1] + 1:]
        ai = app[0]
        if pp.http:
            m = pp.http
            first = ('%s %s' % (m['method'], m['uri'])) if 'method' in m else ('HTTP %d %s' % (m['code'], m['reason']))
            fields[ai] = (s0, e0, 'app', 'HTTP 메시지 마지막 조각', '%d바이트' % (e0 - s0),
                          '여러 패킷에 나뉜 HTTP가 이 패킷에서 끝남 → Wireshark는 여기서 [%s] 로 표시' % first)
            extra = ['', col('  [재조립된 HTTP 메시지 — 여러 패킷을 합친 결과]', 'bold'), '   ' + first]
            extra += ['   %s: %s' % (k.title(), v) for k, v in list(m['hdr'].items())[:12]]
            if m.get('body'):
                extra.append('   본문 %d바이트%s: %s' % (len(m['body']), '(압축 해제)' if m['raw_len'] != len(m['body']) else '',
                                                    preview(m['body'], 120)))
        else:
            fields[ai] = (s0, e0, 'app', 'HTTP 메시지 중간 조각', '%d바이트' % (e0 - s0),
                          '여러 패킷에 나뉜 HTTP의 일부 → Wireshark는 [TCP segment of a reassembled PDU]')
    # 바이트마다 (계층, 밝기, 필드번호)
    owner = [None] * len(raw)
    shade_count = collections.Counter()
    shades = []
    for i, (s, e, layer, name, val, note) in enumerate(fields):
        sh = shade_count[layer] % 2
        shade_count[layer] += 1
        shades.append(sh)
        for k in range(s, e):
            owner[k] = i

    def ccode(i):
        if i is None:
            return ('pad', 0)
        return fields[i][2], shades[i]

    def paint(text, i):
        layer, sh = ccode(i)
        code = LAYER_COLOR[layer][sh]
        return '\033[%sm%s\033[0m' % (code, text) if COLOR else text

    p = pk[n - 1]
    o = [col('■ %s — 프레임 #%d 해부  (%d바이트, %s, 첫 패킷부터 %.6f초)' % (
        os.path.basename(r['path']), n, len(raw), LINKTYPES.get(lt, '?'), ts - info['start']), 'bold'),
        col(PKT_HEADER, 'dim'), pkt_line(p, info['start']), '']
    # 색 범례
    layers = []
    for f in fields:
        if f[2] not in layers:
            layers.append(f[2])
    if None in owner and 'pad' not in layers:
        layers.append('pad')
    o.append(col('[색 범례] ', 'bold') + '  '.join(
        ('\033[%sm██\033[0m' % LAYER_COLOR[l][0] if COLOR else '[%s]' % LAYER_LETTER[l]) + ' ' + LAYER_LABEL[l]
        + ' (%d바이트)' % sum(1 for x in owner if (fields[x][2] if x is not None else 'pad') == l) for l in layers))
    legend_line = o[-1]
    o.append(col('  같은 계층 안에서는 필드마다 진하게/밝게를 번갈아 칠함 → 색이 바뀌는 곳이 필드 경계. 오른쪽에 그 줄에서 시작하는 필드 이름.', 'dim'))
    o.append('')
    head_len = len(o)
    # 16진수 덤프 + 오른쪽에 필드 이름
    o.append(col('  오프셋  ' + ' '.join('%02x' % k for k in range(16)) + '   ASCII' + ('             이 줄의 필드' if term_size()[0] >= 92 else ''), 'dim'))
    app_start = min([f[0] for f in fields if f[2] == 'app'] or [len(raw)])
    hex_end = len(raw) if (full_hex or len(raw) - app_start <= 48) else app_start + 32
    for row in range(0, len(raw), 16):
        if row >= hex_end:
            o.append(col('          … 데이터 %d바이트 중 앞부분만 표시 (x 키: 16진수 전체 보기)' % (len(raw) - app_start), 'yellow'))
            break
        hx, asc = [], []
        for k in range(row, row + 16):
            if k < len(raw):
                hx.append(paint('%02x' % raw[k], owner[k]))
                ch = raw[k]
                asc.append(paint(chr(ch) if 32 <= ch < 127 else '.', owner[k]))
            else:
                hx.append('  ')
                asc.append(' ')
        names, room = [], term_size()[0] - 81
        for i, f in enumerate(fields):
            if row <= f[0] < row + 16 and room >= 10:
                nm = re.sub(r'\(.*?\)', '', f[3]).replace('옵션: ', '').strip()
                if sum(dispw(x) + 3 for x in names) + dispw(nm) + 4 > room:
                    names.append('…')
                    break
                names.append(nm)
        painted, k = [], 0
        for i, f in enumerate(fields):
            if row <= f[0] < row + 16 and k < len(names):
                painted.append(names[k] if names[k] == '…' else paint(names[k], i))
                k += 1
        line = '  %04x    %s   %s  %s' % (row, ' '.join(hx), ''.join(asc), ' · '.join(painted))
        o.append(line)
        if not COLOR:
            o.append('          ' + ' '.join((LAYER_LETTER[ccode(owner[k])[0]] * 2) if k < len(raw) else '  ' for k in range(row, row + 16)))
    if not COLOR:
        o.append('  (색이 안 나오는 터미널: 아래 글자로 계층 표시 — ' + ', '.join('%s=%s' % (LAYER_LETTER[l], LAYER_LABEL[l]) for l in layers) + ')')
    o.append('')
    # 필드 상세 (Wireshark 패킷 상세 창과 같은 역할)
    o.append(col('  [필드별 해석]   오프셋 = 패킷 처음부터 몇 번째 바이트인지', 'bold'))
    cur = None
    for i, (s, e, layer, name, val, note) in enumerate(fields):
        if layer != cur:
            cur = layer
            span = [f for f in fields if f[2] == layer]
            o.append(paint('▌ %s' % LAYER_LABEL[layer], i) + col('  (오프셋 %d~%d)' % (span[0][0], max(f[1] for f in span) - 1), 'dim'))
        hb = raw[s:e]
        hxs = ' '.join('%02x' % x for x in hb[:8]) + (' …' if len(hb) > 8 else '')
        o.append('   %s  %s  %s  %s%s' % (
            col('%04x' % s, 'dim'), paint(pad(hxs, 26), i), paint(pad(name, 22), i), val,
            col('   ← ' + note, 'dim') if note else ''))
    if parts:
        return legend_line, o[head_len:] + extra
    return o + extra


# ════════════════════════════════════════════════════════════════════
# 9. 파일 모으기 / 불러오기
# ════════════════════════════════════════════════════════════════════
CAP_EXT = ('.pcap', '.pcapng', '.cap', '.dmp')
CAP_MAGIC = (b'\xd4\xc3\xb2\xa1', b'\xa1\xb2\xc3\xd4', b'\x4d\x3c\xb2\xa1', b'\xa1\xb2\x3c\x4d', b'\x0a\x0d\x0d\x0a')


def is_capture(path):
    if path.lower().endswith(CAP_EXT):
        return True
    try:
        if os.path.getsize(path) < 24:
            return False
        with open(path, 'rb') as f:
            return f.read(4) in CAP_MAGIC
    except OSError:
        return False


def natural_key(s):
    return [int(t) if t.isdigit() else t.lower() for t in re.split(r'(\d+)', os.path.basename(s))]


def expand_inputs(args):
    files = []
    for a in args:
        if os.path.isdir(a):
            for f in os.listdir(a):
                full = os.path.join(a, f)
                if os.path.isfile(full) and is_capture(full):
                    files.append(full)
        elif any(ch in a for ch in '*?['):
            files += glob.glob(a)
        else:
            files.append(a)
    return sorted(set(files), key=natural_key)


def load_results(files, verbose=False, progress=True):
    res = []
    for i, f in enumerate(files, 1):
        if progress:
            sys.stdout.write('\r분석 중 %d/%d  %-40s' % (i, len(files), os.path.basename(f)[:40]))
            sys.stdout.flush()
        try:
            info, fs = analyze_file(f, verbose)
            res.append({'path': f, 'info': info, 'fs': fs, 'err': None, 'verbose': verbose})
        except Exception as e:
            res.append({'path': f, 'info': None, 'fs': [], 'err': '%s: %s' % (type(e).__name__, e), 'verbose': verbose})
    if progress:
        sys.stdout.write('\r' + ' ' * 60 + '\r')
    return res


def plain_full_report(results, verbose=False):
    global COLOR
    saved, COLOR = COLOR, False
    try:
        o = []
        for r in results:
            if r['err']:
                o.append('■ %s : 분석 실패 (%s)' % (os.path.basename(r['path']), r['err']))
            else:
                o += render_report(r['info'], r['fs'], verbose)
        o += ['', '═' * 78, '■ 요약표', '─' * 78] + render_table(results)
        o += ['', '═' * 78] + [x[0] if isinstance(x, tuple) else x for x in
                               sum((answer_lines(r) for r in results), ['■ 답안 (판정 + 근거 프레임 + 확인 필터)', ''])]
        o += ['═' * 78] + creds_lines(results)
        return o
    finally:
        COLOR = saved


# ════════════════════════════════════════════════════════════════════
# 10. 대화형 화면 — 키 하나로 조작, 화면 아래에 항상 단축키 표시
# ════════════════════════════════════════════════════════════════════
VT = False          # ANSI 화면 제어 가능 여부
ASCII = False       # 일부 Windows 콘솔에서 2칸으로 그려지는 기호를 ASCII로 바꿀지

ASCII_MAP = str.maketrans({'═': '=', '─': '-', '━': '=', '▌': '|', '█': '#', '■': '#', '▣': '#', '▶': '>', '▸': '>',
                           '★': '*', '→': '->', '←': '<-', '↑': '^', '↓': 'v', '…': '..', '·': '.', '※': '*', '↵': '/',
                           '—': '-', '×': 'x', '│': '|', '●': '*', '◀': '<', '↔': '<->'})
ANSI_RX = re.compile(r'\x1b\[[0-9;?]*[A-Za-z]')
HANGUL_KEYS = {'ㅂ': 'q', 'ㅈ': 'w', 'ㄷ': 'e', 'ㄱ': 'r', 'ㅅ': 't', 'ㅛ': 'y', 'ㅕ': 'u', 'ㅑ': 'i', 'ㅐ': 'o', 'ㅔ': 'p',
               'ㅁ': 'a', 'ㄴ': 's', 'ㅇ': 'd', 'ㄹ': 'f', 'ㅎ': 'g', 'ㅗ': 'h', 'ㅓ': 'j', 'ㅏ': 'k', 'ㅣ': 'l',
               'ㅋ': 'z', 'ㅌ': 'x', 'ㅊ': 'c', 'ㅍ': 'v', 'ㅠ': 'b', 'ㅜ': 'n', 'ㅡ': 'm', 'ㅃ': 'Q', 'ㅉ': 'W',
               'ㄸ': 'E', 'ㄲ': 'R', 'ㅆ': 'T', 'ㅒ': 'O', 'ㅖ': 'P'}


def tx(s):
    return s.translate(ASCII_MAP) if ASCII else s


def strip_ansi(s):
    return ANSI_RX.sub('', s)


def _cw(ch):
    if unicodedata.combining(ch):
        return 0
    return 2 if unicodedata.east_asian_width(ch) in 'WF' else 1


def vwidth(s):
    return sum(_cw(c) for c in strip_ansi(s))


def fit(s, w):
    """ANSI 색을 유지하면서 화면 폭 w 칸에 맞게 자름"""
    s = s.replace('\t', '    ')
    if vwidth(s) <= w:
        return s
    out, cur, i, has = [], 0, 0, False
    while i < len(s):
        m = ANSI_RX.match(s, i)
        if m:
            out.append(m.group())
            i, has = m.end(), True
            continue
        cw = _cw(s[i])
        if cur + cw > w - 1:
            break
        out.append(s[i])
        cur += cw
        i += 1
    return ''.join(out) + ('\033[0m' if has else '') + ('…' if not ASCII else '>')


def wrap(s, w, indent=4, maxl=6):
    """긴 설명 줄을 여러 줄로 접기 (색 유지)"""
    s = s.replace('\t', '    ')
    if vwidth(s) <= w:
        return [s]
    lines, cur, buf, sgr, i, sp = [], 0, [], '', 0, None
    while i < len(s):
        m = ANSI_RX.match(s, i)
        if m:
            buf.append(m.group())
            sgr = '' if m.group() == '\033[0m' else m.group()
            i = m.end()
            continue
        cw = _cw(s[i])
        if cur + cw > w:
            rest, rcur = [], 0
            if sp and sp[1] > indent + 8 and cur - sp[1] < w // 2:
                rest, rcur = buf[sp[0] + 1:], cur - sp[1] - 1
                buf = buf[:sp[0]]
            lines.append(''.join(buf) + ('\033[0m' if sgr else ''))
            if len(lines) >= maxl:
                lines[-1] = fit(lines[-1], w - 1) + '…'
                return lines
            buf, cur, sp = [' ' * indent + (sp[2] if sp else sgr)] + rest, indent + rcur, None
        if s[i] == ' ':
            sp = (len(buf), cur, sgr)
        buf.append(s[i])
        cur += cw
        i += 1
    lines.append(''.join(buf))
    return lines


# ── 키 입력 ──────────────────────────────────────────────────────────
class Term:
    def __init__(self):
        self.win = os.name == 'nt'
        self.raw = False
        self.old = None
        self.fd = None
        if not sys.stdin.isatty():
            return
        if self.win:
            try:
                import msvcrt
                self.msvcrt = msvcrt
                self.raw = True
            except ImportError:
                pass
        else:
            try:
                import termios
                import tty
                self.termios, self.tty = termios, tty
                self.fd = sys.stdin.fileno()
                self.old = termios.tcgetattr(self.fd)
                self.raw = True
            except Exception:
                self.raw = False

    def enter(self):
        if self.raw and not self.win:
            self.tty.setcbreak(self.fd)
        if VT:
            sys.stdout.write('\033[?1049h\033[?25l')
            sys.stdout.flush()

    def leave(self):
        if VT:
            sys.stdout.write('\033[?25h\033[?1049l')
            sys.stdout.flush()
        if self.raw and not self.win and self.old is not None:
            try:
                self.termios.tcsetattr(self.fd, self.termios.TCSADRAIN, self.old)
            except Exception:
                pass

    def getkey(self, literal=False):
        """literal=True: 한글 자모를 영문 키로 바꾸지 않음 (글자 입력용)"""
        try:
            if not self.raw:
                line = input()
                return 'ENTER' if line == '' else (HANGUL_KEYS.get(line, line) if len(line) == 1 else line)
            if self.win:
                ch = self.msvcrt.getwch()
                if ch in ('\x00', '\xe0'):
                    c2 = self.msvcrt.getwch()
                    return {'H': 'UP', 'P': 'DOWN', 'K': 'LEFT', 'M': 'RIGHT', 'I': 'PGUP', 'Q': 'PGDN',
                            'G': 'HOME', 'O': 'END', 'S': 'DEL'}.get(c2, '')
                return self._name(ch, literal)
            import select
            b = os.read(self.fd, 1)
            if not b:
                return 'ESC'
            if b == b'\x1b':
                seq = b''
                while select.select([self.fd], [], [], 0.03)[0]:
                    seq += os.read(self.fd, 1)
                    if seq in (b'O', b'['):
                        continue
                    if seq[-1:].isalpha() or seq[-1:] == b'~':
                        break
                return {b'[A': 'UP', b'[B': 'DOWN', b'[C': 'RIGHT', b'[D': 'LEFT', b'OA': 'UP', b'OB': 'DOWN',
                        b'OC': 'RIGHT', b'OD': 'LEFT', b'[5~': 'PGUP', b'[6~': 'PGDN', b'[H': 'HOME', b'[F': 'END',
                        b'[1~': 'HOME', b'[4~': 'END', b'OH': 'HOME', b'OF': 'END', b'[3~': 'DEL'}.get(seq, 'ESC' if not seq else '')
            n = 1
            if b[0] >= 0xf0:
                n = 4
            elif b[0] >= 0xe0:
                n = 3
            elif b[0] >= 0xc0:
                n = 2
            while len(b) < n:
                b += os.read(self.fd, 1)
            return self._name(b.decode('utf-8', 'replace'), literal)
        except KeyboardInterrupt:
            return 'ESC'
        except EOFError:
            raise SystemExit(0)

    def _name(self, ch, literal=False):
        k = {'\r': 'ENTER', '\n': 'ENTER', '\x1b': 'ESC', '\x08': 'BS', '\x7f': 'BS', '\t': 'TAB',
             ' ': 'SPACE', '\x03': 'ESC', '\x15': 'KILL'}.get(ch)
        if k:
            return k
        return ch if literal else HANGUL_KEYS.get(ch, ch)

    def line_input(self, prompt, init=''):
        """글자 여러 개 입력(필터·검색·경로). Enter=확인, Esc=취소(None), Backspace=지우기"""
        if not self.raw:
            sys.stdout.write('\n' + tx(prompt))
            sys.stdout.flush()
            try:
                return input().strip()
            except (KeyboardInterrupt, EOFError):
                return None
        buf = list(init)
        res = None
        try:
            while True:
                w, rows = term_size()
                p = tx(prompt)
                shown = ''.join(buf)
                room = max(5, w - 2 - vwidth(p))
                while vwidth(shown) > room:
                    shown = shown[1:]
                if VT:
                    sys.stdout.write('\033[%d;1H\033[K\033[7m%s\033[0m%s\033[?25h' % (rows, p, shown))
                else:
                    sys.stdout.write('\r' + ' ' * (w - 2) + '\r' + p + shown)
                sys.stdout.flush()
                k = self.getkey(literal=True)
                if k == 'ENTER':
                    res = ''.join(buf).strip()
                    break
                if k == 'ESC':
                    break
                if k == 'BS':
                    if buf:
                        buf.pop()
                elif k == 'KILL':
                    buf = []
                elif k == 'SPACE':
                    buf.append(' ')
                elif len(k) == 1 and k.isprintable():
                    buf.append(k)
        finally:
            if VT:
                sys.stdout.write('\033[?25l')
                sys.stdout.flush()
        return res


TERM = None


def enable_vt():
    if not sys.stdout.isatty():
        return False
    if os.name != 'nt':
        return True
    try:
        import ctypes
        k = ctypes.windll.kernel32
        h = k.GetStdHandle(-11)
        m = ctypes.c_uint32()
        if not k.GetConsoleMode(h, ctypes.byref(m)):
            return False
        return bool(k.SetConsoleMode(h, m.value | 0x0004))
    except Exception:
        return False


def draw(lines):
    w, h = term_size()
    lines = (lines + [''] * h)[:h]
    if VT:
        out = '\033[H' + '\n'.join(l + '\033[K' for l in lines[:-1]) + '\n' + lines[-1] + '\033[K\033[J'
    else:
        os.system('cls' if os.name == 'nt' else 'clear')
        out = '\n'.join(fit(strip_ansi(l), w - 2) for l in lines)
    sys.stdout.write(out)
    sys.stdout.flush()


def keybar(hints, w):
    """[(키, 설명)] → 화면 아래 단축키 줄(최대 2줄). 폭이 모자라면 덜 중요한 것부터 빼고 ?·q 는 항상 남김"""
    mk = lambda k, d: (col(' %s ' % tx(k), 'rev') if (COLOR and VT) else '[%s]' % tx(k)) + ' ' + tx(d)
    must = [h for h in hints if h[0] in ('?', 'q', 'y', 'n')]
    rest = [h for h in hints if h not in must]

    def pack(items):
        lines, cur = [''], 0
        for it in items:
            iw = vwidth(it) + 2
            if cur + iw > w - 1 and lines[-1]:
                if len(lines) == 2:
                    return None
                lines.append('')
                cur = 0
            lines[-1] += it + '  '
            cur += iw
        return lines
    while True:
        got = pack([mk(k, d) for k, d in rest + must])
        if got is not None or not rest:
            return got or pack([mk(k, d) for k, d in must]) or ['']
        rest.pop()


# ── 화면 기본 틀: 위(고정) / 가운데(스크롤) / 아래(상태 + 단축키) ──────
class Screen:
    nav_hint = True

    def __init__(self):
        self.top = 0
        self.cur = None
        self.mark = None
        self.msg = ''
        self.last_find = ''
        self._rows = None
        self._disp = None
        self._w = None
        self.follow = False       # True = 커서 줄이 항상 화면 안에 보이도록 따라감
        self._bh = None
        self._fpos = None

    def header(self):
        return []

    def build(self):
        return []

    def hints(self):
        return []

    def on_act(self, act):
        return None

    def on_key(self, k):
        return None

    def status(self):
        return ''

    def refresh(self):
        self._rows = None

    def rows(self):
        if self._rows is None:
            self._rows = [r if isinstance(r, tuple) else (r, None) for r in self.build()]
            self._disp = None
            sel = [i for i, (t, a) in enumerate(self._rows) if a is not None]
            if self.cur is None or self.cur >= len(self._rows) or self._rows[self.cur][1] is None:
                self.cur = sel[0] if sel else None
        return self._rows

    wrap_all = False

    def tabular(self, t):
        """표 모양 줄(칸 사이가 공백 여러 개)은 접으면 칸이 어긋나므로 잘라서 한 줄로"""
        p = strip_ansi(t)
        return not self.wrap_all and '←' not in p and len(re.findall(r'\S {2,}(?=\S)', p)) >= 3

    def disp(self, w):
        rows = self.rows()
        if self._disp is None or self._w != w:
            d = []
            for i, (t, a) in enumerate(rows):
                if a is not None:
                    d.append((t, i))          # 고를 수 있는 줄은 그릴 때 자름 (큰 파일에서 빠르게)
                    continue
                t = tx(t)
                if vwidth(t) <= w - 1 or self.tabular(t):
                    d.append((t, i))
                else:
                    d.extend((x, i) for x in wrap(t, w - 1))
            self._disp, self._w = d, w
            self._first = {}
            for k, (_, i) in enumerate(d):
                self._first.setdefault(i, k)
        return self._disp

    def selectable(self):
        return [i for i, (t, a) in enumerate(self.rows()) if a is not None]

    def render(self):
        w, h = term_size()
        head = [fit(tx(x), w - 1) for x in self.header()]
        hints = self.hints()
        if self.nav_hint:
            hints = hints + [('/', '찾기'), ('?', '도움말'), ('q', '뒤로')]
        bar = keybar(hints, w)
        body_h = max(3, h - len(head) - len(bar) - 1)
        self._bh = body_h
        d = self.disp(w)
        if self.cur is not None and self.follow:
            k = self._first.get(self.cur, 0)
            if k < self.top:
                self.top = k
            elif k >= self.top + body_h:
                self.top = k - body_h + 1
        self.top = max(0, min(self.top, max(0, len(d) - body_h)))
        body = []
        for t, i in d[self.top:self.top + body_h]:
            t = fit(tx(t), w - 1)
            if i == self.cur or i == self.mark:
                plain = strip_ansi(t)
                body.append(('\033[7m' + plain + ' ' * max(0, w - 1 - vwidth(plain)) + '\033[0m') if VT else '>' + plain)
            else:
                body.append(t)
        body += [''] * (body_h - len(body))
        pos = '%d-%d / %d줄' % (self.top + 1, min(len(d), self.top + body_h), len(d)) if len(d) > body_h else '%d줄' % len(d)
        st = self.status()
        left = col(self.msg, 'yellow', 'bold') if self.msg else st
        stline = fit(tx(left), max(10, w - 1 - vwidth(pos) - 2))
        stline = stline + ' ' * max(1, w - 1 - vwidth(stline) - vwidth(pos)) + col(pos, 'cyan')
        draw(head + body + [stline] + bar)

    def page_h(self):
        if self._bh:
            return self._bh
        w, h = term_size()
        return max(3, h - len(self.header()) - 4)

    def cur_visible(self):
        if self.cur is None:
            return False
        self.disp(term_size()[0])
        k = self._first.get(self.cur, -1)
        return self.top <= k < self.top + self.page_h()

    def visible_sel(self):
        d = self.disp(term_size()[0])
        rows = self.rows()
        out = []
        for _, i in d[self.top:self.top + self.page_h()]:
            if rows[i][1] is not None and (not out or out[-1] != i):
                out.append(i)
        return out

    def clamp_top(self):
        d = self.disp(term_size()[0])
        self.top = max(0, min(self.top, max(0, len(d) - self.page_h())))

    def move(self, step):
        """↑↓: 다음 고를 줄이 가까우면 커서를 옮기고, 멀면(사이에 설명 줄) 한 줄씩 스크롤"""
        self.mark = None
        sel = self.selectable()
        if not sel:
            self.top = max(0, self.top + step)
            self.clamp_top()
            return
        bh = self.page_h()
        if not self.cur_visible():
            vis = self.visible_sel()
            if vis:
                self.cur = vis[0] if step > 0 else vis[-1]
                self.follow = True
            else:
                self.top = max(0, self.top + step)
                self.clamp_top()
                self.follow = False
            return
        pos = sel.index(self.cur)
        np_ = pos + step
        if 0 <= np_ < len(sel):
            k = self._first.get(sel[np_], 0)
            if self.top - 1 <= k <= self.top + bh:
                self.cur = sel[np_]
                self.follow = True
                return
        old = self.top
        self.top = max(0, self.top + step)
        self.clamp_top()
        self.follow = False
        if self.top == old and 0 <= np_ < len(sel):
            self.cur = sel[np_]
            self.follow = True

    def page(self, step):
        self.mark = None
        self.top = max(0, self.top + step * self.page_h())
        self.clamp_top()
        self.follow = False
        vis = self.visible_sel()
        if vis and not self.cur_visible():
            self.cur = vis[0]

    def go_home(self):
        self.mark = None
        self.top, self.follow = 0, False
        vis = self.visible_sel()
        if vis:
            self.cur = vis[0]

    def go_end(self):
        self.mark = None
        self.top, self.follow = 10 ** 9, False
        self.clamp_top()
        vis = self.visible_sel()
        if vis:
            self.cur = vis[-1]

    def find(self, again=False):
        if not again or not self.last_find:
            t = TERM.line_input('찾을 글자 (Enter=확인, Esc=취소): ')
            if not t:
                return
            self.last_find = t
            self._fpos = None
        q = self.last_find.lower()
        rows = self.rows()
        if again and self._fpos is not None:
            start = self._fpos + 1
        else:
            start = self._row_at_top()
            if self.cur_visible():
                start = min(start, self.cur)
        hits = [i for i, (t, a) in enumerate(rows) if q in strip_ansi(t).lower()]
        if not hits:
            self.msg = '"%s" 없음' % self.last_find
            return
        nxt = next((i for i in hits if i >= start), hits[0])
        self._fpos = nxt
        if rows[nxt][1] is not None:
            self.cur, self.mark = nxt, None
        else:
            self.mark = nxt
        self.disp(term_size()[0])
        k = self._first.get(nxt, 0)
        if not (self.top <= k < self.top + self.page_h()):
            self.top = max(0, k - self.page_h() // 3)
            self.clamp_top()
        self.follow = False
        if rows[nxt][1] is None and not self.cur_visible():
            vis = self.visible_sel()
            if vis:
                self.cur = vis[0]
        self.msg = '"%s" %d/%d번째 일치  (n 다음 일치)' % (self.last_find, hits.index(nxt) + 1, len(hits))

    def _row_at_top(self):
        d = self.disp(term_size()[0])
        return d[self.top][1] if d and self.top < len(d) else 0

    def run(self):
        while True:
            try:
                self.render()
            except Exception as e:
                draw([col('화면 그리기 오류: %s: %s' % (type(e).__name__, e), 'red'), '', 'q = 뒤로'])
                if TERM.getkey() in ('q', 'Q', 'ESC', 'BS'):
                    return 'back'
                continue
            k = TERM.getkey()
            self.msg = ''
            try:
                r = self.on_key(k)
                if r in ('back', 'quit', 'folder', 'reload'):
                    return r
                if r is not None:
                    continue
                if k in ('UP', 'k'):
                    self.move(-1)
                elif k in ('DOWN', 'j'):
                    self.move(1)
                elif k in ('PGDN', 'SPACE'):
                    self.page(1)
                elif k == 'PGUP':
                    self.page(-1)
                elif k == 'HOME':
                    self.go_home()
                elif k == 'END':
                    self.go_end()
                elif k == '/':
                    self.find()
                elif k == 'N' or (k == 'n' and self.last_find):
                    self.find(again=True)
                elif k == '?':
                    HelpScreen().run()
                elif k == 'ENTER':
                    if self.cur is not None and self.cur_visible():
                        r = self.on_act(self.rows()[self.cur][1])
                        if r == 'quit':
                            return r
                    elif self.visible_sel():
                        self.cur, self.follow = self.visible_sel()[0], True
                    else:
                        self.page(1)
                elif k in ('q', 'Q', 'ESC', 'BS'):
                    return 'back'
                elif k:
                    self.msg = '모르는 키: %s  — 아래 단축키를 보세요 (한/영 키가 한글이면 영문으로)' % k
            except KeyboardInterrupt:
                self.msg = '중단했습니다'
            except Exception as e:
                self.msg = '오류: %s: %s' % (type(e).__name__, e)


class TextScreen(Screen):
    """글만 보여주는 화면 (도움말, 추출 파일 내용 등)"""

    def __init__(self, title, lines, extra_hints=None, keys=None):
        super().__init__()
        self.title, self.lines, self.extra, self.keys = title, lines, extra_hints or [], keys or {}

    def header(self):
        return [col(self.title, 'bold')]

    def build(self):
        return self.lines

    def hints(self):
        return [('↑↓', '스크롤'), ('Space', '다음 쪽')] + self.extra

    def on_key(self, k):
        if k in self.keys:
            self.msg = self.keys[k]() or ''
            return True
        return None


class ListScreen(Screen):
    """줄을 골라 Enter로 여는 목록 (여러 파일 결과, 세션 패킷 등)"""

    def __init__(self, title, rows, results, colhead=None, extra=None):
        super().__init__()
        self.title, self._src, self.results, self.colhead, self.extra = title, rows, results, colhead, extra or []

    def header(self):
        return [col(self.title, 'bold')] + ([col(self.colhead, 'dim')] if self.colhead else [])

    def build(self):
        return self._src

    def hints(self):
        return [('↑↓', '선택'), ('Enter', '패킷 해부'), ('PgUp/PgDn', '쪽 이동')] + self.extra

    def on_act(self, act):
        return open_act(act, self.results, self.rows())


def ask_filter(screen, results, only):
    """필터 입력 → (필터 글, 결과 줄). 문법 오류면 고쳐서 다시 입력, ? = 필터 도움말"""
    t, err = '', ''
    while True:
        screen.render()
        if err and VT:
            sys.stdout.write('\033[%d;1H\033[K%s' % (term_size()[1] - 1, col(fit(tx('필터 오류: ' + err), term_size()[0] - 1), 'red', 'bold')))
        t = TERM.line_input('Wireshark 필터 (? 도움말, Esc 취소): ', t)
        if not t:
            return None
        if t == '?':
            TextScreen('Wireshark 필터 도움말', FILTER_HELP).run()
            t, err = '', ''
            continue
        try:
            return t, filter_rows(results, only, t)
        except FilterError as e:
            err = str(e)
            if not VT:
                print('\n필터 오류: ' + err)


def open_act(act, results, rows=None):
    """목록 줄에 달린 동작 실행: ('f', 파일번호, 프레임) / ('file', 파일번호)"""
    if not act:
        return None
    if act[0] == 'f':
        ri, fr = act[1], act[2]
        if not fr or not 1 <= fr <= len(results[ri]['info']['pkts']):
            return None
        nav = list(dict.fromkeys(a[2] for t, a in (rows or []) if a and a[0] == 'f' and a[1] == ri and a[2]))
        DissectScreen(results, ri, fr, nav or None).run()
    elif act[0] == 'file':
        return FileScreen(results, act[1]).run()
    return None


# ── 목록 화면 (첫 화면) ────────────────────────────────────────────────
class MainScreen(Screen):
    nav_hint = False

    def __init__(self, results, folder):
        super().__init__()
        self.results, self.folder, self.numbuf = results, folder, ''

    def header(self):
        w = term_size()[0]
        t = render_table(self.results, numbered=True, width=w)
        return [col('PCAP 분석기', 'cyan', 'bold') + '  %s  (%d개 파일)' % (self.folder, len(self.results)), t[0]]

    def build(self):
        w = term_size()[0]
        t = render_table(self.results, numbered=True, width=w)
        return [(line, ('file', i)) for i, line in enumerate(t[2:])]

    def hints(self):
        return [('↑↓', '이동'), ('Enter', '열기'), ('숫자', '바로 열기'), ('/', '찾기'), ('d', '전체 답안'),
                ('c', '계정·비번'), ('f', 'Wireshark 필터'), ('s', '글자 검색'), ('w', 'txt 저장'),
                ('o', '폴더 변경'), ('?', '도움말'), ('q', '종료')]

    def status(self):
        if self.numbuf:
            return '번호 입력 중: %s  (숫자 더 입력 또는 Enter로 열기)' % self.numbuf
        if self.cur is not None and self.cur < len(self.results):
            r = self.results[self.cur]
            t, sm = row_of(r)
            return col('▶ %s: ' % os.path.basename(r['path']), 'bold') + sm
        return ''

    def on_act(self, act):
        self.numbuf = ''
        r = open_act(act, self.results)
        self.refresh()
        return r

    def open_num(self, n):
        self.numbuf = ''
        if 1 <= n <= len(self.results):
            self.cur, self.follow = n - 1, True
            self.render()
            FileScreen(self.results, n - 1).run()
            self.refresh()
        else:
            self.msg = '%d번 파일은 없습니다 (1~%d)' % (n, len(self.results))

    def on_key(self, k):
        R = self.results
        if k.isdigit() and k.isascii():
            if len(k) > 1:
                self.open_num(int(k))
                return True
            self.numbuf += k
            n = int(self.numbuf)
            longer = [i for i in range(1, len(R) + 1) if str(i).startswith(self.numbuf) and i != n]
            if not longer:
                self.open_num(n)
            return True
        if k in ('ENTER', 'RIGHT') and self.numbuf:
            self.open_num(int(self.numbuf))
            return True
        if k == 'BS':
            self.numbuf = self.numbuf[:-1]
            return True
        self.numbuf = ''
        if k in ('q', 'Q', 'ESC'):
            return 'quit'
        if k == 'd':
            rows = []
            for ri, r in enumerate(R):
                for x in answer_lines(r):
                    if isinstance(x, tuple):
                        rows.append((x[0], ('f', ri, x[1])))
                    else:
                        rows.append(x)
            ListScreen('전체 답안 — 근거 패킷 줄에서 Enter = 패킷 해부', rows, R).run()
        elif k == 'c':
            ListScreen('계정 / 비밀번호 모아보기 — 줄에서 Enter = 그 패킷', creds_rows(R), R).run()
        elif k == 'f':
            got = ask_filter(self, R, None)
            if got:
                ListScreen('필터: %s' % got[0], got[1], R, col_files()).run()
        elif k == 's':
            t = TERM.line_input('검색할 글자 (예: PASS, 230, admin): ')
            if t:
                ListScreen('글자 검색: %s' % t, grep_rows(R, None, t), R, col_files()).run()
        elif k == 'w':
            self.msg = save_report(R, self.folder)
        elif k == 'o':
            return 'folder'
        elif k == 'r':
            return 'reload'
        else:
            return None
        return True


def col_files():
    return '  파일      ' + PKT_HEADER


# ── 파일 화면: 탭으로 나눔 ─────────────────────────────────────────────
TABS = ['답안·보고서', '중요 패킷', '전체 패킷', 'TCP 대화', '통계', '파일 추출']
TABS_SHORT = {'답안·보고서': '답안', '중요 패킷': '중요', '전체 패킷': '전체', 'TCP 대화': 'TCP', '파일 추출': '추출'}


class FileScreen(Screen):
    def __init__(self, results, i, tab=0):
        super().__init__()
        self.results, self.i, self.tab = results, i, tab
        self.extra_tabs = []          # [(이름, rows)]
        self.show_ack = False
        self.show_bg = False
        self.objs = None

    @property
    def r(self):
        return self.results[self.i]

    def tabnames(self):
        return TABS + [x[0] for x in self.extra_tabs]

    def header(self):
        r = self.r
        name = os.path.basename(r['path'])
        verdict = ' + '.join(x.title for x in main_findings(r['fs'])[:2]) if r['fs'] else r['err']
        prio = main_findings(r['fs'])[0].prio if r['fs'] else 0
        h1 = col('■ %s' % name, 'bold') + '  (%d/%d)  ' % (self.i + 1, len(self.results)) + col(verdict or '', *title_color(prio))
        names = self.tabnames()
        if sum(vwidth(' %d %s ' % (k, n)) + 1 for k, n in enumerate(names, 1)) > term_size()[0] - 1:
            names = [TABS_SHORT.get(n, n) for n in names]      # 좁은 창: 짧은 탭 이름
        tabs = []
        for k, n in enumerate(names, 1):
            lab = ' %d %s ' % (k, n)
            tabs.append(col(lab, 'rev', 'bold') if (k - 1 == self.tab and VT and COLOR) else
                        ('[%s]' % lab.strip() if k - 1 == self.tab else lab))
        h = [h1, '│'.join(tabs)]
        if self.tab in (1, 2) or self.tab >= len(TABS):
            h.append(col(PKT_HEADER, 'dim'))
        return h

    def build(self):
        r = self.r
        if r['err']:
            return ['분석 실패: ' + r['err']]
        ri = self.i
        t = self.tab
        if t == 0:
            rows = []
            for x in answer_lines(r):
                rows.append((x[0], ('f', ri, x[1])) if isinstance(x, tuple) else x)
            rows += ['', col('─' * 30 + ' 상세 보고서 ' + '─' * 30, 'dim')] + render_report(r['info'], r['fs'], r['verbose'])[3:]
            return rows
        if t == 1:
            return key_rows(r, ri, self.show_ack)
        if t == 2:
            return all_rows(r, ri, self.show_bg)
        if t == 3:
            return follow_rows(r, ri)
        if t == 4:
            return [(x[0], ('f', ri, x[1])) if isinstance(x, tuple) else x for x in stats_lines(r)]
        if t == 5:
            if self.objs is None:
                self.objs = extract_objects(r)
            return obj_rows(r, self.objs)
        return self.extra_tabs[t - len(TABS)][1]

    def hints(self):
        if self.r['err']:
            return [('[ ]', '이전/다음 파일')]
        h = [('←→', '탭'), ('↑↓', '선택'), ('Enter', '패킷 해부' if self.tab != 5 else '내용 보기')]
        if self.tab == 1:
            h.append(('a', 'ACK %s' % ('숨기기' if self.show_ack else '보이기')))
        if self.tab == 2:
            h.append(('b', '배경 트래픽 %s' % ('숨기기' if self.show_bg else '보이기')))
        if self.tab == 5:
            h.append(('e', '모두 저장'))
        h += [('f', '필터'), ('s', '글자 검색'), ('#', '번호로 패킷'), ('[ ]', '이전/다음 파일'), ('v', '자세히')]
        return h

    def status(self):
        if self.r['err']:
            return ''
        return '탭: 숫자 1~%d 또는 ←→   |   패킷 줄에서 Enter = 바이트 해부' % len(self.tabnames())

    def set_tab(self, t):
        self.tab = t % len(self.tabnames())
        self.top, self.cur, self.mark = 0, None, None
        self.refresh()

    def on_act(self, act):
        if act and act[0] == 'obj':
            ob = self.objs[act[1]]
            TextScreen('파일 내용: %s' % ob['name'], obj_text(ob), [('e', '이 파일 저장')],
                       {'e': lambda: save_one(self.r, ob)}).run()
            return None
        return open_act(act, self.results, self.rows())

    def on_key(self, k):
        r = self.r
        if k in ('RIGHT', 'TAB', 'l'):
            self.set_tab(self.tab + 1)
        elif k in ('LEFT', 'h'):
            self.set_tab(self.tab - 1)
        elif k.isdigit() and k.isascii() and 1 <= int(k) <= len(self.tabnames()):
            self.set_tab(int(k) - 1)
        elif k in (']', '>', '.'):
            self.switch_file(1)
        elif k in ('[', '<', ','):
            self.switch_file(-1)
        elif r['err']:
            return None
        elif k == 'a' and self.tab == 1:
            self.show_ack = not self.show_ack
            self.refresh()
        elif k == 'b' and self.tab == 2:
            self.show_bg = not self.show_bg
            self.refresh()
        elif k == 'e' and self.tab == 5:
            try:
                self.msg = ('저장 완료 → %s  (%d개)' % (short_path(save_objects(r, self.objs)), len(self.objs))) if self.objs else '저장할 파일이 없습니다'
            except OSError as e:
                self.msg = '저장 실패: %s' % e
        elif k == 'f':
            got = ask_filter(self, self.results, self.i)
            if got:
                self.add_tab('필터', got[1], got[0])
        elif k == 's':
            t = TERM.line_input('검색할 글자 (re: 로 시작하면 정규식): ')
            if t:
                self.add_tab('검색', grep_rows(self.results, self.i, t), t)
        elif k in ('#', 'g'):
            t = TERM.line_input('프레임 번호 (1~%d): ' % len(r['info']['pkts']))
            if t and t.lstrip('#').isdigit():
                n = int(t.lstrip('#'))
                if 1 <= n <= len(r['info']['pkts']):
                    DissectScreen(self.results, self.i, n).run()
                else:
                    self.msg = '없는 번호입니다 (1~%d)' % len(r['info']['pkts'])
            elif t:
                self.msg = '숫자만 입력하세요'
        elif k == 'v':
            v = not r['verbose']
            self.msg = '다시 분석 중…'
            self.render()
            info, fs = analyze_file(r['path'], v)
            r.update(info=info, fs=fs, verbose=v)
            self.msg = '자세히 %s' % ('켬' if v else '끔')
            self.refresh()
        else:
            return None
        return True

    def add_tab(self, name, rows, text):
        self.extra_tabs = [x for x in self.extra_tabs if x[0] != name] + [(name, rows, text)]
        self.set_tab(len(TABS) + [x[0] for x in self.extra_tabs].index(name))

    def switch_file(self, step):
        """이전/다음 파일 — 같은 탭 유지, 필터·검색 탭은 새 파일에 다시 적용"""
        self.i = (self.i + 1 * step) % len(self.results)
        self.objs = None
        new = []
        if not self.r['err']:
            for name, _, text in self.extra_tabs:
                try:
                    rows = filter_rows(self.results, self.i, text) if name == '필터' else grep_rows(self.results, self.i, text)
                except FilterError:
                    continue
                new.append((name, rows, text))
        self.extra_tabs = new
        self.set_tab(min(self.tab, len(self.tabnames()) - 1))


def _pure_ack(p):
    return p.proto == 6 and not p.data and p.flags == 0x10


def key_rows(r, ri, show_ack):
    pk, base = r['info']['pkts'], r['info']['start']
    rows = []
    for f in r['fs']:
        rows.append(col('▣ ' + f.title, *title_color(f.prio)) + '  ' + f.summary)
        for flt, desc in f.wf:
            rows.append('   Wireshark 필터: ' + col(flt, 'cyan') + (col('  ← ' + desc, 'dim') if desc else ''))
        for label, idxs in (f.groups or [(None, f.ev)]):
            ev = sorted(set(i for i in idxs if 1 <= i <= len(pk)))
            if not ev:
                continue
            shown = ev if show_ack else [i for i in ev if not _pure_ack(pk[i - 1])]
            if label:
                rows.append(col('  ▸ ' + label, 'magenta'))
            for i in shown[:300]:
                rows.append((hl(pkt_line(pk[i - 1], base)), ('f', ri, i)))
            if len(ev) > len(shown):
                rows.append(col('     (데이터 없는 ACK %d개 숨김 — a 키로 보기)' % (len(ev) - len(shown)), 'dim'))
        rows.append('')
    return rows


def all_rows(r, ri, show_bg):
    key = ('_all', show_bg)
    if r.get(key) is None:
        pk, base = r['info']['pkts'], r['info']['start']
        rows, hidden = [], 0
        for p in pk:
            n = noise_name(p)
            if n and not show_bg:
                hidden += 1
                continue
            line = pkt_line(p, base)
            rows.append((col(line + '  (배경)', 'dim') if n else hl(line), ('f', ri, p.idx)))
        if hidden:
            rows.insert(0, col('  라우터 배경 트래픽(OSPF/CDP/SLARP 등) %d개 숨김 — b 키로 보기' % hidden, 'dim'))
        r[key] = rows
    return r[key]


def follow_rows(r, ri):
    conns = [c for c in r['info']['conns'] if c.bytes[0] or c.bytes[1]]
    rows = [col('C> = 클라이언트가 보낸 것,  S< = 서버가 보낸 것.  줄에서 Enter = 그 패킷 해부', 'dim'), '']
    if not conns:
        return rows + ['(데이터가 있는 TCP 세션 없음)']
    for n, c in enumerate(conns, 1):
        ok = (c.service == 'FTP' and re.search(rb'(^|\n)230 ', c.stream(1))) or \
             (c.service in ('POP3', 'IMAP', 'SMTP', 'Telnet') and re.search(rb'(?i)\+OK.*logged|OK LOGIN|235 |last login', c.stream(1)))
        rows.append(col('━━ 세션 %d: %s → %s [%s] %s  (클라 %dB / 서버 %dB)%s' % (
            n, ep(c.cli), ep(c.srv), c.service or identify_service(c), fmt_ts(c.start, False), c.bytes[0], c.bytes[1],
            '  ★ 로그인 성공' if ok else ''), 'green' if ok else 'cyan'))
        shown = 0
        for ts, d, data, ix in c.timeline():
            if shown >= 6000:
                rows.append(col('   … (이 세션 나머지 생략)', 'dim'))
                break
            if c.service in ('SSH', 'TLS(HTTPS 등)') and not data.startswith(b'SSH-'):
                txt = '(암호화 데이터 %dB)' % len(data)
            else:
                txt = data.decode('utf-8', 'replace').replace('\r', '')
                txt = ''.join(ch if ch.isprintable() or ch in '\n' else '.' for ch in txt)
            for k, l in enumerate(txt.rstrip('\n').split('\n')[:60]):
                tag = col(' C> ', 'blue') if d == 0 else col(' S< ', 'red')
                rows.append((tag + l + col('  #%d' % ix, 'dim'), ('f', ri, ix)))
            shown += len(data)
        rows.append('')
    return rows


def obj_rows(r, objs):
    rows = [col('Wireshark: File > Export Objects 와 같음.  줄에서 Enter = 내용 보기,  e = 모두 저장', 'dim'), '']
    if not objs:
        return rows + ['추출할 파일이 없습니다 (HTTP 응답 본문 / FTP 데이터 / SMTP 메일 본문 없음).']
    for k, ob in enumerate(objs):
        rows.append(('%3d) %s  %s바이트  [%s]  #%d  — %s' % (k + 1, ob['name'], format(len(ob['data']), ','),
                                                         guess_type(ob['data']), ob['frame'], ob['desc']), ('obj', k)))
    return rows


def obj_text(ob):
    t = guess_type(ob['data'])
    o = ['  ' + ob['desc'], '']
    if t == 'HTML':
        html = ob['data'][:200000].decode('utf-8', 'replace')
        title = re.search(r'(?is)<title[^>]*>(.*?)</title>', html)
        body = re.sub(r'(?is)<(script|style)[^>]*>.*?</\1>', ' ', html)
        body = re.sub(r'(?s)<[^>]+>', '\n', body)
        texts = [re.sub(r'\s+', ' ', x).strip() for x in body.split('\n')]
        texts = [x for x in texts if x][:40]
        o += [col('[요약] 제목: ' + (title.group(1).strip() if title else '(없음)'), 'bold')] + ['  ' + x for x in texts]
        o += ['', col('[원본 HTML]', 'bold')]
    if t in ('텍스트', 'HTML'):
        txt = ob['data'][:20000].decode('utf-8', 'replace').replace('\r', '')
        o += [''.join(ch if ch.isprintable() else '.' for ch in l) for l in txt.split('\n')]
    else:
        for off in range(0, min(len(ob['data']), 4096), 16):
            ch = ob['data'][off:off + 16]
            o.append('  %04x  %-48s  %s' % (off, ' '.join('%02x' % x for x in ch),
                                            ''.join(chr(x) if 32 <= x < 127 else '.' for x in ch)))
    return o


def short_path(p, n=55):
    """긴 경로는 앞을 줄이고 폴더·파일 이름이 보이게"""
    return p if len(p) <= n else '…' + p[-(n - 1):]


def save_dirs(r):
    d = [os.path.dirname(os.path.abspath(r['path'])), os.getcwd(), os.path.join(os.path.expanduser('~'), 'Desktop')]
    try:
        import tempfile
        d.append(tempfile.gettempdir())
    except Exception:
        pass
    return d


def save_one(r, ob):
    err = None
    for d in save_dirs(r):
        p = os.path.join(d, '추출_%s' % _safe_name(ob['name']))
        try:
            with open(p, 'wb') as fp:
                fp.write(ob['data'])
            return '저장 완료 → ' + short_path(p)
        except OSError as e:
            err = e
    return '저장 실패: %s' % err


def filter_rows(results, only, text):
    f = compile_filter(text)
    rows = []
    for ri, r in enumerate(results):
        if r['err'] or (only is not None and ri != only):
            continue
        hits = [p for p in r['info']['pkts'] if f(p)]
        if only is None:
            if not hits:
                continue
            rows.append(col('▣ %s  (%d개)' % (os.path.basename(r['path']), len(hits)), 'bold'))
        for p in hits[:5000]:
            rows.append((('%-8s ' % os.path.basename(r['path']) if only is None else '') + hl(pkt_line(p, r['info']['start'])),
                         ('f', ri, p.idx)))
        if len(hits) > 5000:
            rows.append(col('  … 처음 5000개만 표시 (전체 %d개)' % len(hits), 'dim'))
        if only is not None:
            rows.insert(0, col('필터 %s → %d개 일치' % (text, len(hits)), 'bold'))
    if not rows or (only is not None and len(rows) == 1):
        rows = ['필터 %s → 일치하는 패킷이 없습니다.' % text]
    return rows


def grep_rows(results, only, text):
    """기본은 글자 그대로 검색 (re: 로 시작하면 정규식)"""
    try:
        rx = re.compile(text[3:].encode('utf-8') if text.startswith('re:') else re.escape(text.encode('utf-8')), re.I)
    except (re.error, OverflowError) as e:
        return ['정규식 오류: %s' % e]
    rows = []
    for ri, r in enumerate(results):
        if r['err'] or (only is not None and ri != only):
            continue
        for p in r['info']['pkts']:
            blob = p.data if p.src else p.rawb
            if blob and rx.search(blob):
                rows.append((('%-8s ' % os.path.basename(r['path']) if only is None else '') + hl(pkt_line(p, r['info']['start'])),
                             ('f', ri, p.idx)))
    return [col('"%s" → %d개 패킷' % (text, len(rows)), 'bold')] + rows if rows else ['"%s" 이(가) 들어있는 패킷이 없습니다.' % text]


def creds_rows(results):
    rows = [col('평문 프로토콜(FTP/Telnet/HTTP/POP3/IMAP/SMTP)에서 찾은 로그인 정보. SSH/HTTPS는 암호화라 안 보임', 'dim'), '']
    found = False
    for ri, r in enumerate(results):
        if r['err'] or not r['info'].get('creds'):
            continue
        found = True
        rows.append(col('▣ ' + os.path.basename(r['path']), 'bold'))
        cr = r['info']['creds']
        normal = [c for c in cr if not c['brute']]
        brute = [c for c in cr if c['brute']]
        for c in normal:
            rows.append(('  계정 %s / 비번 %s → %s   (%s %s → %s)' % (c['user'], c['pass'], res_col(c['result']), c['svc'], c['cli'], c['srv']),
                         ('f', ri, c['frames'][-1]) if c['frames'] else None))
        if brute:
            rows.append('  %s 무차별 대입 %d회 (계정 %d개 × 비밀번호 %d개)' % (
                brute[0]['svc'], len(brute), len({c['user'] for c in brute}), len({c['pass'] for c in brute})))
            for c in brute:
                if '성공' in str(c['result']):
                    rows.append(('    ★ 계정 %s / 비번 %s → %s' % (c['user'], c['pass'], res_col(c['result'])),
                                 ('f', ri, c['frames'][-1]) if c['frames'] else None))
        rows.append('')
    return rows if found else rows + ['평문 로그인 정보가 있는 파일이 없습니다.']


def save_report(results, folder):
    text = '\n'.join(plain_full_report(results)) + '\n'
    cands = [os.path.join(folder, 'pcap_분석결과.txt'), os.path.abspath('pcap_분석결과.txt'),
             os.path.join(os.path.expanduser('~'), 'Desktop', 'pcap_분석결과.txt')]
    try:
        import tempfile
        cands.append(os.path.join(tempfile.gettempdir(), 'pcap_분석결과.txt'))
    except Exception:
        pass
    for p in cands:
        try:
            with open(p, 'w', encoding='utf-8') as fp:
                fp.write(text)
            return '저장 완료 → ' + short_path(p)
        except OSError:
            continue
    return '저장 실패: 쓸 수 있는 폴더가 없습니다'


# ── 패킷 해부 화면 ───────────────────────────────────────────────────
class DissectScreen(Screen):
    def __init__(self, results, ri, frame, nav=None):
        super().__init__()
        self.results, self.ri, self.frame = results, ri, frame
        pk = results[ri]['info']['pkts']
        self.nav = nav if nav else [p.idx for p in pk if not noise_name(p)] or [p.idx for p in pk]
        if frame not in self.nav:
            self.nav = sorted(set(self.nav) | {frame})
        self.full_hex = False
        self._legend, self._body = '', []

    @property
    def r(self):
        return self.results[self.ri]

    def conn_of(self, n):
        info = self.r['info']
        if '_connof' not in info:
            m = {}
            for c in info['conns']:
                for i in c.idxs:
                    m.setdefault(i, c)
            info['_connof'] = m
        return info['_connof'].get(n)

    def header(self):
        r, n = self.r, self.frame
        pk = r['info']['pkts']
        if not 1 <= n <= len(pk):
            self._legend, self._body = '', []
            return [col('프레임 번호는 1 ~ %d 사이입니다' % len(pk), 'red')]
        p = pk[n - 1]
        pos = self.nav.index(n) + 1 if n in self.nav else 0
        h = [col('■ %s  프레임 #%d' % (os.path.basename(r['path']), n), 'bold') + '   (이 목록에서 %d/%d번째, %d바이트)' % (
            pos, len(self.nav), p.flen), col(PKT_HEADER, 'dim'), hl(pkt_line(p, r['info']['start']))]
        c = self.conn_of(n)
        if c:
            h.append(col('TCP 세션 %s ↔ %s [%s] — 이 세션 패킷 %d개 중 %d번째' % (
                ep(c.cli), ep(c.srv), c.service or '-', len(c.idxs), c.idxs.index(n) + 1), 'cyan'))
        self._legend, self._body = dissect_lines(r, n, parts=True, full_hex=self.full_hex)
        h.append(self._legend)
        return h

    def build(self):
        self.header()
        return self._body

    def hints(self):
        h = [('←→', '이전/다음 패킷'), ('↑↓', '스크롤'), ('x', '16진수 %s' % ('줄이기' if self.full_hex else '전체'))]
        if self.conn_of(self.frame):
            h.append(('t', '이 세션 패킷만'))
        return h + [('#', '번호로 이동')]

    def status(self):
        return '색 = 계층 (위 범례), 같은 색 안에서 진하게/밝게가 바뀌는 곳 = 필드 경계'

    def go(self, n):
        self.frame = n
        self.top, self.cur, self.mark = 0, None, None
        self.refresh()

    def on_key(self, k):
        if k in ('RIGHT', 'l', 'n') and not (k == 'n' and self.last_find):
            i = self.nav.index(self.frame) if self.frame in self.nav else 0
            if i + 1 < len(self.nav):
                self.go(self.nav[i + 1])
            else:
                self.msg = '마지막 패킷입니다'
        elif k in ('LEFT', 'h', 'p'):
            i = self.nav.index(self.frame) if self.frame in self.nav else 0
            if i > 0:
                self.go(self.nav[i - 1])
            else:
                self.msg = '첫 패킷입니다'
        elif k == 'x':
            self.full_hex = not self.full_hex
            self.refresh()
        elif k == 't':
            c = self.conn_of(self.frame)
            if c:
                base = self.r['info']['start']
                pk = self.r['info']['pkts']
                rows = [(hl(pkt_line(pk[i - 1], base)), ('f', self.ri, i)) for i in c.idxs]
                ls = ListScreen('세션 %s ↔ %s 의 패킷 %d개' % (ep(c.cli), ep(c.srv), len(c.idxs)), rows, self.results, PKT_HEADER)
                ls.cur, ls.follow = c.idxs.index(self.frame), True
                ls.run()
        elif k in ('#', 'g'):
            t = TERM.line_input('프레임 번호 (1~%d): ' % len(self.r['info']['pkts']))
            if t and t.lstrip('#').isdigit():
                n = int(t.lstrip('#'))
                if 1 <= n <= len(self.r['info']['pkts']):
                    if n not in self.nav:
                        self.nav = sorted(set(self.nav) | {n})
                    self.go(n)
                else:
                    self.msg = '없는 번호입니다'
        else:
            return None
        return True


class HelpScreen(TextScreen):
    wrap_all = True

    def __init__(self):
        sw = lambda l: ('\033[%sm██\033[0m ' % LAYER_COLOR[l][0] if COLOR else '[%s] ' % LAYER_LETTER[l]) + LAYER_LABEL[l]
        lines = [
            col('[어디서나]', 'bold'),
            '  ↑↓ 한 줄    PgUp/PgDn 또는 Space 한 쪽    Home/End 처음/끝    Enter 고른 줄 열기',
            '  / 찾기 (n 다음 일치)    q 또는 Esc 뒤로    ? 이 도움말',
            '  ※ 키를 눌러도 반응이 없으면 한/영 키가 한글인지 확인',
            '  글자 입력 칸: Enter 확인, Esc 취소, Backspace 지우기 (마우스 오른쪽 클릭으로 붙여넣기)',
            '',
            col('[첫 화면 — 파일 목록]', 'bold'),
            '  ↑↓ + Enter 또는 파일 번호를 바로 입력 → 파일 열기',
            '  d 모든 파일 답안   c 계정·비밀번호   f Wireshark 필터(모든 파일)   s 글자 검색(모든 파일)',
            '  w 결과를 txt로 저장   o 다른 폴더   r 다시 분석   q 종료',
            '',
            col('[파일 화면 — 위쪽 탭]', 'bold'),
            '  ←→ 또는 숫자 1~6 으로 탭 이동:  1 답안·보고서  2 중요 패킷  3 전체 패킷  4 TCP 대화  5 통계  6 파일 추출',
            '  패킷 줄에서 Enter → 그 패킷 해부        [ ] 이전/다음 파일',
            '  f 필터 / s 글자 검색 → 결과가 새 탭으로 열림 (다른 파일로 넘어가도 같은 필터 적용)',
            '  # 프레임 번호로 바로 해부   v 자세히(분석 내용 더 많이)',
            '  2 중요 패킷 탭: a ACK 보이기/숨기기    3 전체 패킷 탭: b 배경 트래픽(OSPF 등) 보이기',
            '  6 파일 추출 탭: Enter 내용 보기, e 모두 저장',
            '',
            col('[패킷 해부 화면]', 'bold'),
            '  ←→ 이전/다음 패킷 (들어온 목록 기준: 중요 패킷에서 왔으면 중요 패킷끼리)',
            '  ↑↓ 스크롤   x 16진수 전체/줄이기   t 같은 TCP 세션 패킷 목록   # 번호로 이동',
            '',
            col('[해부 화면 색]', 'bold')] + ['  ' + sw(l) for l in ('link', 'net', 'tcp', 'udp', 'icmp', 'arp', 'app', 'inner', 'pad')] + [
            '  같은 계층 안에서는 필드마다 진하게/밝게를 번갈아 칠함 → 색이 바뀌는 곳 = 필드 경계',
            '',
            col('[판정 색]', 'bold'),
            '  ' + col('빨강', 'red', 'bold') + ' 공격   ' + col('노랑', 'yellow', 'bold') + ' 정찰(스캔·탐색)   ' +
            col('초록', 'green', 'bold') + ' 일반 통신', '', col('[Wireshark 필터]', 'bold')] + ['  ' + x for x in FILTER_HELP]
        super().__init__('도움말', lines)

    def on_key(self, k):
        if k == '?':
            return 'back'
        return super().on_key(k)


# ── 폴더 고르기 ───────────────────────────────────────────────────────
def gui_pick():
    try:
        import tkinter
        import tkinter.filedialog
        root = tkinter.Tk()
        root.withdraw()
        try:
            root.attributes('-topmost', True)
        except Exception:
            pass
        p = tkinter.filedialog.askdirectory(title='pcap 파일이 들어있는 폴더 선택')
        root.destroy()
        return p or None
    except Exception:
        return None


def candidate_folders():
    roots = [os.getcwd(), os.path.dirname(os.path.abspath(__file__))]
    home = os.path.expanduser('~')
    for sub in ('Desktop', '바탕 화면', 'Downloads', '다운로드', 'Documents', '문서', os.path.join('OneDrive', '바탕 화면'),
                os.path.join('OneDrive', 'Desktop')):
        roots.append(os.path.join(home, sub))
    seen, out = set(), []
    for r in roots:
        if not os.path.isdir(r):
            continue
        dirs = [r]
        try:
            dirs += [os.path.join(r, x) for x in sorted(os.listdir(r), key=natural_key)
                     if not x.startswith('.') and os.path.isdir(os.path.join(r, x))]
        except OSError:
            pass
        for d in dirs:
            rp = os.path.realpath(d)
            if rp in seen:
                continue
            seen.add(rp)
            try:
                n = sum(1 for f in os.listdir(d) if f.lower().endswith(CAP_EXT))
            except OSError:
                n = 0
            if n:
                out.append((d, n))
    return out[:40]


def clean_path(a):
    a = a.strip()
    if a.startswith('& '):              # PowerShell 드래그&드롭
        a = a[2:].strip()
    return os.path.expandvars(os.path.expanduser(a.strip('"').strip("'")))


class FolderScreen(Screen):
    nav_hint = False

    def __init__(self, msg='', can_back=False):
        super().__init__()
        self.cands = candidate_folders()
        self.result = None
        self.msg = msg
        self.can_back = can_back

    def header(self):
        return [col('PCAP 분석기 — 분석할 폴더 고르기', 'cyan', 'bold')]

    def build(self):
        if not self.cands:
            return ['근처에서 pcap 폴더를 못 찾았습니다.  p 로 경로를 입력하거나 g 로 폴더 창을 여세요.']
        return [('%s  %s' % (d, col('(pcap %d개)' % n, 'dim')), ('dir', d)) for d, n in self.cands]

    def hints(self):
        return [('↑↓', '고르기'), ('Enter', '열기'), ('p', '경로 입력/붙여넣기'), ('g', '폴더 선택 창'),
                ('q', '뒤로' if self.can_back else '종료')]

    def on_act(self, act):
        self.result = act[1]
        return 'quit'

    def on_key(self, k):
        if k == 'p':
            t = TERM.line_input('폴더 경로 (탐색기에서 끌어다 놓기/붙여넣기 가능, Esc 취소): ')
            p = clean_path(t or '')
            if p and os.path.isfile(p):
                p = os.path.dirname(os.path.abspath(p))
            if p and os.path.isdir(p):
                self.result = p
                return 'quit'
            if t:
                self.msg = '폴더를 찾을 수 없습니다: %s' % t
            return True
        if k == 'g':
            p = gui_pick()
            if p:
                self.result = p
                return 'quit'
            self.msg = '폴더 선택 창을 열 수 없습니다 — p 로 경로를 입력하세요'
            return True
        if k in ('q', 'Q', 'ESC'):
            return 'quit'
        return None


def loading(files):
    res = []
    for i, f in enumerate(files, 1):
        if VT:
            draw([col('PCAP 분석기', 'cyan', 'bold'), '', '분석 중… %d / %d   %s' % (i, len(files), os.path.basename(f))])
        else:
            sys.stdout.write('\r분석 중 %d/%d  %-40s' % (i, len(files), os.path.basename(f)[:40]))
            sys.stdout.flush()
        try:
            info, fs = analyze_file(f, False)
            res.append({'path': f, 'info': info, 'fs': fs, 'err': None, 'verbose': False})
        except KeyboardInterrupt:
            raise
        except Exception as e:
            res.append({'path': f, 'info': None, 'fs': [], 'err': '%s: %s' % (type(e).__name__, e), 'verbose': False})
    return res


def interactive(folder):
    msg, prev, results = '', None, None
    while True:
        if folder is None:
            fs = FolderScreen(msg, can_back=prev is not None)
            fs.run()
            msg = ''
            if fs.result:
                folder, results = fs.result, None
            elif prev:
                folder, results = prev
            else:
                return
        if results is None:
            files = expand_inputs([folder])
            if not files:
                msg = '그 폴더에는 pcap 파일이 없습니다: %s' % folder
                folder = None
                continue
            try:
                results = loading(files)
            except KeyboardInterrupt:
                if not prev:
                    return
                folder, results = prev
        prev = (folder, results)
        act = MainScreen(results, os.path.abspath(folder)).run()
        if act == 'folder':
            folder = None
        elif act == 'reload':
            results = None
        else:
            return


# ════════════════════════════════════════════════════════════════════
# 11. 실행:  python pcap_analyzer.py 폴더
# ════════════════════════════════════════════════════════════════════
def main():
    global VT, ASCII, TERM
    try:
        sys.stdout.reconfigure(encoding='utf-8', errors='replace')
    except Exception:
        pass
    VT = enable_vt()
    setup_color(VT)
    # 옛 Windows 콘솔(conhost)은 ═ → … 같은 기호를 2칸으로 그려 줄이 깨질 수 있어 ASCII로 대체
    ASCII = os.name == 'nt' and not os.environ.get('WT_SESSION') and not os.environ.get('TERM_PROGRAM')
    TERM = Term()
    path = clean_path(' '.join(sys.argv[1:])) if len(sys.argv) > 1 else ''
    if path and os.path.isfile(path):
        path = os.path.dirname(os.path.abspath(path))
    if path and not os.path.isdir(path):
        path = ''
    TERM.enter()
    try:
        interactive(path or None)
    except KeyboardInterrupt:
        pass
    finally:
        TERM.leave()


if __name__ == '__main__':
    main()
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
압축파일 비밀번호 자동 대입 해제 스크립트 (v2)
-------------------------------------------------
- passwords.txt 에 있는 후보 비밀번호(한 줄에 하나)를 하나씩 대입해서
  맞는 비밀번호를 찾으면 압축을 풀어줍니다.
- ZIP 파일: 7-Zip 설치 없이 파이썬만으로 처리 (AES 암호는 pyzipper 자동 설치).
- 7z / RAR / ALZ 등: 7-Zip이 있으면 사용.

사용법:
    python extract_with_passwords.py <압축파일경로> [비밀번호목록파일] [풀어낼폴더]

예:
    python extract_with_passwords.py test.zip
    python extract_with_passwords.py "C:\\Users\\me\\Downloads\\data.7z" passwords.txt output
"""

import os
import sys
import subprocess
import shutil
import zipfile


PASSWORD_FILE = "passwords.txt"   # 기본 비밀번호 목록
OUTPUT_DIR    = "extracted"       # 기본 출력 폴더


# ===========================================================================
# 비밀번호 목록 읽기
# ===========================================================================
def load_passwords(path):
    with open(path, "r", encoding="utf-8", errors="ignore") as f:
        seen, passwords = set(), []
        for line in f:
            pw = line.rstrip("\r\n")   # 줄바꿈만 제거(공백도 비번일 수 있음)
            if pw and pw not in seen:
                seen.add(pw)
                passwords.append(pw)
    return passwords


# ===========================================================================
# [방법 A] ZIP — 파이썬 내장 기능으로 처리 (7-Zip 불필요)
# ===========================================================================
def _smallest_entry(zf):
    """암호 검증용으로 압축 안의 가장 작은 파일 하나를 고른다."""
    files = [i for i in zf.infolist() if not i.is_dir()]
    if not files:
        return None
    return min(files, key=lambda i: i.file_size)

def crack_zip(archive, passwords, out_dir):
    # AES 암호(7-Zip의 'AES-256' 옵션)인지 먼저 확인
    uses_aes = False
    with zipfile.ZipFile(archive) as zf:
        for info in zf.infolist():
            # compress_type 99 == WinZip AES
            if getattr(info, "compress_type", None) == 99:
                uses_aes = True
                break

    if uses_aes:
        return crack_zip_aes(archive, passwords, out_dir)

    # 표준 ZipCrypto 암호 → 내장 zipfile로 처리
    with zipfile.ZipFile(archive) as zf:
        target = _smallest_entry(zf)
        if target is None:
            print("[!] 압축 안에 풀 수 있는 파일이 없습니다.")
            return False

        total = len(passwords)
        for i, pw in enumerate(passwords, 1):
            print(f"\r    [{i}/{total}] 시도 중...", end="", flush=True)
            try:
                zf.read(target.filename, pwd=pw.encode("utf-8"))
            except (RuntimeError, zipfile.BadZipFile):
                continue   # 비번 틀림
            except Exception:
                continue
            # 성공 → 전체 해제
            print(f"\r[✓] 비밀번호를 찾았습니다! → {pw}" + " " * 20)
            os.makedirs(out_dir, exist_ok=True)
            zf.extractall(out_dir, pwd=pw.encode("utf-8"))
            return _finish(pw, out_dir)

    return _fail(total)

def crack_zip_aes(archive, passwords, out_dir):
    try:
        import pyzipper
    except ImportError:
        print("[*] AES 암호 ZIP입니다. 처리용 모듈(pyzipper)을 설치합니다...")
        subprocess.run([sys.executable, "-m", "pip", "install", "--quiet", "pyzipper"])
        try:
            import pyzipper
        except ImportError:
            print("[!] pyzipper 설치에 실패했습니다. 인터넷 연결을 확인하거나 7-Zip을 설치하세요.")
            return False

    total = len(passwords)
    with pyzipper.AESZipFile(archive) as zf:
        target = _smallest_entry(zf)
        for i, pw in enumerate(passwords, 1):
            print(f"\r    [{i}/{total}] 시도 중...", end="", flush=True)
            try:
                zf.pwd = pw.encode("utf-8")
                zf.read(target.filename)
            except Exception:
                continue
            print(f"\r[✓] 비밀번호를 찾았습니다! → {pw}" + " " * 20)
            os.makedirs(out_dir, exist_ok=True)
            zf.extractall(out_dir, pwd=pw.encode("utf-8"))
            return _finish(pw, out_dir)
    return _fail(total)


# ===========================================================================
# [방법 B] 7z / RAR / 기타 — 7-Zip 사용
# ===========================================================================
def find_7z():
    for name in ("7z", "7za", "7zz"):
        p = shutil.which(name)
        if p:
            return p
    for c in (
        r"C:\Program Files\7-Zip\7z.exe",
        r"C:\Program Files (x86)\7-Zip\7z.exe",
        r"C:\Program Files\Bandizip\7z.exe",
        "/opt/homebrew/bin/7z", "/usr/local/bin/7z", "/usr/bin/7z", "/usr/bin/7za",
    ):
        if os.path.isfile(c):
            return c
    return None

def crack_with_7z(archive, passwords, out_dir):
    seven = find_7z()
    if not seven:
        print("[!] 이 형식은 7-Zip이 필요합니다. 설치 후 다시 실행하세요.")
        print("    Windows: https://www.7-zip.org")
        return False

    total = len(passwords)
    for i, pw in enumerate(passwords, 1):
        print(f"\r    [{i}/{total}] 시도 중...", end="", flush=True)
        r = subprocess.run([seven, "t", archive, f"-p{pw}", "-y"],
                           stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        if r.returncode == 0:
            print(f"\r[✓] 비밀번호를 찾았습니다! → {pw}" + " " * 20)
            os.makedirs(out_dir, exist_ok=True)
            subprocess.run([seven, "x", archive, f"-p{pw}", f"-o{out_dir}", "-y"],
                           stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            return _finish(pw, out_dir)
    return _fail(total)


# ===========================================================================
# 공통 마무리/실패 출력
# ===========================================================================
def _finish(pw, out_dir):
    print(f"[✓] 완료! '{out_dir}' 폴더에 풀었습니다.")
    with open("found_password.txt", "w", encoding="utf-8") as g:
        g.write(pw + "\n")
    print("[*] 찾은 비밀번호는 found_password.txt 에도 저장했습니다.")
    return True

def _fail(total):
    print(f"\r[!] {total}개 비밀번호를 모두 시도했지만 맞는 것이 없습니다." + " " * 20)
    print("    - 목록이 정확한지, 압축파일이 손상되지 않았는지 확인해 보세요.")
    return False


# ===========================================================================
# 메인
# ===========================================================================
def main():
    archive       = sys.argv[1] if len(sys.argv) >= 2 else None
    password_file = sys.argv[2] if len(sys.argv) >= 3 else PASSWORD_FILE
    out_dir       = sys.argv[3] if len(sys.argv) >= 4 else OUTPUT_DIR

    if not archive:
        print("[!] 사용법: python extract_with_passwords.py <압축파일> [비밀번호목록.txt] [출력폴더]")
        sys.exit(1)
    if not os.path.isfile(archive):
        print(f"[!] 압축파일을 찾을 수 없습니다: {archive}")
        sys.exit(1)
    if not os.path.isfile(password_file):
        print(f"[!] 비밀번호 목록 파일을 찾을 수 없습니다: {password_file}")
        sys.exit(1)

    passwords = load_passwords(password_file)
    print(f"[*] 대상 파일  : {archive}")
    print(f"[*] 시도할 비번: {len(passwords)} 개")
    print(f"[*] 시작합니다...\n")

    ext = os.path.splitext(archive)[1].lower()
    if ext == ".zip":
        crack_zip(archive, passwords, out_dir)
    else:
        crack_with_7z(archive, passwords, out_dir)


if __name__ == "__main__":
    main()

사용방법 : 바탕화면에서 하는게 오류가 없음

바탕화면에 “테스트” 이름의 파일을 만들어서 준 파일 넣고, 시험문제로 나온 암호걸린 압축파일을 “test”파일로 이름을 바꿔서 넣는다

password.txt 에 교수님이 주신 비밀번호는 다 집어넣는다

테스트 파일에 들어가서 cmd를 켜서 python extract_with_passwords.py test.zip 를 입력하면 끝

Comments

GitHub Discussions