<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jude Hilgendorf</title>
    <description>The latest articles on DEV Community by Jude Hilgendorf (@tiltedlunar123).</description>
    <link>https://dev.to/tiltedlunar123</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3847611%2F5372ff69-df32-4335-9ef6-65d8c9504ae5.jpeg</url>
      <title>DEV Community: Jude Hilgendorf</title>
      <link>https://dev.to/tiltedlunar123</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tiltedlunar123"/>
    <language>en</language>
    <item>
      <title>Auditing Windows security from a Python script, no pip install needed</title>
      <dc:creator>Jude Hilgendorf</dc:creator>
      <pubDate>Sun, 10 May 2026 09:17:16 +0000</pubDate>
      <link>https://dev.to/tiltedlunar123/auditing-windows-security-from-a-python-script-no-pip-install-needed-2k0f</link>
      <guid>https://dev.to/tiltedlunar123/auditing-windows-security-from-a-python-script-no-pip-install-needed-2k0f</guid>
      <description>&lt;p&gt;I had a problem. I wanted a Windows security audit script I could drop on any machine, run as admin, and walk away with a readable report. Just a single .py file. No &lt;code&gt;pip install&lt;/code&gt;, no virtualenv, no "wait, do you have Python 3.10 or what."&lt;/p&gt;

&lt;p&gt;The catch is that "real" Windows auditing tools usually pull in pywin32, wmi, or some chunky vendor SDK. None of that flies on a locked down workstation. So I tried writing the whole thing on the standard library.&lt;/p&gt;

&lt;p&gt;That is what WinRecon turned into. 20 checks, single Python module, no dependencies past stdlib.&lt;/p&gt;

&lt;p&gt;Here's how the dependency-free constraint shaped the architecture.&lt;/p&gt;

&lt;p&gt;For registry reads I went straight to &lt;code&gt;winreg&lt;/code&gt;. Anything that needs Windows tooling goes through &lt;code&gt;subprocess&lt;/code&gt; with the actual built-in binaries (&lt;code&gt;netstat&lt;/code&gt;, &lt;code&gt;net&lt;/code&gt;, &lt;code&gt;sc query&lt;/code&gt;, &lt;code&gt;wmic&lt;/code&gt;, and PowerShell for Defender and audit policy queries). It is not elegant. You end up parsing CLI text output a lot. But it works on a fresh Windows 11 box with nothing installed.&lt;/p&gt;

&lt;p&gt;Example. Getting Defender status without &lt;code&gt;pywin32&lt;/code&gt;. PowerShell already returns it as JSON, you just have to ask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_defender_status&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;powershell&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-NoProfile&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-Command&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Get-MpComputerStatus | ConvertTo-Json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Subprocess plus &lt;code&gt;ConvertTo-Json&lt;/code&gt; got me out of a hole on probably half the checks. WMI bindings would have been faster but the trade is dependencies, and I wanted the script to just run.&lt;/p&gt;

&lt;p&gt;The 20 checks cover the obvious stuff (firewall state, RDP config, password policy, open ports, BitLocker, Credential Guard, Secure Boot, audit policy, antivirus status) plus the stuff a SOC analyst actually wants to see: suspicious scheduled tasks, sketchy startup entries, weird PowerShell flags. The scheduled task check looks for things like encoded payloads (&lt;code&gt;-enc&lt;/code&gt;, &lt;code&gt;frombase64string&lt;/code&gt;), LOLBins (certutil, bitsadmin, regsvr32), &lt;code&gt;-windowstyle hidden&lt;/code&gt;, &lt;code&gt;IEX&lt;/code&gt;, and C2 indicators like ngrok or pastebin URLs. Cheap pattern matching but it catches the lazy stuff.&lt;/p&gt;

&lt;p&gt;Output is a self-contained HTML report. All CSS inlined, no external assets. You can email it, drop it on a fileshare, open it on a stripped down server, and it renders. There is also a JSON file for anyone who wants to parse findings into a SIEM later.&lt;/p&gt;

&lt;p&gt;Scoring is dumb on purpose. Each finding is CRITICAL (-20), WARNING (-10), or PASS/INFO (0). Start at 100, deduct, end with a letter grade A through F. The point is not "is this CVSS-accurate." The point is that you can hand the HTML to a non-security person and they get it in three seconds.&lt;/p&gt;

&lt;p&gt;What broke along the way.&lt;/p&gt;

&lt;p&gt;The biggest pain was admin vs standard user. Some checks (BitLocker, audit policy, credential guard, firewall details) just fail or return degraded results without elevation. I wanted them to fail loudly without crashing the whole run, so each check returns a Finding object with status PASS, WARNING, CRITICAL, or INFO, and the runner aggregates them. If one check explodes, the others still finish. Took me a couple iterations to stop having one bad subprocess timeout kill the whole report.&lt;/p&gt;

&lt;p&gt;The other thing I underestimated: HTML escaping. All those scheduled task names and registry values go straight into the report. If a malicious task name had &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; in it, my report would happily render it. So I added pytest coverage specifically for the escape path, and a test that drops &lt;code&gt;&amp;lt;img src=x onerror=alert(1)&amp;gt;&lt;/code&gt; into a finding to make sure it comes out as text. Coverage is at 80% min, which feels right for a tool you might run on a real machine.&lt;/p&gt;

&lt;p&gt;Things I would still fix.&lt;/p&gt;

&lt;p&gt;The grading is too coarse. A box with one critical finding and 19 passes lands a C. That is fine for "is this safe" but it overweights single critical findings. I want to weight them by category eventually, so a missing antivirus is not the same as a deprecated SMBv1 enabled.&lt;/p&gt;

&lt;p&gt;Subprocess timeouts also have a default of 60s per check, which is fine on a normal machine and miserable on a slow domain-joined one. I should make them adaptive.&lt;/p&gt;

&lt;p&gt;The suspicious-pattern detector is regex on strings, which means false positives. A scheduled task named "regsvr32-cleanup" gets flagged. The custom keywords file partially fixes this but I should ship a default trusted-paths list that covers common vendor software.&lt;/p&gt;

&lt;p&gt;If you have a Windows machine and 30 seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/TiltedLunar123/WinRecon
&lt;span class="nb"&gt;cd &lt;/span&gt;WinRecon
python &lt;span class="nt"&gt;-m&lt;/span&gt; winrecon
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It writes the HTML to &lt;code&gt;./winrecon_reports/&lt;/code&gt;. Open it. Tell me which check is wrong on your box. That is actually the most useful feedback I can get right now.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/TiltedLunar123/WinRecon" rel="noopener noreferrer"&gt;https://github.com/TiltedLunar123/WinRecon&lt;/a&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>windows</category>
      <category>security</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Testing Sigma Rules Against Local Logs Without a SIEM</title>
      <dc:creator>Jude Hilgendorf</dc:creator>
      <pubDate>Wed, 06 May 2026 13:23:48 +0000</pubDate>
      <link>https://dev.to/tiltedlunar123/testing-sigma-rules-against-local-logs-without-a-siem-1cp9</link>
      <guid>https://dev.to/tiltedlunar123/testing-sigma-rules-against-local-logs-without-a-siem-1cp9</guid>
      <description>&lt;p&gt;I'd written a few Sigma rules for my home lab and wanted to know if they actually fired on real Sysmon events. The standard answer is "deploy to Wazuh and replay logs". That's a lot of overhead when I just want to confirm a regex matches.&lt;/p&gt;

&lt;p&gt;So I built SIEMForge. It's a Python CLI that loads Sigma YAML files, parses the detection logic, and matches it against JSON, JSONL, syslog, or CSV log files locally. No SIEM required.&lt;/p&gt;

&lt;p&gt;This post is the messy version of how it came together. The final code is on GitHub at github.com/TiltedLunar123/SIEMForge.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;I had ten Sigma rules covering things like LSASS dumps, suspicious PowerShell, and registry persistence. To validate them I'd been:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;starting Wazuh in a VM&lt;/li&gt;
&lt;li&gt;shipping a Sysmon JSONL via filebeat&lt;/li&gt;
&lt;li&gt;SSHing to the manager and tailing alerts.log&lt;/li&gt;
&lt;li&gt;realizing the rule didn't fire because I had a typo in the field name&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Round trip on a single rule edit was about 4 minutes. For ten rules iterating through false positive checks, the math gets bad.&lt;/p&gt;

&lt;p&gt;What I actually wanted: &lt;code&gt;siemforge --scan events.json&lt;/code&gt; and a list of which rules fired with which event ID.&lt;/p&gt;

&lt;h2&gt;
  
  
  First attempt: just regex everything
&lt;/h2&gt;

&lt;p&gt;Naive plan. Sigma rules look simple. They have a &lt;code&gt;detection&lt;/code&gt; block with &lt;code&gt;selection&lt;/code&gt;, &lt;code&gt;filter&lt;/code&gt;, and &lt;code&gt;condition&lt;/code&gt; keys. The condition usually says &lt;code&gt;selection and not filter&lt;/code&gt;. How hard can it be?&lt;/p&gt;

&lt;p&gt;Hard. The condition language allows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;selection&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;selection and filter&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;selection or filter&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;selection and not filter&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;1 of selection*&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;all of them&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I started with a bare boolean parser that handled &lt;code&gt;and&lt;/code&gt;, &lt;code&gt;or&lt;/code&gt;, &lt;code&gt;not&lt;/code&gt;. About 70% of my rules worked. The &lt;code&gt;1 of selection*&lt;/code&gt; ones broke. So did the wildcards in field values like &lt;code&gt;CommandLine|contains: '*-ep bypass*'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I rewrote the matcher around a small expression tree instead of regex. Each detection block compiles to a callable: given an event dict, return bool. The condition is parsed once at load and evaluated per event.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;compile_selection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Callable&lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;matchers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;selection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;partition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;matcher&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_field_matcher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;modifier&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;equals&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;matchers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;matcher&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;m&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;matchers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;build_field_matcher&lt;/code&gt; handles &lt;code&gt;contains&lt;/code&gt;, &lt;code&gt;startswith&lt;/code&gt;, &lt;code&gt;endswith&lt;/code&gt;, and &lt;code&gt;re&lt;/code&gt; modifiers. Wildcards in raw values (&lt;code&gt;*-ep bypass*&lt;/code&gt;) get translated to a &lt;code&gt;contains&lt;/code&gt; check at compile time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The field name problem
&lt;/h2&gt;

&lt;p&gt;Sysmon JSON via Wazuh ships fields as &lt;code&gt;process.command_line&lt;/code&gt;. Raw Sysmon EVTX via &lt;code&gt;evtxecmd&lt;/code&gt; ships them as &lt;code&gt;CommandLine&lt;/code&gt;. Splunk ships them as &lt;code&gt;CommandLine&lt;/code&gt; too but inside a &lt;code&gt;_raw&lt;/code&gt; blob.&lt;/p&gt;

&lt;p&gt;My rules were written against the EVTX naming. When I tested against the Wazuh-formatted JSON, nothing matched. Took me an hour to figure out why.&lt;/p&gt;

