<?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: Louis Wright</title>
    <description>The latest articles on DEV Community by Louis Wright (@mrlouiswright).</description>
    <link>https://dev.to/mrlouiswright</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%2F4184228%2Fa3ef0069-61f9-4d37-95bc-ffb548e1b8ee.png</url>
      <title>DEV Community: Louis Wright</title>
      <link>https://dev.to/mrlouiswright</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mrlouiswright"/>
    <language>en</language>
    <item>
      <title>Sephora Paid $1.2M for Ignoring Global Privacy Control. Check Your Site in CI</title>
      <dc:creator>Louis Wright</dc:creator>
      <pubDate>Tue, 20 Oct 2026 04:06:38 +0000</pubDate>
      <link>https://dev.to/mrlouiswright/sephora-paid-12m-for-ignoring-global-privacy-control-check-your-site-in-ci-e15</link>
      <guid>https://dev.to/mrlouiswright/sephora-paid-12m-for-ignoring-global-privacy-control-check-your-site-in-ci-e15</guid>
      <description>&lt;p&gt;&lt;em&gt;This article was written with AI assistance and reviewed before publishing. The demo site used in the examples is made up for this article.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On August 24, 2022, California's Attorney General announced a $1.2 million settlement with Sephora. The allegations were simple: Sephora sold customers' personal information without telling them, and it failed to process opt-out requests sent through user-enabled global privacy controls such as the Global Privacy Control (GPC) [1]. On the same day, the office said it had sent notices to a number of other businesses over the same failure to process GPC opt-outs [1].&lt;/p&gt;

&lt;p&gt;Nobody at Sephora had to click a button for that to happen. A shopper's browser sent a signal, the site did not act on it, and advertising tags kept loading. Almost three years later, in July 2025, the same office announced a $1.55 million settlement with Healthline.com, describing it at the time as the largest CCPA settlement to date. One of the allegations was that Healthline kept sharing data with some advertising third parties even for consumers who had opted out, and the announcement spelled out that businesses must honour opt-out requests, including those sent through GPC [2].&lt;/p&gt;

&lt;p&gt;If you ship a site with a Meta Pixel, a TikTok Pixel or a LinkedIn Insight Tag on it, this is a frontend problem as much as a legal one. This post walks through what the signal actually is, what "honouring" it means in code, and how to catch the gaps in CI with an open-source CLI called gpc-warden.&lt;/p&gt;

&lt;h2&gt;
  
  
  What GPC actually sends
&lt;/h2&gt;

&lt;p&gt;Global Privacy Control is a W3C specification. When a user turns it on, the browser does two things [3]:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It adds a &lt;code&gt;Sec-GPC: 1&lt;/code&gt; request header to the requests it makes.&lt;/li&gt;
&lt;li&gt;It exposes &lt;code&gt;navigator.globalPrivacyControl&lt;/code&gt;, a read-only boolean that tells your scripts whether that header was sent for the current top-level page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The spec also defines an optional support resource at &lt;code&gt;/.well-known/gpc.json&lt;/code&gt;. Its &lt;code&gt;gpc&lt;/code&gt; member must be &lt;code&gt;true&lt;/code&gt; (the server intends to abide by GPC requests, at least as far as it is legally obliged to) or &lt;code&gt;false&lt;/code&gt;, and an optional &lt;code&gt;lastUpdate&lt;/code&gt; member holds a &lt;code&gt;YYYY-MM-DD&lt;/code&gt; date [3]. A valid file looks like this:&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;"gpc"&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;"lastUpdate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-20"&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;California's own CCPA guidance lists the right to opt out of the sale or sharing of personal information "including via the GPC", and tells consumers they can submit an opt-out request through a user-enabled global privacy control [4]. So the header is not a hint. Under that guidance it is a request your site is expected to act on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why sites get this wrong
&lt;/h2&gt;

&lt;p&gt;Reading a boolean is easy. The hard part is that most ad tags are pasted straight into a layout as a bare &lt;code&gt;&amp;lt;script async src="..."&amp;gt;&lt;/code&gt;. A tag loaded like that runs on every page view, and there is no point at which your code gets to ask "did this visitor opt out?" before the pixel fires.&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%2Fzuj8vw50jgjaftpoiij8.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%2Fzuj8vw50jgjaftpoiij8.png" alt="What honouring GPC takes: browser signal, your code, gated ad tags" width="800" height="353"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Honouring GPC in a static or mostly static site comes down to two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Some code has to read the signal: an inline script, a loader in your own JS bundle, an edge or serverless function that looks at &lt;code&gt;Sec-GPC&lt;/code&gt;, or a consent management platform (CMP) configured to respect it.&lt;/li&gt;
&lt;li&gt;Every sale/share tag has to load in a way that code can stop: through that loader, or tagged so your CMP controls it (for example &lt;code&gt;type="text/plain"&lt;/code&gt; plus a category attribute).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Miss either one and the signal is effectively ignored, even if your privacy policy says otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking a built site with gpc-warden
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/MrLouisWright/gpc-warden" rel="noopener noreferrer"&gt;gpc-warden&lt;/a&gt; is a zero-dependency Node.js CLI, library and GitHub Action that inspects the folder your build writes (Astro, Next.js &lt;code&gt;output: 'export'&lt;/code&gt;, Eleventy, Hugo, Jekyll, Docusaurus, VitePress or plain HTML). It runs offline and checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;whether any inline script, local JS file or edge/serverless function in the build reads &lt;code&gt;navigator.globalPrivacyControl&lt;/code&gt; or &lt;code&gt;Sec-GPC&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;whether advertising vendors that sell or share data (Meta, Google Ads, TikTok, LinkedIn, X, Pinterest, Snap, Microsoft, Criteo, Taboola and others) load unconditionally;&lt;/li&gt;
&lt;li&gt;whether &lt;code&gt;/.well-known/gpc.json&lt;/code&gt; exists, is valid, and does not say &lt;code&gt;false&lt;/code&gt; while sale/share tags are present;&lt;/li&gt;
&lt;li&gt;whether the privacy policy mentions GPC or opt-out preference signals.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It needs Node.js 18 or newer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx gpc-warden dist
npx gpc-warden dist &lt;span class="nt"&gt;--fail-on&lt;/span&gt; medium
npx gpc-warden out &lt;span class="nt"&gt;--format&lt;/span&gt; sarif &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; gpc.sarif
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is a run against a small demo shop (made up for this article). The home page pastes in the Meta and TikTok pixels directly, there is no &lt;code&gt;gpc.json&lt;/code&gt;, and the privacy page only talks about a "Do Not Sell" link:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- demo/before/index.html (made-up demo) --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;async&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://connect.facebook.net/en_US/fbevents.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;async&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://analytics.tiktok.com/i18n/pixel/events.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Fjzhexp0sa2ovmnkqfwj3.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%2Fjzhexp0sa2ovmnkqfwj3.png" alt="gpc-warden output for the demo site: three high and two low findings" width="800" height="387"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three high findings fail the run with exit code 1: no code reads the signal, and each pixel loads unconditionally. The two low findings point at the missing &lt;code&gt;gpc.json&lt;/code&gt; and the policy wording. Every finding comes with a concrete fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing the demo
&lt;/h2&gt;

&lt;p&gt;The fix is a small loader that only injects the sale/share tags when the browser has not sent a GPC opt-out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// demo/after/js/ads.js (made-up demo)&lt;/span&gt;
&lt;span class="c1"&gt;// Load sale/share tags only when the browser has not sent a GPC opt-out.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;globalPrivacyControl&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://connect.facebook.net/en_US/fbevents.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://analytics.tiktok.com/i18n/pixel/events.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;script&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;src&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&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;The page now includes &lt;code&gt;&amp;lt;script src="/js/ads.js"&amp;gt;&amp;lt;/script&amp;gt;&lt;/code&gt; instead of the two pixels, the build publishes &lt;code&gt;/.well-known/gpc.json&lt;/code&gt;, and the privacy policy says a GPC signal is treated as a request to opt out of sale and sharing. In a real site you would also combine this with your consent logic and read &lt;code&gt;Sec-GPC&lt;/code&gt; server-side where you set cookies or forward data. Re-running the check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gpc-warden — https://shop.example
2 page(s); 0 sale/share vendor(s); GPC handling found in js/ads.js

