Skip to content

Commit 3b797bb

Browse files
Mikaayensongithub-actions[bot]
authored andcommitted
add new field related_integrations to the post build (#2060)
* add new field `related_integrations` to the post build * add exception for endpoint `integration` * Skip rules without related integrations * lint * refactor related_integrations to TOMLRuleContents class * update to reflect required_fields updates * add todo * add new line for linting * related_integrations updates, get_packaged_integrations returns list of dictionaries, started work on integrations py * build_integrations_manifest command completed * initial test completed for post-building related_integrations * removed get_integration_manifest method from rule, removed global integrations path * moved integration related methods to integrations.py and fixed flake issues * adjustments for PipedQuery from eql sequence rules and packages with no integration * adjusted github client import for integrations.py * Update detection_rules/devtools.py Co-authored-by: Justin Ibarra <[email protected]> * Update detection_rules/devtools.py Co-authored-by: Justin Ibarra <[email protected]> * added integration manifest schema, made adjustments * Update detection_rules/integrations.py * Update detection_rules/rule.py Co-authored-by: Justin Ibarra <[email protected]> * Update detection_rules/rule.py Co-authored-by: Justin Ibarra <[email protected]> * Update detection_rules/integrations.py Co-authored-by: Justin Ibarra <[email protected]> * Update detection_rules/integrations.py Co-authored-by: Justin Ibarra <[email protected]> * Update detection_rules/integrations.py Co-authored-by: Justin Ibarra <[email protected]> * Update detection_rules/rule.py Co-authored-by: Justin Ibarra <[email protected]> * removed get_integrations_package to consolidate code * removed type list return * adjusted import flake errors * Update detection_rules/integrations.py Co-authored-by: Justin Ibarra <[email protected]> * Update detection_rules/integrations.py Co-authored-by: Justin Ibarra <[email protected]> * adjusted indentation error * adjusted rule.get_packaged_integrations to account for kql.ast.OrExpr if event.dataset is not set * Update detection_rules/devtools.py Co-authored-by: Justin Ibarra <[email protected]> * Update detection_rules/devtools.py Co-authored-by: Justin Ibarra <[email protected]> * Update detection_rules/integrations.py Co-authored-by: Justin Ibarra <[email protected]> * Update detection_rules/integrations.py Co-authored-by: Justin Ibarra <[email protected]> * adjusted find_least_compatible_version in integrations.py * Update detection_rules/integrations.py Co-authored-by: Justin Ibarra <[email protected]> * fixed flake issues * adjusted get_packaged_integrations * iterate the ast for literal event.dataset values * Update detection_rules/integrations.py Co-authored-by: Justin Ibarra <[email protected]> * Update detection_rules/integrations.py Co-authored-by: Justin Ibarra <[email protected]> * Update detection_rules/integrations.py Co-authored-by: Justin Ibarra <[email protected]> * Update detection_rules/integrations.py Co-authored-by: Justin Ibarra <[email protected]> * made small adjustments to address errors during build manifests command * addressing integrations.find_least_compatible method to return None instead of raise error only * Update detection_rules/integrations.py Co-authored-by: Mika Ayenson <[email protected]> Co-authored-by: Justin Ibarra <[email protected]> Co-authored-by: Terrance DeJesus <[email protected]> Co-authored-by: Terrance DeJesus <[email protected]> (cherry picked from commit 7d973a3)
1 parent 7f0a140 commit 3b797bb

File tree

5 files changed

+205
-6
lines changed

5 files changed

+205
-6
lines changed

detection_rules/devtools.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from .schemas import definitions, get_stack_versions
4141
from .semver import Version
4242
from .utils import dict_hash, get_path, get_etc_path, load_dump
43+
from .integrations import build_integrations_manifest
4344

4445
RULES_DIR = get_path('rules')
4546
GH_CONFIG = Path.home() / ".config" / "gh" / "hosts.yml"
@@ -1088,3 +1089,17 @@ def get_branches(outfile: Path):
10881089
branch_list = get_stack_versions(drop_patch=True)
10891090
target_branches = json.dumps(branch_list[:-1]) + "\n"
10901091
outfile.write_text(target_branches)
1092+
1093+
1094+
@dev_group.group('integrations')
1095+
def integrations_group():
1096+
"""Commands for dev integrations methods."""
1097+
1098+
1099+
@integrations_group.command('build-manifests')
1100+
@click.option('--overwrite', '-o', is_flag=True, help="Overwrite the existing integrations-manifest.json.gz file")
1101+
@click.option("--token", required=True, prompt=get_github_token() is None, default=get_github_token(),
1102+
help="GitHub token to use for the PR", hide_input=True)
1103+
def build_integration_manifests(overwrite: bool, token: str):
1104+
"""Builds consolidated integrations manifests file."""
1105+
build_integrations_manifest(token, overwrite)
Binary file not shown.

detection_rules/integrations.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
2+
# or more contributor license agreements. Licensed under the Elastic License
3+
# 2.0; you may not use this file except in compliance with the Elastic License
4+
# 2.0.
5+
6+
"""Functions to support and interact with Kibana integrations."""
7+
import gzip
8+
import json
9+
import os
10+
import re
11+
from pathlib import Path
12+
from typing import Union
13+
14+
import yaml
15+
from marshmallow import EXCLUDE, Schema, fields, post_load
16+
17+
from .ghwrap import GithubClient
18+
from .semver import Version
19+
from .utils import INTEGRATION_RULE_DIR, cached, get_etc_path, read_gzip
20+
21+
MANIFEST_FILE_PATH = Path(get_etc_path('integration-manifests.json.gz'))
22+
23+
24+
@cached
25+
def load_integrations_manifests() -> dict:
26+
"""Load the consolidated integrations manifest."""
27+
return json.loads(read_gzip(get_etc_path('integration-manifests.json.gz')))
28+
29+
30+
class IntegrationManifestSchema(Schema):
31+
name = fields.Str(required=True)
32+
version = fields.Str(required=True)
33+
release = fields.Str(required=True)
34+
description = fields.Str(required=True)
35+
conditions = fields.Dict(required=True)
36+
policy_templates = fields.List(fields.Dict, required=True)
37+
owner = fields.Dict(required=True)
38+
39+
@post_load
40+
def transform_policy_template(self, data, **kwargs):
41+
data["policy_templates"] = [policy["name"] for policy in data["policy_templates"]]
42+
return data
43+
44+
45+
def build_integrations_manifest(token: str, overwrite: bool) -> None:
46+
"""Builds a new local copy of manifest.yaml from integrations Github."""
47+
if overwrite:
48+
if os.path.exists(MANIFEST_FILE_PATH):
49+
os.remove(MANIFEST_FILE_PATH)
50+
rule_integrations = [d.name for d in Path(INTEGRATION_RULE_DIR).glob('*') if d.is_dir()]
51+
if "endpoint" in rule_integrations:
52+
rule_integrations.remove("endpoint")
53+
54+
final_integration_manifests = {integration: {} for integration in rule_integrations}
55+
56+
# initialize github client and point to package-storage prod
57+
github = GithubClient(token)
58+
client = github.authenticated_client
59+
organization = client.get_organization("elastic")
60+
repository = organization.get_repo("package-storage")
61+
pkg_storage_prod_branch = repository.get_branch("production")
62+
pkg_storage_branch_sha = pkg_storage_prod_branch.commit.sha
63+
64+
for integration in rule_integrations:
65+
integration_manifests = get_integration_manifests(repository, pkg_storage_branch_sha,
66+
pkg_path=f"packages/{integration}")
67+
for manifest in integration_manifests:
68+
validated_manifest = IntegrationManifestSchema(unknown=EXCLUDE).load(manifest)
69+
package_version = validated_manifest.pop("version")
70+
final_integration_manifests[integration][package_version] = validated_manifest
71+
72+
manifest_file = gzip.open(MANIFEST_FILE_PATH, "w+")
73+
manifest_file_bytes = json.dumps(final_integration_manifests).encode("utf-8")
74+
manifest_file.write(manifest_file_bytes)
75+
76+
77+
def find_least_compatible_version(package: str, integration: str,
78+
current_stack_version: str, packages_manifest: dict) -> Union[str, None]:
79+
"""Finds least compatible version for specified integration based on stack version supplied."""
80+
integration_manifests = {k: v for k, v in sorted(packages_manifest[package].items(), key=Version)}
81+
82+
def compare_versions(int_ver: str, pkg_ver: str) -> bool:
83+
"""Compares integration and package version"""
84+
pkg_major, pkg_minor = Version(pkg_ver)
85+
integration_major, integration_minor = Version(int_ver)[:2]
86+
87+
if int(integration_major) < int(pkg_major) or int(pkg_major) > int(integration_major):
88+
return False
89+
90+
compatible = Version(int_ver) <= Version(pkg_ver)
91+
return compatible
92+
93+
for version, manifest in integration_manifests.items():
94+
for kibana_compat_vers in re.sub(r"\>|\<|\=|\^", "", manifest["conditions"]["kibana.version"]).split(" || "):
95+
if compare_versions(kibana_compat_vers, current_stack_version):
96+
return version
97+
print(f"no compatible version for integration {package}:{integration}")
98+
return None
99+
100+
101+
def get_integration_manifests(repository, sha: str, pkg_path: str) -> list:
102+
"""Iterates over specified integrations from package-storage and combines manifests per version."""
103+
integration = pkg_path.split("/")[-1]
104+
versioned_packages = repository.get_dir_contents(pkg_path, ref=sha)
105+
versions = [p.path.split("/")[-1] for p in versioned_packages]
106+
107+
manifests = []
108+
for version in versions:
109+
contents = repository.get_dir_contents(f"{pkg_path}/{version}", ref=sha)
110+
print(f"Processing {integration} - Version: {version}")
111+
112+
processing_version = contents[0].path.split("/")[2]
113+
manifest_content = [c for c in contents if "manifest" in c.path]
114+
115+
if len(manifest_content) < 1:
116+
raise Exception(f"manifest file does not exist for {integration}:{processing_version}")
117+
118+
path = manifest_content[0].path
119+
manifest_content = yaml.safe_load(repository.get_contents(path, ref=sha).decoded_content.decode())
120+
manifests.append(manifest_content)
121+
122+
return manifests

detection_rules/rule.py

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,14 @@
1717

1818
import eql
1919
import kql
20+
from kql.ast import FieldComparison
2021
from marko.block import Document as MarkoDocument
2122
from marko.ext.gfm import gfm
2223
from marshmallow import ValidationError, validates_schema
2324

2425
from . import beats, ecs, utils
26+
from .integrations import (find_least_compatible_version,
27+
load_integrations_manifests)
2528
from .misc import load_current_package_version
2629
from .mixins import MarshmallowDataclassMixin, StackCompatMixin
2730
from .rule_formatter import nested_normalize, toml_write
@@ -165,6 +168,12 @@ class RequiredFields:
165168
type: definitions.NonEmptyStr
166169
ecs: bool
167170

171+
@dataclass
172+
class RelatedIntegrations:
173+
package: definitions.NonEmptyStr
174+
version: definitions.NonEmptyStr
175+
integration: Optional[definitions.NonEmptyStr]
176+
168177
actions: Optional[list]
169178
author: List[str]
170179
building_block_type: Optional[str]
@@ -186,7 +195,7 @@ class RequiredFields:
186195
# explicitly NOT allowed!
187196
# output_index: Optional[str]
188197
references: Optional[List[str]]
189-
related_integrations: Optional[List[str]] = field(metadata=dict(metadata=dict(min_compat="8.3")))
198+
related_integrations: Optional[List[RelatedIntegrations]] = field(metadata=dict(metadata=dict(min_compat="8.3")))
190199
required_fields: Optional[List[RequiredFields]] = field(metadata=dict(metadata=dict(min_compat="8.3")))
191200
risk_score: definitions.RiskScore
192201
risk_score_mapping: Optional[List[RiskScoreMapping]]
@@ -665,7 +674,7 @@ def _post_dict_transform(self, obj: dict) -> dict:
665674
"""Transform the converted API in place before sending to Kibana."""
666675
super()._post_dict_transform(obj)
667676

668-
self.add_related_integrations(obj)
677+
self._add_related_integrations(obj)
669678
self._add_required_fields(obj)
670679
self._add_setup(obj)
671680

@@ -675,10 +684,37 @@ def _post_dict_transform(self, obj: dict) -> dict:
675684
subclass.from_dict(obj)
676685
return obj
677686

678-
def add_related_integrations(self, obj: dict) -> None:
687+
def _add_related_integrations(self, obj: dict) -> None:
679688
"""Add restricted field related_integrations to the obj."""
680-
# field_name = "related_integrations"
681-
...
689+
field_name = "related_integrations"
690+
package_integrations = obj.get(field_name, [])
691+
692+
if not package_integrations and self.metadata.integration:
693+
packages_manifest = load_integrations_manifests()
694+
current_stack_version = load_current_package_version()
695+
696+
if self.check_restricted_field_version(field_name):
697+
if isinstance(self.data, QueryRuleData) and self.data.language != 'lucene':
698+
package_integrations = self._get_packaged_integrations(packages_manifest)
699+
700+
if not package_integrations:
701+
return
702+
703+
for package in package_integrations:
704+
package["version"] = find_least_compatible_version(
705+
package=package["package"],
706+
integration=package["integration"],
707+
current_stack_version=current_stack_version,
708+
packages_manifest=packages_manifest)
709+
710+
# if integration is not a policy template remove
711+
if package["version"]:
712+
policy_templates = packages_manifest[
713+
package["package"]][package["version"]]["policy_templates"]
714+
if package["integration"] not in policy_templates:
715+
del package["integration"]
716+
717+
obj.setdefault("related_integrations", package_integrations)
682718

683719
def _add_required_fields(self, obj: dict) -> None:
684720
"""Add restricted field required_fields to the obj, derived from the query AST."""
@@ -689,7 +725,7 @@ def _add_required_fields(self, obj: dict) -> None:
689725
required_fields = []
690726

691727
field_name = "required_fields"
692-
if self.check_restricted_field_version(field_name=field_name):
728+
if required_fields and self.check_restricted_field_version(field_name=field_name):
693729
obj.setdefault(field_name, required_fields)
694730

695731
def _add_setup(self, obj: dict) -> None:
@@ -759,6 +795,31 @@ def compare_field_versions(min_stack: Version, max_stack: Version) -> bool:
759795
max_stack = max_stack or current_version
760796
return Version(min_stack) <= current_version >= Version(max_stack)
761797

798+
def _get_packaged_integrations(self, package_manifest: dict) -> Optional[List[dict]]:
799+
packaged_integrations = []
800+
datasets = set()
801+
802+
for node in self.data.get('ast', []):
803+
if isinstance(node, eql.ast.Comparison) and str(node.left) == 'event.dataset':
804+
datasets.update(set(n.value for n in node if isinstance(n, eql.ast.Literal)))
805+
elif isinstance(node, FieldComparison) and str(node.field) == 'event.dataset':
806+
datasets.update(set(str(n) for n in node if isinstance(n, kql.ast.Value)))
807+
808+
if not datasets:
809+
return
810+
811+
for value in sorted(datasets):
812+
integration = 'Unknown'
813+
if '.' in value:
814+
package, integration = value.split('.', 1)
815+
else:
816+
package = value
817+
818+
if package in list(package_manifest):
819+
packaged_integrations.append({"package": package, "integration": integration})
820+
821+
return packaged_integrations
822+
762823
@validates_schema
763824
def post_validation(self, value: dict, **kwargs):
764825
"""Additional validations beyond base marshmallow schemas."""

detection_rules/utils.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
CURR_DIR = os.path.dirname(os.path.abspath(__file__))
3434
ROOT_DIR = os.path.dirname(CURR_DIR)
3535
ETC_DIR = os.path.join(ROOT_DIR, "detection_rules", "etc")
36+
INTEGRATION_RULE_DIR = os.path.join(ROOT_DIR, "rules", "integrations")
3637

3738

3839
class NonelessDict(dict):

0 commit comments

Comments
 (0)