小步合併,讓 CI 早一點說話:一次 Semgrep 掃描教我的事

約 4 分鐘

收尾一個 Next.js 專案時,把 dev 合併到 main 的那一刻,CI 的 Semgrep 掃描跳出了 SSRF 弱點。追下去才發現被標記的那層 API Routes 其實只是在轉送 Mock 資料。這篇記錄當時怎麼判斷、怎麼處理,還有我從這件事學到的幾個習慣:掃描結果其實是在講架構、Mock 資料該放在哪一層,以及為什麼要小步合併。


目錄

2024 年底,我在收尾一個 Next.js 專案。這個專案後來沒有上線,不過它還有另一個任務,就是把架構整理成 Starter Kit,之後的專案可以直接沿用,不用每次從零開始。

收尾的最後一步是把 dev 分支合併到 main。結果就在這一步,CI 裡的 Semgrep 掃描跳出了 SSRF(Server-Side Request Forgery)的弱點。

被標記的是什麼

Semgrep 指的是專案裡的 Next.js API Routes。查了一下,這其實不是我們獨有的狀況,Semgrep 的規則會把「在 API Route 裡依請求內容去呼叫其他位址」的寫法當成 SSRF 風險,Next.js 的 GitHub 上也有不少人回報類似的誤判,當時官方並沒有給出解法。

回頭看那層 API Routes 到底在做什麼,其實蠻尷尬的,它只是把 Mock 資料轉一手。當時後端還沒有就位,我們用 API Routes 模擬「前端呼叫 API、API 回傳資料」的流程,讓畫面有東西可以顯示。它不是產品需要的功能,只是開發期的替身。

當時怎麼處理

既然那一層只是替身,最省事的做法就是把它拿掉,移除 API Routes 跟相關設定,前端直接用假資料呈現畫面。我先把查到的資料整理給主管討論,有共識之後才動手,以推進專案為優先。

拿掉之後警告自然就消失了。我也順手更新了專案 Wiki 上的資料夾結構文件,讓之後接手的人知道現在的架構長什麼樣、為什麼沒有 API Routes。事情本身很小,不過倒是讓我想了幾件事。

掃描結果其實是在講架構

Semgrep 報的是 SSRF,但真正的問題是「這一層為什麼存在」。如果那層 API Routes 有真正的工作,像是處理登入的 Session、把後端的 SSE 串流轉送給瀏覽器、藏住不能給前端的金鑰,那它就該留著,然後針對警告去補輸入驗證和白名單。

這種 API Route 每一條都有存在的理由,掃描報警時要做的是補防護,不是拿掉它。

但如果它只是為了模擬一個流程才蓋出來的,掃描工具等於幫我指出了一層多餘的東西。後來我看每一層架構,都會先問它有沒有理由存在。

Mock 資料要放哪

那開發期的假資料該放哪裡?我現在的答案是放在資料取得的邊界,也就是每個功能的 API 呼叫函式裡,不要另外蓋一套假的 API。

// features/overview/api.ts
import { mockOverview } from './mock'
import type { Overview } from './types'
 
const USE_MOCK = process.env.NEXT_PUBLIC_USE_MOCK === 'true'
 
export async function getOverview(): Promise<Overview> {
  if (USE_MOCK) return mockOverview
 
  const response = await fetch(`${process.env.NEXT_PUBLIC_API_BASE}/overview`)
  if (!response.ok) throw new Error(`getOverview failed: ${response.status}`)
  return response.json()
}

這樣做的好處是畫面、狀態管理、型別都跟正式版一模一樣,後端就位的時候只要把開關關掉,不用重寫任何元件。假資料跟型別放在同一個 feature 底下,型別一改假資料就得跟著改,不會出現假資料的形狀跟真 API 對不上的情況。而且沒有多出一層要維護、要被掃描的程式碼。

如果需要模擬更真實的網路行為,像是延遲、錯誤、分頁,再考慮 MSW 這類在請求層攔截的工具,它一樣不用自己蓋 API。

合併的節奏

這次最直接的教訓是合併的節奏。我是在收尾的時候才把累積了一段時間的程式碼一次合併到 main,所以 Semgrep 到最後一刻才有機會掃到。如果問題再嚴重一點,在這個時間點才修,複雜度會高很多。

CI 裡的檢查,不管是 Lint、測試還是安全掃描,都要等程式碼進到它看得到的分支才會跑。頻繁一點、持續地合併,等於讓這些檢查早一點、小一點地執行,每次跳出來的問題都是剛寫完、記憶還熱的時候,修起來最便宜。

順便 Code Review 也變小了,審的人輕鬆,回饋也快。

誤報怎麼處理

安全掃描一定會有誤報,不過處理誤報也該有個流程。先判斷是不是真問題,以 SSRF 來說關鍵是「要呼叫的位址有沒有可能來自使用者輸入」,如果位址是寫死的環境變數,風險就完全不同。

再來是把決定記下來,為什麼判定是誤報、為什麼移除或保留,寫在 PR 或文件裡,之後的人就不用重查一次。最後才是忽略,Semgrep 可以用 // nosemgrep: <rule-id> 的註解搭配說明,只忽略那一個位置的那一條規則,不要把整個掃描關掉。

我們這次是連忽略都不用,直接把不需要的程式碼移掉。能拿掉的東西,總是比需要被忽略的警告乾淨一些。

順帶一提:Commit Message

同一段時間,專案也訂了 Commit Message 的規範,用 Conventional Commits 的格式,type 固定一份清單。這跟掃描沒有直接關係,但我覺得它們在講同一件事,就是讓每一次變更都小而清楚。小的變更配上清楚的訊息,出問題的時候才找得到是哪一步造成的。

回顧

這件事花不到一天就解決了,但它改掉了我幾個習慣。掃描工具報警的時候,我會先問「這一層為什麼存在」再問「怎麼修」。開發期的假資料放在資料取得的邊界,用開關切換。合併的節奏小一點、頻繁一點,讓 CI 在問題還小的時候就有機會說話。誤報要判斷、記錄,再有理由地忽略。這些習慣後來都帶到了下一個專案。

References

留言

使用 GitHub 帳號登入即可留言,內容會存放在本站 repo 的 Discussions。