iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0
Kubernetes

防範軟體供應鏈攻擊:從零打造具備硬性阻擋能力的雲原生 CI/CD 流水線系列 第 4

Day 4:建立全系列共用的自動化規範:PowerShell 5.1 的 JSON、編碼與物件流三大陷阱

  • 分享至 

  • xImage
  •  

這篇看起來跟 Kubernetes 無關,但在 Windows 工作站上驅動 CRC 加 Tekton 這整套流程,這一關過不了就沒有後面。

今日目的:建立全系列共用的 PowerShell JSON/編碼/物件流處理規範,避免後續每個 Task 各自踩同樣的坑。

先備知識:本篇銜接 Day 3〈你看到的狀態,不是系統正在做的事〉,讀過該篇即可上手。

命令列自動化環境差異

Day 3 收尾時說,PowerShell 把 CLI 輸出重新包裝成物件的時候,會在使用者沒注意到的地方悄悄改變型別或省略內容。這篇接著看的是同一個模式的另外三種變體:PowerShell 替你處理引號、處理檔案編碼、處理陣列型別,處理對了不會有人注意,處理錯了大半也不會報錯,錯誤只會留到下游才浮現。

同一段 curl 指令,換到 PowerShell 5.1 執行就會讓 Gitea 回 invalid character。網路正常、帳密正確、路徑沒錯——問題出在指令送出去之前,PowerShell 就已經把它改壞了。

這篇會處理三類主題、共五個具體現象,細節在後面各節逐一展開,先列成一張表對照:

陷阱 看起來會發生什麼 實際發生什麼 有沒有失敗訊號
引號拼接(curl.exe 傳參) JSON 正常送出 未逸出的雙引號在 argv 解析階段被吃掉,Gitea 收到損毀的 JSON 有,422
ConvertTo-Json -Depth 預設 2 巢狀結構完整序列化 第三層以下的物件變成 System.Collections.Hashtable 這類型別名稱字串
Set-Content 預設編碼 檔案寫入成功 非 ASCII 字元(如中文)被換成 ?
Out-File> 預設編碼 檔案寫入成功 多寫入 UTF-16 LE 的 BOM 位元組 有,422
單行輸出摺疊 [0] 取到第一行 只有一行結果時型別摺疊成純量字串,[0] 取到第一個字元

五個現象裡,引號拼接跟 BOM 這兩個會直接讓下游回錯誤;另外三個不會,出錯也不吭聲。標題講的「三大陷阱」是三個主題分類(JSON 處理、檔案編碼、物件流),不是現象數量。

實測環境(後面所有指令輸出都基於這組版本,換一組版本不保證重現):

