<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: XI CHEN</title>
    <description>The latest articles on DEV Community by XI CHEN (@xichen).</description>
    <link>https://dev.to/xichen</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4123961%2Fc9fa0385-be56-4e51-ac79-02d9b22e40db.png</url>
      <title>DEV Community: XI CHEN</title>
      <link>https://dev.to/xichen</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/xichen"/>
    <language>en</language>
    <item>
      <title>I built a GitHub Actions security scanner, then spent longer proving it wrong</title>
      <dc:creator>XI CHEN</dc:creator>
      <pubDate>Mon, 14 Sep 2026 05:58:09 +0000</pubDate>
      <link>https://dev.to/xichen/i-built-a-github-actions-security-scanner-then-spent-longer-proving-it-wrong-1o3b</link>
      <guid>https://dev.to/xichen/i-built-a-github-actions-security-scanner-then-spent-longer-proving-it-wrong-1o3b</guid>
      <description>&lt;p&gt;&lt;em&gt;I'm a cybersecurity undergraduate at the University of Adelaide. I wrote this tool because I wanted to understand CI/CD supply-chain attacks properly, and the fastest way I know to find out whether you actually understand something is to build a thing that claims to.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The number that gave it away was 59 out of 61.&lt;/p&gt;

&lt;p&gt;I had just finished a sweep of 67 mid-sized open-source repositories — the kind with real CI but not enough fame to have been audited by anyone. My scanner reported 61 medium-severity findings. Fifty-nine of them came from a single rule.&lt;/p&gt;

&lt;p&gt;No rule is that important. A rule that accounts for 97% of a severity band is not detecting something rare; it is describing something normal and calling it dangerous. So I stopped looking at the repositories and went to read GitHub's documentation about the thing my rule claimed.&lt;/p&gt;

&lt;p&gt;My rule was wrong. Not imprecise — wrong, about how the platform works, in the text it printed to users.&lt;/p&gt;

&lt;p&gt;This is a write-up of that, and of eleven other times the same tool told me something false with complete confidence.&lt;/p&gt;




&lt;h2&gt;
  
  
  The tool
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ghast&lt;/code&gt; is a taint-analysis scanner for GitHub Actions workflows. It traces attacker-controlled data — an issue title, a comment body, a fork's branch name — from where it enters a workflow to wherever something interprets it, and scores each finding by what an attacker actually gets.&lt;/p&gt;

&lt;p&gt;The interesting bugs in CI are rarely visible one &lt;code&gt;run:&lt;/code&gt; block at a time, because the data travels:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;github.event.comment.body
  → job &lt;span class="nb"&gt;env&lt;/span&gt;: TITLE
  → &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"slug=&lt;/span&gt;&lt;span class="nv"&gt;$TITLE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$GITHUB_OUTPUT&lt;/span&gt;     &lt;span class="o"&gt;(&lt;/span&gt;step output&lt;span class="o"&gt;)&lt;/span&gt;
  → needs.prepare.outputs.slug               &lt;span class="o"&gt;(&lt;/span&gt;a different job&lt;span class="o"&gt;)&lt;/span&gt;
  → run: ./deploy.sh &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="p"&gt;{ needs.prepare.outputs.slug &lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four hops, two jobs. Every individual line looks fine.&lt;/p&gt;

&lt;p&gt;That part works. This article is not about that part.&lt;/p&gt;




&lt;h2&gt;
  
  
  The method
&lt;/h2&gt;

&lt;p&gt;The design constraint I set at the start was: &lt;strong&gt;the documented mitigation must never be flagged.&lt;/strong&gt; GitHub tells people to route untrusted values through the environment and quote the read:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;TITLE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.issue.title }}&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;echo "$TITLE"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A scanner that reports that line is a scanner nobody runs twice. Everything below follows from taking that seriously.&lt;/p&gt;

&lt;p&gt;So I built a batch mode — point it at a list of public repositories, read their workflows over the API, rank what comes back — and ran it against roughly 200 repositories across five language corpora and one long-tail sweep: about 1,800 workflow files.&lt;/p&gt;

&lt;p&gt;Then I read every high-severity finding by hand, against the actual source.&lt;/p&gt;

&lt;p&gt;Total number of exploitable vulnerabilities found: &lt;strong&gt;zero.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Total number of bugs found in my own scanner: &lt;strong&gt;twelve.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;They fall into four groups, and the groups get worse as they go.&lt;/p&gt;




&lt;h2&gt;
  
  
  Group 1: the pattern was too loose
&lt;/h2&gt;

