diff --git a/vulnerabilities/pipelines/v2_importers/gitlab_importer.py b/vulnerabilities/pipelines/v2_importers/gitlab_importer.py index ce9ff91cb..2fb6246c2 100644 --- a/vulnerabilities/pipelines/v2_importers/gitlab_importer.py +++ b/vulnerabilities/pipelines/v2_importers/gitlab_importer.py @@ -275,12 +275,19 @@ def parse_gitlab_advisory( parsed_fixed_versions = [] for fixed_version in fixed_versions: + # GitLab advisories sometimes use a "v" prefix (e.g., "v2.0.1") that + # certain version classes cannot parse. Strip it before attempting + # to instantiate the version object so we preserve as much data as + # possible. See https://github.com/aboutcode-org/vulnerablecode/issues/2411 + normalized = fixed_version.lstrip("v") if fixed_version else fixed_version try: - fixed_version = vrc.version_class(fixed_version) - parsed_fixed_versions.append(fixed_version.string) + parsed = vrc.version_class(normalized) + parsed_fixed_versions.append(parsed.string) except Exception as e: logger( - f"parse_yaml_file: fixed_version is not parsable`: {fixed_version!r} error: {e!r}\n {traceback.format_exc()}", + f"parse_yaml_file: fixed_version is not parsable: " + f"{fixed_version!r} (normalized: {normalized!r}) " + f"for: {purl!s} error: {e!r}\n {traceback.format_exc()}", level=logging.ERROR, ) @@ -290,7 +297,27 @@ def parse_gitlab_advisory( if parsed_fixed_versions: fixed_version_range = vrc.from_versions(parsed_fixed_versions) if not fixed_version_range and not affected_version_range: - return + # We still store the advisory without affected_packages so that + # downstream improvers can enrich it with version data later. + # Returning None here silently drops advisories whose version + # strings failed to parse. + # See https://github.com/aboutcode-org/vulnerablecode/issues/2412 + logger( + f"parse_yaml_file: no parsable version range or fixed versions " + f"for: {purl!s} in {file!r} " + f"-- storing advisory without affected_packages", + level=logging.WARNING, + ) + return AdvisoryDataV2( + advisory_id=advisory_id, + aliases=aliases, + summary=summary, + references=references, + date_published=date_published, + weaknesses=cwe_list, + url=advisory_url, + original_advisory_text=json.dumps(gitlab_advisory, indent=2, ensure_ascii=False), + ) affected_package = AffectedPackageV2( package=purl, diff --git a/vulnerabilities/tests/pipelines/v2_importers/test_gitlab_importer_v2.py b/vulnerabilities/tests/pipelines/v2_importers/test_gitlab_importer_v2.py index 070d2f694..0dc08ee22 100644 --- a/vulnerabilities/tests/pipelines/v2_importers/test_gitlab_importer_v2.py +++ b/vulnerabilities/tests/pipelines/v2_importers/test_gitlab_importer_v2.py @@ -206,3 +206,97 @@ def mock_get_purl(package_slug, purl_type_by_gitlab_scheme, logger): assert isinstance(result.date_published, datetime) assert result.date_published.year == 2018 assert result.affected_packages == [] # Because get_purl was mocked to return None + + +def test_parse_gitlab_advisory_v_prefix_fixed_version(tmp_path): + """ + Regression test for https://github.com/aboutcode-org/vulnerablecode/issues/2411 + + GitLab advisories sometimes carry fixed_versions with a leading "v" prefix + (e.g. "v2.28.2") that certain version classes reject, causing the version + to be silently discarded. The importer must strip the prefix and still + produce a valid AdvisoryDataV2 with a non-empty fixed_version_range. + + The advisory intentionally has no affected_range so that the only path to + a stored fixed_version_range is through the normalized fixed_version. + """ + content = { + "identifier": "CVE-2023-9999", + "package_slug": "pypi/requests", + "title": "Example v-prefix vulnerability", + "description": "A test advisory with a v-prefixed fixed version.", + "pubdate": "2023-01-10", + # No affected_range -- the only version data is the v-prefixed fixed version. + # Without the fix this advisory would be stored with no affected_packages. + "fixed_versions": ["v2.28.2"], + "urls": ["https://example.com/advisory"], + "cwe_ids": [], + "identifiers": ["CVE-2023-9999"], + } + advisory_path = tmp_path / "CVE-2023-9999.yml" + advisory_path.write_text(saneyaml.dump(content)) + + dummy_logger = lambda *args, **kwargs: None + + result = parse_gitlab_advisory( + file=advisory_path, + base_path=tmp_path, + gitlab_scheme_by_purl_type={"pypi": "pypi"}, + purl_type_by_gitlab_scheme={"pypi": "pypi"}, + logger=dummy_logger, + ) + + assert isinstance(result, AdvisoryDataV2) + assert result.affected_packages, "Expected at least one affected_package" + fixed_range = result.affected_packages[0].fixed_version_range + assert fixed_range is not None, "fixed_version_range must not be None" + # "2.28.2" must appear in the range string despite the input being "v2.28.2" + assert "2.28.2" in str(fixed_range), f"Unexpected fixed_version_range: {fixed_range}" + + +def test_parse_gitlab_advisory_unparsable_version_still_stored(tmp_path): + """ + Regression test for https://github.com/aboutcode-org/vulnerablecode/issues/2412 + + When neither the affected_range nor any fixed_version can be parsed, the + advisory must still be returned (without affected_packages) rather than + silently dropped. This allows downstream improvers to enrich the record + with version data later. + """ + content = { + "identifier": "CVE-2023-8888", + "package_slug": "pypi/somelib", + "title": "Advisory with unparsable version", + "description": "Version string is deliberately invalid.", + "pubdate": "2023-05-01", + # No affected_range and an unparsable fixed_version. + # Before the fix, parse_gitlab_advisory returned None here. + "fixed_versions": ["!!!invalid!!!"], + "urls": ["https://example.com/advisory2"], + "cwe_ids": [], + "identifiers": ["CVE-2023-8888"], + } + advisory_path = tmp_path / "CVE-2023-8888.yml" + advisory_path.write_text(saneyaml.dump(content)) + + dummy_logger = lambda *args, **kwargs: None + + result = parse_gitlab_advisory( + file=advisory_path, + base_path=tmp_path, + gitlab_scheme_by_purl_type={"pypi": "pypi"}, + purl_type_by_gitlab_scheme={"pypi": "pypi"}, + logger=dummy_logger, + ) + + # Must NOT be None -- advisory should be stored even when version parsing fails + assert ( + result is not None + ), "Advisory must not be silently dropped when version parsing fails (issue #2412)" + assert isinstance(result, AdvisoryDataV2) + assert result.advisory_id == "pypi/somelib/CVE-2023-8888" + assert result.summary == ( + "Advisory with unparsable version\nVersion string is deliberately invalid." + ) + # No affected_packages because no version string could be parsed + assert result.affected_packages == []