項目 版本/設定
PowerShell 5.1.26100.8875(Windows 內建版本)
作業系統 Windows 11 Pro(build 26200)
curl.exe 8.21.0(Schannel/libcurl 8.21.0)
Gitea 1.26.1
主控台作用中字碼頁(chcp 65001(UTF-8)
系統 ANSI 字碼頁(ACP,Set-Content 實際採用的編碼來源) 1252(Windows-1252,西歐語系)——跟上一行的 chcp 讀數不同,兩者是不同層級的設定,細節見後面「編碼邊界」一節

例如以下常見的 Bash 風格命令列呼叫:

$title = "fix: update config"
curl.exe -X POST -H "Content-Type: application/json" `
    -u "<username>:<password>" `
    "$giteaUrl/pulls" `
    -d '{"title":"$title","head":"feature","base":"main"}'

🪟 Windows/CRC 限定:本篇整篇處理的 JSON 轉義、編碼與物件流三大陷阱,都是 PowerShell 5.1(Windows 內建版本)特有的行為,在 PowerShell 7 上每一項的預設值都不同($PSNativeCommandArgumentPassing、預設無 BOM UTF-8、超深度會發警告),不能拿 7 來驗證 5.1 的行為

字串解析與動態變數陷阱:從字串拼接轉向 ConvertTo-Json

問題現象

在透過 curl.exe 送出包含動態變數的 JSON body 時,若採用字串拼接或直接內嵌變數:

# 雙引號寫法:外層雙引號會先被 PowerShell 吃掉一層,剩下的內容仍需正確逸出(見下方實測)
curl.exe -d "{\"title\":\"$title\",\"head\":\"feature\"}"

# 單引號寫法:單引號會完全抑制變數展開,$title 將被當作純文字字串發送
curl.exe -d '{"title":"$title","head":"feature"}'

上述寫法皆無法正確傳遞動態變數,但兩者壞掉的原因並不對稱,也都比字面上看起來嚴重。

真的拿雙引號那行去打 Gitea,curl -v 看到的請求是 Content-Length: 2——整段 JSON body 最後只剩兩個位元組送出去,Gitea 回應:

HTTP/1.1 422
{"message":"[]: jsontext: invalid character '\\' at start of value after offset 1","url":"https://gitea-gitea.apps-crc.testing/api/swagger"}

offset 1 的字元是反斜線,跟 Content-Length: 2 對得上:送到 Gitea 的 body 就是 {\ 這兩個位元組,其餘內容在傳參給 curl.exe 這一步就已經整個消失,不是「轉義寫錯」這種語法層級的小問題。

拆解:引號到底是誰吃掉的

要看清楚原生程式實際收到什麼cmd /c echo 是不夠的——cmd 會把參數再解析一輪,你看到的是 cmd 的輸出,不是 PowerShell 交出去的東西。用 .NET Framework 內建的編譯器做一個傾印器,中間就沒有第二個解析器:

$code = @'
using System;
class ArgDump {
    static int Main(string[] args) {
        Console.WriteLine("RawCommandLine: " + Environment.CommandLine);
        Console.WriteLine("ArgCount: " + args.Length);
        for (int i = 0; i < args.Length; i++) {
            Console.WriteLine(String.Format("argv[{0}] = <{1}>", i, args[i]));
        }
        return 0;
    }
}
'@

Add-Type -TypeDefinition $code `
         -OutputType ConsoleApplication `
         -OutputAssembly (Join-Path $PWD "argdump.exe")

Environment.CommandLine 是作業系統交給行程的原始命令列字串,未經 .NET 的參數切分。有了它,才分得出「PowerShell 沒送出去」跟「送了但在接收端被拆掉」。

實測:

PS> $json = '{"title":"hello","body":"world"}'
PS> .\argdump.exe -d $json
RawCommandLine: "C:\...\argdump.exe" -d {"title":"hello","body":"world"}
ArgCount: 2
argv[0] = <-d>
argv[1] = <{title:hello,body:world}>

注意這兩行的差異:RawCommandLine 裡雙引號是在的,argv[1] 裡不見了。

這推翻了一個常見的說法。引號不是 PowerShell 移除的——PowerShell 把它們原樣放進命令列,只是沒有替你逸出;拆解發生在接收端,Windows 的 CommandLineToArgvW 依 C runtime 規則解析,未逸出的 " 被當成引用符號消耗掉。

搞清楚機制之後,四種寫法的成敗就都能預測,而且實測全部對得上:

寫法 argv[1] 實測 為什麼
-d $json {title:hello,body:world} 引號未逸出,被 argv 解析吃掉
-d ($json -replace '"','\"') {"title":"hello","body":"world"} \" 正是 CommandLineToArgvW 期待的逸出形式
-d "'$json'" '{title:hello,body:world}' 單引號只是普通字元,保不住裡面的雙引號
--% -d {"title":"hello"} {title:hello} 無效

最後一列值得單獨講。--% 是 PowerShell 的停止解析符號,直覺上應該能保住引號——但實測沒有。因為問題不在 PowerShell 的解析,而在缺少逸出;--% 停掉的是不是問題的那一半。

PowerShell 官方文件 about_Parsing 對這套行為有明確說明,並在 7.3 加入 $PSNativeCommandArgumentPassing 之後,回頭把舊行為命名為 Legacy——5.1 本身沒有這個切換開關,也不存在這個變數,Legacy 只是後續版本替舊行為取的名字,5.1 使用者不必去自己的版本裡找這個設定。

單引號寫法的真正失敗模式

單引號寫法原本預期的失敗模式,是 $title 不會展開、標題會變成字面文字 $title、Gitea 仍會回 201——這個預期在實測裡並不成立。對這個叢集上真實的 Gitea 端點,逐字跑一次上面那行單引號寫法:

curl.exe -X POST -H "Content-Type: application/json" `
    -u "${giteaUser}:${giteaPass}" -k `
    "$giteaUrl/api/v1/repos/$repo/pulls" `
    -d '{"title":"$title","head":"test/day4-quote-repro","base":"main"}'

範圍提醒-k 是跳過 TLS 憑證驗證,這裡是因為 CRC 對外服務用的是自簽憑證,測試才需要加;正式環境的 Gitea 若有受信任的憑證,不應該帶這個旗標,否則等於關掉憑證驗證,中間人可以攔截或竄改流量。

拿到的不是 201,而是:

HTTP_STATUS:422
{"message":"[]: jsontext: invalid character 'i' in literal true (expecting 'r') after offset 2","url":"https://gitea-gitea.apps-crc.testing/api/swagger"}

錯誤裡的 'i''r' 這組線索能對上:Gitea 收到的 body 並不是 {"title":...},而是雙引號整組消失之後的 {title:...}——JSON 解析器看到不帶引號的裸字 title,先當成要解析 true 這個字面值(開頭同樣是 t),比對第二個字元時預期 rtrue 的第二個字元)卻讀到 ititle 的第二個字元),才回報這句看起來語意不明的錯誤。

