4
4
# 2.0.
5
5
6
6
"""Functions to support and interact with Kibana integrations."""
7
+ import glob
7
8
import gzip
8
9
import json
9
- import os
10
10
import re
11
11
from collections import OrderedDict
12
12
from pathlib import Path
13
+ from typing import Generator , Tuple , Union
13
14
14
15
import requests
16
+ import yaml
15
17
from marshmallow import EXCLUDE , Schema , fields , post_load
16
18
19
+ import kql
20
+
21
+ from . import ecs
22
+ from .beats import flatten_ecs_schema
17
23
from .semver import Version
18
- from .utils import cached , get_etc_path , read_gzip
24
+ from .utils import cached , get_etc_path , read_gzip , unzip
19
25
20
26
MANIFEST_FILE_PATH = Path (get_etc_path ('integration-manifests.json.gz' ))
27
+ SCHEMA_FILE_PATH = Path (get_etc_path ('integration-schemas.json.gz' ))
21
28
22
29
23
30
@cached
@@ -26,11 +33,18 @@ def load_integrations_manifests() -> dict:
26
33
return json .loads (read_gzip (get_etc_path ('integration-manifests.json.gz' )))
27
34
28
35
36
+ @cached
37
+ def load_integrations_schemas () -> dict :
38
+ """Load the consolidated integrations schemas."""
39
+ return json .loads (read_gzip (get_etc_path ('integration-schemas.json.gz' )))
40
+
41
+
29
42
class IntegrationManifestSchema (Schema ):
30
43
name = fields .Str (required = True )
31
44
version = fields .Str (required = True )
32
45
release = fields .Str (required = True )
33
46
description = fields .Str (required = True )
47
+ download = fields .Str (required = True )
34
48
conditions = fields .Dict (required = True )
35
49
policy_templates = fields .List (fields .Dict , required = True )
36
50
owner = fields .Dict (required = False )
@@ -44,8 +58,8 @@ def transform_policy_template(self, data, **kwargs):
44
58
def build_integrations_manifest (overwrite : bool , rule_integrations : list ) -> None :
45
59
"""Builds a new local copy of manifest.yaml from integrations Github."""
46
60
if overwrite :
47
- if os . path . exists (MANIFEST_FILE_PATH ):
48
- os . remove ( MANIFEST_FILE_PATH )
61
+ if MANIFEST_FILE_PATH . exists ():
62
+ MANIFEST_FILE_PATH . unlink ( )
49
63
50
64
final_integration_manifests = {integration : {} for integration in rule_integrations }
51
65
@@ -62,6 +76,63 @@ def build_integrations_manifest(overwrite: bool, rule_integrations: list) -> Non
62
76
print (f"final integrations manifests dumped: { MANIFEST_FILE_PATH } " )
63
77
64
78
79
+ def build_integrations_schemas (overwrite : bool ) -> None :
80
+ """Builds a new local copy of integration-schemas.json.gz from EPR integrations."""
81
+
82
+ final_integration_schemas = {}
83
+ saved_integration_schemas = {}
84
+
85
+ # Check if the file already exists and handle accordingly
86
+ if overwrite and SCHEMA_FILE_PATH .exists ():
87
+ SCHEMA_FILE_PATH .unlink ()
88
+ elif SCHEMA_FILE_PATH .exists ():
89
+ saved_integration_schemas = load_integrations_schemas ()
90
+
91
+ # Load the integration manifests
92
+ integration_manifests = load_integrations_manifests ()
93
+
94
+ # Loop through the packages and versions
95
+ for package , versions in integration_manifests .items ():
96
+ print (f"processing { package } " )
97
+ final_integration_schemas .setdefault (package , {})
98
+ for version , manifest in versions .items ():
99
+ if package in saved_integration_schemas and version in saved_integration_schemas [package ]:
100
+ continue
101
+
102
+ # Download the zip file
103
+ download_url = f"https://epr.elastic.co{ manifest ['download' ]} "
104
+ response = requests .get (download_url )
105
+ response .raise_for_status ()
106
+
107
+ # Update the final integration schemas
108
+ final_integration_schemas [package ].update ({version : {}})
109
+
110
+ # Open the zip file
111
+ with unzip (response .content ) as zip_ref :
112
+ for file in zip_ref .namelist ():
113
+ # Check if the file is a match
114
+ if glob .fnmatch .fnmatch (file , '*/fields/*.yml' ):
115
+ integration_name = Path (file ).parent .parent .name
116
+ final_integration_schemas [package ][version ].setdefault (integration_name , {})
117
+ file_data = zip_ref .read (file )
118
+ schema_fields = yaml .safe_load (file_data )
119
+
120
+ # Parse the schema and add to the integration_manifests
121
+ data = flatten_ecs_schema (schema_fields )
122
+ flat_data = {field ['name' ]: field ['type' ] for field in data }
123
+
124
+ final_integration_schemas [package ][version ][integration_name ].update (flat_data )
125
+
126
+ del file_data
127
+
128
+ # Write the final integration schemas to disk
129
+ with gzip .open (SCHEMA_FILE_PATH , "w" ) as schema_file :
130
+ schema_file_bytes = json .dumps (final_integration_schemas ).encode ("utf-8" )
131
+ schema_file .write (schema_file_bytes )
132
+
133
+ print (f"final integrations manifests dumped: { SCHEMA_FILE_PATH } " )
134
+
135
+
65
136
def find_least_compatible_version (package : str , integration : str ,
66
137
current_stack_version : str , packages_manifest : dict ) -> str :
67
138
"""Finds least compatible version for specified integration based on stack version supplied."""
@@ -89,12 +160,54 @@ def find_least_compatible_version(package: str, integration: str,
89
160
raise ValueError (f"no compatible version for integration { package } :{ integration } " )
90
161
91
162
163
+ def find_latest_compatible_version (package : str , integration : str ,
164
+ rule_stack_version : str , packages_manifest : dict ) -> Union [None , Tuple [str , str ]]:
165
+ """Finds least compatible version for specified integration based on stack version supplied."""
166
+
167
+ if not package :
168
+ raise ValueError ("Package must be specified" )
169
+
170
+ package_manifest = packages_manifest .get (package )
171
+ if package_manifest is None :
172
+ raise ValueError (f"Package { package } not found in manifest." )
173
+
174
+ # Converts the dict keys (version numbers) to Version objects for proper sorting (descending)
175
+ integration_manifests = sorted (package_manifest .items (), key = lambda x : Version (str (x [0 ])), reverse = True )
176
+ notice = ""
177
+
178
+ for version , manifest in integration_manifests :
179
+ kibana_conditions = manifest .get ("conditions" , {}).get ("kibana" , {})
180
+ version_requirement = kibana_conditions .get ("version" )
181
+ if not version_requirement :
182
+ raise ValueError (f"Manifest for { package } :{ integration } version { version } is missing conditions." )
183
+
184
+ compatible_versions = re .sub (r"\>|\<|\=|\^" , "" , version_requirement ).split (" || " )
185
+
186
+ if not compatible_versions :
187
+ raise ValueError (f"Manifest for { package } :{ integration } version { version } is missing compatible versions" )
188
+
189
+ highest_compatible_version = max (compatible_versions , key = lambda x : Version (x ))
190
+
191
+ if Version (highest_compatible_version ) > Version (rule_stack_version ):
192
+ # generate notice message that a later integration version is available
193
+ integration = f" { integration .strip ()} " if integration else ""
194
+
195
+ notice = (f"There is a new integration { package } { integration } version { version } available!" ,
196
+ f"Update the rule min_stack version from { rule_stack_version } to "
197
+ f"{ highest_compatible_version } if using new features in this latest version." )
198
+
199
+ elif int (highest_compatible_version [0 ]) == int (rule_stack_version [0 ]):
200
+ return version , notice
201
+
202
+ raise ValueError (f"no compatible version for integration { package } :{ integration } " )
203
+
204
+
92
205
def get_integration_manifests (integration : str ) -> list :
93
206
"""Iterates over specified integrations from package-storage and combines manifests per version."""
94
207
epr_search_url = "https://epr.elastic.co/search"
95
208
96
209
# link for search parameters - https://github.com/elastic/package-registry
97
- epr_search_parameters = {"package" : f"{ integration } " , "prerelease" : "true " ,
210
+ epr_search_parameters = {"package" : f"{ integration } " , "prerelease" : "false " ,
98
211
"all" : "true" , "include_policy_templates" : "true" }
99
212
epr_search_response = requests .get (epr_search_url , params = epr_search_parameters )
100
213
epr_search_response .raise_for_status ()
@@ -106,3 +219,63 @@ def get_integration_manifests(integration: str) -> list:
106
219
print (f"loaded { integration } manifests from the following package versions: "
107
220
f"{ [manifest ['version' ] for manifest in manifests ]} " )
108
221
return manifests
222
+
223
+
224
+ def get_integration_schema_data (data , meta , package_integrations : dict ) -> Generator [dict , None , None ]:
225
+ """Iterates over specified integrations from package-storage and combines schemas per version."""
226
+
227
+ # lazy import to avoid circular import
228
+ from .rule import ( # pylint: disable=import-outside-toplevel
229
+ QueryRuleData , RuleMeta
230
+ )
231
+
232
+ data : QueryRuleData = data
233
+ meta : RuleMeta = meta
234
+
235
+ packages_manifest = load_integrations_manifests ()
236
+ integrations_schemas = load_integrations_schemas ()
237
+
238
+ # validate the query against related integration fields
239
+ if isinstance (data , QueryRuleData ) and data .language != 'lucene' and meta .maturity == "production" :
240
+
241
+ # flag to only warn once per integration for available upgrades
242
+ notify_update_available = True
243
+
244
+ for stack_version , mapping in meta .get_validation_stack_versions ().items ():
245
+ ecs_version = mapping ['ecs' ]
246
+ endgame_version = mapping ['endgame' ]
247
+
248
+ ecs_schema = ecs .flatten_multi_fields (ecs .get_schema (ecs_version , name = 'ecs_flat' ))
249
+
250
+ for pk_int in package_integrations :
251
+ package = pk_int ["package" ]
252
+ integration = pk_int ["integration" ]
253
+
254
+ package_version , notice = find_latest_compatible_version (package = package ,
255
+ integration = integration ,
256
+ rule_stack_version = meta .min_stack_version ,
257
+ packages_manifest = packages_manifest )
258
+
259
+ if notify_update_available and notice and data .get ("notify" , False ):
260
+ # Notify for now, as to not lock rule stacks to integrations
261
+ notify_update_available = False
262
+ print (f"\n { data .get ('name' )} " )
263
+ print (* notice )
264
+
265
+ schema = {}
266
+ if integration is None :
267
+ # Use all fields from each dataset
268
+ for dataset in integrations_schemas [package ][package_version ]:
269
+ schema .update (integrations_schemas [package ][package_version ][dataset ])
270
+ else :
271
+ if integration not in integrations_schemas [package ][package_version ]:
272
+ raise ValueError (f"Integration { integration } not found in package { package } "
273
+ f"version { package_version } " )
274
+ schema = integrations_schemas [package ][package_version ][integration ]
275
+ schema .update (ecs_schema )
276
+ integration_schema = {k : kql .parser .elasticsearch_type_family (v ) for k , v in schema .items ()}
277
+
278
+ data = {"schema" : integration_schema , "package" : package , "integration" : integration ,
279
+ "stack_version" : stack_version , "ecs_version" : ecs_version ,
280
+ "package_version" : package_version , "endgame_version" : endgame_version }
281
+ yield data
0 commit comments