← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~enriqueesanchz/launchpad:add-vulnerability_patches into launchpad:master

 

Enrique Sánchez has proposed merging ~enriqueesanchz/launchpad:add-vulnerability_patches into launchpad:master.

Commit message:
Add bugattachment.vulnerability_patches support
    
Use one row per package and vulnerability_patches JSON column instead of
a bugattachment row for each patch. Now we mantain patches order.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~enriqueesanchz/launchpad/+git/launchpad/+merge/485293
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~enriqueesanchz/launchpad:add-vulnerability_patches into launchpad:master.
diff --git a/lib/lp/bugs/interfaces/bugattachment.py b/lib/lp/bugs/interfaces/bugattachment.py
index 4edb5de..8410bd7 100644
--- a/lib/lp/bugs/interfaces/bugattachment.py
+++ b/lib/lp/bugs/interfaces/bugattachment.py
@@ -22,7 +22,7 @@ from lazr.restful.declarations import (
 )
 from lazr.restful.fields import Reference
 from zope.interface import Interface
-from zope.schema import URI, Bool, Bytes, Choice, Int, TextLine
+from zope.schema import URI, Bool, Bytes, Choice, Int, List, TextLine
 
 from lp import _
 from lp.bugs.interfaces.hasbug import IHasBug
@@ -118,6 +118,9 @@ class IBugAttachmentView(IHasBug):
         ),
         readonly=True,
     )
+    vulnerability_patches = List(
+        title=_("Vulnerability patches"), readonly=True
+    )
 
     def getFileByName(filename):
         """Return the `ILibraryFileAlias` for the given file name.
diff --git a/lib/lp/bugs/model/bug.py b/lib/lp/bugs/model/bug.py
index 989abe8..2ebaad5 100644
--- a/lib/lp/bugs/model/bug.py
+++ b/lib/lp/bugs/model/bug.py
@@ -27,6 +27,7 @@ from email.utils import make_msgid
 from functools import wraps
 from io import BytesIO
 from itertools import chain
+from typing import List
 
 from lazr.lifecycle.event import ObjectCreatedEvent
 from lazr.lifecycle.snapshot import Snapshot
@@ -1634,6 +1635,7 @@ class Bug(StormBase, InformationTypeMixin):
         content_type=None,
         description=None,
         from_api=False,
+        vulnerability_patches: List[dict] = None,
     ):
         """See `IBug`."""
         # XXX: StevenK 2013-02-06 bug=1116954: We should not need to refetch
@@ -1673,6 +1675,7 @@ class Bug(StormBase, InformationTypeMixin):
             comment=comment,
             is_patch=is_patch,
             description=description,
+            vulnerability_patches=vulnerability_patches,
         )
 
     def linkAttachment(
@@ -1684,6 +1687,7 @@ class Bug(StormBase, InformationTypeMixin):
         is_patch=False,
         description=None,
         send_notifications=True,
+        vulnerability_patches: List[dict] = None,
     ):
         """See `IBug`.
 
@@ -1717,6 +1721,7 @@ class Bug(StormBase, InformationTypeMixin):
             title=description,
             message=message,
             send_notifications=send_notifications,
+            vulnerability_patches=vulnerability_patches,
         )
 
     def linkBranch(self, branch, registrant):
diff --git a/lib/lp/bugs/model/bugattachment.py b/lib/lp/bugs/model/bugattachment.py
index 064482a..896a956 100644
--- a/lib/lp/bugs/model/bugattachment.py
+++ b/lib/lp/bugs/model/bugattachment.py
@@ -3,7 +3,10 @@
 
 __all__ = ["BugAttachment", "BugAttachmentSet"]
 
+from typing import List
+
 from lazr.lifecycle.event import ObjectCreatedEvent, ObjectDeletedEvent
+from storm.databases.postgres import JSON
 from storm.locals import Int, Reference, Store, Unicode
 from zope.event import notify
 from zope.interface import implementer
@@ -42,6 +45,7 @@ class BugAttachment(StormBase):
     url = Unicode(allow_none=True)
     _message_id = Int(name="message", allow_none=False)
     _message = Reference(_message_id, "Message.id")
+    vulnerability_patches = JSON(allow_none=True)
 
     def __init__(
         self,
@@ -51,6 +55,7 @@ class BugAttachment(StormBase):
         url,
         message,
         type=IBugAttachment["type"].default,
+        vulnerability_patches: List[dict] = None,
     ):
         super().__init__()
         self.bug = bug
@@ -59,6 +64,7 @@ class BugAttachment(StormBase):
         self.url = url
         self._message = message
         self.type = type
+        self.vulnerability_patches = vulnerability_patches
 
     @property
     def title(self) -> str:
@@ -139,13 +145,20 @@ class BugAttachmentSet:
         message,
         attach_type=None,
         send_notifications=False,
+        vulnerability_patches: List[dict] = None,
     ):
         """See `IBugAttachmentSet`."""
-        if not filealias and not url:
-            raise ValueError("Either filealias or url must be provided")
-
-        if filealias and url:
-            raise ValueError("Only one of filealias or url may be provided")
+        if not filealias and not url and not vulnerability_patches:
+            raise ValueError(
+                "Either filealias, url or vulnerability_patches "
+                "must be provided"
+            )
+
+        if filealias and url and vulnerability_patches:
+            raise ValueError(
+                "Only one of filealias, url or vulnerability_patches may be "
+                "provided"
+            )
 
         if attach_type is None:
             # XXX kiko 2005-08-03 bug=1659: this should use DEFAULT.
@@ -157,6 +170,7 @@ class BugAttachmentSet:
             type=attach_type,
             title=title,
             message=message,
+            vulnerability_patches=vulnerability_patches,
         )
         # canonial_url(attachment) (called by notification subscribers
         # to generate the download URL of the attachments) blows up if
diff --git a/lib/lp/bugs/scripts/tests/test_uct.py b/lib/lp/bugs/scripts/tests/test_uct.py
index d2242d5..2fe5eee 100644
--- a/lib/lp/bugs/scripts/tests/test_uct.py
+++ b/lib/lp/bugs/scripts/tests/test_uct.py
@@ -722,8 +722,8 @@ class TestUCTImporterExporter(TestCaseWithFactory):
                     ),
                     package_name=self.esm_package.sourcepackagename,
                     importance=None,
-                    status=BugTaskStatus.WONTFIX,
-                    status_explanation="ignored",
+                    status=BugTaskStatus.FIXRELEASED,
+                    status_explanation="fix released",
                 ),
                 CVE.SeriesPackage(
                     target=SourcePackage(
@@ -748,8 +748,8 @@ class TestUCTImporterExporter(TestCaseWithFactory):
                     target=self.product_2,
                     package_name=self.esm_package.sourcepackagename,
                     importance=BugTaskImportance.LOW,
-                    status=BugTaskStatus.WONTFIX,
-                    status_explanation="ignored",
+                    status=BugTaskStatus.FIXRELEASED,
+                    status_explanation="fix released",
                 ),
             ],
             importance=BugTaskImportance.MEDIUM,
@@ -784,6 +784,12 @@ class TestUCTImporterExporter(TestCaseWithFactory):
                     url="https://github.com/389ds/389-ds-base/"; "commit/456",
                     notes=None,
                 ),
+                CVE.PatchURL(
+                    package_name=self.esm_package.sourcepackagename,
+                    type="upstream",
+                    url="https://github.com/vim/vim/"; "commit/188",
+                    notes=None,
+                ),
             ],
             global_tags={"cisa-kev"},
         )
@@ -871,18 +877,53 @@ class TestUCTImporterExporter(TestCaseWithFactory):
             self.assertEqual(cve.assignee, t.assignee)
 
     def checkBugAttachments(self, bug: Bug, cve: CVE):
-        attachments_by_url = {a.url: a for a in bug.attachments if a.url}
+        # attachment.title is the package name
+        attachments_by_pkg = {att.title: att for att in bug.attachments}
+        patch_url_by_pkg = defaultdict(list)
         for patch_url in cve.patch_urls:
-            self.assertIn(patch_url.url, attachments_by_url)
-            attachment = attachments_by_url[patch_url.url]
-            expected_title = "{}/{}".format(
-                patch_url.package_name.name, patch_url.type
+            patch_url_by_pkg[patch_url.package_name.name].append(patch_url)
+
+        self.assertEqual(
+            len(attachments_by_pkg),
+            len(patch_url_by_pkg),
+            "Mismatch in attachment count and patch URL count",
+        )
+
+        for pkg, patch_urls in patch_url_by_pkg.items():
+            attachment = attachments_by_pkg.get(pkg)
+            self.assertIsNotNone(
+                attachment, f"Attachment for package '{pkg}' not found"
             )
-            if patch_url.notes:
-                expected_title = "{}/{}".format(
-                    expected_title, patch_url.notes
+
+            self.assertEqual(pkg, attachment.title)
+
+            vulnerability_patches = attachment.vulnerability_patches
+            self.assertEqual(
+                len(patch_urls),
+                len(vulnerability_patches),
+                "Number of patches and vulnerabilities don't match for "
+                f"package '{pkg}'",
+            )
+
+            # Check content and its order
+            for patch_url, vulnerability_patch in zip(
+                patch_urls, vulnerability_patches
+            ):
+                self.assertEqual(
+                    patch_url.type,
+                    vulnerability_patch["name"],
+                    f"Type mismatch for patch in package '{pkg}'",
+                )
+                self.assertEqual(
+                    patch_url.url,
+                    vulnerability_patch["value"],
+                    f"URL mismatch for patch in package '{pkg}'",
+                )
+                self.assertEqual(
+                    patch_url.notes,
+                    vulnerability_patch["comment"],
+                    f"Notes mismatch for patch in package '{pkg}'",
                 )
-            self.assertEqual(expected_title, attachment.title)
 
     def checkVulnerabilities(self, bug: Bug, cve: CVE):
         vulnerabilities = bug.vulnerabilities
@@ -1312,6 +1353,14 @@ class TestUCTImporterExporter(TestCaseWithFactory):
                 notes=None,
             )
         )
