<?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: TiltedLunar123</title>
    <description>The latest articles on DEV Community by TiltedLunar123 (@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: TiltedLunar123</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>My Windows audit tool flagged rundll32 as suspicious. It was right, and useless.</title>
      <dc:creator>TiltedLunar123</dc:creator>
      <pubDate>Mon, 01 Jun 2026 09:14:38 +0000</pubDate>
      <link>https://dev.to/tiltedlunar123/my-windows-audit-tool-flagged-rundll32-as-suspicious-it-was-right-and-useless-1bp9</link>
      <guid>https://dev.to/tiltedlunar123/my-windows-audit-tool-flagged-rundll32-as-suspicious-it-was-right-and-useless-1bp9</guid>
      <description>&lt;p&gt;I built a thing called WinRecon. it's a python script that audits a windows box and hands you back a security score. 20 checks, runs on the standard library only, no pip, no internet. you point it at a machine and it tells you the firewall is off on the public profile, smbv1 is still enabled, rdp has network level auth turned off, defender is disabled, that kind of stuff. it writes one self-contained html report and a json file you can feed into a SIEM.&lt;/p&gt;

&lt;p&gt;the check i spent the most time on reads scheduled tasks and startup entries and tries to spot an attacker living off the land. encoded powershell, certutil pulling down a file, regsvr32 running a remote scriptlet, bitsadmin, msiexec, the usual lolbin crowd. the idea was simple. attackers reuse the same handful of signed binaries, so just scan the command lines for those keywords.&lt;/p&gt;

&lt;p&gt;first time i ran it on my own laptop it threw four criticals. every one of them was rundll32 or certutil. none of them were malware.&lt;/p&gt;

&lt;h2&gt;
  
  
  the keyword scanner was technically correct
&lt;/h2&gt;

&lt;p&gt;here's roughly what the first version did. i had a flat list of bad strings and i checked if any of them showed up in the task's command line.&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="n"&gt;SUSPICIOUS&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;-enc&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;-encodedcommand&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;frombase64string&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;bypass&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;certutil&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;bitsadmin&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;regsvr32&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;rundll32&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;msiexec&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;invoke-expression&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;downloadstring&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;ngrok.io&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;raw.githubusercontent&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;pastebin.com&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;def&lt;/span&gt; &lt;span class="nf"&gt;scan_command&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;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;SUSPICIOUS&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&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;hits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Finding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CRITICAL&lt;/span&gt;&lt;span class="sh"&gt;"&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;suspicious task: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hits&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="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the problem is that windows ships with a pile of scheduled tasks that legitimately call rundll32. there's one that runs &lt;code&gt;rundll32.exe advpack.dll,DelNodeRunDLL32&lt;/code&gt;. there's printer stuff, there's a microsoft compatibility appraiser task. certutil shows up in cert maintenance. so the scanner was right that the binary was there. it just had no idea whether the binary was doing something bad.&lt;/p&gt;

&lt;p&gt;that's the actual hard part of lolbin detection and i'd basically skipped it. presence of certutil isn't the signal. certutil reaching out to a url is the signal. rundll32 loading a dll out of &lt;code&gt;%temp%&lt;/code&gt; is the signal. rundll32 firing off a signed microsoft task is just tuesday.&lt;/p&gt;

&lt;h2&gt;
  
  
  what i changed
&lt;/h2&gt;

&lt;p&gt;i stopped treating the keyword list as one bucket. i split it by how much the match actually tells you.&lt;/p&gt;

&lt;p&gt;a bare lolbin name on its own is weak. it only gets interesting when it's paired with something else. so a hit became critical only if the binary keyword showed up &lt;em&gt;with&lt;/em&gt; a second-stage indicator, like a url, a base64 blob, &lt;code&gt;-windowstyle hidden&lt;/code&gt;, or a path that points at a temp or appdata directory. a lolbin by itself with none of that drops down to INFO, which in the report means "here, look at this, but i'm not going to scare you about it."&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="n"&gt;STAGE2&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;http://&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;https://&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;-enc&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;frombase64string&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;-windowstyle hidden&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;%temp%&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;%appdata%&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;downloadstring&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;scan_command&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;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;lol&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;LOLBINS&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;c&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;lol&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="n"&gt;stage2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;STAGE2&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;c&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;stage2&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Finding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CRITICAL&lt;/span&gt;&lt;span class="sh"&gt;"&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;lol&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; with &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;stage2&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="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Finding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INFO&lt;/span&gt;&lt;span class="sh"&gt;"&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;lol&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; present, no second-stage indicators&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;after that, my laptop went from four criticals to zero, with a handful of INFO notes for the microsoft tasks i now know are fine. and the one time i tested it against a fake task that ran &lt;code&gt;powershell -enc &amp;lt;base64&amp;gt;&lt;/code&gt; out of appdata, it lit up critical like it should.&lt;/p&gt;

&lt;p&gt;it's still keyword matching. i want to be honest about that. it doesn't parse the command line into a real argument tree, it doesn't follow what the dll actually does, and a half-clever attacker who renames their payload path or splits the command can walk right past it. it's a tripwire, not a verdict. for a tier 1 "is anything obviously wrong on this box" pass that's about the right altitude, but i wouldn't call it detection.&lt;/p&gt;

&lt;h2&gt;
  
  
  the part i'm actually happy with
&lt;/h2&gt;

&lt;p&gt;the constraint that shaped the whole project was no dependencies. it had to run on a locked-down windows machine with no pip and no outbound internet, because that's the machine you actually want to audit. so everything is &lt;code&gt;subprocess&lt;/code&gt; against built-in windows commands and stdlib parsing. &lt;code&gt;netsh advfirewall&lt;/code&gt; for the firewall, &lt;code&gt;net user&lt;/code&gt; and &lt;code&gt;net localgroup&lt;/code&gt; for accounts, the registry for the powershell logging and uac settings.&lt;/p&gt;

&lt;p&gt;that sounds annoying and it sort of was, but it means you can drop a single .py file on a fresh box and run it. no install step, nothing to flag, nothing to phone home. the report is one html file with the css inlined so it opens with no network either.&lt;/p&gt;

&lt;p&gt;scoring is deliberately dumb. you start at 100, every critical costs 20, every warning costs 10, and you land on an A through F grade. i went back and forth on weighting findings more cleverly and decided against it. a crude score that a hiring manager or a non-security person can read in two seconds beats a precise one nobody trusts. exit code is 2 if anything critical fired, so you can wire it into a pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  what's next / what's broken
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;the lolbin scanner still can't tell a renamed binary or a split command from a clean one. real argument parsing is the obvious next step.&lt;/li&gt;
&lt;li&gt;no native event log correlation yet. it checks that the event log service is healthy but doesn't read the logs.&lt;/li&gt;
&lt;li&gt;the roadmap has compliance mapping (CIS, NIST), but i didn't want to claim a mapping i hadn't actually verified against the benchmark text, so it's not in there yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;repo's here if you want to poke at it or tell me where the detection logic is naive: &lt;a href="https://github.com/TiltedLunar123/WinRecon" rel="noopener noreferrer"&gt;https://github.com/TiltedLunar123/WinRecon&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;it's MIT, and it's for boxes you're allowed to audit. it works. not perfect, but it works.&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>python</category>
      <category>windows</category>
      <category>blueteam</category>
    </item>
    <item>
      <title>I let the AI write the report, not decide the alerts</title>
      <dc:creator>TiltedLunar123</dc:creator>
      <pubDate>Sun, 31 May 2026 10:45:18 +0000</pubDate>
      <link>https://dev.to/tiltedlunar123/i-let-the-ai-write-the-report-not-decide-the-alerts-593o</link>
      <guid>https://dev.to/tiltedlunar123/i-let-the-ai-write-the-report-not-decide-the-alerts-593o</guid>
      <description>&lt;p&gt;I've been building a SOC triage tool called TriageLens, and the whole thing started from one annoyance. Every "AI security analyst" demo I tried was just a chatbot with a log pasted into the prompt. Ask it twice, get two different verdicts. For triage that's useless. If the tool says "brute force, critical" one run and "looks fine" the next, I can't trust either answer.&lt;/p&gt;

&lt;p&gt;So I drew a hard line early. The AI doesn't get to decide what's a finding. It only gets to write the finding up.&lt;/p&gt;

&lt;h2&gt;
  
  
  the split
&lt;/h2&gt;

&lt;p&gt;Parsing, detection, and risk scoring are plain TypeScript. No model involved. The pipeline normalizes Windows Security 4688, Sysmon Event 1, Linux SSH &lt;code&gt;auth.log&lt;/code&gt;, and generic JSON into one event shape, runs a list of detection rules over those events, and scores the result 0-100. All deterministic. Same logs in, same findings out, every time.&lt;/p&gt;

&lt;p&gt;The AI layer sits at the very end. It takes the structured findings that already exist and turns them into analyst-style prose: a summary, per-finding notes, prioritized next steps. If I swap the provider from the built-in demo one to Ollama to Claude, the findings and the MITRE mapping don't move at all. Only the wording changes.&lt;/p&gt;

&lt;p&gt;That property is the part I actually care about. The detections are auditable. The model is just the writer.&lt;/p&gt;

&lt;h2&gt;
  
  
  a rule is just a function
&lt;/h2&gt;

&lt;p&gt;Each detection rule is a pure function that looks at the events and returns evidence strings. Empty array means it didn't fire. Here's the one I'm happiest with, the chained one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;successful-auth-after-brute-force&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Successful login after brute-force activity&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;critical&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;techniques&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;techniques&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;T1110&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;T1078&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nx"&gt;detect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&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;failsByIp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;countFailuresByIp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="k"&gt;for &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;e&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;)&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auth-success&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
        &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sourceIp&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;failsByIp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sourceIp&lt;/span&gt;&lt;span class="p"&gt;]&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="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="s2"&gt;`Successful login for "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;" from &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sourceIp&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; after &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;failsByIp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sourceIp&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt; failures`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&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;return&lt;/span&gt; &lt;span class="nx"&gt;evidence&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;On its own, a pile of failed SSH logons is just noise. Lots of hosts get sprayed all day. What changes the picture is a success from the same IP that just failed a bunch. The brute-force rule alone is &lt;code&gt;high&lt;/code&gt;. The success-after-brute-force rule is &lt;code&gt;critical&lt;/code&gt;, mapped to T1110 and T1078, because at that point you're probably looking at a real compromise, not background scanning.&lt;/p&gt;