&lt;p&gt;Two options: rewrite all the rules, or normalize the events. I went with normalize. There's a &lt;code&gt;field_aliases.yml&lt;/code&gt; that maps common variants:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;CommandLine&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;process.command_line&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;data.win.eventdata.commandLine&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;winlog.event_data.CommandLine&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scanner tries each alias when the canonical field is missing. Not pretty but it stopped me from owning two copies of every rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually works
&lt;/h2&gt;

&lt;p&gt;After three rewrites the scanner runs on a 4823-event Sysmon dump in under a second. Output looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[*] Scanning /var/log/sysmon/events.jsonl (jsonl, 4823 events)

[ALERT] Rule: Suspicious PowerShell Download Cradle
        Technique: T1059.001
        Event #312 | 2026-03-14T08:41:02Z
        CommandLine: powershell -ep bypass -c "IEX(New-Object Net.WebClient).DownloadString(...)"

[*] Scan complete: 2 alerts across 4823 events
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the round trip I wanted. Edit a rule, rerun, see if it fires. About 2 seconds end to end now.&lt;/p&gt;

&lt;p&gt;The Sigma to Splunk/Elastic/Kibana converter is a side benefit. Same compiled tree, different emit step.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's broken
&lt;/h2&gt;

&lt;p&gt;Plenty.&lt;/p&gt;

&lt;p&gt;The syslog parser is held together with tape. RFC 3164 vs RFC 5424 timestamp detection works on the obvious cases, but if the host writes a non-standard date format (looking at you, pfSense) fields end up mis-split. I have a TODO to switch to &lt;code&gt;pyparsing&lt;/code&gt; instead of my hand-rolled tokenizer.&lt;/p&gt;

&lt;p&gt;The MITRE coverage matrix is per-rule, not per-technique. It tells you which rule covers which technique, but not which techniques you have zero coverage on. That's the actually useful direction. v3.2 work.&lt;/p&gt;

&lt;p&gt;Sigma's &lt;code&gt;1 of&lt;/code&gt; operator is implemented for selection groups but not for conditions like &lt;code&gt;1 of selection_*&lt;/code&gt;. About 10% of public Sigma rules use that pattern, so the rule loader currently warns and skips them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;I should have started with the reference Sigma backend (&lt;code&gt;pySigma&lt;/code&gt;) instead of writing a parser from scratch. By the time I realized that, I'd already shipped two converters and ripping it out felt worse than maintaining the homegrown one. If I started today I'd wrap pySigma and add my own scanner on top.&lt;/p&gt;

&lt;p&gt;Test data. I waited too long to build sample log files for each technique. Now there's a &lt;code&gt;samples/&lt;/code&gt; directory with process injection, service installation, user creation, CSV examples, and a clean baseline for false positive checks. Should have been there from commit one.&lt;/p&gt;

&lt;p&gt;CI. I added it at v3.0 after a regression broke the Splunk converter on Windows. 138 tests run on every push now. Worth the upfront cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/TiltedLunar123/SIEMForge.git
&lt;span class="nb"&gt;cd &lt;/span&gt;SIEMForge
pip &lt;span class="nb"&gt;install &lt;/span&gt;pyyaml
python &lt;span class="nt"&gt;-m&lt;/span&gt; siemforge &lt;span class="nt"&gt;--scan&lt;/span&gt; samples/events.jsonl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you write Sigma rules and hate the deploy-test loop, give it a try. Issues and PRs welcome. There's a CONTRIBUTING file with rule submission guidelines.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/TiltedLunar123/SIEMForge" rel="noopener noreferrer"&gt;https://github.com/TiltedLunar123/SIEMForge&lt;/a&gt;&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>sigma</category>
      <category>python</category>
      <category>devops</category>
    </item>
    <item>
      <title>My Sigma rule was silently failing and the test suite didn't catch it</title>
      <dc:creator>Jude Hilgendorf</dc:creator>
      <pubDate>Tue, 05 May 2026 11:14:24 +0000</pubDate>
      <link>https://dev.to/tiltedlunar123/my-sigma-rule-was-silently-failing-and-the-test-suite-didnt-catch-it-1775</link>
      <guid>https://dev.to/tiltedlunar123/my-sigma-rule-was-silently-failing-and-the-test-suite-didnt-catch-it-1775</guid>
      <description>&lt;p&gt;I'm 18, taking cybersecurity at a community college in Michigan, and most of my detection engineering knowledge comes from reading other people's Sigma rules at like 1am. So when I started building SIEMForge, an open source toolkit that bundles Sigma rules with a Sysmon config and Wazuh custom rules mapped to MITRE ATT&amp;amp;CK, I figured the rules part would be the easy bit. Wrong.&lt;/p&gt;

&lt;p&gt;Here's the bug that took me two evenings to actually understand.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;SIEMForge ships with 10 detections in &lt;code&gt;rules/sigma/&lt;/code&gt;. They cover the usual suspects: PowerShell download cradles, LSASS dumps, mshta and rundll32 abuse, Run key persistence, scheduled tasks, that kind of thing. There's also a CLI scanner I wrote that loads every rule and runs it against a log file (JSON, JSONL, syslog, or CSV). The point is to test rules locally before you ship them into Splunk or Elastic, so you don't write a rule, deploy it, and then realize three weeks later that it's never fired.&lt;/p&gt;

&lt;p&gt;I had one extra rule sitting in a branch called &lt;code&gt;ssh_bruteforce_burst.yml&lt;/code&gt;. The intent was simple: if you see N failed ssh logins from the same source IP inside a short window, fire an alert.&lt;/p&gt;

&lt;p&gt;I dropped it into the rules folder, ran my sample syslog through the scanner, and got zero alerts. Which would've been fine, except I had also dropped 50 fake "Failed password for root from 198.51.100.4" lines into the syslog sample. Should've been a wall of red.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I checked first
&lt;/h2&gt;

&lt;p&gt;The dumb stuff first, like always.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Was the rule actually loaded? Yes. The scanner logged &lt;code&gt;[*] Loaded 11 Sigma rules&lt;/code&gt; instead of 10.&lt;/li&gt;
&lt;li&gt;Was the syslog parser reading the right fields? I added a &lt;code&gt;--verbose&lt;/code&gt; flag and dumped the parsed events. Confirmed &lt;code&gt;program=sshd&lt;/code&gt;, &lt;code&gt;message="Failed password for root from 198.51.100.4 port 51234 ssh2"&lt;/code&gt;. Looked fine.&lt;/li&gt;
&lt;li&gt;Was the rule selectable in isolation? I ran the converter against just this rule and it spat out valid Splunk SPL. No errors.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So the rule loaded, the events parsed, the conversion worked. And nothing matched.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual problem
&lt;/h2&gt;

&lt;p&gt;I went and re-read the Sigma spec for like the third time and noticed something. My detection block looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;detection&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selection&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;program&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sshd&lt;/span&gt;
    &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Failed&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;password'&lt;/span&gt;
  &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;selection | count() by source_ip &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The issue is that &lt;code&gt;condition&lt;/code&gt; doesn't take an aggregation expression in the form I had written. The correct form puts the aggregation as part of the rule using a separate &lt;code&gt;timeframe&lt;/code&gt; field and a &lt;code&gt;condition&lt;/code&gt; that references the count. Mine was a frankenstein of old Sigma syntax I'd half-remembered from a blog post.&lt;/p&gt;

&lt;p&gt;Worse, my scanner code did the right thing technically. It looked at &lt;code&gt;condition: selection&lt;/code&gt;, found the events that matched the selection, and then tried to evaluate &lt;code&gt;| count() by source_ip &amp;gt; 10&lt;/code&gt; as a literal pipeline. My pipeline parser saw something it didn't recognize and bailed silently with a &lt;code&gt;False&lt;/code&gt; result, which is exactly the wrong default.&lt;/p&gt;

&lt;p&gt;That's the real bug, by the way. The rule file was wrong, but the scanner not telling me it was wrong is what cost the two hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Two changes. First, I rewrote the rule with proper field-condition mapping:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;detection&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selection&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;program&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sshd'&lt;/span&gt;
    &lt;span class="na"&gt;message|contains&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Failed&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;password&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;for'&lt;/span&gt;
  &lt;span class="na"&gt;timeframe&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;
  &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;selection | count(source_ip) by source_ip &amp;gt; &lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, and more importantly, the scanner now raises on unknown pipeline operators instead of returning False. Here's the relevant chunk in &lt;code&gt;siemforge/scanner.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;evaluate_condition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;pipeline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_evaluate_base&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;op&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_pipeline_handlers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_op_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;UnknownPipelineOperator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown pipeline operator &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; in condition &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_evaluate_base&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;UnknownPipelineOperator&lt;/code&gt; is a custom exception that gets caught one level up and surfaced as a load-time error with the rule filename. So now if a rule is malformed in a way the scanner can't handle, you find out when you load it, not when you wonder why nothing's firing.&lt;/p&gt;

&lt;p&gt;Test count went from 137 to 138 because I added one specifically for this case: feed a rule with a garbage pipeline op, assert that loading raises.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's still broken
&lt;/h2&gt;

&lt;p&gt;Some honest disclosure since this is a portfolio project, not a real product.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The aggregation pipeline in the scanner only supports &lt;code&gt;count() by &amp;lt;field&amp;gt; &amp;gt; N&lt;/code&gt; right now. Any other aggregation isn't implemented. It just raises the same exception. That's better than silently passing, but it's not actually doing detection on those rules yet.&lt;/li&gt;
&lt;li&gt;The syslog parser is RFC 3164 plus a fallback for RFC 5424. It does not handle vendor-specific formats like Cisco ASA out of the box. You'd need to preprocess.&lt;/li&gt;
&lt;li&gt;I don't have a real corpus of malicious vs benign logs to measure false positive rates. The CI just smoke-tests that rules load and the converters produce non-empty output. That's not the same as knowing if a rule is good.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd tell past me
&lt;/h2&gt;

&lt;p&gt;Two things.&lt;/p&gt;

&lt;p&gt;One, if you're writing a rule and the test data should match and it doesn't, the bug is in either the rule, the parser, or the matcher. Add print statements to all three before you start theorizing. I wasted an hour assuming it was the syslog parser when it wasn't.&lt;/p&gt;

&lt;p&gt;Two, if your detection engine returns &lt;code&gt;False&lt;/code&gt; on something it doesn't understand, you've built a system that will lie to you. Always raise. Always.&lt;/p&gt;

&lt;p&gt;If you want to look at the code or fork it for your own home lab, the repo is at github.com/TiltedLunar123/SIEMForge. v3.1 has the fix and the expanded test suite. PRs welcome, especially if you've got rules you've tested in the wild.&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>detection</category>
      <category>sigma</category>
      <category>sysmon</category>
    </item>
    <item>
      <title>How I taught a log scanner to tell brute force from credential spray</title>
      <dc:creator>Jude Hilgendorf</dc:creator>
      <pubDate>Sat, 02 May 2026 10:14:52 +0000</pubDate>
      <link>https://dev.to/tiltedlunar123/how-i-taught-a-log-scanner-to-tell-brute-force-from-credential-spray-3aaf</link>
      <guid>https://dev.to/tiltedlunar123/how-i-taught-a-log-scanner-to-tell-brute-force-from-credential-spray-3aaf</guid>
      <description>&lt;p&gt;I've been building a CLI tool called ThreatLens. It parses EVTX, JSON, Syslog, and CEF logs offline, runs detection rules, and spits out alerts mapped to MITRE ATT&amp;amp;CK. One of the first detectors I wrote was for failed logons (Event ID 4625). Easy, right? Count the failures, threshold it, alert if it crosses N in a window.&lt;/p&gt;

