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/Rakefile b/Rakefile deleted file mode 100644 index 0ba1e9698b..0000000000 --- a/Rakefile +++ /dev/null @@ -1,41 +0,0 @@ -require 'yaml' - -begin - require 'rspec/core/rake_task' -rescue LoadError - warn "Warning: RSpec is not installed. Please run `gem install rspec` to install RSpec." -end - -if defined?(RSpec::Core::RakeTask) - namespace :lint do - desc "Lint reports (excluding schema validation)" - RSpec::Core::RakeTask.new(:yaml) do |t| - t.exclude_pattern = 'spec/schema_validation_spec.rb' - end - - desc "Validate report schema" - RSpec::Core::RakeTask.new(:schema) do |t| - t.pattern = 'spec/schema_validation_spec.rb' - end - end - - desc "Run all linting tasks" - task :lint => [ 'lint:schema', 'lint:yaml', :yamllint ] - - desc "Run yamllint command on all 'gems/*/*.yml' and 'rubies/*/*.yml' files" - task :yamllint do - abort "yamllint is not installed. Install it with: pip install yamllint" \ - unless system("which yamllint > /dev/null 2>&1") - - sh "yamllint gems rubies" - end - - task :default => [ :lint ] -end - -desc "Sync GitHub RubyGem Advisories into this project" -task :sync_github_advisories, [:gem_name] do |_, args| - require_relative "lib/github_advisory_sync" - GitHub::GitHubAdvisorySync.sync(gem_name: args[:gem_name]) - sh "bash lib/rad-ignores.sh" -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"