&lt;p&gt;These are ordinary bugs. A regex matched more than I meant it to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ENTRY="- ${PR_TITLE}"&lt;/code&gt; is not a command.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;rtk-ai/rtk&lt;/code&gt; came back as a high-severity execution sink for this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;ENTRY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"- &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PR_TITLE&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; [#&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PR_NUMBER&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;](&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PR_URL&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the &lt;em&gt;correct&lt;/em&gt; pattern. The value arrives through &lt;code&gt;env:&lt;/code&gt; and every read is quoted. My command-position detector saw &lt;code&gt;ENTRY="-&lt;/code&gt; followed by a space, matched it against "one or more &lt;code&gt;NAME=value&lt;/code&gt; assignments", and concluded the variable sat where the shell expects a command name.&lt;/p&gt;

&lt;p&gt;It does not. The unterminated double quote means everything after it is one word. Command position is only possible for an &lt;em&gt;unquoted&lt;/em&gt; read — a distinction the detector was not making at all, because it folded "is this in &lt;code&gt;eval&lt;/code&gt;?" and "is this in command position?" into a single boolean. &lt;code&gt;eval "$V"&lt;/code&gt; is execution regardless of quoting; &lt;code&gt;$V&lt;/code&gt; in command position is execution only when bare. Two different questions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;npm install -g &amp;lt;pinned-package&amp;gt;&lt;/code&gt; does not run your repository's code.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A generated workflow in &lt;code&gt;elastic/kibana&lt;/code&gt; installs a pinned CLI after checking out a pull request head. My rule for "does this job execute the checked-out tree?" was a substring match against a list containing &lt;code&gt;"npm install"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Whether a package manager hands control to the working directory depends entirely on its arguments. &lt;code&gt;npm ci&lt;/code&gt; runs whatever lifecycle scripts the tree's &lt;code&gt;package.json&lt;/code&gt; declares. &lt;code&gt;npm install -g @scope/pkg@1.2.3&lt;/code&gt; fetches a named package from a registry and never reads the tree. Same six characters at the front, opposite security properties.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;preview-tarballs&lt;/code&gt; is not &lt;code&gt;upload-preview-tarballs.js&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;vercel/next.js&lt;/code&gt; has a well-hardened &lt;code&gt;workflow_run&lt;/code&gt; pipeline: minimal permissions, SHA-pinned actions, a sparse checkout of exactly one script, and the artifact downloaded into &lt;code&gt;${{ runner.temp }}&lt;/code&gt; rather than the workspace. I reported it as high severity.&lt;/p&gt;

&lt;p&gt;The artifact is named &lt;code&gt;preview-tarballs&lt;/code&gt;. The script that reads it is &lt;code&gt;scripts/upload-preview-tarballs.js&lt;/code&gt;. I was deciding "does this path point into the downloaded artifact?" with a substring check, so a file from the checkout looked attacker-controlled. Paths compare by segment, never by substring.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;path: ./tmp&lt;/code&gt; is isolation.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ant-design/ant-design&lt;/code&gt; downloads an untrusted artifact and unpacks it. I reported that the download "unpacks into the workspace and can overwrite the files the checkout placed there" — but their download step sets &lt;code&gt;path: ./tmp&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I was testing whether the path was isolated by checking that it did not start with &lt;code&gt;.&lt;/code&gt;, which rejects every relative path. A named subdirectory keeps the artifact off the checkout's own files; only an unset path, or the workspace root itself, drops it on top of them.&lt;/p&gt;




&lt;h2&gt;
  
  
  Group 2: the answer was in another file
&lt;/h2&gt;

&lt;p&gt;These are more interesting, because the code was doing exactly what I wrote. I had simply written something that could not be correct while looking at one file, or one job, at a time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Whether &lt;code&gt;workflow_run&lt;/code&gt; is reachable is a property of a different workflow.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ruvnet/RuView&lt;/code&gt; — 93k stars — came back at 10.0, critical. A six-hop chain across three jobs, ending in &lt;code&gt;kubectl set image&lt;/code&gt; on a production deployment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;github.event.workflow_run.head_branch
  → step &lt;span class="nb"&gt;env &lt;/span&gt;PUBLISHED_REF
  → &lt;span class="nv"&gt;$GITHUB_OUTPUT&lt;/span&gt; → job output
  → needs.pre-deployment.outputs.image_tag
  → kubectl &lt;span class="nb"&gt;set &lt;/span&gt;image deployment/...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The data flow is real. The injection shape is real: a branch named &lt;code&gt;v$(...)&lt;/code&gt; passes their &lt;code&gt;== v*&lt;/code&gt; check, and git permits &lt;code&gt;$&lt;/code&gt;, &lt;code&gt;(&lt;/code&gt; and &lt;code&gt;)&lt;/code&gt; in refnames.&lt;/p&gt;

