Skip to content

Security

Supply-Chain Security for Trading Code: SLSA, Sigstore, and Defending Against npm/PyPI/cargo Attacks

The Bybit hack was a supply-chain attack. After it, I rewrote our dependency security posture. Here is the full model: SLSA levels, Sigstore, hash pinning, and the DPRK threat.

13 min
#supply-chain #security #slsa #sigstore #dependencies #bybit #trading #devsecops

The Bybit hack in February 2025 was not a key management failure. It was not a smart contract vulnerability. It was a supply-chain attack on the Safe multi-sig interface. The attacker compromised the JavaScript bundle served by Safe’s frontend infrastructure, replacing the signing UI with a version that showed the expected transaction to the signers while sending a different transaction to the blockchain. $1.4 billion in ETH and stETH, gone, because the frontend dependency chain was not attested.

After that, I spent three weeks auditing ZeroCopy’s dependency security posture. What I found was that we were - like most firms - at what the industry now calls SLSA Level 0: we had source control, but we had no systematic way to verify that the artifact we were running was built from the source we intended, by a process we controlled, without tampering in the middle.

This is the guide I produced from that audit.

SLSA: A Framework for Artifact Trustworthiness

SLSA (Supply-chain Levels for Software Artifacts, pronounced “salsa”) is a framework developed by Google and now stewarded by the OpenSSF. It defines four levels of increasing rigor:

SLSA Level 1 - Documented build process The build process is documented and automated. Artifacts are uploaded to a registry. No tampering detection yet.

Most firms are here. Your CI/CD pipeline exists. But you cannot prove that the artifact in your registry was built by that pipeline.

SLSA Level 2 - Version-controlled builds with provenance The build runs on a version-controlled CI system. The CI system generates a signed provenance document: “artifact X was built from source commit Y by CI run Z at time T.” The signature uses the CI system’s signing key.

This is the first level that provides any tamper detection. If an artifact is replaced in your registry, the provenance document will not match.

SLSA Level 3 - Hardened build environment The build runs in an ephemeral, isolated environment. The environment cannot be modified by the build itself (no network access to download additional packages during the build). The provenance is signed by a system the build itself cannot compromise.

SLSA Level 4 - Reproducible builds Given the same source code, any compliant build environment can produce a bit-for-bit identical artifact. This allows independent third parties to verify that the published artifact matches the source code.

Most open-source dependencies you use are at L0-L1. Some major projects (the Linux kernel, Rust’s standard library, parts of the Python ecosystem) are working toward L4. For your own trading infrastructure code, targeting L2 is achievable with current tooling.

Sigstore and Cosign: Cryptographic Attestation in Practice

Sigstore is an open-source project that provides the infrastructure for signing and verifying software artifacts. Its key components:

  • Cosign - CLI tool for signing and verifying container images and other artifacts
  • Fulcio - A certificate authority that issues short-lived code-signing certificates tied to OIDC identities (GitHub Actions identity, Google service accounts, etc.)
  • Rekor - A transparency log that records all signing events immutably

The signing flow in a GitHub Actions pipeline:

name: Build and Sign
on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read
  packages: write

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

      - name: Build container image
        run: |
          docker build -t ghcr.io/zerocopy/trading-engine:${{ github.sha }} .

      - name: Push image
        run: |
          docker push ghcr.io/zerocopy/trading-engine:${{ github.sha }}

      - name: Sign image with Cosign
        uses: sigstore/cosign-installer@v3
        with:
          cosign-release: 'v2.2.0'

      - name: Sign
        run: |
          cosign sign --yes \
            ghcr.io/zerocopy/trading-engine:${{ github.sha }}
        env:
          COSIGN_EXPERIMENTAL: 1

The cosign sign step uses the GitHub Actions OIDC token to request a short-lived certificate from Fulcio, signs the image digest with that certificate, and records the signing event to Rekor. The certificate is tied to the GitHub Actions run ID and repository, so you can verify that a specific image was signed by a CI run in a specific repository.

The verification step before deployment:

- name: Verify image signature before deploy
  run: |
    cosign verify \
      --certificate-identity-regexp="https://github.com/zerocopy/.*" \
      --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
      ghcr.io/zerocopy/trading-engine:${{ env.DEPLOY_SHA }}

This command fails if:

  • The image was not signed
  • The image was signed by a different identity than the specified pattern
  • The signature was not issued by the specified OIDC issuer
  • The signing event is not present in the Rekor transparency log

An attacker who replaces your container image in the registry cannot produce a valid signature for it without access to your CI system’s OIDC identity.

Dependency Pinning: The Foundation

Before attestation, you need pinning. An unpinned dependency is a moving target - the same requirements.txt line can resolve to different package versions on different days, and any of those versions could contain a vulnerability introduced by a maintainer compromise.

The wrong approach:

