<?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: Marcin</title>
    <description>The latest articles on DEV Community by Marcin (@mar0ls).</description>
    <link>https://dev.to/mar0ls</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4013423%2F209676aa-08ca-4082-9a94-598c5b2a7c51.png</url>
      <title>DEV Community: Marcin</title>
      <link>https://dev.to/mar0ls</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mar0ls"/>
    <language>en</language>
    <item>
      <title>On the wire: network traffic analysis and detection engineering with open tools</title>
      <dc:creator>Marcin</dc:creator>
      <pubDate>Sat, 08 Aug 2026 16:08:06 +0000</pubDate>
      <link>https://dev.to/mar0ls/on-the-wire-network-traffic-analysis-and-detection-engineering-with-open-tools-1h84</link>
      <guid>https://dev.to/mar0ls/on-the-wire-network-traffic-analysis-and-detection-engineering-with-open-tools-1h84</guid>
      <description>&lt;p&gt;Most write-ups about network security tools stop at "here are the flags." That is the least interesting part. The interesting part is the reasoning: you have a capture or a live sensor, you have a question — &lt;em&gt;did this host get owned, and how would I know?&lt;/em&gt; — and you need to turn a wall of packets into an answer you can defend.&lt;/p&gt;

&lt;p&gt;This article walks through the toolchain I actually use for that, in the order I usually reach for it: capture and triage with &lt;code&gt;tcpdump&lt;/code&gt; and &lt;code&gt;tshark&lt;/code&gt;, flow-level context with SiLK, protocol reconstruction with Zeek, signature detection with Snort and Suricata, and packet crafting with Scapy to test your own rules. The second half is dedicated to writing detection rules, from a one-line signature to entropy-aware DNS tunneling checks.&lt;/p&gt;

&lt;p&gt;Every command here is copy-paste ready. The companion cheat sheets for each tool live in the &lt;a href="https://github.com/mar0ls/cheatsheet" rel="noopener noreferrer"&gt;same repository&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The toolkit at a glance
&lt;/h2&gt;

&lt;p&gt;Each tool answers a different question. Reaching for the wrong one is how you burn an afternoon.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;The question it answers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;tcpdump&lt;/td&gt;
&lt;td&gt;"Show me packets matching &lt;em&gt;this&lt;/em&gt; precisely."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tshark&lt;/td&gt;
&lt;td&gt;"Decode and extract fields from a protocol."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SiLK&lt;/td&gt;
&lt;td&gt;"Who talked to whom, how much, over what period?"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zeek&lt;/td&gt;
&lt;td&gt;"Give me structured logs of everything that happened."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Snort / Suricata&lt;/td&gt;
&lt;td&gt;"Alert me when traffic matches a known-bad pattern."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Scapy&lt;/td&gt;
&lt;td&gt;"Let me build a packet by hand and send it."&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Capture is the raw material. Flow data is the map. Zeek logs are the timeline. Signatures are the verdict. Scapy is your test harness. Keep those roles separate in your head and the workflow falls into place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 1 — Capture and first triage
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;tcpdump&lt;/code&gt; is the tool you never fully outgrow. Its power is the Berkeley Packet Filter (BPF) language: you can express, before a single packet is decoded, exactly what you want to see.&lt;/p&gt;

&lt;p&gt;Start simple — read a file, skip name resolution (which is slow and can leak lookups), and drop timestamps for cleaner grep-able output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tcpdump &lt;span class="nt"&gt;-r&lt;/span&gt; capture.pcap &lt;span class="nt"&gt;-n&lt;/span&gt;
tcpdump &lt;span class="nt"&gt;-r&lt;/span&gt; capture.pcap &lt;span class="nt"&gt;-ntc&lt;/span&gt; 20        &lt;span class="c"&gt;# first 20 records only&lt;/span&gt;
tcpdump &lt;span class="nt"&gt;-r&lt;/span&gt; capture.pcap &lt;span class="nt"&gt;-nX&lt;/span&gt; &lt;span class="s1"&gt;'dst host 10.10.10.5 and src port 4444'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-X&lt;/code&gt; flag prints hex and ASCII side by side, which is enough to eyeball a plaintext C2 beacon or a suspicious user agent without opening Wireshark.&lt;/p&gt;

&lt;h3&gt;
  
  
  Filtering on the bits, not just the fields
&lt;/h3&gt;

&lt;p&gt;Where BPF earns its keep is reaching into header bytes directly. The TCP flags live in a single byte, &lt;code&gt;tcp[13]&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bit&lt;/th&gt;
&lt;th&gt;7&lt;/th&gt;
&lt;th&gt;6&lt;/th&gt;
&lt;th&gt;5&lt;/th&gt;
&lt;th&gt;4&lt;/th&gt;
&lt;th&gt;3&lt;/th&gt;
&lt;th&gt;2&lt;/th&gt;
&lt;th&gt;1&lt;/th&gt;
&lt;th&gt;0&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Flag&lt;/td&gt;
&lt;td&gt;CWR&lt;/td&gt;
&lt;td&gt;ECE&lt;/td&gt;
&lt;td&gt;URG&lt;/td&gt;
&lt;td&gt;ACK&lt;/td&gt;
&lt;td&gt;PSH&lt;/td&gt;
&lt;td&gt;RST&lt;/td&gt;
&lt;td&gt;SYN&lt;/td&gt;
&lt;td&gt;FIN&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hex&lt;/td&gt;
&lt;td&gt;0x80&lt;/td&gt;
&lt;td&gt;0x40&lt;/td&gt;
&lt;td&gt;0x20&lt;/td&gt;
&lt;td&gt;0x10&lt;/td&gt;
&lt;td&gt;0x08&lt;/td&gt;
&lt;td&gt;0x04&lt;/td&gt;
&lt;td&gt;0x02&lt;/td&gt;
&lt;td&gt;0x01&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;To find connection attempts — SYN set, ACK not set — mask off the two high bits and compare:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tcpdump &lt;span class="nt"&gt;-r&lt;/span&gt; capture.pcap &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'tcp[13] &amp;amp; 0x3f = 0x02'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Chain that into a quick top-ports summary and you have a horizontal-scan detector in one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tcpdump &lt;span class="nt"&gt;-r&lt;/span&gt; capture.pcap &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'tcp[13] &amp;amp; 0x3f = 0x02'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; 5 | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; 5 | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-nr&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same byte-offset trick works on DNS. A DNS message rides in the UDP payload, and since the UDP header is 8 bytes, the DNS header starts at &lt;code&gt;udp[8]&lt;/code&gt;. That fixes the offsets you care about:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;DNS field&lt;/th&gt;
&lt;th&gt;UDP offset&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Flags&lt;/td&gt;
&lt;td&gt;&lt;code&gt;udp[10:2]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Questions (QDCOUNT)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;udp[12:2]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Answers (ANCOUNT)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;udp[14:2]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authority (NSCOUNT)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;udp[16:2]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Additional (ARCOUNT)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;udp[18:2]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The high bit of &lt;code&gt;udp[10]&lt;/code&gt; is the QR flag: 0 for a query, 1 for a response. So "all DNS queries" and "all DNS responses" are:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tcpdump &lt;span class="nt"&gt;-r&lt;/span&gt; dns.pcap &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'dst port 53 and udp[10] &amp;amp; 0x80 = 0'&lt;/span&gt;      &lt;span class="c"&gt;# queries&lt;/span&gt;
tcpdump &lt;span class="nt"&gt;-r&lt;/span&gt; dns.pcap &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'src port 53 and udp[10] &amp;amp; 0x80 = 0x80'&lt;/span&gt;   &lt;span class="c"&gt;# responses&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is worth doing by hand once, even though Zeek and Suricata will parse DNS for you later. Understanding where the bits are is what lets you write good rules — and debug the ones that do not fire.&lt;/p&gt;

&lt;h3&gt;
  
  
  tshark when you need the parsed field
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;tcpdump&lt;/code&gt; filters on raw structure. &lt;code&gt;tshark&lt;/code&gt; understands protocols, so you can pull named fields straight into columns — perfect for feeding a spreadsheet or another script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tshark &lt;span class="nt"&gt;-r&lt;/span&gt; dns.pcap &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;-Y&lt;/span&gt; &lt;span class="s1"&gt;'udp.port == 53'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-T&lt;/span&gt; fields &lt;span class="nt"&gt;-e&lt;/span&gt; ip.src &lt;span class="nt"&gt;-e&lt;/span&gt; ip.dst &lt;span class="nt"&gt;-e&lt;/span&gt; dns.qry.name &lt;span class="nt"&gt;-e&lt;/span&gt; dns.a
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two &lt;code&gt;tshark&lt;/code&gt; features I use constantly: stream numbers to isolate a conversation, and &lt;code&gt;follow&lt;/code&gt; to dump one stream as text:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tshark &lt;span class="nt"&gt;-r&lt;/span&gt; capture.pcap &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;-Y&lt;/span&gt; &lt;span class="s1"&gt;'tcp.port == 25'&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; fields &lt;span class="nt"&gt;-e&lt;/span&gt; tcp.stream | &lt;span class="nb"&gt;uniq
&lt;/span&gt;tshark &lt;span class="nt"&gt;-r&lt;/span&gt; capture.pcap &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; follow,tcp,ascii,9 | less
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Part 2 — Flow-level context with SiLK
&lt;/h2&gt;

&lt;p&gt;Packets are detail; flows are perspective. When you are staring at gigabytes and do not yet know what matters, NetFlow tells you who talked to whom, how much, and when — without the payload.&lt;/p&gt;

&lt;p&gt;SiLK's &lt;code&gt;rwfilter&lt;/code&gt; selects flows and pipes them to &lt;code&gt;rwcut&lt;/code&gt; for display:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rwfilter &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;all &lt;span class="nt"&gt;--start&lt;/span&gt; 2024/01/01 &lt;span class="nt"&gt;--end&lt;/span&gt; 2024/12/31 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--proto&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;6 &lt;span class="nt"&gt;--dport&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;443 &lt;span class="nt"&gt;--pass&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;stdout &lt;span class="se"&gt;\&lt;/span&gt;
  | rwcut &lt;span class="nt"&gt;--fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sip,sport,dip,dport,bytes &lt;span class="nt"&gt;--no-columns&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pattern that pays off in an investigation is chaining filters to answer a real question — for example, "what leaves my internal subnets but does &lt;em&gt;not&lt;/em&gt; land in another internal subnet?" (i.e. traffic actually heading out):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rwfilter &lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;all &lt;span class="nt"&gt;--start&lt;/span&gt; 2024/01/01 &lt;span class="nt"&gt;--end&lt;/span&gt; 2024/12/31 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--scidr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10.200.0.0/16 &lt;span class="nt"&gt;--proto&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;6 &lt;span class="nt"&gt;--pass&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;stdout &lt;span class="se"&gt;\&lt;/span&gt;
  | rwfilter &lt;span class="nt"&gt;--dcidr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10.200.0.0/16 &lt;span class="nt"&gt;--fail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;stdout &lt;span class="nt"&gt;--input-pipe&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;stdin &lt;span class="se"&gt;\&lt;/span&gt;
  | rwcut &lt;span class="nt"&gt;--fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sip,sport,dip,dport,bytes,flags &lt;span class="nt"&gt;--no-columns&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trick is &lt;code&gt;--input-pipe=stdin&lt;/code&gt; on the second &lt;code&gt;rwfilter&lt;/code&gt;, and &lt;code&gt;--fail&lt;/code&gt; to keep only the flows that did &lt;em&gt;not&lt;/em&gt; match the internal destination. For top-talker summaries, &lt;code&gt;rwstats&lt;/code&gt; does the counting for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 3 — Turning packets into evidence with Zeek
&lt;/h2&gt;

&lt;p&gt;Zeek is the tool that changed how I read traffic. Instead of decoding packets, it produces structured, tab-separated logs — &lt;code&gt;conn.log&lt;/code&gt;, &lt;code&gt;dns.log&lt;/code&gt;, &lt;code&gt;http.log&lt;/code&gt;, &lt;code&gt;ssl.log&lt;/code&gt;, &lt;code&gt;files.log&lt;/code&gt;, &lt;code&gt;x509.log&lt;/code&gt; — and stitches them together with a shared connection id (&lt;code&gt;uid&lt;/code&gt;). Find one interesting record and you can pivot to every other log entry for the same connection.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;zeek &lt;span class="nt"&gt;-r&lt;/span&gt; capture.pcap                          &lt;span class="c"&gt;# generates *.log in the cwd&lt;/span&gt;
&lt;span class="nb"&gt;cat &lt;/span&gt;http.log | zeek-cut &lt;span class="nt"&gt;-u&lt;/span&gt; ts uid id.orig_h id.resp_h host uri status_code
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;zeek-cut&lt;/code&gt; extracts named columns (the &lt;code&gt;-u&lt;/code&gt; renders timestamps in UTC). Chasing a file by its id across every log is a one-liner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep &lt;/span&gt;FYAp0L1VyrDDhX8uu &lt;span class="k"&gt;*&lt;/span&gt;.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Beyond logging, Zeek is programmable. You can write scripts that react to events — extract every file carried over HTTP, flag a DNS answer that points at a domain you saw in an email, and so on. That scripting layer is where Zeek stops being a logger and becomes a detection platform, but even the default logs alone are worth the setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 4 — Signature detection: Snort and Suricata
&lt;/h2&gt;

