Skip to content

[FR] Improve DAC custom folder init #3653

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

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 64 additions & 6 deletions detection_rules/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ class UnitTest:
test_only: Optional[List[str]] = None

def __post_init__(self):
assert not (self.bypass and self.test_only), 'Cannot use both test_only and bypass'
assert (self.bypass is None or self.test_only is None), \
'Cannot set both `test_only` and `bypass` in test_config!'


@dataclass
Expand All @@ -41,6 +42,44 @@ def __post_init__(self):
assert not (self.bypass and self.test_only), 'Cannot use both test_only and bypass'


@dataclass
class ConfigFile:
"""Base object for configuration files."""

@dataclass
class FilePaths:
packages_file: str
stack_schema_map_file: str
deprecated_rules_file: Optional[str] = None
version_lock_file: Optional[str] = None

@dataclass
class TestConfigPath:
config: str

files: FilePaths
rule_dir: List[str]
testing: Optional[TestConfigPath] = None

@classmethod
def from_dict(cls, obj: dict) -> 'ConfigFile':
files_data = obj.get('files', {})
files = cls.FilePaths(
deprecated_rules_file=files_data.get('deprecated_rules'),
packages_file=files_data['packages'],
stack_schema_map_file=files_data['stack_schema_map'],
version_lock_file=files_data.get('version_lock')
)
rule_dir = obj['rule_dirs']

testing_data = obj.get('testing')
testing = cls.TestConfigPath(
config=testing_data['config']
) if testing_data else None

return cls(files=files, rule_dir=rule_dir, testing=testing)


@dataclass
class TestConfig:
"""Detection rules test config file"""
Expand All @@ -50,7 +89,7 @@ class TestConfig:

@classmethod
def from_dict(cls, test_file: Optional[Path] = None, unit_tests: Optional[dict] = None,
rule_validation: Optional[dict] = None):
rule_validation: Optional[dict] = None) -> 'TestConfig':
return cls(test_file=test_file or None, unit_tests=UnitTest(**unit_tests or {}),
rule_validation=RuleValidation(**rule_validation or {}))

Expand Down Expand Up @@ -149,6 +188,14 @@ class RulesConfig:
action_dir: Optional[Path] = None
exception_dir: Optional[Path] = None

def __post_init__(self):
"""Perform post validation on packages.yml file."""
if 'package' not in self.packages:
raise ValueError('Missing the `package` field defined in packages.yml.')

if 'name' not in self.packages['package']:
raise ValueError('Missing the `name` field defined in packages.yml.')


@cached
def parse_rules_config(path: Optional[Path] = None) -> RulesConfig:
Expand All @@ -158,13 +205,19 @@ def parse_rules_config(path: Optional[Path] = None) -> RulesConfig:
loaded = yaml.safe_load(path.read_text())
elif CUSTOM_RULES_DIR:
path = Path(CUSTOM_RULES_DIR) / '_config.yaml'
assert path.exists(), f'_config.yaml file missing in {CUSTOM_RULES_DIR}'
loaded = yaml.safe_load(path.read_text())
else:
path = Path(get_etc_path('_config.yaml'))
loaded = load_etc_dump('_config.yaml')

assert loaded, f'No data loaded from {path}'
try:
ConfigFile.from_dict(loaded)
except KeyError as e:
raise SystemExit(f'Missing key `{str(e)}` in _config.yaml file.')
except (AttributeError, TypeError):
raise SystemExit(f'No data properly loaded from {path}')
except ValueError as e:
raise SystemExit(e)

base_dir = path.resolve().parent

Expand Down Expand Up @@ -196,6 +249,7 @@ def parse_rules_config(path: Optional[Path] = None) -> RulesConfig:
# paths are relative
files = {f'{k}_file': base_dir.joinpath(v) for k, v in loaded['files'].items()}
contents = {k: load_dump(str(base_dir.joinpath(v))) for k, v in loaded['files'].items()}

contents.update(**files)

# directories
Expand All @@ -205,9 +259,13 @@ def parse_rules_config(path: Optional[Path] = None) -> RulesConfig:

# rule_dirs
# paths are relative
contents['rule_dirs'] = [base_dir.joinpath(d) for d in loaded.get('rule_dirs', [])]
contents['rule_dirs'] = [base_dir.joinpath(d) for d in loaded.get('rule_dirs')]

try:
rules_config = RulesConfig(test_config=test_config, **contents)
except (ValueError, TypeError) as e:
raise SystemExit(f'Error parsing packages.yml: {str(e)}')

rules_config = RulesConfig(test_config=test_config, **contents)
return rules_config


Expand Down
104 changes: 89 additions & 15 deletions detection_rules/custom_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,43 +7,117 @@
from pathlib import Path

import click
import yaml

from .main import root
from .utils import get_etc_path
from .utils import get_etc_path, load_etc_dump, ROOT_DIR

from semver import Version

DEFAULT_CONFIG_PATH = Path(get_etc_path('_config.yaml'))
CUSTOM_RULES_DOC_PATH = Path(ROOT_DIR).joinpath('docs', 'custom-rules.md')


@root.group('custom-rules')
def custom_rules():
"""Commands for supporting custom rules."""


@custom_rules.command('init-config')
def create_config_content() -> str:
"""Create the content for the _config.yaml file."""
# Base structure of the configuration
config_content = {
'rule_dirs': ['rules', 'rules_building_block'],
'files': {
'deprecated_rules': 'etc/deprecated_rules.json',
'packages': 'etc/packages.yml',
'stack_schema_map': 'etc/stack-schema-map.yaml',
'version_lock': 'etc/version.lock.json',
},
'testing': {
'config': 'etc/test_config.yaml'
}
}

return yaml.safe_dump(config_content, default_flow_style=False)


def create_test_config_content() -> str:
"""Generate the content for the test_config.yaml with special content and references."""
example_test_config_path = DEFAULT_CONFIG_PATH.parent.joinpath("example_test_config.yaml")
content = f"# For more details, refer to the example configuration:\n# {example_test_config_path}\n" \
"# Define tests to explicitly bypass, with all others being run.\n" \
"# To run all tests, set bypass to empty or leave this file commented out.\n\n" \
"unit_tests:\n bypass:\n# - tests.test_all_rules.TestValidRules.test_schema_and_dupes\n" \
"# - tests.test_packages.TestRegistryPackage.test_registry_package_config\n"

return content


@custom_rules.command('setup-config')
@click.argument('directory', type=Path)
def init_config(directory: Path):
"""Initialize the custom rules configuration."""
etc_dir = directory / 'etc'
@click.argument('kibana-version', type=str, default=load_etc_dump('packages.yml')['package']['name'])
@click.option('--overwrite', is_flag=True, help="Overwrite the existing _config.yaml file.")
def setup_config(directory: Path, kibana_version: str, overwrite: bool):
"""Setup the custom rules configuration directory and files with defaults."""

config = directory / '_config.yaml'
if not overwrite and config.exists():
raise FileExistsError(f'{config} already exists. Use --overwrite to update')

