← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~lgp171188/launchpad:vulnerability-creation-api into launchpad:master

 

Guruprasad has proposed merging ~lgp171188/launchpad:vulnerability-creation-api into launchpad:master.

Commit message:
WIP changes

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~lgp171188/launchpad/+git/launchpad/+merge/420401
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~lgp171188/launchpad:vulnerability-creation-api into launchpad:master.
diff --git a/lib/lp/_schema_circular_imports.py b/lib/lp/_schema_circular_imports.py
index 9f981c0..1d3cb17 100644
--- a/lib/lp/_schema_circular_imports.py
+++ b/lib/lp/_schema_circular_imports.py
@@ -47,6 +47,7 @@ from lp.bugs.interfaces.structuralsubscription import (
     IStructuralSubscription,
     IStructuralSubscriptionTarget,
     )
+from lp.bugs.interfaces.vulnerability import IVulnerability
 from lp.buildmaster.interfaces.builder import (
     IBuilder,
     IBuilderSet,
@@ -452,6 +453,7 @@ patch_collection_property(IDistribution, 'all_distro_archives', IArchive)
 patch_entry_return_type(IDistribution, 'newOCIProject', IOCIProject)
 patch_collection_return_type(
     IDistribution, 'searchOCIProjects', IOCIProject)
+patch_collection_property(IDistribution, 'vulnerabilities', IVulnerability)
 
 
 # IDistributionMirror
diff --git a/lib/lp/bugs/browser/configure.zcml b/lib/lp/bugs/browser/configure.zcml
index 142d99a..d03a00c 100644
--- a/lib/lp/bugs/browser/configure.zcml
+++ b/lib/lp/bugs/browser/configure.zcml
@@ -892,6 +892,11 @@
             permission="zope.Public"
             template="../templates/cve-portlet-bugs2.pt"/>
     <browser:url
+        for="lp.bugs.interfaces.vulnerability.IVulnerability"
+        path_expression="string:+vulnerability/${id}"
+        attribute_to_parent="distribution"
+        rootsite="bugs"/>
+    <browser:url
         for="lp.bugs.interfaces.bugsubscription.IBugSubscription"
         path_expression="string:+subscription/${person/name}"
         attribute_to_parent="bug"
diff --git a/lib/lp/bugs/enums.py b/lib/lp/bugs/enums.py
index 913e1bf..e6f6fc4 100644
--- a/lib/lp/bugs/enums.py
+++ b/lib/lp/bugs/enums.py
@@ -8,6 +8,7 @@ __all__ = [
     'BugLockStatus',
     'BugNotificationLevel',
     'BugNotificationStatus',
+    'VulnerabilityStatus',
     ]
 
 from lazr.enum import (
@@ -103,3 +104,31 @@ class BugLockedStatus(DBEnumeratedType):
     The various locked status values of a bug.
     """
     use_template(BugLockStatus, exclude=('UNLOCKED',))
+
+
+class VulnerabilityStatus(DBEnumeratedType):
+    """Vulnerability status"""
+
+    NEEDS_TRIAGE = DBItem(0, """
+        Needs triage
+
+        Not looked at yet.
+        """)
+
+    ACTIVE = DBItem(1, """
+        Active
+
+        The vulnerability is active.
+        """)
+
+    IGNORED = DBItem(2, """
+        Ignored
+
+        The vulnerability is currently ignored.
+        """)
+
+    RETIRED = DBItem(3, """
+        Retired
+
+        This vulnerability is now retired.
+        """)
diff --git a/lib/lp/bugs/interfaces/vulnerability.py b/lib/lp/bugs/interfaces/vulnerability.py
index 8fa01da..0d0b8ad 100644
--- a/lib/lp/bugs/interfaces/vulnerability.py
+++ b/lib/lp/bugs/interfaces/vulnerability.py
@@ -9,13 +9,16 @@ __all__ = [
     'IVulnerabilityActivitySet',
     'IVulnerabilitySet',
     'VulnerabilityChange',
-    'VulnerabilityStatus'
     ]
 
 from lazr.enum import (
     DBEnumeratedType,
     DBItem,
     )
+from lazr.restful.declarations import (
+    exported,
+    exported_as_webservice_entry,
+    )
 from lazr.restful.fields import Reference
 from zope.interface import Interface
 from zope.schema import (
@@ -28,6 +31,7 @@ from zope.schema import (
 from lp import _
 from lp.app.enums import InformationType
 from lp.app.interfaces.informationtype import IInformationType
+from lp.bugs.enums import VulnerabilityStatus
 from lp.bugs.interfaces.bugtask import BugTaskImportance
 from lp.bugs.interfaces.cve import ICve
 from lp.registry.interfaces.distribution import IDistribution
@@ -84,52 +88,53 @@ class VulnerabilityChange(DBEnumeratedType):
         """)
 
 
-class VulnerabilityStatus(DBEnumeratedType):
-    """Vulnerability status"""
-
-    NEEDS_TRIAGE = DBItem(0, """
-        Needs triage
-
-        Not looked at yet.
-        """)
-
-    ACTIVE = DBItem(1, """
-        Active
-
-        The vulnerability is active.
-        """)
-
-    IGNORED = DBItem(2, """
-        Ignored
-
-        The vulnerability is currently ignored.
-        """)
-
-    RETIRED = DBItem(3, """
-        Retired
-
-        This vulnerability is now retired.
-        """)
-
-
 class IVulnerabilityView(Interface):
     """`IVulnerability` attributes that require launchpad.View."""
 
-    id = Int(title=_("ID"), required=True, readonly=True)
+    id = exported(
+        Int(title=_("ID"), required=True, readonly=True),
+        as_of="devel"
+    )
 
-    distribution = Reference(IDistribution, title=_("Distribution"),
-                             required=True, readonly=True)
+    distribution = exported(
+        Reference(
+            IDistribution,
+            title=_("Distribution"),
+            required=True,
+            readonly=True
+        ),
+        as_of="devel"
+    )
 
-    cve = Reference(ICve, title=_('External CVE reference corresponding'
-                                  ' to this vulnerability, if any.'),
-                    required=False, readonly=True)
+    cve = exported(
+        Reference(
+            ICve,
+            title=_('External CVE reference corresponding'
+                    ' to this vulnerability, if any.'),
+            required=False,
+            readonly=True
+        ),
+        as_of="devel"
+    )
 
-    date_created = Datetime(
-        title=_("The date this vulnerability was made public."),
-        required=True, readonly=True)
+    date_created = exported(
+        Datetime(
+            title=_("The date this vulnerability was created."),
+            required=True,
+            readonly=True
+        ),
+        as_of="devel"
+    )
 
-    creator = Reference(
-        title=_('Person'), schema=IPerson, required=True, readonly=True)
+    creator = exported(
+        Reference(
+            title=_('Person'),
+            schema=IPerson,
+            required=True,
+            readonly=True
+        ),
+        as_of="devel"
+    )
 
 
 class IVulnerabilityEditableAttributes(Interface):
@@ -138,51 +143,100 @@ class IVulnerabilityEditableAttributes(Interface):
     These attributes need launchpad.View to see, and launchpad.Edit to change.
     """
 
-    status = Choice(
-        title=_('Result of the report'),  readonly=True,
-        required=True, vocabulary=VulnerabilityStatus)
+    status = exported(
+        Choice(
+            title=_('The status of the vulnerability.'),
+            readonly=True,
+            required=True,
+            vocabulary=VulnerabilityStatus
+        ),
+        as_of="devel"
+    )
 
-    description = TextLine(
-        title=_("A short description of the vulnerability."), required=False,
-        readonly=False)
+    description = exported(
+        TextLine(
+            title=_("A short description of the vulnerability."),
+            required=False,
+            readonly=False
+        ),
+        as_of="devel"
+    )
 
-    notes = TextLine(
-        title=_("Free-form notes for this vulnerability."), required=False,
-        readonly=False)
+    notes = exported(
+        TextLine(
+            title=_("Free-form notes for this vulnerability."),
+            required=False,
+            readonly=False
+        ),
+        as_of="devel"
+    )
 
-    mitigation = TextLine(
-        title=_("Explains why we're ignoring a vulnerability."),
-        required=False, readonly=False)
+    mitigation = exported(
+        TextLine(
+            title=_("Explains why we're ignoring this vulnerability."),
+            required=False,
+            readonly=False
+        ),
+        as_of="devel"
+    )
 
-    importance = Choice(title=_('Importance used to indicate work priority,'
-                                ' not severity'),
-                        vocabulary=BugTaskImportance, required=True,
-                        default=BugTaskImportance.UNDECIDED, readonly=True)
+    importance = exported(
+        Choice(
+            title=_('Indicates the work priority, not the severity'),
+            vocabulary=BugTaskImportance,
+            required=True,
+            default=BugTaskImportance.UNDECIDED,
+            readonly=True
+        ),
+        as_of="devel"
+    )
 
-    importance_explanation = TextLine(
-        title=_("Used to explain why our importance differs "
-                "from somebody else's CVSS score."),
-        required=False, readonly=False)
+    importance_explanation = exported(
+        TextLine(
+            title=_("Used to explain why our importance differs "
+                    "from somebody else's CVSS score."),
+            required=False,
+            readonly=False
+        ),
+        as_of="devel"
+    )
 
-    information_type = Choice(
-        title=_("Information type"), vocabulary=InformationType,
-        required=True, readonly=False, default=InformationType.PUBLIC,
-        description=_(
-            "Indicates privacy of the vulnerability."))
+    information_type = exported(
+        Choice(
+            title=_("Information type"),
+            vocabulary=InformationType,
+            required=True,
+            readonly=False,
+            default=InformationType.PUBLIC,
+            description=_(
+                "Indicates the privacy of the vulnerability."
+            )
+        ),
+        as_of="devel"
+    )
 
-    date_made_public = Datetime(
-        title=_("The date this vulnerability was made public."),
-        required=False, readonly=False)
+    date_made_public = exported(
+        Datetime(
+            title=_("The date this vulnerability was made public."),
+            required=False,
+            readonly=False
+        ),
+        as_of="devel"
+    )
 
 
 class IVulnerabilityEdit(Interface):
     """`IVulnerability` attributes that require launchpad.Edit."""
 
 
+# XXX lgp171188 2022-04-20 bug=760849: "beta" is a lie to get WADL
+# generation working.  Individual attributes must set their version to
+# "devel".
+@exported_as_webservice_entry(as_of="beta")
 class IVulnerability(IVulnerabilityView,
                      IVulnerabilityEditableAttributes,
                      IVulnerabilityEdit, IInformationType):
-    """Contract describing a vulnerability."""
+    """A vulnerability."""
 
 
 class IVulnerabilitySet(Interface):
diff --git a/lib/lp/bugs/interfaces/webservice.py b/lib/lp/bugs/interfaces/webservice.py
index f4e1425..82f0cdd 100644
--- a/lib/lp/bugs/interfaces/webservice.py
+++ b/lib/lp/bugs/interfaces/webservice.py
@@ -34,6 +34,7 @@ __all__ = [
     'IStructuralSubscriptionTarget',
     'IllegalRelatedBugTasksParams',
     'IllegalTarget',
+    'IVulnerability',
     'NominationError',
     'NominationSeriesObsoleteError',
     'UserCannotEditBugTaskAssignee',
@@ -87,6 +88,7 @@ from lp.bugs.interfaces.structuralsubscription import (
     IStructuralSubscription,
     IStructuralSubscriptionTarget,
     )
+from lp.bugs.interfaces.vulnerability import IVulnerability
 
 
 _schema_circular_imports
diff --git a/lib/lp/bugs/model/vulnerability.py b/lib/lp/bugs/model/vulnerability.py
index 77ebcb3..f10ece1 100644
--- a/lib/lp/bugs/model/vulnerability.py
+++ b/lib/lp/bugs/model/vulnerability.py
@@ -19,6 +19,7 @@ from zope.component import getUtility
 from zope.interface import implementer
 
 from lp.app.enums import InformationType
+from lp.bugs.enums import VulnerabilityStatus
 from lp.bugs.interfaces.buglink import IBugLinkTarget
 from lp.bugs.interfaces.bugtask import BugTaskImportance
 from lp.bugs.interfaces.vulnerability import (
@@ -27,7 +28,6 @@ from lp.bugs.interfaces.vulnerability import (
     IVulnerabilityActivitySet,
     IVulnerabilitySet,
     VulnerabilityChange,
-    VulnerabilityStatus,
     )
 from lp.bugs.model.bug import Bug
 from lp.bugs.model.buglinktarget import BugLinkTargetMixin
@@ -140,6 +140,7 @@ class VulnerabilitySet:
                                       importance_explanation,
                                       date_made_public=date_made_public)
         store.add(vulnerability)
+        store.flush()
         return vulnerability
 
 
diff --git a/lib/lp/registry/browser/distribution.py b/lib/lp/registry/browser/distribution.py
index 28f7e46..303ea20 100644
--- a/lib/lp/registry/browser/distribution.py
+++ b/lib/lp/registry/browser/distribution.py
@@ -226,6 +226,16 @@ class DistributionNavigation(
         else:
             return series
 
+    @stepthrough('+vulnerability')
+    def traverse_vulnerability(self, id):
+        try:
+            id = int(id)
+        except ValueError:
+            # Not a number.
+            return None
+
+        return self.context.getVulnerability(id)
+
     def traverse(self, name):
         policy = self.context.default_traversal_policy
         if policy == DistributionDefaultTraversalPolicy.SERIES:
diff --git a/lib/lp/registry/interfaces/distribution.py b/lib/lp/registry/interfaces/distribution.py
index 5ccc03d..afee0b5 100644
--- a/lib/lp/registry/interfaces/distribution.py
+++ b/lib/lp/registry/interfaces/distribution.py
@@ -59,6 +59,7 @@ from zope.schema import (
 from lp import _
 from lp.answers.interfaces.faqtarget import IFAQTarget
 from lp.answers.interfaces.questiontarget import IQuestionTarget
+from lp.app.enums import InformationType
 from lp.app.errors import NameLookupFailed
 from lp.app.interfaces.informationtype import IInformationType
 from lp.app.interfaces.launchpad import (
@@ -71,6 +72,7 @@ from lp.app.interfaces.launchpad import (
 from lp.app.validators.name import name_validator
 from lp.blueprints.interfaces.specificationtarget import ISpecificationTarget
 from lp.blueprints.interfaces.sprint import IHasSprints
+from lp.bugs.enums import VulnerabilityStatus
 from lp.bugs.interfaces.bugsupervisor import IHasBugSupervisor
 from lp.bugs.interfaces.bugtarget import (
     IBugTarget,
@@ -78,6 +80,8 @@ from lp.bugs.interfaces.bugtarget import (
     IOfficialBugTagTargetPublic,
     IOfficialBugTagTargetRestricted,
     )
+from lp.bugs.interfaces.bugtask import BugTaskImportance
+from lp.bugs.interfaces.cve import ICve
 from lp.bugs.interfaces.structuralsubscription import (
     IStructuralSubscriptionTarget,
     )
@@ -98,6 +102,7 @@ from lp.registry.interfaces.milestone import (
     IHasMilestones,
     )
 from lp.registry.interfaces.oopsreferences import IHasOOPSReferences
+from lp.registry.interfaces.person import IPerson
 from lp.registry.interfaces.pillar import (
     IHasSharingPolicies,
     IPillar,
@@ -475,6 +480,14 @@ class IDistributionView(
     has_current_commercial_subscription = Attribute(
         "Whether the distribution has a current commercial subscription.")
 
+    vulnerabilities = exported(
+        doNotSnapshot(CollectionField(
+            description=_("Vulnerabilities in this distribution."),
+            readonly=True,
+            # Really IVulnerability, see _schema_circular_imports.py.
+            value_type=Object(schema=Interface)))
+        )
+
     def getArchiveIDList(archive=None):
         """Return a list of archive IDs suitable for sqlvalues() or quote().
 
@@ -797,6 +810,9 @@ class IDistributionView(
                       "images in this distribution to a registry."),
         required=False, readonly=False)
 
