<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>CI/CD Archives - Linuxcent</title>
	<atom:link href="https://linuxcent.com/tag/ci-cd/feed/" rel="self" type="application/rss+xml" />
	<link>https://linuxcent.com/tag/ci-cd/</link>
	<description>Infrastructure security, from the kernel up.</description>
	<lastBuildDate>Mon, 27 Jul 2026 11:57:31 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.0.4</generator>

<image>
	<url>https://linuxcent.com/wp-content/uploads/2026/04/favicon-512x512-1-150x150.png</url>
	<title>CI/CD Archives - Linuxcent</title>
	<link>https://linuxcent.com/tag/ci-cd/</link>
	<width>32</width>
	<height>32</height>
</image> 
<site xmlns="com-wordpress:feed-additions:1">211632295</site>	<item>
		<title>Product Quality vs Code Quality: Why Your Green CI Still Loses Users</title>
		<link>https://linuxcent.com/product-quality-vs-code-quality/</link>
					<comments>https://linuxcent.com/product-quality-vs-code-quality/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Mon, 20 Jul 2026 04:15:40 +0000</pubDate>
				<category><![CDATA[Product Quality]]></category>
		<category><![CDATA[CI/CD]]></category>
		<category><![CDATA[DevSecOps]]></category>
		<category><![CDATA[Invigil]]></category>
		<category><![CDATA[Open Source]]></category>
		<category><![CDATA[Software Quality]]></category>
		<guid isPermaLink="false">https://linuxcent.com/product-quality-vs-code-quality/</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 6</span> <span class="rt-label rt-postfix">minutes</span></span>Product quality vs code quality: why green CI still loses users, and how a repo-grading gate catches the gaps linters never see. Try it in 2 minutes.</p>
