Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions .github/workflows/pythonpackage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,15 @@ jobs:

runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
python-version: [2.7, 3.5, 3.6, 3.7]
python-version: ["3.14"]

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the actual python version we're running, let's just set it in CI for this fork that's only used by us


steps:
- uses: actions/checkout@master
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1
uses: actions/setup-python@v5
with:
version: ${{ matrix.python-version }}
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
Expand Down
8 changes: 6 additions & 2 deletions msg_parser/data_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,12 @@ def PtypRuleAction(data_value):

@staticmethod
def PtypBinary(data_value):
if data_value and b"\x00" in data_value:
data_value = data_value.replace(b"\x00", b"")
# PtypBinary (0x0102) is raw binary data: null bytes are significant
# content, not string padding. Stripping them corrupts the value -- most
# visibly the RtfCompressed body stream, whose LZFu header and compressed
# payload contain legitimate 0x00 bytes. Removing them shifts the header
# and makes compressed_rtf raise "Unknown type of RTF compression!".
# Return the bytes untouched.
return data_value

@staticmethod
Expand Down
21 changes: 11 additions & 10 deletions msg_parser/email_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,18 +54,19 @@ def build_email(self):
else:
self.message.add_header("reply-to", from_address)

# Required Email body content
# Email body content. Some valid messages legitimately have no body
# (subject-only sends, automated alerts, RTF-only shells whose text
# collapses to empty) yet still carry useful headers and attachments.
# Attach an empty plain-text part rather than raising, so those messages
# still export instead of dropping the whole file.
body_content = self.msg_obj.body
if body_content:
if "<html>" in body_content:
body_type = "html"
else:
body_type = "plain"

body = MIMEText(_text=body_content, _subtype=body_type, _charset="UTF-8")
self.message.attach(body)
if body_content and "<html>" in body_content:
body_type = "html"
else:
raise KeyError("Missing email body")
body_type = "plain"

body = MIMEText(_text=body_content or "", _subtype=body_type, _charset="UTF-8")
self.message.attach(body)

# Add message preamble
self.message.preamble = "You will not see this in a MIME-aware mail reader.\n"
Expand Down
37 changes: 30 additions & 7 deletions msg_parser/msg_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -436,13 +436,36 @@ def _set_properties(self):
self.body = self.body.decode("utf-8", "ignore")

if not self.body and "RtfCompressed" in property_values:
try:
import compressed_rtf
except ImportError:
compressed_rtf = None
if compressed_rtf:
compressed_rtf_body = property_values["RtfCompressed"]
self.body = compressed_rtf.decompress(compressed_rtf_body)
self.body = self._rtf_compressed_to_text(property_values["RtfCompressed"])

@staticmethod
def _rtf_compressed_to_text(compressed_rtf_body):
"""Turn a PR_RTF_COMPRESSED stream into the plain-text message body.

Returns a str (consistent with the Html/Body branches), or None when
compressed_rtf is not installed. An RTF formatting shell with no real
text yields "".

A genuinely corrupt/unsupported compressed stream is left to raise from
decompress(), so callers can still surface broken files. A striprtf
hiccup on otherwise-valid RTF falls back to the decoded RTF rather than
aborting the whole parse and losing already-readable headers/attachments.
"""
try:
import compressed_rtf
except ImportError:
return None
decompressed = compressed_rtf.decompress(compressed_rtf_body)
if isinstance(decompressed, bytes):
decompressed = decompressed.decode("utf-8", "ignore")
try:
from striprtf.striprtf import rtf_to_text
except ImportError:
return decompressed
try:
return rtf_to_text(decompressed)
except Exception:
return decompressed

def _set_recipients(self):
recipients = self._message.recipients
Expand Down
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# msg_parser requirements

olefile>=0.46

compressed_rtf>=1.0.5
striprtf>=0.0.32
3 changes: 0 additions & 3 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,4 @@
test_suite='tests',
tests_require=test_requirements,
zip_safe=False,
extras_require={
'rtf': ['compressed_rtf >= 1.0.5'],
},
)
18 changes: 18 additions & 0 deletions tests/test_data_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,24 @@ def test_get_multi_value_offsets(self):
self.assertEqual(offsets[:-1], offset_values) # Last offset is length of data
self.assertEqual(offsets[-1], len(test_value))