&lt;p&gt;That worked for about ten minutes.&lt;/p&gt;

&lt;p&gt;The problem showed up when I fed it a real-looking dataset with both a brute force attack and a password spray happening at the same time. My detector lit up on the brute force (one IP hammering one account) but completely missed the spray (one account name being tried against 30 hosts from different sources, slowly). They're both T1110 in MITRE land, but operationally they look nothing alike, and lumping them together meant tuning the threshold either let real attacks through or buried analysts in noise.&lt;/p&gt;

&lt;p&gt;So I split them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tried first
&lt;/h2&gt;

&lt;p&gt;The naive version groups by &lt;code&gt;source_ip&lt;/code&gt; and counts failed events. Something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;detect_brute_force&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;window_seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;buckets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;4625&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;buckets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_ip&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;alerts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;times&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;buckets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;times&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;times&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;times&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;times&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;window_seconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;make_alert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;times&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
                &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;alerts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fine for the textbook brute force. Useless for spray.&lt;/p&gt;

&lt;p&gt;The thing is, spray doesn't burst from one IP. It rotates targets. A single account hits five hosts, then ten, paced out so no individual host's failed-logon bucket trips. If you're grouping by source, you'll never see it. If you crank the threshold down to compensate, every misconfigured printer in the building becomes a "brute force" alert.&lt;/p&gt;

&lt;h2&gt;
  
  
  What worked
&lt;/h2&gt;

&lt;p&gt;Two detectors, two grouping keys, two threshold profiles. Brute force groups by &lt;code&gt;(source_ip, target_username)&lt;/code&gt; and looks for tight bursts. Spray groups by &lt;code&gt;target_username&lt;/code&gt; only and looks for breadth across distinct hosts in a wider window.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# brute-force: tight, narrow, single source
&lt;/span&gt;&lt;span class="n"&gt;GROUP_BY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source_ip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;target_username&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;THRESHOLD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
&lt;span class="n"&gt;WINDOW&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;  &lt;span class="c1"&gt;# seconds
&lt;/span&gt;
&lt;span class="c1"&gt;# spray: wide, slow, single target
&lt;/span&gt;&lt;span class="n"&gt;GROUP_BY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;target_username&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,)&lt;/span&gt;
&lt;span class="n"&gt;DISTINCT_FIELD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;computer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# how many hosts did this account hit?
&lt;/span&gt;&lt;span class="n"&gt;THRESHOLD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
&lt;span class="n"&gt;WINDOW&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The spray detector is basically asking a different question: "did anyone try this account against an unusual number of hosts in a short window?" Counting raw events doesn't help here. What helps is counting distinct hosts touched.&lt;/p&gt;

&lt;p&gt;Once I had both, I added a small ranking step. If both fire on the same dataset, the brute force usually fires first (smaller window) and the spray fires on the same account but against different hosts. The output now flags both with separate severity, separate evidence, separate recommendations. An analyst can tell at a glance which one to chase.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code path that actually ships
&lt;/h2&gt;

&lt;p&gt;It ended up living in &lt;code&gt;threatlens/detections/brute_force.py&lt;/code&gt;. Two classes, both subclassing the same &lt;code&gt;DetectionRule&lt;/code&gt; base, both producing &lt;code&gt;Alert&lt;/code&gt; objects with the same shape. The base class handles time-window grouping so each detector only writes its own correlation logic. You can tune both via &lt;code&gt;rules/default_rules.yaml&lt;/code&gt; without editing Python. Service accounts get suppressed with an allowlist that takes a reason field, which sounds dumb but it's been useful for remembering why the line is there six months later:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;allowlist&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;rule_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Brute-Force"&lt;/span&gt;
    &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;svc_monitor"&lt;/span&gt;
    &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;service&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;account,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;expected&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;failed&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;auths&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;from&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;health&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;check"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What's still broken
&lt;/h2&gt;

&lt;p&gt;A few things that bug me.&lt;/p&gt;

&lt;p&gt;The detection is window-based, not session-aware. If a spray runs slowly enough to fall outside the 5-minute window but completes over 30 minutes, I miss it. I've thought about a sliding global account-watch list with decay, but I haven't written it yet.&lt;/p&gt;

&lt;p&gt;The MITRE mapping is correct but coarse. Both detectors emit T1110 with no sub-technique. I want to add T1110.001 (password guessing) and T1110.003 (password spraying) explicitly so downstream tools can tell them apart without parsing my description string.&lt;/p&gt;

&lt;p&gt;There's no enrichment for source IP. I don't know if the spray is coming from a known-bad ASN, a Tor exit, or just the marketing intern who forgot a password. The architecture supports a plugin step for this, I just haven't written that plugin.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;If I started over, I'd build the time-window grouping primitive first and then write detectors on top, instead of writing each detector with its own buckets. I refactored to that shape after the fourth or fifth detector and it would have saved me a lot of duplicated correlation code.&lt;/p&gt;

&lt;p&gt;The repo is at github.com/TiltedLunar123/ThreatLens. It's MIT, runs on Python 3.10+, only runtime dep is PyYAML, and there's a Docker image if you don't want to deal with venvs. Sample logs are included so you can run it and see what the output looks like in about 30 seconds.&lt;/p&gt;

&lt;p&gt;It works. Not perfect but it works.&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>python</category>
      <category>blueteam</category>
      <category>sigma</category>
    </item>
    <item>
      <title>After event viewer crashed on a 400mb evtx, i wrote my own log triage cli</title>
      <dc:creator>Jude Hilgendorf</dc:creator>
      <pubDate>Fri, 01 May 2026 09:53:27 +0000</pubDate>
      <link>https://dev.to/tiltedlunar123/after-event-viewer-crashed-on-a-400mb-evtx-i-wrote-my-own-log-triage-cli-1f18</link>
      <guid>https://dev.to/tiltedlunar123/after-event-viewer-crashed-on-a-400mb-evtx-i-wrote-my-own-log-triage-cli-1f18</guid>
      <description>&lt;p&gt;last week i was poking through event logs from a home lab vm i suspected had been scanned hard. dropped the evtx into event viewer. it took 90 seconds to load, then crashed the moment i tried to filter by event id 4624.&lt;/p&gt;

&lt;p&gt;splunk is overkill for one machine. wazuh wants infra i didn't want to set up just to look at one file. pysigma converts sigma rules to backend queries, but i didn't have a backend. so i wrote threatlens.&lt;/p&gt;

&lt;p&gt;it's a cli. point it at a log file or directory, get alerts mapped to mitre att&amp;amp;ck.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;threatlens scan logs/ &lt;span class="nt"&gt;--min-severity&lt;/span&gt; high
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;that's the whole interface for the common case.&lt;/p&gt;

&lt;h2&gt;
  
  
  what i actually wanted
&lt;/h2&gt;

&lt;p&gt;three things, roughly in priority order.&lt;/p&gt;

&lt;p&gt;works on a single laptop with no infra. no daemon. no agent. no message queue. only runtime dep is pyyaml.&lt;/p&gt;

&lt;p&gt;reads the formats i actually have. evtx (windows native), json/ndjson (modern stuff), syslog (linux), cef (network gear).&lt;/p&gt;

&lt;p&gt;speaks sigma. the community has thousands of detection rules already written. i didn't want to invent another rule format.&lt;/p&gt;

&lt;h2&gt;
  
  
  what i tried first
&lt;/h2&gt;

&lt;p&gt;python-evtx for parsing, which worked fine. then plyara for sigma (turns out plyara is yara, not sigma, oops). then pysigma, which converts sigma to backend queries. i didn't want a query string, i wanted in-memory matching against parsed events.&lt;/p&gt;

&lt;p&gt;ended up writing my own sigma loader. about 400 lines. handles selection blocks, field modifiers (&lt;code&gt;|contains&lt;/code&gt;, &lt;code&gt;|startswith&lt;/code&gt;, &lt;code&gt;|endswith&lt;/code&gt;, &lt;code&gt;|re&lt;/code&gt;, &lt;code&gt;|all&lt;/code&gt;), and the gnarly conditions like &lt;code&gt;selection and not filter&lt;/code&gt; or &lt;code&gt;1 of selection*&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;the precedence stuff bit me hard. my first parser handled &lt;code&gt;a or b and c&lt;/code&gt; left-to-right and got the wrong answer half the time. rewrote it three times before it matched sigma's reference behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  the part i'm most proud of
&lt;/h2&gt;

&lt;p&gt;the elasticsearch output. i wanted to push alerts to ES so they'd show up alongside other security data. the official &lt;code&gt;elasticsearch&lt;/code&gt; python client is 40mb installed and pulls in dozens of transitive deps i didn't want to audit.&lt;/p&gt;

&lt;p&gt;then i remembered: the bulk api is just newline-delimited json over http.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;push_alerts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="p"&gt;}}))&lt;/span&gt;
        &lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_dict&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/x-ndjson&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ApiKey &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/_bulk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;stdlib only. works against real ES clusters. saves about 40mb of install size and removes a whole category of supply chain risk.&lt;/p&gt;

&lt;h2&gt;
  
  
  attack chain correlation
&lt;/h2&gt;

&lt;p&gt;single alerts are noisy. "failed logon" by itself means nothing. "failed logon burst, then privilege escalation, then lateral movement on the same account inside a 10 minute window" is a story.&lt;/p&gt;

&lt;p&gt;the chain detector groups alerts by username and timestamp, then walks them through kill chain order. credential access then priv esc then lateral movement then execution. if the order matches and the events sit inside a tunable time window, it fires a single high severity chain alert that links back to the constituent events.&lt;/p&gt;

&lt;p&gt;against a 52 event mixed-noise dataset i wrote, it pulls out two distinct chains and produces zero false positives on the benign activity. focused 26 event simulation lights up correctly too.&lt;/p&gt;

&lt;h2&gt;
  
  
  the 12 detectors
&lt;/h2&gt;

&lt;p&gt;each one is a separate python module subclassing a &lt;code&gt;DetectionRule&lt;/code&gt; base. brute force, lateral movement, privilege escalation, suspicious process, defense evasion, persistence, discovery, exfiltration, kerberos attacks (kerberoasting and as-rep roasting), credential access (lsass, sam, dcsync), initial access (external rdp, after-hours logons), and the chain correlator.&lt;/p&gt;

&lt;p&gt;custom yaml rules and sigma rules get loaded on top of those. you can also drop a &lt;code&gt;.py&lt;/code&gt; file into &lt;code&gt;--plugin-dir&lt;/code&gt; and the loader picks it up at scan time.&lt;/p&gt;

&lt;h2&gt;
  
  
  what's still rough
&lt;/h2&gt;

