diff --git a/vulnerabilities/importers/__init__.py b/vulnerabilities/importers/__init__.py index 594021092..8b63bfa08 100644 --- a/vulnerabilities/importers/__init__.py +++ b/vulnerabilities/importers/__init__.py @@ -47,6 +47,7 @@ from vulnerabilities.pipelines.v2_importers import apache_kafka_importer as apache_kafka_importer_v2 from vulnerabilities.pipelines.v2_importers import apache_tomcat_importer as apache_tomcat_v2 from vulnerabilities.pipelines.v2_importers import archlinux_importer as archlinux_importer_v2 +from vulnerabilities.pipelines.v2_importers import collabora_importer as collabora_importer_v2 from vulnerabilities.pipelines.v2_importers import collect_fix_commits as collect_fix_commits_v2 from vulnerabilities.pipelines.v2_importers import curl_importer as curl_importer_v2 from vulnerabilities.pipelines.v2_importers import debian_importer as debian_importer_v2 @@ -118,6 +119,7 @@ retiredotnet_importer_v2.RetireDotnetImporterPipeline, ubuntu_osv_importer_v2.UbuntuOSVImporterPipeline, alpine_linux_importer_v2.AlpineLinuxImporterPipeline, + collabora_importer_v2.CollaboraImporterPipeline, nvd_importer.NVDImporterPipeline, github_importer.GitHubAPIImporterPipeline, gitlab_importer.GitLabImporterPipeline, diff --git a/vulnerabilities/pipelines/v2_importers/collabora_importer.py b/vulnerabilities/pipelines/v2_importers/collabora_importer.py new file mode 100644 index 000000000..c4501ed24 --- /dev/null +++ b/vulnerabilities/pipelines/v2_importers/collabora_importer.py @@ -0,0 +1,118 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# VulnerableCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/aboutcode-org/vulnerablecode for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# + +import json +import logging +from typing import Iterable + +import dateparser +import requests + +from vulnerabilities.importer import AdvisoryDataV2 +from vulnerabilities.importer import ReferenceV2 +from vulnerabilities.importer import VulnerabilitySeverity +from vulnerabilities.pipelines import VulnerableCodeBaseImporterPipelineV2 +from vulnerabilities.severity_systems import SCORING_SYSTEMS + +logger = logging.getLogger(__name__) + +COLLABORA_URL = "https://api.github.com/repos/CollaboraOnline/online/security-advisories" + + +class CollaboraImporterPipeline(VulnerableCodeBaseImporterPipelineV2): + """Collect Collabora Online security advisories from the GitHub Security Advisory API.""" + + pipeline_id = "collabora_importer" + spdx_license_expression = "LicenseRef-scancode-proprietary-license" + license_url = "https://github.com/CollaboraOnline/online/security/advisories" + precedence = 200 + + @classmethod + def steps(cls): + return (cls.collect_and_store_advisories,) + + def advisories_count(self) -> int: + return 0 + + def collect_advisories(self) -> Iterable[AdvisoryDataV2]: + url = COLLABORA_URL + params = {"state": "published", "per_page": 100} + while url: + try: + resp = requests.get(url, params=params, timeout=30) + resp.raise_for_status() + except Exception as e: + logger.error("Failed to fetch Collabora advisories from %s: %s", url, e) + break + for item in resp.json(): + advisory = parse_advisory(item) + if advisory: + yield advisory + # cursor is already embedded in the next URL + url = resp.links.get("next", {}).get("url") + params = None + + +def parse_advisory(data: dict): + """Parse a GitHub security advisory object; return None if the GHSA ID is missing.""" + ghsa_id = data.get("ghsa_id") or "" + if not ghsa_id: + return None + + cve_id = data.get("cve_id") or "" + aliases = [cve_id] if cve_id else [] + + summary = data.get("summary") or "" + html_url = data.get("html_url") or "" + references = [ReferenceV2(url=html_url)] if html_url else [] + + date_published = None + published_at = data.get("published_at") or "" + if published_at: + date_published = dateparser.parse(published_at) + if date_published is None: + logger.warning("Could not parse date %r for %s", published_at, ghsa_id) + + severities = [] + cvss_v3 = (data.get("cvss_severities") or {}).get("cvss_v3") or {} + cvss_vector = cvss_v3.get("vector_string") or "" + cvss_score = cvss_v3.get("score") + if cvss_vector and cvss_score: + system = ( + SCORING_SYSTEMS["cvssv3.1"] + if cvss_vector.startswith("CVSS:3.1/") + else SCORING_SYSTEMS["cvssv3"] + ) + severities.append( + VulnerabilitySeverity( + system=system, + value=str(cvss_score), + scoring_elements=cvss_vector, + ) + ) + + weaknesses = [] + for cwe_str in data.get("cwe_ids") or []: + # cwe_ids entries are like "CWE-79"; extract the integer part + suffix = cwe_str[4:] if cwe_str.upper().startswith("CWE-") else "" + if suffix.isdigit(): + weaknesses.append(int(suffix)) + + return AdvisoryDataV2( + advisory_id=ghsa_id, + aliases=aliases, + summary=summary, + affected_packages=[], + references=references, + date_published=date_published, + severities=severities, + weaknesses=weaknesses, + url=html_url, + original_advisory_text=json.dumps(data, indent=2, ensure_ascii=False), + ) diff --git a/vulnerabilities/tests/test_collabora_importer.py b/vulnerabilities/tests/test_collabora_importer.py new file mode 100644 index 000000000..c9d3f4883 --- /dev/null +++ b/vulnerabilities/tests/test_collabora_importer.py @@ -0,0 +1,153 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# VulnerableCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/aboutcode-org/vulnerablecode for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# + +import json +import os +from unittest import TestCase +from unittest.mock import MagicMock +from unittest.mock import patch + +from vulnerabilities.pipelines.v2_importers.collabora_importer import CollaboraImporterPipeline +from vulnerabilities.pipelines.v2_importers.collabora_importer import parse_advisory + +TEST_DATA = os.path.join(os.path.dirname(__file__), "test_data", "collabora") + + +def load_json(filename): + with open(os.path.join(TEST_DATA, filename), encoding="utf-8") as f: + return json.load(f) + + +class TestCollaboraImporter(TestCase): + def test_parse_advisory_with_cvss31(self): + # mock1: GHSA-68v6-r6qq-mmq2, CVSS 3.1 score 5.3, no CWEs + data = load_json("collabora_mock1.json") + advisory = parse_advisory(data) + self.assertIsNotNone(advisory) + self.assertEqual(advisory.advisory_id, "GHSA-68v6-r6qq-mmq2") + self.assertIn("CVE-2026-23623", advisory.aliases) + self.assertEqual(len(advisory.severities), 1) + self.assertEqual(advisory.severities[0].value, "5.3") + self.assertIn("CVSS:3.1/", advisory.severities[0].scoring_elements) + self.assertEqual(advisory.weaknesses, []) + self.assertEqual(len(advisory.references), 1) + self.assertIsNotNone(advisory.date_published) + + def test_parse_advisory_with_cvss30_and_cwe(self): + # mock2: GHSA-7582-pwfh-3pwr, CVSS 3.0 score 9.0, CWE-79 + data = load_json("collabora_mock2.json") + advisory = parse_advisory(data) + self.assertIsNotNone(advisory) + self.assertEqual(advisory.advisory_id, "GHSA-7582-pwfh-3pwr") + self.assertIn("CVE-2023-34088", advisory.aliases) + self.assertEqual(len(advisory.severities), 1) + self.assertEqual(advisory.severities[0].value, "9.0") + self.assertIn("CVSS:3.0/", advisory.severities[0].scoring_elements) + self.assertEqual(advisory.weaknesses, [79]) + + def test_parse_advisory_missing_ghsa_id_returns_none(self): + advisory = parse_advisory({"cve_id": "CVE-2024-0001", "summary": "test"}) + self.assertIsNone(advisory) + + def test_parse_advisory_no_cve_id_has_empty_aliases(self): + data = load_json("collabora_mock1.json") + data = dict(data) + data["cve_id"] = None + advisory = parse_advisory(data) + self.assertIsNotNone(advisory) + self.assertEqual(advisory.aliases, []) + + def test_parse_advisory_no_cvss_has_empty_severities(self): + data = load_json("collabora_mock1.json") + data = dict(data) + data["cvss_severities"] = { + "cvss_v3": {"vector_string": None, "score": None}, + "cvss_v4": None, + } + advisory = parse_advisory(data) + self.assertIsNotNone(advisory) + self.assertEqual(advisory.severities, []) + + def test_parse_advisory_multiple_cwes(self): + data = load_json("collabora_mock1.json") + data = dict(data) + data["cwe_ids"] = ["CWE-79", "CWE-89", "CWE-200"] + advisory = parse_advisory(data) + self.assertIsNotNone(advisory) + self.assertEqual(advisory.weaknesses, [79, 89, 200]) + + def test_parse_advisory_malformed_cwe_skipped(self): + data = load_json("collabora_mock1.json") + data = dict(data) + data["cwe_ids"] = ["CWE-abc", "INVALID", "CWE-79", ""] + advisory = parse_advisory(data) + self.assertIsNotNone(advisory) + self.assertEqual(advisory.weaknesses, [79]) + + def test_parse_advisory_no_html_url_empty_references(self): + data = load_json("collabora_mock1.json") + data = dict(data) + data["html_url"] = None + advisory = parse_advisory(data) + self.assertIsNotNone(advisory) + self.assertEqual(advisory.references, []) + self.assertEqual(advisory.url, "") + + def test_parse_advisory_summary_stored(self): + data = load_json("collabora_mock1.json") + advisory = parse_advisory(data) + self.assertIsNotNone(advisory) + self.assertIsInstance(advisory.summary, str) + self.assertEqual(advisory.summary, data["summary"]) + + def test_parse_advisory_original_text_is_json(self): + data = load_json("collabora_mock1.json") + advisory = parse_advisory(data) + self.assertIsNotNone(advisory) + parsed = json.loads(advisory.original_advisory_text) + self.assertEqual(parsed["ghsa_id"], data["ghsa_id"]) + + +class TestCollaboraImporterPipeline(TestCase): + def _mock_response(self, data, next_url=None): + resp = MagicMock() + resp.json.return_value = data + resp.raise_for_status.return_value = None + resp.links = {"next": {"url": next_url}} if next_url else {} + return resp + + @patch("vulnerabilities.pipelines.v2_importers.collabora_importer.requests.get") + def test_collect_advisories_single_page(self, mock_get): + data = load_json("collabora_mock1.json") + mock_get.return_value = self._mock_response([data]) + advisories = list(CollaboraImporterPipeline().collect_advisories()) + self.assertEqual(len(advisories), 1) + self.assertEqual(advisories[0].advisory_id, data["ghsa_id"]) + + @patch("vulnerabilities.pipelines.v2_importers.collabora_importer.requests.get") + def test_collect_advisories_pagination(self, mock_get): + data1 = load_json("collabora_mock1.json") + data2 = load_json("collabora_mock2.json") + mock_get.side_effect = [ + self._mock_response([data1], next_url="https://api.github.com/page2"), + self._mock_response([data2]), + ] + advisories = list(CollaboraImporterPipeline().collect_advisories()) + self.assertEqual(len(advisories), 2) + self.assertEqual(advisories[0].advisory_id, data1["ghsa_id"]) + self.assertEqual(advisories[1].advisory_id, data2["ghsa_id"]) + + @patch("vulnerabilities.pipelines.v2_importers.collabora_importer.requests.get") + def test_collect_advisories_http_error_logs_and_stops(self, mock_get): + mock_get.side_effect = Exception("connection refused") + logger_name = "vulnerabilities.pipelines.v2_importers.collabora_importer" + with self.assertLogs(logger_name, level="ERROR") as cm: + advisories = list(CollaboraImporterPipeline().collect_advisories()) + self.assertEqual(advisories, []) + self.assertTrue(any("connection refused" in msg for msg in cm.output)) diff --git a/vulnerabilities/tests/test_data/collabora/collabora_mock1.json b/vulnerabilities/tests/test_data/collabora/collabora_mock1.json new file mode 100644 index 000000000..082d07554 --- /dev/null +++ b/vulnerabilities/tests/test_data/collabora/collabora_mock1.json @@ -0,0 +1,123 @@ +{ + "ghsa_id": "GHSA-68v6-r6qq-mmq2", + "cve_id": "CVE-2026-23623", + "url": "https://api.github.com/repos/CollaboraOnline/online/security-advisories/GHSA-68v6-r6qq-mmq2", + "html_url": "https://github.com/CollaboraOnline/online/security/advisories/GHSA-68v6-r6qq-mmq2", + "summary": "CVE-2026-23623 Authorization Bypass: ability to download read-only files in Collabora Online", + "description": "### Summary\r\n\r\nA user with view-only rights and no download privileges can obtain a local copy of a shared file. Although there are no corresponding buttons in the interface, pressing Ctrl+Shift+S initiates the file download process. This allows the user to bypass the access restrictions and leads to unauthorized data retrieval.\r\n\r\n### Details\r\n\r\nNextcloud 31\r\nCollabora Online Development Edition 25.04.08.1\r\n\r\n### PoC\r\n\r\nIn the Nextcloud environment with integrated Collabora Online, UserA grants access to file A (format .xlsx) to UserB with view-only rights and an explicit prohibition on downloading.\r\n\r\nFor UserB:\r\n\r\n- there is no option to download the file in the Nextcloud web interface;\r\n- there are no “Download”, “Save as” or “Print” buttons in the Collabora Online web interface;\r\n- the file is available for viewing only, as specified in the access settings.\r\n\r\nHowever, using the Ctrl + Shift + S key combination in the Collabora Online web interface initiates the process of saving (downloading) the file. As a result, UserB receives a local copy of the original file, despite not having the appropriate access rights.\r\n\r\n### Impact\r\n\r\n- Violation of access control models.\r\n- Unauthorized distribution of confidential documents.\r\n- Risk of data leakage in corporate and regulated environments.\r\n- False sense of security for file owners who rely on “view only” mode.", + "severity": "medium", + "author": null, + "publisher": { + "login": "caolanm", + "id": 833656, + "node_id": "MDQ6VXNlcjgzMzY1Ng==", + "avatar_url": "https://avatars.githubusercontent.com/u/833656?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/caolanm", + "html_url": "https://github.com/caolanm", + "followers_url": "https://api.github.com/users/caolanm/followers", + "following_url": "https://api.github.com/users/caolanm/following{/other_user}", + "gists_url": "https://api.github.com/users/caolanm/gists{/gist_id}", + "starred_url": "https://api.github.com/users/caolanm/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/caolanm/subscriptions", + "organizations_url": "https://api.github.com/users/caolanm/orgs", + "repos_url": "https://api.github.com/users/caolanm/repos", + "events_url": "https://api.github.com/users/caolanm/events{/privacy}", + "received_events_url": "https://api.github.com/users/caolanm/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "identifiers": [ + { + "value": "GHSA-68v6-r6qq-mmq2", + "type": "GHSA" + }, + { + "value": "CVE-2026-23623", + "type": "CVE" + } + ], + "state": "published", + "created_at": null, + "updated_at": "2026-02-05T11:20:00Z", + "published_at": "2026-02-05T11:20:00Z", + "closed_at": null, + "withdrawn_at": null, + "submission": null, + "vulnerabilities": [ + { + "package": { + "ecosystem": "", + "name": "Collabora Online Development Edition" + }, + "vulnerable_version_range": "< 25.04.08.2", + "patched_versions": "25.04.08.2", + "vulnerable_functions": [ + + ] + }, + { + "package": { + "ecosystem": "", + "name": "Collabora Online" + }, + "vulnerable_version_range": "< 25.04.7.5", + "patched_versions": "25.04.7.5", + "vulnerable_functions": [ + + ] + }, + { + "package": { + "ecosystem": "", + "name": "Collabora Online" + }, + "vulnerable_version_range": "< 24.04.17.3", + "patched_versions": "24.04.17.3", + "vulnerable_functions": [ + + ] + }, + { + "package": { + "ecosystem": "", + "name": "Collabora Online" + }, + "vulnerable_version_range": "< 23.05.20.1", + "patched_versions": "23.05.20.1", + "vulnerable_functions": [ + + ] + } + ], + "cvss_severities": { + "cvss_v3": { + "vector_string": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N", + "score": 5.3 + }, + "cvss_v4": { + "vector_string": null, + "score": null + } + }, + "cwes": [ + + ], + "cwe_ids": [ + + ], + "credits": [ + + ], + "credits_detailed": [ + + ], + "collaborating_users": null, + "collaborating_teams": null, + "private_fork": null, + "cvss": { + "vector_string": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N", + "score": 5.3 + } +} diff --git a/vulnerabilities/tests/test_data/collabora/collabora_mock2.json b/vulnerabilities/tests/test_data/collabora/collabora_mock2.json new file mode 100644 index 000000000..c5adaed25 --- /dev/null +++ b/vulnerabilities/tests/test_data/collabora/collabora_mock2.json @@ -0,0 +1,115 @@ +{ + "ghsa_id": "GHSA-7582-pwfh-3pwr", + "cve_id": "CVE-2023-34088", + "url": "https://api.github.com/repos/CollaboraOnline/online/security-advisories/GHSA-7582-pwfh-3pwr", + "html_url": "https://github.com/CollaboraOnline/online/security/advisories/GHSA-7582-pwfh-3pwr", + "summary": "CVE-2023-34088 Stored Cross-Site-Scripting vulnerability in admin interface", + "description": "### Impact\r\nA stored XSS vulnerability was found in Collabora Online. An attacker could create a document with an XSS payload as a document name. Later, if an administrator opens the admin console and navigates to the history page the document name is injected as unescaped HTML and executed as a script inside the context of the admin console. The administrator JWT used for the websocket connection can be leaked through this flaw.\r\n\r\n### Patches\r\nUsers should upgrade to Collabora Online 22.05.13 or higher; Collabora Online 21.11.9.1 or higher; Collabora Online 6.4.27 or higher.\r\n\r\n### Credits\r\nThanks to René de Sain (@renniepak) for reporting this flaw.\r\n\r\n### For more information\r\nIf you have any questions or comments about this advisory:\r\n * Open an issue in [CollaboraOnline/online](https://github.com/CollaboraOnline/online/issues)\r\n", + "severity": "critical", + "author": null, + "publisher": { + "login": "caolanm", + "id": 833656, + "node_id": "MDQ6VXNlcjgzMzY1Ng==", + "avatar_url": "https://avatars.githubusercontent.com/u/833656?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/caolanm", + "html_url": "https://github.com/caolanm", + "followers_url": "https://api.github.com/users/caolanm/followers", + "following_url": "https://api.github.com/users/caolanm/following{/other_user}", + "gists_url": "https://api.github.com/users/caolanm/gists{/gist_id}", + "starred_url": "https://api.github.com/users/caolanm/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/caolanm/subscriptions", + "organizations_url": "https://api.github.com/users/caolanm/orgs", + "repos_url": "https://api.github.com/users/caolanm/repos", + "events_url": "https://api.github.com/users/caolanm/events{/privacy}", + "received_events_url": "https://api.github.com/users/caolanm/received_events", + "type": "User", + "user_view_type": "public", + "site_admin": false + }, + "identifiers": [ + { + "value": "GHSA-7582-pwfh-3pwr", + "type": "GHSA" + }, + { + "value": "CVE-2023-34088", + "type": "CVE" + } + ], + "state": "published", + "created_at": null, + "updated_at": "2023-05-31T15:43:23Z", + "published_at": "2023-05-31T15:43:23Z", + "closed_at": null, + "withdrawn_at": null, + "submission": null, + "vulnerabilities": [ + { + "package": { + "ecosystem": "", + "name": "coolwsd" + }, + "vulnerable_version_range": "< 22.05.13", + "patched_versions": "22.05.13", + "vulnerable_functions": [ + + ] + }, + { + "package": { + "ecosystem": "", + "name": "coolwsd" + }, + "vulnerable_version_range": "< 21.11.9.1", + "patched_versions": "21.11.9.1", + "vulnerable_functions": [ + + ] + }, + { + "package": { + "ecosystem": "", + "name": "loolwsd" + }, + "vulnerable_version_range": "< 6.4.27", + "patched_versions": "6.4.27", + "vulnerable_functions": [ + + ] + } + ], + "cvss_severities": { + "cvss_v3": { + "vector_string": "CVSS:3.0/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H", + "score": 9.0 + }, + "cvss_v4": { + "vector_string": null, + "score": null + } + }, + "cwes": [ + { + "cwe_id": "CWE-79", + "name": "Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')" + } + ], + "cwe_ids": [ + "CWE-79" + ], + "credits": [ + + ], + "credits_detailed": [ + + ], + "collaborating_users": null, + "collaborating_teams": null, + "private_fork": null, + "cvss": { + "vector_string": "CVSS:3.0/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H", + "score": 9.0 + } +}