這正是本篇開場那句「Gitea 回 invalid character」的真正來源。而且要注意:外層包的是單引號、PowerShell 完全沒有對內容做任何變數展開,引號一樣被吃掉。 因為拆解不發生在 PowerShell 這一端。真正的坑不是「單引號會不會展開變數」,是「傳參給原生 exe 時,內嵌的雙引號能不能原封不動送到」,這一步在單引號與雙引號兩種寫法下都不保證成立。

怎麼修:Hashtable 搭配 ConvertTo-Json 靜態檔轉載

在 PowerShell 5.1 中,處理動態 JSON Payload 比較穩妥的做法,是建立 PowerShell 哈希表(Hashtable)或自訂物件,再透過 ConvertTo-Json 轉成標準 JSON。

這個做法不需手動處理任何雙引號轉義,也能完整支援動態變數展開。將產出的 JSON 寫入臨時檔案後,改用 curl.exe 的 -d "@tmpFile" 讀檔案內容發送——JSON 內容本身因此不會再經過「傳參給原生 exe」這一關,不會再被裡面的雙引號絆到;但 @$tmpFile 這個參數字串本身仍然要走 PowerShell 的參數解析規則,路徑含空白或特殊字元時一樣要正確加引號:

$title = "fix: update config"
$headBranch = "feature"

# 1. 用 Hashtable 裝資料與動態變數,[ordered] 保留欄位順序方便之後比對 payload diff
$payloadObj = [ordered]@{
    title = $title
    head  = $headBranch
    base  = "main"
    body  = ""
}

# 2. 透過 ConvertTo-Json 自動處理型別轉換與字串轉義
$jsonBody = $payloadObj | ConvertTo-Json -Compress

所有字串轉義、特殊字元與型別對映都交給 ConvertTo-Json 處理,不用再手動拼接容易出錯的 JSON。

[ordered] 不是可有可無的細節。5.1 的一般 hashtable 不保證列舉順序,實測:

PS> @{ zebra=1; alpha=2; mango=3; beta=4; kiwi=5 } | ConvertTo-Json -Compress
{"alpha":2,"beta":4,"kiwi":5,"zebra":1,"mango":3}

PS> [ordered]@{ zebra=1; alpha=2; mango=3; beta=4; kiwi=5 } | ConvertTo-Json -Compress
{"zebra":1,"alpha":2,"mango":3,"beta":4,"kiwi":5}

序列化出來的字串每次都可能不同,放進 Git 會產生假變更,比對兩次請求也看不出真正的差異。

延伸陷阱:-Depth 截斷

上面這行 ConvertTo-Json 呼叫還藏著另一個陷阱,跟字串拼接或動態變數展開無關,是物件序列化深度的限制:-Depth 預設值是 2,超過兩層的巢狀結構會被截斷成 System.Collections.Hashtable 這類字串,而且不會報錯。這篇的範例都是扁平結構所以沒事,但組 Tekton Task 或 Kyverno ClusterPolicy 這類多層巢狀結構時很容易撞到。用一個會踩到這個限制的 Tekton Task 結構實測:

$task = @{
    apiVersion = "tekton.dev/v1"
    kind = "Task"
    metadata = @{ name = "demo-task" }
    spec = @{
        params = @( @{ name = "image"; type = "string"; default = "alpine" } )
        steps  = @( @{ name = "build"; image = "alpine"; script = "echo hello" } )
    }
}

$task | ConvertTo-Json -Compress

預設 -Depth 2 的結果:

{"spec":{"params":["System.Collections.Hashtable"],"steps":["System.Collections.Hashtable"]},"metadata":{"name":"demo-task"},"apiVersion":"tekton.dev/v1","kind":"Task"}

paramssteps 陣列裡原本該有的內容全部變成同一句字面字串 "System.Collections.Hashtable",整段呼叫沒有任何錯誤或警告。加上 -Depth 10 才是正確結果:

{"spec":{"params":[{"default":"alpine","name":"image","type":"string"}],"steps":[{"script":"echo hello","name":"build","image":"alpine"}]},"metadata":{"name":"demo-task"},"apiVersion":"tekton.dev/v1","kind":"Task"}

