OSS Network Library PR Trust Chain: When a Merged PR Changes Your TLS Stack

OSS Network Library PR Trust Chain: When a Merged PR Changes Your TLS Stack

The Problem

Open-source network libraries sit at the bottom of every production application’s trust chain. Go services use crypto/tls; Python services use urllib3 via requests; C++ services use OpenSSL or BoringSSL. These libraries make the cryptographic decisions — minimum TLS version, cipher suites, certificate verification — that determine whether connections are actually secure.

The less-addressed risk is not a dramatic compromise but legitimate, security-degrading changes merged in good faith. A “compatibility PR” to curl lowers the default minimum TLS version from 1.2 to 1.1 to support older enterprise clients — every downstream consumer of the distribution package now negotiates TLS 1.1 by default. A TLS library adds InsecureSkipVerify support to ease testing — a downstream developer copies the pattern into production. The Go crypto/tls package adds a cipher suite to the default list — an attacker with MITM position forces negotiation of the weaker suite.

The deliberate supply chain attack is structurally similar: a PR framed as fixing a TLS handshake failure adds a conditional certificate verification bypass, triggered by an environment variable or a specific server header. The condition makes the bypass non-obvious in code review and non-triggering in standard CI. curl’s CVE history (CVE-2023-38545, CVE-2022-27776, CVE-2021-22946) demonstrates that these patterns appear in legitimate bug reports and would be indistinguishable from deliberate bypasses at the diff level.

In both cases, automated dependency update tools — Dependabot, Renovate — pull the change in within hours of release, and the consuming team has no visibility into what changed in the library.

Specific gaps in environments without network library update controls:

  • Dependabot and Renovate auto-merge patch version updates without review; a patch version can change TLS defaults.
  • No automated test verifies that a service still refuses TLS 1.1 connections after a dependency update.
  • Security teams lack tooling to detect TLS-related changes in dependency update PRs at diff time.
  • Go module proxy and npm registry do not provide a diff of security-relevant changes between versions.

Target systems: Go services using crypto/tls and net/http; Python services using requests, httpx, urllib3; Node.js services using https and tls modules; services using curl/libcurl; any application that updates dependencies via Dependabot or Renovate.

Threat Model

Adversary 1 — Malicious PR to popular network library. An attacker submits a PR to a widely-used TLS library adding a certificate validation bypass flag. The PR is framed as fixing a specific compatibility issue, includes a credible reproduction case, and the bypass is conditional on an environment variable (CURL_SKIP_CERT_VERIFY_FOR_TESTING=1 or equivalent). The condition makes the bypass non-obvious in code review and non-triggering in standard CI. Once merged and distributed in a patch release, the attacker can trigger the bypass in applications that set the flag (often copied from documentation examples) or by controlling network responses to activate conditional paths.

Adversary 2 — Compromised library maintainer commits trust-weakening change. A library maintainer’s account is compromised. The attacker uses it to commit a change that modifies cipher suite ordering to prefer weaker suites, or lowers the default minimum TLS version. The change is small, plausible, and signed with the maintainer’s credentials. It is released in a patch version. Dependabot creates update PRs across all downstream repositories within hours.

Adversary 3 — Typosquatting library with modified TLS stack. A new package is published under a name similar to a popular network library (request vs requests, urllib4 as a successor narrative). The package’s TLS implementation adds an unconditional certificate verification bypass or routes all connections through an attacker-controlled proxy. Applications that accidentally install the typosquatted package transmit their TLS traffic with degraded protection.

Adversary 4 — Legitimate but security-degrading compatibility change. No malicious intent, but a PR that widens TLS version support, adds cipher suites for enterprise firewall compatibility, or adds an InsecureSkipVerify-equivalent for private network use cases degrades the security of every consumer. This is the most common vector — it happens regularly and is typically invisible until a security audit.

  • Access objective: Intercept TLS-encrypted traffic via MITM by forcing use of compromised cipher suites or bypassed certificate verification.
  • Detection surface: Dependency update PR diffs, semgrep scanning of updated library code, TLS negotiation regression tests, network-level TLS inspection.
  • Blast radius: Applications that consume the affected library across all environments; the blast radius is proportional to the library’s download count.

