From 63487b8a3f83b51ecf87a9b605dfeb05e137df77 Mon Sep 17 00:00:00 2001 From: Al Snow <43523+jasnow@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:33:27 -0400 Subject: [PATCH 1/4] version string pattern deep dive --- docs/pessimistic-operator.txt | 170 +++++++++++ lib/cve-range-incorrectness-detector.rb | 122 ++++++++ lib/run-cve-range-incorrectness-detector.sh | 3 + spec/versions_validator.rb | 302 ++++++++++++++++++++ 4 files changed, 597 insertions(+) create mode 100644 docs/pessimistic-operator.txt create mode 100755 lib/cve-range-incorrectness-detector.rb create mode 100755 lib/run-cve-range-incorrectness-detector.sh create mode 100644 spec/versions_validator.rb diff --git a/docs/pessimistic-operator.txt b/docs/pessimistic-operator.txt new file mode 100644 index 0000000000..5c9e206a89 --- /dev/null +++ b/docs/pessimistic-operator.txt @@ -0,0 +1,170 @@ +## The ~> (Pessimistic) Operator Cheatsheet + +* Definition: Means "greater than or equal to this version, but less than + the next major/minor milestone". It locks the left-most specified + digits and only allows the right-most digit to change. + + * How it behaves with two digits (~> x.y): It allows patch and minor + updates, but blocks the next major number. + * ~> 2.2 is the same as >= 2.2 and < 3.0. It allows 2.3, 2.9, + but blocks 3.0. + + * How it behaves with three digits (~> x.y.z): It allows only patch + updates, but blocks the next minor number. + * ~> 2.2.0 is the same as >= 2.2.0 and < 2.3.0. It allows + 2.2.1, 2.2.8, but blocks 2.3.0. + + * How it behaves with four digits (~> w.x.y.z): It allows only patch + updates, but blocks the next minor number. + * Upper bound: w.x.(y+1) + * ~> 7.2.3.1 is the same as >= 7.2.3.1 and < 7.2.4. It allows + 7.2.3.2, 7.2.3.3, 7.2.3.9 but blocks 7.2.4.0 and blocks 7.3.x. + + * When to use: Recommended for most production applications because + it respects Semantic Versioning—allowing safe bug fixes while + preventing breaking changes. + +### Test Code + + * Gem::Requirement.new("~> 7.2.3.1").satisfied_by?(Gem::Version.new("7.2.3.2")) + * Gem::Requirement.new("~> 7.2.3.1").satisfied_by?(Gem::Version.new("7.2.4")) + * Gem::Requirement.new("~> 7.2.3.1").satisfied_by?(Gem::Version.new("7.3.0")) + * req = Gem::Requirement.new("~> 7.2.3.1") [ + "7.2.3.1", + "7.2.3.2", + "7.2.4", + "7.3.0" + ].each do |v| + puts "#{v}: #{req.satisfied_by?(Gem::Version.new(v))}" + end + +### References + + * https://railsfactory.com/blog/ruby-gem-versions-explained + * https://guides.rubygems.org/patterns/#semantic-versioning + * Example: https://github.com/rubysec/ruby-advisory-db/pull/1191: + * Per the Rails GHSA and the v7.2.3.2 release, the affected ranges are: + * activestorage < 7.2.3.2 + * activestorage >= 8.0, < 8.0.5.1 + * activestorage >= 8.1, < 8.1.3.1 + * Two bugs in the current entry: + 1. "~> 7.2.3.1" marks the vulnerable 7.2.3.1 as patched. The 7.2.x fix + shipped in 7.2.3.2, not 7.2.3.1 (the current file even links 7.2.3.2 + as the fixed release). As written, an app on the vulnerable 7.2.3.1 + gets a clean bundle-audit — a false "not vulnerable" for a CVSS 9.5. + 2. "~> 8.0.5.1" is too narrow. ~> 8.0.5.1 means >= 8.0.5.1, < 8.0.6, so + it wrongly flags 8.0.6+ as still vulnerable. + * Fix + * Use compound constraints (matching the existing convention in e.g. + activerecord/CVE-2022-44566.yml): + * patched_versions: + - "~> 7.2.3, >= 7.2.3.2" (Verified against: 7.2.3.1/.2, 7.2.4) + - "~> 8.0.5, >= 8.0.5.1" (Verified against: 8.0.0/8.0.5.0/ + 8.0.5.1/8.0.6) + - ">= 8.1.3.1" (Verified against: 8.1.0/8.1.3.0/8.1.3.1,8.2.0, 9.0.0) + +## RAD VERSION PATTERN TYPES + + * Legend + * "m+n" means expect a "," with m and n as digit(s). + * "+" at end of line means expect non-digits + +### Pattern Types + +2 + 4 - "~> %.%" + 6 - ">= %.%" +2+ + 1 - ">= %.%.rc" +2+2 + 2 - "~> %.%, >= %.%" +2+3 + 1 - "~> %.%, >= %.%.%" +2+4 + 1 - "~> %.%, >= %.%.%.%" +3 + 633 - "~> %.%.%" + 1088 - ">= %.%.%" +3+ + 1 - ">= %.%.%-beta.%" + 1 - "~> %.%.%.beta%" + 1 - ">= %.%.%-p%" + 1 - ">= %.%.%.p%" + 1 - ">= %.%.%p%" + 1 - "~> %.%.%-p%" + 1 - "~> %.%.%-preview" + 1 - ">= %.%.%-r%" + 1 - ">= %.%.%-rc" + 1 - ">= %.%.%.rc%.%" + 2 - "~> %.%.%.p%" + 2 - ">= %.%.%.pre.%" + 2 - ">= %.%.%-preview%" + 2 - ">= %.%.%.preview.%" + 2 - ">= %.%.%-rc%" + 3 - "~> %.%.%.preview.%" + 3 - "~> %.%.%.rc%" + 6 - ">= %.%.%.beta%.%" + 6 - ">= %.%.%.beta.%" + 9 - ">= %.%.%.rc%" + 16 - ">= %.%.%.beta%" + 27 - "~> %.%.%-rc" +3+3 + 1 - ">= %.%.%, <= %.%.%" + 1 - ">= %.%.%, <= %.%.%" + 6 - ">= %.%.%, < %.%.%" +3+4 + 132 - "~> %.%.%, >= %.%.%.%" +4 + 110 - "~> %.%.%.%" + 127 - ">= %.%.%.%" +4+ + 5 - "~> %.%.%.rc%.%" + +## BNF +``` + ::= + | "," + + ::= + | " " + + ::= + | + | + + ::= "~>" + + ::= + + ::= "<" | "<=" | ">" | ">=" + + ::= + + ::= + + ::= + | "." + | "." "." + | "." "." "." + + ::= "" + | "." + + ::= + | "." + + ::= + | + + ::= | + + ::= "a" | "b" | "c" | ... | "z" + | "A" | "B" | "C" | ... | "Z" + + ::= + | + + ::= "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" + + ::= " " | "\t" +``` diff --git a/lib/cve-range-incorrectness-detector.rb b/lib/cve-range-incorrectness-detector.rb new file mode 100755 index 0000000000..5800f491d2 --- /dev/null +++ b/lib/cve-range-incorrectness-detector.rb @@ -0,0 +1,122 @@ +#!/usr/bin/env ruby + +# Usage: $0 + +# frozen_string_literal: true +require "rubygems" +require "safe_yaml" + +module CveRangeDetector + module_function + + # NEW: split comma‑separated multi‑constraint strings + def normalize_constraints(list) + Array(list).flat_map { |entry| entry.split(/\s*,\s*/) } + end + + # Explain a single pessimistic constraint (~>) + def explain_pessimistic(constraint) + req = Gem::Requirement.new(constraint) + op, ver = req.requirements.first + raise ArgumentError, "Only ~> supported, got #{op.inspect}" unless op == "~>" + + segments = ver.segments + raise ArgumentError, "Empty version" if segments.empty? + + lower = ver + + locked = segments[0..-2] + if locked.empty? + # "~> 2" + upper = Gem::Version.new((segments[0] + 1).to_s) + else + inc = locked.dup + inc[-1] = inc[-1].succ + upper = Gem::Version.new(inc.join(".")) + end + + { lower: lower, upper: upper } + end + + # Check if a version is covered by any requirement string + def covered?(requirements, version) + reqs = Array(requirements).map { |r| Gem::Requirement.new(r) } + v = Gem::Version.new(version) + reqs.any? { |r| r.satisfied_by?(v) } + end + + # Heuristic checks: + # - fixed_version must be included in patched_versions + # - fixed_version must be excluded from affected_versions + # - optionally: last vulnerable version must be included in affected_versions + def analyze_advisory(advisory_hash) + gem_name = advisory_hash["gem"] + + # CHANGED: now supports "~> 5.2.2, >= 5.2.2.1" + fixed_versions = normalize_constraints(advisory_hash["patched_versions"]) + affected_ranges = normalize_constraints(advisory_hash["affected_versions"]) + + issues = [] + + fixed_versions.each do |constraint| + next unless constraint.start_with?("~>") + + range = explain_pessimistic(constraint) + fixed = range[:lower].to_s + + unless covered?(fixed_versions, fixed) + issues << { + type: :patched_not_cover_fixed_lower, + gem: gem_name, + constraint: constraint, + fixed_version: fixed, + detail: "Lower bound #{fixed} is not actually satisfied by #{constraint}" + } + end + end + + # Cross‑check: any patched lower bound still marked affected? + fixed_versions.each do |constraint| + next unless constraint.start_with?("~>") + + range = explain_pessimistic(constraint) + fixed = range[:lower].to_s + + if covered?(affected_ranges, fixed) + issues << { + type: :fixed_still_affected, + gem: gem_name, + constraint: constraint, + fixed_version: fixed, + detail: "Fixed version #{fixed} is still within affected_versions" + } + end + end + + issues + end + + # CLI helper: run on a single advisory YAML file + def analyze_file(path) + advisory = YAML.safe_load_file(path) + issues = analyze_advisory(advisory) + + if issues.empty? + puts "#{path}: OK" + else + puts "#{path}: #{issues.size} issue(s)" + issues.each do |i| + puts "- [#{i[:type]}] gem=#{i[:gem]} constraint=#{i[:constraint]} fixed=#{i[:fixed_version]}" + puts " #{i[:detail]}" + end + end + end +end + +# Usage: +# ruby cve_range_detector.rb path/to/advisory.yml +if $PROGRAM_NAME == __FILE__ + ARGV.each do |file| + CveRangeDetector.analyze_file(file) + end +end diff --git a/lib/run-cve-range-incorrectness-detector.sh b/lib/run-cve-range-incorrectness-detector.sh new file mode 100755 index 0000000000..b9b8edb81c --- /dev/null +++ b/lib/run-cve-range-incorrectness-detector.sh @@ -0,0 +1,3 @@ +lib/cve-range-incorrectness-detector.rb \ + $(find gems rubies -type f |grep yml$) \ +| grep -v ": OK" diff --git a/spec/versions_validator.rb b/spec/versions_validator.rb new file mode 100644 index 0000000000..1367e41209 --- /dev/null +++ b/spec/versions_validator.rb @@ -0,0 +1,302 @@ +# frozen_string_literal: true + +# Usage: rspec spec/versions_validator.rb + +require "rspec" +require "rubygems" + +module AdvisoryDB + class VersionFieldValidator + Constraint = Struct.new(:op, :version) + Range = Struct.new(:constraints) + Error = Struct.new(:message, :input) + + COMMA_SPLIT = /\s*,\s*/ + SPACE_SPLIT = /\s+/ + + VALID_OPS = ["~>", "<", "<=", ">", ">=", "="].freeze + + def validate_list(input) + return [Error.new("empty version list", input)] if input.nil? || input.strip.empty? + + ranges = input.split(COMMA_SPLIT).map { |r| parse_range(r) } + errors = ranges.flat_map { |range| validate_range(range) } + errors.compact + end + + private + + # + # Merge operator + version tokens: + # "< 2.0.0.beta" → ["< 2.0.0.beta"] + # ">= 1.2.3.rc1 < 2.0.0.beta" → [">= 1.2.3.rc1", "< 2.0.0.beta"] + # + def merge_operator_tokens(parts) + merged = [] + i = 0 + while i < parts.length + # ANY operator-like prefix must merge with the next token + if parts[i] =~ /\A[<>=~]+\z/ && parts[i+1] + merged << "#{parts[i]} #{parts[i+1]}".strip + i += 2 + else + merged << parts[i] + i += 1 + end + end + merged + end + + def parse_range(str) + parts = str.strip.split(SPACE_SPLIT) + parts = merge_operator_tokens(parts) + constraints = parts.map { |p| parse_constraint(p) } + Range.new(constraints) + end + + # + # parse_constraint NEVER returns nil + # + def parse_constraint(str) + # Known operators + if str =~ /\A~>\s*(.+)\z/ + return Constraint.new("~>", parse_version($1)) + elsif str =~ /\A(<=|>=|<|>)\s*(.+)\z/ + return Constraint.new($1, parse_version($2)) + end + + # STRICT unknown operator detection: + # If it starts with operator characters but is not a valid operator, treat as unknown. + if str =~ /\A([<>=~]+)\s+(.+)\z/ + op = $1 + ver = $2 + return Constraint.new(op, parse_version(ver)) + end + + # Fallback: exact version + Constraint.new("=", parse_version(str)) + end + + def parse_version(str) + Gem::Version.new(str) + rescue ArgumentError + nil + end + + def validate_range(range) + range.constraints.map { |c| validate_constraint(c) } + end + + def validate_constraint(constraint) + # Defensive guard + return Error.new("invalid constraint", constraint.inspect) if constraint.nil? + + unless VALID_OPS.include?(constraint.op) + return Error.new("unknown operator #{constraint.op}", constraint.inspect) + end + + return Error.new("invalid version syntax", constraint.inspect) if constraint.version.nil? + + case constraint.op + when "~>" + validate_pessimistic(constraint) + else + nil + end + end + + def validate_pessimistic(constraint) + v = constraint.version + segments = v.segments + + return Error.new("pessimistic operator requires at least one segment", v.to_s) if segments.empty? + + last = segments.last + + # Reject prerelease-only segment (alphabetic-only) + if last.is_a?(String) && last.match?(/\A[a-zA-Z]+\z/) + return Error.new("pessimistic operator cannot apply to prerelease-only segment", v.to_s) + end + + nil + end + end +end + +RSpec.describe AdvisoryDB::VersionFieldValidator do + subject(:validator) { described_class.new } + + def errors(input) + validator.validate_list(input) + end + + describe "#validate_list" do + context "with empty input" do + it "returns an error" do + expect(errors("")).not_to be_empty + expect(errors(" ").first.message).to eq("empty version list") + end + end + + context "with exact versions" do + it "accepts a single exact version" do + expect(errors("1.2.3")).to be_empty + end + + it "accepts multiple exact versions" do + expect(errors("1.2.3, 2.0.0")).to be_empty + end + end + + context "with prerelease versions" do + it "accepts rc versions" do + expect(errors("1.2.3.rc1")).to be_empty + end + + it "accepts beta versions" do + expect(errors("2.0.0.beta.2")).to be_empty + end + + it "accepts pre versions" do + expect(errors("3.1.0.pre")).to be_empty + end + end + + context "with comparison operators" do + it "accepts <" do + expect(errors("< 2.0.0.beta")).to be_empty + end + + it "accepts <=" do + expect(errors("<= 1.2.3.rc1")).to be_empty + end + + it "accepts >" do + expect(errors("> 1.0.0")).to be_empty + end + + it "accepts >=" do + expect(errors(">= 1.0.0.pre")).to be_empty + end + end + + context "with pessimistic operator" do + it "accepts ~> with numeric last segment" do + expect(errors("~> 1.2.3")).to be_empty + end + + it "rejects ~> when last segment is prerelease-only" do + err = errors("~> 1.2.3.rc").first + expect(err.message).to eq("pessimistic operator cannot apply to prerelease-only segment") + end + + it "accepts ~> with prerelease segments as long as last segment is numeric" do + expect(errors("~> 1.2.3.rc1")).to be_empty + end + end + + context "with multi-constraint ranges" do + it "accepts >= and < together" do + expect(errors(">= 1.2.3.rc1 < 2.0.0.beta")).to be_empty + end + + it "accepts comma-separated ranges" do + expect(errors(">= 1.0.0, < 2.0.0")).to be_empty + end + end + + context "with invalid syntax" do + it "rejects unknown operators" do + err = errors("~~ 1.2.3").first + expect(err.message).to match(/unknown operator/) + end + + it "rejects invalid version strings" do + err = errors(">= not_a_version").first + expect(err.message).to eq("invalid version syntax") + end + end + + context "with multi-segment versions" do + it "accepts four-segment versions" do + expect(errors("1.2.3.4")).to be_empty + end + + it "accepts five-segment versions" do + expect(errors("1.2.3.4.5")).to be_empty + end + + it "accepts four-segment prerelease versions" do + expect(errors("1.2.3.4.rc1")).to be_empty + end + end + + context "with prerelease variants seen in advisories" do + it "accepts preview versions" do + expect(errors("2.3.0-preview1")).to be_empty + end + + it "accepts mixed prerelease tokens" do + expect(errors("3.0.0.alpha.1")).to be_empty + end + end + + context "with pessimistic operator on multi-segment versions" do + it "accepts ~> on four-segment versions" do + expect(errors("~> 1.2.3.4")).to be_empty + end + + it "rejects ~> when last segment is prerelease-only" do + err = errors("~> 1.2.3.4.rc").first + expect(err.message).to eq("pessimistic operator cannot apply to prerelease-only segment") + end + end + + context "with chained constraints used in advisories" do + it "accepts >= and <= together" do + expect(errors(">= 1.0.0 <= 2.0.0")).to be_empty + end + + it "accepts > and < together" do + expect(errors("> 1.9.3 < 2.0.0")).to be_empty + end + end + + context "with comma-separated multi-constraint ranges" do + it "accepts >= and < separated by comma" do + expect(errors(">= 2.3.0, < 2.3.5")).to be_empty + end + + it "accepts > and <= separated by comma" do + expect(errors("> 1.9.3, <= 2.0.0")).to be_empty + end + end + + context "with invalid advisory patterns" do + it "rejects operator-only tokens" do + err = errors(">=").first + expect(err.message).to eq("invalid version syntax") + end + + it "rejects malformed version strings" do + err = errors("1..2.3").first + expect(err.message).to eq("invalid version syntax") + end + + it "rejects malformed prerelease versions" do + err = errors("1.2.3..rc1").first + expect(err.message).to eq("invalid version syntax") + end + + it "rejects invalid operators seen in bad PRs" do + err = errors("=> 1.2.3").first + expect(err.message).to match(/unknown operator/) + end + + it "rejects multi-character invalid operators" do + err = errors(">== 1.2.3").first + expect(err.message).to match(/invalid version syntax/) + end + end + end +end From 4c7815501081d8f7ffc69269955f38f5ee842e3a Mon Sep 17 00:00:00 2001 From: Al Snow <43523+jasnow@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:52:15 -0400 Subject: [PATCH 2/4] PR responses to review requests --- Gemfile | 1 + ...c-operator.txt => pessimistic-operator.md} | 109 +++++++++--------- lib/cve-range-incorrectness-detector.rb | 1 - 3 files changed, 57 insertions(+), 54 deletions(-) rename docs/{pessimistic-operator.txt => pessimistic-operator.md} (60%) diff --git a/Gemfile b/Gemfile index b547f12aa6..f5da4280c8 100644 --- a/Gemfile +++ b/Gemfile @@ -10,4 +10,5 @@ group :development do gem 'pry' gem 'nokogiri', '~> 1.0' gem 'activesupport', '~> 8.0' + gem 'safe_yaml' end diff --git a/docs/pessimistic-operator.txt b/docs/pessimistic-operator.md similarity index 60% rename from docs/pessimistic-operator.txt rename to docs/pessimistic-operator.md index 5c9e206a89..52605e90e1 100644 --- a/docs/pessimistic-operator.txt +++ b/docs/pessimistic-operator.md @@ -1,63 +1,63 @@ -## The ~> (Pessimistic) Operator Cheatsheet - -* Definition: Means "greater than or equal to this version, but less than - the next major/minor milestone". It locks the left-most specified - digits and only allows the right-most digit to change. - - * How it behaves with two digits (~> x.y): It allows patch and minor - updates, but blocks the next major number. - * ~> 2.2 is the same as >= 2.2 and < 3.0. It allows 2.3, 2.9, - but blocks 3.0. - - * How it behaves with three digits (~> x.y.z): It allows only patch - updates, but blocks the next minor number. - * ~> 2.2.0 is the same as >= 2.2.0 and < 2.3.0. It allows - 2.2.1, 2.2.8, but blocks 2.3.0. - - * How it behaves with four digits (~> w.x.y.z): It allows only patch - updates, but blocks the next minor number. - * Upper bound: w.x.(y+1) - * ~> 7.2.3.1 is the same as >= 7.2.3.1 and < 7.2.4. It allows - 7.2.3.2, 7.2.3.3, 7.2.3.9 but blocks 7.2.4.0 and blocks 7.3.x. - - * When to use: Recommended for most production applications because - it respects Semantic Versioning—allowing safe bug fixes while - preventing breaking changes. - -### Test Code - - * Gem::Requirement.new("~> 7.2.3.1").satisfied_by?(Gem::Version.new("7.2.3.2")) - * Gem::Requirement.new("~> 7.2.3.1").satisfied_by?(Gem::Version.new("7.2.4")) - * Gem::Requirement.new("~> 7.2.3.1").satisfied_by?(Gem::Version.new("7.3.0")) - * req = Gem::Requirement.new("~> 7.2.3.1") [ +# The ~> (Pessimistic) Operator Cheatsheet + + - Definition: Means "greater than or equal to this version, but less than + the next major/minor milestone". It locks the left-most specified + digits and only allows the right-most digit to change. + + - How it behaves with two digits (~> x.y): It allows patch and minor + updates, but blocks the next major number. + - ~> 2.2 is the same as >= 2.2 and < 3.0. It allows 2.3, 2.9, + but blocks 3.0. + + - How it behaves with three digits (~> x.y.z): It allows only patch + updates, but blocks the next minor number. + - ~> 2.2.0 is the same as >= 2.2.0 and < 2.3.0. It allows + 2.2.1, 2.2.8, but blocks 2.3.0. + + - How it behaves with four digits (~> w.x.y.z): It allows only patch + updates, but blocks the next minor number. + - Upper bound: w.x.(y+1) + - ~> 7.2.3.1 is the same as >= 7.2.3.1 and < 7.2.4. It allows + 7.2.3.2, 7.2.3.3, 7.2.3.9 but blocks 7.2.4.0 and blocks 7.3.x. + + - When to use: Recommended for most production applications because + it respects Semantic Versioning—allowing safe bug fixes while + preventing breaking changes. + +## Test Code + + - Gem::Requirement.new("~> 7.2.3.1").satisfied_by?(Gem::Version.new("7.2.3.2")) + - Gem::Requirement.new("~> 7.2.3.1").satisfied_by?(Gem::Version.new("7.2.4")) + - Gem::Requirement.new("~> 7.2.3.1").satisfied_by?(Gem::Version.new("7.3.0")) + - req = Gem::Requirement.new("~> 7.2.3.1") [ "7.2.3.1", "7.2.3.2", "7.2.4", "7.3.0" - ].each do |v| - puts "#{v}: #{req.satisfied_by?(Gem::Version.new(v))}" - end - -### References - - * https://railsfactory.com/blog/ruby-gem-versions-explained - * https://guides.rubygems.org/patterns/#semantic-versioning - * Example: https://github.com/rubysec/ruby-advisory-db/pull/1191: - * Per the Rails GHSA and the v7.2.3.2 release, the affected ranges are: - * activestorage < 7.2.3.2 - * activestorage >= 8.0, < 8.0.5.1 - * activestorage >= 8.1, < 8.1.3.1 - * Two bugs in the current entry: + ].each do |v| + puts "#{v}: #{req.satisfied_by?(Gem::Version.new(v))}" + end + +## References + + - https://railsfactory.com/blog/ruby-gem-versions-explained + - https://guides.rubygems.org/patterns/#semantic-versioning + - Example: https://github.com/rubysec/ruby-advisory-db/pull/1191: + - Per the Rails GHSA and the v7.2.3.2 release, the affected ranges are: + - activestorage < 7.2.3.2 + - activestorage >= 8.0, < 8.0.5.1 + - activestorage >= 8.1, < 8.1.3.1 + - Two bugs in the current entry: 1. "~> 7.2.3.1" marks the vulnerable 7.2.3.1 as patched. The 7.2.x fix shipped in 7.2.3.2, not 7.2.3.1 (the current file even links 7.2.3.2 as the fixed release). As written, an app on the vulnerable 7.2.3.1 gets a clean bundle-audit — a false "not vulnerable" for a CVSS 9.5. 2. "~> 8.0.5.1" is too narrow. ~> 8.0.5.1 means >= 8.0.5.1, < 8.0.6, so it wrongly flags 8.0.6+ as still vulnerable. - * Fix - * Use compound constraints (matching the existing convention in e.g. + - Fix + - Use compound constraints (matching the existing convention in e.g. activerecord/CVE-2022-44566.yml): - * patched_versions: + - patched_versions: - "~> 7.2.3, >= 7.2.3.2" (Verified against: 7.2.3.1/.2, 7.2.4) - "~> 8.0.5, >= 8.0.5.1" (Verified against: 8.0.0/8.0.5.0/ 8.0.5.1/8.0.6) @@ -65,12 +65,13 @@ ## RAD VERSION PATTERN TYPES - * Legend - * "m+n" means expect a "," with m and n as digit(s). - * "+" at end of line means expect non-digits + - Legend + - "m+n" means expect a "," with m and n as digit(s). + - "+" at end of line means expect non-digits ### Pattern Types +``` 2 4 - "~> %.%" 6 - ">= %.%" @@ -119,8 +120,10 @@ 127 - ">= %.%.%.%" 4+ 5 - "~> %.%.%.rc%.%" +``` + +### BNF -## BNF ``` ::= | "," diff --git a/lib/cve-range-incorrectness-detector.rb b/lib/cve-range-incorrectness-detector.rb index 5800f491d2..d755e768a3 100755 --- a/lib/cve-range-incorrectness-detector.rb +++ b/lib/cve-range-incorrectness-detector.rb @@ -3,7 +3,6 @@ # Usage: $0 # frozen_string_literal: true -require "rubygems" require "safe_yaml" module CveRangeDetector From 7a4d839dc3a38145ea629165f5c79b7b3b5d9c67 Mon Sep 17 00:00:00 2001 From: Al Snow <43523+jasnow@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:59:14 -0400 Subject: [PATCH 3/4] Simplify Gem::Requirement usage in documentation Refactor Gem::Requirement example for clarity. --- docs/pessimistic-operator.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/docs/pessimistic-operator.md b/docs/pessimistic-operator.md index 52605e90e1..f8a3cf75af 100644 --- a/docs/pessimistic-operator.md +++ b/docs/pessimistic-operator.md @@ -29,12 +29,7 @@ - Gem::Requirement.new("~> 7.2.3.1").satisfied_by?(Gem::Version.new("7.2.3.2")) - Gem::Requirement.new("~> 7.2.3.1").satisfied_by?(Gem::Version.new("7.2.4")) - Gem::Requirement.new("~> 7.2.3.1").satisfied_by?(Gem::Version.new("7.3.0")) - - req = Gem::Requirement.new("~> 7.2.3.1") [ - "7.2.3.1", - "7.2.3.2", - "7.2.4", - "7.3.0" - ].each do |v| + - req = Gem::Requirement.new("~> 7.2.3.1") ["7.2.3.1", "7.2.3.2", "7.2.4", "7.3.0"].each do |v| puts "#{v}: #{req.satisfied_by?(Gem::Version.new(v))}" end @@ -124,6 +119,7 @@ ### BNF +# bnf ``` ::= | "," From 002378944cea53a581c44f682a0b20e76736454e Mon Sep 17 00:00:00 2001 From: Al Snow <43523+jasnow@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:02:12 -0400 Subject: [PATCH 4/4] Fix bugs and improve formatting in pessimistic operator doc Corrected bugs in the example and updated formatting. --- docs/pessimistic-operator.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/pessimistic-operator.md b/docs/pessimistic-operator.md index f8a3cf75af..5096e88468 100644 --- a/docs/pessimistic-operator.md +++ b/docs/pessimistic-operator.md @@ -41,8 +41,8 @@ - Per the Rails GHSA and the v7.2.3.2 release, the affected ranges are: - activestorage < 7.2.3.2 - activestorage >= 8.0, < 8.0.5.1 - - activestorage >= 8.1, < 8.1.3.1 - - Two bugs in the current entry: + - activestorage >= 8.1, < 8.1.3.1 + - Two bugs in the example: 1. "~> 7.2.3.1" marks the vulnerable 7.2.3.1 as patched. The 7.2.x fix shipped in 7.2.3.2, not 7.2.3.1 (the current file even links 7.2.3.2 as the fixed release). As written, an app on the vulnerable 7.2.3.1 @@ -66,7 +66,7 @@ ### Pattern Types -``` +```text 2 4 - "~> %.%" 6 - ">= %.%" @@ -119,8 +119,7 @@ ### BNF -# bnf -``` +```text ::= | ","