&lt;p&gt;Writing it as a plain function means I can unit test it with a handful of fake events and know it fires on exactly the case I want. No prompt tuning, no "please respond in JSON." &lt;code&gt;countFailuresByIp&lt;/code&gt; is about six lines and counts &lt;code&gt;auth-failure&lt;/code&gt; events per source IP. The whole rule file reads top to bottom like a checklist.&lt;/p&gt;

&lt;h2&gt;
  
  
  what I tried first and dropped
&lt;/h2&gt;

&lt;p&gt;My first version actually did hand the raw logs to the model and ask it to return findings as JSON. It worked in the demo and fell apart the moment I fed it anything weird. Sometimes it invented an event ID that wasn't in the log. Once it confidently flagged a normal &lt;code&gt;svchost&lt;/code&gt; as a LOLBin. And the JSON would occasionally come back with a trailing comment or markdown fence that broke the parser.&lt;/p&gt;

&lt;p&gt;I spent a day trying to prompt my way out of that and then gave up on the approach entirely. Moving detection into code wasn't a performance decision, it was a "I need to be able to trust this" decision. The model is great at writing the summary. It's bad at being the source of truth.&lt;/p&gt;

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

&lt;p&gt;The honest part. It only reads EVTX if you've already exported it to JSON. There's no native &lt;code&gt;.evtx&lt;/code&gt; binary parser yet, which is the next thing on the roadmap, and right now that export step is an annoying manual hop. The rule set is small, seven rules, so it catches the obvious stuff (encoded PowerShell, Office spawning a child process, log clearing, the SSH chain) and misses plenty. I want Sigma import so I'm not the only one writing detections in my own format.&lt;/p&gt;

&lt;p&gt;It also isn't a SIEM and I'm not pretending it is. It's a learning project and a triage aid. It does not replace tuned detection content or a human deciding what matters.&lt;/p&gt;

&lt;p&gt;It runs with zero setup though. &lt;code&gt;npm install&lt;/code&gt;, &lt;code&gt;npm run dev&lt;/code&gt;, a sample log is already loaded, click Analyze. The default provider needs no API key, so you can see the whole loop without signing up for anything.&lt;/p&gt;

&lt;p&gt;Repo's here if you want to poke at it or tell me which rule is wrong: &lt;a href="https://github.com/TiltedLunar123/triagelens" rel="noopener noreferrer"&gt;https://github.com/TiltedLunar123/triagelens&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Built with React, TypeScript, Vite, and vitest for the rule tests. Happy to take detection ideas, that's the part I most want to grow.&lt;/p&gt;

</description>
      <category>cybersecurity</category>
      <category>typescript</category>
      <category>react</category>
      <category>ai</category>
    </item>
    <item>
      <title>The VirtualBox settings I had to turn off before shipping a Whonix installer</title>
      <dc:creator>TiltedLunar123</dc:creator>
      <pubDate>Fri, 29 May 2026 09:32:24 +0000</pubDate>
      <link>https://dev.to/tiltedlunar123/the-virtualbox-settings-i-had-to-turn-off-before-shipping-a-whonix-installer-5fl2</link>
      <guid>https://dev.to/tiltedlunar123/the-virtualbox-settings-i-had-to-turn-off-before-shipping-a-whonix-installer-5fl2</guid>
      <description>&lt;p&gt;Whonix is a pair of linux VMs that route all your traffic through Tor. One VM (gateway) does tor. The other (workstation) has no direct internet at all, only a private adapter that connects to the gateway. If something in the workstation gets compromised, it still can't see your real IP, because it doesn't have a path to it.&lt;/p&gt;

&lt;p&gt;That gateway/workstation isolation is the whole pitch and it works. The part people don't talk about as much is that the workstation VM itself has a bunch of communication channels back to the host machine, and those channels are not protected by the tor isolation at all. They're configured in VirtualBox, and VirtualBox defaults assume you want a usable desktop, not an isolated one.&lt;/p&gt;

&lt;p&gt;I built a powershell installer for Whonix on windows. The first version downloaded the OVA, imported it, started the gateway, started the workstation. Done. I opened the workstation settings in the VirtualBox GUI to take a screenshot for the README, and saw this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Audio: enabled, PulseAudio driver&lt;/li&gt;
&lt;li&gt;Shared clipboard: bidirectional&lt;/li&gt;
&lt;li&gt;Drag and drop: bidirectional&lt;/li&gt;
&lt;li&gt;USB controller: enabled (USB 2.0 OHCI/EHCI)&lt;/li&gt;
&lt;li&gt;3D acceleration: enabled&lt;/li&gt;
&lt;li&gt;Remote display: enabled on port 3389&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a privacy VM, every one of those is a problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clipboard and drag-and-drop
&lt;/h2&gt;

&lt;p&gt;Bidirectional clipboard means anything copied on the host shows up in the workstation, and anything copied in the workstation shows up on the host. If you're using Whonix to do something you don't want associated with your real identity, and you have a password manager on the host that auto-pulls clipboard, you've crossed the boundary in two directions.&lt;/p&gt;

&lt;p&gt;Drag-and-drop is the same thing for files. Either disable both or set them to one direction. I default to off:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;VBoxManage&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;modifyvm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Whonix-Workstation-Xfce"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--clipboard-mode&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;disabled&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;VBoxManage&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;modifyvm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Whonix-Workstation-Xfce"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--draganddrop&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;disabled&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Audio
&lt;/h2&gt;

&lt;p&gt;PulseAudio in a privacy VM is just noise (literal and figurative). The audio device gets a name from the host config, which can be a fingerprintable string. Even ignoring fingerprinting, you almost never want sound out of a tor-routed VM.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;VBoxManage&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;modifyvm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Whonix-Workstation-Xfce"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--audio-driver&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;none&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  USB controller
&lt;/h2&gt;

&lt;p&gt;USB passthrough lets the guest see USB devices on the host. Plug in a YubiKey, the guest can read serial number, vendor ID, product ID. Same with USB drives, webcams, phones. None of that should be reachable from a workstation that's supposed to be isolated.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;VBoxManage&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;modifyvm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Whonix-Workstation-Xfce"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--usb-ohci&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;off&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--usb-ehci&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;off&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--usb-xhci&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;off&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3D acceleration
&lt;/h2&gt;

&lt;p&gt;3D accel routes guest graphics calls through the host GPU driver. The history of VM escapes through 3D drivers is long enough that it's the first thing to turn off on any VM you actually care about. For a workstation running tor browser and a text editor, you don't need it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;VBoxManage&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;modifyvm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Whonix-Workstation-Xfce"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--accelerate3d&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;off&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Remote display
&lt;/h2&gt;

&lt;p&gt;Default-on RDP inside a VM that's supposed to be isolated. Bound to localhost by default, but it's still a service running that the workstation has no reason to expose.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;VBoxManage&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;modifyvm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Whonix-Workstation-Xfce"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--vrde&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;off&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I had to learn the hard way
&lt;/h2&gt;

&lt;p&gt;Two things tripped me up writing this.&lt;/p&gt;

&lt;p&gt;First, you can't apply most of these settings while the VM is running. VBoxManage gives you a polite error and exits. The installer order matters: import the OVA, configure with VM stopped, then start. I had import-then-start before I added the configure step, and the script ran without errors but quietly never applied any hardening.&lt;/p&gt;

&lt;p&gt;Second, VirtualBox has both &lt;code&gt;--clipboard-mode&lt;/code&gt; (newer) and &lt;code&gt;--clipboard&lt;/code&gt; (older). Depending on which version is installed, one of them throws an unknown option error. I pin VirtualBox to a known version in the installer to dodge this, but it bit me on a friend's machine that had an old 6.x version laying around from a previous install.&lt;/p&gt;

&lt;p&gt;The installer also does SHA-512 verification of the OVA, and there's an optional flag to pin the VirtualBox installer hash. Different post. If you trust the OS image but not the hypervisor binary, your supply chain story has a hole in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What still bugs me
&lt;/h2&gt;

&lt;p&gt;The big one: clipboard fully off is annoying. If someone uses Whonix as a daily-driver browsing VM, they want to copy URLs in and out. The right call is probably host-to-guest only (you can paste in, the workstation can't push back to the host). I haven't shipped that change because picking a default direction the user can't easily fight is its own design problem, and I haven't decided which direction wins.&lt;/p&gt;

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

</description>
      <category>powershell</category>
      <category>privacy</category>
      <category>cybersecurity</category>
      <category>virtualization</category>
    </item>
    <item>
      <title>How Canvas LMS tracks tab-switches during quizzes, and a chrome extension to stop it</title>
      <dc:creator>TiltedLunar123</dc:creator>
      <pubDate>Thu, 28 May 2026 09:54:39 +0000</pubDate>
      <link>https://dev.to/tiltedlunar123/how-canvas-lms-tracks-tab-switches-during-quizzes-and-a-chrome-extension-to-stop-it-377p</link>
      <guid>https://dev.to/tiltedlunar123/how-canvas-lms-tracks-tab-switches-during-quizzes-and-a-chrome-extension-to-stop-it-377p</guid>
      <description>&lt;p&gt;a friend told me her professor pulled her aside after a quiz because the LMS flagged her for "tab switching 7 times". she wasn't cheating. she alt-tabbed to check the time on her clock app, then back. seven times over a 50-minute quiz.&lt;/p&gt;

&lt;p&gt;i went looking for what canvas actually sends back when you blur the window. turns out it's pretty noisy.&lt;/p&gt;

&lt;h2&gt;
  
  
  what canvas tracks
&lt;/h2&gt;

&lt;p&gt;open devtools on a quiz page, switch tabs, switch back. you'll see POSTs to something like &lt;code&gt;/api/v1/courses/X/quizzes/Y/submissions/Z/events&lt;/code&gt; with payloads like:&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="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"page_blurred"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"event_data"&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="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1716902400000&lt;/span&gt;&lt;span class="w"&gt; &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="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;then &lt;code&gt;page_focused&lt;/code&gt; when you come back. it also pings on &lt;code&gt;visibilitychange&lt;/code&gt; for good measure, and there's a separate page-view heartbeat that ticks every few seconds.&lt;/p&gt;

&lt;p&gt;the events get attached to your submission. teachers see them in speedgrader as a little timeline. some schools have explicit policies that more than N tab-switches is grounds for "additional scrutiny".&lt;/p&gt;

&lt;h2&gt;
  
  
  first attempt: just block the endpoint
&lt;/h2&gt;

&lt;p&gt;my first idea was a one-line declarativeNetRequest rule blocking &lt;code&gt;*/quiz_submission_events*&lt;/code&gt;. easy. doesn't work.&lt;/p&gt;

&lt;p&gt;why? canvas uses &lt;code&gt;navigator.sendBeacon()&lt;/code&gt; for some of these. beacons queue at the browser level and behave a little differently than fetch when it comes to extension interception. some events were still leaking through. there are also a couple of analytics endpoints that the same event posts to as redundancy, so blocking the obvious URL misses a few.&lt;/p&gt;

&lt;h2&gt;
  
  
  the actual approach
&lt;/h2&gt;

&lt;p&gt;two layers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;layer 1: declarativeNetRequest.&lt;/strong&gt; static rules in &lt;code&gt;rules.json&lt;/code&gt; that block five known endpoints. cheap, fast. browser handles it before any js runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;layer 2: a main-world inject script.&lt;/strong&gt; patches &lt;code&gt;addEventListener&lt;/code&gt; so anything registering for blur/focus/visibilitychange on quiz pages just gets a no-op. patches &lt;code&gt;sendBeacon&lt;/code&gt; to return true without sending. patches &lt;code&gt;fetch&lt;/code&gt; and &lt;code&gt;XMLHttpRequest&lt;/code&gt; to filter the same URL patterns. also overrides &lt;code&gt;document.visibilityState&lt;/code&gt; and &lt;code&gt;document.hidden&lt;/code&gt; so even if a listener slips through, it always reads "visible".&lt;/p&gt;

&lt;p&gt;manifest v3 makes you jump through hoops here. content scripts run in an isolated world by default and can't patch page globals. you need &lt;code&gt;world: "MAIN"&lt;/code&gt; in the content_scripts entry, which is a relatively recent MV3 addition. without it none of the prototype patching works.&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;// snippet from inject.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;origAdd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;EventTarget&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;addEventListener&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;EventTarget&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;addEventListener&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;)&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;BLOCKED_EVENTS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;isQuizPage&lt;/span&gt;&lt;span class="p"&gt;())&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="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;origAdd&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="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;listener&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;opts&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;dumb but it works. canvas's quiz js binds blur/focus exactly once on load, so if you patch addEventListener before their script runs, those listeners never get registered.&lt;/p&gt;

&lt;h2&gt;
  
  
  tier system
&lt;/h2&gt;

&lt;p&gt;i ended up with three settings because heavy mode broke a few classes that legitimately use page-view tracking for participation grades.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;lite&lt;/strong&gt;: blocks the quiz_events endpoint and drops blur/focus listeners. minimal footprint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mod&lt;/strong&gt;: adds beacon/heartbeat blocking and stubs sendBeacon.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;heavy&lt;/strong&gt; (default): everything above plus visibility spoofing and full fetch/XHR interception.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;per-domain allowlist. doesn't activate unless you've explicitly added the school's canvas instance.&lt;/p&gt;

&lt;h2&gt;
  
  
  what it can't do
&lt;/h2&gt;

&lt;p&gt;proctoring software (respondus lockdown, proctorio, honorlock) is outside the browser sandbox. they hook the OS-level focus events through native code. nothing a chrome extension can touch.&lt;/p&gt;

&lt;p&gt;server-side page views are also unblockable. canvas logs an HTTP request every time it serves a page. if you load the quiz, that's logged. you can stop the blur tracking but not "they opened the quiz at 3:47pm".&lt;/p&gt;

&lt;h2&gt;
  
  
  what i'm not happy with yet
&lt;/h2&gt;

&lt;p&gt;the inject script timing is fragile. on slow networks the canvas quiz js sometimes runs before my patches apply, and then a blur event leaks through. i've seen it twice in 50-ish tests. moving the prototype patching to a &lt;code&gt;run_at: document_start&lt;/code&gt; content script in a separate file would close that gap. on the todo list.&lt;/p&gt;

&lt;p&gt;DNR's five-rule cap on static rulesets is also annoying. dynamic rules would let me add more endpoints based on what i see in network logs, but then i need storage permission and the security review for store distribution gets harder. for now i'm shipping unpacked from github.&lt;/p&gt;

&lt;h2&gt;
  
  
  privacy note
&lt;/h2&gt;

&lt;p&gt;doesn't send anything anywhere. no analytics, no crash reports, no remote config. the only network requests it makes are the ones it's blocking.&lt;/p&gt;

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

&lt;p&gt;if you want to see what canvas actually tracks before installing anything, open devtools on a quiz, switch tabs a few times, and watch the network panel. it's all there in cleartext.&lt;/p&gt;

</description>
      <category>chrome</category>
      <category>privacy</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My log triage tool is slower than Chainsaw, and I shipped it anyway</title>
      <dc:creator>TiltedLunar123</dc:creator>
      <pubDate>Wed, 27 May 2026 09:13:04 +0000</pubDate>
      <link>https://dev.to/tiltedlunar123/my-log-triage-tool-is-slower-than-chainsaw-and-i-shipped-it-anyway-dd1</link>
      <guid>https://dev.to/tiltedlunar123/my-log-triage-tool-is-slower-than-chainsaw-and-i-shipped-it-anyway-dd1</guid>
      <description>&lt;p&gt;i build security tools as a cybersec student, mostly so i actually understand the stuff i'm studying instead of just memorizing it for Security+. ThreatLens started because i got tired of scrolling raw windows event logs looking for the one line that mattered.&lt;/p&gt;

&lt;p&gt;the idea is simple. point it at a folder of logs, it runs detections, it tells you what looks like an attack. offline, no server, no agent. just a CLI.&lt;/p&gt;

&lt;h2&gt;
  
  
  the problem
&lt;/h2&gt;

&lt;p&gt;if you've ever opened a security.json export with 900k events in it you know the feeling. somewhere in there is a brute force, or a service getting installed it shouldn't, or someone dumping creds. but you're not finding it by eye. you need something to do the first pass so you can spend your time investigating instead of reading.&lt;/p&gt;

&lt;p&gt;i wanted that first pass to be local. a lot of the triage stuff assumes you've already shipped everything to a SIEM. i don't always have one running on my own boxes, and i wanted something i could just &lt;code&gt;pip install&lt;/code&gt; and run.&lt;/p&gt;

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

&lt;p&gt;first version just regex'd through lines and counted failed logins. that catches the dumbest brute force and nothing else. real attacks are multi step. someone gets in with valid creds (T1078), runs something (T1059), sets up persistence (T1543), then moves sideways. a single rule never sees the whole picture.&lt;/p&gt;

&lt;p&gt;so the detections became modules, 12 of them, each mapped to a MITRE technique. that part was fine. the part that took longer was correlation. a failed-login spike on its own is noise. a failed-login spike followed by a success followed by a new service on the same host is a story. linking those across detection boundaries is where most of the actual work went.&lt;/p&gt;

&lt;p&gt;one design choice i'm happy with: separating targeted brute force from credential spray. they look similar if you just count failures. but a brute force hammers one account in a burst, and a spray tries one password across many accounts slowly. burst analysis on the timing tells them apart, and they're different investigations, so calling them the same thing would be lying to the analyst.&lt;/p&gt;

&lt;h2&gt;
  
  
  what worked
&lt;/h2&gt;

&lt;p&gt;YAML rules with a handful of operators (equals, contains, regex, threshold) cover most of what i wanted to write without touching python. and it reads Sigma rules directly, which mattered because i didn't want to reinvent a rule format the whole industry already uses. selections, filters, field modifiers, conditions. not the full sigma-rs engine, but enough.&lt;/p&gt;

&lt;p&gt;output goes to terminal, json, csv, or an html report with severity charts and an attack timeline. it can also push to elastic, splunk HEC, wazuh, or spit out an ATT&amp;amp;CK Navigator layer so you can see coverage on the matrix.&lt;/p&gt;

&lt;h2&gt;
  
  
  the numbers
&lt;/h2&gt;

&lt;p&gt;synthetic benchmark, single core, python 3.11 on windows 11:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;9,009 events (2.3 MB): 0.13s, about 69k events/sec&lt;/li&gt;
&lt;li&gt;90,145 events (22.6 MB): 1.27s&lt;/li&gt;
&lt;li&gt;901,341 events (226 MB): 14.24s, about 63k events/sec&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;on the sample dataset it hit every embedded technique and threw zero false positives on the benign activity. small sample, so i'm not going to pretend that's a real detection-rate claim, but it's a start.&lt;/p&gt;

&lt;h2&gt;
  
  
  what's broken / what i'd change
&lt;/h2&gt;

&lt;p&gt;here's the honest part. at 63k events/sec it's slower than Chainsaw and Hayabusa, which are compiled. for a single-core python tool reading EVTX i think that's fine, but if you've got hundreds of millions of events you'd want one of those instead. i'm not pretending to compete on raw speed.&lt;/p&gt;

&lt;p&gt;the parser also wants event IDs and syslog fields mapped to formats it knows. throw it something weird and it'll shrug. that's the next thing i want to fix, a more forgiving field mapper so onboarding a new log source doesn't mean editing code.&lt;/p&gt;

&lt;p&gt;and the false-positive testing needs real ugly data, not synthetic. synthetic logs are too clean. real environments are full of weird-but-benign stuff that trips naive rules, and i won't trust the detection numbers until i've run it against messier input.&lt;/p&gt;

&lt;p&gt;it's defensive only. no remote access, no capture, no exploit anything. just reads logs you already have on systems you're allowed to look at.&lt;/p&gt;

&lt;p&gt;it works. not perfect, but it works, and i learned more about how attacks actually chain together building this than i did from any single chapter of studying.&lt;/p&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 engineering for real, i'd genuinely like to know what log source you'd want supported first.&lt;/p&gt;

</description>
      <category>security</category>
      <category>python</category>
      <category>devops</category>
      <category>opensource</category>
    </item>
    <item>
      <title>shipping an offline log triage cli, and the parser bugs that still haunt me</title>
      <dc:creator>TiltedLunar123</dc:creator>
      <pubDate>Tue, 19 May 2026 11:10:45 +0000</pubDate>
      <link>https://dev.to/tiltedlunar123/shipping-an-offline-log-triage-cli-and-the-parser-bugs-that-still-haunt-me-246m</link>
      <guid>https://dev.to/tiltedlunar123/shipping-an-offline-log-triage-cli-and-the-parser-bugs-that-still-haunt-me-246m</guid>
      <description>&lt;p&gt;a few weeks back i had a 226MB JSON log dump from a lab exercise and absolutely no desire to stand up a full SIEM just to find brute force attempts and lateral movement traces. i tried grep, gave up, tried jq, gave up harder, then ended up writing a python script that snowballed into ThreatLens.&lt;/p&gt;

&lt;p&gt;it's a CLI that does offline triage on security logs. 12 detection modules, sigma rule compat, MITRE ATT&amp;amp;CK mapping, no daemon, no docker, no infra. you point it at a folder, it gives you a report.&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; sigma/rules/ &lt;span class="nt"&gt;--min-severity&lt;/span&gt; high &lt;span class="nt"&gt;-o&lt;/span&gt; report.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;that's it. one command, html report on the other side.&lt;/p&gt;

&lt;h3&gt;
  
  
  what i actually had to solve
&lt;/h3&gt;

&lt;p&gt;the first thing that bit me was EVTX parsing. windows event log binary format is annoyingly underdocumented in places, and the python-evtx library is solid but slow if you use it naive. i was getting around 8k events/sec on a 22MB file which was unusable.&lt;/p&gt;

&lt;p&gt;i ended up streaming records instead of loading them, plus deferring the XML-to-dict conversion until a record actually matched a candidate detector. that pulled the throughput up. on synthetic benchmarks (single-core python 3.11):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;9k events / 2.3 MB: 0.13s, 69.3k events/sec&lt;/li&gt;
&lt;li&gt;90k events / 22.6 MB: 1.27s, 71.0k events/sec&lt;/li&gt;
&lt;li&gt;900k events / 226 MB: 14.24s, 63.3k events/sec&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;it scales pretty flat, which i didn't expect. i thought GC churn or memory pressure would tank the big runs. it didn't.&lt;/p&gt;

&lt;h3&gt;
  
  
  the detector design
&lt;/h3&gt;

&lt;p&gt;there are 12 built-in detection modules and they all implement the same interface. you can write custom ones in python or YAML.&lt;/p&gt;

&lt;p&gt;YAML rule example:&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;suspicious_powershell_encoded&lt;/span&gt;
&lt;span class="na"&gt;event_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;4104&lt;/span&gt;
&lt;span class="na"&gt;match&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;script_block&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;regex&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;(?i)([A-Za-z0-9+/]{50,}={0,2})'&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;user&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;not_equals&lt;/span&gt;
    &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SYSTEM&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;T1059.001&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;twelve operators total: equals, contains, regex, thresholds, time windows, and a few others. sigma rules also work. i implemented selection/filter/condition parsing and most field modifiers. not all of them. the sigma &lt;code&gt;cidr&lt;/code&gt; modifier is half-broken in my impl and i know it. it's on the issue list.&lt;/p&gt;

&lt;h3&gt;
  
  
  multi-stage chain correlation
&lt;/h3&gt;

&lt;p&gt;this is the part i'm actually proud of. instead of just firing alerts per-event, ThreatLens groups events that look like they're part of the same kill chain. brute force, then an interactive logon, then mimikatz-style SAM access. it links them across time windows.&lt;/p&gt;

&lt;p&gt;on a 26-event focused simulation it found 1 CRITICAL, 8 HIGH, 2 MEDIUM, 1 LOW. on a 52-event mixed dataset (benign noise plus embedded attack) it hit zero false positives and 100% detection on the embedded TTPs.&lt;/p&gt;

&lt;p&gt;i don't trust those numbers as a generalization. the corpus is small and i wrote both. but it's enough to say the correlation logic isn't just hallucinating, which is the bar i actually cared about.&lt;/p&gt;

&lt;h3&gt;
  
  
  configuration and CI use
&lt;/h3&gt;

&lt;p&gt;there's a &lt;code&gt;~/.threatlens.yaml&lt;/code&gt; file for defaults so you don't have to repeat flags. CLI overrides config. you can also ship an allowlist.yaml that suppresses known-good alerts, which matters more than it sounds, because as soon as you point a tool like this at real logs you get drowned in legitimate-but-suspicious-looking activity (admin tooling, scheduled tasks, backup agents).&lt;/p&gt;

&lt;p&gt;i ended up adding the allowlist mid-project because i was getting 200 alerts on my own dev box and almost none were real. now they live in YAML and i version-control them per environment.&lt;/p&gt;

&lt;p&gt;there's also a &lt;code&gt;--fail-on&lt;/code&gt; flag that returns exit code 2 if alerts above a threshold fire. dumb little thing, but it means you can wire ThreatLens into a CI step on a log corpus and have it actually break the build if a regression sneaks in.&lt;/p&gt;

&lt;h3&gt;
  
  
  what's broken
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;27 open issues. some of them are real.&lt;/li&gt;
&lt;li&gt;sigma &lt;code&gt;cidr&lt;/code&gt; modifier as mentioned&lt;/li&gt;
&lt;li&gt;the streamlit dashboard exporter occasionally double-counts events when the input is NDJSON with trailing whitespace lines. i thought i fixed it. i didn't.&lt;/li&gt;
&lt;li&gt;the &lt;code&gt;follow&lt;/code&gt; subcommand (real-time tailing) leaks file handles if you ctrl-C during a log rotation event. found this one in the wild. embarrassing.&lt;/li&gt;
&lt;li&gt;EVTX parsing on logs that have been touched by &lt;code&gt;wevtutil epl&lt;/code&gt; sometimes desyncs. i think this is upstream but i haven't proved it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  outputs i ship
&lt;/h3&gt;

&lt;p&gt;it can dump to JSON, CSV, HTML, interactive timelines (one html file with a vis-timeline embed), and push to Elasticsearch, Wazuh, Splunk HEC, ATT&amp;amp;CK Navigator layers, or STIX 2.1 bundles. the navigator output is the one i use most. you scan, you load the layer, and the heatmap of touched techniques is instantly readable.&lt;/p&gt;

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

&lt;p&gt;honestly, i'd write the YAML rule loader first. i wrote the python plugin system first because it was more fun, then bolted YAML on later, and there are seams where the two abstractions don't quite agree. if i rewrote it i'd start at the rule format and make python plugins compile down to the same internal representation.&lt;/p&gt;

&lt;p&gt;also i'd write tests earlier. test coverage is maybe 40%. the correlation logic has decent coverage because i kept breaking it. the output formatters basically have none.&lt;/p&gt;

&lt;h3&gt;
  
  
  repo
&lt;/h3&gt;

&lt;p&gt;&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;MIT licensed. PRs welcome, issues even more welcome. if you triage logs and have an EVTX file that breaks the parser, i would actually love to see it.&lt;/p&gt;

</description>
      <category>cli</category>
      <category>python</category>
      <category>security</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I wrote a PowerShell script to guess my PC's resale value (and learned my depreciation math was wrong)</title>
      <dc:creator>TiltedLunar123</dc:creator>
      <pubDate>Sat, 16 May 2026 09:19:45 +0000</pubDate>
      <link>https://dev.to/tiltedlunar123/i-wrote-a-powershell-script-to-guess-my-pcs-resale-value-and-learned-my-depreciation-math-was-2mh</link>
      <guid>https://dev.to/tiltedlunar123/i-wrote-a-powershell-script-to-guess-my-pcs-resale-value-and-learned-my-depreciation-math-was-2mh</guid>
      <description>&lt;p&gt;Tried selling my old laptop last month. eBay sold listings were all over the place. Same model, same year, prices ranging from $180 to $420. Some had keyboards with missing keys, some were "great condition, no charger." Useless for getting a real number.&lt;/p&gt;

&lt;p&gt;So I built a script. It pulls hardware info out of WMI, looks up parts in a local pricing database, and spits out an estimate. No admin needed. No internet required for the basic run.&lt;/p&gt;

&lt;p&gt;Here's where I got it wrong the first time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive version
&lt;/h2&gt;

&lt;p&gt;My first depreciation model was straight linear. Drop 20% the first year, 15% the next, and so on. Simple. Wrong.&lt;/p&gt;

&lt;p&gt;When I tested it against actual sold listings, the script came in 25-40% high on anything older than 2 years. Linear decay overstates value because hardware doesn't lose worth on a flat schedule. It loses worth fast in year one (you opened the box, congrats) then slower after that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I switched to
&lt;/h2&gt;

&lt;p&gt;Multiplicative decay. Year 1 retains 70% of original. Year 2 multiplies that by 0.80. Year 3 by 0.85. Floor at 15% so you never get $0 for a working machine.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="kr"&gt;function&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Get-DepreciationMultiplier&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;param&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$Years&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$Years&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-le&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&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="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.70&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$Years&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-ge&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&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="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.80&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$Years&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-ge&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&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="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.85&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-le&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$Years&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="o"&gt;++&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="w"&gt;
        &lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.88&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="kr"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;Max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.15&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="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 3-year-old system lands at roughly 48% of its original component total. That tracked with what I was actually seeing on completed eBay sales.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hardware detection
&lt;/h2&gt;

&lt;p&gt;WMI does most of the work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$cpu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Get-CimInstance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Win32_Processor&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Select-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-First&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$gpu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Get-CimInstance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Win32_VideoController&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Where-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$_&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AdapterRAM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-gt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$ram&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Get-CimInstance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Win32_PhysicalMemory&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Measure-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Capacity&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Sum&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Sum&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;1GB&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Battery is its own thing on laptops. &lt;code&gt;Get-WmiObject -Class BatteryStatus -Namespace root\WMI&lt;/code&gt; gives you charge state but not health. For health I parse the output of &lt;code&gt;powercfg /batteryreport&lt;/code&gt; which writes an HTML file with design capacity and full charge capacity. Health percentage is the ratio.&lt;/p&gt;

&lt;p&gt;That HTML parse is the ugliest part of the script. I used regex against the report because pulling in HtmlAgilityPack felt like overkill for two numbers. It works. If Microsoft changes the report format I'll find out the hard way.&lt;/p&gt;

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

&lt;p&gt;Figuring out how old the machine is turned out to be harder than I expected. WMI doesn't expose a clean "manufactured on" date. I tried three sources:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;BIOS release date from &lt;code&gt;Win32_BIOS.ReleaseDate&lt;/code&gt;. Sometimes accurate, sometimes the BIOS was updated and the date is post-manufacture.&lt;/li&gt;
&lt;li&gt;OS install date. Useful only if the original owner never reinstalled.&lt;/li&gt;
&lt;li&gt;SMBIOS table parsing. Most accurate. Most annoying to do from PowerShell.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I went with BIOS date as the primary, OS install date as a fallback if the BIOS date is missing or clearly bogus (in the future, before 1995, etc). It's wrong sometimes. Most users sell within a couple years of buying so the error doesn't compound much.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing lookup
&lt;/h2&gt;

&lt;p&gt;The offline pricing database is a JSON file with CPU/GPU model strings mapped to current estimated values. I scraped these once from a hardware pricing site, normalized the model names, and committed it. The script does fuzzy matching because WMI returns CPU strings like "Intel(R) Core(TM) i7-10750H CPU @ 2.60GHz" and my database has "i7-10750H." Levenshtein distance under 5 counts as a match.&lt;/p&gt;

&lt;p&gt;The eBay check is optional and slow. It hits the sold listings API, pulls the last 30 results for a query like "$cpu $gpu $ram", filters out wrecks (titles containing "broken," "parts," "no power"), and averages the middle 50% to drop outliers.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Desktop GPU detection is messy when there's an integrated and a discrete card. I take the one with more VRAM but that's hacky.&lt;/li&gt;
&lt;li&gt;No support for AMD APUs as a single unit. The script treats the CPU and GPU separately even though they're the same chip.&lt;/li&gt;
&lt;li&gt;The pricing database goes stale fast. I update it manually every few months. Should probably write a scraper.&lt;/li&gt;
&lt;li&gt;It assumes Windows. PowerShell Core would let it run on Linux but I haven't tested it.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;If I were starting over I'd skip the JSON pricing database and hit the eBay API every time, with a 24-hour cache. Maintaining the offline DB is the worst part of this project and the eBay numbers are more accurate anyway. The "offline" feature sounded cool when I started. In practice I always run with &lt;code&gt;-UseEbay&lt;/code&gt; enabled.&lt;/p&gt;

&lt;p&gt;Code is on GitHub. PowerShell 5.1 ships with Windows so there's nothing to install.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/TiltedLunar123/pc-worth" rel="noopener noreferrer"&gt;https://github.com/TiltedLunar123/pc-worth&lt;/a&gt;&lt;/p&gt;

</description>
      <category>powershell</category>
      <category>windows</category>
      <category>sysadmin</category>
      <category>scripting</category>
    </item>
    <item>
      <title>Setting up Whonix on Windows without clicking through 30 VirtualBox dialogs</title>
      <dc:creator>TiltedLunar123</dc:creator>
      <pubDate>Thu, 14 May 2026 09:13:32 +0000</pubDate>
      <link>https://dev.to/tiltedlunar123/setting-up-whonix-on-windows-without-clicking-through-30-virtualbox-dialogs-5p5</link>
      <guid>https://dev.to/tiltedlunar123/setting-up-whonix-on-windows-without-clicking-through-30-virtualbox-dialogs-5p5</guid>
      <description>&lt;p&gt;Whonix gives you Tor isolation by routing one VM's traffic through another. Gateway VM handles Tor. Workstation VM does the actual work. If the Workstation gets compromised, your real IP doesn't leak because it can only talk to the Gateway.&lt;/p&gt;

&lt;p&gt;Setting it up by hand is tedious. Download VirtualBox, download two OVA files, import them, configure network adapters, allocate RAM, disable USB and audio, boot Gateway, wait for Tor, then boot Workstation. Miss a step and your isolation is broken.&lt;/p&gt;

&lt;p&gt;I wrote a PowerShell project that does the whole thing.&lt;/p&gt;

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

&lt;p&gt;Four scripts run in order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;\prereq-check.ps1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;\setup.ps1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;\configure-vms.ps1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;\start-whonix.ps1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;prereq-check.ps1&lt;/code&gt; validates RAM, CPU cores, disk space, and whether VT-x or AMD-V is enabled. It fails fast if your machine can't run two VMs.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;setup.ps1&lt;/code&gt; pulls the right VirtualBox version and the two Whonix OVAs (Gateway and Workstation), then verifies SHA-512 hashes before importing. If a download is tampered with mid-flight, the install stops there.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;configure-vms.ps1&lt;/code&gt; sets the security defaults. USB controllers off, audio off, 3D acceleration off, nested virtualization off. Clipboard isolation between host and guest. Gateway gets a fixed 1 CPU and 1 GB of RAM because Tor doesn't need more, and giving it more is wasted memory. Workstation gets 25 to 40 percent of available RAM depending on what the host can spare.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;start-whonix.ps1&lt;/code&gt; boots Gateway first, then waits.&lt;/p&gt;

&lt;h2&gt;
  
  
  The startup ordering thing
&lt;/h2&gt;

&lt;p&gt;This is the part that took the longest to get right.&lt;/p&gt;

&lt;p&gt;You can't just boot both VMs at once. The Workstation has its only network route through the Gateway. If the Workstation comes up before Tor finishes bootstrapping inside the Gateway, the Workstation has no DNS, no connectivity, nothing. Some apps will time out and stay broken until you restart them.&lt;/p&gt;

&lt;p&gt;My first attempt was a hardcoded sleep. Wait 90 seconds after starting Gateway, then start Workstation. It worked on my fast machine. On a slower one it didn't. Sometimes Tor took 3 minutes to bootstrap.&lt;/p&gt;

&lt;p&gt;The fix was to poll. After Gateway boots, the script opens a TCP connection to the Gateway's Tor SocksPort. If it connects, Tor is ready. If it refuses or hangs, keep waiting. There's a timeout at 5 minutes because if Tor hasn't bootstrapped by then something else is wrong.&lt;/p&gt;

&lt;p&gt;The check is dumb but reliable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$tcpClient&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;New-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;System.Net.Sockets.TcpClient&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$connect&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$tcpClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;BeginConnect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$gatewayIP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;9050&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$wait&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$connect&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AsyncWaitHandle&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;WaitOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$wait&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-and&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$tcpClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Connected&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="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$true&lt;/span&gt;&lt;span class="w"&gt; &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;Wrapped in a retry loop with a small delay between attempts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version pinning
&lt;/h2&gt;

&lt;p&gt;The other thing I wanted was reproducibility. If I deploy this on three machines, I want the exact same VirtualBox version and the exact same Whonix images on all of them.&lt;/p&gt;

&lt;p&gt;Whonix releases change. So does VirtualBox. So the script takes pinned version parameters with their expected hashes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;\setup.ps1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-WhonixVersion&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"18.1.4.2"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-WhonixHash&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sha512:abc..."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you don't pass them, it uses a default set that's known to work. If you pass a version, you also pass the hash, and the script refuses to run if the download doesn't match. This is annoying when versions change because I have to update the defaults. But it means a stale clone of the repo won't silently pull a different image.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security defaults
&lt;/h2&gt;

&lt;p&gt;A lot of the configure step is just turning things off. USB passthrough off, because a compromised workstation shouldn't be able to read your USB drives. Audio off, same reason. 3D acceleration off, because the graphics passthrough has had escape bugs before. Nested virtualization off, because the Workstation shouldn't be spinning up its own VMs.&lt;/p&gt;

&lt;p&gt;Clipboard sharing is set to host-to-guest only, not bidirectional. Drag and drop is disabled. Shared folders are not configured by default. If you want any of these you have to turn them on yourself, which is the point.&lt;/p&gt;

&lt;p&gt;None of this is novel. The Whonix docs recommend most of these settings. The script just makes sure I don't forget one.&lt;/p&gt;

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

&lt;p&gt;It only works on Windows. The PowerShell is everywhere but VBoxManage paths and registry checks are Windows-specific. Linux users have their own tooling for this already.&lt;/p&gt;

&lt;p&gt;It doesn't handle the case where VirtualBox is already installed but with a mismatched version. Right now it just warns. I should add a flag to force-upgrade or skip.&lt;/p&gt;

&lt;p&gt;And the prerequisites check is conservative. It refuses to run on machines with less than 8 GB of RAM, but Whonix technically runs on 4 GB if you're patient. Someone with a low-end machine asked me to add an override and I haven't yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Repo
&lt;/h2&gt;

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

&lt;p&gt;The README has the full parameter list and a few troubleshooting notes for when the Tor bootstrap fails. If you run it and something breaks, the issue tracker is open.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>privacy</category>
      <category>security</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I built a Windows optimizer that refuses to run if Outlook is open</title>
      <dc:creator>TiltedLunar123</dc:creator>
      <pubDate>Wed, 13 May 2026 09:23:30 +0000</pubDate>
      <link>https://dev.to/tiltedlunar123/i-built-a-windows-optimizer-that-refuses-to-run-if-outlook-is-open-1fl5</link>
      <guid>https://dev.to/tiltedlunar123/i-built-a-windows-optimizer-that-refuses-to-run-if-outlook-is-open-1fl5</guid>
      <description>&lt;p&gt;I wrote a PowerShell script that hardens and tunes Windows 10/11. Pretty standard stuff. Disables telemetry, kills bloatware, tweaks the registry for performance, hardens a few obvious holes (SMBv1, AutoRun, Remote Desktop if you don't use it).&lt;/p&gt;

&lt;p&gt;Writing those tweaks isn't hard. The hard part is what happens when you run a script like that on a machine someone is actually using.&lt;/p&gt;

&lt;p&gt;My first version was clean. Smooth even. Then I ran it on my dad's laptop while he was on a Zoom call. The script disabled a couple of audio services it thought were unused. Mic cut out mid-meeting. He was not impressed.&lt;/p&gt;

&lt;p&gt;That was the moment I added context-aware safety.&lt;/p&gt;

&lt;p&gt;Now before the script touches anything, it scans for running processes and connected hardware that would care. Outlook running? Skip the network resets. RDP session active? Don't touch firewall rules. Touchscreen detected? Leave the tablet input services alone. Print job queued? Don't kill the spooler.&lt;/p&gt;

&lt;p&gt;It's a stupid amount of edge-case logic for a script people will run once. But it's the difference between "optimizer" and "incident."&lt;/p&gt;

&lt;p&gt;Here's roughly how the privacy module handles it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="kr"&gt;function&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;Invoke-PrivacyOptimization&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="kr"&gt;param&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;switch&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="nv"&gt;$DryRun&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Get-RuntimeContext&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nx"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Skip&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-contains&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'Cortana-active'&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="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;Write-Log&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Cortana in use, skipping Cortana disable"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="kr"&gt;return&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="nv"&gt;$changes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s1"&gt;'AllowTelemetry'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s1"&gt;'AllowCortana'&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s1"&gt;'AdvertisingId'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

    &lt;/span&gt;&lt;span class="kr"&gt;foreach&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$changes&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Keys&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="w"&gt;
        &lt;/span&gt;&lt;span class="n"&gt;Save-UndoEntry&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="kr"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-not&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$DryRun&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="w"&gt;
            &lt;/span&gt;&lt;span class="n"&gt;Set-RegistryValue&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Value&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$changes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$key&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="w"&gt;
    &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="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The undo logic is what I'm most proud of. Every registry change writes the previous value to a JSON file in &lt;code&gt;%LOCALAPPDATA%\UWSO\&lt;/code&gt; with restricted ACLs (so a non-admin user on the box can't tamper with the rollback). If anything breaks, you run the script with &lt;code&gt;-Undo&lt;/code&gt; and it restores every value from the most recent run.&lt;/p&gt;

&lt;p&gt;The catch I didn't see coming: some registry keys don't exist until you set them. So the "previous value" is null. If you blindly restore null, you delete the key entirely. Sometimes that's fine. Sometimes you've just removed a default that Windows would have created on demand, and now an app behaves oddly because that key being absent means something specific. I ended up adding a flag in the undo file marking whether the original key existed or not. Boolean, not the worst code I've ever written, but it took me a full afternoon to figure out why some restores were leaving the system in a weirder state than before the optimizer ran.&lt;/p&gt;

&lt;p&gt;I also added a health score. It's a 0-100 number based on services running, telemetry endpoints reachable, disk type vs. configured caching, firewall posture, and a few other things. Mostly it's there so the user has a before/after to look at. Nobody trusts a script that just says "done." They want a number that went up.&lt;/p&gt;

&lt;p&gt;What's still rough:&lt;/p&gt;

&lt;p&gt;The detection for "active session" is shallow. I'm checking process names. If you renamed Outlook.exe to something custom or you're running a different mail client, the script wouldn't notice. A real solution would hook into Windows session APIs and check what's holding open file handles or audio endpoints. I'm not there yet.&lt;/p&gt;

&lt;p&gt;Gaming tweaks are mostly hard-coded. The script enables Game Mode and disables Game DVR if it sees a discrete GPU. Should probably check whether the user actually games. Right now if you have a 4090 and only use the machine for Excel, it'll still flip those bits. Harmless, mostly, but annoying that it lies a little on the report.&lt;/p&gt;

&lt;p&gt;The hardware tier classifier is fragile. I divide systems into rough buckets based on CPU/RAM/storage type. The thresholds are arbitrary numbers I picked after testing on six machines. Probably wrong for anything outside that range. If you run it on a Surface tablet or a Steam Deck booted into Windows, results may be funky.&lt;/p&gt;

&lt;p&gt;The SSD-specific tweaks (disabling Prefetch/Superfetch, ensuring TRIM is on) only fire if it detects an NVMe or SATA SSD via the storage cmdlets. Hybrid drives confuse it. I have one in a backup laptop and it always classifies it wrong.&lt;/p&gt;

&lt;p&gt;If you want to try it, install is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;irm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;https://raw.githubusercontent.com/TiltedLunar123/Ultimate-Windows-System-Optimizer/main/run.ps1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;iex&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with &lt;code&gt;-DryRun&lt;/code&gt; first. Always. The dry-run mode prints every change it would make without touching anything. I use this on every fresh machine before letting it commit. You can also do &lt;code&gt;-Only "Privacy","Cleanup"&lt;/code&gt; to scope it to a few modules, or &lt;code&gt;-Skip "Gaming"&lt;/code&gt; to leave specific stuff alone.&lt;/p&gt;

&lt;p&gt;If it ever does something you don't like, &lt;code&gt;-Undo&lt;/code&gt; will pull the most recent rollback file from &lt;code&gt;%LOCALAPPDATA%\UWSO\&lt;/code&gt; and put things back. The rollback files are plain JSON. You can read them, edit them, or delete them if you want a clean slate.&lt;/p&gt;

&lt;p&gt;Repo and full module list here: &lt;a href="https://github.com/TiltedLunar123/Ultimate-Windows-System-Optimizer" rel="noopener noreferrer"&gt;https://github.com/TiltedLunar123/Ultimate-Windows-System-Optimizer&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Issues and PRs welcome. Especially if you have a weird hardware setup that breaks the tier classifier. I want to know.&lt;/p&gt;

</description>
      <category>powershell</category>
      <category>windows</category>
      <category>security</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>I tested 17 DNS resolvers from my apartment so you don't have to</title>
      <dc:creator>TiltedLunar123</dc:creator>
      <pubDate>Tue, 12 May 2026 09:16:37 +0000</pubDate>
      <link>https://dev.to/tiltedlunar123/i-tested-17-dns-resolvers-from-my-apartment-so-you-dont-have-to-2cpi</link>
      <guid>https://dev.to/tiltedlunar123/i-tested-17-dns-resolvers-from-my-apartment-so-you-dont-have-to-2cpi</guid>
      <description>&lt;p&gt;i kept seeing "just use 1.1.1.1" and "switch to quad9 for security" in every networking thread, and nobody ever showed numbers. so i wrote a powershell script that actually benchmarks all of them on my machine and picks one based on weighted scoring.&lt;/p&gt;

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

&lt;h2&gt;
  
  
  the problem
&lt;/h2&gt;

&lt;p&gt;my ISP's default DNS resolves twitter.com in ~38ms. cloudflare claims sub-15ms globally. is that real from comcast in metro detroit at 11pm? i had no idea. every "best DNS" article is the same five recommendations with no measurements behind them.&lt;/p&gt;

&lt;p&gt;the existing tools i tried either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;only test latency (ignores reliability, ignores DNSSEC support)&lt;/li&gt;
&lt;li&gt;need GUI clicks (i wanted something i could run from a script on a fresh windows install)&lt;/li&gt;
&lt;li&gt;pick winners with no visible methodology&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;so i built one.&lt;/p&gt;

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

&lt;p&gt;it pulls the active network adapter, runs queries against 17 public resolvers across 10 test domains, scores them, and offers to apply the winner. with a backup so you don't brick your DNS at 2am.&lt;/p&gt;

&lt;p&gt;the 17 providers cover the main families: cloudflare (three variants including malware and family filtering), google, quad9 (filtered + unfiltered), opendns, adguard, comodo, cleanbrowsing, mullvad, control d, neustar, level3. enough to actually represent the space, not just three vendors.&lt;/p&gt;

&lt;h2&gt;
  
  
  the scoring choice that took me too long
&lt;/h2&gt;

&lt;p&gt;i kept flip-flopping on weights. first attempt was pure latency. then i ran it a few times and noticed quad9 would win one run, cloudflare the next, depending on which domain happened to be hot in cache. so consistency had to matter.&lt;/p&gt;

&lt;p&gt;settled on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;speed 40%&lt;/li&gt;
&lt;li&gt;reliability 25% (% of queries that actually resolved without timeout)&lt;/li&gt;
&lt;li&gt;security 25% (DNSSEC support, malware blocking, no logging claims)&lt;/li&gt;
&lt;li&gt;consistency 10% (low jitter across runs)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;the security score is partially hardcoded from each provider's published policy, which i'm not thrilled about. i don't have a great way to verify "no logging" claims from a script. open to suggestions there.&lt;/p&gt;

&lt;h2&gt;
  
  
  the install line
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;irm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;https://raw.githubusercontent.com/TiltedLunar123/DNS-Benchmark/master/install.ps1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;iex&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;yes, &lt;code&gt;irm | iex&lt;/code&gt; is the powershell equivalent of &lt;code&gt;curl | bash&lt;/code&gt; and yes you should read the script before running it. the install.ps1 is under 100 lines. takes about 30 seconds to skim.&lt;/p&gt;

&lt;h2&gt;
  
  
  sample output
&lt;/h2&gt;

&lt;p&gt;results come back as letter grades, A+ through F, with the top 3 starred. cloudflare 1.1.1.1 won on my home connection but quad9 came within 2ms and scored higher on security weighting. it was closer than the internet would have you believe.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;it assumes you have admin. if you don't, it fails late instead of checking up front. fixing.&lt;/li&gt;
&lt;li&gt;the 10 test domains are hardcoded. should probably read from a config file or accept a &lt;code&gt;-Domains&lt;/code&gt; param.&lt;/li&gt;
&lt;li&gt;no ipv6 support yet. on the list.&lt;/li&gt;
&lt;li&gt;jitter analysis uses standard deviation which is fine for normal cases but gets weird when a provider has one big outlier query. probably should use median absolute deviation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;flags supported: &lt;code&gt;-TestCount&lt;/code&gt;, &lt;code&gt;-SkipApply&lt;/code&gt;, &lt;code&gt;-Report&lt;/code&gt; (markdown out), &lt;code&gt;-Restore&lt;/code&gt; (back to your previous DNS if the new one feels wrong).&lt;/p&gt;

&lt;h2&gt;
  
  
  the thing i actually learned
&lt;/h2&gt;

&lt;p&gt;the "best DNS" depends way more on your geographic distance to the resolver's anycast nodes than on the brand. mullvad scored surprisingly high for me, probably because their detroit-area peering is good. i would never have guessed that without measuring.&lt;/p&gt;

&lt;p&gt;if you run it on your network and get a different winner, that's the point.&lt;/p&gt;

&lt;p&gt;repo's MIT, PRs welcome, especially anyone who knows the right way to verify logging policies from a script. &lt;a href="https://github.com/TiltedLunar123/DNS-Benchmark" rel="noopener noreferrer"&gt;https://github.com/TiltedLunar123/DNS-Benchmark&lt;/a&gt;&lt;/p&gt;

</description>
      <category>powershell</category>
      <category>dns</category>
      <category>networking</category>
      <category>windows</category>
    </item>
    <item>
      <title>Auditing Windows security from a Python script, no pip install needed</title>
      <dc:creator>TiltedLunar123</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>TiltedLunar123</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>
  </channel>
</rss>