no findings
✔ Passing (fail-on: high).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you use a CMP such as OneTrust, Osano, Usercentrics, Didomi, Termly, Ketch, TrustArc or Transcend, gpc-warden spots it, but it cannot see your dashboard settings. Once GPC support is switched on in the CMP, pass &lt;code&gt;--cmp-honours-gpc&lt;/code&gt; (or set &lt;code&gt;"cmpHonoursGpc": true&lt;/code&gt; in config) to confirm it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it in CI
&lt;/h2&gt;

&lt;p&gt;The repository ships a GitHub Action that runs the bundled CLI with no install step. Build the site first, then point the action at the output folder:&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;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
  &lt;span class="na"&gt;security-events&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;   &lt;span class="c1"&gt;# only needed for the SARIF upload&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;gpc-warden&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@11d5960a326750d5838078e36cf38b85af677262&lt;/span&gt; &lt;span class="c1"&gt;# v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;persist-credentials&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;false&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci &amp;amp;&amp;amp; npm run build&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MrLouisWright/gpc-warden@v1&lt;/span&gt;
        &lt;span class="na"&gt;with&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;dist&lt;/span&gt;
          &lt;span class="na"&gt;fail-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;high&lt;/span&gt;
          &lt;span class="na"&gt;sarif&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gpc-warden.sarif&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github/codeql-action/upload-sarif@v3&lt;/span&gt;
        &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;always()&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;sarif_file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;gpc-warden.sarif&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By default it also adds a Markdown report to the job summary. Exit codes are 0 for a pass, 1 for findings at or above &lt;code&gt;--fail-on&lt;/code&gt;, and 2 for usage or config errors, so it works the same way in any other CI system with a plain &lt;code&gt;npx gpc-warden dist&lt;/code&gt; step.&lt;/p&gt;

&lt;p&gt;For pages that genuinely do not need the check, the config file supports a time-boxed allowlist. Each entry needs a reason, and once its &lt;code&gt;expires&lt;/code&gt; date passes the finding comes back along with an &lt;code&gt;allowlist-expired&lt;/code&gt; finding:&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;"failOn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"medium"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"allowlist"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"rule"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gpc-tracker-not-suppressed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"subject"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/landing/*"&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;"Landing pages served only outside the US"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"expires"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2027-03-31"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is also a small library API if you want to wire the checks into your own tooling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;check&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;validateGpcJson&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gpc-warden&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;report&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dist&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;validateGpcJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;gpc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lastUpdate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-10-01&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt; &lt;span class="c1"&gt;// null&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;p&gt;gpc-warden is a static check of your build output. It cannot prove that a tag is actually suppressed at runtime, it does not audit consent banners (a sibling tool, consent-warden, covers those) or build a full tracker inventory (tracker-warden does that), and it is not legal advice. What it does is catch the most common, most mechanical mistake: an ad tag sitting in the HTML where no GPC check can ever reach it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Regulators in California have already treated an ignored GPC signal as an ignored opt-out, first with Sephora [1] and again with Healthline [2]. The spec gives you a clear header, a clear JavaScript property and a clear declaration file [3]. Gate your sale/share tags behind the signal, publish &lt;code&gt;gpc.json&lt;/code&gt;, say so in your policy, and let CI fail the build when a pixel slips back in.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Project site: &lt;a href="https://mrlouiswright.github.io/gpc-warden/" rel="noopener noreferrer"&gt;https://mrlouiswright.github.io/gpc-warden/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/gpc-warden" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/gpc-warden&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/MrLouisWright/gpc-warden" rel="noopener noreferrer"&gt;https://github.com/MrLouisWright/gpc-warden&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Louis Wright&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;State of California Department of Justice, Office of the Attorney General. "Attorney General Bonta Announces Settlement with Sephora as Part of Ongoing Enforcement of California Consumer Privacy Act". 2022. &lt;a href="https://oag.ca.gov/news/press-releases/attorney-general-bonta-announces-settlement-sephora-part-ongoing-enforcement" rel="noopener noreferrer"&gt;https://oag.ca.gov/news/press-releases/attorney-general-bonta-announces-settlement-sephora-part-ongoing-enforcement&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;State of California Department of Justice, Office of the Attorney General. "Attorney General Bonta Announces Largest CCPA Settlement to Date, Secures $1.55 Million from Healthline.com". 2025. &lt;a href="https://oag.ca.gov/news/press-releases/attorney-general-bonta-announces-largest-ccpa-settlement-date-secures-155" rel="noopener noreferrer"&gt;https://oag.ca.gov/news/press-releases/attorney-general-bonta-announces-largest-ccpa-settlement-date-secures-155&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;W3C (Zimmeck, S., Snyder, P., Brookman, J., Zucker-Scharff, A., eds.). "Global Privacy Control (GPC)". W3C Working Draft, 2026. &lt;a href="https://www.w3.org/TR/gpc/" rel="noopener noreferrer"&gt;https://www.w3.org/TR/gpc/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;State of California Department of Justice, Office of the Attorney General. "California Consumer Privacy Act (CCPA)". Accessed Oct 2026. &lt;a href="https://oag.ca.gov/privacy/ccpa" rel="noopener noreferrer"&gt;https://oag.ca.gov/privacy/ccpa&lt;/a&gt;
&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>privacy</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>ai</category>
    </item>
    <item>
      <title>23% of News Pages Have a Broken Link. Catch Yours Before Deploy</title>
      <dc:creator>Louis Wright</dc:creator>
      <pubDate>Fri, 16 Oct 2026 04:07:09 +0000</pubDate>
      <link>https://dev.to/mrlouiswright/23-of-news-pages-have-a-broken-link-catch-yours-before-deploy-2g6b</link>
      <guid>https://dev.to/mrlouiswright/23-of-news-pages-have-a-broken-link-catch-yours-before-deploy-2g6b</guid>
      <description>&lt;p&gt;&lt;em&gt;This article was written with AI assistance and reviewed by the author.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In May 2024, Pew Research Center reported that 23% of news webpages in its sample contained at least one broken link, and 21% of government webpages did too [1]. Across a decade of Common Crawl snapshots, a quarter of pages that existed between 2013 and 2023 were already inaccessible by October 2023 [1]. Link rot is not a niche SEO footnote. It is a measurable property of the public web.&lt;/p&gt;

&lt;p&gt;Yet many static sites still discover 404s the hard way: after deploy, after a CDN purge, after a user files a ticket. Source review and local preview often miss the problems that only appear in the built output on a case-sensitive host.&lt;/p&gt;

&lt;p&gt;That is the gap &lt;strong&gt;static-link-sentinel&lt;/strong&gt; is built for. It crawls &lt;code&gt;dist/&lt;/code&gt;, &lt;code&gt;build/&lt;/code&gt;, &lt;code&gt;out/&lt;/code&gt;, &lt;code&gt;public/&lt;/code&gt;, or &lt;code&gt;_site/&lt;/code&gt; before you ship, maps URLs the way Netlify, Vercel, GitHub Pages, and Cloudflare Pages do, and fails CI when links, assets, anchors, or sitemap entries do not resolve.&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%2Fa3drohb5w8soxidbt0od.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%2Fa3drohb5w8soxidbt0od.png" alt="Cover: broken links caught in the build" width="800" height="336"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The build is not the source tree
&lt;/h2&gt;