&lt;p&gt;What was wrong was the attacker. I treated &lt;code&gt;workflow_run&lt;/code&gt; as reachable by anyone, unconditionally. It is not: its reachability is a property of the workflow named in &lt;code&gt;on.workflow_run.workflows&lt;/code&gt;. That workflow runs on push to main, on &lt;code&gt;v*&lt;/code&gt; tags, and on &lt;code&gt;workflow_dispatch&lt;/code&gt;. All three need write access. So the branch name can only come from someone who already has push access, and "critical, any GitHub user" overstated it by an order of magnitude.&lt;/p&gt;

&lt;p&gt;Fixing this meant making scanning two-pass — parse every workflow in the repository before analysing any of them — so a rule can ask about a file other than the one it is looking at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A guard on one job protects every job downstream of it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;huggingface/transformers&lt;/code&gt; came back as a critical pwn request. It is close to the opposite. Their comment-triggered CI gates &lt;code&gt;get-pr-number&lt;/code&gt; on an &lt;code&gt;author_association&lt;/code&gt; allow-list, and there is a &lt;code&gt;check-timestamps&lt;/code&gt; job whose only purpose is to refuse to run when the merge commit is newer than the comment that triggered it — a defence against the exact race where a maintainer approves and the contributor then pushes.&lt;/p&gt;

&lt;p&gt;The job doing the checkout carries no &lt;code&gt;if:&lt;/code&gt; of its own, because it does not need one. GitHub skips a job when anything in its &lt;code&gt;needs:&lt;/code&gt; was skipped, so the gate upstream holds the whole chain. I was reading each job's own condition and nothing else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An actor check is not always spelled &lt;code&gt;github.actor&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Stirling-Tools/Stirling-PDF&lt;/code&gt; — 92k stars — came back as two criticals: &lt;code&gt;issue_comment&lt;/code&gt; checking out &lt;code&gt;refs/pull/N/merge&lt;/code&gt; with secrets in scope, no guard detected.&lt;/p&gt;

&lt;p&gt;There is a guard. It allow-lists eight maintainer logins:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;github.event.comment.user.login == 'frooodle' ||&lt;/span&gt;
&lt;span class="s"&gt;github.event.comment.user.login == 'sf298' || ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For an &lt;code&gt;issue_comment&lt;/code&gt; workflow, the comment author &lt;em&gt;is&lt;/em&gt; the actor that matters. I was matching &lt;code&gt;github.actor&lt;/code&gt; and nothing else. The same applies to &lt;code&gt;pull_request.user.login&lt;/code&gt;, &lt;code&gt;sender.login&lt;/code&gt;, and the review variants.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A gate does not need a comparison operator.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ant-design/ant-design&lt;/code&gt; gates a privileged job with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;fromJSON(needs.check-trust.outputs.trusted)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My delegated-gate pattern required &lt;code&gt;== 'true'&lt;/code&gt; after the output reference, so it saw no gate at all.&lt;/p&gt;

&lt;p&gt;Recognising it turned out to be worth more than the severity change, because it let the tool report the more interesting fact: &lt;code&gt;check-trust&lt;/code&gt; compares &lt;code&gt;workflow_run.repository&lt;/code&gt; against the repository name — and for a fork pull request, &lt;code&gt;workflow_run.repository&lt;/code&gt; is the &lt;em&gt;base&lt;/em&gt; repository. The gate keeps the workflow from running in forks. It says nothing whatsoever about who wrote the artifact it is about to unpack. The tool now prints: &lt;em&gt;"gated on &lt;code&gt;needs.check-trust.outputs.trusted&lt;/code&gt;, but job &lt;code&gt;check-trust&lt;/code&gt; was not seen checking who the actor is."&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Group 3: I asserted things I could not know
&lt;/h2&gt;

&lt;p&gt;These are the ones I find most uncomfortable, because the code had no bug. It was confidently answering a question the input does not contain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not every non-GitHub runner is a persistent machine.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;My self-hosted-runner rule scores code execution at 9.0 even when no secrets are present, because a self-hosted runner is a computer: whatever an attacker leaves behind is waiting for the next, possibly privileged, job.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;supabase/supabase&lt;/code&gt; produced twenty-odd criticals on runners named &lt;code&gt;blacksmith-*&lt;/code&gt;. Blacksmith is a managed provider whose VMs are destroyed after every job. The persistence argument — the entire basis for that 9.0 — does not apply. So I added a list of managed providers and stopped flagging them.&lt;/p&gt;

&lt;p&gt;Then &lt;code&gt;langchain-ai/langchain&lt;/code&gt; turned up &lt;code&gt;codspeed-macro&lt;/code&gt;, and my new list quietly got that wrong in the other direction. CodSpeed's macro runners are &lt;em&gt;dedicated bare metal&lt;/em&gt;, and hardware consistency is the whole product — which argues against tearing them down between jobs. I read their documentation and still could not tell whether state survives.&lt;/p&gt;

