Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FR] Add support for configurable tests and validation #4

Merged

Conversation

brokensound77
Copy link
Owner

@brokensound77 brokensound77 commented Jan 30, 2024

Updates for configurable tests. This is just to show the delta since this targets my custom directory support PR.

Summary

This makes unit tests configurable by name and pattern and rule validation by rule_id, configurable by a test_config,yaml file.

Details

The test file is exposed either by setting DETECTION_RULES_TEST_CONFIG to the path, or by referencing it from within the _config.yaml. The enivronment variable allow bypassing tests on prebuilt rules where modifying the built-in _config.yaml is not feasible due to VCS.

Opting in/out of tests

# `bypass` and `test_only` are mutually exclusive and will cause an error if both are specified.
#
# tests can be defined by their full name or using glob-style patterns with the following notation
#   pattern:*rule*
#   the patterns are case sensitive

unit_tests:
  # define tests to explicitly bypass, with all others being run
  #
  # to run all tests, set bypass to empty or leave this file commented out
  bypass:
#  - tests.test_all_rules.TestRuleMetadata.test_event_dataset
#  - tests.test_all_rules.TestRuleMetadata.test_integration_tag
#  - tests.test_gh_workflows.TestWorkflows.test_matrix_to_lock_version_defaults
#  - pattern:*rule*
#  - pattern:*kuery*

  # define tests to explicitly run, with all others being bypassed
  #
  # to bypass all tests, set test_only to empty
  test_only:
#  - tests.test_all_rules.TestRuleMetadata.test_event_dataset
#  - pattern:*rule*

Opting out of rule validation

# `bypass` and `test_only` are mutually exclusive and will cause an error if both are specified.
#
# both variables require a list of rule_ids
rule_validation:

  bypass:
#    - "34fde489-94b0-4500-a76f-b8a157cf9269"


  test_only:
#    - "34fde489-94b0-4500-a76f-b8a157cf9269"

Testing

Sample config:

unit_tests:
  test_only:
    - pattern:*rules*
   # bypass:

rule_validation:
  test_only:
  - "34fde489-94b0-4500-a76f-b8a157cf9269"
   # bypass:

1. make test (or python -m detection_rules test)

  • this tests the defaults, which opts in to all unit tests and validation

2. DETECTION_RULES_TEST_CONFIG=path/to/test_config.yaml make test

  • this tests built in rules against a config (optionally play with config parameters to opt in and out)