&lt;p&gt;the html report works but the css is ugly. svg donut chart for severity, expandable evidence per alert, but the typography needs polish.&lt;/p&gt;

&lt;p&gt;i haven't tested against a real enterprise dataset. sample data is hand-crafted to exercise specific detectors. there's a synthetic generator for 1000 event datasets, but synthetic isn't real.&lt;/p&gt;

&lt;p&gt;sigma loader doesn't handle &lt;code&gt;count() by&lt;/code&gt; aggregations yet. doesn't handle cross-rule correlations either. those are the next two things on the list.&lt;/p&gt;

&lt;p&gt;native evtx parsing requires &lt;code&gt;python-evtx&lt;/code&gt; as an optional extra. without it you have to export to json first. i'd rather auto-detect at runtime and fall back gracefully.&lt;/p&gt;

&lt;h2&gt;
  
  
  what i'd do differently
&lt;/h2&gt;

&lt;p&gt;write the sigma test corpus before the parser. every fix would have been faster with real test cases in place.&lt;/p&gt;

&lt;p&gt;design the alert model around elasticsearch field naming rules from day one. had to rename three fields late because they weren't valid es field names.&lt;/p&gt;

&lt;p&gt;decide upfront whether it's a tool or a framework. threatlens is mostly a tool, but the plugin system pushed it toward framework, and the ambiguity cost some design clarity.&lt;/p&gt;

&lt;h2&gt;
  
  
  links
&lt;/h2&gt;

&lt;p&gt;repo: &lt;a href="https://github.com/TiltedLunar123/ThreatLens" rel="noopener noreferrer"&gt;https://github.com/TiltedLunar123/ThreatLens&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;if you do detection work and the sigma compat breaks on a real community rule, open an issue. that's the part i most want stress-tested.&lt;/p&gt;

</description>
      <category>blueteam</category>
      <category>threathunting</category>
      <category>python</category>
      <category>cybersecurity</category>
    </item>
    <item>
      <title>I Built a Privacy-First Chrome Extension That Saves Your Forms Locally, Zero Network Requests</title>
      <dc:creator>Jude Hilgendorf</dc:creator>
      <pubDate>Fri, 01 May 2026 01:20:11 +0000</pubDate>
      <link>https://dev.to/tiltedlunar123/i-built-a-privacy-first-chrome-extension-that-saves-your-forms-locally-zero-network-requests-97j</link>
      <guid>https://dev.to/tiltedlunar123/i-built-a-privacy-first-chrome-extension-that-saves-your-forms-locally-zero-network-requests-97j</guid>
      <description>&lt;h1&gt;
  
  
  I Built a Privacy-First Chrome Extension That Saves Your Forms Locally
&lt;/h1&gt;

&lt;p&gt;You're 800 words deep into a job application. Tab crashes. Refresh. Gone.&lt;/p&gt;

&lt;p&gt;We've all been there. Most "form saver" extensions phone home to some SaaS with a freemium tier and a privacy policy nobody reads. I wanted the opposite: something that runs entirely on my machine, never makes a network request, and just works.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;FormVault&lt;/strong&gt;, an open-source Chrome extension that auto-saves form data to local storage, with a one-click restore toast when you come back to the page.&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/TiltedLunar123/FormVault" rel="noopener noreferrer"&gt;https://github.com/TiltedLunar123/FormVault&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Auto-saves any form field you type into, debounced 3 seconds after your last keystroke&lt;/li&gt;
&lt;li&gt;Pops a non-intrusive toast when you revisit a page with saved data&lt;/li&gt;
&lt;li&gt;Skips passwords, credit card fields, SSNs, never saved, period&lt;/li&gt;
&lt;li&gt;Banking sites blocked by default, with a user-configurable domain blocklist&lt;/li&gt;
&lt;li&gt;All UI injected via Shadow DOM so it never collides with host page styles&lt;/li&gt;
&lt;li&gt;100% local storage. Zero fetch calls. Zero analytics. Zero telemetry. Verifiable in the source.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's Manifest V3, MIT-licensed, and the entire codebase is small enough to audit in an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I built it
&lt;/h2&gt;

&lt;p&gt;I'm a cybersecurity student. I spend a lot of my time studying how user data leaks through "convenience" features. Every time I install a browser extension, I read its permissions and skim its code. Most form auto-savers either sync to a cloud (which means your half-finished forms, including stuff that should have been redacted, sit on someone else's server), or they have hidden analytics calls.&lt;/p&gt;

&lt;p&gt;I wanted the boring, paranoid version: storage stays on your machine, code is small, nothing exfiltrates. Building it also forced me to learn a few things about the Chrome extension platform that I'd been hand-waving over.&lt;/p&gt;

&lt;h2&gt;
  
  
  The interesting technical bit: making auto-restore work with React
&lt;/h2&gt;

&lt;p&gt;Here's where it got fun. Saving form values is easy, you &lt;code&gt;addEventListener('input', ...)&lt;/code&gt; on every field, debounce, write to &lt;code&gt;chrome.storage.local&lt;/code&gt;, done. Restoring is where most extensions fail silently on modern apps.&lt;/p&gt;

&lt;p&gt;Set &lt;code&gt;input.value = 'foo'&lt;/code&gt; on a React-controlled input and... nothing visible happens. The DOM updates, but React's internal state still has the old value, and the next render snaps it back. Same story with Vue, Svelte, and most reactive frameworks.&lt;/p&gt;

&lt;p&gt;The fix is to call the native &lt;code&gt;value&lt;/code&gt; setter directly and then dispatch the events React's synthetic event system listens for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;setReactCompatibleValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Use the native setter from the prototype, not the framework's overridden one&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;proto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;HTMLTextAreaElement&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;HTMLTextAreaElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HTMLInputElement&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;prototype&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nativeSetter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getOwnPropertyDescriptor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;proto&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;value&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="kd"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nativeSetter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;nativeSetter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// React listens to these, bubbling matters&lt;/span&gt;
  &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispatchEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;input&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;bubbles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispatchEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;change&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;bubbles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispatchEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blur&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;bubbles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why this works: React patches the &lt;code&gt;value&lt;/code&gt; setter on input elements to track changes through its synthetic event system. When you write &lt;code&gt;el.value = ...&lt;/code&gt;, you hit React's patched setter, which marks the value as "set by React" and ignores the subsequent &lt;code&gt;input&lt;/code&gt; event as a no-op. By grabbing the native setter from &lt;code&gt;HTMLInputElement.prototype&lt;/code&gt; and calling it directly, you bypass the patched version, so when you then fire a synthetic &lt;code&gt;input&lt;/code&gt; event with &lt;code&gt;bubbles: true&lt;/code&gt;, React picks it up and updates state correctly.&lt;/p&gt;

&lt;p&gt;The same technique works for Vue, Preact, and basically any framework that uses synthetic events.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other thing: identifying fields without owning them
&lt;/h2&gt;

&lt;p&gt;The other rabbit hole was figuring out how to find the same field again on a later page load. SPAs rerender. Element IDs sometimes survive, sometimes don't. CSS selectors that work today may break tomorrow.&lt;/p&gt;

&lt;p&gt;FormVault stores three identifiers per field and falls back through them at restore time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Unique selector&lt;/strong&gt;, &lt;code&gt;#id&lt;/code&gt; if there's an ID, otherwise &lt;code&gt;tag[name="..."]&lt;/code&gt; if names are unique, otherwise a &lt;code&gt;nth-of-type&lt;/code&gt; path up to the body.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;name&lt;/code&gt; attribute&lt;/strong&gt;, used as a secondary lookup if the selector misses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;XPath&lt;/strong&gt;, last-resort positional path for sites with no useful identifiers.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Three layers of redundancy means restore works on the messy real-world web instead of just on demos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy guarantees you can actually verify
&lt;/h2&gt;

&lt;p&gt;One of the things I find frustrating about "privacy-focused" tools is that you have to take their word for it. FormVault is small enough that you don't.&lt;/p&gt;

&lt;p&gt;The repo is one content script, one service worker, one popup, and a tiny storage helper. Search the codebase for &lt;code&gt;fetch&lt;/code&gt;, &lt;code&gt;XMLHttpRequest&lt;/code&gt;, &lt;code&gt;WebSocket&lt;/code&gt;, or any analytics SDK. You won't find them. The manifest doesn't request &lt;code&gt;host_permissions&lt;/code&gt; for any external domain. Network tab will be silent forever.&lt;/p&gt;

&lt;p&gt;For sensitive field detection, I check four signals before saving anything:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Input &lt;code&gt;type&lt;/code&gt; (passwords and hidden are auto-skipped)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;autocomplete&lt;/code&gt; attribute (&lt;code&gt;new-password&lt;/code&gt;, &lt;code&gt;cc-number&lt;/code&gt;, &lt;code&gt;cc-csc&lt;/code&gt;, etc.)&lt;/li&gt;
&lt;li&gt;Regex against &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;aria-label&lt;/code&gt;, &lt;code&gt;placeholder&lt;/code&gt; for &lt;code&gt;password|ssn|cc-num|cvv|routing-number|account-number|pin-code&lt;/code&gt; and similar&lt;/li&gt;
&lt;li&gt;Domain blocklist (banking sites are blocked out of the box)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;False negatives are still possible on weirdly-named fields, but the multi-signal approach catches most real-world cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;It's a developer-mode install for now (Chrome Web Store fee is a hurdle for a side project):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/TiltedLunar123/FormVault.git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;code&gt;chrome://extensions/&lt;/code&gt; → Developer mode on → Load unpacked → pick the folder.&lt;/p&gt;

&lt;p&gt;If you find a form that doesn't restore properly, open an issue with the URL and I'll take a look. Bonus points if you can break the sensitive field detection, that's the one thing I want to keep tightening.&lt;/p&gt;

&lt;p&gt;If you'd like to support the project, a star on the repo helps with discoverability. And if you have ideas, iframe support, Firefox port, export/import, the roadmap is open.&lt;/p&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/TiltedLunar123/FormVault" rel="noopener noreferrer"&gt;https://github.com/TiltedLunar123/FormVault&lt;/a&gt;&lt;/p&gt;

</description>
      <category>chromeextension</category>
      <category>javascript</category>
      <category>privacy</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I Built a Chrome Extension That Bulk-Cleans Gmail in One Click (and Why You Probably Need It)</title>
      <dc:creator>Jude Hilgendorf</dc:creator>
      <pubDate>Thu, 30 Apr 2026 14:15:32 +0000</pubDate>
      <link>https://dev.to/tiltedlunar123/i-built-a-chrome-extension-that-bulk-cleans-gmail-in-one-click-and-why-you-probably-need-it-51a4</link>
      <guid>https://dev.to/tiltedlunar123/i-built-a-chrome-extension-that-bulk-cleans-gmail-in-one-click-and-why-you-probably-need-it-51a4</guid>
      <description>&lt;h1&gt;
  
  
  I Built a Chrome Extension That Bulk-Cleans Gmail in One Click
&lt;/h1&gt;

