Skip to content

Commit 2939a41

Browse files
authored
Merge pull request #188 from google/feature/parser-run-parsed-statedump
feat: add statedump parsing support to parser run
2 parents 551dce2 + 5d5f182 commit 2939a41

9 files changed

Lines changed: 374 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.35.2] - 2026-03-02
9+
### Added
10+
- `parse_statedump` parameter to `run_parser()` method for converting
11+
statedump strings into structured JSON format
12+
- CLI `--parse-statedump` flag for `secops parser run` command
13+
814
## [0.35.1] - 2026-02-23
915
### Added
1016
- `as_list` parameter to `search_udm()` for returning events as a list instead of dictionary

CLI.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,8 +616,18 @@ secops parser run \
616616
secops parser run \
617617
--log-type OKTA \
618618
--logs-file "./test.log"
619+
620+
# Run parser with statedump for debugging (outputs readable parser state)
621+
secops parser run \
622+
--log-type WINEVTLOG \
623+
--parser-code-file "./parser.conf" \
624+
--logs-file "./logs.txt" \
625+
--statedump-allowed \
626+
--parse-statedump
619627
```
620628

629+
The `--statedump-allowed` flag enables statedump output in the parser results, which shows the internal state of the parser during execution. The `--parse-statedump` flag converts the statedump string into a structured JSON format.
630+
621631
The command validates:
622632
- Log type and parser code are provided
623633
- At least one log is provided

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1666,6 +1666,27 @@ if "runParserResults" in result:
16661666
print(f" Parsed events: {parser_result['parsedEvents']}")
16671667
if "errors" in parser_result:
16681668
print(f" Errors: {parser_result['errors']}")
1669+
1670+
# Run parser with statedump for debugging
1671+
# Statedump provides internal parser state useful for troubleshooting
1672+
result_with_statedump = chronicle.run_parser(
1673+
log_type=log_type,
1674+
parser_code=parser_text,
1675+
parser_extension_code=None,
1676+
logs=sample_logs,
1677+
statedump_allowed=True, # Enable statedump in parser output
1678+
parse_statedump=True # Parse statedump string into structured format
1679+
)
1680+
1681+
# Check statedump results (useful for parser debugging)
1682+
if "runParserResults" in result_with_statedump:
1683+
for i, parser_result in enumerate(result_with_statedump["runParserResults"]):
1684+
if "statedumpResults" in parser_result:
1685+
for dump in parser_result["statedumpResults"]:
1686+
statedump = dump.get("statedumpResult", {})
1687+
print(f"\nParser state for log {i+1}:")
1688+
print(f" Info: {statedump.get('info', '')}")
1689+
print(f" State: {statedump.get('state', {})}")
16691690
```
16701691

16711692
The `run_parser` function includes comprehensive validation:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "secops"
7-
version = "0.35.1"
7+
version = "0.35.2"
88
description = "Python SDK for wrapping the Google SecOps API for common use cases"
99
readme = "README.md"
1010
requires-python = ">=3.10"

src/secops/chronicle/client.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2441,6 +2441,7 @@ def run_parser(
24412441
parser_extension_code: str,
24422442
logs: list,
24432443
statedump_allowed: bool = False,
2444+
parse_statedump: bool = False,
24442445
):
24452446
"""Run parser against sample logs.
24462447
@@ -2451,6 +2452,8 @@ def run_parser(
24512452
parser_extension_code: Content of the parser extension
24522453
logs: list of logs to test parser against
24532454
statedump_allowed: Statedump filter is enabled or not for a config
2455+
parse_statedump: Whether to parse statedump results into
2456+
structured format.
24542457
24552458
Returns:
24562459
Dictionary containing the parser result
@@ -2465,6 +2468,7 @@ def run_parser(
24652468
parser_extension_code=parser_extension_code,
24662469
logs=logs,
24672470
statedump_allowed=statedump_allowed,
2471+
parse_statedump=parse_statedump,
24682472
)
24692473

24702474
# Rule Set methods

src/secops/chronicle/parser.py

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"""Parser management functionality for Chronicle."""
1616

1717
import base64
18+
import json
1819
from typing import Any
1920

2021
from secops.exceptions import APIError
@@ -319,6 +320,7 @@ def run_parser(
319320
parser_extension_code: str | None,
320321
logs: list[str],
321322
statedump_allowed: bool = False,
323+
parse_statedump: bool = False,
322324
) -> dict[str, Any]:
323325
"""Run parser against sample logs.
324326
@@ -329,17 +331,22 @@ def run_parser(
329331
parser_extension_code: Optional content of the parser extension
330332
logs: List of log strings to test parser against
331333
statedump_allowed: Whether statedump filter is enabled for the config
334+
parse_statedump: Whether to parse statedump results into structured
335+
format.
332336
333337
Returns:
334338
Dictionary containing the parser evaluation results with structure:
335339
{
336340
"runParserResults": [
337341
{
338342
"parsedEvents": [...],
339-
"errors": [...]
343+
"errors": [...],
344+
"statedumpResults": [...] (if statedump_allowed=True)
340345
}
341346
]
342347
}
348+
If parse_statedump is True, statedumpResult strings are converted
349+
to structured objects.
343350
344351
Raises:
345352
ValueError: If input parameters are invalid
@@ -450,4 +457,35 @@ def run_parser(
450457

451458
raise APIError(error_detail)
452459

453-
return response.json()
460+
result = response.json()
461+
462+
if parse_statedump and "runParserResults" in result:
463+
for run_result in result["runParserResults"]:
464+
if "statedumpResults" in run_result:
465+
for statedump_item in run_result["statedumpResults"]:
466+
if "statedumpResult" in statedump_item:
467+
try:
468+
dump_str = statedump_item["statedumpResult"]
469+
if isinstance(dump_str, str):
470+
stripped = dump_str.strip()
471+
if ":" in stripped:
472+
parts = stripped.split("\n", 1)
473+
info_line = parts[0].strip()
474+
if "Internal State" in info_line:
475+
info = info_line
476+
if len(parts) > 1:
477+
state_json = parts[1].strip()
478+
state = json.loads(state_json)
479+
else:
480+
state = {}
481+
statedump_item["statedumpResult"] = {
482+
"info": info,
483+
"state": state,
484+
}
485+
except (
486+
ValueError,
487+
json.JSONDecodeError,
488+
) as e:
489+
print(f"Warning: Failed to parse statedump: {e}")
490+
491+
return result

src/secops/cli/commands/parser.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,12 @@ def setup_parser_command(subparsers):
223223
action="store_true",
224224
help="Enable statedump filter for the parser configuration",
225225
)
226+
run_parser_sub.add_argument(
227+
"--parse-statedump",
228+
"--parse_statedump",
229+
action="store_true",
230+
help=("Parse statedump results into readable format"),
231+
)
226232
run_parser_sub.set_defaults(func=handle_parser_run_command)
227233

228234

@@ -404,6 +410,7 @@ def handle_parser_run_command(args, chronicle):
404410
parser_extension_code,
405411
logs,
406412
args.statedump_allowed,
413+
args.parse_statedump,
407414
)
408415

409416
output_formatter(result, args.output)

