Store output API key secrets in .fleet-secrets instead of .fleet-agents - #7416
Conversation
This comment has been minimized.
This comment has been minimized.
|
This pull request does not have a backport label. Could you fix it @ycombinator? 🙏
|
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
81d725e to
c083c9b
Compare
TL;DRBoth cloud E2E jobs fail before the E2E suite starts because the provisioned deployment has no Fleet endpoint: Terraform outputs Remediation
Investigation detailsRoot CauseThe confirmed failure is the cloud E2E precondition. In The captured artifacts do not include the earlier Elastic Agent/Integrations Server startup error, so they cannot establish whether the endpoint was missing because the image failed to start, the snapshot lacked the required Elasticsearch privilege, or another deployment issue occurred. The PR changes no cloud Terraform or E2E orchestration code; both failures use the same shared script. Evidence
VerificationNot run locally: the failure depends on the external Elastic Cloud deployment, and the provided logs omit the deployment startup log that caused the Fleet endpoint to be unavailable. What is this? | From workflow: PR Buildkite Detective Give us feedback! React with 🚀 if perfect, 👍 if helpful, 👎 if not. |
TL;DRBoth Cloud e2E jobs fail before the Go tests start because Terraform returns an empty Remediation
Investigation detailsRoot CauseThe captured teardown plan for both jobs reports Evidence
Verification
Follow-upIf a full rerun reaches the Go tests, separately validate the PR’s documented Elasticsearch dependency before attributing any later What is this? | From workflow: PR Buildkite Detective Give us feedback! React with 🚀 if perfect, 👍 if helpful, 👎 if not. |
020d99e to
7c22d8f
Compare
Fixes a privilege escalation (elastic/security#12225) where the kibana_system role's read access to .fleet-agents-7 could be used to extract plaintext output API key secrets and escalate to SIEM write access. Changes: - On new key generation, write the raw key material to .fleet-secrets via POST /_fleet/secret and store only a $co.elastic.secret{id} reference in outputs.{name}.api_key in .fleet-agents-7 - At agent checkin, resolve the reference via GET /_fleet/secret/{id} before injecting api_key into the policy payload delivered to the agent - On ACK (key rotation), delete the retired secret from .fleet-secrets via DELETE /_fleet/secret/{id}; the secret ID is carried in a new secret_id field on ToRetireAPIKeyIdsItems - Plaintext values already stored in .fleet-agents-7 continue to work transparently (backward compat); migration to the new format happens naturally on the next key rotation Requires elastic/elasticsearch#154498 (write_fleet_secrets privilege for the elastic/fleet-server service account) to be merged and deployed first. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When an output is removed from the policy, the retired API key entry was missing SecretID, so deleteRetiredSecrets would skip cleaning up the corresponding .fleet-secrets entry. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
c.Perform only errors on network failures, not 4xx/5xx responses.
Without this check, a 403 (missing write_fleet_secrets privilege) or
500 would cause Write() to return an empty secret ID, producing a
broken $co.elastic.secret{} reference silently stored in the agent doc.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ReadSecrets returns no error when a secret ID is absent from the response (e.g. deleted between write and read). The zero-value map lookup would silently inject an empty api_key into the agent policy, causing the agent to fail connecting to Elasticsearch. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
handleUnenroll was invalidating all output API keys but never deleting
their corresponding secrets from .fleet-secrets. Add a deleteRetiredSecrets
call that covers both retired keys (SecretID already set in ToRetireAPIKeyIds
items) and the current active output key (secret ID parsed from the
$co.elastic.secret{} reference in output.APIKey).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
7c22d8f to
0af4d41
Compare
The golangci-lint config has check-type-assertions: true and --whole-files is passed, so touching bulk.go surfaces all pre-existing single-value type assertions. Convert them to v, _ := args.Get(0).(T) throughout. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
--whole-files causes golangci-lint to flag pre-existing issues in any file touched by the PR. Fix prealloc warnings in secret.go by adding capacity hints to slice literals, and replace WriteString(fmt.Sprintf) with fmt.Fprintf in policy_output.go as suggested by staticcheck QF1012. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
blakerouse
left a comment
There was a problem hiding this comment.
Overall the code looks good, but I do have a worry. Correct me if I am off base here.
Being that the API key is now in secrets and this is not running like a transactional database across two tables there is a case that might not be handled correctly.
Write the secret to the secret store, but then writing the reference into .fleet-agents fails. I don't see where if that fails the secret is then deleted. I feel like it would need to be deleted then, because it is just left in there with no reference.
Also what happens if writing into the secret store fails? Does that just error back to the caller and they make the API call again? Seems like it, I believe that is correct.
…Secret If WriteSecret succeeds but the subsequent bulker.Update to .fleet-agents fails, the written secret would previously be left in .fleet-secrets with no reference. Now a compensating DeleteSecret is issued on the update failure path; if that cleanup also fails it logs a warning and still returns the original update error. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Yep, good catch. This is a gap. Fixed now in 0818c0e.
Yes, that's what happens. This early return when |
blakerouse
left a comment
There was a problem hiding this comment.
Nice! Thanks for the fixes.
maps.Copy was introduced in the conflict resolution but the maps package was not imported on this branch (it landed on main via #7416 which was not backported). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
maps.Copy was introduced in the conflict resolution but the maps package was not imported on this branch (it landed on main via #7416 which was not backported). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
maps.Copy was introduced in the conflict resolution but the maps package was not imported on this branch (it landed on main via #7416 which was not backported). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
What is the problem this PR solves?
The
kibana_systembuilt-in role has broadread(actuallyall) access to.fleet-agents*. Fleet Server currently writes output API key secrets as plaintextid:secretstrings intooutputs.{name}.api_keyon agent documents in.fleet-agents-7. Any principal withkibana_systemcredentials can therefore extract these secrets and use them to write to data streams thatkibana_systemitself cannot access — a privilege escalation.How does this PR solve the problem?
Instead of storing the raw
id:secretstring in.fleet-agents-7, Fleet Server now:policy_output.go): writes the key material to.fleet-secretsviaPOST /_fleet/secretand stores only a$co.elastic.secret{<secretId>}reference inoutputs.{name}.api_key.policy_output.go): detects the reference format and resolves it viaGET /_fleet/secret/{id}before injectingapi_keyinto the policy payload sent to the agent.handleAck.go): deletes the retired secret from.fleet-secretsviaDELETE /_fleet/secret/{id}. The secret ID is carried in a newsecret_idfield onToRetireAPIKeyIdsItems(schema change inmodel/schema.json).Backward compatibility: existing plaintext values already stored in
.fleet-agents-7continue to work unchanged. The checksecret.ParseSecretReference(output.APIKey)returnsfalsefor plainid:secretstrings, so they are used directly. Agents migrate to the new format naturally on the next key rotation.Dependency: requires elastic/elasticsearch#154498 (granting
write_fleet_secretscluster privilege to theelastic/fleet-serverservice account) to be merged and deployed first.How to test this PR locally
Run the integration test suite against a stack with elastic/elasticsearch#154498 applied:
Verify that:
api_keyin checkin responses.fleet-agents-7documents show$co.elastic.secret{...}inoutputs.*.api_keyrather than a plaintext key.fleet-secretscontains the corresponding entries.fleet-secretsDesign Checklist
POST /_fleet/secretwrite; reads are already part of the checkin path viaReadSecrets.)Checklist
./changelog/fragmentsusing the changelog toolRelated issues