這個陷阱最麻煩的地方在於它有多安靜。用一個四層結構把整條鏈跑一次:

PS> $deep = @{ L1 = @{ L2 = @{ L3 = @{ L4 = "bottom" } } } }
PS> $json = $deep | ConvertTo-Json -Compress
PS> $json
{"L1":{"L2":{"L3":"System.Collections.Hashtable"}}}

PS> $back = $json | ConvertFrom-Json
PS> $back.L1.L2.L3
System.Collections.Hashtable
PS> $back.L1.L2.L3.GetType().FullName
System.String

ConvertTo-Json 不警告、產出的 JSON 語法完全合法、ConvertFrom-Json 成功解析、物件正常還原、其他欄位都對。如果驗證腳本只檢查「有沒有丟例外」,這份損毀的資料會被判定為通過。

一律顯式指定:

$jsonBody = $payloadObj | ConvertTo-Json -Compress -Depth 10

PowerShell 7.1 之後超過深度會發出警告。這個修正沒有回移到 5.1,且 Windows PowerShell 已進入維護模式(只收安全性修補),所以 5.1 不會有。相關討論見 PowerShell/PowerShell issue #8393 與 #23960。

編碼邊界:UTF-16 LE 與 BOM 位元組序列

問題現象

$jsonBody 寫入臨時檔案時,若直接呼叫 PowerShell 5.1 內建 Cmdlet,會踩到兩個機制不同的編碼坑:

$jsonBody | Set-Content -Path $tmpFile   # 坑一:預設系統 ANSI 字碼頁,無法表示的字元變成 ?
$jsonBody | Out-File -FilePath $tmpFile  # 坑二:預設 UTF-16 LE + BOM
$jsonBody > $tmpFile                     # 坑二同源:重導向 > 同樣是 UTF-16 LE + BOM

這三種寫法送到接收端(通常為 Linux 容器環境)後,其實是兩種性質不同的結果:Out-File> 因為多寫入了 BOM 位元組,會被下游 JSON 解析器判定為非法字元,直接拒絕並回傳錯誤;Set-Content 則相反,不會觸發任何錯誤,只是無法表示的字元在寫入當下已經悄悄流失,接收端收到的是一份格式合法、但內容跑掉的 payload。

實際用 Format-Hex 攤開 Out-File 產生的檔案,開頭兩個位元組就是肉眼看不到的 BOM:

PS> $jsonBody | Out-File -FilePath $tmpFile
PS> Format-Hex $tmpFile | Select-Object -First 1

           Path: C:\Users\...\AppData\Local\Temp\api-payload.json

           00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F