&lt;p&gt;A Markdown link that looks fine in the editor can still point at a page the bundler never emitted. An image path that works on a default macOS volume can 404 on Linux. Amazon S3 documents that object key names are case sensitive: &lt;code&gt;Logo.png&lt;/code&gt; and &lt;code&gt;logo.png&lt;/code&gt; are different objects [2]. Local APFS defaults often hide that mismatch. Production hosts and object stores do not.&lt;/p&gt;

&lt;p&gt;Google's crawl guidance is equally blunt about the cost of pretending missing pages still exist. Soft 404s (error-like or empty pages that still return HTTP 200) keep getting crawled and waste crawl budget; Google recommends returning a real 404 or 410 for permanently removed content and avoiding long redirect chains [3][4]. Broken internal links and stale sitemap entries feed exactly that mess.&lt;/p&gt;

&lt;p&gt;So the useful check is not "does this string look like a URL in the repo?" It is "given the files that will actually be served, does every reference resolve the way the host will resolve it?"&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%2Fir4at855vf8araq2cey2.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%2Fir4at855vf8araq2cey2.png" alt="How static-link-sentinel checks a build" width="799" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What static-link-sentinel actually checks
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://www.npmjs.com/package/static-link-sentinel" rel="noopener noreferrer"&gt;static-link-sentinel&lt;/a&gt; is a zero-dependency CLI and library for Node 18+. Point it at a built directory (or let it auto-detect common output folders). It walks HTML, CSS, web manifests, &lt;code&gt;sitemap*.xml&lt;/code&gt;, and &lt;code&gt;robots.txt&lt;/code&gt;, plus optional Markdown/MDX sources.&lt;/p&gt;

&lt;p&gt;Internal findings include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;broken-link&lt;/strong&gt; and &lt;strong&gt;missing-asset&lt;/strong&gt;: pages, scripts, stylesheets, images, and related refs with no matching file&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;case-mismatch&lt;/strong&gt;: a path that only matches when letter case is ignored (works on many Mac volumes, fails on Linux/CDN origins)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;missing-fragment&lt;/strong&gt;: &lt;code&gt;#anchors&lt;/code&gt; with no matching &lt;code&gt;id&lt;/code&gt; or &lt;code&gt;name&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;sitemap-url&lt;/strong&gt; / &lt;strong&gt;robots-sitemap&lt;/strong&gt;: sitemap or robots entries that are not in the build&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;mixed-content&lt;/strong&gt; and related HTTPS warnings when a site URL is known&lt;/li&gt;
&lt;li&gt;optional &lt;strong&gt;orphan-page&lt;/strong&gt; reporting with &lt;code&gt;--orphans&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With &lt;code&gt;--external&lt;/code&gt;, unique outbound URLs are checked (HEAD with GET fallback), including redirect chains hop by hop, with caching for CI reruns.&lt;/p&gt;

&lt;p&gt;URL resolution understands clean URLs, &lt;code&gt;index.html&lt;/code&gt;, trailing-slash policy, &lt;code&gt;basePath&lt;/code&gt;, Netlify &lt;code&gt;_redirects&lt;/code&gt;, and &lt;code&gt;vercel.json&lt;/code&gt; redirects/rewrites/cleanUrls. Exit codes are &lt;code&gt;0&lt;/code&gt; (pass), &lt;code&gt;1&lt;/code&gt; (findings at or above &lt;code&gt;--fail-on&lt;/code&gt;), and &lt;code&gt;2&lt;/code&gt; (usage error). Output can be pretty CLI, JSON, SARIF 2.1.0, or Markdown. Vite and webpack plugins and a GitHub Action are included.&lt;/p&gt;

&lt;p&gt;None of that invents a live server. The tool maps paths to files the same way those hosts do.&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%2Fugweb7tj9n18qihrl3vu.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%2Fugweb7tj9n18qihrl3vu.png" alt="Case mismatch: OK on macOS, 404 on Linux/S3" width="800" height="366"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A labelled demo on a tiny dist
&lt;/h2&gt;

&lt;p&gt;The following is &lt;strong&gt;example/demo data&lt;/strong&gt;, not a production site: a three-page build with intentional typos, a case-mismatched image path, a missing CSS background, a bad fragment, and a sitemap URL for a page that was never built.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx static-link-sentinel dist &lt;span class="nt"&gt;--site-url&lt;/span&gt; https://demo.example
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Authentic output from that demo run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;static-link-sentinel: dist/ 3 page(s), 1 stylesheet(s), 7 file(s), 15 internal reference(s)

about.html
  ✖ error Broken internal link [broken-link]  line 12
          &amp;lt;a href="/pricing/"&amp;gt; resolves to no file in the build
          fix: Remove the link or create the page.

assets/style.css
  ✖ error Missing asset [missing-asset]  line 2
          ../img/bg.png resolves to no file in the build
          fix: Check the build output and asset paths (publicPath/base).

index.html
  ✖ error Broken internal link [broken-link]  line 16
          &amp;lt;a href="/blog/post-l/"&amp;gt; resolves to no file in the build
          fix: did you mean /blog/post-1/?
  ✖ error Path case mismatch [case-mismatch]  line 18
          &amp;lt;a href="/Blog/Post-1/"&amp;gt; only matches blog/post-1/index.html with different case (fails on Linux/CDN hosts)
          fix: Use /blog/post-1/ or rename the file
  ✖ error Path case mismatch [case-mismatch]  line 20
          &amp;lt;img src="/img/Logo.png"&amp;gt; only matches img/logo.png with different case (fails on Linux/CDN hosts)
          fix: Use /img/logo.png or rename the file
  ✖ error Missing asset [missing-asset]  line 22
          &amp;lt;img src="/img/hero-missing.png"&amp;gt; resolves to no file in the build
          fix: Check the build output and asset paths (publicPath/base).
  ⚠ warn  Missing #fragment anchor [missing-fragment]  line 24
          &amp;lt;a href="/about#tem"&amp;gt;: no id="tem" in about.html
          fix: did you mean #team?

sitemap.xml
  ✖ error sitemap.xml URL not in build [sitemap-url]  line 6
          &amp;lt;loc&amp;gt;https://demo.example/gone/&amp;lt;/loc&amp;gt; has no matching page in the build
          fix: Regenerate the sitemap or remove the entry

3 page(s) checked: 7 error(s), 1 warning(s), 0 info
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Frn24u8y0x1r9v9l0rok9.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%2Frn24u8y0x1r9v9l0rok9.png" alt="Terminal output from the labelled demo dist" width="800" height="680"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Notice the "did you mean" hints: typo links and near-miss fragments get a Levenshtein-style suggestion instead of a bare failure. The case-mismatch lines are the ones source review usually misses, because the file "looks present" on a case-insensitive laptop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wire it into CI after the build
&lt;/h2&gt;

&lt;p&gt;The cheapest habit is a postbuild script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i &lt;span class="nt"&gt;-D&lt;/span&gt; static-link-sentinel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;"scripts"&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;"postbuild"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"static-link-sentinel dist --site-url https://example.com"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or the GitHub Action after &lt;code&gt;npm run build&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MrLouisWright/static-link-sentinel@v1&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;dir&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dist&lt;/span&gt;
    &lt;span class="na"&gt;site-url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://example.com&lt;/span&gt;
    &lt;span class="na"&gt;fail-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;error&lt;/span&gt;
    &lt;span class="na"&gt;sarif&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;links.sarif&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Framework recipes follow the same pattern: run after &lt;code&gt;vite build&lt;/code&gt;, &lt;code&gt;next build&lt;/code&gt; (static export to &lt;code&gt;out/&lt;/code&gt;), &lt;code&gt;astro build&lt;/code&gt;, Eleventy, or Hugo, and pass &lt;code&gt;--base-path&lt;/code&gt; when the site is served under a subpath. Vite and webpack plugins can attach the check to the bundler lifecycle so a bad build never exits green.&lt;/p&gt;

