Skip to content

Commit bbfd4bf

Browse files
authored
fix(pcap,pcapng)!: a cut PCAP header or PCAP-NG block rebuilds as captured (#1470) (#1475)
1 parent 6323852 commit bbfd4bf

5 files changed

Lines changed: 371 additions & 15 deletions

File tree

‎pcapkit/protocols/data/misc/pcapng.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,12 @@ class PCAPNG(Protocol):
6969
#: Block total length.
7070
length: 'int'
7171

72+
if TYPE_CHECKING:
73+
#: The block's octets as captured, when the capture ended inside it;
74+
#: present only then. A rebuild parses them again rather than writing
75+
#: the block at its declared length (:issue:`1470`).
76+
__truncated_raw__: 'bytes'
77+
7278

7379
@info_final
7480
class UnknownBlock(PCAPNG):

‎pcapkit/protocols/misc/pcap/header.py‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,15 @@
3535
from pcapkit.protocols.data.misc.pcap.header import MagicNumber as Data_MagicNumber
3636
from pcapkit.protocols.protocol import ProtocolBase
3737
from pcapkit.protocols.schema.misc.pcap.header import Header as Schema_Header
38+
from pcapkit.protocols.schema.schema import keep_short_read, replay_short_read
3839
from pcapkit.utilities.exceptions import EndianError, FileError, UnsupportedCall
3940

4041
if TYPE_CHECKING:
4142
from enum import IntEnum as StdlibEnum
4243
from typing import IO, Any, NoReturn, Optional, Type
4344

4445
from aenum import IntEnum as AenumEnum
45-
from typing_extensions import Literal
46+
from typing_extensions import Literal, Self
4647

4748
__all__ = ['Header']
4849

@@ -256,6 +257,11 @@ def __post_init__(self, file: 'Optional[IO[bytes] | bytes]' = None,
256257
See Also:
257258
For construction argument, please refer to :meth:`make`.
258259
260+
Notes:
261+
A header the data ends inside keeps that in its :attr:`info`, so
262+
that :meth:`from_data` rebuilds only what was captured
263+
(:issue:`1470`).
264+
259265
"""
260266
if file is None:
261267
_data = self.pack(**kwargs)
@@ -270,6 +276,7 @@ def __post_init__(self, file: 'Optional[IO[bytes] | bytes]' = None,
270276
self._file.name = file.name
271277
#: pcapkit.corekit.infoclass.Info: Parsed packet data.
272278
self._info = self.unpack(length, **kwargs)
279+
keep_short_read(self)
273280

274281
def __len__(self) -> 'Literal[24]':
275282
"""Total length of corresponding protocol."""
@@ -279,6 +286,22 @@ def __length_hint__(self) -> 'Literal[24]':
279286
"""Return an estimated length for the object."""
280287
return 24
281288

289+
@classmethod
290+
def from_data(cls, data: 'Data_Header | dict[str, Any]', **kwargs: 'Any') -> 'Self':
291+
"""Create protocol instance from data.
292+
293+
Args:
294+
data: Protocol data.
295+
**kwargs: Construction keywords, as for :meth:`ProtocolBase.from_data
296+
<pcapkit.protocols.protocol.ProtocolBase.from_data>`.
297+
298+
Returns:
299+
Protocol instance, cut back to what was captured when the header
300+
it was parsed from was cut short (:issue:`1470`).
301+
302+
"""
303+
return replay_short_read(super().from_data(data, **kwargs), data)
304+
282305
@classmethod
283306
def __index__(cls) -> 'NoReturn':
284307
"""Numeral registry index of the protocol.

‎pcapkit/protocols/misc/pcapng.py‎

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import base64
1616
import builtins
1717
import collections
18+
import collections.abc
1819
import datetime
1920
import decimal
2021
import enum
@@ -178,8 +179,9 @@
178179
from pcapkit.protocols.schema.misc.pcapng import ZigBeeNWKKey as Schema_ZigBeeNWKKey
179180
from pcapkit.protocols.schema.schema import Schema
180181
from pcapkit.utilities.compat import StrEnum, localcontext
181-
from pcapkit.utilities.exceptions import (EnumValueError, FormatError, ProtocolError, RegistryError,
182-
StreamEOFError, UnsupportedCall, stacklevel)
182+
from pcapkit.utilities.exceptions import (BaseError, EnumValueError, FormatError, ProtocolError,
183+
RegistryError, StreamEOFError, UnsupportedCall,
184+
stacklevel)
183185
from pcapkit.utilities.warnings import (AttributeWarning, DeprecatedFormatWarning, ProtocolWarning,
184186
RegistryWarning, warn)
185187

@@ -1264,7 +1266,15 @@ def read(self, length: 'Optional[int]' = None, *, _read: 'bool' = True,
12641266
getattr(self, meth_name, self._read_block_unknown))
12651267
else:
12661268
meth = name[0]
1267-
block = meth(schema.block, header=schema)
1269+
try:
1270+
block = meth(schema.block, header=schema)
1271+
except BaseError as error:
1272+
# NOTE: Fields past the end of a block the capture ended inside are
1273+
# read as zeros, and a value its parser refuses is an artefact of
1274+
# that, not of the block; it is kept as captured instead.
1275+
if not _read or self._captured(_seek_set) >= schema.block.length:
1276+
raise
1277+
block = self._read_block_cut(schema, _seek_set, error)
12681278

12691279
# NOTE: An option or record area holding a malformed entry is kept as
12701280
# the octets captured, as one :obj:`bytes` item (see
@@ -1314,6 +1324,14 @@ def read(self, length: 'Optional[int]' = None, *, _read: 'bool' = True,
13141324
stacklevel=stacklevel())
13151325
seek_cur = min(seek_cur, _seek_set + read)
13161326

1327+
# NOTE: The fields read past the end of the capture are zeros,
1328+
# and an option, record or packet the end cut through is only
1329+
# its first octets, so the block cannot be rebuilt from them at
1330+
# the length it declares. The octets captured are kept as
1331+
# ``__truncated_raw__`` instead, which :meth:`__post_init__` parses
1332+
# again in place of building the block (:issue:`1470`).
1333+
block.__update__([('__truncated_raw__', self._data)])
1334+
13171335
# move backward to the beginning of next block
13181336
self._file.seek(seek_cur, io.SEEK_SET)
13191337

@@ -1413,6 +1431,13 @@ def __post_init__(self, file: 'Optional[IO[bytes] | bytes]' = None, length: 'Opt
14131431
#: collections.Counter: Counter for option types.
14141432
self._opt = collections.Counter() # type: Counter[Enum_OptionType]
14151433

1434+
# NOTE: A block the capture ended inside rebuilds as the octets that
1435+
# were captured, parsed again as they were the first time (see
1436+
# :meth:`read`), rather than at the length it declares (:issue:`1470`).
1437+
block = kwargs.get('block')
1438+
if file is None and isinstance(block, collections.abc.Mapping) and '__truncated_raw__' in block:
1439+
file = bytes(block['__truncated_raw__'])
1440+
14161441
if file is None:
14171442
_read = False
14181443
#: bytes: Raw packet data.
@@ -1521,6 +1546,52 @@ def _check_block_floor(self, length: 'Optional[int]') -> 'None':
15211546
raise StreamEOFError(f'PCAP-NG: block truncated: {length} octet(s) left, '
15221547
'fewer than the 12 a block needs', quiet=True)
15231548

1549+
def _captured(self, offset: 'int') -> 'int':
1550+
"""Count the octets left in the stream from ``offset`` on.
1551+
1552+
Args:
1553+
offset: File offset of the block.
1554+
1555+
Returns:
1556+
Octets from ``offset`` to the end of
1557+
:attr:`self._file <pcapkit.protocols.protocol.Protocol._file>`.
1558+
1559+
"""
1560+
current = self._file.tell()
1561+
end = self._file.seek(0, io.SEEK_END)
1562+
self._file.seek(current, io.SEEK_SET)
1563+
return end - offset
1564+
1565+
def _read_block_cut(self, header: 'Schema_PCAPNG', offset: 'int',
1566+
error: 'BaseError') -> 'Data_UnknownBlock':
1567+
"""Read a PCAP-NG block the capture ended inside, whose parser refused it.
1568+
1569+
Args:
1570+
header: Parsed PCAP-NG header schema.
1571+
offset: File offset of the block.
1572+
error: What the block's parser raised.
1573+
1574+
Returns:
1575+
Parsed packet data, with the octets captured after the Block Total
1576+
Length as its body. :meth:`read` keeps the block's octets as
1577+
``__truncated_raw__`` too, which a rebuild parses again, so that it
1578+
rebuilds as captured (:issue:`1470`).
1579+
1580+
"""
1581+
warn(f'PCAP-NG: [Block {header.type}] block truncated; kept as captured: {error}',
1582+
ProtocolWarning, stacklevel=stacklevel())
1583+
1584+
current = self._file.tell()
1585+
self._file.seek(offset + 8, io.SEEK_SET)
1586+
body = self._read_fileng(header.block.length - 12)
1587+
self._file.seek(current, io.SEEK_SET)
1588+
1589+
return Data_UnknownBlock(
1590+
type=header.type,
1591+
length=header.block.length,
1592+
body=body,
1593+
)
1594+
15241595
def _get_payload(self) -> 'bytes':
15251596
"""Get payload of :attr:`self.__header__ <pcapkit.protocols.protocol.Protocol.__header__>`.
15261597

‎pcapkit/protocols/schema/misc/pcapng.py‎

Lines changed: 44 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,25 @@ def pcapng_block_selector(packet: 'dict[str, Any]') -> 'Field':
522522
return SchemaField(length=max(packet['__length__'], 0), schema=schema)
523523

524524

525+
def _cut_last_line(schema: 'Schema', index: 'int', lines: 'list[str]') -> 'bool':
526+
"""Tell whether a key log line that did not parse is one the capture cut.
527+
528+
Args:
529+
schema: Key log secrets schema, just unpacked.
530+
index: Index of the line in ``lines``.
531+
lines: Lines of the key log.
532+
533+
Returns:
534+
Whether ``lines[index]`` is the last line of a key log the data ended
535+
inside, which :meth:`Schema.unpack
536+
<pcapkit.protocols.schema.schema.Schema.unpack>` records as
537+
``__short_read__``. Such a line is only the first octets of an entry,
538+
and the log text, kept verbatim, still rebuilds it (:issue:`1470`).
539+
540+
"""
541+
return index == len(lines) - 1 and '__short_read__' in schema.__dict__
542+
543+
525544
def dsb_secrets_selector(packet: 'dict[str, Any]') -> 'Field':
526545
"""Selector function for :attr:`DecryptionSecretsBlock.secrets_data` field.
527546
@@ -2194,14 +2213,20 @@ def post_process(self, packet: 'dict[str, Any]') -> 'Schema':
21942213
from pcapkit.protocols.misc.pcapng import TLSKeyLabel
21952214

21962215
entries = collections.defaultdict(OrderedMultiDict) # type: dict[TLSKeyLabel, OrderedMultiDict[bytes, bytes]]
2197-
for line in self.data.splitlines():
2216+
lines = self.data.splitlines()
2217+
for index, line in enumerate(lines):
21982218
if not line or line.startswith('#'):
21992219
continue
22002220

2201-
label, random, secret = line.strip().split()
2202-
label_enum = TLSKeyLabel(label.upper())
2203-
entries[label_enum].add(bytes.fromhex(random),
2204-
bytes.fromhex(secret))
2221+
try:
2222+
label, random, secret = line.strip().split()
2223+
label_enum = TLSKeyLabel(label.upper())
2224+
entries[label_enum].add(bytes.fromhex(random),
2225+
bytes.fromhex(secret))
2226+
except ValueError as error:
2227+
if _cut_last_line(self, index, lines):
2228+
break
2229+
raise FieldValueError(f'invalid TLS key log format: {line!r}') from error
22052230

22062231
self.entries = entries
22072232
return self
@@ -2233,16 +2258,24 @@ def post_process(self, packet: 'dict[str, Any]') -> 'Schema':
22332258
from pcapkit.protocols.misc.pcapng import WireGuardKeyLabel
22342259

22352260
entries = OrderedMultiDict() # type: OrderedMultiDict[WireGuardKeyLabel, bytes]
2236-
for line in self.data.splitlines():
2261+
lines = self.data.splitlines()
2262+
for index, line in enumerate(lines):
22372263
if not line or line.startswith('#'):
22382264
continue
22392265

22402266
fields = line.strip().split()
2241-
if len(fields) != 3 or fields[1] != '=':
2242-
raise FieldValueError(f'invalid WireGuard key log format: {line!r}')
2243-
label, _, secret = fields
2244-
label_enum = WireGuardKeyLabel(label.upper())
2245-
entries.add(label_enum, base64.b64decode(secret))
2267+
try:
2268+
if len(fields) != 3 or fields[1] != '=':
2269+
raise FieldValueError(f'invalid WireGuard key log format: {line!r}')
2270+
label, _, secret = fields
2271+
label_enum = WireGuardKeyLabel(label.upper())
2272+
entries.add(label_enum, base64.b64decode(secret))
2273+
except ValueError as error:
2274+
if _cut_last_line(self, index, lines):
2275+
break
2276+
if isinstance(error, FieldValueError):
2277+
raise
2278+
raise FieldValueError(f'invalid WireGuard key log format: {line!r}') from error
22462279

22472280
self.entries = entries
22482281
return self

0 commit comments

Comments
 (0)