&lt;p&gt;Snort and Suricata both match traffic against rules and raise alerts. Suricata is multi-threaded and speaks the richer, more modern rule syntax, so the rule-writing section below uses it — but the two dialects are close cousins.&lt;/p&gt;

&lt;p&gt;Run Suricata over a pcap and point it at your ruleset:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;suricata &lt;span class="nt"&gt;-T&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; suricata.yaml &lt;span class="nt"&gt;-S&lt;/span&gt; local.rules &lt;span class="nt"&gt;-vvv&lt;/span&gt;        &lt;span class="c"&gt;# validate config + rules&lt;/span&gt;
suricata &lt;span class="nt"&gt;-r&lt;/span&gt; sample.pcap &lt;span class="nt"&gt;-c&lt;/span&gt; suricata.yaml &lt;span class="nt"&gt;-S&lt;/span&gt; local.rules &lt;span class="nt"&gt;-l&lt;/span&gt; ./logs/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alerts and protocol events land in &lt;code&gt;logs/eve.json&lt;/code&gt; in EVE format — JSON, one event per line, which means &lt;code&gt;jq&lt;/code&gt; is your query engine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;jq &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'select(.event_type=="alert") | {sig: .alert.signature, src: .src_ip, dst: .dest_ip}'&lt;/span&gt; logs/eve.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that the signature id is nested under the &lt;code&gt;alert&lt;/code&gt; object as &lt;code&gt;.alert.signature_id&lt;/code&gt;, not at the top level — a small detail that quietly returns &lt;code&gt;null&lt;/code&gt; if you get it wrong.&lt;/p&gt;

&lt;p&gt;Snort's equivalents are worth knowing when you land on a Snort box:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;snort &lt;span class="nt"&gt;-r&lt;/span&gt; sample.pcap &lt;span class="nt"&gt;-c&lt;/span&gt; snort.lua &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; alert_fast &lt;span class="nt"&gt;-R&lt;/span&gt; local.rules
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Part 5 — Writing detection rules, simple to advanced
&lt;/h2&gt;

&lt;p&gt;This is the part that separates using an IDS from operating one. We will start from a rule you can read at a glance and finish with stateful, intel-backed detection that reasons about byte layout and entropy. Along the way, the rules pick up the scaffolding that distinguishes a lab example from something you would actually deploy: precise buffers, a fast-pattern anchor, MITRE-tagged metadata, tuning, and a lifecycle.&lt;/p&gt;

&lt;h3&gt;
  
  
  Anatomy of a rule
&lt;/h3&gt;

&lt;p&gt;Every Suricata rule has three parts: an &lt;strong&gt;action&lt;/strong&gt;, a &lt;strong&gt;header&lt;/strong&gt;, and &lt;strong&gt;options&lt;/strong&gt; in parentheses.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;action protocol src_ip src_port -&amp;gt; dst_ip dst_port (options)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Action&lt;/strong&gt; — &lt;code&gt;alert&lt;/code&gt; (log it), &lt;code&gt;drop&lt;/code&gt; (block, IPS mode), &lt;code&gt;reject&lt;/code&gt; (block and tear down), &lt;code&gt;pass&lt;/code&gt; (allow, stop evaluating).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Header&lt;/strong&gt; — the protocol (&lt;code&gt;tcp&lt;/code&gt;, &lt;code&gt;udp&lt;/code&gt;, &lt;code&gt;dns&lt;/code&gt;, &lt;code&gt;http&lt;/code&gt;, &lt;code&gt;tls&lt;/code&gt;, …), addresses and ports, and direction (&lt;code&gt;-&amp;gt;&lt;/code&gt; or &lt;code&gt;&amp;lt;&amp;gt;&lt;/code&gt;). &lt;code&gt;$HOME_NET&lt;/code&gt; and &lt;code&gt;$EXTERNAL_NET&lt;/code&gt; are variables defined in &lt;code&gt;suricata.yaml&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Options&lt;/strong&gt; — the detection logic plus the metadata that makes a rule maintainable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A rule with no &lt;code&gt;msg&lt;/code&gt;, no &lt;code&gt;classtype&lt;/code&gt;, no &lt;code&gt;reference&lt;/code&gt;, and no &lt;code&gt;metadata&lt;/code&gt; will still fire — and it will be worthless six months later when an analyst triages the alert and has no idea what it means or how confident to be. Treat metadata as part of the detection, not decoration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Level 1 — a precise, documented first rule
&lt;/h3&gt;

&lt;p&gt;The textbook version of a first rule is "match a bad hostname." The deployable version scopes the flow, anchors the fast pattern, pins the buffer length, and carries enough context to triage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;alert http $HOME_NET any -&amp;gt; $EXTERNAL_NET any ( \
    msg:"MALWARE Outbound HTTP to known-bad C2 host"; \
    flow:established,to_server; \
    http.host; content:"evil.example.com"; fast_pattern; bsize:16; \
    classtype:trojan-activity; \
    reference:url,attack.mitre.org/techniques/T1071/001/; \
    metadata:attack_target Client_Endpoint, deployment Perimeter, \
      signature_severity Major, confidence High, \
      mitre_tactic_id TA0011, mitre_technique_id T1071, \
      created_at 2024_01_10, updated_at 2024_01_10; \
    sid:1000001; rev:1;)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What earns each keyword its place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;flow:established,to_server&lt;/code&gt; — evaluate only the client-to-server side of an established session. This one keyword removes most false positives and a lot of wasted CPU.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bsize:16&lt;/code&gt; — the &lt;code&gt;http.host&lt;/code&gt; buffer must be exactly 16 bytes (&lt;code&gt;evil.example.com&lt;/code&gt;), so a lookalike subdomain does not slip through and the match is unambiguous.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;fast_pattern&lt;/code&gt; — tells the engine which &lt;code&gt;content&lt;/code&gt; to load into the multi-pattern matcher for the first-pass filter. On a busy sensor, choosing a good fast pattern is the single biggest performance lever you have.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;classtype&lt;/code&gt; / &lt;code&gt;reference&lt;/code&gt; / &lt;code&gt;metadata&lt;/code&gt; — the triage context: what kind of activity, where it maps in ATT&amp;amp;CK, how severe, and how much to trust it. Keep local &lt;code&gt;sid&lt;/code&gt;s in the &lt;code&gt;1000000+&lt;/code&gt; range and bump &lt;code&gt;rev&lt;/code&gt; on every edit.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Level 2 — content matching that performs
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;content&lt;/code&gt; is the workhorse; the modifiers are what make it precise &lt;em&gt;and&lt;/em&gt; cheap. The goal is always to match the smallest, most specific buffer possible.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http.method; content:"POST";
http.uri; content:".php?id="; fast_pattern;
http.user_agent; content:"Mozilla/4.0 (compatible|3b 20|MSIE";
http.header_names; content:!"Referer"; content:!"Accept-Language";
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;offset&lt;/code&gt; / &lt;code&gt;depth&lt;/code&gt; constrain &lt;em&gt;where&lt;/em&gt; to look; &lt;code&gt;distance&lt;/code&gt; / &lt;code&gt;within&lt;/code&gt; position one &lt;code&gt;content&lt;/code&gt; relative to the previous one — enough to parse structured payloads without paying for a regex.&lt;/li&gt;
&lt;li&gt;Negation (&lt;code&gt;content:!"..."&lt;/code&gt;) is underused: a lot of automated clients give themselves away by the headers a real browser sends and they omit. Matching on &lt;code&gt;http.header_names&lt;/code&gt; for &lt;em&gt;absent&lt;/em&gt; &lt;code&gt;Referer&lt;/code&gt; and &lt;code&gt;Accept-Language&lt;/code&gt; is a classic, cheap C2 heuristic.&lt;/li&gt;
&lt;li&gt;Bytes you cannot type go in pipes as hex: &lt;code&gt;|3b 20|&lt;/code&gt; is &lt;code&gt;"; "&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Put &lt;code&gt;fast_pattern&lt;/code&gt; on the longest, rarest &lt;code&gt;content&lt;/code&gt; — never on something like &lt;code&gt;"GET"&lt;/code&gt; that appears in nearly every packet.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Level 3 — stateful detection: flowbits and xbits
&lt;/h3&gt;

&lt;p&gt;Single-packet rules miss anything that unfolds over a conversation. &lt;code&gt;flowbits&lt;/code&gt; carries state &lt;em&gt;within a flow&lt;/em&gt;; &lt;code&gt;xbits&lt;/code&gt; carries it &lt;em&gt;across flows on the same host&lt;/em&gt;, which is how you correlate, say, a phishing click with a later beacon from the same endpoint.&lt;/p&gt;

&lt;p&gt;Two-stage detection within one flow — a download request followed by an executable in the response — alerting only on the combination:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;alert http $HOME_NET any -&amp;gt; $EXTERNAL_NET any ( \
    msg:"MALWARE Stage 1 suspicious payload request"; \
    flow:established,to_server; \
    http.uri; content:"/api/v1/getfile"; fast_pattern; \
    flowbits:set,payload.request; flowbits:noalert; \
    classtype:trojan-activity; sid:1000010; rev:1;)

alert http $EXTERNAL_NET any -&amp;gt; $HOME_NET any ( \
    msg:"MALWARE PE delivered after suspicious request"; \
    flow:established,to_client; \
    flowbits:isset,payload.request; \
    file.data; content:"MZ"; startswith; \
    content:"This program cannot be run in DOS mode"; distance:0; \
    classtype:trojan-activity; \
    metadata:mitre_tactic_id TA0011, mitre_technique_id T1105; \
    sid:1000011; rev:1;)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first rule sets a bit and stays silent (&lt;code&gt;noalert&lt;/code&gt;); the second fires only if that bit is set earlier in the same flow &lt;em&gt;and&lt;/em&gt; the response actually carries a PE. Correlating across flows on a host looks the same, with &lt;code&gt;xbits:set,name, track ip_src, expire 3600;&lt;/code&gt; and a matching &lt;code&gt;xbits:isset&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Level 4 — protocol intelligence (DNS and TLS)
&lt;/h3&gt;

&lt;p&gt;Modern detection lives in the parsed protocol buffers, and it shines on traffic you cannot read.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DNS — beaconing and tunneling.&lt;/strong&gt; A single long query means little; &lt;em&gt;many&lt;/em&gt; long, high-entropy queries to one parent domain in a short window is what exfiltration looks like. Combine a PCRE length/charset test with a &lt;code&gt;threshold&lt;/code&gt; so you alert on the behavior, not on each packet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;alert dns $HOME_NET any -&amp;gt; any any ( \
    msg:"MALWARE Possible DNS tunneling - sustained long high-entropy labels"; \
    dns.query; \
    pcre:"/^[a-z0-9]{40,}\.[a-z0-9\-]+\.[a-z]{2,}$/i"; \
    threshold:type both, track by_src, count 15, seconds 60; \
    classtype:bad-unknown; \
    reference:url,attack.mitre.org/techniques/T1071/004/; \
    metadata:attack_target Client_Endpoint, deployment Perimeter, \
      signature_severity Major, confidence Medium, \
      mitre_tactic_id TA0011, mitre_technique_id T1071, \
      created_at 2024_01_10, updated_at 2024_01_10; \
    sid:1000020; rev:1;)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;threshold:type both, count 15, seconds 60&lt;/code&gt; means: only alert after 15 matches from the same source within a minute, then at most once per window. That is the line between a signal and an alert storm. Pair it with hunting for a burst of &lt;code&gt;NXDOMAIN&lt;/code&gt; responses in the DNS logs, which DGA and tunneling both generate heavily.&lt;/p&gt;

