from __future__ import annotations from pathlib import Path from unittest.mock import patch import pytest from tests.helpers import make_alert, make_dependency from vuln_scan.core.models import Dependency, Ecosystem, SecurityVulnerability from vuln_scan.ecosystems.ruby import RubyEcosystemHandler # -------------------------------------------------------- # Fixtures # -------------------------------------------------------- @pytest.fixture def handler() -> RubyEcosystemHandler: return RubyEcosystemHandler() # -------------------------------------------------------- # File discovery # -------------------------------------------------------- def test_file_discovery_properties(handler: RubyEcosystemHandler) -> None: assert handler.manifest_names == {"Gemfile"} assert handler.lockfile_names == {"Gemfile.lock"} assert handler.manifest_globs == {"*.gemspec"} @pytest.mark.parametrize( "filename, expected", [ pytest.param("Gemfile", True, id="gemfile"), pytest.param("Gemfile.lock", True, id="gemfile-lock"), pytest.param("my_gem.gemspec", True, id="gemspec"), pytest.param("package.json", False, id="unknown-file"), ], ) def test_supports_manifest( handler: RubyEcosystemHandler, filename: str, expected: bool, ) -> None: assert handler.supports_manifest(filename) is expected @pytest.mark.parametrize( "filename, expected", [ pytest.param("Gemfile.lock", True, id="gemfile-lock"), pytest.param("Gemfile", False, id="gemfile"), pytest.param("my_gem.gemspec", False, id="gemspec"), ], ) def test_is_lockfile( handler: RubyEcosystemHandler, filename: str, expected: bool, ) -> None: assert handler.is_lockfile(filename) is expected @pytest.mark.parametrize( "filename, expected", [ pytest.param("Gemfile", True, id="gemfile"), pytest.param("my_gem.gemspec", True, id="gemspec"), pytest.param("Gemfile.lock", False, id="gemfile-lock"), ], ) def test_is_manifest( handler: RubyEcosystemHandler, filename: str, expected: bool, ) -> None: assert handler.is_manifest(filename) is expected # -------------------------------------------------------- # parse_manifest / parse_manifest_content dispatch # -------------------------------------------------------- def test_parse_manifest_reads_file_and_parses( handler: RubyEcosystemHandler, tmp_path: Path, ) -> None: manifest = tmp_path / "Gemfile" manifest.write_text( "gem 'rails', '~> 7.0'\n", encoding="utf-8", ) parsed = handler.parse_manifest("Gemfile", manifest) assert len(parsed.dependencies) == 1 assert parsed.dependencies[0].name == "rails" assert parsed.manifest_type == "manifest" assert parsed.parser_name == "ruby" @pytest.mark.parametrize( "filename, content, expected_type, expected_count, expected_name", [ pytest.param( "unknown.xyz", "x", "manifest", 0, None, id="unknown-file-returns-empty", ), pytest.param( "Gemfile", "gem 'rails', '~> 7.0'\n", "manifest", 1, "rails", id="gemfile-dispatch", ), pytest.param( "Gemfile.lock", "GEM\n remote: https://rubygems.org/\n specs:\n rails (7.0.4)\n\n", "lockfile", 1, "rails", id="gemfile-lock-dispatch", ), pytest.param( "my_gem.gemspec", "Gem::Specification.new do |s|\n s.add_dependency 'rails', '~> 7.0'\nend\n", "manifest", 1, "rails", id="gemspec-dispatch", ), ], ) def test_parse_manifest_content_dispatch( handler: RubyEcosystemHandler, filename: str, content: str, expected_type: str, expected_count: int, expected_name: str | None, ) -> None: parsed = handler.parse_manifest_content(filename, content) assert parsed.manifest_type == expected_type assert len(parsed.dependencies) == expected_count if expected_name is not None: assert parsed.dependencies[0].name == expected_name @pytest.mark.parametrize( "filename, content, expected_count", [ # ---- Gemfile ---- pytest.param( "Gemfile", "", 0, id="gemfile-empty", ), pytest.param( "Gemfile", "# just a comment\n\n", 0, id="gemfile-only-comments", ), pytest.param( "Gemfile", "source 'https://rubygems.org'\n", 0, id="gemfile-only-source-no-gems", ), # ---- Gemfile.lock ---- pytest.param( "Gemfile.lock", "", 0, id="gemfile-lock-empty", ), pytest.param( "Gemfile.lock", "PLATFORMS\n ruby\n\nBUNDLED WITH\n 2.4.0\n", 0, id="gemfile-lock-no-specs-section", ), pytest.param( "Gemfile.lock", "GEM\n remote: https://rubygems.org/\n specs:\n\n", 0, id="gemfile-lock-empty-specs", ), # ---- gemspec ---- pytest.param( "my_gem.gemspec", "Gem::Specification.new do |s|\n s.name = 'my_gem'\nend\n", 0, id="gemspec-no-dependencies", ), pytest.param( "my_gem.gemspec", "", 0, id="gemspec-empty", ), ], ) def test_parse_manifest_content_negative_cases( handler: RubyEcosystemHandler, filename: str, content: str, expected_count: int, ) -> None: parsed = handler.parse_manifest_content(filename, content) assert len(parsed.dependencies) == expected_count # -------------------------------------------------------- # Gemfile.lock # -------------------------------------------------------- def test_parse_gemfile_lock_basic(handler: RubyEcosystemHandler) -> None: content = """\ GEM remote: https://rubygems.org/ specs: rails (7.0.4) actioncable (= 7.0.4) nokogiri (1.14.0) mini_portile2 (~> 2.8.0) PLATFORMS ruby BUNDLED WITH 2.4.0 """ deps = handler._parse_gemfile_lock_content(content, "Gemfile.lock") assert len(deps) == 2 assert {d.name for d in deps} == {"rails", "nokogiri"} assert all(d.is_lockfile for d in deps) assert all(d.ecosystem == Ecosystem.RUBYGEMS for d in deps) assert deps[0].version == "7.0.4" assert deps[1].version == "1.14.0" def test_parse_gemfile_lock_skips_sub_dependencies(handler: RubyEcosystemHandler) -> None: """Sub-dependencies are indented with 6+ spaces; only 4-space gems are captured.""" content = """\ GEM remote: https://rubygems.org/ specs: nokogiri (1.14.0) mini_portile2 (~> 2.8.0) racc (~> 1.4) """ deps = handler._parse_gemfile_lock_content(content, "Gemfile.lock") assert len(deps) == 1 assert deps[0].name == "nokogiri" def test_parse_gemfile_lock_multiple_sources(handler: RubyEcosystemHandler) -> None: """Multiple GEM/specs blocks should all be parsed.""" content = """\ GEM remote: https://rubygems.org/ specs: rails (7.0.4) GEM remote: https://other.example.com/ specs: my_private_gem (1.0.0) PLATFORMS ruby """ deps = handler._parse_gemfile_lock_content(content, "Gemfile.lock") assert len(deps) == 2 assert {d.name for d in deps} == {"rails", "my_private_gem"} def test_parse_gemfile_lock_four_part_version(handler: RubyEcosystemHandler) -> None: content = """\ GEM remote: https://rubygems.org/ specs: activesupport (5.2.4.2) """ deps = handler._parse_gemfile_lock_content(content, "Gemfile.lock") assert len(deps) == 1 assert deps[0].name == "activesupport" assert deps[0].version == "5.2.4.2" def test_parse_gemfile_lock_pre_release_version(handler: RubyEcosystemHandler) -> None: content = """\ GEM remote: https://rubygems.org/ specs: axlsx (3.0.0.pre) """ deps = handler._parse_gemfile_lock_content(content, "Gemfile.lock") assert len(deps) == 1 assert deps[0].version == "3.0.0.pre" def test_parse_gemfile_lock_section_ends_on_non_indented_line( handler: RubyEcosystemHandler, ) -> None: content = """\ GEM remote: https://rubygems.org/ specs: rails (7.0.4) PLATFORMS ruby """ deps = handler._parse_gemfile_lock_content(content, "Gemfile.lock") assert len(deps) == 1 assert deps[0].name == "rails" # -------------------------------------------------------- # Gemfile # -------------------------------------------------------- def test_parse_gemfile_basic(handler: RubyEcosystemHandler) -> None: content = """\ source 'https://rubygems.org' gem 'rails', '~> 7.0' gem "nokogiri", ">= 1.13", "< 2.0" gem 'puma' """ deps = handler._parse_gemfile_content(content, "Gemfile") assert len(deps) == 3 assert all(d.ecosystem == Ecosystem.RUBYGEMS for d in deps) assert all(not d.is_lockfile for d in deps) rails = next(d for d in deps if d.name == "rails") assert rails.requirement == "~> 7.0" assert rails.version is None nokogiri = next(d for d in deps if d.name == "nokogiri") assert nokogiri.requirement == ">= 1.13, < 2.0" assert nokogiri.version is None puma = next(d for d in deps if d.name == "puma") assert puma.version is None assert puma.requirement is None def test_parse_gemfile_exact_version(handler: RubyEcosystemHandler) -> None: content = "gem 'rails', '7.0.4'\n" deps = handler._parse_gemfile_content(content, "Gemfile") assert len(deps) == 1 assert deps[0].version == "7.0.4" assert deps[0].requirement is None def test_parse_gemfile_skips_comments(handler: RubyEcosystemHandler) -> None: content = """\ # This is a comment gem 'rails', '~> 7.0' # gem 'old_gem', '1.0.0' """ deps = handler._parse_gemfile_content(content, "Gemfile") assert len(deps) == 1 assert deps[0].name == "rails" def test_parse_gemfile_skips_blank_lines(handler: RubyEcosystemHandler) -> None: content = "\n\n\ngem 'rails', '~> 7.0'\n\n\n" deps = handler._parse_gemfile_content(content, "Gemfile") assert len(deps) == 1 def test_parse_gemfile_double_quotes(handler: RubyEcosystemHandler) -> None: content = 'gem "rails", "~> 7.0"\n' deps = handler._parse_gemfile_content(content, "Gemfile") assert len(deps) == 1 assert deps[0].name == "rails" assert deps[0].requirement == "~> 7.0" def test_parse_gemfile_mixed_quotes(handler: RubyEcosystemHandler) -> None: content = """gem 'rails', "~> 7.0"\n""" deps = handler._parse_gemfile_content(content, "Gemfile") assert len(deps) == 1 assert deps[0].name == "rails" def test_parse_gemfile_no_version(handler: RubyEcosystemHandler) -> None: content = "gem 'puma'\n" deps = handler._parse_gemfile_content(content, "Gemfile") assert len(deps) == 1 assert deps[0].name == "puma" assert deps[0].version is None assert deps[0].requirement is None def test_parse_gemfile_indented_gem(handler: RubyEcosystemHandler) -> None: content = """\ group :development do gem 'pry', '~> 0.14' end """ deps = handler._parse_gemfile_content(content, "Gemfile") assert len(deps) == 1 assert deps[0].name == "pry" def test_parse_gemfile_multiple_constraints(handler: RubyEcosystemHandler) -> None: content = """gem 'nokogiri', '>= 1.13', '< 2.0'\n""" deps = handler._parse_gemfile_content(content, "Gemfile") assert len(deps) == 1 assert deps[0].requirement == ">= 1.13, < 2.0" assert deps[0].version is None # -------------------------------------------------------- # .gemspec # -------------------------------------------------------- def test_parse_gemspec_basic(handler: RubyEcosystemHandler) -> None: content = """\ lib = File.expand_path('lib', __dir__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) Gem::Specification.new do |spec| spec.name = 'qa_helpers' spec.version = '1.0.0' spec.add_dependency 'axlsx', '3.0.0.pre' spec.add_dependency 'excon', '~> 0.54.0' spec.add_dependency 'activesupport', '5.0.0' spec.add_runtime_dependency 'thor', '~> 0.19.1' spec.add_development_dependency 'rspec', '~> 3.0' end """ deps = handler._parse_gemspec_content(content, "qa_helpers.gemspec") assert len(deps) == 5 assert all(d.ecosystem == Ecosystem.RUBYGEMS for d in deps) assert all(not d.is_lockfile for d in deps) names = [d.name for d in deps] assert names == ["axlsx", "excon", "activesupport", "thor", "rspec"] # Exact versions activesupport = next(d for d in deps if d.name == "activesupport") assert activesupport.version == "5.0.0" assert activesupport.requirement is None # Range requirements excon = next(d for d in deps if d.name == "excon") assert excon.version is None assert excon.requirement == "~> 0.54.0" # Pre-release as requirement (not bare version) axlsx = next(d for d in deps if d.name == "axlsx") assert axlsx.version is None assert axlsx.requirement == "3.0.0.pre" def test_parse_gemspec_with_parens(handler: RubyEcosystemHandler) -> None: content = """\ Gem::Specification.new do |s| s.add_dependency('rails', '~> 7.0') s.add_runtime_dependency('nokogiri', '>= 1.13') end """ deps = handler._parse_gemspec_content(content, "test.gemspec") assert len(deps) == 2 assert deps[0].name == "rails" assert deps[0].requirement == "~> 7.0" assert deps[1].name == "nokogiri" assert deps[1].requirement == ">= 1.13" def test_parse_gemspec_no_version(handler: RubyEcosystemHandler) -> None: content = """\ Gem::Specification.new do |s| s.add_dependency 'puma' end """ deps = handler._parse_gemspec_content(content, "test.gemspec") assert len(deps) == 1 assert deps[0].name == "puma" assert deps[0].version is None assert deps[0].requirement is None def test_parse_gemspec_multiple_constraints(handler: RubyEcosystemHandler) -> None: content = """\ Gem::Specification.new do |s| s.add_dependency 'nokogiri', '>= 1.13', '< 2.0' end """ deps = handler._parse_gemspec_content(content, "test.gemspec") assert len(deps) == 1 assert deps[0].requirement == ">= 1.13, < 2.0" def test_parse_gemspec_no_deps(handler: RubyEcosystemHandler) -> None: content = """\ Gem::Specification.new do |s| s.name = 'my_gem' s.version = '1.0.0' end """ deps = handler._parse_gemspec_content(content, "test.gemspec") assert deps == [] def test_parse_gemspec_real_world_qa_helpers(handler: RubyEcosystemHandler) -> None: """Regression test using the real qa_helpers.gemspec content.""" content = """\ lib = File.expand_path('lib', __dir__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require 'qa_helpers/version' Gem::Specification.new do |spec| spec.name = 'qa_helpers' spec.version = QAHelpers::VERSION spec.summary = 'Ruby QA tools/helpers' spec.authors = ['qa'] spec.email = ['qa@theorchard.com'] spec.files = `git ls-files`.split($INPUT_RECORD_SEPARATOR) spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } spec.require_paths = ['lib'] spec.add_dependency 'axlsx', '3.0.0.pre' spec.add_dependency 'excon', '~> 0.54.0' spec.add_dependency 'mysql2', '~> 0.4.3' spec.add_dependency 'capybara', '3.3.1' spec.add_dependency 'selenium-webdriver', '3.141.0' spec.add_dependency 'progress_bar', '~> 0' spec.add_dependency 'pry', '~> 0' spec.add_dependency 'activesupport', '5.0.0' spec.add_dependency 'terminal-table', '1.8.0' spec.add_runtime_dependency 'thor', '~> 0.19.1' spec.required_ruby_version = '~> 2.5.3p105' end """ deps = handler._parse_gemspec_content(content, "qa_helpers.gemspec") assert len(deps) == 10 names = [d.name for d in deps] assert names == [ "axlsx", "excon", "mysql2", "capybara", "selenium-webdriver", "progress_bar", "pry", "activesupport", "terminal-table", "thor", ] # Verify exact versions are captured capybara = next(d for d in deps if d.name == "capybara") assert capybara.version == "3.3.1" assert capybara.requirement is None selenium = next(d for d in deps if d.name == "selenium-webdriver") assert selenium.version == "3.141.0" # Verify range requirements are captured excon = next(d for d in deps if d.name == "excon") assert excon.version is None assert excon.requirement == "~> 0.54.0" thor = next(d for d in deps if d.name == "thor") assert thor.version is None assert thor.requirement == "~> 0.19.1" def test_parse_gemspec_keyword_arg_only_dependency(handler: RubyEcosystemHandler) -> None: """Covers line 310: version_part becomes empty after stripping keyword args.""" content = """\ Gem::Specification.new do |s| s.add_dependency 'foo', require: false end """ deps = handler._parse_gemspec_content(content, "test.gemspec") assert len(deps) == 1 assert deps[0].name == "foo" assert deps[0].version is None assert deps[0].requirement is None # -------------------------------------------------------- # _parse_gem_version_args # -------------------------------------------------------- @pytest.mark.parametrize( "version_part, expected_version, expected_requirement", [ pytest.param(None, None, None, id="none-input"), pytest.param("", None, None, id="empty-string"), pytest.param("'1.2.3'", "1.2.3", None, id="exact-version-single-quotes"), pytest.param('"1.2.3"', "1.2.3", None, id="exact-version-double-quotes"), pytest.param("'7.0.4.1'", "7.0.4.1", None, id="four-part-exact-version"), pytest.param("'~> 7.0'", None, "~> 7.0", id="pessimistic-constraint"), pytest.param("'>= 1.0'", None, ">= 1.0", id="gte-constraint"), pytest.param( "'>= 1.13', '< 2.0'", None, ">= 1.13, < 2.0", id="multiple-constraints", ), pytest.param("'3.0.0.pre'", None, "3.0.0.pre", id="pre-release-as-requirement"), pytest.param("no_quotes_here", None, None, id="no-quotes-returns-none"), pytest.param("'~> 0'", None, "~> 0", id="zero-constraint"), pytest.param( "'~> 7.0', git: 'https://github.com/rails/rails.git'", None, "~> 7.0", id="git-url-filtered-out", ), pytest.param( "'~> 1.0', path: '../my_gem'", None, "~> 1.0", id="path-option-filtered-out", ), pytest.param( "git: 'https://github.com/foo/bar.git'", None, None, id="only-git-url-returns-none", ), pytest.param( "'>= 1.0', '< 2.0', require: 'foo_bar'", None, ">= 1.0, < 2.0", id="require-string-filtered-out", ), pytest.param( "'~> 2.0', branch: 'main'", None, "~> 2.0", id="branch-option-filtered-out", ), ], ) def test_parse_gem_version_args( handler: RubyEcosystemHandler, version_part: str | None, expected_version: str | None, expected_requirement: str | None, ) -> None: version, requirement = handler._parse_gem_version_args(version_part) assert version == expected_version assert requirement == expected_requirement # -------------------------------------------------------- # _coerce_version_token # -------------------------------------------------------- @pytest.mark.parametrize( "version, expected", [ pytest.param("7.0.4", "7.0.4", id="standard-three-part"), pytest.param("1.0", "1.0.0", id="two-part-padded"), pytest.param("3", "3.0.0", id="one-part-padded"), pytest.param("7.2.3.1", "7.2.30001", id="four-part-weighted"), pytest.param("5.2.4.2", "5.2.40002", id="four-part-weighted-2"), pytest.param("6.1.7.3", "6.1.70003", id="four-part-weighted-3"), pytest.param("5.2.4.10", "5.2.40010", id="four-part-weighted-larger-fourth"), pytest.param("2.0.0.rc1", "2.0.0-rc1", id="pre-release-dot-separated"), pytest.param("3.0.0.beta2", "3.0.0-beta2", id="beta-pre-release"), pytest.param("1.0.0.alpha", "1.0.0-alpha", id="alpha-pre-release"), pytest.param("1.0.0.pre", "1.0.0-pre", id="pre-pre-release"), pytest.param("1.0.0.preview1", "1.0.0-preview1", id="preview-pre-release"), pytest.param("2.0.0-rc1", "2.0.0-rc1", id="hyphen-pre-release"), pytest.param(" 7.0.4 ", "7.0.4", id="whitespace-stripped"), pytest.param("1.0.0.SOMETHING", "1.0.0", id="unknown-qualifier-stripped"), pytest.param("rc1", "0.0.0-rc1", id="no-numeric-parts-pre-release-only"), ], ) def test_coerce_version_token( version: str, expected: str, ) -> None: assert RubyEcosystemHandler._coerce_version_token(version) == expected # -------------------------------------------------------- # _normalize_version # -------------------------------------------------------- @pytest.mark.parametrize( "version, expected", [ pytest.param("7.0.4", "7.0.4", id="three-part"), pytest.param("1.0", "1.0.0", id="two-part-padded"), pytest.param("5.2.4.2", "5.2.40002", id="four-part-coerced"), pytest.param("2.0.0.rc1", "2.0.0-rc1", id="pre-release-coerced"), pytest.param(" 3.0.0 ", "3.0.0", id="whitespace-stripped"), ], ) def test_normalize_version( handler: RubyEcosystemHandler, version: str, expected: str, ) -> None: assert handler._normalize_version(version) == expected @pytest.mark.parametrize( "version", [ pytest.param("not-a-version", id="text-string"), pytest.param("", id="empty-string"), pytest.param(" ", id="whitespace-only"), ], ) def test_normalize_version_rejects_invalid( handler: RubyEcosystemHandler, version: str, ) -> None: with pytest.raises(ValueError, match="Not a valid version"): handler._normalize_version(version) # -------------------------------------------------------- # _normalize_vuln_range # -------------------------------------------------------- @pytest.mark.parametrize( "spec, expected", [ pytest.param("", "", id="empty-string"), pytest.param(" ", "", id="whitespace-only"), pytest.param(None, "", id="none-input"), pytest.param( ">= 5.0.0, <= 5.2.4.2", ">=5.0.0 <=5.2.40002", id="four-part-version-in-range-coerced", ), pytest.param( "< 6.1.7.3", "<6.1.70003", id="less-than-four-part", ), pytest.param( "< 7.2.3.1", "<7.2.30001", id="less-than-four-part-2", ), pytest.param( ">= 2.0.0, < 3.5.0", ">=2.0.0 <3.5.0", id="comma-to-space-operator-collapsed", ), pytest.param( ">= 1.0.0", ">=1.0.0", id="gte-operator-spacing-collapsed", ), pytest.param( "< 2.0.0", "<2.0.0", id="lt-operator-spacing-collapsed", ), # ~> pessimistic operator expansion pytest.param( "~> 2.1", ">=2.1.0 <3.0.0", id="pessimistic-two-part-expands-major", ), pytest.param( "~> 2.1.0", ">=2.1.0 <2.2.0", id="pessimistic-three-part-expands-minor", ), pytest.param( "~> 0.9.4", ">=0.9.4 <0.10.0", id="pessimistic-three-part-rolls-over", ), pytest.param( "~> 5", ">=5.0.0 <6.0.0", id="pessimistic-one-part-expands-major", ), pytest.param( "~> 1.2.3.4", ">=1.2.30004 <1.2.40000", id="pessimistic-four-part-expands-and-coerces", ), ], ) def test_normalize_vuln_range( handler: RubyEcosystemHandler, spec: str | None, expected: str, ) -> None: assert handler._normalize_vuln_range(spec) == expected # -------------------------------------------------------- # _build_spec # -------------------------------------------------------- def test_build_spec_returns_npm_spec(handler: RubyEcosystemHandler) -> None: import semantic_version result = handler._build_spec(">=2.0.0 <3.0.0") assert isinstance(result, semantic_version.NpmSpec) def test_build_spec_handles_four_part_versions(handler: RubyEcosystemHandler) -> None: """Regression: four-part Ruby versions must not blow up NpmSpec.""" import semantic_version # These previously raised ValueError result = handler._build_spec(">=5.0.0 <=5.2.4.2") assert isinstance(result, semantic_version.NpmSpec) result = handler._build_spec("<6.1.7.3") assert isinstance(result, semantic_version.NpmSpec) result = handler._build_spec("<7.2.3.1") assert isinstance(result, semantic_version.NpmSpec) # -------------------------------------------------------- # is_vulnerable # -------------------------------------------------------- @pytest.mark.parametrize( "dep, alert, expected_vuln, expected_confidence", [ pytest.param( make_dependency( version="3.0.0", ecosystem=Ecosystem.RUBYGEMS, is_lockfile=True, ), make_alert(ecosystem=Ecosystem.RUBYGEMS, vulnerable_range=None), True, "low", id="no-vuln-range-returns-true-low", ), pytest.param( make_dependency( version="3.0.0", ecosystem=Ecosystem.RUBYGEMS, is_lockfile=True, ), make_alert(ecosystem=Ecosystem.RUBYGEMS, vulnerable_range=""), True, "low", id="empty-vuln-range-returns-true-low", ), pytest.param( make_dependency( version="3.0.0", ecosystem=Ecosystem.RUBYGEMS, is_lockfile=True, ), make_alert( ecosystem=Ecosystem.RUBYGEMS, vulnerable_range=">=2.0.0, <3.5.0", ), True, "high", id="lockfile-version-vulnerable-high-confidence", ), pytest.param( make_dependency( version="3.5.0", ecosystem=Ecosystem.RUBYGEMS, is_lockfile=True, ), make_alert( ecosystem=Ecosystem.RUBYGEMS, vulnerable_range=">=2.0.0, <3.5.0", ), False, "high", id="lockfile-version-not-vulnerable-high-confidence", ), pytest.param( make_dependency( version="3.0.0", ecosystem=Ecosystem.RUBYGEMS, is_lockfile=False, ), make_alert( ecosystem=Ecosystem.RUBYGEMS, vulnerable_range=">=2.0.0, <3.5.0", ), True, "medium", id="manifest-version-vulnerable-medium-confidence", ), pytest.param( make_dependency( version=None, requirement=">=5.0.0", ecosystem=Ecosystem.RUBYGEMS, ), make_alert( ecosystem=Ecosystem.RUBYGEMS, vulnerable_range=">=2.0.0, <2.5.0", ), False, "low", id="requirement-no-overlap", ), pytest.param( make_dependency( version=None, requirement=">=2.0.0", ecosystem=Ecosystem.RUBYGEMS, ), make_alert( ecosystem=Ecosystem.RUBYGEMS, vulnerable_range=">=2.0.0, <3.5.0", ), True, "low", id="requirement-overlap-true", ), pytest.param( make_dependency( version=None, requirement=None, ecosystem=Ecosystem.RUBYGEMS, ), make_alert( ecosystem=Ecosystem.RUBYGEMS, vulnerable_range=">=2.0.0, <3.5.0", ), True, "low", id="no-version-no-requirement-conservative", ), pytest.param( make_dependency( version="not-a-version", ecosystem=Ecosystem.RUBYGEMS, ), make_alert( ecosystem=Ecosystem.RUBYGEMS, vulnerable_range=">=2.0.0, <3.5.0", ), True, "low", id="invalid-version-returns-true-low", ), ], ) def test_is_vulnerable( handler: RubyEcosystemHandler, dep: Dependency, alert: SecurityVulnerability, expected_vuln: bool, expected_confidence: str, ) -> None: is_vuln, confidence = handler.is_vulnerable(dep, alert) assert is_vuln is expected_vuln assert confidence == expected_confidence def test_is_vulnerable_normalize_returns_empty(handler: RubyEcosystemHandler) -> None: """Covers: `if not normalized: return True, 'low'`.""" dep = make_dependency( version="3.0.0", ecosystem=Ecosystem.RUBYGEMS, is_lockfile=True, ) alert = make_alert(ecosystem=Ecosystem.RUBYGEMS, vulnerable_range="some-range") with patch.object(handler, "_normalize_vuln_range", return_value=""): is_vuln, confidence = handler.is_vulnerable(dep, alert) assert is_vuln is True assert confidence == "low" def test_is_vulnerable_unexpected_exception_returns_true_low( handler: RubyEcosystemHandler, ) -> None: """Covers: outer `except Exception` catch-all.""" dep = make_dependency( version="3.0.0", ecosystem=Ecosystem.RUBYGEMS, is_lockfile=True, ) alert = make_alert(ecosystem=Ecosystem.RUBYGEMS, vulnerable_range=">=2.0.0, <3.5.0") with patch.object( handler, "_normalize_vuln_range", side_effect=RuntimeError("unexpected"), ): is_vuln, confidence = handler.is_vulnerable(dep, alert) assert is_vuln is True assert confidence == "low" # -------------------------------------------------------- # is_vulnerable — four-part version regression tests # -------------------------------------------------------- def test_is_vulnerable_four_part_vuln_range(handler: RubyEcosystemHandler) -> None: """Regression: four-part versions in vulnerability ranges must not crash.""" dep = make_dependency( version="5.0.0", ecosystem=Ecosystem.RUBYGEMS, is_lockfile=True, ) alert = make_alert( ecosystem=Ecosystem.RUBYGEMS, vulnerable_range=">=5.0.0, <=5.2.4.2", ) is_vuln, confidence = handler.is_vulnerable(dep, alert) assert is_vuln is True assert confidence == "high" def test_is_vulnerable_four_part_less_than(handler: RubyEcosystemHandler) -> None: """Regression: `<6.1.7.3` must not raise ValueError.""" dep = make_dependency( version="6.1.7.2", ecosystem=Ecosystem.RUBYGEMS, is_lockfile=True, ) alert = make_alert( ecosystem=Ecosystem.RUBYGEMS, vulnerable_range="<6.1.7.3", ) is_vuln, confidence = handler.is_vulnerable(dep, alert) # 6.1.7.2 → 6.1.70002, range <6.1.70003 → vulnerable assert is_vuln is True assert confidence == "high" def test_is_vulnerable_four_part_patched_version(handler: RubyEcosystemHandler) -> None: """A version at or above the patch should NOT be vulnerable.""" dep = make_dependency( version="7.2.3.1", ecosystem=Ecosystem.RUBYGEMS, is_lockfile=True, ) alert = make_alert( ecosystem=Ecosystem.RUBYGEMS, vulnerable_range="<7.2.3.1", ) is_vuln, confidence = handler.is_vulnerable(dep, alert) assert is_vuln is False assert confidence == "high" def test_is_vulnerable_four_part_ordering_preserved(handler: RubyEcosystemHandler) -> None: """5.2.4.2 should be vulnerable when range is <=5.2.4.2.""" dep = make_dependency( version="5.2.4.2", ecosystem=Ecosystem.RUBYGEMS, is_lockfile=True, ) alert = make_alert( ecosystem=Ecosystem.RUBYGEMS, vulnerable_range=">=5.0.0, <=5.2.4.2", ) is_vuln, confidence = handler.is_vulnerable(dep, alert) assert is_vuln is True assert confidence == "high" def test_is_vulnerable_four_part_above_range(handler: RubyEcosystemHandler) -> None: """5.2.5.0 should NOT be vulnerable when range is <=5.2.4.2.""" dep = make_dependency( version="5.2.5.0", ecosystem=Ecosystem.RUBYGEMS, is_lockfile=True, ) alert = make_alert( ecosystem=Ecosystem.RUBYGEMS, vulnerable_range=">=5.0.0, <=5.2.4.2", ) is_vuln, confidence = handler.is_vulnerable(dep, alert) assert is_vuln is False assert confidence == "high" # -------------------------------------------------------- # _version_in_range / _ranges_overlap — error paths # -------------------------------------------------------- def test_version_in_range_exception_returns_true(handler: RubyEcosystemHandler) -> None: """Covers: _version_in_range catches exception and returns True.""" import semantic_version version = semantic_version.Version.coerce("1.0.0") with patch.object(handler, "_build_spec", side_effect=RuntimeError("boom")): assert handler._version_in_range(version, ">=1.0.0") is True def test_ranges_overlap_exception_returns_true(handler: RubyEcosystemHandler) -> None: """Covers: _ranges_overlap catches exception and returns True.""" with patch.object(handler, "_build_spec", side_effect=RuntimeError("boom")): assert handler._ranges_overlap(">=1.0.0", ">=2.0.0 <3.0.0") is True # -------------------------------------------------------- # Handler identity # -------------------------------------------------------- def test_handler_id_and_ecosystem(handler: RubyEcosystemHandler) -> None: assert handler.id == "ruby" assert handler.ecosystem == Ecosystem.RUBYGEMS # -------------------------------------------------------- # _expand_pessimistic_constraints # -------------------------------------------------------- @pytest.mark.parametrize( "spec, expected", [ pytest.param("~> 2.1", ">=2.1, <3.0", id="two-part"), pytest.param("~> 2.1.0", ">=2.1.0, <2.2.0", id="three-part"), pytest.param("~> 0.9.4", ">=0.9.4, <0.10.0", id="three-part-rolls-over"), pytest.param("~> 5", ">=5, <6", id="one-part"), pytest.param("~> 1.2.3.4", ">=1.2.3.4, <1.2.4.0", id="four-part"), pytest.param("~> 0", ">=0, <1", id="zero"), pytest.param(">= 1.0", ">= 1.0", id="non-pessimistic-unchanged"), pytest.param("< 2.0", "< 2.0", id="less-than-unchanged"), pytest.param( "~> 2.0, ~> 3.1.0", ">=2.0, <3.0, >=3.1.0, <3.2.0", id="multiple-pessimistic-in-one-spec", ), pytest.param( "~> 2.0.0.rc1", ">=2.0.0, <2.1.0.rc1", id="pre-release-segment-bails-out-unchanged", ), ], ) def test_expand_pessimistic_constraints( spec: str, expected: str, ) -> None: assert RubyEcosystemHandler._expand_pessimistic_constraints(spec) == expected