# requirements.txt
requests>=2.28.0
numpy>=1.24.0

This installs whatever the latest version is at install time. If requests 2.31.1 is released with a compromised maintainer injecting code, you will pull it on your next deployment.

The correct approach for production dependencies:

# requirements.txt - always pin exact versions
requests==2.31.0
numpy==1.26.3

But pinning to a version number is not sufficient. A version number can be overwritten on PyPI by a maintainer (though most registries prevent this for stable releases). The defense that actually matters is hash pinning:

# requirements.txt with hashes
requests==2.31.0 \
    --hash=sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1 \
    --hash=sha256:58cd2187423d77b898475a6b05c08f4bc8f92b76ddf0e0a59c698c3f0d4cd1ec

When pip encounters a hash-pinned requirement, it verifies the downloaded package against the hash before installation. A package substituted in transit or at the registry level will fail the hash check.

Generate a pinned requirements file:

pip-compile --generate-hashes requirements.in -o requirements.txt

For cargo (Rust), Cargo.lock provides version pinning and cargo fetch downloads packages with hash verification. The Cargo.lock file must be committed for application code (it is intentionally excluded for library crates). For npm/yarn, the lockfile serves the same function.

CVE Scanning in CI: Necessary but Not Sufficient

Adding dependency scanning to CI is table stakes:

# GitHub Actions
- name: Audit Python dependencies
  run: pip-audit -r requirements.txt --format=json --output=audit.json

- name: Audit cargo dependencies
  run: cargo audit --json > cargo-audit.json

- name: Audit npm dependencies
  run: npm audit --json > npm-audit.json

pip-audit, cargo audit, and npm audit check your dependencies against known CVE databases. They catch vulnerabilities in packages that have been publicly disclosed.

What they do not catch: a compromised maintainer account that pushes a new version of a package you depend on that contains malicious code. If that code does not match any CVE signature (because it was just introduced), the scanner will not flag it.

The supplementary controls:

Monitor for new versions of direct dependencies. Use Dependabot or Renovate to get notifications when your pinned dependencies have new releases. Review every new version before updating - especially for packages with broad permissions (network access, file system access, subprocess execution).

Audit transitive dependency changes. When you update a direct dependency, the transitive dependency tree can change. pip-compile and cargo tree show the full dependency tree. A new transitive dependency that appears after an update should be reviewed.

Watch for unusual package size changes. A patch release of a utility package that doubles in binary size is a red flag. The pip-audit ecosystem does not catch this - you need to build this check yourself or rely on third-party monitoring services.

Typosquatting and Dependency Confusion

Two supply-chain attack vectors that do not require compromising a legitimate maintainer:

Typosquatting: The attacker publishes a package named reqeusts (note the transposed letters) to PyPI. Developers who mistype the package name in their requirements file install the malicious package instead. The malicious package may include the legitimate package’s functionality to avoid detection.

Defense: use a private registry that serves only packages you have reviewed and approved. When a developer installs a package that is not in the approved list, the install fails and requires a manual addition to the approved list.

Dependency confusion: If your organization has internal packages hosted on a private registry, and an attacker publishes a public package with the same name on PyPI with a higher version number, some configurations of pip will prefer the public package over the private one.

Defense: namespace all internal packages with a prefix that is unlikely to appear publicly (zerocopy-internal-*, for example). Configure your package manager to only fetch packages from your private registry for anything in that namespace.

The DPRK-Specific Threat: Watching for Maintainer Compromise

The Lazarus Group (DPRK state-sponsored hackers) has specifically targeted software supply chains for crypto and trading infrastructure. Their pattern: gain access to a maintainer account of a widely-used package through social engineering or credential compromise, then push a new version with malicious code.

Known campaigns have targeted npm packages in the following categories:

  • Cryptocurrency wallet libraries
  • Exchange API clients
  • Build tooling used in Web3 development

The practical defenses:

Review new maintainers on packages you depend on. When a package you depend on adds a new maintainer, this should be visible in your Dependabot/Renovate notifications. Unusual maintainer additions (especially if the new maintainer has a newly created account or minimal prior contribution history) warrant scrutiny before updating to the latest version.

Use deps.dev for dependency insights. Google’s Open Source Insights project at deps.dev provides dependency graph analysis, OpenSSF Scorecard scores, and maintainer history for packages across npm, PyPI, cargo, and Go modules. If a package you depend on has a low scorecard score (particularly a low “Maintained” score or a poor “Dangerous Workflow” score), this is a risk signal.

Pin to commits, not tags, for critical dependencies. For packages where the supply-chain risk is highest - exchange client libraries, cryptographic libraries - pin to a specific commit hash rather than a version tag. Tags can be moved to point to different commits. Commit hashes cannot be reassigned.

In GitHub Actions:

# Wrong - tag can be reassigned
uses: some-action/action@v3

