Defending Kubernetes CI/CD pipelines from self-replicating npm worms requires a four-layer approach: disable preinstall hooks with ignore-scripts=true in CI runner .npmrc files, deploy Falco runtime detection rules targeting binary execution and metadata endpoint access, enforce image signing with Kyverno admission policies, and apply NetworkPolicies blocking the cloud metadata endpoint at 169.254.169.254.

Two active npm supply chain campaigns are targeting Kubernetes CI/CD pipelines right now, and your production cluster is not the primary objective. Your build infrastructure is. The credentials that live in GitHub Actions environments, IMDS endpoints, and CI runner pods are worth more to attackers than any production workload they could reach after a perimeter breach.

Miasma compromised 32 packages across 90+ malicious versions in the @redhat-cloud-services scope by stealing OIDC tokens directly from GitHub Actions workflows. Those tokens let it publish poisoned packages carrying what appear to be valid SLSA provenance attestations - because the build pipeline that signed them was already compromised. IronWorm followed a different approach: a 976 KB Rust binary inside a preinstall hook deploys a full eBPF kernel rootkit, establishes Tor-based C2, and hides itself from every container security scanner on the market.

This guide is not another attack anatomy. It is a four-layer defense implementation for platform engineers who need to protect Kubernetes CI/CD pipelines from both of these campaigns and from the next ones.

Why Is CI/CD the Real Target Instead of Your Production Cluster?

The question platform engineers ask after a supply chain compromise is “how did they get to production?” The better question is “why did they bother?”

A compromised CI runner typically holds GitHub PATs, npm publish tokens, AWS IMDS credentials, and kubeconfig files scoped to multiple environments. Miasma demonstrated this: within 49 seconds of initial compromise, scripted sweeps used stolen GitHub tokens to inject malicious commits into five separate repositories. The attacker did not need a foothold in production. The build environment handed over everything.

IronWorm sweeps 86 environment variables and more than 20 credential file paths on every compromised machine, covering AWS, GCP, Azure, Vault, Kubernetes, npm, Docker, GitHub, and AI provider keys from all seven major providers. It takes all of them whether or not they are immediately useful.

The credential scope that AI coding agents and CI runners share is a compounding factor. See The AI Agent Credential Crisis: IAM Attack Surface in Kubernetes for how this exposure extends across the full agent infrastructure stack.

The attack surface on a typical CI runner - permissive OIDC scopes, access to the cloud metadata endpoint at 169.254.169.254, long-lived npm tokens, unrestricted egress - is significantly larger than most production workloads that sit behind network policies and admission controls.

How Do Miasma and IronWorm Attack Kubernetes CI/CD Pipelines?

Miasma: OIDC Token Theft and Self-Replication

Miasma exploits GitHub Actions workflows with id-token: write permission. The compromised workflow fires on push to any branch, executes the malicious preinstall hook, and uses the obtained OIDC token to authenticate to npm’s Trusted Publishing API. The resulting published packages carry attestations signed by the compromised workflow’s own OIDC identity - the provenance is technically real, because the signing entity was the compromised pipeline, not a separate attacker key.

Propagation is bidirectional. Miasma enumerates repositories and organizations via stolen GitHub tokens, exchanges OIDC tokens for npm publish rights, and republishes poisoned packages. It injects payloads as .github/setup.js into non-protected branches using the Git Data API with a spoofed commit author (github-actions@github.com) and commit message chore: update dependencies [skip ci] to suppress CI notifications.

IronWorm: eBPF Rootkit from a Preinstall Hook

IronWorm executes before dependency resolution completes. A 976 KB Rust ELF binary at tools/setup fires automatically during npm install. The binary uses a custom UPX stub with the UPX! magic signature overwritten to defeat signature-based detection, and encrypts every internal string with a unique per-call-site key.

The embedded eBPF rootkit provides three stealth capabilities:

  • Process hiding: Rewrites /proc directory listings in-kernel, removing hidden PIDs before userland tools can read them. ps, top, and ls /proc all report clean.
  • Network hiding: Filters /proc/net/tcp rows and netlink responses to remove implant connections. Tor C2 traffic disappears from ss and netstat.
  • Anti-debugging: Any ptrace call against protected processes returns SIGKILL, killing the calling shell.

These capabilities depend on bpf_probe_write_user. On systems with kernel lockdown mode enabled, they fail silently - which is exactly the defense Layer 4 below leverages.

AI Coding Agent Config Injection: The New Vector

Miasma introduces an attack surface that no existing Kubernetes CI/CD security guide addresses. It plants malicious configuration files that auto-execute when developers open compromised repositories in AI-assisted IDEs:

  • .claude/settings.json - SessionStart hook executing node .github/setup.js
  • .gemini/settings.json - identical SessionStart structure
  • .cursor/rules/setup.mdc - prompt injection with alwaysApply: true
  • .vscode/tasks.json - runOptions.runOn: "folderOpen" trigger
  • package.json - hijacked test script

Any developer who clones a compromised repository and opens it in a targeted IDE detonates the payload without performing any deliberate action.

sequenceDiagram
    participant A as Attacker
    participant G as Git Data API
    participant D as Developer Machine
    participant N as npm Registry
    participant R as Next Repo

    A->>G: POST /repos/{owner}/{repo}/contents/.claude/settings.json
    Note over G: Commit: "chore: update dependencies [skip ci]"
    Note over G: Author: github-actions@github.com

    D->>G: git clone compromised-repo
    D->>D: Opens project in Claude Code
    Note over D: SessionStart hook fires
    D->>D: node .github/setup.js executes
    Note over D: AWS, GCP, GitHub PAT, npm tokens stolen

    D->>N: OIDC token exchanged for npm publish
    N-->>D: Publish rights granted
    D->>N: Poisoned package published with valid provenance

    D->>R: GitHub PAT used across 5 repos (49 seconds)
    R-->>A: Cycle repeats

Miasma’s propagation from a single compromised repository through an AI coding agent into the npm registry and adjacent repositories - all within 49 seconds.

The Four-Layer Defense Model

graph LR
    subgraph L1["Layer 1: npm Entry Point"]
        A[--ignore-scripts]
        B[Staged publish + 2FA]
        C[Install controls]
    end
    subgraph L2["Layer 2: Falco Runtime Detection"]
        D[Binary exec from node_modules]
        E[Metadata endpoint access]
        F[Tor bootstrap detection]
    end
    subgraph L3["Layer 3: Kyverno Admission"]
        G[Image signature verification]
        H[SLSA provenance attestation]
        I[Namespace-scoped policies]
    end
    subgraph L4["Layer 4: Kernel + Network Isolation"]
        J[NetworkPolicy: block 169.254.169.254]
        K[Egress allowlisting]
        L[Kernel lockdown mode]
        M[gVisor / Kata RuntimeClass]
    end

    L1 -->|blocks| N[Hook Execution]
    L2 -->|detects| O[Payload Activity]
    L3 -->|enforces| P[Image Integrity]
    L4 -->|contains| Q[Lateral Movement]

Each layer targets a different phase of the attack chain. Layers 1 and 4 are deployable today without infrastructure changes.

Layer 1: Block the Entry Point

Disable Lifecycle Scripts in CI Runners

Both Miasma and IronWorm detonate through npm lifecycle hooks. Disabling them in CI eliminates the primary entry point.

Add to your CI runner’s .npmrc:

# .npmrc for CI/CD runners
ignore-scripts=true
audit=true
fund=false
allow-file=false
allow-remote=false
allow-directory=false

The allow-file, allow-remote, and allow-directory flags shipped in npm 11.15.0 and restrict install sources in addition to blocking scripts.

The practical problem: packages like esbuild, playwright, and bcrypt require install scripts for native compilation. For these, use LavaMoat’s @lavamoat/allow-scripts to maintain an explicit allowlist:

npx --yes @lavamoat/allow-scripts setup
# Generates .lavamoat/allow-scripts.yml with an explicit
# allowlist of packages permitted to run install scripts
npx allow-scripts run

Any package not in the allowlist runs with scripts disabled. Any new package attempting to run a script fails the build with an explicit error, prompting a human review before the allowlist is updated.

Enable Staged Publishing with 2FA

npm’s staged publishing (generally available since May 22, 2026) is the single most effective defense against OIDC-token-based package hijacking. Instead of npm publish pushing directly to the registry, npm stage publish places the tarball in a staging queue. A human maintainer must authenticate with 2FA and explicitly approve via npm stage approve before the version becomes installable.

The critical property: npm stage approve requires interactive authentication that OIDC tokens and granular access tokens cannot satisfy. Miasma’s entire propagation chain breaks here - the stolen OIDC token can run npm stage publish, but it cannot run npm stage approve.

Configure your package for stage-only mode by setting your trusted publisher configuration to reject direct publishes:

# CI can stage but not publish directly
npm stage publish

# A human maintainer approves from their local machine
npm stage list          # Review pending staged versions
npm stage view <id>     # Inspect the tarball before approval
npm stage approve <id>  # Requires interactive 2FA

Layer 2: Detect at Runtime

