CP-25960: allowlist and require HTTPS for discovery-derived hosts - #829
Conversation
CP-25960 (AG1, Medium): service discovery's identity/discoverycontext/ secrets_manager API hosts were trusted verbatim from the response body and handed straight to the Conjur/Identity clients, which then POST the agent's SA token (or username/password) to that host with no allowlist — an SSRF-shaped hole if the discovery response is ever tampered with. Mirrors finding R2 already fixed on the discoverycontext-regional-resources side (token.py's iss-host allowlist). Constrain each discovered host to a known CyberArk root domain, or to the same host we already made the successful, TLS-authenticated discovery call to (covers the ARK_DISCOVERY_API dev/CI override and same-origin test mocks without a per-env map). A dropped host is treated the same as one absent from the response. CP-25964 (A1, Low): the authn-jwt exchange error included up to 4KiB of Conjur's response body, which propagates to a Kubernetes Pod Event (pkg/agent/run.go's PushingErr notification) — readable by anyone with `get events` in the namespace. The body can contain Conjur policy structure, service IDs and host identities. Log it at V(2) instead; the returned error (and therefore the Event) now carries only the status code and the existing troubleshooting hint. SCR: https://ca-il-confluence.il.cyber-ark.com/pages/viewpage.action?pageId=710861530
The allowlist added in the previous commit was copied from the identity authorizer Lambda's local commercial-only clone of everest_env_utils' ROOT_DOMAIN map, not the real package. The real map (everest_env_utils_cyberark, v2.0.117) has 8 more entries for the GOV_* environments (*-cyberarkgov.com/.cloud) that the clone omits. Without them, any gov-cloud tenant's agent would have every discovery-derived host rejected, breaking identity discovery entirely (fatal, since it's required unconditionally).
|
Follow-up commit: the allowlist added above was copied from the identity authorizer Lambda's local commercial-only clone of `everest_env_utils`'s `ROOT_DOMAIN` map. Checked the real package (`everest_env_utils_cyberark` v2.0.117, installed in `discoverycontext-file-ingestor-service`'s venv) and found 8 more entries for the GOV_* environments that the Lambda's local clone omits. Added them — without this, any gov-cloud tenant's agent would have every discovery-derived host rejected (fatal for |
CI's verify job failed: gci requires third-party imports (k8s.io/klog) grouped separately from and before this module's own imports, per .golangci.yaml's [standard, default, localmodule] section order.
Per the pentest finding CP-23593 ("Broken Trust Chain in Tenant Discovery
Bootstrap"), the domain allowlist alone doesn't require HTTPS -- a discovery
response could point identity/discoverycontext/secrets_manager at a plain
HTTP endpoint on an allowlisted-looking host.
This also closes the loopback-attacker shape of the "same host as
discoveryHost" escape hatch in isAllowedServiceHost: an attacker-controlled
ARK_DISCOVERY_API pointing at e.g. 127.0.0.1 (the PoC in CP-23593) can't
present a certificate this client's TLS verification will accept, so
requiring https closes exactly the vector the PoC demonstrated even for the
same-host case.
Does not touch the discovery bootstrap call's own base URL (ARK_DISCOVERY_API
itself) -- that override is deliberately left alone for dev/CI use, per AG1's
original writeup.
|
Added HTTPS-only enforcement on discovery-derived hosts (commit above). Not bundling key/fingerprint pinning — that needs an onboarding-time design decision (out-of-band pin distribution + a key-rotation story), not a mechanical fix; will file separately. |
wallrj-cyberark
left a comment
There was a problem hiding this comment.
Review of the discovery host allowlist and the Conjur error-body change. The direction is right and the tests are good. Seven points below, two of which are availability risks rather than security ones.
Two findings could not be anchored inline because they are outside the diff:
internal/envelope/keyfetch/client.go:147still doesfmt.Errorf("unexpected status code %d from %s: %s", resp.StatusCode, endpoint, string(body)). That is the same class of leak as CP-25964, against the discovery-context host, and it is not addressed here. It does not reach a Pod Event (onlypostDatafailures go througheventf), so it is lower risk, but it is worth either fixing alongside or noting explicitly as out of scope so the next reader does not assume the class is closed.- Residual on the allowlist itself:
cyberark.cloudadmits every tenant's host, so a tampered response can still redirect the SA-token POST to another tenant'ssecretsmgr/idhost. That is a much smaller blast radius than arbitrary SSRF, but the response already carriestenant_id/subdomainand the client already knowsc.subdomain, so tenant-level pinning is at least worth recording as a deliberate non-goal in the comment.
| // domains we actually trust, before anything downstream authenticates | ||
| // against it. A dropped URL is treated exactly like one absent from the | ||
| // response — see the required/optional distinction below. | ||
| identityAPI = sanitizeServiceAPI(ctx, IdentityServiceName, identityAPI, u.Hostname()) |
There was a problem hiding this comment.
A rejected identity_administration host is fatal, and the error the operator sees says the wrong thing.
When sanitizeServiceAPI drops the identity URL, identityAPI becomes "" and the check below returns didn't find identity_administration in service discovery response, which may indicate a suspended tenant. The tenant is fine; the agent simply does not recognise the root domain.
This matters because the allowlist is a hardcoded copy of a CyberArk-internal ROOT_DOMAIN map baked into a customer-deployed binary. The moment a new environment root domain is added, or a tenant's identity API moves, every agent in that environment fails at startup and the only accurate signal is a separate Info line. The response already demonstrates that CyberArk emits service hosts outside these roots — testdata/discovery_success.json.template has idaptive_risk_analytics on *.analytics.idaptive.qa and fmcdp on tagtig.io.
At minimum, distinguish the two cases so the error names the real cause. Consider also whether dropping identity should be fatal at all, or whether an escape hatch (an extra-allowed-domain env var) is warranted given the agent cannot be updated on CyberArk's release cadence.
There was a problem hiding this comment.
Fixed in 6fea542. Split the fatal-identity error into two cases: absent from the response (kept the existing "suspended tenant" message) vs. present but rejected by the allowlist (new message naming the rejected host explicitly). Confirmed the testdata fixture does carry out-of-root-domain services today (idaptive.qa, tagtig.io) — didn't spot that when I wrote the allowlist. Not adding an escape-hatch env var for extra allowed domains without discussing it first; that's a real widening of the trust boundary, not a mechanical fix.
| return true | ||
| } | ||
| for _, root := range allowedRootDomains { | ||
| if host == root || strings.HasSuffix(host, "."+root) { |
There was a problem hiding this comment.
The host comparison is case-sensitive, but net/url does not normalise host case.
url.Parse lowercases the scheme but leaves the host exactly as written — confirmed:
url.Parse("HTTPS://Tenant.CyberArk.Cloud/api") -> scheme="https" hostname="Tenant.CyberArk.Cloud"
So a discovery response carrying any uppercase in the hostname fails both host == root and strings.HasSuffix(host, "."+root) and is dropped. DNS is case-insensitive, so such a response is entirely legitimate. For identity_administration the result is a hard startup failure with the misleading "suspended tenant" error.
It fails closed rather than open, so this is availability rather than a bypass, but it is a one-line fix.
| if host == root || strings.HasSuffix(host, "."+root) { | |
| for _, root := range allowedRootDomains { | |
| if strings.EqualFold(host, root) || strings.HasSuffix(strings.ToLower(host), "."+root) { | |
| return true | |
| } | |
| } |
(the same normalisation should be applied to the host == discoveryHost comparison above)
There was a problem hiding this comment.
Confirmed: url.Parse lowercases the scheme but not the host. Fixed in 6fea542 — strings.EqualFold for the host==discoveryHost case, lowercased comparison against allowedRootDomains for the rest. Added a test with a mixed-case identity host.
| // discovery call itself, so a service response pointing back at that same | ||
| // host can't be a new SSRF target. | ||
| func isAllowedServiceHost(host, discoveryHost string) bool { | ||
| if host == discoveryHost { |
There was a problem hiding this comment.
The same-host escape hatch compares hostname only, so the port is unconstrained — and the doc comment's justification does not hold for an HTTP override.
Two separate points:
-
discoveryHostisu.Hostname(), which drops the port. WithARK_DISCOVERY_API=https://127.0.0.1:8443, a tampered response naminghttps://127.0.0.1:9443forsecrets_manageris accepted, and the agent POSTs its SA token to a different listener on the same host. Comparingu.Host(host:port) instead would cost nothing and close it — the mock server would still pass its own URL through, it just would not pass sibling ports. -
The comment says
discoveryHostis "the host we just made a successful, TLS-authenticated discovery call to".ARK_DISCOVERY_APImay be plainhttp://, in which case the discovery call was never TLS-authenticated at all, and the HTTPS check on the service URL does not retroactively authenticate the discovery host. It also is not necessarily the host the call succeeded against, sincehttp.Clientfollows redirects by default and this is the requested host, not the final one. That direction is fail-safe, but the comment as written will mislead the next reader.
There was a problem hiding this comment.
Both points are correct, and I looked at fixing the port comparison directly in this PR before deciding not to. A host:port check here would break every test currently relying on the same-host-different-port shape of this escape hatch (dataupload/conjur/identity mocks each on their own port, matched only by hostname today) -- fixing that cleanly needs the same mock-transport rework already in #830 (stacked on this branch), which removes the escape hatch entirely rather than tightening it. Doing a partial fix here and then deleting it in #830 seemed worse than just pointing at #830. Corrected the doc comment in 6fea542 to stop overclaiming TLS-authentication and to say this plainly. If you'd rather see #829 stand on its own without depending on #830 landing, say so and I'll patch it here too.
| // against it. A dropped URL is treated exactly like one absent from the | ||
| // response — see the required/optional distinction below. | ||
| identityAPI = sanitizeServiceAPI(ctx, IdentityServiceName, identityAPI, u.Hostname()) | ||
| discoveryContextAPI = sanitizeServiceAPI(ctx, DiscoveryContextServiceName, discoveryContextAPI, u.Hostname()) |
There was a problem hiding this comment.
Dropping discoverycontext makes an already-poor downstream failure newly reachable.
The comment below correctly notes that keyfetch does not check DiscoveryContext.API. Previously an empty value only happened for an absent or inactive service; now a live-but-unrecognised host produces the same empty string. The resulting failure in keyfetch.FetchKey is opaque — verified:
url.JoinPath("", "discovery-context/jwks") -> "discovery-context/jwks", err=nil
http.NewRequest(...) -> err=nil
client.Do(...) -> Get "discovery-context/jwks": unsupported protocol scheme ""
An operator sees unsupported protocol scheme "" with nothing tying it back to a dropped discovery host. Adding the missing empty check in keyfetch.FetchKey (matching NewDatauploadClient's) would make the new failure mode self-explanatory.
There was a problem hiding this comment.
Confirmed the failure mode (unsupported protocol scheme ""). Fixed in 6fea542 -- FetchKey now checks services.DiscoveryContext.API == "" explicitly, mirroring NewDatauploadClient's existing check on the same field, with a new test covering it.
| API: "https://identity.example.invalid", | ||
| // the Conjur path itself. Never dialed, so it just needs a host | ||
| // servicediscovery's allowlist accepts. | ||
| API: "https://identity.example.integration-cyberark.cloud", |
There was a problem hiding this comment.
Swapping .invalid for a name in a live CyberArk zone gives up a hermeticity guarantee.
example.invalid is reserved by RFC 6761 and is guaranteed never to resolve, so if any code path ever dialled it the test failed instantly and locally. identity.example.integration-cyberark.cloud sits under a zone that resolves today:
$ getent hosts integration-cyberark.cloud
1.1.1.1 integration-cyberark.cloud
The specific label does not resolve right now, but nothing stops a wildcard or a new record appearing, at which point a regression that used to fail fast instead makes real network egress from CI to CyberArk infrastructure.
Since MockDiscoveryServer sets ARK_DISCOVERY_API to its own https://127.0.0.1:PORT, the same-host clause already accepts any 127.0.0.1 URL. Pointing these placeholders at an unused loopback port keeps them unroutable off-box and needs no change to the allowlist. The same applies to internal/cyberark/client_test.go:44 and internal/envelope/keyfetch/client_test.go:35, :291, :337.
There was a problem hiding this comment.
Fixed in 6fea542, and the same swap at the other three locations you named. Used a loopback literal (https://127.0.0.1:1) instead -- it passes the allowlist via the same-host-as-discovery escape hatch (MockDiscoveryServer's own ARK_DISCOVERY_API is also 127.0.0.1) without depending on integration-cyberark.cloud staying free of a record at this exact label.
- Case-insensitive host comparison (net/url doesn't lowercase the host, only the scheme -- verified). A discovery response with any uppercase in an otherwise-legitimate hostname was being dropped, with identity_administration hitting the misleading "suspended tenant" error as a result. - Distinguish "identity_administration absent from the response" from "present but rejected by the allowlist" in the fatal error -- same underlying bug, different accurate message. - keyfetch.FetchKey now rejects an empty DiscoveryContext.API explicitly (mirrors cyberark.NewDatauploadClient's existing check on the same field), instead of surfacing url.JoinPath's silent empty-string fallout as an opaque "unsupported protocol scheme \"\"". - keyfetch.FetchKey no longer embeds the JWKS endpoint's raw error body in the returned error -- same leak class as CP-25964 (conjur.go's authn-jwt exchange), against the discoverycontext host instead of the Conjur host. Logged at V(2) instead. This was flagged as in-scope-but-outside-the-diff since keyfetch/client.go wasn't otherwise touched by CP-25960; fixed here rather than deferred, since it's the same mechanical change as CP-25964. - Test placeholders that are never dialed (Identity.API in several test setups) now use a loopback literal instead of a name under a real, resolvable CyberArk zone -- preserves the hermeticity property those values are supposed to have. - Corrected the isAllowedServiceHost doc comment: ARK_DISCOVERY_API isn't guaranteed HTTPS, and the same-host escape hatch being hostname-only (port-unconstrained) is real -- both are actually fixed by CP-26002's restructuring (already open as a stacked PR), not by a partial patch here, since a host:port comparison alone would break every test relying on same-host-different-port mocks without that same rework. Not addressed here, tracked separately as a new subtask: the tenant-scoping residual on the allowlist itself (cyberark.cloud admits every tenant's host, so a tampered response can redirect the SA-token POST to a different tenant's secretsmgr/id host) -- a real design decision on whether to add tenant-id pinning, not a mechanical fix.
Raised by wallrj-cyberark on PR jetstack#829 (CP-25960): allowedRootDomains admits every tenant's host, not just the caller's own -- a tampered response can still redirect the SA-token POST to a different tenant's secrets_manager/ identity_administration host. sanitizeServiceAPI now logs (Info, not enforced) when a discovery-derived host's leading label doesn't match the caller's own subdomain, reusing the two-shape matching discoverycontext-regional-resources' token.py:197-212 (_is_host_subdomain_matching) uses for its own inbound host-binding check. Not enforcement yet: we only have live evidence of the dot shape for jetstack-secure's three actual services (identity/discoverycontext/ secrets_manager render as id/inventory/secretsmgr in every mock and the one live-verified tenant); the hyphen shape is only confirmed for a different service (discoverycontext's own GraphQL host, per token.py's docstring example). Failing closed on an unverified shape risks breaking real agents in a service-label combination we haven't observed. CP-26094 tracks coming back to decide on enforcement once this telemetry confirms the shape holds.
|
On the tenant-scoping residual ( Not enforcing yet: I only have live evidence of the dot shape ( On |
wallrj-cyberark
left a comment
There was a problem hiding this comment.
One nit on the follow-up commit. Everything else from the previous round looks properly addressed — thanks for chasing the gov-cloud domains and for the 127.0.0.1:1 placeholder, which is a better answer than the one I suggested.
| // policy structure, service IDs and host identities) must not appear in it. | ||
| // The body is still logged at V(2) for an operator to go find, but that's | ||
| // exercised via the "authn-jwt exchange rejected" log line, not asserted | ||
| // here — ktesting has no easy log-buffer assertion in this codebase. |
There was a problem hiding this comment.
I dispute this last clause — the codebase already has the helper.
pkg/agent/config_test.go:1040:
func recordLogs(t *testing.T) (logr.Logger, ktesting.Buffer) {
log := ktesting.NewLogger(t, ktesting.NewConfig(ktesting.BufferLogs(true)))
testingLogger, ok := log.GetSink().(ktesting.Underlier)
require.True(t, ok)
return log, testingLogger.GetBuffer()
}Six tests in that file already assert against the buffer with gotLogs.String(). It is three lines, not a gap in the tooling.
I wrote the assertion for this test to check I was not talking nonsense, and it passes:
logger := ktesting.NewLogger(t, ktesting.NewConfig(ktesting.BufferLogs(true), ktesting.Verbosity(2)))
buf := logger.GetSink().(ktesting.Underlier).GetBuffer()
ctx := klog.NewContext(t.Context(), logger)
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://example.com/x", nil)
_, err := c.AuthenticateRequest(req)
require.Error(t, err)
require.NotContains(t, err.Error(), "CONJ00001E Invalid JWT token")
require.Contains(t, buf.String(), "authn-jwt exchange rejected")
require.Contains(t, buf.String(), "CONJ00001E Invalid JWT token")--- PASS: TestScratch_BodyIsLoggedAtV2 (0.01s)
captured buffer:
INFO authn-jwt exchange rejected statusCode=401 body="{\"error\":{\"message\":\"CONJ00001E Invalid JWT token\"}}"
Two things that are genuinely non-obvious, and are probably what you hit:
conjur.go:100logs viaklog.FromContext(ctx), so the logger has to reach it through the request context —klog.NewContext(t.Context(), logger). Passing it any other way silently captures nothing and the buffer just comes back empty.- You need
ktesting.Verbosity(2), otherwise theV(2)line is filtered out and, again, you get an empty buffer rather than a failure that tells you why.
Worth doing, because it closes the actual loop: right now the test proves the body is absent from the error, but nothing proves it is still present in the log. Someone deleting the klog line to "fix" a leak would keep this test green and quietly remove the operator's only way to see Conjur's response.
If you would rather not, that is fine — but then please reword rather than leave the claim, since the next person will believe it:
| // here — ktesting has no easy log-buffer assertion in this codebase. | |
| // here. |
There was a problem hiding this comment.
Verified — wrong. pkg/agent/config_test.go:1040's recordLogs helper does exactly this, six tests already use it. Added the same pattern here: TestAuthenticateRequest_ExchangeErrorOmitsConjurResponseBody now asserts the body is still present in the V(2) log line via ktesting.BufferLogs, not just absent from the returned error. Reworded the comment to state that rather than the false claim.
wallrj-cyberark
left a comment
There was a problem hiding this comment.
Coming back on the allowlist with a threat-model question rather than a code one. I am not asking you to remove it — I am asking that the description stop claiming more for it than it delivers, because that shapes what gets built next.
| // 2026-09-03), not the Lambda's local commercial-only clone — that clone | ||
| // omits the GOV_* environments entirely, which would have made this | ||
| // allowlist silently break every gov-cloud tenant's agent. | ||
| var allowedRootDomains = []string{ |
There was a problem hiding this comment.
Which attacker does this stop? I can only make it bind against one, and it is not the one the description implies.
The discovery call is already HTTPS against system roots, so work through who can actually change what comes back:
- Network attacker with no trusted certificate. They cannot alter the discovery response at all — TLS stops them. The allowlist has nothing to defend.
- Network attacker who can pass TLS for the discovery host (misissued certificate, an extra CA in the pod trust store, an intercepting proxy). They tamper with the response — and they can equally intercept the connection to whichever host the allowlist permits. They name a genuine
*.cyberark.cloudhost and intercept that too. The allowlist buys nothing. - The discovery service itself returns a host it should not — compromised, or buggy, or reflecting input someone else influenced. TLS is intact everywhere; the only lever is the response body. Here, and only here, the allowlist binds.
So this is defence-in-depth against our own service misbehaving. That is a legitimate thing to want, and worth keeping. But it is not a fix for a broken trust chain at the network layer, and the description reads as though it is.
Two consequences worth being explicit about:
Requiring HTTPS does not substitute for the allowlist. Anyone can get a publicly trusted certificate for a domain they own, so in case 3 https://evil.example/ is accepted by TLS and the agent POSTs its SA token there. The allowlist is the only thing standing in the way. Conversely, in case 2, HTTPS does not help either. The two controls are not layers on the same threat — they address different ones, and neither covers case 2.
In case 3, cyberark.cloud admits every tenant's host. A compromised discovery service does not need an outside domain; it names another tenant's id or secretsmgr host. That makes the tenant-scoping check the load-bearing control and the root-domain list the weaker one — which is the opposite of the emphasis here. I appreciate why you shipped it warn-only, and I am not arguing with that call.
Separately, on the loopback shape the comment below cites: an attacker who can set ARK_DISCOVERY_API on the agent already controls the pod's environment, and the token is /var/run/secrets/tokens/jwt in that same pod (jwtsource.DefaultTokenPath). They can read the file; they have no need to make the agent post it to them. Requiring HTTPS is still worth having — it stops a plain-HTTP dev configuration reaching production — but it should be described as protecting against misconfiguration, not against that attacker.
None of this needs a code change. What I would like is for the description and this comment to say "constrains a misbehaving discovery service" rather than implying it closes a network-level gap, so that nobody later reads the allowlist as a reason not to do the pinning work.
There was a problem hiding this comment.
Agreed with the threat model — walked through the same three cases and land in the same place: TLS already stops case 1, case 2 gains nothing from this check (an attacker who can defeat the discovery host's TLS can equally intercept whichever host the allowlist permits), and case 3 (our own discovery service misbehaving) is the only place it binds. Reworded the doc comment to say exactly that rather than "SSRF-shaped hole"/"tampered with", and made the tenant-scoping gap explicit there too (cyberark.cloud admits every tenant's host; hostLeadingLabelMatchesSubdomain is warn-only, not a fix for it). Also corrected the "loopback-attacker" framing in both PR descriptions to "loopback-misconfiguration" — same point applies to ARK_DISCOVERY_API: someone who can set it already has the pod's token file directly.
Two more points from wallrj-cyberark's review round on jetstack#829/jetstack#830. allowedRootDomains' doc comment claimed to close "an SSRF-shaped hole if the response is ever tampered with", implying defence against a network attacker. Verified against the actual threat model: the discovery call is already HTTPS against system roots, so a plain network attacker can't alter the response, and one who could defeat that TLS session could equally intercept whichever host the allowlist permits instead -- the allowlist buys nothing there. What it actually constrains is our own discovery service returning a bad host (compromised, buggy, or otherwise misbehaving) inside an intact TLS session. Reworded to say that precisely, and noted the tenant-scoping gap (cyberark.cloud admits every tenant's host) explicitly rather than leaving it implicit. TestAuthenticateRequest_ExchangeErrorOmitsConjurResponseBody's comment claimed "ktesting has no easy log-buffer assertion in this codebase" -- wrong, pkg/agent/config_test.go:1040's recordLogs helper already does this and six tests use it. Added the same assertion here: the test now proves the Conjur response body is still present in the V(2) log line, not just absent from the returned error, so a future change deleting the klog line entirely would fail this test too.
wallrj-cyberark
left a comment
There was a problem hiding this comment.
Approving. The allowlist and HTTPS work is sound, and the last round addressed everything I raised — the split identity error, the case-insensitive comparison, the keyfetch empty check and the 127.0.0.1:1 fixtures all look right.
Two things I would normally have asked for before merge. I am not holding this up for them, because #830 is stacked on this branch and still open, so they can land there instead. They should land, though.
1. The new identity-rejection error puts an untrusted URL into a Pod Event
internal/cyberark/servicediscovery/discovery.go:319 formats rawIdentityAPI into the returned error. That value comes straight from the discovery response body, and the error reaches the same Warning Pod Event this PR is scrubbing Conjur bodies out of: pkg/client/client_cyberark.go:79 returns it unwrapped, and pkg/agent/run.go:388 writes it with eventf("Warning", "PushingErr", ...) on every backoff retry.
So a tampered or buggy response can put text of its choosing in front of anyone with get events in the namespace, bounded only by maxDiscoverBodySize. This is the leak class the PR exists to close, reintroduced two files away. Quoting u.Hostname(), or nothing at all, avoids it — the rejected host is already in the Info log. The unparseable-URL log at :123 logs rawAPI verbatim too.
Still present in #830 at :324.
2. The upload path still embeds upstream response bodies
internal/cyberark/dataupload/dataupload.go:177 and :251 both still do io.ReadAll(io.LimitReader(res.Body, 500)) and put the result in the error, and both are on that same PushingErr path. Worth either giving them the same treatment or saying in the description that they are out of scope, so the class is not recorded as closed when the most reachable sites are untouched. Still present in #830.
Already handled in #830
Flagging so they are not raised again: the V(2) buffer assertion landed in 1000d8f, and the stale "keyfetch's client doesn't check discoveryContextAPI" sentence is gone. Thank you for both.
One deliberate call worth recording
identity_administration is required unconditionally and now allowlist-gated, but on the Conjur JWT path it is never dialed. So a tenant whose identity host sits on a root domain missing from the hardcoded list gets a hard outage on a path that never needed that host, with no override and no fix until a new agent release ships. You have decided knowingly to keep it fatal and I am not reopening it — but please make sure it is written down somewhere an on-call engineer will find it, because the symptom will not point at the allowlist.
Six smaller observations, none blocking, listed so they are not lost
-
Startup key-fetch failure disables secret encryption for the pod lifetime.
pkg/agent/run.go:175runsloadEncryptoronce and setsencryptSecrets = falseon failure with no retry. A droppedsecrets_manageror rejected identity host is a new trigger for that. Uploads keep succeeding without secret data until the pod restarts, and nothing alerts. Pre-existing shape, but this PR widens the trigger. -
A dropped host is cached as empty for an hour, with the reason logged once. The rejection produces a single Info line at drop time; for the next hour every consumer error says only that the API is empty. An operator may reasonably conclude the tenant is not onboarded. Either carry the reason in
Servicesor re-log on each cached return. -
One error string covers three rejection reasons. "is not on an allowed CyberArk domain over HTTPS" also covers the unparseable-URL and empty-host branches, so a malformed URL such as
https:///tenantis reported as an allowlist failure. Returning(string, error)fromsanitizeServiceAPIwould remove both that ambiguity and the need forrawIdentityAPI, which also fixes point 1 above. -
The JWKS V(2) log caps at 1 MiB where Conjur caps at 4 KiB.
internal/envelope/keyfetch/client.go:163logs up tomaxResponseBytes;conjur.go:96uses4*1024. Worth matching the smaller cap and draining the rest. -
The host allowlist does not strip a trailing dot.
ajp5871.id.cyberark.cloud.matches neither the equality nor the suffix test, and Go's TLS stack accepts absolute FQDNs. No such host appears in testdata, so the trigger is unevidenced.strings.TrimSuffix(host, ".")closes it. -
Settled earlier, not reopening: the same-host escape hatch and the
127.0.0.1:1fixtures both go with #830, and the-v=2hint is valid — it is the preserved shorthand for--log-level, which I withdrew on the other PR.
Raised by wallrj-cyberark on PR jetstack#829 (CP-25960): allowedRootDomains admits every tenant's host, not just the caller's own -- a tampered response can still redirect the SA-token POST to a different tenant's secrets_manager/ identity_administration host. sanitizeServiceAPI now logs (Info, not enforced) when a discovery-derived host's leading label doesn't match the caller's own subdomain, reusing the two-shape matching discoverycontext-regional-resources' token.py:197-212 (_is_host_subdomain_matching) uses for its own inbound host-binding check. Not enforcement yet: we only have live evidence of the dot shape for jetstack-secure's three actual services (identity/discoverycontext/ secrets_manager render as id/inventory/secretsmgr in every mock and the one live-verified tenant); the hyphen shape is only confirmed for a different service (discoverycontext's own GraphQL host, per token.py's docstring example). Failing closed on an unverified shape risks breaking real agents in a service-label combination we haven't observed. CP-26094 tracks coming back to decide on enforcement once this telemetry confirms the shape holds.
Two more points from wallrj-cyberark's review round on jetstack#829/jetstack#830. allowedRootDomains' doc comment claimed to close "an SSRF-shaped hole if the response is ever tampered with", implying defence against a network attacker. Verified against the actual threat model: the discovery call is already HTTPS against system roots, so a plain network attacker can't alter the response, and one who could defeat that TLS session could equally intercept whichever host the allowlist permits instead -- the allowlist buys nothing there. What it actually constrains is our own discovery service returning a bad host (compromised, buggy, or otherwise misbehaving) inside an intact TLS session. Reworded to say that precisely, and noted the tenant-scoping gap (cyberark.cloud admits every tenant's host) explicitly rather than leaving it implicit. TestAuthenticateRequest_ExchangeErrorOmitsConjurResponseBody's comment claimed "ktesting has no easy log-buffer assertion in this codebase" -- wrong, pkg/agent/config_test.go:1040's recordLogs helper already does this and six tests use it. Added the same assertion here: the test now proves the Conjur response body is still present in the V(2) log line, not just absent from the returned error, so a future change deleting the klog line entirely would fail this test too.
Summary
Two hardening fixes, plus a fix for the underlying trust-chain gap in "Broken Trust Chain in Tenant Discovery Bootstrap":
identity/discoverycontext/secrets_managerAPI hosts were trusted verbatim from the response body and handed straight to the Conjur/Identity clients, which then POST the agent's SA token (or username/password) to that host — no allowlist. Now constrains each discovered host to a known CyberArk root domain (commercial + gov-cloud), or to the same host the discovery call itself succeeded against (coversARK_DISCOVERY_APIdev/CI overrides and same-origin test mocks with no per-env map needed). A dropped host is treated the same as one absent from the response. This is defence-in-depth against our own discovery service misbehaving (compromised, buggy, or returning a host influenced by something else) — the discovery call is already HTTPS against system roots, so it doesn't defend against a network-level attacker who could equally intercept whichever host the allowlist permits instead.https. Also closes the loopback-misconfiguration shape of the "same host as discoveryHost" allowance above — a rogueARK_DISCOVERY_APIpointed at a plain-HTTP127.0.0.1server can't present a certificate this client's TLS verification will accept once HTTPS is required. (Note: someone who can setARK_DISCOVERY_APIon the agent already controls the pod spec and therefore the SA token file itself —/var/run/secrets/tokens/jwt— so this guards against a plain-HTTP dev configuration reaching production, not against that attacker gaining anything they didn't already have.) Key/fingerprint pinning is a separate, larger design decision and not part of this PR.get eventsin the namespace. Now logged atV(2)instead; the error/Event carries only the status code and the existing troubleshooting hint.Test plan
go vet ./...make test-unit— 434 tests, 4 skipped, 2 pre-existing unrelated failures (KUBEBUILDER_ASSETS-gated test not run viamake's own env in this invocation path is unaffected; thejson.RawMessage/jsontext.Valuemessage-drift failure inpkg/clientis pre-existing onmaster, unrelated to this change)golangci-lintcleanservicediscoveryhost-allowlist accept/reject cases (incl. gov-cloud domains and plain-HTTP rejection);conjurerror-body-omitted-from-returned-error case, now also asserting the body is still present in theV(2)log line