iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0

本文同步發表於個人部落格:醫療 app 的安全思維


火線超人除了幫病人查資料,還多做一件事:用 LLM 幫病人解讀自己的健康資料。 LLM 就是大型語言模型,ChatGPT 那一類。檢驗值是什麼意思、這個數字算不算正常,這些問題交給模型回答。

跑這個模型的機器在醫院裡,是醫院提供的服務。

醫院對這件事有一條資安要求:內網只准對外連線,不開任何入口讓外面連進來。

所以問題變成這樣。我的後端跑在雲端,租的是 DigitalOcean 的機器。那要怎麼叫得動院內那台模型?

外面的機器要連進來,防火牆上得有個對應的洞,那個洞就是 port。開了洞就會有人來敲門,醫院那條規矩等於說一個洞都不開。

我用的是 Kafka。這是一套訊息佇列,你不直接打對方的服務,而是把訊息丟進一個叫 topic 的收件匣。丟的那一端叫 producer,拿的那一端叫 consumer。中間放訊息的那台伺服器叫 broker。

火線超人就是這樣跑的。雲端的後端是 producer,把要解讀的資料丟進 broker 的 topic。院內有一台機器整天掛著一支程式,我叫它 AI worker。這支程式就是 consumer。

重點在 consumer 怎麼拿訊息。consumer 不是等人來派工,是自己連出去找 broker。找到之後說我要訂 topic 這個收件匣,這個動作叫 subscribe。訂完就一直問有沒有新的,有就拉回來跑。

從頭到尾都是院內往外連。broker 從來不需要回頭連院內,院內也不用生一個網址出來給人連。

這在術語上叫解耦合,白話講就是兩邊根本不認識。producer 不知道 consumer 是誰,也不知道它在哪。兩邊只認 topic 那個名字。雲端手上沒有院內任何一台機器的位址,想連也連不了。牆上那個洞從頭到尾不用開。

當初會這樣設計,理由很單純。在我想得到的做法裡,這是最安全的一種資料交換方式。

每一種設計我都拿同一個問題去問:雲端那台主機哪天被入侵了,會怎麼樣?

最直覺的替代方案是 VPN。VPN 就是把兩個網路接成一個。接通之後,雲端跟院內像在同一個內網裡。問題就在這裡。雲端那台如果被入侵成功,攻擊者順著這條通道就進了醫院內網。

另一種是反向通道。院內主動連出去一條線,接通之後就掛著不關,這叫持續連線。線一旦接上,資料就能兩頭跑。雲端可以沿著它把請求送進院內。這確實不用開 port,但雲端被入侵的話,攻擊者能發什麼請求一樣沒有限制。

Kafka 這條路不一樣,因為這條通道只能做一件事。雲端能做的只有把一則請求丟進 topic,沒有別的動作可以發。要不要處理、什麼時候處理,決定權在院內。就算雲端那台被入侵,攻擊者也只是多了一個丟訊息的管道,連不進醫院。

這裡也有一條當時列為待改善的。那台 broker 的對外通道沒有加密,也沒有認證授權。稽核建議補上 TLS 與 SASL。攻擊者進不了醫院內網這件事不受影響。但「只能丟訊息」要真的成立,broker 還得擋住誰能丟、能丟去哪。

三種通道做法的對照圖,米色底,標題「三種做法,差別在被入侵之後」,副標「同一個需求,雲端要叫得動院內那台模型。把雲端主機被入侵當成前提再比一次」。畫面上方一列灰色座標標籤,由左至右是「雲端(攻擊者在這)」、珊瑚色的「院內防火牆」、「院內網路」。下方三張橫列卡片,每列左端一條色條,左側是做法名稱與一句說明,中間是一條由左往右的滲透路徑,路徑上有一條虛線代表院內防火牆,右側是被入侵之後的結果。第一列白底、珊瑚色條,名稱 VPN,說明「把兩個網路接起來」,路徑從雲端起點一路穿過防火牆延伸到院內網路,路徑下方珊瑚色字「一路穿進去」,右側珊瑚色粗體兩行「攻擊者進到院內網路,能碰的不只那台模型」。第二列白底、琥珀色條,名稱「反向通道」,說明「院內建一條持續連線出去」,路徑穿過防火牆但比第一列短,下方琥珀色字「沿著通道回頭」,右側琥珀色粗體兩行「不用開 port,但請求內容沒有限制」。第三列是深藍底,名稱 Kafka 為白字,說明「只能往 topic 丟一則訊息」,路徑是淺藍色,停在防火牆左側沒有穿過去,下方灰字「停在 broker」,右側淺藍色粗體兩行「只多一個丟訊息的管道,連不進醫院」。三列下方是一張白底、左緣深藍直條的橫幅,粗體字「安全是架構屬性,不是事後修補」,下一行灰字「三種做法搬的是同一份資料。差別不在誰多加了防護,在攻擊者入侵雲端之後還能走多遠」。圖片最下方一行灰字:火線超人採用第三種。要不要處理、什麼時候處理,決定權留在院內