&lt;p&gt;If you're like me, your Gmail inbox is a graveyard of newsletters you never read, promo emails from 2019, and 40MB attachments from a group project you forgot about. Google gives you 15GB free, and somehow that "free" tier feels like extortion the second you cross it.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;&lt;a href="https://github.com/TiltedLunar123/gmail-one-click-cleaner" rel="noopener noreferrer"&gt;Gmail One-Click Cleaner&lt;/a&gt;&lt;/strong&gt;, an open-source, Manifest V3 Chrome extension that bulk-cleans Gmail in one click. No servers, no analytics, no API keys. Everything runs in your browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;The "obvious" solution everyone reaches for is one of those services that asks for full Gmail OAuth scope. You hand over your entire mail history to a startup you've never heard of, and they delete some emails for you. That's a wild trade.&lt;/p&gt;

&lt;p&gt;I wanted something that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Runs &lt;strong&gt;100% locally&lt;/strong&gt;, never touches my email content over the network&lt;/li&gt;
&lt;li&gt;Uses &lt;strong&gt;Gmail's own UI&lt;/strong&gt; to do the deletion (so it's basically simulating what I'd do manually)&lt;/li&gt;
&lt;li&gt;Has &lt;strong&gt;safety rails&lt;/strong&gt; so I don't accidentally nuke important stuff&lt;/li&gt;
&lt;li&gt;Is &lt;strong&gt;fast&lt;/strong&gt; enough to clean tens of thousands of emails without me babysitting it&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;It runs configurable cleanup rules against your inbox. Out of the box it ships with categories like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Promotions older than 6 months&lt;/li&gt;
&lt;li&gt;Social updates older than 1 year&lt;/li&gt;
&lt;li&gt;Anything with "unsubscribe" older than 1 year&lt;/li&gt;
&lt;li&gt;Attachments larger than 10MB older than 6 months&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You pick a rule set (Light / Normal / Deep), pick a mode (Live / Review / Dry-Run), and hit go. A live dashboard shows progress, per-rule results, and estimated MB freed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture (the interesting bit)
&lt;/h2&gt;

&lt;p&gt;The whole thing is a Manifest V3 extension, which forced me into some interesting design decisions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;manifest.json         → MV3 manifest, minimal permissions
background.js         → Service worker (alarms, messaging, stats)
contentScript.js      → Gmail DOM automation (injected into Gmail)
popup.html/js         → Extension popup UI
progress.html/js      → Live progress dashboard
options.html/js       → Rules &amp;amp; settings
shared.css/js         → Design tokens + GCC utility namespace
browser-polyfill.js   → Cross-browser shim (Chrome, Edge, Brave, Firefox)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cleanup engine lives in &lt;code&gt;contentScript.js&lt;/code&gt;. It uses Gmail's existing search syntax, the same thing you'd type into the search bar, to scope each rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Example: pull anything in promotions older than 6 months&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;category:promotions older_than:6m&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Then we drive Gmail's UI: open search, select all matching threads,&lt;/span&gt;
&lt;span class="c1"&gt;// click "Select all conversations that match this search", click Trash.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sounds simple, but Gmail's UI is a moving target. Every few months the DOM shifts. So I built helpers around &lt;code&gt;MutationObserver&lt;/code&gt; to wait for elements rather than &lt;code&gt;setTimeout&lt;/code&gt; everywhere, that one change cut flakiness by maybe 80%.&lt;/p&gt;

&lt;h2&gt;
  
  
  The safety rails
&lt;/h2&gt;

&lt;p&gt;The first version of this thing scared me. So I wired in real guardrails:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Minimum age cutoff.&lt;/strong&gt; Every rule has a "never touch anything newer than X" floor. Default is 3 months. You'd be amazed how often this saves you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Global whitelist.&lt;/strong&gt; Senders or domains you protect from every rule, no matter what. Bank, employer, family, done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Safe Mode.&lt;/strong&gt; Skips categories that are statistically dangerous: receipts, order confirmations, shipping updates. Even if a rule matches, Safe Mode will block it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Skip starred &amp;amp; important.&lt;/strong&gt; On by default. Gmail's "Important" classifier isn't perfect, but it's good enough as a backstop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dry-Run mode.&lt;/strong&gt; Run any rule set and just count matches. No deletion. This is the mode I always tell new users to start in.&lt;/p&gt;

&lt;p&gt;Even with all that, Gmail keeps trashed mail for ~30 days, so worst case you've got a recovery window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Privacy: this part actually matters
&lt;/h2&gt;

&lt;p&gt;The thing I'm proudest of is the permissions footprint. The whole extension needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"activeTab"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"scripting"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tabs"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"storage"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"host_permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"https://mail.google.com/*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No &lt;code&gt;&amp;lt;all_urls&amp;gt;&lt;/code&gt;. No identity. No webRequest. Storage is local (your settings live in &lt;code&gt;chrome.storage.local&lt;/code&gt;, never synced unless you toggle it). There is no backend. There is no analytics SDK. There is no telemetry. The repo's open source, go check &lt;code&gt;manifest.json&lt;/code&gt; yourself.&lt;/p&gt;

&lt;p&gt;If you've ever installed a "free" extension and felt that creeping suspicion, this should feel different.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I built this
&lt;/h2&gt;

&lt;p&gt;I'm a CompTIA-track cybersecurity student who reads a lot of breach reports for fun. Most "free" tools that touch email are not free, you're paying with your data, and the second their startup pivots or gets acquired, the rules change.&lt;/p&gt;

&lt;p&gt;I wanted a tool I'd actually trust on my own inbox. So I built it, used it, and shipped it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Grab it here: &lt;strong&gt;&lt;a href="https://github.com/TiltedLunar123/gmail-one-click-cleaner" rel="noopener noreferrer"&gt;github.com/TiltedLunar123/gmail-one-click-cleaner&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you find it useful, a star helps a ton. PRs welcome, there's a &lt;code&gt;CONTRIBUTING.md&lt;/code&gt; with the dev setup and a Jest test suite to keep things honest.&lt;/p&gt;

&lt;p&gt;What's the most ridiculous thing your Gmail is hoarding right now? Drop a screenshot in the comments, I'm taking nominations for default rules in v5.&lt;/p&gt;

</description>
      <category>chromeextension</category>
      <category>javascript</category>
      <category>opensource</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Building ThreatLens: An Offline Threat Hunting CLI That Maps Logs to MITRE ATT&amp;CK</title>
      <dc:creator>Jude Hilgendorf</dc:creator>
      <pubDate>Tue, 28 Apr 2026 12:24:17 +0000</pubDate>
      <link>https://dev.to/tiltedlunar123/building-threatlens-an-offline-threat-hunting-cli-that-maps-logs-to-mitre-attck-580d</link>
      <guid>https://dev.to/tiltedlunar123/building-threatlens-an-offline-threat-hunting-cli-that-maps-logs-to-mitre-attck-580d</guid>
      <description>&lt;p&gt;Most blue team tooling assumes you have a SIEM, a budget, and a network connection. That's a fine assumption for an enterprise SOC, but it kills the feedback loop for students, home-labbers, IR consultants who land in air-gapped environments, and anyone who just wants to triage a dump of logs without spinning up infrastructure.&lt;/p&gt;

&lt;p&gt;That's the gap I built &lt;a href="https://github.com/TiltedLunar123/ThreatLens" rel="noopener noreferrer"&gt;ThreatLens&lt;/a&gt; to fill: a single Python CLI that ingests EVTX, JSON, Syslog, and CEF logs, runs Sigma rules plus a set of opinionated detectors, correlates findings across stages of an attack, and produces alerts already mapped to MITRE ATT&amp;amp;CK. No agents, no cloud, no license keys. Just &lt;code&gt;pip install threatlens&lt;/code&gt; and point it at a log file.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually does
&lt;/h2&gt;

&lt;p&gt;ThreatLens isn't trying to replace Splunk. It's the layer that runs &lt;em&gt;before&lt;/em&gt; a SIEM, or &lt;em&gt;instead of&lt;/em&gt; one when you don't have access to one. The detection coverage is what you'd expect a junior analyst to want pre-built:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Brute-force and password spray&lt;/strong&gt; with logic that distinguishes the two&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lateral movement&lt;/strong&gt; via rapid network logons across hosts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privilege escalation&lt;/strong&gt; by watching sensitive privilege assignments&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Suspicious process execution&lt;/strong&gt; (LOLBins, encoded PowerShell, certutil download cradles, SAM dumping)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Defense evasion&lt;/strong&gt; (log clearing, Defender disabled, audit policy tampering)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistence&lt;/strong&gt; (services, scheduled tasks, Run keys, startup folders)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Discovery bursts&lt;/strong&gt; (whoami, ipconfig, net, nltest, dsquery in rapid succession)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Kerberoasting and AS-REP roasting&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credential access&lt;/strong&gt; including LSASS, SAM, and DCSync&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attack chain correlation&lt;/strong&gt; that links credential access → priv esc → lateral movement → execution into a single multi-stage alert&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each alert ships with the MITRE technique ID, the matching evidence, and a severity that's actually based on signal density instead of a flat "MEDIUM" stamp.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I built it
&lt;/h2&gt;

&lt;p&gt;I'm a cybersecurity student grinding through CompTIA certs and trying to build muscle memory around log-based threat hunting. The problem is, the second you step outside a paid lab, the resources dry up. Hunting in a free Splunk trial means re-uploading data every week. ELK is a weekend of YAML before you see your first alert. Even paid SaaS SIEMs hide the detection logic behind a UI, which is great for production and terrible for learning.&lt;/p&gt;

&lt;p&gt;I wanted a tool that made the detection logic &lt;em&gt;the artifact&lt;/em&gt;. Open the repo, read &lt;code&gt;detections/brute_force.py&lt;/code&gt;, and you can see exactly why a 4625 burst from a single IP becomes a "Brute-Force" alert and why the same IP hitting 30 different usernames becomes "Password Spray" instead. That transparency is the entire point.&lt;/p&gt;

&lt;h2&gt;
  
  
  A code walkthrough: the brute-force detector
&lt;/h2&gt;

&lt;p&gt;The cleanest example of how the project is structured is the brute-force detector, because it's also the one that taught me the most. The naive version is "5 failed logons in 5 minutes, alert." That misses password spray (one attempt against many users) and over-fires on legitimate failures during outages.&lt;/p&gt;

&lt;p&gt;Here's the core of the actual detector (&lt;a href="https://github.com/TiltedLunar123/ThreatLens/blob/main/threatlens/detections/brute_force.py" rel="noopener noreferrer"&gt;source&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BruteForceDetector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DetectionRule&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Brute-Force / Password Spray&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;mitre_tactic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Credential Access&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;mitre_technique&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;T1110 - Brute Force&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;analyze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;LogEvent&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Alert&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;FAILED_LOGON_IDS&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

        &lt;span class="n"&gt;alerts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Alert&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

        &lt;span class="c1"&gt;# Group by source IP