+    def getVulnerability(vulnerability_id):
+        """Return the vulnerability in this distribution with the given id."""
+
 
 class IDistributionEditRestricted(IOfficialBugTagTargetRestricted):
     """IDistribution properties requiring launchpad.Edit permission."""
@@ -873,6 +889,70 @@ class IDistributionEditRestricted(IOfficialBugTagTargetRestricted):
     def deleteOCICredentials():
         """Delete any existing OCI credentials for the distribution."""
 
+    @call_with(creator=REQUEST_USER)
+    @operation_parameters(
+        status=Choice(
+            title=_('The status of the vulnerability.'),
+            required=True,
+            vocabulary=VulnerabilityStatus,
+        ),
+        creator=Reference(
+            title=_('Person creating the vulnerability.'),
+            schema=IPerson,
+            required=True,
+        ),
+        importance=Choice(
+            title=_('Indicates the work priority, not the severity. '
+                    'Defaults to `Undecided`.'),
+            vocabulary=BugTaskImportance,
+            required=False,
+            default=BugTaskImportance.UNDECIDED,
+        ),
+        information_type=Choice(
+            title=_('Information Type. Defaults to `Public`.'),
+            required=False,
+            vocabulary=InformationType,
+            default=InformationType.PUBLIC,
+        ),
+        cve=Reference(
+            ICve,
+            title=_('External CVE reference corresponding to '
+                    'this vulnerability, if any.'),
+            required=False,
+        ),
+        description=TextLine(
+            title=_('A short description of the vulnerability.'),
+            required=False,
+        ),
+        notes = TextLine(
+            title=_("Free-form notes for this vulnerability."),
+            required=False,
+            readonly=False
+        ),
+        mitigation=TextLine(
+            title=_("Explains why we're ignoring this vulnerability."),
+            required=False,
+        ),
+        importance_explanation=TextLine(
+            title=_('Used to explain why our importance differs from '
+                    "somebody else's CVSS score."),
+            required=False,
+        ),
+        date_made_public=Datetime(
+            title=_("The date this vulnerability was made public."),
+            required=False,
+        ),
+    )
+    @export_write_operation()
+    @operation_for_version("devel")
+    def newVulnerability(status, creator,
+                         importance=BugTaskImportance.UNDECIDED,
+                         information_type=InformationType.PUBLIC,
+                         cve=None, description=None, notes=None,
+                         mitigation=None, importance_explanation=None,
+                         date_made_public=None):
+        """Create a new vulnerability in the distribution."""
+
 
 @exported_as_webservice_entry(as_of="beta")
 class IDistribution(
diff --git a/lib/lp/registry/model/distribution.py b/lib/lp/registry/model/distribution.py
index 0b6dbd4..c0a3198 100644
--- a/lib/lp/registry/model/distribution.py
+++ b/lib/lp/registry/model/distribution.py
@@ -94,7 +94,9 @@ from lp.bugs.interfaces.bugtarget import (
     BUG_POLICY_ALLOWED_TYPES,
     BUG_POLICY_DEFAULT_TYPES,
     )
+from lp.bugs.interfaces.bugtask import BugTaskImportance
 from lp.bugs.interfaces.bugtaskfilter import OrderedBugTask
+from lp.bugs.interfaces.vulnerability import IVulnerabilitySet
 from lp.bugs.model.bugtarget import (
     BugTargetBase,
     OfficialBugTagTargetMixin,
@@ -103,6 +105,7 @@ from lp.bugs.model.bugtaskflat import BugTaskFlat
 from lp.bugs.model.structuralsubscription import (
     StructuralSubscriptionTargetMixin,
     )
+from lp.bugs.model.vulnerability import Vulnerability
 from lp.code.interfaces.seriessourcepackagebranch import (
     IFindOfficialBranchLinks,
     )
@@ -1838,6 +1841,27 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
             self.oci_registry_credentials = None
             old_credentials.destroySelf()
 
+    def newVulnerability(self, status, creator,
+                         importance=BugTaskImportance.UNDECIDED,
+                         information_type=InformationType.PUBLIC,
+                         cve=None, description=None, notes=None,
+                         mitigation=None, importance_explanation=None,
+                         date_made_public=None):
+        """See `IDistribution`."""
+        return getUtility(IVulnerabilitySet).new(
+            self,
+            status,
+            importance,
+            creator,
+            information_type,
+            cve,
+            description,
+            notes,
+            mitigation,
+            importance_explanation,
+            date_made_public,
+        )
+
     @cachedproperty
     def _known_viewers(self):
         """A set of known persons able to view this distribution."""
@@ -1874,6 +1898,21 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
             DistributionSet.getDistributionPrivacyFilter(user.person),
             ).is_empty()
 
+    @cachedproperty
+    def vulnerabilities(self):
+        """See `IDistribution`."""
+        return Store.of(self).find(
+            Vulnerability,
+            Vulnerability.distribution == self,
+        )
+
+    def getVulnerability(self, vulnerability_id):
+        """See `IDistribution`."""
+        return Store.of(self).find(
+            Vulnerability,
+            distribution=self,
+            id=vulnerability_id).one()
+
 
 @implementer(IDistributionSet)
 class DistributionSet:
diff --git a/lib/lp/registry/tests/test_distribution.py b/lib/lp/registry/tests/test_distribution.py
index 5270940..daffc9b 100644
--- a/lib/lp/registry/tests/test_distribution.py
+++ b/lib/lp/registry/tests/test_distribution.py
@@ -4,6 +4,7 @@
 """Tests for Distribution."""
 
 import datetime
+import urllib.parse
 
 from fixtures import FakeLogger
 from lazr.lifecycle.snapshot import Snapshot
@@ -12,9 +13,13 @@ import soupmatchers
 from storm.store import Store
 from testtools import ExpectedException
 from testtools.matchers import (
+    ContainsDict,
+    Equals,
     MatchesAll,
     MatchesAny,
+    MatchesStructure,
     Not,
+    StartsWith,
     )
 from zope.component import getUtility
 from zope.security.interfaces import Unauthorized
@@ -35,7 +40,9 @@ from lp.app.interfaces.services import IService
 from lp.blueprints.model.specification import (
     SPECIFICATION_POLICY_ALLOWED_TYPES,
     )
+from lp.bugs.enums import VulnerabilityStatus
 from lp.bugs.interfaces.bugtarget import BUG_POLICY_ALLOWED_TYPES
+from lp.bugs.interfaces.bugtask import BugTaskImportance
 from lp.code.model.branchnamespace import BRANCH_POLICY_ALLOWED_TYPES
 from lp.oci.tests.helpers import OCIConfigHelperMixin
 from lp.registry.enums import (
@@ -1813,3 +1820,285 @@ class TestDistributionWebservice(OCIConfigHelperMixin, TestCaseWithFactory):
             france, MirrorContent.RELEASE)
         self.assertTrue(len(mirrors) > 1, "Not enough mirrors")
         self.assertEqual(main_mirror, mirrors[-1])
+
+
+class TestDistributionVulnerabilities(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_vulnerabilities_no_vulnerability_present(self):
+        distribution = self.factory.makeDistribution()
+        self.assertEqual(0, distribution.vulnerabilities.count())
+
+    def test_vulnerabilities_vulnerabilities_present(self):
+        distribution = self.factory.makeDistribution()
+        first_vulnerability = self.factory.makeVulnerability(
+            distribution
+        )
+        second_vulnerability = self.factory.makeVulnerability(
+            distribution
+        )
+        self.assertEqual(
+            {first_vulnerability, second_vulnerability},
+            set(distribution.vulnerabilities),
+        )
+
+    def test_newVulnerability_default_arguments(self):
+        distribution = self.factory.makeDistribution()
+        owner = distribution.owner
+
+        with person_logged_in(owner):
+            # The distribution owner can create a new vulnerability in
+            # the distribution.
+            vulnerability = distribution.newVulnerability(
+                status=VulnerabilityStatus.NEEDS_TRIAGE,
+                creator=owner,
+            )
+
+            self.assertThat(
+                vulnerability,
+                MatchesStructure.byEquality(
+                    status=VulnerabilityStatus.NEEDS_TRIAGE,
+                    creator=owner,
+                    importance=BugTaskImportance.UNDECIDED,
+                    information_type=InformationType.PUBLIC,
+                    cve=None,
+                    description=None,
+                    notes=None,
+                    mitigation=None,
+                    importance_explanation=None,
+                    date_made_public=None
+                )
+            )
+
+    def test_newVulnerability_all_parameters(self):
+        distribution = self.factory.makeDistribution()
+        owner = distribution.owner
+        cve = self.factory.makeCVE(sequence='2022-1234')
+        now = datetime.datetime.now(pytz.UTC)
+
+        with person_logged_in(owner):
+            # The distribution owner can create a new vulnerability in
+            # the distribution.
+            vulnerability = distribution.newVulnerability(
+                status=VulnerabilityStatus.ACTIVE,
+                creator=owner,
+                importance=BugTaskImportance.CRITICAL,
+                information_type=InformationType.PRIVATESECURITY,
+                cve=cve,
+                description='Vulnerability',
+                notes='lgp171188> Foo bar',
+                mitigation='Foo bar baz',
+                importance_explanation='Foo bar baz',
+                date_made_public=now,
+                )
+            self.assertThat(
+                vulnerability,
+                MatchesStructure.byEquality(
+                    status=VulnerabilityStatus.ACTIVE,
+                    creator=owner,
+                    importance=BugTaskImportance.CRITICAL,
+                    information_type=InformationType.PRIVATESECURITY,
+                    cve=cve,
+                    description='Vulnerability',
+                    notes='lgp171188> Foo bar',
+                    mitigation='Foo bar baz',
+                    importance_explanation='Foo bar baz',
+                    date_made_public=now,
+                )
+            )
+
+    def test_getVulnerability_non_existent_id(self):
+        distribution = self.factory.makeDistribution()
+        vulnerability = distribution.getVulnerability(9999999)
+        self.assertIsNone(vulnerability)
+
+    def test_getVulnerability(self):
+        distribution = self.factory.makeDistribution()
+        vulnerability = self.factory.makeVulnerability(distribution)
+        self.assertEqual(
+            vulnerability,
+            distribution.getVulnerability(
+                removeSecurityProxy(vulnerability).id
+            )
+        )
+
+
+class TestDistributionVulnerabilitiesWebService(TestCaseWithFactory):
+
+    layer = DatabaseFunctionalLayer
+
+    def test_vulnerability_api_url_data(self):
+        distribution = self.factory.makeDistribution()
+        person = distribution.owner
+        cve = self.factory.makeCVE('2022-1234')
+        now = datetime.datetime.now(pytz.UTC)
+        vulnerability = removeSecurityProxy(
+            self.factory.makeVulnerability(
+                distribution,
+                creator=person,
+                cve=cve,
+                description="Foo bar baz",
+                notes="Foo bar baz",
+                mitigation="Foo bar baz",
+                importance_explanation="Foo bar baz",
+                date_made_public=now
+            )
+        )
+        vulnerability_url = api_url(vulnerability)
+
+        webservice = webservice_for_person(
+            person,
+            permission=OAuthPermission.WRITE_PRIVATE,
+            default_api_version="devel"
+        )
+        with person_logged_in(person):
+            distribution_url = webservice.getAbsoluteUrl(api_url(distribution))
+            cve_url = webservice.getAbsoluteUrl(api_url(cve))
+            creator_url = webservice.getAbsoluteUrl(api_url(person))
+
+        response = webservice.get(
+            vulnerability_url
+        )
+        self.assertEqual(200, response.status)
+
+        self.assertThat(response.jsonBody(), ContainsDict({
+            "id": Equals(vulnerability.id),
+            "distribution_link": Equals(distribution_url),
+            "cve_link": Equals(cve_url),
+            "creator_link": Equals(creator_url),
+            "status": Equals("Needs triage"),
+            "description": Equals("Foo bar baz"),
+            "notes": Equals("Foo bar baz"),
+            "mitigation": Equals("Foo bar baz"),
+            "importance": Equals("Undecided"),
+            "importance_explanation": Equals("Foo bar baz"),
+            "information_type": Equals("Public"),
+            "date_made_public": StartsWith(now.strftime("%Y-%m-%dT%H:%M:%S")),
+        }))
+
+    def test_vulnerability_api_url_invalid_id(self):
+        person = self.factory.makePerson()
+        distribution = self.factory.makeDistribution(owner=person)
+        webservice = webservice_for_person(
+            person,
+            permission=OAuthPermission.WRITE_PRIVATE,
+            default_api_version="devel"
+        )
+        with person_logged_in(person):
+            distribution_url = api_url(distribution)
+        invalid_vulnerability_url = urllib.parse.urljoin(
+            distribution_url, '/+vulnerability/foo'
+        )
+        response = webservice.get(invalid_vulnerability_url)
+        self.assertEqual(404, response.status)
+
+    def test_vulnerability_api_url_nonexistent_id(self):
+        person = self.factory.makePerson()
+        distribution = self.factory.makeDistribution(owner=person)
+        webservice = webservice_for_person(
+            person,
+            permission=OAuthPermission.WRITE_PRIVATE,
+            default_api_version="devel"
+        )
+        with person_logged_in(person):
+            distribution_url = api_url(distribution)
+        vulnerability_url = urllib.parse.urljoin(
+            distribution_url, '/+vulnerability/99999999'
+        )
+        response = webservice.get(vulnerability_url)
+        self.assertEqual(404, response.status)
+
+    def test_vulnerabilities_collection_link(self):
+        person = self.factory.makePerson()
+        distribution = self.factory.makeDistribution(owner=person)
+        cve = self.factory.makeCVE('2022-1234')
+        another_cve = self.factory.makeCVE('2022-1235')
+        now = datetime.datetime.now(pytz.UTC)
+
+        first_vulnerability = removeSecurityProxy(
+            self.factory.makeVulnerability(
+                distribution,
+                creator=person,
+                cve=cve,
+                description="Foo bar baz",
+                notes="Foo bar baz",
+                mitigation="Foo bar baz",
+                importance_explanation="Foo bar baz",
+                date_made_public=now
+            )
+        )
+        second_vulnerability = removeSecurityProxy(
+            self.factory.makeVulnerability(
+                distribution,
+                creator=person,
+                cve=another_cve,
+                description="A B C",
+                notes="A B C",
+                mitigation="A B C",
+                importance_explanation="A B C",
+                date_made_public=now
+            )
+        )
+
+        distribution_url = api_url(distribution)
+        webservice = webservice_for_person(
+            person,
+            permission=OAuthPermission.WRITE_PRIVATE,
+            default_api_version="devel"
+        )
+        with person_logged_in(person):
+            distribution_url = webservice.getAbsoluteUrl(api_url(distribution))
+            cve_url = webservice.getAbsoluteUrl(api_url(cve))
+            another_cve_url = webservice.getAbsoluteUrl(api_url(another_cve))
+            creator_url = webservice.getAbsoluteUrl(api_url(person))
+
+        response = webservice.get(
+            distribution_url
+        )
+        response_json = response.jsonBody()
+        self.assertEqual(200, response.status)
+        self.assertIn('vulnerabilities_collection_link', response_json)
+
+        response = webservice.get(
+            response_json['vulnerabilities_collection_link']
+        )
+        response_json = response.jsonBody()
+        self.assertEqual(200, response.status)
+        self.assertEqual(2, len(response_json['entries']))
+        self.assertThat(
+            response_json['entries'][0],
+            ContainsDict({
+                "id": Equals(first_vulnerability.id),
+                "distribution_link": Equals(distribution_url),
+                "cve_link": Equals(cve_url),
+                "creator_link": Equals(creator_url),
+                "status": Equals("Needs triage"),
+                "description": Equals("Foo bar baz"),
+                "notes": Equals("Foo bar baz"),
+                "mitigation": Equals("Foo bar baz"),
+                "importance": Equals("Undecided"),
+                "importance_explanation": Equals("Foo bar baz"),
+                "information_type": Equals("Public"),
+                "date_made_public": StartsWith(
+                    now.strftime("%Y-%m-%dT%H:%M:%S")
+                ),
+        }))
+        self.assertThat(
+            response_json['entries'][1],
+            ContainsDict({
+                "id": Equals(second_vulnerability.id),
+                "distribution_link": Equals(distribution_url),
+                "cve_link": Equals(another_cve_url),
+                "creator_link": Equals(creator_url),
+                "status": Equals("Needs triage"),
+                "description": Equals("A B C"),
+                "notes": Equals("A B C"),
+                "mitigation": Equals("A B C"),
+                "importance": Equals("Undecided"),
+                "importance_explanation": Equals("A B C"),
+                "information_type": Equals("Public"),
+                "date_made_public": StartsWith(
+                    now.strftime("%Y-%m-%dT%H:%M:%S")
+                ),
+        }))

Follow ups