Skip to content

Commit 5b24eca

Browse files
[Bug] CLI Fixes (elastic#1073)
* add support for self-signed certs in es and kibana * allow Kibana to auth against any providerType * fix export-rules command * fix kibana upload-rule command * fix view-rule command * fix validate-rule command * fix search-rules command * fix dev kibana-diff command * fix dev package-stats command * fix dev search-rule-prs command * fix dev deprecate-rule command * replace toml with pytoml to fix import-rules command * use no_verify in get_kibana_client * use Path for rule-file type in view-rule * update schemas to resolve additionalProperties type bug * fix missing unique_fields in package rule filter * fix github pr loader * Load gh rules as TOMLRule instead of dict * remove unnecessary version insertion
1 parent 0875c1e commit 5b24eca

21 files changed

+391
-140
lines changed

detection_rules/devtools.py

+41-32
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
import time
1616
import typing
1717
from pathlib import Path
18-
from typing import Optional, Tuple, List
18+
from typing import Dict, Optional, Tuple, List
1919

2020
import click
2121
import yaml
@@ -32,6 +32,7 @@
3232
from .version_lock import manage_versions, load_versions
3333
from .rule import AnyRuleData, BaseRuleData, QueryRuleData, TOMLRule
3434
from .rule_loader import RuleCollection, production_filter
35+
from .schemas import definitions
3536
from .semver import Version
3637
from .utils import dict_hash, get_path, load_dump
3738

@@ -212,8 +213,6 @@ def kibana_diff(rule_id, repo, branch, threads):
212213
else:
213214
rules = rules.filter(production_filter).id_map
214215

215-
# add versions to the rules
216-
manage_versions(list(rules.values()), verbose=False)
217216
repo_hashes = {r.id: r.contents.sha256(include_version=True) for r in rules.values()}
218217

219218
kibana_rules = {r['rule_id']: r for r in get_kibana_rules(repo=repo, branch=branch, threads=threads).values()}
@@ -594,32 +593,39 @@ def search_rule_prs(ctx, no_loop, query, columns, language, token, threads):
594593
from uuid import uuid4
595594
from .main import search_rules
596595

597-
all_rules = {}
596+
all_rules: Dict[Path, TOMLRule] = {}
598597
new, modified, errors = rule_loader.load_github_pr_rules(token=token, threads=threads)
599598

600-
def add_github_meta(this_rule, status, original_rule_id=None):
599+
def add_github_meta(this_rule: TOMLRule, status: str, original_rule_id: Optional[definitions.UUIDString] = None):
601600
pr = this_rule.gh_pr
602-
rule.metadata['status'] = status
603-
rule.metadata['github'] = {
604-
'base': pr.base.label,
605-
'comments': [c.body for c in pr.get_comments()],
606-
'commits': pr.commits,
607-
'created_at': str(pr.created_at),
608-
'head': pr.head.label,
609-
'is_draft': pr.draft,
610-
'labels': [lbl.name for lbl in pr.get_labels()],
611-
'last_modified': str(pr.last_modified),
612-
'title': pr.title,
613-
'url': pr.html_url,
614-
'user': pr.user.login
601+
data = rule.contents.data
602+
extend_meta = {
603+
'status': status,
604+
'github': {
605+
'base': pr.base.label,
606+
'comments': [c.body for c in pr.get_comments()],
607+
'commits': pr.commits,
608+
'created_at': str(pr.created_at),
609+
'head': pr.head.label,
610+
'is_draft': pr.draft,
611+
'labels': [lbl.name for lbl in pr.get_labels()],
612+
'last_modified': str(pr.last_modified),
613+
'title': pr.title,
614+
'url': pr.html_url,
615+
'user': pr.user.login
616+
}
615617
}
616618

617619
if original_rule_id:
618-
rule.metadata['original_rule_id'] = original_rule_id
619-
rule.contents['rule_id'] = str(uuid4())
620+
extend_meta['original_rule_id'] = original_rule_id
621+
data = dataclasses.replace(rule.contents.data, rule_id=str(uuid4()))
622+
623+
rule_path = Path(f'pr-{pr.number}-{rule.path}')
624+
new_meta = dataclasses.replace(rule.contents.metadata, extended=extend_meta)
625+
contents = dataclasses.replace(rule.contents, metadata=new_meta, data=data)
626+
new_rule = TOMLRule(path=rule_path, contents=contents)
620627

621-
rule_path = f'pr-{pr.number}-{rule.path}'
622-
all_rules[rule_path] = rule.rule_format()
628+
all_rules[new_rule.path] = new_rule
623629

624630
for rule_id, rule in new.items():
625631
add_github_meta(rule, 'new')
@@ -638,32 +644,35 @@ def add_github_meta(this_rule, status, original_rule_id=None):
638644

639645

640646
@dev_group.command('deprecate-rule')
641-
@click.argument('rule-file', type=click.Path(dir_okay=False))
647+
@click.argument('rule-file', type=Path)
642648
@click.pass_context
643-
def deprecate_rule(ctx: click.Context, rule_file: str):
649+
def deprecate_rule(ctx: click.Context, rule_file: Path):
644650
"""Deprecate a rule."""
645-
import pytoml
646-
647651
version_info = load_versions()
648-
rule_file = Path(rule_file)
649-
contents = pytoml.loads(rule_file.read_text())
652+
rule_collection = RuleCollection()
653+
contents = rule_collection.load_file(rule_file).contents
650654
rule = TOMLRule(path=rule_file, contents=contents)
651655

652-
if rule.id not in version_info:
656+
if rule.contents.id not in version_info:
653657
click.echo('Rule has not been version locked and so does not need to be deprecated. '
654658
'Delete the file or update the maturity to `development` instead')
655659
ctx.exit()
656660

657661
today = time.strftime('%Y/%m/%d')
658662

663+
new_meta = {
664+
'updated_date': today,
665+
'deprecation_date': today,
666+
'maturity': 'deprecated'
667+
}
668+
deprecated_path = get_path('rules', '_deprecated', rule_file.name)
669+
670+
# create the new rule and save it
659671
new_meta = dataclasses.replace(rule.contents.metadata,
660672
updated_date=today,
661673
deprecation_date=today,
662674
maturity='deprecated')
663675
contents = dataclasses.replace(rule.contents, metadata=new_meta)
664-
deprecated_path = get_path('rules', '_deprecated', rule_file.name)
665-
666-
# create the new rule and save it
667676
new_rule = TOMLRule(contents=contents, path=Path(deprecated_path))
668677
new_rule.save_toml()
669678

detection_rules/kbwrap.py

+16-3
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ def kibana_group(ctx: click.Context, **kibana_kwargs):
3939
@click.pass_context
4040
def upload_rule(ctx, rules, replace_id):
4141
"""Upload a list of rule .toml files to Kibana."""
42-
4342
kibana = ctx.obj['kibana']
4443
api_payloads = []
4544

@@ -60,8 +59,22 @@ def upload_rule(ctx, rules, replace_id):
6059
api_payloads.append(rule)
6160

6261
with kibana:
63-
rules = RuleResource.bulk_create(api_payloads)
64-
click.echo(f"Successfully uploaded {len(rules)} rules")
62+
results = RuleResource.bulk_create(api_payloads)
63+
64+
success = []
65+
errors = []
66+
for result in results:
67+
if 'error' in result:
68+
errors.append(f'{result["rule_id"]} - {result["error"]["message"]}')
69+
else:
70+
success.append(result['rule_id'])
71+
72+
if success:
73+
click.echo('Successful uploads:\n - ' + '\n - '.join(success))
74+
if errors:
75+
click.echo('Failed uploads:\n - ' + '\n - '.join(errors))
76+
77+
return results
6578

6679

6780
@kibana_group.command('search-alerts')

detection_rules/main.py

+56-18
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import time
1313
from datetime import datetime
1414
from pathlib import Path
15-
from typing import Dict
15+
from typing import Dict, Optional
1616
from uuid import uuid4
1717

1818
import click
@@ -22,7 +22,7 @@
2222
from .rule import TOMLRule, TOMLRuleContents
2323
from .rule_formatter import toml_write
2424
from .rule_loader import RuleCollection
25-
from .schemas import all_versions
25+
from .schemas import all_versions, definitions
2626
from .utils import get_path, get_etc_path, clear_caches, load_dump, load_rule_contents
2727

2828
RULES_DIR = get_path('rules')
@@ -41,7 +41,7 @@ def root(ctx, debug):
4141

4242

4343
@root.command('create-rule')
44-
@click.argument('path', type=click.Path(dir_okay=False))
44+
@click.argument('path', type=Path)
4545
@click.option('--config', '-c', type=click.Path(exists=True, dir_okay=False), help='Rule or config file')
4646
@click.option('--required-only', is_flag=True, help='Only prompt for required fields')
4747
@click.option('--rule-type', '-t', type=click.Choice(sorted(TOMLRuleContents.all_rule_types())),
@@ -95,7 +95,7 @@ def import_rules(input_file, directory):
9595

9696
rule_contents = []
9797
for rule_file in rule_files:
98-
rule_contents.extend(load_rule_contents(rule_file))
98+
rule_contents.extend(load_rule_contents(Path(rule_file)))
9999

100100
if not rule_contents:
101101
click.echo('Must specify at least one file!')
@@ -156,7 +156,7 @@ def mass_update(ctx, query, metadata, language, field):
156156

157157

158158
@root.command('view-rule')
159-
@click.argument('rule-file')
159+
@click.argument('rule-file', type=Path)
160160
@click.option('--api-format/--rule-format', default=True, help='Print the rule in final api or rule format')
161161
@click.pass_context
162162
def view_rule(ctx, rule_file, api_format):
@@ -168,21 +168,57 @@ def view_rule(ctx, rule_file, api_format):
168168
else:
169169
click.echo(toml_write(rule.contents.to_dict()))
170170

171+
return rule
172+
173+
174+
def _export_rules(rules: RuleCollection, outfile: Path, downgrade_version: Optional[definitions.SemVer] = None,
175+
verbose=True, skip_unsupported=False):
176+
"""Export rules into a consolidated ndjson file."""
177+
from .rule import downgrade_contents_from_rule
178+
179+
outfile = outfile.with_suffix('.ndjson')
180+
unsupported = []
181+
182+
if downgrade_version:
183+
if skip_unsupported:
184+
output_lines = []
185+
186+
for rule in rules:
187+
try:
188+
output_lines.append(json.dumps(downgrade_contents_from_rule(rule, downgrade_version),
189+
sort_keys=True))
190+
except ValueError as e:
191+
unsupported.append(f'{e}: {rule.id} - {rule.name}')
192+
continue
193+
194+
else:
195+
output_lines = [json.dumps(downgrade_contents_from_rule(r, downgrade_version), sort_keys=True)
196+
for r in rules]
197+
else:
198+
output_lines = [json.dumps(r.contents.to_api_format(), sort_keys=True) for r in rules]
199+
200+
outfile.write_text('\n'.join(output_lines) + '\n')
201+
202+
if verbose:
203+
click.echo(f'Exported {len(rules) - len(unsupported)} rules into {outfile}')
204+
205+
if skip_unsupported and unsupported:
206+
unsupported_str = '\n- '.join(unsupported)
207+
click.echo(f'Skipped {len(unsupported)} unsupported rules: \n- {unsupported_str}')
208+
171209

172210
@root.command('export-rules')
173211
@multi_collection
174-
@click.option('--outfile', '-o', default=get_path('exports', f'{time.strftime("%Y%m%dT%H%M%SL")}.ndjson'),
175-
type=click.Path(dir_okay=False), help='Name of file for exported rules')
212+
@click.option('--outfile', '-o', default=Path(get_path('exports', f'{time.strftime("%Y%m%dT%H%M%SL")}.ndjson')),
213+
type=Path, help='Name of file for exported rules')
176214
@click.option('--replace-id', '-r', is_flag=True, help='Replace rule IDs with new IDs before export')
177215
@click.option('--stack-version', type=click.Choice(all_versions()),
178216
help='Downgrade a rule version to be compatible with older instances of Kibana')
179217
@click.option('--skip-unsupported', '-s', is_flag=True,
180218
help='If `--stack-version` is passed, skip rule types which are unsupported '
181219
'(an error will be raised otherwise)')
182-
def export_rules(rules, outfile, replace_id, stack_version, skip_unsupported) -> RuleCollection:
220+
def export_rules(rules, outfile: Path, replace_id, stack_version, skip_unsupported) -> RuleCollection:
183221
"""Export rule(s) into an importable ndjson file."""
184-
from .packaging import Package
185-
186222
assert len(rules) > 0, "No rules found"
187223

188224
if replace_id:
@@ -196,10 +232,11 @@ def export_rules(rules, outfile, replace_id, stack_version, skip_unsupported) ->
196232
new_contents = dataclasses.replace(rule.contents, data=new_data)
197233
rules.add_rule(TOMLRule(contents=new_contents))
198234

199-
Path(outfile).parent.mkdir(exist_ok=True)
200-
package = Package(rules, '_', verbose=False)
201-
package.export(outfile, downgrade_version=stack_version, skip_unsupported=skip_unsupported)
202-
return package.rules
235+
outfile.parent.mkdir(exist_ok=True)
236+
_export_rules(rules=rules, outfile=outfile, downgrade_version=stack_version,
237+
skip_unsupported=skip_unsupported)
238+
239+
return rules
203240

204241

205242
@root.command('validate-rule')
@@ -231,13 +268,14 @@ def search_rules(query, columns, language, count, verbose=True, rules: Dict[str,
231268
from eql.build import get_engine
232269
from eql import parse_query
233270
from eql.pipes import CountPipe
271+
from .rule import get_unique_query_fields
234272

235273
flattened_rules = []
236274
rules = rules or {str(rule.path): rule for rule in RuleCollection.default()}
237275

238-
for file_name, rule_doc in rules.items():
276+
for file_name, rule in rules.items():
239277
flat: dict = {"file": os.path.relpath(file_name)}
240-
flat.update(rule_doc.contents.to_dict())
278+
flat.update(rule.contents.to_dict())
241279
flat.update(flat["metadata"])
242280
flat.update(flat["rule"])
243281

@@ -254,8 +292,8 @@ def search_rules(query, columns, language, count, verbose=True, rules: Dict[str,
254292
technique_ids.extend([t['id'] for t in techniques])
255293
subtechnique_ids.extend([st['id'] for t in techniques for st in t.get('subtechnique', [])])
256294

257-
flat.update(techniques=technique_ids, tactics=tactic_names, subtechniques=subtechnique_ids)
258-
# unique_fields=TOMLRule.get_unique_query_fields(rule_doc['rule']))
295+
flat.update(techniques=technique_ids, tactics=tactic_names, subtechniques=subtechnique_ids,
296+
unique_fields=get_unique_query_fields(rule))
259297
flattened_rules.append(flat)
260298

261299
flattened_rules.sort(key=lambda dct: dct["name"])

detection_rules/misc.py

+24-6
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ def get_elasticsearch_client(cloud_id=None, elasticsearch_url=None, es_user=None
283283
es_password = es_password or click.prompt("es_password", hide_input=True)
284284
hosts = [elasticsearch_url] if elasticsearch_url else None
285285
timeout = kwargs.pop('timeout', 60)
286+
kwargs['verify_certs'] = not kwargs.pop('ignore_ssl_errors', False)
286287

287288
try:
288289
client = Elasticsearch(hosts=hosts, cloud_id=cloud_id, http_auth=(es_user, es_password), timeout=timeout,
@@ -295,8 +296,10 @@ def get_elasticsearch_client(cloud_id=None, elasticsearch_url=None, es_user=None
295296
client_error(error_msg, e, ctx=ctx, err=True)
296297

297298

298-
def get_kibana_client(cloud_id, kibana_url, kibana_user, kibana_password, kibana_cookie, **kwargs):
299+
def get_kibana_client(cloud_id, kibana_url, kibana_user, kibana_password, kibana_cookie, space, ignore_ssl_errors,
300+
provider_type, provider_name, **kwargs):
299301
"""Get an authenticated Kibana client."""
302+
from requests import HTTPError
300303
from kibana import Kibana
301304

302305
if not (cloud_id or kibana_url):
@@ -307,11 +310,22 @@ def get_kibana_client(cloud_id, kibana_url, kibana_user, kibana_password, kibana
307310
kibana_user = kibana_user or click.prompt("kibana_user")
308311
kibana_password = kibana_password or click.prompt("kibana_password", hide_input=True)
309312

310-
with Kibana(cloud_id=cloud_id, kibana_url=kibana_url, **kwargs) as kibana:
313+
verify = not ignore_ssl_errors
314+
315+
with Kibana(cloud_id=cloud_id, kibana_url=kibana_url, space=space, verify=verify, **kwargs) as kibana:
311316
if kibana_cookie:
312317
kibana.add_cookie(kibana_cookie)
313-
else:
314-
kibana.login(kibana_user, kibana_password)
318+
return kibana
319+
320+
try:
321+
kibana.login(kibana_user, kibana_password, provider_type=provider_type, provider_name=provider_name)
322+
except HTTPError as exc:
323+
if exc.response.status_code == 401:
324+
err_msg = f'Authentication failed for {kibana_url}. If credentials are valid, check --provider-name'
325+
client_error(err_msg, exc, err=True)
326+
else:
327+
raise
328+
315329
return kibana
316330

317331

@@ -323,14 +337,18 @@ def get_kibana_client(cloud_id, kibana_url, kibana_user, kibana_password, kibana
323337
'kibana_password': click.Option(['--kibana-password', '-kp'], default=getdefault('kibana_password')),
324338
'kibana_url': click.Option(['--kibana-url'], default=getdefault('kibana_url')),
325339
'kibana_user': click.Option(['--kibana-user', '-ku'], default=getdefault('kibana_user')),
326-
'space': click.Option(['--space'], default=None, help='Kibana space')
340+
'provider_type': click.Option(['--provider-type'], default=getdefault('provider_type')),
341+
'provider_name': click.Option(['--provider-name'], default=getdefault('provider_name')),
342+
'space': click.Option(['--space'], default=None, help='Kibana space'),
343+
'ignore_ssl_errors': click.Option(['--ignore-ssl-errors'], default=getdefault('ignore_ssl_errors'))
327344
},
328345
'elasticsearch': {
329346
'cloud_id': click.Option(['--cloud-id'], default=getdefault("cloud_id")),
330347
'elasticsearch_url': click.Option(['--elasticsearch-url'], default=getdefault("elasticsearch_url")),
331348
'es_user': click.Option(['--es-user', '-eu'], default=getdefault("es_user")),
332349
'es_password': click.Option(['--es-password', '-ep'], default=getdefault("es_password")),
333-
'timeout': click.Option(['--timeout', '-et'], default=60, help='Timeout for elasticsearch client')
350+
'timeout': click.Option(['--timeout', '-et'], default=60, help='Timeout for elasticsearch client'),
351+
'ignore_ssl_errors': click.Option(['--ignore-ssl-errors'], default=getdefault('ignore_ssl_errors'))
334352
}
335353
}
336354
kibana_options = list(client_options['kibana'].values())

0 commit comments

Comments
 (0)