&lt;/span&gt;        &lt;span class="n"&gt;by_ip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;LogEvent&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_ip&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="n"&gt;by_ip&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;source_events&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;by_ip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="n"&gt;windows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;find_dense_windows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;source_events&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;window_seconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;threshold&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;windows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;targets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;target_username&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;username&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
                    &lt;span class="n"&gt;is_spray&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;targets&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
                    &lt;span class="n"&gt;rule_label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Password Spray&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;is_spray&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Brute-Force&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things are happening that are worth pulling apart:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. It groups by source first, not by user.&lt;/strong&gt; Most beginner tutorials key on the username. That breaks the moment an attacker rotates usernames against the same source. Keying on source IP catches the spray pattern naturally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The window logic is delegated.&lt;/strong&gt; &lt;code&gt;find_dense_windows&lt;/code&gt; is a small utility that finds the densest sub-windows inside a list of events sorted by timestamp. Pulling it into a utility means lateral movement, discovery, and credential access detectors all share the same primitive, the rules stay declarative.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Brute-force vs spray is one line.&lt;/strong&gt; &lt;code&gt;is_spray = len(targets) &amp;gt; 3&lt;/code&gt;. If a single source is hammering more than three distinct usernames inside the window, it's almost never a misconfigured client and almost always a spray. The threshold is configurable, but the default is calibrated against the public detection logic in the SOC Prime and Sigma communities.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Severity scales with density.&lt;/strong&gt; The alert isn't just "found something." If the burst is 3x the threshold it ships as &lt;code&gt;HIGH&lt;/code&gt;, 2x as &lt;code&gt;MEDIUM&lt;/code&gt;, and so on. That keeps the noise floor sane when you point it at a noisy log.&lt;/p&gt;

&lt;p&gt;The same shape repeats across the other detectors. Each one is ~100 lines, each one inherits from a tiny &lt;code&gt;DetectionRule&lt;/code&gt; base class, and each one is unit tested with synthetic events so you can read the test file and see exactly what the rule fires on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-stage correlation
&lt;/h2&gt;

&lt;p&gt;The detection that took the longest to get right is the attack chain correlator. Individually, a single 4625, a single privilege assignment, and a single network logon are all noise. Stitched together, credential access → priv esc → lateral movement, on the same actor, inside the same time window, they're a kill chain.&lt;/p&gt;

&lt;p&gt;The correlator scans all alerts produced by the per-tactic detectors, groups them by actor (user or source IP), sorts by tactic-stage, and emits a single multi-stage alert when at least two ATT&amp;amp;CK tactics fire in sequence. The output is a timeline a human can actually read, not a wall of unrelated tickets.&lt;/p&gt;

&lt;p&gt;That's the part that feels closest to real SOC work. You stop staring at a single event and start asking "what's the story?"&lt;/p&gt;

&lt;h2&gt;
  
  
  Output formats
&lt;/h2&gt;

&lt;p&gt;ThreatLens ships three:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Terminal&lt;/strong&gt;, color-coded summary, severity counts, top techniques, exit code = severity tier (so you can chain it in CI)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSON&lt;/strong&gt;, full structured alerts, ready to feed into a SIEM, a webhook, or just &lt;code&gt;jq&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTML report&lt;/strong&gt;, standalone, no JS framework, includes a severity chart and an interactive attack timeline. This is the format I keep open in a tab while I work through a dataset.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The roadmap I'm actually working on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;More native parsers (currently leaning on &lt;code&gt;python-evtx&lt;/code&gt;; eventually want a pure-Python EVTX reader for environments where pip is restricted)&lt;/li&gt;
&lt;li&gt;Better Sigma backend coverage so community rules drop in cleanly&lt;/li&gt;
&lt;li&gt;A small library of "hunt packs", pre-built configurations targeted at specific scenarios (ransomware aftermath, lateral movement audit, post-phish triage)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;If any of this sounds useful, the repo is here: &lt;strong&gt;&lt;a href="https://github.com/TiltedLunar123/ThreatLens" rel="noopener noreferrer"&gt;github.com/TiltedLunar123/ThreatLens&lt;/a&gt;&lt;/strong&gt;. There's a sample log in &lt;code&gt;sample_data/&lt;/code&gt; so you can run &lt;code&gt;threatlens scan sample_data/sample_security_log.json&lt;/code&gt; and see real alerts in under 30 seconds.&lt;/p&gt;

&lt;p&gt;A star helps the project show up for other students and early-career analysts trying to find tools that aren't locked behind enterprise pricing. PRs welcome, especially new detectors. If there's a tactic you've been wanting to hunt for and the repo doesn't cover it yet, open an issue and we'll talk through the rule design.&lt;/p&gt;

</description>
      <category>cli</category>
      <category>python</category>
      <category>security</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I Built an Offline Log Triage CLI That Detects MITRE ATT&amp;CK Techniques in EVTX, Syslog, JSON, and CEF</title>
      <dc:creator>Jude Hilgendorf</dc:creator>
      <pubDate>Mon, 20 Apr 2026 14:00:42 +0000</pubDate>
      <link>https://dev.to/tiltedlunar123/i-built-an-offline-log-triage-cli-that-detects-mitre-attck-techniques-in-evtx-syslog-json-and-2d9p</link>
      <guid>https://dev.to/tiltedlunar123/i-built-an-offline-log-triage-cli-that-detects-mitre-attck-techniques-in-evtx-syslog-json-and-2d9p</guid>
      <description>&lt;h1&gt;
  
  
  I Built an Offline Log Triage CLI That Detects MITRE ATT&amp;amp;CK Techniques
&lt;/h1&gt;

&lt;p&gt;SOC analysts routinely face thousands of daily events, and most of it is noise. If you're on an incident response engagement with no network access, or you're staring at a zip of EVTX files a client dropped in your lap, spinning up a full SIEM isn't realistic.&lt;/p&gt;

&lt;p&gt;That's the gap &lt;a href="https://github.com/TiltedLunar123/ThreatLens" rel="noopener noreferrer"&gt;ThreatLens&lt;/a&gt; is trying to fill. It's a single Python CLI that parses log formats SOC analysts actually encounter — JSON/NDJSON, EVTX, Syslog (RFC 3164 and 5424), and CEF — and runs a detection engine mapped to MITRE ATT&amp;amp;CK, entirely offline, with only PyYAML as a hard runtime dependency.&lt;/p&gt;

&lt;p&gt;This post walks through what it does, why I built it, and a code walkthrough of how the detection engine works.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Actually Does
&lt;/h2&gt;

&lt;p&gt;Point it at a log file or a directory, and it runs 12 built-in detection modules covering tactics like brute force, lateral movement, privilege escalation, and persistence. Output is whatever you need for the workflow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Color-coded terminal output for quick triage&lt;/li&gt;
&lt;li&gt;Self-contained HTML reports with charts and interactive timelines&lt;/li&gt;
&lt;li&gt;JSON for piping into a SIEM&lt;/li&gt;
&lt;li&gt;CSV for handoffs to analysts who live in spreadsheets&lt;/li&gt;
&lt;li&gt;Elasticsearch bulk API format when you want to ingest the findings&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A few representative commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Basic scan against a JSON log&lt;/span&gt;
threatlens scan logs/security.json

&lt;span class="c"&gt;# Native EVTX parsing — no conversion step&lt;/span&gt;
threatlens scan evidence/security.evtx

&lt;span class="c"&gt;# HTML report, filtered to high severity and above&lt;/span&gt;
threatlens scan logs/ &lt;span class="nt"&gt;-o&lt;/span&gt; report.html &lt;span class="nt"&gt;-f&lt;/span&gt; html &lt;span class="nt"&gt;--min-severity&lt;/span&gt; high

&lt;span class="c"&gt;# Real-time tailing for live triage&lt;/span&gt;
threatlens follow /var/log/events.json

&lt;span class="c"&gt;# Use community Sigma rules&lt;/span&gt;
threatlens scan logs/ &lt;span class="nt"&gt;--sigma-rules&lt;/span&gt; sigma/rules/windows/

&lt;span class="c"&gt;# CI/CD gate — exit non-zero on high-severity findings&lt;/span&gt;
threatlens scan logs/ &lt;span class="nt"&gt;--fail-on&lt;/span&gt; high
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why I Built It
&lt;/h2&gt;

&lt;p&gt;I'm a cybersecurity student, and the gap between "read a blog post about MITRE ATT&amp;amp;CK" and "actually recognize T1110 in a pile of Event ID 4625s" is large. The only way I found to close it was to write the detection logic myself.&lt;/p&gt;

&lt;p&gt;Existing tools kept tripping on the same pain points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chainsaw, EvtxHussar, hayabusa&lt;/strong&gt; are excellent but Windows-centric. I wanted one tool that chews through EVTX &lt;em&gt;and&lt;/em&gt; the Linux syslog a cloud workload dumped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full SIEM stacks&lt;/strong&gt; are overkill for a one-off engagement. I didn't want to stand up Elastic + Kibana + Filebeat to look at 200MB of logs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom scripts&lt;/strong&gt; get rewritten every engagement. I wanted rule-based logic I could extend without touching parsing code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the design goal was: one binary, offline, format-agnostic, rule-driven, zero infra.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code Walkthrough: The Rule Engine
&lt;/h2&gt;

&lt;p&gt;The most interesting piece architecturally is the rule engine, because it has to be expressive enough to write real detections but simple enough that an analyst can author rules in YAML without writing Python.&lt;/p&gt;

&lt;p&gt;A rule looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;After-Hours&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Logon"&lt;/span&gt;
    &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;medium&lt;/span&gt;
    &lt;span class="na"&gt;conditions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;event_id&lt;/span&gt;
        &lt;span class="na"&gt;operator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;equals&lt;/span&gt;
        &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4624"&lt;/span&gt;
    &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a toy example. The real power shows up when you combine operators. The engine ships with 12: &lt;code&gt;equals&lt;/code&gt;, &lt;code&gt;not_equals&lt;/code&gt;, &lt;code&gt;contains&lt;/code&gt;, &lt;code&gt;not_contains&lt;/code&gt;, &lt;code&gt;startswith&lt;/code&gt;, &lt;code&gt;endswith&lt;/code&gt;, &lt;code&gt;regex&lt;/code&gt;, &lt;code&gt;gt&lt;/code&gt;, &lt;code&gt;lt&lt;/code&gt;, &lt;code&gt;gte&lt;/code&gt;, &lt;code&gt;lte&lt;/code&gt;, and &lt;code&gt;in&lt;/code&gt;. Conditions AND together by default, and thresholds turn a single-event match into a frequency-based detection (5 failures in 60 seconds, etc.).&lt;/p&gt;

&lt;p&gt;The evaluation loop is roughly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;evaluate_rule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;condition&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conditions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;field_value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;field&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;OPERATORS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;operator&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="n"&gt;field_value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Where &lt;code&gt;OPERATORS&lt;/code&gt; is a dict mapping operator names to small comparison lambdas. Adding a new operator is a one-line change — no parser work, no AST manipulation. This is deliberate. I wanted rule authoring to feel like writing a conditional, not configuring a parser.&lt;/p&gt;

&lt;p&gt;Thresholded rules layer on top. The engine keeps a sliding window per &lt;code&gt;(rule, grouping_key)&lt;/code&gt; — typically grouped by source IP or username — and fires the alert only when &lt;code&gt;count &amp;gt;= threshold&lt;/code&gt; within the window. Grouping keys are pulled from the event by name, which means a rule author can correlate on any field the parser exposes without modifying code.&lt;/p&gt;