一條對外連線換掉一整排對內規則

這個架構省掉的麻煩很具體。

如果反過來,讓雲端主動連進院內查,那就得在院內防火牆開一個入口。開了入口就多出四類要維護的規則:

  • 誰能連進來
  • 來源位址的白名單
  • 憑證多久輪替一次
  • 有沒有人在掃這個 port

每一條都要有人顧,每一條都可能被改壞。院內主動出去的話,這四條整批消失。院內只要能連得出去就好,而「允許對外連線」這條規則本來就存在。

這就是「安全是架構屬性,不是事後修補」最具體的樣子。 所謂攻擊面,就是外面那些人碰得到的地方,開一個 port 就多一塊。院內只出不進,那個洞從頭到尾沒有被開出來,也就沒得防。

清單第一條:最小權限的 scope

從架構回到 app。第一條也是最基本的一條。

第三幕 day10 和 day11 講過 scope 怎麼組。這裡要補的是:scope 不該是一個全域常數。

火線超人的做法是每一台伺服器自己帶一組 scope。要送哪一組,照三層順序決定。呼叫時明確傳入的優先,其次是那台伺服器的設定,都沒有才用全域預設。

火線超人那邊的邏輯大致長這樣:

const SERVERS = {
  hospitalA: { scope: 'system/Appointment.read system/Location.read' },
  hospitalB: { scope: 'system/Appointment.read' },
}

const DEFAULT_SCOPE = 'system/Appointment.read'

function resolveScope(serverKey, explicitScope) {
  // 三層順序:明確傳入 > 那台伺服器的設定 > 全域預設
  return explicitScope
    ?? SERVERS[serverKey]?.scope
    ?? DEFAULT_SCOPE
}

為什麼要這樣?因為每家醫院開放的資源不一樣。你用同一組 scope 去要所有醫院,結果就是對某些醫院要太多。輕則授權伺服器直接拒絕,重則在對方的稽核紀錄上留下一筆。

實際的數字。我在醫院端那條系統憑證只申請了五個唯讀 scope,涵蓋這五種資源:

  • Appointment
  • Location
  • Schedule
  • Practitioner
  • Organization

這剛好是看診進度功能需要的全部,一個都沒多要。

沒試過 403 的最小權限,不算驗證過。

最小權限實測的對照圖,米色底,標題「五種資源回 200,Patient 回 403」,副標「同一把系統 token,申請到的資源查得到,沒申請的在資源端就被擋下」。副標同一列的最右邊是一枚珊瑚色外框、淺粉底的圓角標籤,寫著「醫院端實測,不是公開 sandbox」。副標下方是一張深藍色卡片,左上角淺藍底的標籤寫著「系統憑證申請的 scope」,旁邊白色等寬字寫著 system/<資源>.read,同一列最右邊淺藍字寫著只申請 5 個、全部唯讀,其中 5 是白色粗體。卡片下半排著五個深藍色圓角方塊,由左到右依序是 Appointment、Location、Schedule、Practitioner、Organization。卡片底部中央往下拉出一條灰色直線,到中途分成左右兩條,各自直角轉彎、轉角帶圓角之後往下,左邊那條是淺藍色、右邊那條是珊瑚色,兩條末端各有一個同色的向下箭頭。分岔點的右側以灰色粗體字寫著「同一把 token 往下打」。左邊箭頭指到一張白底、左緣淺藍直條的卡片,上行灰字「查那五種資源」,底下是深藍色的大字 200,右邊灰字寫著五種都在申請的 scope 裡,再下一行灰字「要得到的權限,資源端就給得出資料」。右邊箭頭指到一張淺粉底、左緣珊瑚色直條的卡片,比左卡高,上行寫著「用同一把 token 查 Patient 的筆數」,底下是珊瑚色的大字 403,字級比左卡的 200 更大,右邊珊瑚色粗體字寫著沒有 Patient 的 scope,再下一行寫著回應訊息逐字是,底下一個白色小方塊以珊瑚色等寬字寫著 access token has no scope for Patient。兩張卡片下方是一張白底、左緣珊瑚色直條的橫幅,粗體字「最小權限不是宣稱,是要打得到 403」,下一行灰字「申請了五個 scope 只證明你要得少,那個 403 才證明多要的部分真的拿不到」。圖片最下方一行灰字註記:這兩組回應都在醫院端的 FHIR 資料平台實測。公開 sandbox 在資源端不檢查 scope,同一組請求在那台上不會回 403,只看得到 token response 的 scope 欄位