def test_ptyp_binary_preserves_null_bytes(self):
"""PtypBinary must return binary data unchanged, including embedded
null bytes.

Null bytes are significant content in binary properties (0x0102), not
string padding. The PR_RTF_COMPRESSED body stream in particular carries
legitimate 0x00 bytes in its LZFu header and compressed payload;
stripping them shifts the header and makes compressed_rtf raise
"Unknown type of RTF compression!". Upstream still strips here, so this
guards against the bug being reintroduced on a re-sync.
"""
# Shape mirrors a real LZFu compressed-RTF header (length, 'LZFu' magic
# at offset 8, CRC) -- full of meaningful null bytes.
value = b"\x85\x00\x00\x00\x09\x01\x00\x00LZFu\x21\x00\x21\xb4\x00\x00"
result = self.data_model.PtypBinary(value)
self.assertEqual(result, value)
self.assertEqual(result.count(b"\x00"), value.count(b"\x00"))

def test_get_value_with_data_type_name(self):
"""Test get_value method using data_type_name parameter."""
# Test with PtypInteger32
Expand Down
48 changes: 48 additions & 0 deletions tests/test_email_builder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""Tests for EmailFormatter body handling."""

import unittest

from msg_parser.email_builder import EmailFormatter


class _FakeAttachment:
AttachMimeTag = "application/pdf"
data = b"%PDF-1.4 fake attachment bytes"
Filename = "evidence.pdf"


class _FakeMsg:
"""Minimal stand-in exposing the attributes EmailFormatter.build_email reads."""

def __init__(self, body):
self.message_id = "<id@example.com>"
self.subject = "subject only message"
self.sent_date = "Mon, 13 Jan 2025 10:54:13 -0800"
self.sender = ["sender@example.com"]
self.reply_to = None
self.header_dict = {}
self.body = body
self.attachments = [_FakeAttachment()]


class TestEmptyBodyExport(unittest.TestCase):
"""Body-less messages must still export instead of dropping the whole file."""

def test_empty_body_still_builds_with_attachments(self):
"""A subject-only / RTF-shell message (empty or missing body) must build
an EML with its attachments intact, not raise KeyError."""
for empty_body in ("", None):
eml = EmailFormatter(_FakeMsg(empty_body)).build_email()
self.assertIsInstance(eml, str)
# an (empty) body part is attached rather than raising
self.assertIn("Content-Type: text/plain", eml)
# and the attachment survives
self.assertIn("Content-Disposition: attachment", eml)
self.assertIn("evidence.pdf", eml)


if __name__ == "__main__":
unittest.main()
53 changes: 53 additions & 0 deletions tests/test_rtf_body.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""Tests for compressed-RTF body handling in MsOxMessage._rtf_compressed_to_text."""

import unittest
from unittest import mock

import compressed_rtf

from msg_parser.msg_parser import MsOxMessage


class TestRtfCompressedToText(unittest.TestCase):
"""RTF-only message bodies must decode to text without taking down the parse."""

def test_empty_rtf_shell_collapses_to_empty_string(self):
"""An RTF formatting shell with no real text yields '' (so callers can
treat it as a body-less message), not raw RTF markup."""
shell = (
rb"{\rtf1\ansi\deff0{\fonttbl{\f0\fswiss Arial;}}"
rb"{\colortbl\red0\green0\blue0;}\f0\fs20 }"
)
body = MsOxMessage._rtf_compressed_to_text(compressed_rtf.compress(shell))
self.assertIsInstance(body, str)
self.assertEqual(body.strip(), "")

def test_real_rtf_text_is_extracted_as_string(self):
"""An RTF body with real text comes back as that plain text (a str),
never as raw bytes of RTF markup."""
doc = rb"{\rtf1\ansi\deff0{\fonttbl{\f0\fswiss Arial;}}\f0\fs20 Hello world.}"
body = MsOxMessage._rtf_compressed_to_text(compressed_rtf.compress(doc))
self.assertIsInstance(body, str)
self.assertIn("Hello world.", body)

def test_striprtf_runtime_error_falls_back_to_decoded_rtf(self):
"""If striprtf raises at runtime, keep the decoded RTF rather than
aborting the parse and losing already-readable headers/attachments."""
compressed = compressed_rtf.compress(rb"{\rtf1\ansi\deff0 hello}")
with mock.patch("striprtf.striprtf.rtf_to_text", side_effect=ValueError("boom")):
body = MsOxMessage._rtf_compressed_to_text(compressed)
self.assertIsInstance(body, str)
self.assertIn("rtf1", body) # decoded RTF preserved, no exception raised

def test_corrupt_stream_still_raises(self):
"""A genuinely unsupported/corrupt compressed stream must surface rather
than be silently swallowed, so callers can flag broken files."""
with self.assertRaises(Exception):
MsOxMessage._rtf_compressed_to_text(b"not a valid compressed rtf stream")


if __name__ == "__main__":
unittest.main()
Loading