<p>The post <a href="https://linuxcent.com/product-quality-vs-code-quality/">Product Quality vs Code Quality: Why Your Green CI Still Loses Users</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 6</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>9 min read</em></p>
<p><strong>EP01: Product Quality vs Code Quality</strong> · <a href="/the-legible-repo/">All The Legible Repo Episodes →</a></p>
<p>This series is about the quality layer your CI can&#8217;t see. Each episode takes one failure that linters, scanners, and test suites never catch, shows the incident that proves it, and ends with one command you can run today. This opener names the problem — and introduces the gate that measures it.</p>
<h2 id="table-of-contents">Table of Contents</h2>
<ul>
<li><a href="#tldr">TL;DR</a></li>
<li><a href="#quick-check-what-grade-is-your-repo-right-now">Quick Check: What Grade Is Your Repo Right Now</a></li>
<li><a href="#the-500-nobody-reported">The 500 Nobody Reported</a></li>
<li><a href="#where-product-quality-sits-and-why-linters-cant-see-it">Where Product Quality Sits (and Why Linters Can&#8217;t See It)</a></li>
<li><a href="#the-questions-that-decide-whether-a-stranger-stays">The Questions That Decide Whether a Stranger Stays</a></li>
<li><a href="#how-the-gate-works-scorecard-plus-cold-start">How the Gate Works: Scorecard Plus Cold-Start</a></li>
<li><a href="#what-this-means-for-your-repos-right-now">What This Means for Your Repos Right Now</a></li>
<li><a href="#-production-gotchas"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Production Gotchas</a></li>
<li><a href="#quick-reference">Quick Reference</a></li>
<li><a href="#framework-alignment">Framework Alignment</a></li>
<li><a href="#key-takeaways">Key Takeaways</a></li>
<li><a href="#whats-next">What&#8217;s Next</a></li>
</ul>
<h2 id="tldr">TL;DR</h2>
<ul>
<li>Product quality vs code quality is the gap between &#8220;the tests pass&#8221; and &#8220;a stranger can actually use this&#8221; — and no linter measures it.</li>
<li>The costliest failures are silent: the person who hits friction in the first ten minutes never files an issue. They close the tab.</li>
<li>Legibility is checkable: README length, copy-paste quickstart, <code class="" data-line="">.env.example</code>, actionable errors, a published artifact that still installs today.</li>
<li>Invigil (Apache-2.0) mechanizes ~35 of these checks into gate levels G1–G7 with a letter grade — and prints the exact fix for every failure.</li>
<li>It grades itself in CI: a pull request that lowers Invigil&#8217;s own score doesn&#8217;t merge.</li>
</ul>
<h2 id="quick-check-what-grade-is-your-repo-right-now">Quick Check: What Grade Is Your Repo Right Now</h2>
<p>Before the story, the evidence. Two commands, two minutes, on any repo you maintain:</p>
<pre><code class="" data-line="">pip install invigil
invigil score . --offline
</code></pre>
<p>Sample output, annotated:</p>
<pre><code class="" data-line="">Invigil — myproject
Gate G2 · Grade C+ · 19/27 (70%)          ← gate = maturity rung, grade = weighted score

FAIL [G1] README is a landing page (≤300 lines)     (effort: minutes)
      fix: move deep-dive sections to docs/; keep quickstart + pitch
FAIL [G1] .env.example documents every config var   (effort: minutes)
      fix: create .env.example listing each var with purpose + default
FAIL [G2] Errors carry a correlation ID             (effort: hours)
      fix: add a global exception handler returning {&quot;error_id&quot;: ...}
</code></pre>
<p>Every failing line names the check, the effort class, and the exact fix. However you feel about the individual opinions, notice what just happened: nothing in your existing CI produces this view.</p>
<h2 id="the-500-nobody-reported">The 500 Nobody Reported</h2>
<p>The day I renamed a package and pushed the new wheel, every UI page it served returned a 500. The commit message said &#8220;verified.&#8221; I had verified the <em>import</em> — not the <em>experience</em>. No test caught it, because the tests ran against my source tree, not against the artifact a stranger downloads. And no user caught it for me. The first stranger who hit that 500 did what strangers do: closed the tab and never came back.</p>
<p>That is the failure mode that should keep maintainers up at night. <strong>Absence of complaints is not absence of problems.</strong> Silence is the loudest negative signal a project gets.</p>
<p>A clean-virtualenv install from an empty directory found the bug in minutes. That habit — being your own first angry user — became a doctrine. Later, the doctrine became a CI gate called <strong>Invigil</strong>, because habits don&#8217;t run nightly and machines do.</p>
<h2 id="where-product-quality-sits-and-why-linters-cant-see-it">Where Product Quality Sits (and Why Linters Can&#8217;t See It)</h2>
<pre><code class="" data-line="">                    ┌─────────────────────────────────────────┐
                    │        WHAT YOUR CI CHECKS TODAY        │
                    │  ruff / eslint      → code style        │
                    │  pytest / jest      → source behavior   │
                    │  Trivy / Dependabot → CVEs, deps        │
                    │  Scorecard          → supply chain      │
                    └────────────────┬────────────────────────┘
                                     │  all green &#x2705;
                                     ▼
                    ┌─────────────────────────────────────────┐
                    │        WHAT THE STRANGER MEETS          │
                    │  README (landing page or wall of text?) │
                    │  Quickstart (works from empty dir?)     │
                    │  Published artifact (installs TODAY?)   │
                    │  First error (fix included or trace?)   │
                    │  llms.txt / AGENTS.md (agent-readable?) │
                    └─────────────────────────────────────────┘
                          nothing above checks this layer
</code></pre>
<p>The product quality vs code quality distinction is exactly this diagram. As a result, a repo can be immaculate in the top box and unusable in the bottom one — green CI, linted code, zero CVEs, and a quickstart that fails on the first copy-paste. In contrast to code quality, product quality has no reflexive tooling. Every good maintainer checks these things by hand, occasionally, when they remember. Nobody&#8217;s CI does it on every pull request.</p>
<p>I build hardened infrastructure for a living, and the same lesson repeats there: a standard that isn&#8217;t enforced mechanically is a wish. That&#8217;s why <a href="/linux-hardening-as-code/">Linux hardening as code</a> beats hardening runbooks — and it&#8217;s why legibility needs a gate, not a checklist.</p>
<h2 id="the-questions-that-decide-whether-a-stranger-stays">The Questions That Decide Whether a Stranger Stays</h2>
<p>Specifically, the gate asks the questions your CI never asks:</p>
<ul>
<li>Can someone get from &#8220;found the repo&#8221; to &#8220;it worked on my machine&#8221; in ten minutes?</li>
<li>When something fails, does the error include the fix — or a traceback?</li>
<li>Is the README a landing page, or 600 lines of accumulated documentation?</li>
<li>Does the artifact you published <em>still install today</em>, after your dependencies drifted?</li>
<li>Is there an <code class="" data-line="">.env.example</code>, or do users reverse-engineer your config from source?</li>
<li>Can an AI agent — now often the first reader — parse your <code class="" data-line="">llms.txt</code> and <code class="" data-line="">AGENTS.md</code> without hitting stale paths or a leaked key?</li>
</ul>
<p>Each question maps to a mechanical check. Together, ~35 checks roll up into gate levels <strong>G1–G7</strong> — a maturity ladder, not a binary pass/fail — plus a weighted letter grade. A repo reaches gate G<em>n</em> only when every mandatory check at or below <em>n</em> passes.</p>
<h2 id="how-the-gate-works-scorecard-plus-cold-start">How the Gate Works: Scorecard Plus Cold-Start</h2>
<h3 id="layer-1-the-scorecard-every-pr-seconds">Layer 1 — the scorecard (every PR, seconds)</h3>
<p>The static layer inspects the repo and its metadata: LICENSE, README length, quickstart shape, tracked secrets, SHA-pinned actions, enforced lockfile, coverage floor, docs index, <code class="" data-line="">llms.txt</code>/<code class="" data-line="">AGENTS.md</code> hygiene, and more. It runs offline in a pre-commit hook in roughly 120 ms, because a gate that adds friction is a gate that gets uninstalled.</p>
<pre><code class="" data-line="">invigil score . --format markdown   # PR-comment-ready table
</code></pre>
<h3 id="layer-2-the-cold-start-gate-nightly">Layer 2 — the cold-start gate (nightly)</h3>
<p>This is the layer that would have caught my 500. Instead of testing the source tree, it boots the <em>published</em> artifact — the wheel on PyPI, the image on GHCR — on a clean runner and probes its surface within a ten-minute budget:</p>
<pre><code class="" data-line=""># .invigil.yml
artifacts:
  - { type: pypi, name: &quot;myapp[all]&quot; }
  - { type: ghcr, image: ghcr.io/me/myapp:latest, port: 8000 }
probes:
  - { url: &quot;/&quot;, expect_status: 200 }
</code></pre>
<p>Because it installs from the real registry into a real empty environment, it catches the class of bug where CI passes but the shipped thing is broken: the missing template directory, the config default pointing at localhost, the dependency that resolved differently after an upstream release.</p>
<h2 id="what-this-means-for-your-repos-right-now">What This Means for Your Repos Right Now</h2>
<p>Start in report-only mode. The <code class="" data-line="">progressive</code> profile scores everything and gates nothing — you get the visibility without a wall of red blocking your next merge. Flip to <code class="" data-line="">enforce</code> once the grade stabilizes, the same way you&#8217;d introduce any merge check.</p>
<p>The doctrine is opinionated, and that&#8217;s deliberate — but the gate bends instead of breaking. Profiles (<code class="" data-line="">strict | progressive | light</code>), per-check <code class="" data-line="">weights</code>, and <code class="" data-line="">optional</code> flags let a team disagree with a specific opinion without forking the tool. Additionally, network-dependent checks that time out become SKIPs excluded from the grade — never a false downgrade that erodes trust in the number.</p>
<p>One more thing, because trust matters for a tool that grades others: <strong>Invigil grades itself in CI.</strong> A pull request that lowers its own score won&#8217;t merge. The gate passes its own gate — currently G5, grade A+.</p>
<h2 id="production-gotchas"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Production Gotchas</h2>
<p><strong>Enforcing on day one.</strong> Turning on <code class="" data-line="">enforce: true</code> before the team has seen the report produces a wall of failures and an uninstall. What breaks: adoption. How to detect it: grumbling in your PR comments. The fix: <code class="" data-line="">progressive</code> first, enforce after two weeks of stable grades.</p>
<p><strong>Treating the grade as the goal.</strong> The grade is a proxy for a stranger&#8217;s first ten minutes. Gaming it (a hollow <code class="" data-line="">.env.example</code>, a README split that hides the quickstart) passes the check and still loses the user. The fix is cultural, not mechanical — review the fix, not just the score delta.</p>
<p><strong>Skipping the cold-start layer because &#8220;CI already tests installs.&#8221;</strong> CI installs from the source tree with your lockfile present. The stranger installs from the registry into nothing. These diverge silently after any packaging change — that divergence is invisible until you test the published artifact itself.</p>
<h2 id="quick-reference">Quick Reference</h2>
<table>
<thead>
<tr>
<th>Command</th>
<th>What it does</th>
</tr>
</thead>
<tbody>
<tr>
<td><code class="" data-line="">invigil score .</code></td>
<td>Full scorecard: gate, grade, exact fix per failure</td>
</tr>
<tr>
<td><code class="" data-line="">invigil score . --offline</code></td>
<td>Fast local checks only (~120 ms class)</td>
</tr>
<tr>
<td><code class="" data-line="">invigil score . --format markdown</code></td>
<td>PR-comment / job-summary table</td>
</tr>
<tr>
<td><code class="" data-line="">invigil evaluate .</code></td>
<td>Alias of <code class="" data-line="">score</code> — the verb agents reach for</td>
</tr>
<tr>
<td><code class="" data-line="">invigil portfolio p1 p2 --update FILE.md</code></td>
<td>Grade many repos, update a tracked table</td>
</tr>
<tr>
<td>GitHub Action</td>
<td><code class="" data-line="">uses: invigil/invigil@v1</code> — report-only by default</td>
</tr>
</tbody>
</table>
<h2 id="framework-alignment">Framework Alignment</h2>
<table>
<thead>
<tr>
<th>CISSP Domain</th>
<th>Relevance</th>
</tr>
</thead>
<tbody>
<tr>
<td>Domain 8 — Software Development Security</td>
<td>The gate enforces secure-SDLC hygiene (no tracked secrets, least-privilege config via <code class="" data-line="">.env.example</code>, SHA-pinned actions, enforced lockfile) as a merge condition rather than a wiki page.</td>
</tr>
<tr>
<td>Domain 7 — Security Operations</td>
<td>Signed releases, SBOM, and the nightly published-artifact check operationalize artifact integrity — continuous evidence instead of a pre-audit scramble.</td>
</tr>
<tr>
<td>Domain 1 — Security &amp; Risk Management</td>
<td>Profiles and weighted gates turn a subjective quality bar into a measurable control with a defined threshold — governance expressed in code.</td>
</tr>
</tbody>
</table>
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>Product quality and code quality are different layers; your CI only watches one of them.</li>
<li>Test the experience, not the import — the developer&#8217;s machine is a lie, and so is the source tree.</li>
<li>Silence is data: the users you never hear from are the ones who hit the friction.</li>
<li>A quality bar you can&#8217;t measure is an opinion; a gate you bypass is dead weight — make it fast, bendable, and report-only by default.</li>
<li>Trust tools that hold themselves to their own standard: Invigil&#8217;s own PRs merge only if its self-grade holds.</li>
<li>Defaults are never neutral — the same reason <a href="/cloud-ami-security-risks-custom-os-images/">cloud AMI security risks</a> demand custom images applies to your repo&#8217;s out-of-the-box experience.</li>
</ul>
<h2 id="whats-next">What&#8217;s Next</h2>
<p>EP02 goes deep on the layer that caught my 500: testing the <em>published</em> artifact, not the source tree. Clean-runner boots, real-registry installs, probe budgets — and why &#8220;it works in CI&#8221; is a statement about your lockfile, not your users. <strong>EP02: How to Test Your Published PyPI Package — Before a Stranger Does.</strong></p>
<blockquote>
<p><strong>Invigil is Apache-2.0 and built in the open.</strong> If this episode named a failure you&#8217;ve shipped (we all have), there are more checks waiting to be written — good-first-issues with acceptance criteria at <strong><a href="https://github.com/invigil/invigil">github.com/invigil/invigil</a></strong>. Pick one, or open a Discussion and say hello. First-time contributors get fast reviews and release-notes credit.</p>
</blockquote>
<p>Get EP02 in your inbox when it publishes → <a href="/subscribe/">subscribe</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fproduct-quality-vs-code-quality%2F&amp;linkname=Product%20Quality%20vs%20Code%20Quality%3A%20Why%20Your%20Green%20CI%20Still%20Loses%20Users" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fproduct-quality-vs-code-quality%2F&amp;linkname=Product%20Quality%20vs%20Code%20Quality%3A%20Why%20Your%20Green%20CI%20Still%20Loses%20Users" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fproduct-quality-vs-code-quality%2F&amp;linkname=Product%20Quality%20vs%20Code%20Quality%3A%20Why%20Your%20Green%20CI%20Still%20Loses%20Users" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fproduct-quality-vs-code-quality%2F&amp;linkname=Product%20Quality%20vs%20Code%20Quality%3A%20Why%20Your%20Green%20CI%20Still%20Loses%20Users" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fproduct-quality-vs-code-quality%2F&amp;linkname=Product%20Quality%20vs%20Code%20Quality%3A%20Why%20Your%20Green%20CI%20Still%20Loses%20Users" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fproduct-quality-vs-code-quality%2F&amp;linkname=Product%20Quality%20vs%20Code%20Quality%3A%20Why%20Your%20Green%20CI%20Still%20Loses%20Users" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fproduct-quality-vs-code-quality%2F&amp;linkname=Product%20Quality%20vs%20Code%20Quality%3A%20Why%20Your%20Green%20CI%20Still%20Loses%20Users" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fproduct-quality-vs-code-quality%2F&#038;title=Product%20Quality%20vs%20Code%20Quality%3A%20Why%20Your%20Green%20CI%20Still%20Loses%20Users" data-a2a-url="https://linuxcent.com/product-quality-vs-code-quality/" data-a2a-title="Product Quality vs Code Quality: Why Your Green CI Still Loses Users"></a></p><p>The post <a href="https://linuxcent.com/product-quality-vs-code-quality/">Product Quality vs Code Quality: Why Your Green CI Still Loses Users</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/product-quality-vs-code-quality/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">2272</post-id>	</item>
		<item>
		<title>CI/CD Secrets Exposure: How Supply Chain Attacks Target Your Pipeline</title>
		<link>https://linuxcent.com/cicd-secrets-exposure-supply-chain/</link>
					<comments>https://linuxcent.com/cicd-secrets-exposure-supply-chain/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Tue, 16 Jun 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[Purple Team]]></category>
		<category><![CDATA[CI/CD]]></category>
		<category><![CDATA[CircleCI]]></category>
		<category><![CDATA[DevSecOps]]></category>
		<category><![CDATA[GitHub Actions]]></category>
		<category><![CDATA[Secrets Management]]></category>
		<category><![CDATA[Supply Chain Security]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1858</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 11</span> <span class="rt-label rt-postfix">minutes</span></span>CI/CD secrets exposure is structural, not behavioral. How CircleCI and GitHub Actions breaches happened and how pre-commit hooks plus secrets scanning close the path permanently.</p>
<p>The post <a href="https://linuxcent.com/cicd-secrets-exposure-supply-chain/">CI/CD Secrets Exposure: How Supply Chain Attacks Target Your Pipeline</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 11</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><a href="/what-is-purple-team-security/">What is purple team security</a> → <a href="/owasp-top-10-cloud-infrastructure/">OWASP Top 10 mapped to cloud infrastructure</a> → <a href="/cloud-security-breaches-2020-2025/">Cloud security breaches 2020–2025</a> → <a href="/broken-access-control-aws/">Broken access control in AWS</a> → <a href="/mfa-fatigue-attack/">MFA fatigue attacks</a> → <strong>CI/CD secrets exposure</strong></p>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li><strong>CI/CD secrets exposure</strong> is OWASP A08 + A02: credentials committed to repositories or stored in pipeline environment variables can be exfiltrated when the platform is compromised, and automated scanners find them within seconds of a public commit</li>
<li>The CircleCI breach (January 2023): an engineer&#8217;s laptop was compromised via malware → session token stolen → attacker accessed CircleCI production systems → all customer environment variables (AWS keys, GitHub tokens, SSH keys) exfiltrated</li>
<li>The structural problem: long-lived credentials stored in a CI/CD platform are only as secure as the platform itself — if the platform is compromised, all stored secrets are compromised</li>
<li>The structural fix: <a href="/oidc-workload-identity-eliminate-cloud-access-keys/">OIDC workload identity</a> replaces stored credentials with short-lived tokens issued at job runtime — there is nothing to exfiltrate</li>
<li>Pre-commit hooks and CI-layer secret scanning are detection layers, not structural fixes — they catch accidents, not determined attackers</li>
<li>Automated secret scanners (TruffleHog, Gitleaks) find credentials in public repos within 60–90 seconds of commit</li>
</ul>
<hr />
<blockquote>
<p><strong>OWASP Mapping:</strong> A08 Software and Data Integrity Failures — build pipeline integrity. A02 Cryptographic Failures — secrets stored in ways that allow exfiltration.</p>
</blockquote>
<hr />
<h2 id="the-big-picture">The Big Picture</h2>
<pre><code class="" data-line="">┌─────────────────────────────────────────────────────────────────────┐
│                  CI/CD SECRETS ATTACK SURFACE                       │
│                                                                     │
│   VECTOR 1: COMMITTED TO VCS                                        │
│   Developer ── git commit ──&#x25b6; .env with AWS_SECRET_KEY              │
│   Automated scanner ──────&#x25b6;  clones within 60 seconds              │
│   Attacker ───────────────&#x25b6;  accesses AWS before dev notices        │
│                                                                     │
│   VECTOR 2: STORED IN CI/CD PLATFORM                                │
│   DevOps ─── configures ──&#x25b6;  AWS_ACCESS_KEY_ID in CircleCI         │
│   Attacker compromises CircleCI → exfiltrates all org env vars      │
│                                                                     │
│   VECTOR 3: IN CONTAINER/PROCESS ENV                                │
│   kubectl exec / docker inspect ──&#x25b6;  printenv shows credentials     │
│   Anyone with container exec access = credential access             │
│                                                                     │
│   VECTOR 4: IN BUILD ARTIFACTS / LOGS                               │
│   Build log: &quot;Using token: ghp_xxxxxxxxxxxx...&quot; → exposed in log   │
│                                                                     │
│   ═══════════════════════════════════════════════════════           │
│   STRUCTURAL FIX: OIDC WORKLOAD IDENTITY                            │
│   No stored credential → nothing to commit, nothing to exfiltrate  │
│   CI job requests token at runtime → 1-hour TTL → expired          │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘
</code></pre>
<p><strong>CI/CD secrets exposure</strong> is not primarily a developer discipline problem — it is a structural problem. When credentials are stored in a CI/CD platform, in environment variables, or in version control, the only question is when they will be exposed, not whether. The structural answer replaces stored credentials with dynamically issued, short-lived tokens that cannot be exfiltrated because they don&#8217;t persist.</p>
<hr />
<h2 id="the-25-minute-compromise-how-automated-scanning-works-against-you">The 25-Minute Compromise: How Automated Scanning Works Against You</h2>
<p>At 2:47 AM, a developer committed a <code class="" data-line="">.env</code> file to a public GitHub repository. It contained:</p>
<pre><code class="" data-line="">DATABASE_URL=postgres://admin:prod_p@ssw0rd@db.internal.company.com:5432/customers
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
STRIPE_SECRET_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
</code></pre>
<p>At 2:48 AM — 60 seconds later — an automated scanner had cloned the repository. These scanners run continuously against GitHub&#8217;s public event stream, looking for credential patterns in new commits, new files, and new repository forks.</p>
<p>At 3:12 AM — 25 minutes after the commit — the database started receiving unusual queries. The automated scanning infrastructure is not operated by individuals manually watching for leaks. It is fully automated: pattern match → clone → test credential validity → if valid, begin exploitation or sell.</p>
<p>GitHub now runs its own secret scanning and immediately invalidates some credential types (GitHub tokens, AWS IAM keys partnered with AWS) when detected in public repositories. This covers a subset of credential types. It does not cover database passwords, service-specific tokens for non-partnered services, or private repository commits that become public via fork.</p>
<hr />
<h2 id="the-circleci-breach-platform-level-credential-exfiltration">The CircleCI Breach: Platform-Level Credential Exfiltration</h2>
<p>The CircleCI breach (January 2023) is the definitive example of CI/CD platform-level secrets exposure. The attack chain:</p>
<pre><code class="" data-line="">1. CircleCI engineer&#039;s laptop compromised via malware (initial vector not fully disclosed)
2. Malware steals a 2FA-authenticated SSO session token
3. Session token valid, not expired
4. Attacker uses session token to authenticate to CircleCI internal systems
5. From internal access, attacker reaches production database
6. Production database contains encrypted customer secrets (environment variables)
7. Database also contains the encryption keys (in accessible internal system)
8. Attacker exfiltrates: encrypted secrets + encryption keys = plaintext secrets
</code></pre>
<p><strong>What was stored in CircleCI environment variables by customers:</strong><br />
&#8211; AWS IAM access key ID and secret access key pairs<br />
&#8211; GitHub personal access tokens and OAuth tokens<br />
&#8211; DockerHub credentials<br />
&#8211; SSH private keys (for deployment access)<br />
&#8211; Heroku API keys<br />
&#8211; Stripe, Twilio, SendGrid API keys<br />
&#8211; Internal service account credentials</p>
<p>CircleCI could not determine which customer secrets were accessed and which were not — they notified all customers to rotate all credentials stored in their system.</p>
<p><strong>The scale of the blast radius:</strong> Any customer who had stored long-lived credentials in CircleCI environment variables was potentially compromised. The credential was valid. The CircleCI platform&#8217;s encryption only protected against offline attacks — an attacker with internal database access and access to the key management system had everything needed to decrypt.</p>
<hr />
<h2 id="red-phase-enumerating-secrets-exposure-in-your-pipeline">Red Phase: Enumerating Secrets Exposure in Your Pipeline</h2>
<h3 id="scanning-repositories-for-committed-secrets">Scanning Repositories for Committed Secrets</h3>
<pre><code class="" data-line=""># Install: pip install trufflehog3 or use the Docker image
docker run --rm \
  -v &quot;$(pwd):/repo&quot; \
  trufflesecurity/trufflehog:latest \
  git file:///repo \
  --json \
  --only-verified \
  2&gt;/dev/null | \
  jq &#039;{
    file: .SourceMetadata.Data.Git.file,
    commit: .SourceMetadata.Data.Git.commit,
    detector: .DetectorName,
    verified: .Verified,
    line: .SourceMetadata.Data.Git.line
  }&#039;
</code></pre>
<pre><code class="" data-line=""># Gitleaks: alternative scanner with SARIF output for CI integration
gitleaks detect \
  --source . \
  --report-format sarif \
  --report-path gitleaks-report.sarif \
  --verbose

# Or: scan entire git history (catches secrets that were committed then deleted)
gitleaks detect \
  --source . \
  --log-opts=&quot;--all&quot; \
  --report-format json \
  --report-path gitleaks-history.json
</code></pre>
<pre><code class="" data-line=""># Scan a specific GitHub organization&#039;s public repositories
# (test your own org before red team exercises)
trufflehog github \
  --org your-github-org \
  --token &quot;${GITHUB_TOKEN}&quot; \
  --json \
  --only-verified \
  2&gt;/dev/null | \
  jq &#039;{
    repo: .SourceMetadata.Data.Github.repository,
    file: .SourceMetadata.Data.Github.file,
    detector: .DetectorName,
    verified: .Verified
  }&#039;
</code></pre>
<h3 id="enumerating-secrets-in-cicd-platform-environment-variables">Enumerating Secrets in CI/CD Platform Environment Variables</h3>
<pre><code class="" data-line=""># GitHub Actions: list secrets defined in a repository
# (shows names only — values are not returned by API, but names reveal what&#039;s stored)
curl -H &quot;Authorization: Bearer ${GITHUB_TOKEN}&quot; \
  -H &quot;Accept: application/vnd.github+json&quot; \
  &quot;https://api.github.com/repos/your-org/your-repo/actions/secrets&quot; | \
  jq &#039;.secrets[] | {name: .name, updated: .updated_at}&#039;

# GitHub Actions: list organization-level secrets
curl -H &quot;Authorization: Bearer ${GITHUB_TOKEN}&quot; \
  -H &quot;Accept: application/vnd.github+json&quot; \
  &quot;https://api.github.com/orgs/your-org/actions/secrets&quot; | \
  jq &#039;.secrets[] | {name: .name, visibility: .visibility, updated: .updated_at}&#039;
</code></pre>
<pre><code class="" data-line=""># Check for credentials in running pod environment variables (Kubernetes)
# This is what an attacker with kubectl exec access would do
kubectl get pods -A -o json | \
  jq -r &#039;.items[] | 
    .metadata.namespace + &quot;/&quot; + .metadata.name + &quot;: &quot; + 
    ([.spec.containers[].env[]? | 
      select(.name | test(&quot;KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL|API&quot;; &quot;i&quot;)) |
      .name
    ] | join(&quot;, &quot;))&#039; | \
  grep -v &quot;: $&quot;  # Only show pods with matching env var names
</code></pre>
<h3 id="testing-whether-aws-keys-in-cicd-are-over-permissioned">Testing Whether AWS Keys in CI/CD Are Over-Permissioned</h3>
<pre><code class="" data-line=""># If you find an AWS access key in a scan — test its permissions
# (on your own test account&#039;s keys only)
aws sts get-caller-identity
# Returns: account, user/role ARN, caller ID

# What can this key do?
aws iam simulate-principal-policy \
  --policy-source-arn $(aws sts get-caller-identity --query Arn --output text) \
  --action-names &quot;s3:*&quot; &quot;ec2:*&quot; &quot;iam:*&quot; &quot;sts:AssumeRole&quot; \
  --query &#039;EvaluationResults[?EvalDecision==`allowed`].EvalActionName&#039; \
  --output text
</code></pre>
<hr />
<h2 id="blue-phase-detection-across-the-secret-lifecycle">Blue Phase: Detection Across the Secret Lifecycle</h2>
<h3 id="github-secret-scanning-alerts">GitHub Secret Scanning Alerts</h3>
<pre><code class="" data-line=""># List secret scanning alerts in a repository via GitHub API
curl -H &quot;Authorization: Bearer ${GITHUB_TOKEN}&quot; \
  -H &quot;Accept: application/vnd.github+json&quot; \
  &quot;https://api.github.com/repos/your-org/your-repo/secret-scanning/alerts?state=open&quot; | \
  jq &#039;.[] | {
    type: .secret_type,
    state: .state,
    created: .created_at,
    url: .html_url
  }&#039;
