<?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: murphy</title>
    <description>The latest articles on DEV Community by murphy (@mafeis).</description>
    <link>https://dev.to/mafeis</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%2F4130903%2F8c9e6c07-64d8-45b5-8c97-afb22e33dd79.png</url>
      <title>DEV Community: murphy</title>
      <link>https://dev.to/mafeis</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mafeis"/>
    <language>en</language>
    <item>
      <title>A regex DLP layer for an LLM gateway: blocking keys, masking IDs, and what to do with chat history</title>
      <dc:creator>murphy</dc:creator>
      <pubDate>Fri, 18 Sep 2026 06:04:50 +0000</pubDate>
      <link>https://dev.to/mafeis/a-regex-dlp-layer-for-an-llm-gateway-blocking-keys-masking-ids-and-what-to-do-with-chat-history-4md5</link>
      <guid>https://dev.to/mafeis/a-regex-dlp-layer-for-an-llm-gateway-blocking-keys-masking-ids-and-what-to-do-with-chat-history-4md5</guid>
      <description>&lt;p&gt;LLM gateways are becoming standard infrastructure: one service sits between your team and the model providers, handling auth, rate limits and logging. But most write-ups stop at forwarding. The interesting part is what happens &lt;em&gt;before&lt;/em&gt; a request leaves your network — the data-loss-prevention (DLP) layer.&lt;/p&gt;

&lt;p&gt;This post walks through the design of a regex-based DLP layer I built for a self-hosted LLM gateway (MIT, source linked at the end). It's deliberately simple: no ML classifier, no NER model — just a rule engine that has to be fast, predictable and hot-reloadable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule model
&lt;/h2&gt;

&lt;p&gt;Every rule is three things: a regex, an action, and a label.&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"block-api-keys"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"pattern"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sk-[a-zA-Z0-9]{20,}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"block"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"API key"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mask-credit-card"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"pattern"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s2"&gt;d{16,19}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mask"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Card number"&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;Three actions, in increasing severity:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;log&lt;/strong&gt; — record the hit, let the request through. Good for tuning: run in log-only mode for a week, see what your false-positive rate looks like before you ever block anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mask&lt;/strong&gt; — replace the match but keep the request alive. For IDs and card numbers we keep the first and last 4 characters: &lt;code&gt;6222 0211 **** 1234&lt;/code&gt;. Enough to recognize &lt;em&gt;which&lt;/em&gt; card it was in an audit, not enough to use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;block&lt;/strong&gt; — reject the request outright with a 4xx. Used for credentials, because a masked API key is still a leaked key: masking &lt;code&gt;sk-abc...xyz&lt;/code&gt; just tells an attacker which characters to brute-force around.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The engine caps rules at 50. That's not a technical limitation — it's a design statement. If your DLP policy needs more than 50 regexes, the problem is your policy, and no engine will save you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Only scan the last user message
&lt;/h2&gt;

&lt;p&gt;The naive implementation scans every message in the &lt;code&gt;messages&lt;/code&gt; array on every request. That's O(history) per request, and chat history grows.&lt;/p&gt;

&lt;p&gt;But here's the thing: &lt;strong&gt;every historical message was already scanned when it was sent.&lt;/strong&gt; Re-scanning it adds latency and creates a weird failure mode — a user sends a message today, the policy tightens tomorrow, and suddenly their &lt;em&gt;entire conversation&lt;/em&gt; stops working.&lt;/p&gt;

&lt;p&gt;So the gateway scans only the last user message. History hits are handled differently: if a historical message matches a &lt;code&gt;block&lt;/code&gt; rule, it's replaced wholesale with a &lt;code&gt;[redacted]&lt;/code&gt; placeholder rather than trying surgical masking.&lt;/p&gt;

&lt;p&gt;Why wholesale? Because surgical redaction of history gives a false sense of precision — you don't know which tokens the model will actually attend to, and partial redaction of a &lt;em&gt;known&lt;/em&gt; leak is worse than honest replacement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hot reload, the boring way
&lt;/h2&gt;

&lt;p&gt;Rules live in the gateway config. The scanner reads the current config on every request — no in-memory cache to invalidate, no reload endpoint to call, no "did the workers pick it up" doubt.&lt;/p&gt;

&lt;p&gt;Is re-reading a JSON file per request wasteful? At the scale of one team or one company, the config read is nanoseconds against a multi-second LLM call. Boring wins.&lt;/p&gt;

&lt;h2&gt;
  
  
  What regex DLP can't do
&lt;/h2&gt;

&lt;p&gt;Be honest about the limits:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Semantic leakage.&lt;/strong&gt; "Our CTO's password is the name of his dog followed by 123" sails through every regex. Blocking that requires classification, not pattern matching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encodings.&lt;/strong&gt; Base64-encoded keys, homoglyphs, deliberate spacing (&lt;code&gt;s k - a b c&lt;/code&gt;). Some are patchable with more regexes; some are a rabbit hole.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured data beyond the patterns you wrote.&lt;/strong&gt; This is a allowlist-of-known-bad approach, not a guarantee.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The roadmap answer is a classifier stage after the regex stage — regex catches the cheap 95% with zero false positives, a model catches semantics with a human-review queue. But shipping regex-only DLP is still strictly better than shipping nothing: the default rules (API keys, ID numbers, card numbers, phone numbers) catch what actually leaks in practice.&lt;/p&gt;

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

&lt;p&gt;The gateway is open source (MIT): a single Node.js process with built-in SQLite, no external dependencies. The DLP rules are configurable per deployment, and the whole thing — auth, model catalog, plugin allowlists, billing, audit logs with daily tamper-evident anchors — is one &lt;code&gt;node gateway.mjs&lt;/code&gt; away.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Live demo (no signup, admin console fully clickable): &lt;a href="https://fffly.com/demo" rel="noopener noreferrer"&gt;https://fffly.com/demo&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Source: &lt;a href="https://github.com/mafeis/dsh-enterprise" rel="noopener noreferrer"&gt;https://github.com/mafeis/dsh-enterprise&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Feedback welcome — especially from anyone who's run DLP in production and has scars to share.&lt;/p&gt;

</description>
      <category>security</category>
      <category>ai</category>
      <category>opensource</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
