From a81a4918c0f473cea25f60d24591e3e0b0a54bb9 Mon Sep 17 00:00:00 2001 From: Sahil Bansal Date: Fri, 20 Feb 2026 11:40:26 +0530 Subject: [PATCH] add support to merge system CA with PMG CA (#169) * add support to merge system CA with PMG CA * Keep CA cert immutable; merge system bundle only when writing proxy CA file * Update proxy/certmanager/generator.go Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> Signed-off-by: Sahil Bansal * Update internal/flows/proxy_flow.go Co-authored-by: Abhisek Datta Signed-off-by: Sahil Bansal * update config field name * Use integer-only guard when sizing merged CA bundle * Use int64-only guard when sizing merged CA bundle * add tests for firstReadablePath * Update proxy/certmanager/generator.go Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> Signed-off-by: Sahil Bansal * fix linter * refactor system CA merge API * add max limit to 2MB for certificates * update max limit to 2MB for certificate merging * remove python specific env var for CAs --------- Signed-off-by: Sahil Bansal Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Abhisek Datta --- internal/flows/proxy_flow.go | 4 +- proxy/certmanager/certmanager_test.go | 101 ++++++++++++++++ proxy/certmanager/generator.go | 167 ++++++++++++++++++++++++++ 3 files changed, 270 insertions(+), 2 deletions(-) diff --git a/internal/flows/proxy_flow.go b/internal/flows/proxy_flow.go index 9f186d1..bfdcd44 100644 --- a/internal/flows/proxy_flow.go +++ b/internal/flows/proxy_flow.go @@ -206,13 +206,13 @@ func handleExecutionResultError(err error) error { return fmt.Errorf("failed to execute command: %w", err) } -// setupCACertificate generates or loads a CA certificate for MITM +// setupCACertificate generates CA for MITM and writes proxy bundle for child package managers. func (f *proxyFlow) setupCACertificate() (*certmanager.Certificate, string, error) { log.Debugf("Generating CA certificate for proxy MITM") // Generate CA certificate caConfig := certmanager.DefaultCertManagerConfig() - caCert, err := certmanager.GenerateCA(caConfig) + caCert, err := certmanager.GenerateCAWithSystemCA(caConfig) if err != nil { return nil, "", fmt.Errorf("failed to generate CA certificate: %w", err) } diff --git a/proxy/certmanager/certmanager_test.go b/proxy/certmanager/certmanager_test.go index 59c9475..1a4ac93 100644 --- a/proxy/certmanager/certmanager_test.go +++ b/proxy/certmanager/certmanager_test.go @@ -3,6 +3,9 @@ package certmanager import ( "crypto/tls" "crypto/x509" + "os" + "path/filepath" + "strings" "testing" "time" @@ -245,3 +248,101 @@ func BenchmarkCacheOperations(b *testing.B) { } }) } + +func TestGenerateCAWithSystemCA(t *testing.T) { + config := DefaultCertManagerConfig() + + systemBundlePath := filepath.Join(t.TempDir(), "system.pem") + assert.NoError(t, os.WriteFile(systemBundlePath, []byte("SYSTEM_CA_CERT\n"), 0600)) + + t.Setenv("SSL_CERT_FILE", systemBundlePath) + + ca, err := GenerateCAWithSystemCA(config) + assert.NoError(t, err, "Failed to generate CA with system CA bundle") + assert.NotNil(t, ca, "CA certificate should not be nil") + assert.True(t, strings.Contains(string(ca.Certificate), "SYSTEM_CA_CERT"), "Merged certificate should contain system CA content") +} + +func TestSystemCABundleCandidatesForOS_WindowsIncludesCommonBundlePaths(t *testing.T) { + t.Setenv("SSL_CERT_FILE", "") + + t.Setenv("CURL_CA_BUNDLE", "") + t.Setenv("ProgramFiles", `C:\Program Files`) + t.Setenv("ProgramFiles(x86)", `C:\Program Files (x86)`) + t.Setenv("SystemRoot", `C:\Windows`) + + candidates := systemCABundleCandidatesForOS(goosWindows) + joined := strings.Join(candidates, "|") + + assert.Contains(t, joined, `Git/mingw64/ssl/certs/ca-bundle.crt`) + assert.Contains(t, joined, `Git/usr/ssl/certs/ca-bundle.crt`) + assert.Contains(t, joined, `Git/mingw32/ssl/certs/ca-bundle.crt`) + assert.Contains(t, joined, `System32/curl-ca-bundle.crt`) +} + +func TestSystemCABundleCandidatesForOS_DarwinIncludesKnownBundlePaths(t *testing.T) { + t.Setenv("SSL_CERT_FILE", "") + + t.Setenv("CURL_CA_BUNDLE", "") + candidates := systemCABundleCandidatesForOS(goosDarwin) + joined := strings.Join(candidates, "|") + + assert.Contains(t, joined, "/opt/homebrew/etc/openssl@3/cert.pem") + assert.Contains(t, joined, "/usr/local/etc/openssl@3/cert.pem") + assert.Contains(t, joined, "/etc/ssl/cert.pem") +} + +func TestSystemCABundleCandidatesForOS_LinuxIncludesKnownBundlePaths(t *testing.T) { + t.Setenv("SSL_CERT_FILE", "") + + t.Setenv("CURL_CA_BUNDLE", "") + candidates := systemCABundleCandidatesForOS(goosLinux) + joined := strings.Join(candidates, "|") + + assert.Contains(t, joined, "/etc/ssl/certs/ca-certificates.crt") + assert.Contains(t, joined, "/etc/pki/tls/certs/ca-bundle.crt") + assert.Contains(t, joined, "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem") + assert.Contains(t, joined, "/etc/ssl/ca-bundle.pem") + assert.Contains(t, joined, "/etc/ssl/cert.pem") +} + +func TestFirstReadablePath(t *testing.T) { + tempDir := t.TempDir() + + readableFile1 := filepath.Join(tempDir, "ca1.pem") + readableFile2 := filepath.Join(tempDir, "ca2.pem") + dirPath := filepath.Join(tempDir, "certs") + missingFile := filepath.Join(tempDir, "missing.pem") + + assert.NoError(t, os.MkdirAll(dirPath, 0o755)) + assert.NoError(t, os.WriteFile(readableFile1, []byte("CERT1"), 0o600)) + assert.NoError(t, os.WriteFile(readableFile2, []byte("CERT2"), 0o600)) + + t.Run("returns first readable file and skips empty, directory, and missing", func(t *testing.T) { + got := firstReadablePath("", dirPath, missingFile, readableFile1, readableFile2) + assert.Equal(t, readableFile1, got) + }) + + t.Run("returns empty when no readable file exists", func(t *testing.T) { + got := firstReadablePath("", dirPath, missingFile) + assert.Equal(t, "", got) + }) +} + +func TestGenerateCAWithSystemCA_SkipsOversizedSystemBundle(t *testing.T) { + config := DefaultCertManagerConfig() + + systemBundlePath := filepath.Join(t.TempDir(), "oversized-system.pem") + oversized := make([]byte, maxSystemCABundleBytes+1) + for i := range oversized { + oversized[i] = 'A' + } + assert.NoError(t, os.WriteFile(systemBundlePath, oversized, 0600)) + + t.Setenv("SSL_CERT_FILE", systemBundlePath) + + ca, err := GenerateCAWithSystemCA(config) + assert.NoError(t, err, "oversized system CA bundle should be skipped") + assert.NotNil(t, ca, "CA certificate should not be nil") + assert.Less(t, len(ca.Certificate), int(maxSystemCABundleBytes), "oversized system CA content must not be merged") +} diff --git a/proxy/certmanager/generator.go b/proxy/certmanager/generator.go index 720ec0e..2028971 100644 --- a/proxy/certmanager/generator.go +++ b/proxy/certmanager/generator.go @@ -9,7 +9,19 @@ import ( "encoding/pem" "fmt" "math/big" + "os" + "path/filepath" + "runtime" "time" + + "github.com/safedep/dry/log" +) + +const ( + maxSystemCABundleBytes int64 = 10 * 1024 * 1024 + goosDarwin = "darwin" + goosLinux = "linux" + goosWindows = "windows" ) // certManager implements the CertificateManager interface @@ -260,3 +272,158 @@ func ParseTLSCertificate(cert *Certificate) (tls.Certificate, error) { return tlsCert, nil } + +// GenerateCAWithSystemCA generates a self-signed certificate and appends system CA bundle +// content to the certificate bytes. If the system bundle is unavailable or too large to merge, +// it falls back to the PMG CA so proxy startup remains functional. +func GenerateCAWithSystemCA(config CertManagerConfig) (*Certificate, error) { + caCert, err := GenerateCA(config) + if err != nil { + return nil, fmt.Errorf("failed to generate CA: %w", err) + } + + caCertPEM := caCert.Certificate + systemBundlePath := firstReadablePath(systemCABundleCandidates()...) + + // No system CA found. Continue using only PMG cert. + if systemBundlePath == "" { + log.Warnf("Skipping system CA bundle merge: No system CA bundle file found") + return caCert, nil + } + + info, err := os.Stat(systemBundlePath) + if err != nil { + return nil, fmt.Errorf("failed to stat system CA bundle %s: %w", systemBundlePath, err) + } + + // We make sure there is a boundary on the size of CA bundle loaded + // from the system. Beyond that, we just skip it and return PMG cert. + if info.Size() > maxSystemCABundleBytes { + log.Errorf( + "Skipping system CA bundle merge: %s is too large (%d bytes > %d bytes)", + systemBundlePath, + info.Size(), + maxSystemCABundleBytes, + ) + + return caCert, nil + } + + systemBundle, err := os.ReadFile(systemBundlePath) + if err != nil { + return nil, fmt.Errorf("failed to read system CA bundle %s: %w", systemBundlePath, err) + } + + caLen := int64(len(caCertPEM)) + sysLen := int64(len(systemBundle)) + const extra = int64(2) + + totalCap := caLen + sysLen + extra + if totalCap > maxSystemCABundleBytes { + log.Errorf( + "Skipping system CA bundle merge: merged CA would be too large (%d bytes > %d bytes)", + totalCap, + maxSystemCABundleBytes, + ) + + return caCert, nil + } + + merged := make([]byte, 0, int(totalCap)) + merged = append(merged, caCertPEM...) + + if len(merged) > 0 && merged[len(merged)-1] != '\n' { + merged = append(merged, '\n') + } + merged = append(merged, systemBundle...) + + if len(merged) > 0 && merged[len(merged)-1] != '\n' { + merged = append(merged, '\n') + } + + return &Certificate{ + Certificate: merged, + PrivateKey: caCert.PrivateKey, + X509Cert: caCert.X509Cert, + PrivKey: caCert.PrivKey, + }, nil +} + +func firstReadablePath(paths ...string) string { + for _, path := range paths { + if path == "" { + continue + } + + info, err := os.Stat(path) + if err != nil || info.IsDir() { + continue + } + + f, err := os.Open(path) + if err != nil { + continue + } + _ = f.Close() + + return path + } + + return "" +} + +func systemCABundleCandidates() []string { + return systemCABundleCandidatesForOS(runtime.GOOS) +} + +func systemCABundleCandidatesForOS(goos string) []string { + var candidates []string + appendIfSet := func(key string) { + if value := os.Getenv(key); value != "" { + candidates = append(candidates, value) + } + } + + appendIfSet("SSL_CERT_FILE") + appendIfSet("CURL_CA_BUNDLE") + + switch goos { + case goosDarwin: + candidates = append(candidates, + "/opt/homebrew/etc/openssl@3/cert.pem", + "/usr/local/etc/openssl@3/cert.pem", + "/etc/ssl/cert.pem", + ) + case goosLinux: + candidates = append(candidates, + "/etc/ssl/certs/ca-certificates.crt", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", + "/etc/ssl/ca-bundle.pem", + "/etc/ssl/cert.pem", + ) + case goosWindows: + programFiles := os.Getenv("ProgramFiles") + programFilesX86 := os.Getenv("ProgramFiles(x86)") + systemRoot := os.Getenv("SystemRoot") + + if programFiles != "" { + candidates = append(candidates, + filepath.Join(programFiles, "Git", "mingw64", "ssl", "certs", "ca-bundle.crt"), + filepath.Join(programFiles, "Git", "usr", "ssl", "certs", "ca-bundle.crt"), + ) + } + if programFilesX86 != "" { + candidates = append(candidates, + filepath.Join(programFilesX86, "Git", "mingw32", "ssl", "certs", "ca-bundle.crt"), + ) + } + if systemRoot != "" { + candidates = append(candidates, + filepath.Join(systemRoot, "System32", "curl-ca-bundle.crt"), + ) + } + } + + return candidates +}