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.md b/docs/pessimistic-operator.md new file mode 100644 index 0000000000..5096e88468 --- /dev/null +++ b/docs/pessimistic-operator.md @@ -0,0 +1,168 @@ +# 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 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 + 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 + +```text +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 + +```text + ::= + | "," + + ::= + | " " + + ::= + | + | + + ::= "~>" + + ::= + + ::= "<" | "<=" | ">" | ">=" + + ::= + + ::= + + ::= + | "." + | "." "." + | "." "." "." + + ::= "" + | "." + + ::= + | "." + + ::= + | + + ::= | + + ::= "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..d755e768a3 --- /dev/null +++ b/lib/cve-range-incorrectness-detector.rb @@ -0,0 +1,121 @@ +#!/usr/bin/env ruby + +# Usage: $0 + +# frozen_string_literal: true +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