+        cve.patch_urls.append(
+            CVE.PatchURL(
+                package_name=cve.distro_packages[1].package_name,
+                type="upstream",
+                url="https://github.com/012";,
+                notes=None,
+            )
+        )
         self.importer.update_bug(bug, cve, self.lp_cve)
         self.checkBug(bug, cve)
 
diff --git a/lib/lp/bugs/scripts/uct/uctexport.py b/lib/lp/bugs/scripts/uct/uctexport.py
index 937860f..8fc72aa 100644
--- a/lib/lp/bugs/scripts/uct/uctexport.py
+++ b/lib/lp/bugs/scripts/uct/uctexport.py
@@ -2,7 +2,6 @@
 #  GNU Affero General Public License version 3 (see the file LICENSE).
 
 import logging
-import re
 from collections import defaultdict
 from pathlib import Path
 from typing import Dict, List, NamedTuple, Optional
@@ -42,13 +41,6 @@ class UCTExporter:
         description: str
         references: List[str]
 
-    # Example:
-    # linux/upstream
-    # linux/upstream/5.19.1
-    BUG_ATTACHMENT_TITLE_RE = re.compile(
-        "^(?P<package_name>.+?)/(?P<patch_type>.+?)(/(?P<notes>.+?))?$"
-    )
-
     def export_bug_to_uct_file(
         self, bug_id: int, output_dir: Path
     ) -> Optional[Path]:
@@ -203,24 +195,20 @@ class UCTExporter:
         patch_urls = []
         for attachment in bug.attachments:
             if (
-                not attachment.url
+                not attachment.vulnerability_patches
                 or not attachment.type == BugAttachmentType.PATCH
             ):
                 continue
-            title_match = self.BUG_ATTACHMENT_TITLE_RE.match(attachment.title)
-            if not title_match:
-                continue
-            spn = packages_by_name.get(title_match.groupdict()["package_name"])
-            if not spn:
-                continue
-            patch_urls.append(
-                CVE.PatchURL(
-                    package_name=spn,
-                    type=title_match.groupdict()["patch_type"],
-                    url=attachment.url,
-                    notes=title_match.groupdict().get("notes"),
+
+            for patch in attachment.vulnerability_patches:
+                patch_urls.append(
+                    CVE.PatchURL(
+                        package_name=packages_by_name.get(attachment.title),
+                        type=patch["name"],
+                        url=patch["value"],
+                        notes=patch["comment"],
+                    )
                 )
-            )
 
         return CVE(
             sequence=f"CVE-{lp_cve.sequence}",
diff --git a/lib/lp/bugs/scripts/uct/uctimport.py b/lib/lp/bugs/scripts/uct/uctimport.py
index e9f1dde..4a04510 100644
--- a/lib/lp/bugs/scripts/uct/uctimport.py
+++ b/lib/lp/bugs/scripts/uct/uctimport.py
@@ -25,6 +25,7 @@ Three types of bug tags are created:
    status of the package in upstream.
 """
 import logging
+from collections import defaultdict
 from datetime import timezone
 from itertools import chain
 from pathlib import Path
@@ -446,26 +447,41 @@ class UCTImporter:
             bug_watch_set.fromText(external_bug_url, bug, self.bug_importer)
 
     def _update_patches(self, bug: BugModel, patch_urls: List[CVE.PatchURL]):
-        attachments_by_url = {a.url: a for a in bug.attachments if a.url}
+        if not patch_urls:
+            return
+
+        attachments_by_pkg = {att.title: att for att in bug.attachments}
+
+        vulnerability_patches_by_pkg = defaultdict(list)
         for patch_url in patch_urls:
-            title = f"{patch_url.package_name.name}/{patch_url.type}"
-            if patch_url.notes:
-                title = f"{title}/{patch_url.notes}"
-            if patch_url in attachments_by_url:
-                attachment = removeSecurityProxy(
-                    attachments_by_url[patch_url.url]
-                )
-                attachment.title = title
+            vulnerability_patches_by_pkg[patch_url.package_name.name].append(
+                {
+                    "name": patch_url.type,
+                    "value": patch_url.url,
+                    "comment": patch_url.notes,
+                }
+            )
+
+        for (
+            name,
+            vulnerability_patches,
+        ) in vulnerability_patches_by_pkg.items():
+            attachment = attachments_by_pkg.get(name)
+
+            if attachment:
+                attachment = removeSecurityProxy(attachment)
                 attachment.type = BugAttachmentType.PATCH
+                attachment.vulnerability_patches = vulnerability_patches
             else:
                 bug.addAttachment(
                     owner=bug.owner,
                     data=None,
                     comment=None,
                     filename=None,
-                    url=patch_url.url,
+                    url="REMOVE",
                     is_patch=True,
-                    description=title,
+                    description=name,
+                    vulnerability_patches=vulnerability_patches,
                 )
 
     def _make_bug_description(self, cve: CVE) -> str: