Recently, a supply chain compromise affected axios, a library used by over 80 million projects weekly. This briefly impacted one of the projects I work on, FormSG, where a 3rd-party dependency in our CI pipeline pulled a poisoned axios version. This led to a brief compromise of one of our staging environments.

After a post-mortem, we identified several simple yet effective strategies that can be applied to almost any project to drastically reduce exposure to future supply chain attacks.

Why should I be concerned about supply chain attacks? How common are they?

Supply chain attacks have been happening frequently and are becoming the norm. Attackers target a popular package, such as Log4j, compromise the maintainer's account (often through sophisticated social engineering which may take even multiple years to gain the maintainer's trust and may be sponsored by state attackers, in the case of the xz utils backdoor), and publish malware-laden versions to run arbitrary code or steal credentials and secrets.

In the past 12 months alone, we've seen high-profile packages such as axios and nx being compromised. Even security tools like Trivy are not spared. As long as you install from registries like npm or PyPI, you are managing a web of third-party trust. Attackers don't only target obscure packages; they usually target the giants to maximize the blast radius. Scarily, these are only the known or detected compromises. The xz backdoor had almost went undiscovered.

Technical Recommendations

1. Restrict Post-install Scripts

Why: Malicious code often hides in postinstall hooks to execute immediately upon download. This was a primary vector in the axios compromise.

Actionable Tips:

JSON

// Example: package.json (pnpm)
{
  "pnpm": {
    "onlyBuiltDependencies": [
      "sharp",
      "sqlite3" // do we really need this? 
    ]
  }
}

2. Strict Version Pinning (Zero "Floating" Tags)

Why: Using latest or floating tags (like :lts-alpine) allows compromised transitive dependencies to slip into your builds. In our case, an unpinned CLI tool pulled the poisoned axios version during a CI run.

Actionable Tips:

  • Docker: Avoid node:lts-alpine. Use specific versions or even SHA256 hashes.
  • GitHub Actions: Use commit hashes rather than tags (e.g., v4). This prevents an attacker from overwriting a tag with malicious code.
# Dockerfile: Pin to a specific version
FROM node:22.22.2-alpine3.22
# GitHub Actions: Pin to a specific commit hash
- uses: chromaui/action@c93e0bc3a63aa176e14a75b61a31847cbfdd341c # v1
  • npx: Avoid raw npx commands. Use npm exec --no-install to ensure the environment uses the version pinned in your lockfile. Consider installing previous npx deps into your devDeps to pin all transitive versions.

For example, FormSG replaced usage of npx and pinned our dependencies and actions.

3. Enforce Frozen Lockfiles

Why: Running a standard npm install in CI can update your lockfile and pull in "new" (and potentially poisoned) versions. Using --frozen-lockfile (pnpm) or npm ci ensures your build uses the exact versions verified during development.

Pro-tip: While pnpm detects CI environments automatically, it doesn't always do so inside a Docker container. Explicitly define it:

# Ensure production builds match the lockfile exactly
RUN pnpm install --frozen-lockfile
RUN npm ci

4. Use Non-Privileged Users in Docker

Why: If a dependency is compromised and executes a shell, a root user gives the attacker full control over the container.

# Create a system user for the app
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
# Run the application
CMD ["node", "dist/index.js"]

5. Use Multi-Stage Docker Builds for Production

Why: A single-stage Dockerfile often contains build tools (compilers, git, SSH keys) and the full node_modules (including devDependencies). If an attacker gains shell access, these tools provide kit to further penetrate your infrastructure. Splitting your Dockerfile into build and production stages ensures your final image only contains the bare essentials.

Actionable Tips:

  • Use a build stage to install all dependencies and compile your code.
  • Use a production stage to copy only the compiled dist folder and the production-ready node_modules.
  • This significantly reduces the attack surface and results in much smaller images.

FormSG's production dockerfile applies the abovementioned practices for reference here

6. Isolate Environments (The Blast Radius)

Why: Isolation prevents a compromise in Staging from reaching Production. At FormSG, our IaC (Infrastructure as Code) work to fully isolate staging, UAT, and production environments limited the axios compromise to a single staging environment.

Action: If using AWS, create separate AWS accounts within an Organization for each environment. Accounts are free security boundaries.

7. Use GitHub OIDC for Cloud Roles

Why: Instead of storing long-lived AWS keys in GitHub Secrets, use OIDC. This allows you to specify exactly which repository and branch can assume the role, limiting the damage if credentials are leaked.

Example: AWS IAM Trust Policy snippet for GitHub Actions:

JSON

{
  "Effect": "Allow",
  "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" },
  "Action": "sts:AssumeRoleWithWebIdentity",
  "Condition": {
    "StringLike": {
      "token.actions.githubusercontent.com:sub": "repo:<orgname>/<reponame>:ref:refs/heads/main"
    }
  }
}

Sources and credits: