Skip to content

Commit 876e4ed

Browse files
[Bug ]Fix Kibana version parsing for package version (#5962)
* [Bug ]Fix kibana version parsing for package version --------- Co-authored-by: Shashank K S <Shashank.Suryanarayana@elastic.co>
1 parent aa89d25 commit 876e4ed

3 files changed

Lines changed: 343 additions & 33 deletions

File tree

detection_rules/integrations.py

Lines changed: 83 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import fnmatch
99
import gzip
1010
import json
11-
import re
1211
from collections import OrderedDict, defaultdict
1312
from collections.abc import Iterator
1413
from pathlib import Path
@@ -182,6 +181,69 @@ def build_integrations_schemas(overwrite: bool, integration: str | None = None)
182181
print(f"final integrations manifests dumped: {SCHEMA_FILE_PATH}")
183182

184183

184+
def _parse_clause(clause: str) -> tuple[Version, Version | None]:
185+
"""Parse a single AND'd clause of npm-style range tokens into ``[lo, hi)`` bounds.
186+
187+
``hi`` is ``None`` when the clause has no upper bound. Supports the subset of
188+
npm semver currently emitted by EPR ``conditions.kibana.version`` strings:
189+
``^X.Y.Z``, ``~X.Y.Z``, ``>=X.Y.Z``, ``>X.Y.Z``, ``<=X.Y.Z``, ``<X.Y.Z``,
190+
``=X.Y.Z``, and bare ``X.Y.Z``. Unsupported tokens raise ``ValueError`` so
191+
we fail loudly if EPR's grammar grows.
192+
"""
193+
lo = Version(0, 0, 0)
194+
hi: Version | None = None
195+
196+
def tighten_hi(current: Version | None, candidate: Version) -> Version:
197+
return candidate if current is None else min(current, candidate)
198+
199+
for token in clause.strip().split():
200+
if not token:
201+
continue
202+
if token.startswith("^"):
203+
base = Version.parse(token[1:])
204+
if base.major == 0:
205+
raise ValueError(f"caret on 0.x kibana version is unsupported: {token!r}")
206+
lo = max(lo, base)
207+
hi = tighten_hi(hi, Version(base.major + 1, 0, 0))
208+
elif token.startswith("~"):
209+
base = Version.parse(token[1:])
210+
lo = max(lo, base)
211+
hi = tighten_hi(hi, Version(base.major, base.minor + 1, 0))
212+
elif token.startswith(">="):
213+
lo = max(lo, Version.parse(token[2:]))
214+
elif token.startswith("<="):
215+
hi = tighten_hi(hi, Version.parse(token[2:]).bump_patch())
216+
elif token.startswith(">"):
217+
lo = max(lo, Version.parse(token[1:]).bump_patch())
218+
elif token.startswith("<"):
219+
hi = tighten_hi(hi, Version.parse(token[1:]))
220+
elif token.startswith("="):
221+
exact = Version.parse(token[1:])
222+
lo = max(lo, exact)
223+
hi = tighten_hi(hi, exact.bump_patch())
224+
elif token[0].isdigit():
225+
exact = Version.parse(token)
226+
lo = max(lo, exact)
227+
hi = tighten_hi(hi, exact.bump_patch())
228+
else:
229+
raise ValueError(f"unsupported kibana version token: {token!r}")
230+
return lo, hi
231+
232+
233+
def _parse_kibana_range(version_requirement: str) -> list[tuple[Version, Version | None]]:
234+
"""Parse an EPR ``conditions.kibana.version`` string into a list of ``[lo, hi)`` clauses.
235+
236+
Clauses separated by ``||`` are OR'd; whitespace-separated tokens within a
237+
clause are AND'd.
238+
"""
239+
return [_parse_clause(c) for c in version_requirement.split("||")]
240+
241+
242+
def _satisfies_kibana_range(stack: Version, version_requirement: str) -> bool:
243+
"""Return True iff ``stack`` satisfies the EPR ``conditions.kibana.version`` string."""
244+
return any(lo <= stack and (hi is None or stack < hi) for lo, hi in _parse_kibana_range(version_requirement))
245+
246+
185247
def find_least_compatible_version(
186248
package: str,
187249
integration: str,
@@ -207,14 +269,9 @@ def find_least_compatible_version(
207269
for version, manifest in OrderedDict(
208270
sorted(major_integration_manifests.items(), key=lambda x: Version.parse(x[0]))
209271
).items():
210-
compatible_versions = re.sub(r"\>|\<|\=|\^|\~", "", manifest["conditions"]["kibana"]["version"]).split(
211-
" || "
212-
)
213-
for kibana_ver in compatible_versions:
214-
_kibana_ver = Version.parse(kibana_ver)
215-
# check versions have the same major
216-
if _kibana_ver.major == stack_version.major and _kibana_ver <= stack_version:
217-
return f"^{version}"
272+
version_requirement = manifest["conditions"]["kibana"]["version"]
273+
if _satisfies_kibana_range(stack_version, version_requirement):
274+
return f"^{version}"
218275

219276
raise ValueError(f"no compatible version for integration {package}:{integration}")
220277

@@ -236,38 +293,32 @@ def find_latest_compatible_version(
236293

237294
# Converts the dict keys (version numbers) to Version objects for proper sorting (descending)
238295
integration_manifests = sorted(package_manifest.items(), key=lambda x: Version.parse(x[0]), reverse=True)
239-
notice = [""]
296+
notice: list[str] = [""]
297+
newest_skipped: tuple[str, Version] | None = None
240298

241299
for version, manifest in integration_manifests:
242300
kibana_conditions = manifest.get("conditions", {}).get("kibana", {})
243301
version_requirement = kibana_conditions.get("version")
244302
if not version_requirement:
245303
raise ValueError(f"Manifest for {package}:{integration} version {version} is missing conditions.")
246304

247-
compatible_versions = re.sub(r"\>|\<|\=|\^|\~", "", version_requirement).split(" || ")
248-
249-
if not compatible_versions:
250-
raise ValueError(f"Manifest for {package}:{integration} version {version} is missing compatible versions")
251-
252-
highest_compatible_version = Version.parse(max(compatible_versions, key=Version.parse))
253-
254-
if highest_compatible_version > rule_stack_version:
255-
# generate notice message that a later integration version is available
256-
integration = f" {integration.strip()}" if integration else ""
257-
258-
notice = [
259-
f"There is a new integration {package}{integration} version {version} available!",
260-
f"Update the rule min_stack version from {rule_stack_version} to "
261-
f"{highest_compatible_version} if using new features in this latest version.",
262-
]
263-
264-
if highest_compatible_version.major == rule_stack_version.major:
305+
if _satisfies_kibana_range(rule_stack_version, version_requirement):
306+
if newest_skipped is not None:
307+
skipped_version, skipped_floor = newest_skipped
308+
integration_label = f" {integration.strip()}" if integration else ""
309+
notice = [
310+
f"There is a new integration {package}{integration_label} version {skipped_version} available!",
311+
f"Update the rule min_stack version from {rule_stack_version} to "
312+
f"{skipped_floor} if using new features in this latest version.",
313+
]
265314
return version, notice
266315

267-
# Check for rules that cross majors
268-
for compatible_version in compatible_versions:
269-
if Version.parse(compatible_version) <= rule_stack_version:
270-
return version, notice
316+
# Track the newest manifest we had to skip so the notice can still
317+
# point the reader at the most recent incompatible version and its floor.
318+
if newest_skipped is None:
319+
clauses = _parse_kibana_range(version_requirement)
320+
floor = min(lo for lo, _ in clauses)
321+
newest_skipped = (version, floor)
271322

272323
raise ValueError(f"no compatible version for integration {package}:{integration}")
273324

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "detection_rules"
3-
version = "1.6.20"
3+
version = "1.6.21"
44
description = "Detection Rules is the home for rules used by Elastic Security. This repository is used for the development, maintenance, testing, validation, and release of rules for Elastic Security’s Detection Engine."
55
readme = "README.md"
66
requires-python = ">=3.12"

0 commit comments

Comments
 (0)