&lt;p&gt;For outbound links, enable &lt;code&gt;--external&lt;/code&gt; on a schedule or on &lt;code&gt;push&lt;/code&gt; (not necessarily every PR) and cache &lt;code&gt;.link-cache.json&lt;/code&gt; between runs so unchanged URLs are not re-fetched every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits worth knowing
&lt;/h2&gt;

&lt;p&gt;Only static markup and assets are visible. Client-side routers and content injected by JavaScript after load are out of scope, and SPA fallbacks that rewrite every path to &lt;code&gt;index.html&lt;/code&gt; make every route look "present." Soft 404s on remote hosts can still return HTTP 200. HTML is tokenized with an offset-preserving regex approach rather than a full HTML5 parser, so badly malformed markup may be misread. Those are honest boundaries, not marketing fine print.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the bytes you will serve
&lt;/h2&gt;

&lt;p&gt;Pew's figures show broken links are common even on news and government pages [1]. Case-sensitive object stores will not forgive a capital letter that a laptop filesystem ignored [2]. Search systems treat soft 404s and redirect chains as crawl waste [3][4]. The practical response for a static site is simple: after the build, crawl the output, fail the job when references do not resolve, and fix the path before the CDN ever sees it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Site: &lt;a href="https://mrlouiswright.github.io/static-link-sentinel/" rel="noopener noreferrer"&gt;https://mrlouiswright.github.io/static-link-sentinel/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/static-link-sentinel" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/static-link-sentinel&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/MrLouisWright/static-link-sentinel" rel="noopener noreferrer"&gt;https://github.com/MrLouisWright/static-link-sentinel&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Louis Wright&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;Chapekis, A., Bestvater, S., Remy, E., and Rivero, G. (Pew Research Center). "When Online Content Disappears". 2024. &lt;a href="https://www.pewresearch.org/data-labs/2024/05/17/when-online-content-disappears/" rel="noopener noreferrer"&gt;https://www.pewresearch.org/data-labs/2024/05/17/when-online-content-disappears/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Amazon Web Services. "Naming Amazon S3 objects". Amazon Simple Storage Service User Guide. Accessed Oct 2026. &lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Google Search Central. "Troubleshoot crawling errors" (soft 404 errors). Accessed Oct 2026. &lt;a href="https://developers.google.com/search/docs/crawling-indexing/troubleshoot-crawling-errors" rel="noopener noreferrer"&gt;https://developers.google.com/search/docs/crawling-indexing/troubleshoot-crawling-errors&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Google Crawling Infrastructure. "Crawl budget management for site owners". Accessed Oct 2026. &lt;a href="https://developers.google.com/crawling/docs/crawl-budget" rel="noopener noreferrer"&gt;https://developers.google.com/crawling/docs/crawl-budget&lt;/a&gt;
&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>devops</category>
      <category>ai</category>
    </item>
    <item>
      <title>CAPTCHAs Lost to AkiraBot on 80,000 Sites. Here Is Captcha-Free Form Protection</title>
      <dc:creator>Louis Wright</dc:creator>
      <pubDate>Thu, 15 Oct 2026 04:14:15 +0000</pubDate>
      <link>https://dev.to/mrlouiswright/captchas-lost-to-akirabot-on-80000-sites-here-is-captcha-free-form-protection-f7a</link>
      <guid>https://dev.to/mrlouiswright/captchas-lost-to-akirabot-on-80000-sites-here-is-captcha-free-form-protection-f7a</guid>
      <description>&lt;p&gt;&lt;em&gt;This article was written with AI assistance and reviewed by the author.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In April 2025, SentinelLABS published research on AkiraBot: an AI-powered spam framework that targeted more than 420,000 websites and successfully spammed at least 80,000 of them since September 2024 [1]. It used OpenAI to generate custom outreach for each site, then pushed those messages into contact forms and chat widgets. The operators also invested heavily in defeating CAPTCHAs, including hCAPTCHA, reCAPTCHA, and Cloudflare Turnstile [1].&lt;/p&gt;

&lt;p&gt;That is the uncomfortable part. The industry's most common "prove you are human" control is on the target list for commodity spam tooling. If CAPTCHA is optional for the attacker, what does a contact form owner actually have left?&lt;/p&gt;

&lt;h2&gt;
  
  
  Form spam is an automated threat, not a UX bug
&lt;/h2&gt;

&lt;p&gt;OWASP lists form and content spam as OAT-017 Spamming in its Automated Threats taxonomy: automated addition of malicious or questionable content and messages, including SEO and form spam [2]. A related threat, OAT-009 CAPTCHA Defeat, covers the other side of the same fight: automation that solves or bypasses anti-automation puzzles with OCR, image databases, or human solver farms [3].&lt;/p&gt;

&lt;p&gt;OWASP's Bot Management cheat sheet is blunt about visible CAPTCHAs: they are accessibility-hostile, increasingly machine-solvable, and available to solver farms for fractions of a cent [4]. The W3C's Inaccessibility of CAPTCHA note has made the same accessibility case for years: visual and audio challenges routinely exclude people who should still be able to use the form [5].&lt;/p&gt;

&lt;p&gt;So the failure mode for a typical contact page stacks up like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A bot finds the form (or buys a target list).&lt;/li&gt;
&lt;li&gt;It fills every field, including ones humans never see if the honeypot is weak or missing.&lt;/li&gt;
&lt;li&gt;If there is a CAPTCHA, a bypass service or farm solves it.&lt;/li&gt;
&lt;li&gt;Your inbox fills with SEO pitches, fake leads, or worse.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A puzzle in front of every visitor is the wrong primary control. You want checks that cost almost nothing for a real browser session and raise the price of bulk automation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cheap layers that bots fail
&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%2Feyhos3g5ueusnkhqkqe2.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%2Feyhos3g5ueusnkhqkqe2.png" alt="Layered honeypot, timing, JS proof, rate limit and optional PoW" width="800" height="301"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is the design behind &lt;strong&gt;form-honeypot-guard&lt;/strong&gt;, a captcha-free package I built for Node apps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an off-screen, accessible &lt;strong&gt;honeypot&lt;/strong&gt; field with a randomised, realistic name on each build;&lt;/li&gt;
&lt;li&gt;an &lt;strong&gt;HMAC-signed timing token&lt;/strong&gt;, so submissions that arrive too fast, too late, tampered, or replayed are rejected;&lt;/li&gt;
&lt;li&gt;a &lt;strong&gt;JS proof&lt;/strong&gt; field that only a real browser fills, plus light interaction signals (keypress, pointer, focus);&lt;/li&gt;
&lt;li&gt;optional &lt;strong&gt;content heuristics&lt;/strong&gt; (link spam, URLs in name fields, spam keywords);&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;sliding-window rate limiting&lt;/strong&gt; per IP (and optionally per email or fingerprint), with a correct &lt;code&gt;429&lt;/code&gt; and &lt;code&gt;Retry-After&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;optional &lt;strong&gt;Altcha-style proof-of-work&lt;/strong&gt; when you want a stronger challenge without a captcha vendor.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The client runtime is under 2 kB gzipped. Webpack and Vite plugins inject the honeypot markup at build time, randomise field names, and write a manifest the server reads. Helpers cover Express/Connect, Fastify, Next.js, SvelteKit, Remix, Astro, and plain Fetch runtimes, plus an optional React wrapper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auditing built HTML with the CLI
&lt;/h2&gt;

