From 07066feba9b27a890044ec59d9fb0d46dd82082a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Donncha=20=C3=93=20Cearbhaill?= Date: Wed, 19 Aug 2026 12:02:14 +0200 Subject: [PATCH] Add first and last interaction timeline events for WhatsApp chats Extract one record per ZWACHATSESSION with the first and last stored message dates, the session's own last-message date, the group creation date and message counts. Each chat produces chat_first_message and chat_last_message timeline events, and groups a group_created event. The session last-message date is preferred over the newest stored message because it survives message deletion. --- docs/ios/records.md | 2 +- src/mvt/ios/modules/mixed/whatsapp.py | 130 +++++++++++++++++- .../7c7fba66680ef796b916b067077cc246adacf01d | Bin 0 -> 24576 bytes tests/ios_backup/test_whatsapp.py | 54 ++++++++ tests/ios_fs/test_filesystem.py | 8 +- 5 files changed, 187 insertions(+), 7 deletions(-) create mode 100644 tests/artifacts/ios_backup/7c/7c7fba66680ef796b916b067077cc246adacf01d diff --git a/docs/ios/records.md b/docs/ios/records.md index 10ddab9..909b627 100644 --- a/docs/ios/records.md +++ b/docs/ios/records.md @@ -417,7 +417,7 @@ If indicators are provided through the command-line, they are checked against th Backup: :material-check: Full filesystem dump: :material-check: -This JSON file is created by mvt-ios' `WhatsApp` module. The module extracts a list of WhatsApp messages from the SQLite database located at *private/var/mobile/Containers/Shared/AppGroup/\*/ChatStorage.sqlite*. +This JSON file is created by mvt-ios' `WhatsApp` module. The module extracts a list of WhatsApp messages from the SQLite database located at *private/var/mobile/Containers/Shared/AppGroup/\*/ChatStorage.sqlite*, along with one record per chat session (marked with `"record_type": "chat_session"`) containing the first and last interaction dates of each conversation. Chat sessions produce `chat_first_message` and `chat_last_message` timeline events, and group chats additionally produce a `group_created` event. A chat session's last-message date can postdate its newest stored message when the most recent messages in the chat were deleted. If indicators are provided through the command-line, they are checked against the extracted HTTP links. Any matches are stored in *whatsapp_detected.json*. diff --git a/src/mvt/ios/modules/mixed/whatsapp.py b/src/mvt/ios/modules/mixed/whatsapp.py index 0a80aad..73d8fba 100644 --- a/src/mvt/ios/modules/mixed/whatsapp.py +++ b/src/mvt/ios/modules/mixed/whatsapp.py @@ -4,6 +4,7 @@ # https://license.mvt.re/1.1/ import logging +import sqlite3 from typing import Optional from mvt.common.module_types import ( @@ -22,9 +23,50 @@ WHATSAPP_ROOT_PATHS = [ "private/var/mobile/Containers/Shared/AppGroup/*/ChatStorage.sqlite", ] +CHAT_SESSIONS_QUERY = """ + SELECT + ZWACHATSESSION.Z_PK AS "session_pk", + ZWACHATSESSION.ZCONTACTJID AS "contact_jid", + ZWACHATSESSION.ZPARTNERNAME AS "partner_name", + ZWACHATSESSION.ZSESSIONTYPE AS "session_type", + ZWACHATSESSION.ZARCHIVED AS "archived", + ZWACHATSESSION.ZREMOVED AS "removed", + ZWACHATSESSION.ZMESSAGECOUNTER AS "message_counter", + ZWACHATSESSION.ZLASTMESSAGEDATE AS "last_message_date", + ZWAGROUPINFO.ZCREATIONDATE AS "group_creation_date", + MIN(ZWAMESSAGE.ZMESSAGEDATE) AS "first_stored_message_date", + MAX(ZWAMESSAGE.ZMESSAGEDATE) AS "last_stored_message_date", + COUNT(ZWAMESSAGE.Z_PK) AS "stored_message_count" + FROM ZWACHATSESSION + LEFT JOIN ZWAGROUPINFO + ON ZWACHATSESSION.ZGROUPINFO = ZWAGROUPINFO.Z_PK + LEFT JOIN ZWAMESSAGE + ON ZWAMESSAGE.ZCHATSESSION = ZWACHATSESSION.Z_PK + GROUP BY ZWACHATSESSION.Z_PK; +""" + +CHAT_SESSION_DATE_FIELDS = [ + "last_message_date", + "group_creation_date", + "first_stored_message_date", + "last_stored_message_date", +] + + +def _describe_chat(record: ModuleAtomicResult) -> str: + jid = record.get("contact_jid") or "unknown" + name = record.get("partner_name") + label = f"'{name}' ({jid})" if name else jid + is_group = jid.endswith("@g.us") or record.get("group_creation_date") + if is_group: + return f"WhatsApp group chat {label}" + return f"WhatsApp chat with {label}" + class Whatsapp(IOSExtraction): - """This module extracts all WhatsApp messages containing links.""" + """This module extracts all WhatsApp messages containing links, as well + as per-chat records with the first and last interaction dates of each + conversation.""" def __init__( self, @@ -45,6 +87,9 @@ class Whatsapp(IOSExtraction): ) def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult: + if record.get("record_type") == "chat_session": + return self._serialize_chat_session(record) + text = record.get("ZTEXT", "").replace("\n", "\\n") links_text = "" if record.get("links"): @@ -57,6 +102,49 @@ class Whatsapp(IOSExtraction): "data": f"'{text}' from {record.get('ZFROMJID', 'Unknown')}{links_text}", } + def _serialize_chat_session( + self, record: ModuleAtomicResult + ) -> ModuleSerializedResult: + records = [] + chat = _describe_chat(record) + + if record.get("group_creation_date"): + records.append( + { + "timestamp": record["group_creation_date"], + "module": self.__class__.__name__, + "event": "group_created", + "data": f"{chat} was created", + } + ) + + if record.get("first_stored_message_date"): + records.append( + { + "timestamp": record["first_stored_message_date"], + "module": self.__class__.__name__, + "event": "chat_first_message", + "data": f"First stored message in {chat}", + } + ) + + # The chat session's own last-message date is authoritative: it can + # postdate the newest stored message if that message was deleted. + last_message_date = record.get("last_message_date") or record.get( + "last_stored_message_date" + ) + if last_message_date: + records.append( + { + "timestamp": last_message_date, + "module": self.__class__.__name__, + "event": "chat_last_message", + "data": f"Last message in {chat}", + } + ) + + return records + def check_indicators(self) -> None: if not self.indicators: return @@ -146,7 +234,45 @@ class Whatsapp(IOSExtraction): message["links"] = list(set(filtered_links)) self.results.append(message) + total_messages = len(self.results) + total_sessions = self._extract_chat_sessions(cur) + cur.close() conn.close() - self.log.info("Extracted a total of %d WhatsApp messages", len(self.results)) + self.log.info( + "Extracted a total of %d WhatsApp messages and %d chat sessions", + total_messages, + total_sessions, + ) + + def _extract_chat_sessions(self, cur: sqlite3.Cursor) -> int: + """Extract one record per chat session with the first and last + interaction dates of each conversation.""" + try: + cur.execute(CHAT_SESSIONS_QUERY) + except sqlite3.OperationalError as exc: + self.log.warning( + "Unable to extract WhatsApp chat sessions: %s", exc + ) + return 0 + + names = [description[0] for description in cur.description] + + total_sessions = 0 + for row in cur: + session = dict(zip(names, row)) + session["record_type"] = "chat_session" + + for field in CHAT_SESSION_DATE_FIELDS: + if session.get(field): + session[field] = ( + convert_mactime_to_iso(session[field]) or None + ) + else: + session[field] = None + + self.results.append(session) + total_sessions += 1 + + return total_sessions diff --git a/tests/artifacts/ios_backup/7c/7c7fba66680ef796b916b067077cc246adacf01d b/tests/artifacts/ios_backup/7c/7c7fba66680ef796b916b067077cc246adacf01d new file mode 100644 index 0000000000000000000000000000000000000000..10a26a644291b9d707edb56d490f8a05506470fa GIT binary patch literal 24576 zcmeI(!B5jr90%~%Zetq@Ok?5>Udn;s64Oz@nCL;C-D9h)>+Ie{NDPdn0y@}aBX|^u zR}&BZDPBBz^WfQ|{{VOIzHS{Wr8_J!#*ps|tdIA5@AcQu>swM7)n}!>uA{ta9q!k= z$|AWzB#A653L#{|`xb{@iHX;!S9cGRuRf|eLDbJbrbINBx=p6fr34sjPi=*uTSVUqns(6J}EK9A5rd3 zDqh*xtgI?VnX>}3m5Obc)LvIs+4{X<54}ivl~t>TRUY*8N8OW%P~JV2sF!*Jv!}14 z@k}-=eMk>-^i3mj@%%9od+R_HBfGI6S!UGimpdP|CD zW@eF6zG-_@?$kBlek5 zbx$pJrZ%9d*0$jmb%pB-Dr|lEeP^e2-3 zE?!U|009U<00Izz00bZa0SG_<0ucB&0!wm=Jeo@;5(z1q)E4G17j1b*J?gOjXa1GK zVe6