&lt;p&gt;The parsing phase normalizes every format into a flat dict with a small set of canonical fields (&lt;code&gt;timestamp&lt;/code&gt;, &lt;code&gt;source_ip&lt;/code&gt;, &lt;code&gt;username&lt;/code&gt;, &lt;code&gt;event_id&lt;/code&gt;, &lt;code&gt;process&lt;/code&gt;, &lt;code&gt;command_line&lt;/code&gt;, etc.) plus the raw original. That normalization is what makes the rule engine format-agnostic — the same "Brute Force" rule works against Windows 4625 events, SSH auth.log failures, and a CEF feed from a firewall, because they all expose &lt;code&gt;username&lt;/code&gt; and &lt;code&gt;source_ip&lt;/code&gt; after parsing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attack Chain Correlation
&lt;/h2&gt;

&lt;p&gt;The feature I'm most proud of is multi-stage correlation. A single brute-force alert is interesting. A brute-force alert followed 30 seconds later by a successful logon from the same IP, followed by a &lt;code&gt;net user /add&lt;/code&gt; execution, is an incident. The correlation engine takes a chain definition — an ordered list of rule names with time windows — and fires a higher-severity meta-alert when it sees the full kill chain in order.&lt;/p&gt;

&lt;p&gt;That's how low-severity events ladder up to high-severity incidents without drowning analysts in noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Allowlist Suppression
&lt;/h2&gt;

&lt;p&gt;Every detection tool dies on false positives. The allowlist layer suppresses known-good activity by rule name plus any combination of event fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;allowlist&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;rule_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Brute-Force"&lt;/span&gt;
    &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;svc_monitor"&lt;/span&gt;
    &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Expected&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;service&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;account&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;failures"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;reason&lt;/code&gt; field is required. It forces the author to document &lt;em&gt;why&lt;/em&gt; this exception exists, which is the single most important thing about any suppression rule in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;

&lt;p&gt;Against the sample datasets in the repo, ThreatLens hit zero false positives on benign activity while detecting 100% of embedded attack techniques with correct MITRE ATT&amp;amp;CK classification. That's on curated data — real-world logs are messier — but it's a useful floor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;threatlens
threatlens scan /path/to/your/logs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or clone it and poke at the rule engine: &lt;a href="https://github.com/TiltedLunar123/ThreatLens" rel="noopener noreferrer"&gt;github.com/TiltedLunar123/ThreatLens&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you find it useful, a star helps the repo surface for other analysts. PRs for new detections or log formats are especially welcome — I'd love to see what rules other people write.&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>python</category>
      <category>opensource</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Building SIEMForge: A Portable SIEM Detection Toolkit with Sigma, Sysmon, and MITRE ATT&amp;CK</title>
      <dc:creator>Jude Hilgendorf</dc:creator>
      <pubDate>Mon, 20 Apr 2026 13:47:45 +0000</pubDate>
      <link>https://dev.to/tiltedlunar123/building-siemforge-a-portable-siem-detection-toolkit-with-sigma-sysmon-and-mitre-attck-59p2</link>
      <guid>https://dev.to/tiltedlunar123/building-siemforge-a-portable-siem-detection-toolkit-with-sigma-sysmon-and-mitre-attck-59p2</guid>
      <description>&lt;p&gt;If you've ever tried to stand up detection content across more than one SIEM, you already know the pain. Sigma rules live in one repo, Sysmon config lives in another, your Wazuh custom rules are scattered across three &lt;code&gt;local_rules.xml&lt;/code&gt; files, and MITRE mapping is an afterthought buried in a spreadsheet. I built &lt;strong&gt;SIEMForge&lt;/strong&gt; to fix that.&lt;/p&gt;

&lt;p&gt;SIEMForge is a single Python toolkit that bundles Sigma rules, a Sysmon configuration, and Wazuh custom rules — all mapped to MITRE ATT&amp;amp;CK — with an offline log scanner and a multi-backend rule converter. It runs from a home lab to a production SOC without changing the workflow.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/TiltedLunar123/SIEMForge" rel="noopener noreferrer"&gt;github.com/TiltedLunar123/SIEMForge&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I built it
&lt;/h2&gt;

&lt;p&gt;I'm a cybersecurity student, and when I started building detections for a home lab, the tooling gap became obvious fast. Every tutorial assumed you had a Splunk license or a full Elastic stack. The actual Sigma ecosystem is great, but turning a Sigma rule into something that runs against real logs without spinning up a SIEM is friction that kills learning momentum.&lt;/p&gt;

&lt;p&gt;I wanted three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write detection logic &lt;strong&gt;once&lt;/strong&gt; in Sigma, deploy it anywhere&lt;/li&gt;
&lt;li&gt;Test rules &lt;strong&gt;offline&lt;/strong&gt; against sample logs before pushing anything to production&lt;/li&gt;
&lt;li&gt;See MITRE ATT&amp;amp;CK coverage at a glance so I knew where the gaps were&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;SIEMForge is the tool I wished existed when I started.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Log Scanner Engine.&lt;/strong&gt; Point it at a JSON, JSONL, syslog, or CSV log file and it runs your Sigma rules against it — no SIEM required. Human-readable alerts by default, JSON output if you want to pipe it somewhere.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-Backend Rule Conversion.&lt;/strong&gt; Convert one Sigma rule into Splunk SPL, Elasticsearch Lucene, or Kibana KQL. No vendor lock-in, no rewriting detections when you change platforms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MITRE ATT&amp;amp;CK Coverage Matrix.&lt;/strong&gt; Every bundled rule is tagged with techniques. Run one command and see exactly what you cover.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule Validation.&lt;/strong&gt; Catches bad YAML, broken field-condition mappings, and malformed Sigma syntax before you deploy anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sysmon + Wazuh bundled.&lt;/strong&gt; A production-ready Sysmon config and Wazuh custom rules ship with the project, mapped to the same techniques as the Sigma rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code walkthrough: the log scanner
&lt;/h2&gt;

&lt;p&gt;The piece I'm proudest of is the offline log scanner. It lets you validate detection logic against raw logs without deploying anything. Here's how it works conceptually:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; siemforge &lt;span class="nt"&gt;--scan&lt;/span&gt; /var/log/sysmon/events.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood, the scanner:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Loads every Sigma rule from &lt;code&gt;rules/sigma/&lt;/code&gt; and parses the YAML into a rule object&lt;/li&gt;
&lt;li&gt;Opens the log file and streams records (auto-detects JSON / JSONL / syslog / CSV)&lt;/li&gt;
&lt;li&gt;For each record, walks each rule's &lt;code&gt;detection&lt;/code&gt; block and evaluates the field-condition logic&lt;/li&gt;
&lt;li&gt;On a match, emits an alert with the rule name, MITRE technique, and the matching log line&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The tricky part is Sigma's detection syntax — it supports wildcards, regex, contains-all, contains-any, logical AND/OR between selection groups, and negation. Getting the evaluator right is what the 138-test suite is mostly for. If you're building anything similar, test-drive it hard. Edge cases around &lt;code&gt;null&lt;/code&gt;, case sensitivity, and list vs scalar matching will eat you alive otherwise.&lt;/p&gt;

&lt;p&gt;Here's what a PowerShell detection rule looks like after conversion to Splunk SPL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;index=windows EventCode=1 CommandLine="*powershell*"
AND (CommandLine="*-ep bypass*" OR CommandLine="*DownloadString*")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That SPL query came from a Sigma YAML that looks roughly like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;detection&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selection&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;EventID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
    &lt;span class="na"&gt;CommandLine|contains&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;powershell'&lt;/span&gt;
  &lt;span class="na"&gt;suspicious&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;CommandLine|contains&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;-ep&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;bypass'&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;DownloadString'&lt;/span&gt;
  &lt;span class="na"&gt;condition&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;selection and suspicious&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One source of truth, three SIEM backends. That's the whole point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install and try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/TiltedLunar123/SIEMForge.git
&lt;span class="nb"&gt;cd &lt;/span&gt;SIEMForge
pip &lt;span class="nb"&gt;install &lt;/span&gt;pyyaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scan a log file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; siemforge &lt;span class="nt"&gt;--scan&lt;/span&gt; samples/suspicious_powershell.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Convert a rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; siemforge &lt;span class="nt"&gt;--convert&lt;/span&gt; splunk rules/sigma/proc_creation_suspicious_powershell.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Print MITRE coverage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; siemforge &lt;span class="nt"&gt;--mitre&lt;/span&gt; rules/sigma/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;v3.1 expanded the test suite, added Windows CI matrix testing, and fixed a Sigma spec edge case around SSH bruteforce detection. Next up: more rules covering cloud attack paths (T1078.004, T1580), a proper detection-as-code pipeline example, and maybe a web UI for the coverage matrix if there's demand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Call to action
&lt;/h2&gt;

&lt;p&gt;If you're studying blue team, running a home lab, or just want a way to prototype detections without spinning up a full SIEM — clone it, break it, open an issue. Stars help the project surface to more people who'd benefit.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/TiltedLunar123/SIEMForge" rel="noopener noreferrer"&gt;&lt;strong&gt;Star SIEMForge on GitHub →&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you'd build something with it, tell me what rule you'd write first.&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>python</category>
      <category>showdev</category>
      <category>tooling</category>
    </item>
    <item>
      <title>I Built an Offline Threat-Hunting CLI in Python — Here's How ThreatLens Catches Real Attacks</title>
      <dc:creator>Jude Hilgendorf</dc:creator>
      <pubDate>Fri, 17 Apr 2026 15:53:01 +0000</pubDate>
      <link>https://dev.to/tiltedlunar123/i-built-an-offline-threat-hunting-cli-in-python-heres-how-threatlens-catches-real-attacks-53jc</link>
      <guid>https://dev.to/tiltedlunar123/i-built-an-offline-threat-hunting-cli-in-python-heres-how-threatlens-catches-real-attacks-53jc</guid>
      <description>&lt;p&gt;If you've ever stared down thousands of EVTX, Syslog, or JSON log events after a suspected incident, you already know the pain: the signal-to-noise ratio is brutal, and most "SIEM-lite" tools either want a cloud subscription or a 20-step install.&lt;/p&gt;

&lt;p&gt;I wanted something different — a single CLI I could drop on an air-gapped workstation, point at a folder of logs, and get a ranked list of attack patterns mapped to MITRE ATT&amp;amp;CK within seconds.&lt;/p&gt;

&lt;p&gt;That's &lt;strong&gt;&lt;a href="https://github.com/TiltedLunar123/ThreatLens" rel="noopener noreferrer"&gt;ThreatLens&lt;/a&gt;&lt;/strong&gt; — an offline log analysis and threat hunting CLI written in Python. No agents, no cloud, no license server. Just &lt;code&gt;pip install&lt;/code&gt;, &lt;code&gt;scan&lt;/code&gt;, read the report.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually does
&lt;/h2&gt;