Tests skipped per config (89):
tests/kuery/test_dsl.py::TestKQLtoDSL::test_and_query
tests/kuery/test_dsl.py::TestKQLtoDSL::test_field_exists
tests/kuery/test_dsl.py::TestKQLtoDSL::test_field_inequality
tests/kuery/test_dsl.py::TestKQLtoDSL::test_field_match
tests/kuery/test_dsl.py::TestKQLtoDSL::test_not_query
tests/kuery/test_dsl.py::TestKQLtoDSL::test_optimizations
tests/kuery/test_dsl.py::TestKQLtoDSL::test_or_query
tests/kuery/test_eql2kql.py::TestEql2Kql::test_and_query
tests/kuery/test_eql2kql.py::TestEql2Kql::test_boolean_precedence
tests/kuery/test_eql2kql.py::TestEql2Kql::test_field_equals
tests/kuery/test_eql2kql.py::TestEql2Kql::test_field_inequality
tests/kuery/test_eql2kql.py::TestEql2Kql::test_ip_checks
tests/kuery/test_eql2kql.py::TestEql2Kql::test_list_of_values
tests/kuery/test_eql2kql.py::TestEql2Kql::test_not_query
tests/kuery/test_eql2kql.py::TestEql2Kql::test_or_query
tests/kuery/test_eql2kql.py::TestEql2Kql::test_wildcard_field
tests/kuery/test_evaluator.py::EvaluatorTests::test_and_expr
tests/kuery/test_evaluator.py::EvaluatorTests::test_and_values
tests/kuery/test_evaluator.py::EvaluatorTests::test_cidr_match
tests/kuery/test_evaluator.py::EvaluatorTests::test_field_exists
tests/kuery/test_evaluator.py::EvaluatorTests::test_flattening
tests/kuery/test_evaluator.py::EvaluatorTests::test_list_value
tests/kuery/test_evaluator.py::EvaluatorTests::test_not_value
tests/kuery/test_evaluator.py::EvaluatorTests::test_or_expr
tests/kuery/test_evaluator.py::EvaluatorTests::test_or_values
tests/kuery/test_evaluator.py::EvaluatorTests::test_quoted_wildcard
tests/kuery/test_evaluator.py::EvaluatorTests::test_range
tests/kuery/test_evaluator.py::EvaluatorTests::test_single_value
tests/kuery/test_evaluator.py::EvaluatorTests::test_wildcard
tests/kuery/test_kql2eql.py::TestKql2Eql::test_and_query
tests/kuery/test_kql2eql.py::TestKql2Eql::test_boolean_precedence
tests/kuery/test_kql2eql.py::TestKql2Eql::test_field_equals
tests/kuery/test_kql2eql.py::TestKql2Eql::test_field_inequality
tests/kuery/test_kql2eql.py::TestKql2Eql::test_list_of_values
tests/kuery/test_kql2eql.py::TestKql2Eql::test_lone_value
tests/kuery/test_kql2eql.py::TestKql2Eql::test_nested_query
tests/kuery/test_kql2eql.py::TestKql2Eql::test_not_query
tests/kuery/test_kql2eql.py::TestKql2Eql::test_or_query
tests/kuery/test_kql2eql.py::TestKql2Eql::test_schema
tests/kuery/test_lint.py::LintTests::test_and_not
tests/kuery/test_lint.py::LintTests::test_compound
tests/kuery/test_lint.py::LintTests::test_double_negate
tests/kuery/test_lint.py::LintTests::test_extract_not
tests/kuery/test_lint.py::LintTests::test_ip
tests/kuery/test_lint.py::LintTests::test_lint_field
tests/kuery/test_lint.py::LintTests::test_lint_precedence
tests/kuery/test_lint.py::LintTests::test_merge_fields
tests/kuery/test_lint.py::LintTests::test_mixed_demorgans
tests/kuery/test_lint.py::LintTests::test_not_demorgans
tests/kuery/test_lint.py::LintTests::test_not_or
tests/kuery/test_lint.py::LintTests::test_upper_tokens
tests/kuery/test_parser.py::ParserTests::test_conversion
tests/kuery/test_parser.py::ParserTests::test_date
tests/kuery/test_parser.py::ParserTests::test_keyword
tests/kuery/test_parser.py::ParserTests::test_list_equals
tests/kuery/test_parser.py::ParserTests::test_multiple_types_fail
tests/kuery/test_parser.py::ParserTests::test_multiple_types_success
tests/kuery/test_parser.py::ParserTests::test_number_exists
tests/kuery/test_parser.py::ParserTests::test_number_wildcard_fail
tests/kuery/test_parser.py::ParserTests::test_type_family_fail
tests/kuery/test_parser.py::ParserTests::test_type_family_success
tests/test_gh_workflows.py::TestWorkflows::test_matrix_to_lock_version_defaults
tests/test_mappings.py::TestMappings::test_false_positives
tests/test_mappings.py::TestMappings::test_true_positives
tests/test_packages.py::TestPackages::test_package_loader_default_configs
tests/test_packages.py::TestPackages::test_package_loader_production_config
tests/test_packages.py::TestPackages::test_package_summary
tests/test_packages.py::TestPackages::test_rule_versioning
tests/test_packages.py::TestRegistryPackage::test_registry_package_config
tests/test_schemas.py::TestSchemas::test_eql_validation
tests/test_schemas.py::TestSchemas::test_query_downgrade_7_x
tests/test_schemas.py::TestSchemas::test_query_downgrade_8_x
tests/test_schemas.py::TestSchemas::test_threshold_downgrade_7_x
tests/test_schemas.py::TestSchemas::test_threshold_downgrade_8_x
tests/test_schemas.py::TestSchemas::test_versioned_downgrade_7_x
tests/test_schemas.py::TestSchemas::test_versioned_downgrade_8_x
tests/test_schemas.py::TestVersionLockSchema::test_version_lock_has_nested_previous
tests/test_schemas.py::TestVersionLockSchema::test_version_lock_no_previous
tests/test_schemas.py::TestVersions::test_stack_schema_map
tests/test_toml_formatter.py::TestRuleTomlFormatter::test_formatter_deep
tests/test_toml_formatter.py::TestRuleTomlFormatter::test_formatter_rule
tests/test_toml_formatter.py::TestRuleTomlFormatter::test_normalization
tests/test_transform_fields.py::TestGuideMarkdownPlugins::test_plugin_conversion
tests/test_transform_fields.py::TestGuideMarkdownPlugins::test_transform_guide_markdown_plugins
tests/test_utils.py::TestTimeUtils::test_caching
tests/test_utils.py::TestTimeUtils::test_event_class_normalization
tests/test_utils.py::TestTimeUtils::test_schema_multifields
tests/test_utils.py::TestTimeUtils::test_time_normalize
tests/test_version_locking.py::TestVersionLock::test_previous_entries_gte_current_min_stack
========================================================================================== test session starts ===========================================================================================
platform darwin -- Python 3.8.4, pytest-7.2.2, pluggy-1.0.0 -- /Users/jibarra/PycharmProjects/detection-rules-fork/env/detection-rules-build/bin/python
cachedir: .pytest_cache
rootdir: /Users/jibarra/PycharmProjects/detection-rules-fork, configfile: pyproject.toml
plugins: typeguard-2.13.3
collected 52 items                                                                                                                                                                                       

