From b222dd5560ac9eb37c922dec32a18067a843d88a Mon Sep 17 00:00:00 2001 From: Praising Harris Ratnam Date: Sun, 11 Oct 2026 14:45:07 +0530 Subject: [PATCH 1/5] security: bound and validate numpy headers --- modelhawk.py | 52 +++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/modelhawk.py b/modelhawk.py index d184d12..194de80 100644 --- a/modelhawk.py +++ b/modelhawk.py @@ -283,20 +283,50 @@ def scan_yaml_bytes(data: bytes, label: str = "") -> "StreamReport": # Numpy .npy scanning # --------------------------------------------------------------------------- # +# Reject hostile or corrupt headers before decoding/parsing them. NumPy's +# format uses a small Python-literal dictionary; a 1 MiB ceiling is generous +# for real headers while bounding work on untrusted artifacts. +_MAX_NPY_HEADER_BYTES = 1 << 20 + + def _parse_npy_header(data: bytes) -> tuple[str, int]: - """Return (dtype_str, data_start_offset). Raises ValueError on bad files.""" - if not data.startswith(_NPY_MAGIC): - raise ValueError("missing numpy .npy magic bytes") - major = data[6] + """Return (dtype_str, data_start_offset); reject malformed/oversized headers.""" + if len(data) < 8 or not data.startswith(_NPY_MAGIC): + raise ValueError("missing or truncated numpy .npy magic/version bytes") + + major, minor = data[6], data[7] if major == 1: - header_len = int.from_bytes(data[8:10], "little") - hdr_start, data_start = 10, 10 + header_len - else: # v2+: 4-byte header length field - header_len = int.from_bytes(data[8:12], "little") - hdr_start, data_start = 12, 12 + header_len + length_start, length_size, hdr_start = 8, 2, 10 + encoding = "latin-1" + elif major in (2, 3): + length_start, length_size, hdr_start = 8, 4, 12 + encoding = "utf-8" if major == 3 else "latin-1" + else: + raise ValueError(f"unsupported numpy .npy format version {major}.{minor}") + + if len(data) < hdr_start: + raise ValueError("truncated numpy .npy header-length field") + + header_len = int.from_bytes(data[length_start:length_start + length_size], "little") + if header_len <= 0: + raise ValueError("empty numpy .npy header") + if header_len > _MAX_NPY_HEADER_BYTES: + raise ValueError( + f"numpy .npy header exceeds {_MAX_NPY_HEADER_BYTES} byte safety limit" + ) + + data_start = hdr_start + header_len + if data_start > len(data): + raise ValueError("truncated numpy .npy header body") + raw_hdr = data[hdr_start:data_start] - header = ast.literal_eval(raw_hdr.decode("latin-1").strip().rstrip("\x00").rstrip()) - return header.get("descr", ""), data_start + try: + header = ast.literal_eval(raw_hdr.decode(encoding).strip().rstrip("\\x00").rstrip()) + except (UnicodeDecodeError, SyntaxError, ValueError) as exc: + raise ValueError(f"invalid numpy .npy header: {exc}") from exc + if not isinstance(header, dict) or not isinstance(header.get("descr"), str): + raise ValueError("numpy .npy header must contain a string 'descr' field") + return header["descr"], data_start def _npy_extract_pickle(data: bytes): From e28befcac525336d5a5344548fb441f1986e3434 Mon Sep 17 00:00:00 2001 From: Praising Harris Ratnam Date: Sun, 11 Oct 2026 14:45:14 +0530 Subject: [PATCH 2/5] fix: strip NUL padding in NPY headers --- modelhawk.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modelhawk.py b/modelhawk.py index 194de80..1615dbe 100644 --- a/modelhawk.py +++ b/modelhawk.py @@ -321,7 +321,7 @@ def _parse_npy_header(data: bytes) -> tuple[str, int]: raw_hdr = data[hdr_start:data_start] try: - header = ast.literal_eval(raw_hdr.decode(encoding).strip().rstrip("\\x00").rstrip()) + header = ast.literal_eval(raw_hdr.decode(encoding).strip().rstrip("\x00").rstrip()) except (UnicodeDecodeError, SyntaxError, ValueError) as exc: raise ValueError(f"invalid numpy .npy header: {exc}") from exc if not isinstance(header, dict) or not isinstance(header.get("descr"), str): From 9a356f10bc194f18c1c7ea71271d0f9433733ce8 Mon Sep 17 00:00:00 2001 From: Praising Harris Ratnam Date: Sun, 11 Oct 2026 14:45:20 +0530 Subject: [PATCH 3/5] test: cover malformed and oversized NPY headers --- tests/test_modelhawk.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/test_modelhawk.py b/tests/test_modelhawk.py index 56f16a5..52cb1f3 100644 --- a/tests/test_modelhawk.py +++ b/tests/test_modelhawk.py @@ -168,6 +168,40 @@ def test_npy_float_array_safe(): assert stream.severity == "SAFE", f"expected SAFE, got {stream.severity}" + +def test_npy_header_bounds_and_versions(): + """Reject truncated/oversized headers and parse supported v3 UTF-8 headers.""" + import struct + + # Truncated version and length fields must fail closed. + for data in (b"\\x93NUMPY", b"\\x93NUMPY\\x01\\x00\\x05"): + try: + modelhawk._parse_npy_header(data) + except ValueError: + pass + else: + raise AssertionError(f"truncated header accepted: {data!r}") + + # An attacker-controlled length must be rejected before header parsing. + oversized = ( + b"\\x93NUMPY\\x02\\x00" + + struct.pack(" Date: Sun, 11 Oct 2026 14:45:24 +0530 Subject: [PATCH 4/5] test: run NPY header hardening regression --- tests/test_modelhawk.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_modelhawk.py b/tests/test_modelhawk.py index 52cb1f3..46b8ea2 100644 --- a/tests/test_modelhawk.py +++ b/tests/test_modelhawk.py @@ -235,6 +235,7 @@ def __reduce__(self): test_yaml_detection, test_npy_object_array_handcrafted, test_npy_float_array_safe, + test_npy_header_bounds_and_versions, test_npy_real_numpy_object_array, ] failed = 0 From ebf5b8b421f82972d4d0f2da835144627271560a Mon Sep 17 00:00:00 2001 From: Praising Harris Ratnam Date: Sun, 11 Oct 2026 14:45:37 +0530 Subject: [PATCH 5/5] fix: encode NPY regression fixtures correctly --- tests/test_modelhawk.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_modelhawk.py b/tests/test_modelhawk.py index 46b8ea2..46cbebc 100644 --- a/tests/test_modelhawk.py +++ b/tests/test_modelhawk.py @@ -174,7 +174,7 @@ def test_npy_header_bounds_and_versions(): import struct # Truncated version and length fields must fail closed. - for data in (b"\\x93NUMPY", b"\\x93NUMPY\\x01\\x00\\x05"): + for data in (b"\x93NUMPY", b"\x93NUMPY\x01\x00\x05"): try: modelhawk._parse_npy_header(data) except ValueError: @@ -184,7 +184,7 @@ def test_npy_header_bounds_and_versions(): # An attacker-controlled length must be rejected before header parsing. oversized = ( - b"\\x93NUMPY\\x02\\x00" + b"\x93NUMPY\x02\x00" + struct.pack("