&lt;p&gt;So the tool now has a third class, and says what it does not know: &lt;em&gt;"whether it is torn down between jobs is the provider's business and is not visible here."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And a custom label is genuinely ambiguous.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Then &lt;code&gt;microsoft/vscode&lt;/code&gt; produced &lt;code&gt;vscode-large-runners&lt;/code&gt; and &lt;code&gt;google-gemini/gemini-cli&lt;/code&gt; produced &lt;code&gt;gemini-cli-ubuntu-16-core&lt;/code&gt;, and I had to admit the deeper problem: organisations name GitHub-hosted larger runners whatever they like, and a self-hosted runner is also just a string. &lt;strong&gt;Nothing in the workflow file distinguishes them.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A literal &lt;code&gt;self-hosted&lt;/code&gt; in the labels is unambiguous and still scores full confidence. Everything else is now reported as &lt;em&gt;unresolved&lt;/em&gt;, at reduced confidence, with the ambiguity stated in the output.&lt;/p&gt;

&lt;p&gt;I had been printing a conclusion the input could not support. Twice.&lt;/p&gt;




&lt;h2&gt;
  
  
  Group 4: I was wrong about the platform
&lt;/h2&gt;

&lt;p&gt;Which brings us back to 59 out of 61.&lt;/p&gt;

&lt;p&gt;The rule was cache poisoning. It fired on any &lt;code&gt;actions/cache&lt;/code&gt; step in a &lt;code&gt;pull_request&lt;/code&gt; workflow, and it told the reader:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;cache entries created here are visible to default-branch workflows through the cache fallback rules&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is false. From &lt;a href="https://docs.github.com/en/actions/reference/dependency-caching-reference" rel="noopener noreferrer"&gt;GitHub's cache documentation&lt;/a&gt;: a pull request's caches are scoped to &lt;code&gt;refs/pull/N/merge&lt;/code&gt; and can only be restored by re-runs of that pull request. Low-trust triggers get &lt;strong&gt;read-only&lt;/strong&gt; access to the default branch's scope. Cache sharing flows downward, from the default branch to feature branches, and never upward.&lt;/p&gt;

&lt;p&gt;A plain &lt;code&gt;actions/cache&lt;/code&gt; step in fork CI cannot poison anything. It is the ordinary, documented, correct thing to do, and my tool was pointing at fifty-nine projects doing it and making a false claim about the platform to justify the accusation.&lt;/p&gt;

&lt;p&gt;There &lt;em&gt;is&lt;/em&gt; a real cache-poisoning shape. It is the mirror image: a job running under a trigger that &lt;strong&gt;can&lt;/strong&gt; write the default branch's scope — &lt;code&gt;push&lt;/code&gt;, &lt;code&gt;workflow_run&lt;/code&gt;, &lt;code&gt;schedule&lt;/code&gt;, &lt;code&gt;workflow_dispatch&lt;/code&gt; — which caches something &lt;em&gt;after&lt;/em&gt; checking out a pull request head or unpacking an artifact from an untrusted run. Whatever it caches is derived from attacker-controlled input, and every later run on the default branch restores it.&lt;/p&gt;

&lt;p&gt;That is what the rule detects now. Across the same corpus, it fires almost never — which is what a rule about a genuine, specific mistake should do.&lt;/p&gt;




&lt;h2&gt;
  
  
  What this cost, and what it is worth
&lt;/h2&gt;

&lt;p&gt;The scanner has 120 tests. They passed before this bug, during it, and after it. Not one of them was capable of catching it, and no amount of additional testing would have been, because every test encoded the same wrong belief the code did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A test suite proves your code matches your model. It cannot tell you your model is wrong.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The thing that caught it was a number with the wrong shape. Fifty-nine out of sixty-one. Not a failure, not an exception, not a diff — a distribution that did not look like the world.&lt;/p&gt;

&lt;p&gt;That is the actual finding here, and it generalises past CI security. Every static analysis tool is a set of claims about how a platform behaves. The code can be perfect and the claims can still be false, and users will not tell you, because a tool that cries wolf does not get bug reports. It gets muted.&lt;/p&gt;

&lt;p&gt;The tool is better now, and I trust it much less than I did a week ago. Those turn out to be the same sentence.&lt;/p&gt;

&lt;p&gt;All twelve cases are pinned as regression tests, each one named after the repository that found it. The public corpus is the test suite.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;&lt;code&gt;ghast&lt;/code&gt; is MIT-licensed and lives at &lt;a href="https://github.com/citizen204/ghast" rel="noopener noreferrer"&gt;github.com/citizen204/ghast&lt;/a&gt;. It scans its own workflows in CI. Everything above is reproducible with &lt;code&gt;ghast hunt&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every repository mentioned is public and was read through the GitHub API. No finding was tested against anyone's CI, and nothing here is a vulnerability report: every high-severity result discussed turned out to be either a deliberate architectural choice or a bug in my own tool.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>github</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