tests/test_all_rules.py::TestAlertSuppression::test_group_field_in_schemas FAILED                                                                                                                  [  1%]
tests/test_all_rules.py::TestAlertSuppression::test_group_length SKIPPED (Rule loader failure)                                                                                                     [  3%]
tests/test_all_rules.py::TestBuildTimeFields::test_build_fields_min_stack SKIPPED (Rule loader failure)                                                                                            [  5%]
tests/test_all_rules.py::TestIncompatibleFields::test_rule_backports_for_restricted_fields SKIPPED (Rule loader failure)                                                                           [  7%]
tests/test_all_rules.py::TestIntegrationRules::test_all_min_stack_rules_have_comment SKIPPED (Rule loader failure)                                                                                 [  9%]
tests/test_all_rules.py::TestIntegrationRules::test_integration_guide SKIPPED (8.3+ Stacks Have Related Integrations Feature)                                                                      [ 11%]
tests/test_all_rules.py::TestIntegrationRules::test_ml_integration_jobs_exist SKIPPED (Rule loader failure)                                                                                        [ 13%]
tests/test_all_rules.py::TestIntegrationRules::test_rule_demotions SKIPPED (Rule loader failure)                                                                                                   [ 15%]
tests/test_all_rules.py::TestLicense::test_elastic_license_only_v2 SKIPPED (Rule loader failure)                                                                                                   [ 17%]
tests/test_all_rules.py::TestNoteMarkdownPlugins::test_if_plugins_explicitly_defined SKIPPED (Rule loader failure)                                                                                 [ 19%]
tests/test_all_rules.py::TestNoteMarkdownPlugins::test_note_has_osquery_warning SKIPPED (Rule loader failure)                                                                                      [ 21%]
tests/test_all_rules.py::TestNoteMarkdownPlugins::test_plugin_placeholders_match_entries SKIPPED (Rule loader failure)                                                                             [ 23%]
tests/test_all_rules.py::TestRiskScoreMismatch::test_rule_risk_score_severity_mismatch SKIPPED (Rule loader failure)                                                                               [ 25%]
tests/test_all_rules.py::TestRuleFiles::test_bbr_in_correct_dir SKIPPED (Rule loader failure)                                                                                                      [ 26%]
tests/test_all_rules.py::TestRuleFiles::test_non_bbr_in_correct_dir SKIPPED (Rule loader failure)                                                                                                  [ 28%]
tests/test_all_rules.py::TestRuleFiles::test_rule_file_name_tactic SKIPPED (Rule loader failure)                                                                                                   [ 30%]
tests/test_all_rules.py::TestRuleMetadata::test_deprecated_rules SKIPPED (Rule loader failure)                                                                                                     [ 32%]
tests/test_all_rules.py::TestRuleMetadata::test_event_dataset SKIPPED (Rule loader failure)                                                                                                        [ 34%]
tests/test_all_rules.py::TestRuleMetadata::test_integration_tag SKIPPED (Rule loader failure)                                                                                                      [ 36%]
tests/test_all_rules.py::TestRuleMetadata::test_invalid_queries SKIPPED (Rule loader failure)                                                                                                      [ 38%]
tests/test_all_rules.py::TestRuleMetadata::test_updated_date_newer_than_creation SKIPPED (Rule loader failure)                                                                                     [ 40%]
tests/test_all_rules.py::TestRuleTags::test_casing_and_spacing SKIPPED (Rule loader failure)                                                                                                       [ 42%]
tests/test_all_rules.py::TestRuleTags::test_investigation_guide_tag SKIPPED (Skipping until all Investigation Guides follow the proper format.)                                                    [ 44%]
tests/test_all_rules.py::TestRuleTags::test_ml_rule_type_tags SKIPPED (Rule loader failure)                                                                                                        [ 46%]
tests/test_all_rules.py::TestRuleTags::test_no_duplicate_tags SKIPPED (Rule loader failure)                                                                                                        [ 48%]
tests/test_all_rules.py::TestRuleTags::test_os_tags SKIPPED (Rule loader failure)                                                                                                                  [ 50%]
tests/test_all_rules.py::TestRuleTags::test_primary_tactic_as_tag SKIPPED (Rule loader failure)                                                                                                    [ 51%]
tests/test_all_rules.py::TestRuleTags::test_required_tags SKIPPED (Rule loader failure)                                                                                                            [ 53%]
tests/test_all_rules.py::TestRuleTags::test_tag_prefix SKIPPED (Rule loader failure)                                                                                                               [ 55%]
tests/test_all_rules.py::TestRuleTimelines::test_timeline_has_title SKIPPED (Rule loader failure)                                                                                                  [ 57%]
tests/test_all_rules.py::TestRuleTiming::test_eql_interval_to_maxspan SKIPPED (Rule loader failure)                                                                                                [ 59%]
tests/test_all_rules.py::TestRuleTiming::test_eql_lookback SKIPPED (Rule loader failure)                                                                                                           [ 61%]
tests/test_all_rules.py::TestRuleTiming::test_event_override SKIPPED (Rule loader failure)                                                                                                         [ 63%]
tests/test_all_rules.py::TestRuleTiming::test_required_lookback SKIPPED (Rule loader failure)                                                                                                      [ 65%]
tests/test_all_rules.py::TestThreatMappings::test_duplicated_tactics SKIPPED (Rule loader failure)                                                                                                 [ 67%]
tests/test_all_rules.py::TestThreatMappings::test_tactic_to_technique_correlations SKIPPED (Rule loader failure)                                                                                   [ 69%]
tests/test_all_rules.py::TestThreatMappings::test_technique_deprecations SKIPPED (Rule loader failure)                                                                                             [ 71%]
tests/test_all_rules.py::TestValidRules::test_all_rule_queries_optimized SKIPPED (Rule loader failure)                                                                                             [ 73%]
tests/test_all_rules.py::TestValidRules::test_bbr_validation SKIPPED (Rule loader failure)                                                                                                         [ 75%]
tests/test_all_rules.py::TestValidRules::test_duplicate_file_names SKIPPED (Rule loader failure)                                                                                                   [ 76%]
tests/test_all_rules.py::TestValidRules::test_file_names SKIPPED (Rule loader failure)                                                                                                             [ 78%]
tests/test_all_rules.py::TestValidRules::test_production_rules_have_rta SKIPPED (Rule loader failure)                                                                                              [ 80%]
tests/test_all_rules.py::TestValidRules::test_rule_type_changes SKIPPED (Rule loader failure)                                                                                                      [ 82%]
tests/test_all_rules.py::TestValidRules::test_schema_and_dupes SKIPPED (Rule loader failure)                                                                                                       [ 84%]
tests/test_mappings.py::TestRTAs::test_rtas_with_triggered_rules_have_uuid PASSED                                                                                                                  [ 86%]
tests/test_specific_rules.py::TestESQLRules::test_esql_queries SKIPPED (Rule loader failure)                                                                                                       [ 88%]
tests/test_specific_rules.py::TestEndpointQuery::test_os_and_platform_in_query SKIPPED (Rule loader failure)                                                                                       [ 90%]
tests/test_specific_rules.py::TestNewTerms::test_history_window_start SKIPPED (Rule loader failure)                                                                                                [ 92%]
tests/test_specific_rules.py::TestNewTerms::test_new_terms_field_exists SKIPPED (Rule loader failure)                                                                                              [ 94%]
tests/test_specific_rules.py::TestNewTerms::test_new_terms_fields SKIPPED (Rule loader failure)                                                                                                    [ 96%]
tests/test_specific_rules.py::TestNewTerms::test_new_terms_fields_unique SKIPPED (Rule loader failure)                                                                                             [ 98%]
tests/test_specific_rules.py::TestNewTerms::test_new_terms_max_limit SKIPPED (Rule loader failure)                                                                                                 [100%]