&lt;p&gt;Protecting forms is half the story. The other half is noticing when a form shipped without the trap. The CLI audits built HTML:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx form-honeypot-guard check dist &lt;span class="nt"&gt;--strict&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The demo below is made up for this article: two HTML files under &lt;code&gt;demo/dist&lt;/code&gt; with an unprotected POST contact form, a newsletter form that accidentally uses GET (so the email would land in the query string), and one form that already looks partially guarded. Real output from version 1.1.1:&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%2F7pkfj9k1hvlp54zxwuui.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%2F7pkfj9k1hvlp54zxwuui.png" alt="form-honeypot-guard check --strict on made-up demo HTML" width="800" height="359"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two errors and two warnings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a POST form with no honeypot;&lt;/li&gt;
&lt;li&gt;a GET form that would put personal data in the URL;&lt;/li&gt;
&lt;li&gt;CSRF token warnings on both POST forms (the checker reminds you; SameSite cookies alone are not always enough).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;--strict&lt;/code&gt; treats warnings as errors and exits 1, which is what you want in CI. &lt;code&gt;form-honeypot-guard secret&lt;/code&gt; prints a fresh HMAC secret for &lt;code&gt;FORM_GUARD_SECRET&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring the guard into a build
&lt;/h2&gt;

&lt;p&gt;Mark forms with &lt;code&gt;data-guard&lt;/code&gt; (or guard all of them), then add the webpack plugin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;FormGuardPlugin&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;form-honeypot-guard/webpack&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;FormGuardPlugin&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;forms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;form[data-guard]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;secretEnv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;FORM_GUARD_SECRET&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;minTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;manifestPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;server/form-guard.manifest.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the server (Express example):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;expressGuard&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cleanFields&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;form-honeypot-guard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;expressGuard&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;server/form-guard.manifest.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/contact&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;rateLimit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;windowMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ip&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in CI, after the frontend build:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm run build&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Audit forms for honeypot coverage&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx form-honeypot-guard@1 check dist --strict&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;AkiraBot showed that LLM-written spam plus CAPTCHA bypass can hit hundreds of thousands of sites [1]. OWASP already treats form spam and CAPTCHA defeat as first-class automated threats [2][3], and both OWASP and W3C warn against leaning on visible CAPTCHAs as the main defense [4][5]. Layer cheap server-checked signals instead, and fail the build when a form ships unprotected.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Site: &lt;a href="https://mrlouiswright.github.io/form-honeypot-guard/" rel="noopener noreferrer"&gt;https://mrlouiswright.github.io/form-honeypot-guard/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/form-honeypot-guard" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/form-honeypot-guard&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/MrLouisWright/form-honeypot-guard" rel="noopener noreferrer"&gt;https://github.com/MrLouisWright/form-honeypot-guard&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ol&gt;
&lt;li&gt;Delamotte, A. and Walter, J. (SentinelLABS / SentinelOne). "AkiraBot | AI-Powered Bot Bypasses CAPTCHAs, Spams Websites At Scale". 2025. &lt;a href="https://www.sentinelone.com/labs/akirabot-ai-powered-bot-bypasses-captchas-spams-websites-at-scale/" rel="noopener noreferrer"&gt;https://www.sentinelone.com/labs/akirabot-ai-powered-bot-bypasses-captchas-spams-websites-at-scale/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;OWASP. "OAT-017 Spamming". OWASP Automated Threats to Web Applications. Accessed Oct 2026. &lt;a href="https://owasp.github.io/www-project-automated-threats-to-web-applications/assets/oats/EN/OAT-017_Spamming.html" rel="noopener noreferrer"&gt;https://owasp.github.io/www-project-automated-threats-to-web-applications/assets/oats/EN/OAT-017_Spamming.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;OWASP. "OAT-009 CAPTCHA Defeat". OWASP Automated Threats to Web Applications. Accessed Oct 2026. &lt;a href="https://owasp.github.io/www-project-automated-threats-to-web-applications/assets/oats/EN/OAT-009_CAPTCHA_Defeat.html" rel="noopener noreferrer"&gt;https://owasp.github.io/www-project-automated-threats-to-web-applications/assets/oats/EN/OAT-009_CAPTCHA_Defeat.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;OWASP. "Bot Management and Anti-Automation Cheat Sheet". Accessed Oct 2026. &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Bot_Management_and_Anti-Automation_Cheat_Sheet.html" rel="noopener noreferrer"&gt;https://cheatsheetseries.owasp.org/cheatsheets/Bot_Management_and_Anti-Automation_Cheat_Sheet.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;W3C. "Inaccessibility of CAPTCHA: Alternatives to Visual Turing Tests on the Web". W3C Working Group Note. Accessed Oct 2026. &lt;a href="https://www.w3.org/TR/turingtest/" rel="noopener noreferrer"&gt;https://www.w3.org/TR/turingtest/&lt;/a&gt;
&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>security</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>ai</category>
    </item>
    <item>
      <title>Your .env Is Not Where Your Secrets End Up: Scanning the Bundle You Actually Ship</title>
      <dc:creator>Louis Wright</dc:creator>
      <pubDate>Wed, 14 Oct 2026 09:52:07 +0000</pubDate>
      <link>https://dev.to/mrlouiswright/your-env-is-not-where-your-secrets-end-up-scanning-the-bundle-you-actually-ship-5d4m</link>
      <guid>https://dev.to/mrlouiswright/your-env-is-not-where-your-secrets-end-up-scanning-the-bundle-you-actually-ship-5d4m</guid>
      <description>&lt;p&gt;&lt;em&gt;This article was written with AI assistance and reviewed by the author.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In 2019, researchers at North Carolina State University went looking for API keys and private keys in public GitHub repositories. Their NDSS paper, "How Bad Can It Git?", found that secret leakage was pervasive, affecting over 100,000 repositories, and that thousands of new, unique secrets were leaked every day [1].&lt;/p&gt;

&lt;p&gt;Most of the advice that followed focused on the repository: add &lt;code&gt;.env&lt;/code&gt; to &lt;code&gt;.gitignore&lt;/code&gt;, run a pre-commit scanner, never commit credentials. That advice is good. But there is a second place secrets leak that a clean repository does not protect you from: the JavaScript bundle you ship to every visitor.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a secret walks out of the build
&lt;/h2&gt;

&lt;p&gt;Modern frontend tools deliberately copy some environment variables into client code. Vite's documentation says it plainly: variables prefixed with &lt;code&gt;VITE_&lt;/code&gt; are exposed in client-side source code after bundling, and it warns you to avoid that prefix to prevent leaking variables to the client [2]. Next.js works the same way: to expose a variable to the browser it must be prefixed with &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt;, and those values are inlined into the JavaScript bundle during &lt;code&gt;next build&lt;/code&gt; [3].&lt;/p&gt;

&lt;p&gt;So the failure mode is not exotic. Someone needs a key "just for a quick test", gives it the public prefix (or pastes it straight into a config object), and the build faithfully writes the literal value into &lt;code&gt;dist/assets/index-&amp;lt;hash&amp;gt;.js&lt;/code&gt;. The &lt;code&gt;.env&lt;/code&gt; file never touches git. The secret still ends up on the public internet.&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%2Fxu9aeukiyb8whn115qu8.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%2Fxu9aeukiyb8whn115qu8.png" alt="How a server secret reaches the browser: .env, build, deploy, leak" width="800" height="288"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;MITRE classifies this family of mistakes as CWE-798, Use of Hard-coded Credentials [4]. Once a key has shipped, deleting it from the next build is not enough. OWASP's Secrets Management Cheat Sheet notes that restarting an application does not revoke stolen credentials; they remain usable until the backing service expires or revokes them [5]. The only real fix is rotation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking the artifact, not just the source
&lt;/h2&gt;

