From a180bc82349cda73daa37924702a7e4c1c7f4a69 Mon Sep 17 00:00:00 2001 From: osku Date: Thu, 12 Dec 2024 14:59:40 +0200 Subject: [PATCH 01/10] Add jpeg exif parser --- plaso/data/formatters/generic.yaml | 20 +++ plaso/data/timeliner.yaml | 6 + plaso/parsers/__init__.py | 1 + plaso/parsers/jpeg_exif.py | 275 +++++++++++++++++++++++++++++ test_data/testimage.jpg | Bin 0 -> 35960 bytes tests/parsers/jpeg_exif.py | 51 ++++++ 6 files changed, 353 insertions(+) create mode 100644 plaso/parsers/jpeg_exif.py create mode 100644 test_data/testimage.jpg create mode 100644 tests/parsers/jpeg_exif.py diff --git a/plaso/data/formatters/generic.yaml b/plaso/data/formatters/generic.yaml index 146029666e..300600fdde 100644 --- a/plaso/data/formatters/generic.yaml +++ b/plaso/data/formatters/generic.yaml @@ -489,6 +489,26 @@ short_source: 'JAVA_IDX' source: 'Java Cache IDX' --- type: 'conditional' +data_type: 'jpeg:exif' +message: +- 'Width: {image_width}' +- 'Height: {image_height}' +- 'Manufacturer: {manufacturer}' +- 'Model: {model}' +- 'Serial: {bodyserialnumber}' +- 'Software: {software}' +- 'Latitude: {latitude}' +- 'Longitude: {longitude}' +short_message: +- 'Width: {image_width}' +- 'Height: {image_height}' +- 'Manufacturer: {manufacturer}' +- 'Model: {model}' +- 'Software: {software}' +short_source: 'JPEG_EXIF' +source: 'JPEG EXIF Metadata' +--- +type: 'conditional' data_type: 'kodi:videos:viewing' message: - 'Video: {filename}' diff --git a/plaso/data/timeliner.yaml b/plaso/data/timeliner.yaml index eada32f665..1844a9ab22 100644 --- a/plaso/data/timeliner.yaml +++ b/plaso/data/timeliner.yaml @@ -699,6 +699,12 @@ attribute_mappings: description: 'Content Modification Time' place_holder_event: true --- +data_type: 'jpeg:exif' +attribute_mappings: +- name: 'exif_datetime' + description: 'EXIF datetime' +place_holder_event: true +--- data_type: 'kodi:videos:viewing' attribute_mappings: - name: 'last_played_time' diff --git a/plaso/parsers/__init__.py b/plaso/parsers/__init__.py index 3fdd8f9418..cbc033a953 100644 --- a/plaso/parsers/__init__.py +++ b/plaso/parsers/__init__.py @@ -17,6 +17,7 @@ from plaso.parsers import fseventsd from plaso.parsers import ios_discord from plaso.parsers import java_idx +from plaso.parsers import jpeg_exif from plaso.parsers import jsonl_parser from plaso.parsers import locate from plaso.parsers import macos_keychain diff --git a/plaso/parsers/jpeg_exif.py b/plaso/parsers/jpeg_exif.py new file mode 100644 index 0000000000..b2d40184b4 --- /dev/null +++ b/plaso/parsers/jpeg_exif.py @@ -0,0 +1,275 @@ +# -*- coding: utf-8 -*- +"""Parser for JPG exif data.""" + +from plaso.parsers import interface +from plaso.parsers import manager +from plaso.containers import events + +from datetime import datetime +from dfdatetime import posix_time as dfdatetime_posix_time + +import struct # for inline exif parser + +# Mapping of Exif tag IDs to human-readable names +exiftags = { + 0x0100: "Image Width", + 0x0101: "Image Height", + 0x0102: "Bits per Sample", + 0x0103: "Compression", + 0x0106: "Photometric Interpre", + 0x010f: "Manufacturer", + 0x0110: "Model", + 0x0112: "Orientation", + 0x0115: "Samples per Pixel", + 0x011a: "X-Resolution", + 0x011b: "Y-Resolution", + 0x011c: "Planar Configuration", + 0x0128: "Resolution Unit", + 0x0131: "Software", + 0x0132: "Date and Time", + 0x014a: "SubIFD Offsets", + 0x02bc: "XML Packet", + 0x829a: "Exposure Time", + 0x829d: "F-Number", + 0x8822: "Exposure Program", + 0x8827: "ISO Speed Ratings", + 0x9000: "Exif Version", + 0x9003: "Date and Time", + 0x9004: "Date and Time (modified)", + 0x9204: "Exposure Bias", + 0x9205: "Maximum Aperture Val", + 0x9207: "Metering Mode", + 0x9208: "Light Source", + 0x9209: "Flash", + 0x920a: "Focal Length", + 0xa300: "File Source", + 0xa301: "Scene Type", + 0xa402: "Exposure Mode", + 0xa403: "White Balance", + 0xa404: "Digital Zoom Ratio", + 0xa405: "Focal Length in 35mm", + 0xa406: "Scene Capture Type", + 0xa407: "Gain Control", + 0xa408: "Contrast", + 0xa409: "Saturation", + 0xa40a: "Sharpness", + 0xa431: "Body Serial Number", + 0xa432: "Lens Specification", + 0xa000: "FlashPixVersion", + 0xa001: "Color Space", + 0x0000: "GPS Tag Version", + 0x0001: "Latitude Reference", + 0x0002: "Latitude", + 0x0003: "Longitude Reference", + 0x0004: "Longitude", + 0x0005: "Altitude Reference", + 0x0006: "Altitude", + 0x0009: "GPS Receiver Status", + 0x0012: "Geodetic Survey Data", + 0xc62f: "Body Serial Number", + 0xc614: "Make and Model" +} + +def parse_exif(file_object): + data = file_object.read() + + # Verify JPEG SOI marker + if data[:2] != b'\xFF\xD8': + raise ValueError("Not a valid JPEG file") + + # Look for APP1 (Exif) marker + offset = 2 + while offset < len(data): + marker, length = struct.unpack(">2sH", data[offset:offset+4]) + if marker == b'\xFF\xE1': # APP1 marker + exif_data = data[offset+4:offset+4+length-2] # Skip the marker length + if exif_data[:6] == b'Exif\x00\x00': + return parse_tiff(exif_data[6:]) + offset += length + 2 + + raise ValueError("Exif data not found") + +def parse_tiff(data): + # Verify TIFF header + endianness = data[:2] + if endianness == b'II': # Little endian + endian = "<" + elif endianness == b'MM': # Big endian + endian = ">" + else: + raise ValueError("Invalid TIFF header") + + # Verify TIFF magic number + magic_number = struct.unpack(endian + "H", data[2:4])[0] + if magic_number != 0x002A: + raise ValueError("Invalid TIFF magic number") + + # Get offset to first IFD (Image File Directory) + ifd_offset = struct.unpack(endian + "I", data[4:8])[0] + + # Parse IFD + return parse_ifd(data, ifd_offset, endian, base_offset=0) + +def parse_ifd(data, offset, endian, base_offset): + # Number of directory entries + num_entries = struct.unpack(endian + "H", data[offset:offset+2])[0] + entries = {} + for i in range(num_entries): + entry_offset = offset + 2 + i * 12 + tag, tag_type, count, value_offset = struct.unpack(endian + "HHII", data[entry_offset:entry_offset+12]) + + # Interpret the value based on the tag type + absolute_offset = base_offset + value_offset + # short strings! they are inline after the entry offset values + if tag_type == 2 and count * size_of_type(tag_type) <= 4: + value = data[entry_offset+8:entry_offset+8+count].rstrip(b'\x00').decode('ascii','ignore') + elif count * size_of_type(tag_type) <= 4: # other inline data + value = extract_inline_data(value_offset, tag_type, endian) + else: # Normal data at offset + value = extract_value(data, absolute_offset, tag_type, count, endian) + if tag == 0x0002 or tag == 0x0004 : #gps latlon, convert from deg/min/sec to decimal degrees + value = value[0] + value[1] / 60.0 + value[2] / 3600.0 + elif count==1: + value=value[0] + + # Check for subdirectories (ExifIFDPointer, GPSInfo) + if tag == 0x8769 or tag == 0x8825: # Subdirectory pointers + sub_ifd = parse_ifd(data, base_offset + value_offset, endian, base_offset) + entries.update(sub_ifd) + else: + entries[tag] = value + + return entries + +def size_of_type(tag_type): + sizes = {1: 1, 2: 1, 3: 2, 4: 4, 5: 8} # BYTE, ASCII, SHORT, LONG, RATIONAL + return sizes.get(tag_type, 0) + +def extract_inline_data(value_offset, tag_type, endian): + if tag_type == 3: # SHORT + return struct.unpack(endian + "H", struct.pack(endian + "I", value_offset)[:2])[0] + elif tag_type == 4: # LONG + return struct.unpack(endian + "I", struct.pack(endian + "I", value_offset))[0] + elif tag_type == 1: # BYTE + return value_offset & 0xFF + elif tag_type == 2: # ASCII + return chr(value_offset & 0xFF) # Interpret a single byte as ASCII + return value_offset + +def format_value(tag, value, tag_type, count, endian): + if tag_type == 2: # ASCII + return value if isinstance(value, str) else "".join(map(chr, value)) + elif tag_type == 3 or tag_type == 4: # SHORT or LONG + return value# if count > 1 else value[0] + elif tag_type == 5: # RATIONAL + return [f"{num}/{den}" for num, den in value] + return value + +def extract_value(data, offset, tag_type, count, endian): + if tag_type == 1: # BYTE + return data[offset:offset+count] + elif tag_type == 2: # ASCII + return data[offset:offset+count].rstrip(b'\x00').decode('utf-8') + elif tag_type == 3: # SHORT (2 bytes) + return struct.unpack(endian + f"{count}H", data[offset:offset+count*2]) + elif tag_type == 4: # LONG (4 bytes) + return struct.unpack(endian + f"{count}I", data[offset:offset+count*4]) + elif tag_type == 5: # RATIONAL (two LONGs: numerator/denominator) + # Extract all LONGs (numerator/denominator pairs) + raw_values = struct.unpack(endian + f"{count * 2}I", data[offset:offset + count * 8]) + # Convert to floats (numerator/denominator) + return [raw_values[i] / raw_values[i + 1] if raw_values[i + 1] != 0 else None for i in range(0, len(raw_values), 2)] + return None + +def annotated_exif(values): + r = {} + for tag, value in values.items(): + tag_name = exiftags.get(tag, f"Unknown tag ({hex(tag)})") + if tag_name == 'XML Packet':continue + r[tag_name] = value + return r + + +class JpegExifEventData(events.EventData): + """JPEG EXIF event data. + + Attributes: + exif_datetime (dfdatetime.DateTimeValues): exif datetime + image_width: image width + image_height: image height + software: software + xres: X resolution + yres: Y resolution + manufacturer: Manufacturer/Make + model: Model + latitude: Latitude + longitude: Longitude + bodyserialnumber: Body serial number + """ + + DATA_TYPE = 'jpeg:exif' + + def __init__(self): + super(JpegExifEventData, self).__init__(data_type=self.DATA_TYPE) + self.exif_datetime = None + self.width = None + self.height = None + self.software = None + self.xres = None + self.yres = None + self.manufacturer = None + self.model = None + self.latitude = None + self.longitude = None + self.bodyserialnumber = None + + +class JpegExifParser(interface.FileObjectParser): + """Parser for JPEG EXIF data""" + + NAME='jpeg_exif' + DATA_FORMAT = 'JPEG file' + + def _ExtractExifMetadata(self, file_object): + event_data = JpegExifEventData() + + ed = annotated_exif(parse_exif(file_object)) + print(ed) + print(type(ed)) + #print(json.dumps(dict(ed), sort_keys=True, indent=4)) + key = 'Date and Time' + if key in ed.keys(): + ts = str(ed.get(key)) + dt = datetime.strptime(ts, "%Y:%m:%d %H:%M:%S") + posix_timestamp = int(dt.timestamp()) + event_data.exif_datetime = dfdatetime_posix_time.PosixTime(timestamp=posix_timestamp) + event_data.makeandmodel = ed.get('Make and Model', 'Unknown') + event_data.manufacturer = ed.get('Manufacturer', 'Unknown') + event_data.model = str(ed.get('Model')) + event_data.xres = str(ed.get('X-Resolution')) + event_data.yres = str(ed.get('Y-Resolution')) + event_data.width = str(ed.get('Image Width')) + event_data.height = str(ed.get('Image Height')) + event_data.software = str(ed.get('Software')) + event_data.bodyserialnumber = str(ed.get('Body Serial Number')) + event_data.latitude = "%2.5f%s"%(ed.get('Latitude', 0.0), ed.get('Latitude Reference','')) + event_data.longitude = "%2.5f%s"%(ed.get('Longitude', 0.0), ed.get('Longitude Reference', '')) + return event_data + + + def ParseFileObject(self, parser_mediator, file_object, **kwargs): + """Parses a JPEG file. + + Args: + parser_mediator (ParserMediator): parser mediator. + file_object (dfvfx.FileIO): file-like object to be parsed. + + Raises: + WrongParser: ... + """ + + event_data = self._ExtractExifMetadata(file_object) + parser_mediator.ProduceEventData(event_data) + +manager.ParsersManager.RegisterParser(JpegExifParser) + diff --git a/test_data/testimage.jpg b/test_data/testimage.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3034dd6431fa0e6b2874530f46d2b3b0f1b9da34 GIT binary patch literal 35960 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 literal 0 HcmV?d00001 diff --git a/tests/parsers/jpeg_exif.py b/tests/parsers/jpeg_exif.py new file mode 100644 index 0000000000..47b7984d17 --- /dev/null +++ b/tests/parsers/jpeg_exif.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Tests for the JPEG EXIF parser.""" + +import unittest + +from plaso.parsers import jpeg_exif + +from tests.parsers import test_lib + + +class JpegExifParserTest(test_lib.ParserTestCase): + """Tests for the JPEG EXIF parser.""" + + def testParseFile(self): + """Tests the ParseFile function on a JPEG file.""" + parser = jpeg_exif.JpegExifParser() + storage_writer = self._ParseFile(['testimage.jpg'], parser) + + number_of_event_data = storage_writer.GetNumberOfAttributeContainers( + 'event_data') + self.assertEqual(number_of_event_data, 1) + + number_of_warnings = storage_writer.GetNumberOfAttributeContainers( + 'extraction_warning') + self.assertEqual(number_of_warnings, 0) + + number_of_warnings = storage_writer.GetNumberOfAttributeContainers( + 'recovery_warning') + self.assertEqual(number_of_warnings, 0) + + expected_event_values = { + "bodyserialnumber": "53HQK520M10K6U", + "data_type": "jpeg:exif", + "latitude": "68.87571N", + "longitude": "27.76492E", + "manufacturer": "DJI", + "model": "FC3582", + "software": "RawTherapee 5.9", + "width": "4024", + "height": "3016", + "xres": "300.0", + "yres": "300.0"} + + event_data = storage_writer.GetAttributeContainerByIndex('event_data', 0) + print(str(event_data.__dict__)) + self.CheckEventData(event_data, expected_event_values) + + +if __name__ == '__main__': + unittest.main() From cb046a03fbffc02b04dd3256cd7e3b0ee9bd0c0a Mon Sep 17 00:00:00 2001 From: osku Date: Fri, 13 Dec 2024 09:01:18 +0200 Subject: [PATCH 02/10] width/height are integers, not strings --- plaso/parsers/jpeg_exif.py | 4 ++-- tests/parsers/jpeg_exif.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plaso/parsers/jpeg_exif.py b/plaso/parsers/jpeg_exif.py index b2d40184b4..b4cc6096ce 100644 --- a/plaso/parsers/jpeg_exif.py +++ b/plaso/parsers/jpeg_exif.py @@ -248,8 +248,8 @@ def _ExtractExifMetadata(self, file_object): event_data.model = str(ed.get('Model')) event_data.xres = str(ed.get('X-Resolution')) event_data.yres = str(ed.get('Y-Resolution')) - event_data.width = str(ed.get('Image Width')) - event_data.height = str(ed.get('Image Height')) + event_data.width = ed.get('Image Width') + event_data.height = ed.get('Image Height') event_data.software = str(ed.get('Software')) event_data.bodyserialnumber = str(ed.get('Body Serial Number')) event_data.latitude = "%2.5f%s"%(ed.get('Latitude', 0.0), ed.get('Latitude Reference','')) diff --git a/tests/parsers/jpeg_exif.py b/tests/parsers/jpeg_exif.py index 47b7984d17..05e3d574a3 100644 --- a/tests/parsers/jpeg_exif.py +++ b/tests/parsers/jpeg_exif.py @@ -37,8 +37,8 @@ def testParseFile(self): "manufacturer": "DJI", "model": "FC3582", "software": "RawTherapee 5.9", - "width": "4024", - "height": "3016", + "width": 4024, + "height": 3016, "xres": "300.0", "yres": "300.0"} From 7f326ddf602b1c01d34029e17850188b1c6507b7 Mon Sep 17 00:00:00 2001 From: osku Date: Sun, 15 Dec 2024 15:09:54 +0200 Subject: [PATCH 03/10] minor style cleanup --- plaso/parsers/jpeg_exif.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/plaso/parsers/jpeg_exif.py b/plaso/parsers/jpeg_exif.py index b4cc6096ce..72f528f70c 100644 --- a/plaso/parsers/jpeg_exif.py +++ b/plaso/parsers/jpeg_exif.py @@ -210,6 +210,7 @@ class JpegExifEventData(events.EventData): DATA_TYPE = 'jpeg:exif' def __init__(self): + """Initializes a JpegExif event data.""" super(JpegExifEventData, self).__init__(data_type=self.DATA_TYPE) self.exif_datetime = None self.width = None @@ -231,12 +232,12 @@ class JpegExifParser(interface.FileObjectParser): DATA_FORMAT = 'JPEG file' def _ExtractExifMetadata(self, file_object): - event_data = JpegExifEventData() + return parse_exif(file_object) - ed = annotated_exif(parse_exif(file_object)) - print(ed) - print(type(ed)) - #print(json.dumps(dict(ed), sort_keys=True, indent=4)) + def _BuildEventData(self, parsed_jpeg_exif): + ed = annotated_exif(parsed_jpeg_exif) + event_data = JpegExifEventData() + key = 'Date and Time' if key in ed.keys(): ts = str(ed.get(key)) @@ -255,7 +256,6 @@ def _ExtractExifMetadata(self, file_object): event_data.latitude = "%2.5f%s"%(ed.get('Latitude', 0.0), ed.get('Latitude Reference','')) event_data.longitude = "%2.5f%s"%(ed.get('Longitude', 0.0), ed.get('Longitude Reference', '')) return event_data - def ParseFileObject(self, parser_mediator, file_object, **kwargs): """Parses a JPEG file. @@ -268,7 +268,8 @@ def ParseFileObject(self, parser_mediator, file_object, **kwargs): WrongParser: ... """ - event_data = self._ExtractExifMetadata(file_object) + parsed_jpeg_exif = self._ExtractExifMetadata(file_object) + event_data = self._BuildEventData(parsed_jpeg_exif) parser_mediator.ProduceEventData(event_data) manager.ParsersManager.RegisterParser(JpegExifParser) From 1ebd9a0a2608627e1422f14838adc85897a0780c Mon Sep 17 00:00:00 2001 From: osku Date: Sun, 15 Dec 2024 15:16:40 +0200 Subject: [PATCH 04/10] The JPEG EXIF parser is now inline and defined inside the _ExtractExifMetadata method --- plaso/parsers/jpeg_exif.py | 391 +++++++++++++++++++------------------ 1 file changed, 197 insertions(+), 194 deletions(-) diff --git a/plaso/parsers/jpeg_exif.py b/plaso/parsers/jpeg_exif.py index 72f528f70c..d120d10545 100644 --- a/plaso/parsers/jpeg_exif.py +++ b/plaso/parsers/jpeg_exif.py @@ -11,183 +11,6 @@ import struct # for inline exif parser # Mapping of Exif tag IDs to human-readable names -exiftags = { - 0x0100: "Image Width", - 0x0101: "Image Height", - 0x0102: "Bits per Sample", - 0x0103: "Compression", - 0x0106: "Photometric Interpre", - 0x010f: "Manufacturer", - 0x0110: "Model", - 0x0112: "Orientation", - 0x0115: "Samples per Pixel", - 0x011a: "X-Resolution", - 0x011b: "Y-Resolution", - 0x011c: "Planar Configuration", - 0x0128: "Resolution Unit", - 0x0131: "Software", - 0x0132: "Date and Time", - 0x014a: "SubIFD Offsets", - 0x02bc: "XML Packet", - 0x829a: "Exposure Time", - 0x829d: "F-Number", - 0x8822: "Exposure Program", - 0x8827: "ISO Speed Ratings", - 0x9000: "Exif Version", - 0x9003: "Date and Time", - 0x9004: "Date and Time (modified)", - 0x9204: "Exposure Bias", - 0x9205: "Maximum Aperture Val", - 0x9207: "Metering Mode", - 0x9208: "Light Source", - 0x9209: "Flash", - 0x920a: "Focal Length", - 0xa300: "File Source", - 0xa301: "Scene Type", - 0xa402: "Exposure Mode", - 0xa403: "White Balance", - 0xa404: "Digital Zoom Ratio", - 0xa405: "Focal Length in 35mm", - 0xa406: "Scene Capture Type", - 0xa407: "Gain Control", - 0xa408: "Contrast", - 0xa409: "Saturation", - 0xa40a: "Sharpness", - 0xa431: "Body Serial Number", - 0xa432: "Lens Specification", - 0xa000: "FlashPixVersion", - 0xa001: "Color Space", - 0x0000: "GPS Tag Version", - 0x0001: "Latitude Reference", - 0x0002: "Latitude", - 0x0003: "Longitude Reference", - 0x0004: "Longitude", - 0x0005: "Altitude Reference", - 0x0006: "Altitude", - 0x0009: "GPS Receiver Status", - 0x0012: "Geodetic Survey Data", - 0xc62f: "Body Serial Number", - 0xc614: "Make and Model" -} - -def parse_exif(file_object): - data = file_object.read() - - # Verify JPEG SOI marker - if data[:2] != b'\xFF\xD8': - raise ValueError("Not a valid JPEG file") - - # Look for APP1 (Exif) marker - offset = 2 - while offset < len(data): - marker, length = struct.unpack(">2sH", data[offset:offset+4]) - if marker == b'\xFF\xE1': # APP1 marker - exif_data = data[offset+4:offset+4+length-2] # Skip the marker length - if exif_data[:6] == b'Exif\x00\x00': - return parse_tiff(exif_data[6:]) - offset += length + 2 - - raise ValueError("Exif data not found") - -def parse_tiff(data): - # Verify TIFF header - endianness = data[:2] - if endianness == b'II': # Little endian - endian = "<" - elif endianness == b'MM': # Big endian - endian = ">" - else: - raise ValueError("Invalid TIFF header") - - # Verify TIFF magic number - magic_number = struct.unpack(endian + "H", data[2:4])[0] - if magic_number != 0x002A: - raise ValueError("Invalid TIFF magic number") - - # Get offset to first IFD (Image File Directory) - ifd_offset = struct.unpack(endian + "I", data[4:8])[0] - - # Parse IFD - return parse_ifd(data, ifd_offset, endian, base_offset=0) - -def parse_ifd(data, offset, endian, base_offset): - # Number of directory entries - num_entries = struct.unpack(endian + "H", data[offset:offset+2])[0] - entries = {} - for i in range(num_entries): - entry_offset = offset + 2 + i * 12 - tag, tag_type, count, value_offset = struct.unpack(endian + "HHII", data[entry_offset:entry_offset+12]) - - # Interpret the value based on the tag type - absolute_offset = base_offset + value_offset - # short strings! they are inline after the entry offset values - if tag_type == 2 and count * size_of_type(tag_type) <= 4: - value = data[entry_offset+8:entry_offset+8+count].rstrip(b'\x00').decode('ascii','ignore') - elif count * size_of_type(tag_type) <= 4: # other inline data - value = extract_inline_data(value_offset, tag_type, endian) - else: # Normal data at offset - value = extract_value(data, absolute_offset, tag_type, count, endian) - if tag == 0x0002 or tag == 0x0004 : #gps latlon, convert from deg/min/sec to decimal degrees - value = value[0] + value[1] / 60.0 + value[2] / 3600.0 - elif count==1: - value=value[0] - - # Check for subdirectories (ExifIFDPointer, GPSInfo) - if tag == 0x8769 or tag == 0x8825: # Subdirectory pointers - sub_ifd = parse_ifd(data, base_offset + value_offset, endian, base_offset) - entries.update(sub_ifd) - else: - entries[tag] = value - - return entries - -def size_of_type(tag_type): - sizes = {1: 1, 2: 1, 3: 2, 4: 4, 5: 8} # BYTE, ASCII, SHORT, LONG, RATIONAL - return sizes.get(tag_type, 0) - -def extract_inline_data(value_offset, tag_type, endian): - if tag_type == 3: # SHORT - return struct.unpack(endian + "H", struct.pack(endian + "I", value_offset)[:2])[0] - elif tag_type == 4: # LONG - return struct.unpack(endian + "I", struct.pack(endian + "I", value_offset))[0] - elif tag_type == 1: # BYTE - return value_offset & 0xFF - elif tag_type == 2: # ASCII - return chr(value_offset & 0xFF) # Interpret a single byte as ASCII - return value_offset - -def format_value(tag, value, tag_type, count, endian): - if tag_type == 2: # ASCII - return value if isinstance(value, str) else "".join(map(chr, value)) - elif tag_type == 3 or tag_type == 4: # SHORT or LONG - return value# if count > 1 else value[0] - elif tag_type == 5: # RATIONAL - return [f"{num}/{den}" for num, den in value] - return value - -def extract_value(data, offset, tag_type, count, endian): - if tag_type == 1: # BYTE - return data[offset:offset+count] - elif tag_type == 2: # ASCII - return data[offset:offset+count].rstrip(b'\x00').decode('utf-8') - elif tag_type == 3: # SHORT (2 bytes) - return struct.unpack(endian + f"{count}H", data[offset:offset+count*2]) - elif tag_type == 4: # LONG (4 bytes) - return struct.unpack(endian + f"{count}I", data[offset:offset+count*4]) - elif tag_type == 5: # RATIONAL (two LONGs: numerator/denominator) - # Extract all LONGs (numerator/denominator pairs) - raw_values = struct.unpack(endian + f"{count * 2}I", data[offset:offset + count * 8]) - # Convert to floats (numerator/denominator) - return [raw_values[i] / raw_values[i + 1] if raw_values[i + 1] != 0 else None for i in range(0, len(raw_values), 2)] - return None - -def annotated_exif(values): - r = {} - for tag, value in values.items(): - tag_name = exiftags.get(tag, f"Unknown tag ({hex(tag)})") - if tag_name == 'XML Packet':continue - r[tag_name] = value - return r class JpegExifEventData(events.EventData): @@ -232,29 +55,209 @@ class JpegExifParser(interface.FileObjectParser): DATA_FORMAT = 'JPEG file' def _ExtractExifMetadata(self, file_object): - return parse_exif(file_object) - - def _BuildEventData(self, parsed_jpeg_exif): - ed = annotated_exif(parsed_jpeg_exif) + """Parses EXIF metadata embedded in a JPEG file. Returns a dictionary of the values found.""" + exiftags = { + 0x0100: "Image Width", + 0x0101: "Image Height", + 0x0102: "Bits per Sample", + 0x0103: "Compression", + 0x0106: "Photometric Interpre", + 0x010f: "Manufacturer", + 0x0110: "Model", + 0x0112: "Orientation", + 0x0115: "Samples per Pixel", + 0x011a: "X-Resolution", + 0x011b: "Y-Resolution", + 0x011c: "Planar Configuration", + 0x0128: "Resolution Unit", + 0x0131: "Software", + 0x0132: "Date and Time", + 0x014a: "SubIFD Offsets", + 0x02bc: "XML Packet", + 0x829a: "Exposure Time", + 0x829d: "F-Number", + 0x8822: "Exposure Program", + 0x8827: "ISO Speed Ratings", + 0x9000: "Exif Version", + 0x9003: "Date and Time", + 0x9004: "Date and Time (modified)", + 0x9204: "Exposure Bias", + 0x9205: "Maximum Aperture Val", + 0x9207: "Metering Mode", + 0x9208: "Light Source", + 0x9209: "Flash", + 0x920a: "Focal Length", + 0xa300: "File Source", + 0xa301: "Scene Type", + 0xa402: "Exposure Mode", + 0xa403: "White Balance", + 0xa404: "Digital Zoom Ratio", + 0xa405: "Focal Length in 35mm", + 0xa406: "Scene Capture Type", + 0xa407: "Gain Control", + 0xa408: "Contrast", + 0xa409: "Saturation", + 0xa40a: "Sharpness", + 0xa431: "Body Serial Number", + 0xa432: "Lens Specification", + 0xa000: "FlashPixVersion", + 0xa001: "Color Space", + 0x0000: "GPS Tag Version", + 0x0001: "Latitude Reference", + 0x0002: "Latitude", + 0x0003: "Longitude Reference", + 0x0004: "Longitude", + 0x0005: "Altitude Reference", + 0x0006: "Altitude", + 0x0009: "GPS Receiver Status", + 0x0012: "Geodetic Survey Data", + 0xc62f: "Body Serial Number", + 0xc614: "Make and Model" + } + + def parse_exif(file_object): + data = file_object.read() + + # Verify JPEG SOI marker + if data[:2] != b'\xFF\xD8': + raise ValueError("Not a valid JPEG file") + + # Look for APP1 (Exif) marker + offset = 2 + while offset < len(data): + marker, length = struct.unpack(">2sH", data[offset:offset+4]) + if marker == b'\xFF\xE1': # APP1 marker + exif_data = data[offset+4:offset+4+length-2] # Skip the marker length + if exif_data[:6] == b'Exif\x00\x00': + return parse_tiff(exif_data[6:]) + offset += length + 2 + + raise ValueError("Exif data not found") + + def parse_tiff(data): + # Verify TIFF header + endianness = data[:2] + if endianness == b'II': # Little endian + endian = "<" + elif endianness == b'MM': # Big endian + endian = ">" + else: + raise ValueError("Invalid TIFF header") + + # Verify TIFF magic number + magic_number = struct.unpack(endian + "H", data[2:4])[0] + if magic_number != 0x002A: + raise ValueError("Invalid TIFF magic number") + + # Get offset to first IFD (Image File Directory) + ifd_offset = struct.unpack(endian + "I", data[4:8])[0] + + # Parse IFD + return parse_ifd(data, ifd_offset, endian, base_offset=0) + + def parse_ifd(data, offset, endian, base_offset): + # Number of directory entries + num_entries = struct.unpack(endian + "H", data[offset:offset+2])[0] + entries = {} + for i in range(num_entries): + entry_offset = offset + 2 + i * 12 + tag, tag_type, count, value_offset = struct.unpack(endian + "HHII", data[entry_offset:entry_offset+12]) + + # Interpret the value based on the tag type + absolute_offset = base_offset + value_offset + # short strings! they are inline after the entry offset values + if tag_type == 2 and count * size_of_type(tag_type) <= 4: + value = data[entry_offset+8:entry_offset+8+count].rstrip(b'\x00').decode('ascii','ignore') + elif count * size_of_type(tag_type) <= 4: # other inline data + value = extract_inline_data(value_offset, tag_type, endian) + else: # Normal data at offset + value = extract_value(data, absolute_offset, tag_type, count, endian) + if tag == 0x0002 or tag == 0x0004 : #gps latlon, convert from deg/min/sec to decimal degrees + value = value[0] + value[1] / 60.0 + value[2] / 3600.0 + elif count==1: + value=value[0] + + # Check for subdirectories (ExifIFDPointer, GPSInfo) + if tag == 0x8769 or tag == 0x8825: # Subdirectory pointers + sub_ifd = parse_ifd(data, base_offset + value_offset, endian, base_offset) + entries.update(sub_ifd) + else: + entries[tag] = value + + return entries + + def size_of_type(tag_type): + sizes = {1: 1, 2: 1, 3: 2, 4: 4, 5: 8} # BYTE, ASCII, SHORT, LONG, RATIONAL + return sizes.get(tag_type, 0) + + def extract_inline_data(value_offset, tag_type, endian): + if tag_type == 3: # SHORT + return struct.unpack(endian + "H", struct.pack(endian + "I", value_offset)[:2])[0] + elif tag_type == 4: # LONG + return struct.unpack(endian + "I", struct.pack(endian + "I", value_offset))[0] + elif tag_type == 1: # BYTE + return value_offset & 0xFF + elif tag_type == 2: # ASCII + return chr(value_offset & 0xFF) # Interpret a single byte as ASCII + return value_offset + + def format_value(tag, value, tag_type, count, endian): + if tag_type == 2: # ASCII + return value if isinstance(value, str) else "".join(map(chr, value)) + elif tag_type == 3 or tag_type == 4: # SHORT or LONG + return value# if count > 1 else value[0] + elif tag_type == 5: # RATIONAL + return [f"{num}/{den}" for num, den in value] + return value + + def extract_value(data, offset, tag_type, count, endian): + if tag_type == 1: # BYTE + return data[offset:offset+count] + elif tag_type == 2: # ASCII + return data[offset:offset+count].rstrip(b'\x00').decode('utf-8') + elif tag_type == 3: # SHORT (2 bytes) + return struct.unpack(endian + f"{count}H", data[offset:offset+count*2]) + elif tag_type == 4: # LONG (4 bytes) + return struct.unpack(endian + f"{count}I", data[offset:offset+count*4]) + elif tag_type == 5: # RATIONAL (two LONGs: numerator/denominator) + # Extract all LONGs (numerator/denominator pairs) + raw_values = struct.unpack(endian + f"{count * 2}I", data[offset:offset + count * 8]) + # Convert to floats (numerator/denominator) + return [raw_values[i] / raw_values[i + 1] if raw_values[i + 1] != 0 else None for i in range(0, len(raw_values), 2)] + return None + + def annotated_exif(values): + r = {} + for tag, value in values.items(): + tag_name = exiftags.get(tag, f"Unknown tag ({hex(tag)})") + if tag_name == 'XML Packet':continue + r[tag_name] = value + return r + + return annotated_exif(parse_exif(file_object)) + + def _BuildEventData(self, annotated_exif): event_data = JpegExifEventData() key = 'Date and Time' - if key in ed.keys(): - ts = str(ed.get(key)) + if key in annotated_exif.keys(): + ts = str(annotated_exif.get(key)) dt = datetime.strptime(ts, "%Y:%m:%d %H:%M:%S") posix_timestamp = int(dt.timestamp()) event_data.exif_datetime = dfdatetime_posix_time.PosixTime(timestamp=posix_timestamp) - event_data.makeandmodel = ed.get('Make and Model', 'Unknown') - event_data.manufacturer = ed.get('Manufacturer', 'Unknown') - event_data.model = str(ed.get('Model')) - event_data.xres = str(ed.get('X-Resolution')) - event_data.yres = str(ed.get('Y-Resolution')) - event_data.width = ed.get('Image Width') - event_data.height = ed.get('Image Height') - event_data.software = str(ed.get('Software')) - event_data.bodyserialnumber = str(ed.get('Body Serial Number')) - event_data.latitude = "%2.5f%s"%(ed.get('Latitude', 0.0), ed.get('Latitude Reference','')) - event_data.longitude = "%2.5f%s"%(ed.get('Longitude', 0.0), ed.get('Longitude Reference', '')) + event_data.makeandmodel = annotated_exif.get('Make and Model', 'Unknown') + event_data.manufacturer = annotated_exif.get('Manufacturer', 'Unknown') + event_data.model = str(annotated_exif.get('Model')) + event_data.xres = str(annotated_exif.get('X-Resolution')) + event_data.yres = str(annotated_exif.get('Y-Resolution')) + event_data.width = annotated_exif.get('Image Width') + event_data.height = annotated_exif.get('Image Height') + event_data.software = str(annotated_exif.get('Software')) + event_data.bodyserialnumber = str(annotated_exif.get('Body Serial Number')) + event_data.latitude = "%2.5f%s"%(annotated_exif.get('Latitude', 0.0), + annotated_exif.get('Latitude Reference','')) + event_data.longitude = "%2.5f%s"%(annotated_exif.get('Longitude', 0.0), + annotated_exif.get('Longitude Reference', '')) return event_data def ParseFileObject(self, parser_mediator, file_object, **kwargs): From f5a58b07a0988e60b7d9b517c7c3e276d5ea7019 Mon Sep 17 00:00:00 2001 From: osku Date: Tue, 17 Dec 2024 15:55:45 +0200 Subject: [PATCH 05/10] Raise WrongParser error when attempting to parse anything than JPEG files --- plaso/parsers/jpeg_exif.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plaso/parsers/jpeg_exif.py b/plaso/parsers/jpeg_exif.py index d120d10545..954c3fda90 100644 --- a/plaso/parsers/jpeg_exif.py +++ b/plaso/parsers/jpeg_exif.py @@ -271,7 +271,11 @@ def ParseFileObject(self, parser_mediator, file_object, **kwargs): WrongParser: ... """ - parsed_jpeg_exif = self._ExtractExifMetadata(file_object) + try: + parsed_jpeg_exif = self._ExtractExifMetadata(file_object) + except ValueError as exception: + raise errors.WrongParser( + 'Unable to parse a JPEG file: {0!s}'.format(exception)) event_data = self._BuildEventData(parsed_jpeg_exif) parser_mediator.ProduceEventData(event_data) From 2e79c734da33c69f9146413e5c23a5c215170dcd Mon Sep 17 00:00:00 2001 From: osku Date: Tue, 17 Dec 2024 19:19:07 +0200 Subject: [PATCH 06/10] fixed import --- plaso/parsers/jpeg_exif.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plaso/parsers/jpeg_exif.py b/plaso/parsers/jpeg_exif.py index 954c3fda90..ce28a40ced 100644 --- a/plaso/parsers/jpeg_exif.py +++ b/plaso/parsers/jpeg_exif.py @@ -5,6 +5,8 @@ from plaso.parsers import manager from plaso.containers import events +from plaso.lib import errors + from datetime import datetime from dfdatetime import posix_time as dfdatetime_posix_time From 961afbf61b7dcb7ad2b2f6966ed2e871bb3c5e24 Mon Sep 17 00:00:00 2001 From: Joachim Metz Date: Mon, 11 May 2026 07:15:10 +0200 Subject: [PATCH 07/10] Changes after rebase --- plaso/parsers/jpeg_exif.py | 3 +-- tests/parsers/jpeg_exif.py | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/plaso/parsers/jpeg_exif.py b/plaso/parsers/jpeg_exif.py index ce28a40ced..cc6837a63f 100644 --- a/plaso/parsers/jpeg_exif.py +++ b/plaso/parsers/jpeg_exif.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- """Parser for JPG exif data.""" from plaso.parsers import interface @@ -36,7 +35,7 @@ class JpegExifEventData(events.EventData): def __init__(self): """Initializes a JpegExif event data.""" - super(JpegExifEventData, self).__init__(data_type=self.DATA_TYPE) + super().__init__(data_type=self.DATA_TYPE) self.exif_datetime = None self.width = None self.height = None diff --git a/tests/parsers/jpeg_exif.py b/tests/parsers/jpeg_exif.py index 05e3d574a3..b5bb1fe790 100644 --- a/tests/parsers/jpeg_exif.py +++ b/tests/parsers/jpeg_exif.py @@ -1,5 +1,4 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- """Tests for the JPEG EXIF parser.""" import unittest From 20d9c9a28a7c94b0d7f5a9a78c41e9e27687f87e Mon Sep 17 00:00:00 2001 From: Joachim Metz Date: Mon, 11 May 2026 08:13:46 +0200 Subject: [PATCH 08/10] Addressed linter warnings --- plaso/parsers/jpeg_exif.py | 592 ++++++++++++++++++++++--------------- tests/parsers/jpeg_exif.py | 7 +- 2 files changed, 361 insertions(+), 238 deletions(-) diff --git a/plaso/parsers/jpeg_exif.py b/plaso/parsers/jpeg_exif.py index cc6837a63f..5e994ac8ed 100644 --- a/plaso/parsers/jpeg_exif.py +++ b/plaso/parsers/jpeg_exif.py @@ -1,284 +1,408 @@ -"""Parser for JPG exif data.""" +"""Parser for Exchangeable Image File Format (EXIF) data in JPEG files.""" -from plaso.parsers import interface -from plaso.parsers import manager -from plaso.containers import events - -from plaso.lib import errors +import struct from datetime import datetime -from dfdatetime import posix_time as dfdatetime_posix_time -import struct # for inline exif parser +from dfdatetime import posix_time as dfdatetime_posix_time -# Mapping of Exif tag IDs to human-readable names +from plaso.parsers import interface +from plaso.parsers import manager +from plaso.containers import events +from plaso.lib import errors class JpegExifEventData(events.EventData): """JPEG EXIF event data. Attributes: - exif_datetime (dfdatetime.DateTimeValues): exif datetime - image_width: image width - image_height: image height - software: software - xres: X resolution - yres: Y resolution - manufacturer: Manufacturer/Make - model: Model - latitude: Latitude - longitude: Longitude - bodyserialnumber: Body serial number + bodyserialnumber (TODO): Body serial number. + exif_datetime (dfdatetime.DateTimeValues): exif datetime. + image_height (TODO): image height. + image_width (TODO): image width. + latitude (TODO): Latitude. + longitude (TODO): Longitude. + makeandmodel (TODO): Make and model. + manufacturer (TODO): Manufacturer/Make. + model (TODO): Model. + software (TODO): software. + xres (float): X resolution. + yres (float): Y resolution. """ - + DATA_TYPE = 'jpeg:exif' - + def __init__(self): - """Initializes a JpegExif event data.""" + """Initializes event data.""" super().__init__(data_type=self.DATA_TYPE) + self.bodyserialnumber = None self.exif_datetime = None - self.width = None self.height = None + self.latitude = None + self.longitude = None + self.makeandmodel = None + self.manufacturer = None + self.model = None self.software = None + self.width = None self.xres = None self.yres = None - self.manufacturer = None - self.model = None - self.latitude = None - self.longitude = None - self.bodyserialnumber = None - + class JpegExifParser(interface.FileObjectParser): """Parser for JPEG EXIF data""" NAME='jpeg_exif' DATA_FORMAT = 'JPEG file' - def _ExtractExifMetadata(self, file_object): - """Parses EXIF metadata embedded in a JPEG file. Returns a dictionary of the values found.""" - exiftags = { - 0x0100: "Image Width", - 0x0101: "Image Height", - 0x0102: "Bits per Sample", - 0x0103: "Compression", - 0x0106: "Photometric Interpre", - 0x010f: "Manufacturer", - 0x0110: "Model", - 0x0112: "Orientation", - 0x0115: "Samples per Pixel", - 0x011a: "X-Resolution", - 0x011b: "Y-Resolution", - 0x011c: "Planar Configuration", - 0x0128: "Resolution Unit", - 0x0131: "Software", - 0x0132: "Date and Time", - 0x014a: "SubIFD Offsets", - 0x02bc: "XML Packet", - 0x829a: "Exposure Time", - 0x829d: "F-Number", - 0x8822: "Exposure Program", - 0x8827: "ISO Speed Ratings", - 0x9000: "Exif Version", - 0x9003: "Date and Time", - 0x9004: "Date and Time (modified)", - 0x9204: "Exposure Bias", - 0x9205: "Maximum Aperture Val", - 0x9207: "Metering Mode", - 0x9208: "Light Source", - 0x9209: "Flash", - 0x920a: "Focal Length", - 0xa300: "File Source", - 0xa301: "Scene Type", - 0xa402: "Exposure Mode", - 0xa403: "White Balance", - 0xa404: "Digital Zoom Ratio", - 0xa405: "Focal Length in 35mm", - 0xa406: "Scene Capture Type", - 0xa407: "Gain Control", - 0xa408: "Contrast", - 0xa409: "Saturation", - 0xa40a: "Sharpness", - 0xa431: "Body Serial Number", - 0xa432: "Lens Specification", - 0xa000: "FlashPixVersion", - 0xa001: "Color Space", - 0x0000: "GPS Tag Version", - 0x0001: "Latitude Reference", - 0x0002: "Latitude", - 0x0003: "Longitude Reference", - 0x0004: "Longitude", - 0x0005: "Altitude Reference", - 0x0006: "Altitude", - 0x0009: "GPS Receiver Status", - 0x0012: "Geodetic Survey Data", - 0xc62f: "Body Serial Number", - 0xc614: "Make and Model" - } - - def parse_exif(file_object): - data = file_object.read() - - # Verify JPEG SOI marker - if data[:2] != b'\xFF\xD8': - raise ValueError("Not a valid JPEG file") - - # Look for APP1 (Exif) marker - offset = 2 - while offset < len(data): - marker, length = struct.unpack(">2sH", data[offset:offset+4]) - if marker == b'\xFF\xE1': # APP1 marker - exif_data = data[offset+4:offset+4+length-2] # Skip the marker length - if exif_data[:6] == b'Exif\x00\x00': - return parse_tiff(exif_data[6:]) - offset += length + 2 - - raise ValueError("Exif data not found") - - def parse_tiff(data): - # Verify TIFF header - endianness = data[:2] - if endianness == b'II': # Little endian - endian = "<" - elif endianness == b'MM': # Big endian - endian = ">" + _EXIF_TAGS = { + 0x0000: 'GPS Tag Version', + 0x0001: 'Latitude Reference', + 0x0002: 'Latitude', + 0x0003: 'Longitude Reference', + 0x0004: 'Longitude', + 0x0005: 'Altitude Reference', + 0x0006: 'Altitude', + 0x0009: 'GPS Receiver Status', + 0x0012: 'Geodetic Survey Data', + 0x0100: 'Image Width', + 0x0101: 'Image Height', + 0x0102: 'Bits per Sample', + 0x0103: 'Compression', + 0x0106: 'Photometric Interpre', + 0x010f: 'Manufacturer', + 0x0110: 'Model', + 0x0112: 'Orientation', + 0x0115: 'Samples per Pixel', + 0x011a: 'X-Resolution', + 0x011b: 'Y-Resolution', + 0x011c: 'Planar Configuration', + 0x0128: 'Resolution Unit', + 0x0131: 'Software', + 0x0132: 'Date and Time', + 0x014a: 'SubIFD Offsets', + 0x02bc: 'XML Packet', + 0x829a: 'Exposure Time', + 0x829d: 'F-Number', + 0x8822: 'Exposure Program', + 0x8827: 'ISO Speed Ratings', + 0x9000: 'Exif Version', + 0x9003: 'Date and Time', + 0x9004: 'Date and Time (modified)', + 0x9204: 'Exposure Bias', + 0x9205: 'Maximum Aperture Val', + 0x9207: 'Metering Mode', + 0x9208: 'Light Source', + 0x9209: 'Flash', + 0x920a: 'Focal Length', + 0xa000: 'FlashPixVersion', + 0xa001: 'Color Space', + 0xa300: 'File Source', + 0xa301: 'Scene Type', + 0xa402: 'Exposure Mode', + 0xa403: 'White Balance', + 0xa404: 'Digital Zoom Ratio', + 0xa405: 'Focal Length in 35mm', + 0xa406: 'Scene Capture Type', + 0xa407: 'Gain Control', + 0xa408: 'Contrast', + 0xa409: 'Saturation', + 0xa40a: 'Sharpness', + 0xa431: 'Body Serial Number', + 0xa432: 'Lens Specification', + 0xc614: 'Make and Model', + 0xc62f: 'Body Serial Number'} + + def _GetAnnotatedExif(self, values): + """TODO. + + Args: + values (TODO): TODO. + + Returns: + TODO: TODO. + """ + r = {} + for tag, value in values.items(): + tag_name = self._EXIF_TAGS.get(tag, f'Unknown tag ({hex(tag)})') + if tag_name == 'XML Packet': + continue + r[tag_name] = value + return r + + def _ExtractInlineData(self, value_offset, tag_type, endian): + """TODO. + + Args: + value_offset (TODO): TODO. + tag_type (TODO): TODO. + endian (TODO): TODO. + + Returns: + TODO: TODO. + """ + if tag_type == 3: # SHORT + return struct.unpack( + endian + 'H', struct.pack(endian + 'I', value_offset)[:2])[0] + + if tag_type == 4: # LONG + return struct.unpack( + endian + 'I', struct.pack(endian + 'I', value_offset))[0] + + if tag_type == 1: # BYTE + return value_offset & 0xFF + + if tag_type == 2: # ASCII + return chr(value_offset & 0xFF) # Interpret a single byte as ASCII + + return value_offset + + def _ExtractValue(self, data, offset, tag_type, count, endian): + """TODO. + + Args: + data (TODO): TODO. + offset (TODO): TODO. + tag_type (TODO): TODO. + count (TODO): TODO. + endian (TODO): TODO. + + Returns: + TODO: TODO. + """ + if tag_type == 1: # BYTE + return data[offset:offset+count] + + if tag_type == 2: # ASCII + return data[offset:offset+count].rstrip(b'\x00').decode('utf-8') + + if tag_type == 3: # SHORT (2 bytes) + return struct.unpack(endian + f'{count}H', data[offset:offset+count*2]) + + if tag_type == 4: # LONG (4 bytes) + return struct.unpack(endian + f'{count}I', data[offset:offset+count*4]) + + if tag_type == 5: # RATIONAL (two LONGs: numerator/denominator) + # Extract all LONGs (numerator/denominator pairs) + raw_values = struct.unpack( + endian + f'{count * 2}I', data[offset:offset + count * 8]) + # Convert to floats (numerator/denominator) + return ( + [raw_values[i] / raw_values[i + 1] + if raw_values[i + 1] != 0 else None + for i in range(0, len(raw_values), 2)]) + + return None + + # TODO: not used, remove? + def _FormatValue(self, value, tag_type): + """TODO. + + Args: + value (TODO): TODO. + tag_type (TODO): TODO. + + Returns: + TODO: TODO. + """ + if tag_type == 2: # ASCII + return value if isinstance(value, str) else ''.join(map(chr, value)) + + if tag_type in (3, 4): # SHORT or LONG + return value# if count > 1 else value[0] + + if tag_type == 5: # RATIONAL + return [f'{num}/{den}' for num, den in value] + + return value + + def _ParseExif(self, file_object): + """TODO. + + Args: + file_object (dfvfs.FileIO): a file-like object. + + Returns: + TODO: TODO. + """ + data = file_object.read() + + # Verify JPEG SOI marker + if data[:2] != b'\xFF\xD8': + raise ValueError('Not a valid JPEG file') + + # Look for APP1 (Exif) marker + offset = 2 + while offset < len(data): + marker, length = struct.unpack('>2sH', data[offset:offset+4]) + if marker == b'\xFF\xE1': # APP1 marker + exif_data = data[offset+4:offset+4+length-2] # Skip the marker length + if exif_data[:6] == b'Exif\x00\x00': + return self._ParseTiff(exif_data[6:]) + offset += length + 2 + + raise ValueError('Exif data not found') + + def _ParseIfd(self, data, offset, endian, base_offset): + """TODO. + + Args: + data (TODO): TODO. + offset (TODO): TODO. + endian (TODO): TODO. + base_offset (TODO): TODO. + + Returns: + TODO: TODO. + """ + # Number of directory entries + num_entries = struct.unpack(endian + 'H', data[offset:offset+2])[0] + entries = {} + for i in range(num_entries): + entry_offset = offset + 2 + i * 12 + tag, tag_type, count, value_offset = struct.unpack( + endian + 'HHII', data[entry_offset:entry_offset+12]) + + # Interpret the value based on the tag type + absolute_offset = base_offset + value_offset + # short strings! they are inline after the entry offset values + if tag_type == 2 and count * self._GetSizeOfType(tag_type) <= 4: + value_data = data[entry_offset+8:entry_offset+8+count] + value = value_data.rstrip(b'\x00').decode('ascii','ignore') + + elif count * self._GetSizeOfType(tag_type) <= 4: # other inline data + value = self._ExtractInlineData(value_offset, tag_type, endian) + + else: # Normal data at offset + value = self._ExtractValue( + data, absolute_offset, tag_type, count, endian) + if tag in (0x0002, 0x0004): + # gps latlon, convert from deg/min/sec to decimal degrees + value = value[0] + value[1] / 60.0 + value[2] / 3600.0 + elif count==1: + value=value[0] + + # Check for subdirectories (ExifIFDPointer, GPSInfo) + if tag in (0x8769, 0x8825): # Subdirectory pointers + sub_ifd = self._ParseIfd( + data, base_offset + value_offset, endian, base_offset) + entries.update(sub_ifd) else: - raise ValueError("Invalid TIFF header") - - # Verify TIFF magic number - magic_number = struct.unpack(endian + "H", data[2:4])[0] - if magic_number != 0x002A: - raise ValueError("Invalid TIFF magic number") - - # Get offset to first IFD (Image File Directory) - ifd_offset = struct.unpack(endian + "I", data[4:8])[0] - - # Parse IFD - return parse_ifd(data, ifd_offset, endian, base_offset=0) - - def parse_ifd(data, offset, endian, base_offset): - # Number of directory entries - num_entries = struct.unpack(endian + "H", data[offset:offset+2])[0] - entries = {} - for i in range(num_entries): - entry_offset = offset + 2 + i * 12 - tag, tag_type, count, value_offset = struct.unpack(endian + "HHII", data[entry_offset:entry_offset+12]) - - # Interpret the value based on the tag type - absolute_offset = base_offset + value_offset - # short strings! they are inline after the entry offset values - if tag_type == 2 and count * size_of_type(tag_type) <= 4: - value = data[entry_offset+8:entry_offset+8+count].rstrip(b'\x00').decode('ascii','ignore') - elif count * size_of_type(tag_type) <= 4: # other inline data - value = extract_inline_data(value_offset, tag_type, endian) - else: # Normal data at offset - value = extract_value(data, absolute_offset, tag_type, count, endian) - if tag == 0x0002 or tag == 0x0004 : #gps latlon, convert from deg/min/sec to decimal degrees - value = value[0] + value[1] / 60.0 + value[2] / 3600.0 - elif count==1: - value=value[0] - - # Check for subdirectories (ExifIFDPointer, GPSInfo) - if tag == 0x8769 or tag == 0x8825: # Subdirectory pointers - sub_ifd = parse_ifd(data, base_offset + value_offset, endian, base_offset) - entries.update(sub_ifd) - else: - entries[tag] = value - - return entries - - def size_of_type(tag_type): - sizes = {1: 1, 2: 1, 3: 2, 4: 4, 5: 8} # BYTE, ASCII, SHORT, LONG, RATIONAL - return sizes.get(tag_type, 0) - - def extract_inline_data(value_offset, tag_type, endian): - if tag_type == 3: # SHORT - return struct.unpack(endian + "H", struct.pack(endian + "I", value_offset)[:2])[0] - elif tag_type == 4: # LONG - return struct.unpack(endian + "I", struct.pack(endian + "I", value_offset))[0] - elif tag_type == 1: # BYTE - return value_offset & 0xFF - elif tag_type == 2: # ASCII - return chr(value_offset & 0xFF) # Interpret a single byte as ASCII - return value_offset - - def format_value(tag, value, tag_type, count, endian): - if tag_type == 2: # ASCII - return value if isinstance(value, str) else "".join(map(chr, value)) - elif tag_type == 3 or tag_type == 4: # SHORT or LONG - return value# if count > 1 else value[0] - elif tag_type == 5: # RATIONAL - return [f"{num}/{den}" for num, den in value] - return value - - def extract_value(data, offset, tag_type, count, endian): - if tag_type == 1: # BYTE - return data[offset:offset+count] - elif tag_type == 2: # ASCII - return data[offset:offset+count].rstrip(b'\x00').decode('utf-8') - elif tag_type == 3: # SHORT (2 bytes) - return struct.unpack(endian + f"{count}H", data[offset:offset+count*2]) - elif tag_type == 4: # LONG (4 bytes) - return struct.unpack(endian + f"{count}I", data[offset:offset+count*4]) - elif tag_type == 5: # RATIONAL (two LONGs: numerator/denominator) - # Extract all LONGs (numerator/denominator pairs) - raw_values = struct.unpack(endian + f"{count * 2}I", data[offset:offset + count * 8]) - # Convert to floats (numerator/denominator) - return [raw_values[i] / raw_values[i + 1] if raw_values[i + 1] != 0 else None for i in range(0, len(raw_values), 2)] - return None - - def annotated_exif(values): - r = {} - for tag, value in values.items(): - tag_name = exiftags.get(tag, f"Unknown tag ({hex(tag)})") - if tag_name == 'XML Packet':continue - r[tag_name] = value - return r - - return annotated_exif(parse_exif(file_object)) + entries[tag] = value + + return entries + + def _ParseTiff(self, data): + """TODO. + + Args: + data (TODO): TODO. + + Returns: + TODO: TODO. + """ + # Verify TIFF header + endianness = data[:2] + if endianness == b'II': # Little endian + endian = '<' + elif endianness == b'MM': # Big endian + endian = '>' + else: + raise ValueError('Invalid TIFF header') + + # Verify TIFF magic number + magic_number = struct.unpack(endian + 'H', data[2:4])[0] + if magic_number != 0x002A: + raise ValueError('Invalid TIFF magic number') + + # Get offset to first IFD (Image File Directory) + ifd_offset = struct.unpack(endian + 'I', data[4:8])[0] + + # Parse IFD + return self._ParseIfd(data, ifd_offset, endian, base_offset=0) + + def _GetSizeOfType(self, tag_type): + """TODO. + + Args: + tag_type (TODO): TODO. + + Returns: + TODO: TODO. + """ + # BYTE, ASCII, SHORT, LONG, RATIONAL + sizes = {1: 1, 2: 1, 3: 2, 4: 4, 5: 8} + return sizes.get(tag_type, 0) + + def _ExtractExifMetadata(self, file_object): + """Extracts EXIF metadata. + + Args: + file_object (dfvfs.FileIO): a file-like object. + + Returns: + TODO: TODO + """ + return self._GetAnnotatedExif(self._ParseExif(file_object)) def _BuildEventData(self, annotated_exif): + """TODO. + + Args: + annotated_exif (TODO): TODO. + + Returns: + JpegExifEventData: event data. + """ event_data = JpegExifEventData() - + key = 'Date and Time' if key in annotated_exif.keys(): ts = str(annotated_exif.get(key)) - dt = datetime.strptime(ts, "%Y:%m:%d %H:%M:%S") + dt = datetime.strptime(ts, '%Y:%m:%d %H:%M:%S') posix_timestamp = int(dt.timestamp()) - event_data.exif_datetime = dfdatetime_posix_time.PosixTime(timestamp=posix_timestamp) - event_data.makeandmodel = annotated_exif.get('Make and Model', 'Unknown') - event_data.manufacturer = annotated_exif.get('Manufacturer', 'Unknown') - event_data.model = str(annotated_exif.get('Model')) - event_data.xres = str(annotated_exif.get('X-Resolution')) - event_data.yres = str(annotated_exif.get('Y-Resolution')) - event_data.width = annotated_exif.get('Image Width') + event_data.exif_datetime = dfdatetime_posix_time.PosixTime( + timestamp=posix_timestamp) + + event_data.bodyserialnumber = annotated_exif.get('Body Serial Number') event_data.height = annotated_exif.get('Image Height') - event_data.software = str(annotated_exif.get('Software')) - event_data.bodyserialnumber = str(annotated_exif.get('Body Serial Number')) - event_data.latitude = "%2.5f%s"%(annotated_exif.get('Latitude', 0.0), - annotated_exif.get('Latitude Reference','')) - event_data.longitude = "%2.5f%s"%(annotated_exif.get('Longitude', 0.0), - annotated_exif.get('Longitude Reference', '')) + event_data.makeandmodel = annotated_exif.get('Make and Model') + event_data.manufacturer = annotated_exif.get('Manufacturer') + event_data.model = annotated_exif.get('Model') + event_data.software = annotated_exif.get('Software') + event_data.width = annotated_exif.get('Image Width') + event_data.xres = annotated_exif.get('X-Resolution') + event_data.yres = annotated_exif.get('Y-Resolution') + + latitude = annotated_exif.get('Latitude', 0.0) + latitude_reference = annotated_exif.get('Latitude Reference', '') + longitude = annotated_exif.get('Longitude', 0.0) + longitude_reference = annotated_exif.get('Longitude Reference', '') + + event_data.latitude = f'{latitude:2.5f}{latitude_reference:s}' + event_data.longitude = f'{longitude:2.5f}{longitude_reference:s}' + return event_data - - def ParseFileObject(self, parser_mediator, file_object, **kwargs): - """Parses a JPEG file. + + def ParseFileObject(self, parser_mediator, file_object): + """Parses a JPEG file for EXIF data. Args: - parser_mediator (ParserMediator): parser mediator. - file_object (dfvfx.FileIO): file-like object to be parsed. + parser_mediator (ParserMediator): mediates interactions between parsers + and other components, such as storage and dfVFS. + file_object (dfvfs.FileIO): a file-like object. Raises: - WrongParser: ... + WrongParser: when the file cannot be parsed. """ - try: parsed_jpeg_exif = self._ExtractExifMetadata(file_object) except ValueError as exception: raise errors.WrongParser( - 'Unable to parse a JPEG file: {0!s}'.format(exception)) + f'Unable to parse a JPEG file with error: {exception!s}') + event_data = self._BuildEventData(parsed_jpeg_exif) parser_mediator.ProduceEventData(event_data) -manager.ParsersManager.RegisterParser(JpegExifParser) +manager.ParsersManager.RegisterParser(JpegExifParser) diff --git a/tests/parsers/jpeg_exif.py b/tests/parsers/jpeg_exif.py index b5bb1fe790..9292efdd68 100644 --- a/tests/parsers/jpeg_exif.py +++ b/tests/parsers/jpeg_exif.py @@ -31,18 +31,17 @@ def testParseFile(self): expected_event_values = { "bodyserialnumber": "53HQK520M10K6U", "data_type": "jpeg:exif", + "height": 3016, "latitude": "68.87571N", "longitude": "27.76492E", "manufacturer": "DJI", "model": "FC3582", "software": "RawTherapee 5.9", "width": 4024, - "height": 3016, - "xres": "300.0", - "yres": "300.0"} + "xres": 300.0, + "yres": 300.0} event_data = storage_writer.GetAttributeContainerByIndex('event_data', 0) - print(str(event_data.__dict__)) self.CheckEventData(event_data, expected_event_values) From c123237ff85b366230d6016c5067d7efbddb01bb Mon Sep 17 00:00:00 2001 From: Joachim Metz Date: Mon, 11 May 2026 09:11:10 +0200 Subject: [PATCH 09/10] Clean up --- plaso/data/formatters/generic.yaml | 2 +- plaso/parsers/jpeg_exif.py | 295 ++++++++++++++--------------- tests/parsers/jpeg_exif.py | 24 +-- 3 files changed, 154 insertions(+), 167 deletions(-) diff --git a/plaso/data/formatters/generic.yaml b/plaso/data/formatters/generic.yaml index 300600fdde..2896c97fcc 100644 --- a/plaso/data/formatters/generic.yaml +++ b/plaso/data/formatters/generic.yaml @@ -495,7 +495,7 @@ message: - 'Height: {image_height}' - 'Manufacturer: {manufacturer}' - 'Model: {model}' -- 'Serial: {bodyserialnumber}' +- 'Serial number: {body_serial_number}' - 'Software: {software}' - 'Latitude: {latitude}' - 'Longitude: {longitude}' diff --git a/plaso/parsers/jpeg_exif.py b/plaso/parsers/jpeg_exif.py index 5e994ac8ed..bdce8a7ef1 100644 --- a/plaso/parsers/jpeg_exif.py +++ b/plaso/parsers/jpeg_exif.py @@ -1,33 +1,32 @@ """Parser for Exchangeable Image File Format (EXIF) data in JPEG files.""" import struct +import os -from datetime import datetime +from dfdatetime import time_elements as dfdatetime_time_elements -from dfdatetime import posix_time as dfdatetime_posix_time - -from plaso.parsers import interface -from plaso.parsers import manager from plaso.containers import events from plaso.lib import errors +from plaso.parsers import interface +from plaso.parsers import manager class JpegExifEventData(events.EventData): """JPEG EXIF event data. Attributes: - bodyserialnumber (TODO): Body serial number. + body_serial_number (str): Body serial number. exif_datetime (dfdatetime.DateTimeValues): exif datetime. - image_height (TODO): image height. - image_width (TODO): image width. - latitude (TODO): Latitude. - longitude (TODO): Longitude. - makeandmodel (TODO): Make and model. - manufacturer (TODO): Manufacturer/Make. - model (TODO): Model. - software (TODO): software. - xres (float): X resolution. - yres (float): Y resolution. + height (int): image height. + latitude (str): Latitude. + longitude (str): Longitude. + make_and_model (str): Make and model. + manufacturer (str): Manufacturer/Make. + model (str): Model. + software (str): software. + width (int): image width. + x_resolution (float): X resolution. + y_resolution (float): Y resolution. """ DATA_TYPE = 'jpeg:exif' @@ -35,18 +34,18 @@ class JpegExifEventData(events.EventData): def __init__(self): """Initializes event data.""" super().__init__(data_type=self.DATA_TYPE) - self.bodyserialnumber = None + self.body_serial_number = None self.exif_datetime = None self.height = None self.latitude = None self.longitude = None - self.makeandmodel = None + self.make_and_model = None self.manufacturer = None self.model = None self.software = None self.width = None - self.xres = None - self.yres = None + self.x_resolution = None + self.y_resolution = None class JpegExifParser(interface.FileObjectParser): @@ -113,6 +112,19 @@ class JpegExifParser(interface.FileObjectParser): 0xc614: 'Make and Model', 0xc62f: 'Body Serial Number'} + _TAG_TYPE_BYTE = 1 + _TAG_TYPE_ASCII = 2 + _TAG_TYPE_SHORT = 3 + _TAG_TYPE_LONG = 4 + _TAG_TYPE_RATIONAL = 5 + + _BYTE_SIZE_PER_TAG_TYPE = { + _TAG_TYPE_BYTE: 1, + _TAG_TYPE_ASCII: 1, + _TAG_TYPE_SHORT: 2, + _TAG_TYPE_LONG: 4, + _TAG_TYPE_RATIONAL: 8} + def _GetAnnotatedExif(self, values): """TODO. @@ -130,152 +142,147 @@ def _GetAnnotatedExif(self, values): r[tag_name] = value return r - def _ExtractInlineData(self, value_offset, tag_type, endian): - """TODO. + def _ExtractInlineData(self, value_offset, tag_type, byte_order): + """Extracts inline data. Args: value_offset (TODO): TODO. - tag_type (TODO): TODO. - endian (TODO): TODO. + tag_type (int): tag type. + byte_order (str): struct byte-order indicator. Returns: - TODO: TODO. + int: value. """ - if tag_type == 3: # SHORT - return struct.unpack( - endian + 'H', struct.pack(endian + 'I', value_offset)[:2])[0] + if tag_type == self._TAG_TYPE_SHORT: + value_data = struct.pack(f'{byte_order:s}I', value_offset) + return struct.unpack(f'{byte_order:s}H', value_data[:2])[0] - if tag_type == 4: # LONG - return struct.unpack( - endian + 'I', struct.pack(endian + 'I', value_offset))[0] + if tag_type == self._TAG_TYPE_LONG: + value_data = struct.pack(f'{byte_order:s}I', value_offset) + return struct.unpack(f'{byte_order:s}I', value_data)[0] - if tag_type == 1: # BYTE + if tag_type == self._TAG_TYPE_BYTE: return value_offset & 0xFF - if tag_type == 2: # ASCII + if tag_type == self._TAG_TYPE_ASCII: return chr(value_offset & 0xFF) # Interpret a single byte as ASCII return value_offset - def _ExtractValue(self, data, offset, tag_type, count, endian): + def _ExtractValue(self, data, offset, tag_type, count, byte_order): """TODO. Args: data (TODO): TODO. offset (TODO): TODO. tag_type (TODO): TODO. - count (TODO): TODO. - endian (TODO): TODO. + count (int): number of values. + byte_order (str): struct byte-order indicator. Returns: TODO: TODO. """ - if tag_type == 1: # BYTE + if tag_type == self._TAG_TYPE_BYTE: return data[offset:offset+count] - if tag_type == 2: # ASCII - return data[offset:offset+count].rstrip(b'\x00').decode('utf-8') + if tag_type == self._TAG_TYPE_ASCII: + end_offset = offset + count + # TODO: catch UnicodeDecodeError + return data[offset:end_offset].rstrip(b'\x00').decode('utf-8') - if tag_type == 3: # SHORT (2 bytes) - return struct.unpack(endian + f'{count}H', data[offset:offset+count*2]) + if tag_type == self._TAG_TYPE_SHORT: + end_offset = offset + (count * 2) + return struct.unpack(f'{byte_order:s}{count:d}H', data[offset:end_offset]) - if tag_type == 4: # LONG (4 bytes) - return struct.unpack(endian + f'{count}I', data[offset:offset+count*4]) + if tag_type == self._TAG_TYPE_LONG: + end_offset = offset + (count * 4) + return struct.unpack(f'{byte_order:s}{count:d}I', data[offset:end_offset]) - if tag_type == 5: # RATIONAL (two LONGs: numerator/denominator) + if tag_type == self._TAG_TYPE_RATIONAL: + # A rational consist of 2 LONG values: numerator and denominator # Extract all LONGs (numerator/denominator pairs) + end_offset = offset + (count * 8) raw_values = struct.unpack( - endian + f'{count * 2}I', data[offset:offset + count * 8]) + f'{byte_order:s}{count * 2}I', data[offset:end_offset]) + # Convert to floats (numerator/denominator) - return ( - [raw_values[i] / raw_values[i + 1] + return [ + raw_values[i] / raw_values[i + 1] if raw_values[i + 1] != 0 else None - for i in range(0, len(raw_values), 2)]) + for i in range(0, len(raw_values), 2)] return None - # TODO: not used, remove? - def _FormatValue(self, value, tag_type): - """TODO. - - Args: - value (TODO): TODO. - tag_type (TODO): TODO. - - Returns: - TODO: TODO. - """ - if tag_type == 2: # ASCII - return value if isinstance(value, str) else ''.join(map(chr, value)) - - if tag_type in (3, 4): # SHORT or LONG - return value# if count > 1 else value[0] - - if tag_type == 5: # RATIONAL - return [f'{num}/{den}' for num, den in value] - - return value - def _ParseExif(self, file_object): - """TODO. + """Extracts EXIF data. Args: file_object (dfvfs.FileIO): a file-like object. Returns: TODO: TODO. + + Raises: + ValueError: if the EXIF data could not be extracted. """ + # TODO: this can fill the memory if the file is too large. data = file_object.read() - - # Verify JPEG SOI marker - if data[:2] != b'\xFF\xD8': - raise ValueError('Not a valid JPEG file') + data_size = len(data) # Look for APP1 (Exif) marker offset = 2 - while offset < len(data): - marker, length = struct.unpack('>2sH', data[offset:offset+4]) + while offset < data_size: + end_offset = offset + 4 + marker, length = struct.unpack('>2sH', data[offset:end_offset]) + if marker == b'\xFF\xE1': # APP1 marker exif_data = data[offset+4:offset+4+length-2] # Skip the marker length if exif_data[:6] == b'Exif\x00\x00': return self._ParseTiff(exif_data[6:]) + offset += length + 2 raise ValueError('Exif data not found') - def _ParseIfd(self, data, offset, endian, base_offset): + def _ParseIfd(self, data, offset, byte_order, base_offset): """TODO. Args: data (TODO): TODO. offset (TODO): TODO. - endian (TODO): TODO. + byte_order (str): struct byte-order indicator. base_offset (TODO): TODO. Returns: TODO: TODO. """ # Number of directory entries - num_entries = struct.unpack(endian + 'H', data[offset:offset+2])[0] + end_offset = offset + 2 + num_entries = struct.unpack(f'{byte_order:s}H', data[offset:end_offset])[0] entries = {} for i in range(num_entries): entry_offset = offset + 2 + i * 12 + end_offset = entry_offset + 12 tag, tag_type, count, value_offset = struct.unpack( - endian + 'HHII', data[entry_offset:entry_offset+12]) + f'{byte_order:s}HHII', data[entry_offset:end_offset]) # Interpret the value based on the tag type absolute_offset = base_offset + value_offset # short strings! they are inline after the entry offset values - if tag_type == 2 and count * self._GetSizeOfType(tag_type) <= 4: + + # TODO: tracker parser warning if tag type not supported. + byte_size = self._BYTE_SIZE_PER_TAG_TYPE.get(tag_type, 0) + + if tag_type == 2 and count * byte_size <= 4: value_data = data[entry_offset+8:entry_offset+8+count] value = value_data.rstrip(b'\x00').decode('ascii','ignore') - elif count * self._GetSizeOfType(tag_type) <= 4: # other inline data - value = self._ExtractInlineData(value_offset, tag_type, endian) + elif count * byte_size <= 4: # other inline data + value = self._ExtractInlineData(value_offset, tag_type, byte_order) else: # Normal data at offset value = self._ExtractValue( - data, absolute_offset, tag_type, count, endian) + data, absolute_offset, tag_type, count, byte_order) if tag in (0x0002, 0x0004): # gps latlon, convert from deg/min/sec to decimal degrees value = value[0] + value[1] / 60.0 + value[2] / 3600.0 @@ -285,7 +292,7 @@ def _ParseIfd(self, data, offset, endian, base_offset): # Check for subdirectories (ExifIFDPointer, GPSInfo) if tag in (0x8769, 0x8825): # Subdirectory pointers sub_ifd = self._ParseIfd( - data, base_offset + value_offset, endian, base_offset) + data, base_offset + value_offset, byte_order, base_offset) entries.update(sub_ifd) else: entries[tag] = value @@ -304,104 +311,82 @@ def _ParseTiff(self, data): # Verify TIFF header endianness = data[:2] if endianness == b'II': # Little endian - endian = '<' + byte_order = '<' elif endianness == b'MM': # Big endian - endian = '>' + byte_order = '>' else: raise ValueError('Invalid TIFF header') # Verify TIFF magic number - magic_number = struct.unpack(endian + 'H', data[2:4])[0] + magic_number = struct.unpack(f'{byte_order:s}H', data[2:4])[0] if magic_number != 0x002A: raise ValueError('Invalid TIFF magic number') # Get offset to first IFD (Image File Directory) - ifd_offset = struct.unpack(endian + 'I', data[4:8])[0] + ifd_offset = struct.unpack(f'{byte_order:s}I', data[4:8])[0] - # Parse IFD - return self._ParseIfd(data, ifd_offset, endian, base_offset=0) - - def _GetSizeOfType(self, tag_type): - """TODO. + return self._ParseIfd(data, ifd_offset, byte_order, 0) - Args: - tag_type (TODO): TODO. - - Returns: - TODO: TODO. - """ - # BYTE, ASCII, SHORT, LONG, RATIONAL - sizes = {1: 1, 2: 1, 3: 2, 4: 4, 5: 8} - return sizes.get(tag_type, 0) - - def _ExtractExifMetadata(self, file_object): - """Extracts EXIF metadata. + def ParseFileObject(self, parser_mediator, file_object): + """Parses a JPEG file for EXIF data. Args: + parser_mediator (ParserMediator): mediates interactions between parsers + and other components, such as storage and dfVFS. file_object (dfvfs.FileIO): a file-like object. - Returns: - TODO: TODO + Raises: + WrongParser: when the file cannot be parsed. """ - return self._GetAnnotatedExif(self._ParseExif(file_object)) - - def _BuildEventData(self, annotated_exif): - """TODO. + if file_object.read(2) != b'\xff\xd8': + display_name = parser_mediator.GetDisplayName() + raise errors.WrongParser( + f'[{self.NAME!s}] {display_name!s} is not a JPEG file.') - Args: - annotated_exif (TODO): TODO. + file_object.seek(0, os.SEEK_SET) - Returns: - JpegExifEventData: event data. - """ - event_data = JpegExifEventData() + try: + exif_data = self._ParseExif(file_object) + except ValueError as exception: + raise errors.WrongParser( + f'Unable to parse a JPEG file with error: {exception!s}') - key = 'Date and Time' - if key in annotated_exif.keys(): - ts = str(annotated_exif.get(key)) - dt = datetime.strptime(ts, '%Y:%m:%d %H:%M:%S') - posix_timestamp = int(dt.timestamp()) - event_data.exif_datetime = dfdatetime_posix_time.PosixTime( - timestamp=posix_timestamp) + annotated_exif = self._GetAnnotatedExif(exif_data) - event_data.bodyserialnumber = annotated_exif.get('Body Serial Number') - event_data.height = annotated_exif.get('Image Height') - event_data.makeandmodel = annotated_exif.get('Make and Model') - event_data.manufacturer = annotated_exif.get('Manufacturer') - event_data.model = annotated_exif.get('Model') - event_data.software = annotated_exif.get('Software') - event_data.width = annotated_exif.get('Image Width') - event_data.xres = annotated_exif.get('X-Resolution') - event_data.yres = annotated_exif.get('Y-Resolution') + event_data = JpegExifEventData() latitude = annotated_exif.get('Latitude', 0.0) latitude_reference = annotated_exif.get('Latitude Reference', '') longitude = annotated_exif.get('Longitude', 0.0) longitude_reference = annotated_exif.get('Longitude Reference', '') + event_data.body_serial_number = annotated_exif.get('Body Serial Number') + event_data.height = annotated_exif.get('Image Height') event_data.latitude = f'{latitude:2.5f}{latitude_reference:s}' event_data.longitude = f'{longitude:2.5f}{longitude_reference:s}' + event_data.make_and_model = annotated_exif.get('Make and Model') + event_data.manufacturer = annotated_exif.get('Manufacturer') + event_data.model = annotated_exif.get('Model') + event_data.software = annotated_exif.get('Software') + event_data.width = annotated_exif.get('Image Width') + event_data.x_resolution = annotated_exif.get('X-Resolution') + event_data.y_resolution = annotated_exif.get('Y-Resolution') + + time_string = annotated_exif.get('Date and Time') + if time_string: + date, time = time_string.split(' ') + year, month, day_of_month = [int(value, 10) for value in date.split(':')] + hours, minutes, seconds = [int(value, 10) for value in time.split(':')] + + try: + date_time = dfdatetime_time_elements.TimeElements(time_elements_tuple=( + year, month, day_of_month, hours, minutes, seconds)) + event_data.exif_datetime = date_time + except (TypeError, ValueError) as exception: + error_string = ( + f'Unable to parse date time value with error: {exception!s}') + parser_mediator.ProduceExtractionWarning(error_string) - return event_data - - def ParseFileObject(self, parser_mediator, file_object): - """Parses a JPEG file for EXIF data. - - Args: - parser_mediator (ParserMediator): mediates interactions between parsers - and other components, such as storage and dfVFS. - file_object (dfvfs.FileIO): a file-like object. - - Raises: - WrongParser: when the file cannot be parsed. - """ - try: - parsed_jpeg_exif = self._ExtractExifMetadata(file_object) - except ValueError as exception: - raise errors.WrongParser( - f'Unable to parse a JPEG file with error: {exception!s}') - - event_data = self._BuildEventData(parsed_jpeg_exif) parser_mediator.ProduceEventData(event_data) diff --git a/tests/parsers/jpeg_exif.py b/tests/parsers/jpeg_exif.py index 9292efdd68..3da7deb02a 100644 --- a/tests/parsers/jpeg_exif.py +++ b/tests/parsers/jpeg_exif.py @@ -29,17 +29,19 @@ def testParseFile(self): self.assertEqual(number_of_warnings, 0) expected_event_values = { - "bodyserialnumber": "53HQK520M10K6U", - "data_type": "jpeg:exif", - "height": 3016, - "latitude": "68.87571N", - "longitude": "27.76492E", - "manufacturer": "DJI", - "model": "FC3582", - "software": "RawTherapee 5.9", - "width": 4024, - "xres": 300.0, - "yres": 300.0} + 'body_serial_number': '53HQK520M10K6U', + 'data_type': 'jpeg:exif', + 'exif_datetime': '2023-08-01T23:19:48+00:00', + 'height': 3016, + 'latitude': '68.87571N', + 'longitude': '27.76492E', + 'make_and_model': 'DJI FC3582', + 'manufacturer': 'DJI', + 'model': 'FC3582', + 'software': 'RawTherapee 5.9', + 'width': 4024, + 'x_resolution': 300.0, + 'y_resolution': 300.0} event_data = storage_writer.GetAttributeContainerByIndex('event_data', 0) self.CheckEventData(event_data, expected_event_values) From 6e58c15a14c670686b50b875e232951468a4a743 Mon Sep 17 00:00:00 2001 From: Joachim Metz Date: Mon, 11 May 2026 09:57:51 +0200 Subject: [PATCH 10/10] Clean up --- plaso/data/timeliner.yaml | 4 +- plaso/parsers/jpeg_exif.py | 189 +++++++++++++++---------------------- tests/parsers/jpeg_exif.py | 2 +- 3 files changed, 78 insertions(+), 117 deletions(-) diff --git a/plaso/data/timeliner.yaml b/plaso/data/timeliner.yaml index 1844a9ab22..922121323c 100644 --- a/plaso/data/timeliner.yaml +++ b/plaso/data/timeliner.yaml @@ -701,8 +701,8 @@ place_holder_event: true --- data_type: 'jpeg:exif' attribute_mappings: -- name: 'exif_datetime' - description: 'EXIF datetime' +- name: 'modification_time' + description: 'Content Modification Time' place_holder_event: true --- data_type: 'kodi:videos:viewing' diff --git a/plaso/parsers/jpeg_exif.py b/plaso/parsers/jpeg_exif.py index bdce8a7ef1..ec135a9ef1 100644 --- a/plaso/parsers/jpeg_exif.py +++ b/plaso/parsers/jpeg_exif.py @@ -16,13 +16,14 @@ class JpegExifEventData(events.EventData): Attributes: body_serial_number (str): Body serial number. - exif_datetime (dfdatetime.DateTimeValues): exif datetime. height (int): image height. latitude (str): Latitude. longitude (str): Longitude. make_and_model (str): Make and model. manufacturer (str): Manufacturer/Make. model (str): Model. + modification_time (dfdatetime.DateTimeValues): EXIF modification date and + time. software (str): software. width (int): image width. x_resolution (float): X resolution. @@ -35,13 +36,13 @@ def __init__(self): """Initializes event data.""" super().__init__(data_type=self.DATA_TYPE) self.body_serial_number = None - self.exif_datetime = None self.height = None self.latitude = None self.longitude = None self.make_and_model = None self.manufacturer = None self.model = None + self.modification_time = None self.software = None self.width = None self.x_resolution = None @@ -54,63 +55,20 @@ class JpegExifParser(interface.FileObjectParser): NAME='jpeg_exif' DATA_FORMAT = 'JPEG file' - _EXIF_TAGS = { - 0x0000: 'GPS Tag Version', - 0x0001: 'Latitude Reference', - 0x0002: 'Latitude', - 0x0003: 'Longitude Reference', - 0x0004: 'Longitude', - 0x0005: 'Altitude Reference', - 0x0006: 'Altitude', - 0x0009: 'GPS Receiver Status', - 0x0012: 'Geodetic Survey Data', - 0x0100: 'Image Width', - 0x0101: 'Image Height', - 0x0102: 'Bits per Sample', - 0x0103: 'Compression', - 0x0106: 'Photometric Interpre', - 0x010f: 'Manufacturer', - 0x0110: 'Model', - 0x0112: 'Orientation', - 0x0115: 'Samples per Pixel', - 0x011a: 'X-Resolution', - 0x011b: 'Y-Resolution', - 0x011c: 'Planar Configuration', - 0x0128: 'Resolution Unit', - 0x0131: 'Software', - 0x0132: 'Date and Time', - 0x014a: 'SubIFD Offsets', - 0x02bc: 'XML Packet', - 0x829a: 'Exposure Time', - 0x829d: 'F-Number', - 0x8822: 'Exposure Program', - 0x8827: 'ISO Speed Ratings', - 0x9000: 'Exif Version', - 0x9003: 'Date and Time', - 0x9004: 'Date and Time (modified)', - 0x9204: 'Exposure Bias', - 0x9205: 'Maximum Aperture Val', - 0x9207: 'Metering Mode', - 0x9208: 'Light Source', - 0x9209: 'Flash', - 0x920a: 'Focal Length', - 0xa000: 'FlashPixVersion', - 0xa001: 'Color Space', - 0xa300: 'File Source', - 0xa301: 'Scene Type', - 0xa402: 'Exposure Mode', - 0xa403: 'White Balance', - 0xa404: 'Digital Zoom Ratio', - 0xa405: 'Focal Length in 35mm', - 0xa406: 'Scene Capture Type', - 0xa407: 'Gain Control', - 0xa408: 'Contrast', - 0xa409: 'Saturation', - 0xa40a: 'Sharpness', - 0xa431: 'Body Serial Number', - 0xa432: 'Lens Specification', - 0xc614: 'Make and Model', - 0xc62f: 'Body Serial Number'} + _TAG_BODY_SERIAL_NUMBER = 0xa431 + _TAG_MODIFICATION_DATE = 0x0132 + _TAG_IMAGE_HEIGHT = 0x0101 + _TAG_IMAGE_WIDTH = 0x0100 + _TAG_LATITUDE = 0x0002 + _TAG_LATITUDE_REFERENCE = 0x0001 + _TAG_LONGTITUDE = 0x0004 + _TAG_LONGTITUDE_REFERENCE = 0x0003 + _TAG_MAKE_AND_MODEL = 0xc614 + _TAG_MANUFACTURER = 0x010f + _TAG_MODEL = 0x0110 + _TAG_SOFTWARE = 0x0131 + _TAG_X_RESOLUTION = 0x011a + _TAG_Y_RESOLUTION = 0x011b _TAG_TYPE_BYTE = 1 _TAG_TYPE_ASCII = 2 @@ -125,23 +83,6 @@ class JpegExifParser(interface.FileObjectParser): _TAG_TYPE_LONG: 4, _TAG_TYPE_RATIONAL: 8} - def _GetAnnotatedExif(self, values): - """TODO. - - Args: - values (TODO): TODO. - - Returns: - TODO: TODO. - """ - r = {} - for tag, value in values.items(): - tag_name = self._EXIF_TAGS.get(tag, f'Unknown tag ({hex(tag)})') - if tag_name == 'XML Packet': - continue - r[tag_name] = value - return r - def _ExtractInlineData(self, value_offset, tag_type, byte_order): """Extracts inline data. @@ -220,7 +161,7 @@ def _ParseExif(self, file_object): file_object (dfvfs.FileIO): a file-like object. Returns: - TODO: TODO. + dict[int, object]: EXIF data with integer tags as keys. Raises: ValueError: if the EXIF data could not be extracted. @@ -245,13 +186,13 @@ def _ParseExif(self, file_object): raise ValueError('Exif data not found') def _ParseIfd(self, data, offset, byte_order, base_offset): - """TODO. + """Parses the Image File Directory (IFD). Args: - data (TODO): TODO. - offset (TODO): TODO. + data (bytes): TODO. + offset (int): TODO. byte_order (str): struct byte-order indicator. - base_offset (TODO): TODO. + base_offset (int): TODO. Returns: TODO: TODO. @@ -275,7 +216,7 @@ def _ParseIfd(self, data, offset, byte_order, base_offset): if tag_type == 2 and count * byte_size <= 4: value_data = data[entry_offset+8:entry_offset+8+count] - value = value_data.rstrip(b'\x00').decode('ascii','ignore') + value = value_data.rstrip(b'\x00').decode('ascii', 'ignore') elif count * byte_size <= 4: # other inline data value = self._ExtractInlineData(value_offset, tag_type, byte_order) @@ -286,8 +227,8 @@ def _ParseIfd(self, data, offset, byte_order, base_offset): if tag in (0x0002, 0x0004): # gps latlon, convert from deg/min/sec to decimal degrees value = value[0] + value[1] / 60.0 + value[2] / 3600.0 - elif count==1: - value=value[0] + elif count == 1: + value = value[0] # Check for subdirectories (ExifIFDPointer, GPSInfo) if tag in (0x8769, 0x8825): # Subdirectory pointers @@ -327,6 +268,37 @@ def _ParseTiff(self, data): return self._ParseIfd(data, ifd_offset, byte_order, 0) + def _GetDateTimeValue(self, parser_mediator, exif_data, tag): + """Retrieves a date and time value. + + Args: + parser_mediator (ParserMediator): mediates interactions between parsers + and other components, such as storage and dfVFS. + exif_data (dict[int, object]): EXIF data with integer tags as keys. + tag (int): EXIF tag. + + Returns: + dfdatetime.DateTimeValues: date and time or None if not set. + """ + time_string = exif_data.get(tag) + if not time_string: + return None + + try: + date, time = time_string.split(' ') + year, month, day_of_month = [int(value, 10) for value in date.split(':')] + hours, minutes, seconds = [int(value, 10) for value in time.split(':')] + + date_time = dfdatetime_time_elements.TimeElements(time_elements_tuple=( + year, month, day_of_month, hours, minutes, seconds)) + except (TypeError, ValueError) as exception: + parser_mediator.ProduceExtractionWarning( + f'Unable to parse date time value: 0x{tag:02x} with error: ' + f'{exception!s}') + date_time = None + + return date_time + def ParseFileObject(self, parser_mediator, file_object): """Parses a JPEG file for EXIF data. @@ -351,41 +323,30 @@ def ParseFileObject(self, parser_mediator, file_object): raise errors.WrongParser( f'Unable to parse a JPEG file with error: {exception!s}') - annotated_exif = self._GetAnnotatedExif(exif_data) - event_data = JpegExifEventData() - latitude = annotated_exif.get('Latitude', 0.0) - latitude_reference = annotated_exif.get('Latitude Reference', '') - longitude = annotated_exif.get('Longitude', 0.0) - longitude_reference = annotated_exif.get('Longitude Reference', '') + latitude = exif_data.get(self._TAG_LATITUDE, 0.0) + latitude_reference = exif_data.get(self._TAG_LATITUDE_REFERENCE, '') + longitude = exif_data.get(self._TAG_LONGTITUDE, 0.0) + longitude_reference = exif_data.get(self._TAG_LONGTITUDE_REFERENCE, '') + + # TODO: add support for 0x9003 DateTimeOriginal + # TODO: add support for 0x9004 CreateDate + # TODO: add support for 0xc62f CameraSerialNumber - event_data.body_serial_number = annotated_exif.get('Body Serial Number') - event_data.height = annotated_exif.get('Image Height') + event_data.body_serial_number = exif_data.get(self._TAG_BODY_SERIAL_NUMBER) + event_data.height = exif_data.get(self._TAG_IMAGE_HEIGHT) event_data.latitude = f'{latitude:2.5f}{latitude_reference:s}' event_data.longitude = f'{longitude:2.5f}{longitude_reference:s}' - event_data.make_and_model = annotated_exif.get('Make and Model') - event_data.manufacturer = annotated_exif.get('Manufacturer') - event_data.model = annotated_exif.get('Model') - event_data.software = annotated_exif.get('Software') - event_data.width = annotated_exif.get('Image Width') - event_data.x_resolution = annotated_exif.get('X-Resolution') - event_data.y_resolution = annotated_exif.get('Y-Resolution') - - time_string = annotated_exif.get('Date and Time') - if time_string: - date, time = time_string.split(' ') - year, month, day_of_month = [int(value, 10) for value in date.split(':')] - hours, minutes, seconds = [int(value, 10) for value in time.split(':')] - - try: - date_time = dfdatetime_time_elements.TimeElements(time_elements_tuple=( - year, month, day_of_month, hours, minutes, seconds)) - event_data.exif_datetime = date_time - except (TypeError, ValueError) as exception: - error_string = ( - f'Unable to parse date time value with error: {exception!s}') - parser_mediator.ProduceExtractionWarning(error_string) + event_data.make_and_model = exif_data.get(self._TAG_MAKE_AND_MODEL) + event_data.manufacturer = exif_data.get(self._TAG_MANUFACTURER) + event_data.model = exif_data.get(self._TAG_MODEL) + event_data.modification_time = self._GetDateTimeValue( + parser_mediator, exif_data, self._TAG_MODIFICATION_DATE) + event_data.software = exif_data.get(self._TAG_SOFTWARE) + event_data.width = exif_data.get(self._TAG_IMAGE_WIDTH) + event_data.x_resolution = exif_data.get(self._TAG_X_RESOLUTION) + event_data.y_resolution = exif_data.get(self._TAG_Y_RESOLUTION) parser_mediator.ProduceEventData(event_data) diff --git a/tests/parsers/jpeg_exif.py b/tests/parsers/jpeg_exif.py index 3da7deb02a..78300f4603 100644 --- a/tests/parsers/jpeg_exif.py +++ b/tests/parsers/jpeg_exif.py @@ -31,13 +31,13 @@ def testParseFile(self): expected_event_values = { 'body_serial_number': '53HQK520M10K6U', 'data_type': 'jpeg:exif', - 'exif_datetime': '2023-08-01T23:19:48+00:00', 'height': 3016, 'latitude': '68.87571N', 'longitude': '27.76492E', 'make_and_model': 'DJI FC3582', 'manufacturer': 'DJI', 'model': 'FC3582', + 'modification_time': '2023-08-01T23:19:48+00:00', 'software': 'RawTherapee 5.9', 'width': 4024, 'x_resolution': 300.0,