← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~pappacena/launchpad-buildd:security-manifest-packages into launchpad-buildd:master

 

Thiago F. Pappacena has proposed merging ~pappacena/launchpad-buildd:security-manifest-packages into launchpad-buildd:master with ~pappacena/launchpad-buildd:security-manifest-build-metadata as a prerequisite.

Commit message:
Adding OS info and packages to OCI security manifest file

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~pappacena/launchpad-buildd/+git/launchpad-buildd/+merge/392623
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~pappacena/launchpad-buildd:security-manifest-packages into launchpad-buildd:master.
diff --git a/lpbuildd/target/build_oci.py b/lpbuildd/target/build_oci.py
index 679a12f..b49677c 100644
--- a/lpbuildd/target/build_oci.py
+++ b/lpbuildd/target/build_oci.py
@@ -9,6 +9,7 @@ from collections import OrderedDict
 import json
 import logging
 import os.path
+import re
 import sys
 import tempfile
 from textwrap import dedent
@@ -112,7 +113,8 @@ class BuildOCI(SnapBuildProxyOperationMixin, VCSOperationMixin,
             # Add any proxy settings that are needed
             self._add_docker_engine_proxy_settings()
         deps.extend(self.vcs_deps)
-        deps.extend(["docker.io"])
+        # Install dctrl-tools to extract installed packages using grep-dctrl.
+        deps.extend(["docker.io", "dctrl-tools"])
         self.backend.run(["apt-get", "-y", "install"] + deps)
         if self.args.backend in ("lxd", "fake"):
             self.snap_store_set_proxy()
@@ -136,6 +138,48 @@ class BuildOCI(SnapBuildProxyOperationMixin, VCSOperationMixin,
             revision_cmd, cwd=os.path.join("/home/buildd", self.args.name),
             get_output=True).decode("UTF-8", "replace").strip()
 
+    def _getContainerPackageList(self):
+        tmp_file = "/tmp/dpkg-status"
+        self.run_build_command([
+            "docker", "cp", "-L",
+            "%s:/var/lib/dpkg/status" % self.args.name, tmp_file])
+        output = self.backend.run([
+            "grep-dctrl", "-s", "Package,Version,Source", "", tmp_file],
+            get_output=True).decode("UTF-8", "replace")
+        packages = []
+        empty_pkg_details = dict.fromkeys(["package", "version", "source"])
+        current_package = empty_pkg_details.copy()
+        for line in output.split("\n"):
+            if not line.strip():
+                if not all(i is None for i in current_package.values()):
+                    packages.append(current_package)
+                    current_package = empty_pkg_details.copy()
+                continue
+            k, v = line.split(":", 1)
+            current_package[k.lower().strip()] = v.strip()
+        if not all(i is None for i in current_package.values()):
+            packages.append(current_package)
+        return packages
+
+    def _getContainerOSRelease(self):
+        tmp_file = "/tmp/os-release"
+        self.run_build_command([
+            "docker", "cp",  "-L",
+            "%s:/etc/os-release" % self.args.name, tmp_file])
+        content = self.backend.run(["cat", tmp_file], get_output=True)
+        os_release = {}
+        # Variable content might be enclosed by double-quote, single-quote
+        # or no quote at all. We accept everything.
+        content_expr = re.compile(r""""(.*)"|'(.*)'|(.*)""")
+        unquote = lambda string: [
+            i for i in content_expr.match(string).groups() if i is not None][0]
+        for line in content.decode("UTF-8", "replace").split("\n"):
+            if '=' not in line:
+                continue
+            key, value = line.strip().split("=", 1)
+            os_release[key] = unquote(value)
+        return os_release
+
     def _getSecurityManifestContent(self):
         try:
             metadata = json.loads(self.args.metadata) or {}
@@ -147,14 +191,26 @@ class BuildOCI(SnapBuildProxyOperationMixin, VCSOperationMixin,
                   if i.get("email")]
 
         try:
+            packages = self._getContainerPackageList()
+        except Exception as e:
+            logger.warning("Failed to get container package list: %s", e)
+            packages = []
+        try:
             vcs_current_version = self._getCurrentVCSRevision()
         except Exception as e:
             logger.warning("Failed to get current VCS revision: %s" % e)
             vcs_current_version = None
+        try:
+            os_release = self._getContainerOSRelease()
+        except Exception as e:
+            logger.warning("Failed to get /etc/os-release info: %s" % e)
+            os_release = {}
 
         return {
             "manifest-version": "1",
             "name": self.args.name,
+            "os-release-id": os_release.get("ID"),
+            "os-release-version-id": os_release.get("VERSION_ID"),
             "architectures": metadata.get("architectures") or [self.args.arch],
             "publisher-emails": emails,
             "image-info": {
@@ -170,7 +226,8 @@ class BuildOCI(SnapBuildProxyOperationMixin, VCSOperationMixin,
                 "source-subdir": self.args.build_path,
                 "source-build-file": self.args.build_file,
                 "source-build-args": self.args.build_arg
-            }]
+            }],
+            "packages": packages
         }
 
     def createSecurityManifest(self):
@@ -182,6 +239,7 @@ class BuildOCI(SnapBuildProxyOperationMixin, VCSOperationMixin,
         destination_path = self.security_manifest_target_path.lstrip(
             os.path.sep)
         destination = os.path.join(self.backend_tmp_fs_dir, destination_path)
+        logger.info("Security manifest: %s" % content)
         with open(local_filename, 'w') as fd:
             json.dump(content, fd, indent=2)
         self.backend.copy_in(local_filename, destination)
diff --git a/lpbuildd/target/tests/test_build_oci.py b/lpbuildd/target/tests/test_build_oci.py
index 0572b4e..7d79678 100644
--- a/lpbuildd/target/tests/test_build_oci.py
+++ b/lpbuildd/target/tests/test_build_oci.py
@@ -5,6 +5,10 @@ __metaclass__ = type
 
 import datetime
 import json
+try:
+    from unittest import mock
+except ImportError:
+    import mock
 import os.path
 import stat
 import subprocess
@@ -99,10 +103,53 @@ class TestBuildOCIManifestGeneration(TestCase):
             "test-image"
         ]
         build_oci = parse_args(args=args).operation
-        build_oci.backend.run.result = b"a1b2c3d4e5f5\n"
+
+        # Expected build_oci.backend.run outputs.
+        commit_hash = b"a1b2c3d4e5f5"
+        grep_dctrl_output = dedent("""
+        Package: adduser
+        Version: 3.118
+        
+        Package: apt
+        Version: 1.8.2.1
+
+        Package: util-linux
+        Version: 2.33.1
+        
+        Package: zlib1g
+        Version: 1:1.2.11
+        Source: zlib
+        """).encode('utf8')
+
+        os_release_cat_output = dedent("""
+        NAME="Ubuntu"
+        VERSION="20.04.1 LTS (Focal Fossa)"
+        ID=ubuntu
+        ID_LIKE=debian
+        PRETTY_NAME="Ubuntu 20.04.1 LTS"
+        VERSION_ID="20.04"
+        HOME_URL="https://www.ubuntu.com/";
+        SUPPORT_URL="https://help.ubuntu.com/";
+        BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/";
+        PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy";
+        VERSION_CODENAME=focal
+        UBUNTU_CODENAME=focal
+        """).encode("utf8")
+
+        # Side effect for "docker cp...", "dgrep-dctrl" and "git rev-parse..."
+        build_oci.backend.run = mock.Mock(side_effect=[
+            # docker cp and dgrep-dctrl to get packages.
+            None, grep_dctrl_output,
+            # git rev-parse HEAD to get current revision.
+            commit_hash,
+            # docker cp and cat for container /etc/os-release.
+            None, os_release_cat_output])
+
         self.assertEqual(build_oci._getSecurityManifestContent(), {
             "manifest-version": "1",
             "name": "test-image",
+            'os-release-id': "ubuntu",
+            'os-release-version-id': "20.04",
             "architectures": ["amd64", "386"],
             "publisher-emails": ["me@xxxxxxx", "someone@xxxxxxx"],
             "image-info": {
@@ -118,7 +165,13 @@ class TestBuildOCIManifestGeneration(TestCase):
                 "source-build-file": "SomeDockerfile",
                 "source-commit": "a1b2c3d4e5f5",
                 "source-subdir": "docker/builder"
-            }]
+            }],
+            "packages": [
+                {'package': 'adduser', 'source': None, 'version': '3.118'},
+                {'package': 'apt', 'source': None, 'version': '1.8.2.1'},
+                {'package': 'util-linux', 'source': None, 'version': '2.33.1'},
+                {'package': 'zlib1g', 'source': 'zlib', 'version': '1:1.2.11'}
+            ]
         })
 
     def test_getSecurityManifestContent_without_manifest(self):
@@ -130,10 +183,17 @@ class TestBuildOCIManifestGeneration(TestCase):
             "--git-repository", "lp:git-repo", "--git-path", "refs/heads/main",
             "test-image"
         ]
+
+        # Here we will not mock the package gathering nor os-release file
+        # reading in order to let it raise exception, so we end up with a
+        # manifest without packages.
         build_oci = parse_args(args=args).operation
+
         self.assertEqual(build_oci._getSecurityManifestContent(), {
             "manifest-version": "1",
             "name": "test-image",
+            'os-release-id': None,
+            'os-release-version-id': None,
             "architectures": ["amd64"],
             "publisher-emails": [],
             "image-info": {
@@ -147,9 +207,38 @@ class TestBuildOCIManifestGeneration(TestCase):
                 "source-build-file": None,
                 "source-commit": None,
                 "source-subdir": "."
-            }]
+            }],
+            "packages": []
         })
 
