From cc58a3130b4cbe5a9696399038c3857b8479ae56 Mon Sep 17 00:00:00 2001 From: newklei Date: Tue, 10 Mar 2026 23:39:52 -0400 Subject: [PATCH 1/2] feat: add Collabora Online security advisory importer for issue #1899 Adds CollaboraImporterPipeline to collect published security advisories from the GitHub Security Advisory REST API for CollaboraOnline/online. Parses GHSA id, CVE alias, CVSS 3.x severity (version detected from vector prefix), CWE weaknesses, date, and reference URL. Pagination is handled via the Link header cursor returned by the GitHub API. Includes 5 unit tests with real API fixture data covering CVSS 3.1, CVSS 3.0 with CWE, missing GHSA id, missing CVE id, and missing CVSS. Signed-off-by: newklei --- vulnerabilities/importers/__init__.py | 2 + .../v2_importers/collabora_importer.py | 118 +++++++++++++++++ .../tests/test_collabora_importer.py | 111 ++++++++++++++++ .../test_data/collabora/collabora_mock1.json | 123 ++++++++++++++++++ .../test_data/collabora/collabora_mock2.json | 115 ++++++++++++++++ 5 files changed, 469 insertions(+) create mode 100644 vulnerabilities/pipelines/v2_importers/collabora_importer.py create mode 100644 vulnerabilities/tests/test_collabora_importer.py create mode 100644 vulnerabilities/tests/test_data/collabora/collabora_mock1.json create mode 100644 vulnerabilities/tests/test_data/collabora/collabora_mock2.json 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..20e70d3ad --- /dev/null +++ b/vulnerabilities/tests/test_collabora_importer.py @@ -0,0 +1,111 @@ +# +# 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 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"]) 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 + } +} From 77542d8e2e5d998ae935721f4178236963638d76 Mon Sep 17 00:00:00 2001 From: newklei Date: Wed, 11 Mar 2026 11:23:24 -0400 Subject: [PATCH 2/2] fix: add pipeline tests for collabora importer Signed-off-by: newklei --- .../tests/test_collabora_importer.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/vulnerabilities/tests/test_collabora_importer.py b/vulnerabilities/tests/test_collabora_importer.py index 20e70d3ad..c9d3f4883 100644 --- a/vulnerabilities/tests/test_collabora_importer.py +++ b/vulnerabilities/tests/test_collabora_importer.py @@ -10,7 +10,10 @@ 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") @@ -109,3 +112,42 @@ def test_parse_advisory_original_text_is_json(self): 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))