00000000   FF FE 7B 00 22 00 74 00 69 00 74 00 6C 00 65 00  .þ{.".t.i.t.l.e.

FF FE 就是 UTF-16 LE 的 BOM,後面 7B 00 才是真正的內容開頭 {(每個 ASCII 字元後面都夾了一個 00,這正是 UTF-16 LE 用兩個位元組編碼一個字元的痕跡)。Gitea 端的 JSON 解析器看到檔案第一個位元組不是 {0x7B),直接判定非法。

Set-Content 這條路線壞得比較安靜,不會有明顯的位元組序列可看,只會讓非 ASCII 字元消失:

PS> "測試" | Set-Content -Path $tmpFile
PS> Get-Content $tmpFile
??

中文字「測試」寫進去,讀回來變成兩個問號——寫入當下就已經永久遺失,不是讀取時的顯示問題。

實際拿一份帶 BOM 的檔案打到 Gitea API,收到的錯誤訊息也印證了同一件事:

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json;charset=utf-8

{"message":"[]: jsontext: invalid character '\\xff' at start of value","url":"https://gitea-gitea.apps-crc.testing/api/swagger"}

\xff 正是 UTF-16 LE BOM 的第一個位元組(0xFF),Gitea 的 JSON 解析器在還沒讀到任何合法內容前就先撞見它,直接回傳 422。

機制剖析:這個行為換一台機器就不同

Set-Content 不帶 -Encoding 時用的是系統 ANSI 字碼頁,而那個值來自 GetACP()——不是 chcp 顯示的主控台字碼頁。這兩層混為一談會完全誤判行為。

這台機器 chcp 顯示 65001(UTF-8),但 Set-Content 寫出來的中文變成 ?,因為它讀的是另一層。確認方式:

Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Nls\CodePage' -Name ACP

在另一台同樣跑 5.1.26100.8875 的機器上,這個值是 65001,Set-Content 寫出的是正確的 UTF-8 無 BOM。 逐檔案比對六種寫法的位元組:

寫法 位元組 前 4 bytes 實際編碼
Set-Content(無 -Encoding 26 7B 22 74 69 UTF-8 無 BOM + CRLF
Out-File(無 -Encoding 38 FF FE 7B 00 UTF-16LE + BOM + CRLF
Set-Content -Encoding UTF8 29 EF BB BF 7B UTF-8 有 BOM + CRLF
Out-File -Encoding UTF8 29 EF BB BF 7B UTF-8 有 BOM + CRLF
[IO.File]::WriteAllText($p,$s) 24 7B 22 74 69 UTF-8 無 BOM,無行尾
WriteAllText + UTF8Encoding $false 24 7B 22 74 69 同上

(測資為 {"title":"中文測試"},12 個 ASCII 加 4 個中文字。)

兩台機器的差別來自 Region 設定裡「Beta:使用 Unicode UTF-8 提供全球語言支援」這個核取方塊。Get-WinSystemLocale 兩台都是 zh-TW——那個值不會告訴你 ACP 是什麼。

這件事的實務意義比單一結論更重要:同一份腳本、同一個 PowerShell 版本、看起來一樣的兩台 Windows,寫出來的位元組不同,而且兩邊都不報錯。 如果你的 pipeline 在自己機器上好好的、到同事機器上壞掉,這是第一個該查的地方。

另外兩個從上表能直接讀出來的重點:

  • -Encoding UTF8 在 5.1 是帶 BOM 的。 這是 5.1 與 7 最常見的落差,7 的 UTF8 預設不帶 BOM。想要無 BOM,-Encoding UTF8 幫不了你。
  • 只有 WriteAllText 不附加行尾。 所有 cmdlet 一律補 CRLF。

PowerShell 官方文件 about_Character_Encoding 證實了 Out-FileSet-Content 的不對稱:Out-File 與重導向一律用 UTF-16LE;Set-Content 在目標檔案不存在或空白時採用 Default 編碼,也就是系統當前地區設定的 ANSI 舊式字碼頁。Kubernetes 生態組件與 REST API 規範預設採用無 BOM 的 UTF-8,兩個內建做法都不符合這個預期,只是壞的方式不同。

怎麼修:呼叫 .NET API 明確宣告無 BOM UTF-8

使用 .NET 的 System.IO.File::WriteAllText 靜態方法,明確指定無 BOM 的 UTF-8 編碼:

$tmpFile = "$env:TEMP\api-payload.json"

# 傳入 $false 代表不寫入 BOM 標頭
[System.IO.File]::WriteAllText($tmpFile, $jsonBody, (New-Object System.Text.UTF8Encoding $false))

try {
    curl.exe -s -X POST `
        -H "Content-Type: application/json" `
        -u "<username>:<password>" `
        "$giteaUrl/pulls" `
        -d "@$tmpFile"
} finally {
    Remove-Item $tmpFile -Force
}

這個做法把控制項收斂成一個:編碼由你在呼叫時明確指定,不依賴任何系統設定。繞開的問題,不用管它有沒有被設對。

一定要用絕對路徑。 Set-Location 不會同步更新 [Environment]::CurrentDirectory.NET 認定的目前目錄可能還停在 C:\windows\system32

PS> "PowerShell:$($PWD.Path)"
PowerShell:C:\Users\...\AppData\Local\Temp\ps51-verify
PS> ".NET      :$([Environment]::CurrentDirectory)"
.NET      :C:\windows\system32

用相對路徑呼叫 WriteAllText,檔案會寫到你完全沒預期的地方,而且不報錯——你只會覺得「檔案怎麼不見了」。

密碼處理提醒:範例中的 <username>:<password> 僅為佔位符,正式腳本不應在指令列中硬編碼明碼密碼。本系列後續會處理憑證管理,此處僅聚焦於 JSON 轉義與編碼問題。

怎麼驗證你寫對了

寫檔之後不要直接送出,先用指令確認檔案本身沒問題:

# 1. 確認無 BOM:第一個位元組應是 7B({),不是 EF BB BF 或 FF FE
Format-Hex $tmpFile | Select-Object -First 1

# 2. 確認 JSON 合法且內容正確——讀檔一樣要帶 -Encoding UTF8,否則這一步會在讀取端犯同一類編碼錯誤
Get-Content $tmpFile -Raw -Encoding UTF8 | ConvertFrom-Json

第一步確認的是編碼層面。第二步確認的是內容層面,但這一步本身也有陷阱:Get-Content 5.1 版不加 -Encoding UTF8 時,一樣是用系統 ANSI 字碼頁去讀檔。

差別是這裡壞的不是檔案本身,是這次讀取的解碼結果:Set-Content 寫入當下字元就已經永久換成問號,資料回不來;Get-Content 讀錯編碼時,磁碟上的原始位元組完好無缺,只是這一次解讀的結果是亂碼,換個讀法就能拿回正確內容。用一份含中文欄位的 payload 實測兩種讀法:

PS> Get-Content $tmpFile -Raw | ConvertFrom-Json | ConvertTo-Json -Compress
{"title":"fix: update config","note":"ä¸­æ–‡æ¬„ä½æ¸¬è©¦","base":"main"}

PS> Get-Content $tmpFile -Raw -Encoding UTF8 | ConvertFrom-Json | ConvertTo-Json -Compress
{"title":"fix: update config","note":"中文欄位測試","base":"main"}

不加 -Encoding UTF8 那組,中文欄位讀回來是亂碼,但 ConvertFrom-Json 完全不報錯,物件照樣還原、其他欄位照樣正確——如果驗證腳本只檢查「有沒有丟例外」,這組亂碼會直接被判定為驗證通過。

這裡有一條可以推廣的原則:驗證工具要跟可能出錯的機制獨立。 ConvertFrom-JsonConvertTo-Json 是同一套序列化家族,用它驗證同源的錯誤永遠會通過;Format-Hex 觀察的是產物本身的位元組,獨立於產生它的機制,所以抓得到。這條原則在後面幾天處理 Tekton 與 Kyverno 的驗證時會反覆用到。

為什麼不用 Invoke-RestMethod

Invoke-RestMethod 可以省掉暫存檔那一步,但送字串 Body 時有個預設值會害人:-ContentType 沒有明確帶上 charset=utf-8 時,字串會以 latin-1(ISO-8859-1) 編碼送出。

架一個把 request body 原始位元組印出來的接收端實測,同一份 payload 三種送法:

送法 ByteCount Hex(中文段) 結果
-ContentType "application/json" + 字串 16 3F 3F 3F 3F 中文全毀
-ContentType "application/json; charset=utf-8" + 字串 24 E4 B8 AD E6 96 87 ... 正確
-ContentType "application/json" + byte[] 24 E4 B8 AD E6 96 87 ... 正確

第一列的 Content-Length 就是 16——四個中文字各被壓成一個 3F?),資料在送出前就毀了。Invoke-RestMethod 不報錯,接收端也會照樣回 201,只是標題變成一排問號。

這個編碼是寫死的,跟系統設定無關。用一個 latin-1 表示得了的字元驗證:

PS> Invoke-RestMethod -Uri $dumpUrl -Method Post -ContentType "application/json" -Body '{"title":"café"}'
ContentLength: 16
Hex: 7B 22 74 69 74 6C 65 22 3A 22 63 61 66 E9 22 7D
QuestionMarkBytes: 0

é(U+00E9)活下來變成單一位元組 E9,中文則沒有對應字元而變 3F。兩次都是 16 bytes:latin-1 是單位元組編碼,16 個字元對應 16 個位元組。

這是 HTTP 的歷史遺留——RFC 2616 曾規定 text/* 沒帶 charset 時預設 ISO-8859-1,HttpWebRequest 保留了這個行為。前面 Set-Content 那個坑會隨系統 ACP 設定改變,這個不會,換哪台機器都一樣。

要用的話,送 byte[] 最乾淨:

$bytes = [System.Text.Encoding]::UTF8.GetBytes($jsonBody)
Invoke-RestMethod -Uri $url -Method Post -ContentType "application/json" -Body $bytes

編碼由這行 GetBytes 決定,不經過命令列解析也不經過管線,連 charset 都不用帶。

本篇主線仍用 curl.exe 加暫存檔,理由是每一步都能單獨檢查(Format-Hex 看編碼、ConvertFrom-Json 看內容),在 CI 裡也留下可事後稽核的產物。三種做法的控制項都只有一個,差別在要不要留檔。

管線架構:PowerShell 物件流與文字流之處理

問題現象

PowerShell 處理外部指令輸出時,是否恰好只有一行,會改變回傳的型別——這個差異不會拋出任何錯誤,只會讓依賴陣列索引的程式碼在特定情境下悄悄拿到錯誤的資料。對這個叢集實測(ci 這個 namespace 當下有 72 個 Pod):

PS> $singleLine = & $oc get ns ci -o jsonpath='{.metadata.name}'
PS> $singleLine.GetType().Name
String
PS> $singleLine[0]
c

PS> $multiLine = & $oc get pods -n ci --no-headers
PS> $multiLine.GetType().Name
Object[]
PS> $multiLine.Count
72
PS> $multiLine[0]
el-frontend-nx-mono-ci-77689f446-zpm5z                   1/1   Running     2     2d2h

單行 jsonpath 查詢的結果被摺疊成純量字串(String),[0] 取到的是第一個「字元」cci 的第一個字);多行輸出才是真正的物件陣列(Object[]),[0] 才是完整的第一行。同一段依賴 [0] 索引取「第一行」的程式碼,套用在查詢結果恰好只有一筆的情境時,會從「取第一行」悄悄退化成「取第一個字元」——不會報錯,只會讓後續邏輯拿到看起來合理、實際錯誤的資料。

另一個常見情境,是直接把 oc get -o yaml 的輸出交給 Select-String 過濾。對這個叢集上實際的 Pipeline 跑一次:

& $oc get pipeline frontend-nx-mono-ci -n ci -o yaml | Select-String "COMMIT"

這條指令本身可以執行、也確實找得到 7 筆匹配,但第一筆是 kubectl.kubernetes.io/last-applied-configuration 這個 annotation——整份 Manifest 被壓縮成一行 JSON 字串存在裡面,長度 4563 個字元,只因為 COMMIT 這個關鍵字剛好也出現在裡面(tasks.git-clone.results.COMMIT)就被連帶命中:

      {"apiVersion":"tekton.dev/v1","kind":"Pipeline","metadata":{"annotations":{},"name":"frontend-nx-mono-ci","namespace":"ci"},"spec":{"finally":[{"name":"report-status","params":[{"name":"git-revi...

(原始長度 4563 字元,上面只截前 200 個。)真正有用的另外 6 筆匹配,每筆只有 46~67 字元:

      value: $(tasks.git-clone.results.COMMIT)
      value: $(params.image-name):$(tasks.git-clone.results.COMMIT)

長達數千字元的雜訊排在最前面,把後面真正有用的匹配結果埋在底下。

機制剖析

單行摺疊跟 annotation 噪訊是兩個各自獨立的成因。PowerShell 管線處理外部指令輸出時不保證固定回傳字串陣列——只有一個元素時,管線會自動把它攤平成純量,而不是包一層的單元素陣列;這個型別差異在執行當下沒有任何警告,只有程式碼真的用到陣列專屬行為(索引、.Count)、而且剛好遇到單行結果時才會顯現。annotation 噪訊則是另一回事:oc get -o yaml 的輸出裡混有 last-applied-configuration 這類內嵌 JSON 的 annotation,對整份輸出做純文字關鍵字比對時,沒有辦法區分「欄位名稱剛好命中」跟「真正想找的內容命中」。

怎麼修:型別轉型或原生 JSON 解析

針對 CLI 的輸出,依情境選擇文字流轉型或原生 JSON 物件流解析:

  1. 文字流轉型:用 @() 陣列子運算式包住呼叫,強制輸出無論一行或多行都是陣列,不受單行摺疊影響——但仍是對整份文字做關鍵字比對,無法避開 annotation 噪訊。
  2. 原生 JSON 物件流:傳入 -o json 搭配 ConvertFrom-Json,直接以物件屬性(如 .spec.tasks)存取——同時解決單行摺疊與 annotation 噪訊兩個問題,是這個情境下更穩健的做法。這條路不需要額外包 @()ConvertFrom-Json 會等管線輸入全部收完,才把累積到的完整字串一次解析成物件,不像 Select-String 逐行處理,本來就不受單行/多行摺疊影響。
$oc = (Get-ChildItem "$env:USERPROFILE\.crc\cache" -Recurse -Filter "oc.exe" | Select-Object -First 1).FullName

# 方式 A:陣列子運算式強制轉型
$rawYaml = @(& $oc get pipeline frontend-nx-mono-ci -n ci -o yaml)
$rawYaml | Where-Object { $_ -match "COMMIT" }

# 方式 B:原生 JSON 物件解析(同時避開 annotation 噪訊)
$pipelineObj = & $oc get pipeline frontend-nx-mono-ci -n ci -o json | ConvertFrom-Json
$pipelineObj.spec.tasks | Where-Object { $_.name -eq "cosign-sign" }

Windows 自動化腳本標準範例

綜合上述處理機制,呼叫 REST API 與解析 CLI 輸出的自動化腳本規範範例:

# --- 1. API 呼叫:Hashtable + ConvertTo-Json + 無 BOM UTF-8 寫檔 ---
$title = "ci: trigger automated build"
$targetBranch = "main"

$payloadObj = [ordered]@{
    title = $title
    head  = "feature/login"
    base  = $targetBranch
    draft = $false
}

$jsonPayload = $payloadObj | ConvertTo-Json -Compress -Depth 10
$tmpFile = "$env:TEMP\api-payload.json"

[System.IO.File]::WriteAllText($tmpFile, $jsonPayload, (New-Object System.Text.UTF8Encoding $false))

try {
    curl.exe -s -X POST `
        -H "Content-Type: application/json" `
        -u "<username>:<password>" `
        "https://api.example.com/endpoint" `
        -d "@$tmpFile"
} finally {
    Remove-Item $tmpFile -Force
}

# --- 2. CLI 輸出處理:物件導向解析 ---
$oc = (Get-ChildItem "$env:USERPROFILE\.crc\cache" -Recurse -Filter "oc.exe" | Select-Object -First 1).FullName

& $oc get pods -n ci -o json | ConvertFrom-Json |
    Select-Object -ExpandProperty items |
    Where-Object { $_.status.phase -eq "Running" }

PowerShell 自動化寫作規範備忘

在撰寫維運自動化腳本時,請遵循以下規範:

  • 構建包含動態變數的 JSON Payload 時,一律採用 [ordered]@{} 搭配 ConvertTo-Json,避免使用字串拼接或引號轉義;用 [ordered] 是因為 5.1 的一般 hashtable 不保證順序,欄位順序亂跳會讓 payload 的 diff 難以比對。
  • 呼叫 ConvertTo-Json 一律顯式指定 -Depth 10,不依賴預設值 2,避免多層巢狀結構被截斷成字串卻不報錯。
  • 寫入 JSON 臨時檔案時,必須呼叫 [System.IO.File]::WriteAllText(path, content, (New-Object System.Text.UTF8Encoding $false)) 確保產生無 BOM 的 UTF-8 檔案,且一律使用絕對路徑;payload 含換行時改用 curl.exe --data-binary "@$tmpFile"-d 在某些版本會把換行去掉。
  • 不要依賴 Set-Content -Encoding UTF8——5.1 的 UTF8帶 BOM 的。
  • 改用 Invoke-RestMethod 時,Body 一律先轉成 byte[][System.Text.Encoding]::UTF8.GetBytes());送字串而沒帶 charset=utf-8,內容會以 latin-1 編碼送出,非西歐字元直接變 ?
  • 讀 JSON 臨時檔案驗證內容時,Get-Content 一律加 -Raw -Encoding UTF8,不依賴預設編碼,否則非 ASCII 欄位會悄悄讀成亂碼卻不報錯。
  • 處理 oc/kubectl 文字輸出前,用 @() 陣列子運算式包住呼叫,強制轉成陣列型別,不受單行摺疊影響;若處理 JSON 輸出,直接使用 -o json 搭配 ConvertFrom-Json 轉為物件存取。
  • 交接新機器或新環境時,先確認 ACP 讀數——它會改變 Set-Content 的預設行為,而 chcpGet-WinSystemLocale 都不會告訴你。

這篇列的五個現象裡,Out-File> 的 BOM 跟本篇一開始的引號拼接是同一類——都會讓下游直接回一個看得見的錯誤(422),只是壞的位置不同:一個壞在傳參給 curl.exe 那一步,一個壞在寫檔那一步。真正會「不出錯、直接做錯事」的是另外三個:-Depth 預設值把多層結構截斷成一串字串、Set-Content 把非 ASCII 字元換成問號、單行輸出摺疊讓 [0] 拿到字元而不是行。這三個沒有一個會拋錯,全部要自己驗證才會發現。

argdump.exe 那段拆解帶出的東西比五個現象更通用:要判斷資料有沒有在某個交接處壞掉,觀察工具必須跟可能出錯的機制獨立。cmd /c echo 看參數會被 cmd 再解析一輪,用 ConvertFrom-JsonConvertTo-Json 的產物永遠會通過,用記事本開檔案會自動偵測編碼而正好把問題藏起來。跟 Day 3 那三個事後查不到的故障一樣,真正該問的不是這一步有沒有跑完,是它跑完之後,把哪些細節省略掉了沒告訴你。

有了這套跨全系列共用的自動化寫作規範,明天(Day 5)換一條主線:kubeadmin 與 developer 的權限邊界,以及 OpenShift SCC。


參考文件


上一篇
Day 3:你看到的狀態,不是系統正在做的事——CRC 上三個事後查不到的故障
下一篇
Day 5:權限邊界劃分:Kubeadmin 與 Developer 權限切換與 OpenShift SCC 核心機制解密
系列文
防範軟體供應鏈攻擊:從零打造具備硬性阻擋能力的雲原生 CI/CD 流水線5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言