diff --git a/changelog/fragments/1726735182-Elastic-Defend-accepts-passphrase-protected-client-certificate-key-for-mTLS.yaml b/changelog/fragments/1726735182-Elastic-Defend-accepts-passphrase-protected-client-certificate-key-for-mTLS.yaml new file mode 100644 index 00000000000..36b7579892c --- /dev/null +++ b/changelog/fragments/1726735182-Elastic-Defend-accepts-passphrase-protected-client-certificate-key-for-mTLS.yaml @@ -0,0 +1,32 @@ +# Kind can be one of: +# - breaking-change: a change to previously-documented behavior +# - deprecation: functionality that is being removed in a later release +# - bug-fix: fixes a problem in a previous version +# - enhancement: extends functionality but does not break or fix existing behavior +# - feature: new functionality +# - known-issue: problems that we are aware of in a given version +# - security: impacts on the security of a product or a user’s deployment. +# - upgrade: important information for someone upgrading from a prior version +# - other: does not fit into any of the other categories +kind: feature + +# Change summary; a 80ish characters long description of the change. +summary: Elastic Defend accepts passphrase protected client certificate key for mTLS + +# Long description; in case the summary is not enough to describe the change +# this field accommodate a description without length limits. +# NOTE: This field will be rendered only for breaking-change and known-issue kinds at the moment. +#description: + +# Affected component; a word indicating the component this changeset affects. +#component: + +# PR URL; optional; the PR number that added the changeset. +# If not present is automatically filled by the tooling finding the PR where this changelog fragment has been added. +# NOTE: the tooling supports backports, so it's able to fill the original PR number instead of the backport PR number. +# Please provide it if you are adding a fragment for a different PR. +#pr: https://github.com/owner/repo/1234 + +# Issue URL; optional; the GitHub issue related to this changeset (either closes or is part of). +# If not present is automatically filled by the tooling with the issue linked to the PR number. +issue: https://github.com/elastic/elastic-agent/issues/5490 diff --git a/internal/pkg/agent/application/application.go b/internal/pkg/agent/application/application.go index c9d50e4dd3a..eb7fbe1ab3f 100644 --- a/internal/pkg/agent/application/application.go +++ b/internal/pkg/agent/application/application.go @@ -172,9 +172,11 @@ func New( log.Info("Parsed configuration and determined agent is managed by Fleet") composableManaged = true - compModifiers = append(compModifiers, FleetServerComponentModifier(cfg.Fleet.Server), + compModifiers = append(compModifiers, + FleetServerComponentModifier(cfg.Fleet.Server), InjectFleetConfigComponentModifier(cfg.Fleet, agentInfo), EndpointSignedComponentModifier(), + EndpointTLSComponentModifier(log), InjectProxyEndpointModifier(), ) diff --git a/internal/pkg/agent/application/endpoint_component_modifier.go b/internal/pkg/agent/application/endpoint_component_modifier.go new file mode 100644 index 00000000000..bc9b4b1db07 --- /dev/null +++ b/internal/pkg/agent/application/endpoint_component_modifier.go @@ -0,0 +1,265 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package application + +import ( + "encoding/pem" + "errors" + "fmt" + "os" + "sync" + + "github.com/elastic/elastic-agent-client/v7/pkg/client" + "github.com/elastic/elastic-agent-libs/transport/tlscommon" + "github.com/elastic/elastic-agent/internal/pkg/agent/application/coordinator" + "github.com/elastic/elastic-agent/pkg/component" + "github.com/elastic/elastic-agent/pkg/core/logger" +) + +// tlsCache is used to cache the decrypted client certificate and key. +// In environments with a high rate of config changes, such as in Kubernetes +// using autodiscover, loading files and decrypting them might have a +// non-negligible overhead. +type tlsCache struct { + mu *sync.Mutex + + CacheKey string + + Certificate string + Key string +} + +func (tlsCache) MakeKey(keyPassPath, certPath, keyPath string) string { + return keyPassPath + certPath + keyPath +} + +// EndpointSignedComponentModifier copies "signed" properties to the top level "signed" for the endpoint input. +// Enpoint team want to be able to validate the signature and parse the signed configuration (not trust the agent). +// Endpoint uses uninstall_token_hash in order to verify uninstall command token +// and signing_key in order validate the action signature. +// Example: +// +// { +// .... +// "signed": { +// "data": "eyJpZCI6ImFhZWM4OTYwLWJiYjAtMTFlZC1hYzBkLTVmNjI0YTQxZjM4OCIsImFnZW50Ijp7InByb3RlY3Rpb24iOnsiZW5hYmxlZCI6dHJ1ZSwidW5pbnN0YWxsX3Rva2VuX2hhc2giOiIiLCJzaWduaW5nX2tleSI6Ik1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEUWdBRW1tckhDSTdtZ2tuZUJlYVJkc2VkQXZBU2l0UHRLbnpPdUlzeHZJRWdGTkFLVlg3MWpRTTVmalo1eUdsSDB0TmJuR2JrU2pVM0VEVUZsOWllQ1J0ME5nPT0ifX19", +// "signature": "MEUCIQCWoScyJW0dejHFxXBTEcSCOZiBHRVMjuJRPwFCwOdA1QIgKrtKUBzkvVeljRtJyMXfD8zIvWjrMzqhSkgjNESPW5E=" +// }, +// "revision": 1, +// "type": "endpoint" +// } +func EndpointSignedComponentModifier() coordinator.ComponentsModifier { + return func(comps []component.Component, cfg map[string]interface{}) ([]component.Component, error) { + const signedKey = "signed" + + compIdx, unitIdx, ok := findEndpointUnit(comps, client.UnitTypeInput) + if !ok { + return comps, nil + } + + unit := comps[compIdx].Units[unitIdx] + unitCfgMap := unit.Config.Source.AsMap() + if signed, ok := cfg[signedKey]; ok { + unitCfgMap[signedKey] = signed + } + + unitCfg, err := component.ExpectedConfig(unitCfgMap) + if err != nil { + return nil, err + } + + unit.Config = unitCfg + comps[compIdx].Units[unitIdx] = unit + + return comps, nil + } +} + +// EndpointTLSComponentModifier decrypts the client TLS certificate key if it's +// passphrase-protected. It replaces the content of 'fleet.ssl.key' +// and 'certificate' with theirs decrypted version and removes +// 'key_passphrase_path'. +// It does so, ONLY for the client TLS configuration for mTLS used with +// fleet-server. +func EndpointTLSComponentModifier(log *logger.Logger) coordinator.ComponentsModifier { + return newEndpointTLSComponentModifier(log, &tlsCache{mu: &sync.Mutex{}}) +} + +func newEndpointTLSComponentModifier(log *logger.Logger, cache *tlsCache) func(comps []component.Component, cfg map[string]interface{}) ([]component.Component, error) { + return func(comps []component.Component, cfg map[string]interface{}) ([]component.Component, error) { + compIdx, unitIdx, ok := findEndpointUnit(comps, client.UnitTypeInput) + if !ok { + // endpoint not present, nothing to do + return comps, nil + } + + unit := comps[compIdx].Units[unitIdx] + unitCfgMap := unit.Config.Source.AsMap() + + // ensure the following config exists: + // fleet.ssl: + // key_passphrase_path + // certificate + // key + fleetNode, ok := unitCfgMap["fleet"] + if !ok { + // if 'fleet' isn't, present nothing to do + return comps, nil + } + fleetMap, ok := fleetNode.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("EndpointTLSComponentModifier: 'fleet' node isn't a map, it is: %T", fleetNode) + } + + sslNode, ok := fleetMap["ssl"] + if !ok { + // 'ssl' node not present isn't an issue + return comps, nil + } + sslMap, ok := sslNode.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("EndpointTLSComponentModifier: 'ssl' node isn't a map, it is: %T", sslNode) + } + + keyPassPathI, ok := sslMap["key_passphrase_path"] + if !ok { + // if no key_passphrase_path, nothing to decrypt + return comps, nil + } + keyPassPath, ok := keyPassPathI.(string) + if !ok { + return nil, errors.New("EndpointTLSComponentModifier: 'key_passphrase_path' isn't a string") + } + if keyPassPath == "" { + // key_passphrase_path shouldn't be empty, but if it's, nothing to decrypt + return comps, nil + } + + keyI, ok := sslMap["key"] + if !ok { + // if there is a key_passphrase_path, the key must be present + return nil, errors.New("EndpointTLSComponentModifier: 'key_passphrase_path' present, but 'key' isn't present") + } + keyPath, ok := keyI.(string) + if !ok { + return nil, fmt.Errorf("EndpointTLSComponentModifier: 'key' isn't a string, it's %T", keyI) + } + + certI, ok := sslMap["certificate"] + if !ok { + // if there is a key_passphrase_path, the certificate must be present + return nil, errors.New("EndpointTLSComponentModifier: 'key_passphrase_path' present, but 'certificate' isn't present") + } + certPath, ok := certI.(string) + if !ok { + return nil, errors.New("EndpointTLSComponentModifier: 'certificate' isn't a string") + } + + // all TLS config exists and the certificate key is passphrase protected, + // now load and decrypt the key. + cert, key, err := loadCertificatesWithCache(log, cache, keyPassPath, certPath, keyPath) + if err != nil { + return nil, err + } + + // remove 'key_passphrase_path' as the certificate key isn't encrypted + // anymore. + delete(sslMap, "key_passphrase_path") + + // update the certificate and its key with their decrypted version. + sslMap["certificate"] = cert + sslMap["key"] = key + + unitCfg, err := component.ExpectedConfig(unitCfgMap) + if err != nil { + return nil, fmt.Errorf("EndpointTLSComponentModifier: could not covert modified config to expected config: %w", err) + } + + unit.Config = unitCfg + comps[compIdx].Units[unitIdx] = unit + + return comps, nil + } +} + +func loadCertificatesWithCache(log *logger.Logger, cache *tlsCache, keyPassPath string, certPath string, keyPath string) (string, string, error) { + cache.mu.Lock() + defer cache.mu.Unlock() + + cacheKey := cache.MakeKey(keyPassPath, certPath, keyPath) + + // cache hit + if cache.CacheKey == cacheKey { + return cache.Certificate, cache.Key, nil + } + + cert, key, err := loadCertificates(log, keyPassPath, certPath, keyPath) + if err != nil { + return "", "", err + } + + cache.CacheKey = cacheKey + cache.Certificate = cert + cache.Key = key + + return cert, key, nil +} + +func loadCertificates(log *logger.Logger, keyPassPathStr string, certStr string, keyStr string) (string, string, error) { + pass, err := os.ReadFile(keyPassPathStr) + if err != nil { + return "", "", fmt.Errorf("EndpointTLSComponentModifier: failed to read client certificate passphrase file: %w", err) + } + + // we don't really support encrypted certificates, but it's how + // tlscommon.LoadCertificate does. Thus, let's keep the same behaviour. + // Also, tlscommon.LoadCertificate 'loses' the type of the private key. + // It stores they private key as an interface and there is no way to + // retrieve the type os the private key without a type assertion. + // Therefore, instead of manually checking the type, which would mean + // to check for all supported private key types and keep it up to date, + // better to load the certificate and its key directly from the PEM file. + cert, err := tlscommon.ReadPEMFile(log, + certStr, string(pass)) + if err != nil { + return "", "", fmt.Errorf("EndpointTLSComponentModifier: unable to load TLS certificate: %w", err) + } + key, err := tlscommon.ReadPEMFile(log, + keyStr, + string(pass)) + if err != nil { + return "", "", fmt.Errorf("EndpointTLSComponentModifier: unable to load TLS certificate key: %w", err) + } + + // tlscommon.ReadPEMFile only removes the 'DEK-Info' header, not the + // 'Proc-Type', so remove it now. Create a pem.Block to avoid editing + // the PEM data manually: + keyBlock, _ := pem.Decode(key) + delete(keyBlock.Headers, "Proc-Type") + key = pem.EncodeToMemory(keyBlock) + + return string(cert), string(key), nil +} + +// findEndpointUnit finds the endpoint component and its unit of type 'unitType'. +// It returns the component and unit index and true if found, if not, it returns +// 0, 0, false. +func findEndpointUnit(comps []component.Component, unitType client.UnitType) (int, int, bool) { + // find the endpoint component + for compIdx, comp := range comps { + if comp.InputSpec != nil && comp.InputSpec.InputType != endpoint { + continue + } + + for unitIdx, unit := range comp.Units { + if unit.Type != unitType { + continue + } + + return compIdx, unitIdx, true + } + } + return 0, 0, false +} diff --git a/internal/pkg/agent/application/endpoint_component_modifier_test.go b/internal/pkg/agent/application/endpoint_component_modifier_test.go new file mode 100644 index 00000000000..424b2709f52 --- /dev/null +++ b/internal/pkg/agent/application/endpoint_component_modifier_test.go @@ -0,0 +1,612 @@ +// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +// or more contributor license agreements. Licensed under the Elastic License 2.0; +// you may not use this file except in compliance with the Elastic License 2.0. + +package application + +import ( + "crypto/rand" + "crypto/x509" + "encoding/pem" + "fmt" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/elastic/elastic-agent-client/v7/pkg/client" + "github.com/elastic/elastic-agent-client/v7/pkg/proto" + "github.com/elastic/elastic-agent-libs/testing/certutil" + "github.com/elastic/elastic-agent/internal/pkg/agent/application/coordinator" + "github.com/elastic/elastic-agent/pkg/core/logger/loggertest" + + "github.com/elastic/elastic-agent/pkg/component" + + "google.golang.org/protobuf/types/known/structpb" +) + +func TestEndpointComponentModifier(t *testing.T) { + log, obs := loggertest.New("TestEndpointSignedComponentModifier") + defer func() { + if !t.Failed() { + return + } + + loggertest.PrintObservedLogs(obs.TakeAll(), t.Log) + }() + + pair, certPath, certKeyPath, certKeyPassPath := prepareEncTLSCertificates(t) + + tests := map[string][]struct { + name string + compModifier coordinator.ComponentsModifier + comps []component.Component + cfg map[string]interface{} + wantComps []component.Component + wantErr func(*testing.T, error) + }{ + "EndpointSignedComponentModifier": { + { + name: "nil", + compModifier: EndpointSignedComponentModifier(), + }, + { + name: "non endpoint", + compModifier: EndpointSignedComponentModifier(), + comps: []component.Component{ + { + ID: "asdfasd", + InputSpec: &component.InputRuntimeSpec{ + InputType: "osquery", + }, + Units: []component.Unit{ + { + ID: "34534", + Type: client.UnitTypeInput, + }, + }, + }, + }, + wantComps: []component.Component{ + { + ID: "asdfasd", + InputSpec: &component.InputRuntimeSpec{ + InputType: "osquery", + }, + Units: []component.Unit{ + { + ID: "34534", + Type: client.UnitTypeInput, + }, + }, + }, + }, + }, + { + name: "endpoint", + compModifier: EndpointSignedComponentModifier(), + comps: []component.Component{ + { + ID: "asdfasd", + InputSpec: &component.InputRuntimeSpec{ + InputType: "endpoint", + }, + Units: []component.Unit{ + { + ID: "34534", + Type: client.UnitTypeInput, + Config: &proto.UnitExpectedConfig{ + Type: "endpoint", + Source: &structpb.Struct{}, + }, + }, + }, + }, + }, + cfg: map[string]interface{}{ + "signed": map[string]interface{}{ + "data": "eyJpZCI6ImFhZWM4OTYwLWJiYjAtMTFlZC1hYzBkLTVmNjI0YTQxZjM4OCIsImFnZW50Ijp7InByb3RlY3Rpb24iOnsiZW5hYmxlZCI6dHJ1ZSwidW5pbnN0YWxsX3Rva2VuX2hhc2giOiIiLCJzaWduaW5nX2tleSI6Ik1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEUWdBRW1tckhDSTdtZ2tuZUJlYVJkc2VkQXZBU2l0UHRLbnpPdUlzeHZJRWdGTkFLVlg3MWpRTTVmalo1eUdsSDB0TmJuR2JrU2pVM0VEVUZsOWllQ1J0ME5nPT0ifX19", + "signature": "MEUCIQCWoScyJW0dejHFxXBTEcSCOZiBHRVMjuJRPwFCwOdA1QIgKrtKUBzkvVeljRtJyMXfD8zIvWjrMzqhSkgjNESPW5E=", + }, + }, + wantComps: []component.Component{ + { + ID: "asdfasd", + InputSpec: &component.InputRuntimeSpec{ + InputType: "endpoint", + }, + Units: []component.Unit{ + { + ID: "34534", + Type: client.UnitTypeInput, + Config: &proto.UnitExpectedConfig{ + Source: func() *structpb.Struct { + var source structpb.Struct + err := source.UnmarshalJSON([]byte(`{"signed":{"data":"eyJpZCI6ImFhZWM4OTYwLWJiYjAtMTFlZC1hYzBkLTVmNjI0YTQxZjM4OCIsImFnZW50Ijp7InByb3RlY3Rpb24iOnsiZW5hYmxlZCI6dHJ1ZSwidW5pbnN0YWxsX3Rva2VuX2hhc2giOiIiLCJzaWduaW5nX2tleSI6Ik1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEUWdBRW1tckhDSTdtZ2tuZUJlYVJkc2VkQXZBU2l0UHRLbnpPdUlzeHZJRWdGTkFLVlg3MWpRTTVmalo1eUdsSDB0TmJuR2JrU2pVM0VEVUZsOWllQ1J0ME5nPT0ifX19", "signature":"MEUCIQCWoScyJW0dejHFxXBTEcSCOZiBHRVMjuJRPwFCwOdA1QIgKrtKUBzkvVeljRtJyMXfD8zIvWjrMzqhSkgjNESPW5E="}}`)) + require.NoError(t, err, "could not create want component source config") + return &source + }(), + }, + }, + }, + }, + }, + }, + }, + "EndpointTLSComponentModifier": { + { + name: "nil", + compModifier: EndpointSignedComponentModifier(), + }, + { + name: "non endpoint", + compModifier: EndpointSignedComponentModifier(), + comps: makeComponent(t, "{}"), + wantComps: makeComponent(t, "{}"), + }, + + { + name: "endpoint-no-fleet", + compModifier: EndpointTLSComponentModifier(log), + comps: makeComponent(t, `{}`), + cfg: map[string]interface{}{ + "fleet": map[string]interface{}{}, + }, + wantComps: makeComponent(t, `{}`), + }, + { + name: "endpoint-no-fleet-wrong-type", + compModifier: EndpointTLSComponentModifier(log), + comps: makeComponent(t, `{"fleet": 42}`), + cfg: map[string]interface{}{ + "fleet": 1, + }, + wantComps: nil, + wantErr: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "'fleet' node isn't a map") + }, + }, + { + name: "endpoint-no-fleet.ssl", + compModifier: EndpointTLSComponentModifier(log), + comps: makeComponent(t, `{"fleet": {}}`), + cfg: map[string]interface{}{ + "fleet": map[string]interface{}{}, + }, + wantComps: makeComponent(t, `{"fleet": {}}`), + }, + { + name: "endpoint-wrong-fleet.ssl", + compModifier: EndpointTLSComponentModifier(log), + comps: makeComponent(t, `{"fleet": {"ssl": 42}}`), + cfg: map[string]interface{}{ + "fleet": map[string]interface{}{}, + }, + wantComps: nil, + wantErr: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "'ssl' node isn't a map") + }, + }, + { + name: "endpoint-wrong-fleet.ssl.key_passphrase_path", + compModifier: EndpointTLSComponentModifier(log), + comps: makeComponent(t, ` + {"fleet": {"ssl": + {"key_passphrase_path": 42}}}`), + cfg: map[string]interface{}{ + "fleet": map[string]interface{}{}, + }, + wantComps: nil, + wantErr: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "'key_passphrase_path' isn't a string") + }, + }, + { + name: "endpoint-wrong-fleet.ssl.key", + compModifier: EndpointTLSComponentModifier(log), + comps: makeComponent(t, ` +{"fleet": {"ssl": { + "key_passphrase_path": "/path/to/passphrase", + "key": 42}}}`), + cfg: map[string]interface{}{ + "fleet": map[string]interface{}{}, + }, + wantComps: nil, + wantErr: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "'key' isn't a string") + }, + }, + { + name: "endpoint-wrong-fleet.ssl.certificate", + compModifier: EndpointTLSComponentModifier(log), + comps: makeComponent(t, ` + {"fleet": {"ssl": { + "key_passphrase_path": "/path/to/passphrase", + "key": "", + "certificate": 42}}}`), + cfg: map[string]interface{}{ + "fleet": map[string]interface{}{}, + }, + wantComps: nil, + wantErr: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "'certificate' isn't a string") + }, + }, + + { + name: "endpoint-mTLS-passphrase", + compModifier: EndpointTLSComponentModifier(log), + comps: makeComponent(t, fmt.Sprintf(`{ + "fleet": { + "ssl": { + "certificate": %q, + "key": %q, + "key_passphrase_path": %q + } + } + }`, certPath, certKeyPath, certKeyPassPath)), + cfg: map[string]interface{}{ + "fleet": map[string]interface{}{ + "ssl": map[string]interface{}{ + "certificate": certPath, + "key": certKeyPath, + "key_passphrase_path": certKeyPassPath, + }, + }, + }, + wantComps: makeComponent(t, fmt.Sprintf(`{ + "fleet": { + "ssl": { + "certificate": %q, + "key": %q + } + } + }`, pair.Cert, pair.Key)), + }, + { + name: "endpoint-mTLS-passphrase-no-key", + compModifier: EndpointTLSComponentModifier(log), + comps: makeComponent(t, fmt.Sprintf(`{ + "fleet": { + "ssl": { + "certificate": %q, + "key_passphrase_path": %q + } + } + }`, certPath, certKeyPassPath)), + cfg: map[string]interface{}{ + "fleet": map[string]interface{}{ + "ssl": map[string]interface{}{ + "certificate": certPath, + "key_passphrase_path": certKeyPassPath, + }, + }, + }, + wantComps: nil, + wantErr: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "'key' isn't present") + }, + }, + { + name: "endpoint-mTLS-passphrase-no-certificate", + compModifier: EndpointTLSComponentModifier(log), + comps: makeComponent(t, `{ + "fleet": { + "ssl": { + "key": "/path/to/key", + "key_passphrase_path": "/path/to/key_passphrase_path" + } + } + }`), + cfg: map[string]interface{}{ + "fleet": map[string]interface{}{ + "ssl": map[string]interface{}{ + "key": "/path/to/cert", + "key_passphrase_path": "/path/to/key_passphrase_path", + }, + }, + }, + wantComps: nil, + wantErr: func(t *testing.T, err error) { + assert.ErrorContains(t, err, "'certificate' isn't present") + }, + }, + { + name: "endpoint-mTLS-no-passphrase", + compModifier: EndpointTLSComponentModifier(log), + comps: makeComponent(t, `{ + "fleet": { + "ssl": { + "certificate": "/path/to/cert", + "key": "/path/to/key" + } + } + }`), + cfg: map[string]interface{}{ + "fleet": map[string]interface{}{ + "ssl": map[string]interface{}{ + "certificate": "/path/to/cert", + "key": "/path/to/key", + }, + }, + }, + wantComps: makeComponent(t, `{ + "fleet": { + "ssl": { + "certificate": "/path/to/cert", + "key": "/path/to/key" + } + } + }`), + }, + { + name: "endpoint-mTLS-empty-passphrase", + compModifier: EndpointTLSComponentModifier(log), + comps: makeComponent(t, `{ + "fleet": { + "ssl": { + "key_passphrase_path": "", + "certificate": "/path/to/cert", + "key": "/path/to/key" + } + } + }`), + cfg: map[string]interface{}{ + "fleet": map[string]interface{}{ + "ssl": map[string]interface{}{ + "key_passphrase_path": "", + "certificate": "/path/to/cert", + "key": "/path/to/key", + }, + }, + }, + wantComps: makeComponent(t, `{ + "fleet": { + "ssl": { + "key_passphrase_path": "", + "certificate": "/path/to/cert", + "key": "/path/to/key" + } + } + }`), + }, + { + name: "endpoint-TLS", + compModifier: EndpointTLSComponentModifier(log), + comps: makeComponent(t, `{ + "fleet": { + "ssl": { + "certificate_authorities": ["/path/to/ca1", "/path/to/ca2"] + } + } + }`), + cfg: map[string]interface{}{ + "fleet": map[string]interface{}{ + "ssl": map[string]interface{}{ + "certificate_authorities": []string{"/path/to/ca1", "/path/to/ca2"}, + }, + }, + }, + wantComps: makeComponent(t, `{ + "fleet": { + "ssl": { + "certificate_authorities": ["/path/to/ca1", "/path/to/ca2"] + } + } + }`), + }, + }, + } + + for name, tcs := range tests { + t.Run(name, func(t *testing.T) { + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + comps, err := tc.compModifier(tc.comps, tc.cfg) + + if tc.wantErr != nil { + tc.wantErr(t, err) + } else { + assert.NoError(t, err) + } + + // Cumbersome comparison of the source config encoded in protobuf, + // cmp panics protobufs comparison otherwise. + compareComponents(t, comps, tc.wantComps) + }) + } + }) + } +} + +func compareComponents(t *testing.T, got, want []component.Component) { + if len(want) > 0 && + len(want[0].Units) > 0 && + got[0].Units[0].Config != nil && + got[0].Units[0].Config.Source != nil { + + unitCgf := got[0].Units[0].Config.Source.AsMap() + wantUnitCfg := want[0].Units[0].Config.Source.AsMap() + + assert.Equal(t, wantUnitCfg, unitCgf, "unit config do not match") + } +} + +func TestEndpointTLSComponentModifier_cache_miss(t *testing.T) { + log, obs := loggertest.New("TestEndpointSignedComponentModifier") + defer func() { + if !t.Failed() { + return + } + + loggertest.PrintObservedLogs(obs.TakeAll(), t.Log) + }() + + cache := tlsCache{ + mu: &sync.Mutex{}, + + CacheKey: "/old-cache-key", + Certificate: "cached certificate", + Key: "cached key", + } + pair, certPath, certKeyPath, certKeyPassPath := prepareEncTLSCertificates(t) + cackeKey := cache.MakeKey(certKeyPassPath, certPath, certKeyPath) + + comps := makeComponent(t, fmt.Sprintf(`{ + "fleet": { + "ssl": { + "certificate": %q, + "key": %q, + "key_passphrase_path": %q + } + } + }`, certPath, certKeyPath, certKeyPassPath)) + cfg := map[string]interface{}{ + "fleet": map[string]interface{}{ + "ssl": map[string]interface{}{ + "certificate": certPath, + "key": certKeyPath, + "key_passphrase_path": certKeyPassPath, + }, + }, + } + wantComps := makeComponent(t, fmt.Sprintf(`{ + "fleet": { + "ssl": { + "certificate": %q, + "key": %q + } + } + }`, pair.Cert, pair.Key)) + + modifier := newEndpointTLSComponentModifier(log, &cache) + got, err := modifier(comps, cfg) + require.NoError(t, err, "unexpected error") + + assert.Equal(t, cackeKey, cache.CacheKey, "passphrase path did not match") + assert.Equal(t, string(pair.Cert), cache.Certificate, "certificate did not match") + assert.Equal(t, string(pair.Key), cache.Key, "key did not match") + + compareComponents(t, got, wantComps) +} + +func TestEndpointTLSComponentModifier_cache_hit(t *testing.T) { + log, obs := loggertest.New("TestEndpointSignedComponentModifier") + defer func() { + if !t.Failed() { + return + } + + loggertest.PrintObservedLogs(obs.TakeAll(), t.Log) + }() + + certPath := "/path/to/cert" + certKeyPath := "/path/to/key" + certKeyPassPath := "/path/to/key_passphrase_path" //nolint:gosec // not a real key + + cache := tlsCache{ + mu: &sync.Mutex{}, + + Certificate: "cached certificate", + Key: "cached key", + } + cacheKey := cache.MakeKey(certKeyPassPath, certPath, certKeyPath) + cache.CacheKey = cacheKey + + comps := makeComponent(t, fmt.Sprintf(`{ + "fleet": { + "ssl": { + "certificate": %q, + "key": %q, + "key_passphrase_path": %q + } + } + }`, certPath, certKeyPath, certKeyPassPath)) + cfg := map[string]interface{}{ + "fleet": map[string]interface{}{ + "ssl": map[string]interface{}{ + "certificate": cache.Certificate, + "key": cache.Key, + "key_passphrase_path": cache.CacheKey, + }, + }, + } + + wantComps := makeComponent(t, fmt.Sprintf(`{ + "fleet": { + "ssl": { + "certificate": %q, + "key": %q + } + } + }`, cache.Certificate, cache.Key)) + + modifier := newEndpointTLSComponentModifier(log, &cache) + got, err := modifier(comps, cfg) + require.NoError(t, err, "unexpected error") + + assert.Equal(t, cacheKey, cache.CacheKey, "passphrase should not have changed") + compareComponents(t, got, wantComps) +} + +func prepareEncTLSCertificates(t *testing.T) (certutil.Pair, string, string, string) { + passphrase := "secure_passphrase" + _, _, pair, err := certutil.NewRootCA() + require.NoError(t, err, "could not create TLS certificate") + agentChildDERKey, _ := pem.Decode(pair.Key) + require.NoError(t, err, "could not create tls.Certificates from child certificate") + + encPem, err := x509.EncryptPEMBlock( //nolint:staticcheck // we need to drop support for this, but while we don't, it needs to be tested. + rand.Reader, + "EC PRIVATE KEY", + agentChildDERKey.Bytes, + []byte(passphrase), + x509.PEMCipherAES128) + require.NoError(t, err, "failed encrypting agent child certificate key block") + + certKeyEnc := pem.EncodeToMemory(encPem) + + // save to disk + tmpDir := t.TempDir() + certPath := filepath.Join(tmpDir, "cert.pem") + certKeyPath := filepath.Join(tmpDir, "key.pem") + certKeyPassPath := filepath.Join(tmpDir, "key_pass.pem") + + err = os.WriteFile(certPath, pair.Cert, 0400) + require.NoError(t, err, "could write certificate key") + err = os.WriteFile(certKeyPath, certKeyEnc, 0400) + require.NoError(t, err, "could write certificate key") + err = os.WriteFile(certKeyPassPath, []byte(passphrase), 0400) + require.NoError(t, err, "could write certificate key passphrase") + + return pair, certPath, certKeyPath, certKeyPassPath +} + +func makeComponent(t *testing.T, sourceCfg string) []component.Component { + return []component.Component{ + { + ID: "ClientCertKey", + InputSpec: &component.InputRuntimeSpec{ + InputType: "endpoint", + }, + Units: []component.Unit{ + { + ID: "34534", + Type: client.UnitTypeInput, + Config: &proto.UnitExpectedConfig{ + Type: "endpoint", + Source: func() *structpb.Struct { + var source structpb.Struct + err := source.UnmarshalJSON([]byte(sourceCfg)) + require.NoError(t, err, "could not create component source config") + return &source + }(), + }, + }, + }, + }, + } +} diff --git a/internal/pkg/agent/application/endpoint_signed_component_modifier.go b/internal/pkg/agent/application/endpoint_signed_component_modifier.go deleted file mode 100644 index 39aac35604a..00000000000 --- a/internal/pkg/agent/application/endpoint_signed_component_modifier.go +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one -// or more contributor license agreements. Licensed under the Elastic License 2.0; -// you may not use this file except in compliance with the Elastic License 2.0. - -package application - -import ( - "github.com/elastic/elastic-agent-client/v7/pkg/client" - "github.com/elastic/elastic-agent/internal/pkg/agent/application/coordinator" - "github.com/elastic/elastic-agent/pkg/component" -) - -// EndpointSignedComponentModifier copies "signed" properties to the top level "signed" for the endpoint input. -// Enpoint team want to be able to validate the signature and parse the signed configuration (not trust the agent). -// Endpoint uses uninstall_token_hash in order to verify uninstall command token -// and signing_key in order validate the action signature. -// Example: -// -// { -// .... -// "signed": { -// "data": "eyJpZCI6ImFhZWM4OTYwLWJiYjAtMTFlZC1hYzBkLTVmNjI0YTQxZjM4OCIsImFnZW50Ijp7InByb3RlY3Rpb24iOnsiZW5hYmxlZCI6dHJ1ZSwidW5pbnN0YWxsX3Rva2VuX2hhc2giOiIiLCJzaWduaW5nX2tleSI6Ik1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEUWdBRW1tckhDSTdtZ2tuZUJlYVJkc2VkQXZBU2l0UHRLbnpPdUlzeHZJRWdGTkFLVlg3MWpRTTVmalo1eUdsSDB0TmJuR2JrU2pVM0VEVUZsOWllQ1J0ME5nPT0ifX19", -// "signature": "MEUCIQCWoScyJW0dejHFxXBTEcSCOZiBHRVMjuJRPwFCwOdA1QIgKrtKUBzkvVeljRtJyMXfD8zIvWjrMzqhSkgjNESPW5E=" -// }, -// "revision": 1, -// "type": "endpoint" -// } -func EndpointSignedComponentModifier() coordinator.ComponentsModifier { - return func(comps []component.Component, cfg map[string]interface{}) ([]component.Component, error) { - const signedKey = "signed" - for i, comp := range comps { - if comp.InputSpec != nil && (comp.InputSpec.InputType == endpoint) { - for j, unit := range comp.Units { - if unit.Type == client.UnitTypeInput && (unit.Config.Type == endpoint) { - unitCfgMap := unit.Config.Source.AsMap() - if signed, ok := cfg[signedKey]; ok { - unitCfgMap[signedKey] = signed - } - - unitCfg, err := component.ExpectedConfig(unitCfgMap) - if err != nil { - return nil, err - } - - unit.Config = unitCfg - comp.Units[j] = unit - } - } - } - comps[i] = comp - } - return comps, nil - } -} diff --git a/internal/pkg/agent/application/endpoint_signed_component_modifier_test.go b/internal/pkg/agent/application/endpoint_signed_component_modifier_test.go deleted file mode 100644 index fcc16321d5c..00000000000 --- a/internal/pkg/agent/application/endpoint_signed_component_modifier_test.go +++ /dev/null @@ -1,137 +0,0 @@ -// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one -// or more contributor license agreements. Licensed under the Elastic License 2.0; -// you may not use this file except in compliance with the Elastic License 2.0. - -package application - -import ( - "testing" - - "github.com/elastic/elastic-agent-client/v7/pkg/client" - "github.com/elastic/elastic-agent-client/v7/pkg/proto" - - "github.com/elastic/elastic-agent/pkg/component" - - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "google.golang.org/protobuf/types/known/structpb" -) - -func TestEndpointSignedComponentModifier(t *testing.T) { - compModifier := EndpointSignedComponentModifier() - - tests := []struct { - name string - comps []component.Component - cfg map[string]interface{} - wantComps []component.Component - wantErr error - }{ - { - name: "nil", - }, - { - name: "non endpoint", - comps: []component.Component{ - { - ID: "asdfasd", - InputSpec: &component.InputRuntimeSpec{ - InputType: "osquery", - }, - Units: []component.Unit{ - { - ID: "34534", - Type: client.UnitTypeInput, - }, - }, - }, - }, - wantComps: []component.Component{ - { - ID: "asdfasd", - InputSpec: &component.InputRuntimeSpec{ - InputType: "osquery", - }, - Units: []component.Unit{ - { - ID: "34534", - Type: client.UnitTypeInput, - }, - }, - }, - }, - }, - { - name: "endpoint", - comps: []component.Component{ - { - ID: "asdfasd", - InputSpec: &component.InputRuntimeSpec{ - InputType: "endpoint", - }, - Units: []component.Unit{ - { - ID: "34534", - Type: client.UnitTypeInput, - Config: &proto.UnitExpectedConfig{ - Type: "endpoint", - Source: &structpb.Struct{}, - }, - }, - }, - }, - }, - cfg: map[string]interface{}{ - "signed": map[string]interface{}{ - "data": "eyJpZCI6ImFhZWM4OTYwLWJiYjAtMTFlZC1hYzBkLTVmNjI0YTQxZjM4OCIsImFnZW50Ijp7InByb3RlY3Rpb24iOnsiZW5hYmxlZCI6dHJ1ZSwidW5pbnN0YWxsX3Rva2VuX2hhc2giOiIiLCJzaWduaW5nX2tleSI6Ik1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEUWdBRW1tckhDSTdtZ2tuZUJlYVJkc2VkQXZBU2l0UHRLbnpPdUlzeHZJRWdGTkFLVlg3MWpRTTVmalo1eUdsSDB0TmJuR2JrU2pVM0VEVUZsOWllQ1J0ME5nPT0ifX19", - "signature": "MEUCIQCWoScyJW0dejHFxXBTEcSCOZiBHRVMjuJRPwFCwOdA1QIgKrtKUBzkvVeljRtJyMXfD8zIvWjrMzqhSkgjNESPW5E=", - }, - }, - wantComps: []component.Component{ - { - ID: "asdfasd", - InputSpec: &component.InputRuntimeSpec{ - InputType: "endpoint", - }, - Units: []component.Unit{ - { - ID: "34534", - Type: client.UnitTypeInput, - Config: &proto.UnitExpectedConfig{ - Source: func() *structpb.Struct { - var source structpb.Struct - err := source.UnmarshalJSON([]byte(`{"signed":{"data":"eyJpZCI6ImFhZWM4OTYwLWJiYjAtMTFlZC1hYzBkLTVmNjI0YTQxZjM4OCIsImFnZW50Ijp7InByb3RlY3Rpb24iOnsiZW5hYmxlZCI6dHJ1ZSwidW5pbnN0YWxsX3Rva2VuX2hhc2giOiIiLCJzaWduaW5nX2tleSI6Ik1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEUWdBRW1tckhDSTdtZ2tuZUJlYVJkc2VkQXZBU2l0UHRLbnpPdUlzeHZJRWdGTkFLVlg3MWpRTTVmalo1eUdsSDB0TmJuR2JrU2pVM0VEVUZsOWllQ1J0ME5nPT0ifX19", "signature":"MEUCIQCWoScyJW0dejHFxXBTEcSCOZiBHRVMjuJRPwFCwOdA1QIgKrtKUBzkvVeljRtJyMXfD8zIvWjrMzqhSkgjNESPW5E="}}`)) - if err != nil { - t.Fatal(err) - } - return &source - }(), - }, - }, - }, - }, - }, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - comps, err := compModifier(tc.comps, tc.cfg) - - diff := cmp.Diff(tc.wantErr, err, cmpopts.EquateErrors()) - if diff != "" { - t.Fatal(diff) - } - - // Cumbersome comparison of the source config encoded in protobuf, cmp panics protobufs comparison otherwise - if len(tc.wantComps) > 0 && len(tc.wantComps[0].Units) > 0 && comps[0].Units[0].Config != nil && comps[0].Units[0].Config.Source != nil { - m := comps[0].Units[0].Config.Source.AsMap() - wantM := tc.wantComps[0].Units[0].Config.Source.AsMap() - - diff = cmp.Diff(wantM, m) - if diff != "" { - t.Fatal(diff) - } - } - }) - } -} diff --git a/testing/integration/metrics_monitoring_test.go b/testing/integration/metrics_monitoring_test.go index 08eda658ce6..b4d885a19cb 100644 --- a/testing/integration/metrics_monitoring_test.go +++ b/testing/integration/metrics_monitoring_test.go @@ -8,6 +8,7 @@ package integration import ( "context" + "encoding/json" "fmt" "testing" "time" @@ -80,11 +81,14 @@ func (runner *MetricsRunner) SetupSuite() { } func (runner *MetricsRunner) TestBeatsMetrics() { + t := runner.T() + UnitOutputName := "default" ctx, cancel := context.WithTimeout(context.Background(), time.Minute*20) defer cancel() + agentStatus, err := runner.agentFixture.ExecStatus(ctx) - require.NoError(runner.T(), err) + require.NoError(t, err, "could not to get agent status") componentIds := []string{ fmt.Sprintf("system/metrics-%s", UnitOutputName), @@ -95,19 +99,36 @@ func (runner *MetricsRunner) TestBeatsMetrics() { "filestream-monitoring", } - require.Eventually(runner.T(), func() bool { + now := time.Now() + var query map[string]any + defer func() { + if t.Failed() { + bs, err := json.Marshal(query) + if err != nil { + // nothing we can do, just log the map + t.Errorf("executed at %s: %v", + now.Format(time.RFC3339Nano), query) + return + } + t.Errorf("executed at %s: query: %s", + now.Format(time.RFC3339Nano), string(bs)) + } + }() + + t.Logf("starting to ES for metrics at %s", now.Format(time.RFC3339Nano)) + require.Eventually(t, func() bool { for _, cid := range componentIds { - query := genESQuery(agentStatus.Info.ID, cid) + query = genESQuery(agentStatus.Info.ID, cid) + now = time.Now() res, err := estools.PerformQueryForRawQuery(ctx, query, "metrics-elastic_agent*", runner.info.ESClient) - require.NoError(runner.T(), err) - runner.T().Logf("Fetched metrics for %s, got %d hits", cid, res.Hits.Total.Value) + require.NoError(t, err) + t.Logf("Fetched metrics for %s, got %d hits", cid, res.Hits.Total.Value) if res.Hits.Total.Value < 1 { return false } - } return true - }, time.Minute*10, time.Second*10, "could not fetch metrics for all known beats in default install: %v", componentIds) + }, time.Minute*10, time.Second*10, "could not fetch metrics for all known components in default install: %v", componentIds) } func genESQuery(agentID string, componentID string) map[string]interface{} {