From 814901f4e27a9d6d63c7d20f47e90c9e7caef0f3 Mon Sep 17 00:00:00 2001 From: Mladen Todorovic Date: Mon, 27 Apr 2026 12:59:51 +0200 Subject: [PATCH] Add support for self-singed CA --- charts/stackrox-mcp/templates/_helpers.tpl | 18 + .../templates/central-ca-secret.yaml | 14 + charts/stackrox-mcp/templates/configmap.yaml | 3 + charts/stackrox-mcp/templates/deployment.yaml | 11 + charts/stackrox-mcp/values.yaml | 6 + internal/client/client.go | 111 ++++- internal/client/client_test.go | 442 ++++++++++++++++++ internal/config/config.go | 7 + internal/config/config_test.go | 70 +++ 9 files changed, 680 insertions(+), 2 deletions(-) create mode 100644 charts/stackrox-mcp/templates/central-ca-secret.yaml diff --git a/charts/stackrox-mcp/templates/_helpers.tpl b/charts/stackrox-mcp/templates/_helpers.tpl index ca65311..d754a19 100644 --- a/charts/stackrox-mcp/templates/_helpers.tpl +++ b/charts/stackrox-mcp/templates/_helpers.tpl @@ -80,3 +80,21 @@ TLS Secret name - returns existingSecretName if set, otherwise generates name {{- include "stackrox-mcp.fullname" . }}-tls {{- end }} {{- end }} + +{{/* +Central CA Secret name - returns existingSecretName if set, otherwise generates name +*/}} +{{- define "stackrox-mcp.centralCASecretName" -}} +{{- if .Values.centralCACert.existingSecretName }} +{{- .Values.centralCACert.existingSecretName }} +{{- else }} +{{- include "stackrox-mcp.fullname" . }}-central-ca +{{- end }} +{{- end }} + +{{/* +Central CA enabled - returns "true" if either cert or existingSecretName is set +*/}} +{{- define "stackrox-mcp.centralCAEnabled" -}} +{{- if or .Values.centralCACert.cert .Values.centralCACert.existingSecretName }}true{{- end }} +{{- end }} diff --git a/charts/stackrox-mcp/templates/central-ca-secret.yaml b/charts/stackrox-mcp/templates/central-ca-secret.yaml new file mode 100644 index 0000000..bdef372 --- /dev/null +++ b/charts/stackrox-mcp/templates/central-ca-secret.yaml @@ -0,0 +1,14 @@ +{{- if and .Values.centralCACert.cert .Values.centralCACert.existingSecretName }} +{{- fail "centralCACert: cannot set both 'cert' and 'existingSecretName' — use one or the other" }} +{{- end }} +{{- if and .Values.centralCACert.cert (not .Values.centralCACert.existingSecretName) }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "stackrox-mcp.fullname" . }}-central-ca + labels: + {{- include "stackrox-mcp.labels" . | nindent 4 }} +type: Opaque +data: + ca.crt: {{ .Values.centralCACert.cert | b64enc }} +{{- end }} diff --git a/charts/stackrox-mcp/templates/configmap.yaml b/charts/stackrox-mcp/templates/configmap.yaml index 51247ba..632df05 100644 --- a/charts/stackrox-mcp/templates/configmap.yaml +++ b/charts/stackrox-mcp/templates/configmap.yaml @@ -13,6 +13,9 @@ data: auth_type: "passthrough" insecure_skip_tls_verify: {{ .Values.config.central.insecureSkipTLSVerify }} force_http1: {{ .Values.config.central.forceHTTP1 }} + {{- if include "stackrox-mcp.centralCAEnabled" . }} + ca_cert_path: "/central-ca/ca.crt" + {{- end }} request_timeout: {{ .Values.config.central.requestTimeout | quote }} max_retries: {{ .Values.config.central.maxRetries }} initial_backoff: {{ .Values.config.central.initialBackoff | quote }} diff --git a/charts/stackrox-mcp/templates/deployment.yaml b/charts/stackrox-mcp/templates/deployment.yaml index c15bc29..f27948a 100644 --- a/charts/stackrox-mcp/templates/deployment.yaml +++ b/charts/stackrox-mcp/templates/deployment.yaml @@ -111,6 +111,11 @@ spec: mountPath: /certs readOnly: true {{- end }} + {{- if include "stackrox-mcp.centralCAEnabled" . }} + - name: central-ca + mountPath: /central-ca + readOnly: true + {{- end }} volumes: - name: config configMap: @@ -121,6 +126,12 @@ spec: secretName: {{ include "stackrox-mcp.tlsSecretName" . }} defaultMode: 0440 {{- end }} + {{- if include "stackrox-mcp.centralCAEnabled" . }} + - name: central-ca + secret: + secretName: {{ include "stackrox-mcp.centralCASecretName" . }} + defaultMode: 0440 + {{- end }} {{- with .Values.nodeSelector }} nodeSelector: {{- toYaml . | nindent 8 }} diff --git a/charts/stackrox-mcp/values.yaml b/charts/stackrox-mcp/values.yaml index 81f9eab..41f2330 100644 --- a/charts/stackrox-mcp/values.yaml +++ b/charts/stackrox-mcp/values.yaml @@ -69,6 +69,12 @@ tlsSecret: # Server TLS Private Key (PEM format) key: "" +# CA certificate for verifying Central's TLS certificate (e.g., self-signed) +# Only one of cert or existingSecretName should be set. +centralCACert: + existingSecretName: "" + cert: "" + # Resource limits and requests resources: limits: diff --git a/internal/client/client.go b/internal/client/client.go index 04c8c61..fcf4c08 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -4,7 +4,11 @@ package client import ( "context" "crypto/tls" + "crypto/x509" + "encoding/pem" "fmt" + "log/slog" + "os" "sync" "testing" "time" @@ -23,6 +27,7 @@ import ( const ( minConnectTimeout = 5 * time.Second backoffJitter = 0.2 + maxCACertFileSize = 1 << 20 // 1MB ) // Client provides gRPC connection to StackRox Central API. @@ -229,11 +234,113 @@ func (c *Client) tlsConfig() (*tls.Config, error) { return nil, errors.Wrap(err, "failed to get central URL hostname") } - return &tls.Config{ + tlsCfg := &tls.Config{ InsecureSkipVerify: c.config.InsecureSkipTLSVerify, //nolint:gosec MinVersion: tls.VersionTLS12, ServerName: hostname, - }, nil + } + + // There is no reason to load certificates if we allow InsecureSkipTLSVerify. + if !c.config.InsecureSkipTLSVerify && c.config.CACertPath != "" { + certPool, err := loadCACertPool(c.config.CACertPath) + if err != nil { + return nil, err + } + + tlsCfg.RootCAs = certPool + } + + return tlsCfg, nil +} + +func loadCACertPool(caCertPath string) (*x509.CertPool, error) { + // File size guard + fileInfo, err := os.Stat(caCertPath) + if err != nil { + return nil, errors.Wrapf(err, "failed to access CA certificate at %s", caCertPath) + } + + if !fileInfo.Mode().IsRegular() { + return nil, errors.Errorf("CA certificate path %s is not a regular file", caCertPath) + } + + if fileInfo.Size() == 0 { + return nil, errors.Errorf("CA certificate file %s is empty", caCertPath) + } + + if fileInfo.Size() > maxCACertFileSize { + return nil, errors.Errorf( + "CA certificate file %s is too large (%d bytes, max %d)", + caCertPath, fileInfo.Size(), + maxCACertFileSize, + ) + } + + //nolint: gosec + caCert, err := os.ReadFile(caCertPath) + if err != nil { + return nil, errors.Wrapf(err, "failed to read CA certificate from %s", caCertPath) + } + + // Get system cert pool, warn on fallback + certPool, err := x509.SystemCertPool() + if err != nil { + slog.Warn("Failed to load system CA pool, using custom CA only", "error", err) + + certPool = x509.NewCertPool() + } + + if !certPool.AppendCertsFromPEM(caCert) { + return nil, errors.Errorf("failed to parse CA certificate from %s: no valid PEM data found", caCertPath) + } + + showCertInfo(caCert) + + return certPool, nil +} + +// showCertInfo parses and logs certificate metadata. +func showCertInfo(caCert []byte) { + block, _ := pem.Decode(caCert) + if block == nil { + slog.Warn("Unable to decode CA certificate") + + return + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + slog.Warn("Failed to parse CA certificate", "error", err) + + return + } + + slog.Info("Loaded CA certificate", + "subject", cert.Subject.CommonName, + "issuer", cert.Issuer.CommonName, + "notAfter", cert.NotAfter, + "isCA", cert.IsCA, + ) + + if !cert.IsCA { + slog.Warn("Provided certificate does not have the CA basic constraint set — TLS verification may fail", + "subject", cert.Subject.CommonName, + ) + } + + if time.Now().After(cert.NotAfter) { + slog.Warn("CA certificate is expired — TLS verification will fail", + "subject", cert.Subject.CommonName, + "expiredAt", cert.NotAfter, + ) + } + + if time.Now().Before(cert.NotBefore) { + slog.Warn("CA certificate is not yet valid", + "subject", cert.Subject.CommonName, + "validFrom", cert.NotBefore, + ) + } } func (c *Client) connectHTTP1( diff --git a/internal/client/client_test.go b/internal/client/client_test.go index be7e835..90698ef 100644 --- a/internal/client/client_test.go +++ b/internal/client/client_test.go @@ -2,8 +2,18 @@ package client import ( "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" "net" + "os" + "path/filepath" "strconv" "testing" "time" @@ -13,6 +23,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc" + "google.golang.org/grpc/connectivity" ) func TestClientReconnectsAfterServerRestart(t *testing.T) { @@ -138,3 +149,434 @@ func TestClient_tlsConfig_insecureSkipVerify(t *testing.T) { require.NotNil(t, tlsCfg) assert.False(t, tlsCfg.InsecureSkipVerify) } + +// generateTestCAWithKey creates a CA certificate and returns the PEM, parsed cert, and private key. +func generateTestCAWithKey(t *testing.T) ([]byte, *x509.Certificate, *ecdsa.PrivateKey) { + t.Helper() + + caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Test CA"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + IsCA: true, + BasicConstraintsValid: true, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &caKey.PublicKey, caKey) + require.NoError(t, err) + + caCert, err := x509.ParseCertificate(certDER) + require.NoError(t, err) + + caCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + return caCertPEM, caCert, caKey +} + +// generateTestCACert creates a self-signed CA certificate PEM for testing. +func generateTestCACert(t *testing.T) []byte { + t.Helper() + + encode, _, _ := generateTestCAWithKey(t) + + return encode +} + +// writeTestFile creates a file with the given content in a temp directory and returns the path. +func writeTestFile(t *testing.T, name string, content []byte) string { + t.Helper() + + path := filepath.Join(t.TempDir(), name) + err := os.WriteFile(path, content, 0o600) + require.NoError(t, err) + + return path +} + +func TestLoadCACertPool_NonexistentFile(t *testing.T) { + _, err := loadCACertPool("/nonexistent/path/to/ca.crt") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to access CA certificate") + // Error should include the file path for debuggability. + assert.Contains(t, err.Error(), "/nonexistent/path/to/ca.crt") +} + +func TestLoadCACertPool_EmptyFile(t *testing.T) { + path := writeTestFile(t, "empty-ca.crt", []byte{}) + + _, err := loadCACertPool(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "is empty") +} + +func TestLoadCACertPool_FileTooLarge(t *testing.T) { + // Create file just over the 1MB limit. + oversized := make([]byte, maxCACertFileSize+1) + path := writeTestFile(t, "oversized-ca.crt", oversized) + + _, err := loadCACertPool(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "too large") + // Error message should include both actual size and max size for debuggability. + assert.Contains(t, err.Error(), fmt.Sprintf("%d bytes", maxCACertFileSize+1)) + assert.Contains(t, err.Error(), fmt.Sprintf("max %d", maxCACertFileSize)) +} + +func TestLoadCACertPool_InvalidPEM(t *testing.T) { + path := writeTestFile(t, "invalid-ca.crt", []byte("this is not a PEM certificate")) + + _, err := loadCACertPool(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "no valid PEM data found") +} + +func TestLoadCACertPool_ValidCACert(t *testing.T) { + certPEM := generateTestCACert(t) + path := writeTestFile(t, "valid-ca.crt", certPEM) + + pool, err := loadCACertPool(path) + require.NoError(t, err) + require.NotNil(t, pool) +} + +func TestLoadCACertPool_UnreadableFile(t *testing.T) { + certPEM := generateTestCACert(t) + path := writeTestFile(t, "unreadable-ca.crt", certPEM) + + // Remove read permission. + err := os.Chmod(path, 0o000) + require.NoError(t, err) + + _, err = loadCACertPool(path) + require.Error(t, err) + // Depending on the platform, os.Stat or os.ReadFile will fail. + // The error should reference the file path. + assert.Contains(t, err.Error(), "unreadable-ca.crt") +} + +func TestLoadCACertPool_DirectoryPath(t *testing.T) { + // Pointing ca_cert_path at a directory should produce a clear error, not a panic. + dir := t.TempDir() + + _, err := loadCACertPool(dir) + require.Error(t, err) + assert.Contains(t, err.Error(), "not a regular file") + assert.Contains(t, err.Error(), dir) +} + +func TestClient_tlsConfig_WithCACertPath(t *testing.T) { + certPEM := generateTestCACert(t) + path := writeTestFile(t, "ca.crt", certPEM) + + client := &Client{ + config: &config.CentralConfig{ + URL: "central.stackrox.io:8443", + CACertPath: path, + }, + } + + tlsCfg, err := client.tlsConfig() + require.NoError(t, err) + require.NotNil(t, tlsCfg) + assert.NotNil(t, tlsCfg.RootCAs, "RootCAs should be set when CACertPath is provided") +} + +func TestClient_tlsConfig_EmptyCACertPath(t *testing.T) { + client := &Client{ + config: &config.CentralConfig{ + URL: "central.stackrox.io:8443", + CACertPath: "", + }, + } + + tlsCfg, err := client.tlsConfig() + require.NoError(t, err) + require.NotNil(t, tlsCfg) + assert.Nil(t, tlsCfg.RootCAs, "RootCAs should be nil when CACertPath is empty") +} + +func TestClient_tlsConfig_NonexistentCACertPath(t *testing.T) { + client := &Client{ + config: &config.CentralConfig{ + URL: "central.stackrox.io:8443", + CACertPath: "/nonexistent/ca.crt", + }, + } + + _, err := client.tlsConfig() + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to access CA certificate") +} + +// generateTestCert creates a certificate PEM with the given options, signed by the given CA. +// If ca/caKey are nil, the cert is self-signed. +func generateTestCert( + t *testing.T, template *x509.Certificate, caCert *x509.Certificate, caKey *ecdsa.PrivateKey, +) []byte { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + parent := template + signingKey := key + + if caCert != nil && caKey != nil { + parent = caCert + signingKey = caKey + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, parent, &key.PublicKey, signingKey) + require.NoError(t, err) + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + return certPEM +} + +func TestLoadCACertPool_MultiCertBundle(t *testing.T) { + // A PEM certs with multiple CA certificates should add ALL certs to the pool, + // even though diagnostic logging only inspects the first PEM block. + ca1Template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Test CA 1"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + IsCA: true, + BasicConstraintsValid: true, + } + + ca2Template := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "Test CA 2"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + IsCA: true, + BasicConstraintsValid: true, + } + + cert1 := generateTestCert(t, ca1Template, nil, nil) + cert2 := generateTestCert(t, ca2Template, nil, nil) + + certs := append(cert1, cert2...) //nolint: gocritic + path := writeTestFile(t, "certs-ca.crt", certs) + + pool, err := loadCACertPool(path) + require.NoError(t, err) + require.NotNil(t, pool) + + expectedPool, err := x509.SystemCertPool() + require.NoError(t, err) + + expectedPool.AppendCertsFromPEM(cert1) + expectedPool.AppendCertsFromPEM(cert2) + + assert.True(t, expectedPool.Equal(pool)) +} + +// generateTestServerCert creates a server certificate signed by the given CA, with SANs for 127.0.0.1. +func generateTestServerCert(t *testing.T, caCert *x509.Certificate, caKey *ecdsa.PrivateKey) ([]byte, []byte) { + t.Helper() + + serverKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "localhost"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)}, + DNSNames: []string{"localhost"}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, caCert, &serverKey.PublicKey, caKey) + require.NoError(t, err) + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + keyDER, err := x509.MarshalECPrivateKey(serverKey) + require.NoError(t, err) + + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + + return certPEM, keyPEM +} + +// startTLSGRPCServer starts a gRPC server with TLS on a random port. +func startTLSGRPCServer(t *testing.T, certPEM, keyPEM []byte) net.Listener { + t.Helper() + + serverCert, err := tls.X509KeyPair(certPEM, keyPEM) + require.NoError(t, err) + + tlsCfg := &tls.Config{ + Certificates: []tls.Certificate{serverCert}, + MinVersion: tls.VersionTLS12, + NextProtos: []string{"h2"}, + } + + lis, err := tls.Listen("tcp", "127.0.0.1:0", tlsCfg) + require.NoError(t, err) + + srv := grpc.NewServer() + + go func() { _ = srv.Serve(lis) }() + + t.Cleanup(func() { srv.Stop() }) + + return lis +} + +func checkRawConn(t *testing.T, lis net.Listener, caCertPEM []byte) { + t.Helper() + + // Verify the TLS handshake works at the raw TCP level first. + caCertPool := x509.NewCertPool() + require.True(t, caCertPool.AppendCertsFromPEM(caCertPEM)) + + dialer := tls.Dialer{ + Config: &tls.Config{ + RootCAs: caCertPool, + MinVersion: tls.VersionTLS12, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + rawConn, err := dialer.DialContext(ctx, "tcp", lis.Addr().String()) + require.NoError(t, err, "raw TLS dial should succeed with CA cert") + require.NoError(t, rawConn.Close()) +} + +func TestClient_ConnectWithCACert_Positive(t *testing.T) { + caCertPEM, caCert, caKey := generateTestCAWithKey(t) + serverCertPEM, serverKeyPEM := generateTestServerCert(t, caCert, caKey) + + lis := startTLSGRPCServer(t, serverCertPEM, serverKeyPEM) + caCertPath := writeTestFile(t, "ca.crt", caCertPEM) + + checkRawConn(t, lis, caCertPEM) + + cfg := &config.CentralConfig{ + URL: lis.Addr().String(), + AuthType: config.AuthTypeStatic, + APIToken: "dummy", + CACertPath: caCertPath, + RequestTimeout: 2 * time.Second, + MaxRetries: 0, + InitialBackoff: time.Millisecond, + MaxBackoff: 5 * time.Millisecond, + } + + client, err := NewClient(cfg) + require.NoError(t, err) + + defer func() { assert.NoError(t, client.Close()) }() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + require.NoError(t, client.Connect(ctx)) + + conn := client.Conn() + require.NotNil(t, conn) + + conn.Connect() + + for { + state := conn.GetState() + if state == connectivity.Ready { + break + } + + if state == connectivity.TransientFailure { + t.Fatalf("connection entered TransientFailure — TLS handshake likely failed (addr=%s)", lis.Addr().String()) + } + + if !conn.WaitForStateChange(ctx, state) { + t.Fatalf("timeout waiting for connection to become Ready (last state: %s)", state) + } + } +} + +func TestClient_ConnectWithoutCACert_Negative(t *testing.T) { + _, caCert, caKey := generateTestCAWithKey(t) + serverCertPEM, serverKeyPEM := generateTestServerCert(t, caCert, caKey) + + lis := startTLSGRPCServer(t, serverCertPEM, serverKeyPEM) + + cfg := &config.CentralConfig{ + URL: lis.Addr().String(), + AuthType: config.AuthTypeStatic, + APIToken: "dummy", + InsecureSkipTLSVerify: false, + RequestTimeout: 2 * time.Second, + MaxRetries: 0, + InitialBackoff: time.Millisecond, + MaxBackoff: 5 * time.Millisecond, + } + + client, err := NewClient(cfg) + require.NoError(t, err) + + defer func() { assert.NoError(t, client.Close()) }() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + require.NoError(t, client.Connect(ctx)) + + conn := client.Conn() + require.NotNil(t, conn) + + conn.Connect() + + for { + state := conn.GetState() + if state == connectivity.TransientFailure { + break + } + + if !conn.WaitForStateChange(ctx, state) { + break + } + } + + assert.NotEqual(t, connectivity.Ready, conn.GetState(), + "connection must not reach Ready state without CA cert for self-signed server") +} + +func TestLoadCACertPool_MixedPEMContent(t *testing.T) { + // A PEM file containing a valid CERTIFICATE block plus a PRIVATE KEY block. + // AppendCertsFromPEM ignores non-CERTIFICATE blocks and returns true if at least + // one cert was found. The pool should be functional. + mixedCerts := generateTestCACert(t) + + // Generate a throwaway private key PEM block. + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + keyDER, err := x509.MarshalECPrivateKey(key) + require.NoError(t, err) + + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + + // Bundle cert + key into one file. + mixedCerts = append(mixedCerts, keyPEM...) + path := writeTestFile(t, "mixed-ca.crt", mixedCerts) + + pool, err := loadCACertPool(path) + require.NoError(t, err, "mixed PEM content with at least one valid cert should succeed") + require.NotNil(t, pool) +} diff --git a/internal/config/config.go b/internal/config/config.go index 4033c69..98f8fe1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "fmt" + "log/slog" "net/url" "strings" "time" @@ -53,6 +54,7 @@ type CentralConfig struct { APIToken string `mapstructure:"api_token"` InsecureSkipTLSVerify bool `mapstructure:"insecure_skip_tls_verify"` ForceHTTP1 bool `mapstructure:"force_http1"` + CACertPath string `mapstructure:"ca_cert_path"` // Timeouts and retry settings RequestTimeout time.Duration `mapstructure:"request_timeout"` @@ -130,6 +132,10 @@ func LoadConfig(configPath string) (*Config, error) { return nil, errors.Wrap(err, "invalid configuration") } + if cfg.Central.InsecureSkipTLSVerify && cfg.Central.CACertPath != "" { + slog.Warn("ca_cert_path is configured but will be ignored because insecure_skip_tls_verify is true") + } + return &cfg, nil } @@ -140,6 +146,7 @@ func setDefaults(viper *viper.Viper) { viper.SetDefault("central.api_token", "") viper.SetDefault("central.insecure_skip_tls_verify", false) viper.SetDefault("central.force_http1", false) + viper.SetDefault("central.ca_cert_path", "") viper.SetDefault("central.request_timeout", defaultRequestTimeout) viper.SetDefault("central.max_retries", defaultMaxRetries) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 9607237..c9354ac 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -557,3 +557,73 @@ func TestConfig_Redacted_EmptyToken(t *testing.T) { // Empty token should remain empty, not be replaced with redacted marker. assert.Empty(t, redactedConfig.Central.APIToken) } + +func TestLoadConfig_CACertPathFromYAML(t *testing.T) { + yamlContent := ` +central: + url: central.example.com:8443 + auth_type: static + api_token: test-token + ca_cert_path: /custom/ca.crt +tools: + vulnerability: + enabled: true +` + configPath := testutil.WriteYAMLFile(t, yamlContent) + + defer func() { assert.NoError(t, os.Remove(configPath)) }() + + cfg, err := LoadConfig(configPath) + require.NoError(t, err) + require.NotNil(t, cfg) + + assert.Equal(t, "/custom/ca.crt", cfg.Central.CACertPath) +} + +func TestLoadConfig_CACertPathFromEnvVar(t *testing.T) { + t.Setenv("STACKROX_MCP__CENTRAL__URL", "central.example.com:8443") + t.Setenv("STACKROX_MCP__CENTRAL__AUTH_TYPE", string(AuthTypeStatic)) + t.Setenv("STACKROX_MCP__CENTRAL__API_TOKEN", "test-token") + t.Setenv("STACKROX_MCP__CENTRAL__CA_CERT_PATH", "/env/ca-bundle.crt") + t.Setenv("STACKROX_MCP__TOOLS__VULNERABILITY__ENABLED", "true") + + cfg, err := LoadConfig("") + require.NoError(t, err) + require.NotNil(t, cfg) + + assert.Equal(t, "/env/ca-bundle.crt", cfg.Central.CACertPath) +} + +func TestLoadConfig_CACertPathEnvVarOverridesYAML(t *testing.T) { + yamlContent := ` +central: + url: central.example.com:8443 + auth_type: static + api_token: test-token + ca_cert_path: /yaml/ca.crt +tools: + vulnerability: + enabled: true +` + configPath := testutil.WriteYAMLFile(t, yamlContent) + + defer func() { assert.NoError(t, os.Remove(configPath)) }() + + t.Setenv("STACKROX_MCP__CENTRAL__CA_CERT_PATH", "/env/ca-override.crt") + + cfg, err := LoadConfig(configPath) + require.NoError(t, err) + require.NotNil(t, cfg) + + assert.Equal(t, "/env/ca-override.crt", cfg.Central.CACertPath) +} + +func TestLoadConfig_CACertPathDefaultsToEmpty(t *testing.T) { + t.Setenv("STACKROX_MCP__TOOLS__CONFIG_MANAGER__ENABLED", "true") + + cfg, err := LoadConfig("") + require.NoError(t, err) + require.NotNil(t, cfg) + + assert.Empty(t, cfg.Central.CACertPath) +}