Zum Inhalt springen

Supply-Chain-Sicherheit: Abwehr von Angriffen auf Abhängigkeiten

Umfassender Leitfaden zum Schutz der Software-Lieferkette vor Dependency Confusion, Typosquatting, kompromittierten Paketen und Build-Angriffen.

5 Min. Lesezeit
Schlosssymbol über einer Kette von Softwareabhängigkeits-Paketen

Die Angriffsfläche, die man nicht sieht

Deine Anwendung besteht zu 10 % aus eigenem Code und zu 90 % aus fremdem Code. Ein typisches Node.js-Projekt zieht Hunderte transitive Abhängigkeiten nach sich. Jede einzelne ist eine Vertrauensentscheidung: Du vertraust darauf, dass der npm-Account des Maintainers nicht kompromittiert wurde, dass die Paket-Registry nicht manipuliert wurde und dass jede transitive Abhängigkeit, drei Ebenen tiefer, ebenso vertrauenswürdig ist.

Der SolarWinds-Angriff, die Kompromittierung von ua-parser-js, die Backdoor in event-stream, die Sabotage von colors.js – das sind keine theoretischen Risiken. Es sind dokumentierte Vorfälle, bei denen vertrauenswürdige Pakete zu Angriffsvektoren wurden. Supply-Chain-Sicherheit ist längst keine Option mehr, sondern eine Kernaufgabe der Softwareentwicklung.

Dependency Confusion: die Falle mit internen Paketen

Dependency Confusion nutzt aus, wie Paketmanager Namen auflösen. Wenn dein Unternehmen ein privates Paket namens @company/auth-utils verwendet, veröffentlicht ein Angreifer ein Paket mit demselben Namen im öffentlichen npm-Registry – allerdings mit einer höheren Versionsnummer. Prüft dein Paketmanager zuerst die öffentliche Registry, installiert er die Version des Angreifers.

jsonjson
// ❌ Bad: No registry scoping — vulnerable to dependency confusion
{
  "dependencies": {
    "auth-utils": "^2.0.0",
    "payment-sdk": "^1.5.0"
  }
}
jsonjson
// ✅ Good: Scoped packages with registry pinning
{
  "dependencies": {
    "@mycompany/auth-utils": "^2.0.0",
    "@mycompany/payment-sdk": "^1.5.0"
  }
}
iniini
# .npmrc — Pin scoped packages to your private registry
@mycompany:registry=https://npm.mycompany.com/
//npm.mycompany.com/:_authToken=${NPM_PRIVATE_TOKEN}

Scoped Packages mit expliziter Registry-Bindung in .npmrc verhindern, dass der Paketmanager für deine internen Pakete überhaupt in der öffentlichen Registry nachschaut. Das ist die einfachste und wirksamste Verteidigung gegen Dependency Confusion.

Lockfile-Integrität: deine erste Verteidigungslinie

Die Lockfile fixiert exakte Abhängigkeitsversionen und enthält Integritäts-Hashes. Ändert jemand eine Abhängigkeit zwischen zwei Installationen – sei es durch eine kompromittierte Registry oder einen Supply-Chain-Angriff –, schlägt die Integritätsprüfung fehl.

tstypescript
// Script to verify lockfile integrity in CI
import { readFileSync } from "fs";
import crypto from "crypto";
 
interface LockfilePackage {
  version: string;
  resolved: string;
  integrity: string;
}
 
function verifyLockfileIntegrity(lockfilePath: string): {
  valid: boolean;
  issues: string[];
} {
  const issues: string[] = [];
  const lockfile = JSON.parse(readFileSync(lockfilePath, "utf-8"));
 
  const packages: Record<string, LockfilePackage> =
    lockfile.packages || {};
 
  for (const [name, pkg] of Object.entries(packages)) {
    if (!name) continue; // Skip root
 
    // Check for missing integrity hashes
    if (!pkg.integrity) {
      issues.push(`Missing integrity hash: ${name}@${pkg.version}`);
    }
 
    // Check for non-registry URLs
    if (
      pkg.resolved &&
      !pkg.resolved.startsWith("https://registry.npmjs.org/") &&
      !pkg.resolved.startsWith("https://npm.mycompany.com/")
    ) {
      issues.push(`Unexpected registry URL: ${name} → ${pkg.resolved}`);
    }
 
    // Check for git dependencies (potential risk)
    if (pkg.resolved?.startsWith("git+")) {
      issues.push(`Git dependency detected: ${name} → ${pkg.resolved}`);
    }
  }
 
  return { valid: issues.length === 0, issues };
}
ymlyaml
# CI pipeline step: Verify lockfile before install
- name: Verify dependency integrity
  run: |
    # Fail if lockfile would change (ensures it's committed and current)
    npm ci --ignore-scripts
    # The --ignore-scripts flag prevents pre/post install scripts from running
    # during the verification phase

Verwende in der CI immer npm ci (oder bun install --frozen-lockfile), niemals npm install. Der Befehl ci schlägt fehl, wenn die Lockfile nicht zur package.json passt, und verhindert so ein stilles Auseinanderdriften der Abhängigkeiten.

Typosquatting und bösartige Pakete erkennen

Typosquatting-Pakete tragen Namen, die legitimen zum Verwechseln ähnlich sehen: lodahs statt lodash, cross-env2 statt cross-env. Automatisiertes Scannen erkennt sie, bevor sie in deinen Abhängigkeitsbaum gelangen.

tstypescript
// Pre-install hook to check for suspicious packages
import { execSync } from "child_process";
 
interface PackageAudit {
  name: string;
  version: string;
  riskLevel: "low" | "medium" | "high" | "critical";
  reasons: string[];
}
 
function auditNewDependency(packageName: string): PackageAudit {
  const reasons: string[] = [];
  let riskLevel: "low" | "medium" | "high" | "critical" = "low";
 
  // Check package age
  const info = JSON.parse(
    execSync(`npm view ${packageName} --json`, {
      encoding: "utf-8",
    })
  );
 
  const createdDate = new Date(info.time?.created);
  const ageInDays =
    (Date.now() - createdDate.getTime()) / (1000 * 60 * 60 * 24);
 
  if (ageInDays < 30) {
    reasons.push(`Package is only ${Math.floor(ageInDays)} days old`);
    riskLevel = "high";
  }
 
  // Check download count
  const downloads = JSON.parse(
    execSync(
      `npm view ${packageName} --json | jq '.downloads'`,
      { encoding: "utf-8" }
    ).trim() || "0"
  );
 
  if (typeof downloads === "number" && downloads < 100) {
    reasons.push(`Low download count: ${downloads}`);
    riskLevel = riskLevel === "high" ? "critical" : "high";
  }
 
  // Check for install scripts
  if (info.scripts?.preinstall || info.scripts?.postinstall) {
    reasons.push("Contains install scripts");
    riskLevel = "medium";
  }
 
  // Check maintainer count
  const maintainers = info.maintainers || [];
  if (maintainers.length === 1) {
    reasons.push("Single maintainer");
  }
 
  return {
    name: packageName,
    version: info["dist-tags"]?.latest,
    riskLevel,
    reasons,
  };
}

Die Warnsignale: neue Pakete mit Installationsskripten, ein einzelner Maintainer, niedrige Downloadzahlen und Namen, die populären Paketen ähneln. Kein einzelnes Signal ist für sich genommen beweiskräftig, aber die Kombination ergibt ein klares Bild.

Versionen fixieren und Updates automatisieren

Versionsbereiche (^ und ~) erlauben automatische Minor- und Patch-Updates – und genau so verbreiten sich kompromittierte Versionen. Das Fixieren exakter Versionen gibt dir die Kontrolle darüber, wann Updates stattfinden.

jsonjson
// ❌ Bad: Version ranges allow silent updates
{
  "dependencies": {
    "express": "^4.18.0",
    "lodash": "~4.17.0"
  }
}
jsonjson
// ✅ Good: Exact versions with automated update PRs
{
  "dependencies": {
    "express": "4.18.2",
    "lodash": "4.17.21"
  }
}
ymlyaml
# Renovate config for controlled dependency updates
# renovate.json
{
  "extends": ["config:base"],
  "rangeStrategy": "pin",
  "schedule": ["after 10pm every weekday", "before 5am every weekday"],
  "vulnerabilityAlerts": {
    "enabled": true,
    "labels": ["security"],
    "schedule": ["at any time"]
  },
  "packageRules": [
    {
      "matchDepTypes": ["devDependencies"],
      "automerge": true,
      "automergeType": "pr",
      "requiredStatusChecks": ["ci"]
    },
    {
      "matchDepTypes": ["dependencies"],
      "automerge": false,
      "reviewers": ["team:security"]
    }
  ]
}

