<?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>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>
