Every patch now ships with a runnable benchmark verifying complexity claims: - bleach/unit/test_bleach_cwe1333.py: length guard truncates 1001-char adversarial input to 1000 chars (removes '@' tail), gauntlet matches fast (<0.5s) - salt/unit/test_salt_cwe1333.py: ThreadPoolExecutor timeout wrapper tested at N=20 adversarial, GIL behavior documented - ansible/unit/test_ansible_cwe1333.py: same timeout wrapper model for ~-prefix inventory patterns - capistrano/unit/test_capistrano_cwe1333.rb: Regexp.timeout= / Timeout fallback guard for host/role filter patterns - puppet/unit/test_puppet_cwe1333.rb: RegexGuard.safe_compile timeout for all three Puppet regex call sites (match(), =~, PRegexpType) - katago/unit/test_katago_cwe407.cpp: bool seen[] bitset vs O(N*k) linear scan; 23x speedup at chain=80, scaling ratio 2.5x at 3x chain size (limit 4x) - pachi/unit/test_pachi_cwe362.c: 8-thread hammer, 100k iterations, zero double-expansion events with __atomic_store_n fix
204 lines
6.2 KiB
Ruby
204 lines
6.2 KiB
Ruby
#!/usr/bin/env ruby
|
|
# frozen_string_literal: true
|
|
#
|
|
# CWE-1333 benchmark for Puppet defects puppet-0001 and puppet-0002.
|
|
#
|
|
# UNDF: UNDF-2026-000001269 (puppet-0001, timeout wrapper)
|
|
# UNDF-2026-000001273 (puppet-0002, RE2 correct fix)
|
|
# Patches:
|
|
# puppet-0001.patch
|
|
# puppet-0002-regex-redos-re2.patch
|
|
#
|
|
# Defect: Three Puppet code paths compile user-supplied strings with bare Regexp.new():
|
|
# 1. lib/puppet/functions/match.rb:88 -- match() built-in
|
|
# 2. lib/puppet/pops/evaluator/evaluator_impl.rb:616 -- =~ operator
|
|
# 3. lib/puppet/pops/types/types.rb:1697 -- PRegexpType constructor
|
|
# A catalog containing "(a+)+$" as a pattern hangs the agent indefinitely.
|
|
#
|
|
# Fix puppet-0001: RegexGuard.safe_compile() with Regexp.timeout= / Timeout fallback.
|
|
# Fix puppet-0002: RE2::Regexp.new() -- Thompson NFA, O(N), no backtracking.
|
|
#
|
|
# Complexity gate:
|
|
# pattern "(a+)+$" against "a"*25+"b" must complete in <2s with puppet-0001
|
|
# RE2 version must complete in <50ms
|
|
|
|
require "timeout"
|
|
|
|
ADVERSARIAL_PATTERN = "(a+)+$"
|
|
ADVERSARIAL_INPUT_25 = "a" * 25 + "b"
|
|
ADVERSARIAL_INPUT_20 = "a" * 20 + "b"
|
|
BENIGN_PATTERN = "^foo-\\d+$"
|
|
BENIGN_STRING = "foo-42"
|
|
NONMATCH_STRING = "bar-42"
|
|
|
|
COMPILE_TIMEOUT = 1.0 # seconds
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Before: bare Regexp.new without timeout
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def safe_compile_before(pattern)
|
|
Regexp.new(pattern)
|
|
end
|
|
|
|
def do_match_before(string, pattern_str)
|
|
re = safe_compile_before(pattern_str)
|
|
re.match(string)
|
|
end
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# After (puppet-0001): RegexGuard.safe_compile equivalent
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def safe_compile_with_timeout(pattern, options = 0)
|
|
return pattern if pattern.is_a?(Regexp)
|
|
|
|
if Regexp.respond_to?(:timeout=)
|
|
old = Regexp.timeout
|
|
Regexp.timeout = COMPILE_TIMEOUT
|
|
begin
|
|
Regexp.new(pattern, options)
|
|
rescue Regexp::TimeoutError
|
|
raise "Regular expression #{pattern.inspect} timed out (CWE-1333)"
|
|
ensure
|
|
Regexp.timeout = old
|
|
end
|
|
else
|
|
Timeout.timeout(COMPILE_TIMEOUT) { Regexp.new(pattern, options) }
|
|
end
|
|
rescue RegexpError => e
|
|
raise "Invalid regular expression #{pattern.inspect}: #{e}"
|
|
rescue Timeout::Error
|
|
raise "Regular expression #{pattern.inspect} timed out (CWE-1333)"
|
|
end
|
|
|
|
def do_match_safe(string, pattern_str)
|
|
re = safe_compile_with_timeout(pattern_str)
|
|
re.match(string)
|
|
end
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# After (puppet-0002): RE2 correct fix
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def do_match_re2(string, pattern_str)
|
|
begin
|
|
require "re2"
|
|
re = RE2::Regexp.new(pattern_str)
|
|
re.match(string)
|
|
rescue LoadError
|
|
raise "re2 gem not installed -- skipping RE2 test"
|
|
end
|
|
end
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test runner
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def assert_truthy(val, msg)
|
|
if val
|
|
puts "PASS #{msg}"
|
|
else
|
|
puts "FAIL #{msg}: expected truthy, got #{val.inspect}"
|
|
exit 1
|
|
end
|
|
end
|
|
|
|
def assert_falsy(val, msg)
|
|
if !val
|
|
puts "PASS #{msg}"
|
|
else
|
|
puts "FAIL #{msg}: expected falsy, got #{val.inspect}"
|
|
exit 1
|
|
end
|
|
end
|
|
|
|
def assert_raises(msg, &block)
|
|
begin
|
|
block.call
|
|
puts "FAIL #{msg}: expected exception but none raised"
|
|
exit 1
|
|
rescue => e
|
|
puts "PASS #{msg}: raised #{e.class}"
|
|
end
|
|
end
|
|
|
|
def assert_lt(value, limit, msg)
|
|
if value < limit
|
|
puts "PASS #{msg} (#{(value * 1000).round(1)}ms < #{(limit * 1000).round}ms)"
|
|
else
|
|
puts "FAIL #{msg}: #{(value * 1000).round(1)}ms >= #{(limit * 1000).round}ms"
|
|
exit 1
|
|
end
|
|
end
|
|
|
|
|
|
# Test 1: benign pattern correctness -- before and after agree
|
|
before_match = do_match_before(BENIGN_STRING, BENIGN_PATTERN)
|
|
after_match = do_match_safe(BENIGN_STRING, BENIGN_PATTERN)
|
|
assert_truthy before_match, "puppet-0001 benign match: before"
|
|
assert_truthy after_match, "puppet-0001 benign match: after"
|
|
|
|
before_nomatch = do_match_before(NONMATCH_STRING, BENIGN_PATTERN)
|
|
after_nomatch = do_match_safe(NONMATCH_STRING, BENIGN_PATTERN)
|
|
assert_falsy before_nomatch, "puppet-0001 benign non-match: before"
|
|
assert_falsy after_nomatch, "puppet-0001 benign non-match: after"
|
|
|
|
|
|
# Test 2: invalid pattern raises (not silently ignored)
|
|
assert_raises("puppet-0001 invalid pattern raises") do
|
|
safe_compile_with_timeout("[invalid")
|
|
end
|
|
|
|
|
|
# Test 3: adversarial pattern raises timeout error in <2s
|
|
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
begin
|
|
do_match_safe(ADVERSARIAL_INPUT_25, ADVERSARIAL_PATTERN)
|
|
puts "FAIL puppet-0001 adversarial N=25: expected timeout/error"
|
|
exit 1
|
|
rescue => e
|
|
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
|
assert_lt elapsed, 2.0, "puppet-0001 complexity gate: adversarial N=25 timeout"
|
|
puts "PASS puppet-0001 adversarial N=25: #{e.message[0..60]}"
|
|
end
|
|
|
|
|
|
# Test 4: N=20 adversarial also completes fast
|
|
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
begin
|
|
do_match_safe(ADVERSARIAL_INPUT_20, ADVERSARIAL_PATTERN)
|
|
rescue => _e
|
|
end
|
|
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
|
assert_lt elapsed, 2.0, "puppet-0001 N=20 adversarial"
|
|
|
|
|
|
# Test 5: RE2 correct fix (if available)
|
|
begin
|
|
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
result_re2 = do_match_re2(ADVERSARIAL_INPUT_25, ADVERSARIAL_PATTERN)
|
|
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
|
|
assert_falsy result_re2, "puppet-0002 RE2 adversarial N=25: no match"
|
|
assert_lt elapsed, 0.05, "puppet-0002 complexity gate: RE2 N=25"
|
|
|
|
# Verify RE2 still matches benign patterns
|
|
result_benign = do_match_re2(BENIGN_STRING, BENIGN_PATTERN)
|
|
assert_truthy result_benign, "puppet-0002 RE2 benign match"
|
|
rescue RuntimeError => e
|
|
puts "SKIP puppet-0002 RE2: #{e}"
|
|
end
|
|
|
|
|
|
# Test 6: PRegexpType-style escaped pattern (escape=true path)
|
|
escaped = Regexp.escape("foo.bar") # safe path, no backtracking risk
|
|
re = safe_compile_with_timeout(escaped)
|
|
assert_truthy re.match?("foo.bar"), "puppet-0001 escaped pattern matches literal"
|
|
assert_falsy re.match?("fooXbar"), "puppet-0001 escaped pattern rejects non-literal"
|
|
|
|
|
|
puts "ALL PASS"
|