這兩組回應是我在真的醫院端打的。我們用的那台公開 sandbox 在資源端不檢查 scope。day10 與 day21 都講過。

清單第二條:token 的保護

day14 講過 token 存哪裡的取捨。這裡補三個實作上的細節。

快取的存活時間要比 expires_in 短。 火線超人短 60 秒。理由是時鐘會有誤差,網路也會有延遲。你算的到期時間跟伺服器算的不會完全一樣,留 60 秒的餘裕比較保險。在請求送到一半才發現過期,那是最難處理的狀況。

收到 401 要先失效快取再重試,而且只重試一次。 重試無上限就是在幫對方做壓力測試。

這兩條寫成程式碼就是這樣:

// 存 token:到期時間往前算 60 秒
function cacheToken(res) {
  return {
    accessToken: res.access_token,
    expiresAt: Date.now() + (res.expires_in - 60) * 1000,
  }
}

// 收到 401:先讓快取失效,重新換一次 token,只重試這一次
async function callWithRetry(sendRequest) {
  let res = await sendRequest(getCachedToken())
  if (res.status === 401) {
    invalidateCache()
    res = await sendRequest(await fetchNewToken())
  }
  return res
}

client secret 只能存在加密的資料庫欄位裡。 這條有一個可驗收的方式:拿那個 secret 的值 git grep 一次,必須零命中。醫院給的憑證資料夾要寫進 .gitignoregit status 不能列出它。

明文憑證進版控這件事,一旦發生就沒有辦法乾淨地收回。歷史紀錄裡永遠有那一筆。

清單第三條:唯讀要寫成自己訂的規則

day18 教過怎麼寫回 FHIR。那也就代表你的 app 有寫入的能力。

火線超人有一支對醫院端的探測工具。這支工具定期連上醫院的 FHIR 端點,確認資料來源還活著、格式沒有變。

這種工具很容易寫著寫著就多出寫入能力。所以在動手寫之前,我先在規格文件裡列死它能發哪些請求。這就是我自己給這支程式訂的規則,寫完的程式碼不准超出它。

這件事的重點是:唯讀不能只靠 scope。

scope 是對方給你的限制,這條規則是你給自己的限制。兩層都有,才不會因為某天對方多給了寫入權限,你的工具就默默開始寫東西。

兩層限制的對照圖,米色底,標題「scope 是對方給的,規則是自己訂的」,副標「同一支唯讀工具,兩層各判一次能不能寫」。上半部是一張白底、左緣淺藍直條的卡片,左上角淺藍底標籤寫著「第一層」,旁邊等寬字的 scope,同一列最右邊灰字寫著改動的人是對方。卡片內並排兩個方塊:左邊淺灰藍底,上行「對方只給唯讀 scope」,下行深藍粗體字「寫入被擋下」;右邊淺粉底,上行「對方哪天多給了寫入 scope」,下行珊瑚色粗體字「寫入直接放行」。從右邊那個淺粉方塊往下拉出一支珊瑚色的直向箭頭,箭頭右側珊瑚色小字寫著「這一層失守,還有下一層」,箭頭指到下半部的深藍色卡片。深藍卡片左上角標籤寫著「第二層」,旁邊白色粗體字「自己訂的規則」,同一列最右邊淺藍字寫著改動的人是你自己。卡片左側是四條規則,每一條前面一個深色圓形的綠色勾號,由上而下依序是只發 GET、唯一的非 GET 是取 token 的那個 POST、不得呼叫任何 create、update、delete、報告本身不得包含 token 或 secret,其中 GET、POST 與 create、update、delete 以珊瑚色標示。卡片右側是一個更深色的方塊,珊瑚色粗體字「兩種都擋下」,底下淺灰字兩行「規格明文寫死,不看對方給了什麼」。最下方是一張白底、左緣珊瑚色直條的橫幅,粗體字「只有第一層的話,寫不寫得進去由對方決定」,下一行灰字「scope 是別人的設定,這些規則是你自己寫的,要改得先有人改你的規格」。圖片最後一行灰字註記:兩層擋的是同一件事,但只有第二層在對方的設定改掉之後還站得住

