Skip to content

Commit a8aa6da

Browse files
authored
Migrate mattermost importer to V2 (#2095)
* Migrate mattermost Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com> * Add tests Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com> --------- Signed-off-by: Tushar Goel <tushar.goel.dav@gmail.com>
1 parent 6db35f0 commit a8aa6da

File tree

4 files changed

+352
-1
lines changed

4 files changed

+352
-1
lines changed

vulnerabilities/importers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
from vulnerabilities.pipelines.v2_importers import github_osv_importer as github_osv_importer_v2
5353
from vulnerabilities.pipelines.v2_importers import gitlab_importer as gitlab_importer_v2
5454
from vulnerabilities.pipelines.v2_importers import istio_importer as istio_importer_v2
55+
from vulnerabilities.pipelines.v2_importers import mattermost_importer as mattermost_importer_v2
5556
from vulnerabilities.pipelines.v2_importers import mozilla_importer as mozilla_importer_v2
5657
from vulnerabilities.pipelines.v2_importers import npm_importer as npm_importer_v2
5758
from vulnerabilities.pipelines.v2_importers import nvd_importer as nvd_importer_v2
@@ -87,6 +88,7 @@
8788
aosp_importer_v2.AospImporterPipeline,
8889
ruby_importer_v2.RubyImporterPipeline,
8990
epss_importer_v2.EPSSImporterPipeline,
91+
mattermost_importer_v2.MattermostImporterPipeline,
9092
nvd_importer.NVDImporterPipeline,
9193
github_importer.GitHubAPIImporterPipeline,
9294
gitlab_importer.GitLabImporterPipeline,
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# VulnerableCode is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: Apache-2.0
5+
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
6+
# See https://github.com/aboutcode-org/vulnerablecode for support or download.
7+
# See https://aboutcode.org for more information about nexB OSS projects.
8+
#
9+
10+
from typing import Iterable
11+
12+
from packageurl import PackageURL
13+
from univers.version_range import GitHubVersionRange
14+
15+
from vulnerabilities import severity_systems
16+
from vulnerabilities.importer import AdvisoryData
17+
from vulnerabilities.importer import AffectedPackageV2
18+
from vulnerabilities.importer import ReferenceV2
19+
from vulnerabilities.importer import VulnerabilitySeverity
20+
from vulnerabilities.pipelines import VulnerableCodeBaseImporterPipelineV2
21+
from vulnerabilities.utils import fetch_response
22+
23+
MM_REPO = {
24+
"Mattermost Mobile Apps": "mattermost-mobile",
25+
"Mattermost Server": "mattermost-server",
26+
"Mattermost Desktop App": "desktop",
27+
"Mattermost Boards": "mattermost-plugin-boards",
28+
"Mattermost Plugins": "mattermost-plugin-github",
29+
}
30+
31+
32+
class MattermostImporterPipeline(VulnerableCodeBaseImporterPipelineV2):
33+
"""
34+
Importer for Xen Security Advisories from xsa.json.
35+
"""
36+
37+
pipeline_id = "mattermost_importer_v2"
38+
url = "https://securityupdates.mattermost.com/security_updates.json"
39+
spdx_license_expression = "LicenseRef-scancode-other-permissive"
40+
41+
_cached_data = None # Class-level cache
42+
43+
@classmethod
44+
def steps(cls):
45+
return (cls.collect_and_store_advisories,)
46+
47+
def get_mattermost_data(self):
48+
if self._cached_data is None:
49+
self._cached_data = fetch_response(self.url).json()
50+
return self._cached_data
51+
52+
def advisories_count(self) -> int:
53+
data = self.get_mattermost_data()
54+
return len(data) if data else 0
55+
56+
def collect_advisories(self) -> Iterable[AdvisoryData]:
57+
data = self.get_mattermost_data()
58+
if not data:
59+
return
60+
61+
for advisory in data:
62+
vuln_id = advisory.get("issue_id")
63+
if not vuln_id or not vuln_id.startswith("MMSA-"):
64+
self.log(f"Skipping advisory with missing issue_id. {vuln_id}")
65+
continue
66+
cve_id = advisory.get("cve_id")
67+
details = advisory.get("details")
68+
69+
platform = advisory.get("platform")
70+
71+
fixed_versions = advisory.get("fix_versions", [])
72+
73+
package_name = MM_REPO.get(platform)
74+
75+
affected_packages = []
76+
severity = advisory.get("severity")
77+
if not package_name:
78+
self.log(f"Unknown platform '{platform}' in advisory '{vuln_id}'.")
79+
80+
else:
81+
package = PackageURL(
82+
type="github",
83+
namespace="mattermost",
84+
name=MM_REPO.get(platform),
85+
)
86+
87+
if isinstance(fixed_versions, list):
88+
fixed_versions = [v for v in fixed_versions if v and v.strip()]
89+
fixed_versions = [v.lstrip("v") for v in fixed_versions]
90+
if isinstance(fixed_versions, str):
91+
fixed_versions = [fixed_versions.lstrip("v")]
92+
93+
fixed_versions = [v.replace("and ", "") for v in fixed_versions]
94+
fixed_versions = [v.strip() for v in fixed_versions]
95+
96+
try:
97+
affected_packages.append(
98+
AffectedPackageV2(
99+
package=package,
100+
fixed_version_range=GitHubVersionRange.from_versions(fixed_versions),
101+
)
102+
)
103+
except Exception as e:
104+
self.log(
105+
f"Error processing fixed versions '{fixed_versions}' for advisory '{vuln_id}': {e}"
106+
)
107+
108+
severities = []
109+
severities.append(
110+
VulnerabilitySeverity(system=severity_systems.CVSS31_QUALITY, value=severity)
111+
)
112+
113+
reference = ReferenceV2(
114+
url="https://mattermost.com/security-updates/",
115+
)
116+
117+
yield AdvisoryData(
118+
advisory_id=vuln_id,
119+
aliases=[cve_id],
120+
summary=details,
121+
references_v2=[reference],
122+
affected_packages=affected_packages,
123+
url=self.url,
124+
)
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
#
2+
# Copyright (c) nexB Inc. and others. All rights reserved.
3+
# VulnerableCode is a trademark of nexB Inc.
4+
# SPDX-License-Identifier: Apache-2.0
5+
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
6+
# See https://github.com/aboutcode-org/vulnerablecode for support or download.
7+
# See https://aboutcode.org for more information about nexB OSS projects.
8+
#
9+
10+
import pytest
11+
from packageurl import PackageURL
12+
from univers.version_range import GitHubVersionRange
13+
14+
from vulnerabilities.importer import AdvisoryData
15+
from vulnerabilities.pipelines.v2_importers.mattermost_importer import MattermostImporterPipeline
16+
17+
18+
@pytest.fixture
19+
def sample_mattermost_data():
20+
return [
21+
{
22+
"issue_id": "MMSA-2024-001",
23+
"cve_id": "CVE-2024-1234",
24+
"details": "Test vulnerability in Mattermost Server",
25+
"platform": "Mattermost Server",
26+
"severity": "HIGH",
27+
"fix_versions": ["v9.0.1", "v8.1.5"],
28+
}
29+
]
30+
31+
32+
@pytest.fixture
33+
def importer(monkeypatch, sample_mattermost_data):
34+
"""
35+
Create an importer with fetch_response mocked.
36+
"""
37+
38+
def mock_fetch_response(url):
39+
class MockResponse:
40+
def json(self_inner):
41+
return sample_mattermost_data
42+
43+
return MockResponse()
44+
45+
monkeypatch.setattr(
46+
"vulnerabilities.pipelines.v2_importers.mattermost_importer.fetch_response",
47+
mock_fetch_response,
48+
)
49+
50+
return MattermostImporterPipeline()
51+
52+
53+
def test_advisories_count(importer):
54+
assert importer.advisories_count() == 1
55+
56+
57+
def test_collect_advisories_happy_path(importer):
58+
advisories = list(importer.collect_advisories())
59+
60+
assert len(advisories) == 1
61+
advisory = advisories[0]
62+
63+
assert isinstance(advisory, AdvisoryData)
64+
assert advisory.advisory_id == "MMSA-2024-001"
65+
assert advisory.aliases == ["CVE-2024-1234"]
66+
assert "Test vulnerability" in advisory.summary
67+
68+
assert advisory.affected_packages
69+
affected = advisory.affected_packages[0]
70+
71+
assert affected.package == PackageURL(
72+
type="github",
73+
namespace="mattermost",
74+
name="mattermost-server",
75+
)
76+
77+
assert isinstance(affected.fixed_version_range, GitHubVersionRange)
78+
assert str(affected.fixed_version_range) == "vers:github/8.1.5|9.0.1"
79+
80+
81+
def test_skip_invalid_issue_id(monkeypatch):
82+
data = [
83+
{
84+
"issue_id": "INVALID-001",
85+
"platform": "Mattermost Server",
86+
}
87+
]
88+
89+
def mock_fetch_response(url):
90+
class MockResponse:
91+
def json(self):
92+
return data
93+
94+
return MockResponse()
95+
96+
monkeypatch.setattr(
97+
"vulnerabilities.pipelines.v2_importers.mattermost_importer.fetch_response",
98+
mock_fetch_response,
99+
)
100+
101+
importer = MattermostImporterPipeline()
102+
advisories = list(importer.collect_advisories())
103+
104+
assert advisories == []
105+
106+
107+
def test_unknown_platform(monkeypatch):
108+
data = [
109+
{
110+
"issue_id": "MMSA-2024-002",
111+
"platform": "Unknown Product",
112+
"fix_versions": ["1.0.0"],
113+
}
114+
]
115+
116+
def mock_fetch_response(url):
117+
class MockResponse:
118+
def json(self):
119+
return data
120+
121+
return MockResponse()
122+
123+
monkeypatch.setattr(
124+
"vulnerabilities.pipelines.v2_importers.mattermost_importer.fetch_response",
125+
mock_fetch_response,
126+
)
127+
128+
importer = MattermostImporterPipeline()
129+
advisories = list(importer.collect_advisories())
130+
131+
assert len(advisories) == 1
132+
assert advisories[0].affected_packages == []
133+
134+
135+
def test_fixed_version_string_normalization(monkeypatch):
136+
data = [
137+
{
138+
"issue_id": "MMSA-2024-003",
139+
"platform": "Mattermost Desktop App",
140+
"fix_versions": "v2.0.0",
141+
}
142+
]
143+
144+
def mock_fetch_response(url):
145+
class MockResponse:
146+
def json(self):
147+
return data
148+
149+
return MockResponse()
150+
151+
monkeypatch.setattr(
152+
"vulnerabilities.pipelines.v2_importers.mattermost_importer.fetch_response",
153+
mock_fetch_response,
154+
)
155+
156+
importer = MattermostImporterPipeline()
157+
advisories = list(importer.collect_advisories())
158+
159+
affected = advisories[0].affected_packages[0]
160+
assert "2.0.0" in str(affected.fixed_version_range)
161+
162+
163+
def test_bad_version_does_not_crash(monkeypatch):
164+
data = [
165+
{
166+
"issue_id": "MMSA-2024-004",
167+
"platform": "Mattermost Server",
168+
"fix_versions": ["not-a-version"],
169+
}
170+
]
171+
172+
def mock_fetch_response(url):
173+
class MockResponse:
174+
def json(self):
175+
return data
176+
177+
return MockResponse()
178+
179+
monkeypatch.setattr(
180+
"vulnerabilities.pipelines.v2_importers.mattermost_importer.fetch_response",
181+
mock_fetch_response,
182+
)
183+
184+
importer = MattermostImporterPipeline()
185+
advisories = list(importer.collect_advisories())
186+
187+
# Advisory should still be yielded, but without affected packages
188+
assert len(advisories) == 1
189+
assert advisories[0].affected_packages == []
190+
191+
192+
def test_fetch_is_cached(monkeypatch):
193+
call_count = {"count": 0}
194+
195+
def mock_fetch_response(url):
196+
call_count["count"] += 1
197+
198+
class MockResponse:
199+
def json(self):
200+
return []
201+
202+
return MockResponse()
203+
204+
monkeypatch.setattr(
205+
"vulnerabilities.pipelines.v2_importers.mattermost_importer.fetch_response",
206+
mock_fetch_response,
207+
)
208+
209+
importer = MattermostImporterPipeline()
210+
importer.advisories_count()
211+
importer.collect_advisories()
212+
213+
assert call_count["count"] == 1

vulnerabilities/views.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from django.contrib.auth.views import LoginView
1616
from django.core.exceptions import ValidationError
1717
from django.core.mail import send_mail
18+
from django.db.models import F
1819
from django.db.models import Prefetch
1920
from django.http.response import Http404
2021
from django.shortcuts import get_object_or_404
@@ -691,7 +692,18 @@ class AdvisoryPackagesDetails(DetailView):
691692
model = models.AdvisoryV2
692693
template_name = "advisory_package_details.html"
693694
slug_url_kwarg = "avid"
694-
slug_field = "avid"
695+
696+
def get_object(self, queryset=None):
697+
avid = self.kwargs.get(self.slug_url_kwarg)
698+
if not avid:
699+
raise Http404("Missing advisory identifier")
700+
701+
advisory = models.AdvisoryV2.objects.latest_for_avid(avid)
702+
703+
if not advisory:
704+
raise Http404(f"No advisory found for avid: {avid}")
705+
706+
return advisory
695707

696708
def get_queryset(self):
697709
"""

0 commit comments

Comments
 (0)