Hardening Configuration

Step 1: Renovate and Dependabot Configuration Requiring Security Review for Network Libraries

Configure automated dependency update tools to require security team review for updates to network and cryptographic libraries, while allowing automatic merge for lower-risk updates:

// renovate.json
{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": ["config:base"],
  "packageRules": [
    {
      "description": "Network and cryptographic libraries require security team review",
      "matchPackageNames": [
        "requests",
        "httpx",
        "urllib3",
        "aiohttp",
        "cryptography",
        "pyOpenSSL",
        "certifi"
      ],
      "matchPackagePatterns": [
        ".*tls.*",
        ".*ssl.*",
        ".*crypto.*",
        ".*openssl.*",
        ".*http.*client.*"
      ],
      "reviewers": ["team:security"],
      "reviewersSampleSize": 1,
      "automerge": false,
      "labels": ["security-review-required", "network-library-update"],
      "prBodyNotes": [
        "**Security Review Required**: This PR updates a network or cryptographic library.",
        "Before approving, verify:",
        "- [ ] No changes to TLS minimum version or cipher suites",
        "- [ ] No new certificate verification bypass flags",
        "- [ ] No changes to trust store or certificate pinning logic",
        "- [ ] `semgrep-tls-audit` CI check passes"
      ]
    },
    {
      "description": "Go crypto/tls and net/http always require review",
      "matchLanguages": ["go"],
      "matchPackageNames": [
        "golang.org/x/crypto",
        "golang.org/x/net",
        "github.com/quic-go/quic-go"
      ],
      "reviewers": ["team:security"],
      "automerge": false,
      "labels": ["security-review-required", "go-crypto-update"]
    },
    {
      "description": "Patch updates to non-network libraries can auto-merge after CI",
      "matchUpdateTypes": ["patch"],
      "excludePackageNames": [
        "requests", "httpx", "urllib3", "cryptography"
      ],
      "excludePackagePatterns": [".*tls.*", ".*ssl.*", ".*crypto.*"],
      "automerge": true,
      "automergeType": "pr",
      "platformAutomerge": true
    }
  ],
  "vulnerabilityAlerts": {
    "enabled": true,
    "labels": ["vulnerability"],
    "reviewers": ["team:security"],
    "automerge": false
  }
}

Equivalent Dependabot configuration:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: pip
    directory: "/"
    schedule:
      interval: weekly
    reviewers:
      - "security-team"
    labels:
      - "dependencies"
    # Group network-related updates for consolidated security review.
    groups:
      network-libraries:
        patterns:
          - "requests*"
          - "urllib3*"
          - "cryptography*"
          - "certifi*"
          - "httpx*"
        update-types:
          - "minor"
          - "patch"

  - package-ecosystem: gomod
    directory: "/"
    schedule:
      interval: weekly
    reviewers:
      - "security-team"
    groups:
      go-crypto:
        patterns:
          - "golang.org/x/crypto*"
          - "golang.org/x/net*"

Run semgrep against the updated library source when a dependency PR is raised, scanning for TLS-weakening patterns:

# .semgrep/tls-security-audit.yaml
rules:
  - id: insecure-skip-verify-added
    patterns:
      - pattern: InsecureSkipVerify = true
    message: "InsecureSkipVerify set to true — disables TLS certificate verification"
    languages: [go]
    severity: ERROR

  - id: tls-min-version-downgrade
    patterns:
      - pattern: MinVersion = tls.VersionTLS10
      - pattern: MinVersion = tls.VersionTLS11
      - pattern: MinVersion = tls.VersionSSL30
    message: >
      TLS minimum version set below TLS 1.2.
      TLS 1.0 and 1.1 are deprecated and vulnerable to BEAST/POODLE.
    languages: [go]
    severity: ERROR

  - id: python-ssl-no-verify
    patterns:
      - pattern: verify=False
    message: "SSL verification disabled in requests/httpx call"
    languages: [python]
    severity: ERROR

  - id: python-ssl-context-unverified
    patterns:
      - pattern: ssl.create_default_context()
      - pattern: $CTX.check_hostname = False
    message: "SSL context has hostname checking disabled"
    languages: [python]
    severity: ERROR

  - id: weak-cipher-suite-addition
    patterns:
      - pattern: |
          CipherSuites = [..., tls.TLS_RSA_WITH_RC4_128_SHA, ...]
      - pattern: |
          CipherSuites = [..., tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA, ...]
    message: "Weak cipher suite added to TLS configuration"
    languages: [go]
    severity: ERROR

  - id: node-reject-unauthorized-false
    patterns:
      - pattern: rejectUnauthorized = false
      - pattern: rejectUnauthorized: false
    message: "Node.js TLS: rejectUnauthorized=false disables certificate verification"
    languages: [javascript, typescript]
    severity: ERROR

CI job that runs this scan when a dependency update PR modifies a network library:

# .github/workflows/tls-security-audit.yml
name: TLS Security Audit
on:
  pull_request:
    paths:
      - "requirements*.txt"
      - "go.mod"
      - "go.sum"
      - "package*.json"
      - "Gemfile*"

jobs:
  tls-audit:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Install dependencies
        run: pip install semgrep

      - name: Identify updated packages
        id: changed-deps
        run: |
          git diff origin/main...HEAD -- requirements*.txt go.mod package.json \
            > /tmp/dep-changes.diff
          echo "Dependency changes:"
          cat /tmp/dep-changes.diff

      - name: Download updated library source for analysis
        run: |
          # For Python: download the updated package and extract source.
          if grep -q "^+requests==" /tmp/dep-changes.diff || \
             grep -q "^+urllib3==" /tmp/dep-changes.diff; then
            pip download --no-deps -d /tmp/pkg-source \
              $(grep "^+requests\|^+urllib3" /tmp/dep-changes.diff | \
                sed 's/^+//' | tr '\n' ' ')
            for pkg in /tmp/pkg-source/*.whl; do
              unzip -q "$pkg" -d /tmp/pkg-extracted/
            done
          fi

      - name: Run TLS security audit
        run: |
          semgrep --config .semgrep/tls-security-audit.yaml \
            --error \
            --json \
            /tmp/pkg-extracted/ \
            . \
            > /tmp/semgrep-results.json 2>&1 || SEMGREP_EXIT=$?

          if [ "${SEMGREP_EXIT:-0}" -ne 0 ]; then
            echo "::error::TLS security issues found in dependency update"
            cat /tmp/semgrep-results.json | \
              jq -r '.results[] | "::error file=\(.path),line=\(.start.line)::\(.check_id): \(.extra.message)"'
            exit 1
          fi

      - name: Comment on PR with audit results
        if: always()
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const results = JSON.parse(
              fs.readFileSync('/tmp/semgrep-results.json', 'utf8')
            );
            if (results.results && results.results.length > 0) {
              github.rest.issues.createComment({
                issue_number: context.issue.number,
                owner: context.repo.owner,
                repo: context.repo.repo,
                body: `## TLS Security Audit Results\n\n` +
                  `Found ${results.results.length} TLS security issue(s) in dependency update.\n\n` +
                  `@security-team please review before merging.`
              });
            }

Step 3: Automated Test Suite Verifying TLS Negotiation After Dependency Updates

A regression test suite that verifies TLS behaviour after each dependency update merge:

#!/bin/bash
# scripts/tls-regression-test.sh
# Verifies that application endpoints maintain expected TLS security posture.
# Run after each dependency update merge in staging environment.

set -euo pipefail

APP_HOST="${1:?Usage: $0 <host> [port]}"
APP_PORT="${2:-443}"
FAILURES=0

echo "=== TLS Regression Test Suite: $APP_HOST:$APP_PORT ==="
echo ""

# Test 1: TLS 1.1 is rejected.
echo "Test 1: TLS 1.1 should be rejected..."
RESULT=$(openssl s_client \
  -connect "$APP_HOST:$APP_PORT" \
  -tls1_1 \
  -quiet 2>&1 | head -5)
if echo "$RESULT" | grep -q "Handshake failure\|no protocols\|alert"; then
  echo "  PASS: TLS 1.1 rejected"
else
  echo "  FAIL: TLS 1.1 accepted — expected rejection"
  echo "  Output: $RESULT"
  FAILURES=$((FAILURES + 1))
fi

# Test 2: TLS 1.2 is accepted.
echo "Test 2: TLS 1.2 should be accepted..."
RESULT=$(openssl s_client \
  -connect "$APP_HOST:$APP_PORT" \
  -tls1_2 \
  -quiet 2>&1 | head -5)
if echo "$RESULT" | grep -q "Cipher is\|SSL-Session"; then
  echo "  PASS: TLS 1.2 accepted"
else
  echo "  FAIL: TLS 1.2 rejected — check TLS configuration"
  FAILURES=$((FAILURES + 1))
fi

# Test 3: Weak cipher suites are rejected.
echo "Test 3: RC4 cipher suites should be rejected..."
RESULT=$(openssl s_client \
  -connect "$APP_HOST:$APP_PORT" \
  -cipher "RC4-SHA:RC4-MD5" \
  -quiet 2>&1 | head -5)
if echo "$RESULT" | grep -qE "Handshake failure|no shared cipher|alert"; then
  echo "  PASS: RC4 cipher suites rejected"
else
  echo "  FAIL: RC4 cipher suite negotiated"
  FAILURES=$((FAILURES + 1))
fi

# Test 4: Self-signed certificate is rejected (verify the app doesn't trust everything).
echo "Test 4: Client should reject self-signed certificate..."
# Start a test server with self-signed cert on a different port.
openssl req -x509 -newkey rsa:2048 -keyout /tmp/test.key \
  -out /tmp/test.crt -days 1 -nodes \
  -subj "/CN=$APP_HOST" 2>/dev/null
openssl s_server \
  -key /tmp/test.key \
  -cert /tmp/test.crt \
  -port 18443 \
  -quiet & SERVER_PID=$!
sleep 1

RESULT=$(python3 -c "
import requests
try:
    r = requests.get('https://$APP_HOST:18443/', timeout=2)
    print('ACCEPTED')
except requests.exceptions.SSLError:
    print('REJECTED')
except Exception as e:
    print('OTHER: ' + str(e))
" 2>/dev/null)

kill "$SERVER_PID" 2>/dev/null
rm -f /tmp/test.key /tmp/test.crt

if echo "$RESULT" | grep -q "REJECTED"; then
  echo "  PASS: Self-signed certificate rejected"
else
  echo "  FAIL: Self-signed certificate accepted by client"
  FAILURES=$((FAILURES + 1))
fi

# Test 5: Certificate expiry validation.
echo "Test 5: Expired certificate should be rejected..."
RESULT=$(openssl s_client \
  -connect "$APP_HOST:$APP_PORT" \
  -verify_return_error \
  -quiet 2>&1 | grep "Verify return code")
if echo "$RESULT" | grep -q "ok (0)"; then
  echo "  PASS: Valid certificate accepted"
else
  VERIFY_CODE=$(echo "$RESULT" | grep -oE "[0-9]+ \([A-Za-z ]+\)")
  echo "  INFO: Certificate verify code: $VERIFY_CODE"
fi

echo ""
echo "=== Results: $FAILURES failure(s) ==="
if [ "$FAILURES" -gt 0 ]; then
  echo "TLS regression tests FAILED — review dependency changes"
  exit 1
fi
echo "TLS regression tests PASSED"

For Go services, an equivalent test using the standard library:

// tls_regression_test.go
package main_test

import (
    "crypto/tls"
    "net/http"
    "testing"
)

// TestTLSMinimumVersion verifies the service refuses TLS 1.1.
func TestTLSMinimumVersion(t *testing.T) {
    client := &http.Client{
        Transport: &http.Transport{
            TLSClientConfig: &tls.Config{
                MaxVersion: tls.VersionTLS11,  // Force TLS 1.1.
            },
        },
    }
    _, err := client.Get("https://staging.example.com/healthz")
    if err == nil {
        t.Error("FAIL: Server accepted TLS 1.1 — expected handshake failure")
    }
    t.Logf("PASS: TLS 1.1 rejected: %v", err)
}

// TestNoInsecureSkipVerify verifies the app config doesn't disable cert checking.
func TestNoInsecureSkipVerify(t *testing.T) {
    // This test scans the application's TLS configuration at test time.
    // A dependency update that changes a default to InsecureSkipVerify=true
    // would make the application's outbound connections insecure.
    // We verify by checking that connections to a self-signed cert fail.
    cfg := &tls.Config{}
    if cfg.InsecureSkipVerify {
        t.Error("FAIL: Default TLS config has InsecureSkipVerify=true")
    }
}

Step 4: Pinning Network Library Versions with Digests

Prevent unexpected updates by pinning network libraries to specific digests:

# Python: use pip-compile with hashes for all dependencies.
pip-compile \
  --generate-hashes \
  --output-file requirements.lock \
  requirements.in

# The resulting lockfile pins to content hashes:
# requests==2.31.0 \
#     --hash=sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1 \
#     --hash=sha256:58cd2187423d62c17556555dbb68b7f8fd8f17ba700a3e5e9a0e45a3eeb2c6f1
pip install --require-hashes -r requirements.lock
// Go: go.sum provides digest pinning automatically.
// Explicitly verify go.sum in CI to detect tampering.
go mod verify

// The go.sum file contains hashes for every dependency:
// github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt38=
// github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=

// Alert if go.sum is modified without a corresponding go.mod change.
#!/bin/bash
# Pin network library versions in CI and alert on unexpected changes.
PINNED_REQUESTS_HASH="sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"

INSTALLED_HASH=$(pip show requests 2>/dev/null | grep -i "^Location" | \
  xargs -I{} find {} -name "*.dist-info" -path "*requests*" | \
  head -1 | xargs -I{} cat {}/RECORD | \
  sha256sum | awk '{print "sha256:" $1}')

if [ "$INSTALLED_HASH" != "$PINNED_REQUESTS_HASH" ]; then
  echo "WARNING: requests hash mismatch"
  echo "Expected: $PINNED_REQUESTS_HASH"
  echo "Got: $INSTALLED_HASH"
fi

Step 5: Network-Level Verification That Services Refuse TLS 1.1 After Updates

Integrate TLS configuration checks into the deployment pipeline’s smoke tests:

#!/bin/bash
# deploy-smoke-test.sh — Run after each deployment to verify TLS posture.

SERVICES=(
  "api.example.com:443"
  "internal-api.example.com:8443"
  "grpc.example.com:443"
)

FAILED_SERVICES=()

for service in "${SERVICES[@]}"; do
  HOST=$(echo "$service" | cut -d: -f1)
  PORT=$(echo "$service" | cut -d: -f2)

  # Check minimum TLS version.
  TLS11_RESULT=$(timeout 5 openssl s_client \
    -connect "$service" \
    -tls1_1 -quiet 2>&1)

  if ! echo "$TLS11_RESULT" | grep -qE "alert|failure|no protocols"; then
    echo "FAIL: $service accepts TLS 1.1"
    FAILED_SERVICES+=("$service")
    continue
  fi

  # Check certificate chain validity.
  CERT_RESULT=$(timeout 5 openssl s_client \
    -connect "$service" \
    -verify_return_error \
    -quiet 2>&1)

  if echo "$CERT_RESULT" | grep -qE "verify error|self.signed"; then
    echo "FAIL: $service has certificate verification issue"
    FAILED_SERVICES+=("$service")
    continue
  fi

  echo "PASS: $service — TLS posture verified"
done

if [ ${#FAILED_SERVICES[@]} -gt 0 ]; then
  echo ""
  echo "TLS posture failures in: ${FAILED_SERVICES[*]}"
  echo "Possible cause: recent dependency update changed TLS defaults"
  echo "Run: ./scripts/tls-regression-test.sh <host> to investigate"
  exit 1
fi

Expected Behaviour After Hardening

Renovate PR flagged for security review. requests is updated from 2.31.0 to 2.32.0 in a Python service. Renovate creates a PR with the security-review-required label and assigns the security team. The semgrep CI job downloads the new requests source and scans it:

Running TLS security audit on requests 2.32.0 source...

No TLS security findings in requests 2.32.0.
Audit complete: 0 findings.

Review checklist in PR body requires security team sign-off before merge.

The PR waits for a human security reviewer who confirms no changes to certificate verification or cipher suite handling before approving.

Semgrep flags insecure change. A hypothetical update to a Go HTTP client library introduces InsecureSkipVerify as a configuration option with a default of false. The CI scan detects the new code path:

[insecure-skip-verify-added] at vendor/github.com/example/httpclient/tls.go:142
InsecureSkipVerify set to true in test configuration path.
Though this is in a test path, the pattern is flagged for review.
Severity: ERROR

1 finding(s). Exiting with error code 1.

The PR is blocked. Security team review confirms the flag is test-only but adds a note requiring that production configurations are verified against the test configuration template.

TLS regression test catches downgrade. After merging a dependency update, the CI pipeline runs the TLS regression suite against staging:

Test 1: TLS 1.1 should be rejected...
  FAIL: TLS 1.1 accepted — expected rejection

=== Results: 1 failure(s) ===
TLS regression tests FAILED — review dependency changes

The deployment to production is blocked. Investigation reveals that the updated library changed the default MinVersion from tls.VersionTLS12 to tls.VersionTLS10 for compatibility. The application’s TLS configuration is updated to explicitly set MinVersion: tls.VersionTLS12, overriding the library default.

Trade-offs and Operational Considerations

Control Benefit Cost / Friction
Renovate/Dependabot with security review gates Security team reviews all TLS library updates Slows dependency updates; security team becomes a bottleneck for routine patch updates
Semgrep scanning of dependency source Catches TLS-weakening patterns in updated library code Requires downloading library source in CI; slow; false positives on legitimate bypass flags in test-only code
TLS negotiation regression tests Directly verifies runtime TLS behaviour after updates Test environment must match production TLS configuration; requires a running application instance
Digest pinning Prevents arbitrary updates; detects tampering Requires regular manual updates for security patches; breaks automated update workflows if not coordinated
Deploy-time TLS smoke tests Catches regressions before traffic reaches production Adds latency to deployment pipeline; requires access to deployed services from CI

The tension between security review gates and automated update velocity is real. A team of two security engineers cannot review every network library update across ten microservices. The practical solution is tiered review: explicit cert verification changes require security approval; TLS version and cipher suite changes require security approval; all other changes in network libraries require security team awareness but can proceed with standard engineering review.

Failure Modes

Failure Mode Consequence Prevention
Semgrep scans application code but not library source Changes in the library’s TLS behaviour are not detected Configure semgrep to scan vendored or downloaded library source, not just application code
TLS regression tests run against HTTP-only endpoints Tests pass because TLS is not used on the test endpoint Verify test target URLs use HTTPS; fail the test suite if the target responds on plain HTTP
Security review gate has an auto-approval path for patch updates Malicious patch update bypasses review Remove the patch auto-approve exception for packages matching the network library pattern list
Go go.sum verification skipped in CI Tampered dependency passes undetected Run go mod verify as a required CI step; fail builds if verification fails
Typosquatted package installed by developer mistake Modified TLS stack deployed silently Configure package manager to warn on packages not in an approved allowlist; use a private proxy that only serves approved packages
TLS regression test uses the same library it’s testing A compromised requests library passes its own TLS test Implement TLS tests using openssl s_client or a separate minimal HTTP client not depending on the library under test