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 <bansalsahil315@gmail.com>

* Update internal/flows/proxy_flow.go

Co-authored-by: Abhisek Datta <abhisek.datta@gmail.com>
Signed-off-by: Sahil Bansal <bansalsahil315@gmail.com>

* 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 <bansalsahil315@gmail.com>

* 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 <bansalsahil315@gmail.com>
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Abhisek Datta <abhisek.datta@gmail.com>
This commit is contained in:
Sahil Bansal
2026-02-20 11:40:26 +05:30
committed by GitHub
co-authored by devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> Abhisek Datta devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent b0305cb54d
commit a81a4918c0
3 changed files with 270 additions and 2 deletions
+2 -2
View File
@@ -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)
}
+101
View File
@@ -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")
}
+167
View File
@@ -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
}