{"id":"692b2f5622f526dc2cf758d7","title":"2025 11 29 Codex","path":"how-to/technical-articles/2025-11-29-codex","contentMarkdown":"# Protecting project against the Shai-Hulud Exploit\n\n### Question Originally Asked in local codex session\n\n_I'm the owner of ngx-ramblers and I made [this commit the other day](https://github.com/nbarrett/ngx-ramblers/commit/73a827441618e274bca6bd8be0bde8e8a84a285f) to hopefully protect my codebase against the shai-hulud exploit on 25 Nov. However I am now looking at the [Post-mortem of Shai-Hulud attack on November 24th, 2025](https://posthog.com/blog/nov-24-shai-hulud-attack-post-mortem), and are seeing that there are a few ways that your CI pipeline can be vulnerable to malicious operators opening PRs on your project and running workflows that can harvest secrets on your main pipeline. Can you review the post mortem and then review my project (note there is a client and server portion) and tell me whether there's anything I should do to tighten it up?_\n\n## Security Review Summary (NGX‑Ramblers)\n\n  ### 1. Third‑Party `server` Package in Frontend Build (High)\n  Your frontend `package.json` brings in the public npm package `server@1.0.41` (`package.json:84`, `package-lock.json:34690`). This is unrelated to your checked‑in Express backend under `/server`, so every `npm ci` executes that third‑party package (and its install scripts), recreating the same supply‑chain attack surface used by Shai‑Hulud.\n\n  ```json\n  {\n    ...\n    \"dependencies\": {\n      ...\n      \"rxjs\": \"7.8.2\",\n      \"server\": \"1.0.41\",\n      \"turndown\": \"7.2.0\"\n    }\n  }\n```\n  Action: Remove it (or convert to \"server\": \"file:server\" if you meant to re-use the local code) so CI never runs unvetted install hooks.\n\n### 2. Deploy Workflow Runs on Pull Requests with Secrets (High)\n\n  .github/workflows/build-push-and-deploy-ngx-ramblers-docker-image.yml triggers on both push and pull_request (lines 3‑7). The deploy job later loads CONFIGS_JSON, Docker, and Fly secrets (lines 65‑109). An attacker with any contributor access could open a PR and immediately exfiltrate secrets—exactly the PostHog scenario.\n```\n  on:\n    push:\n      branches: [ \"main\", \"pre-main\" ]\n    pull_request:\n      branches: [ \"main\" ]\n  ...\n    deploy:\n      needs: test\n      runs-on: ubuntu-latest\n      steps:\n        ...\n        - name: Create configs.json from secrets\n          env:\n            CONFIGS_JSON: ${{ secrets.CONFIGS_JSON }}\n          run: |\n            echo \"$CONFIGS_JSON\" > non-vcs/fly-io/configs.json\n```\n  Action: Restrict deployments to push events (or wrap the deploy job with if: github.event_name == 'push') so PRs never receive secrets.\n\n### 3. Default GITHUB_TOKEN Permissions (Medium)\n\n  Neither workflow sets permissions, so GitHub grants the default token repo write access. If any dependency becomes compromised (e.g., via npm ci), the attacker already holds a write-capable token.\n\n  Action: Add explicit permissions blocks per job:\n\n  permissions:\n    contents: read\n    packages: write   # only where needed\n\n### 4. Floating Action References and Global Installs (Medium)\n\nBoth workflows use floating references (superfly/flyctl-actions/setup-flyctl@master) and npm install -g ts-node, which pull arbitrary code at runtime.\n```\n  - name: Install ts-node\n    run: npm install -g ts-node\n\n  - name: Install Flyctl\n    uses: superfly/flyctl-actions/setup-flyctl@master\n```\n  Action: Pin actions by commit SHA and leverage the repo’s checked-in ts-node via npx ts-node … to keep builds within locked dependencies.\n\n### 5. npm ci Executes Third-Party Scripts in PR Jobs (Medium)\n\n  Both workflows run npm ci with default script execution (build-push-and-deploy...:23-44, deploy-to-environments.yml:69-74). Shai-Hulud exploited preinstall hooks.\n\n  Action: Use npm ci --ignore-scripts for PR/test jobs and explicitly execute the scripts you control afterward (e.g., npx ng test, npm run build).\n\n### 6. Shai-Hulud Scanner Misses Published IoCs (Medium)\n\n`server/deploy/check-shai-hulud.ts` only checks for three persistence artifacts:\n\n`const suspicious: string[] = [\"cloud.json\", \"truffleSecrets.json\", \".github/workflows/discussion.yaml\"];`\n\n  The PostHog post-mortem listed additional files (setup_bun.js, bun_environment.js, contents.json, environment.json).\n\n  Action: Expand the list:\n```\n  const suspicious = [\n    \"cloud.json\",\n    \"truffleSecrets.json\",\n    \".github/workflows/discussion.yaml\",\n    \"setup_bun.js\",\n    \"bun_environment.js\",\n    \"contents.json\",\n    \"environment.json\"\n  ];\n```\n### 7. Scanner Not Wired Into CI (Low)\n\nThe CLI is defined in server/package.json:\n```\n  \"check:shai-hulud\": \"tsx deploy/check-shai-hulud.ts\"\n```\n  …but no workflow runs it. A compromised lockfile could therefore merge unnoticed.\n\n  Action: Add a lightweight GitHub Actions job:\n```\n  - name: Shai-Hulud scan\n    run: npm run --prefix server check:shai-hulud -- --skip-audit\n\n  Trigger it whenever lockfiles or dependency manifests change.\n```\n\n### 8. Environment Secret Fan-Out (Low)\n\n  Workflows hydrate the entire CONFIGS_JSON (all environments) before deploying a single environment. A compromised job gains every environment’s secret set.\n\nAction: Split secrets per environment (via GitHub Environments or separate secrets) so jobs only materialize what they deploy.\n\n### Command Output (Reference)\n```\n$ npm run --prefix server check:shai-hulud -- --path .. --skip-audit\n  node:net:1915\n        const error = new UVExceptionWithHostPort(rval, 'listen', address, port);\n  ...\n  Error: listen EPERM: operation not permitted /var/folders/.../tsx-502/40273.pipe\n```\n (The CLI fails locally due to sandbox restrictions but should run normally on GitHub-hosted runners.)\n\n### Next Steps\n\n  1. Remove or relink the npm server dependency, re-lock, and re-run the scanner.\n  2. Update workflows: restrict deploy job to pushes, set explicit permissions, pin all third-party actions/binaries, and run npm ci --ignore-scripts for PR jobs.\n  3. Extend the Shai-Hulud CLI to cover all IoCs and wire it into CI so compromised lockfiles or persistence files block merges.\n\n\n\n• Key Findings\n\n  - High – The frontend package.json depends on the published npm package named server (package.json:84, package-lock.json:34690). This is unrelated to your checked-in Express code under /server, so every npm ci pulls a third-party framework with full install scripts. It dramatically enlarges the attack surface for exactly the kind of supply-chain poisoning described in the post-mortem. Unless you truly need\n    that npm module, drop it (or swap it for a file:server link).\n  - High – The deploy job inside .github/workflows/build-push-and-deploy-ngx-ramblers-docker-image.yml runs for pull_request events as well as push (lines 3‑107). Although forked PRs don’t automatically receive repository secrets, insiders/compromised accounts could open a PR and immediately execute the deployment script with all secrets available, exactly mirroring the Shai‑Hulud entry point. Gate the\n    entire deploy job behind if: github.event_name == 'push' (or split it into a push-only workflow) so untrusted PRs can never reach CONFIGS_JSON, Fly tokens, or Docker credentials.\n  - Medium – Neither workflow declares a restrictive permissions block, so GITHUB_TOKEN defaults to full repo write on push-triggered runs (.github/workflows/build-push-and-deploy-ngx-ramblers-docker-image.yml:1-11, .github/workflows/deploy-to-environments.yml:1-20). If any step is compromised (for example via a malicious dependency install), the token already has push/delete rights. Explicitly scope\n    permissions per job (e.g., contents: read for tests, packages: write only where you push images) to limit blast radius.\n  - Medium – superfly/flyctl-actions/setup-flyctl@master is referenced by a floating branch (build-push-and-deploy…:100-107, deploy-to-environments.yml:95-107), and the deploy workflow installs ts-node globally without a version (build-push-and-deploy…:74, deploy-to-environments.yml:85). Both steps bypass your lockfiles and trust whatever code is published at run time, giving attackers a straightforward\n    place to plant malicious updates. Pin GitHub Actions to immutable commit SHAs and invoke the checked-in ts-node binary via npx ts-node … instead of npm install -g.\n  - Medium – The CI install steps (build-push-and-deploy…:23-44, deploy-to-environments.yml:69-74) run npm ci with default script execution, so every dependency’s preinstall/install hooks run on your runners. Shai-Hulud used exactly that hook to exfiltrate secrets. Consider npm ci --ignore-scripts for the PR/test job and then run the handful of repository-controlled scripts you actually need (e.g., npm run\n    tsc, npm run copy-assets) to keep third-party hooks from firing.\n  - Medium – The Shai-Hulud scanner you added only looks for cloud.json, truffleSecrets.json, and .github/workflows/discussion.yaml (server/deploy/check-shai-hulud.ts:259-265), but the PostHog IoCs also include setup_bun.js, bun_environment.js, contents.json, and environment.json. Extend the list so the CLI (and any automation built on top) detects every known persistence file mentioned in the post-mortem.\n  - Low – The scanner exists as npm run --prefix server check:shai-hulud (server/package.json:26), yet it’s not part of any workflow. Without CI coverage, it relies on manual vigilance and can’t block tainted lockfiles in PRs. Add a lightweight job that runs the checker (with --skip-audit if necessary) whenever package-lock.json or server deps change and fails the build on findings.\n  - Low – Secrets for every environment are injected straight into the job workspace via CONFIGS_JSON before the Fly deploy runs (build-push-and-deploy…:65-109, deploy-to-environments.yml:29-110). If an attacker compromises the job, they immediately gain access to all environment credentials, not just the target. Move per-environment tokens into GitHub “environments” with required approvals, or split the\n    secret blob so each job only ever materializes the environment it’s deploying.\n\n  Open Questions\n\n  - The attempt to run npm run --prefix server check:shai-hulud -- --path .. --skip-audit failed locally because tsx tried to open an IPC pipe (EPERM). Please confirm the CLI runs successfully inside your GitHub Actions runner, since that’s where it really matters.\n  - Was the npm dependency on \"server\": \"1.0.41\" intentional, or is it a leftover placeholder? Clarifying that will determine whether the safest fix is removal or converting it to a local file reference.\n\n  Next Steps\n\n  1. Remove/replace the stray server npm dependency and re-lock.\n  2. Restrict the deploy workflow to push events, add explicit permissions, and pin third-party actions/binaries.\n  3. Update CI install steps to minimize third-party install hooks, and integrate the Shai-Hulud scanner with an expanded IoC list so lockfile compromises are caught automatically.\n\n","contentHtml":"<h1>Protecting project against the Shai-Hulud Exploit</h1>\n<h3>Question Originally Asked in local codex session</h3>\n<p><em>I&#39;m the owner of ngx-ramblers and I made <a href=\"https://github.com/nbarrett/ngx-ramblers/commit/73a827441618e274bca6bd8be0bde8e8a84a285f\">this commit the other day</a> to hopefully protect my codebase against the shai-hulud exploit on 25 Nov. However I am now looking at the <a href=\"https://posthog.com/blog/nov-24-shai-hulud-attack-post-mortem\">Post-mortem of Shai-Hulud attack on November 24th, 2025</a>, and are seeing that there are a few ways that your CI pipeline can be vulnerable to malicious operators opening PRs on your project and running workflows that can harvest secrets on your main pipeline. Can you review the post mortem and then review my project (note there is a client and server portion) and tell me whether there&#39;s anything I should do to tighten it up?</em></p>\n<h2>Security Review Summary (NGX‑Ramblers)</h2>\n<h3>1. Third‑Party <code>server</code> Package in Frontend Build (High)</h3>\n<p>  Your frontend <code>package.json</code> brings in the public npm package <code>server@1.0.41</code> (<code>package.json:84</code>, <code>package-lock.json:34690</code>). This is unrelated to your checked‑in Express backend under <code>/server</code>, so every <code>npm ci</code> executes that third‑party package (and its install scripts), recreating the same supply‑chain attack surface used by Shai‑Hulud.</p>\n<pre><code class=\"language-json\">{\n  ...\n  &quot;dependencies&quot;: {\n    ...\n    &quot;rxjs&quot;: &quot;7.8.2&quot;,\n    &quot;server&quot;: &quot;1.0.41&quot;,\n    &quot;turndown&quot;: &quot;7.2.0&quot;\n  }\n}\n</code></pre>\n<p>  Action: Remove it (or convert to &quot;server&quot;: &quot;file:server&quot; if you meant to re-use the local code) so CI never runs unvetted install hooks.</p>\n<h3>2. Deploy Workflow Runs on Pull Requests with Secrets (High)</h3>\n<p>  .github/workflows/build-push-and-deploy-ngx-ramblers-docker-image.yml triggers on both push and pull_request (lines 3‑7). The deploy job later loads CONFIGS_JSON, Docker, and Fly secrets (lines 65‑109). An attacker with any contributor access could open a PR and immediately exfiltrate secrets—exactly the PostHog scenario.</p>\n<pre><code>  on:\n    push:\n      branches: [ &quot;main&quot;, &quot;pre-main&quot; ]\n    pull_request:\n      branches: [ &quot;main&quot; ]\n  ...\n    deploy:\n      needs: test\n      runs-on: ubuntu-latest\n      steps:\n        ...\n        - name: Create configs.json from secrets\n          env:\n            CONFIGS_JSON: ${{ secrets.CONFIGS_JSON }}\n          run: |\n            echo &quot;$CONFIGS_JSON&quot; &gt; non-vcs/fly-io/configs.json\n</code></pre>\n<p>  Action: Restrict deployments to push events (or wrap the deploy job with if: github.event_name == &#39;push&#39;) so PRs never receive secrets.</p>\n<h3>3. Default GITHUB_TOKEN Permissions (Medium)</h3>\n<p>  Neither workflow sets permissions, so GitHub grants the default token repo write access. If any dependency becomes compromised (e.g., via npm ci), the attacker already holds a write-capable token.</p>\n<p>  Action: Add explicit permissions blocks per job:</p>\n<p>  permissions:\n    contents: read\n    packages: write   # only where needed</p>\n<h3>4. Floating Action References and Global Installs (Medium)</h3>\n<p>Both workflows use floating references (superfly/flyctl-actions/setup-flyctl@master) and npm install -g ts-node, which pull arbitrary code at runtime.</p>\n<pre><code>  - name: Install ts-node\n    run: npm install -g ts-node\n\n  - name: Install Flyctl\n    uses: superfly/flyctl-actions/setup-flyctl@master\n</code></pre>\n<p>  Action: Pin actions by commit SHA and leverage the repo’s checked-in ts-node via npx ts-node … to keep builds within locked dependencies.</p>\n<h3>5. npm ci Executes Third-Party Scripts in PR Jobs (Medium)</h3>\n<p>  Both workflows run npm ci with default script execution (build-push-and-deploy...:23-44, deploy-to-environments.yml:69-74). Shai-Hulud exploited preinstall hooks.</p>\n<p>  Action: Use npm ci --ignore-scripts for PR/test jobs and explicitly execute the scripts you control afterward (e.g., npx ng test, npm run build).</p>\n<h3>6. Shai-Hulud Scanner Misses Published IoCs (Medium)</h3>\n<p><code>server/deploy/check-shai-hulud.ts</code> only checks for three persistence artifacts:</p>\n<p><code>const suspicious: string[] = [&quot;cloud.json&quot;, &quot;truffleSecrets.json&quot;, &quot;.github/workflows/discussion.yaml&quot;];</code></p>\n<p>  The PostHog post-mortem listed additional files (setup_bun.js, bun_environment.js, contents.json, environment.json).</p>\n<p>  Action: Expand the list:</p>\n<pre><code>  const suspicious = [\n    &quot;cloud.json&quot;,\n    &quot;truffleSecrets.json&quot;,\n    &quot;.github/workflows/discussion.yaml&quot;,\n    &quot;setup_bun.js&quot;,\n    &quot;bun_environment.js&quot;,\n    &quot;contents.json&quot;,\n    &quot;environment.json&quot;\n  ];\n</code></pre>\n<h3>7. Scanner Not Wired Into CI (Low)</h3>\n<p>The CLI is defined in server/package.json:</p>\n<pre><code>  &quot;check:shai-hulud&quot;: &quot;tsx deploy/check-shai-hulud.ts&quot;\n</code></pre>\n<p>  …but no workflow runs it. A compromised lockfile could therefore merge unnoticed.</p>\n<p>  Action: Add a lightweight GitHub Actions job:</p>\n<pre><code>  - name: Shai-Hulud scan\n    run: npm run --prefix server check:shai-hulud -- --skip-audit\n\n  Trigger it whenever lockfiles or dependency manifests change.\n</code></pre>\n<h3>8. Environment Secret Fan-Out (Low)</h3>\n<p>  Workflows hydrate the entire CONFIGS_JSON (all environments) before deploying a single environment. A compromised job gains every environment’s secret set.</p>\n<p>Action: Split secrets per environment (via GitHub Environments or separate secrets) so jobs only materialize what they deploy.</p>\n<h3>Command Output (Reference)</h3>\n<pre><code>$ npm run --prefix server check:shai-hulud -- --path .. --skip-audit\n  node:net:1915\n        const error = new UVExceptionWithHostPort(rval, &#39;listen&#39;, address, port);\n  ...\n  Error: listen EPERM: operation not permitted /var/folders/.../tsx-502/40273.pipe\n</code></pre>\n<p> (The CLI fails locally due to sandbox restrictions but should run normally on GitHub-hosted runners.)</p>\n<h3>Next Steps</h3>\n<ol>\n<li>Remove or relink the npm server dependency, re-lock, and re-run the scanner.</li>\n<li>Update workflows: restrict deploy job to pushes, set explicit permissions, pin all third-party actions/binaries, and run npm ci --ignore-scripts for PR jobs.</li>\n<li>Extend the Shai-Hulud CLI to cover all IoCs and wire it into CI so compromised lockfiles or persistence files block merges.</li>\n</ol>\n<p>• Key Findings</p>\n<ul>\n<li>High – The frontend package.json depends on the published npm package named server (package.json:84, package-lock.json:34690). This is unrelated to your checked-in Express code under /server, so every npm ci pulls a third-party framework with full install scripts. It dramatically enlarges the attack surface for exactly the kind of supply-chain poisoning described in the post-mortem. Unless you truly need\nthat npm module, drop it (or swap it for a file:server link).</li>\n<li>High – The deploy job inside .github/workflows/build-push-and-deploy-ngx-ramblers-docker-image.yml runs for pull_request events as well as push (lines 3‑107). Although forked PRs don’t automatically receive repository secrets, insiders/compromised accounts could open a PR and immediately execute the deployment script with all secrets available, exactly mirroring the Shai‑Hulud entry point. Gate the\nentire deploy job behind if: github.event_name == &#39;push&#39; (or split it into a push-only workflow) so untrusted PRs can never reach CONFIGS_JSON, Fly tokens, or Docker credentials.</li>\n<li>Medium – Neither workflow declares a restrictive permissions block, so GITHUB_TOKEN defaults to full repo write on push-triggered runs (.github/workflows/build-push-and-deploy-ngx-ramblers-docker-image.yml:1-11, .github/workflows/deploy-to-environments.yml:1-20). If any step is compromised (for example via a malicious dependency install), the token already has push/delete rights. Explicitly scope\npermissions per job (e.g., contents: read for tests, packages: write only where you push images) to limit blast radius.</li>\n<li>Medium – superfly/flyctl-actions/setup-flyctl@master is referenced by a floating branch (build-push-and-deploy…:100-107, deploy-to-environments.yml:95-107), and the deploy workflow installs ts-node globally without a version (build-push-and-deploy…:74, deploy-to-environments.yml:85). Both steps bypass your lockfiles and trust whatever code is published at run time, giving attackers a straightforward\nplace to plant malicious updates. Pin GitHub Actions to immutable commit SHAs and invoke the checked-in ts-node binary via npx ts-node … instead of npm install -g.</li>\n<li>Medium – The CI install steps (build-push-and-deploy…:23-44, deploy-to-environments.yml:69-74) run npm ci with default script execution, so every dependency’s preinstall/install hooks run on your runners. Shai-Hulud used exactly that hook to exfiltrate secrets. Consider npm ci --ignore-scripts for the PR/test job and then run the handful of repository-controlled scripts you actually need (e.g., npm run\ntsc, npm run copy-assets) to keep third-party hooks from firing.</li>\n<li>Medium – The Shai-Hulud scanner you added only looks for cloud.json, truffleSecrets.json, and .github/workflows/discussion.yaml (server/deploy/check-shai-hulud.ts:259-265), but the PostHog IoCs also include setup_bun.js, bun_environment.js, contents.json, and environment.json. Extend the list so the CLI (and any automation built on top) detects every known persistence file mentioned in the post-mortem.</li>\n<li>Low – The scanner exists as npm run --prefix server check:shai-hulud (server/package.json:26), yet it’s not part of any workflow. Without CI coverage, it relies on manual vigilance and can’t block tainted lockfiles in PRs. Add a lightweight job that runs the checker (with --skip-audit if necessary) whenever package-lock.json or server deps change and fails the build on findings.</li>\n<li>Low – Secrets for every environment are injected straight into the job workspace via CONFIGS_JSON before the Fly deploy runs (build-push-and-deploy…:65-109, deploy-to-environments.yml:29-110). If an attacker compromises the job, they immediately gain access to all environment credentials, not just the target. Move per-environment tokens into GitHub “environments” with required approvals, or split the\nsecret blob so each job only ever materializes the environment it’s deploying.</li>\n</ul>\n<p>  Open Questions</p>\n<ul>\n<li>The attempt to run npm run --prefix server check:shai-hulud -- --path .. --skip-audit failed locally because tsx tried to open an IPC pipe (EPERM). Please confirm the CLI runs successfully inside your GitHub Actions runner, since that’s where it really matters.</li>\n<li>Was the npm dependency on &quot;server&quot;: &quot;1.0.41&quot; intentional, or is it a leftover placeholder? Clarifying that will determine whether the safest fix is removal or converting it to a local file reference.</li>\n</ul>\n<p>  Next Steps</p>\n<ol>\n<li>Remove/replace the stray server npm dependency and re-lock.</li>\n<li>Restrict the deploy workflow to push events, add explicit permissions, and pin third-party actions/binaries.</li>\n<li>Update CI install steps to minimize third-party install hooks, and integrate the Shai-Hulud scanner with an expanded IoC list so lockfile compromises are caught automatically.</li>\n</ol>\n"}