Source code for pcapkit.foundation.engines.pyshark

# -*- coding: utf-8 -*-
"""PyShark Support
=====================

.. module:: pcapkit.foundation.engines.pyshark

This module contains the implementation for `PyShark`_ engine
support, as is used by :class:`pcapkit.foundation.extraction.Extractor`.

.. _PyShark: https://kiminewt.github.io/pyshark

"""
import sys
from typing import TYPE_CHECKING, cast

from pcapkit.foundation.engines.engine import EngineBase
from pcapkit.foundation.reassembly import ReassemblyManager
from pcapkit.utilities.exceptions import stacklevel
from pcapkit.utilities.logging import get_logger
from pcapkit.utilities.warnings import AttributeWarning, warn

__all__ = ['PyShark']

if TYPE_CHECKING:
    from types import ModuleType
    from typing import Optional

    from pyshark.capture.file_capture import FileCapture
    from pyshark.packet.layers.base import BaseLayer
    from pyshark.packet.packet import Packet as PySharkPacket

    from pcapkit.foundation.extraction import Extractor

#: logging.Logger: Module-level logger, a child of the package-wide
#: :data:`pcapkit.utilities.logging.logger`.
logger = get_logger(__name__)


def _frame_layer(packet: 'PySharkPacket') -> 'Optional[BaseLayer]':
    """The ``frame`` layer of a `PyShark`_ packet, or :data:`None` when the record has none.

    Looked up by name, because `PyShark`_ takes the second PDML ``<proto>`` as
    ``frame_info``. On a packet with a comment, that is ``pkt_comment``, which has
    no ``protocols``, and the frame layer is ``layers[0]``. By its source,
    :program:`tshark` 4.2.2 roots a Sysdig event at ``syscall`` and a Netflix
    custom block at ``bblog``, so those records have no ``frame`` layer.

    .. _PyShark: https://kiminewt.github.io/pyshark

    """
    for layer in (packet.frame_info, *packet.layers):
        if layer.layer_name == 'frame':
            return layer
    return None


def _is_packet(packet: 'PySharkPacket') -> 'bool':
    """Whether a `PyShark`_ packet is a packet record, rather than another kind of tshark record.

    :program:`tshark` numbers every record it reads, not only packets: a PCAP-NG
    systemd Journal Export Block, Custom Block or Sysdig Event Block is a frame
    to it, measured with :program:`tshark` 4.6.9. This engine counts packet
    records only.

    The marker is ``frame.encap_type`` on the record's :func:`_frame_layer`.
    Wireshark adds it for ``REC_TYPE_PACKET`` records only:
    :file:`epan/dissectors/packet-frame.c` lines 1001-1002 in 4.2.2, and 828-890
    in 4.6.9. The summary line is not used, because it differs between those
    versions. A record with no ``frame`` layer is not a packet either.

    .. _PyShark: https://kiminewt.github.io/pyshark

    """
    frame = _frame_layer(packet)
    return frame is not None and 'encap_type' in frame.field_names


