Repository: mitmproxy/mitmproxy

Issue:
Add .ico content view
@GreyCat added a kaitai-struct parser for ICO files in https://github.com/kaitai-io/kaitai_struct_formats/commit/22ecfbf7759c3e575bba092b27ddcc1e8f1aff9e. :tada:
Given that favicons are quite common in mitmproxy, adding this would be a fantastic addition to the project!
Add .ico content view
@GreyCat added a kaitai-struct parser for ICO files in https://github.com/kaitai-io/kaitai_struct_formats/commit/22ecfbf7759c3e575bba092b27ddcc1e8f1aff9e. :tada:
Given that favicons are quite common in mitmproxy, adding this would be a fantastic addition to the project!


Discussion:

Resolving patch:
diff --git a/mitmproxy/contentviews/image/image_parser.py b/mitmproxy/contentviews/image/image_parser.py
--- a/mitmproxy/contentviews/image/image_parser.py
+++ b/mitmproxy/contentviews/image/image_parser.py
@@ -6,6 +6,7 @@
 from mitmproxy.contrib.kaitaistruct import png
 from mitmproxy.contrib.kaitaistruct import gif
 from mitmproxy.contrib.kaitaistruct import jpeg
+from mitmproxy.contrib.kaitaistruct import ico
 
 Metadata = typing.List[typing.Tuple[str, str]]
 
@@ -78,3 +79,25 @@ def parse_jpeg(data: bytes) -> Metadata:
                     if field.data is not None:
                         parts.append((field.tag._name_, field.data.decode('UTF-8').strip('\x00')))
     return parts
+
+
+def parse_ico(data: bytes) -> Metadata:
+    img = ico.Ico(KaitaiStream(io.BytesIO(data)))
+    parts = [
+        ('Format', 'ICO'),
+        ('Number of images', str(img.num_images)),
+    ]
+
+    for i, image in enumerate(img.images):
+        parts.append(
+            (
+                'Image {}'.format(i + 1), "Size: {} x {}\n"
+                                          "{: >18}Bits per pixel: {}\n"
+                                          "{: >18}PNG: {}".format(256 if not image.width else image.width,
+                                                                  256 if not image.height else image.height,
+                                                                  '', image.bpp,
+                                                                  '', image.is_png)
+            )
+        )
+
+    return parts
diff --git a/mitmproxy/contentviews/image/view.py b/mitmproxy/contentviews/image/view.py
--- a/mitmproxy/contentviews/image/view.py
+++ b/mitmproxy/contentviews/image/view.py
@@ -5,6 +5,14 @@
 from . import image_parser
 
 
+def test_ico(h, f):
+    if h.startswith(b"\x00\x00\x01\x00"):
+        return "ico"
+
+
+imghdr.tests.append(test_ico)
+
+
 class ViewImage(base.View):
     name = "Image"
     prompt = ("image", "i")
@@ -27,6 +35,8 @@ def __call__(self, data, **metadata):
             image_metadata = image_parser.parse_gif(data)
         elif image_type == 'jpeg':
             image_metadata = image_parser.parse_jpeg(data)
