launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #32903
[Merge] ~enriqueesanchz/launchpad:add-vulnerabilityjob-model into launchpad:master
Enrique Sánchez has proposed merging ~enriqueesanchz/launchpad:add-vulnerabilityjob-model into launchpad:master.
Commit message:
Add `ImportVulnerabilityJob`
It is the `VulnerabilityJob` that imports data from cve trackers. Only SOSS is supported now.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~enriqueesanchz/launchpad/+git/launchpad/+merge/491368
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~enriqueesanchz/launchpad:add-vulnerabilityjob-model into launchpad:master.
diff --git a/lib/lp/bugs/configure.zcml b/lib/lp/bugs/configure.zcml
index b0bec51..687a347 100644
--- a/lib/lp/bugs/configure.zcml
+++ b/lib/lp/bugs/configure.zcml
@@ -1121,6 +1121,22 @@
interface="lp.bugs.interfaces.apportjob.IProcessApportBlobJobSource"/>
</lp:securedutility>
+ <!-- VulnerabilityJob -->
+ <class class="lp.bugs.model.vulnerabilityjob.VulnerabilityJob">
+ <allow interface="lp.bugs.interfaces.vulnerabilityjob.IVulnerabilityJob" />
+ </class>
+
+ <!-- ImportVulnerabilityJobSource -->
+ <lp:securedutility
+ component="lp.bugs.model.importvulnerabilityjob.ImportVulnerabilityJob"
+ provides="lp.bugs.interfaces.vulnerabilityjob.IImportVulnerabilityJobSource">
+ <allow interface="lp.bugs.interfaces.vulnerabilityjob.IImportVulnerabilityJobSource"/>
+ </lp:securedutility>
+ <class class="lp.bugs.model.importvulnerabilityjob.ImportVulnerabilityJob">
+ <allow interface="lp.bugs.interfaces.vulnerabilityjob.IImportVulnerabilityJob" />
+ <allow interface="lp.bugs.interfaces.vulnerabilityjob.IVulnerabilityJob" />
+ </class>
+
<!-- FileBugData -->
<class class="lp.bugs.model.bug.FileBugData">
<allow interface="lp.bugs.interfaces.bug.IFileBugData" />
diff --git a/lib/lp/bugs/enums.py b/lib/lp/bugs/enums.py
index 109ec8f..be1a968 100644
--- a/lib/lp/bugs/enums.py
+++ b/lib/lp/bugs/enums.py
@@ -9,6 +9,7 @@ __all__ = [
"BugNotificationLevel",
"BugNotificationStatus",
"VulnerabilityStatus",
+ "VulnerabilityHandlerEnum",
]
from lazr.enum import DBEnumeratedType, DBItem, use_template
@@ -168,3 +169,15 @@ class VulnerabilityStatus(DBEnumeratedType):
This vulnerability is now retired.
""",
)
+
+
+class VulnerabilityHandlerEnum(DBEnumeratedType):
+ SOSS = DBItem(
+ 1,
+ """
+ SOSS Handler
+
+ Specific handler to use for SOSS vulnerability data imports and
+ exports.
+ """,
+ )
diff --git a/lib/lp/bugs/interfaces/vulnerabilityjob.py b/lib/lp/bugs/interfaces/vulnerabilityjob.py
new file mode 100644
index 0000000..f057825
--- /dev/null
+++ b/lib/lp/bugs/interfaces/vulnerabilityjob.py
@@ -0,0 +1,152 @@
+# Copyright 2025 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__all__ = [
+ "VulnerabilityJobType",
+ "IVulnerabilityJob",
+ "VulnerabilityJobInProgress",
+ "VulnerabilityJobException",
+ "IImportVulnerabilityJobSource",
+ "IImportVulnerabilityJob",
+ "IExportVulnerabilityJobSource",
+ "IExportVulnerabilityJob",
+]
+
+from lazr.enum import DBEnumeratedType, DBItem
+from zope.interface import Attribute, Interface
+from zope.schema import Choice, Int, Object, Text
+
+from lp import _
+from lp.bugs.enums import VulnerabilityHandlerEnum
+from lp.services.job.interfaces.job import IJob, IJobSource, IRunnableJob
+
+
+class VulnerabilityJobType(DBEnumeratedType):
+ IMPORT_DATA = DBItem(
+ 1,
+ """
+ Import Vulnerability data
+
+ This job imports Vulnerability data from the format given by the
+ handler.
+ """,
+ )
+
+ EXPORT_DATA = DBItem(
+ 2,
+ """
+ Export Vulnerability data
+
+ This job exports Vulnerability data to the format given by the hanlder.
+ """,
+ )
+
+
+class IVulnerabilityJob(Interface):
+ """A Job that acts on vulnerabilities."""
+
+ id = Int(
+ title=_("DB ID"),
+ required=True,
+ readonly=True,
+ description=_("The tracking number for this job."),
+ )
+
+ handler = Choice(
+ title=_("The handler for this job."),
+ vocabulary=VulnerabilityHandlerEnum,
+ required=True,
+ readonly=True,
+ )
+
+ job_type = Choice(
+ title=_("Job type"),
+ vocabulary=VulnerabilityJobType,
+ required=True,
+ readonly=True,
+ )
+
+ job = Object(
+ title=_("The common Job attributes"), schema=IJob, required=True
+ )
+
+ metadata = Attribute("A dict of data about the job.")
+
+ def destroySelf():
+ """Destroy this object."""
+
+
+class VulnerabilityJobInProgress(Exception):
+ """The VulnerabilityJob for the handler is already in progress."""
+
+ def __init__(self, job):
+ super().__init__()
+ self.job = job
+
+
+class VulnerabilityJobException(Exception):
+ """There was an error during VulnerabilityJob creation."""
+
+
+class IImportVulnerabilityJobSource(IJobSource):
+ """An interface for acquiring IImportVulnerabilityJobs."""
+
+ def create(
+ handler,
+ git_repository,
+ git_ref,
+ git_paths,
+ information_type,
+ import_since_commit_sha1,
+ ):
+ """Create a new import job for a handler."""
+
+ def get(handler):
+ """Retrieve the import job for a handler, if any.
+
+ :return: `None` or an `IImportVulnerabilityJobSource`.
+ """
+
+
+class IImportVulnerabilityJob(IRunnableJob):
+ """A Job that imports SVT data."""
+
+ error_description = Text(
+ title=_("Error description"),
+ description=_(
+ "A short description of the last error this "
+ "job encountered, if any."
+ ),
+ readonly=True,
+ required=False,
+ )
+
+
+class IExportVulnerabilityJobSource(IJobSource):
+ """An interface for acquiring IExportVulnerabilityJob."""
+
+ def create(
+ handler,
+ sources,
+ ):
+ """Create a new export job for sources using a handler."""
+
+ def get(handler):
+ """Retrieve the export job for a handler, if any.
+
+ :return: `None` or an `IExportVulnerabilityJobSource`.
+ """
+
+
+class IExportVulnerabilityJob(IRunnableJob):
+ """A Job that exports SVT data."""
+
+ error_description = Text(
+ title=_("Error description"),
+ description=_(
+ "A short description of the last error this "
+ "job encountered, if any."
+ ),
+ readonly=True,
+ required=False,
+ )
diff --git a/lib/lp/bugs/model/importvulnerabilityjob.py b/lib/lp/bugs/model/importvulnerabilityjob.py
new file mode 100644
index 0000000..3753a7f
--- /dev/null
+++ b/lib/lp/bugs/model/importvulnerabilityjob.py
@@ -0,0 +1,280 @@
+# Copyright 2025 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__all__ = [
+ "ImportVulnerabilityJob",
+]
+
+import logging
+import re
+
+from zope.component import getUtility
+from zope.interface import implementer, provider
+
+from lp.app.enums import InformationType
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+from lp.bugs.enums import VulnerabilityHandlerEnum
+from lp.bugs.interfaces.vulnerabilityjob import (
+ IImportVulnerabilityJob,
+ IImportVulnerabilityJobSource,
+ VulnerabilityJobException,
+ VulnerabilityJobInProgress,
+ VulnerabilityJobType,
+)
+from lp.bugs.model.vulnerabilityjob import (
+ VulnerabilityJob,
+ VulnerabilityJobDerived,
+)
+from lp.bugs.scripts.soss.models import SOSSRecord
+from lp.bugs.scripts.soss.sossimport import SOSSImporter
+from lp.code.interfaces.githosting import IGitHostingClient
+from lp.code.interfaces.gitlookup import IGitLookup
+from lp.services.config import config
+from lp.services.database.interfaces import IPrimaryStore, IStore
+from lp.services.job.interfaces.job import JobStatus
+from lp.services.job.model.job import Job
+from lp.testing import person_logged_in
+
+CVE_PATTERN = re.compile(r"^CVE-\d{4}-\d+$")
+logger = logging.getLogger(__name__)
+
+
+@implementer(IImportVulnerabilityJob)
+@provider(IImportVulnerabilityJobSource)
+class ImportVulnerabilityJob(VulnerabilityJobDerived):
+ class_job_type = VulnerabilityJobType.IMPORT_DATA
+
+ user_error_types = (VulnerabilityJobException,)
+
+ config = config.IImportVulnerabilityJobSource
+
+ @property
+ def git_repository(self):
+ return self.metadata.get("git_repository")
+
+ @property
+ def git_ref(self):
+ return self.metadata.get("git_ref")
+
+ @property
+ def git_paths(self):
+ return self.metadata.get("git_paths")
+
+ @property
+ def information_type(self):
+ return self.metadata.get("information_type")
+
+ @property
+ def import_since_commit_sha1(self):
+ return self.metadata.get("import_since_commit_sha1")
+
+ @property
+ def error_description(self):
+ return self.metadata.get("error_description")
+
+ @classmethod
+ def create(
+ cls,
+ handler,
+ git_repository,
+ git_ref,
+ git_paths,
+ information_type,
+ import_since_commit_sha1=False,
+ ):
+ """Create a new `ImportVulnerabilityJob`.
+
+ :param handler: What handler to use for importing the data. Can be one
+ of a group of predefined classes (SOSS, UCT, ...).
+ :param git_repository: Git repository to import from.
+ :param git_ref: Git branch/tag to get data from.
+ :param git_paths: List of relative directories within the repository to
+ get data from.
+ :param information_type: Whether imported data (bugs) should be private
+ or public. Can be one of a group of predefined options (PUBLIC,
+ PRIVATE_SECURITY...). If the source git repository is private, then
+ the information_type needs to be private also.
+ :param import_since_commit_sha1: Import data from files that were
+ altered since the given commit_sha1
+ """
+ store = IPrimaryStore(VulnerabilityJob)
+
+ vulnerability_job = store.find(
+ VulnerabilityJob,
+ VulnerabilityJob.job_id == Job.id,
+ VulnerabilityJob.job_type == cls.class_job_type,
+ VulnerabilityJob.handler == handler,
+ ).one()
+
+ if vulnerability_job is not None and (
+ vulnerability_job.job.status == JobStatus.WAITING
+ or vulnerability_job.job.status == JobStatus.RUNNING
+ ):
+ raise VulnerabilityJobInProgress(cls(vulnerability_job))
+
+ # Schedule the initialization.
+ metadata = {
+ "git_repository": git_repository,
+ "git_ref": git_ref,
+ "git_paths": git_paths,
+ "information_type": information_type,
+ "import_since_commit_sha1": import_since_commit_sha1,
+ }
+
+ vulnerability_job = VulnerabilityJob(
+ handler, cls.class_job_type, metadata
+ )
+ store.add(vulnerability_job)
+ derived_job = cls(vulnerability_job)
+ derived_job.celeryRunOnCommit()
+ IStore(VulnerabilityJob).flush()
+ return derived_job
+
+ @classmethod
+ def get(cls, handler):
+ """See `IImportVulnerabilityJob`."""
+ vulnerability_job = (
+ IStore(VulnerabilityJob)
+ .find(
+ VulnerabilityJob,
+ VulnerabilityJob.job_id == Job.id,
+ VulnerabilityJob.job_type == cls.class_job_type,
+ VulnerabilityJob.handler == handler,
+ )
+ .one()
+ )
+ return None if vulnerability_job is None else cls(vulnerability_job)
+
+ def __repr__(self):
+ """Returns an informative representation of the job."""
+ parts = "%s for" % self.__class__.__name__
+ parts += " handler: %s" % self.handler
+ parts += ", metadata: %s" % self.metadata
+ return "<%s>" % parts
+
+ def _get_parser_importer(
+ self,
+ handler: VulnerabilityHandlerEnum,
+ information_type: InformationType,
+ ):
+ """Decide which parser and importer to use
+
+ :return: a tuple of (parser, importer) where parser is the function
+ that gets a blob and returns a record and importer is the function that
+ gets a record and imports it.
+ """
+
+ if handler == VulnerabilityHandlerEnum.SOSS:
+ parser = SOSSRecord.from_yaml
+ importer = SOSSImporter(
+ information_type=information_type
+ ).import_cve
+ else:
+ exception = VulnerabilityJobException("Handler not found")
+ self.notifyUserError(exception)
+ raise exception
+
+ return parser, importer
+
+ def run(self):
+ """See `IRunnableJob`."""
+ self.metadata["result"] = {"succeeded": [], "failed": []}
+ admin = getUtility(ILaunchpadCelebrities).admin
+
+ # InformationType is passed as a value as DBItem is not serializable
+ information_type = InformationType.items[self.information_type]
+ parser, importer = self._get_parser_importer(
+ self.context.handler, information_type
+ )
+
+ # Get git repository
+ git_lookup = getUtility(IGitLookup)
+ repository = git_lookup.getByUrl(self.git_repository)
+ if not repository:
+ exception = VulnerabilityJobException("Git repository not found")
+ self.notifyUserError(exception)
+ raise exception
+
+ # Get git reference
+ ref = repository.getRefByPath(self.git_ref)
+ if not ref:
+ exception = VulnerabilityJobException("Git ref not found")
+ self.notifyUserError(exception)
+ raise exception
+
+ # turnip API call to get added/modified files
+ stats = getUtility(IGitHostingClient).getDiffStats(
+ path=self.git_repository,
+ old=self.import_since_commit_sha1,
+ new=ref.commit_sha1,
+ logger=logger,
+ )
+
+ files = [*stats.get("added", ()), *stats.get("modified", ())]
+ for file in files:
+ # Check if files that changed are in the desired path
+ found_path = False
+ for path in self.git_paths:
+ if file.startswith(path):
+ found_path = True
+ break
+
+ if not found_path:
+ logger.debug(
+ f"[ImportVulnerabilityJob] {file} is not in git_paths"
+ )
+ continue
+
+ cve_sequence = file.rsplit("/", maxsplit=1)[-1]
+ if not CVE_PATTERN.match(cve_sequence):
+ logger.debug(
+ f"[ImportVulnerabilityJob] {cve_sequence} is not a CVE "
+ "sequence"
+ )
+ continue
+
+ try:
+ logger.debug(f"[ImportVulnerabilityJob] Getting {file}")
+ blob = ref.getBlob(file)
+
+ logger.debug(
+ f"[ImportVulnerabilityJob] Parsing {cve_sequence}"
+ )
+ record = parser(blob)
+
+ # Logged as admin
+ with person_logged_in(admin):
+ bug, vulnerability = importer(record, cve_sequence)
+
+ if bug and vulnerability:
+ self.metadata["result"]["succeeded"].append(cve_sequence)
+ else:
+ self.metadata["result"]["failed"].append(cve_sequence)
+ except Exception as e:
+ self.notifyUserError(e)
+
+ def notifyUserError(self, error):
+ """Calls up and also saves the error text in this job's metadata.
+
+ See `BaseRunnableJob`.
+ """
+ # This method is called when error is an instance of
+ # self.user_error_types.
+ super().notifyUserError(error)
+ error_description = self.metadata.get("error_description", [])
+ error_description.append(str(error))
+ self.metadata = dict(
+ self.metadata, error_description=error_description
+ )
+
+ def getOopsVars(self):
+ """See `IRunnableJob`."""
+ vars = super().getOopsVars()
+ vars.extend(
+ [
+ ("vulnerabilityjob_job_id", self.context.id),
+ ("vulnerability_job_type", self.context.job_type.title),
+ ("handler", self.context.handler),
+ ]
+ )
+ return vars
diff --git a/lib/lp/bugs/model/vulnerabilityjob.py b/lib/lp/bugs/model/vulnerabilityjob.py
new file mode 100644
index 0000000..80a0753
--- /dev/null
+++ b/lib/lp/bugs/model/vulnerabilityjob.py
@@ -0,0 +1,102 @@
+# Copyright 2025 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+__all__ = [
+ "VulnerabilityJob",
+ "VulnerabilityJobDerived",
+]
+
+from lazr.delegates import delegate_to
+from storm.databases.postgres import JSON
+from storm.locals import And, Int, Reference
+from zope.interface import implementer
+
+from lp.app.errors import NotFoundError
+from lp.bugs.enums import VulnerabilityHandlerEnum
+from lp.bugs.interfaces.vulnerabilityjob import (
+ IVulnerabilityJob,
+ VulnerabilityJobType,
+)
+from lp.services.database.enumcol import DBEnum
+from lp.services.database.interfaces import IStore
+from lp.services.database.stormbase import StormBase
+from lp.services.job.model.job import EnumeratedSubclass, Job
+from lp.services.job.runner import BaseRunnableJob
+
+
+@implementer(IVulnerabilityJob)
+class VulnerabilityJob(StormBase):
+ """Base class for jobs related to Vulnerabilities."""
+
+ __storm_table__ = "VulnerabilityJob"
+
+ id = Int(primary=True)
+
+ handler = DBEnum(enum=VulnerabilityHandlerEnum, allow_none=False)
+
+ job_type = DBEnum(enum=VulnerabilityJobType, allow_none=False)
+
+ job_id = Int(name="job")
+ job = Reference(job_id, Job.id)
+
+ metadata = JSON("json_data", allow_none=False)
+
+ def __init__(self, handler, job_type, metadata):
+ super().__init__()
+ self.job = Job()
+ self.handler = handler
+ self.job_type = job_type
+ self.metadata = metadata
+
+ def makeDerived(self):
+ return VulnerabilityJobDerived.makeSubclass(self)
+
+
+@delegate_to(IVulnerabilityJob)
+class VulnerabilityJobDerived(BaseRunnableJob, metaclass=EnumeratedSubclass):
+ """Abstract class for deriving from VulnerabilityJob."""
+
+ def __init__(self, job):
+ self.context = job
+
+ @classmethod
+ def get(cls, job_id):
+ """Get a job by id.
+
+ :return: the VulnerabilityJob with the specified id, as
+ the current VulnerabilityJobDerived subclass.
+ :raises: NotFoundError if there is no job with the specified id,
+ or its job_type does not match the desired subclass.
+ """
+ job = VulnerabilityJob.get(job_id)
+ if job.job_type != cls.class_job_type:
+ raise NotFoundError(
+ "No object found with id %d and type %s"
+ % (job_id, cls.class_job_type.title)
+ )
+ return cls(job)
+
+ @classmethod
+ def iterReady(cls):
+ """Iterate through all ready VulnerabilityJob."""
+ jobs = IStore(VulnerabilityJob).find(
+ VulnerabilityJob,
+ And(
+ VulnerabilityJob.job_type == cls.class_job_type,
+ VulnerabilityJob.job == Job.id,
+ Job.id.is_in(Job.ready_jobs),
+ ),
+ )
+ return (cls(job) for job in jobs)
+
+ def getOopsVars(self):
+ """See `IRunnableJob`."""
+ vars = super().getOopsVars()
+ vars.extend(
+ [
+ ("vulnerabilityjob_job_id", self.context.id),
+ ("vulnerability_job_type", self.context.job_type.title),
+ ("handler", self.context.handler),
+ ]
+ )
+ return vars
diff --git a/lib/lp/bugs/scripts/soss/sossimport.py b/lib/lp/bugs/scripts/soss/sossimport.py
index 1c9ad0a..c96ec23 100644
--- a/lib/lp/bugs/scripts/soss/sossimport.py
+++ b/lib/lp/bugs/scripts/soss/sossimport.py
@@ -85,13 +85,17 @@ class SOSSImporter:
self.information_type = information_type
self.dry_run = dry_run
self.bug_importer = getUtility(ILaunchpadCelebrities).bug_importer
- self.soss = getUtility(IDistributionSet).getByName(DISTRIBUTION_NAME)
self.person_set = getUtility(IPersonSet)
self.source_package_name_set = getUtility(ISourcePackageNameSet)
self.bugtask_set = getUtility(IBugTaskSet)
self.vulnerability_set = getUtility(IVulnerabilitySet)
self.bug_set = getUtility(IBugSet)
self.cve_set = getUtility(ICveSet)
+ self.soss = getUtility(IDistributionSet).getByName(DISTRIBUTION_NAME)
+
+ if self.soss is None:
+ logger.error("[SOSSImporter] SOSS distribution not found")
+ raise Exception("SOSS distribution not found")
def import_cve_from_file(
self, cve_path: str
diff --git a/lib/lp/bugs/tests/test_importvulnerabilityjob.py b/lib/lp/bugs/tests/test_importvulnerabilityjob.py
new file mode 100644
index 0000000..80aec80
--- /dev/null
+++ b/lib/lp/bugs/tests/test_importvulnerabilityjob.py
@@ -0,0 +1,595 @@
+# Copyright 2025 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+from pathlib import Path
+
+import transaction
+from zope.component import getUtility
+from zope.security.proxy import removeSecurityProxy
+
+from lp.app.enums import InformationType
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
+from lp.bugs.enums import VulnerabilityHandlerEnum
+from lp.bugs.interfaces.vulnerabilityjob import (
+ IImportVulnerabilityJob,
+ IImportVulnerabilityJobSource,
+ VulnerabilityJobException,
+ VulnerabilityJobInProgress,
+)
+from lp.bugs.model.importvulnerabilityjob import ImportVulnerabilityJob
+from lp.code.tests.helpers import GitHostingFixture
+from lp.services.features.testing import FeatureFixture
+from lp.services.job.interfaces.job import JobStatus
+from lp.services.job.tests import block_on_job
+from lp.testing import TestCaseWithFactory, person_logged_in
+from lp.testing.layers import CeleryJobLayer, DatabaseFunctionalLayer
+
+
+class ImportVulnerabilityJobTests(TestCaseWithFactory):
+ """Test case for ImportVulnerabilityJob."""
+
+ layer = DatabaseFunctionalLayer
+
+ def setUp(self):
+ super().setUp()
+ self.repository = self.factory.makeGitRepository()
+ self.refs = self.factory.makeGitRefs(
+ repository=self.repository,
+ paths=("ref/heads/main", "ref/tags/v1.0"),
+ )
+ self.cve_path = (
+ Path(__file__).parent
+ / ".."
+ / ".."
+ / "bugs"
+ / "scripts"
+ / "soss"
+ / "tests"
+ / "sampledata"
+ / "CVE-2025-1979"
+ )
+
+ @property
+ def job_source(self):
+ return getUtility(IImportVulnerabilityJobSource)
+
+ def test_getOopsVars(self):
+ """Test getOopsVars method."""
+ handler = VulnerabilityHandlerEnum.SOSS
+ git_repository = self.repository.git_https_url
+ git_ref = "ref/heads/main"
+ git_paths = ["cves"]
+ information_type = InformationType.PRIVATESECURITY.value
+ import_since_commit_sha1 = None
+
+ job = self.job_source.create(
+ handler,
+ git_repository,
+ git_ref,
+ git_paths,
+ information_type,
+ import_since_commit_sha1,
+ )
+ vars = job.getOopsVars()
+ naked_job = removeSecurityProxy(job)
+ self.assertIn(("vulnerabilityjob_job_id", naked_job.id), vars)
+ self.assertIn(
+ ("vulnerability_job_type", naked_job.job_type.title), vars
+ )
+ self.assertIn(("handler", naked_job.handler), vars)
+
+ def _getJobs(self):
+ """Return the pending IImportVulnerabilityJob as a list."""
+ return list(IImportVulnerabilityJob.iterReady())
+
+ def _getJobCount(self):
+ """Return the number of IImportVulnerabilityJob in the
+ queue."""
+ return len(self._getJobs())
+
+ def test___repr__(self):
+ """Test __repr__ method."""
+ handler = VulnerabilityHandlerEnum.SOSS
+ git_repository = self.repository.git_https_url
+ git_ref = "ref/heads/main"
+ git_paths = ["cves"]
+ information_type = InformationType.PRIVATESECURITY.value
+ import_since_commit_sha1 = None
+ metadata = {
+ "git_repository": git_repository,
+ "git_ref": git_ref,
+ "git_paths": git_paths,
+ "information_type": information_type,
+ "import_since_commit_sha1": import_since_commit_sha1,
+ }
+
+ job = self.job_source.create(
+ handler,
+ git_repository,
+ git_ref,
+ git_paths,
+ information_type,
+ import_since_commit_sha1,
+ )
+
+ expected = (
+ "<ImportVulnerabilityJob for "
+ f"handler: {handler}, "
+ f"metadata: {metadata}"
+ ">"
+ )
+ self.assertEqual(expected, repr(job))
+
+ def test_create_with_existing_in_progress_job(self):
+ """If there's already a waiting/running ImportVulnerabilityJob for the
+ handler ImportVulnerabilityJob.create() raises an exception.
+ """
+ handler = VulnerabilityHandlerEnum.SOSS
+ git_repository = self.repository.git_https_url
+ git_ref = "ref/heads/main"
+ git_paths = ["cves"]
+ information_type = InformationType.PRIVATESECURITY.value
+ import_since_commit_sha1 = None
+
+ # Job waiting status
+ job = self.job_source.create(
+ handler,
+ git_repository,
+ git_ref,
+ git_paths,
+ information_type,
+ import_since_commit_sha1,
+ )
+ waiting_exception = self.assertRaises(
+ VulnerabilityJobInProgress,
+ self.job_source.create,
+ handler,
+ git_repository,
+ git_ref,
+ git_paths,
+ information_type,
+ import_since_commit_sha1,
+ )
+ self.assertEqual(job, waiting_exception.job)
+
+ # Job status from WAITING to RUNNING
+ job.start()
+ running_exception = self.assertRaises(
+ VulnerabilityJobInProgress,
+ self.job_source.create,
+ handler,
+ git_repository,
+ git_ref,
+ git_paths,
+ information_type,
+ import_since_commit_sha1,
+ )
+ self.assertEqual(job, running_exception.job)
+
+ def test_create_with_existing_completed_job(self):
+ """If there's already a completed ImportVulnerabilityJob for the
+ handler the job can be runned again.
+ """
+ handler = VulnerabilityHandlerEnum.SOSS
+ git_repository = self.repository.git_https_url
+ git_ref = "ref/heads/main"
+ git_paths = ["cves"]
+ information_type = InformationType.PRIVATESECURITY.value
+ import_since_commit_sha1 = None
+
+ job = self.job_source.create(
+ handler,
+ git_repository,
+ git_ref,
+ git_paths,
+ information_type,
+ import_since_commit_sha1,
+ )
+ job.start()
+ job.complete()
+ self.assertEqual(job.status, JobStatus.COMPLETED)
+
+ job_duplicated = self.job_source.create(
+ handler,
+ git_repository,
+ git_ref,
+ git_paths,
+ information_type,
+ import_since_commit_sha1,
+ )
+ job_duplicated.start()
+ job_duplicated.complete()
+ self.assertEqual(job_duplicated.status, JobStatus.COMPLETED)
+
+ def test_create_with_existing_failed_job(self):
+ """If there's a failed ImportVulnerabilityJob for the handler the job
+ can be runned again.
+ """
+ handler = VulnerabilityHandlerEnum.SOSS
+ git_repository = self.repository.git_https_url
+ git_ref = "ref/heads/main"
+ git_paths = ["cves"]
+ information_type = InformationType.PRIVATESECURITY.value
+ import_since_commit_sha1 = None
+
+ job = self.job_source.create(
+ handler,
+ git_repository,
+ git_ref,
+ git_paths,
+ information_type,
+ import_since_commit_sha1,
+ )
+ job.start()
+ job.fail()
+ self.assertEqual(job.status, JobStatus.FAILED)
+
+ job_duplicated = self.job_source.create(
+ handler,
+ git_repository,
+ git_ref,
+ git_paths,
+ information_type,
+ import_since_commit_sha1,
+ )
+ job_duplicated.start()
+ job_duplicated.complete()
+ self.assertEqual(job_duplicated.status, JobStatus.COMPLETED)
+
+ def test_arguments(self):
+ """Test that ImportVulnerabilityJob specified with arguments can
+ be gotten out again."""
+ git_repository = self.factory.makeGitRepository()
+ self.useFixture(GitHostingFixture(blob=b"Some text"))
+
+ handler = VulnerabilityHandlerEnum.SOSS
+ git_repository = self.repository.git_https_url
+ git_ref = "ref/heads/main"
+ git_paths = ["cves"]
+ information_type = InformationType.PRIVATESECURITY.value
+ import_since_commit_sha1 = None
+ metadata = {
+ "git_repository": git_repository,
+ "git_ref": git_ref,
+ "git_paths": git_paths,
+ "information_type": information_type,
+ "import_since_commit_sha1": import_since_commit_sha1,
+ }
+
+ job = self.job_source.create(
+ handler,
+ git_repository,
+ git_ref,
+ git_paths,
+ information_type,
+ import_since_commit_sha1,
+ )
+
+ naked_job = removeSecurityProxy(job)
+ self.assertEqual(naked_job.handler, handler)
+ self.assertEqual(naked_job.git_repository, git_repository)
+ self.assertEqual(naked_job.git_ref, git_ref)
+ self.assertEqual(naked_job.git_paths, git_paths)
+ self.assertEqual(naked_job.information_type, information_type)
+ self.assertEqual(
+ naked_job.import_since_commit_sha1, import_since_commit_sha1
+ )
+ self.assertEqual(naked_job.metadata, metadata)
+
+ def test_run_import(self):
+ """Run ImportVulnerabilityJob."""
+ with open(self.cve_path, encoding="utf-8") as file:
+ self.useFixture(
+ GitHostingFixture(
+ blob=file.read(),
+ refs=self.refs,
+ diff_stats={"added": ["cves/CVE-2025-1979"]},
+ )
+ )
+
+ cve = self.factory.makeCVE("2025-1979")
+ self.factory.makeDistribution(name="soss")
+
+ job = self.job_source.create(
+ handler=VulnerabilityHandlerEnum.SOSS,
+ git_repository=self.repository.git_https_url,
+ git_ref="ref/tags/v1.0",
+ git_paths=["cves"],
+ information_type=InformationType.PRIVATESECURITY.value,
+ import_since_commit_sha1=None,
+ )
+ job.run()
+
+ # Check that it created the bug and vulnerability
+ self.assertEqual(len(cve.bugs), 1)
+
+ admin = getUtility(ILaunchpadCelebrities).admin
+ with person_logged_in(admin):
+ self.assertEqual(len(list(cve.vulnerabilities)), 1)
+
+ self.assertEqual(
+ job.metadata["result"],
+ {"succeeded": ["CVE-2025-1979"], "failed": []},
+ )
+
+ def test_run_import_with_wrong_git_paths(self):
+ """Run ImportVulnerabilityJob with wrong git_paths."""
+ with open(self.cve_path, encoding="utf-8") as file:
+ self.useFixture(
+ GitHostingFixture(
+ blob=file.read(),
+ refs=self.refs,
+ diff_stats={"added": ["cves/CVE-2025-1979"]},
+ )
+ )
+
+ cve = self.factory.makeCVE("2025-1979")
+ self.factory.makeDistribution(name="soss")
+
+ job = self.job_source.create(
+ handler=VulnerabilityHandlerEnum.SOSS,
+ git_repository=self.repository.git_https_url,
+ git_ref="ref/tags/v1.0",
+ git_paths=["wrong_path"],
+ information_type=InformationType.PRIVATESECURITY.value,
+ import_since_commit_sha1=None,
+ )
+ job.run()
+
+ # Check that it did not create the bug and vulnerability
+ self.assertEqual(len(cve.bugs), 0)
+
+ admin = getUtility(ILaunchpadCelebrities).admin
+ with person_logged_in(admin):
+ self.assertEqual(len(list(cve.vulnerabilities)), 0)
+
+ self.assertEqual(
+ job.metadata["result"],
+ {"succeeded": [], "failed": []},
+ )
+
+ def test_run_import_with_wrong_git_repository(self):
+ """Run ImportVulnerabilityJob with wrong git_repository."""
+ self.factory.makeCVE("2025-1979")
+ self.factory.makeDistribution(name="soss")
+
+ job = self.job_source.create(
+ handler=VulnerabilityHandlerEnum.SOSS,
+ git_repository="wrong_url",
+ git_ref="ref/heads/main",
+ git_paths=["cves"],
+ information_type=InformationType.PRIVATESECURITY.value,
+ import_since_commit_sha1=None,
+ )
+
+ self.assertRaises(VulnerabilityJobException, job.run)
+
+ self.assertEqual(
+ job.metadata["result"],
+ {"succeeded": [], "failed": []},
+ )
+
+ def test_run_import_with_wrong_git_ref(self):
+ """Run ImportVulnerabilityJob with wrong git_ref."""
+ with open(self.cve_path, encoding="utf-8") as file:
+ self.useFixture(
+ GitHostingFixture(
+ blob=file.read(),
+ refs=self.refs,
+ diff_stats={"added": ["cves/CVE-2025-1979"]},
+ )
+ )
+
+ self.factory.makeCVE("2025-1979")
+ self.factory.makeDistribution(name="soss")
+
+ job = self.job_source.create(
+ handler=VulnerabilityHandlerEnum.SOSS,
+ git_repository=self.repository.git_https_url,
+ git_ref="ref/heads/wrong-ref",
+ git_paths=["cves"],
+ information_type=InformationType.PRIVATESECURITY.value,
+ import_since_commit_sha1=None,
+ )
+
+ self.assertRaises(VulnerabilityJobException, job.run)
+
+ self.assertEqual(
+ job.metadata["result"],
+ {"succeeded": [], "failed": []},
+ )
+
+ def test_run_import_with_wrong_blob(self):
+ """Run ImportVulnerabilityJob with a blob that is not a cve record.
+ This will not raise an exception, it will only not be imported.
+ """
+ self.useFixture(
+ GitHostingFixture(
+ blob=b"Bad blob",
+ refs=self.refs,
+ diff_stats={"added": ["cves/CVE-2025-1979"]},
+ )
+ )
+
+ cve = self.factory.makeCVE("2025-1979")
+ self.factory.makeDistribution(name="soss")
+
+ job = self.job_source.create(
+ handler=VulnerabilityHandlerEnum.SOSS,
+ git_repository=self.repository.git_https_url,
+ git_ref="ref/tags/v1.0",
+ git_paths=["cves"],
+ information_type=InformationType.PRIVATESECURITY.value,
+ import_since_commit_sha1=None,
+ )
+ job.run()
+
+ # Check that it did not create the bug and vulnerability
+ self.assertEqual(len(cve.bugs), 0)
+
+ admin = getUtility(ILaunchpadCelebrities).admin
+ with person_logged_in(admin):
+ self.assertEqual(len(list(cve.vulnerabilities)), 0)
+
+ self.assertEqual(
+ job.metadata["result"],
+ {"succeeded": [], "failed": []},
+ )
+
+ def test_run_import_with_import_since_commit_sha1(self):
+ """Run ImportVulnerabilityJob using import_since_commit_sha1"""
+ with open(self.cve_path, encoding="utf-8") as file:
+ self.useFixture(
+ GitHostingFixture(
+ blob=file.read(),
+ refs=self.refs,
+ diff_stats={"added": ["cves/CVE-2025-1979"]},
+ )
+ )
+
+ cve = self.factory.makeCVE("2025-1979")
+ self.factory.makeDistribution(name="soss")
+
+ job = self.job_source.create(
+ handler=VulnerabilityHandlerEnum.SOSS,
+ git_repository=self.repository.git_https_url,
+ git_ref="ref/tags/v1.0",
+ git_paths=["cves"],
+ information_type=InformationType.PRIVATESECURITY.value,
+ import_since_commit_sha1="1" * 40,
+ )
+ job.run()
+
+ # Check that it created the bug and vulnerability
+ self.assertEqual(len(cve.bugs), 1)
+
+ admin = getUtility(ILaunchpadCelebrities).admin
+ with person_logged_in(admin):
+ self.assertEqual(len(list(cve.vulnerabilities)), 1)
+
+ self.assertEqual(
+ job.metadata["result"],
+ {"succeeded": ["CVE-2025-1979"], "failed": []},
+ )
+
+ def test_get(self):
+ """ImportVulnerabilityJob.get() returns the import job for the given
+ handler.
+ """
+ handler = VulnerabilityHandlerEnum.SOSS
+ git_repository = self.repository.git_https_url
+ git_ref = "ref/heads/main"
+ git_paths = ["cves"]
+ information_type = InformationType.PRIVATESECURITY.value
+ import_since_commit_sha1 = None
+
+ # There is no job before creating it
+ self.assertIs(None, self.job_source.get(handler))
+
+ job = self.job_source.create(
+ handler,
+ git_repository,
+ git_ref,
+ git_paths,
+ information_type,
+ import_since_commit_sha1,
+ )
+ job_gotten = self.job_source.get(handler)
+
+ self.assertIsInstance(job, ImportVulnerabilityJob)
+ self.assertEqual(job, job_gotten)
+
+ def test_error_description_when_no_error(self):
+ """The ImportVulnerabilityJob.error_description property returns
+ None when no error description is recorded."""
+ handler = VulnerabilityHandlerEnum.SOSS
+ git_repository = self.repository.git_https_url
+ git_ref = "ref/heads/main"
+ git_paths = ["cves"]
+ information_type = InformationType.PRIVATESECURITY.value
+ import_since_commit_sha1 = None
+
+ job = self.job_source.create(
+ handler,
+ git_repository,
+ git_ref,
+ git_paths,
+ information_type,
+ import_since_commit_sha1,
+ )
+ self.assertIs(None, removeSecurityProxy(job).error_description)
+
+ def test_error_description_set_when_notifying_about_user_errors(self):
+ """Test that error_description is set by notifyUserError()."""
+ handler = VulnerabilityHandlerEnum.SOSS
+ git_repository = self.repository.git_https_url
+ git_ref = "ref/heads/main"
+ git_paths = ["cves"]
+ information_type = InformationType.PRIVATESECURITY.value
+ import_since_commit_sha1 = None
+
+ job = self.job_source.create(
+ handler,
+ git_repository,
+ git_ref,
+ git_paths,
+ information_type,
+ import_since_commit_sha1,
+ )
+ message = "This is an example message."
+ job.notifyUserError(VulnerabilityJobException(message))
+ self.assertEqual([message], removeSecurityProxy(job).error_description)
+
+
+class TestViaCelery(TestCaseWithFactory):
+ layer = CeleryJobLayer
+
+ def setUp(self):
+ super().setUp()
+ self.repository = self.factory.makeGitRepository()
+ self.refs = self.factory.makeGitRefs(
+ repository=self.repository,
+ paths=("ref/heads/main", "ref/tags/v1.0"),
+ )
+
+ def test_job(self):
+ """Job runs via Celery."""
+ self.factory.makeCVE("2025-1979")
+ transaction.commit()
+
+ fixture = FeatureFixture(
+ {
+ "jobs.celery.enabled_classes": "ImportVulnerabilityJob",
+ }
+ )
+ self.useFixture(fixture)
+ job_source = getUtility(IImportVulnerabilityJobSource)
+
+ handler = VulnerabilityHandlerEnum.SOSS
+ git_repository = self.repository.git_https_url
+ git_ref = "ref/heads/main"
+ git_paths = ["cves"]
+ information_type = InformationType.PRIVATESECURITY.value
+ import_since_commit_sha1 = None
+ metadata = {
+ "git_repository": git_repository,
+ "git_ref": git_ref,
+ "git_paths": git_paths,
+ "information_type": information_type,
+ "import_since_commit_sha1": import_since_commit_sha1,
+ }
+
+ with block_on_job():
+ job_source.create(
+ handler,
+ git_repository,
+ git_ref,
+ git_paths,
+ information_type,
+ import_since_commit_sha1,
+ )
+ transaction.commit()
+
+ job = job_source.get(handler)
+ self.assertEqual(job.handler, handler)
+ self.assertEqual(job.metadata, metadata)
diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
index 0d5f38b..5dbcf5a 100644
--- a/lib/lp/services/config/schema-lazr.conf
+++ b/lib/lp/services/config/schema-lazr.conf
@@ -2250,6 +2250,10 @@ crontab_group: MAIN
[IUpdatePreviewDiffJobSource]
link: IBranchMergeProposalJobSource
+[IImportVulnerabilityJobSource]
+module: lp.bugs.interfaces.vulnerabilityjob
+dbuser: launchpad_main
+
[IWebhookDeliveryJobSource]
module: lp.services.webhooks.interfaces
dbuser: webhookrunner
Follow ups