← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~andrey-fedoseev/launchpad:uct-import into launchpad:master

 

Andrey Fedoseev has proposed merging ~andrey-fedoseev/launchpad:uct-import into launchpad:master.

Commit message:
Add `uct-import` script

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~andrey-fedoseev/launchpad/+git/launchpad/+merge/425142

IMPORTANT: you can find some TODO items in the diff, please review them. While the script is functional, some things may need adjustment.


Add `uct-import` script

The script is meant to import CVE entries from `ubuntu-cve-tracker` to
Launchpad database as bugs/bug tasks/vulnerabilities

The script parses the CVE files using the code copied from
`cve_lib` in `ubuntu-cve-tracker`, and transforms the data to
structured `CVE` objects.

The `CVE` object is then imported as a `Bug`, with `BugTask` created for:

 - Every package listed in the CVE
 - Every combination of package/distroseries listed in the CVE

`Vulnerability` instance is created for every distribution affected by the CVE

The `Bug` and the `Vulnerability`s are linked to the corresponding `Cve`
object which is supposed to be present in LP database
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~andrey-fedoseev/launchpad:uct-import into launchpad:master.
diff --git a/lib/lp/bugs/scripts/tests/sampledata/CVE-2022-23222 b/lib/lp/bugs/scripts/tests/sampledata/CVE-2022-23222
new file mode 100644
index 0000000..6d665be
--- /dev/null
+++ b/lib/lp/bugs/scripts/tests/sampledata/CVE-2022-23222
@@ -0,0 +1,44 @@
+PublicDateAtUSN: 2022-01-14 08:15:00 UTC
+Candidate: CVE-2022-23222
+PublicDate: 2022-01-14 08:15:00 UTC
+References:
+ https://ubuntu.com/security/notices/USN-5368-1
+Description:
+ kernel/bpf/verifier.c in the Linux kernel through 5.15.14 allows local
+ users to gain privileges because of the availability of pointer arithmetic
+ via certain *_OR_NULL pointer types.
+Ubuntu-Description:
+ It was discovered that the BPF verifier in the Linux kernel did not
+ properly restrict pointer types in certain situations. A local attacker
+ could use this to cause a denial of service (system crash) or possibly
+ execute arbitrary code.
+Notes:
+ sbeattie> Ubuntu 21.10 / 5.13+ kernels disable unprivileged BPF by default.
+ sbeattie> kernels 5.8 and older are not affected, priority high is for
+   5.10 and 5.11 based kernels only
+Mitigation:
+ seth-arnold> set kernel.unprivileged_bpf_disabled to 1
+Bugs:
+ https://github.com/mm2/Little-CMS/issues/29
+ https://github.com/mm2/Little-CMS/issues/30
+ https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=745471
+Priority: critical
+Discovered-by: tr3e wang
+Assigned-to:
+CVSS:
+ nvd: CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H [7.8 HIGH]
+
+Patches_linux:
+ break-fix: 457f44363a8894135c85b7a9afd2bd8196db24ab c25b2ae136039ffa820c26138ed4a5e5f3ab3841|local-CVE-2022-23222-fix
+upstream_linux: released (5.17~rc1)
+impish_linux: released (5.13.0-37.42)
+devel_linux: not-affected (5.15.0-25.25)
+Priority_linux_impish: medium
+Priority_linux_devel: medium
+Tags_linux: not-ue
+
+Patches_linux-hwe:
+upstream_linux-hwe: released (5.17~rc1)
+impish_linux-hwe: DNE
+devel_linux-hwe: DNE
+Priority_linux-hwe: high
diff --git a/lib/lp/bugs/scripts/tests/test_uctimport.py b/lib/lp/bugs/scripts/tests/test_uctimport.py
new file mode 100644
index 0000000..743eea9
--- /dev/null
+++ b/lib/lp/bugs/scripts/tests/test_uctimport.py
@@ -0,0 +1,357 @@
+#  Copyright 2022 Canonical Ltd.  This software is licensed under the
+#  GNU Affero General Public License version 3 (see the file LICENSE).
+import datetime
+from pathlib import Path
+
+from dateutil.tz import tzutc
+from zope.component import getUtility
+
+from lp.app.enums import InformationType
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+from lp.bugs.enums import VulnerabilityStatus
+from lp.bugs.interfaces.bugtask import (
+    BugTaskImportance,
+    BugTaskStatus,
+    )
+from lp.bugs.scripts.uctimport import (
+    CVE,
+    DistroSeriesPackageStatus,
+    Note,
+    Package,
+    PackageStatus,
+    Patch,
+    Priority,
+    UCTImporter,
+    )
+from lp.registry.interfaces.series import SeriesStatus
+from lp.testing import TestCaseWithFactory
+from lp.testing.layers import ZopelessDatabaseLayer
+
+
+class TestUCTImporter(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def setUp(self, *args, **kwargs):
+        super().setUp(*args, **kwargs)
+        self.importer = UCTImporter()
+
+    def test_load_cve_from_file(self):
+        cve_path = Path(__file__).parent / "sampledata" / "CVE-2022-23222"
+        cve = self.importer.load_cve_from_file(cve_path)
+        self.assertEqual(
+            cve,
+            CVE(
+                assigned_to="",
+                bugs=[
+                    "https://github.com/mm2/Little-CMS/issues/29";,
+                    "https://github.com/mm2/Little-CMS/issues/30";,
+                    "https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=745471";,
+                ],
+                cvss=[
+                    {
+                        "source": "nvd",
+                        "vector": (
+                            "CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H"
+                        ),
+                        "baseScore": "7.8",
+                        "baseSeverity": "HIGH",
+                    }
+                ],
+                candidate="CVE-2022-23222",
+                date_made_public=datetime.datetime(
+                    2022, 1, 14, 8, 15, tzinfo=tzutc()
+                ),
+                description=(
+                    "kernel/bpf/verifier.c in the Linux kernel through "
+                    "5.15.14 allows local\nusers to gain privileges because "
+                    "of the availability of pointer arithmetic\nvia certain "
+                    "*_OR_NULL pointer types."
+                ),
+                discovered_by="tr3e wang",
+                mitigation=(
+                    "seth-arnold> set kernel.unprivileged_bpf_disabled to 1"
+                ),
+                notes=[
+                    Note(
+                        author="sbeattie",
+                        text=(
+                            "Ubuntu 21.10 / 5.13+ kernels disable "
+                            "unprivileged BPF by default.\nkernels 5.8 and "
+                            "older are not affected, priority high is "
+                            "for\n5.10 and 5.11 based kernels only"
+                        ),
+                    ),
+                ],
+                priority=Priority.CRITICAL,
+                references=[
+                    "https://ubuntu.com/security/notices/USN-5368-1";
+                ],
+                ubuntu_description=(
+                    "It was discovered that the BPF verifier in the Linux "
+                    "kernel did not\nproperly restrict pointer types in "
+                    "certain situations. A local attacker\ncould use this to "
+                    "cause a denial of service (system crash) or possibly\n"
+                    "execute arbitrary code."
+                ),
+                packages=[
+                    Package(
+                        name="linux",
+                        statuses=[
+                            DistroSeriesPackageStatus(
+                                distroseries="upstream",
+                                status=PackageStatus.RELEASED,
+                                reason="5.17~rc1",
+                                priority=None,
+                            ),
+                            DistroSeriesPackageStatus(
+                                distroseries="impish",
+                                status=PackageStatus.RELEASED,
+                                reason="5.13.0-37.42",
+                                priority=Priority.MEDIUM,
+                            ),
+                            DistroSeriesPackageStatus(
+                                distroseries="devel",
+                                status=PackageStatus.NOT_AFFECTED,
+                                reason="5.15.0-25.25",
+                                priority=Priority.MEDIUM,
+                            ),
+                        ],
+                        priority=None,
+                        tags={"not-ue"},
+                        patches=[
+                            Patch(
+                                patch_type="break-fix",
+                                entry=(
+                                    "457f44363a8894135c85b7a9afd2bd8196db24ab "
+                                    "c25b2ae136039ffa820c26138ed4a5e5f3ab3841|"
+                                    "local-CVE-2022-23222-fix"
+                                ),
+                            )
+                        ],
+                    ),
+                    Package(
+                        name="linux-hwe",
+                        statuses=[
+                            DistroSeriesPackageStatus(
+                                distroseries="upstream",
+                                status=PackageStatus.RELEASED,
+                                reason="5.17~rc1",
+                                priority=None,
+                            ),
+                            DistroSeriesPackageStatus(
+                                distroseries="impish",
+                                status=PackageStatus.DOES_NOT_EXIST,
+                                reason="",
+                                priority=None,
+                            ),
+                            DistroSeriesPackageStatus(
+                                distroseries="devel",
+                                status=PackageStatus.DOES_NOT_EXIST,
+                                reason="",
+                                priority=None,
+                            ),
+                        ],
+                        priority=Priority.HIGH,
+                        tags=set(),
+                        patches=[],
+                    ),
+                ],
+            ),
+        )
+
+    def test_create_bug(self):
+        celebrities = getUtility(ILaunchpadCelebrities)
+        ubuntu = celebrities.ubuntu
+        owner = celebrities.bug_importer
+        supported_series = self.factory.makeDistroSeries(
+            distribution=ubuntu, status=SeriesStatus.SUPPORTED
+        )
+        current_series = self.factory.makeDistroSeries(
+            distribution=ubuntu, status=SeriesStatus.CURRENT
+        )
+        devel_series = self.factory.makeDistroSeries(
+            distribution=ubuntu, status=SeriesStatus.DEVELOPMENT
+        )
+        dsp1 = self.factory.makeDistributionSourcePackage(distribution=ubuntu)
+        dsp2 = self.factory.makeDistributionSourcePackage(distribution=ubuntu)
+        lp_cve = self.factory.makeCVE("2022-23222")
+
+        for package in (dsp1, dsp2):
+            for series in (supported_series, current_series, devel_series):
+                self.factory.makeSourcePackagePublishingHistory(
+                    distroseries=series,
+                    sourcepackagerelease=self.factory.makeSourcePackageRelease(
+                        distroseries=series,
+                        sourcepackagename=package.sourcepackagename,
+                    ),
+                )
+
+        now = datetime.datetime.now(tzutc())
+        cve = CVE(
+            assigned_to="",
+            bugs=[
+                "https://github.com/mm2/Little-CMS/issues/29";,
+                "https://github.com/mm2/Little-CMS/issues/30";,
+                "https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=745471";,
+            ],
+            cvss=[],
+            candidate="CVE-2022-23222",
+            date_made_public=now,
+            description="description",
+            discovered_by="tr3e wang",
+            mitigation="mitigation",
+            notes=[Note(author="author", text="text")],
+            priority=Priority.MEDIUM,
+            references=[
+                "https://ubuntu.com/security/notices/USN-5368-1";
+            ],
+            ubuntu_description="ubuntu-description",
+            packages=[
+                Package(
+                    name=dsp1.sourcepackagename.name,
+                    statuses=[
+                        DistroSeriesPackageStatus(
+                            distroseries=supported_series.name,
+                            status=PackageStatus.RELEASED,
+                            reason="released",
+                            priority=Priority.HIGH,
+                        ),
+                        DistroSeriesPackageStatus(
+                            distroseries=current_series.name,
+                            status=PackageStatus.DOES_NOT_EXIST,
+                            reason="does not exist",
+                            priority=None,
+                        ),
+                    ],
+                    priority=Priority.LOW,
+                    patches=[],
+                    tags=set(),
+                ),
+                Package(
+                    name=dsp2.sourcepackagename.name,
+                    statuses=[
+                        DistroSeriesPackageStatus(
+                            distroseries=supported_series.name,
+                            status=PackageStatus.NOT_AFFECTED,
+                            reason="not affected",
+                            priority=Priority.LOW,
+                        ),
+                        DistroSeriesPackageStatus(
+                            distroseries=current_series.name,
+                            status=PackageStatus.IGNORED,
+                            reason="ignored",
+                            priority=None,
+                        ),
+                        DistroSeriesPackageStatus(
+                            distroseries="devel",
+                            status=PackageStatus.NEEDS_TRIAGE,
+                            reason="needs triage",
+                            priority=None,
+                        ),
+                    ],
+                    priority=None,
+                    patches=[],
+                    tags=set(),
+                ),
+            ],
+        )
+        bug, vulnerabilities = self.importer.create_bug(cve, lp_cve)
+
+        self.assertEqual(bug.title, "CVE-2022-23222")
+        self.assertEqual(bug.description, "ubuntu-description")
+        self.assertEqual(bug.owner, owner)
+        self.assertEqual(bug.information_type, InformationType.PRIVATESECURITY)
+
+        messages = list(bug.messages)
+        self.assertEqual(len(messages), 5)
+
+        message = messages.pop(0)
+        self.assertEqual(message.owner, owner)
+        self.assertEqual(message.text_contents, "description")
+
+        for external_bug_url in cve.bugs:
+            message = messages.pop(0)
+            self.assertEqual(message.text_contents, external_bug_url)
+
+        for reference in cve.references:
+            message = messages.pop(0)
+            self.assertEqual(message.text_contents, reference)
+
+        bug_tasks = bug.bugtasks
+        # 7 bug tasks are supposed to be created:
+        #   2 for distro packages
+        #   5 for combinations of distroseries/package:
+        #     2 for the first package (2 distro series)
+        #     3 for the second package (3 distro series)
+        self.assertEqual(len(bug_tasks), 7)
+
+        bug_tasks_by_target = {
+            (t.distribution, t.distroseries, t.sourcepackagename): t
+            for t in bug_tasks
+        }
+        t = bug_tasks_by_target.pop((ubuntu, None, dsp1.sourcepackagename))
+        self.assertEqual(t.importance, BugTaskImportance.LOW)
+        self.assertEqual(t.status, BugTaskStatus.NEW)
+        self.assertEqual(t.status_explanation, None)
+
+        t = bug_tasks_by_target.pop((ubuntu, None, dsp2.sourcepackagename))
+        self.assertEqual(t.importance, BugTaskImportance.MEDIUM)
+        self.assertEqual(t.status, BugTaskStatus.UNKNOWN)
+        self.assertEqual(t.status_explanation, None)
+
+        t = bug_tasks_by_target.pop(
+            (None, supported_series, dsp1.sourcepackagename)
+        )
+        self.assertEqual(t.importance, BugTaskImportance.HIGH)
+        self.assertEqual(t.status, BugTaskStatus.FIXRELEASED)
+        self.assertEqual(t.status_explanation, "released")
+
+        t = bug_tasks_by_target.pop(
+            (None, current_series, dsp1.sourcepackagename)
+        )
+        self.assertEqual(t.importance, BugTaskImportance.LOW)
+        self.assertEqual(t.status, BugTaskStatus.DOESNOTEXIST)
+        self.assertEqual(t.status_explanation, "does not exist")
+
+        t = bug_tasks_by_target.pop(
+            (None, supported_series, dsp2.sourcepackagename)
+        )
+        self.assertEqual(t.importance, BugTaskImportance.LOW)
+        self.assertEqual(t.status, BugTaskStatus.INVALID)
+        self.assertEqual(t.status_explanation, "not affected")
+
+        t = bug_tasks_by_target.pop(
+            (None, current_series, dsp2.sourcepackagename)
+        )
+        self.assertEqual(t.importance, BugTaskImportance.MEDIUM)
+        self.assertEqual(t.status, BugTaskStatus.WONTFIX)
+        self.assertEqual(t.status_explanation, "ignored")
+
+        t = bug_tasks_by_target.pop(
+            (None, devel_series, dsp2.sourcepackagename)
+        )
+        self.assertEqual(t.importance, BugTaskImportance.MEDIUM)
+        self.assertEqual(t.status, BugTaskStatus.UNKNOWN)
+        self.assertEqual(t.status_explanation, "needs triage")
+
+        self.assertEqual(bug.cves, [lp_cve])
+
+        self.assertEqual(len(vulnerabilities), 1)
+
+        vulnerability = vulnerabilities[0]
+        self.assertEqual(vulnerability.distribution, ubuntu)
+        self.assertEqual(vulnerability.creator, owner)
+        self.assertEqual(vulnerability.cve, lp_cve)
+        self.assertEqual(
+            vulnerability.status, VulnerabilityStatus.NEEDS_TRIAGE
+        )
+        self.assertEqual(vulnerability.description, "description")
+        self.assertEqual(vulnerability.notes, "author> text")
+        self.assertEqual(vulnerability.mitigation, "mitigation")
+        self.assertEqual(vulnerability.importance, BugTaskImportance.MEDIUM)
+        self.assertEqual(
+            vulnerability.information_type, InformationType.PRIVATESECURITY
+        )
+        self.assertEqual(vulnerability.date_made_public, now)
+        self.assertEqual(vulnerability.bugs, [bug])
diff --git a/lib/lp/bugs/scripts/uctimport.py b/lib/lp/bugs/scripts/uctimport.py
new file mode 100644
index 0000000..09f8421
--- /dev/null
+++ b/lib/lp/bugs/scripts/uctimport.py
@@ -0,0 +1,2246 @@
+# Copyright 2022 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""A UCT (Ubuntu CVE Tracker) bug importer
+
+This code can import CVE summaries stored in UCT repository to bugs in
+Launchpad.
+
+For each entry in UCT we:
+
+1. Create a Bug instance
+2. Create a Vulnerability instance and link it to the bug (multiple
+    Vulnerabilities may be created if the CVE entry covers multiple
+    distributions)
+3. Create a Bug Task for each package/distro-series in the CVE entry
+4. Update the statuses of Bug Tasks based on the information in the CVE entry
+"""
+import codecs
+from datetime import datetime
+from enum import Enum
+import glob
+import logging
+import math
+import os
+from pathlib import Path
+import re
+import sys
+from typing import (
+    Any,
+    Dict,
+    List,
+    NamedTuple,
+    Optional,
+    Set,
+    Tuple,
+    )
+
+import dateutil.parser
+import yaml
+from zope.component import getUtility
+
+from lp.app.enums import InformationType
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+from lp.bugs.enums import VulnerabilityStatus
+from lp.bugs.interfaces.bug import (
+    CreateBugParams,
+    IBugSet,
+    )
+from lp.bugs.interfaces.bugtask import (
+    BugTaskImportance,
+    BugTaskStatus,
+    IBugTaskSet,
+    )
+from lp.bugs.interfaces.cve import ICveSet
+from lp.bugs.interfaces.vulnerability import IVulnerabilitySet
+from lp.bugs.model.bug import Bug as BugModel
+from lp.bugs.model.cve import Cve as CveModel
+from lp.bugs.model.vulnerability import Vulnerability
+from lp.registry.interfaces.distroseries import IDistroSeriesSet
+from lp.registry.interfaces.series import SeriesStatus
+from lp.registry.interfaces.sourcepackagename import ISourcePackageNameSet
+from lp.registry.model.distribution import Distribution
+from lp.registry.model.distributionsourcepackage import (
+    DistributionSourcePackage,
+    )
+from lp.registry.model.distroseries import DistroSeries
+from lp.registry.model.sourcepackagename import SourcePackageName
+from lp.services.messages.interfaces.message import IMessageSet
+
+
+DEFAULT_LOGGER = logging.getLogger("lp.bugs.scripts.import")
+
+
+class Priority(Enum):
+    CRITICAL = "critical"
+    HIGH = "high"
+    MEDIUM = "medium"
+    LOW = "low"
+    UNTRIAGED = "untriaged"
+    NEGLIGIBLE = "negligible"
+
+
+class PackageStatus(Enum):
+    IGNORED = "ignored"
+    NEEDS_TRIAGE = "needs-triage"
+    DOES_NOT_EXIST = "DNE"
+    RELEASED = "released"
+    NOT_AFFECTED = "not-affected"
+    DEFERRED = "deferred"
+    NEEDED = "needed"
+    PENDING = "pending"
+
+
+DistroSeriesPackageStatus = NamedTuple(
+    "DistroSeriesPackageStatus",
+    [
+        ("distroseries", str),
+        ("status", PackageStatus),
+        ("reason", str),
+        ("priority", Optional[Priority]),
+    ],
+)
+
+
+Patch = NamedTuple(
+    "Patch",
+    [
+        ("patch_type", str),
+        ("entry", str),
+    ],
+)
+
+
+Package = NamedTuple(
+    "Package",
+    [
+        ("name", str),
+        ("statuses", List[DistroSeriesPackageStatus]),
+        ("priority", Optional[Priority]),
+        ("tags", Set[str]),
+        ("patches", List[Patch]),
+    ],
+)
+
+Note = NamedTuple(
+    "Note",
+    [
+        ("author", str),
+        ("text", str),
+    ],
+)
+
+
+CVE = NamedTuple(
+    "CVE",
+    [
+        ("assigned_to", str),
+        ("bugs", List[str]),
+        ("cvss", List[Dict[str, Any]]),
+        ("candidate", str),
+        ("date_made_public", Optional[datetime]),
+        ("description", str),
+        ("discovered_by", str),
+        ("mitigation", str),
+        ("notes", List[Note]),
+        ("priority", Priority),
+        ("references", List[str]),
+        ("ubuntu_description", str),
+        ("packages", List[Package]),
+    ],
+)
+
+PRIORITY_MAP = {
+    Priority.CRITICAL: BugTaskImportance.CRITICAL,
+    Priority.HIGH: BugTaskImportance.HIGH,
+    Priority.MEDIUM: BugTaskImportance.MEDIUM,
+    Priority.LOW: BugTaskImportance.LOW,
+    Priority.UNTRIAGED: BugTaskImportance.UNDECIDED,  # TODO: confirm this
+    Priority.NEGLIGIBLE: BugTaskImportance.WISHLIST,  # TODO: confirm this
+}
+
+STATUS_MAP = {
+    PackageStatus.IGNORED: BugTaskStatus.WONTFIX,
+    PackageStatus.NEEDS_TRIAGE: BugTaskStatus.UNKNOWN,
+    PackageStatus.DOES_NOT_EXIST: BugTaskStatus.DOESNOTEXIST,
+    PackageStatus.RELEASED: BugTaskStatus.FIXRELEASED,
+    PackageStatus.NOT_AFFECTED: BugTaskStatus.INVALID,
+    # PackageStatus.DEFERRED: ...,  # TODO: fix this
+    PackageStatus.NEEDED: BugTaskStatus.NEW,
+    PackageStatus.PENDING: BugTaskStatus.FIXCOMMITTED,
+}
+
+
+def format_cve_notes(notes: List[Note]) -> str:
+    return "\n".join(
+        "{author}> {text}".format(author=note.author, text=note.text)
+        for note in notes
+    )
+
+
+class UCTImporter:
+    def __init__(self, logger: Optional[logging.Logger] = None) -> None:
+        self.logger = logger or DEFAULT_LOGGER
+
+    def import_cve_from_file(self, cve_path: Path) -> None:
+        cve = self.load_cve_from_file(cve_path)
+        self.import_cve(cve)
+
+    @staticmethod
+    def load_cve_from_file(cve_path: Path) -> CVE:
+        """
+        Load a `CVE` instance from data contained in `cve_path`.
+
+        The file is parsed to a dictionary using the code copied from
+        `cve_lib` in `ubuntu-cve-tracker`.
+
+        A `CVE` instance is created from that dictionary, applying some data
+        transformations along the way.
+        """
+
+        cve_data = load_cve(str(cve_path))  # type: Dict[str, Any]
+
+        packages = []  # type: List[Package]
+        tags = cve_data.pop("tags")  # type: Dict[str, Set[str]]
+        patches = cve_data.pop(
+            "patches"
+        )  # type: Dict[str, List[Tuple[str, str]]]
+        for package, statuses_dict in cve_data.pop("pkgs").items():
+            statuses = []  # type: List[DistroSeriesPackageStatus]
+            for distroseries, (status, reason) in statuses_dict.items():
+                distroseries_priority = cve_data.pop(
+                    "Priority_{package}_{distroseries}".format(
+                        package=package,
+                        distroseries=distroseries,
+                    ),
+                    None,
+                )
+                statuses.append(
+                    DistroSeriesPackageStatus(
+                        distroseries=distroseries,
+                        status=PackageStatus(status),
+                        reason=reason,
+                        priority=(
+                            Priority(distroseries_priority)
+                            if distroseries_priority
+                            else None
+                        ),
+                    )
+                )
+            package_priority = cve_data.pop(
+                "Priority_{package}".format(package=package), None
+            )
+            packages.append(
+                Package(
+                    name=package,
+                    statuses=statuses,
+                    priority=(
+                        Priority(package_priority)
+                        if package_priority
+                        else None
+                    ),
+                    tags=tags.pop(package, set()),
+                    patches=[
+                        Patch(patch_type=patch_type, entry=entry)
+                        for patch_type, entry in patches.pop(package, [])
+                    ],
+                )
+            )
+
+        crd = cve_data.pop("CRD", None)
+        if crd == "unknown":
+            crd = None
+        public_date = cve_data.pop("PublicDate", None)
+        if public_date == "unknown":
+            public_date = None
+        public_date_at_USN = cve_data.pop("PublicDateAtUSN", None)
+        if public_date_at_USN == "unknown":
+            public_date_at_USN = None
+
+        date_made_public = crd or public_date or public_date_at_USN
+
+        cve = CVE(
+            assigned_to=cve_data.pop("Assigned-to").strip(),
+            bugs=cve_data.pop("Bugs").strip().split("\n"),
+            cvss=cve_data.pop("CVSS"),
+            candidate=cve_data.pop("Candidate").strip(),
+            date_made_public=dateutil.parser.parse(date_made_public)
+            if date_made_public
+            else None,
+            description=cve_data.pop("Description").strip(),
+            discovered_by=cve_data.pop("Discovered-by").strip(),
+            mitigation=cve_data.pop("Mitigation", "").strip(),
+            notes=[
+                Note(author=author, text=text)
+                for author, text in cve_data.pop("Notes")
+            ],
+            priority=Priority(cve_data.pop("Priority")),
+            references=cve_data.pop("References").strip().split("\n"),
+            ubuntu_description=cve_data.pop("Ubuntu-Description").strip(),
+            packages=packages,
+        )
+
+        # make sure all fields are consumed
+        if cve_data:
+            raise AssertionError(
+                "not all fields are consumed: {}".format(cve_data)
+            )
+
+        return cve
+
+    def import_cve(self, cve: CVE) -> None:
+        lp_cve: CveModel = getUtility(ICveSet)[cve.candidate]
+        if lp_cve is None:
+            self.logger.warning(
+                "Could not find the CVE in LP: %s", cve.candidate
+            )
+            return
+        self.create_bug(cve, lp_cve)
+
+    def create_bug(
+        self, cve: CVE, lp_cve: CveModel
+    ) -> Tuple[Optional[BugModel], List[Vulnerability]]:
+
+        self.logger.debug("creating bug...")
+
+        affected_packages = []  # type: List[DistributionSourcePackage]
+        affected_distro_series = []  # type: List[DistroSeries]
+        affected_distributions = set()  # type: Set[Distribution]
+        importances = {}
+        statuses_with_explanations = {}
+
+        for cve_package in cve.packages:
+            source_package_name = self.get_source_package_name(
+                cve_package.name
+            )
+            if not source_package_name:
+                continue
+
+            package_priority = cve_package.priority or cve.priority
+            importances[source_package_name] = (
+                PRIORITY_MAP[package_priority] if package_priority else None
+            )
+
+            for cve_package_status in cve_package.statuses:
+                distro_series = self.get_distro_series(
+                    cve_package_status.distroseries
+                )
+                if not distro_series:
+                    continue
+
+                if distro_series not in affected_distro_series:
+                    affected_distro_series.append(distro_series)
+
+                affected_distributions.add(distro_series.distribution)
+
+                distro_package = DistributionSourcePackage(
+                    distribution=distro_series.distribution,
+                    sourcepackagename=source_package_name,
+                )
+                if distro_package not in affected_packages:
+                    affected_packages.append(distro_package)
+
+                distro_series_package_priority = (
+                    cve_package_status.priority or package_priority
+                )
+                key = (source_package_name, distro_series)
+                importances[key] = (
+                    PRIORITY_MAP[distro_series_package_priority]
+                    if distro_series_package_priority
+                    else None
+                )
+                statuses_with_explanations[key] = (
+                    STATUS_MAP[cve_package_status.status],
+                    cve_package_status.reason,
+                )
+
+        if not affected_packages:
+            self.logger.warning("Could not find any affected packages")
+            return None, []
+
+        distro_package = affected_packages.pop(0)
+        affected_distributions = {distro_package.distribution}
+
+        # Create the bug
+        # TODO: confirm this. Should this be something like `team_security`?
+        owner = getUtility(ILaunchpadCelebrities).bug_importer
+
+        # TODO: is this correct?
+        if cve.date_made_public:
+            information_type = InformationType.PRIVATESECURITY
+        else:
+            information_type = InformationType.EMBARGOED
+
+        bug = getUtility(IBugSet).createBug(
+            CreateBugParams(
+                description=cve.ubuntu_description,
+                title=cve.candidate,
+                information_type=information_type,
+                owner=owner,
+                msg=getUtility(IMessageSet).fromText(
+                    "", cve.description, owner=owner
+                ),
+                target=distro_package,
+                importance=importances[distro_package.sourcepackagename],
+            )
+        )  # type: BugModel
+
+        # Add links to external bug trackers
+        for external_bug_url in cve.bugs:
+            bug.newMessage(owner=owner, content=external_bug_url)
+
+        # Add references
+        for reference in cve.references:
+            bug.newMessage(owner=owner, content=reference)
+
+        # TODO: shall we store discovered_by?
+        # TODO: shall we store assigned_to? (this looks like a LP username)
+        # TODO: shall we store cvss?
+
+        self.logger.info("Created bug with ID: %s", bug.id)
+
+        # Create bug tasks for distribution packages
+        bug_task_set = getUtility(IBugTaskSet)
+        for distro_package in affected_packages:
+            bug_task_set.createTask(
+                bug,
+                owner,
+                distro_package,
+                importance=importances[distro_package.sourcepackagename],
+            )
+
+        # Create bug tasks for distro series by adding nominations
+        # This may create some extra bug tasks which we will delete later
+        for distro_series in affected_distro_series:
+            nomination = bug.addNomination(owner, distro_series)
+            nomination.approve(owner)
+
+        # Set importance and status on distro series bug tasks
+        # If the bug task's package/series isn't listed in the
+        # CVE entry - delete it
+        for bug_task in bug.bugtasks:
+            distro_series = bug_task.distroseries
+            if not distro_series:
+                continue
+            source_package_name = bug_task.sourcepackagename
+            key = (source_package_name, distro_series)
+            if key not in importances:
+                # This combination of package/series is not present in the CVE
+                # Delete it
+                bug_task.delete(owner)
+                continue
+            bug_task.importance = importances[key]
+            status, status_explanation = statuses_with_explanations[key]
+            bug_task.transitionToStatus(status, owner)
+            bug_task.status_explanation = status_explanation
+
+        # Link the bug to CVE
+        bug.linkCVE(lp_cve, owner)
+
+        # Create the Vulnerabilities
+        vulnerabilities = []
+        for distribution in affected_distributions:
+            vulnerabilities.append(
+                self.create_vulnerability(bug, cve, lp_cve, distribution)
+            )
+
+        return bug, vulnerabilities
+
+    def get_source_package_name(
+        self, package_name: str
+    ) -> Optional[SourcePackageName]:
+        spn = getUtility(ISourcePackageNameSet).queryByName(package_name)
+        if not spn:
+            self.logger.warning("Could not find package: %s", package_name)
+        return spn
+
+    def get_devel_series(
+        self, distribution: Distribution
+    ) -> Optional[DistroSeries]:
+        for series in distribution.series:
+            if series.status == SeriesStatus.FROZEN:
+                return series
+        for series in distribution.series:
+            if series.status == SeriesStatus.DEVELOPMENT:
+                return series
+
+    def get_distro_series(
+        self, distro_series_name: str
+    ) -> Optional[DistroSeries]:
+        if "/" in distro_series_name:
+            series_name, distro_name = distro_series_name.split("/", 1)
+            if distro_name == "esm":
+                # TODO: ESM needs special handling
+                pass
+            return
+        else:
+            series_name = distro_series_name
+            distribution = getUtility(ILaunchpadCelebrities).ubuntu
+            if series_name == "devel":
+                distro_series = self.get_devel_series(distribution)
+            else:
+                distro_series = getUtility(IDistroSeriesSet).queryByName(
+                    distribution, series_name
+                )
+        if not distro_series:
+            self.logger.warning(
+                "Could not find the distro series: %s", distro_series_name
+            )
+        return distro_series
+
+    def create_vulnerability(
+        self,
+        bug: BugModel,
+        cve: CVE,
+        lp_cve: CveModel,
+        distribution: Distribution,
+    ) -> Vulnerability:
+        # TODO: is this correct?
+        if cve.date_made_public:
+            information_type = InformationType.PRIVATESECURITY
+        else:
+            information_type = InformationType.EMBARGOED
+
+        vulnerability = getUtility(IVulnerabilitySet).new(
+            distribution=distribution,
+            creator=bug.owner,
+            cve=lp_cve,
+            status=VulnerabilityStatus.NEEDS_TRIAGE,
+            description=cve.description,
+            notes=format_cve_notes(cve.notes),
+            mitigation=cve.mitigation,
+            importance=PRIORITY_MAP[cve.priority],
+            information_type=information_type,
+            date_made_public=cve.date_made_public,
+        )  # type: Vulnerability
+
+        # TODO: is this correct?
+        vulnerability.linkBug(bug, bug.owner)
+
+        self.logger.info("Create vulnerability with ID: %s", vulnerability)
+
+        return vulnerability
+
+
+############################################################################
+# The code below is copied from `cve_lib` module from `ubuntu-cve-tracker` #
+############################################################################
+
+
+def set_cve_dir(path):
+    """Return a path with CVEs in it. Specifically:
+    - if 'path' has CVEs in it, return path
+    - if 'path' is a relative directory with no CVEs, see if UCT is defined
+      and if so, see if 'UCT/path' has CVEs in it and return path
+    """
+    p = path
+    found = False
+    if len(glob.glob("%s/CVE-*" % path)) > 0:
+        found = True
+    elif not path.startswith("/") and "UCT" in os.environ:
+        tmp = os.path.join(os.environ["UCT"], path)
+        if len(glob.glob("%s/CVE-*" % tmp)) > 0:
+            found = True
+            p = tmp
+            # print("INFO: using '%s'" % p, file=sys.stderr)
+
+    if not found:
+        print(
+            "WARN: could not find CVEs in '%s' (or relative to UCT)" % path,
+            file=sys.stderr,
+        )
+    return p
+
+
+if "UCT" in os.environ:
+    active_dir = set_cve_dir(os.environ["UCT"] + "/active")
+    retired_dir = set_cve_dir(os.environ["UCT"] + "/retired")
+    ignored_dir = set_cve_dir(os.environ["UCT"] + "/ignored")
+    embargoed_dir = os.environ["UCT"] + "/embargoed"
+    meta_dir = os.path.join(os.environ["UCT"], "meta_lists")
+    subprojects_dir = os.environ["UCT"] + "/subprojects"
+else:
+    active_dir = set_cve_dir("active")
+    retired_dir = set_cve_dir("retired")
+    ignored_dir = set_cve_dir("ignored")
+    embargoed_dir = "embargoed"  # Intentionally not using set_cve_dir()
+    meta_dir = os.path.join(
+        os.path.dirname(os.path.dirname(os.path.realpath(__file__))),
+        "meta_lists",
+    )
+    subprojects_dir = "subprojects"
+
+PRODUCT_UBUNTU = "ubuntu"
+
+# common to all scripts
+# these get populated by the contents of subprojects defined below
+all_releases = []
+eol_releases = []
+external_releases = []
+releases = []
+devel_release = ""
+
+# known subprojects which are supported by cve_lib - in general each
+# subproject is defined by the combination of a product and series as
+# <product/series>.
+#
+# For each subproject, it is either internal (ie is part of this static
+# dict) or external (found dynamically at runtime by
+# load_external_subprojects()).
+#
+# eol specifies whether the subproject is now end-of-life.  packages
+# specifies list of files containing the names of supported packages for the
+# subproject. alias defines an alternate preferred name for the subproject
+# (this is often used to support historical names for projects etc).
+subprojects = {
+    "stable-phone-overlay/vivid": {
+        "eol": True,
+        "packages": ["vivid-stable-phone-overlay-supported.txt"],
+        "name": "Ubuntu Touch 15.04",
+        "alias": "vivid/stable-phone-overlay",
+    },
+    "ubuntu-core/vivid": {
+        "eol": True,
+        "packages": ["vivid-ubuntu-core-supported.txt"],
+        "name": "Ubuntu Core 15.04",
+        "alias": "vivid/ubuntu-core",
+    },
+    "esm/precise": {
+        "eol": True,
+        "packages": ["precise-esm-supported.txt"],
+        "name": "Ubuntu 12.04 ESM",
+        "codename": "Precise Pangolin",
+        "alias": "precise/esm",
+        "ppa": "ubuntu-esm/esm",
+        "parent": "ubuntu/precise",
+        "description": (
+            "Available with UA Infra or UA Desktop: "
+            "https://ubuntu.com/advantage";
+        ),
+        "stamp": 1493521200,
+    },
+    "esm/trusty": {
+        "eol": False,
+        "packages": ["trusty-esm-supported.txt"],
+        "name": "Ubuntu 14.04 ESM",
+        "codename": "Trusty Tahr",
+        "alias": "trusty/esm",
+        "ppa": "ubuntu-esm/esm-infra-security",
+        "parent": "ubuntu/trusty",
+        "description": (
+            "Available with UA Infra or UA Desktop: "
+            "https://ubuntu.com/advantage";
+        ),
+        "stamp": 1556593200,
+    },
+    "esm-infra/xenial": {
+        "eol": False,
+        "components": ["main", "restricted"],
+        "packages": ["esm-infra-xenial-supported.txt"],
+        "name": "Ubuntu 16.04 ESM",
+        "codename": "Xenial Xerus",
+        "ppa": "ubuntu-esm/esm-infra-security",
+        "parent": "ubuntu/xenial",
+        "description": (
+            "Available with UA Infra or UA Desktop: "
+            "https://ubuntu.com/advantage";
+        ),
+        "stamp": 1618963200,
+    },
+    "fips/xenial": {
+        "eol": False,
+        "packages": ["fips-xenial-supported.txt"],
+        "name": "Ubuntu 16.04 FIPS Certified",
+        "codename": "Xenial Xerus",
+        "ppa": "ubuntu-advantage/fips",
+        "parent": "ubuntu/xenial",
+        "description": "Available with UA ... https://ubuntu.com/advantage";,
+    },
+    "fips/bionic": {
+        "eol": False,
+        "packages": ["fips-bionic-supported.txt"],
+        "name": "Ubuntu 18.04 FIPS Certified",
+        "codename": "Bionic Beaver",
+        "ppa": "ubuntu-advantage/fips",
+        "parent": "ubuntu/bionic",
+        "description": "Available with UA ... https://ubuntu.com/advantage";,
+    },
+    "fips/focal": {
+        "eol": False,
+        "packages": ["fips-focal-supported.txt"],
+        "name": "Ubuntu 20.04 FIPS Certified",
+        "codename": "Focal Fossa",
+        "ppa": "ubuntu-advantage/fips",
+        "parent": "ubuntu/bionic",
+        "description": "Available with UA ... https://ubuntu.com/advantage";,
+    },
+    "fips-updates/xenial": {
+        "eol": False,
+        "packages": ["fips-updates-xenial-supported.txt"],
+        "name": "Ubuntu 16.04 FIPS Compliant",
+        "codename": "Xenial Xerus",
+        "ppa": "ubuntu-advantage/fips-updates",
+        "parent": "ubuntu/xenial",
+        "description": "Available with UA ... https://ubuntu.com/advantage";,
+    },
+    "fips-updates/bionic": {
+        "eol": False,
+        "packages": ["fips-updates-bionic-supported.txt"],
+        "name": "Ubuntu 18.04 FIPS Compliant",
+        "codename": "Bionic Beaver",
+        "ppa": "ubuntu-advantage/fips-updates",
+        "parent": "ubuntu/bionic",
+        "description": "Available with UA ... https://ubuntu.com/advantage";,
+    },
+    "fips-updates/focal": {
+        "eol": False,
+        "packages": ["fips-updates-focal-supported.txt"],
+        "name": "Ubuntu 20.04 FIPS Compliant",
+        "codename": "Focal Fossa",
+        "ppa": "ubuntu-advantage/fips-updates",
+        "parent": "ubuntu/bionic",
+        "description": "Available with UA ... https://ubuntu.com/advantage";,
+    },
+    "ubuntu/warty": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 4.10",
+        "codename": "Warty Warthog",
+        "alias": "warty",
+        "description": "Interim Release",
+        "stamp": 1098748800,
+    },
+    "ubuntu/hoary": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 5.04",
+        "codename": "Hoary Hedgehog",
+        "alias": "hoary",
+        "description": "Interim Release",
+        "stamp": 1112918400,
+    },
+    "ubuntu/breezy": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 5.10",
+        "codename": "Breezy Badger",
+        "alias": "breezy",
+        "description": "Interim Release",
+        "stamp": 1129075200,
+    },
+    "ubuntu/dapper": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 6.06 LTS",
+        "codename": "Dapper Drake",
+        "alias": "dapper",
+        "description": "Long Term Support",
+        "stamp": 1149120000,
+    },
+    "ubuntu/edgy": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 6.10",
+        "codename": "Edgy Eft",
+        "alias": "edgy",
+        "description": "Interim Release",
+        "stamp": 1161864000,
+    },
+    "ubuntu/feisty": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 7.04",
+        "codename": "Feisty Fawn",
+        "alias": "feisty",
+        "description": "Interim Release",
+        "stamp": 1176984000,
+    },
+    "ubuntu/gutsy": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 7.10",
+        "codename": "Gutsy Gibbon",
+        "alias": "gutsy",
+        "description": "Interim Release",
+        "stamp": 1192708800,
+    },
+    "ubuntu/hardy": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 8.04 LTS",
+        "codename": "Hardy Heron",
+        "alias": "hardy",
+        "description": "Long Term Support",
+        "stamp": 1209038400,
+    },
+    "ubuntu/intrepid": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 8.10",
+        "codename": "Intrepid Ibex",
+        "alias": "intrepid",
+        "description": "Interim Release",
+        "stamp": 1225368000,
+    },
+    "ubuntu/jaunty": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 9.04",
+        "codename": "Jaunty Jackalope",
+        "alias": "jaunty",
+        "description": "Interim Release",
+        "stamp": 1240488000,
+    },
+    "ubuntu/karmic": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 9.10",
+        "codename": "Karmic Koala",
+        "alias": "karmic",
+        "description": "Interim Release",
+        "stamp": 1256817600,
+    },
+    "ubuntu/lucid": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 10.04 LTS",
+        "codename": "Lucid Lynx",
+        "alias": "lucid",
+        "description": "Long Term Support",
+        "stamp": 1272565800,
+    },
+    "ubuntu/maverick": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 10.10",
+        "codename": "Maverick Meerkat",
+        "alias": "maverick",
+        "description": "Interim Release",
+        "stamp": 1286706600,
+    },
+    "ubuntu/natty": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 11.04",
+        "codename": "Natty Narwhal",
+        "alias": "natty",
+        "description": "Interim Release",
+        "stamp": 1303822800,
+    },
+    "ubuntu/oneiric": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 11.10",
+        "codename": "Oneiric Ocelot",
+        "alias": "oneiric",
+        "description": "Interim Release",
+        "stamp": 1318446000,
+    },
+    "ubuntu/precise": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 12.04 LTS",
+        "codename": "Precise Pangolin",
+        "alias": "precise",
+        "description": "Long Term Support",
+        "stamp": 1335423600,
+    },
+    "ubuntu/quantal": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 12.10",
+        "codename": "Quantal Quetzal",
+        "alias": "quantal",
+        "description": "Interim Release",
+        "stamp": 1350547200,
+    },
+    "ubuntu/raring": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 13.04",
+        "codename": "Raring Ringtail",
+        "alias": "raring",
+        "description": "Interim Release",
+        "stamp": 1366891200,
+    },
+    "ubuntu/saucy": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 13.10",
+        "codename": "Saucy Salamander",
+        "alias": "saucy",
+        "description": "Interim Release",
+        "stamp": 1381993200,
+    },
+    "ubuntu/trusty": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 14.04 LTS",
+        "codename": "Trusty Tahr",
+        "alias": "trusty",
+        "description": "Long Term Support",
+        "stamp": 1397826000,
+    },
+    "ubuntu/utopic": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 14.10",
+        "codename": "Utopic Unicorn",
+        "alias": "utopic",
+        "description": "Interim Release",
+        "stamp": 1414083600,
+    },
+    "ubuntu/vivid": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 15.04",
+        "codename": "Vivid Vervet",
+        "alias": "vivid",
+        "description": "Interim Release",
+        "stamp": 1429027200,
+    },
+    "ubuntu/wily": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 15.10",
+        "codename": "Wily Werewolf",
+        "alias": "wily",
+        "description": "Interim Release",
+        "stamp": 1445518800,
+    },
+    "ubuntu/xenial": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 16.04 LTS",
+        "codename": "Xenial Xerus",
+        "alias": "xenial",
+        "description": "Long Term Support",
+        "stamp": 1461279600,
+    },
+    "ubuntu/yakkety": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 16.10",
+        "codename": "Yakkety Yak",
+        "alias": "yakkety",
+        "description": "Interim Release",
+        "stamp": 1476518400,
+    },
+    "ubuntu/zesty": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 17.04",
+        "codename": "Zesty Zapus",
+        "alias": "zesty",
+        "description": "Interim Release",
+        "stamp": 1492153200,
+    },
+    "ubuntu/artful": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 17.10",
+        "codename": "Artful Aardvark",
+        "alias": "artful",
+        "description": "Interim Release",
+        "stamp": 1508418000,
+    },
+    "ubuntu/bionic": {
+        "eol": False,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 18.04 LTS",
+        "codename": "Bionic Beaver",
+        "alias": "bionic",
+        "description": "Long Term Support",
+        "stamp": 1524870000,
+    },
+    "ubuntu/cosmic": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 18.10",
+        "codename": "Cosmic Cuttlefish",
+        "alias": "cosmic",
+        "description": "Interim Release",
+        "stamp": 1540040400,
+    },
+    "ubuntu/disco": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 19.04",
+        "codename": "Disco Dingo",
+        "alias": "disco",
+        "description": "Interim Release",
+        "stamp": 1555581600,
+    },
+    "ubuntu/eoan": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 19.10",
+        "codename": "Eoan Ermine",
+        "alias": "eoan",
+        "description": "Interim Release",
+        "stamp": 1571234400,
+    },
+    "ubuntu/focal": {
+        "eol": False,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 20.04 LTS",
+        "codename": "Focal Fossa",
+        "alias": "focal",
+        "description": "Long Term Support",
+        "stamp": 1587567600,
+    },
+    "ubuntu/groovy": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 20.10",
+        "codename": "Groovy Gorilla",
+        "alias": "groovy",
+        "description": "Interim Release",
+        "stamp": 1603288800,
+    },
+    "ubuntu/hirsute": {
+        "eol": True,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 21.04",
+        "codename": "Hirsute Hippo",
+        "alias": "hirsute",
+        "description": "Interim Release",
+        "stamp": 1619049600,
+    },
+    "ubuntu/impish": {
+        "eol": False,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 21.10",
+        "codename": "Impish Indri",
+        "alias": "impish",
+        "description": "Interim Release",
+        "stamp": 1634220000,
+    },
+    "ubuntu/jammy": {
+        "eol": False,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 22.04 LTS",
+        "codename": "Jammy Jellyfish",
+        "alias": "jammy",
+        "description": "Long Term Support",
+        "stamp": 1650693600,
+    },
+    "ubuntu/kinetic": {
+        "eol": False,
+        "components": [
+            "main",
+            "restricted",
+            "universe",
+            "multiverse",
+            "partner",
+        ],
+        "name": "Ubuntu 22.10",
+        "codename": "Kinetic Kudu",
+        "alias": "kinetic",
+        "devel": True,  # there can be only one ⚔
+        "description": "Interim Release",
+    },
+    "snap": {
+        "eol": False,
+        "packages": ["snap-supported.txt"],
+    },
+}
+
+
+def product_series(rel):
+    """Return the product,series tuple for rel."""
+    series = ""
+    parts = rel.split("/", 1)
+    product = parts[0]
+    if len(parts) == 2:
+        series = parts[1]
+    return product, series
+
+
+# get the subproject details for rel along with
+# it's canonical name, product and series
+def get_subproject_details(rel):
+    """Return the product,series,details tuple for rel."""
+    canon, product, series, details = None, None, None, None
+    try:
+        details = subprojects[rel]
+        product, series = product_series(rel)
+        canon = product + "/" + series
+    except (ValueError, KeyError):
+        # look for alias
+        for r in subprojects:
+            try:
+                if subprojects[r]["alias"] == rel:
+                    product, series = product_series(r)
+                    details = subprojects[r]
+                    canon = product + "/" + series
+                    break
+            except KeyError:
+                pass
+            if details is not None:
+                break
+    return canon, product, series, details
+
+
+def release_alias(rel):
+    """Return the alias for rel or just rel if no alias is defined."""
+    alias = rel
+    _, _, _, details = get_subproject_details(rel)
+    try:
+        alias = details["alias"]
+    except (KeyError, TypeError):
+        pass
+    return alias
+
+
+def release_parent(rel):
+    """Return the parent for rel or None if no parent is defined."""
+    parent = None
+    _, _, _, details = get_subproject_details(rel)
+    try:
+        parent = release_alias(details["parent"])
+    except (KeyError, TypeError):
+        pass
+    return parent
+
+
+def get_external_subproject_cve_dir(subproject):
+    """Get the directory where CVE files are stored for the subproject.
+
+    Get the directory where CVE files are stored for a subproject. In
+    general this is within the higher level project directory, not within
+    the specific subdirectory for the particular series that defines this
+    subproject.
+
+    """
+    rel, product, _, _ = get_subproject_details(subproject)
+    if rel not in external_releases:
+        raise ValueError("%s is not an external subproject" % rel)
+    # CVEs live in the product dir
+    return os.path.join(subprojects_dir, product)
+
+
+def get_external_subproject_dir(subproject):
+    """Get the directory for the given external subproject."""
+    rel, _, _, _ = get_subproject_details(subproject)
+    if rel not in external_releases:
+        raise ValueError("%s is not an external subproject" % rel)
+    return os.path.join(subprojects_dir, rel)
+
+
+def read_external_subproject_config(subproject):
+    """Read and return the configuration for the given subproject."""
+    sp_dir = get_external_subproject_dir(subproject)
+    config_yaml = os.path.join(sp_dir, "config.yaml")
+    with open(config_yaml) as cfg:
+        return yaml.safe_load(cfg)
+
+
+def find_files_recursive(path, name):
+    """Return a list of all files under path with name."""
+    matches = []
+    for root, _, files in os.walk(path, followlinks=True):
+        for f in files:
+            if f == name:
+                filepath = os.path.join(root, f)
+                matches.append(filepath)
+    return matches
+
+
+def find_external_subproject_cves(cve):
+    """
+    Return the list of external subproject CVE snippets for the given CVE.
+    """
+    cves = []
+    for rel in external_releases:
+        # fallback to the series specific subdir rather than just the
+        # top-level project directory even though this is preferred
+        for d in [
+            get_external_subproject_cve_dir(rel),
+            get_external_subproject_dir(rel),
+        ]:
+            path = os.path.join(d, cve)
+            if os.path.exists(path):
+                cves.append(path)
+    return cves
+
+
+def load_external_subprojects():
+    """Search for and load subprojects into the global subprojects dict.
+
+    Search for and load subprojects into the global subprojects dict.
+
+    A subproject is defined as a directory which resides within
+    subprojects_dir and contains a supported.txt file. It can also contain
+    a project.yml file which specifies configuration directives for the
+    project as well as snippet CVE files. By convention, a subproject is
+    usually defined as the combination of a product and series, ie:
+
+    esm-apps/focal
+
+    as such in this case there would expect to be within subprojects_dir a
+    directory called esm-apps/ and within that a subdirectory called
+    focal/. Inside this focal/ subdirectory a supported.txt file would list
+    the packages which are supported by the esm-apps/focal subproject. By
+    convention, snippet CVE files should reside within the esm-apps/
+    project directory rather than the esm-apps/focal/ subdirectory to avoid
+    unnecessary fragmentation across different subproject series.
+
+    """
+    for supported_txt in find_files_recursive(
+        subprojects_dir, "supported.txt"
+    ):
+        # rel name is the path component between subprojects/ and
+        # /supported.txt
+        rel = supported_txt[
+            len(subprojects_dir) + 1:-len("supported.txt") - 1
+        ]
+        external_releases.append(rel)
+        subprojects.setdefault(rel, {"packages": [], "eol": False})
+        # an external subproject can append to an internal one
+        subprojects[rel]["packages"].append(supported_txt)
+        try:
+            # use config to populate other parts of the
+            # subproject settings
+            config = read_external_subproject_config(rel)
+            subprojects[rel].setdefault("ppa", config["ppa"])
+            subprojects[rel].setdefault("name", config["name"])
+            subprojects[rel].setdefault("description", config["description"])
+            subprojects[rel].setdefault("parent", config["parent"])
+        except Exception:
+            pass
+
+
+load_external_subprojects()
+
+for release in subprojects:
+    details = subprojects[release]
+    rel = release_alias(release)
+    # prefer the alias name
+    all_releases.append(rel)
+    if details["eol"]:
+        eol_releases.append(rel)
+    if "devel" in details and details["devel"]:
+        if devel_release != "":
+            raise ValueError("there can be only one ⚔ devel")
+        devel_release = rel
+    # ubuntu specific releases
+    product, series = product_series(release)
+    if product == PRODUCT_UBUNTU:
+        releases.append(rel)
+
+
+VALID_TAGS = {
+    "universe-binary": (
+        "Binaries built from this source package are in universe and so are "
+        "supported by the community. For more details see "
+        "https://wiki.ubuntu.com/SecurityTeam/FAQ#Official_Support";
+    ),
+    "not-ue": (
+        "This package is not directly supported by the Ubuntu Security Team"
+    ),
+    "apparmor": (
+        "This vulnerability is mitigated in part by an AppArmor profile. "
+        "For more details see "
+        "https://wiki.ubuntu.com/Security/Features#apparmor";
+    ),
+    "stack-protector": (
+        "This vulnerability is mitigated in part by the use of gcc's stack "
+        "protector in Ubuntu. For more details see "
+        "https://wiki.ubuntu.com/Security/Features#stack-protector";
+    ),
+    "fortify-source": (
+        "This vulnerability is mitigated in part by the use of "
+        "-D_FORTIFY_SOURCE=2 in Ubuntu. For more details see "
+        "https://wiki.ubuntu.com/Security/Features#fortify-source";
+    ),
+    "symlink-restriction": (
+        "This vulnerability is mitigated in part by the use of symlink "
+        "restrictions in Ubuntu. For more details see "
+        "https://wiki.ubuntu.com/Security/Features#symlink";
+    ),
+    "hardlink-restriction": (
+        "This vulnerability is mitigated in part by the use of hardlink "
+        "restrictions in Ubuntu. For more details see "
+        "https://wiki.ubuntu.com/Security/Features#hardlink";
+    ),
+    "heap-protector": (
+        "This vulnerability is mitigated in part by the use of GNU C Library "
+        "heap protector in Ubuntu. For more details see "
+        "https://wiki.ubuntu.com/Security/Features#heap-protector";
+    ),
+    "pie": (
+        "This vulnerability is mitigated in part by the use of Position "
+        "Independent Executables in Ubuntu. For more details see "
+        "https://wiki.ubuntu.com/Security/Features#pie";
+    ),
+}
+
+# Possible CVE priorities
+PRIORITIES = ["negligible", "low", "medium", "high", "critical"]
+
+NOTE_RE = re.compile(r"^\s+([A-Za-z0-9-]+)([>|]) *(.*)$")
+
+EXIT_FAIL = 1
+EXIT_OKAY = 0
+
+# New CVE file format for release package field is:
+# <product>[/<where or who>]_SOFTWARE[/<modifier>]: <status> [(<when>)]
+# <product> is the Canonical product or supporting technology (eg, ‘esm-apps’
+# or ‘snap’). ‘ubuntu’ is the implied product when ‘<product>/’ is omitted
+# from the ‘<product>[/<where or who>]’ tuple (ie, where we might use
+# ‘ubuntu/bionic_DEBSRCPKG’ for consistency, we continue to use
+# ‘bionic_DEBSRCPKG’)
+# <where or who> indicates where the software lives or in the case of snaps or
+# other technologies with a concept of publishers, who the publisher is
+# SOFTWARE is the name of the software as dictated by the product (eg, the deb
+# source package, the name of the snap or the name of the software project
+# <modifier> is an optional key for grouping collections of packages (eg,
+# ‘melodic’ for the ROS Melodic release or ‘rocky’ for the OpenStack Rocky
+# release)
+# <status> indicates the statuses as defined in UCT (eg, needs-triage, needed,
+# pending, released, etc)
+# <when> indicates ‘when’ the software will be/was fixed when used with the
+# ‘pending’ or ‘released’ status (eg, the source package version, snap
+# revision, etc)
+# e.g.: esm-apps/xenial_jackson-databind: released (2.4.2-3ubuntu0.1~esm2)
+# e.g.: git/github.com/gogo/protobuf_gogoprotobuf: needs-triage
+# This method should keep supporting existing current format:
+# e.g.: bionic_jackson-databind: needs-triage
+def parse_cve_release_package_field(
+    cve, field, data, value, code, msg, linenum
+):
+    package = ""
+    release = ""
+    state = ""
+    details = ""
+    try:
+        release, package = field.split("_", 1)
+    except ValueError:
+        msg += "%s: %d: bad field with '_': '%s'\n" % (cve, linenum, field)
+        code = EXIT_FAIL
+        return False, package, release, state, details, code, msg
+
+    try:
+        info = value.split(" ", 1)
+    except ValueError:
+        msg += "%s: %d: missing state for '%s': '%s'\n" % (
+            cve,
+            linenum,
+            field,
+            value,
+        )
+        code = EXIT_FAIL
+        return False, package, release, state, details, code, msg
+
+    state = info[0]
+    if state == "":
+        state = "needs-triage"
+
+    if len(info) < 2:
+        details = ""
+    else:
+        details = info[1].strip()
+
+    if details.startswith("["):
+        msg += "%s: %d: %s has details that starts with a bracket: '%s'\n" % (
+            cve,
+            linenum,
+            field,
+            details,
+        )
+        code = EXIT_FAIL
+        return False, package, release, state, details, code, msg
+
+    if details.startswith("("):
+        details = details[1:]
+    if details.endswith(")"):
+        details = details[:-1]
+
+    # Work-around for old-style of only recording released versions
+    if details == "" and state[0] in ("0123456789"):
+        details = state
+        state = "released"
+
+    valid_states = [
+        "needs-triage",
+        "needed",
+        "active",
+        "pending",
+        "released",
+        "deferred",
+        "DNE",
+        "ignored",
+        "not-affected",
+    ]
+    if state not in valid_states:
+        msg += (
+            "%s: %d: %s has unknown state: '%s' (valid states are: %s)\n"
+            % (
+                cve,
+                linenum,
+                field,
+                state,
+                " ".join(valid_states),
+            )
+        )
+        code = EXIT_FAIL
+        return False, package, release, state, details, code, msg
+
+    # Verify "released" kernels have version details
+    # if state == 'released' and package in kernel_srcs and details == '':
+    #    msg += "%s: %s_%s has state '%s' but lacks version note\n" % (
+    #       cve, package, release, state
+    #    )
+    #    code = EXIT_FAIL
+
+    # Verify "active" states have an Assignee
+    if state == "active" and data["Assigned-to"].strip() == "":
+        msg += "%s: %d: %s has state '%s' but lacks 'Assigned-to'\n" % (
+            cve,
+            linenum,
+            field,
+            state,
+        )
+        code = EXIT_FAIL
+        return False, package, release, state, details, code, msg
+
+    return True, package, release, state, details, code, msg
+
+
+class NotesParser:
+    def __init__(self):
+        self.notes = list()
+        self.user = None
+        self.separator = None
+        self.note = None
+
+    def parse_line(self, cve, line, linenum, code):
+        msg = ""
+        m = NOTE_RE.match(line)
+        if m is not None:
+            new_user = m.group(1)
+            new_sep = m.group(2)
+            new_note = m.group(3)
+        else:
+            # follow up comments should have 2 space indent and
+            # an author
+            if self.user is None:
+                msg += "%s: %d: Note entry with no author: '%s'\n" % (
+                    cve,
+                    linenum,
+                    line[1:],
+                )
+                code = EXIT_FAIL
+            if not line.startswith("  "):
+                msg += (
+                    "%s: %d: Note continuations should be indented by "
+                    "2 spaces: '%s'.\n" % (cve, linenum, line)
+                )
+                code = EXIT_FAIL
+            new_user = self.user
+            new_sep = self.separator
+            new_note = line.strip()
+        if self.user and self.separator and self.note:
+            # if is different user, start a new note
+            if new_user != self.user:
+                self.notes.append((self.user, self.note))
+                self.user = new_user
+                self.note = new_note
+                self.separator = new_sep
+            elif new_sep != self.separator:
+                # finish this note and start a new one since this has new
+                # semantics
+                self.notes.append((self.user, self.note))
+                self.separator = new_sep
+                self.note = new_note
+            else:
+                if self.separator == "|":
+                    self.note = self.note + " " + new_note
+                else:
+                    assert self.separator == ">"
+                    self.note = self.note + "\n" + new_note
+        else:
+            # this is the first note
+            self.user = new_user
+            self.separator = new_sep
+            self.note = new_note
+        return code, msg
+
+    def finalize(self):
+        if self.user is not None and self.note is not None:
+            # add last Note
+            self.notes.append((self.user, self.note))
+            self.user = None
+            self.note = None
+        notes = self.notes
+        self.user = None
+        self.separator = None
+        self.notes = None
+        return notes
+
+
+def load_cve(cve, strict=False, srcmap=None):
+    """Loads a given CVE into:
+    dict( fields...
+          'pkgs' -> dict(  pkg -> dict(  release ->  (state, details)   ) )
+        )
+    """
+
+    msg = ""
+    code = EXIT_OKAY
+    required_fields = [
+        "Candidate",
+        "PublicDate",
+        "References",
+        "Description",
+        "Ubuntu-Description",
+        "Notes",
+        "Bugs",
+        "Priority",
+        "Discovered-by",
+        "Assigned-to",
+        "CVSS",
+    ]
+    extra_fields = ["CRD", "PublicDateAtUSN", "Mitigation"]
+
+    data = dict()
+    # maps entries in data to their source line - if didn't supply one
+    # create a local one to simplify the code
+    if srcmap is None:
+        srcmap = dict()
+    srcmap.setdefault("pkgs", dict())
+    srcmap.setdefault("tags", dict())
+    data.setdefault("tags", dict())
+    srcmap.setdefault("patches", dict())
+    data.setdefault("patches", dict())
+    affected = dict()
+    lastfield = ""
+    fields_seen = []
+    if not os.path.exists(cve):
+        raise ValueError("File does not exist: '%s'" % (cve))
+    linenum = 0
+    notes_parser = NotesParser()
+    cvss_entries = []
+
+    cve_file = codecs.open(cve, encoding="utf-8")
+
+    for line in cve_file.readlines():
+        line = line.rstrip()
+        linenum += 1
+
+        # Ignore blank/commented lines
+        if len(line) == 0 or line.startswith("#"):
+            continue
+        if line.startswith(" "):
+            try:
+                # parse Notes properly
+                if lastfield == "Notes":
+                    code, newmsg = notes_parser.parse_line(
+                        cve, line, linenum, code
+                    )
+                    if code != EXIT_OKAY:
+                        msg += newmsg
+                elif "Patches_" in lastfield:
+                    try:
+                        _, pkg = lastfield.split("_", 1)
+                        patch_type, entry = line.split(":", 1)
+                        patch_type = patch_type.strip()
+                        entry = entry.strip()
+                        data["patches"][pkg].append((patch_type, entry))
+                        srcmap["patches"][pkg].append((cve, linenum))
+                    except Exception as e:
+                        msg += (
+                            "%s: %d: Failed to parse '%s' entry %s: %s\n"
+                            % (
+                                cve,
+                                linenum,
+                                lastfield,
+                                line,
+                                e,
+                            )
+                        )
+                        code = EXIT_FAIL
+                elif lastfield == "CVSS":
+                    try:
+                        cvss = dict()
+                        result = re.search(
+                            r" (.+)\: (\S+)( \[(.*) (.*)\])?", line
+                        )
+                        if result is None:
+                            continue
+                        cvss["source"] = result.group(1)
+                        cvss["vector"] = result.group(2)
+                        entry = parse_cvss(cvss["vector"])
+                        if entry is None:
+                            raise RuntimeError(
+                                "Failed to parse_cvss() without raising "
+                                "an exception."
+                            )
+                        if result.group(3):
+                            cvss["baseScore"] = result.group(4)
+                            cvss["baseSeverity"] = result.group(5)
+
+                        cvss_entries.append(cvss)
+                        # CVSS in srcmap will be a tuple since this is the
+                        # line where the CVSS block starts - so convert it
+                        # to a dict first if needed
+                        if type(srcmap["CVSS"]) is tuple:
+                            srcmap["CVSS"] = dict()
+                        srcmap["CVSS"].setdefault(
+                            cvss["source"], (cve, linenum)
+                        )
+                    except Exception as e:
+                        msg += "%s: %d: Failed to parse CVSS: %s\n" % (
+                            cve,
+                            linenum,
+                            e,
+                        )
+                        code = EXIT_FAIL
+                else:
+                    data[lastfield] += "\n%s" % (line[1:])
+            except KeyError as e:
+                msg += "%s: %d: bad line '%s' (%s)\n" % (cve, linenum, line, e)
+                code = EXIT_FAIL
+            continue
+
+        try:
+            field, value = line.split(":", 1)
+        except ValueError as e:
+            msg += "%s: %d: bad line '%s' (%s)\n" % (cve, linenum, line, e)
+            code = EXIT_FAIL
+            continue
+
+        lastfield = field = field.strip()
+        if field in fields_seen:
+            msg += "%s: %d: repeated field '%s'\n" % (cve, linenum, field)
+            code = EXIT_FAIL
+        else:
+            fields_seen.append(field)
+        value = value.strip()
+        if field == "Candidate":
+            data.setdefault(field, value)
+            srcmap.setdefault(field, (cve, linenum))
+            if (
+                value != ""
+                and not value.startswith("CVE-")
+                and not value.startswith("UEM-")
+                and not value.startswith("EMB-")
+            ):
+                msg += (
+                    "%s: %d: unknown Candidate '%s' "
+                    "(must be /(CVE|UEM|EMB)-/)\n"
+                    % (
+                        cve,
+                        linenum,
+                        value,
+                    )
+                )
+                code = EXIT_FAIL
+        elif "Priority" in field:
+            # For now, throw away comments on Priority fields
+            if " " in value:
+                value = value.split()[0]
+            if "Priority_" in field:
+                try:
+                    _, pkg = field.split("_", 1)
+                except ValueError:
+                    msg += "%s: %d: bad field with 'Priority_': '%s'\n" % (
+                        cve,
+                        linenum,
+                        field,
+                    )
+                    code = EXIT_FAIL
+                    continue
+            data.setdefault(field, value)
+            srcmap.setdefault(field, (cve, linenum))
+            if value not in ["untriaged", "not-for-us"] + PRIORITIES:
+                msg += "%s: %d: unknown Priority '%s'\n" % (
+                    cve,
+                    linenum,
+                    value,
+                )
+                code = EXIT_FAIL
+        elif "Patches_" in field:
+            try:
+                _, pkg = field.split("_", 1)
+            except ValueError:
+                msg += "%s: %d: bad field with 'Patches_': '%s'\n" % (
+                    cve,
+                    linenum,
+                    field,
+                )
+                code = EXIT_FAIL
+                continue
+            # value should be empty
+            if len(value) > 0:
+                msg += "%s: %d: '%s' field should have no value\n" % (
+                    cve,
+                    linenum,
+                    field,
+                )
+                code = EXIT_FAIL
+                continue
+            data["patches"].setdefault(pkg, list())
+            srcmap["patches"].setdefault(pkg, list())
+        elif "Tags_" in field:
+            """These are processed into the "tags" hash"""
+            try:
+                _, pkg = field.split("_", 1)
+            except ValueError:
+                msg += "%s: %d: bad field with 'Tags_': '%s'\n" % (
+                    cve,
+                    linenum,
+                    field,
+                )
+                code = EXIT_FAIL
+                continue
+            data["tags"].setdefault(pkg, set())
+            srcmap["tags"].setdefault(pkg, (cve, linenum))
+            for word in value.strip().split(" "):
+                if word not in VALID_TAGS:
+                    msg += "%s: %d: invalid tag '%s': '%s'\n" % (
+                        cve,
+                        linenum,
+                        word,
+                        field,
+                    )
+                    code = EXIT_FAIL
+                    continue
+                data["tags"][pkg].add(word)
+        elif "_" in field:
+            (
+                success,
+                pkg,
+                rel,
+                state,
+                details,
+                code,
+                msg,
+            ) = parse_cve_release_package_field(
+                cve, field, data, value, code, msg, linenum
+            )
+            if not success:
+                assert code == EXIT_FAIL
+                continue
+            canon, _, _, _ = get_subproject_details(rel)
+            if canon is None and rel not in ["upstream", "devel"]:
+                msg += "%s: %d: unknown entry '%s'\n" % (cve, linenum, rel)
+                code = EXIT_FAIL
+                continue
+            affected.setdefault(pkg, dict())
+            if rel in affected[pkg]:
+                msg += (
+                    "%s: %d: duplicate entry for '%s': original at line %d\n"
+                    % (
+                        cve,
+                        linenum,
+                        rel,
+                        srcmap["pkgs"][pkg][rel][1],
+                    )
+                )
+                code = EXIT_FAIL
+                continue
+            affected[pkg].setdefault(rel, [state, details])
+            srcmap["pkgs"].setdefault(pkg, dict())
+            srcmap["pkgs"][pkg].setdefault(rel, (cve, linenum))
+        elif field not in required_fields + extra_fields:
+            msg += "%s: %d: unknown field '%s'\n" % (cve, linenum, field)
+            code = EXIT_FAIL
+        else:
+            data.setdefault(field, value)
+            srcmap.setdefault(field, (cve, linenum))
+
+    cve_file.close()
+
+    data["Notes"] = notes_parser.finalize()
+    data["CVSS"] = cvss_entries
+
+    # Check for required fields
+    for field in required_fields:
+        nonempty = ["Candidate"]
+        if strict:
+            nonempty += ["PublicDate"]
+        # boilerplate files are special and can (should?) be empty
+        if "boilerplate" in cve:
+            nonempty = []
+
+        if field not in data or field not in fields_seen:
+            msg += "%s: %d: missing field '%s'\n" % (cve, linenum, field)
+            code = EXIT_FAIL
+        elif field in nonempty and data[field].strip() == "":
+            msg += "%s: %d: required field '%s' is empty\n" % (
+                cve,
+                linenum,
+                field,
+            )
+            code = EXIT_FAIL
+
+    # Fill in defaults for missing fields
+    if "Priority" not in data:
+        data.setdefault("Priority", "untriaged")
+        srcmap.setdefault("Priority", (cve, 1))
+    # Perform override fields
+    if "PublicDateAtUSN" in data:
+        data["PublicDate"] = data["PublicDateAtUSN"]
+        srcmap["PublicDate"] = srcmap["PublicDateAtUSN"]
+    if (
+        "CRD" in data
+        and data["CRD"].strip() != ""
+        and data["PublicDate"] != data["CRD"]
+    ):
+        if cve.startswith("embargoed"):
+            print(
+                "%s: %d: adjusting PublicDate to use CRD: %s"
+                % (cve, linenum, data["CRD"]),
+                file=sys.stderr,
+            )
+        data["PublicDate"] = data["CRD"]
+        srcmap["PublicDate"] = srcmap["CRD"]
+
+    # entries need an upstream entry if any entries are from the internal
+    # list of subprojects
+    for pkg in affected:
+        needs_upstream = False
+        for rel in affected[pkg]:
+            if rel not in external_releases:
+                needs_upstream = True
+        if needs_upstream and "upstream" not in affected[pkg]:
+            msg += "%s: %d: missing upstream '%s'\n" % (cve, linenum, pkg)
+            code = EXIT_FAIL
+
+    data["pkgs"] = affected
+
+    code, msg = load_external_subproject_cve_data(cve, data, srcmap, code, msg)
+
+    if code != EXIT_OKAY:
+        raise ValueError(msg.strip())
+    return data
+
+
+def amend_external_subproject_pkg(cve, data, srcmap, amendments, code, msg):
+    linenum = 0
+    for line in amendments.splitlines():
+        linenum += 1
+        if len(line) == 0 or line.startswith("#") or line.startswith(" "):
+            continue
+        try:
+            field, value = line.split(":", 1)
+            field = field.strip()
+            value = value.strip()
+        except ValueError as e:
+            msg += "%s: bad line '%s' (%s)\n" % (cve, line, e)
+            code = EXIT_FAIL
+            return code, msg
+
+        if "_" in field:
+            (
+                success,
+                pkg,
+                release,
+                state,
+                details,
+                code,
+                msg,
+            ) = parse_cve_release_package_field(
+                cve, field, data, value, code, msg, linenum
+            )
+            if not success:
+                return code, msg
+
+            data.setdefault("pkgs", dict())
+            data["pkgs"].setdefault(pkg, dict())
+            srcmap["pkgs"].setdefault(pkg, dict())
+            # override existing release info if it exists
+            data["pkgs"][pkg][release] = [state, details]
+            srcmap["pkgs"][pkg][release] = (cve, linenum)
+
+    return code, msg
+
+
+def load_external_subproject_cve_data(cve, data, srcmap, code, msg):
+    cve_id = os.path.basename(cve)
+    for f in find_external_subproject_cves(cve_id):
+        with codecs.open(f, "r", encoding="utf-8") as fp:
+            amendments = fp.read()
+            fp.close()
+        code, msg = amend_external_subproject_pkg(
+            f, data, srcmap, amendments, code, msg
+        )
+
+    return code, msg
+
+
+def parse_cvss(cvss):
+    # parse a CVSS string into components suitable for MITRE / NVD JSON
+    # format - assumes only the Base metric group from
+    # https://www.first.org/cvss/specification-document since this is
+    # mandatory - also validates by raising exceptions on errors
+    metrics = {
+        "attackVector": {
+            "abbrev": "AV",
+            "values": {
+                "NETWORK": 0.85,
+                "ADJACENT": 0.62,
+                "LOCAL": 0.55,
+                "PHYSICAL": 0.2,
+            },
+        },
+        "attackComplexity": {
+            "abbrev": "AC",
+            "values": {"LOW": 0.77, "HIGH": 0.44},
+        },
+        "privilegesRequired": {
+            "abbrev": "PR",
+            "values": {
+                "NONE": 0.85,
+                # [ scope unchanged, changed ]
+                "LOW": [0.62, 0.68],  # depends on scope
+                "HIGH": [0.27, 0.5],
+            },  # depends on scope
+        },
+        "userInteraction": {
+            "abbrev": "UI",
+            "values": {"NONE": 0.85, "REQUIRED": 0.62},
+        },
+        "scope": {"abbrev": "S", "values": {"UNCHANGED", "CHANGED"}},
+        "confidentialityImpact": {
+            "abbrev": "C",
+            "values": {"HIGH": 0.56, "LOW": 0.22, "NONE": 0},
+        },
+        "integrityImpact": {
+            "abbrev": "I",
+            "values": {"HIGH": 0.56, "LOW": 0.22, "NONE": 0},
+        },
+        "availabilityImpact": {
+            "abbrev": "A",
+            "values": {"HIGH": 0.56, "LOW": 0.22, "NONE": 0},
+        },
+    }
+    severities = {
+        "NONE": 0.0,
+        "LOW": 3.9,
+        "MEDIUM": 6.9,
+        "HIGH": 8.9,
+        "CRITICAL": 10.0,
+    }
+    js = None
+    # coerce cvss into a string
+    cvss = str(cvss)
+    for c in cvss.split("/"):
+        elements = c.split(":")
+        if len(elements) != 2:
+            raise ValueError("Invalid CVSS element '%s'" % c)
+        valid = False
+        metric = elements[0]
+        value = elements[1]
+        if metric == "CVSS":
+            if value == "3.0" or value == "3.1":
+                js = {"baseMetricV3": {"cvssV3": {"version": value}}}
+                valid = True
+            else:
+                raise ValueError(
+                    "Unable to process CVSS version '%s' (we only support 3.x)"
+                    % value
+                )
+        else:
+            for m in metrics.keys():
+                if metrics[m]["abbrev"] == metric:
+                    for val in metrics[m]["values"]:
+                        if val[0:1] == value:
+                            js["baseMetricV3"]["cvssV3"][m] = val
+                            valid = True
+        if not valid:
+            raise ValueError("Invalid CVSS elements '%s:%s'" % (metric, value))
+    for m in metrics.keys():
+        if m not in js["baseMetricV3"]["cvssV3"]:
+            raise ValueError("Missing required CVSS base element %s" % m)
+    # add vectorString
+    js["baseMetricV3"]["cvssV3"]["vectorString"] = cvss
+
+    # now calculate CVSS scores
+    iss = 1 - (
+        (
+            1
+            - metrics["confidentialityImpact"]["values"][
+                js["baseMetricV3"]["cvssV3"]["confidentialityImpact"]
+            ]
+        )
+        * (
+            1
+            - metrics["integrityImpact"]["values"][
+                js["baseMetricV3"]["cvssV3"]["integrityImpact"]
+            ]
+        )
+        * (
+            1
+            - metrics["availabilityImpact"]["values"][
+                js["baseMetricV3"]["cvssV3"]["availabilityImpact"]
+            ]
+        )
+    )
+    if js["baseMetricV3"]["cvssV3"]["scope"] == "UNCHANGED":
+        impact = 6.42 * iss
+    else:
+        impact = 7.52 * (iss - 0.029) - 3.25 * pow(iss - 0.02, 15)
+    attackVector = metrics["attackVector"]["values"][
+        js["baseMetricV3"]["cvssV3"]["attackVector"]
+    ]
+    attackComplexity = metrics["attackComplexity"]["values"][
+        js["baseMetricV3"]["cvssV3"]["attackComplexity"]
+    ]
+    privilegesRequired = metrics["privilegesRequired"]["values"][
+        js["baseMetricV3"]["cvssV3"]["privilegesRequired"]
+    ]
+    # privilegesRequires could be a list if is LOW or HIGH (and then the
+    # value depends on whether the scope is unchanged or not)
+    if isinstance(privilegesRequired, list):
+        if js["baseMetricV3"]["cvssV3"]["scope"] == "UNCHANGED":
+            privilegesRequired = privilegesRequired[0]
+        else:
+            privilegesRequired = privilegesRequired[1]
+    userInteraction = metrics["userInteraction"]["values"][
+        js["baseMetricV3"]["cvssV3"]["userInteraction"]
+    ]
+    exploitability = (
+        8.22
+        * attackVector
+        * attackComplexity
+        * privilegesRequired
+        * userInteraction
+    )
+    if impact <= 0:
+        base_score = 0
+    elif js["baseMetricV3"]["cvssV3"]["scope"] == "UNCHANGED":
+        # use ceil and * 10 / 10 to get rounded up to nearest 10th decimal
+        # (where rounded-up is say 0.01 -> 0.1)
+        base_score = math.ceil(min(impact + exploitability, 10) * 10) / 10
+    else:
+        base_score = (
+            math.ceil(min(1.08 * (impact + exploitability), 10) * 10) / 10
+        )
+    js["baseMetricV3"]["cvssV3"]["baseScore"] = base_score
+    for severity in severities.keys():
+        if base_score <= severities[severity]:
+            js["baseMetricV3"]["cvssV3"]["baseSeverity"] = severity
+            break
+    # these use normal rounding to 1 decimal place
+    js["baseMetricV3"]["exploitabilityScore"] = round(exploitability * 10) / 10
+    js["baseMetricV3"]["impactScore"] = round(impact * 10) / 10
+    return js
diff --git a/scripts/uct-import.py b/scripts/uct-import.py
new file mode 100755
index 0000000..867aada
--- /dev/null
+++ b/scripts/uct-import.py
@@ -0,0 +1,33 @@
+#!/usr/bin/python3 -S
+#
+# Copyright 2022 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+import _pythonpath  # noqa: F401
+
+import logging
+from pathlib import Path
+
+from lp.bugs.scripts.uctimport import UCTImporter
+from lp.services.scripts.base import LaunchpadScript
+
+
+class UCTImportScript(LaunchpadScript):
+
+    description = (
+        "Import bugs into Launchpad from CVE entries in ubuntu-cve-tracker."
+    )
+    loglevel = logging.INFO
+
+    def main(self):
+        if len(self.args) != 1:
+            self.parser.error('Please specify a CVE file to import')
+
+        importer = UCTImporter()
+
+        cve_path = Path(self.args[0])
+        importer.import_cve_from_file(cve_path)
+
+
+if __name__ == '__main__':
+    script = UCTImportScript('lp.services.scripts.uctimport')
+    script.run()

Follow ups