3. CUSTOM_RULES_DIR=custom-rules make test

  • with the settings for testing.config set to etc/test_config.yaml
testing:
  config: etc/test_config.yaml
  • This tests custom rules against a custom testing config

A config directory is attached for convenience

@brokensound77 brokensound77 marked this pull request as draft January 30, 2024 05:33
@brokensound77
Copy link
Owner Author

custom-rules config files for testing
custom-rules.zip

clear_caches()
ctx.exit(pytest.main(["-v"]))
ctx.exit(pytest.main(['-v'] + tests))
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I initially built a custom class around unittest.TestLoader, but since we build tests in unittest and call via pytest (for formatting), it was moot and easier to just pass as a command line arg

@Mikaayenson
Copy link

It's worth noting that this POC supports a feature that pytest doesn't support natively which is the ability to explicitly list test or a regex of tests within a file.

I also like that we're not touching as many individual unit tests files.

@Mikaayenson
Copy link

Mikaayenson commented Jan 31, 2024

Since we call via pytest, we can use conftest for discovery and enumeration and use the builtin fixture.

You basically create a conftest.py with similar contents:

# conftest.py
import pytest

def pytest_collection_modifyitems(config, items):
    for testcase in items:
        # Print test names
        print(f"Collected test: {testcase.name} with full path {testcase.nodeid}")

        # Skip tests marked with 'elastic' mark if we did decorate with pytest markers
        if testcase.get_closest_marker("elastic"):
            testcase.add_marker(pytest.mark.skip(reason="Skipping test marked with 'elastic'."))

       # Skip tests based on test config
       ...