&lt;p&gt;ThreatLens parses security logs and runs them through a detection engine that covers &lt;strong&gt;12 attack categories mapped to MITRE ATT&amp;amp;CK tactics&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Brute-force and password spray attacks (T1110)&lt;/li&gt;
&lt;li&gt;Lateral movement via rapid multi-host authentication (T1021)&lt;/li&gt;
&lt;li&gt;Privilege escalation through sensitive permission assignments (T1134)&lt;/li&gt;
&lt;li&gt;Suspicious process execution including LOLBins and encoded PowerShell (T1059)&lt;/li&gt;
&lt;li&gt;Defense evasion patterns like log clearing and audit policy changes (T1070, T1562)&lt;/li&gt;
&lt;li&gt;Persistence mechanisms via services, scheduled tasks, and registry modifications (T1543, T1053, T1547)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It ingests JSON/NDJSON, native EVTX, RFC 3164/5424 Syslog, and CEF — with auto-detection, so you don't have to flag formats manually.&lt;/p&gt;

&lt;p&gt;Exports: color-coded terminal, JSON, CSV, self-contained HTML reports with SVG charts and an interactive attack timeline, or direct Elasticsearch ingestion if you already have a stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I built it
&lt;/h2&gt;

&lt;p&gt;I'm a cybersecurity student who spends a lot of lab time on blue-team exercises. Every time I finished a scenario, I'd end up with a mountain of Windows Event Logs and no fast way to confirm what actually happened. Commercial tools were overkill for a lab, and open-source options usually needed a full ELK stack before they'd tell me anything useful.&lt;/p&gt;

&lt;p&gt;I wanted a tool that answered one question fast: &lt;em&gt;"Is there anything interesting in this pile of logs?"&lt;/em&gt; — without spinning up infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code walkthrough: the detection engine
&lt;/h2&gt;

&lt;p&gt;The core idea is a three-layer detection model:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Built-in detections&lt;/strong&gt; tuned via &lt;code&gt;rules/default_rules.yaml&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom YAML rules&lt;/strong&gt; — 12 operators (equals, contains, regex, gt/lt, etc.) with grouping and time windows&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sigma rules&lt;/strong&gt; — natively compatible with community Sigma repos, with field modifiers and compound conditions&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's a custom rule that detects 5+ failed logons from the same source in under 60 seconds — classic brute-force:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;brute_force_failed_logons&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Multiple failed logon attempts from single source&lt;/span&gt;
&lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;high&lt;/span&gt;
&lt;span class="na"&gt;mitre&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;T1110&lt;/span&gt;
&lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;all&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;field&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;event_id&lt;/span&gt;
      &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;equals&lt;/span&gt;
      &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4625&lt;/span&gt;
  &lt;span class="na"&gt;group_by&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;source_ip&lt;/span&gt;
  &lt;span class="na"&gt;window&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;60s&lt;/span&gt;
  &lt;span class="na"&gt;threshold&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;op&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gt&lt;/span&gt;
    &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule engine normalizes incoming events (EVTX → dict, Syslog → dict, etc.), pipes them through each loaded rule, and any match pushes an alert into a correlation stage that links multi-stage kill chains. So a brute-force hit followed by a privileged logon from the same IP a minute later gets stitched into one chain, not two disconnected alerts.&lt;/p&gt;

&lt;p&gt;That correlation step is what makes the output usable — instead of 400 alerts you get 8 attack chains ranked by severity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using it
&lt;/h2&gt;

&lt;p&gt;Quick install from source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/TiltedLunar123/ThreatLens.git
&lt;span class="nb"&gt;cd &lt;/span&gt;ThreatLens
python &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;".[dev]"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or Docker if you want zero local install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;-t&lt;/span&gt; threatlens &lt;span class="nb"&gt;.&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;/logs:/data threatlens scan /data/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then scan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;threatlens scan sample_data/sample_security_log.json
threatlens scan logs/ &lt;span class="nt"&gt;--min-severity&lt;/span&gt; high &lt;span class="nt"&gt;-o&lt;/span&gt; report.json &lt;span class="nt"&gt;-f&lt;/span&gt; json
threatlens scan logs/ &lt;span class="nt"&gt;--custom-rules&lt;/span&gt; my_rules/ &lt;span class="nt"&gt;--sigma-rules&lt;/span&gt; sigma/rules/
threatlens follow /var/log/events.json &lt;span class="nt"&gt;--buffer-size&lt;/span&gt; 50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last one is a &lt;code&gt;tail -f&lt;/code&gt;-style live monitor with a ring buffer for in-memory correlation — handy if you're hunting during an active incident instead of after the fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results so far
&lt;/h2&gt;

&lt;p&gt;Against sample datasets I baked in: zero false positives on benign activity and 100% detection across the embedded techniques. Worth the asterisk that this is my test corpus — I'd love more folks running it against real logs and opening issues.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;On the roadmap: a built-in rule marketplace, richer EVTX field extraction, and a lightweight Windows service wrapper for continuous monitoring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;If you're a SOC analyst, student, or homelabber who wants a dead-simple offline triage tool, give it a run. If it helps, a ⭐ on the repo means a lot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/TiltedLunar123/ThreatLens" rel="noopener noreferrer"&gt;github.com/TiltedLunar123/ThreatLens&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;PRs, issues, and detection rule contributions welcome.&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>python</category>
      <category>opensource</category>
      <category>infosec</category>
    </item>
    <item>
      <title>I Built an Offline Threat Hunting CLI That Parses EVTX, JSON, Syslog &amp; CEF Logs</title>
      <dc:creator>Jude Hilgendorf</dc:creator>
      <pubDate>Thu, 16 Apr 2026 14:37:48 +0000</pubDate>
      <link>https://dev.to/tiltedlunar123/i-built-an-offline-threat-hunting-cli-that-parses-evtx-json-syslog-cef-logs-p6f</link>
      <guid>https://dev.to/tiltedlunar123/i-built-an-offline-threat-hunting-cli-that-parses-evtx-json-syslog-cef-logs-p6f</guid>
      <description>&lt;p&gt;If you've ever worked in a SOC or done any incident response, you know the pain: you've got a pile of log files from a compromised host, and you need answers fast. Maybe you don't have Splunk access. Maybe the SIEM is down. Maybe you're on a plane with a laptop full of EVTX files and zero internet.&lt;/p&gt;

&lt;p&gt;That's why I built &lt;strong&gt;ThreatLens&lt;/strong&gt; — a Python CLI that does offline log analysis and threat hunting without requiring any infrastructure.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ThreatLens Does
&lt;/h2&gt;

&lt;p&gt;ThreatLens ingests logs in multiple formats (EVTX, JSON, NDJSON, Syslog RFC 3164/5424, and CEF), runs them through 12 built-in detection modules, and outputs color-coded alerts mapped to the MITRE ATT&amp;amp;CK framework.&lt;/p&gt;

&lt;p&gt;No Elasticsearch cluster. No cloud account. Just &lt;code&gt;pip install threatlens&lt;/code&gt; and point it at your logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I Built It
&lt;/h2&gt;

&lt;p&gt;I'm a cybersecurity student, and every time I practiced incident response scenarios, I hit the same wall: the tools that do log analysis well are either expensive, require complex deployment, or need network access. I wanted something I could run anywhere — on an air-gapped forensics workstation, in a Docker container during a CTF, or in a CI/CD pipeline for automated security checks.&lt;/p&gt;

&lt;p&gt;So I started building ThreatLens as a learning project, and it grew into something I actually use regularly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Detection Engine: A Code Walkthrough
&lt;/h2&gt;

&lt;p&gt;The part I'm most proud of is the multi-stage attack chain correlation. Most log analysis tools look at events individually. ThreatLens connects the dots.&lt;/p&gt;

&lt;p&gt;Each detection module registers itself with a tactic and technique mapping. When ThreatLens processes a log file, it doesn't just flag individual events — it builds a timeline. If it sees a brute-force login (Initial Access), followed by a suspicious PowerShell execution (Execution), followed by a new scheduled task (Persistence), it correlates those into a potential attack chain and raises the severity.&lt;/p&gt;

&lt;p&gt;The detection modules cover:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Brute-force &amp;amp; password spray&lt;/strong&gt; detection&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lateral movement&lt;/strong&gt; (RDP, PsExec, WMI patterns)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privilege escalation&lt;/strong&gt; monitoring&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LOLBin abuse&lt;/strong&gt; (living-off-the-land binary execution)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encoded PowerShell&lt;/strong&gt; commands&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Defense evasion&lt;/strong&gt; (log clearing, AV tampering)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Persistence mechanisms&lt;/strong&gt; (scheduled tasks, services, registry run keys)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kerberos attacks&lt;/strong&gt; (Kerberoasting, Golden Ticket indicators)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data exfiltration&lt;/strong&gt; patterns&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Multi-stage attack chain correlation&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each module uses a YAML-configurable rule format with 12 operators, so you can write custom rules without touching Python code. And if you already have Sigma rules from your team, ThreatLens supports those natively too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Start
&lt;/h2&gt;

&lt;p&gt;Install from PyPI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;threatlens
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scan a single log file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;threatlens scan /path/to/security.evtx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scan a directory recursively with HTML report output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;threatlens scan /var/log/ &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; report.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run with custom Sigma rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;threatlens scan logs/ &lt;span class="nt"&gt;--sigma-rules&lt;/span&gt; ./my-rules/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tail logs in real-time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;threatlens &lt;span class="nb"&gt;tail&lt;/span&gt; /var/log/syslog &lt;span class="nt"&gt;--severity&lt;/span&gt; high
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Push alerts to Elasticsearch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;threatlens scan logs/ &lt;span class="nt"&gt;--es-host&lt;/span&gt; https://elastic:9200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Output Formats
&lt;/h2&gt;

&lt;p&gt;ThreatLens gives you flexibility in how you consume results:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Terminal&lt;/strong&gt;: Color-coded severity with MITRE technique IDs inline&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSON/CSV&lt;/strong&gt;: For piping into other tools or importing to spreadsheets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTML&lt;/strong&gt;: Self-contained reports with charts and timelines&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Elasticsearch&lt;/strong&gt;: Push alerts directly for dashboarding&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  CI/CD Integration
&lt;/h2&gt;

&lt;p&gt;One underrated use case: drop ThreatLens into your pipeline to scan application logs for security events. It returns proper exit codes, so you can fail a build if high-severity detections fire.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# GitHub Actions example&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Security log check&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;threatlens scan ./logs/ --severity high --exit-code&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;I'm working on expanding the detection library, improving the EVTX parser performance, and adding a plugin marketplace where the community can share detection modules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It Out
&lt;/h2&gt;

&lt;p&gt;The repo is at &lt;a href="https://github.com/TiltedLunar123/ThreatLens" rel="noopener noreferrer"&gt;github.com/TiltedLunar123/ThreatLens&lt;/a&gt;. If it's useful to you, a star helps a lot with visibility. Issues and PRs are welcome — especially new detection rules.&lt;/p&gt;

&lt;p&gt;If you're a SOC analyst, threat hunter, or IR responder, I'd love to hear what detections you'd want added. Drop a comment below.&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>python</category>
      <category>opensource</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