tests/chronicle/test_parser.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -955,3 +955,176 @@ def test_run_parser_validation_non_string_log(chronicle_client):
955955
)
956956
assert "All logs must be strings" in str(exc_info.value)
957957
assert "index 1" in str(exc_info.value)
958+
959+
960+
def test_run_parser_with_statedump_parsing(chronicle_client, mock_response):
961+
"""Test run_parser with parse_statedump=True."""
962+
log_type = "WINEVTLOG"
963+
parser_code = "filter {}"
964+
logs = ["test log"]
965+
966+
statedump_string = '\n\nInternal State (label=):\n{\n "key": "value"\n}'
967+
968+
expected_result = {
969+
"runParserResults": [
970+
{
971+
"parsedEvents": {"events": []},
972+
"statedumpResults": [{"statedumpResult": statedump_string}],
973+
}
974+
]
975+
}
976+
mock_response.json.return_value = expected_result
977+
978+
with patch.object(
979+
chronicle_client.session, "post", return_value=mock_response
980+
) as mock_post:
981+
result = run_parser(
982+
chronicle_client,
983+
log_type=log_type,
984+
parser_code=parser_code,
985+
parser_extension_code="",
986+
logs=logs,
987+
statedump_allowed=True,
988+
parse_statedump=True,
989+
)
990+
991+
called_args = mock_post.call_args
992+
request_body = called_args[1]["json"]
993+
assert request_body["statedump_allowed"] is True
994+
995+
assert "runParserResults" in result
996+
assert len(result["runParserResults"]) == 1
997+
statedump_results = result["runParserResults"][0]["statedumpResults"]
998+
assert len(statedump_results) == 1
999+
parsed_statedump = statedump_results[0]["statedumpResult"]
1000+
assert isinstance(parsed_statedump, dict)
1001+
assert "info" in parsed_statedump
1002+
assert "state" in parsed_statedump
1003+
assert parsed_statedump["info"] == "Internal State (label=):"
1004+
assert parsed_statedump["state"]["key"] == "value"
1005+
1006+
1007+
def test_run_parser_without_statedump_parsing(chronicle_client, mock_response):
1008+
"""Test run_parser with parse_statedump=False (default)."""
1009+
log_type = "WINEVTLOG"
1010+
parser_code = "filter {}"
1011+
logs = ["test log"]
1012+
1013+
statedump_string = '\n\nInternal State (label=):\n{\n "key": "value"\n}'
1014+
1015+
expected_result = {
1016+
"runParserResults": [
1017+
{
1018+
"parsedEvents": {"events": []},
1019+
"statedumpResults": [{"statedumpResult": statedump_string}],
1020+
}
1021+
]
1022+
}
1023+
mock_response.json.return_value = expected_result
1024+
1025+
with patch.object(
1026+
chronicle_client.session, "post", return_value=mock_response
1027+
):
1028+
result = run_parser(
1029+
chronicle_client,
1030+
log_type=log_type,
1031+
parser_code=parser_code,
1032+
parser_extension_code="",
1033+
logs=logs,
1034+
statedump_allowed=True,
1035+
parse_statedump=False,
1036+
)
1037+
1038+
assert "runParserResults" in result
1039+
statedump_results = result["runParserResults"][0]["statedumpResults"]
1040+
original_statedump = statedump_results[0]["statedumpResult"]
1041+
assert original_statedump == statedump_string
1042+
1043+
1044+
def test_run_parser_statedump_parsing_with_invalid_json(
1045+
chronicle_client, mock_response, capsys
1046+
):
1047+
"""Test statedump parsing handles invalid JSON gracefully."""
1048+
log_type = "WINEVTLOG"
1049+
parser_code = "filter {}"
1050+
logs = ["test log"]
1051+
1052+
expected_result = {
1053+
"runParserResults": [
1054+
{
1055+
"parsedEvents": {"events": []},
1056+
"statedumpResults": [
1057+
{"statedumpResult": "Internal State:\n{invalid json}"}
1058+
],
1059+
}
1060+
]
1061+
}
1062+
mock_response.json.return_value = expected_result
1063+
1064+
with patch.object(
1065+
chronicle_client.session, "post", return_value=mock_response
1066+
):
1067+
result = run_parser(
1068+
chronicle_client,
1069+
log_type=log_type,
1070+
parser_code=parser_code,
1071+
parser_extension_code="",
1072+
logs=logs,
1073+
statedump_allowed=True,
1074+
parse_statedump=True,
1075+
)
1076+
1077+
captured = capsys.readouterr()
1078+
assert "Warning: Failed to parse statedump" in captured.out
1079+
1080+
assert "runParserResults" in result
1081+
statedump_results = result["runParserResults"][0]["statedumpResults"]
1082+
assert (
1083+
statedump_results[0]["statedumpResult"]
1084+
== "Internal State:\n{invalid json}"
1085+
)
1086+
1087+
1088+
def test_run_parser_statedump_parsing_multiple_results(
1089+
chronicle_client, mock_response
1090+
):
1091+
"""Test statedump parsing with multiple statedump results."""
1092+
log_type = "WINEVTLOG"
1093+
parser_code = "filter {}"
1094+
logs = ["test log 1", "test log 2"]
1095+
1096+
statedump1 = '\n\nInternal State (label=):\n{\n "log": "1"\n}'
1097+
statedump2 = '\n\nInternal State (label=):\n{\n "log": "2"\n}'
1098+
1099+
expected_result = {
1100+
"runParserResults": [
1101+
{
1102+
"parsedEvents": {"events": []},
1103+
"statedumpResults": [
1104+
{"statedumpResult": statedump1},
1105+
{"statedumpResult": statedump2},
1106+
],
1107+
}
1108+
]
1109+
}
1110+
mock_response.json.return_value = expected_result
1111+
1112+
with patch.object(
1113+
chronicle_client.session, "post", return_value=mock_response
1114+
):
1115+
result = run_parser(
1116+
chronicle_client,
1117+
log_type=log_type,
1118+
parser_code=parser_code,
1119+
parser_extension_code="",
1120+
logs=logs,
1121+
statedump_allowed=True,
1122+
parse_statedump=True,
1123+
)
1124+
1125+
statedump_results = result["runParserResults"][0]["statedumpResults"]
1126+
assert len(statedump_results) == 2
1127+
assert isinstance(statedump_results[0]["statedumpResult"], dict)
1128+
assert statedump_results[0]["statedumpResult"]["state"]["log"] == "1"
1129+
assert isinstance(statedump_results[1]["statedumpResult"], dict)
1130+
assert statedump_results[1]["statedumpResult"]["state"]["log"] == "2"

0 commit comments

Comments
 (0)