You could also skip by regex if needed:

if re.match(pattern_to_skip, testcase.nodeid):
      testcase.add_marker(pytest.mark.skip(reason="Test name matches skip pattern."))

@@ -32,4 +31,12 @@ files:
#

# testing:
# TBD...
# config: etc/example_test_config.yaml
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

may need to specify a custom test path

detection_rules/misc.py Show resolved Hide resolved


@dataclass
class TestConfig:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd recommend moving this class to a dedicated module since it seems like it has a mode dedicated purpose than miscellaneous methods/classes.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

++ totes, I'll make a config.py

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and move all of this

detection_rules/misc.py Show resolved Hide resolved
Comment on lines +388 to +395
elif self.unit_tests.bypass:
tests = []
skipped = []
for test in self.all_tests:
if test not in combined_tests:
tests.append(test)
else:
skipped.append(test)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think with a few set / list operations we can condense this.

basically all_tests - skipped should be tests right?

Comment on lines +401 to +412
def fmt_tests(lt) -> List[str]:
raw = [t.rsplit('.', maxsplit=2) for t in lt]
ft = []
for test in raw:
path, clazz, method = test
path = f'{path.replace(".", os.path.sep)}.py'
ft.append('::'.join([path, clazz, method]))
return ft

return fmt_tests(tests), fmt_tests(skipped)
else:
return tests, skipped

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we break out this to a staticmethod of the class w/doc strings, renamed function arg, + doc string etc.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good idea 👍

Comment on lines +418 to +420
if not (bypass or test_only):
return False
return (bypass and rule_id in bypass) or (test_only and rule_id not in test_only)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For readability, do we want to expand/refactor this?

e.g.

       if self.rule_validation.bypass and rule_id in self.rule_validation.bypass:
            return True
        if self.rule_validation.test_only and rule_id not in self.rule_validation.test_only:
            return True
        return False

or even, close to as-is

       bypass_tests = self.rule_validation.bypass
       test_only_tests = self.rule_validation.test_only
       if bypass_tests and rule_id in bypass_tests:
            return True
        if test_only_tests and rule_id not in test_only_tests:
            return True
        return False

Comment on lines +363 to +372
def parse_out_patterns(names: List[str]) -> (List[str], List[str]):
"""Parse out test patterns from a list of test names."""
patterns = []
tests = []
for name in names:
if name.startswith('pattern:') and '*' in name:
patterns.append(name[len('pattern:'):])
else:
tests.append(name)
return patterns, tests

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we don't prefix lines in the config with pattern, I think fnmatch will still work as expected. Do we need to prefix and parse?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 that should work, yea. Since full name would have a single match only. I can't remember if there was any other reason why I broke them out though. If not, I'll simplify it with this

@Mikaayenson
Copy link

@brokensound77 we have things in our repo to bypass schema validation (e.g. DR_BYPASS_NOTE_VALIDATION_AND_PARSE, DR_BYPASS_BBR_LOOKBACK_VALIDATION, DR_BYPASS_TAGS_VALIDATION, etc.). Are these considered in this PR?

@brokensound77 brokensound77 marked this pull request as ready for review March 13, 2024 03:39
@brokensound77 brokensound77 merged commit 835b88f into custom-rule-dir Mar 13, 2024
2 of 12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants