From b2f8d67b3df4428593d08ecf3daff5b48203e83a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:43:34 +0000 Subject: [PATCH 1/4] Add sigma.matching module for runtime event matching against Sigma rules Co-authored-by: thomaspatzke <1845601+thomaspatzke@users.noreply.github.com> --- .mypy_cache/3.13/cache.0.db | Bin 0 -> 16384 bytes .mypy_cache/3.13/cache.1.db | Bin 0 -> 16384 bytes .mypy_cache/3.13/cache.10.db | Bin 0 -> 16384 bytes .mypy_cache/3.13/cache.11.db | Bin 0 -> 16384 bytes .mypy_cache/3.13/cache.12.db | Bin 0 -> 16384 bytes .mypy_cache/3.13/cache.13.db | Bin 0 -> 16384 bytes .mypy_cache/3.13/cache.14.db | Bin 0 -> 16384 bytes .mypy_cache/3.13/cache.15.db | Bin 0 -> 16384 bytes .mypy_cache/3.13/cache.2.db | Bin 0 -> 16384 bytes .mypy_cache/3.13/cache.3.db | Bin 0 -> 16384 bytes .mypy_cache/3.13/cache.4.db | Bin 0 -> 16384 bytes .mypy_cache/3.13/cache.5.db | Bin 0 -> 16384 bytes .mypy_cache/3.13/cache.6.db | Bin 0 -> 16384 bytes .mypy_cache/3.13/cache.7.db | Bin 0 -> 16384 bytes .mypy_cache/3.13/cache.8.db | Bin 0 -> 16384 bytes .mypy_cache/3.13/cache.9.db | Bin 0 -> 16384 bytes sigma/matching/__init__.py | 14 + sigma/matching/channel.py | 39 +++ sigma/matching/compiler.py | 231 ++++++++++++++++ sigma/matching/correlation_matcher.py | 288 ++++++++++++++++++++ sigma/matching/event.py | 37 +++ sigma/matching/match.py | 19 ++ sigma/matching/matcher.py | 129 +++++++++ sigma/matching/rule_matcher.py | 30 +++ sigma/matching/state.py | 42 +++ tests/test_matching.py | 370 ++++++++++++++++++++++++++ 26 files changed, 1199 insertions(+) create mode 100644 .mypy_cache/3.13/cache.0.db create mode 100644 .mypy_cache/3.13/cache.1.db create mode 100644 .mypy_cache/3.13/cache.10.db create mode 100644 .mypy_cache/3.13/cache.11.db create mode 100644 .mypy_cache/3.13/cache.12.db create mode 100644 .mypy_cache/3.13/cache.13.db create mode 100644 .mypy_cache/3.13/cache.14.db create mode 100644 .mypy_cache/3.13/cache.15.db create mode 100644 .mypy_cache/3.13/cache.2.db create mode 100644 .mypy_cache/3.13/cache.3.db create mode 100644 .mypy_cache/3.13/cache.4.db create mode 100644 .mypy_cache/3.13/cache.5.db create mode 100644 .mypy_cache/3.13/cache.6.db create mode 100644 .mypy_cache/3.13/cache.7.db create mode 100644 .mypy_cache/3.13/cache.8.db create mode 100644 .mypy_cache/3.13/cache.9.db create mode 100644 sigma/matching/__init__.py create mode 100644 sigma/matching/channel.py create mode 100644 sigma/matching/compiler.py create mode 100644 sigma/matching/correlation_matcher.py create mode 100644 sigma/matching/event.py create mode 100644 sigma/matching/match.py create mode 100644 sigma/matching/matcher.py create mode 100644 sigma/matching/rule_matcher.py create mode 100644 sigma/matching/state.py create mode 100644 tests/test_matching.py diff --git a/.mypy_cache/3.13/cache.0.db b/.mypy_cache/3.13/cache.0.db new file mode 100644 index 0000000000000000000000000000000000000000..26ad419ed271d4fad3412df089f381723a4524c2 GIT binary patch literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad literal 0 HcmV?d00001 diff --git a/.mypy_cache/3.13/cache.1.db b/.mypy_cache/3.13/cache.1.db new file mode 100644 index 0000000000000000000000000000000000000000..26ad419ed271d4fad3412df089f381723a4524c2 GIT binary patch literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad literal 0 HcmV?d00001 diff --git a/.mypy_cache/3.13/cache.10.db b/.mypy_cache/3.13/cache.10.db new file mode 100644 index 0000000000000000000000000000000000000000..26ad419ed271d4fad3412df089f381723a4524c2 GIT binary patch literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad literal 0 HcmV?d00001 diff --git a/.mypy_cache/3.13/cache.11.db b/.mypy_cache/3.13/cache.11.db new file mode 100644 index 0000000000000000000000000000000000000000..26ad419ed271d4fad3412df089f381723a4524c2 GIT binary patch literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad literal 0 HcmV?d00001 diff --git a/.mypy_cache/3.13/cache.12.db b/.mypy_cache/3.13/cache.12.db new file mode 100644 index 0000000000000000000000000000000000000000..26ad419ed271d4fad3412df089f381723a4524c2 GIT binary patch literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad literal 0 HcmV?d00001 diff --git a/.mypy_cache/3.13/cache.13.db b/.mypy_cache/3.13/cache.13.db new file mode 100644 index 0000000000000000000000000000000000000000..26ad419ed271d4fad3412df089f381723a4524c2 GIT binary patch literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad literal 0 HcmV?d00001 diff --git a/.mypy_cache/3.13/cache.14.db b/.mypy_cache/3.13/cache.14.db new file mode 100644 index 0000000000000000000000000000000000000000..26ad419ed271d4fad3412df089f381723a4524c2 GIT binary patch literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad literal 0 HcmV?d00001 diff --git a/.mypy_cache/3.13/cache.15.db b/.mypy_cache/3.13/cache.15.db new file mode 100644 index 0000000000000000000000000000000000000000..26ad419ed271d4fad3412df089f381723a4524c2 GIT binary patch literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad literal 0 HcmV?d00001 diff --git a/.mypy_cache/3.13/cache.2.db b/.mypy_cache/3.13/cache.2.db new file mode 100644 index 0000000000000000000000000000000000000000..26ad419ed271d4fad3412df089f381723a4524c2 GIT binary patch literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad literal 0 HcmV?d00001 diff --git a/.mypy_cache/3.13/cache.3.db b/.mypy_cache/3.13/cache.3.db new file mode 100644 index 0000000000000000000000000000000000000000..26ad419ed271d4fad3412df089f381723a4524c2 GIT binary patch literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad literal 0 HcmV?d00001 diff --git a/.mypy_cache/3.13/cache.4.db b/.mypy_cache/3.13/cache.4.db new file mode 100644 index 0000000000000000000000000000000000000000..26ad419ed271d4fad3412df089f381723a4524c2 GIT binary patch literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad literal 0 HcmV?d00001 diff --git a/.mypy_cache/3.13/cache.5.db b/.mypy_cache/3.13/cache.5.db new file mode 100644 index 0000000000000000000000000000000000000000..26ad419ed271d4fad3412df089f381723a4524c2 GIT binary patch literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad literal 0 HcmV?d00001 diff --git a/.mypy_cache/3.13/cache.6.db b/.mypy_cache/3.13/cache.6.db new file mode 100644 index 0000000000000000000000000000000000000000..26ad419ed271d4fad3412df089f381723a4524c2 GIT binary patch literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad literal 0 HcmV?d00001 diff --git a/.mypy_cache/3.13/cache.7.db b/.mypy_cache/3.13/cache.7.db new file mode 100644 index 0000000000000000000000000000000000000000..26ad419ed271d4fad3412df089f381723a4524c2 GIT binary patch literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad literal 0 HcmV?d00001 diff --git a/.mypy_cache/3.13/cache.8.db b/.mypy_cache/3.13/cache.8.db new file mode 100644 index 0000000000000000000000000000000000000000..26ad419ed271d4fad3412df089f381723a4524c2 GIT binary patch literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad literal 0 HcmV?d00001 diff --git a/.mypy_cache/3.13/cache.9.db b/.mypy_cache/3.13/cache.9.db new file mode 100644 index 0000000000000000000000000000000000000000..26ad419ed271d4fad3412df089f381723a4524c2 GIT binary patch literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad literal 0 HcmV?d00001 diff --git a/sigma/matching/__init__.py b/sigma/matching/__init__.py new file mode 100644 index 00000000..1dde9ae8 --- /dev/null +++ b/sigma/matching/__init__.py @@ -0,0 +1,14 @@ +from sigma.matching.event import Event, EventLogSource +from sigma.matching.match import SigmaMatch +from sigma.matching.matcher import SigmaMatcher +from sigma.matching.rule_matcher import SigmaRuleMatcher +from sigma.matching.correlation_matcher import SigmaCorrelationRuleMatcher + +__all__ = [ + "Event", + "EventLogSource", + "SigmaMatch", + "SigmaMatcher", + "SigmaRuleMatcher", + "SigmaCorrelationRuleMatcher", +] diff --git a/sigma/matching/channel.py b/sigma/matching/channel.py new file mode 100644 index 00000000..3604c145 --- /dev/null +++ b/sigma/matching/channel.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import queue +from abc import ABC, abstractmethod +from typing import Generic, TypeVar + +T = TypeVar("T") + + +class MessageChannel(ABC, Generic[T]): + """Abstract message channel for passing typed messages between components.""" + + @abstractmethod + def send(self, message: T) -> None: ... + + @abstractmethod + def receive(self, timeout: float | None = None) -> T | None: ... + + def close(self) -> None: + pass + + +class InProcessChannel(MessageChannel[T]): + """Queue-based in-process channel.""" + + def __init__(self) -> None: + self._queue: queue.Queue[T] = queue.Queue() + + def send(self, message: T) -> None: + self._queue.put(message) + + def receive(self, timeout: float | None = None) -> T | None: + try: + return self._queue.get(timeout=timeout) + except queue.Empty: + return None + + def close(self) -> None: + pass diff --git a/sigma/matching/compiler.py b/sigma/matching/compiler.py new file mode 100644 index 00000000..2393e399 --- /dev/null +++ b/sigma/matching/compiler.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import re +from ipaddress import ip_address, ip_network +from typing import Any, Callable + +from sigma.conditions import ( + ConditionAND, + ConditionFieldEqualsValueExpression, + ConditionItem, + ConditionNOT, + ConditionOR, + ConditionValueExpression, +) +from sigma.rule.rule import SigmaRule +from sigma.types import ( + CompareOperators, + SigmaBool, + SigmaCasedString, + SigmaCIDRExpression, + SigmaCompareExpression, + SigmaExists, + SigmaExpansion, + SigmaFieldReference, + SigmaNull, + SigmaNumber, + SigmaRegularExpression, + SigmaRegularExpressionFlag, + SigmaString, + SpecialChars, +) + +# Type for compiled matcher callable: (data_dict, raw_log_or_none) -> bool +CompiledMatcher = Callable[[dict[str, Any], str | None], bool] + + +class MatchCompiler: + """Compiles Sigma rule detection logic into optimized Python callables.""" + + def compile_rule(self, rule: SigmaRule) -> CompiledMatcher: + """Compile a SigmaRule's conditions into a single callable matcher.""" + # A rule can have multiple conditions (one per condition string). + # They are OR-linked by default in pySigma. + conditions = rule.detection.parsed_condition + closures: dict[str, Any] = {} + parts: list[str] = [] + + for cond in conditions: + parsed = cond.parsed + if parsed is None: + parts.append("False") + else: + parts.append(self._compile_node(parsed, closures)) + + if len(parts) == 1: + expr = parts[0] + else: + expr = "(" + " or ".join(parts) + ")" + + func_code = f"def _match(data, raw):\n return {expr}\n" + code_obj = compile(func_code, "", "exec") + exec(code_obj, closures) # noqa: S102 + return closures["_match"] + + def _compile_node(self, node: Any, closures: dict[str, Any]) -> str: + if isinstance(node, ConditionAND): + sub = [self._compile_node(arg, closures) for arg in node.args] + return "(" + " and ".join(sub) + ")" + elif isinstance(node, ConditionOR): + sub = [self._compile_node(arg, closures) for arg in node.args] + return "(" + " or ".join(sub) + ")" + elif isinstance(node, ConditionNOT): + sub = self._compile_node(node.args[0], closures) + return f"(not {sub})" + elif isinstance(node, ConditionFieldEqualsValueExpression): + return self._compile_field_value(node.field, node.value, closures) + elif isinstance(node, ConditionValueExpression): + return self._compile_keyword(node.value, closures) + else: + return "False" + + def _compile_field_value(self, field_name: str, value: Any, closures: dict[str, Any]) -> str: + if isinstance(value, SigmaNull): + return f"(data.get({field_name!r}) is None)" + + elif isinstance(value, SigmaBool): + bool_val = bool(value) + return f"(data.get({field_name!r}) is {bool_val})" + + elif isinstance(value, SigmaExists): + if value.exists: + return f"({field_name!r} in data)" + else: + return f"({field_name!r} not in data)" + + elif isinstance(value, SigmaFieldReference): + ref_field = value.field + if value.starts_with and value.ends_with: + # contains + return f"(str(data.get({ref_field!r}, '')) in str(data.get({field_name!r}, '')))" + elif value.starts_with: + return f"(str(data.get({field_name!r}, '')).startswith(str(data.get({ref_field!r}, ''))))" + elif value.ends_with: + return f"(str(data.get({field_name!r}, '')).endswith(str(data.get({ref_field!r}, ''))))" + else: + return f"(data.get({field_name!r}) == data.get({ref_field!r}))" + + elif isinstance(value, SigmaCompareExpression): + num_val = value.number.number + op_map = { + CompareOperators.LT: "<", + CompareOperators.LTE: "<=", + CompareOperators.GT: ">", + CompareOperators.GTE: ">=", + CompareOperators.NEQ: "!=", + } + op_str = op_map[value.op] + return f"(data.get({field_name!r}, 0) {op_str} {num_val!r})" + + elif isinstance(value, SigmaCIDRExpression): + var_name = f"_cidr_{len(closures)}" + closures[var_name] = value.network + closures["_try_cidr"] = _try_cidr + return f"(_try_cidr(data.get({field_name!r}, ''), {var_name}))" + + elif isinstance(value, SigmaExpansion): + # OR over all expansion values + sub_parts = [] + for sub_val in value.values: + sub_parts.append(self._compile_field_value(field_name, sub_val, closures)) + return "(" + " or ".join(sub_parts) + ")" + + elif isinstance(value, SigmaRegularExpression): + var_name = f"_re_{len(closures)}" + flags = 0 + for flag in value.flags: + flags |= value.sigma_to_python_flags[flag] + closures[var_name] = re.compile(str(value.regexp), flags) + return f"({var_name}.search(str(data.get({field_name!r}, ''))) is not None)" + + elif isinstance(value, SigmaNumber): + num = value.number + return f"(data.get({field_name!r}) == {num!r})" + + elif isinstance(value, SigmaCasedString): + # Case-sensitive matching + return self._compile_string_match(field_name, value, closures, case_sensitive=True) + + elif isinstance(value, SigmaString): + # Case-insensitive matching + return self._compile_string_match(field_name, value, closures, case_sensitive=False) + + else: + return "False" + + def _compile_string_match( + self, + field_name: str, + value: SigmaString, + closures: dict[str, Any], + case_sensitive: bool, + ) -> str: + if not value.contains_special(): + # Plain string equality + plain = str(value) + if case_sensitive: + return f"(str(data.get({field_name!r}, '')) == {plain!r})" + else: + lower_val = plain.lower() + return f"(str(data.get({field_name!r}, '')).lower() == {lower_val!r})" + else: + # Contains wildcards - compile to regex + regex_str = self._sigma_string_to_regex(value) + flags = 0 if case_sensitive else re.IGNORECASE + var_name = f"_pat_{len(closures)}" + closures[var_name] = re.compile(regex_str, flags) + return f"({var_name}.search(str(data.get({field_name!r}, ''))) is not None)" + + def _sigma_string_to_regex(self, value: SigmaString) -> str: + """Convert a SigmaString with wildcards to a regex pattern (anchored).""" + parts: list[str] = [] + for item in value.s: + if isinstance(item, str): + parts.append(re.escape(item)) + elif item == SpecialChars.WILDCARD_MULTI: + parts.append(".*") + elif item == SpecialChars.WILDCARD_SINGLE: + parts.append(".") + pattern = "".join(parts) + # Anchor: if no leading wildcard, anchor start; if no trailing, anchor end + if not value.startswith(SpecialChars.WILDCARD_MULTI): + pattern = "^" + pattern + if not value.endswith(SpecialChars.WILDCARD_MULTI): + pattern = pattern + "$" + return pattern + + def _compile_keyword(self, value: Any, closures: dict[str, Any]) -> str: + """Compile a keyword (value-only) expression that searches in raw log.""" + if isinstance(value, SigmaString): + if not value.contains_special(): + plain = str(value).lower() + return f"({plain!r} in (raw or '').lower())" + else: + regex_str = self._sigma_string_to_regex(value) + # For keywords, remove anchors - search anywhere + regex_str = regex_str.lstrip("^").rstrip("$") + var_name = f"_kw_{len(closures)}" + closures[var_name] = re.compile(regex_str, re.IGNORECASE) + return f"({var_name}.search(raw or '') is not None)" + elif isinstance(value, SigmaRegularExpression): + var_name = f"_kw_{len(closures)}" + flags = 0 + for flag in value.flags: + flags |= value.sigma_to_python_flags[flag] + closures[var_name] = re.compile(str(value.regexp), flags) + return f"({var_name}.search(raw or '') is not None)" + elif isinstance(value, SigmaExpansion): + sub_parts = [] + for sub_val in value.values: + sub_parts.append(self._compile_keyword(sub_val, closures)) + return "(" + " or ".join(sub_parts) + ")" + else: + return "False" + + +def _try_cidr(value: Any, network: Any) -> bool: + """Check if value is an IP within the given network.""" + try: + return ip_address(str(value)) in network + except (ValueError, TypeError): + return False diff --git a/sigma/matching/correlation_matcher.py b/sigma/matching/correlation_matcher.py new file mode 100644 index 00000000..5ab59fb3 --- /dev/null +++ b/sigma/matching/correlation_matcher.py @@ -0,0 +1,288 @@ +from __future__ import annotations + +from collections import defaultdict +from statistics import median +from typing import Any + +from sigma.correlations import ( + SigmaCorrelationCondition, + SigmaCorrelationConditionOperator, + SigmaCorrelationRule, + SigmaCorrelationType, +) +from sigma.matching.event import Event +from sigma.matching.match import SigmaMatch +from sigma.matching.state import SlidingWindowState + + +class SigmaCorrelationRuleMatcher: + """Evaluates a SigmaCorrelationRule by tracking matches from referenced base rules.""" + + def __init__( + self, + rule: SigmaCorrelationRule, + max_state: int | None = None, + max_events_per_match: int | None = 100, + ) -> None: + self.rule = rule + self.max_events_per_match = max_events_per_match + self._state = SlidingWindowState( + window_seconds=rule.timespan.seconds, + max_entries=max_state, + ) + # Track which base rule names are relevant + self._rule_names: set[str] = set() + if rule.rules: + for ref in rule.rules: + self._rule_names.add(ref.reference) + + def receive_match(self, match: SigmaMatch, source_rule_name: str) -> list[SigmaMatch]: + """Process an incoming match from a base rule and return correlation matches if any.""" + group_key = self._extract_group_key(match) + self._state.add(group_key, match) + + window_matches = self._state.get_window(group_key, match.timestamp) + result = self._evaluate(window_matches, group_key, match.timestamp) + return result + + def _extract_group_key(self, match: SigmaMatch) -> tuple[Any, ...]: + """Extract group-by field values from match events.""" + if not self.rule.group_by: + return () + + values: list[Any] = [] + event = match.events[0] if match.events else None + if event is None: + return tuple(None for _ in self.rule.group_by) + + for field_name in self.rule.group_by: + # Check aliases + resolved_field = self._resolve_alias(field_name, match) + values.append(event.data.get(resolved_field)) + return tuple(values) + + def _resolve_alias(self, field_name: str, match: SigmaMatch) -> str: + """Resolve field alias to actual field name based on source rule.""" + if field_name in self.rule.aliases.aliases: + alias = self.rule.aliases.aliases[field_name] + # Try to find mapping for the source rule + for ref, mapped_field in alias.mapping.items(): + if hasattr(ref, "rule") and ref.rule is match.rule: + return mapped_field + return field_name + + def _evaluate( + self, + window_matches: list[SigmaMatch], + group_key: tuple[Any, ...], + now: Any, + ) -> list[SigmaMatch]: + """Evaluate the correlation condition against current window state.""" + ctype = self.rule.type + condition = self.rule.condition + + if ctype == SigmaCorrelationType.EVENT_COUNT: + return self._eval_event_count(window_matches, group_key, now, condition) + elif ctype == SigmaCorrelationType.VALUE_COUNT: + return self._eval_value_count(window_matches, group_key, now, condition) + elif ctype == SigmaCorrelationType.TEMPORAL: + return self._eval_temporal(window_matches, group_key, now) + elif ctype == SigmaCorrelationType.TEMPORAL_ORDERED: + return self._eval_temporal_ordered(window_matches, group_key, now) + elif ctype == SigmaCorrelationType.VALUE_SUM: + return self._eval_value_aggregate(window_matches, group_key, now, condition, "sum") + elif ctype == SigmaCorrelationType.VALUE_AVG: + return self._eval_value_aggregate(window_matches, group_key, now, condition, "avg") + elif ctype == SigmaCorrelationType.VALUE_MEDIAN: + return self._eval_value_aggregate(window_matches, group_key, now, condition, "median") + elif ctype == SigmaCorrelationType.VALUE_PERCENTILE: + return self._eval_value_aggregate( + window_matches, group_key, now, condition, "percentile" + ) + return [] + + def _compare(self, op: SigmaCorrelationConditionOperator, value: Any, threshold: int) -> bool: + """Compare a value against a threshold using the given operator.""" + if value is None: + return False + op_map = { + SigmaCorrelationConditionOperator.LT: lambda a, b: a < b, + SigmaCorrelationConditionOperator.LTE: lambda a, b: a <= b, + SigmaCorrelationConditionOperator.GT: lambda a, b: a > b, + SigmaCorrelationConditionOperator.GTE: lambda a, b: a >= b, + SigmaCorrelationConditionOperator.EQ: lambda a, b: a == b, + SigmaCorrelationConditionOperator.NEQ: lambda a, b: a != b, + } + return op_map[op](value, threshold) + + def _collect_events(self, matches: list[SigmaMatch]) -> list[Event]: + """Collect events from matches, respecting the max_events_per_match limit.""" + events: list[Event] = [] + limit = self.max_events_per_match + if limit is None: + for m in matches: + events.extend(m.events) + else: + for m in matches: + for e in m.events: + if len(events) >= limit: + return events + events.append(e) + return events + + def _make_group_values(self, group_key: tuple[Any, ...]) -> dict[str, Any] | None: + if not self.rule.group_by: + return None + return dict(zip(self.rule.group_by, group_key)) + + def _eval_event_count( + self, + matches: list[SigmaMatch], + group_key: tuple[Any, ...], + now: Any, + condition: Any, + ) -> list[SigmaMatch]: + if not isinstance(condition, SigmaCorrelationCondition): + return [] + count = len(matches) + if self._compare(condition.op, count, condition.count): + return [ + SigmaMatch( + rule=self.rule, + events=self._collect_events(matches), + timestamp=now, + group_values=self._make_group_values(group_key), + ) + ] + return [] + + def _eval_value_count( + self, + matches: list[SigmaMatch], + group_key: tuple[Any, ...], + now: Any, + condition: Any, + ) -> list[SigmaMatch]: + if not isinstance(condition, SigmaCorrelationCondition): + return [] + field_ref = condition.fieldref + if field_ref is None: + return [] + # Count distinct values of the field + values: set[Any] = set() + for m in matches: + for e in m.events: + val = e.data.get(field_ref) # type: ignore[arg-type] + if val is not None: + values.add(val) + if self._compare(condition.op, len(values), condition.count): + return [ + SigmaMatch( + rule=self.rule, + events=self._collect_events(matches), + timestamp=now, + group_values=self._make_group_values(group_key), + ) + ] + return [] + + def _eval_temporal( + self, + matches: list[SigmaMatch], + group_key: tuple[Any, ...], + now: Any, + ) -> list[SigmaMatch]: + """All referenced rules must fire within the time window.""" + matched_rules: set[str] = set() + for m in matches: + if hasattr(m.rule, "name") and m.rule.name: + matched_rules.add(m.rule.name) + if self._rule_names and self._rule_names.issubset(matched_rules): + return [ + SigmaMatch( + rule=self.rule, + events=self._collect_events(matches), + timestamp=now, + group_values=self._make_group_values(group_key), + ) + ] + return [] + + def _eval_temporal_ordered( + self, + matches: list[SigmaMatch], + group_key: tuple[Any, ...], + now: Any, + ) -> list[SigmaMatch]: + """All referenced rules must fire in order within the time window.""" + if not self.rule.rules: + return [] + expected_order = [ref.reference for ref in self.rule.rules] + # Find first occurrence of each in order + seen_idx = 0 + for m in sorted(matches, key=lambda x: x.timestamp): + if hasattr(m.rule, "name") and m.rule.name == expected_order[seen_idx]: + seen_idx += 1 + if seen_idx >= len(expected_order): + return [ + SigmaMatch( + rule=self.rule, + events=self._collect_events(matches), + timestamp=now, + group_values=self._make_group_values(group_key), + ) + ] + return [] + + def _eval_value_aggregate( + self, + matches: list[SigmaMatch], + group_key: tuple[Any, ...], + now: Any, + condition: Any, + agg_type: str, + ) -> list[SigmaMatch]: + if not isinstance(condition, SigmaCorrelationCondition): + return [] + field_ref = condition.fieldref + if field_ref is None: + return [] + + values: list[float] = [] + for m in matches: + for e in m.events: + val = e.data.get(field_ref) # type: ignore[arg-type] + if val is not None: + try: + values.append(float(val)) + except (ValueError, TypeError): + pass + + if not values: + return [] + + if agg_type == "sum": + result = sum(values) + elif agg_type == "avg": + result = sum(values) / len(values) + elif agg_type == "median": + result = median(values) + elif agg_type == "percentile": + percentile = condition.percentile or 95 + sorted_vals = sorted(values) + idx = int(len(sorted_vals) * percentile / 100) + idx = min(idx, len(sorted_vals) - 1) + result = sorted_vals[idx] + else: + return [] + + if self._compare(condition.op, result, condition.count): + return [ + SigmaMatch( + rule=self.rule, + events=self._collect_events(matches), + timestamp=now, + group_values=self._make_group_values(group_key), + ) + ] + return [] diff --git a/sigma/matching/event.py b/sigma/matching/event.py new file mode 100644 index 00000000..e4588652 --- /dev/null +++ b/sigma/matching/event.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + + +@dataclass(frozen=True) +class EventLogSource: + """Lightweight logsource descriptor for events.""" + + category: str | None = None + product: str | None = None + service: str | None = None + + def as_tuple(self) -> tuple[str | None, str | None, str | None]: + return (self.category, self.product, self.service) + + +@dataclass +class Event: + """A parsed log event to be matched against Sigma rules.""" + + timestamp: datetime + logsource: EventLogSource + data: dict[str, Any] + raw: str | None = None + _raw_cache: str | None = field(init=False, default=None, repr=False, compare=False) + + def get_raw(self) -> str: + """Return raw log representation, falling back to JSON-serialized data.""" + if self.raw is not None: + return self.raw + if self._raw_cache is None: + self._raw_cache = json.dumps(self.data) + return self._raw_cache diff --git a/sigma/matching/match.py b/sigma/matching/match.py new file mode 100644 index 00000000..38fb9965 --- /dev/null +++ b/sigma/matching/match.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from sigma.matching.event import Event + from sigma.rule.base import SigmaRuleBase + + +@dataclass +class SigmaMatch: + """Result of a successful Sigma rule match against one or more events.""" + + rule: SigmaRuleBase + events: list[Event] + timestamp: datetime + group_values: dict[str, Any] | None = None diff --git a/sigma/matching/matcher.py b/sigma/matching/matcher.py new file mode 100644 index 00000000..80329b52 --- /dev/null +++ b/sigma/matching/matcher.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +import os +from collections import defaultdict +from concurrent.futures import ThreadPoolExecutor, as_completed +from typing import Any, Iterable + +from typing_extensions import Self + +from sigma.collection import SigmaCollection +from sigma.correlations import SigmaCorrelationRule +from sigma.matching.compiler import MatchCompiler +from sigma.matching.correlation_matcher import SigmaCorrelationRuleMatcher +from sigma.matching.event import Event, EventLogSource +from sigma.matching.match import SigmaMatch +from sigma.matching.rule_matcher import SigmaRuleMatcher +from sigma.processing.pipeline import ProcessingPipeline +from sigma.rule.logsource import SigmaLogSource +from sigma.rule.rule import SigmaRule + + +class SigmaMatcher: + """ + Orchestrator that matches events against a collection of Sigma rules. + + Manages logsource dispatch, threading, and correlation. + """ + + def __init__( + self, + collection: SigmaCollection, + pipeline: ProcessingPipeline | None = None, + max_workers: int | None = None, + max_events_per_match: int | None = 100, + max_correlation_state: int | None = None, + ) -> None: + self._compiler = MatchCompiler() + self._max_workers = max_workers or os.cpu_count() or 4 + self._executor = ThreadPoolExecutor(max_workers=self._max_workers) + + # Apply pipeline if provided + if pipeline is not None: + pipeline.apply(collection) + + # Build dispatch table and correlation matchers + self._dispatch: dict[tuple[str | None, str | None, str | None], list[SigmaRuleMatcher]] = ( + defaultdict(list) + ) + self._all_rule_matchers: list[SigmaRuleMatcher] = [] + # Map rule name -> list of correlation matchers that reference it + self._correlation_map: dict[str, list[SigmaCorrelationRuleMatcher]] = defaultdict(list) + self._correlation_matchers: list[SigmaCorrelationRuleMatcher] = [] + + for rule in collection.rules: + if isinstance(rule, SigmaRule): + rm = SigmaRuleMatcher(rule, self._compiler) + self._all_rule_matchers.append(rm) + key = ( + rule.logsource.category, + rule.logsource.product, + rule.logsource.service, + ) + self._dispatch[key].append(rm) + elif isinstance(rule, SigmaCorrelationRule): + cm = SigmaCorrelationRuleMatcher( + rule, + max_state=max_correlation_state, + max_events_per_match=max_events_per_match, + ) + self._correlation_matchers.append(cm) + if rule.rules: + for ref in rule.rules: + self._correlation_map[ref.reference].append(cm) + + def match(self, event: Event) -> list[SigmaMatch]: + """Match a single event against all relevant rules. Returns list of matches.""" + matchers = self._get_matchers(event.logsource) + matches: list[SigmaMatch] = [] + + for rm in matchers: + result = rm.matches(event) + if result is not None: + matches.append(result) + + # Route matches to correlation matchers + correlation_matches: list[SigmaMatch] = [] + for m in matches: + rule_name = getattr(m.rule, "name", None) + if rule_name and rule_name in self._correlation_map: + for cm in self._correlation_map[rule_name]: + correlation_matches.extend(cm.receive_match(m, rule_name)) + + matches.extend(correlation_matches) + return matches + + def bulk_match(self, events: Iterable[Event]) -> list[SigmaMatch]: + """Match multiple events, returning all matches. Processes sequentially for ordering.""" + all_matches: list[SigmaMatch] = [] + for event in events: + all_matches.extend(self.match(event)) + return all_matches + + def _get_matchers(self, logsource: EventLogSource) -> list[SigmaRuleMatcher]: + """Get all rule matchers whose logsource contains the event's logsource.""" + result: list[SigmaRuleMatcher] = [] + event_ls = SigmaLogSource( + category=logsource.category, + product=logsource.product, + service=logsource.service, + ) + for key, matchers in self._dispatch.items(): + rule_ls = SigmaLogSource( + category=key[0], + product=key[1], + service=key[2], + ) + if event_ls in rule_ls: + result.extend(matchers) + return result + + def close(self) -> None: + """Shut down the thread pool.""" + self._executor.shutdown(wait=False) + + def __enter__(self) -> Self: + return self + + def __exit__(self, *args: Any) -> None: + self.close() diff --git a/sigma/matching/rule_matcher.py b/sigma/matching/rule_matcher.py new file mode 100644 index 00000000..4eb11381 --- /dev/null +++ b/sigma/matching/rule_matcher.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +from sigma.matching.compiler import CompiledMatcher, MatchCompiler +from sigma.matching.event import Event +from sigma.matching.match import SigmaMatch + +if TYPE_CHECKING: + from sigma.rule.rule import SigmaRule + + +class SigmaRuleMatcher: + """Evaluates a single SigmaRule against events using a precompiled matcher.""" + + def __init__(self, rule: SigmaRule, compiler: MatchCompiler) -> None: + self.rule = rule + self._matcher: CompiledMatcher = compiler.compile_rule(rule) + + def matches(self, event: Event) -> SigmaMatch | None: + """Return a SigmaMatch if the event matches this rule, else None.""" + raw = event.get_raw() + if self._matcher(event.data, raw): + return SigmaMatch( + rule=self.rule, + events=[event], + timestamp=event.timestamp, + ) + return None diff --git a/sigma/matching/state.py b/sigma/matching/state.py new file mode 100644 index 00000000..259f0ed7 --- /dev/null +++ b/sigma/matching/state.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from collections import defaultdict, deque +from datetime import datetime, timedelta +from typing import Any + +from sigma.matching.match import SigmaMatch + + +class SlidingWindowState: + """Manages sliding time window state for correlation rules.""" + + def __init__(self, window_seconds: int, max_entries: int | None = None) -> None: + self.window_seconds = window_seconds + self.max_entries = max_entries + # group_key -> deque of (timestamp, match) + self._state: dict[tuple[Any, ...], deque[tuple[datetime, SigmaMatch]]] = defaultdict(deque) + + def add(self, group_key: tuple[Any, ...], match: SigmaMatch) -> None: + """Add a match to the state for the given group key.""" + entries = self._state[group_key] + entries.append((match.timestamp, match)) + if self.max_entries is not None and len(entries) > self.max_entries: + entries.popleft() + + def get_window(self, group_key: tuple[Any, ...], now: datetime) -> list[SigmaMatch]: + """Get all matches within the time window for the given group key.""" + self._evict_group(group_key, now) + return [match for _, match in self._state[group_key]] + + def _evict_group(self, group_key: tuple[Any, ...], now: datetime) -> None: + """Remove entries outside the time window.""" + entries = self._state[group_key] + cutoff = now - timedelta(seconds=self.window_seconds) + while entries and entries[0][0] < cutoff: + entries.popleft() + + def evict(self) -> None: + """Remove empty groups.""" + empty_keys = [k for k, v in self._state.items() if not v] + for k in empty_keys: + del self._state[k] diff --git a/tests/test_matching.py b/tests/test_matching.py new file mode 100644 index 00000000..3d5fc86d --- /dev/null +++ b/tests/test_matching.py @@ -0,0 +1,370 @@ +"""Tests for the sigma.matching module.""" + +from datetime import datetime, timezone + +import pytest + +from sigma.collection import SigmaCollection +from sigma.matching import Event, EventLogSource, SigmaMatch, SigmaMatcher + + +@pytest.fixture +def simple_rule_yaml(): + return """ +title: Test Rule +name: test_rule +status: test +logsource: + category: process_creation + product: windows +detection: + selection: + CommandLine|contains: "mimikatz" + condition: selection +""" + + +@pytest.fixture +def simple_collection(simple_rule_yaml): + return SigmaCollection.from_yaml(simple_rule_yaml) + + +class TestEvent: + def test_event_creation(self): + event = Event( + timestamp=datetime.now(tz=timezone.utc), + logsource=EventLogSource(category="process_creation", product="windows"), + data={"CommandLine": "mimikatz.exe"}, + ) + assert event.data["CommandLine"] == "mimikatz.exe" + + def test_event_get_raw_with_raw(self): + event = Event( + timestamp=datetime.now(tz=timezone.utc), + logsource=EventLogSource(), + data={"key": "val"}, + raw="raw log line", + ) + assert event.get_raw() == "raw log line" + + def test_event_get_raw_without_raw(self): + event = Event( + timestamp=datetime.now(tz=timezone.utc), + logsource=EventLogSource(), + data={"key": "val"}, + ) + assert '"key"' in event.get_raw() + + +class TestSigmaMatcher: + def test_basic_match(self, simple_collection): + with SigmaMatcher(simple_collection) as matcher: + event = Event( + timestamp=datetime.now(tz=timezone.utc), + logsource=EventLogSource(category="process_creation", product="windows"), + data={"CommandLine": "C:\\tools\\mimikatz.exe"}, + ) + matches = matcher.match(event) + assert len(matches) == 1 + assert matches[0].rule.name == "test_rule" + + def test_no_match(self, simple_collection): + with SigmaMatcher(simple_collection) as matcher: + event = Event( + timestamp=datetime.now(tz=timezone.utc), + logsource=EventLogSource(category="process_creation", product="windows"), + data={"CommandLine": "notepad.exe"}, + ) + matches = matcher.match(event) + assert len(matches) == 0 + + def test_logsource_mismatch(self, simple_collection): + with SigmaMatcher(simple_collection) as matcher: + event = Event( + timestamp=datetime.now(tz=timezone.utc), + logsource=EventLogSource(category="network_connection", product="windows"), + data={"CommandLine": "mimikatz.exe"}, + ) + matches = matcher.match(event) + assert len(matches) == 0 + + def test_bulk_match(self, simple_collection): + with SigmaMatcher(simple_collection) as matcher: + events = [ + Event( + timestamp=datetime.now(tz=timezone.utc), + logsource=EventLogSource(category="process_creation", product="windows"), + data={"CommandLine": "mimikatz.exe"}, + ), + Event( + timestamp=datetime.now(tz=timezone.utc), + logsource=EventLogSource(category="process_creation", product="windows"), + data={"CommandLine": "notepad.exe"}, + ), + ] + matches = matcher.bulk_match(events) + assert len(matches) == 1 + + +class TestCompilerTypes: + """Test various Sigma types compile correctly.""" + + def _match(self, yaml_str: str, data: dict, logsource: EventLogSource | None = None): + collection = SigmaCollection.from_yaml(yaml_str) + ls = logsource or EventLogSource(category="test", product="test") + with SigmaMatcher(collection) as matcher: + event = Event( + timestamp=datetime.now(tz=timezone.utc), + logsource=ls, + data=data, + ) + return matcher.match(event) + + def test_plain_string(self): + yaml_str = """ +title: Test +name: test +status: test +logsource: + category: test + product: test +detection: + selection: + field: "value" + condition: selection +""" + assert len(self._match(yaml_str, {"field": "value"})) == 1 + assert len(self._match(yaml_str, {"field": "VALUE"})) == 1 # case insensitive + assert len(self._match(yaml_str, {"field": "other"})) == 0 + + def test_wildcard(self): + yaml_str = """ +title: Test +name: test +status: test +logsource: + category: test + product: test +detection: + selection: + field|contains: "test" + condition: selection +""" + assert len(self._match(yaml_str, {"field": "this is a test string"})) == 1 + assert len(self._match(yaml_str, {"field": "no match here"})) == 0 + + def test_number(self): + yaml_str = """ +title: Test +name: test +status: test +logsource: + category: test + product: test +detection: + selection: + field: 42 + condition: selection +""" + assert len(self._match(yaml_str, {"field": 42})) == 1 + assert len(self._match(yaml_str, {"field": 43})) == 0 + + def test_null(self): + yaml_str = """ +title: Test +name: test +status: test +logsource: + category: test + product: test +detection: + selection: + field: null + condition: selection +""" + assert len(self._match(yaml_str, {"field": None})) == 1 + assert len(self._match(yaml_str, {"other": "val"})) == 1 # field not present = None + assert len(self._match(yaml_str, {"field": "something"})) == 0 + + def test_boolean(self): + yaml_str = """ +title: Test +name: test +status: test +logsource: + category: test + product: test +detection: + selection: + field: true + condition: selection +""" + assert len(self._match(yaml_str, {"field": True})) == 1 + assert len(self._match(yaml_str, {"field": False})) == 0 + + def test_regex(self): + yaml_str = """ +title: Test +name: test +status: test +logsource: + category: test + product: test +detection: + selection: + field|re: "^test\\\\d+" + condition: selection +""" + assert len(self._match(yaml_str, {"field": "test123"})) == 1 + assert len(self._match(yaml_str, {"field": "notest"})) == 0 + + def test_cidr(self): + yaml_str = """ +title: Test +name: test +status: test +logsource: + category: test + product: test +detection: + selection: + field|cidr: "192.168.1.0/24" + condition: selection +""" + assert len(self._match(yaml_str, {"field": "192.168.1.50"})) == 1 + assert len(self._match(yaml_str, {"field": "10.0.0.1"})) == 0 + + def test_compare_gt(self): + yaml_str = """ +title: Test +name: test +status: test +logsource: + category: test + product: test +detection: + selection: + field|gt: 10 + condition: selection +""" + assert len(self._match(yaml_str, {"field": 15})) == 1 + assert len(self._match(yaml_str, {"field": 5})) == 0 + + def test_condition_and(self): + yaml_str = """ +title: Test +name: test +status: test +logsource: + category: test + product: test +detection: + sel1: + field1: "a" + sel2: + field2: "b" + condition: sel1 and sel2 +""" + assert len(self._match(yaml_str, {"field1": "a", "field2": "b"})) == 1 + assert len(self._match(yaml_str, {"field1": "a", "field2": "c"})) == 0 + + def test_condition_or(self): + yaml_str = """ +title: Test +name: test +status: test +logsource: + category: test + product: test +detection: + sel1: + field1: "a" + sel2: + field2: "b" + condition: sel1 or sel2 +""" + assert len(self._match(yaml_str, {"field1": "a"})) == 1 + assert len(self._match(yaml_str, {"field2": "b"})) == 1 + assert len(self._match(yaml_str, {"field3": "c"})) == 0 + + def test_condition_not(self): + yaml_str = """ +title: Test +name: test +status: test +logsource: + category: test + product: test +detection: + sel: + field: "a" + condition: not sel +""" + assert len(self._match(yaml_str, {"field": "b"})) == 1 + assert len(self._match(yaml_str, {"field": "a"})) == 0 + + def test_keyword(self): + yaml_str = """ +title: Test +name: test +status: test +logsource: + category: test + product: test +detection: + keywords: + - "suspicious" + condition: keywords +""" + assert len(self._match(yaml_str, {"msg": "this is suspicious activity"})) == 1 + assert len(self._match(yaml_str, {"msg": "normal activity"})) == 0 + + +class TestCorrelation: + def test_event_count(self): + yaml_str = """ +title: Base Rule +name: base_rule +status: test +logsource: + category: test + product: test +detection: + selection: + field: "trigger" + condition: selection +--- +title: Correlation +name: correlation_rule +status: test +correlation: + type: event_count + rules: + - base_rule + group-by: + - src_ip + timespan: 5m + condition: + gte: 3 +""" + collection = SigmaCollection.from_yaml(yaml_str) + with SigmaMatcher(collection) as matcher: + now = datetime.now(tz=timezone.utc) + ls = EventLogSource(category="test", product="test") + + # First two events - no correlation match yet + for i in range(2): + matches = matcher.match( + Event( + timestamp=now, logsource=ls, data={"field": "trigger", "src_ip": "1.2.3.4"} + ) + ) + # Should have base rule match but no correlation yet + assert any(m.rule.name == "base_rule" for m in matches) + assert not any(m.rule.name == "correlation_rule" for m in matches) + + # Third event triggers correlation + matches = matcher.match( + Event(timestamp=now, logsource=ls, data={"field": "trigger", "src_ip": "1.2.3.4"}) + ) + assert any(m.rule.name == "correlation_rule" for m in matches) From 854443c9ef8b419fb2283ee1f1ef31b56c4b2bf8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:46:02 +0000 Subject: [PATCH 2/4] Address code review: remove unused ThreadPoolExecutor, restrict exec namespace, remove .mypy_cache Co-authored-by: thomaspatzke <1845601+thomaspatzke@users.noreply.github.com> --- .gitignore | 2 +- sigma/matching/compiler.py | 6 ++++-- sigma/matching/matcher.py | 8 ++------ 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index 45495352..ea6e747e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,4 @@ docs/_build coverage.xml .python-version .idea -.venv \ No newline at end of file +.venv.mypy_cache/ diff --git a/sigma/matching/compiler.py b/sigma/matching/compiler.py index 2393e399..2ece6cf1 100644 --- a/sigma/matching/compiler.py +++ b/sigma/matching/compiler.py @@ -58,9 +58,11 @@ def compile_rule(self, rule: SigmaRule) -> CompiledMatcher: expr = "(" + " or ".join(parts) + ")" func_code = f"def _match(data, raw):\n return {expr}\n" + namespace: dict[str, Any] = {"__builtins__": {"str": str}} + namespace.update(closures) code_obj = compile(func_code, "", "exec") - exec(code_obj, closures) # noqa: S102 - return closures["_match"] + exec(code_obj, namespace) # noqa: S102 + return namespace["_match"] def _compile_node(self, node: Any, closures: dict[str, Any]) -> str: if isinstance(node, ConditionAND): diff --git a/sigma/matching/matcher.py b/sigma/matching/matcher.py index 80329b52..47dc9d4c 100644 --- a/sigma/matching/matcher.py +++ b/sigma/matching/matcher.py @@ -1,8 +1,6 @@ from __future__ import annotations -import os from collections import defaultdict -from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Any, Iterable from typing_extensions import Self @@ -35,8 +33,6 @@ def __init__( max_correlation_state: int | None = None, ) -> None: self._compiler = MatchCompiler() - self._max_workers = max_workers or os.cpu_count() or 4 - self._executor = ThreadPoolExecutor(max_workers=self._max_workers) # Apply pipeline if provided if pipeline is not None: @@ -119,8 +115,8 @@ def _get_matchers(self, logsource: EventLogSource) -> list[SigmaRuleMatcher]: return result def close(self) -> None: - """Shut down the thread pool.""" - self._executor.shutdown(wait=False) + """Clean up resources.""" + pass def __enter__(self) -> Self: return self From 9afebcd8348fd87a4662d32adde2c8ec77bbcae0 Mon Sep 17 00:00:00 2001 From: Thomas Patzke Date: Sun, 1 Nov 2026 21:29:54 +0100 Subject: [PATCH 3/4] Remove mypy cache files from version control --- .mypy_cache/3.13/cache.0.db | Bin 16384 -> 0 bytes .mypy_cache/3.13/cache.1.db | Bin 16384 -> 0 bytes .mypy_cache/3.13/cache.10.db | Bin 16384 -> 0 bytes .mypy_cache/3.13/cache.11.db | Bin 16384 -> 0 bytes .mypy_cache/3.13/cache.12.db | Bin 16384 -> 0 bytes .mypy_cache/3.13/cache.13.db | Bin 16384 -> 0 bytes .mypy_cache/3.13/cache.14.db | Bin 16384 -> 0 bytes .mypy_cache/3.13/cache.15.db | Bin 16384 -> 0 bytes .mypy_cache/3.13/cache.2.db | Bin 16384 -> 0 bytes .mypy_cache/3.13/cache.3.db | Bin 16384 -> 0 bytes .mypy_cache/3.13/cache.4.db | Bin 16384 -> 0 bytes .mypy_cache/3.13/cache.5.db | Bin 16384 -> 0 bytes .mypy_cache/3.13/cache.6.db | Bin 16384 -> 0 bytes .mypy_cache/3.13/cache.7.db | Bin 16384 -> 0 bytes .mypy_cache/3.13/cache.8.db | Bin 16384 -> 0 bytes .mypy_cache/3.13/cache.9.db | Bin 16384 -> 0 bytes 16 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .mypy_cache/3.13/cache.0.db delete mode 100644 .mypy_cache/3.13/cache.1.db delete mode 100644 .mypy_cache/3.13/cache.10.db delete mode 100644 .mypy_cache/3.13/cache.11.db delete mode 100644 .mypy_cache/3.13/cache.12.db delete mode 100644 .mypy_cache/3.13/cache.13.db delete mode 100644 .mypy_cache/3.13/cache.14.db delete mode 100644 .mypy_cache/3.13/cache.15.db delete mode 100644 .mypy_cache/3.13/cache.2.db delete mode 100644 .mypy_cache/3.13/cache.3.db delete mode 100644 .mypy_cache/3.13/cache.4.db delete mode 100644 .mypy_cache/3.13/cache.5.db delete mode 100644 .mypy_cache/3.13/cache.6.db delete mode 100644 .mypy_cache/3.13/cache.7.db delete mode 100644 .mypy_cache/3.13/cache.8.db delete mode 100644 .mypy_cache/3.13/cache.9.db diff --git a/.mypy_cache/3.13/cache.0.db b/.mypy_cache/3.13/cache.0.db deleted file mode 100644 index 26ad419ed271d4fad3412df089f381723a4524c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad diff --git a/.mypy_cache/3.13/cache.1.db b/.mypy_cache/3.13/cache.1.db deleted file mode 100644 index 26ad419ed271d4fad3412df089f381723a4524c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad diff --git a/.mypy_cache/3.13/cache.10.db b/.mypy_cache/3.13/cache.10.db deleted file mode 100644 index 26ad419ed271d4fad3412df089f381723a4524c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad diff --git a/.mypy_cache/3.13/cache.11.db b/.mypy_cache/3.13/cache.11.db deleted file mode 100644 index 26ad419ed271d4fad3412df089f381723a4524c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad diff --git a/.mypy_cache/3.13/cache.12.db b/.mypy_cache/3.13/cache.12.db deleted file mode 100644 index 26ad419ed271d4fad3412df089f381723a4524c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad diff --git a/.mypy_cache/3.13/cache.13.db b/.mypy_cache/3.13/cache.13.db deleted file mode 100644 index 26ad419ed271d4fad3412df089f381723a4524c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad diff --git a/.mypy_cache/3.13/cache.14.db b/.mypy_cache/3.13/cache.14.db deleted file mode 100644 index 26ad419ed271d4fad3412df089f381723a4524c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad diff --git a/.mypy_cache/3.13/cache.15.db b/.mypy_cache/3.13/cache.15.db deleted file mode 100644 index 26ad419ed271d4fad3412df089f381723a4524c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad diff --git a/.mypy_cache/3.13/cache.2.db b/.mypy_cache/3.13/cache.2.db deleted file mode 100644 index 26ad419ed271d4fad3412df089f381723a4524c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad diff --git a/.mypy_cache/3.13/cache.3.db b/.mypy_cache/3.13/cache.3.db deleted file mode 100644 index 26ad419ed271d4fad3412df089f381723a4524c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad diff --git a/.mypy_cache/3.13/cache.4.db b/.mypy_cache/3.13/cache.4.db deleted file mode 100644 index 26ad419ed271d4fad3412df089f381723a4524c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad diff --git a/.mypy_cache/3.13/cache.5.db b/.mypy_cache/3.13/cache.5.db deleted file mode 100644 index 26ad419ed271d4fad3412df089f381723a4524c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad diff --git a/.mypy_cache/3.13/cache.6.db b/.mypy_cache/3.13/cache.6.db deleted file mode 100644 index 26ad419ed271d4fad3412df089f381723a4524c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad diff --git a/.mypy_cache/3.13/cache.7.db b/.mypy_cache/3.13/cache.7.db deleted file mode 100644 index 26ad419ed271d4fad3412df089f381723a4524c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad diff --git a/.mypy_cache/3.13/cache.8.db b/.mypy_cache/3.13/cache.8.db deleted file mode 100644 index 26ad419ed271d4fad3412df089f381723a4524c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad diff --git a/.mypy_cache/3.13/cache.9.db b/.mypy_cache/3.13/cache.9.db deleted file mode 100644 index 26ad419ed271d4fad3412df089f381723a4524c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI#F-yZh6u|LITB;OEx|JZp8(S1X#ksbcp%7B4DRvM-q$viPR_$53I{MlCLXKXN zc5!f)@_%r5ygZJ}{gUO~kNi|8GF!f7v6f5KRJN_IMU+yu8C5fO@jJJ6g+EqXovl9` z&bO*KN6LA0zRW@(fB*srAb}s-HNLg>^k2;xk&~jbt<4VU*^Q5elv%TFP_WHiJ-6QR~n8_0EZ1Yjs^q74N%xotwTsir@9J z$fNS$=)7Dj2Kq3^Zsb!lp9lmHKmY**5I_I{1Q0*~0R#}(SAlwdG5Y^~{k+@@0R#|0 p009ILKmY**5I_KdLV*6C9{~Xb5I_I{1Q0*~0R#|00D=7%_yM$aONIad From 229f745af9672d5389999e85d295a53dc992b090 Mon Sep 17 00:00:00 2001 From: Thomas Patzke Date: Sun, 1 Nov 2026 21:50:29 +0100 Subject: [PATCH 4/4] fix: typing issues --- sigma/matching/compiler.py | 14 +++++++------- sigma/matching/correlation_matcher.py | 6 ++++-- sigma/matching/matcher.py | 3 ++- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/sigma/matching/compiler.py b/sigma/matching/compiler.py index 2ece6cf1..312933da 100644 --- a/sigma/matching/compiler.py +++ b/sigma/matching/compiler.py @@ -62,18 +62,18 @@ def compile_rule(self, rule: SigmaRule) -> CompiledMatcher: namespace.update(closures) code_obj = compile(func_code, "", "exec") exec(code_obj, namespace) # noqa: S102 - return namespace["_match"] + return namespace["_match"] # type: ignore[no-any-return] def _compile_node(self, node: Any, closures: dict[str, Any]) -> str: if isinstance(node, ConditionAND): - sub = [self._compile_node(arg, closures) for arg in node.args] - return "(" + " and ".join(sub) + ")" + sub_list = [self._compile_node(arg, closures) for arg in node.args] + return "(" + " and ".join(sub_list) + ")" elif isinstance(node, ConditionOR): - sub = [self._compile_node(arg, closures) for arg in node.args] - return "(" + " or ".join(sub) + ")" + sub_list = [self._compile_node(arg, closures) for arg in node.args] + return "(" + " or ".join(sub_list) + ")" elif isinstance(node, ConditionNOT): - sub = self._compile_node(node.args[0], closures) - return f"(not {sub})" + sub_str = self._compile_node(node.args[0], closures) + return f"(not {sub_str})" elif isinstance(node, ConditionFieldEqualsValueExpression): return self._compile_field_value(node.field, node.value, closures) elif isinstance(node, ConditionValueExpression): diff --git a/sigma/matching/correlation_matcher.py b/sigma/matching/correlation_matcher.py index 5ab59fb3..49ea8029 100644 --- a/sigma/matching/correlation_matcher.py +++ b/sigma/matching/correlation_matcher.py @@ -101,7 +101,9 @@ def _evaluate( ) return [] - def _compare(self, op: SigmaCorrelationConditionOperator, value: Any, threshold: int) -> bool: + def _compare( + self, op: SigmaCorrelationConditionOperator, value: Any, threshold: int | float + ) -> bool: """Compare a value against a threshold using the given operator.""" if value is None: return False @@ -113,7 +115,7 @@ def _compare(self, op: SigmaCorrelationConditionOperator, value: Any, threshold: SigmaCorrelationConditionOperator.EQ: lambda a, b: a == b, SigmaCorrelationConditionOperator.NEQ: lambda a, b: a != b, } - return op_map[op](value, threshold) + return bool(op_map[op](value, threshold)) def _collect_events(self, matches: list[SigmaMatch]) -> list[Event]: """Collect events from matches, respecting the max_events_per_match limit.""" diff --git a/sigma/matching/matcher.py b/sigma/matching/matcher.py index 47dc9d4c..6d8f8bc9 100644 --- a/sigma/matching/matcher.py +++ b/sigma/matching/matcher.py @@ -36,7 +36,8 @@ def __init__( # Apply pipeline if provided if pipeline is not None: - pipeline.apply(collection) + for rule in collection.rules: + pipeline.apply(rule) # Build dispatch table and correlation matchers self._dispatch: dict[tuple[str | None, str | None, str | None], list[SigmaRuleMatcher]] = (