-
-
Notifications
You must be signed in to change notification settings - Fork 2.8k
chore: add json/ndjson/jsonl to regression test #6203
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| # Sigma Regression Testing | ||
|
|
||
| Regression tests verify that Sigma rules actually match the events they are | ||
| supposed to detect. Each rule (rules with status `test` or `stable` must) points to an | ||
| `info.yml` describing one or more test cases, each backed by a real event | ||
| sample (EVTX or JSON). CI runs every sample against its rule and fails if the | ||
| expected number of matches is not produced. | ||
|
|
||
| The runner lives at [`tests/regression_tests_runner.py`](../tests/regression_tests_runner.py) | ||
| and runs on every push/PR via [`.github/workflows/regression-tests.yml`](../.github/workflows/regression-tests.yml). | ||
|
|
||
| ## Layout | ||
|
|
||
| ``` | ||
| regression_data/ | ||
| ├── pipelines/ # Sigma conversion pipelines used by JSON tests | ||
| └── rules/<product>/<category>/<rule_name>/ | ||
| ├── info.yml # test definitions for the rule | ||
| ├── <rule_id>.evtx # EVTX sample (name must equal rule id) | ||
| └── <rule_id>.json # optional JSON/NDJSON sample (name must equal rule id) | ||
| ``` | ||
|
|
||
| The rule file references its tests with: | ||
|
|
||
| ```yaml | ||
| regression_tests_path: ../../regression_data/rules/windows/process_creation/<rule_name>/info.yml | ||
| ``` | ||
|
|
||
| ## Supported Types | ||
|
|
||
| ### EVTX | ||
|
|
||
| Runs [`evtx-sigma-checker`](https://github.com/NextronSystems/evtx-baseline) | ||
| against the `.evtx` sample using the THOR log-source config and the rule | ||
| directory. | ||
|
|
||
| ```yaml | ||
| - name: Positive Detection Test | ||
| type: evtx | ||
| provider: Microsoft-Windows-Sysmon # Not used atm | ||
| match_count: 1 | ||
| path: regression_data/rules/windows/process_creation/<rule_name>/<rule_id>.evtx | ||
| ``` | ||
|
|
||
| The `.evtx` file name must equal the rule `id`. | ||
|
|
||
| ### Json / NDJson / JsonL | ||
|
|
||
| `json` means a single JSON object, while `ndjson`/`jsonl` mean newline-delimited | ||
| JSON objects (one object per line). | ||
|
|
||
| The rule is compiled to a `golang_expr` query with `sigma convert` (applying any | ||
| listed `pipelines` and `filters`), then run against the event sample by | ||
| `json_checker`. | ||
|
|
||
| ```yaml | ||
| - name: Positive Detection Test | ||
| type: json # or: ndjson, jsonl | ||
| match_count: 1 | ||
| pipelines: | ||
| - regression_data/pipelines/process_creation_fieldmapping.yml | ||
| path: regression_data/rules/windows/process_creation/<rule_name>/<rule_id>.json | ||
| ``` | ||
|
|
||
| `pipelines` and `filters` are optional and with no pipeline, the rule is converted with `--without-pipeline`. | ||
|
|
||
| ## info.yml Format | ||
|
|
||
| ```yaml | ||
| id: 242d26e0-1ce5-4a34-960d-144f34f60e37 # id of this test-info file | ||
| description: N/A | ||
| date: 2025-12-25 | ||
| author: Author Name | ||
| rule_metadata: | ||
| - id: 7dbbcac2-57a0-45ac-b306-ff30a8bd2981 # must match the rule file id | ||
| title: Windows AMSI Related Registry Tampering Via CommandLine | ||
| regression_tests_info: | ||
| - name: Positive Detection Test | ||
| type: evtx | ||
| provider: Microsoft-Windows-Sysmon # Not used atm | ||
| match_count: 1 | ||
| path: regression_data/rules/.../<rule_id>.evtx | ||
| - name: Positive Detection Test | ||
| type: json | ||
| match_count: 1 | ||
| pipelines: | ||
| - regression_data/pipelines/process_creation_fieldmapping.yml | ||
| path: regression_data/rules/.../<rule_id>.json | ||
| ``` | ||
|
|
||
| Fields per entry in `regression_tests_info`: | ||
|
|
||
| | Field | Required | Description | | ||
| |---------------|----------|--------------------------------------------------------------------| | ||
| | `name` | no | Human-readable test name. | | ||
| | `type` | yes | `evtx`, `json`, `ndjson`, or `jsonl`. | | ||
| | `path` | yes | Path to the event sample. | | ||
| | `match_count` | no | Expected number of matches. Fails if fewer; warns if more. | | ||
| | `provider` | no | Event provider (informational, used by EVTX tests). | | ||
| | `pipelines` | no | Sigma pipelines applied before conversion (JSON types). | | ||
| | `filters` | no | Sigma filters applied during conversion (JSON types). | | ||
|
|
||
| If `match_count` is omitted, the test passes when there is at least one match. | ||
|
|
||
| ## Validation rules | ||
|
|
||
| - Rules with status `test` or `stable` must define `regression_tests_path` | ||
| (enforced unless `--ignore-validation`). | ||
| - Referenced `info.yml` and sample files must exist. | ||
| - Rule `id` == `info.yml`, `rule_metadata[0].id` == EVTX/JSON sample file name. | ||
|
|
||
| The runner exits non-zero on any failed test, missing file, or inconsistency. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| name: process_creation_fieldmapping | ||
| priority: 100 | ||
| transformations: | ||
| - id: field_mapping_process_creation | ||
| type: field_name_mapping | ||
| mapping: | ||
| CommandLine: Event.EventData.CommandLine | ||
| Image: Event.EventData.Image | ||
| OriginalFileName: Event.EventData.OriginalFileName | ||
| rule_conditions: | ||
| - type: logsource | ||
| category: process_creation | ||
| product: windows | ||
| - id: field_mapping_registry_set | ||
| type: field_name_mapping | ||
| mapping: | ||
| TargetObject: Event.EventData.TargetObject | ||
| Details: Event.EventData.Details | ||
| Image: Event.EventData.Image | ||
| rule_conditions: | ||
| - type: logsource | ||
| category: registry_set | ||
| product: windows | ||
|
swachchhanda000 marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -78,13 +78,23 @@ def load_info_yaml( | |
| } | ||
| ) | ||
|
|
||
| base_dir = os.path.dirname(regression_tests_path) | ||
| pipelines = [ | ||
| get_absolute_path(base_dir, p) for p in test.get("pipelines", []) | ||
| ] | ||
| filters = [ | ||
| get_absolute_path(base_dir, f) for f in test.get("filters", []) | ||
| ] | ||
|
Comment on lines
+85
to
+87
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Whats the though behind including filters in this. Can you share an example you think we might apply filters? Imo since this is only true positive testing (AKA intentional malicious matches). This should not be here. Thoughts?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the idea is that ppl can use this to "regression test" their env specific FP tunings in their local repo/ci. i dont think this will be used in the SigmaHQ repo itself. |
||
|
|
||
| test_data.append( | ||
| { | ||
| "type": test.get("type", "unknown"), | ||
| "path": test_path, | ||
| "name": test.get("name", "Unnamed Test"), | ||
| "provider": test.get("provider", ""), | ||
| "match_count": test.get("match_count"), | ||
| "pipelines": pipelines, | ||
| "filters": filters, | ||
| } | ||
| ) | ||
| info_metadata_rule_id = None | ||
|
|
@@ -293,12 +303,88 @@ def run_evtx_checker( | |
| return False, "" | ||
|
|
||
|
|
||
| def compile_rule_to_expr( | ||
| rule_path: str, pipelines: List[str], filters: List[str] | ||
| ) -> str: | ||
| """Compile a Sigma rule to a golang_expr query via the sigma CLI.""" | ||
| cmd = ["sigma", "convert", "-t", "golang_expr"] | ||
| for pipeline in pipelines: | ||
| cmd += ["-p", pipeline] | ||
| if len(pipelines) == 0: | ||
| cmd += ["--without-pipeline"] | ||
| for filter in filters: | ||
| cmd += ["--filter", filter] | ||
| cmd.append(rule_path) | ||
|
|
||
| result = subprocess.run( | ||
| cmd, capture_output=True, text=True, timeout=300, check=True | ||
| ) | ||
|
|
||
| return result.stdout.strip() | ||
|
|
||
|
|
||
| def run_json_checker( | ||
| test_type: str, | ||
| rule_path: str, | ||
| rule_id: str, | ||
| test_data: Dict, | ||
| json_checker_path: str, | ||
| ) -> tuple[bool, str]: | ||
| """Compile the rule to an expr query and run json_checker against the events.""" | ||
| try: | ||
| expr_query = compile_rule_to_expr( | ||
| rule_path, test_data.get("pipelines", []), test_data.get("filters", []) | ||
| ) | ||
| except subprocess.CalledProcessError as e: | ||
| print(f" Error compiling rule {rule_id} with sigma: {e.stderr or e}") | ||
| return False, "" | ||
| except subprocess.TimeoutExpired: | ||
| print(f" Timeout compiling rule {rule_id} with sigma") | ||
| return False, "" | ||
|
|
||
| if not expr_query: | ||
| print(f" Error: sigma produced an empty expr query for {rule_id}") | ||
| return False, "" | ||
|
|
||
| cmd = [json_checker_path, "--event", test_data["path"], "--expr", expr_query, "--test-type", test_type] | ||
| try: | ||
| result = subprocess.run( | ||
| cmd, capture_output=True, text=True, timeout=300, check=True | ||
| ) | ||
| except subprocess.TimeoutExpired: | ||
| print(" Timeout: json_checker timed out") | ||
| return False, "" | ||
| except subprocess.CalledProcessError as e: | ||
| print(f" Error running json_checker: {e.stderr or e}") | ||
| return False, "" | ||
|
|
||
| match_lines = [ln for ln in result.stdout.splitlines() if ln.endswith("MATCH")] | ||
| match_count = len(match_lines) | ||
| all_output = "\n ".join(match_lines) | ||
|
|
||
| expected_count = test_data.get("match_count") | ||
| if expected_count is not None: | ||
| if match_count < expected_count: | ||
| print( | ||
| f" Error: {rule_id}: Match count too low: expected {expected_count}, got {match_count}" | ||
| ) | ||
| return False, all_output | ||
| if match_count > expected_count: | ||
| print( | ||
| f" Warning: {rule_id}: Got {match_count} matches but only {expected_count} expected - consider updating match_count in info.yml" | ||
| ) | ||
| return True, all_output | ||
|
Comment on lines
+372
to
+376
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this should be an error as well. As to not bloat the logs. We should only consider the "malicious" dataset
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 100% agree but its the same for evtx atm (see line 288). this would have been a future proposal from my side. |
||
|
|
||
| return match_count > 0, all_output | ||
|
|
||
|
|
||
| def run_test( | ||
| rule_path: str, | ||
| rule_id: str, | ||
| test_data: Dict, | ||
| evtx_checker_path: str, | ||
| thor_config: str, | ||
| json_checker_path: str, | ||
| ) -> tuple[bool, str]: | ||
| """Run a test based on its type.""" | ||
| test_type = test_data.get("type", "unknown") | ||
|
|
@@ -307,6 +393,12 @@ def run_test( | |
| return run_evtx_checker( | ||
| rule_path, rule_id, test_data, evtx_checker_path, thor_config | ||
| ) | ||
|
|
||
| if test_type in {"json", "ndjson", "jsonl"}: | ||
| if not json_checker_path: | ||
| print(" Error: --json-checker is required for 'ndjson/json/jsonl' tests") | ||
| return False, "" | ||
| return run_json_checker(test_type, rule_path, rule_id, test_data, json_checker_path) | ||
| print(f" Warning: Unknown test type '{test_type}', skipping") | ||
| return False, "" | ||
|
|
||
|
|
@@ -335,6 +427,11 @@ def parse_arguments() -> argparse.Namespace: | |
| help="Path to thor.yml configuration file (required unless using --validate-only)", | ||
| ) | ||
|
|
||
| parser.add_argument( | ||
| "--json-checker", | ||
| help="Path to json_checker binary (required for 'ndjson/json/jsonl' tests)", | ||
| ) | ||
|
|
||
| parser.add_argument( | ||
| "--validate-only", | ||
| action="store_true", | ||
|
|
@@ -379,6 +476,11 @@ def init_checks(args: argparse.Namespace) -> None: | |
| if not os.path.exists(args.thor_config): | ||
| print(f"Error: Thor config not found at {args.thor_config}") | ||
| sys.exit(1) | ||
|
|
||
| # json_checker is optional; only needed for 'ndjson/json/jsonl' tests | ||
| if args.json_checker and not os.path.exists(args.json_checker): | ||
| print(f"Error: json_checker not found at {args.json_checker}") | ||
| sys.exit(1) | ||
| print(f"Rules paths: {args.rules_paths}") | ||
|
|
||
| if not args.validate_only: | ||
|
|
@@ -414,7 +516,12 @@ def run_tests( | |
| total_tests += 1 | ||
|
|
||
| success, output = run_test( | ||
| rule_path, rule_id, test_data, args.evtx_checker, args.thor_config | ||
| rule_path, | ||
| rule_id, | ||
| test_data, | ||
| args.evtx_checker, | ||
| args.thor_config, | ||
| args.json_checker, | ||
| ) | ||
|
|
||
| if success: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We need some guidelines defined around json files that are accepted in this repo.
While its true that any json correct file can be valid, we cannot allow that to happen as we could have heterogeneity that is unmaintainable.
Imo we should define a set of accepted types (that are for example generated by different tooling). Exports from SIEM X, EDR Y, or tool Z and then we can define a schema for those as well later down the line to have some sort of control.
And I think this is a good idea not only for HQ but in general for anyone trying to replicate this on their own
Thoughts?
cc @phantinuss @pH-T
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For the JSON logs I mainly think about all the cloud logs which are natively JSON. I don't think the workload of creating/finding the (correct) schemas is sth we can handle. While I understand the approach and its benefits I am not sure we have the resources to maintain the schemas. We would also lose flexibility when we have a new source we have to add a schema before the first addition is possible.
What is the fear of drift? We can still just decline json logs that e.g. clearly are synthetic.