Falco 0.43.0 ships a default rule - “Network Tool Executed During NPM Package Install” - that detects network tools launched when npm installs packages. Extend it with campaign-specific rules for Miasma and IronWorm behavior.

Detect binary execution from node_modules:

- rule: Suspicious Binary in node_modules
  desc: Alert when an ELF binary executes from node_modules directory
  condition: >
    spawned_process and
    container and
    (proc.exepath contains "node_modules" or
     proc.exepath startswith "/tmp/") and
    not proc.name in (node, npm, npx, yarn, pnpm, bun)
  output: >
    Suspicious binary executed during package install
    (user=%user.name container=%container.name proc=%proc.name
     exe=%proc.exepath cmdline=%proc.cmdline)
  priority: CRITICAL

Detect cloud metadata access from build pods:

- rule: CI Runner Metadata Access
  desc: Detect access to cloud metadata from CI/CD containers
  condition: >
    evt.type = connect and
    container and
    fd.sip = "169.254.169.254" and
    proc.pname in (node, npm, bun)
  output: >
    Cloud metadata access from npm process in CI runner
    (container=%container.name proc=%proc.name parent=%proc.pname)
  priority: CRITICAL

Detect Tor bootstrap in build containers:

- rule: Tor Daemon in CI Container
  desc: Detect Tor process starting in build containers
  condition: >
    spawned_process and
    container and
    proc.name = "tor"
  output: >
    Tor daemon started in container (container=%container.name
     image=%container.image cmdline=%proc.cmdline)
  priority: CRITICAL

Detect process memory access from package manager processes:

- rule: Process Memory Dump in CI Runner
  desc: Detect attempts to read /proc/*/mem or environ from npm processes
  condition: >
    (evt.type = open or evt.type = openat) and
    container and
    (fd.name contains "/proc/" and
     (fd.name contains "/mem" or fd.name contains "/environ")) and
    proc.pname in (node, npm, bun, yarn, pnpm)
  output: >
    Process memory access from package manager process
    (proc=%proc.name file=%fd.name container=%container.name
     image=%container.image)
  priority: CRITICAL

The Tor bootstrap rule and metadata access rule together detect IronWorm’s C2 establishment and credential theft phases. The binary execution rule catches the initial Rust binary detonation before the rootkit loads.

Layer 3: Enforce at Admission

Kyverno’s ImageValidatingPolicy prevents attackers from deploying tampered runner images with weakened security controls. Apply this policy to your CI/CD namespace:

apiVersion: policies.kyverno.io/v1
kind: NamespacedImageValidatingPolicy
metadata:
  name: enforce-signed-runner-images
  namespace: ci-cd-runners
spec:
  matchConstraints:
    resourceRules:
      - apiGroups: ['']
        apiVersions: ['v1']
        operations: ['CREATE', 'UPDATE']
        resources: ['pods']
  matchImageReferences:
    - glob: 'ghcr.io/myorg/runner-*'
  attestors:
    - name: ci-pipeline
      cosign:
        keyless:
          identities:
            - subject: 'https://github.com/myorg/runners/.github/workflows/*'
              issuer: 'https://token.actions.githubusercontent.com'
          ctlog:
            url: 'https://rekor.sigstore.dev'
  validationConfigurations:
    required: true
    mutateDigest: true
  validations:
    - message: 'Only CI/CD pipeline-signed runner images permitted'
      expression: >-
        images.containers.map(image, verifyImageSignatures(image,
        [attestors.ci-pipeline])).all(result, result > 0)

Pair this with SLSA provenance attestation verification to confirm that runner images were built by your trusted pipeline:

attestations:
  - name: slsa-provenance
    intoto:
      type: https://slsa.dev/provenance/v1
validations:
  - expression: >-
      images.containers.map(image, verifyAttestationSignatures(image,
      attestations.slsa-provenance, [attestors.ci-pipeline])).all(e, e > 0)
    message: 'SLSA provenance attestation verification failed'

This does not prevent a compromised pipeline from producing malicious packages with valid attestations - Miasma demonstrated that limit. It does prevent an attacker from deploying arbitrary runner images into your CI namespace after compromising a developer credential.

Layer 4: Contain the Blast Radius

Block Metadata Endpoints and Restrict Egress

Both campaigns depend on reaching 169.254.169.254 for IMDS token theft. A NetworkPolicy blocking this endpoint from CI namespaces is deployable in under five minutes:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: block-metadata-endpoint
  namespace: ci-runners
spec:
  podSelector: {}
  policyTypes:
  - Egress
  egress:
  - to:
    - ipBlock:
        cidr: 0.0.0.0/0
        except:
        - 169.254.169.254/32
  - to:
    - namespaceSelector:
        matchLabels:
          kubernetes.io/metadata.name: kube-system
    ports:
    - protocol: UDP
      port: 53
    - protocol: TCP
      port: 53

For Calico environments, apply cluster-wide:

apiVersion: projectcalico.org/v3
kind: GlobalNetworkPolicy
metadata:
  name: block-cloud-metadata
spec:
  namespaceSelector: block-metadata == "true"
  selector: '!has(metadata-access)'
  order: 100
  types:
  - Egress
  egress:
  - action: Deny
    protocol: TCP
    destination:
      nets:
      - 169.254.169.254/32
  - action: Allow

Extend the egress policy to deny-all with an explicit allowlist covering only package registries (registry.npmjs.org), container registries (ghcr.io, docker.io), and internal services. IronWorm’s Tor C2 traffic has nowhere to go if the runner cannot reach arbitrary outbound endpoints.

Kernel Lockdown Mode and Container Isolation

IronWorm’s eBPF rootkit uses bpf_probe_write_user to modify /proc listings in-kernel. Linux kernel lockdown mode, available since kernel 5.4, restricts this call for all users including root. The rootkit’s stealth capabilities fail silently. Your detection tools continue working.

graph TB
    subgraph Default["Default Kernel"]
        A1[npm install fires preinstall] --> B1[Rust binary executes]
        B1 --> C1[eBPF program loads]
        C1 --> D1[bpf_probe_write_user succeeds]
        D1 --> E1[/proc listings rewritten]
        E1 --> F1[Process + network hidden from defenders]
    end

    subgraph Lockdown["Lockdown-Enabled Kernel"]
        A2[npm install fires preinstall] --> B2[Rust binary executes]
        B2 --> C2[eBPF program loads]
        C2 --> D2[bpf_probe_write_user DENIED]
        D2 --> E2[Rootkit fails silently]
        E2 --> F2[ps / ss / netstat report normally]
        F2 --> G2[Falco rules detect Tor + binary exec]
    end

    style F1 fill:#ef4444,color:#fff
    style G2 fill:#22c55e,color:#fff
    style D2 fill:#3b82f6,color:#fff

Kernel lockdown mode neutralizes IronWorm’s stealth layer. The rootkit loads but cannot hide itself, making all Layer 2 detection rules effective.

Enable on CI nodes:

# /etc/default/grub - add lockdown=confidentiality to GRUB_CMDLINE_LINUX
echo 'kernel.unprivileged_bpf_disabled=1' >> /etc/sysctl.d/99-security.conf
sysctl -p /etc/sysctl.d/99-security.conf

For build pod isolation, use gVisor’s runsc RuntimeClass. gVisor intercepts syscalls at the userspace level and never exposes the host kernel’s eBPF subsystem to untrusted workloads:

apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
  name: gvisor
handler: runsc
---
apiVersion: v1
kind: Pod
metadata:
  name: ci-runner
  namespace: ci-runners
spec:
  runtimeClassName: gvisor
  containers:
  - name: build
    image: ghcr.io/myorg/runner-node:20
    securityContext:
      readOnlyRootFilesystem: true
      runAsNonRoot: true
      allowPrivilegeEscalation: false

Reserve Kata Containers for pipelines handling production secrets or executing untrusted third-party code. Each Kata pod gets its own kernel, providing complete isolation from the host eBPF subsystem, at the cost of 100-300ms additional boot time.

Layer 5: Harden the AI Agent Attack Surface

The .claude/, .gemini/, .cursor/, and .vscode/ directories in a repository are now a live attack surface that CI/CD security guides have not addressed. Add a pre-clone scan to your pipelines:

#!/bin/bash
# Pre-clone hook: scan for malicious AI agent config files
SUSPICIOUS_FILES=(
  ".claude/settings.json"
  ".gemini/settings.json"
  ".cursor/rules/setup.mdc"
  ".vscode/tasks.json"
  ".github/setup.js"
)

for f in "${SUSPICIOUS_FILES[@]}"; do
  if [ -f "$f" ]; then
    echo "WARNING: AI agent config detected: $f"
    if grep -q "SessionStart\|folderOpen\|alwaysApply" "$f" 2>/dev/null; then
      echo "CRITICAL: Auto-execution config found in $f - quarantining"
      mv "$f" "$f.quarantined"
      exit 1
    fi
  fi
done

Enable branch protection rules on all repositories in your organization to prevent force-pushes and require signed commits. Miasma uses the Git Data API to inject commits to non-protected branches without going through a pull request. Branch protection rules at the organization level close this path.

For a broader look at how AI coding agents are becoming a privileged infrastructure attack surface across Kubernetes deployments, see Securing AI Coding Agent Infrastructure Access.

Add commit signing enforcement to your CI workflows. Miasma spoofs github-actions@github.com as the commit author. Requiring signed commits with verified identities makes unsigned injections visible in the commit log and rejectable at the repository level.

Implementation Priority Matrix

DefenseEffortCoverageDeploy Window
ignore-scripts=true in CI .npmrcLowBlocks Miasma + IronWorm hook executionToday
NetworkPolicy blocking 169.254.169.254LowBlocks IMDS credential theftToday
Pre-clone AI config scannerLowBlocks IDE-based propagationToday
Falco runtime rules (binary + metadata + Tor)MediumDetects active IronWorm activityThis sprint
npm staged publishing with 2FAMediumBlocks OIDC token publish hijackingThis sprint
LavaMoat @lavamoat/allow-scriptsMediumSelective script execution for native packagesThis sprint
Branch protection + commit signingMediumBlocks Git Data API injectionThis sprint
kernel.unprivileged_bpf_disabled=1LowDegrades IronWorm stealth on existing nodesThis sprint
Kernel lockdown modeMediumNeutralizes eBPF rootkit on new nodesThis quarter
gVisor RuntimeClass for build podsMediumSyscall-level isolation for CI runnersThis quarter
Kyverno image signing policyHighBlocks tampered runner image deploymentThis quarter
Egress allowlist for registries onlyMediumBlocks Tor C2 and arbitrary exfiltrationThis quarter

Start with the three “Today” rows. They are configuration changes with no infrastructure dependencies, they block the primary entry points for both campaigns, and they take less than an hour to implement across a typical CI/CD namespace.

Frequently Asked Questions

Can SLSA provenance alone protect against these npm worms?

No. Miasma produces SLSA attestations that appear valid because it compromises the CI pipeline itself, obtaining a legitimate OIDC token from the GitHub Actions runner. SLSA protects against binary substitution - an attacker swapping a built artifact for a different one - not against a compromised build system producing malicious artifacts with real provenance. Defense requires multiple layers: staged publishing with human 2FA approval, runtime detection for anomalous behavior, and network isolation to contain credential theft.

Does npm —ignore-scripts completely prevent these worms from executing?

Yes for the primary execution vector. Both Miasma and IronWorm rely on preinstall hooks to fire their payloads, and --ignore-scripts blocks all lifecycle hooks including preinstall, postinstall, and prepare. The practical exception: packages like esbuild, playwright, and bcrypt require install scripts for native binary compilation. Use LavaMoat’s @lavamoat/allow-scripts to selectively enable scripts only for audited packages, or run npm rebuild <package> for specific trusted packages after the initial install completes with scripts disabled.

How does kernel lockdown mode affect legitimate eBPF tools like Falco?

Kernel lockdown restricts dangerous eBPF helpers like bpf_probe_write_user - the specific helper IronWorm uses to modify process memory and hide itself from /proc. It does not prevent read-only tracing operations. Falco uses bpf_probe_read, perf events, and similar read-only paths that remain fully functional under lockdown. The rootkit’s stealth capabilities break while Falco’s detection capabilities continue working normally.

Should I use gVisor or Kata Containers for CI/CD build isolation?

gVisor provides strong syscall-level isolation with minimal overhead and no host kernel eBPF exposure. It works well for standard Node.js, Python, and Go builds. Kata Containers provides full VM-level isolation where each runner pod gets its own kernel - complete isolation from the host eBPF subsystem - but adds 100-300ms boot time per pod. Use gVisor for high-volume build jobs where boot latency matters. Reserve Kata for pipelines that execute untrusted third-party code or that have access to production credentials where the higher isolation guarantee justifies the overhead.

What is the minimum set of defenses I should deploy immediately?

Three changes deployable today with no infrastructure upgrades: (1) Add ignore-scripts=true to your CI runner .npmrc files - this blocks the preinstall hook execution that both Miasma and IronWorm depend on. (2) Apply a NetworkPolicy blocking egress to 169.254.169.254/32 in CI namespaces - this prevents IMDS token theft that funds further propagation. (3) Add a pre-clone step scanning for .claude/, .gemini/, .cursor/, and .vscode/tasks.json in repository checkouts - this blocks the IDE auto-execution vector Miasma uses to spread through developer machines. These three controls address the primary entry point, the primary credential theft vector, and the new AI agent propagation vector that existing guides miss.