Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
13 changes: 10 additions & 3 deletions internal/pkg/api/handleCheckin.go
Original file line number Diff line number Diff line change
Expand Up @@ -903,13 +903,20 @@ func processPolicy(ctx context.Context, zlog zerolog.Logger, bulker bulk.Bulk, a
}

data := model.ClonePolicyData(pp.Policy.Data)
for _, policyOutput := range data.Outputs {
for name, policyOutput := range data.Outputs {
// NOTE: Not sure if output secret keys collected here include new entries, but they are collected for completeness
outputType, _ := policyOutput["type"].(string)
ks, err := secret.ProcessOutputSecret(policyOutput, secretValues)
if err != nil {
return nil, fmt.Errorf("failed to process output secret for output %q: %w", policyOutput["name"], err)
return nil, fmt.Errorf("failed to process output secret for output %q: %w", name, err)
}
for _, key := range ks {
// Remote ES service_token is stripped in Prepare; omit from secret_paths
if outputType == policy.OutputTypeRemoteElasticsearch && key == policy.FieldOutputServiceToken {
continue
}
pp.SecretKeys = append(pp.SecretKeys, "outputs."+name+"."+key)
}
pp.SecretKeys = append(pp.SecretKeys, ks...)
}
// Iterate through the policy outputs and prepare them
for _, policyOutput := range pp.Outputs {
Expand Down
6 changes: 6 additions & 0 deletions internal/pkg/policy/parsed_policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,17 @@ func NewParsedPolicy(ctx context.Context, bulker bulk.Bulk, p model.Policy) (*Pa
return nil, err
}
for name, policyOutput := range p.Data.Outputs {
outputType, _ := policyOutput[FieldOutputType].(string)
ks, err := secret.ProcessOutputSecret(policyOutput, secretValues)
if err != nil {
return nil, fmt.Errorf("failed to replace secrets in output section of policy '%s': %w", name, err)
}
for _, key := range ks {
// Do not advertise remote ES service_token in secret_paths; Prepare(...) deletes it
// before the policy is sent to agents
if outputType == OutputTypeRemoteElasticsearch && key == FieldOutputServiceToken {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be conditional on the service_token actually being removed shouldn't it? Otherwise we leak the service_token.

Is there a way to do this such that if Prepare changes to retain the token this behaves properly?

@khushijain21 khushijain21 Jul 31, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the conditional. PTAL when you can

continue
}
secretKeys = append(secretKeys, "outputs."+name+"."+key)
}
}
Expand Down
29 changes: 29 additions & 0 deletions internal/pkg/policy/parsed_policy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ var logstashOutputPolicy string
//go:embed testdata/remote_es_policy.json
var testPolicyRemoteES string

//go:embed testdata/remote_es_policy_with_secrets.json
var testPolicyRemoteESWithSecrets string

//go:embed testdata/policy_with_secrets_mixed.json
var policyWithSecretsMixed string

Expand Down Expand Up @@ -111,6 +114,32 @@ func TestNewParsedPolicyRemoteES(t *testing.T) {
require.Equal(t, "remote", pp.Default.Name)
}

// TestNewParsedPolicyRemoteESServiceTokenSecret ensures that a remote Elasticsearch
// output with secrets.service_token:
// 1. Resolves the service_token value (needed by fleet-server to create API keys)
// 2. Does NOT list outputs.<name>.service_token in SecretKeys — that field is
// stripped before the policy is sent to agents
func TestNewParsedPolicyRemoteESServiceTokenSecret(t *testing.T) {
var m model.Policy
var d model.PolicyData
err := json.Unmarshal([]byte(testPolicyRemoteESWithSecrets), &d)
require.NoError(t, err)
m.Data = &d

bulker := ftesting.NewMockBulk()
pp, err := NewParsedPolicy(t.Context(), bulker, m)
require.NoError(t, err)

remote := pp.Policy.Data.Outputs["OUTPUT_ID"]
require.Equal(t, "SERVICE_TOKEN_ID_value", remote["service_token"])
require.Equal(t, "SSL_KEY_ID_value", remote["ssl"].(map[string]any)["key"])
_, hasSecrets := remote["secrets"]
require.False(t, hasSecrets, "secrets map should be removed after processing")

require.NotContains(t, pp.SecretKeys, "outputs.OUTPUT_ID.service_token")
require.Contains(t, pp.SecretKeys, "outputs.OUTPUT_ID.ssl.key")
}

// TestParsedPolicyMixedSecretsReplacement tests that secrets specified in a policy
// using either the `secrets.<path-to-key>.<key>.id:<secret ref>` format or the
// `<path>: $co.elastic.secret{<secret ref>}` format are both replaced correctly.
Expand Down
69 changes: 69 additions & 0 deletions internal/pkg/policy/testdata/remote_es_policy_with_secrets.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
{
"id": "remote-with-secrets",
"revision": 1,
"outputs": {
"default": {
"type": "elasticsearch",
"hosts": [
"https://local.es.example:443"
]
},
"OUTPUT_ID": {
"type": "remote_elasticsearch",
"hosts": [
"https://remote.es.example:443"
],
"service_token": null,
"secrets": {
"service_token": {
"id": "SERVICE_TOKEN_ID"
},
"ssl": {
"key": {
"id": "SSL_KEY_ID"
}
}
}
}
},
"output_permissions": {
"default": {
"_fallback": {
"cluster": ["monitor"],
"indices": [
{
"names": ["logs-*", "metrics-*"],
"privileges": ["auto_configure", "create_doc"]
}
]
}
},
"OUTPUT_ID": {
"_fallback": {
"cluster": ["monitor"],
"indices": [
{
"names": ["logs-*", "metrics-*"],
"privileges": ["auto_configure", "create_doc"]
}
]
}
}
},
"inputs": [],
"secret_references": [
{"id": "SERVICE_TOKEN_ID"},
{"id": "SSL_KEY_ID"}
],
"agent": {
"monitoring": {
"enabled": true,
"use_output": "OUTPUT_ID",
"logs": true,
"metrics": true
}
},
"fleet": {
"hosts": ["http://localhost:8220"]
}
}
Loading