etc_dir = directory / 'etc'
test_config = etc_dir / 'test_config.yaml'
package_config = etc_dir / 'packages.yml'
stack_schema_map_config = etc_dir / 'stack-schema-map.yaml'
config_files = [
package_config,
stack_schema_map_config,
test_config,
config,
]
directories = [
directory / 'actions',
directory / 'exceptions',
directory / 'rules',
etc_dir
directory / 'rules_building_block',
etc_dir,
]
files = [
config,
version_files = [
etc_dir / 'deprecated_rules.json',
etc_dir / 'packages.yml',
etc_dir / 'stack-schema-map.yaml',
etc_dir / 'version.lock.json',
etc_dir / 'test_config.yaml',
]

# Create directories
for dir_ in directories:
dir_.mkdir(parents=True, exist_ok=True)
click.echo(f'created directory: {dir_}')
for file_ in files:
click.echo(f'Created directory: {dir_}')

# Create version_files and populate with default content if applicable
for file_ in version_files:
file_.write_text('{}')
click.echo(f'created file: {file_}')
config.write_text(f'# for details on how to configure this file, consult: {DEFAULT_CONFIG_PATH.resolve()} or docs')
click.echo(
f'Created file with default content: {file_}'
)

# Create the stack-schema-map.yaml file
stack_schema_map_content = load_etc_dump('stack-schema-map.yaml')
latest_version = max(stack_schema_map_content.keys(), key=lambda v: Version.parse(v))
latest_entry = {latest_version: stack_schema_map_content[latest_version]}
stack_schema_map_config.write_text(yaml.safe_dump(latest_entry, default_flow_style=False))

# Create default packages.yml
package_content = {'package': {'name': kibana_version}}
package_config.write_text(yaml.safe_dump(package_content, default_flow_style=False))

# Create and configure test_config.yaml
test_config.write_text(create_test_config_content())

# Create and configure _config.yaml
config.write_text(create_config_content())

for file_ in config_files:
click.echo(f'Created file with default content: {file_}')

click.echo(f'\n# For details on how to configure the _config.yaml file,\n'
f'# consult: {DEFAULT_CONFIG_PATH.resolve()}\n'
f'# or the docs: {CUSTOM_RULES_DOC_PATH.resolve()}')
1 change: 1 addition & 0 deletions detection_rules/etc/_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,4 @@ files:
#
# If set in this file, the path should be relative to the location of this config. If passed as an environment variable,
# it should be the full path
# Note: Using the `custom-rules setup-config <name>` command will generate a config called `test_config.yaml`
2 changes: 2 additions & 0 deletions detection_rules/etc/example_test_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ unit_tests:
#
# to run all tests, set bypass to empty or leave this file commented out
bypass:
# - tests.test_all_rules.TestValidRules.test_schema_and_dupes
# - tests.test_packages.TestRegistryPackage.test_registry_package_config
# - 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
Expand Down
14 changes: 7 additions & 7 deletions docs/custom-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
A custom rule is any rule that is not maintained by Elastic under `rules/` or `rules_building_block`. These docs are intended
to show how to manage custom rules using this repository.

For more detailed breakdown and explanation of employing a detections-as-code approach, refer to the
For more detailed breakdown and explanation of employing a detections-as-code approach, refer to the
[dac-reference](https://dac-reference.readthedocs.io/en/latest/index.html).


Expand Down Expand Up @@ -35,7 +35,7 @@ custom-rules
```

This structure represents a portable set of custom rules. This is just an example, and the exact locations of the files
should be defined in the `_config.yaml` file. Refer to the details in the default
should be defined in the `_config.yaml` file. Refer to the details in the default
[_config.yaml](../detection_rules/etc/_config.yaml) for more information.

* deprecated_rules.json - tracks all deprecated rules (optional)
Expand All @@ -44,7 +44,7 @@ should be defined in the `_config.yaml` file. Refer to the details in the defaul
* test-config.yml - a config file for testing (optional)
* version.lock.json - this tracks versioning for rules (optional depending on versioning strategy)

To initialize a custom rule directory, run `python -m detection_rules custom-rules init-config <directory>`
To initialize a custom rule directory, run `python -m detection_rules custom-rules setup-config <directory>`

### Defining a config

Expand Down Expand Up @@ -75,9 +75,9 @@ testing:
config: etc/example_test_config.yaml
```

This points to the testing config file (see example under detection_rules/etc/example_test_config.yaml) and can either
be set in `_config.yaml` or as the environment variable `DETECTION_RULES_TEST_CONFIG`, with precedence going to the
environment variable if both are set. Having both these options allows for configuring testing on prebuilt Elastic rules
This points to the testing config file (see example under detection_rules/etc/example_test_config.yaml) and can either
be set in `_config.yaml` or as the environment variable `DETECTION_RULES_TEST_CONFIG`, with precedence going to the
environment variable if both are set. Having both these options allows for configuring testing on prebuilt Elastic rules
without specifying a rules _config.yaml.


Expand Down Expand Up @@ -109,7 +109,7 @@ class RulesConfig:

action_dir: Optional[Path] = None
exception_dir: Optional[Path] = None

# using the stack_schema_map
RULES_CONFIG.stack_schema_map
```
Expand Down