Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ group :development do
gem 'pry'
gem 'nokogiri', '~> 1.0'
gem 'activesupport', '~> 8.0'
gem 'safe_yaml'
end
168 changes: 168 additions & 0 deletions docs/pessimistic-operator.md
Original file line number Diff line number Diff line change
@@ -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
<version_list> ::= <version_range>
| <version_range> "," <version_list>

<version_range> ::= <version_constraint>
| <version_constraint> " " <version_constraint>

<version_constraint> ::= <pessimistic_constraint>
| <comparison_constraint>
| <exact_version>

<pessimistic_constraint> ::= "~>" <ws> <version>

<comparison_constraint> ::= <comp_op> <ws> <version>

<comp_op> ::= "<" | "<=" | ">" | ">="

<exact_version> ::= <version>

<version> ::= <numeric_segments> <prerelease_opt>

<numeric_segments> ::= <numeric>
| <numeric> "." <numeric>
| <numeric> "." <numeric> "." <numeric>
| <numeric> "." <numeric> "." <numeric> "." <numeric>

<prerelease_opt> ::= ""
| "." <prerelease_segments>

<prerelease_segments> ::= <prerelease_atom>
| <prerelease_atom> "." <prerelease_segments>

<prerelease_atom> ::= <alphanum>
| <alphanum> <prerelease_atom>

<alphanum> ::= <digit> | <letter>

<letter> ::= "a" | "b" | "c" | ... | "z"
| "A" | "B" | "C" | ... | "Z"

<numeric> ::= <digit>
| <digit> <numeric>

<digit> ::= "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"

<ws> ::= " " | "\t"
```
121 changes: 121 additions & 0 deletions lib/cve-range-incorrectness-detector.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
#!/usr/bin/env ruby

# Usage: $0 <yml file(s)>

# frozen_string_literal: true
require "safe_yaml"
Comment thread
jasnow marked this conversation as resolved.

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
3 changes: 3 additions & 0 deletions lib/run-cve-range-incorrectness-detector.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
lib/cve-range-incorrectness-detector.rb \
$(find gems rubies -type f |grep yml$) \
| grep -v ": OK"
Loading