清單第四條:去識別化的邊界

day23 講過匿名化鐵則要寫在技術規格之前。這裡把邊界講清楚。

界線不在「有沒有名字」,在這筆資料能不能指回一個人

叫號進度看起來完全沒有個資,就是一個號碼加一個狀態。但那筆 Appointment 一旦帶了 Patient 參照,它就變成可以指回一個人。所以規格把 Patient 參照列成禁止欄位,跟必填、選填並列。

另一條是資料要分開。day23 講過那條要求,叫號的 identifier.system 要跟臨床的分開,各用自己的命名空間。兩邊的識別碼對不起來,讀叫號的程式在技術上就碰不到病歷。

還有一件當時列為待改善的。2025 年 12 月那次資安稽核建議,送往外部分析服務之前要先移除直接識別資訊。直接識別資訊就是姓名、病歷號、身分證字號那一類,看一眼就知道是誰的欄位。這一項我也還沒做。

清單第五條:稽核軌跡,這一項我還沒做

稽核軌跡就是一份紀錄,誰在什麼時候讀了哪一筆病人資料,一行一行留著。這一條要照實寫。

火線超人沒有實作稽核軌跡。

專案的套件清單裡沒有任何做資料異動紀錄的套件。程式碼裡也沒有存取日誌的模型或服務。同一次資安稽核把「增加健康資料存取的詳細日誌」列為建議事項。

到今天還是建議事項。

為什麼要在一篇講安全的文章裡承認這個?因為不承認的話,這張清單就變成一份行銷文案。而且我猜你的 app 大概也沒有這一條,這樣至少我們是誠實的同一國。

該做的話要做什麼?至少三件:

  • 誰在什麼時間存取了哪一筆病人資料
  • 這筆存取來自哪一個授權來源,哪台伺服器、哪一次授權
  • 這份日誌本身不能被存取它的人改掉

第三件最容易被忽略。稽核軌跡如果能被改,它就不是稽核軌跡。

資安左移,把檢查排到上線之前

前面五條講的是狀態,這一節講的是時間。

開發流程從寫程式碼一路走到上線,越靠左邊越早。安全最貴的做法,是等上線之後被人通報才處理。把檢查往左邊挪,能在自己機器上跑的就不要等到上線,這件事叫資安左移。

火線超人是 Rails 專案,部署前會跑兩種掃描。

Brakeman 掃你自己寫的程式碼。 Brakeman 讀原始碼找常見的漏洞寫法。例如 SQL 拼字串、沒有跳脫的輸出、參數直接灌進 model。不用把 app 跑起來,也不用測試資料。

bundler-audit 掃你裝的套件。 這支工具拿 Gemfile.lock 鎖定的版本去比對公開的漏洞資料庫。你鎖的版本落在某一則公告的影響範圍裡,它就列出來。

兩種掃的地方不一樣,一個看你寫的,一個看你裝的。兩個都排在部署之前,任一個沒過就停在那裡,版本送不上線。

那你這個 JS app 呢?沒有 Rails,也沒有那兩支工具,但「你裝的」那一半照樣適用。

day04 我們用 curl 抓了一份 fhir-client.pure.min.jsvendor/,版本是 2.6.3。那份檔案躺在那裡不會變。哪天 fhirclient 發了一則安全公告,你的檔案不會知道,你也不會知道。

這是 vendor 進版控換來的。好處是誰 clone 下來跑的都是同一份程式碼。壞處是沒有 package.json,也就沒有 bundler-audit 那種工具幫你盯。

所以要自己補兩件事。

記下你 vendor 的是什麼。vendor/ 旁邊放一個 README,寫下網址、版本號、下載日期。沒有這三行,哪天想查更新你連要去哪裡看都不知道。

定期去看一次。 打開那個專案的 releases 頁面與安全公告,找有沒有修掉漏洞的版本。三個月看一次也行,重點是有排。

重點在「定期」。左移管得到的是上線前那一刻,已知的公告先擋掉。上線之後才出現的公告,只能靠排程重查。安全公告不會挑你正好在改那個檔案的那天發表。

還有一條原則值得帶走。火線超人有一份「已知未修」的漏洞清單,掃到清單上的公告不擋。那個檔案如果不見了或是空的,判定不是全部放行,是全部擋下。預設值出錯的時候要往嚴的那邊倒。