+    def test_getContainerPackageList(self):
+        args = [
+            "build-oci",
+            "--backend=fake", "--series=xenial", "--arch=amd64", "1",
+            "--git-repository", "lp:git-repo", "--git-path", "refs/heads/main",
+            "test-image"
+        ]
+        build_oci = parse_args(args=args).operation
+        build_oci.backend.run = mock.Mock(return_value=dedent("""
+        Package: adduser
+        Version: 3.118
+        
+        Package: apt
+        Version: 1.8.2.1
+
+        Package: util-linux
+        Version: 2.33.1-0.1
+        
+        Package: zlib1g
+        Version: 1:1.2.11
+        Source: zlib
+        """).encode("utf8"))
+        self.assertEqual([
+            {'package': 'adduser', 'source': None, 'version': '3.118'},
+            {'package': 'apt', 'source': None, 'version': '1.8.2.1'},
+            {'package': 'util-linux', 'source': None, 'version': '2.33.1-0.1'},
+            {'package': 'zlib1g', 'source': 'zlib', 'version': '1:1.2.11'}
+        ], build_oci._getContainerPackageList())
 
 class TestBuildOCI(TestCase):
 
@@ -192,7 +281,7 @@ class TestBuildOCI(TestCase):
         build_oci = parse_args(args=args).operation
         build_oci.install()
         self.assertThat(build_oci.backend.run.calls, MatchesListwise([
-            RanAptGet("install", "bzr", "docker.io"),
+            RanAptGet("install", "bzr", "docker.io", "dctrl-tools"),
             RanCommand(["systemctl", "restart", "docker"]),
             RanCommand(["mkdir", "-p", "/home/buildd"]),
             ]))
@@ -206,7 +295,7 @@ class TestBuildOCI(TestCase):
         build_oci = parse_args(args=args).operation
         build_oci.install()
         self.assertThat(build_oci.backend.run.calls, MatchesListwise([
-            RanAptGet("install", "git", "docker.io"),
+            RanAptGet("install", "git", "docker.io", "dctrl-tools"),
             RanCommand(["systemctl", "restart", "docker"]),
             RanCommand(["mkdir", "-p", "/home/buildd"]),
             ]))
@@ -262,7 +351,8 @@ class TestBuildOCI(TestCase):
         self.assertThat(build_oci.backend.run.calls, MatchesListwise([
             RanCommand(
                 ["mkdir", "-p", "/etc/systemd/system/docker.service.d"]),
-            RanAptGet("install", "python3", "socat", "git", "docker.io"),
+            RanAptGet("install", "python3", "socat", "git", "docker.io",
+                      "dctrl-tools"),
             RanCommand(["systemctl", "restart", "docker"]),
             RanCommand(["mkdir", "-p", "/home/buildd"]),
             ]))
@@ -377,8 +467,28 @@ class TestBuildOCI(TestCase):
                 ['docker', 'create', '--name', 'test-image', 'test-image'],
                 cwd="/home/buildd/test-image"),
             RanCommand(['mkdir', '-p', '/tmp/image-root-dir/.rocks']),
+
+            # Manifest building: packages discovery.
+            RanBuildCommand([
+                'docker', 'cp', '-L',
+                'test-image:/var/lib/dpkg/status', '/tmp/dpkg-status'],
+                cwd="/home/buildd/test-image"),
+            RanCommand([
+                'grep-dctrl', '-s', 'Package,Version,Source', '',
+                '/tmp/dpkg-status'], get_output=True),
+
+            # Manifest building: get current revision number.
             RanCommand(
                 rev_num_args, cwd="/home/buildd/test-image", get_output=True),
+
+            # Manifest building: os-release file.
+            RanBuildCommand([
+                'docker', 'cp',  '-L', 'test-image:/etc/os-release',
+                '/tmp/os-release'],
+                cwd="/home/buildd/test-image"),
+            RanCommand(['cat', '/tmp/os-release'], get_output=True),
+
+            # Filesystem injection and image commiting.
             RanBuildCommand(
                 ['docker', 'cp', '/tmp/image-root-dir/.', 'test-image:/'],
                 cwd="/home/buildd/test-image"),
@@ -505,7 +615,7 @@ class TestBuildOCI(TestCase):
         build_oci.backend.run = FakeMethod()
         self.assertEqual(0, build_oci.run())
         self.assertThat(build_oci.backend.run.calls, MatchesAll(
-            AnyMatch(RanAptGet("install", "bzr", "docker.io")),
+            AnyMatch(RanAptGet("install", "bzr", "docker.io", "dctrl-tools")),
             AnyMatch(RanBuildCommand(
                 ["bzr", "branch", "lp:foo", "test-image"],
                 cwd="/home/buildd")),