Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions internal/flows/proxy_flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
101 changes: 101 additions & 0 deletions proxy/certmanager/certmanager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ package certmanager
import (
"crypto/tls"
"crypto/x509"
"os"
"path/filepath"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -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 changes: 167 additions & 0 deletions proxy/certmanager/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Loading