&lt;p&gt;When you &lt;em&gt;do&lt;/em&gt; want an exact domain — say, matching a C2 domain but not a lookalike subdomain of it — two keywords make it airtight. &lt;code&gt;bsize&lt;/code&gt; pins the buffer length, and &lt;code&gt;dotprefix&lt;/code&gt; prepends a &lt;code&gt;.&lt;/code&gt; to the query buffer so an &lt;code&gt;endswith&lt;/code&gt; cannot be fooled by &lt;code&gt;evilcdn.example.com&lt;/code&gt; when you meant &lt;code&gt;cdn.example.com&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;alert dns $HOME_NET any -&amp;gt; any any ( \
    msg:"MALWARE C2 domain lookup"; \
    dns.query; dotprefix; content:".cdn.example.com"; endswith; \
    classtype:trojan-activity; sid:1000021; rev:1;)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;TLS — fingerprint what you cannot decrypt.&lt;/strong&gt; The payload is encrypted, but the handshake is not: the SNI, the certificate subject/issuer, and the JA3/JA3S fingerprints are all in the clear and remarkably identifying (JA3 must be enabled under the TLS parser in &lt;code&gt;suricata.yaml&lt;/code&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;alert tls $HOME_NET any -&amp;gt; $EXTERNAL_NET any ( \
    msg:"MALWARE JA3 fingerprint matches known C2 client"; \
    ja3.hash; content:"a0e9f5d64349fb13191bc781f81f42e1"; \
    flow:established,to_server; \
    classtype:trojan-activity; \
    metadata:mitre_tactic_id TA0011, mitre_technique_id T1573, \
      signature_severity Major, confidence High; \
    sid:1000030; rev:1;)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At scale you do not write one rule per indicator. A &lt;code&gt;dataset&lt;/code&gt; matches a buffer against an external list and stays fast with tens of thousands of entries — the right structure for threat-intel feeds of bad JA3 hashes or domains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;alert tls $HOME_NET any -&amp;gt; $EXTERNAL_NET any ( \
    msg:"MALWARE TLS JA3 on threat-intel list"; \
    ja3.hash; dataset:isset,bad_ja3, type string, load bad_ja3.lst; \
    flow:established,to_server; \
    classtype:trojan-activity; sid:1000031; rev:1;)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Level 5 — byte math for protocol and exploit detection
&lt;/h3&gt;

&lt;p&gt;When detection depends on a value the protocol &lt;em&gt;computes&lt;/em&gt; — a length field, a record count, an opcode — you need arithmetic on raw bytes. &lt;code&gt;byte_extract&lt;/code&gt; pulls a value into a variable, &lt;code&gt;byte_test&lt;/code&gt; compares against one, and &lt;code&gt;byte_jump&lt;/code&gt; moves the cursor by a computed amount.&lt;/p&gt;

&lt;p&gt;A generic "declared length is absurdly large" check — the shape of many buffer-overflow attempts against a length-prefixed protocol:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;alert tcp $EXTERNAL_NET any -&amp;gt; $HOME_NET 1521 ( \
    msg:"EXPLOIT Oversized declared length field (possible overflow)"; \
    flow:established,to_server; \
    content:"|00 03|"; offset:0; depth:2; \
    byte_test:2, &amp;gt;, 4096, 2; \
    classtype:attempted-admin; \
    metadata:mitre_tactic_id TA0001, mitre_technique_id T1190; \
    sid:1000040; rev:1;)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read &lt;code&gt;byte_test:2, &amp;gt;, 4096, 2&lt;/code&gt; as: take the 2-byte value at offset 2 and match if it is greater than 4096. Swap the operator for &lt;code&gt;&amp;amp;&lt;/code&gt; to test individual flag bits (&lt;code&gt;byte_test:1, &amp;amp;, 0x80, 3&lt;/code&gt; matches when the high bit of the byte at offset 3 is set).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;byte_jump&lt;/code&gt; is the keyword that makes length-prefixed protocols matchable: read a length field, then skip exactly that many bytes so the cursor lands on whatever comes after a variable-length blob. Consider a protocol shaped as &lt;code&gt;[magic][2-byte length][payload of that length][command byte]&lt;/code&gt; — you want the command byte, but you cannot know its offset in advance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;alert tcp $HOME_NET any -&amp;gt; $EXTERNAL_NET any ( \
    msg:"MALWARE C2 command after variable-length field"; \
    flow:established,to_server; \
    content:"|aa bb|"; depth:2; \
    byte_jump:2, 0, relative; \
    content:"|01|"; distance:0; within:1; \
    classtype:trojan-activity; sid:1000042; rev:1;)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step through it: &lt;code&gt;content:"|aa bb|"&lt;/code&gt; matches the magic at the start and leaves the cursor at byte 2. &lt;code&gt;byte_jump:2, 0, relative&lt;/code&gt; reads a 2-byte value at the cursor (the length field) and advances the cursor forward by that many bytes — over the whole variable payload. The final &lt;code&gt;content:"|01|"; distance:0; within:1&lt;/code&gt; then checks the single byte the cursor now points at. One rule, correct regardless of how long the payload is. Add &lt;code&gt;multiplier N&lt;/code&gt; when the length is counted in words rather than bytes, or &lt;code&gt;from_beginning&lt;/code&gt; to jump from the start of the buffer instead of the current position.&lt;/p&gt;

&lt;p&gt;For proprietary binary C2 with no protocol parser to lean on, you match the wire bytes directly — and &lt;code&gt;dsize&lt;/code&gt; (payload length) is a cheap, discriminating pre-filter. A fixed-size command header is a good example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;alert tcp $HOME_NET any -&amp;gt; $EXTERNAL_NET any ( \
    msg:"MALWARE Custom C2 fixed-size beacon"; \
    flow:established,to_server; dsize:16; \
    content:"|de ad be ef|"; depth:4; \
    content:"|01|"; distance:3; within:1; \
    classtype:trojan-activity; sid:1000041; rev:1;)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;dsize:16&lt;/code&gt; throws out everything that is not exactly a 16-byte payload before any content match runs — the kind of early bail-out that keeps a sensor fast.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tuning out false positives
&lt;/h3&gt;

&lt;p&gt;This is the discipline that separates a rule you &lt;em&gt;wrote&lt;/em&gt; from a rule you can &lt;em&gt;run&lt;/em&gt;. The pattern is always the same: keep the true positives, carve out the benign traffic that happens to look similar — without gutting the rule into uselessness.&lt;/p&gt;

&lt;p&gt;The first tool is negation. If a legitimate host or client trips your hunting rule, exclude precisely that, and nothing more:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Base hunting rule — a generic C2 gate, but a legit app also posts to /gate.php
alert http $HOME_NET any -&amp;gt; $EXTERNAL_NET any ( \
    msg:"HUNT Request to /gate.php"; \
    flow:established,to_server; \
    http.uri; content:"/gate.php"; \
    classtype:bad-unknown; sid:1000050; rev:1;)

# Same rule, minus the one benign app — note the rev bump
alert http $HOME_NET any -&amp;gt; $EXTERNAL_NET any ( \
    msg:"HUNT Request to /gate.php"; \
    flow:established,to_server; \
    http.uri; content:"/gate.php"; \
    http.host; content:!"updates.legit-vendor.com"; \
    classtype:bad-unknown; sid:1000050; rev:2;)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can exclude on any buffer — a specific header the benign client sends (&lt;code&gt;http.header; content:!"X-Requested-With: com.legit.app";&lt;/code&gt;), or the &lt;em&gt;absence&lt;/em&gt; of one the malware never sets (&lt;code&gt;http.header_names; content:!"User-Agent";&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The second tool is tightening the anchor. A short match at the wrong offset produces phantom hits. Suppose &lt;code&gt;content:"Gh0st"; offset:8; depth:5;&lt;/code&gt; false-positives on unrelated traffic that happens to contain that string. Pin it to the byte that actually precedes it in the real protocol:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;content:"|00|Gh0st"; offset:7; depth:6;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same string, but now it only matches where a null byte sits immediately before it — the structural detail the coincidental traffic lacks. Every false positive is a lesson about a byte you were not yet constraining.&lt;/p&gt;

&lt;h3&gt;
  
  
  Making rules production-ready
&lt;/h3&gt;

&lt;p&gt;A rule that matches is only half the job. Before it goes live:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Order for the early bail-out.&lt;/strong&gt; Suricata evaluates cheap checks before expensive ones, so give it a reason to quit early: lead with &lt;code&gt;dsize&lt;/code&gt;, &lt;code&gt;flow&lt;/code&gt;, and a long &lt;code&gt;content&lt;/code&gt; before any &lt;code&gt;pcre&lt;/code&gt;. Never ship a regex-only rule — always pair &lt;code&gt;pcre&lt;/code&gt; with at least one &lt;code&gt;content&lt;/code&gt; the engine can reject on first. Profiling (&lt;code&gt;--enable-profiling&lt;/code&gt;) shows you which rules are actually eating CPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick the fast pattern deliberately.&lt;/strong&gt; Run &lt;code&gt;suricata --engine-analysis&lt;/code&gt; and read &lt;code&gt;rules_analysis.txt&lt;/code&gt; — it tells you which &lt;code&gt;content&lt;/code&gt; Suricata chose as the fast pattern and warns about rules with weak or no anchor. Set &lt;code&gt;fast_pattern&lt;/code&gt; on the longest, rarest &lt;code&gt;content&lt;/code&gt;; a bad fast pattern is the usual cause of a sensor falling behind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tune, do not silence.&lt;/strong&gt; If a rule is noisy, reach for &lt;code&gt;threshold&lt;/code&gt;/&lt;code&gt;detection_filter&lt;/code&gt; and tighter buffers before you disable it. An alert firing 5,000 times has told you nothing 4,999 times.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version and source everything.&lt;/strong&gt; &lt;code&gt;rev&lt;/code&gt;, &lt;code&gt;created_at&lt;/code&gt;/&lt;code&gt;updated_at&lt;/code&gt;, and a &lt;code&gt;reference&lt;/code&gt; are what let you manage rules at scale; &lt;code&gt;suricata-update&lt;/code&gt; expects that discipline when it merges your local rules with feeds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Right-size the action.&lt;/strong&gt; Ship as &lt;code&gt;alert&lt;/code&gt; first, watch it in production, and only promote a high-confidence rule to &lt;code&gt;drop&lt;/code&gt; once you trust it — a false-positive &lt;code&gt;drop&lt;/code&gt; is an outage.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Testing your rules before you trust them
&lt;/h3&gt;

&lt;p&gt;A rule you have not tested is a hypothesis, not a control. This is where Scapy closes the loop: craft the exact packet your rule should catch, replay it, and confirm the alert.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;scapy.all&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;

&lt;span class="n"&gt;pkt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;IP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10.0.0.53&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nc"&gt;UDP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dport&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;53&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nc"&gt;DNS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;rd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qd&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;DNSQR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;qname&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="mi"&gt;45&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.exfil.example&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="nf"&gt;wrpcap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;test_tunnel.pcap&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pkt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;suricata &lt;span class="nt"&gt;-r&lt;/span&gt; test_tunnel.pcap &lt;span class="nt"&gt;-c&lt;/span&gt; suricata.yaml &lt;span class="nt"&gt;-S&lt;/span&gt; local.rules &lt;span class="nt"&gt;-l&lt;/span&gt; ./logs/
jq &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'select(.event_type=="alert").alert.signature'&lt;/span&gt; logs/eve.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One caveat worth internalizing: if you edit a field on a packet Scapy already built, it does &lt;strong&gt;not&lt;/strong&gt; recompute checksums until it re-serializes. Read such a doctored pcap back with &lt;code&gt;tcpdump -v&lt;/code&gt; and you will see &lt;code&gt;bad cksum&lt;/code&gt;. That is expected — Scapy fixes the checksum on send, not on assignment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Part 6 — Tying it together
&lt;/h2&gt;

&lt;p&gt;A realistic investigation is not one tool; it is a relay:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Reduce&lt;/strong&gt; with SiLK — find the handful of flows that are anomalous by volume, timing, or destination.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconstruct&lt;/strong&gt; with Zeek — pull the DNS, HTTP, and TLS logs for those hosts and build a timeline off the shared &lt;code&gt;uid&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Confirm&lt;/strong&gt; with tcpdump/tshark — carve the exact packets and read the payload where it is not encrypted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operationalize&lt;/strong&gt; with Suricata — encode what you found as a rule so the next occurrence pages you instead of hiding in a pcap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify&lt;/strong&gt; with Scapy — replay a synthetic version of the attack and prove the rule fires.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Detection engineering is that last step done deliberately. Every incident should leave behind a tested rule, so your coverage compounds instead of resetting with each investigation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls I learned the slow way
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Match parsed fields, not raw payload, whenever a buffer exists.&lt;/strong&gt; &lt;code&gt;http.host; content:"x"&lt;/code&gt; is precise and fast; a bare &lt;code&gt;content:"x"&lt;/code&gt; scans everything and misfires.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope with &lt;code&gt;flow&lt;/code&gt;.&lt;/strong&gt; Most noisy rules are noisy because they match both directions or unestablished traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Threshold high-frequency signals.&lt;/strong&gt; A rule that fires 5,000 times has told you nothing 4,999 times.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know your offsets.&lt;/strong&gt; DNS additional records are &lt;code&gt;udp[18:2]&lt;/code&gt;, not the question count (&lt;code&gt;udp[12:2]&lt;/code&gt;). A rule built on the wrong offset looks fine and silently never matches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test with real bytes.&lt;/strong&gt; Craft the packet, replay it, read the alert. Trust nothing you have not seen fire.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Further reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Suricata rules — &lt;a href="https://docs.suricata.io/en/latest/rules/index.html" rel="noopener noreferrer"&gt;https://docs.suricata.io/en/latest/rules/index.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Zeek scripting — &lt;a href="https://docs.zeek.org/en/master/scripting/" rel="noopener noreferrer"&gt;https://docs.zeek.org/en/master/scripting/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;tcpdump / pcap-filter man pages — &lt;code&gt;man pcap-filter&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The per-tool cheat sheets that go with this article — tcpdump, tshark, Zeek, Snort, Suricata, SiLK, and Scapy — are in the &lt;a href="https://github.com/mar0ls/cheatsheet" rel="noopener noreferrer"&gt;repository&lt;/a&gt; alongside it.&lt;/p&gt;

</description>
      <category>suricata</category>
      <category>tcpdump</category>
      <category>tshark</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Self-hosting Agentless EDR -bladedr 0.9.0: what works and what is still beta</title>
      <dc:creator>Marcin</dc:creator>
      <pubDate>Mon, 27 Jul 2026 19:59:44 +0000</pubDate>
      <link>https://dev.to/mar0ls/self-hosting-agentless-edr-bladedr-090-what-works-and-what-is-still-beta-k4j</link>
      <guid>https://dev.to/mar0ls/self-hosting-agentless-edr-bladedr-090-what-works-and-what-is-still-beta-k4j</guid>
      <description>

&lt;p&gt;description: "A practical look at bladedr, its agentless Linux scanning model, current limits, and a Docker Compose deployment."&lt;/p&gt;

&lt;p&gt;This post covers&lt;br&gt;
&lt;a href="https://github.com/mar0ls/bladedr/releases/tag/v0.9.0" rel="noopener noreferrer"&gt;&lt;code&gt;v0.9.0&lt;/code&gt;&lt;/a&gt;, released on&lt;br&gt;
July 27, 2026. I pulled the published image, ran its key generator, started the&lt;br&gt;
server, checked its health endpoints, and logged in. I also checked the commands&lt;br&gt;
below against the tagged repository.&lt;/p&gt;

&lt;p&gt;I have not audited the code or run bladedr on a production fleet. The eBPF&lt;br&gt;
sensor and response actions are marked Beta by the project. Risk scoring is&lt;br&gt;
experimental.&lt;/p&gt;
&lt;h2&gt;
  
  
  What bladedr does
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/mar0ls/bladedr" rel="noopener noreferrer"&gt;bladedr&lt;/a&gt; scans Linux hosts over SSH. The&lt;br&gt;
server uploads a static binary called &lt;code&gt;bladedr-probe&lt;/code&gt;, runs it on the target,&lt;br&gt;
and receives the findings as JSON. The probe reads a snapshot of system state,&lt;br&gt;
including data from &lt;code&gt;/proc&lt;/code&gt;, and evaluates detection rules written in YAML and&lt;br&gt;
CEL.&lt;/p&gt;

&lt;p&gt;There is no daemon running on the target between agentless scans. The uploaded&lt;br&gt;
probe is cached under &lt;code&gt;/tmp/.bladedr/&lt;/code&gt;, so "agentless" does not mean that the&lt;br&gt;
scan leaves no file behind. It means there is no resident process, package, or&lt;br&gt;
kernel module in the default scanning mode.&lt;/p&gt;

&lt;p&gt;The optional eBPF tier is different. &lt;code&gt;bladedr-sensor&lt;/code&gt; is a long-running service&lt;br&gt;
that wraps Tetragon. It requires root, Docker, and a kernel that supports BTF.&lt;/p&gt;

&lt;p&gt;The project has four main binaries:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;bladedr-server&lt;/code&gt;: API, web console, inventory, scan scheduling, and storage;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bladedr-probe&lt;/code&gt;: the program executed during an SSH scan;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bladectl&lt;/code&gt;: a command-line API client;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bladedr-sensor&lt;/code&gt;: the optional Tetragon event forwarder.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;mar0ls/bladedr:0.9.0&lt;/code&gt; image supports &lt;code&gt;linux/amd64&lt;/code&gt; and &lt;code&gt;linux/arm64&lt;/code&gt;. It&lt;br&gt;
contains probe and sensor binaries for both architectures. The server can&lt;br&gt;
therefore scan either architecture without a Go toolchain on the control-plane&lt;br&gt;
host. Version 0.9.0 reports 87 built-in detection rules at startup.&lt;/p&gt;

&lt;p&gt;The rules are data rather than compiled Go code. Filesystem and database rules&lt;br&gt;
can extend or override the built-in YAML and CEL rules. The server also tracks&lt;br&gt;
per-host baselines, reports drift, calculates fleet rarity, supports triage and&lt;br&gt;
audit logging, and exports ECS/JSON records.&lt;/p&gt;
&lt;h2&gt;
  
  
  The limit of an SSH snapshot
&lt;/h2&gt;

&lt;p&gt;An SSH scan only sees state that exists while the probe is running. A short&lt;br&gt;
process or network connection can start and stop between scans without being&lt;br&gt;
observed. Continuous telemetry needs the optional sensor tier or another&lt;br&gt;
runtime monitoring tool.&lt;/p&gt;

&lt;p&gt;The project publishes a&lt;br&gt;
&lt;a href="https://github.com/mar0ls/bladedr/blob/v0.9.0/COVERAGE.md" rel="noopener noreferrer"&gt;coverage matrix&lt;/a&gt;.&lt;br&gt;
It lists implemented detections and known gaps. A successful installation does&lt;br&gt;
not imply complete detection coverage.&lt;/p&gt;

&lt;p&gt;The SSH account also affects visibility. The probe can run without root, but an&lt;br&gt;
unprivileged account cannot read all system data. Giving the control plane root&lt;br&gt;
SSH credentials improves visibility while increasing the impact of a control&lt;br&gt;
plane compromise.&lt;/p&gt;
&lt;h2&gt;
  
  
  What is not stable yet
&lt;/h2&gt;

&lt;p&gt;The project's&lt;br&gt;
&lt;a href="https://github.com/mar0ls/bladedr/blob/v0.9.0/docs/stability.md" rel="noopener noreferrer"&gt;stability document&lt;/a&gt;&lt;br&gt;
uses three levels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;agentless SSH scanning, storage, authentication, the rule engine, and the v1
API are listed as GA;&lt;/li&gt;
&lt;li&gt;the eBPF sensor, server-push sensor deployment, response actions, and
&lt;code&gt;bladectl&lt;/code&gt; are Beta;&lt;/li&gt;
&lt;li&gt;learned risk scoring, attack-emulation training, and retention are
experimental.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The image does not ship with Tetragon policies. &lt;code&gt;/etc/bladedr/policies&lt;/code&gt; is&lt;br&gt;
empty. Enabling the sensor requires an operator-supplied &lt;code&gt;TracingPolicy&lt;/code&gt;&lt;br&gt;
bundle.&lt;/p&gt;

&lt;p&gt;Response actions run fixed, allowlisted playbooks instead of accepting arbitrary&lt;br&gt;
shell commands. They require approval from a second administrator by default.&lt;br&gt;
They are still Beta, and the project says they have not been proven on a real&lt;br&gt;
fleet. A bad &lt;code&gt;isolate_host&lt;/code&gt; configuration can cut a machine off from the&lt;br&gt;
control plane.&lt;/p&gt;

&lt;p&gt;Risk scoring only changes the order of findings. It does not generate&lt;br&gt;
detections or suppress an observation.&lt;/p&gt;

&lt;p&gt;There is one stale sentence in the tagged stability document. It says the&lt;br&gt;
release workflow does not publish an image and that Docker Hub is not wired up.&lt;br&gt;
The public &lt;code&gt;0.9.0&lt;/code&gt; image exists and can be pulled, so that note no longer matches&lt;br&gt;
the release.&lt;/p&gt;
&lt;h2&gt;
  
  
  Deploying the control plane
&lt;/h2&gt;

&lt;p&gt;The repository includes a Compose file for the server and its database. This&lt;br&gt;
setup uses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;mar0ls/bladedr:0.9.0&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;paradedb/paradedb:0.23.5&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;a named volume for the database;&lt;/li&gt;
&lt;li&gt;a server port bound to &lt;code&gt;127.0.0.1&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;ParadeDB is used because observation search depends on the BM25 index provided&lt;br&gt;
by &lt;code&gt;pg_search&lt;/code&gt;. Stock PostgreSQL is not the documented production backend.&lt;br&gt;
Without &lt;code&gt;BLADEDR_DATABASE_URL&lt;/code&gt;, bladedr uses an in-memory store and loses its&lt;br&gt;
state on restart.&lt;/p&gt;
&lt;h3&gt;
  
  
  Download the tagged Compose file
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir &lt;/span&gt;bladedr-deploy
&lt;span class="nb"&gt;cd &lt;/span&gt;bladedr-deploy

curl &lt;span class="nt"&gt;-fsSLo&lt;/span&gt; compose.yml &lt;span class="se"&gt;\&lt;/span&gt;
  https://raw.githubusercontent.com/mar0ls/bladedr/v0.9.0/docs/compose.example.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The file pins version &lt;code&gt;0.9.0&lt;/code&gt;. The public image index had this digest when I&lt;br&gt;
checked it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sha256:78533b520303373beb9d97b382d4aee9d173242428f5d67aa07dd19817041368
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A version tag makes upgrades explicit. A digest is stricter because a registry&lt;br&gt;
owner can move a tag, while the digest identifies the exact image content.&lt;br&gt;
&lt;code&gt;latest&lt;/code&gt; is fine for a temporary test, but I would not use it for this control&lt;br&gt;
plane.&lt;/p&gt;
&lt;h3&gt;
  
  
  Generate the node key
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; mar0ls/bladedr:0.9.0 &lt;span class="nt"&gt;-keygen&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The command prints &lt;code&gt;BLADEDR_NODE_KEY&lt;/code&gt; and a public key. Save&lt;br&gt;
&lt;code&gt;BLADEDR_NODE_KEY&lt;/code&gt;. It decrypts stored SSH credentials. Losing it makes those&lt;br&gt;
credentials unrecoverable, even if the database is intact. A database copy on&lt;br&gt;
its own does not contain enough information to decrypt them.&lt;/p&gt;
&lt;h3&gt;
  
  
  Create the environment file
&lt;/h3&gt;

&lt;p&gt;Generate a database password that is safe to place directly in a PostgreSQL&lt;br&gt;
connection string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 32
&lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 600 /dev/null .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Edit &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BLADEDR_NODE_KEY=VALUE_PRINTED_BY_KEYGEN
POSTGRES_PASSWORD=RANDOM_HEX_PASSWORD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not commit this file. Keep a backup of the node key separately from the&lt;br&gt;
database dump.&lt;/p&gt;
&lt;h3&gt;
  
  
  Start the stack
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; compose.yml up &lt;span class="nt"&gt;-d&lt;/span&gt;
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; compose.yml ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The database port is not published on the host. The web server listens on&lt;br&gt;
&lt;code&gt;127.0.0.1:8080&lt;/code&gt;, using plain HTTP in this example.&lt;/p&gt;

&lt;p&gt;Check readiness:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; http://127.0.0.1:8080/readyz
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expected response:&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="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"ready"&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;&lt;code&gt;/readyz&lt;/code&gt; also checks the data store. &lt;code&gt;/healthz&lt;/code&gt; and &lt;code&gt;/metrics&lt;/code&gt; are available&lt;br&gt;
without authentication. Keep all three endpoints internal unless there is a&lt;br&gt;
reason to expose them.&lt;/p&gt;
&lt;h3&gt;
  
  
  Get the initial admin password
&lt;/h3&gt;

&lt;p&gt;On a fresh database, the server creates an &lt;code&gt;admin&lt;/code&gt; account. If&lt;br&gt;
&lt;code&gt;BLADEDR_ADMIN_PASSWORD&lt;/code&gt; is not set, it generates a password and writes it to&lt;br&gt;
the startup log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; compose.yml logs server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://127.0.0.1:8080/ui/login
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generated password must be changed after the first login. It remains in the&lt;br&gt;
container logs, so those logs should be treated as sensitive.&lt;/p&gt;

&lt;p&gt;For a remote control-plane host, an SSH tunnel avoids publishing the plain HTTP&lt;br&gt;
port:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; 8080:127.0.0.1:8080 operator@control-plane-host
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The console will then be available on the local machine at&lt;br&gt;
&lt;code&gt;http://127.0.0.1:8080/ui/login&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For permanent access, terminate TLS in bladedr or at a reverse proxy. Direct TLS&lt;br&gt;
uses &lt;code&gt;BLADEDR_TLS_CERT&lt;/code&gt; and &lt;code&gt;BLADEDR_TLS_KEY&lt;/code&gt;. When TLS terminates at a proxy,&lt;br&gt;
set &lt;code&gt;BLADEDR_SECURE_COOKIES=1&lt;/code&gt;. Configure &lt;code&gt;BLADEDR_TRUSTED_PROXY_CIDRS&lt;/code&gt; as well;&lt;br&gt;
otherwise forwarded client IP headers are ignored and login rate limiting and&lt;br&gt;
audit records see the proxy address.&lt;/p&gt;
&lt;h2&gt;
  
  
  Adding a Linux host
&lt;/h2&gt;

&lt;p&gt;The control plane needs outbound SSH access to the target. In the web console:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open &lt;code&gt;Hosts&lt;/code&gt; and expand &lt;code&gt;Add host&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Enter the IP address, SSH port, username, and either a password or private
key.&lt;/li&gt;
&lt;li&gt;Select &lt;code&gt;amd64&lt;/code&gt; or &lt;code&gt;arm64&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Keep the mode set to &lt;code&gt;scan_only&lt;/code&gt; unless you have supplied Tetragon policies.&lt;/li&gt;
&lt;li&gt;Add the host and click &lt;code&gt;Scan&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The SSH secret is sealed with the node key before it is stored. The server pins&lt;br&gt;
the SSH host key on first use. That is TOFU, so the first connection should take&lt;br&gt;
place over a path you trust.&lt;/p&gt;

&lt;p&gt;No repository checkout or compiler is required on the target. The server&lt;br&gt;
uploads the probe included in its container image.&lt;/p&gt;
&lt;h2&gt;
  
  
  Backup and upgrades
&lt;/h2&gt;

&lt;p&gt;Dump the database before an upgrade:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; compose.yml &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-T&lt;/span&gt; db &lt;span class="se"&gt;\&lt;/span&gt;
  pg_dump &lt;span class="nt"&gt;-U&lt;/span&gt; bladedr bladedr &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; bladedr-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%F&lt;span class="si"&gt;)&lt;/span&gt;.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Back up &lt;code&gt;BLADEDR_NODE_KEY&lt;/code&gt; separately. The dump and node key together are the&lt;br&gt;
documented complete backup.&lt;/p&gt;

&lt;p&gt;Database migrations run when the server starts. Downgrading to a version that&lt;br&gt;
predates an applied migration is not supported. Read the&lt;br&gt;
&lt;a href="https://github.com/mar0ls/bladedr/blob/v0.9.0/CHANGELOG.md" rel="noopener noreferrer"&gt;&lt;code&gt;CHANGELOG.md&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
before changing the image version.&lt;/p&gt;

&lt;p&gt;The 0.9.0 upgrade invalidates existing sessions and replaces the old shared&lt;br&gt;
sensor token with per-host tokens. Its release notes say that hosts, scans,&lt;br&gt;
observations, triage state, rules, baselines, credentials, and audit history are&lt;br&gt;
preserved.&lt;/p&gt;

&lt;h2&gt;
  
  
  My take
&lt;/h2&gt;

&lt;p&gt;Version 0.9.0 is usable for testing central SSH-based scans, writing CEL rules,&lt;br&gt;
and checking how agentless detection fits a Linux environment. The published&lt;br&gt;
image removes the need to compile the server and its architecture-specific&lt;br&gt;
probes.&lt;/p&gt;

&lt;p&gt;I would test it on disposable or non-production hosts first. The control plane&lt;br&gt;
holds SSH access to the fleet, agentless scans have gaps between snapshots, and&lt;br&gt;
the continuous sensor path does not yet include a policy bundle. Those are&lt;br&gt;
important design and maturity limits, not installation details.&lt;/p&gt;

&lt;p&gt;The source, tagged binaries, SBOM, checksums, and container image are public.&lt;br&gt;
Start with the coverage matrix and compare it with the threats that matter in&lt;br&gt;
your environment before granting access to production machines.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/mar0ls/bladedr" rel="noopener noreferrer"&gt;GitHub repository&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/mar0ls/bladedr/releases/tag/v0.9.0" rel="noopener noreferrer"&gt;v0.9.0 release&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://hub.docker.com/r/mar0ls/bladedr" rel="noopener noreferrer"&gt;Docker Hub image&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/mar0ls/bladedr/blob/v0.9.0/docs/deployment.md" rel="noopener noreferrer"&gt;deployment notes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/mar0ls/bladedr/blob/v0.9.0/docs/stability.md" rel="noopener noreferrer"&gt;stability status&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/mar0ls/bladedr/blob/v0.9.0/SECURITY.md" rel="noopener noreferrer"&gt;security policy&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>edr</category>
      <category>linux</category>
      <category>agentless</category>
      <category>docker</category>
    </item>
    <item>
      <title>Managing System Proxy Settings in Go Across macOS, Linux, and Windows</title>
      <dc:creator>Marcin</dc:creator>
      <pubDate>Sat, 25 Jul 2026 20:11:13 +0000</pubDate>
      <link>https://dev.to/mar0ls/managing-system-proxy-settings-in-go-across-macos-linux-and-windows-55j6</link>
      <guid>https://dev.to/mar0ls/managing-system-proxy-settings-in-go-across-macos-linux-and-windows-55j6</guid>
      <description>&lt;p&gt;Picture this: you're writing a Go integration-test harness that must route all traffic through a corporate proxy during the test run, then leave the developer's machine exactly as it found it. You reach for &lt;code&gt;HTTP_PROXY&lt;/code&gt; — and quickly discover that system-level proxy configuration is a different problem entirely.&lt;/p&gt;

&lt;p&gt;On macOS you need &lt;code&gt;networksetup&lt;/code&gt;. GNOME uses &lt;code&gt;gsettings&lt;/code&gt;. KDE has its own tooling. Windows stores proxy settings in the registry. Add authentication, PAC files, cleanup, and error handling, and what looked like a one-afternoon feature becomes a collection of platform-specific scripts that nobody wants to maintain.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/mar0ls/go-sysproxy" rel="noopener noreferrer"&gt;&lt;code&gt;go-sysproxy&lt;/code&gt;&lt;/a&gt; wraps those native mechanisms in a single Go API. The rest of this article uses the test-harness scenario to walk through the library feature by feature — from a first proxy to full multi-protocol configuration, safe restore, and the CLI.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Verification scope: this article was checked against the tagged &lt;a href="https://github.com/mar0ls/go-sysproxy/tree/v0.5.2" rel="noopener noreferrer"&gt;&lt;code&gt;v0.5.2&lt;/code&gt; source&lt;/a&gt; at commit &lt;code&gt;14e44f6&lt;/code&gt;. All public API examples were verified against the implementation, and &lt;code&gt;go test ./...&lt;/code&gt; passes with Go 1.26.2 on &lt;code&gt;darwin/arm64&lt;/code&gt;. Platform descriptions come from inspection of platform-specific source and unit tests; they are not presented as live integration-test results for every supported desktop and Windows version.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Installation
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;go-sysproxy&lt;/code&gt; requires Go 1.22 or newer and has no external Go module dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go get github.com/mar0ls/go-sysproxy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sysproxy&lt;/span&gt; &lt;span class="s"&gt;"github.com/mar0ls/go-sysproxy"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Your first system proxy
&lt;/h2&gt;

&lt;p&gt;The shortest example sets a proxy, runs some code, then clears it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"log"&lt;/span&gt;
    &lt;span class="n"&gt;sysproxy&lt;/span&gt; &lt;span class="s"&gt;"github.com/mar0ls/go-sysproxy"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;main&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="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"http://proxy.example.com:8080"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ScopeGlobal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="k"&gt;func&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="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ScopeGlobal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"could not clear proxy: %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&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="c"&gt;// Run your network-aware code here.&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;Set&lt;/code&gt; accepts URLs with credentials:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://username:password@proxy.example.com:8080
socks5://username:password@proxy.example.com:1080
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the test harness — where you want a hard deadline on how long a proxy change can take — the context-aware variant is the right choice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"http://proxy.example.com:8080"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ScopeGlobal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The implementation checks an already-cancelled context before side effects. Command-backed operations use &lt;code&gt;exec.CommandContext&lt;/code&gt;; this does not make a multi-step OS update transactional. The same pattern is available through &lt;code&gt;UnsetContext&lt;/code&gt;, &lt;code&gt;GetContext&lt;/code&gt;, &lt;code&gt;GetConfigContext&lt;/code&gt;, &lt;code&gt;SetMultiContext&lt;/code&gt;, and &lt;code&gt;SetPACContext&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choose the right scope
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;Set&lt;/code&gt; call above used &lt;code&gt;ScopeGlobal&lt;/code&gt;. The scope controls how far the change reaches:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scope&lt;/th&gt;
&lt;th&gt;What it changes&lt;/th&gt;
&lt;th&gt;Typical use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ScopeShell&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Proxy environment variables in the current process&lt;/td&gt;
&lt;td&gt;One-off commands, child processes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ScopeUser&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Current process plus shell startup files or the PowerShell profile&lt;/td&gt;
&lt;td&gt;Persistent per-user development setup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ScopeGlobal&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Current process plus the native OS proxy store&lt;/td&gt;
&lt;td&gt;Desktop apps, VPN clients, proxy switchers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One nuance: &lt;code&gt;ScopeGlobal&lt;/code&gt; is an API-level label, not a machine-wide guarantee. On Windows, the backend writes &lt;code&gt;HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings&lt;/code&gt;, which is scoped to the current Windows user. On Linux, writing &lt;code&gt;/etc/environment&lt;/code&gt; can require root. &lt;code&gt;Get&lt;/code&gt; and &lt;code&gt;GetConfig&lt;/code&gt; read the OS-level configuration, so they are intended for global proxy state, not shell or user-profile values.&lt;/p&gt;

&lt;p&gt;For the integration-test scenario, &lt;code&gt;ScopeGlobal&lt;/code&gt; is the right choice: it reaches desktop apps and other processes that read the native proxy store, which is exactly what you want when testing through a corporate gateway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The safer pattern: apply, run, restore
&lt;/h2&gt;

&lt;p&gt;The naive approach — &lt;code&gt;Set&lt;/code&gt; at the start, &lt;code&gt;Unset&lt;/code&gt; at the end — breaks the moment a developer already has a proxy configured. Your harness would clobber their setup and fail to restore it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;WithProxy&lt;/code&gt; solves this properly. It reads the current &lt;code&gt;ProxyConfig&lt;/code&gt;, applies the temporary proxy, runs a callback, and then restores the previous state — regardless of whether the callback succeeds or fails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithProxy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"socks5://proxy.example.com:1080"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ScopeGlobal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;runIntegrationTests&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For &lt;code&gt;ScopeGlobal&lt;/code&gt;, the snapshot is a full &lt;code&gt;ProxyConfig&lt;/code&gt; containing HTTP, HTTPS, SOCKS, the bypass list, and PAC fields. The restore path reapplies PAC when &lt;code&gt;PAC&lt;/code&gt; is non-empty; otherwise it reapplies the manual protocol fields, or calls &lt;code&gt;Unset&lt;/code&gt; if none were set.&lt;/p&gt;

&lt;p&gt;Two things to know about restore:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Restore errors are not returned to the caller. Critical failures go to the optional package logger instead. If your harness needs to detect a failed restore, install a logger and verify state with &lt;code&gt;GetConfig&lt;/code&gt; afterward.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ScopeShell&lt;/code&gt; and &lt;code&gt;ScopeUser&lt;/code&gt; do not have a read-back snapshot path. With those scopes, &lt;code&gt;WithProxy&lt;/code&gt; calls &lt;code&gt;Unset&lt;/code&gt; after the callback — it does not restore pre-existing environment or profile values.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Configure each protocol separately
&lt;/h2&gt;

&lt;p&gt;A single proxy URL works for many cases. Corporate environments often require separate endpoints for HTTP, HTTPS, and SOCKS. &lt;code&gt;SetMulti&lt;/code&gt; handles that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetMulti&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProxyConfig&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="s"&gt;"http://http-proxy.example.com:8080"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;HTTPS&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="s"&gt;"http://https-proxy.example.com:8443"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;SOCKS&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="s"&gt;"socks5://socks-proxy.example.com:1080"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;NoProxy&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"localhost,127.0.0.1,10.0.0.0/8"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ScopeGlobal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The implementation validates non-empty HTTP, HTTPS, and SOCKS URLs before starting side effects. Note that &lt;code&gt;SetMulti&lt;/code&gt; does not apply the &lt;code&gt;PAC&lt;/code&gt; field — use &lt;code&gt;SetPAC&lt;/code&gt; for PAC mode. Backend behavior is not fully transactional: the Windows path, for example, enables &lt;code&gt;ProxyEnable&lt;/code&gt; before writing the individual protocol entries.&lt;/p&gt;

&lt;p&gt;There is a temporary multi-protocol variant too, with the same read-back restore semantics as &lt;code&gt;WithProxy&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithProxyMulti&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProxyConfig&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="s"&gt;"http://proxy.example.com:8080"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;HTTPS&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"http://proxy.example.com:8080"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ScopeGlobal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;runIntegrationTests&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Work with PAC files
&lt;/h2&gt;

&lt;p&gt;Some networks distribute proxy configuration via Proxy Auto-Config files. To request PAC mode from the selected backend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SetPAC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"https://config.example.com/proxy.pac"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ScopeGlobal&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;The validator accepts &lt;code&gt;http://&lt;/code&gt;, &lt;code&gt;https://&lt;/code&gt;, and &lt;code&gt;file://&lt;/code&gt; URLs. To inspect what is currently active:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetConfig&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"HTTP:    %s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"HTTPS:   %s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTPS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SOCKS:   %s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SOCKS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"NoProxy: %s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NoProxy&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"PAC:     %s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PAC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When automatic proxy configuration is active, &lt;code&gt;PAC&lt;/code&gt; holds the URL. For manual mode, the individual protocol fields hold the configured endpoints.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handle errors without parsing strings
&lt;/h2&gt;

&lt;p&gt;System proxy configuration can be in several distinct states: no proxy, a disabled proxy, an unsupported target, or a non-critical persistence error. The package exposes sentinel errors and helper functions instead of requiring string parsing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetConfig&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"current proxy: %+v&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrProxyNotSet&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"no proxy is configured"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrProxyNotEnabled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"the manual proxy is not enabled"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrUnsupportedPlatform&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"this operating system is not supported"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&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;ErrToolMissing&lt;/code&gt; is returned when a required binary is absent — currently for the per-application Git and npm paths:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteAppConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AppGit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;proxyURL&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;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ErrToolMissing&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"git is not available in PATH"&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 Linux, failing to update &lt;code&gt;/etc/environment&lt;/code&gt; is wrapped as a non-critical error. A permission failure is additionally classified as requiring elevation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proxyURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ScopeGlobal&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequiresElevation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Println&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"writing /etc/environment requires elevated permissions"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsNonCritical&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"the /etc/environment step failed: %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&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;These helpers classify that persistence error. They do not independently prove that every preceding GNOME or KDE command succeeded, so verify the state with &lt;code&gt;GetConfig&lt;/code&gt; when confirmation matters.&lt;/p&gt;

&lt;p&gt;One broader point that applies across all platforms in v0.5.2: several platform setters intentionally ignore errors from individual native commands. A &lt;code&gt;nil&lt;/code&gt; result therefore does not prove that every sub-step was accepted by the OS. Read state back when your application needs confirmation. On macOS specifically, &lt;code&gt;Get&lt;/code&gt; and &lt;code&gt;GetConfig&lt;/code&gt; inspect the first service returned by &lt;code&gt;networksetup -listallnetworkservices&lt;/code&gt;, while setters iterate over all returned services.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the endpoint before changing the system
&lt;/h2&gt;

&lt;p&gt;Before your test harness reconfigures the OS, it is worth confirming the proxy is actually reachable. &lt;code&gt;Check&lt;/code&gt; opens a TCP connection to the proxy endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Background&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;cancel&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;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"http://proxy.example.com:8080"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatalf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"proxy is unreachable: %v"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&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;This is a reachability check, not a full proxy handshake. It does not authenticate, perform an HTTP &lt;code&gt;CONNECT&lt;/code&gt;, or complete a SOCKS handshake. A successful result means only that the host and port accepted a TCP connection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configure developer tools too
&lt;/h2&gt;

&lt;p&gt;Routing OS traffic through a proxy does not automatically route &lt;code&gt;git clone&lt;/code&gt; or &lt;code&gt;pip install&lt;/code&gt;. The package ships explicit configuration writers for Git, curl, npm, pip, and wget:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;proxyURL&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="s"&gt;"http://proxy.example.com:8080"&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteAppConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AppGit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;proxyURL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&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="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteAppConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AppCurl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;proxyURL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Fatal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// Later:&lt;/span&gt;
&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ClearAppConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AppGit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ClearAppConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sysproxy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AppCurl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These functions update application-specific configuration independently of the OS-level proxy functions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use it without writing Go
&lt;/h2&gt;

&lt;p&gt;The repository includes a standalone CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go &lt;span class="nb"&gt;install &lt;/span&gt;github.com/mar0ls/go-sysproxy/cmd/sysproxy@latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sysproxy &lt;span class="nb"&gt;set &lt;/span&gt;http://127.0.0.1:8080
sysproxy &lt;span class="nb"&gt;set &lt;/span&gt;http://proxy.example.com:8080 &lt;span class="nt"&gt;--scope&lt;/span&gt; global
sysproxy get
sysproxy get &lt;span class="nt"&gt;--json&lt;/span&gt;
sysproxy check http://proxy.example.com:8080 &lt;span class="nt"&gt;--timeout&lt;/span&gt; 10s
sysproxy pac https://config.example.com/proxy.pac
sysproxy &lt;span class="nb"&gt;unset&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One exit-code quirk to be aware of: &lt;code&gt;get&lt;/code&gt; returns exit code &lt;code&gt;2&lt;/code&gt; for any error, including the normal "proxy not set" case. Other command failures return &lt;code&gt;1&lt;/code&gt;. If you are scripting around this, check for both codes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens under the hood
&lt;/h2&gt;

&lt;p&gt;The public API stays the same across platforms; only the backend changes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Native mechanism&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;macOS&lt;/td&gt;
&lt;td&gt;&lt;code&gt;networksetup&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Linux / GNOME&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gsettings&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Linux / KDE&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;kwriteconfig5&lt;/code&gt; for writes; &lt;code&gt;kreadconfig5&lt;/code&gt; or &lt;code&gt;kreadconfig6&lt;/code&gt; for reads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Linux system environment&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Set&lt;/code&gt;/&lt;code&gt;Unset&lt;/code&gt; also edits &lt;code&gt;/etc/environment&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Windows&lt;/td&gt;
&lt;td&gt;Current-user Internet Settings registry keys; &lt;code&gt;Set&lt;/code&gt; calls &lt;code&gt;cmdkey&lt;/code&gt; when URL credentials are present&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All native commands go through &lt;code&gt;exec.CommandContext&lt;/code&gt; with explicit argument lists, not shell construction. When the library creates Unix shell or application configuration files, it requests mode &lt;code&gt;0600&lt;/code&gt;; writing an existing file does not necessarily tighten its previous permissions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical cautions before you ship
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ScopeGlobal&lt;/code&gt; writes outside your process.&lt;/strong&gt; Which applications read that store depends on the platform and the application.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/etc/environment&lt;/code&gt; on Linux may require root.&lt;/strong&gt; Other native commands have their own permission models.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Some native setter subcommands are best-effort in v0.5.2.&lt;/strong&gt; Verify important changes by reading state back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Check&lt;/code&gt; proves TCP reachability only.&lt;/strong&gt; It is not a proxy handshake.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credentials in proxy URLs are handled differently by each backend.&lt;/strong&gt; Avoid hard-coding them, and inspect where the selected OS or application persists them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The package logger emits proxy URLs.&lt;/strong&gt; If you install a logger with &lt;code&gt;SetLogger&lt;/code&gt;, the audit messages from &lt;code&gt;Set&lt;/code&gt; and &lt;code&gt;WriteAppConfig&lt;/code&gt; include the supplied URL. Redact credentials in your adapter before forwarding to a shared system.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;WithProxy&lt;/code&gt; with &lt;code&gt;ScopeGlobal&lt;/code&gt; restores previous state; the other scopes do not.&lt;/strong&gt; Shell and user-profile values are not preserved for &lt;code&gt;ScopeShell&lt;/code&gt; or &lt;code&gt;ScopeUser&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ScopeUser&lt;/code&gt; on Unix appends exports to &lt;code&gt;.bashrc&lt;/code&gt;, &lt;code&gt;.zshrc&lt;/code&gt;, &lt;code&gt;.profile&lt;/code&gt;, and &lt;code&gt;.bash_profile&lt;/code&gt;.&lt;/strong&gt; Repeated calls can append duplicate lines. Inspect that behavior before using it as a configuration reconciler.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Final thoughts
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;go-sysproxy&lt;/code&gt; trades platform-specific scripts for a single Go API. In v0.5.2 that API covers contexts, sentinel errors, per-protocol settings, PAC support, TCP endpoint checks, a global-state restore helper, per-application configuration, and a CLI.&lt;/p&gt;

&lt;p&gt;The scenario that opened this article — a test harness that applies a proxy, runs tests, and restores the previous state without touching the developer's setup — is four lines with &lt;code&gt;WithProxy&lt;/code&gt;. The edge cases (multi-protocol corporate proxies, PAC mode, already-disabled proxies, missing tools) are handled by the same API with explicit error types rather than string matching.&lt;/p&gt;

&lt;p&gt;Relevant source links for those who want to verify the implementation details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/mar0ls/go-sysproxy/blob/v0.5.2/sysproxy.go" rel="noopener noreferrer"&gt;Public API and restore logic&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/mar0ls/go-sysproxy/blob/v0.5.2/sysproxy_darwin.go" rel="noopener noreferrer"&gt;macOS backend&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/mar0ls/go-sysproxy/blob/v0.5.2/sysproxy_linux.go" rel="noopener noreferrer"&gt;Linux and KDE backends&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/mar0ls/go-sysproxy/blob/v0.5.2/sysproxy_windows.go" rel="noopener noreferrer"&gt;Windows backend&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/mar0ls/go-sysproxy/blob/v0.5.2/check.go" rel="noopener noreferrer"&gt;TCP check&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/mar0ls/go-sysproxy/blob/v0.5.2/cmd/sysproxy/main.go" rel="noopener noreferrer"&gt;CLI implementation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The project is open source under the MIT license:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/mar0ls/go-sysproxy" rel="noopener noreferrer"&gt;Source code and documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pkg.go.dev/github.com/mar0ls/go-sysproxy" rel="noopener noreferrer"&gt;Go package reference&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>go</category>
      <category>tutorial</category>
      <category>networking</category>
      <category>proxy</category>
    </item>
    <item>
      <title>JA4 and JA4S fingerprints for QUIC traffic in Wireshark, with a Lua plugin</title>
      <dc:creator>Marcin</dc:creator>
      <pubDate>Sat, 11 Jul 2026 20:29:49 +0000</pubDate>
      <link>https://dev.to/mar0ls/ja4-and-ja4s-fingerprints-for-quic-traffic-in-wireshark-with-a-lua-plugin-29o4</link>
      <guid>https://dev.to/mar0ls/ja4-and-ja4s-fingerprints-for-quic-traffic-in-wireshark-with-a-lua-plugin-29o4</guid>
      <description>&lt;p&gt;More and more TLS traffic runs over QUIC (HTTP/3), and the classic JA3 fingerprint was never defined for it. Its successor, &lt;a href="https://github.com/FoxIO-LLC/ja4" rel="noopener noreferrer"&gt;JA4&lt;/a&gt;, handles QUIC explicitly: the fingerprint gets a &lt;code&gt;q&lt;/code&gt; prefix instead of &lt;code&gt;t&lt;/code&gt;. Wireshark 4.2+ computes JA4 natively, but only the client side — there is no built-in JA4S (server fingerprint). I wrote a Lua plugin that fills that gap: &lt;a href="https://github.com/mar0ls/wireshark_plugin/blob/main/ja4_quic.lua" rel="noopener noreferrer"&gt;ja4_quic.lua&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Wireshark already gives you
&lt;/h2&gt;

&lt;p&gt;Since 4.2, the TLS dissector exposes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;tls.handshake.ja3        tls.handshake.ja3s
tls.handshake.ja4        tls.handshake.ja4_r
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the QUIC dissector hands the ClientHello to the TLS dissector, &lt;code&gt;tls.handshake.ja4&lt;/code&gt; also populates for QUIC Initial packets, with the correct &lt;code&gt;q&lt;/code&gt; prefix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;tshark &lt;span class="nt"&gt;-r&lt;/span&gt; quic-tls-handshake.pcapng &lt;span class="nt"&gt;-T&lt;/span&gt; fields &lt;span class="nt"&gt;-e&lt;/span&gt; tls.handshake.ja4
q13d0310h3_55b375c5d22e_cd85d2d88918
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What's missing from Wireshark's built-in TLS fields is JA4S. JA4 is available under the BSD 3-Clause License, while JA4S is part of the separately licensed JA4+ suite. FoxIO does provide its own JA4+ Wireshark plugin, but JA4S is not implemented by Wireshark's core TLS dissector.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plugin
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ja4_quic.lua&lt;/code&gt; is a postdissector that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;reads JA4 from the built-in field (no point recomputing it — GREASE handling and the QUIC detection are already done),&lt;/li&gt;
&lt;li&gt;computes JA4S from the ServerHello,&lt;/li&gt;
&lt;li&gt;tracks sessions by &lt;code&gt;quic.connection.number&lt;/code&gt; (which survives QUIC connection migration) or &lt;code&gt;tcp.stream&lt;/code&gt;,&lt;/li&gt;
&lt;li&gt;adds filterable fields: &lt;code&gt;ja4_quic.ja4&lt;/code&gt;, &lt;code&gt;ja4_quic.ja4s&lt;/code&gt;, &lt;code&gt;ja4_quic.ja4s_r&lt;/code&gt;, &lt;code&gt;ja4_quic.sni&lt;/code&gt;, &lt;code&gt;ja4_quic.alpn&lt;/code&gt;,&lt;/li&gt;
&lt;li&gt;exports everything to CSV via Tools → Export JA4 Analysis.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  JA4S in one minute
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;q 13 02 00 _ 1301 _ 234ea6891581
| |  |  |    |      |
| |  |  |    |      truncated SHA-256 of the extension list, in order
| |  |  |    cipher suite chosen by the server
| |  |  ALPN chosen ("00" if none visible)
| |  number of extensions
| TLS version (from supported_versions if present)
q = QUIC, t = TCP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One caveat worth knowing: in TLS 1.3 (and therefore in QUIC) the server sends its ALPN choice inside EncryptedExtensions, which is encrypted. Without TLS decryption secrets, a passive analyzer cannot recover that value, so the ALPN part is &lt;code&gt;00&lt;/code&gt;. This is a property of passive TLS 1.3 analysis rather than a limitation specific to the plugin.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation notes
&lt;/h2&gt;

&lt;p&gt;Wireshark doesn't expose a cryptographic hashing API to Lua, so the plugin carries its own SHA-256 implementation. It requires Wireshark 4.4 or newer, which ships with Lua 5.4 and its native bitwise operators. One important detail is that Lua integers are 64-bit, so every 32-bit rotation needs masking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;rotr32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="err"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mh"&gt;0xffffffff&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hash self-tests against a known vector at load time — a silently broken hash producing plausible-looking fingerprints would be worse than a crash.&lt;/p&gt;

&lt;p&gt;Another Wireshark-specific gotcha: a postdissector runs again every time the GUI re-dissects a packet (clicking it in the packet list), so byte counters must only accumulate when &lt;code&gt;pinfo.visited&lt;/code&gt; is false.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification
&lt;/h2&gt;

&lt;p&gt;I compared the output against the FoxIO reference implementation (&lt;code&gt;python/ja4.py&lt;/code&gt; from their repo) on their own sample pcaps. Every JA4S the reference emitted matches the plugin:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;pcap&lt;/th&gt;
&lt;th&gt;plugin&lt;/th&gt;
&lt;th&gt;FoxIO reference&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;latest.pcapng&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;t1206h2_c02f_3603f09c43ba&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;t1206h2_c02f_3603f09c43ba&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;latest.pcapng&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;t130300_1301_6bbbaf601ed8&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;t130300_1301_6bbbaf601ed8&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;chrome-cloudflare-quic&lt;/code&gt; (TCP stream)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;t130200_1301_234ea6891581&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;t130200_1301_234ea6891581&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The reference script didn't emit a JA4S for the QUIC stream in that last capture, so the TCP row is the directly comparable one. On the QUIC side, JA4 matches Wireshark's built-in implementation, and the plugin's QUIC JA4S (&lt;code&gt;q130200_1301_234ea6891581&lt;/code&gt;) differs from the verified TCP fingerprint only by the &lt;code&gt;q&lt;/code&gt; prefix — expected, since it's the same server configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Usage
&lt;/h2&gt;

&lt;p&gt;Install Wireshark 4.4 or newer. The plugin uses Lua 5.4 syntax and will not load in Wireshark 4.2, even though that version already exposes the built-in JA4 fields.&lt;/p&gt;

&lt;p&gt;Drop the file into your personal Lua plugins directory (&lt;code&gt;About Wireshark → Folders → Personal Lua Plugins&lt;/code&gt;, e.g. &lt;code&gt;~/.local/lib/wireshark/plugins/&lt;/code&gt;), reload with Ctrl+Shift+L, and filter away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;ja4_quic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ja4s&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nv"&gt;"q130200_1301_234ea6891581"&lt;/span&gt;
&lt;span class="n"&gt;ja4_quic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sni&lt;/span&gt; &lt;span class="k"&gt;contains&lt;/span&gt; &lt;span class="nv"&gt;"example.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Licensing
&lt;/h2&gt;

&lt;p&gt;JA4 (the client fingerprint) is available under the BSD 3-Clause License. JA4S and the rest of the JA4+ suite are specified by &lt;a href="https://github.com/FoxIO-LLC/ja4" rel="noopener noreferrer"&gt;FoxIO&lt;/a&gt; and covered by the &lt;a href="https://github.com/FoxIO-LLC/ja4/blob/main/LICENSE" rel="noopener noreferrer"&gt;FoxIO License 1.1&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That license permits personal and academic use, as well as internal business use that does not directly monetize JA4+. Direct or indirect monetization — including incorporating JA4S into a paid product, hosted service, managed service, or other offering that provides value to paying customers — requires a separate license from FoxIO. The plugin follows the FoxIO specification and reference implementation and is published for non-commercial personal, academic and internal analysis. Review the full license before redistributing the plugin or using it in a commercial context.&lt;/p&gt;

</description>
      <category>wireshark</category>
      <category>lua</category>
      <category>ja4</category>
    </item>
    <item>
      <title>A self-hosted web UI for managing and running Ansible - Playforge</title>
      <dc:creator>Marcin</dc:creator>
      <pubDate>Tue, 07 Jul 2026 18:07:39 +0000</pubDate>
      <link>https://dev.to/mar0ls/a-self-hosted-web-ui-for-managing-and-running-ansible-playforge-4bhj</link>
      <guid>https://dev.to/mar0ls/a-self-hosted-web-ui-for-managing-and-running-ansible-playforge-4bhj</guid>
      <description>&lt;p&gt;A self-hosted web UI for managing and running Ansible — a simpler, friendlier AWX&lt;br&gt;
that runs from a single &lt;code&gt;docker compose up&lt;/code&gt;, with no Postgres, Redis or Receptor. - Playforge&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy8ch06zaqy9rct66tzvn.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy8ch06zaqy9rct66tzvn.gif" alt="Playforge — generating a playbook, streamed token-by-token and self-checked" width="720" height="466"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What makes it different is the &lt;strong&gt;AI layer that checks its own output&lt;/strong&gt;. Most tools&lt;br&gt;
either don't have AI or trust it blindly. Playforge generates playbooks from plain&lt;br&gt;
language, then verifies them: it flags modules that don't exist, catches logic&lt;br&gt;
mistakes a model misses (SSH/UFW lockout, destructive ops, malformed &lt;code&gt;vars&lt;/code&gt;), and&lt;br&gt;
grounds every answer in your real modules and files — fully offline.&lt;/p&gt;
&lt;h2&gt;
  
  
  Features
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Projects &amp;amp; runs&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Import an existing Ansible project from a local path, a &lt;code&gt;.zip&lt;/code&gt;, or &lt;code&gt;git clone&lt;/code&gt;
(Gitea/GitHub) — junk like &lt;code&gt;.venv/&lt;/code&gt; and caches is filtered out automatically.&lt;/li&gt;
&lt;li&gt;Every project is its own git repo with an auto-commit on each save: free undo,
diff and history. Push/pull to a remote when you want.&lt;/li&gt;
&lt;li&gt;Run playbooks (full or by tags) with live per-task output, a structured
pass/fail summary, and a global, filterable run history.&lt;/li&gt;
&lt;li&gt;Auto-detects playbooks/inventories/roles; works with both the scaffolded layout
and flat repos (playbook + &lt;code&gt;hosts.ini&lt;/code&gt; at the root).&lt;/li&gt;
&lt;li&gt;Cron scheduler in-process (APScheduler — no extra worker), run templates,
environments, ad-hoc commands.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Secrets&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Credentials (SSH keys, SSH passwords, vault/become passwords, WireGuard)
encrypted at rest with Fernet.&lt;/li&gt;
&lt;li&gt;Ansible Vault for in-repo secrets — encrypt/decrypt/view from the UI.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Editor &amp;amp; dependencies&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Monaco editor with inline ansible-lint, per-file commit history (View / Restore
past versions), structured inventory editing (INI + YAML).&lt;/li&gt;
&lt;li&gt;Playbook builder (simple → advanced: handlers, loops, &lt;code&gt;serial&lt;/code&gt;, &lt;code&gt;become&lt;/code&gt; per task).&lt;/li&gt;
&lt;li&gt;Ansible Galaxy: install/remove roles &amp;amp; collections by name or from &lt;code&gt;requirements.yml&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Operations&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Credential test&lt;/strong&gt; — probe an SSH key, SSH password, or sudo password against
an inventory before running a 30-task playbook; per-host ✓/✗ result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ad-hoc command builder&lt;/strong&gt; — any module + args + host pattern in one form
(not just "ping all").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--limit&lt;/code&gt; quick-pick&lt;/strong&gt; — click groups/hosts from the inventory to build the
Ansible &lt;code&gt;:&lt;/code&gt;-separated limit string.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run artifacts&lt;/strong&gt; — files a run wrote into the repo are committed automatically;
the run-detail page previews them inline or opens them in the editor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-schedule timezone&lt;/strong&gt; — cron expressions interpret in any IANA timezone
(&lt;code&gt;Europe/Warsaw&lt;/code&gt;, &lt;code&gt;America/New_York&lt;/code&gt;, …); next-fire times honour DST.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;✨ AI assistant (the part that's actually unique)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chat&lt;/strong&gt; on every page (slide-out dock) and a full page — &lt;em&gt;one shared conversation&lt;/em&gt;,
live-synced and remembered across refreshes. Replies stream in token-by-token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent mode&lt;/strong&gt;: a tool-using agent that actually works on a project — it inspects
files, writes/edits/moves them, installs collections, and can &lt;strong&gt;dry-run (&lt;code&gt;--check&lt;/code&gt;)
or run&lt;/strong&gt; a playbook, then read the failures and fix them. It uses the
self-checking layers below (it won't finish on a hallucinated module), every change
is a separate git commit you can revert, and each capability is opt-in
(read-only → "allow changes" → "allow delete / web").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NL → playbook&lt;/strong&gt;: describe what you want, get a reviewable spec + YAML.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remediation loop&lt;/strong&gt;: after a failure, get a concrete fix and re-run only the
failed hosts — or one-click &lt;strong&gt;Fix with agent&lt;/strong&gt; to have the agent read the run,
patch the playbook, and preview it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-run preview&lt;/strong&gt;: a &lt;code&gt;--check&lt;/code&gt; dry-run narrated in plain language ("what will
change, where").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-runbook&lt;/strong&gt;: living Markdown docs generated from your playbooks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-checking layers&lt;/strong&gt; behind all of it:

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;Anti-hallucination&lt;/em&gt; — module names validated against &lt;code&gt;ansible-doc&lt;/code&gt;
(&lt;code&gt;ansible.builtin.ufw&lt;/code&gt; → flagged as fake; &lt;code&gt;community.general.ufw&lt;/code&gt; → "install
the collection", not "doesn't exist").&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;Rule engine&lt;/em&gt; (neuro-symbolic) — catches lockout, destructive ops,
contradictions, handler misuse, malformed structure, even inside roles.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;RAG / BM25&lt;/em&gt; — grounds answers in the modules actually installed and in your
project's real file contents. Works offline; optional web-fetch from
docs.ansible.com when you allow it.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pluggable model backends: Anthropic, OpenAI (or any OpenAI-compatible endpoint),&lt;br&gt;
or a local &lt;strong&gt;Ollama&lt;/strong&gt; server.&lt;/p&gt;
&lt;h2&gt;
  
  
  Screenshots
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgnoc7r8nqkbdeakifya6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgnoc7r8nqkbdeakifya6.png" alt="Playforge dashboard" width="800" height="390"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Quick start
&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/mar0ls/playforge.git
&lt;span class="nb"&gt;cd &lt;/span&gt;playforge
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env   &lt;span class="c"&gt;# optional — for a password, AI keys, etc.&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;   &lt;span class="c"&gt;# → http://127.0.0.1:8765&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The &lt;code&gt;.env&lt;/code&gt; step is optional: with no &lt;code&gt;.env&lt;/code&gt; the app runs single-user/local with no&lt;br&gt;
AI. Configure the AI helper under &lt;strong&gt;Settings → AI helper&lt;/strong&gt; at runtime, or set&lt;br&gt;
&lt;code&gt;OLLAMA_URL&lt;/code&gt; / &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt; / &lt;code&gt;OPENAI_API_KEY&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt;. State (projects,&lt;br&gt;
git repos, SQLite) lives under &lt;code&gt;./data&lt;/code&gt; on the host and survives rebuilds.&lt;/p&gt;
&lt;h3&gt;
  
  
  Optional
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Password protection&lt;/strong&gt;: set &lt;code&gt;ANSIBLE_GUI_PASSWORD&lt;/code&gt; to require login (signed
session cookie). Unset = single-user local, no login.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Import your projects&lt;/strong&gt;: bind-mount their directories read-only (see the
commented &lt;code&gt;/import/*&lt;/code&gt; examples in &lt;code&gt;docker-compose.yml&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Naming note&lt;/strong&gt;: the image and container are &lt;code&gt;playforge&lt;/code&gt;; environment variables
keep the &lt;code&gt;ANSIBLE_GUI_*&lt;/code&gt; prefix for backward compatibility.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  Try the self-checking loop
&lt;/h2&gt;

&lt;p&gt;See it end to end without a remote host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;scripts/demo.sh   &lt;span class="c"&gt;# creates a project + a deliberately failing run on localhost&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It prints a run URL — open it and click &lt;strong&gt;Fix with agent&lt;/strong&gt;: the agent reads the&lt;br&gt;
failure, fixes the playbook (a typo'd module), and previews it. Then &lt;strong&gt;Re-run&lt;/strong&gt; to&lt;br&gt;
confirm. (To record a GIF, screen-capture the browser during that step.)&lt;/p&gt;
&lt;h2&gt;
  
  
  Development
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make build      &lt;span class="c"&gt;# build the image&lt;/span&gt;
make &lt;span class="nb"&gt;test&lt;/span&gt;       &lt;span class="c"&gt;# run the full suite inside the image (git + ansible available)&lt;/span&gt;
make up / down  &lt;span class="c"&gt;# start / stop&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;For live code reload while developing, layer the dev override (bind-mounts&lt;br&gt;
&lt;code&gt;backend/app&lt;/code&gt; and runs uvicorn with &lt;code&gt;--reload&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.yml &lt;span class="nt"&gt;-f&lt;/span&gt; docker-compose.dev.yml up &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The container exposes &lt;code&gt;GET /health&lt;/code&gt; (DB ping included). The base compose wires&lt;br&gt;
a Docker healthcheck against it, so &lt;code&gt;docker ps&lt;/code&gt; shows &lt;code&gt;(healthy)&lt;/code&gt; once the&lt;br&gt;
service is up.&lt;/p&gt;
&lt;h3&gt;
  
  
  Lab regression (one command)
&lt;/h3&gt;

&lt;p&gt;Run a full API-level regression for a dockerized VM lab (preflight + multiple&lt;br&gt;
playbook runs + JSON report suitable for CI):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PROJECT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;your_project_id&amp;gt; make lab-regression
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Optional knobs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PROJECT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nv"&gt;BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://127.0.0.1:8765 &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nv"&gt;INVENTORY_PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;inventories/lab.ini &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nv"&gt;HOST_PATTERN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;all &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nv"&gt;CHECK_PREFLIGHT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nv"&gt;INCLUDE_TARGETS_PREFLIGHT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nv"&gt;REQUEST_TIMEOUT_SEC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;600 &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nv"&gt;PLAYBOOKS_CSV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;playbooks/lab_ping.yml,playbooks/lab_file.yml,playbooks/lab_apt.yml &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nv"&gt;EXTRA_VARS_JSON&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{"some_var":"value"}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
make lab-regression
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command exits non-zero when preflight fails or any run is not &lt;code&gt;ok&lt;/code&gt;/&lt;br&gt;
&lt;code&gt;successful&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The test suite (370+ cases) runs in CI on every push and PR — see&lt;br&gt;
&lt;code&gt;.github/workflows/ci.yml&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Air-gap friendly&lt;/strong&gt;: core UI JS libraries are vendored locally and AI can run
against a local Ollama. By default, online docs lookup is off. Note: the Monaco
editor assets are loaded from jsDelivr unless you vendor Monaco yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No heavy infra&lt;/strong&gt;: SQLite (WAL mode), in-process scheduler, direct runner.
One container.&lt;/li&gt;
&lt;li&gt;Third-party components are listed in &lt;a href="//THIRD_PARTY_LICENSES.md"&gt;THIRD_PARTY_LICENSES.md&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  License
&lt;/h2&gt;

&lt;p&gt;Copyright (C) 2026 mar0ls. Playforge is licensed under the &lt;strong&gt;GNU General Public&lt;br&gt;
License v3.0&lt;/strong&gt; — see &lt;a href="https://dev.toLICENSE"&gt;LICENSE&lt;/a&gt;. GPL-3.0 keeps the project compatible with&lt;br&gt;
its core dependencies (&lt;code&gt;ansible-core&lt;/code&gt;, &lt;code&gt;ansible-runner&lt;/code&gt;, &lt;code&gt;ansible-lint&lt;/code&gt;), which are&lt;br&gt;
GPL-3.0 themselves. Forks and redistributed versions must stay open-source under&lt;br&gt;
the same license.&lt;/p&gt;

</description>
      <category>ansible</category>
      <category>gui</category>
      <category>aiassistance</category>
      <category>programming</category>
    </item>
    <item>
      <title>Building agentless Linux threat detection in Go — no agents, no kernel modules, just SSH</title>
      <dc:creator>Marcin</dc:creator>
      <pubDate>Fri, 03 Jul 2026 10:27:49 +0000</pubDate>
      <link>https://dev.to/mar0ls/building-agentless-linux-threat-detection-in-go-no-agents-no-kernel-modules-just-ssh-488a</link>
      <guid>https://dev.to/mar0ls/building-agentless-linux-threat-detection-in-go-no-agents-no-kernel-modules-just-ssh-488a</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb5nerei5o114b7rivtlg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb5nerei5o114b7rivtlg.png" alt=" " width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Most host-based detection tools require something running on every host: an agent, a kernel module, an eBPF program loaded at boot. That's fine when you control the fleet. It's less fine when you're dealing with a mix of distros, customer VMs, legacy systems where "please install our agent" gets stuck in a change window for six weeks.&lt;/p&gt;

&lt;p&gt;I wanted something that works over SSH on a host you've never touched before — no install, no persistent process, no kernel dependency. Run a scan, get findings, nothing running between scans.&lt;/p&gt;

&lt;p&gt;That's bladedr. This post explains how it works.&lt;/p&gt;




&lt;h2&gt;
  
  
  How the probe works
&lt;/h2&gt;

&lt;p&gt;The core idea: SSH to the target, stage a static binary, run it, collect results.&lt;/p&gt;

&lt;p&gt;The binary (&lt;code&gt;bladedr-probe&lt;/code&gt;) is a single static Go executable compiled with &lt;code&gt;CGO_ENABLED=0&lt;/code&gt;. It runs on the target for a few seconds, reads from &lt;code&gt;/proc&lt;/code&gt;, &lt;code&gt;/sys&lt;/code&gt;, &lt;code&gt;/etc&lt;/code&gt; and a handful of other paths, builds a JSON snapshot of the host state, evaluates detection rules against it, and returns the findings on stdout. Nothing stays resident.&lt;/p&gt;

&lt;p&gt;The snapshot covers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;running processes (cmdline, environ, exe, cwd, parent chain, deleted/memfd-backed executables)&lt;/li&gt;
&lt;li&gt;listening sockets and raw (&lt;code&gt;AF_PACKET&lt;/code&gt;) socket holders&lt;/li&gt;
&lt;li&gt;kernel modules, &lt;code&gt;/proc/kallsyms&lt;/code&gt;, dmesg ring buffer&lt;/li&gt;
&lt;li&gt;persistence: cron, systemd units, authorized_keys, ld.so.preload, PAM modules, XDG autostart, binfmt_misc, udev rules, Python &lt;code&gt;.pth&lt;/code&gt; files&lt;/li&gt;
&lt;li&gt;accounts (uid/gid, shell, groups)&lt;/li&gt;
&lt;li&gt;suspicious files: world-writable sensitive paths, SUID/SGID in unexpected places, deleted-but-running binaries, memfd mappings&lt;/li&gt;
&lt;li&gt;kernel parameters (sysctl), SELinux state, immutable files&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The probe is read-only. It doesn't start a daemon and it doesn't modify host state. The one thing it writes is itself: the binary is content-addressed (sha256) and cached at &lt;code&gt;/tmp/.bladedr/probe-&amp;lt;hash&amp;gt;&lt;/code&gt;, so repeated scheduled scans skip the multi-MB re-upload. The small per-scan rule bundle is written next to it and deleted when the scan finishes. So there's no agent, no service, no process running between scans — just a cached binary sitting idle until the next SSH session invokes it. If you want it gone, it's one &lt;code&gt;rm -rf /tmp/.bladedr&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rules: YAML + CEL, not code
&lt;/h2&gt;

&lt;p&gt;Detection logic is data, not compiled code. A rule looks like this (this one is a real builtin — &lt;code&gt;global-ld-preload&lt;/code&gt;):&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;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;global-ld-preload&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LD_PRELOAD/LD_AUDIT&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;writable&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;LD_LIBRARY_PATH&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;in&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;global&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;environment&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;file"&lt;/span&gt;
&lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;evasion&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;critical&lt;/span&gt;
&lt;span class="na"&gt;score&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;90&lt;/span&gt;
&lt;span class="na"&gt;mitre&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;T1574.006"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;foreach&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;env_preload&lt;/span&gt;
&lt;span class="na"&gt;when&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;item.suspicious_lines.size()&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;0'&lt;/span&gt;
&lt;span class="na"&gt;evidence&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;item.path&lt;/span&gt;
  &lt;span class="na"&gt;lines&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;item.suspicious_lines&lt;/span&gt;
&lt;span class="na"&gt;dedup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;item.path"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;foreach&lt;/code&gt; walks a collection from the snapshot. &lt;code&gt;when&lt;/code&gt; is a &lt;a href="https://github.com/google/cel-go" rel="noopener noreferrer"&gt;CEL&lt;/a&gt; expression evaluated against each item. CEL is a safe, deterministic expression language — no arbitrary code execution, fast evaluation, easy to audit. (Rules without a collection just run one &lt;code&gt;when&lt;/code&gt; against the whole snapshot — e.g. the &lt;code&gt;ld-so-preload-rootkit&lt;/code&gt; rule that fires on any entry in &lt;code&gt;/etc/ld.so.preload&lt;/code&gt;.)&lt;/p&gt;

&lt;p&gt;Rules ship embedded in the binary (&lt;code&gt;internal/rules/builtin/&lt;/code&gt;), but the server merges three layers: builtin → filesystem dir (&lt;code&gt;BLADEDR_RULES_DIR&lt;/code&gt;) → database. A later rule with the same &lt;code&gt;id&lt;/code&gt; overrides the earlier one, so you can patch a builtin without recompiling, or disable it with &lt;code&gt;enabled: false&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# live rule injection via API, active on the next scan&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST :8080/api/v1/rules &lt;span class="nt"&gt;--data-binary&lt;/span&gt; &lt;span class="s1"&gt;'
id: watch-loader
title: "Process named loader"
category: process
severity: medium
foreach: processes
when: item.comm == "loader"
evidence: { pid: item.pid, comm: item.comm }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Currently 87 builtin rules covering MITRE ATT&amp;amp;CK techniques: rootkits, persistence, privilege escalation, evasion, webshells, supply-chain tampering, eBPF backdoors, and more.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fim6w7ayyv0f9dr86unat.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fim6w7ayyv0f9dr86unat.gif" alt=" " width="799" height="465"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The server
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;bladedr-server&lt;/code&gt; handles inventory, scheduling, the API, and the web console. It runs anywhere — Linux, macOS, Docker. Scan targets are always Linux.&lt;/p&gt;

&lt;p&gt;Storage is in-memory by default (good for dev and demos), or Postgres with &lt;a href="https://github.com/paradedb/paradedb" rel="noopener noreferrer"&gt;pg_search&lt;/a&gt; (BM25 full-text search over findings, useful when you have a few thousand observations and want to &lt;code&gt;curl ':8080/api/v1/observations?q=rootkit'&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The server keeps a per-host baseline. First scan establishes it. Later scans diff against it and raise &lt;code&gt;baseline-new-*&lt;/code&gt; observations for anything new: a new UID-0 account, a new listening port, a new authorized key. You don't have to define what "normal" looks like — it learns from the first clean scan.&lt;/p&gt;

&lt;p&gt;Fleet-rarity works similarly: if a kernel module or a cron entry appears on one host out of fifty, that's a low-severity lead regardless of whether any specific rule fires. Rarity is scoped to a host cohort (same OS), so a lone box doesn't generate rarity noise against unrelated hosts.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw2qxv1dypomi71jd0uco.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw2qxv1dypomi71jd0uco.gif" alt=" " width="560" height="281"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  ML risk scoring — what it does and what it doesn't
&lt;/h2&gt;

&lt;p&gt;With enough hosts, you end up with a lot of medium-severity findings. Some are real, most are noise. The risk model re-ranks open findings by how likely you are to triage them as real.&lt;/p&gt;

&lt;p&gt;It's a multinomial Naive Bayes classifier (Laplace-smoothed) trained on your own triage decisions: &lt;code&gt;acknowledged&lt;/code&gt; = real finding, &lt;code&gt;false_positive&lt;/code&gt; = noise. It uses structural features only:&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="s"&gt;rule:global-ld-preload&lt;/span&gt;
&lt;span class="s"&gt;cat:evasion&lt;/span&gt;
&lt;span class="s"&gt;sev:critical&lt;/span&gt;
&lt;span class="s"&gt;src:agentless_probe&lt;/span&gt;
&lt;span class="s"&gt;tech:T1574.006&lt;/span&gt;
&lt;span class="s"&gt;tac:T1574&lt;/span&gt;
&lt;span class="s"&gt;path:etc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No attacker-controlled strings, no process names, no paths. It learns that certain rule/category/severity/technique combinations correlate with real compromises on &lt;em&gt;your&lt;/em&gt; fleet, not on someone else's.&lt;/p&gt;

&lt;p&gt;The model is honest about its own reliability. &lt;code&gt;GET /api/v1/risk/stats&lt;/code&gt; runs leave-one-out cross-validation and reports whether the labelled set is big enough, balanced enough, and separable enough to trust:&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;"trustworthy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"labeled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;211&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"positives"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;162&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"negatives"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;49&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cv_accuracy"&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.88&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"base_rate"&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.77&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"enough balanced, separable data to prioritise findings"&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;Until both classes exist (you need at least one acknowledged and one false_positive triage), scoring falls back to the rule's static score. A clean fleet produces no positives to label, so there's an attack-emulation range (&lt;code&gt;poligon/&lt;/code&gt;) that plants known technique artifacts in a container, scans them, and writes labelled training data to &lt;code&gt;dataset.jsonl&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It ranks. It does not detect.&lt;/p&gt;




&lt;h2&gt;
  
  
  eBPF tier (Phase 2)
&lt;/h2&gt;

&lt;p&gt;Agentless scanning sees the artifact at rest — a file, a config entry, an account. It misses runtime-only techniques: fileless execution, a reverse shell that runs and exits, a container escape that leaves no trace.&lt;/p&gt;

&lt;p&gt;The eBPF tier fills that gap. &lt;code&gt;bladedr-sensor&lt;/code&gt; wraps &lt;a href="https://github.com/cilium/tetragon" rel="noopener noreferrer"&gt;Tetragon&lt;/a&gt;: it loads TracingPolicies (kprobes/tracepoints), consumes Tetragon's JSON event stream, maps each hit to an observation, and posts them to the server. Same &lt;code&gt;observations&lt;/code&gt; table, same triage flow, same risk model — just &lt;code&gt;source=ebpf_sensor&lt;/code&gt; instead of &lt;code&gt;source=agentless_probe&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Tetragon runs as a privileged container. The sensor deploys over SSH and runs as a systemd unit. A host is either &lt;code&gt;scan_only&lt;/code&gt; or &lt;code&gt;scan_plus_sensor&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Running 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/mar0ls/bladedr
&lt;span class="nb"&gt;cd &lt;/span&gt;bladedr
make build               &lt;span class="c"&gt;# server + probe for your platform&lt;/span&gt;
make build-probe-linux   &lt;span class="c"&gt;# cross-compile static probe for Linux targets&lt;/span&gt;
make demo                &lt;span class="c"&gt;# scan a bundled malicious snapshot, no Linux host needed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For SSH scanning against a real host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# start server with Postgres&lt;/span&gt;
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
./bin/bladedr-server

&lt;span class="c"&gt;# add a host, store credentials, scan&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST :8080/api/v1/hosts &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"hostname":"web-01","primary_ip":"10.0.0.5","arch":"amd64"}'&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST :8080/api/v1/credentials &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"username":"root","auth_type":"ssh_password","secret":"…"}'&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST :8080/api/v1/hosts/&amp;lt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/scans
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;bladedr is at v0.1.0 — the agentless tier is stable, the eBPF sensor works but Phase 2 rules are still thin. If you run it against a compromised or test host, I'd be curious what fires and what doesn't.&lt;/p&gt;

&lt;p&gt;Rule contributions and issue reports welcome.&lt;/p&gt;

&lt;p&gt;GPL-3. Code at [github.com/mar0ls/bladedr]&lt;/p&gt;




</description>
    </item>
  </channel>
</rss>