</code></pre>
<h3 id="cloudtrail-detecting-api-activity-from-cicd-credentials">CloudTrail: Detecting API Activity from CI/CD Credentials</h3>
<p>When a CI/CD credential is used by an attacker, the CloudTrail events show unusual patterns:</p>
<pre><code class="" data-line=""># Find API calls from CI/CD credentials outside normal working hours
# or from unexpected IPs (attacker using the stolen key)
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=Username,AttributeValue=ci-deploy-user \
  --start-time &quot;$(date -d &#039;7 days ago&#039; --iso-8601=seconds)&quot; \
  --query &#039;Events[].{Time:EventTime,Name:EventName,IP:CloudTrailEvent}&#039; \
  --output json | \
  jq &#039;.[] | {
    time: .Time,
    event: .Name,
    ip: (.IP | fromjson | .sourceIPAddress),
    user_agent: (.IP | fromjson | .userAgent)
  }&#039; | \
  jq &#039;select(.ip | test(&quot;^(10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|192\\.168\\.)&quot;) | not)&#039;
  # Filter: events from non-RFC1918 IPs (outside your known CI/CD IP ranges)
</code></pre>
<h3 id="siem-query-credential-used-in-multiple-regions-simultaneously">SIEM Query: Credential Used in Multiple Regions Simultaneously</h3>
<p>A credential being used from multiple regions simultaneously is a strong indicator of compromise:</p>
<pre><code class="" data-line="">-- Athena query against CloudTrail logs
-- Detect: same access key used from multiple regions in same hour
SELECT
  userIdentity.accessKeyId,
  userIdentity.userName,
  COUNT(DISTINCT awsRegion) as region_count,
  ARRAY_AGG(DISTINCT awsRegion) as regions,
  COUNT(DISTINCT sourceIPAddress) as ip_count,
  ARRAY_AGG(DISTINCT sourceIPAddress) as source_ips,
  DATE_TRUNC(&#039;hour&#039;, from_iso8601_timestamp(eventTime)) as hour
FROM cloudtrail_logs
WHERE
  userIdentity.type = &#039;IAMUser&#039;
  AND from_iso8601_timestamp(eventTime) &gt; current_timestamp - interval &#039;7&#039; day
GROUP BY
  userIdentity.accessKeyId,
  userIdentity.userName,
  DATE_TRUNC(&#039;hour&#039;, from_iso8601_timestamp(eventTime))
HAVING COUNT(DISTINCT awsRegion) &gt; 2
ORDER BY region_count DESC;
</code></pre>
<h3 id="guardduty-credential-exfiltration-indicators">GuardDuty: Credential Exfiltration Indicators</h3>
<pre><code class="" data-line=""># GuardDuty findings relevant to CI/CD credential compromise
DETECTOR_ID=$(aws guardduty list-detectors --query &#039;DetectorIds[0]&#039; --output text)

aws guardduty list-findings \
  --detector-id &quot;${DETECTOR_ID}&quot; \
  --finding-criteria &#039;{
    &quot;Criterion&quot;: {
      &quot;type&quot;: {
        &quot;Equals&quot;: [
          &quot;UnauthorizedAccess:IAMUser/TorIPCaller&quot;,
          &quot;UnauthorizedAccess:IAMUser/MaliciousIPCaller&quot;,
          &quot;Discovery:IAMUser/AnomalousBehavior&quot;,
          &quot;Exfiltration:IAMUser/AnomalousBehavior&quot;,
          &quot;CredentialAccess:IAMUser/AnomalousBehavior&quot;
        ]
      }
    }
  }&#039; \
  --query &#039;FindingIds&#039; --output text | \
  xargs -n 10 aws guardduty get-findings \
    --detector-id &quot;${DETECTOR_ID}&quot; \
    --finding-ids | \
  jq &#039;.Findings[] | {type: .Type, user: .Resource.AccessKeyDetails.UserName, severity: .Severity}&#039;
</code></pre>
<hr />
<h2 id="purple-phase-the-structural-fix">Purple Phase: The Structural Fix</h2>
<h3 id="fix-1-oidc-workload-identity-eliminate-stored-credentials">Fix 1: OIDC Workload Identity — Eliminate Stored Credentials</h3>
<p>This is the structural solution. Instead of storing an AWS IAM access key in your CI/CD platform, the CI/CD job authenticates to AWS using an OIDC token issued by the CI/CD provider. AWS validates the token against a pre-configured trust policy and issues temporary credentials valid for the duration of the job.</p>
<p>The <a href="/oidc-workload-identity-eliminate-cloud-access-keys/">OIDC workload identity approach eliminates static cloud access keys</a> entirely — there is no secret to commit, no secret to exfiltrate from the CI/CD platform, and no long-lived credential to rotate on breach.</p>
<p><strong>GitHub Actions with AWS OIDC — complete setup:</strong></p>
<pre><code class="" data-line=""># .github/workflows/deploy.yml
name: Deploy to AWS

on:
  push:
    branches: [main]

permissions:
  id-token: write   # Required for OIDC token request
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy-role
          role-session-name: github-actions-${{ github.run_id }}
          aws-region: us-east-1
          # No AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY needed

      - name: Deploy
        run: aws s3 sync ./dist s3://your-bucket/
</code></pre>
<p><strong>AWS IAM trust policy for GitHub Actions OIDC:</strong></p>
<pre><code class="" data-line="">{
  &quot;Version&quot;: &quot;2012-10-17&quot;,
  &quot;Statement&quot;: [
    {
      &quot;Effect&quot;: &quot;Allow&quot;,
      &quot;Principal&quot;: {
        &quot;Federated&quot;: &quot;arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com&quot;
      },
      &quot;Action&quot;: &quot;sts:AssumeRoleWithWebIdentity&quot;,
      &quot;Condition&quot;: {
        &quot;StringEquals&quot;: {
          &quot;token.actions.githubusercontent.com:aud&quot;: &quot;sts.amazonaws.com&quot;
        },
        &quot;StringLike&quot;: {
          &quot;token.actions.githubusercontent.com:sub&quot;: &quot;repo:your-org/your-repo:ref:refs/heads/main&quot;
        }
      }
    }
  ]
}
</code></pre>
<pre><code class="" data-line=""># Create the OIDC provider in AWS (one-time setup)
aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list &quot;6938fd4d98bab03faadb97b34396831e3780aea1&quot;

# Create the IAM role with the trust policy above
aws iam create-role \
  --role-name github-actions-deploy-role \
  --assume-role-policy-document file://github-actions-trust-policy.json

# Attach a least-privilege policy to the role
aws iam attach-role-policy \
  --role-name github-actions-deploy-role \
  --policy-arn arn:aws:iam::123456789012:policy/deploy-policy
</code></pre>
<h3 id="fix-2-pre-commit-hooks-catch-accidents-before-they-reach-vcs">Fix 2: Pre-Commit Hooks — Catch Accidents Before They Reach VCS</h3>
<p>Pre-commit hooks don&#8217;t stop a determined attacker. They catch accidents — the developer who forgets to move a <code class="" data-line="">.env</code> file to <code class="" data-line="">.gitignore</code> before staging all files.</p>
<pre><code class="" data-line=""># Install pre-commit framework
pip install pre-commit

# .pre-commit-config.yaml in your repository root
cat &gt; .pre-commit-config.yaml &lt;&lt; &#039;EOF&#039;
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.4
    hooks:
      - id: gitleaks
        name: Detect hardcoded secrets
        entry: gitleaks protect --staged --redact --verbose
        language: golang
        pass_filenames: false

  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.5.0
    hooks:
      - id: detect-private-key
      - id: check-added-large-files
        args: [&#039;--maxkb=1000&#039;]
EOF

# Install the hooks in the local repository
pre-commit install

# Test against staged files
pre-commit run --all-files
</code></pre>
<h3 id="fix-3-ci-layer-secret-scanning-block-before-merge">Fix 3: CI-Layer Secret Scanning — Block Before Merge</h3>
<pre><code class="" data-line=""># GitHub Actions: secret scanning as a required status check
# .github/workflows/secret-scan.yml
name: Secret Scan

on:
  pull_request:
    types: [opened, synchronize]

jobs:
  secret-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Full history for git log scanning

      - name: Run TruffleHog
        uses: trufflesecurity/trufflehog@main
        with:
          path: ./
          base: ${{ github.event.repository.default_branch }}
          head: HEAD
          extra_args: --only-verified --json
</code></pre>
<pre><code class="" data-line=""># GitLab CI: secret detection built-in template
include:
  - template: Security/Secret-Detection.gitlab-ci.yml

secret_detection:
  stage: test
  variables:
    SECRET_DETECTION_HISTORIC_SCAN: &quot;true&quot;  # Scan full history
</code></pre>
<h3 id="fix-4-audit-and-rotate-existing-cicd-platform-secrets">Fix 4: Audit and Rotate Existing CI/CD Platform Secrets</h3>
<p>After implementing OIDC, the migration path for existing stored credentials:</p>
<pre><code class="" data-line="">#!/bin/bash
# Purple Team EP06 — CI/CD Secrets Migration Audit
# Identifies AWS IAM keys stored in CI/CD that should be replaced with OIDC

echo &quot;=== AWS IAM Keys Potentially Stored in CI/CD ===&quot;
echo &quot;--- Keys not used from expected CI/CD IPs in last 30 days ---&quot;

# Get all IAM access keys
aws iam list-users --query &#039;Users[].UserName&#039; --output text | tr &#039;\t&#039; &#039;\n&#039; | \
  while read user; do
    keys=$(aws iam list-access-keys --user-name &quot;$user&quot; \
      --query &#039;AccessKeyMetadata[?Status==`Active`].{Key:AccessKeyId,Created:CreateDate}&#039; \
      --output json)

    if [ &quot;$(echo &quot;$keys&quot; | jq length)&quot; -gt 0 ]; then
      echo &quot;&quot;
      echo &quot;User: $user&quot;
      echo &quot;$keys&quot; | jq -r &#039;.[] | &quot;  Key: &quot; + .Key + &quot; | Created: &quot; + .Created&#039;

      # Check last used
      echo &quot;$keys&quot; | jq -r &#039;.[].Key&#039; | while read key_id; do
        last_used=$(aws iam get-access-key-last-used --access-key-id &quot;$key_id&quot; \
          --query &#039;AccessKeyLastUsed.{Date:LastUsedDate,Service:ServiceName,Region:Region}&#039; \
          --output json)
        echo &quot;  Last used: $(echo &quot;$last_used&quot; | jq -r &#039;.Date // &quot;Never&quot;&#039;) | Service: $(echo &quot;$last_used&quot; | jq -r &#039;.Service // &quot;N/A&quot;&#039;)&quot;
      done
    fi
  done

echo &quot;&quot;
echo &quot;=== MIGRATION CHECKLIST ===&quot;
echo &quot;  1. For each CI/CD IAM key above:&quot;
echo &quot;     a. Identify which CI/CD platform uses it&quot;
echo &quot;     b. Set up OIDC trust policy for that platform&quot;
echo &quot;     c. Update pipeline to use OIDC (no stored key)&quot;
echo &quot;     d. Disable and then delete the IAM key&quot;
echo &quot;     e. Verify pipelines still work&quot;
</code></pre>
<hr />
<h2 id="run-this-in-your-own-environment-secrets-exposure-audit">Run This in Your Own Environment: Secrets Exposure Audit</h2>
<pre><code class="" data-line="">#!/bin/bash
# Purple Team EP06 — CI/CD Secrets Exposure Audit
# Run from your workstation with git and trufflehog installed

echo &quot;=== 1. Scan Local Repository for Committed Secrets ===&quot;
if command -v trufflehog &gt; /dev/null 2&gt;&amp;1; then
  trufflehog git file://$(pwd) --only-verified --json 2&gt;/dev/null | \
    jq &#039;{file: .SourceMetadata.Data.Git.file, detector: .DetectorName}&#039; || \
    echo &quot;  No verified secrets found in git history&quot;
else
  echo &quot;  Install trufflehog: pip install trufflehog3&quot;
fi

echo &quot;&quot;
echo &quot;=== 2. Check for .env Files in Git History ===&quot;
git log --all --full-history -- &quot;*.env&quot; &quot;**/.env&quot; &quot;.env.*&quot; 2&gt;/dev/null | \
  grep &quot;^commit&quot; | head -5 | \
  while read _ commit; do
    echo &quot;  .env file committed: $commit&quot;
    git show &quot;$commit&quot; --stat | head -3
  done

echo &quot;&quot;
echo &quot;=== 3. Check Running Pods for Credential Env Vars (Kubernetes) ===&quot;
if command -v kubectl &gt; /dev/null 2&gt;&amp;1; then
  kubectl get pods -A -o json 2&gt;/dev/null | \
    jq -r &#039;.items[] | 
      .metadata.namespace + &quot;/&quot; + .metadata.name + &quot;: &quot; + 
      ([.spec.containers[].env[]? | 
        select(.name | test(&quot;KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL&quot;; &quot;i&quot;)) |
        .name
      ] | join(&quot;, &quot;))&#039; | \
    grep -v &quot;: $&quot; | head -20
else
  echo &quot;  kubectl not found&quot;
fi

echo &quot;&quot;
echo &quot;=== 4. GitHub Actions Secrets Inventory ===&quot;
if [ -n &quot;${GITHUB_TOKEN}&quot; ]; then
  REPO=&quot;your-org/your-repo&quot;  # Update this
  curl -s -H &quot;Authorization: Bearer ${GITHUB_TOKEN}&quot; \
    -H &quot;Accept: application/vnd.github+json&quot; \
    &quot;https://api.github.com/repos/${REPO}/actions/secrets&quot; | \
    jq &#039;.secrets[] | {name: .name, updated: .updated_at}&#039;
else
  echo &quot;  Set GITHUB_TOKEN to enumerate repository secrets&quot;
fi
</code></pre>
<hr />
<h2 id="common-mistakes-when-addressing-cicd-secrets-exposure"><img src="https://s.w.org/images/core/emoji/17.0.2/72x72/26a0.png" alt="⚠" class="wp-smiley" style="height: 1em; max-height: 1em;" /> Common Mistakes When Addressing CI/CD Secrets Exposure</h2>
<p><strong>Treating secret scanning as the primary control.</strong> TruffleHog and Gitleaks catch what gets committed. They do not prevent the CircleCI attack class — an attacker who compromises the CI/CD platform itself bypasses all scanning controls. Scanning is detection; OIDC workload identity is prevention.</p>
<p><strong>Rotating compromised keys without checking CloudTrail for use.</strong> When a secret is exposed, the first question is not &#8220;rotate it&#8221; — it is &#8220;was it used?&#8221; Check CloudTrail for any API activity from the key between the suspected exposure time and the rotation. If the key was used, you have an active incident, not just a credential rotation task.</p>
<p><strong>Using OIDC trust policies that are too broad.</strong> The GitHub Actions OIDC trust policy in the fix section uses a <code class="" data-line="">StringLike</code> condition on the <code class="" data-line="">sub</code> claim to scope to a specific repository and branch. If you use <code class="" data-line="">StringLike: &quot;*&quot;</code> instead, any GitHub Actions job in any repository can assume your role. Always scope OIDC trust policies to the specific repository, branch, and environment that needs the access.</p>
<p><strong>Not scanning git history — only the working tree.</strong> Secrets that were committed and then deleted are still in git history. <code class="" data-line="">git rm</code> removes the file from the working tree but not from the object store. TruffleHog and Gitleaks scan history by default when given the <code class="" data-line="">--all</code> flag. Scanning only the current working tree misses all historical exposures.</p>
<p><strong>Forgetting third-party GitHub Actions.</strong> The supply chain attack surface includes the Actions you reference in your workflows. An Action pinned to a mutable tag (<code class="" data-line="">@main</code>, <code class="" data-line="">@v1</code>) can be changed by the maintainer. Pin to a specific commit SHA and verify the Action&#8217;s provenance.</p>
<pre><code class="" data-line=""># Vulnerable: mutable tag
- uses: aws-actions/configure-aws-credentials@v4

# Secure: pinned SHA
- uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e831c1e4c763fe4
</code></pre>
<hr />
<h2 id="quick-reference">Quick Reference</h2>
<table>
<thead>
<tr>
<th>Secret Storage Pattern</th>
<th>Risk Level</th>
<th>Structural Fix</th>
</tr>
</thead>
<tbody>
<tr>
<td>.env file committed to public repo</td>
<td>Critical</td>
<td>Pre-commit hook + OIDC</td>
</tr>
<tr>
<td>.env file committed to private repo</td>
<td>High</td>
<td>Git history purge + pre-commit hook + OIDC</td>
</tr>
<tr>
<td>Long-lived key in CI/CD env var</td>
<td>High</td>
<td>OIDC workload identity</td>
</tr>
<tr>
<td>Long-lived key in K8s Secret</td>
<td>High</td>
<td>Pod identity / IRSA / Workload Identity</td>
</tr>
<tr>
<td>Secret in build log output</td>
<td>Medium</td>
<td>Mask secrets in CI configuration</td>
</tr>
<tr>
<td>Secret in container env var</td>
<td>Medium</td>
<td>Vault agent / CSI secrets driver</td>
</tr>
<tr>
<td>Key referenced via AWS Secrets Manager</td>
<td>Low (if scoped)</td>
<td>Use for remaining static secrets</td>
</tr>
</tbody>
</table>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li><strong>CI/CD secrets exposure</strong> is structural: long-lived credentials in a CI/CD platform are only as secure as that platform — the CircleCI breach proved that encryption alone is insufficient if the attacker can access the keys</li>
<li>Automated secret scanners find publicly committed credentials within 60–90 seconds — rotation must happen faster than that or assume compromise</li>
<li>Pre-commit hooks and CI secret scanning catch accidents; they do not prevent determined attackers who compromise the platform itself</li>
<li><a href="/oidc-workload-identity-eliminate-cloud-access-keys/">OIDC workload identity</a> is the structural fix: no stored credential means no credential to exfiltrate</li>
<li>When rotating a compromised key, check CloudTrail for usage between exposure and rotation before closing the incident</li>
<li>OIDC trust policies must be scoped to specific repositories and branches — a wildcard trust policy recreates the exposure in a different form</li>
<li>Pin third-party GitHub Actions to commit SHAs, not mutable tags — mutable tags are a supply chain attack surface</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>EP07 covers SSRF to cloud metadata: how an SSRF vulnerability in any application layer becomes a straight line to IAM credentials when IMDSv2 is not enforced. The Capital One breach anatomy — WAF SSRF → EC2 metadata → IAM role credentials → 100 million S3 records — in full technical detail, with the simulation commands and the one-line enforcement fix. If you&#8217;ve addressed identity and secrets, the network attack paths are where EP07 through EP10 focus.</p>
<p>Get EP07 in your inbox when it publishes → <a href="#subscribe">subscribe at linuxcent.com</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fcicd-secrets-exposure-supply-chain%2F&amp;linkname=CI%2FCD%20Secrets%20Exposure%3A%20How%20Supply%20Chain%20Attacks%20Target%20Your%20Pipeline" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fcicd-secrets-exposure-supply-chain%2F&amp;linkname=CI%2FCD%20Secrets%20Exposure%3A%20How%20Supply%20Chain%20Attacks%20Target%20Your%20Pipeline" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fcicd-secrets-exposure-supply-chain%2F&amp;linkname=CI%2FCD%20Secrets%20Exposure%3A%20How%20Supply%20Chain%20Attacks%20Target%20Your%20Pipeline" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fcicd-secrets-exposure-supply-chain%2F&amp;linkname=CI%2FCD%20Secrets%20Exposure%3A%20How%20Supply%20Chain%20Attacks%20Target%20Your%20Pipeline" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fcicd-secrets-exposure-supply-chain%2F&amp;linkname=CI%2FCD%20Secrets%20Exposure%3A%20How%20Supply%20Chain%20Attacks%20Target%20Your%20Pipeline" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fcicd-secrets-exposure-supply-chain%2F&amp;linkname=CI%2FCD%20Secrets%20Exposure%3A%20How%20Supply%20Chain%20Attacks%20Target%20Your%20Pipeline" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fcicd-secrets-exposure-supply-chain%2F&amp;linkname=CI%2FCD%20Secrets%20Exposure%3A%20How%20Supply%20Chain%20Attacks%20Target%20Your%20Pipeline" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fcicd-secrets-exposure-supply-chain%2F&#038;title=CI%2FCD%20Secrets%20Exposure%3A%20How%20Supply%20Chain%20Attacks%20Target%20Your%20Pipeline" data-a2a-url="https://linuxcent.com/cicd-secrets-exposure-supply-chain/" data-a2a-title="CI/CD Secrets Exposure: How Supply Chain Attacks Target Your Pipeline"></a></p><p>The post <a href="https://linuxcent.com/cicd-secrets-exposure-supply-chain/">CI/CD Secrets Exposure: How Supply Chain Attacks Target Your Pipeline</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/cicd-secrets-exposure-supply-chain/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1858</post-id>	</item>
		<item>
		<title>The Pipeline Gate — Hardened Images as a CI/CD Build Constraint</title>
		<link>https://linuxcent.com/hardened-image-cicd-pipeline-gate/</link>
					<comments>https://linuxcent.com/hardened-image-cicd-pipeline-gate/#respond</comments>
		
		<dc:creator><![CDATA[Vamshi Krishna Santhapuri]]></dc:creator>
		<pubDate>Sat, 23 May 2026 02:00:00 +0000</pubDate>
				<category><![CDATA[OS Image Builder]]></category>
		<category><![CDATA[BakeX]]></category>
		<category><![CDATA[CI/CD]]></category>
		<category><![CDATA[DevSecOps]]></category>
		<category><![CDATA[GitOps]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[Pipeline]]></category>
		<category><![CDATA[Security]]></category>
		<guid isPermaLink="false">https://linuxcent.com/?p=1831</guid>

					<description><![CDATA[<p><span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 7</span> <span class="rt-label rt-postfix">minutes</span></span>Make hardened OS images a CI/CD build constraint: POST /api/pipeline/scan fails the build if grade < threshold. Unhardened images never reach production.
</p>
<p>The post <a href="https://linuxcent.com/hardened-image-cicd-pipeline-gate/">The Pipeline Gate — Hardened Images as a CI/CD Build Constraint</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></description>
										<content:encoded><![CDATA[<span class="span-reading-time rt-reading-time" style="display: block;"><span class="rt-label rt-prefix">Reading Time: </span> <span class="rt-time"> 7</span> <span class="rt-label rt-postfix">minutes</span></span><style>
pre{position:relative;background:#1e1e1e;color:#d4d4d4;
    padding:16px 16px 16px 20px;border-radius:6px;overflow-x:auto;
    font-family:'JetBrains Mono','Fira Code','Cascadia Code',Consolas,'Courier New',monospace;
    font-size:.88em;line-height:1.6;border-left:4px solid #555}
code{background:#f4f4f4;padding:2px 5px;border-radius:3px;font-size:.9em}
pre code{background:transparent;padding:0;color:inherit}
pre[data-lang="bash"],pre[data-lang="sh"],
pre[data-lang="shell"],pre[data-lang="zsh"]{border-left-color:#4ec9b0}
pre[data-lang="yaml"],pre[data-lang="json"],
pre[data-lang="toml"],pre[data-lang="xml"]{border-left-color:#569cd6}
pre[data-lang="python"],pre[data-lang="go"],pre[data-lang="rust"],
pre[data-lang="java"],pre[data-lang="c"],pre[data-lang="cpp"]{border-left-color:#c586c0}
pre[data-lang="text"],pre[data-lang="output"],
pre[data-lang="console"]{border-left-color:#888}
.lc-copy-btn{position:absolute;top:8px;right:8px;background:#2d2d2d;color:#ccc;
    border:1px solid #444;border-radius:4px;padding:3px 9px;font-size:.75em;
    font-family:system-ui,sans-serif;cursor:pointer;opacity:0;
    transition:opacity .15s,background .15s;line-height:1.6}
pre:hover .lc-copy-btn{opacity:1}
.lc-copy-btn:hover{background:#3a3a3a;color:#fff}
.lc-copy-btn.copied{color:#4ec9b0;border-color:#4ec9b0}
.lc-lang-badge{position:absolute;top:8px;left:20px;font-family:system-ui,sans-serif;
    font-size:.7em;color:#666;text-transform:uppercase;letter-spacing:.04em;
    line-height:1;pointer-events:none;opacity:0;transition:opacity .15s}
pre:hover .lc-lang-badge{opacity:1}
table{border-collapse:collapse;width:100%;margin:16px 0}
th,td{border:1px solid #ddd;padding:10px 14px;text-align:left}
th{background:#f0f0f0;font-weight:600}
tr:nth-child(even){background:#fafafa}
</style>
<p><script>
(function(){
  if(window.__lcCodeEnhanced)return;
  window.__lcCodeEnhanced=true;
  function enhance(){
    document.querySelectorAll('pre').forEach(function(pre){
      var code=pre.querySelector('code');
      var lang='';
      if(code){var m=(code.className||'').match(/language-(\S+)/);if(m)lang=m[1].toLowerCase();}
      if(lang)pre.setAttribute('data-lang',lang);
      if(lang){var badge=document.createElement('span');badge.className='lc-lang-badge';badge.textContent=lang;pre.insertBefore(badge,pre.firstChild);}
      var btn=document.createElement('button');
      btn.className='lc-copy-btn';btn.textContent='Copy';btn.setAttribute('aria-label','Copy code to clipboard');
      pre.appendChild(btn);
      btn.addEventListener('click',function(){
        var text=code?code.innerText:pre.innerText;
        if(navigator.clipboard&&window.isSecureContext){
          navigator.clipboard.writeText(text).then(function(){ok(btn);}).catch(function(){fb(text,btn);});
        }else{fb(text,btn);}
      });
    });
  }
  function ok(btn){btn.textContent='Copied!';btn.classList.add('copied');setTimeout(function(){btn.textContent='Copy';btn.classList.remove('copied');},2000);}
  function fb(text,btn){
    try{var ta=document.createElement('textarea');ta.value=text;ta.style.cssText='position:fixed;left:-9999px;top:-9999px;opacity:0';document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);ok(btn);}
    catch(e){btn.textContent='✗ Failed';setTimeout(function(){btn.textContent='Copy';},2000);}
  }
  if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',enhance);}else{enhance();}
})();
</script></p>
<p><em>OS Hardening as Code, Episode 5</em><br />
<em><a href="https://linuxcent.com/cloud-ami-security-risks-custom-os-images/">Cloud AMI Security Risks</a> · <a href="/linux-hardening-as-code/">Linux Hardening as Code</a> · <a href="/linux-hardening-multi-cloud/">Multi-Cloud OS Hardening</a> · <a href="/automated-compliance-scanning-openscap/">Automated OpenSCAP Compliance</a> · </em><em>CI/CD Compliance Gate</em>**</p>
<blockquote>
<p><strong>Note:</strong> the tool in this series was released as <strong>Stratum</strong> and renamed to <strong>BakeX</strong> at<br />
v0.6.0 — same project, same license, same team. Commands below use the current <code class="" data-line="">bakex</code><br />
CLI. If you arrived here looking for <code class="" data-line="">stratum</code> or <code class="" data-line="">pip install stratumoss</code>, you&#8217;re in the<br />
right place: <a href="https://github.com/invicton/bakex">github.com/invicton/bakex</a>.</p>
</blockquote>
<hr />
<h2 id="tldr">TL;DR</h2>
<ul>
<li>A CI/CD compliance gate turns an OS hardening grade from a report into a build constraint — unhardened images fail the pipeline before they can be deployed</li>
<li><code class="" data-line="">POST /api/pipeline/scan</code> scores an image against a <code class="" data-line="">pass_threshold</code> and a <code class="" data-line="">severity_threshold</code>, and returns a <code class="" data-line="">passed</code> boolean</li>
<li><strong>The endpoint returns HTTP 200 even when the gate fails.</strong> <code class="" data-line="">curl -sf</code> will not catch it — you must parse <code class="" data-line="">.passed</code>. This is the single most important detail on this page</li>
<li>The gate is two-dimensional: a score floor <em>and</em> a severity ceiling, so one critical finding blocks a release that scores 94</li>
<li>GitHub Actions, GitLab CI, Jenkins, and Tekton integrations are one curl plus one <code class="" data-line="">jq</code></li>
<li>The structural guarantee: an image that doesn&#8217;t pass the gate doesn&#8217;t reach the deploy job</li>
</ul>
<hr />
<h2 id="the-problem-a-grade-no-one-checks-is-decoration">The Problem: A Grade No One Checks Is Decoration</h2>
<pre><code class="" data-line="">Pipeline without compliance gate:
  Build → Test → Security scan (results to dashboard) → Deploy

What actually happens:
  Build → Test → Security scan → &quot;C grade, but we need to ship&quot; → Deploy anyway
                                           │
                                           └─ Dashboard shows C grade
                                              Nobody is paged
                                              Deployment succeeds
</code></pre>
<p>A CI/CD compliance gate means the pipeline can&#8217;t continue if the grade is below threshold.</p>
<p>EP04 showed that automated OpenSCAP compliance gives every image a verified, reproducible grade before deployment. What it assumed is that someone checks the grade before deploying. They don&#8217;t — not under deadline pressure, not when the image has been &#8220;working fine for months,&#8221; not at 2am.</p>
<p>The same problem that made hardening runbooks skippable applies to compliance grades: if checking the grade is a discretionary step, it will be skipped.</p>
<hr />
<p>A new microservice was deployed from an unhardened base image. The team had built it quickly during a sprint, used a community AMI as the base, and planned to harden it &#8220;in the next sprint.&#8221;</p>
<p>Three weeks later, a penetration test found it. SSH password authentication enabled. Three unnecessary services running — one of them with a known CVE. The finding: the instance had full inbound access from the VPC and was reachable from a compromised adjacent instance.</p>
<p>The deployment had gone through the normal CI/CD pipeline. Unit tests passed. Integration tests passed. A vulnerability scan ran. The scan produced a report that went to a dashboard. Nobody had a gate set up to fail the build if the image was unhardened.</p>
<p>The hardening work from the &#8220;next sprint&#8221; plan would have taken four hours. The pentest remediation took a week, plus the time to investigate what had been exposed during the three weeks the instance was running.</p>
<p>The CI/CD pipeline had every check except the one that would have caught the base image problem before the first deployment.</p>
<hr />
<h2 id="the-pipeline-api">The Pipeline API</h2>
<p>The Pipeline API is a single HTTP endpoint that takes an image ID, scans it, and returns a verdict:</p>
<pre><code class="" data-line="">curl -s -X POST https://bakex.yourdomain.com/api/pipeline/scan \
  -H &quot;X-API-Key: ${BAKEX_TOKEN}&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &#039;{
    &quot;image_id&quot;: &quot;ami-0a7f3c9e82d1b4c05&quot;,
    &quot;provider&quot;: &quot;aws&quot;,
    &quot;region&quot;: &quot;us-east-1&quot;,
    &quot;pass_threshold&quot;: 75.0,
    &quot;severity_threshold&quot;: &quot;high&quot;,
    &quot;wait&quot;: true
  }&#039;
</code></pre>
<p>Authentication takes either <code class="" data-line="">X-API-Key</code> or <code class="" data-line="">Authorization: Bearer</code>; keys are created at<br />
<code class="" data-line="">/settings/api-keys</code>. With <code class="" data-line="">wait: true</code> the request blocks until the scan completes — which is what<br />
you want in CI, where a job that returns before the answer exists is worse than a slow one. There&#8217;s<br />
a <code class="" data-line="">timeout_seconds</code> (default 900) for when it doesn&#8217;t.</p>
<p>The response is the same shape whether you passed or failed:</p>
<pre><code class="" data-line="">{
  &quot;job_id&quot;: &quot;7f3c9e82-4d1b-4c05-a7f3-c9e82d1b4c05&quot;,
  &quot;status&quot;: &quot;complete&quot;,
  &quot;passed&quot;: false,
  &quot;grade&quot;: &quot;C&quot;,
  &quot;score_pct&quot;: 72.0,
  &quot;severity_counts&quot;: { &quot;critical&quot;: 0, &quot;high&quot;: 2, &quot;medium&quot;: 5, &quot;low&quot;: 11 },
  &quot;threshold_violations&quot;: [&quot;high&quot;],
  &quot;pass_threshold&quot;: 75.0,
  &quot;severity_threshold&quot;: &quot;high&quot;,
  &quot;image_id&quot;: &quot;ami-0c9d5e3f81a2b6e07&quot;,
  &quot;sarif_url&quot;: &quot;.../api/auditor/scan-image/7f3c9e82.../report?fmt=sarif&quot;,
  &quot;html_report_url&quot;: &quot;.../api/auditor/scan-image/7f3c9e82.../report&quot;
}
</code></pre>
<h3 id="the-detail-that-will-silently-break-your-gate">The detail that will silently break your gate</h3>
<p><strong>A failed gate still returns HTTP 200.</strong> There is no 4xx on failure — the verdict is in the<br />
<code class="" data-line="">passed</code> field, not the status code.</p>
<p>That means the pattern everyone reaches for first is wrong:</p>
<pre><code class="" data-line=""># WRONG — this never fails. -f only reacts to HTTP &gt;= 400,
# and a failed gate returns 200.
curl -sf -X POST .../api/pipeline/scan -d &#039;...&#039; || exit 1
</code></pre>
<p>You have to read the body:</p>
<pre><code class="" data-line=""># RIGHT
RESULT=$(curl -s -X POST &quot;${BAKEX_URL}/api/pipeline/scan&quot; \
  -H &quot;X-API-Key: ${BAKEX_TOKEN}&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &quot;{\&quot;image_id\&quot;: \&quot;${AMI_ID}\&quot;, \&quot;pass_threshold\&quot;: 75.0, \&quot;severity_threshold\&quot;: \&quot;high\&quot;}&quot;)

echo &quot;$RESULT&quot; | jq -r &#039;&quot;grade=\(.grade) score=\(.score_pct) passed=\(.passed)&quot;&#039;

if [ &quot;$(echo &quot;$RESULT&quot; | jq -r &#039;.passed&#039;)&quot; != &quot;true&quot; ]; then
  echo &quot;Compliance gate failed — violations: $(echo &quot;$RESULT&quot; | jq -c &#039;.threshold_violations&#039;)&quot;
  echo &quot;Report: $(echo &quot;$RESULT&quot; | jq -r &#039;.html_report_url&#039;)&quot;
  exit 1
fi
</code></pre>
<p>A gate that reports failure and exits 0 is worse than no gate, because it produces a green<br />
pipeline and the belief that something was checked.</p>
<h3 id="two-thresholds-not-one">Two thresholds, not one</h3>
<p><code class="" data-line="">passed</code> is the AND of two independent conditions:</p>
<pre><code class="" data-line="">passed = (score_pct &gt;= pass_threshold) AND (no findings at or above severity_threshold)
</code></pre>
<p><code class="" data-line="">severity_threshold: &quot;high&quot;</code> means any <code class="" data-line="">critical</code> or <code class="" data-line="">high</code> finding fails the build regardless of<br />
score. An image can score 94 — a comfortable A — and still fail on a single critical finding. That<br />
is the right default: scores average away the thing that gets you breached.</p>
<hr />
<h2 id="github-actions-integration">GitHub Actions Integration</h2>
<pre><code class="" data-line=""># .github/workflows/deploy.yml

jobs:
  build-image:
    runs-on: ubuntu-latest
    outputs:
      ami_id: ${{ steps.build.outputs.ami_id }}
    steps:
      - name: Build hardened AMI
        id: build
        run: |
          AMI_ID=$(bakex build blueprints/ubuntu/22.04/cis-l1-aws.yaml --json \
            | jq -r &#039;.artifact_id&#039;)
          echo &quot;ami_id=${AMI_ID}&quot; &gt;&gt; $GITHUB_OUTPUT

  compliance-gate:
    runs-on: ubuntu-latest
    needs: build-image
    steps:
      - name: BakeX compliance gate
        run: |
          RESULT=$(curl -s -X POST ${{ vars.BAKEX_URL }}/api/pipeline/scan \
            -H &quot;X-API-Key: ${{ secrets.BAKEX_TOKEN }}&quot; \
            -H &quot;Content-Type: application/json&quot; \
            -d &quot;{\&quot;image_id\&quot;: \&quot;${{ needs.build-image.outputs.ami_id }}\&quot;,
                 \&quot;pass_threshold\&quot;: 75.0, \&quot;severity_threshold\&quot;: \&quot;high\&quot;}&quot;)

          echo &quot;$RESULT&quot; | jq -r &#039;&quot;grade=\(.grade) score=\(.score_pct)&quot;&#039;

          # Must check .passed — the endpoint returns 200 on failure
          if [ &quot;$(echo &quot;$RESULT&quot; | jq -r &#039;.passed&#039;)&quot; != &quot;true&quot; ]; then
            echo &quot;::error::Compliance gate failed: $(echo &quot;$RESULT&quot; | jq -c &#039;.threshold_violations&#039;)&quot;
            exit 1
          fi

      - name: Upload SARIF to code scanning
        if: always()
        run: |
          curl -s -o bakex.sarif &quot;$(echo &quot;$RESULT&quot; | jq -r &#039;.sarif_url&#039;)&quot;
      - uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: bakex.sarif

  deploy:
    runs-on: ubuntu-latest
    needs: [build-image, compliance-gate]
    steps:
      - name: Deploy to staging
        run: |
          aws autoscaling update-auto-scaling-group \
            --auto-scaling-group-name my-asg \
            --launch-template &quot;ImageId=${{ needs.build-image.outputs.ami_id }}&quot;
</code></pre>
<p>The <code class="" data-line="">deploy</code> job only runs if <code class="" data-line="">compliance-gate</code> passes. The AMI doesn&#8217;t reach the autoscaling group if it doesn&#8217;t meet the grade threshold.</p>
<hr />
<h2 id="gitlab-ci-integration">GitLab CI Integration</h2>
<pre><code class="" data-line=""># .gitlab-ci.yml

stages:
  - build
  - compliance
  - deploy

build-image:
  stage: build
  script:
    - |
      AMI_ID=$(bakex build blueprints/ubuntu/22.04/cis-l1-aws.yaml --json \
        | jq -r &#039;.artifact_id&#039;)
      echo &quot;AMI_ID=${AMI_ID}&quot; &gt;&gt; build.env
  artifacts:
    reports:
      dotenv: build.env

compliance-gate:
  stage: compliance
  needs: [build-image]
  script:
    - |
      RESULT=$(curl -s -X POST ${BAKEX_URL}/api/pipeline/scan \
        -H &quot;X-API-Key: ${BAKEX_TOKEN}&quot; \
        -H &quot;Content-Type: application/json&quot; \
        -d &quot;{\&quot;image_id\&quot;: \&quot;${AMI_ID}\&quot;, \&quot;pass_threshold\&quot;: 75.0,
             \&quot;severity_threshold\&quot;: \&quot;high\&quot;}&quot;)
      echo &quot;$RESULT&quot; | jq -r &#039;&quot;grade=\(.grade) score=\(.score_pct) passed=\(.passed)&quot;&#039;
      test &quot;$(echo &quot;$RESULT&quot; | jq -r &#039;.passed&#039;)&quot; = &quot;true&quot;

deploy:
  stage: deploy
  needs: [build-image, compliance-gate]
  script:
    - ./deploy.sh ${AMI_ID}
</code></pre>
<hr />
<h2 id="what-the-failed-gate-tells-you">What the Failed Gate Tells You</h2>
<p>The value of the CI/CD compliance gate is not just that it blocks bad images — it&#8217;s that the failure output tells engineers what to fix.</p>
<p>The response carries three things an engineer can act on immediately:</p>
<pre><code class="" data-line="">$ echo &quot;$RESULT&quot; | jq &#039;{grade, score_pct, threshold_violations, severity_counts}&#039;
{
  &quot;grade&quot;: &quot;C&quot;,
  &quot;score_pct&quot;: 72.0,
  &quot;threshold_violations&quot;: [&quot;high&quot;],
  &quot;severity_counts&quot;: { &quot;critical&quot;: 0, &quot;high&quot;: 2, &quot;medium&quot;: 5, &quot;low&quot;: 11 }
}
</code></pre>
<p><code class="" data-line="">threshold_violations</code> names the severities that broke the gate — here, two <code class="" data-line="">high</code> findings, not the<br />
score. That distinction matters: an engineer who reads &#8220;grade C&#8221; starts a broad hardening project,<br />
while one who reads &#8220;two high findings&#8221; goes and fixes two things.</p>
<p>For the rule-level detail, follow <code class="" data-line="">sarif_url</code>. Pushing that SARIF into GitHub code scanning (as in<br />
the workflow above) puts each finding on the pull request diff, which is where someone will actually<br />
read it — a link to a dashboard in a CI log is a link nobody clicks.</p>
<hr />
<h2 id="thresholds-by-environment">Thresholds by Environment</h2>
<p>Not all environments need the same bar, and both dimensions are per-request — so the environment<br />
distinction lives in your pipeline, not in BakeX config:</p>
<pre><code class="" data-line=""># Production — high score floor, nothing high or above
PASS=90.0 ; SEV=high

# Staging — lower floor, still no criticals
PASS=75.0 ; SEV=critical

# Development — score only, severity effectively off
PASS=60.0 ; SEV=low

curl -s -X POST &quot;${BAKEX_URL}/api/pipeline/scan&quot; \
  -H &quot;X-API-Key: ${BAKEX_TOKEN}&quot; -H &quot;Content-Type: application/json&quot; \
  -d &quot;{\&quot;image_id\&quot;: \&quot;${AMI_ID}\&quot;, \&quot;pass_threshold\&quot;: ${PASS}, \&quot;severity_threshold\&quot;: \&quot;${SEV}\&quot;}&quot;
</code></pre>
<p>Note that <code class="" data-line="">severity_threshold</code> gets <em>stricter</em> as it goes down the list: <code class="" data-line="">low</code> fails on any finding<br />
at all, <code class="" data-line="">critical</code> fails only on criticals. It reads backwards the first time. Development wanting a<br />
permissive gate wants <code class="" data-line="">critical</code>, not <code class="" data-line="">low</code>.</p>
<hr />
<h2 id="production-gotchas">Production Gotchas</h2>
<p><strong>The 200-on-failure behaviour is the whole ballgame.</strong> Repeating it because it is the one thing that<br />
turns this page from useful to harmful if missed: check <code class="" data-line="">.passed</code>. Never rely on <code class="" data-line="">curl -f</code>, and never<br />
rely on the HTTP status.</p>
<p><strong>Scans take minutes, and <code class="" data-line="">wait: true</code> blocks.</strong> The endpoint provisions an instance from the image<br />
and scans it. With <code class="" data-line="">wait: true</code> your CI job blocks for the duration; <code class="" data-line="">timeout_seconds</code> defaults to<br />
900. Set your CI step timeout above that, or use <code class="" data-line="">wait: false</code> and poll <code class="" data-line="">GET /api/pipeline/scan/{job_id}</code>.</p>
<p><strong>Token rotation.</strong> The API key should rotate on the same schedule as other service credentials, and<br />
environments should use different keys — a leaked staging key must not be able to satisfy a<br />
production gate.</p>
<p><strong>The gate needs a reachable BakeX server.</strong> This is an HTTP API, not a self-contained action: the<br />
runner must reach the BakeX instance, and that instance needs cloud credentials for the provider<br />
whose image it is scanning.</p>
<hr />
<h2 id="key-takeaways">Key Takeaways</h2>
<ul>
<li>A CI/CD compliance gate turns a compliance grade from a dashboard metric into a pipeline constraint — the image doesn&#8217;t deploy if it doesn&#8217;t pass</li>
<li><code class="" data-line="">POST /api/pipeline/scan</code> is a single HTTP call that any CI/CD system can make — no agent, no plugin, no SDK required</li>
<li><strong>The endpoint returns 200 even when the gate fails.</strong> Parse <code class="" data-line="">.passed</code>; <code class="" data-line="">curl -sf || exit 1</code> produces a green pipeline and a false sense of security</li>
<li>The verdict is two-dimensional — a score floor AND a severity ceiling — so a single critical finding blocks an image that scores 94</li>
<li><code class="" data-line="">threshold_violations</code> tells an engineer <em>why</em> it failed, which is the difference between &#8220;fix two high findings&#8221; and &#8220;start a hardening project&#8221;</li>
<li>Push the <code class="" data-line="">sarif_url</code> into GitHub code scanning so findings land on the pull request, not in a CI log</li>
</ul>
<hr />
<h2 id="whats-next">What&#8217;s Next</h2>
<p>The CI/CD compliance gate closes the final gap: even if an unhardened image gets built, it can&#8217;t deploy. EP05 is the bookmark episode — this is the point where OS hardening becomes structurally enforced rather than procedurally expected.</p>
<p>EP06 is the series closer. For five episodes, you&#8217;ve been using BakeX as a user. What does it look like to run it yourself — extend it with a custom provider, deploy it in your own infrastructure, or contribute a blueprint back?</p>
<p>BakeX is Apache 2.0. EP06 is the architecture reveal, the deployment guide, and the extension points for everything the series taught.</p>
<p><em>Next: <a href="/stratum-os-hardening-platform/">BakeX — open-source OS hardening platform for multi-cloud infrastructure</a></em></p>
<p>Get EP06 in your inbox when it publishes → <a href="https://linuxcent.com/subscribe">linuxcent.com/subscribe</a></p>
<p><a class="a2a_button_mastodon" href="https://www.addtoany.com/add_to/mastodon?linkurl=https%3A%2F%2Flinuxcent.com%2Fhardened-image-cicd-pipeline-gate%2F&amp;linkname=The%20Pipeline%20Gate%20%E2%80%94%20Hardened%20Images%20as%20a%20CI%2FCD%20Build%20Constraint" title="Mastodon" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_email" href="https://www.addtoany.com/add_to/email?linkurl=https%3A%2F%2Flinuxcent.com%2Fhardened-image-cicd-pipeline-gate%2F&amp;linkname=The%20Pipeline%20Gate%20%E2%80%94%20Hardened%20Images%20as%20a%20CI%2FCD%20Build%20Constraint" title="Email" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_whatsapp" href="https://www.addtoany.com/add_to/whatsapp?linkurl=https%3A%2F%2Flinuxcent.com%2Fhardened-image-cicd-pipeline-gate%2F&amp;linkname=The%20Pipeline%20Gate%20%E2%80%94%20Hardened%20Images%20as%20a%20CI%2FCD%20Build%20Constraint" title="WhatsApp" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_reddit" href="https://www.addtoany.com/add_to/reddit?linkurl=https%3A%2F%2Flinuxcent.com%2Fhardened-image-cicd-pipeline-gate%2F&amp;linkname=The%20Pipeline%20Gate%20%E2%80%94%20Hardened%20Images%20as%20a%20CI%2FCD%20Build%20Constraint" title="Reddit" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_x" href="https://www.addtoany.com/add_to/x?linkurl=https%3A%2F%2Flinuxcent.com%2Fhardened-image-cicd-pipeline-gate%2F&amp;linkname=The%20Pipeline%20Gate%20%E2%80%94%20Hardened%20Images%20as%20a%20CI%2FCD%20Build%20Constraint" title="X" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_linkedin" href="https://www.addtoany.com/add_to/linkedin?linkurl=https%3A%2F%2Flinuxcent.com%2Fhardened-image-cicd-pipeline-gate%2F&amp;linkname=The%20Pipeline%20Gate%20%E2%80%94%20Hardened%20Images%20as%20a%20CI%2FCD%20Build%20Constraint" title="LinkedIn" rel="nofollow noopener" target="_blank"></a><a class="a2a_button_copy_link" href="https://www.addtoany.com/add_to/copy_link?linkurl=https%3A%2F%2Flinuxcent.com%2Fhardened-image-cicd-pipeline-gate%2F&amp;linkname=The%20Pipeline%20Gate%20%E2%80%94%20Hardened%20Images%20as%20a%20CI%2FCD%20Build%20Constraint" title="Copy Link" rel="nofollow noopener" target="_blank"></a><a class="a2a_dd addtoany_share_save addtoany_share" href="https://www.addtoany.com/share#url=https%3A%2F%2Flinuxcent.com%2Fhardened-image-cicd-pipeline-gate%2F&#038;title=The%20Pipeline%20Gate%20%E2%80%94%20Hardened%20Images%20as%20a%20CI%2FCD%20Build%20Constraint" data-a2a-url="https://linuxcent.com/hardened-image-cicd-pipeline-gate/" data-a2a-title="The Pipeline Gate — Hardened Images as a CI/CD Build Constraint"></a></p><p>The post <a href="https://linuxcent.com/hardened-image-cicd-pipeline-gate/">The Pipeline Gate — Hardened Images as a CI/CD Build Constraint</a> appeared first on <a href="https://linuxcent.com">Linuxcent</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://linuxcent.com/hardened-image-cicd-pipeline-gate/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
		<post-id xmlns="com-wordpress:feed-additions:1">1831</post-id>	</item>
	</channel>
</rss>

<!--
Performance optimized by W3 Total Cache. Learn more: https://www.boldgrid.com/w3-total-cache/?utm_source=w3tc&utm_medium=footer_comment&utm_campaign=free_plugin

Page Caching using Disk: Enhanced 

Served from: linuxcent.com @ 2026-09-04 09:58:41 by W3 Total Cache
-->