&lt;p&gt;Source scanners look at what you wrote. The browser sees what the bundler produced. &lt;strong&gt;bundle-secret-guard&lt;/strong&gt; is a small CLI that scans built frontend output (it auto-detects &lt;code&gt;dist&lt;/code&gt;, &lt;code&gt;build&lt;/code&gt;, &lt;code&gt;out&lt;/code&gt;, &lt;code&gt;.next/static&lt;/code&gt;, &lt;code&gt;.output/public&lt;/code&gt; and &lt;code&gt;.svelte-kit/output/client&lt;/code&gt;) and fails when secrets or API keys made it into the files you are about to deploy. It can also cross-check the values in your &lt;code&gt;.env&lt;/code&gt; files, so a server-only value that appears literally in the bundle is flagged even if it does not match a known key format.&lt;/p&gt;

&lt;h2&gt;
  
  
  A demo build (with fake keys)
&lt;/h2&gt;

&lt;p&gt;To be upfront: the project below is made up for this article, and every key in it is fake. The AWS pair is a widely used example key pair (note the EXAMPLE suffix), and the Stripe value is a placeholder shaped like a live key. The &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 shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;VITE_API_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://api.example.com
&lt;span class="nv"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk_live_51Hx9QkFakeDemoKey0000000000000000000000000000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a "built" bundle at &lt;code&gt;dist/assets/index-3f9a1c.js&lt;/code&gt; where someone hard-coded the config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;apiUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="na"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sk_live_51Hx9QkFakeDemoKey0000000000000000000000000000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="na"&gt;aws&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="na"&gt;accessKeyId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;AKIAIOSFODNN7EXAMPLE&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="na"&gt;secretAccessKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running &lt;code&gt;npx bundle-secret-guard dist -e .env&lt;/code&gt; with version 1.1.1 gives this real output:&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%2Fzelb69w8nxrdzvhmm5a0.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%2Fzelb69w8nxrdzvhmm5a0.png" alt="bundle-secret-guard output: 4 errors in the demo bundle" width="799" height="569"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Four errors, each with a file, line and column, and the secret itself masked in the output:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a Stripe live secret key, matched by format;&lt;/li&gt;
&lt;li&gt;the same value flagged again as a server-only env leak, because it matches &lt;code&gt;STRIPE_SECRET_KEY&lt;/code&gt; from &lt;code&gt;.env&lt;/code&gt;;&lt;/li&gt;
&lt;li&gt;an AWS access key ID;&lt;/li&gt;
&lt;li&gt;an AWS secret access key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every fix starts the same way: rotate the key, because anything that was deployed must be treated as leaked. Then it points to where the call belongs instead, such as a server or API route, a serverless function, or pre-signed URLs for AWS. The command exits with code 1, so a CI job stops before the deploy step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it into CI
&lt;/h2&gt;

&lt;p&gt;Run it after the build and before the deploy:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm run build&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Check bundle for secrets&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx bundle-secret-guard@1 dist -e .env.production --sarif bundle-secrets.sarif&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Useful options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--baseline &amp;lt;file&amp;gt;&lt;/code&gt; and &lt;code&gt;--update-baseline&lt;/code&gt; to accept known findings (stored as hashes only) and fail only on new ones.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--entropy-scan&lt;/code&gt; for extra high-entropy string detection.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--gitleaks &amp;lt;file&amp;gt;&lt;/code&gt; to reuse existing gitleaks TOML rules.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--verify&lt;/code&gt; is opt-in and checks whether a key is live using the provider's own API. It is off by default.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;The 2019 study showed how often secrets slip into source code [1]. Build tools add a second path, because public env prefixes are designed to be copied into client code [2][3]. Scan the artifact you actually deploy, and if a key ever ships, rotate it rather than just removing it [5].&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Site: &lt;a href="https://mrlouiswright.github.io/bundle-secret-guard/" rel="noopener noreferrer"&gt;https://mrlouiswright.github.io/bundle-secret-guard/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/bundle-secret-guard" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/bundle-secret-guard&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/MrLouisWright/bundle-secret-guard" rel="noopener noreferrer"&gt;https://github.com/MrLouisWright/bundle-secret-guard&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ol&gt;
&lt;li&gt;Meli, M., McNiece, M. R. and Reaves, B. (North Carolina State University). "How Bad Can It Git? Characterizing Secret Leakage in Public GitHub Repositories". NDSS Symposium, 2019. &lt;a href="https://www.ndss-symposium.org/ndss-paper/how-bad-can-it-git-characterizing-secret-leakage-in-public-github-repositories/" rel="noopener noreferrer"&gt;https://www.ndss-symposium.org/ndss-paper/how-bad-can-it-git-characterizing-secret-leakage-in-public-github-repositories/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Vite. "Env Variables and Modes". Accessed Oct 2026. &lt;a href="https://vite.dev/guide/env-and-mode" rel="noopener noreferrer"&gt;https://vite.dev/guide/env-and-mode&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Vercel. "Next.js Docs: Environment Variables". Accessed Oct 2026. &lt;a href="https://nextjs.org/docs/app/guides/environment-variables" rel="noopener noreferrer"&gt;https://nextjs.org/docs/app/guides/environment-variables&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;MITRE. "CWE-798: Use of Hard-coded Credentials". Accessed Oct 2026. &lt;a href="https://cwe.mitre.org/data/definitions/798.html" rel="noopener noreferrer"&gt;https://cwe.mitre.org/data/definitions/798.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;OWASP. "Secrets Management Cheat Sheet". Accessed Oct 2026. &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html" rel="noopener noreferrer"&gt;https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html&lt;/a&gt;
&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>security</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
    <item>
      <title>A Version Tag Leaked CI Secrets: Lessons From CVE-2025-30066 for Your GitHub Actions</title>
      <dc:creator>Louis Wright</dc:creator>
      <pubDate>Wed, 14 Oct 2026 09:45:49 +0000</pubDate>
      <link>https://dev.to/mrlouiswright/a-version-tag-leaked-ci-secrets-lessons-from-cve-2025-30066-for-your-github-actions-49k8</link>
      <guid>https://dev.to/mrlouiswright/a-version-tag-leaked-ci-secrets-lessons-from-cve-2025-30066-for-your-github-actions-49k8</guid>
      <description>&lt;p&gt;&lt;em&gt;This article was written with AI assistance and reviewed by the author.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;On March 14 and 15, 2025, the version tags of a popular GitHub Action stopped meaning what everyone thought they meant. A threat actor modified the tags &lt;code&gt;v1&lt;/code&gt; through &lt;code&gt;v45.0.7&lt;/code&gt; of &lt;code&gt;tj-actions/changed-files&lt;/code&gt; so they pointed at a single malicious commit, &lt;code&gt;0e58ed8&lt;/code&gt;. The result, as recorded in the National Vulnerability Database, was that remote attackers could discover secrets by reading Actions logs [1]. NVD rates it 8.6 (High) and tracks it as CVE-2025-30066 [1].&lt;/p&gt;

&lt;p&gt;Nobody had to change a line of their own workflow to be affected. If a pipeline said &lt;code&gt;uses: tj-actions/changed-files@v45&lt;/code&gt;, the next run quietly fetched the attacker's code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a tag is not a version
&lt;/h2&gt;

&lt;p&gt;A git tag feels like a release number, but it is just a movable pointer. Whoever controls the upstream repository can move it. That is exactly what happened here [1].&lt;/p&gt;

&lt;p&gt;CISA's alert on the incident asked organizations to audit every project that used any version of &lt;code&gt;tj-actions/changed-files&lt;/code&gt; between 2025-03-12 and 2025-03-15, and for public repositories that ran the malicious commit, to check for exposed access keys, GitHub Personal Access Tokens, npm tokens and private RSA keys [2]. In other words: assume anything the workflow could see was compromised.&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%2Frk58vi0xdu9myxlq8p1c.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%2Frk58vi0xdu9myxlq8p1c.png" alt="How a mutable tag becomes a breach: retag, run, leak" width="800" height="323"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;GitHub's own guidance has long pointed at the fix. Its secure-use reference recommends pinning actions to a full-length commit SHA, because an attacker would then need a SHA-1 collision for a valid Git object to slip in a backdoor [3]. The same page notes that workflow dependency lockfiles now supersede SHA pinning for repositories that adopt them [3], so check which option fits your setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quieter cousins of the same mistake
&lt;/h2&gt;

