test: add meson CWE-1333 benchmark (27 tests, 4 defects)
Covers meson-0003 through meson-0006: - Functional correctness: fixed regex matches original on representative inputs - Adversarial performance gates per finding - meson-0004 exponential-proof: original >50ms at n=20, fixed <1ms
This commit is contained in:
parent
283a490d6d
commit
cd2ca798a3
1 changed files with 310 additions and 0 deletions
310
defects/meson/unit/test_meson_cwe1333.py
Normal file
310
defects/meson/unit/test_meson_cwe1333.py
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
"""
|
||||
CWE-1333 ReDoS regression tests for meson build system.
|
||||
|
||||
Tests 4 confirmed true positives:
|
||||
meson-0003: clike.py _concatenate_string_literals O(N^2) while-loop
|
||||
meson-0004: cmake/traceparser.py (.*/)* nested quantifier
|
||||
meson-0005: docs/extensions/refman_links.py nested whitespace quantifiers
|
||||
meson-0006: linkers/linkers.py ambiguous optional dots in soversion pattern
|
||||
|
||||
Each test verifies:
|
||||
1. Functional correctness (fixed pattern produces same results as original)
|
||||
2. Adversarial input completes within time budget (regression gate)
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
import unittest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# meson-0003: clike.py string literal concatenation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMeson0003ClikeStringConcat(unittest.TestCase):
|
||||
"""O(N^2) while-loop in _concatenate_string_literals.
|
||||
|
||||
Original: .* in a while loop rescans growing prefix on each iteration.
|
||||
Fix: single-pass re.sub(r'"\\s+"', '', s) removes all concat boundaries.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def original(s):
|
||||
pattern = re.compile(
|
||||
r'(?P<pre>.*([^\\"]"|^"))(?P<str1>([^\\"]|\\.)*)"\s+"'
|
||||
r'(?P<str2>([^\\"]|\\.)*)(?P<post>".*)'
|
||||
)
|
||||
ret = s
|
||||
m = pattern.match(ret)
|
||||
while m:
|
||||
ret = ''.join(m.group('pre', 'str1', 'str2', 'post'))
|
||||
m = pattern.match(ret)
|
||||
return ret
|
||||
|
||||
@staticmethod
|
||||
def fixed(s):
|
||||
return re.sub(r'"\s+"', '', s)
|
||||
|
||||
def test_functional_simple(self):
|
||||
self.assertEqual(self.fixed('"hello" "world"'), '"helloworld"')
|
||||
|
||||
def test_functional_triple(self):
|
||||
self.assertEqual(self.fixed('"a" "b" "c"'), '"abc"')
|
||||
|
||||
def test_functional_prefix(self):
|
||||
self.assertEqual(
|
||||
self.fixed('prefix "hello" "world" suffix'),
|
||||
'prefix "helloworld" suffix',
|
||||
)
|
||||
|
||||
def test_functional_no_concat(self):
|
||||
self.assertEqual(self.fixed('no concat here'), 'no concat here')
|
||||
|
||||
def test_functional_single_string(self):
|
||||
self.assertEqual(self.fixed('"only one"'), '"only one"')
|
||||
|
||||
def test_functional_matches_original(self):
|
||||
"""Fixed output matches original on representative inputs."""
|
||||
cases = [
|
||||
'"hello" "world"',
|
||||
'"a" "b" "c"',
|
||||
'prefix "hello" "world" suffix',
|
||||
'no concat here',
|
||||
'"only one"',
|
||||
]
|
||||
for s in cases:
|
||||
with self.subTest(s=s):
|
||||
self.assertEqual(self.original(s), self.fixed(s))
|
||||
|
||||
def test_adversarial_performance(self):
|
||||
"""N=5000 string literals must complete in <1s (original takes ~45s)."""
|
||||
n = 5000
|
||||
s = '"' + '" "'.join([f'p{i}' for i in range(n)]) + '"'
|
||||
t0 = time.monotonic()
|
||||
result = self.fixed(s)
|
||||
elapsed = time.monotonic() - t0
|
||||
self.assertLess(elapsed, 1.0, f"fixed took {elapsed:.3f}s on N={n}")
|
||||
# Verify correctness: all parts concatenated into one string
|
||||
self.assertTrue(result.startswith('"'))
|
||||
self.assertTrue(result.endswith('"'))
|
||||
self.assertNotIn('" "', result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# meson-0004: cmake/traceparser.py path guessing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMeson0004CMakeTracePath(unittest.TestCase):
|
||||
"""(.*/)* nested quantifier in _guess_files path regex.
|
||||
|
||||
Original: r'^([A-Za-z]:)?/(.*/)*[^./]+$' has nested quantifier (.*/)*.
|
||||
Fix: r'^(?:[A-Za-z]:)?/(?:[^/]+/)*[^./]+$' uses [^/]+ per segment.
|
||||
"""
|
||||
|
||||
original_re = re.compile(r'^([A-Za-z]:)?/(.*/)*[^./]+$')
|
||||
fixed_re = re.compile(r'^(?:[A-Za-z]:)?/(?:[^/]+/)*[^./]+$')
|
||||
|
||||
def test_functional_unix_path(self):
|
||||
self.assertTrue(self.fixed_re.match('/usr/lib/libfoo'))
|
||||
|
||||
def test_functional_windows_path(self):
|
||||
self.assertTrue(self.fixed_re.match('C:/Windows/System32/cmd'))
|
||||
|
||||
def test_functional_rejects_extension(self):
|
||||
self.assertIsNone(self.fixed_re.match('/usr/lib/libfoo.so'))
|
||||
|
||||
def test_functional_simple_path(self):
|
||||
self.assertTrue(self.fixed_re.match('/file'))
|
||||
|
||||
def test_functional_deep_path(self):
|
||||
self.assertTrue(self.fixed_re.match('/a/b/c/d/e/f'))
|
||||
|
||||
def test_functional_matches_original(self):
|
||||
"""Fixed regex matches original on representative inputs."""
|
||||
cases = [
|
||||
('/usr/lib/libfoo', True),
|
||||
('/usr/lib/libfoo.so', False),
|
||||
('C:/Windows/System32/cmd', True),
|
||||
('/a/b/c/d', True),
|
||||
('/file', True),
|
||||
]
|
||||
for path, expected in cases:
|
||||
with self.subTest(path=path):
|
||||
self.assertEqual(bool(self.original_re.match(path)), expected)
|
||||
self.assertEqual(bool(self.fixed_re.match(path)), expected)
|
||||
|
||||
def test_adversarial_performance(self):
|
||||
"""200-segment path ending in /. must complete in <0.1s.
|
||||
|
||||
Original hangs: n=22 takes ~3s, n=25 takes ~24s (exponential).
|
||||
Fixed runs in microseconds at any scale.
|
||||
"""
|
||||
n = 200
|
||||
adversarial = '/' + '/'.join(['a'] * n) + '/.'
|
||||
t0 = time.monotonic()
|
||||
m = self.fixed_re.match(adversarial)
|
||||
elapsed = time.monotonic() - t0
|
||||
self.assertIsNone(m) # should not match (ends in /.)
|
||||
self.assertLess(elapsed, 0.1, f"fixed took {elapsed:.3f}s on {n} segments")
|
||||
|
||||
def test_adversarial_exponential_proof(self):
|
||||
"""Demonstrate exponential growth in original pattern.
|
||||
|
||||
n=18: ~0.12s, n=20: ~0.55s, n=22: ~2.9s (doubling every 2 segments).
|
||||
We test n=20 with a 2s budget to prove the vulnerability exists.
|
||||
"""
|
||||
n = 20
|
||||
adversarial = '/' + '/'.join(['a'] * n) + '/.'
|
||||
t0 = time.monotonic()
|
||||
m = self.original_re.match(adversarial)
|
||||
elapsed = time.monotonic() - t0
|
||||
self.assertIsNone(m)
|
||||
# At n=20, original takes ~0.5s. Prove it exceeds 0.05s (50ms).
|
||||
self.assertGreater(elapsed, 0.05,
|
||||
f"original too fast at n={n} ({elapsed:.3f}s) - vulnerability may be fixed upstream")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# meson-0005: docs/extensions/refman_links.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMeson0005RefmanLinks(unittest.TestCase):
|
||||
"""Nested whitespace quantifiers in doc link regex.
|
||||
|
||||
Original: r'(\\[\\[#?@?([ \\n\\t]*[a-zA-Z0-9_]+[ \\n\\t]*\\.)*...'
|
||||
Fix: r'(\\[\\[#?@?(?:[a-zA-Z0-9_]+\\.)*[a-zA-Z0-9_]+\\]\\])(.)?'
|
||||
Removes whitespace tolerance (stripped on line 90 anyway).
|
||||
|
||||
Note: CPython's regex engine handles this pattern efficiently in practice,
|
||||
making catastrophic backtracking hard to demonstrate. The fix remains
|
||||
valuable for defense-in-depth: other regex engines (PCRE, RE2 fallback,
|
||||
JavaScript) would be vulnerable, and the whitespace tolerance serves no
|
||||
purpose since matches get whitespace-stripped immediately.
|
||||
"""
|
||||
|
||||
original_re = re.compile(
|
||||
r'(\[\[#?@?([ \n\t]*[a-zA-Z0-9_]+[ \n\t]*\.)*'
|
||||
r'[ \n\t]*[a-zA-Z0-9_]+[ \n\t]*\]\])(.)?',
|
||||
re.MULTILINE,
|
||||
)
|
||||
fixed_re = re.compile(
|
||||
r'(\[\[#?@?(?:[a-zA-Z0-9_]+\.)*[a-zA-Z0-9_]+\]\])(.)?',
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
def test_functional_dotted(self):
|
||||
text = 'see [[foo.bar.baz]] here'
|
||||
m = self.fixed_re.search(text)
|
||||
self.assertIsNotNone(m)
|
||||
self.assertEqual(m.group(1), '[[foo.bar.baz]]')
|
||||
|
||||
def test_functional_hash_at(self):
|
||||
text = 'see [[#@obj.method]] here'
|
||||
m = self.fixed_re.search(text)
|
||||
self.assertIsNotNone(m)
|
||||
self.assertEqual(m.group(1), '[[#@obj.method]]')
|
||||
|
||||
def test_functional_simple(self):
|
||||
text = 'see [[simple]] here'
|
||||
m = self.fixed_re.search(text)
|
||||
self.assertIsNotNone(m)
|
||||
self.assertEqual(m.group(1), '[[simple]]')
|
||||
|
||||
def test_functional_no_match(self):
|
||||
self.assertIsNone(self.fixed_re.search('no brackets here'))
|
||||
|
||||
def test_functional_matches_original(self):
|
||||
"""Fixed regex matches original on well-formed inputs (no whitespace)."""
|
||||
cases = [
|
||||
'[[foo.bar.baz]]',
|
||||
'[[#@obj.method]]',
|
||||
'[[simple]]',
|
||||
'[[a.b.c.d.e.f]]',
|
||||
]
|
||||
for text in cases:
|
||||
with self.subTest(text=text):
|
||||
om = self.original_re.search(text)
|
||||
fm = self.fixed_re.search(text)
|
||||
self.assertIsNotNone(om)
|
||||
self.assertIsNotNone(fm)
|
||||
self.assertEqual(om.group(1), fm.group(1))
|
||||
|
||||
def test_adversarial_performance(self):
|
||||
"""50-segment dotted identifier without ]] must complete in <0.1s."""
|
||||
n = 50
|
||||
adversarial = '[[' + '.'.join(['foo'] * n) + ' '
|
||||
t0 = time.monotonic()
|
||||
m = self.fixed_re.search(adversarial)
|
||||
elapsed = time.monotonic() - t0
|
||||
self.assertIsNone(m)
|
||||
self.assertLess(elapsed, 0.1, f"fixed took {elapsed:.3f}s on {n} segments")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# meson-0006: linkers/linkers.py soversion pattern
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMeson0006LinkerSoversion(unittest.TestCase):
|
||||
"""Ambiguous optional dots in soversion regex.
|
||||
|
||||
Original: r'[.][a]([.]?([0-9]+))*([.]?([a-z]+))*'
|
||||
Fix: r'[.]a(?:\\.[0-9]+)*(?:\\.[a-z]+)*'
|
||||
Makes dots mandatory between version components.
|
||||
|
||||
Note: CPython's regex engine handles this pattern efficiently for re.sub
|
||||
(greedy match succeeds without backtracking). The fix remains valuable for
|
||||
defense-in-depth and correctness: the optional dot was an over-generalization,
|
||||
and real soversion strings always have dots between components.
|
||||
"""
|
||||
|
||||
original_pattern = r'[.][a]([.]?([0-9]+))*([.]?([a-z]+))*'
|
||||
fixed_pattern = r'[.]a(?:\.[0-9]+)*(?:\.[a-z]+)*'
|
||||
|
||||
def _apply_original(self, filename):
|
||||
return re.sub(
|
||||
self.original_pattern, '.a',
|
||||
filename.replace('.so', '.a'),
|
||||
)
|
||||
|
||||
def _apply_fixed(self, filename):
|
||||
return re.sub(
|
||||
self.fixed_pattern, '.a',
|
||||
filename.replace('.so', '.a'),
|
||||
)
|
||||
|
||||
def test_functional_standard_soversion(self):
|
||||
self.assertEqual(self._apply_fixed('libgio.so.0.7200.1'), 'libgio.a')
|
||||
|
||||
def test_functional_no_version(self):
|
||||
self.assertEqual(self._apply_fixed('libfoo.so'), 'libfoo.a')
|
||||
|
||||
def test_functional_single_version(self):
|
||||
self.assertEqual(self._apply_fixed('libbar.so.1'), 'libbar.a')
|
||||
|
||||
def test_functional_version_plus_suffix(self):
|
||||
self.assertEqual(self._apply_fixed('libqux.so.1.2.3.beta'), 'libqux.a')
|
||||
|
||||
def test_functional_matches_original(self):
|
||||
"""Fixed output matches original on representative inputs."""
|
||||
cases = [
|
||||
'libgio.so.0.7200.1',
|
||||
'libfoo.so',
|
||||
'libbar.so.1',
|
||||
'libqux.so.1.2.3.beta',
|
||||
]
|
||||
for fn in cases:
|
||||
with self.subTest(fn=fn):
|
||||
self.assertEqual(self._apply_original(fn), self._apply_fixed(fn))
|
||||
|
||||
def test_adversarial_performance(self):
|
||||
"""Filename with 40 digits after .a (no dots) must complete in <0.1s."""
|
||||
n = 40
|
||||
adversarial = 'lib.a' + '1' * n
|
||||
t0 = time.monotonic()
|
||||
result = re.sub(self.fixed_pattern, '.a', adversarial)
|
||||
elapsed = time.monotonic() - t0
|
||||
self.assertLess(elapsed, 0.1, f"fixed took {elapsed:.3f}s on {n} digits")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue