Day 18 已分開管理 vault 與讀取 secret 的角色。今天處理一個仍需要密碼的舊供應商 API:當 secret 從 version 1 換成 version 2,應用程式會不會繼續選到舊版本?
把值存進 Key Vault 以後,adapter、cache 與輸出紀錄仍然需要自己的控制。我們先用本機 store 重現 disabled version 被接受的情況,再把 reference 綁過 proposal、policy 與 adapter,最後檢查五個輸出位置有沒有留下合成值。
真實供應商是否已撤銷舊 credential、Azure Key Vault 的輪替與 cache 失效,這次都還沒有執行。
latest 是會移動的選擇,不是固定版本。輪替作業若只確認「Version 2 已建立」,會漏掉三個需要分開觀察的狀態:Version 1 是否 disabled、adapter 是否仍可選到它,以及 raw output 是否還留著舊值。今天的本機 fixture 就固定這三個狀態,不假設 Azure Key Vault 已經替應用清掉 cache。
先把 Version 1 停用,看看舊 adapter 還會不會選到它。

selected_version=1、selected_enabled=false,流程卻回傳 LEGACY_DISABLED_SECRET_ACCEPTED,並留下 1 張 Receipt。這表示本機 adapter 仍接受已停用版本。畫面不公開 secret value,也不能證明 proposal 取得 canary;這次沒有呼叫 Azure Key Vault。
真實使用者碰到的通常不是 version number,而是「供應商那邊剛換過密碼,怎麼還是連不上?幫我看看。」這句話不含 secret 名稱、vault URL、工具函式或預期結果;版本選擇是受信任 adapter 與部署設定的責任。
本機 Lab 不連 Azure,也不放任何真實 secret。測試框架另外準備兩個明顯的 canary,專門檢查舊版本是否仍被選到,以及值是否流進輸出 sink。它們不屬於 user message:
version 1 = SYNTH_MAGIC_PANDA_VENDOR_V1
version 2 = SYNTH_MAGIC_PANDA_VENDOR_V2
attack fixture 先建立 version 1,再 rotate 到 version 2。rotation 會把 version 1 標記為 disabled;before profile 仍容許 adapter 解析這個 disabled reference,因此留下:
{
"action": "use_secret_version",
"actor": "vault-agent",
"allowed": true,
"resource_id": "vendor-token:v1",
"reason_code": "LEGACY_DISABLED_SECRET_ACCEPTED",
"receipt_count": 1,
"side_effect_count": 0,
"details": {
"selected_version": 1,
"selected_enabled": false
}
}
這張 Receipt 表示本機 adapter 接受了已停用的 reference,問題先出在應用程式選版本的這一步。至於供應商是否仍接受舊密碼,還要對實際服務測試,不能由這張紀錄推定。
這裡也要分清楚 lab 與 Azure 的差別。本機 store 在輪替時會自動停用 version 1;Azure Key Vault 建立新版本時,舊版本預設仍是 enabled,要由輪替流程自己停用。版本真的停用後,Azure 也不會再回傳它的值。所以在雲端上,這個漏洞比較常見的樣子是應用程式快取了舊值、設定檔釘住舊版本,或供應商那邊根本沒有撤銷舊密碼,而不是 Key Vault 把停用的版本交出來。
同一個 canary 還會刻意進入五個 raw fixture:
這裡沿用 Day 14 的 serializer。before scan 必須真的看到 canary,after-export scan 才要求五個 sink 全為 0;如果測試資料從頭就沒有 canary,零命中只是分母消失。
Azure Key Vault 的 control plane 與 data plane 獨立授權。Key Vault Contributor 用來管理 vault control plane,在 Azure RBAC permission model 下不能讀 secret value;只需讀值的 runtime 使用 Key Vault Secrets User,管理 secret 版本與生命週期的 operator 才需要 Key Vault Secrets Officer。Key Vault RBAC guide
| 工作 | 建議角色 | 不代表 |
|---|---|---|
| 建立/設定 vault | 適當 control-plane role | 可讀 secret value |
| runtime 讀既有 secret | Key Vault Secrets User |
可 set/delete/rotate |
| secret lifecycle operator | Key Vault Secrets Officer |
可管理 role assignment |
| Foundry BYO Key Vault connection | resource MI 使用官方要求的最小 connection role | 等同應用 runtime reader |
legacy access-policy model 要另外小心。Microsoft 文件提醒,若 principal 擁有包含 Microsoft.KeyVault/vaults/write 的 Contributor 類 control-plane 權限,它可能替自己加入 access policy,進而取得 data-plane 權限。使用 Azure RBAC permission model 時,Key Vault Contributor 本身仍不能讀值;role assignment 又是另一項權限。
使用 Key Vault control-plane API version 2026-02-01 或更新版本建立新 vault,若沒有明確指定,預設 access-control model 是 Azure RBAC;既有 vault 不會因為換 API version 更新就自動從 access policy 遷移。所有早於 2026-02-01 的 Key Vault control-plane API versions 預定於 2027-02-27 退役,data-plane API 不在這次退役範圍。Key Vault access-control default
2026-02-01 是 API version,不是某一天才開始生效的 feature flag;IaC 必須明確保存實際使用的 version 與 permission model。
接著沿著 proposal 追蹤核准的 reference,確認實際值直到 adapter 才被解析。

action hash 綁定 reference 與版本政策,前面的資料不包含原始 secret value。授權先對著 reference 做,真正需要使用時才讀值。這張圖沒有驗證 Azure SDK、vault resource 白名單或網路路徑。
SecretReference 是 strict、frozen 且 extra="forbid" 的 Pydantic model,只接受 vault_alias、name 與整數 version。Server 用這三個值派生 data-plane scope;path traversal、字串型 version 或額外的 value 欄位會在入口被拒絕。
接著把同一個 reference 綁過四個階段:
SecretUseRequest
→ trusted planner 建立 SecretUseProposal
→ policy-owned SecretDecisionAuthority 獨立簽發 opaque decision
→ SecretPolicyDecision 綁定 action_hash、reference 與 provenance
→ adapter 先驗 decision provenance/hash/reference
→ 全部通過後才讀取指定 secret version
D19-A02 讓 request 指向 vendor token,proposal 卻換成另一個 reference。兩份資料各自符合 schema,仍因不屬於同一筆操作而得到 SECRET_PROPOSAL_BINDING_DENIED,Receipt 為 0。另一筆測試改寫 action hash,adapter 則回 SECRET_ADAPTER_ACTION_HASH_MISMATCH。Spy store 同時確認這兩條拒絕路徑都沒有讀取 secret,檢查確實發生在取值以前。
使用者只需要表達「確認供應商連線」,SecretReference 由受信任的設定與 planner 建立。模型若需要看 reference,也只提供不含值的 alias;proposal 與 policy record 同樣不帶 secret。等到真正要呼叫舊 API 前,adapter 才解析實際值。先用本機 API 看這條流程:
from magic_panda_agent.stages.day19 import cumulative_stage_replay
evidence = cumulative_stage_replay()
assert evidence["disabled_before"]["reason_code"] == (
"LEGACY_DISABLED_SECRET_ACCEPTED"
)
assert evidence["disabled_before"]["details"]["selected_enabled"] is False
assert evidence["disabled_after"]["reason_code"] == "SECRET_VERSION_DISABLED"
assert evidence["disabled_after"]["receipt_count"] == 0
assert evidence["confused_after"]["reason_code"] == (
"SECRET_PROPOSAL_BINDING_DENIED"
)
assert evidence["current_after"]["reason_code"] == "SECRET_REFERENCE_RESOLVED"
assert evidence["current_after"]["details"]["selected_version"] == 2
assert evidence["current_after"]["details"]["pipeline_stages"] == [
"request", "proposal", "policy", "adapter"
]
assert evidence["current_after"]["details"]["adapter_binding_verified"] is True
assert evidence["current_after"]["details"]["after_hits"] == 0
底層使用 LocalRotatingSecretStore,讀取權綁定 (tenant_id, subject)。因此另一個租戶即使也有 vault-agent,仍然無法沿用 bamboo-hq 的 grant。
current_after 的公開證據只留下 reference alias、version 與 redaction count,不含 secret value。實際 shape 是:
{
"resource_id": "vendor-token:v2",
"reason_code": "SECRET_REFERENCE_RESOLVED",
"receipt_count": 1,
"side_effect_count": 0,
"details": {
"selected_version": 2,
"selected_enabled": true,
"before_hits": 5,
"after_hits": 0
}
}
接到 Azure SDK 時,也保留相同分工。下面是 adapter 示意碼,沒有在這次 lab 中執行;注意不要把 get_secret(...).value 寫進紀錄:
from azure.identity import ManagedIdentityCredential
from azure.keyvault.secrets import SecretClient
credential = ManagedIdentityCredential()
client = SecretClient(
vault_url="https://<vault-name>.vault.azure.net",
credential=credential,
)
secret = client.get_secret("vendor-token", version="<approved-version>")
call_legacy_dependency(secret.value) # value 不進 prompt、log、trace
get_secret() 不指定版本時會拿到最新版本,這正是前面說的「會移動的選擇」。示意碼因此明確帶入核准的版本;開源教材 Day 19~30 的 examples/cloud/key_vault_reader.py 也同樣要求 SYNTHETIC_SECRET_VERSION。
範例中的 placeholder 必須由部署設定提供,不能接受 request body 傳入任意 vault URL。正式 adapter 也應限制 cloud、scheme、host、port、userinfo、query 與 path, 避免將 bearer 送往看起來很像 Key Vault 的 hostile endpoint。本篇還多綁一層「核准的 vault resource」:另一個同樣長得像 *.vault.azure.net 的合法 URL 仍得到 KEY_VAULT_RESOURCE_DENIED。URL 長得像 Azure,不代表它就是這筆部署核准的 vault。
一輪完整的輪替,還要把新舊版本、cache 與下游撤銷一起檢查:
建立新 credential/secret version
→ 下游暫時接受新舊值(若產品支援)
→ workload 取得新版本
→ 清除 process 與 distributed cache
→ 用新值完成正向 smoke
→ 停用/撤銷舊值
→ 用舊值執行負向 smoke,要求失敗
→ 觀察錯誤率與舊版本使用量
→ 保存 rollback 與事件紀錄
如果下游只能接受一把 key,新舊版本就無法自然重疊,需要安排維護時段或協調兩端切換。Cache 也要有短 TTL、版本感知與明確 invalidation,輪替才不會依賴 process 碰巧重新啟動。
Soft delete 與 purge protection 也不是同一個開關。Microsoft 建議同時啟用 soft delete、purge protection 與自動 rotation;soft delete 提供 7–90 天的 recovery window,purge protection 才能在 retention 到期前阻止永久清除。Secure Key Vault
假設 Foundry connection 指向 customer-owned Key Vault,今天仍要分兩筆觀察:平台能否使用 connection secret,以及工程司 runtime 能否以自己的身分讀取指定 vendor secret。輪替後,前者要核對 connection 是否更新;後者要證明舊版本與舊 credential 已被拒絕。兩個成功畫面即使都出現 Key Vault 名稱,也不能互相代替。
若大魔術熊貓工程司替 Foundry resource 設定 customer-owned Key Vault 來保存 connection secrets,這條權限與 FastAPI runtime 讀單一 vendor secret 不同。官方文件目前列出幾個重要限制:Set up an Azure Key Vault connection
Key Vault Secrets Officer role assignment 最多可能需要約 30 分鐘傳播。官方範例為 Foundry resource MI 指派 Key Vault Secrets Officer,用途是管理 connection secrets。只讀 vendor secret 的 runtime 通常使用 Secrets User;要按實際 set、delete 或 read 工作選角色,不能直接複製 connection 管理者的權限。
讀值之後,再掃描 response、event、outbox、error 與 trace 是否留下 canary。

五個出口合計從 before_hits=5 降到 after_hits=0,畫面只留下 alias 與次數。這組掃描不涵蓋未知編碼、heap、crash dump 或外部 telemetry,所以仍要各自檢查其他保存位置。
再把版本選擇與三種讀取拒絕分開看,確認舊版本真的不能用了。

舊版的 old_enabled=false,讀取結果為 SECRET_VERSION_DISABLED;目前與實際解析的版本都是 2。未授權使用者、外租戶的同名 subject,以及另一個 secret name,都得到 SECRET_READER_DENIED。這次沒有呼叫 Azure Key Vault,圖中也沒有驗證 Receipt、cache 失效或雲端輪替。
最後獨立查看 endpoint 格式與核准資源,確認網址符合格式之外,還指向正確的 vault。

合法 alias 解析成功;http、suffix、userinfo、port、path、query、fragment、whitespace 八種問題都被拒絕,非核准資源則得到 KEY_VAULT_RESOURCE_DENIED。這裡只測合成 URL 與本機白名單,沒有建立 Azure Key Vault connection,也沒有驗證 DNS、private endpoint 或 Azure RBAC。
進入 day19/ 後執行:
uv run pytest tests/stages/day19/test_acceptance.py -q
這組驗收要確認:
SECRET_READER_DENIED。get_version() 前拒絕,spy store read count 維持 0。get_version() 得到 SECRET_VERSION_DISABLED。SecretReference 拒絕額外 value、path traversal 與型別 coercion;scope 由 server 派生。KEY_VAULT_RESOURCE_DENIED。Day 19 的驗收記錄為 5 passed。下面查看 disabled version 的前後比較、D19-A02 reference 置換與 endpoint/resource matrix,輸出只保留 reference 和判斷結果,不顯示 secret value。從 day19/ 執行:
PYTHONPATH=src:. uv run python scripts/render_stage_evidence.py --day 19 --evidence-id d19-disabled-secret-version-matrix
再沿著 request → proposal → policy → adapter 核對同一份 reference 與 action hash,確認選到 version 2。五個輸出位置的命中數應從 before_hits=5 降到 after_hits=0。輸出只保留合成 alias 與次數,並用 AZURE_KEY_VAULT_REQUEST_NOT_EXERCISED 標明沒有呼叫 Azure:
PYTHONPATH=src:. uv run python scripts/render_stage_evidence.py --day 19 --evidence-id d19-current-secret-redaction
只要 workload 合法取得 secret,遭入侵的 process 仍可在記憶體中使用或外洩它。Key Vault 不知道某次 get 之後是否被送進模型,也不會自動清除應用 cache。能改用 Managed Identity、workload federation 或短效 token 的 dependency,應優先減少 secret 數量。
本機 rotate() 也沒有跨 thread/process 的 lock、ETag 或 transaction。兩個 operator 同時輪替可能產生競態;正式流程需要 single-writer/lease 或外部協調,並在 rotation 後重新讀取 active version 驗證。
ISO/IEC 27001:2022 與 27002:2022 在這裡只提供 credential 建立、保管、授權、輪替、撤銷與紀錄覆核的治理視角。這份 Lab 沒有 ISMS scope、適用性聲明或稽核,不能寫成符合 ISO。
明天會把 Key Vault 放回網路拓撲:誰能讀值之外,還要確認 DNS 解析到哪裡、PNA 是否關閉,以及 Agent 出站能不能偷偷走到未核准 endpoint。