2424from .main import root
2525from .misc import add_params , client_error , kibana_options , get_kibana_client , nested_set
2626from .rule import downgrade_contents_from_rule , TOMLRuleContents , TOMLRule
27- from .rule_loader import RuleCollection
27+ from .rule_loader import RuleCollection , update_metadata_from_file
2828from .utils import format_command_options , rulename_to_filename
2929
3030RULES_CONFIG = parse_rules_config ()
@@ -108,27 +108,44 @@ def _parse_list_id(s: str):
108108
109109 # Re-try to address known Kibana issue: https://github.com/elastic/kibana/issues/143864
110110 workaround_errors = []
111+ workaround_error_types = set ()
111112
112113 flattened_exceptions = [e for sublist in exception_dicts for e in sublist ]
113114 all_exception_list_ids = {exception ["list_id" ] for exception in flattened_exceptions }
114115
115116 click .echo (f'{ len (response ["errors" ])} rule(s) failed to import!' )
116117
118+ action_connector_validation_error = "Error validating create data"
119+ action_connector_type_error = "expected value of type [string] but got [undefined]"
117120 for error in response ['errors' ]:
118- click .echo (f' - { error ["rule_id" ]} : ({ error ["error" ]["status_code" ]} ) { error ["error" ]["message" ]} ' )
121+ error_message = error ["error" ]["message" ]
122+ click .echo (f' - { error ["rule_id" ]} : ({ error ["error" ]["status_code" ]} ) { error_message } ' )
119123
120- if "references a non existent exception list" in error [ "error" ][ "message" ] :
121- list_id = _parse_list_id (error [ "error" ][ "message" ] )
124+ if "references a non existent exception list" in error_message :
125+ list_id = _parse_list_id (error_message )
122126 if list_id in all_exception_list_ids :
123127 workaround_errors .append (error ["rule_id" ])
128+ workaround_error_types .add ("non existent exception list" )
129+
130+ if action_connector_validation_error in error_message and action_connector_type_error in error_message :
131+ workaround_error_types .add ("connector still being built" )
124132
125133 if workaround_errors :
126134 workaround_errors = list (set (workaround_errors ))
127- click .echo (f'Missing exception list errors detected for { len (workaround_errors )} rules. '
128- 'Try re-importing using the following command and rule IDs:\n ' )
129- click .echo ('python -m detection_rules kibana import-rules -o ' , nl = False )
130- click .echo (' ' .join (f'-id { rule_id } ' for rule_id in workaround_errors ))
131- click .echo ()
135+ if "non existent exception list" in workaround_error_types :
136+ click .echo (
137+ f"Missing exception list errors detected for { len (workaround_errors )} rules. "
138+ "Try re-importing using the following command and rule IDs:\n "
139+ )
140+ click .echo ("python -m detection_rules kibana import-rules -o " , nl = False )
141+ click .echo (" " .join (f"-id { rule_id } " for rule_id in workaround_errors ))
142+ click .echo ()
143+ if "connector still being built" in workaround_error_types :
144+ click .echo (
145+ f"Connector still being built errors detected for { len (workaround_errors )} rules. "
146+ "Please try re-importing the rules again."
147+ )
148+ click .echo ()
132149
133150 def _process_imported_items (imported_items_list , item_type_description , item_key ):
134151 """Displays appropriately formatted success message that all items imported successfully."""
@@ -178,20 +195,38 @@ def _process_imported_items(imported_items_list, item_type_description, item_key
178195@click .option ("--exceptions-directory" , "-ed" , required = False , type = Path , help = "Directory to export exceptions to" )
179196@click .option ("--default-author" , "-da" , type = str , required = False , help = "Default author for rules missing one" )
180197@click .option ("--rule-id" , "-r" , multiple = True , help = "Optional Rule IDs to restrict export to" )
198+ @click .option ("--rule-name" , "-rn" , required = False , help = "Optional Rule name to restrict export to "
199+ "(KQL, case-insensitive, supports wildcards)" )
181200@click .option ("--export-action-connectors" , "-ac" , is_flag = True , help = "Include action connectors in export" )
182201@click .option ("--export-exceptions" , "-e" , is_flag = True , help = "Include exceptions in export" )
183202@click .option ("--skip-errors" , "-s" , is_flag = True , help = "Skip errors when exporting rules" )
184203@click .option ("--strip-version" , "-sv" , is_flag = True , help = "Strip the version fields from all rules" )
204+ @click .option ("--no-tactic-filename" , "-nt" , is_flag = True ,
205+ help = "Exclude tactic prefix in exported filenames for rules. "
206+ "Use same flag for import-rules to prevent warnings and disable its unit test." )
207+ @click .option ("--local-creation-date" , "-lc" , is_flag = True , help = "Preserve the local creation date of the rule" )
208+ @click .option ("--local-updated-date" , "-lu" , is_flag = True , help = "Preserve the local updated date of the rule" )
185209@click .pass_context
186210def kibana_export_rules (ctx : click .Context , directory : Path , action_connectors_directory : Optional [Path ],
187211 exceptions_directory : Optional [Path ], default_author : str ,
188- rule_id : Optional [Iterable [str ]] = None , export_action_connectors : bool = False ,
189- export_exceptions : bool = False , skip_errors : bool = False , strip_version : bool = False
190- ) -> List [TOMLRule ]:
212+ rule_id : Optional [Iterable [str ]] = None , rule_name : Optional [str ] = None ,
213+ export_action_connectors : bool = False ,
214+ export_exceptions : bool = False , skip_errors : bool = False , strip_version : bool = False ,
215+ no_tactic_filename : bool = False , local_creation_date : bool = False ,
216+ local_updated_date : bool = False ) -> List [TOMLRule ]:
191217 """Export custom rules from Kibana."""
192218 kibana = ctx .obj ["kibana" ]
193219 kibana_include_details = export_exceptions or export_action_connectors
220+
221+ # Only allow one of rule_id or rule_name
222+ if rule_name and rule_id :
223+ raise click .UsageError ("Cannot use --rule-id and --rule-name together. Please choose one." )
224+
194225 with kibana :
226+ # Look up rule IDs by name if --rule-name was provided
227+ if rule_name :
228+ found = RuleResource .find (filter = f"alert.attributes.name:{ rule_name } " )
229+ rule_id = [r ["rule_id" ] for r in found ]
195230 results = RuleResource .export_rules (list (rule_id ), exclude_export_details = not kibana_include_details )
196231
197232 # Handle Exceptions Directory Location
@@ -215,6 +250,8 @@ def kibana_export_rules(ctx: click.Context, directory: Path, action_connectors_d
215250 return []
216251
217252 rules_results = results
253+ action_connector_results = []
254+ exception_results = []
218255 if kibana_include_details :
219256 # Assign counts to variables
220257 rules_count = results [- 1 ]["exported_rules_count" ]
@@ -242,22 +279,27 @@ def kibana_export_rules(ctx: click.Context, directory: Path, action_connectors_d
242279 rule_resource ["author" ] = rule_resource .get ("author" ) or default_author or [rule_resource .get ("created_by" )]
243280 if isinstance (rule_resource ["author" ], str ):
244281 rule_resource ["author" ] = [rule_resource ["author" ]]
245- # Inherit maturity from the rule already exists
246- maturity = "development"
282+ # Inherit maturity and optionally local dates from the rule if it already exists
283+ params = {
284+ "rule" : rule_resource ,
285+ "maturity" : "development" ,
286+ }
247287 threat = rule_resource .get ("threat" )
248288 first_tactic = threat [0 ].get ("tactic" ).get ("name" ) if threat else ""
249- rule_name = rulename_to_filename (rule_resource .get ("name" ), tactic_name = first_tactic )
250- # check if directory / f"{rule_name}" exists
251- if (directory / f"{ rule_name } " ).exists ():
252- rules = RuleCollection ()
253- rules .load_file (directory / f"{ rule_name } " )
254- if rules :
255- maturity = rules .rules [0 ].contents .metadata .maturity
256-
257- contents = TOMLRuleContents .from_rule_resource (
258- rule_resource , maturity = maturity
289+ # Check if flag or config is set to not include tactic in the filename
290+ no_tactic_filename = no_tactic_filename or RULES_CONFIG .no_tactic_filename
291+ # Check if the flag is set to not include tactic in the filename
292+ tactic_name = first_tactic if not no_tactic_filename else None
293+ rule_name = rulename_to_filename (rule_resource .get ("name" ), tactic_name = tactic_name )
294+
295+ save_path = directory / f"{ rule_name } "
296+ params .update (
297+ update_metadata_from_file (
298+ save_path , {"creation_date" : local_creation_date , "updated_date" : local_updated_date }
299+ )
259300 )
260- rule = TOMLRule (contents = contents , path = directory / f"{ rule_name } " )
301+ contents = TOMLRuleContents .from_rule_resource (** params )
302+ rule = TOMLRule (contents = contents , path = save_path )
261303 except Exception as e :
262304 if skip_errors :
263305 print (f'- skipping { rule_resource .get ("name" )} - { type (e ).__name__ } ' )
0 commit comments