# Correct - commit hash is immutable
uses: some-action/action@a2b3c4d5e6f7...

Putting It Together: The Pipeline Security Posture

The target state for a trading firm:

  1. All production artifacts are built by an ephemeral CI environment (SLSA L2)
  2. All artifacts are signed with Cosign at build time; verification is required before deployment
  3. All Python/Node/Rust dependencies are hash-pinned
  4. CVE scanning runs on every CI build and fails on critical findings
  5. A private registry serves approved packages; direct access to public registries is blocked from production builds
  6. Dependabot monitors all direct dependencies; new releases require engineering review before the lockfile is updated
  7. New maintainer additions on direct dependencies trigger a manual security review

Private Registry Architecture for a Trading Firm

The supply-chain controls above are most effective when combined with a private package registry that serves as the single approved source for all dependencies. Here is the architecture:

Python: A private PyPI mirror (devpi, Artifactory, or JFrog’s cloud offering) that contains only packages that have been reviewed and approved. The CI pipeline’s pip is configured with --index-url https://pypi.internal.zerocopy.systems/simple/ and --no-index for packages not in the mirror. Direct access to pypi.org from production build environments is blocked at the network firewall level.

npm/Node: A private npm registry (Verdaccio, or Artifactory’s npm repository). Scoped packages (@zerocopy/*) route to the internal registry; external packages are proxied through the internal registry with caching and version pinning.

cargo (Rust): Cargo supports alternative registries. A private cargo registry serves approved crates. The Cargo.toml workspace configuration specifies the internal registry as the source for dependencies.

Container images: Harbor or ECR as the internal registry. All images pulled during the build are pulled from the internal registry. Public images are mirrored into the internal registry after a review process, not pulled directly.

The operational cost of this architecture is real: you need to maintain the registries, review new packages before approving them, and handle the occasional case where a build fails because a dependency is not yet in the internal registry. The security benefit is that you have eliminated the direct dependency on public registries from your production build path. An attacker who compromises a public registry cannot affect your production builds.

Response to a Supply-Chain Compromise

When a supply-chain compromise is discovered - a package you depend on turns out to contain malicious code - the response time window is short. Here is the procedure:

Immediate (first hour):

  1. Identify every service that depends on the compromised package and its version, using your dependency graph tooling
  2. Determine whether the compromised version was ever deployed to production and for what time window
  3. If deployed: rotate all credentials that the affected services had access to - those credentials may have been exfiltrated

Short-term (first day): 4. Build new images from the patched version or a prior safe version 5. Deploy the clean images to all affected services 6. Audit logs for the time window when the compromised version was running - look for anomalous outbound connections, unusual file access, unexpected process spawns

Documentation: 7. Record the incident timeline, affected versions, and response actions 8. Update your dependency graph and registry mirror to block the compromised versions

The credential rotation step (step 3) is the one most commonly skipped under time pressure. Skipping it means that even after you have deployed the clean image, an attacker who exfiltrated credentials during the compromise window still has access to your exchange accounts or database.

How This Breaks in Production

The failure mode I see most often: the supply-chain security controls are applied to the application code but not to the build tooling itself. The CI pipeline scripts that build your trading engine pull from public registries at build time. The Docker base image is not pinned to a digest. The build tools themselves are unsigned.

An attacker who compromises your CI/CD toolchain can inject malicious code into your build output even if your application source code is pristine. The Bybit attack worked precisely this way - the source code was not compromised, but the build pipeline that served the JavaScript bundle was.

The defense: pin everything. The Docker base image in your Dockerfile:

# Wrong
FROM python:3.12-slim

# Correct - pinned to digest
FROM python:3.12-slim@sha256:a99f...

The build tools installed in your CI environment:

- name: Install build tools
  run: |
    pip install \
      --require-hashes \
      --only-binary=:all: \
      build==1.0.3 \
      --hash=sha256:...

The second failure mode is treating supply-chain security as a one-time audit rather than a continuous process. You audit your dependencies today, mark them clean, and move on. Six months later, a new vulnerability is disclosed in a package you depend on, or a maintainer of a package you depend on is compromised. You have no mechanism that surfaces this to you because the supply-chain audit was a project, not a continuous monitor.

The correct model: dependency risk monitoring is a running process, not an annual exercise. Dependabot or Renovate running continuously, CVE feeds being checked on every new advisory publication, maintainer change alerts for your direct dependencies - these are the tools that surface supply-chain risk as it emerges rather than months after the fact.

Supply-chain security is not a feature you add to a secure system. It is the foundation that makes the rest of your security posture meaningful. A code audit of your trading strategy is worthless if an attacker can modify the artifact that runs in production.

Continue Reading

Enjoyed this?

Get one deep infrastructure insight per week.

Free forever. Unsubscribe anytime.

You're in. Check your inbox.