Fixiere exakte Versionen und nutze anschließend Renovate oder Dependabot, um Pull Requests für Updates zu erstellen. Dev-Abhängigkeiten können nach erfolgreicher CI automatisch gemergt werden. Produktionsabhängigkeiten erfordern eine menschliche Prüfung. Sicherheitslücken lösen unabhängig vom Zeitplan sofort einen PR aus.

Build-Pipeline härten

Die Build-Pipeline ist eine weitere Angriffsfläche. Kompromittiert ein Angreifer deine CI-Umgebung, kann er während des Build-Prozesses Code einschleusen – selbst wenn dein Quellcode sauber ist.

ymlyaml
# GitHub Actions with security hardening
name: Build and Deploy
on:
  push:
    branches: [main]
 
permissions:
  contents: read  # Minimum required permissions
  
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          persist-credentials: false  # Don't leak tokens
 
      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version-file: ".node-version"
 
      - name: Install dependencies
        run: npm ci --ignore-scripts
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
 
      - name: Run allowed install scripts explicitly
        run: |
          npx --yes node-gyp rebuild || true  # Only if needed
 
      - name: Audit dependencies
        run: npm audit --audit-level=high
 
      - name: Build
        run: npm run build
 
      - name: Verify no unexpected files
        run: |
          # Check for suspicious files added during build
          git diff --name-only
          if [ -n "$(git diff --name-only)" ]; then
            echo "WARNING: Build modified tracked files"
            git diff --name-only
            exit 1
          fi

Die Option --ignore-scripts bei der Installation verhindert, dass Pre-/Post-Install-Skripte automatisch ausgeführt werden. Das blockiert den häufigsten Angriffsvektor in der Supply Chain: bösartige Installationsskripte, die während npm install laufen. Führe im Anschluss nur die konkret benötigten Skripte gezielt und explizit aus.

Laufzeitschutz: kompromittierte Abhängigkeiten erkennen

Selbst mit allen präventiven Maßnahmen kann eine kompromittierte Abhängigkeit durchrutschen. Laufzeitüberwachung bietet eine letzte Verteidigungslinie.

tstypescript
// Detect suspicious runtime behavior from dependencies
const originalFetch = globalThis.fetch;
 
globalThis.fetch = async function monitoredFetch(
  input: RequestInfo | URL,
  init?: RequestInit
): Promise<Response> {
  const url = typeof input === "string" ? input : input.toString();
 
  // Log all outbound network requests for audit
  const allowedDomains = [
    "api.myapp.com",
    "cdn.myapp.com",
    "sentry.io",
  ];
 
  const requestUrl = new URL(url);
  if (!allowedDomains.some((d) => requestUrl.hostname.endsWith(d))) {
    console.warn(
      `[SECURITY] Unexpected outbound request: ${requestUrl.hostname}${requestUrl.pathname}`
    );
 
    // In strict mode, block the request entirely
    if (process.env.STRICT_NETWORK_POLICY === "true") {
      throw new Error(`Blocked request to unauthorized domain: ${requestUrl.hostname}`);
    }
  }
 
  return originalFetch(input, init);
};

Netzwerküberwachung entlarvt kompromittierte Pakete, die versuchen, Daten zu exfiltrieren. Wenn ein Paket, das du für die Datumsformatierung installiert hast, plötzlich HTTP-Anfragen an unbekannte Server stellt, stimmt etwas nicht. Alarmierung bei unerwarteten ausgehenden Verbindungen liefert eine Frühwarnung.

Das Wichtigste in Kürze

Software-Supply-Chain-Sicherheit ist ein Spektrum, kein binärer Zustand. Beginne mit den wirkungsvollsten Maßnahmen: Lockfile-Integritätsprüfung (npm ci), Scoped Packages mit Registry-Bindung und --ignore-scripts bei CI-Installationen. Diese drei Maßnahmen blockieren den Großteil der bekannten Supply-Chain-Angriffe.

Ergänze weitere Verteidigungsebenen, wie es dein Risikoprofil erfordert: exaktes Versions-Pinning mit automatisierten Update-PRs, Paketprüfung vor der Installation, gehärtete Build-Pipelines mit minimalen Berechtigungen und Netzwerküberwachung zur Laufzeit.

Die unbequeme Wahrheit ist: Du kannst deinem Abhängigkeitsbaum nicht vollständig vertrauen. Du kannst nur die Angriffsfläche verkleinern, Anomalien schnell erkennen und einen Reaktionsplan für den Ernstfall bereithalten. Behandle Abhängigkeiten wie externe Eingaben – validiere, verifiziere und überwache sie.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX