feat(azdext): Add KeyVaultResolver for extension Key Vault secret resolution#7043
feat(azdext): Add KeyVaultResolver for extension Key Vault secret resolution#7043jongio wants to merge 10 commits intoAzure:mainfrom
Conversation
6efe774 to
808a1fe
Compare
…olution
Add KeyVaultResolver to the Extension SDK for resolving Azure Key Vault
secret references embedded in environment variables. Supports three
reference formats:
- akvs://<subscription>/<vault>/<secret>
- @Microsoft.KeyVault(SecretUri=https://<vault>.vault.azure.net/secrets/<name>[/<version>])
- @Microsoft.KeyVault(VaultName=...;SecretName=...)
Features:
- Thread-safe per-vault client caching
- Batch resolution via ResolveMap
- Structured error types with ResolveReason classification
- Configurable vault suffix for sovereign clouds
- Helper functions: IsSecretReference, ParseKeyVaultAppReference,
ResolveSecretEnvironment for bulk env var resolution
Also adds supporting functions to pkg/keyvault:
- IsKeyVaultAppReference, IsSecretReference
- ParseKeyVaultAppReference for @Microsoft.KeyVault format
- SecretFromKeyVaultReference for unified resolution
- ResolveSecretEnvironment for bulk env var processing
Evidence: Extensions like azd-exec, azd-app duplicate 100+ lines of KV
resolution logic each. This centralizes the pattern in the SDK.
Related: Azure#6945, Azure#6853
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Reverted detect_confirm_apphost.go (had color.MagentaString() without import, causing build failure) and show.go (KV comments belong on improvements branch) to match upstream/main. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The 'secret' map key in test data triggers gosec G101 (hardcoded credentials) but these are fake akvs:// URIs in test fixtures, not real credentials. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- SSRF: Validate KeyVault SecretUri hostname against Azure vault suffixes - Scoping: Only resolve azd env vars through KV resolver, not os.Environ() - Errors: Use ServiceError reason for non-ResponseError failures - Security: Explicit DisableChallengeResourceVerification: false - Testing: Add 8 tests for @Microsoft.KeyVault reference format - Reliability: Return empty string on resolution failure, not raw reference - Determinism: Sort keys in ResolveMap, collect all errors with errors.Join Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add 'managedhsm' and 'microsoftazure' to cspell dictionary - Suppress gosec G101 false positive on test data string Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2729231 to
b682e24
Compare
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds shared Key Vault secret-reference resolution to the extension SDK / CLI flow, so extensions can receive resolved secret values (rather than raw akvs://... or @Microsoft.KeyVault(...) references) without duplicating parsing + fetch logic.
Changes:
- Add
azdext.KeyVaultResolverwith structured error classification and batch resolution (ResolveMap). - Extend core
pkg/keyvaultwith parsing helpers for@Microsoft.KeyVault(SecretUri=...)and an env-var resolver (ResolveSecretEnvironment). - Integrate env-var resolution into extension invocation (
cmd/extensions.go) and update spelling dictionary entries.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
cli/azd/pkg/keyvault/keyvault.go |
Adds @Microsoft.KeyVault(SecretUri=...) parsing + unified reference resolution + env-var resolution helper. |
cli/azd/pkg/azdext/keyvault_resolver.go |
Introduces the extension-facing KeyVaultResolver API, parsing, and structured error types. |
cli/azd/pkg/azdext/keyvault_resolver_test.go |
Unit tests covering resolver behavior, parsing, and error classification. |
cli/azd/cmd/extensions.go |
Resolves KV references in azd-managed env vars before launching extension processes. |
cli/azd/.vscode/cspell.yaml |
Adds KV-related terms to cspell dictionary. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Call ResolveSecretEnvironment unconditionally so akvs:// refs work without AZURE_SUBSCRIPTION_ID being set - Add per-vault client cache (sync.Map) to avoid N client creations when resolving N secrets from the same vault - Preserve original reference in ResolveMap output on failure so callers see all input keys - Tighten IsKeyVaultAppReference to only match SecretUri= variant - Use u.Hostname() for port-safe host validation in SSRF checks - Return ([]string, error) from ResolveSecretEnvironment so callers can handle failures explicitly - Update docs to clarify only SecretUri= format is supported Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
@copilot please re-review the latest changes. |
Azure Dev CLI Install InstructionsInstall scriptsMacOS/Linux
bash: pwsh: WindowsPowerShell install MSI install Standalone Binary
MSI
Documentationlearn.microsoft.com documentationtitle: Azure Developer CLI reference
|
wbreza
left a comment
There was a problem hiding this comment.
Code Review Summary
What Looks Good
- Azure KV DNS suffixes are complete — all 5 valid suffixes (public, China, US Gov, Germany deprecated, Managed HSM) match Azure documentation
- Vault name regex is correct — enforces Azure's 3-24 char naming rules accurately
- SSRF protection — host validation against known Azure DNS suffixes prevents arbitrary URL resolution
- Structured error types — KeyVaultResolveError with ResolveReason classification is well-designed
- Per-vault client caching — sync.Map-based getOrCreateClient with LoadOrStore is correct and race-free
- Error collection — ResolveMap and ResolveSecretEnvironment collect all errors via errors.Join
- Reference leak prevention — on failure, env var value is set to empty rather than leaking the raw akvs:// reference
Findings Summary
| Priority | Count |
|---|---|
| Critical | 1 |
| High | 2 |
| Medium | 7 |
| Low | 2 |
| Total | 12 |
Overall Assessment: Comment. Good feature with solid security posture. The inline comments below highlight the sort.Strings bug and areas where additional unit tests would strengthen confidence before merge. Would love to see some more unit tests to validate the PR findings.
|
|
||
| if len(errs) > 0 { | ||
| return result, fmt.Errorf("failed to resolve Key Vault references: %w", errors.Join(errs...)) | ||
| } |
There was a problem hiding this comment.
[Critical] sort.Strings(result) breaks env var override semantics
In extensions.go, system env vars from os.Environ() are appended first, then azd vars after -- last-wins semantics for duplicate keys. Sorting the azd vars alphabetically destroys this ordering guarantee. For example, an azd var PATH=/custom intended to override the system PATH could end up in the wrong position after sorting.
Consider removing sort.Strings(result) entirely. If deterministic output is needed for testing, apply it only in tests.
| } | ||
|
|
||
| // AzureKeyVaultSecret represents a secret stored in an Azure Key Vault. | ||
| // It contains the necessary information to identify and access the secret. |
There was a problem hiding this comment.
[High] No tests for ResolveSecretEnvironment or SecretFromKeyVaultReference
Both are new public functions with non-trivial logic (reference dispatch, error collection, env var parsing, fallthrough to unrecognized format error). Key untested paths include: mixed akvs:// and @Microsoft.KeyVault refs, malformed env vars (no = sign), nil kvService passthrough, and unrecognized format fallthrough.
Consider adding table-driven tests with a mock KeyVaultService.
| t.Fatal("expected error for nil context") | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
[High] IsSecretReference test is missing @Microsoft.KeyVault cases
IsSecretReference returns true for both akvs:// and @Microsoft.KeyVault(SecretUri=...), but the test only covers akvs:// variants. No test verifies that the @Microsoft.KeyVault format returns true, nor that the unsupported VaultName/SecretName form returns false.
Consider adding entries like:
@Microsoft.KeyVault(SecretUri=https://v.vault.azure.net/secrets/s)should be true@Microsoft.KeyVault(VaultName=v;SecretName=s)should be false
| envVars []string, | ||
| defaultSubscriptionId string, | ||
| ) ([]string, error) { | ||
| if kvService == nil { |
There was a problem hiding this comment.
[Medium] Non-standard ports pass vault host validation
Host validation uses u.Hostname() (strips port), but VaultURL is built from u.Host (preserves port). A URI like https://myvault.vault.azure.net:9999/secrets/foo passes validation but sends the bearer token to port 9999.
Consider rejecting non-standard ports explicitly, or using u.Hostname() consistently for VaultURL construction.
| var errs []error | ||
|
|
||
| for i, envVar := range envVars { | ||
| before, after, ok := strings.Cut(envVar, "=") |
There was a problem hiding this comment.
[Medium] Empty vault name accepted when hostname equals a bare suffix
If the SecretUri hostname is exactly a valid suffix (e.g., .vault.azure.net), isValidVaultHost passes because strings.HasSuffix matches. Then vault name extraction on the next line yields an empty string.
Consider checking that dotIdx > 0 after strings.Index(host, '.').
|
|
||
| const secretURIPrefix = "SecretUri=" | ||
| if !strings.HasPrefix(inner, secretURIPrefix) { | ||
| return KeyVaultAppReference{}, fmt.Errorf( |
There was a problem hiding this comment.
[Medium] Case-sensitive prefix matching may silently skip valid references
Azure App Service handles @Microsoft.KeyVault(...) references case-insensitively. The code uses case-sensitive strings.HasPrefix. Variations like @microsoft.keyvault(SecretUri=...) would be silently ignored -- the raw reference string passes through to the extension process unresolved.
Consider using case-insensitive matching for the wrapper prefix.
| // SecretFromKeyVaultReference resolves a secret reference in either the | ||
| // akvs:// or @Microsoft.KeyVault(SecretUri=...) format. The subscriptionId | ||
| // is required for credential scoping; for @Microsoft.KeyVault references | ||
| // (which lack a subscription), the caller should provide the environment's |
There was a problem hiding this comment.
[Medium] Adding a method to an exported interface is a breaking change
Adding SecretFromKeyVaultReference to the KeyVaultService interface breaks all external implementations. The method is only called from ResolveSecretEnvironment (a package-level function). Consider a narrower interface or standalone function to avoid breaking downstream consumers.
There was a problem hiding this comment.
we don't have any external implementations (and shouldn't) for kv service.
|
|
||
| if ref.VaultName != "myvault" { | ||
| t.Errorf("VaultName = %q, want %q", ref.VaultName, "myvault") | ||
| } |
There was a problem hiding this comment.
[Medium] Tests use context.Background() throughout (15 occurrences) instead of t.Context() per repo Go 1.26 conventions.
| // | ||
| // akvs://<subscription-id>/<vault-name>/<secret-name> | ||
| // @Microsoft.KeyVault(SecretUri=https://<vault>.vault.azure.net/secrets/<secret>[/<version>]) | ||
| // |
There was a problem hiding this comment.
[Medium] Modern Go: consider slices.Sorted(maps.Keys(refs)) instead of manual key collection + sort.Strings(keys). Same applies to the errors.As call (~line 178) which could use errors.AsType[*azcore.ResponseError](err).
| t.Errorf("ResolveReason(%d).String() = %q, want %q", tt.reason, got, tt.want) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
[Low] A few test improvement opportunities:
- The 4 HTTP error classification tests (NotFound, Forbidden, Unauthorized, ServerError) are structurally identical -- a single table-driven test would reduce ~100 lines to ~30
stubSecretGetterignores name/version args, so tests never verify the correct secret name or version is forwarded to GetSecret. A recording stub would catch dispatch bugsTestResolveMap_ErrorCollectsAllFailuresonly has 1 failing ref -- adding 2+ would actually test the collection behavior
Global Key Vault Resolution for azd-Passed Environment Variables
Summary
This PR updates Key Vault handling so resolution is not extension-specific. Any azd-managed environment variable value passed by azd to subprocesses is resolved centrally first; extensions then inherit those resolved values automatically.
Intended Behavior
Supported Reference Formats
akvs://<subscription-id>/<vault-name>/<secret-name>@Microsoft.KeyVault(SecretUri=https://<vault>.vault.azure.net/secrets/<secret>[/<version>])Key Changes
pkg/keyvault:SecretFromKeyVaultReference(...)ResolveSecretEnvironment(...)forKEY=VALUElistspkg/execviaRunnerOptions.EnvironmentResolver.cmd/container.go) so azd env values are resolved before subprocess invocation.Scope Clarification
This PR is about centralized azd env var resolution. Extension behavior is inherited from that central mechanism rather than implemented as extension-only logic.
Validation
go test ./pkg/keyvault ./pkg/execgo test ./cmd -run TestNope