From cd2ca798a3a673efdab153c063c3d1117a79e120 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Mon, 13 Apr 2026 17:36:52 -0400 Subject: [PATCH] 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 --- defects/meson/unit/test_meson_cwe1333.py | 310 +++++++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 defects/meson/unit/test_meson_cwe1333.py diff --git a/defects/meson/unit/test_meson_cwe1333.py b/defects/meson/unit/test_meson_cwe1333.py new file mode 100644 index 000000000..0588b1c6d --- /dev/null +++ b/defects/meson/unit/test_meson_cwe1333.py @@ -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
.*([^\\"]"|^"))(?P([^\\"]|\\.)*)"\s+"'
+            r'(?P([^\\"]|\\.)*)(?P".*)'
+        )
+        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()