[docs] class PyShark(EngineBase['PySharkPacket']): """PyShark engine support. Args: extractor: :class:`~pcapkit.foundation.extraction.Extractor` instance. """ if TYPE_CHECKING: #: Engine extraction package, i.e. :mod:`pyshark`. _expkg: 'ModuleType' #: Engine extraction temporary storage. _extmp: 'FileCapture' ########################################################################## # Defaults. ########################################################################## #: Engine name. __engine_name__ = 'PyShark' #: Engine module name. __engine_module__ = 'pyshark' #: First Python version `PyShark`_ does not work on, as a ``(major, minor)`` #: pair. Released 0.6 builds its event loop with #: ``asyncio.get_event_loop_policy().get_event_loop()``, and Python 3.14 made #: :func:`asyncio.get_event_loop` raise :exc:`RuntimeError` when no current #: event loop exists instead of quietly creating one. PYTHON_CEILING = (3, 14) ########################################################################## # Class methods. ##########################################################################
[docs] @classmethod def unsupported_reason(cls) -> 'Optional[str]': """Why this engine cannot run here, or :data:`None` when it can. Consulted by :meth:`pcapkit.foundation.extraction.Extractor.run` *before* the import test, because none of the things that stop this engine is visible to an import. `PyShark`_ imports perfectly well and then fails when it is used, which without this hook escapes from :meth:`run` as a hard error rather than degrading to the default engine with a warning. **The interpreter.** ``pyshark`` 0.6 does ``asyncio.get_event_loop_policy().get_event_loop()`` at :file:`pyshark/capture/capture.py:183`, in a fresh interpreter with no running loop. Measured: 3.10 and 3.11 return a loop silently, 3.12 returns one with a :exc:`DeprecationWarning`, and 3.14 raises ``RuntimeError: There is no current event loop in thread 'MainThread'``. Hence :attr:`PYTHON_CEILING` is ``(3, 14)``. Python 3.13 was not measured; it is expected to work, since it is on the deprecated-but-functional side of that progression, and that expectation is the one thing here that is inferred rather than observed. **The** :program:`tshark` **binary.** ``pyshark`` is a wrapper around Wireshark's command-line tool and does no parsing itself, so it is useless without it. The check delegates to ``pyshark``'s own ``get_process_path()`` rather than calling :func:`shutil.which`, because the two are not equivalent and ``which`` would refuse setups that work: ``pyshark`` looks at ``tshark_path`` in its :file:`config.ini` *first*, and then at :envvar:`PATH` on POSIX, at both Program Files directories on Windows, and at :file:`/Applications/Wireshark.app` on macOS. Asking ``pyshark`` gets all of that for free and cannot disagree with what ``pyshark`` will do a moment later. Note: Deliberately **not cached**, and the cost was measured rather than assumed: the failing path -- which is the expensive one, since it exhausts every candidate -- takes about 290 microseconds with 39 :envvar:`PATH` entries, against about 120 for a bare :func:`shutil.which`. This runs once per :class:`~pcapkit.foundation.extraction.Extractor`, not once per frame, so it is far below the cost of opening the capture. Caching would trade that for an answer about the *environment* that cannot change within the process -- so installing Wireshark, or fixing :envvar:`PATH`, would not take effect until restart. The Windows path does more work than the POSIX one (two Program Files directories, and :func:`shutil.which` there would multiply by ``PATHEXT``), but it is still a bounded handful of :func:`os.stat` calls. Returns: A short phrase naming the limitation, or :data:`None`. .. _PyShark: https://kiminewt.github.io/pyshark """ if sys.version_info[:2] >= cls.PYTHON_CEILING: return (f'pyshark does not support Python ' f'{sys.version_info[0]}.{sys.version_info[1]}; it builds its event ' 'loop with `asyncio.get_event_loop_policy().get_event_loop()`, which ' 'raises RuntimeError ' 'from Python 3.14 when no current event loop exists') try: from pyshark.tshark.tshark import get_process_path # isort:skip except ImportError: # Not installed, which is ``Extractor.import_test``'s business -- it # reports that case in its own words, and answering here as well would # produce two warnings for one problem. An ImportError from a *renamed* # upstream helper lands here too, and the engine then simply proceeds # without the ``tshark`` check. return None try: get_process_path() except Exception as exc: # pylint: disable=broad-except # ``TSharkNotFoundException`` by name, but caught broadly: the whole # point is to turn any failure to locate the binary into a reason # rather than let it escape, and upstream is free to raise something # else. Its own message lists every path it searched, which is exactly # what the user needs, so it is quoted rather than summarised. return (f'pyshark requires Wireshark\'s `tshark` binary, which pyshark ' f'could not find -- {exc}') return None
########################################################################## # Data models. ########################################################################## def __init__(self, extractor: 'Extractor') -> 'None': import pyshark # isort:skip self._expkg = pyshark self._extmp = cast('FileCapture', None) super().__init__(extractor) ########################################################################## # Methods. ##########################################################################
[docs] def run(self) -> 'None': """Call :class:`pyshark.FileCapture` to extract PCAP files. This method assigns :attr:`self._expkg <PyShark._expkg>` as :mod:`pyshark` and :attr:`self._extmp <PyShark._extmp>` as an iterator from :class:`pyshark.FileCapture`. The global header is parsed and dumped first, by :meth:`self.extractor.record_header <pcapkit.foundation.extraction.Extractor.record_header>`. Warns: AttributeWarning: Warns under the following circumstances: * if :attr:`self.extractor._exlyr <pcapkit.foundation.extraction.Extractor._exlyr>` and/or :attr:`self.extractor._exptl <pcapkit.foundation.extraction.Extractor._exptl>` is provided as the PyShark engine does not support such operations. * if reassembly is enabled, as the PyShark engine does not support such operation. * if :attr:`self.extractor._exctx <pcapkit.foundation.extraction.Extractor._exctx>` is provided, as the PyShark engine does not parse with :mod:`pcapkit`'s own protocol implementations. """ ext = self._extractor if ext._exlyr != 'none' or ext._exptl != 'null': warn("'Extractor(engine=pyshark)' does not support protocol and layer threshold; " f"'layer={ext._exlyr}' and 'protocol={ext._exptl}' ignored", AttributeWarning, stacklevel=stacklevel()) if ext._exctx: warn("'Extractor(engine=pyshark)' does not parse with pcapkit's own protocol " "implementations, so the parsing context supplied through " "'context=' is ignored", AttributeWarning, stacklevel=stacklevel()) if ext._flag_r and (ext._ipv4 or ext._ipv6 or ext._tcp): ext._flag_r = False logger.debug('pyshark: reassembly unsupported, disabling it') ext._reasm = ReassemblyManager(ipv4=None, ipv6=None, tcp=None) warn("'Extractor(engine=pyshark)' object does not support reassembly; " f"so 'ipv4={ext._ipv4}', 'ipv6={ext._ipv6}' and 'tcp={ext._tcp}' will be ignored", AttributeWarning, stacklevel=stacklevel()) # setup verbose handler; a packet always has a frame layer, since # read_frame skips every record that does not (#1515) if ext._flag_v: ext._vfunc = lambda e, f: print( f'Frame {e._frnum:>3d}: ' # pylint: disable=protected-access f'{cast("BaseLayer", _frame_layer(f)).protocols}' ) # extract global header ext.record_header() # extract & analyse file logger.debug('pyshark: opening %s', ext._ifnm) self._extmp = self._expkg.FileCapture(ext._ifnm, keep_packets=False)
[docs] def read_frame(self) -> 'PySharkPacket': """Read frames with PyShark engine. A tshark record that is not a packet, such as a PCAP-NG systemd Journal Export Block, is skipped. Frames are numbered by count, not by tshark's frame number. Returns: Parsed frame instance. See Also: Please refer to :meth:`PCAP.read_frame <pcapkit.foundation.engines.pcap.PCAP.read_frame>` for more operational information. """ from pcapkit.toolkit.pyshark import packet2dict, tcp_traceflow ext = self._extractor # fetch the next PyShark packet that is a packet record while True: packet = cast('PySharkPacket', self._extmp.next()) if _is_packet(packet): break logger.debug('pyshark: skipping tshark frame %s, not a packet record', packet.number) # verbose output ext._frnum += 1 ext._vfunc(ext, packet) # write output frnum = f'Frame {ext._frnum}' if not ext._flag_q: info = packet2dict(packet) if ext._flag_f: ofile = ext._ofile(f'{ext._ofnm}/{frnum}.{ext._fext}') ofile(info, name=frnum) else: ext._ofile(info, name=frnum) # trace flows if ext._flag_t: if ext._tcp: data_tf_tcp = tcp_traceflow(packet, count=ext._frnum) if data_tf_tcp is not None: ext._trace.tcp(data_tf_tcp) # record frames if ext._flag_d: # setattr(packet, 'packet2dict', packet2dict) ext._frame.append(packet) # return frame record return packet
[docs] def close(self) -> 'None': """Close engine. This method is to be used for closing the engine instance. It is to close the engine instance after the extraction process is finished. """ self._extmp.close()