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