跳到主要內容
wen aidev

Kiro SDD 入門(二):用 PBT 抓出你測不到的 bug

7 分鐘po-wen

你寫的測試只能覆蓋你想得到的案例,PBT 幫你撞出你想不到的。這篇用 Kiro 官方的 __proto__ 安全洞案例,講清楚 PBT 在 Spec workflow 裡怎麼運作。

目錄

系列文章地圖

點節點可以直接跳到那一篇。

上一篇講了 Specs 的三階段,這篇要接一個問題:AI 照著你的 requirements 生了 code,你怎麼知道它真的對?

上一篇:Spec 基礎


先講一般測試的問題

假設你做了一個「儲存 API key」的功能,你會怎麼測?

  • 存一個 key 叫 "openai",值是 "sk-abc123",取出來看對不對
  • 再存一個 "anthropic",值是 "sk-xyz789",取出來看對不對

測完兩三個案例,看起來都過了,你就覺得沒問題了。

但你測的都是「你想得到的正常輸入」。如果有人存了一個 provider name 叫 "__proto__" 呢?你不會想到要測這個,AI 寫測試也不會想到。


PBT 的做法:讓電腦幫你想案例

PBT(Property-Based Testing)換了一個思路,你不用自己想案例,你只要告訴框架「什麼規則應該永遠成立」,框架會自動生成大量隨機輸入去測這條規則。

以剛才的 API key 功能來說,規則就是:

不管 provider name 跟 API key 是什麼字串,存進去再取出來,值應該要一樣。

框架會自動生成各種你想不到的字串去測,像空字串、超長字串、特殊字元和 unicode,也包括 "__proto__" 這種在 JavaScript 物件裡有特殊意義的屬性名稱。一次跑個 100 組,看有沒有哪一組會讓這條規則不成立。


真實案例:第 75 次就抓到安全問題

這是 Kiro 官方 blog 記錄的真實案例。

背景

在做一個 chat app,需要存各家 LLM provider 的 API key。Kiro 照著 Spec workflow 走,從 requirements 推出了一條規則(property):「存進去再取出來要一樣」,然後用 fast-check 這個框架去跑 PBT。

程式碼大概長這樣:

fc.assert(
  fc.property(fc.string(), fc.string(), async (provider, apiKey) => {
    saveApiKey(provider, apiKey);
    const retrieved = loadApiKey(provider);
    return retrieved === apiKey;
  }),
  { numRuns: 100 },
);

fc.string() 會隨機生成字串,numRuns: 100 就是跑 100 組。

跑到第 75 次,fail 了

框架隨機生成了 provider = "__proto__",結果存進去之後取出來的值不對,測試 fail。

框架接著做了一件很有用的事

fail 之後,框架會先自動「簡化」那組亂七八糟的隨機輸入,再交給你。

這個過程叫 shrinking,概念跟你 debug 的時候一樣:你會試著把問題簡化到最小重現步驟,把不相關的東西一個一個拿掉,直到找出最核心的觸發條件。框架自動幫你做這件事。

簡化完之後,最小的失敗案例只剩這兩個值。

  • provider:"__proto__"
  • apiKey:" "(一個空白)

手寫案例只驗證你想得到的輸入;PBT 用大量隨機輸入去撞規則,撞到失敗後再用 shrinking 把失敗案例縮到最小。

情境對演 · 手寫案例 vs PBT
saveApiKey → loadApiKey測試輸出
// 選上面一種測法,開始跑
1 · 輸入從哪來
—
2 · 失敗案例(縮小 0 次)
—
3 · 最小失敗案例
—
手寫案例—
PBT 隨機輸入—
選一種測法

apiKey 被簡化到只剩一個空白,代表問題跟 value 無關,純粹是 "__proto__" 這個 key 造成的。

為什麼 "__proto__" 會出問題

JavaScript 的物件有一個特殊機制:每個物件都有一個隱藏的 __proto__ 屬性,指向它的原型(prototype)。這是 JavaScript 繼承系統的核心。

所以當你把 "__proto__" 當成一般的 key 去寫入:

obj['__proto__'] = 'some value';

JavaScript 會把這次寫入當成是想改這個物件的原型,不會當普通資料存起來。結果寫入看起來成功了,讀取的時候拿到的卻是原本的 prototype 物件,不是你存的值。

這就是為什麼「存進去再取出來」這條規則被打破了。

修法

用 Object.create(null) 建立一個「沒有原型」的乾淨物件:

const apiKeys = Object.create(null);

一般的 {} 會繼承 Object.prototype,所以 __proto__ 有特殊意義。但 Object.create(null) 建出來的物件沒有原型鏈,__proto__ 就變成一個普通的 key,可以正常存取。

這種 bug 你手動測不會想到,AI 寫測試也不會想到。但 fast-check 的隨機生成器裡面內建了 "__proto__" 這類常見的危險字串(是社群多年累積貢獻的),所以 PBT 會撞到。


PBT 在 Kiro 裡怎麼用

Kiro 把 PBT 整合進 Specs workflow,從 EARS 需求抽出 property,跟著 tasks 產生 PBT,失敗時把最小失敗案例交回給你決定改哪裡。

Kiro specs · Property-based testing
1
用 EARS 寫需求
需求用 WHEN … THE SYSTEM SHALL … 的句型寫,條件和結果都寫清楚。
2
design 抽出 property
Kiro 在 design 階段從需求抽出可以測的規則,也就是 property。
3
tasks 附上 PBT
生成 tasks 時,每條 property 會有對應的 PBT 任務。
4
PBT 預設 optional
可以先把核心實作跑完,確認基本功能沒問題,再開 PBT。
5
失敗時給最小案例
PBT 失敗,Kiro 顯示縮小後的失敗案例,由你決定改哪裡。
property 0PBT 任務 0PBT 已跑 0 組
Spec 產出
requirements.md
requirements.md · 需求 1
WHEN 使用者儲存某個 provider 的 API key
THE SYSTEM SHALL 在之後讀取時回傳同一個值
需求先寫成 EARS
  1. 你用 EARS 寫 requirements(上一篇講的)
  2. Kiro 在 design 階段自動從 requirements 抽出可以測的規則(properties)
  3. 生成 tasks 的時候會包含 PBT 測試
  4. PBT 預設是 optional,你可以先跑核心實作,確認基本功能沒問題再開 PBT
  5. 如果 PBT fail,Kiro 會顯示簡化後的最小失敗案例,你可以決定要改 code、改 test、還是改 requirement

PBT 跟 unit test 的關係

這兩個搭配用效果最好。

  • unit test:鎖定特定案例,確保已知的 bug 不會再出現
  • PBT:用大量隨機輸入去撞你沒想到的邊界

numRuns 預設 100,可以自己調。跑越多信心越高但越花時間,看你的需求。


參考

  • https://kiro.dev/docs/specs/correctness/
  • https://kiro.dev/blog/property-based-testing-fixed-security-bug/
  • https://kiro.dev/docs/specs/bugfix-specs/

支持作者 ☕

台灣用戶:

透過 LINE Pay 支持

國際用戶:

透過 Ko-fi 支持

相關文章

留言討論

Facebook 留言