How to defend software supply chains by vendoring and SHA-pinning all GitHub Actions into a single internal mirror that rewrites every upstream reference.
Adapted from @skwp# Supply Chain Security Hardening Against Shai-Hulud Class Attacks Supply chain security is hard! Plug this guide into your agent to get a head start. In Frank Herbert's Dune, the Shai-Hulud — the great sandworm — is a force so massive and so deeply embedded in the desert that by the time you feel the vibration, it's already too late. In late ‘25/early ‘26, we saw the emergence of accelerated threats to software supply chains including the Shai Hulud worm by TeamPCP, which compromised developer machines, and then used compromised credentials to publish more malicious packages to compromise more developer machines. (https://www.wiz.io/blog/mini-shai-hulud-teampcp-hits-antv-supply-chain) When the worm hit, Swan was ready. Our systems were not infected or impacted. However, we used the lessons learned and published by other impacted companies to further harden our systems. This post covers some of the security controls we’ve built in our CI/CD pipelines to defend against such attacks. While it is not a comprehensive look at our entire program, we believe these are the foundational steps any company should take to treat supply chain security as a first-class concern. ## One Shared Control Plane The foundation of everything is an internal repository for GitHub actions. We do not permit our repositories to import external actions; every CI/CD workflow across every critical repo inherits from this one place. External actions are very dangerous, because the public nature of those repositories leaves them open to attacks such as Megaldon and the cache poisoning attacks by TeamPCP. (https://www.csoonline.com/article/4177124/github-actions-abused-by-megalodon-attack-to-slip-malicious-commits-into-5500-repos.html) (https://www.wired.com/story/teampcp-software-supply-chain-attack-spree-github/) When we vendor a GitHub Action, we fork it into our internal mirror, audit it, and pin it to a specific commit SHA. Every uses: directive in every workflow file points to our copy — never directly to upstream. We don’t point to tags, we point to specific SHAs, which are hashed fingerprints of the software, the same ideas used by the Bitcoin blockchain to ensure that tampering with data is impossible without disrupting its fingerprint. Why does this matter? When the tj-actions/changed-files action was compromised in March 2025, attackers injected malicious code that dumped CI secrets to build logs. Every organization that referenced the upstream action — tens of thousands of repos — was exposed. If you pointed at tj-actions/changed-files@v44, you got whatever code the attacker pushed. Swan's workflows never would have pulled that malicious code, because we don't reference upstream. We reference our own vetted, SHA-pinned copy. The mirror tooling does the dangerous parts for you. A single file, versions.txt, lists every external action we trust and the exact version we trust it at. A vendor-action script adds a new entry. A sync-actions script then does three things that are easy to forget if a human does them manually: - Rewrites every uses: reference inside the vendored action. When we mirror oven-sh/setup-bun, its action.yml might internally call actions/checkout@v4. The sync script automatically rewrites that to point at our internal mirror instead. There is no path by which a vendored action can call out to the public GitHub Actions registry at runtime — every reference, at every depth, is rewritten to an internal one. - Detects missing mirrors. If a vendored action depends on another action that we haven't mirrored yet, the script tells us before we ship. No silent escapes. - Pins the internal references to a SHA at vendor time. Even our own swan-bitcoin/actions master is resolved to a commit hash and baked in, so a future change to our actions repo can't silently propagate into an already-vendored action. ## Supply Chain Lint The first gate in our actions pipeline is a static scanner we built called supply-chain-lint. Its job is deceptively simple: find every place where a vendored GitHub Action might silently pull untrusted code at runtime. You can audit a GitHub Action's source code top to bottom, pin it by SHA, and feel safe. But if that action's entrypoint.sh runs npm install or curl | bash at execution time, your audit is worthless. The code that actually runs on your CI runner isn't the code you reviewed — it's whatever the internet served up at build time. The goal of this action was to guard against attacks such as the one on Datadog’s action which used an unpinned package installation in its flow https://github.com/DataDog/junit-upload-github-action/issues/49 Supply-chain-lint catches patterns such as: - npm install, npx, or pip install without version constraints - curl | bash and similar fetch-and-execute patterns - @latest tags that resolve to whatever's newest - -no-lockfile flags that bypass integrity checks When the scanner does flag something legitimate — say, a vendored action that genuinely needs to install a specific tool that we control — we don't just suppress the warning. The allowlist requires an exact (file, pattern) pair, a written reason, and a date. It's an auditable record. Six months from now, anyone can look at the allowlist and understand exactly why each exception exists and who approved it. The scanner is deliberately narrow in scope. It flags the fetch mechanism — the moment where untrusted code could enter the pipeline — and leaves deeper package inspection to the next layer. If supply-chain-lint passes, it means nothing in our vendored actions is reaching out to the internet for unreviewed code at runtime. ## AI-Powered Dependency Security Gate The second gate, analyze-dependencies, is where things get interesting. This is a multi-layered analysis system that evaluates every new dependency introduced in a pull request. Layer 1: Lockfile Diffing. When a developer opens a PR that adds or updates a dependency, the system parses both the base branch lockfile and the PR's lockfile, then isolates exactly which package@version pairs are new. This works across yarn v1, yarn berry, pnpm, and monorepo setups. Layer 2: Age Quarantine. Recently published package versions are automatically blocked for a window after their npm publish date. This is one of the simplest and most effective defenses against supply chain attacks — the majority of malicious packages are caught and removed by the npm security team shortly after publication, so requiring packages to age in the wild filters out a large class of attacks before they ever reach our code. We don't publish the exact threshold. Layer 3: GuardDog Static Scan. Trail of Bits' open-source GuardDog tool runs a static analysis pass over each new package. It checks for lifecycle hook abuse (install scripts that execute arbitrary code), typosquatting (package names that look like popular libraries but aren't), and obfuscated payloads. This catches the low-sophistication attacks — the ones that rely on developers not looking too closely at what they're installing. Layer 4: AI Source Code Review. This is the layer that catches what basic static rules can't. The system downloads the actual tarball for each new package, walks the source tree, and sends it to an LLM for analysis against a multi-category supply-chain threat rubric that broadly covers: - Code that's actively trying not to be read — obfuscation, dynamic execution, payloads hidden in binary assets or non-code files - Code reaching outside its lane — unexpected network activity, file system access, command execution, or persistence mechanisms - Install-time behavior — lifecycle scripts that fetch or run remote code, multi-stage payload delivery, time- or environment-gated activation - Identity and metadata signals — typosquatting, mismatched metadata fields, packages that fingerprint their environment before deciding how to behave Static rules catch known bad patterns; the AI catches novel ones that a human reviewer would flag but a regex would miss. Each finding comes back with a severity, file location, and recommended action. ## Customized Claude Security Scanner The third gate is a customized fork of Anthropic's open-source Claude code security review action. Our fork makes a handful of changes that any team running this in production should consider: - STRIDE threat model on every meaningful PR. Beyond the inline scan, we run a separate STRIDE pass (Spoofing, Tampering, Repudiation, Information Disclosure, DoS, Elevation of Privilege) and post it as a top-level PR comment. A cheap Haiku pre-screen first skips docs-only, test-only, formatting, dep-bump, and rename PRs so we don't burn tokens on changes that can't introduce risk. - Model tiering across a PR's lifecycle. Opus runs the first scan, when the whole diff is new and reasoning depth matters most. Sonnet handles subsequent commits, where the incremental diff is small. This is what makes the next change affordable. - Re-scan every commit, with per-finding dedup. Upstream scans once and never again. We scan every commit so feedback reflects the current diff, and the commenting layer dedupes against existing inline comments so reviewers don't see the same issue posted twice. - Surfaced false-positive filtering. Upstream silently drops findings the filter classifies as false positives. We still drop them from the blocking list but post them in a collapsed audit section of the PR comment, with the exclusion reason and a confidence score — so reviewers can audit the filter and push back when it's wrong. - Expanded vulnerability taxonomy. The audit prompt adds TypeScript/Node-specific patterns (prototype pollution, unsafe require, Express middleware ordering) and treats IDOR as a first-class category. Applied consistently across the inline scan, STRIDE, and false-positive filter prompts. - Supply-chain hardening on the action itself. npm ci against a committed lockfile (no surprise CLI upgrades), SHA-pinned internal self-references, and markdown-injection hardening on the PR-comment renderer so a malicious diff can't smuggle content into the review comment. The theme across all of these: a production security gate needs to be debuggable, accountable, and fail-loud. Each change closes a path where the scanner could silently underperform. ## Foundational Controls The gates above catch threats at PR time, but we also enforce controls at every other layer of the stack. The examples below are JavaScript/npm-specific because that's where the largest share of attacks land, but equivalent controls (registry pinning, lockfile immutability, advisory scanning, age gating where the ecosystem supports it) are applicable to Go, Python, and Rust toolchains. Immutable lockfiles everywhere. Every yarn install in CI, in Docker builds, and in production runs with --frozen-lockfile or --immutable. If the lockfile doesn't match package.json exactly, the build fails. This means no phantom dependency updates, no resolution drift between what a developer tested locally and what runs in production. Downstream repos float, actions repo is locked. The actions repo itself is fully SHA-pinned (external and internal references both), but the downstream consumer repos pin to @master of the actions repo on purpose. This asymmetry is deliberate: it lets us push a security fix into the deeply-audited and secured by multiple approvals and code owners actions repo and have every consumer pick it up on their next CI run, without chasing dozens of pin-bumping PRs. Age gating at install time. Beyond the CI gate, we enforce age gating at the package manager level itself. A minimum-age setting in .yarnrc.yml (npmMinimalAgeGate) means yarn refuses to install any version newer than our threshold, before CI even runs. This is the same quarantine as the CI gate, but enforced locally on every developer's machine. Build scripts off by default. Package install scripts — the postinstall hooks that run arbitrary code when you npm install — are globally disabled via enableScripts: false in our yarn configuration. The handful of packages that legitimately need native compilation (think: node-gyp for cryptographic modules) are individually opted in through dependenciesMeta. Everything else is silent. Compromised version blocking. When a specific package version is known to be compromised, we don't just hope developers update. We use resolutions and overrides in our package configuration to force the entire dependency tree — including transitive dependencies — away from the bad version. When axios 1.14.1 was found to contain malicious code, a single config change across our repos ensured no Swan project could resolve to that version, even as a transitive dependency three levels deep. ## Egress Hardening CI runners by default can connect to anywhere on the internet, on any port. That's a problem the day a malicious dependency or compromised tool decides to phone home with your deploy keys. Our egress-lock GitHub Action shuts that door: it forces all HTTP/HTTPS through a local proxy with a hostname allowlist, and uses firewall rules to reject everything else. Most workflows only need to add a line or two listing project-specific domains; common destinations like GitHub, npm, and AWS are allowed by default. Under the hood: - Tinyproxy as a bouncer — runs locally in default-deny mode, checking every connection's hostname against an allowlist. - Firewall as backup — iptables rules permit only DNS, loopback, established connections, and the proxy's own outbound traffic. Anything else gets rejected fast so builds fail quickly with a clear error instead of hanging. - Tool wiring is automatic — sets HTTP_PROXY/HTTPS_PROXY plus tool-specific config for git, npm, Yarn, and Node.js. - Easy debugging — a report-only mode opens the gates but keeps logging every destination, and the companion egress-report action dumps proxy logs on failure so you can see exactly what got blocked. ## Change Control: Guarding the Guards All of the controls above are code or config — which means they can be modified, and a security system whose own configuration can be edited is only as strong as the laxest reviewer. Every file that defines a security policy — the action mirror's versions.txt, the yarn configuration, the supply-chain-lint allowlist, dependency resolution overrides, and the workflow files that wire each gate into CI — is protected by GitHub CODEOWNERS and requires multiple approvals from designated security maintainers before changes can merge. A developer with commit access cannot single-handedly loosen an age gate, opt a package into running install scripts, or remove a compromised-version block. Every relaxation of a control gets the same scrutiny as adding a new dependency. Any violations of this policy are flagged and alerted. This closes a class of insider risk that pure technical controls can't: the gates only matter if you can't quietly turn them off. ## Executable Audit Skill Security controls are only as good as their enforcement across the org. So we built a Claude Code skill that codifies the entire playbook as an executable audit. Point it at any Swan repository and it reads the actual configuration files. It checks for frozen installs, age gating settings, GuardDog integration, AI dependency review wiring, advisory scanning, actions linting configuration, internal mirror usage, resolution overrides for known-bad versions, script blocking settings, and the merge-override procedure. It doesn't check a box on a compliance spreadsheet. It reads .yarnrc.yml and tells you whether enableScripts is actually set to false. It reads the workflow files and tells you whether supply-chain-lint is actually wired up as a required check. The difference between "we have a policy" and "the policy is enforced" is the difference between security theater and security engineering. ## Defense in Depth: Where Each Control Fires No single control catches everything. The point of the system is that a malicious package would have to defeat all of these layers, at different points in the pipeline, to make it into production. Here's the full picture: At Pull Request time: - dependency-review-action blocks any dependency change that pulls in a version with a known high-severity CVE (GitHub advisory database) - supply-chain-lint rejects vendored actions that fetch untrusted code at runtime (npm install without locks, curl | bash, @latest tags) - analyze-dependencies quarantines recently published packages, runs GuardDog static analysis, and AI-reviews the source against our supply-chain rubric - claude-code-security-review runs a STRIDE threat model on the PR diff itself; a Haiku pre-screen decides whether the PR is worth scanning, then Opus runs the first deep scan and Sonnet handles subsequent commits - check-linear-link ensures every PR is attributable to a tracked Linear ticket — no anonymous merges At install time (CI and developer machines): - Frozen lockfiles — yarn install --immutable fails the build if package.json and the lockfile disagree - npmMinimalAgeGate — yarn itself refuses to install any version newer than our minimum-age threshold, before CI even runs - enableScripts: false — package lifecycle hooks are off by default, with individual opt-ins via dependenciesMeta - resolutions / overrides — known-compromised versions are forced out of the entire dependency tree, including transitives At runtime in CI: - egress-lock routes all HTTP/HTTPS through a default-deny proxy with a hostname allowlist; iptables rejects everything else (non-proxy HTTP gets TCP RST, all other ports get ICMP port-unreachable for fast tool errors) - egress-report dumps proxy logs on failure so blocked traffic is visible and debuggable Ongoing: - The executable audit skill reads the actual configuration files in every critical repo and verifies each of the above is wired up — not "we have a policy," but "the policy is enforced." An attacker trying to land a malicious package in production would need to: age it past the quarantine window without being detected, evade GuardDog and the AI review, slip past the STRIDE diff scan, avoid every CVE database, get past frozen lockfiles, somehow execute despite scripts being disabled, get a CODEOWNERS-protected config change merged in order to loosen any of the above, and then exfiltrate without ever opening a non-allowlisted egress connection. That's the bar. ## Why This Matters Supply chain attacks aren't theoretical — they're the dominant attack vector against software companies today. The npm ecosystem has over two million packages, and any one of them, or any one of their transitive dependencies, can be the entry point. GitHub Actions run with access to your source code, secrets, and deployment credentials. A single compromised action can exfiltrate everything. Most companies respond by adding a vulnerability scanner and hoping for the best. A scanner only catches what's already known to be bad; the worm-class attacks of 2025 spread faster than any advisory database could keep up. The point of the system above is to treat unknown packages as guilty until proven innocent, and to make every link in the chain — the action mirror, the dependency gate, the PR scanner, the install config, the network egress — independently enforce that posture. Even if one layer is bypassed, the next one still has to be defeated. That's the bar attackers should have to clear before they reach your secrets. Plug this article into your agent to get an audit on your systems!