From 97d32017c5afe0ab698ee84747c9f0b89f1e4e4e Mon Sep 17 00:00:00 2001 From: dylan Date: Thu, 26 Feb 2026 17:38:57 -0800 Subject: [PATCH 1/4] feat: add semver targeting support to local flag evaluation Implement 9 semver comparison operators (semver_eq, semver_neq, semver_gt, semver_gte, semver_lt, semver_lte, semver_tilde, semver_caret, semver_wildcard) for feature flag local evaluation. Uses regex-based parsing that matches the server-side sortableSemver behavior to handle v-prefix, whitespace, pre-release suffixes, and non-standard version formats. Co-Authored-By: Claude Haiku 4.5 --- posthog/feature_flags.py | 157 ++++++++++++++++++++++ posthog/test/test_feature_flags.py | 200 +++++++++++++++++++++++++++++ 2 files changed, 357 insertions(+) diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index 93b8119e..b2ba1ce7 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -14,6 +14,18 @@ __LONG_SCALE__ = float(0xFFFFFFFFFFFFFFF) +SEMVER_OPERATORS = ( + "semver_eq", + "semver_neq", + "semver_gt", + "semver_gte", + "semver_lt", + "semver_lte", + "semver_tilde", + "semver_caret", + "semver_wildcard", +) + log = logging.getLogger("posthog") NONE_VALUES_ALLOWED_OPERATORS = ["is_not"] @@ -505,6 +517,69 @@ def compare(lhs, rhs, operator): "The date provided must be a string or date object" ) + if operator in SEMVER_OPERATORS: + try: + override_parsed = parse_semver(override_value) + except (ValueError, TypeError): + raise InconclusiveMatchError( + f"Person property value '{override_value}' is not a valid semver" + ) + + if operator in ( + "semver_eq", + "semver_neq", + "semver_gt", + "semver_gte", + "semver_lt", + "semver_lte", + ): + try: + flag_parsed = parse_semver(value) + except (ValueError, TypeError): + raise InconclusiveMatchError( + f"Flag semver value '{value}' is not a valid semver" + ) + + if operator == "semver_eq": + return override_parsed == flag_parsed + elif operator == "semver_neq": + return override_parsed != flag_parsed + elif operator == "semver_gt": + return override_parsed > flag_parsed + elif operator == "semver_gte": + return override_parsed >= flag_parsed + elif operator == "semver_lt": + return override_parsed < flag_parsed + elif operator == "semver_lte": + return override_parsed <= flag_parsed + + elif operator == "semver_tilde": + try: + lower, upper = _tilde_bounds(str(value)) + except (ValueError, TypeError): + raise InconclusiveMatchError( + f"Flag semver value '{value}' is not valid for tilde operator" + ) + return lower <= override_parsed < upper + + elif operator == "semver_caret": + try: + lower, upper = _caret_bounds(str(value)) + except (ValueError, TypeError): + raise InconclusiveMatchError( + f"Flag semver value '{value}' is not valid for caret operator" + ) + return lower <= override_parsed < upper + + elif operator == "semver_wildcard": + try: + lower, upper = _wildcard_bounds(str(value)) + except (ValueError, TypeError): + raise InconclusiveMatchError( + f"Flag semver value '{value}' is not valid for wildcard operator" + ) + return lower <= override_parsed < upper + # if we get here, we don't know how to handle the operator raise InconclusiveMatchError(f"Unknown operator {operator}") @@ -686,3 +761,85 @@ def relative_date_parse_for_feature_flag_matching( return parsed_dt else: return None + + +SEMVER_EXTRACT_RE = re.compile(r"(\d+(?:\.\d+)+)") + + +def parse_semver(value: str) -> tuple: + """Parse a semver string into a comparable (major, minor, patch) integer tuple. + + Matches the behavior of the sortableSemver HogQL function: + - Uses regex to extract version numbers from strings like "v1.2.3-alpha" + - Handles v-prefix, whitespace, pre-release suffixes + - Defaults missing components to 0 (e.g., 1.2 -> 1.2.0) + Raises ValueError if parsing fails. + """ + match = SEMVER_EXTRACT_RE.search(str(value)) + if not match: + raise ValueError("Invalid semver format") + + parts = match.group(1).split(".") + + major = int(parts[0]) + minor = int(parts[1]) if len(parts) > 1 else 0 + patch = int(parts[2]) if len(parts) > 2 else 0 + + return (major, minor, patch) + + +def _tilde_bounds(value: str) -> tuple: + """~1.2.3 means >=1.2.3 <1.3.0 (allows patch-level changes).""" + major, minor, patch = parse_semver(value) + return (major, minor, patch), (major, minor + 1, 0) + + +def _caret_bounds(value: str) -> tuple: + """Caret follows semver spec: + ^1.2.3 means >=1.2.3 <2.0.0 + ^0.2.3 means >=0.2.3 <0.3.0 + ^0.0.3 means >=0.0.3 <0.0.4 + """ + major, minor, patch = parse_semver(value) + lower = (major, minor, patch) + + if major > 0: + upper = (major + 1, 0, 0) + elif minor > 0: + upper = (0, minor + 1, 0) + else: + upper = (0, 0, patch + 1) + + return lower, upper + + +def _wildcard_bounds(value: str) -> tuple: + """Wildcard matching: + 1.* means >=1.0.0 <2.0.0 + 1.2.* means >=1.2.0 <1.3.0 + """ + # Strip wildcards and trailing dots, then extract version digits + cleaned = str(value).replace("*", "").rstrip(".") + if not cleaned: + raise ValueError("Invalid wildcard pattern") + + match = SEMVER_EXTRACT_RE.search(cleaned) + if match: + parts = match.group(1).split(".") + else: + # Try single number (e.g., "1" from "1.*") + cleaned = cleaned.strip() + if cleaned.isdigit(): + parts = [cleaned] + else: + raise ValueError("Invalid wildcard pattern") + + if len(parts) == 1: + major = int(parts[0]) + return (major, 0, 0), (major + 1, 0, 0) + elif len(parts) == 2: + major, minor = int(parts[0]), int(parts[1]) + return (major, minor, 0), (major, minor + 1, 0) + else: + major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2]) + return (major, minor, patch), (major, minor, patch + 1) diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index 1b0192ff..dd007197 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -4198,6 +4198,206 @@ def test_none_property_value_with_all_operators(self): with self.assertRaises(InconclusiveMatchError): self.assertFalse(match_property(property_k, {"key": "random"})) + def test_match_properties_semver_eq(self): + prop = self.property(key="version", value="1.2.3", operator="semver_eq") + self.assertTrue(match_property(prop, {"version": "1.2.3"})) + self.assertFalse(match_property(prop, {"version": "1.2.4"})) + self.assertFalse(match_property(prop, {"version": "1.2.2"})) + self.assertFalse(match_property(prop, {"version": "2.0.0"})) + + # Pre-release suffix is stripped for comparison + self.assertTrue(match_property(prop, {"version": "1.2.3-alpha.1"})) + + # Partial versions default missing parts to 0 + prop_partial = self.property(key="version", value="1.2", operator="semver_eq") + self.assertTrue(match_property(prop_partial, {"version": "1.2.0"})) + self.assertFalse(match_property(prop_partial, {"version": "1.2.1"})) + + def test_match_properties_semver_neq(self): + prop = self.property(key="version", value="1.2.3", operator="semver_neq") + self.assertFalse(match_property(prop, {"version": "1.2.3"})) + self.assertTrue(match_property(prop, {"version": "1.2.4"})) + self.assertTrue(match_property(prop, {"version": "2.0.0"})) + + def test_match_properties_semver_gt(self): + prop = self.property(key="version", value="1.2.3", operator="semver_gt") + self.assertTrue(match_property(prop, {"version": "1.2.4"})) + self.assertTrue(match_property(prop, {"version": "1.3.0"})) + self.assertTrue(match_property(prop, {"version": "2.0.0"})) + self.assertFalse(match_property(prop, {"version": "1.2.3"})) + self.assertFalse(match_property(prop, {"version": "1.2.2"})) + self.assertFalse(match_property(prop, {"version": "0.9.0"})) + + def test_match_properties_semver_gte(self): + prop = self.property(key="version", value="1.2.3", operator="semver_gte") + self.assertTrue(match_property(prop, {"version": "1.2.3"})) + self.assertTrue(match_property(prop, {"version": "1.2.4"})) + self.assertTrue(match_property(prop, {"version": "2.0.0"})) + self.assertFalse(match_property(prop, {"version": "1.2.2"})) + self.assertFalse(match_property(prop, {"version": "0.9.0"})) + + def test_match_properties_semver_lt(self): + prop = self.property(key="version", value="1.2.3", operator="semver_lt") + self.assertTrue(match_property(prop, {"version": "1.2.2"})) + self.assertTrue(match_property(prop, {"version": "1.1.0"})) + self.assertTrue(match_property(prop, {"version": "0.9.0"})) + self.assertFalse(match_property(prop, {"version": "1.2.3"})) + self.assertFalse(match_property(prop, {"version": "1.2.4"})) + self.assertFalse(match_property(prop, {"version": "2.0.0"})) + + def test_match_properties_semver_lte(self): + prop = self.property(key="version", value="1.2.3", operator="semver_lte") + self.assertTrue(match_property(prop, {"version": "1.2.3"})) + self.assertTrue(match_property(prop, {"version": "1.2.2"})) + self.assertTrue(match_property(prop, {"version": "0.9.0"})) + self.assertFalse(match_property(prop, {"version": "1.2.4"})) + self.assertFalse(match_property(prop, {"version": "2.0.0"})) + + def test_match_properties_semver_tilde(self): + # ~1.2.3 means >=1.2.3 <1.3.0 + prop = self.property(key="version", value="1.2.3", operator="semver_tilde") + self.assertTrue(match_property(prop, {"version": "1.2.3"})) + self.assertTrue(match_property(prop, {"version": "1.2.5"})) + self.assertTrue(match_property(prop, {"version": "1.2.99"})) + self.assertFalse(match_property(prop, {"version": "1.3.0"})) + self.assertFalse(match_property(prop, {"version": "1.2.2"})) + self.assertFalse(match_property(prop, {"version": "2.0.0"})) + + def test_match_properties_semver_caret(self): + # ^1.2.3 means >=1.2.3 <2.0.0 + prop = self.property(key="version", value="1.2.3", operator="semver_caret") + self.assertTrue(match_property(prop, {"version": "1.2.3"})) + self.assertTrue(match_property(prop, {"version": "1.9.0"})) + self.assertTrue(match_property(prop, {"version": "1.99.99"})) + self.assertFalse(match_property(prop, {"version": "2.0.0"})) + self.assertFalse(match_property(prop, {"version": "1.2.2"})) + self.assertFalse(match_property(prop, {"version": "0.9.0"})) + + # ^0.2.3 means >=0.2.3 <0.3.0 (leftmost non-zero is minor) + prop_zero_major = self.property( + key="version", value="0.2.3", operator="semver_caret" + ) + self.assertTrue(match_property(prop_zero_major, {"version": "0.2.3"})) + self.assertTrue(match_property(prop_zero_major, {"version": "0.2.9"})) + self.assertFalse(match_property(prop_zero_major, {"version": "0.3.0"})) + self.assertFalse(match_property(prop_zero_major, {"version": "1.0.0"})) + + # ^0.0.3 means >=0.0.3 <0.0.4 (leftmost non-zero is patch) + prop_zero_minor = self.property( + key="version", value="0.0.3", operator="semver_caret" + ) + self.assertTrue(match_property(prop_zero_minor, {"version": "0.0.3"})) + self.assertFalse(match_property(prop_zero_minor, {"version": "0.0.4"})) + self.assertFalse(match_property(prop_zero_minor, {"version": "0.1.0"})) + + def test_match_properties_semver_wildcard(self): + # 1.2.* means >=1.2.0 <1.3.0 + prop = self.property(key="version", value="1.2.*", operator="semver_wildcard") + self.assertTrue(match_property(prop, {"version": "1.2.0"})) + self.assertTrue(match_property(prop, {"version": "1.2.5"})) + self.assertTrue(match_property(prop, {"version": "1.2.99"})) + self.assertFalse(match_property(prop, {"version": "1.3.0"})) + self.assertFalse(match_property(prop, {"version": "1.1.9"})) + self.assertFalse(match_property(prop, {"version": "2.0.0"})) + + # 1.* means >=1.0.0 <2.0.0 + prop_major = self.property( + key="version", value="1.*", operator="semver_wildcard" + ) + self.assertTrue(match_property(prop_major, {"version": "1.0.0"})) + self.assertTrue(match_property(prop_major, {"version": "1.99.99"})) + self.assertFalse(match_property(prop_major, {"version": "2.0.0"})) + self.assertFalse(match_property(prop_major, {"version": "0.9.0"})) + + def test_match_properties_semver_with_prerelease(self): + # Pre-release suffixes are stripped before comparison + prop = self.property(key="version", value="1.2.3", operator="semver_gt") + self.assertTrue(match_property(prop, {"version": "1.3.0-beta.1"})) + self.assertFalse(match_property(prop, {"version": "1.2.2-rc.1"})) + + # Flag value can also have pre-release suffix + prop_pre = self.property( + key="version", value="1.2.3-alpha", operator="semver_gte" + ) + self.assertTrue(match_property(prop_pre, {"version": "1.2.3"})) + self.assertTrue(match_property(prop_pre, {"version": "2.0.0"})) + self.assertFalse(match_property(prop_pre, {"version": "1.2.2"})) + + def test_match_properties_semver_edge_cases(self): + """Test semver parsing handles v-prefix, whitespace, leading zeros, and other common formats.""" + prop = self.property(key="version", value="1.2.3", operator="semver_eq") + + # v-prefix: "v1.2.3" -> extracts "1.2.3" + self.assertTrue(match_property(prop, {"version": "v1.2.3"})) + + # Leading space: " 1.2.3" -> extracts "1.2.3" + self.assertTrue(match_property(prop, {"version": " 1.2.3"})) + + # Trailing space: "1.2.3 " -> extracts "1.2.3" + self.assertTrue(match_property(prop, {"version": "1.2.3 "})) + + # Leading zeros: "01.02.03" -> int("01")=1, int("02")=2, int("03")=3 + self.assertTrue(match_property(prop, {"version": "01.02.03"})) + + # Flag value with v-prefix + prop_v = self.property(key="version", value="v1.2.3", operator="semver_eq") + self.assertTrue(match_property(prop_v, {"version": "1.2.3"})) + + # 0.0.0 minimal version + prop_min = self.property(key="version", value="0.0.0", operator="semver_eq") + self.assertTrue(match_property(prop_min, {"version": "0.0.0"})) + + prop_gt_min = self.property(key="version", value="0.0.0", operator="semver_gt") + self.assertTrue(match_property(prop_gt_min, {"version": "0.0.1"})) + self.assertFalse(match_property(prop_gt_min, {"version": "0.0.0"})) + + # 4-part version: regex extracts "1.2.3.4" -> takes first 3 parts + prop_four = self.property(key="version", value="1.2.3", operator="semver_eq") + self.assertTrue(match_property(prop_four, {"version": "1.2.3.4"})) + + # Truly invalid values raise InconclusiveMatchError + with self.assertRaises(InconclusiveMatchError): + match_property(prop, {"version": "abc"}) + + with self.assertRaises(InconclusiveMatchError): + match_property(prop, {"version": ""}) + + # Leading dot: ".1.2.3" -> regex extracts "1.2.3" + self.assertTrue(match_property(prop, {"version": ".1.2.3"})) + + # Caret with v-prefix in flag value + prop_caret_v = self.property( + key="version", value="v1.2.3", operator="semver_caret" + ) + self.assertTrue(match_property(prop_caret_v, {"version": "1.5.0"})) + self.assertFalse(match_property(prop_caret_v, {"version": "2.0.0"})) + + # Wildcard with v-prefix in property value + prop_wild = self.property( + key="version", value="1.2.*", operator="semver_wildcard" + ) + self.assertTrue(match_property(prop_wild, {"version": "v1.2.5"})) + self.assertFalse(match_property(prop_wild, {"version": "v1.3.0"})) + + def test_match_properties_semver_invalid_values(self): + prop = self.property(key="version", value="1.2.3", operator="semver_eq") + + # Invalid person property value + with self.assertRaises(InconclusiveMatchError): + match_property(prop, {"version": "not-a-version"}) + + # Missing key + with self.assertRaises(InconclusiveMatchError): + match_property(prop, {"other_key": "1.2.3"}) + + # None override value returns False (handled before semver logic) + self.assertFalse(match_property(prop, {"version": None})) + + # Invalid flag value + prop_bad = self.property(key="version", value="not-valid", operator="semver_gt") + with self.assertRaises(InconclusiveMatchError): + match_property(prop_bad, {"version": "1.2.3"}) + def test_unknown_operator(self): property_a = self.property(key="key", value="2022-05-01", operator="is_unknown") with self.assertRaises(InconclusiveMatchError) as exception_context: From f891a71c9247c2db6f11761304f168ae3baa2f19 Mon Sep 17 00:00:00 2001 From: dylan Date: Thu, 26 Feb 2026 17:43:40 -0800 Subject: [PATCH 2/4] fix: guard against ReDoS in semver regex parsing Add input length limit before regex search to prevent polynomial backtracking on adversarial input (CodeQL py/polynomial-redos). --- posthog/feature_flags.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index b2ba1ce7..cf7928de 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -775,7 +775,10 @@ def parse_semver(value: str) -> tuple: - Defaults missing components to 0 (e.g., 1.2 -> 1.2.0) Raises ValueError if parsing fails. """ - match = SEMVER_EXTRACT_RE.search(str(value)) + text = str(value) + if len(text) > 200: + raise ValueError("Version string too long") + match = SEMVER_EXTRACT_RE.search(text) if not match: raise ValueError("Invalid semver format") From bfe4f7372885411a89b0976cdcd4c9d8fc31cde0 Mon Sep 17 00:00:00 2001 From: dylan Date: Thu, 26 Feb 2026 17:52:12 -0800 Subject: [PATCH 3/4] fix: replace regex with string parsing to resolve ReDoS warning Replace SEMVER_EXTRACT_RE regex with simple string splitting to eliminate nested quantifiers that CodeQL flagged as polynomial-redos. --- posthog/feature_flags.py | 37 ++++++++++-------------------- posthog/test/test_feature_flags.py | 5 ++-- 2 files changed, 15 insertions(+), 27 deletions(-) diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index cf7928de..cec5b6aa 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -763,30 +763,25 @@ def relative_date_parse_for_feature_flag_matching( return None -SEMVER_EXTRACT_RE = re.compile(r"(\d+(?:\.\d+)+)") - - def parse_semver(value: str) -> tuple: """Parse a semver string into a comparable (major, minor, patch) integer tuple. Matches the behavior of the sortableSemver HogQL function: - - Uses regex to extract version numbers from strings like "v1.2.3-alpha" - Handles v-prefix, whitespace, pre-release suffixes - Defaults missing components to 0 (e.g., 1.2 -> 1.2.0) Raises ValueError if parsing fails. """ - text = str(value) - if len(text) > 200: - raise ValueError("Version string too long") - match = SEMVER_EXTRACT_RE.search(text) - if not match: - raise ValueError("Invalid semver format") + text = str(value).strip().lstrip("vV") + # Strip pre-release/build metadata suffix + text = text.split("-")[0].split("+")[0] + parts = text.split(".") - parts = match.group(1).split(".") + if not parts or not parts[0]: + raise ValueError("Invalid semver format") major = int(parts[0]) - minor = int(parts[1]) if len(parts) > 1 else 0 - patch = int(parts[2]) if len(parts) > 2 else 0 + minor = int(parts[1]) if len(parts) > 1 and parts[1] else 0 + patch = int(parts[2]) if len(parts) > 2 and parts[2] else 0 return (major, minor, patch) @@ -821,21 +816,13 @@ def _wildcard_bounds(value: str) -> tuple: 1.* means >=1.0.0 <2.0.0 1.2.* means >=1.2.0 <1.3.0 """ - # Strip wildcards and trailing dots, then extract version digits - cleaned = str(value).replace("*", "").rstrip(".") + cleaned = str(value).strip().lstrip("vV").replace("*", "").rstrip(".") if not cleaned: raise ValueError("Invalid wildcard pattern") - match = SEMVER_EXTRACT_RE.search(cleaned) - if match: - parts = match.group(1).split(".") - else: - # Try single number (e.g., "1" from "1.*") - cleaned = cleaned.strip() - if cleaned.isdigit(): - parts = [cleaned] - else: - raise ValueError("Invalid wildcard pattern") + parts = [p for p in cleaned.split(".") if p] + if not parts: + raise ValueError("Invalid wildcard pattern") if len(parts) == 1: major = int(parts[0]) diff --git a/posthog/test/test_feature_flags.py b/posthog/test/test_feature_flags.py index dd007197..1d94c031 100644 --- a/posthog/test/test_feature_flags.py +++ b/posthog/test/test_feature_flags.py @@ -4362,8 +4362,9 @@ def test_match_properties_semver_edge_cases(self): with self.assertRaises(InconclusiveMatchError): match_property(prop, {"version": ""}) - # Leading dot: ".1.2.3" -> regex extracts "1.2.3" - self.assertTrue(match_property(prop, {"version": ".1.2.3"})) + # Leading dot: ".1.2.3" -> invalid, empty first component + with self.assertRaises(InconclusiveMatchError): + match_property(prop, {"version": ".1.2.3"}) # Caret with v-prefix in flag value prop_caret_v = self.property( From dd6f34494b82eb0fd503cf4f159bf98fb6c8d6bb Mon Sep 17 00:00:00 2001 From: dylan Date: Fri, 27 Feb 2026 13:33:24 -0800 Subject: [PATCH 4/4] refactor: inline semver operator tuple to match existing patterns --- posthog/feature_flags.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/posthog/feature_flags.py b/posthog/feature_flags.py index cec5b6aa..5a5bf8e9 100644 --- a/posthog/feature_flags.py +++ b/posthog/feature_flags.py @@ -14,18 +14,6 @@ __LONG_SCALE__ = float(0xFFFFFFFFFFFFFFF) -SEMVER_OPERATORS = ( - "semver_eq", - "semver_neq", - "semver_gt", - "semver_gte", - "semver_lt", - "semver_lte", - "semver_tilde", - "semver_caret", - "semver_wildcard", -) - log = logging.getLogger("posthog") NONE_VALUES_ALLOWED_OPERATORS = ["is_not"] @@ -517,7 +505,17 @@ def compare(lhs, rhs, operator): "The date provided must be a string or date object" ) - if operator in SEMVER_OPERATORS: + if operator in ( + "semver_eq", + "semver_neq", + "semver_gt", + "semver_gte", + "semver_lt", + "semver_lte", + "semver_tilde", + "semver_caret", + "semver_wildcard", + ): try: override_parsed = parse_semver(override_value) except (ValueError, TypeError):