排程先記到行事曆上。下面回到前面那五條,拿它們檢查你自己的 app。

跟著做:用這張清單稽核你的 app

打開你第三幕做出來的那個專案,逐列回答。除了最後一列,每一列在前三幕都找得到對應的篇章。

檢查項 怎麼查
scope 有沒有多要 servers.js 裡每台的 scope 字串,逐個問「這個資源我畫面上用得到嗎」
授權有沒有 PKCE 看授權網址有沒有 code_challenge
有沒有防跨站請求偽造 看授權網址有沒有 state,導回時有沒有驗它
token 存在哪裡 找存 token 的那一行,是 sessionStorage 還是 localStorage
token 過期怎麼辦 找收到 401 的處理,有沒有無上限重試
錯誤訊息會不會外洩 看丟到畫面上的錯誤有沒有帶到 token 或完整網址
寫入的範圍 找所有 POST 與 PUT,確認每一個都是使用者主動觸發的
每台伺服器的憑證有沒有分開 看 client id 是不是每台一組,有沒有共用
有沒有稽核軌跡 找記錄「誰在什麼時候讀了什麼」的地方

對應關係圖,米色底,標題「九個檢查項,八個指得到篇章」,副標「拿這張清單稽核你第三幕做出來的 app,每一項都查得到答案,除了最後一項」。畫面分左右兩欄,左上角灰色小標「檢查項」,右上角灰色小標「前三幕對應」。左欄由上而下九張白色圓角卡片,每張左緣一條深藍直條,卡片右側各以一條灰色點線連到右欄的深藍色圓角標籤。第一列 scope 有沒有多要,連到 day10 與 day11 兩個標籤;第二列 授權有沒有 PKCE,連到 day08;第三列 有沒有防跨站請求偽造,連到 day09;第四列 token 存在哪裡,連到 day14;第五列 token 過期怎麼辦,連到 day14 與 day19;第六列 錯誤訊息會不會外洩,連到 day19;第七列 寫入的範圍,連到 day18 與 day20;第八列 每台伺服器的憑證有沒有分開,連到 day21。第九列與前八列不同,卡片左緣的直條是珊瑚色,內容是有沒有稽核軌跡,連出去的線是珊瑚色虛線,線的終點不是深藍標籤,而是一個珊瑚色虛線外框、內部空白的框,裡面以珊瑚色寫著「沒有對應」。九列下方是一張深藍色橫幅,左側白色粗體字「八項在前三幕都寫過,第九項沒有」,下一行淺藍字「day08 到 day21 各自回答一項,稽核軌跡沒有任何一篇對得上」;橫幅右側並排兩組數字,白色的 8 底下寫「查得到答案」,珊瑚色的 1 底下寫「查不到」。圖片最下方一行灰字註記:前八項查的是你已經寫過的程式碼,最後一項查的是一個還沒有被寫出來的東西

最後一列指不到任何一篇。這是刻意的。

只照前三幕做的話,最後一列一定打叉,因為前三幕從來沒有教過稽核軌跡。我的也沒有。

這就是這一節真正要你發現的東西。一份看起來完整的清單,走到最後一列會發現有一格填不了。安全清單的價值不在全部打勾,在於讓你知道哪一格還是空的。

順便看一下 day18 那顆寫回按鈕。你的 app 現在有寫入能力,而它拿到的 scope 是 patient/*.rs,那個 s 是搜尋不是寫入。所以第七列你可能會發現一件事:你申請的權限跟你實際做的事,其實對不太起來。

小結

安全清單有五條:

  1. 最小權限的 scope
  2. token 保護
  3. 唯讀寫成自己訂的規則
  4. 去識別化的邊界
  5. 稽核軌跡

第五條我自己還沒做,寫出來是因為清單少一條比清單造假好。

清單之外還有時間這一項。這五條今天做完都對,vendor/ 那份 JS 卻會慢慢變舊。能往前挪的檢查就往前挪,挪不動的就排時間重查。

架構那一段的決策當時沒有寫成文件。判準倒是很清楚:假設雲端那台被入侵,這個設計還剩多少防線。最好的防護是讓攻擊面不存在,不是在攻擊面前面加一道牆。

第四幕到這裡結束。明天進第五幕,把這 27 天做出來的東西真的送上線。


上一篇
Day26 - 為什麼選 LINE
下一篇
Day28 - 上線檢查清單
系列文
SMART on FHIR 開發之路:30 天做一個跨醫院的 app30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言