&lt;p&gt;A retagged action is the dramatic case. GitHub Actions has a few other well-documented ways for an outsider to end up running code with your secrets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pwn requests.&lt;/strong&gt; GitHub Security Lab warns that combining the &lt;code&gt;pull_request_target&lt;/code&gt; trigger with an explicit checkout of an untrusted pull request is a dangerous practice that may lead to repository compromise [4].&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Script injection.&lt;/strong&gt; Fields such as &lt;code&gt;github.event.pull_request.title&lt;/code&gt; are attacker controlled. The recommended fix is to pass them through an intermediate environment variable instead of interpolating &lt;code&gt;${{ }}&lt;/code&gt; straight into a &lt;code&gt;run:&lt;/code&gt; script [5].&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Over-privileged tokens.&lt;/strong&gt; GitHub advises granting the &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; the minimum required permissions, with read-only access to repository contents as a good default [3].&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these is easy to spot in code review once you know the pattern, and easy to miss in a 200-line YAML file at 6pm on a Friday.&lt;/p&gt;

&lt;h2&gt;
  
  
  Letting a tool do the staring
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;workflow-sentinel&lt;/strong&gt; is a small CLI that scans &lt;code&gt;.github/workflows/*.yml&lt;/code&gt; and &lt;code&gt;action.yml&lt;/code&gt; files and fails the build when it finds these patterns. To see it work, here is a deliberately bad workflow that combines all of the problems above:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PR checks&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pull_request_target&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;lint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.pull_request.head.sha }}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tj-actions/changed-files@v45&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;changed&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "Checking ${{ github.event.pull_request.title }}"&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci &amp;amp;&amp;amp; npm test&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;NPM_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.NPM_TOKEN }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running &lt;code&gt;npx workflow-sentinel&lt;/code&gt; (version 1.1.0) in that repository produces this real output:&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%2Fa80wgsbd51ej1j1lbi1v.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%2Fa80wgsbd51ej1j1lbi1v.png" alt="workflow-sentinel output: 2 critical, 3 high, 2 medium findings" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Seven findings, each tied to a line number and a concrete fix: two CRITICAL (the pull-request-head checkout under &lt;code&gt;pull_request_target&lt;/code&gt;, and the title interpolated into &lt;code&gt;run:&lt;/code&gt;), three HIGH (two mutable tags, plus secrets exposed to a job running PR code) and two MEDIUM (no &lt;code&gt;permissions:&lt;/code&gt; block, and checkout without &lt;code&gt;persist-credentials: false&lt;/code&gt;). The exit code is non-zero because the default &lt;code&gt;--fail-on&lt;/code&gt; level is &lt;code&gt;high&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For contrast, the same command against the current workflows in the &lt;code&gt;expressjs/express&lt;/code&gt; repository reported 0 findings across 5 files and passed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing it, not just flagging it
&lt;/h2&gt;

&lt;p&gt;The part that makes this practical on a real codebase is &lt;code&gt;--fix&lt;/code&gt;. It pins actions to commit SHAs using the GitHub API (with &lt;code&gt;GH_TOKEN&lt;/code&gt; or &lt;code&gt;GITHUB_TOKEN&lt;/code&gt;), and &lt;code&gt;--fix-dry-run&lt;/code&gt; shows the changes first. A few other options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--trusted-owner actions&lt;/code&gt; skips SHA-pin requirements for owners you choose to trust.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--sarif &amp;lt;file&amp;gt;&lt;/code&gt; emits SARIF 2.1.0 for GitHub code scanning.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--json&lt;/code&gt; and &lt;code&gt;--markdown&lt;/code&gt; for reports and PR comments.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A minimal CI step:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Harden workflows&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx workflow-sentinel@1 --sarif workflow-sentinel.sarif&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;CVE-2025-30066 did not exploit a bug in anyone's application. It exploited a habit: trusting that &lt;code&gt;@v45&lt;/code&gt; means the same code tomorrow as it did yesterday [1]. Pin what you run, give the token as little as possible, and keep attacker-controlled strings out of shell scripts [3][4][5]. Then let a linter check it on every push, because the YAML will keep changing.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Site: &lt;a href="https://mrlouiswright.github.io/workflow-sentinel/" rel="noopener noreferrer"&gt;https://mrlouiswright.github.io/workflow-sentinel/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/workflow-sentinel" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/workflow-sentinel&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/MrLouisWright/workflow-sentinel" rel="noopener noreferrer"&gt;https://github.com/MrLouisWright/workflow-sentinel&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ol&gt;
&lt;li&gt;NIST National Vulnerability Database. "CVE-2025-30066 Detail". 2025. &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2025-30066" rel="noopener noreferrer"&gt;https://nvd.nist.gov/vuln/detail/CVE-2025-30066&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;CISA. "Supply Chain Compromise of Third-Party tj-actions/changed-files (CVE-2025-30066) and reviewdog/action-setup@v1 (CVE-2025-30154)". 2025. &lt;a href="https://www.cisa.gov/news-events/alerts/2025/03/18/supply-chain-compromise-third-party-tj-actionschanged-files-cve-2025-30066-and-reviewdogaction" rel="noopener noreferrer"&gt;https://www.cisa.gov/news-events/alerts/2025/03/18/supply-chain-compromise-third-party-tj-actionschanged-files-cve-2025-30066-and-reviewdogaction&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub Docs. "Secure use reference". Accessed Oct 2026. &lt;a href="https://docs.github.com/en/actions/reference/security/secure-use" rel="noopener noreferrer"&gt;https://docs.github.com/en/actions/reference/security/secure-use&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub Security Lab (jarlob). "Keeping your GitHub Actions and workflows secure Part 1: Preventing pwn requests". 2021. &lt;a href="https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/" rel="noopener noreferrer"&gt;https://securitylab.github.com/resources/github-actions-preventing-pwn-requests/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub Security Lab (jarlob). "Keeping your GitHub Actions and workflows secure Part 2: Untrusted input". 2021. &lt;a href="https://securitylab.github.com/resources/github-actions-untrusted-input/" rel="noopener noreferrer"&gt;https://securitylab.github.com/resources/github-actions-untrusted-input/&lt;/a&gt;
&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>security</category>
      <category>githubactions</category>
      <category>devops</category>
      <category>cicd</category>
    </item>
    <item>
      <title>81% of Sites Ship Without a CSP Header. Here Is How to Catch Yours in CI</title>
      <dc:creator>Louis Wright</dc:creator>
      <pubDate>Wed, 14 Oct 2026 09:27:13 +0000</pubDate>
      <link>https://dev.to/mrlouiswright/81-of-sites-ship-without-a-csp-header-here-is-how-to-catch-yours-in-ci-3pk0</link>
      <guid>https://dev.to/mrlouiswright/81-of-sites-ship-without-a-csp-header-here-is-how-to-catch-yours-in-ci-3pk0</guid>
      <description>&lt;p&gt;&lt;em&gt;This article was written with AI assistance and reviewed by the author.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here is a number that should bother anyone who ships websites: in the HTTP Archive's 2024 crawl, only 19% of hosts sent a Content-Security-Policy header [1]. Flip that around and roughly four out of five sites go live with no CSP at all. HSTS fares little better: just 30% of mobile responses carried a Strict-Transport-Security header [1].&lt;/p&gt;

&lt;p&gt;These are not exotic, cutting-edge defenses. They are one-line response headers. So why are they missing so often?&lt;/p&gt;

&lt;h2&gt;
  
  
  The header that quietly disappears
&lt;/h2&gt;

&lt;p&gt;My theory, after watching it happen on my own projects: headers are rarely &lt;em&gt;forgotten&lt;/em&gt;. They are &lt;em&gt;lost&lt;/em&gt;. Someone sets them up in Nginx, then the site moves to a CDN. A framework upgrade changes the default middleware. A new preview environment gets its own config. Nobody notices, because a missing header breaks nothing. The page still loads, the tests still pass, and the only person who finds out is the one probing for weaknesses.&lt;/p&gt;

&lt;p&gt;The data backs up how uneven this is. Among the top 1,000 sites on mobile, 64.3% enable HSTS, but adoption drops steadily as you move down the popularity ranks [1]. And even where a CSP exists, it often does less than it seems: 91% of desktop sites with a CSP allow &lt;code&gt;'unsafe-inline'&lt;/code&gt; in &lt;code&gt;script-src&lt;/code&gt; [1], which undermines the XSS protection CSP is meant to provide [1][3].&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%2F3u4a9tmzl6tijn9lkc5z.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%2F3u4a9tmzl6tijn9lkc5z.png" alt="Share of desktop site requests carrying each security header in 2024" width="800" height="395"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What each header actually buys you
&lt;/h2&gt;

&lt;p&gt;A quick tour, so the scanner output below makes sense:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Strict-Transport-Security&lt;/strong&gt; tells the browser to only reach your site over HTTPS. It was standardized in RFC 6797 to defend against man-in-the-middle attacks that downgrade connections to plain HTTP [2][4].&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content-Security-Policy&lt;/strong&gt; lets you restrict which sources may load scripts and other resources, reducing the impact of cross-site scripting [3][5].&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;X-Frame-Options&lt;/strong&gt; or CSP &lt;code&gt;frame-ancestors&lt;/code&gt; stops other sites from framing your pages, the core defense against clickjacking [6].&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;X-Content-Type-Options: nosniff&lt;/strong&gt; tells the browser not to MIME-sniff responses, so the declared content type is respected [7].&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;OWASP's Secure Headers Project maintains a broader catalogue of recommended headers and values if you want to go deeper [8].&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning it into a test
&lt;/h2&gt;

&lt;p&gt;The fix for "lost" headers is the same as for any regression: test for it continuously. That is why I built &lt;strong&gt;header-sentinel&lt;/strong&gt;, a small CLI that fetches your deployed URLs and fails when security headers are missing or misconfigured. Grades follow the Mozilla HTTP Observatory scoring model, where every site starts at a baseline of 100 and modifiers are applied per test [9].&lt;/p&gt;

&lt;p&gt;Install-free:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx header-sentinel https://example.com https://github.com &lt;span class="nt"&gt;--fail-on&lt;/span&gt; none
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is real output from version 1.1.1 against two sites today:&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%2Ffhq2o22g13e2a8x8uwha.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%2Ffhq2o22g13e2a8x8uwha.png" alt="header-sentinel terminal output for example.com and github.com" width="800" height="800"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://example.com/ (HTTP 200)  Grade D score 30/100 - MEDIUM risk
  x error Strict-Transport-Security missing [hsts]
  x error Content-Security-Policy missing [csp]
  x error Clickjacking protection missing [frame-options]
  x error X-Content-Type-Options missing [content-type-options]
  ! warn  Referrer-Policy missing [referrer-policy]
  ! warn  Permissions-Policy missing [permissions-policy]
  ! warn  Cross-Origin-Opener-Policy missing [coop]

https://github.com/ (HTTP 200)  Grade A+ score 115/100 - LOW risk
  ! warn  CSP style-src allows 'unsafe-inline' [csp-unsafe-inline]
  ! warn  Permissions-Policy missing [permissions-policy]
  ! warn  Cross-Origin-Opener-Policy missing [coop]
  ! warn  Set-Cookie present (3 headers) [cookies]

2 URL(s) scanned: 4 error(s), 7 warning(s)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things I like about this output. First, every finding comes with a copy-paste fix for Nginx, Express (helmet) and Vercel/Netlify, so the error message is also the patch. Second, even an A+ site gets useful nudges: GitHub's CSP allows &lt;code&gt;'unsafe-inline'&lt;/code&gt; for styles, and the tool says so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it into CI
&lt;/h2&gt;

&lt;p&gt;The default &lt;code&gt;--fail-on error&lt;/code&gt; exits non-zero on any error, which is exactly what you want after a deploy step:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Check security headers&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx header-sentinel@1 https://staging.example.com --sarif headers.sarif&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Other options worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--sitemap &amp;lt;url&amp;gt;&lt;/code&gt; scans every URL in your sitemap.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--baseline &amp;lt;file&amp;gt;&lt;/code&gt; reports only &lt;em&gt;new&lt;/em&gt; findings, so you can adopt it on a legacy site without a wall of red.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--sarif&lt;/code&gt; writes SARIF 2.1.0 for GitHub code scanning.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--json&lt;/code&gt; for your own dashboards.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Security headers are cheap to add and easy to lose. The Web Almanac numbers say most of the web is still missing them [1]. Treat them like any other contract your deployment must honor, and let a test tell you the day one goes missing.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Site: &lt;a href="https://mrlouiswright.github.io/header-sentinel/" rel="noopener noreferrer"&gt;https://mrlouiswright.github.io/header-sentinel/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;npm: &lt;a href="https://www.npmjs.com/package/header-sentinel" rel="noopener noreferrer"&gt;https://www.npmjs.com/package/header-sentinel&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/MrLouisWright/header-sentinel" rel="noopener noreferrer"&gt;https://github.com/MrLouisWright/header-sentinel&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ol&gt;
&lt;li&gt;Franken, G. and Vanderlinden, V. (HTTP Archive). "Security", Web Almanac 2024. 2024. &lt;a href="https://almanac.httparchive.org/en/2024/security" rel="noopener noreferrer"&gt;https://almanac.httparchive.org/en/2024/security&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Hodges, J., Jackson, C. and Barth, A. (IETF). "RFC 6797: HTTP Strict Transport Security (HSTS)". 2012. &lt;a href="https://www.rfc-editor.org/rfc/rfc6797" rel="noopener noreferrer"&gt;https://www.rfc-editor.org/rfc/rfc6797&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;MDN Web Docs (Mozilla). "Content Security Policy (CSP)". Accessed Oct 2026. &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP" rel="noopener noreferrer"&gt;https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;MDN Web Docs (Mozilla). "Strict-Transport-Security". Accessed Oct 2026. &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Strict-Transport-Security" rel="noopener noreferrer"&gt;https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Strict-Transport-Security&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;W3C. "Content Security Policy Level 3". Accessed Oct 2026. &lt;a href="https://www.w3.org/TR/CSP3/" rel="noopener noreferrer"&gt;https://www.w3.org/TR/CSP3/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;OWASP. "Clickjacking Defense Cheat Sheet". Accessed Oct 2026. &lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Clickjacking_Defense_Cheat_Sheet.html" rel="noopener noreferrer"&gt;https://cheatsheetseries.owasp.org/cheatsheets/Clickjacking_Defense_Cheat_Sheet.html&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;MDN Web Docs (Mozilla). "X-Content-Type-Options". Accessed Oct 2026. &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Content-Type-Options" rel="noopener noreferrer"&gt;https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Content-Type-Options&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;OWASP. "OWASP Secure Headers Project". Accessed Oct 2026. &lt;a href="https://owasp.org/www-project-secure-headers/" rel="noopener noreferrer"&gt;https://owasp.org/www-project-secure-headers/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;MDN Web Docs (Mozilla). "HTTP Observatory: Tests and scoring". Accessed Oct 2026. &lt;a href="https://developer.mozilla.org/en-US/observatory/docs/tests_and_scoring" rel="noopener noreferrer"&gt;https://developer.mozilla.org/en-US/observatory/docs/tests_and_scoring&lt;/a&gt;
&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>devops</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
