<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Security on Not That Kind of Agent</title><link>https://agentolivia.github.io/tags/security/</link><description>Recent content in Security on Not That Kind of Agent</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><lastBuildDate>Wed, 13 May 2026 00:00:00 -0700</lastBuildDate><atom:link href="https://agentolivia.github.io/tags/security/index.xml" rel="self" type="application/rss+xml"/><item><title>Hardening GitHub Actions workflows against supply chain attacks</title><link>https://agentolivia.github.io/p/hardening-github-actions-workflows-against-supply-chain-attacks/</link><pubDate>Wed, 13 May 2026 00:00:00 -0700</pubDate><guid>https://agentolivia.github.io/p/hardening-github-actions-workflows-against-supply-chain-attacks/</guid><description>&lt;p&gt;I asked Claude Code to review the GitHub Actions workflows in this repo for supply chain attack risk. This post covers what it found, what we changed, and a prompt you can reuse on your own workflows.&lt;/p&gt;
&lt;h2 id="the-threat-model"&gt;The threat model
&lt;/h2&gt;&lt;p&gt;A supply chain attack via GitHub Actions works like this: a third-party action you depend on (say, &lt;code&gt;some-org/some-action@v2&lt;/code&gt;) gets its tag force-pushed with malicious code. On your next workflow run, that code executes in your CI environment. It has access to your secrets and token permissions.&lt;/p&gt;
&lt;p&gt;The same risk applies to npm. If a package in your &lt;code&gt;node_modules&lt;/code&gt; tree is compromised, its postinstall script runs during &lt;code&gt;npm ci&lt;/code&gt;. It can read any credentials in the job environment.&lt;/p&gt;
&lt;p&gt;Neither is theoretical. Both attack types have hit real CI pipelines.&lt;/p&gt;
&lt;h2 id="what-i-changed"&gt;What I changed
&lt;/h2&gt;&lt;h3 id="1-pin-third-party-actions-to-commit-shas"&gt;1. Pin third-party actions to commit SHAs
&lt;/h3&gt;&lt;p&gt;GitHub&amp;rsquo;s version tags (e.g., &lt;code&gt;@v2&lt;/code&gt;, &lt;code&gt;@v8&lt;/code&gt;) are mutable. A tag can be moved to point to a different commit without notice. Pinning to a commit SHA means the code you reviewed is the code that runs, forever.&lt;/p&gt;
&lt;details&gt;
&lt;summary&gt;Immutable vs mutable&lt;/summary&gt;
&lt;p&gt;Git tags are just labels. The &lt;code&gt;v8&lt;/code&gt; tag is a pointer, and whoever controls the repo can move it to a different commit. Same name, different code. That&amp;rsquo;s what mutable means here.&lt;/p&gt;
&lt;p&gt;A commit SHA is different. Git derives it from the content of the commit, so it can only refer to that one thing. You can&amp;rsquo;t redirect it.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s what makes SHA pinning an actual security control. Once you&amp;rsquo;ve reviewed what&amp;rsquo;s at a given SHA, nobody can swap something else in without you noticing.&lt;/p&gt;
&lt;/details&gt;
&lt;p&gt;I was using two third-party actions:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# Before&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;peter-evans/create-pull-request@v8&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;errata-ai/vale-action@v2&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# After&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# v8&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;errata-ai/vale-action@d89dee975228ae261d22c15adcd03578634d429c&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# v2&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The &lt;code&gt;# v8&lt;/code&gt; comment keeps it human-readable. To get the SHA for a tag:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gh api repos/peter-evans/create-pull-request/git/ref/tags/v8 --jq &lt;span class="s1"&gt;&amp;#39;.object.sha&amp;#39;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;GitHub&amp;rsquo;s own first-party &lt;code&gt;actions/*&lt;/code&gt; are lower risk, but if your threat model requires it, the same pinning applies.&lt;/p&gt;
&lt;h3 id="2-add-explicit-permissions-blocks"&gt;2. Add explicit &lt;code&gt;permissions&lt;/code&gt; blocks
&lt;/h3&gt;&lt;p&gt;Without an explicit &lt;code&gt;permissions&lt;/code&gt; block, a workflow job inherits the repository&amp;rsquo;s default token permissions, which can be &lt;code&gt;read/write&lt;/code&gt; depending on repo settings. Declaring minimum permissions limits what an attacker can do with a compromised token. This is the security principle of &amp;ldquo;least privilege.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;Three of my workflow files had no &lt;code&gt;permissions&lt;/code&gt; block at all:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c"&gt;# Added to lint.yml, security.yml, vale.yml&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;read&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The &lt;code&gt;deploy.yml&lt;/code&gt; and &lt;code&gt;update-theme.yml&lt;/code&gt; workflows were already scoped correctly. &lt;code&gt;deploy.yml&lt;/code&gt; needs &lt;code&gt;pages: write&lt;/code&gt; and &lt;code&gt;id-token: write&lt;/code&gt; for GitHub Pages deployment. &lt;code&gt;update-theme.yml&lt;/code&gt; needs &lt;code&gt;contents: write&lt;/code&gt; and &lt;code&gt;pull-requests: write&lt;/code&gt; to create automated PRs.&lt;/p&gt;
&lt;h3 id="3-set-persist-credentials-false-on-checkouts-that-dont-need-to-push"&gt;3. Set &lt;code&gt;persist-credentials: false&lt;/code&gt; on checkouts that don&amp;rsquo;t need to push
&lt;/h3&gt;&lt;p&gt;By default, &lt;code&gt;actions/checkout&lt;/code&gt; writes the &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; into &lt;code&gt;.git/config&lt;/code&gt; where it stays accessible to every subsequent step in the job. That includes &lt;code&gt;npm ci&lt;/code&gt;, which runs third-party postinstall scripts.&lt;/p&gt;
&lt;p&gt;For jobs that check out code but never push (linting, security scanning, prose review), there&amp;rsquo;s no reason for the credential to persist:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- &lt;span class="nt"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;actions/checkout@v6&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;with&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;persist-credentials&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;I applied this to &lt;code&gt;lint.yml&lt;/code&gt;, &lt;code&gt;security.yml&lt;/code&gt;, and &lt;code&gt;vale.yml&lt;/code&gt;. The &lt;code&gt;deploy.yml&lt;/code&gt; and &lt;code&gt;update-theme.yml&lt;/code&gt; workflows keep the default because they run git operations that need authentication.&lt;/p&gt;
&lt;h3 id="4-add-timeout-minutes-to-every-job"&gt;4. Add &lt;code&gt;timeout-minutes&lt;/code&gt; to every job
&lt;/h3&gt;&lt;p&gt;Jobs without a timeout run for up to 6 hours by default. That&amp;rsquo;s a long window of live token exposure if something goes wrong. Tight timeouts also limit the damage from a hung or hijacked job.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;lint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;runs-on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;ubuntu-latest&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;timeout-minutes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Each of these jobs completes in seconds, with the build and deploy pipeline taking under two minutes. I set all jobs to 5 minutes. That&amp;rsquo;s a reasonable buffer given actual run times, and a lot better than leaving the 6-hour default in place.&lt;/p&gt;
&lt;h2 id="the-prompt"&gt;The prompt
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the prompt I used. It works well with Claude Code in a repo with workflow files present:&lt;/p&gt;
&lt;hr&gt;

 &lt;blockquote&gt;
 &lt;p&gt;Review the GitHub Actions workflows in &lt;code&gt;.github/workflows/&lt;/code&gt; for supply chain attack risk. For each workflow file, check:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Action pinning&lt;/strong&gt; — Are any third-party actions (non-&lt;code&gt;actions/*&lt;/code&gt;) pinned to a mutable version tag instead of a commit SHA? If so, fetch the current SHA for each tag using &lt;code&gt;gh api repos/&amp;lt;owner&amp;gt;/&amp;lt;repo&amp;gt;/git/ref/tags/&amp;lt;tag&amp;gt;&lt;/code&gt; and pin them. Add the version tag as a comment for readability.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Permissions&lt;/strong&gt; — Does every job have an explicit &lt;code&gt;permissions&lt;/code&gt; block declaring the minimum required scopes? If not, add one. Jobs that only read code should use &lt;code&gt;permissions: contents: read&lt;/code&gt;. Note which jobs legitimately need write scopes and why.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;persist-credentials&lt;/code&gt;&lt;/strong&gt; — Does &lt;code&gt;actions/checkout&lt;/code&gt; default to persisting the &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; into &lt;code&gt;.git/config&lt;/code&gt;? For any job that does not need to &lt;code&gt;git push&lt;/code&gt;, add &lt;code&gt;persist-credentials: false&lt;/code&gt; to the checkout step. This prevents the token from being readable by npm postinstall scripts or other downstream steps.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Timeouts&lt;/strong&gt; — Does every job have a &lt;code&gt;timeout-minutes&lt;/code&gt; value set? If not, add one. Base the value on a realistic upper bound for that job&amp;rsquo;s expected runtime.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;For each finding, explain the risk and make the change. Do not pin GitHub&amp;rsquo;s own first-party &lt;code&gt;actions/*&lt;/code&gt; actions unless I ask — focus on third-party dependencies.&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;hr&gt;
&lt;p&gt;Run this against a repo with a clean working tree. The diff lands in one place, which makes it easier to spot anything unexpected before you commit.&lt;/p&gt;
&lt;h2 id="what-this-doesnt-cover"&gt;What this doesn&amp;rsquo;t cover
&lt;/h2&gt;&lt;p&gt;This review focuses on the workflow files themselves. It doesn&amp;rsquo;t cover:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Repository secrets&lt;/strong&gt; — if you have a personal access token (PAT) with broad org-wide scope stored as a repo secret, that&amp;rsquo;s a higher-impact risk than anything in the workflow YAML. Audit your repo and org secrets separately.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;pull_request_target&lt;/code&gt;&lt;/strong&gt; — workflows using this trigger run with write permissions even for PRs from forks, which can give fork contributors unintended write access to your repo. None of my workflows use it, but it&amp;rsquo;s worth checking yours.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dependabot for Actions&lt;/strong&gt; — automatically opens PRs to bump action versions. Pairing it with SHA pinning requires extra config but keeps pinned SHAs from going stale.&lt;/li&gt;
&lt;/ul&gt;</description></item></channel></rss>