+        elif image_type == 'ico':
+            image_metadata = image_parser.parse_ico(data)
         else:
             image_metadata = [
                 ("Image Format", image_type or "unknown")
diff --git a/mitmproxy/contrib/kaitaistruct/ico.py b/mitmproxy/contrib/kaitaistruct/ico.py
new file mode 100644
--- /dev/null
+++ b/mitmproxy/contrib/kaitaistruct/ico.py
@@ -0,0 +1,90 @@
+# This is a generated file! Please edit source .ksy file and use kaitai-struct-compiler to rebuild
+
+from pkg_resources import parse_version
+from kaitaistruct import __version__ as ks_version, KaitaiStruct, KaitaiStream, BytesIO
+import struct
+
+
+if parse_version(ks_version) < parse_version('0.7'):
+    raise Exception("Incompatible Kaitai Struct Python API: 0.7 or later is required, but you have %s" % (ks_version))
+
+class Ico(KaitaiStruct):
+    """Microsoft Windows uses specific file format to store applications
+    icons - ICO. This is a container that contains one or more image
+    files (effectively, DIB parts of BMP files or full PNG files are
+    contained inside).
+    
+    .. seealso::
+       Source - https://msdn.microsoft.com/en-us/library/ms997538.aspx
+    """
+    def __init__(self, _io, _parent=None, _root=None):
+        self._io = _io
+        self._parent = _parent
+        self._root = _root if _root else self
+        self._read()
+
+    def _read(self):
+        self.magic = self._io.ensure_fixed_contents(struct.pack('4b', 0, 0, 1, 0))
+        self.num_images = self._io.read_u2le()
+        self.images = [None] * (self.num_images)
+        for i in range(self.num_images):
+            self.images[i] = self._root.IconDirEntry(self._io, self, self._root)
+
+
+    class IconDirEntry(KaitaiStruct):
+        def __init__(self, _io, _parent=None, _root=None):
+            self._io = _io
+            self._parent = _parent
+            self._root = _root if _root else self
+            self._read()
+
+        def _read(self):
+            self.width = self._io.read_u1()
+            self.height = self._io.read_u1()
+            self.num_colors = self._io.read_u1()
+            self.reserved = self._io.ensure_fixed_contents(struct.pack('1b', 0))
+            self.num_planes = self._io.read_u2le()
+            self.bpp = self._io.read_u2le()
+            self.len_img = self._io.read_u4le()
+            self.ofs_img = self._io.read_u4le()
+
+        @property
+        def img(self):
+            """Raw image data. Use `is_png` to determine whether this is an
+            embedded PNG file (true) or a DIB bitmap (false) and call a
+            relevant parser, if needed to parse image data further.
+            """
+            if hasattr(self, '_m_img'):
+                return self._m_img if hasattr(self, '_m_img') else None
+
+            _pos = self._io.pos()
+            self._io.seek(self.ofs_img)
+            self._m_img = self._io.read_bytes(self.len_img)
+            self._io.seek(_pos)
+            return self._m_img if hasattr(self, '_m_img') else None
+
+        @property
+        def png_header(self):
+            """Pre-reads first 8 bytes of the image to determine if it's an
+            embedded PNG file.
+            """
+            if hasattr(self, '_m_png_header'):
+                return self._m_png_header if hasattr(self, '_m_png_header') else None
+
+            _pos = self._io.pos()
+            self._io.seek(self.ofs_img)
+            self._m_png_header = self._io.read_bytes(8)
+            self._io.seek(_pos)
+            return self._m_png_header if hasattr(self, '_m_png_header') else None
+
+        @property
+        def is_png(self):
+            """True if this image is in PNG format."""
+            if hasattr(self, '_m_is_png'):
+                return self._m_is_png if hasattr(self, '_m_is_png') else None
+
+            self._m_is_png = self.png_header == struct.pack('8b', -119, 80, 78, 71, 13, 10, 26, 10)
+            return self._m_is_png if hasattr(self, '_m_is_png') else None
+
+
+


Test patch:
diff --git a/test/mitmproxy/contentviews/image/test_image_parser.py b/test/mitmproxy/contentviews/image/test_image_parser.py
--- a/test/mitmproxy/contentviews/image/test_image_parser.py
+++ b/test/mitmproxy/contentviews/image/test_image_parser.py
@@ -167,3 +167,26 @@ def test_parse_gif(filename, metadata):
 def test_parse_jpeg(filename, metadata):
     with open(tutils.test_data.path(filename), 'rb') as f:
         assert metadata == image_parser.parse_jpeg(f.read())
+
+
+@pytest.mark.parametrize("filename, metadata", {
+    "mitmproxy/data/image.ico": [
+        ('Format', 'ICO'),
+        ('Number of images', '3'),
+        ('Image 1', "Size: {} x {}\n"
+                    "{: >18}Bits per pixel: {}\n"
+                    "{: >18}PNG: {}".format(48, 48, '', 24, '', False)
+         ),
+        ('Image 2', "Size: {} x {}\n"
+                    "{: >18}Bits per pixel: {}\n"
+                    "{: >18}PNG: {}".format(32, 32, '', 24, '', False)
+         ),
+        ('Image 3', "Size: {} x {}\n"
+                    "{: >18}Bits per pixel: {}\n"
+                    "{: >18}PNG: {}".format(16, 16, '', 24, '', False)
+         )
+    ]
+}.items())
+def test_ico(filename, metadata):
+    with open(tutils.test_data.path(filename), 'rb') as f:
+        assert metadata == image_parser.parse_ico(f.read())
diff --git a/test/mitmproxy/contentviews/image/test_view.py b/test/mitmproxy/contentviews/image/test_view.py
--- a/test/mitmproxy/contentviews/image/test_view.py
+++ b/test/mitmproxy/contentviews/image/test_view.py
@@ -9,8 +9,7 @@ def test_view_image():
         "mitmproxy/data/image.png",
         "mitmproxy/data/image.gif",
         "mitmproxy/data/all.jpeg",
-        # https://bugs.python.org/issue21574
-        # "mitmproxy/data/image.ico",
+        "mitmproxy/data/image.ico",
     ]:
         with open(tutils.test_data.path(img), "rb") as f:
             viewname, lines = v(f.read())