U12 檢查結果:零散殘段+系統設定守門補掃+人員對帳跨公司比對

U12 檢查結果:零散殘段+系統設定守門補掃+人員對帳跨公司比對

檢查日期 2026-09-25|對應卡片 CM-2164(母卡 CM-2152)|範圍 16 個檔案、1,921 行|工具 run ID wf_a2018933-ffc|掃描版本 e1c92303(feature/review)|驗證章 verified(3 候選、9 票全投完、零漏投)

§1

🔴 一句話結論

卡片點名最要緊的「系統設定守門」確實是這一棒的重點:工具兩條 3:0 都落在這支,而且都是總表已知的洞(第 74、72 項),掃描當時主線一條都還沒修(後已修,1.21.0 出貨)。本棒淨新增的是修正線那道「總部層級」守門本身的一個縫:它擋得住子公司,擋不住別家客戶的總部。

  • 工具兩條,都是舊帳(驗證章 verified,3 候選 9 票全投完):F1(高,3:0)任何客戶的管理員都能改掉全站唯一那一列寄信/LDAP 設定=總表第 74 項;F2(中,3:0)任何登入者讀得到物件儲存的帳號密碼明文=總表第 72 項。工具另報的第三條「即時通知連線不驗身分」被面板 0:3 否決。
  • 系統設定守門(卡片第一重點):AI 金鑰和 Google 雲端硬碟設定兩組的「能力點+平台管理員」兩道都在;其他設定群組只有一道能力點,而這是刻意的,不是漏。 真正的缺口是總表第 74 項那三組(登入規則、LDAP、寄信):主線(feature/review)上仍然只有能力點一道,子公司管理員還是寫得進全站共用那一列。修正線(fix/security-b1)已經補了「總部層級」第二道(CM-2054),但我實測發現那道守門擋得住「同一家客戶的子公司」,擋不住「另一家客戶的總部」——而寄信和 LDAP 的共用列是全站只有一列,不是每家客戶一列。
  • 人員對帳跨公司比對(卡片第二重點):人員比對程式收到了「是哪家公司」這個參數,但完全沒拿來用;組織比對有用。今天會不會真的配到別家的人,取決於資料庫隔離:一般客戶的使用者看不到別家的人(我在開發環境實測,客戶 102 的身分只看得到 38 人、看不到客戶 131 的 2 人),但平台管理員身分的隔離是全開的,比對會橫跨所有客戶。配對結果只寫進「對到哪個使用者編號」的附註欄,不會把人加進專案或給權限;影響是 SSP 文件上多一個別家公司的人名、email 和編號。
  • 兩支解析工作單程式:新增那一條的資料庫保護是「任何人都能新增」(WITH CHECK (true)),但寫進去的公司編號一律來自登入身分,不從網址或內容拿,目前沒有可以利用的路。讀/改/刪的保護是正確版本(5 月已修好),跟總表第 178 項那兩張壞掉的表不一樣。
  • 即時通知連線(notification_socketio_handler.py)真的完全不驗身分,但目前沒有東西經過它:不用登入就能連、進任何房間、對房間廣播任意內容,我實測重現。面板否決的理由我開檔核對過屬實——前端唯一會連它的元件 ProcessDiscussBox.vue 沒有任何地方引用,伺服器也從不往這個頻道送資料。所以今天是一扇沒有門的空房間:評低,但哪天有人把討論串元件接回畫面,就會變成不用帳號就能偷聽別家討論串。
  • 防竄改接線 common/integrity/adapters.py:不寫 api_logs,只讀。 卡片問的「雜湊鏈寫入 api_logs 的租戶歸屬」前提不成立。有一個小問題:竄改事件旁證裡的「來源 IP」直接讀 X-Forwarded-For 標頭,可以偽造;修正線已改掉。
  • 新租戶設定繼承(tenant_storage_config_seeder.py):寫入有限定租戶;把原廠 AI 金鑰整格抄給新客戶這件事=總表第 161 項,修正線 CM-2192 已修,主線還沒合回。
§2

這一棒在檢查什麼

三塊零散但決策者裁定一起做的程式:

  1. 原本盤點的零散殘段(7 支):防竄改功能的主專案接線、兩張「匯入解析工作單」表的讀寫程式、即時通知的 WebSocket(瀏覽器與伺服器之間長時間開著的雙向連線)處理器、SSP 人員欄位的格式定義。
  2. 系統設定守門服務(1 支 665 行+1 支新租戶設定繼承):上次掃描之後又加了 391 行,新增的正是「哪些設定只有平台管理員能動」的守門本身。
  3. 人員對帳(6 支):使用者上傳 Word/Excel 格式的 SSP(系統安全計畫)時,系統會把文件裡寫的人名、email、單位名稱,自動對到系統裡的使用者與組織,並在 SSP 上註記「這個人=系統裡的哪個帳號」。問題是:比對的時候會不會拿到別家公司的人。
檔案 行數 角色
app/system_config/service/guarded_system_config_service.py 665 系統設定讀寫的總守門:遮罩機密、共用設定落總部那列、受限群組加驗平台管理員
common/integrity/adapters.py 357 防竄改套件的主專案接線:驗章、機器指紋、竄改時蒐集旁證
app/system_config/service/tenant_storage_config_seeder.py 129 建新客戶時,把原廠的儲存設定與 AI 設定抄一份給他
domain/oscal/service/reconciliation/base.py 107 人員對帳共用骨架:人工指定 → 完全相同 → 正規化後相同 → 模糊比對 四段
domain/oscal/service/reconciliation/person_reconciler.py 101 人員比對:用 email、帳號名稱找系統使用者
domain/oscal/service/reconciliation/organization_reconciler.py 101 組織比對:用單位名稱找組織
domain/oscal/service/ssp_docx_parse_job_domain_service.py 71 Word 匯入解析工作單的建立/狀態轉換
domain/oscal/service/ssp_excel_parse_job_domain_service.py 71 Excel 匯入解析工作單的建立/狀態轉換
infra/oscal/repository/ssp_excel_parse_job_repo_impl.py 56 Excel 解析工作單表的讀寫
infra/oscal/repository/ssp_docx_parse_job_repo_impl.py 54 Word 解析工作單表的讀寫
app/notification/handler/notification_socketio_handler.py 53 即時通知 WebSocket 處理器
domain/oscal/service/reconciliation/_normalizers.py 51 比對前的字串整理(去空白、去 +別名、去「股份有限公司」)
api/oscal/serializers/ssp/ssp_party.py 47 SSP 人員欄位的輸入/輸出格式
domain/oscal/service/party_reconciliation_service.py 30 人員對帳入口:分派給人員/組織比對
domain/oscal/service/reconciliation/__init__.py 18 套件匯出
domain/oscal/service/reconciliation/match_method.py 10 比對方式的列舉值
§3

掃到什麼(總覽)

# 這是什麼問題 出事會怎樣 要先有什麼才打得到 該補檢查的位置 嚴重度+為什麼 來源
F1 任何客戶的管理員都能改掉全站唯一那一列寄信伺服器/LDAP 設定,進而攔截任何人(含平台管理員)的重設密碼信 見總表第 74 項 任一客戶的系統管理員(新客戶開通時自動拿到能力點) guarded_system_config_service.py:458(寫入全站共用列之前要求平台管理員) 🔴 高 工具 F1,面板 3:0;=總表第 74 項,不另計
F2 任何登入者都讀得到物件儲存的帳號密碼明文 見總表第 72 項 任一有效登入 guarded_system_config_service.py:554(儲存設定比照 AI/雲端硬碟遮罩);api/system_config/routes/system_config_route.py:131(讀取補能力點) 🟠 中 工具 F2,面板 3:0;=總表第 72 項,不另計
U12-1 即時通知連線不驗身分、加入任何房間不檢查、reload 事件把呼叫端內容原樣廣播 今天沒有實害:伺服器從不往這個頻道送資料、前端唯一的使用元件沒被引用。哪天討論串元件接回畫面、或伺服器那行 namespace 對齊,就變成不用帳號偷聽別家流程討論串(含留言者帳號與留言全文) 不需要任何帳號;偷聽要知道房間名稱(process-discussion:<流程編號>) app/notification/handler/notification_socketio_handler.py:19(改繼承 AuthenticatedNamespace);:34 on_join 核對流程歸屬;:43 on_reload 整支刪掉 🟢 低:門完全沒鎖,但房間裡沒東西 工具 C3,面板 0:3 否決(否決理由 runner 開檔核對屬實);runner 實測重現「不驗身分」本身
U12-3 修正線的「總部層級」守門:同一家客戶的子公司擋得住,另一家客戶的總部擋不住,而寄信/LDAP 的共用列全站只有一列 客戶 B 的總部管理員仍然能改掉全站唯一那一列寄信伺服器/LDAP 設定,影響到客戶 A 和平台自己 另一家客戶的總部管理員帳號(持有 smtp-config.update 或 ldap-config.update,新客戶開通時自動拿到) 修正線 common/authz/tenant_hq.py(判準是「站在第二層」,沒區分「是哪一家的第二層」);產品面要先決定:寄信/LDAP 究竟是「全站一份」還是「每家客戶一份」 🔴 高(若產品定義是全站一份):跟第 74 項原本的危害完全相同,只是攻擊者從「任何客戶的子公司管理員」縮小到「任何客戶的總部管理員」 runner 對修正線實測(未經三人面板);見下方「疑點 ②」
U12-4 人員比對收到公司編號但沒拿來過濾;平台管理員身分下會橫跨所有客戶比對 平台管理員替客戶 A 匯入 SSP 時,文件上的 email 若剛好對到客戶 B 的某人,SSP 上會註記成客戶 B 那個人的使用者編號;匯入當下預覽畫面顯示對方名字與帳號,編號隨 SSP 匯出帶出去(客戶 A 自己的使用者之後打開,名字會被資料庫隔離擋成空白) 平台管理員身分操作匯入;文件裡的 email 在別家公司有同名帳號(開發環境已有 1 組實例) domain/oscal/service/reconciliation/person_reconciler.py:26、:41、:58、:76(四處查詢都要比照組織比對加 tenant_id) 🟢 低:一般客戶身分被資料庫隔離擋住;配對結果只是文件上的附註,不給權限、不把人加進專案 runner 開檔+實測(未經三人面板)
U12-5 解析工作單兩張表的「新增」資料庫保護是 WITH CHECK (true) 程式若哪天從請求內容拿公司編號,資料庫不會擋 目前沒有路:公司編號一律取自登入身分 出貨基線 scripts/init/02-schema.sql:24970、:25004 🟢 低(第二道防線有缺、第一道完整) runner 開檔+DEV 唯讀查 pg_policies(未經三人面板)
U12-6 防竄改旁證的「來源 IP」直接讀 X-Forwarded-For 標頭 竄改事件的旁證紀錄可被偽造 IP,事後追查誤導 在竄改被偵測的那個請求裡帶假標頭 common/integrity/adapters.py:269-273(修正線已改成 request.remote_addr) 🟢 低:只影響旁證,旁證本身文件就寫明「不是身分溯源」 runner 開檔(未經三人面板);修正線已修

現況(2026-10-01):本棒各條後來的處理結果如下(過程紀錄保留,不改)。

  • U12-1(即時通知頻道不驗身分)=總表第 213 項,✅ 已修(拔除,CM-2373,1.21.1;頻道、留言 API 與前端元件一併拆除)。
  • U12-3(總部層級守門)=總表第 212 項,✅ 已修(CM-2202,commit 6cfcb116e,1.21.0 出貨)。
  • U12-4(人員對帳不分公司)=總表第 214 項,✅ 已修(CM-2211,commit fe928859f,1.21.0 出貨)。
  • U12-5(解析工作單表新增規則)=總表第 215 項,✅ 已修(CM-2215,commit 822216b6c,1.21.0 出貨)。
  • U12-6(旁證來源 IP)=總表第 216 項,✅ 已修(FR-114 CM-2171,commit 908f9dd0e,1.21.0 出貨)。
  • 工具 F1/F2 舊帳:總表第 74/72 項,均 ✅ 已修。
§4

工具報的逐條

工具只讀程式碼、沒有執行任何攻擊;三條候選各由三位檢查員從「打不打得到」「後果多大」「有沒有別的防線」三個角度投票。

F1 🔴 任何客戶的管理員都能改掉全站唯一那一列寄信/LDAP 設定(高,3:0)=總表第 74 項

  • 在哪:app/system_config/service/guarded_system_config_service.py:458(_write_shared_root_config)。
  • 怎麼打:任一客戶的系統管理員送 PUT /api/1.0/system/config/SMTP/*,把主機改成自己的機器、changePwd: false(系統會保留原本的寄信密碼)。然後對任何人(包括平台管理員)按「忘記密碼」,重設信就經過攻擊者的郵件伺服器,攻擊者拿到重設連結就能改掉對方密碼。系統登入攻擊者的郵件伺服器時,還會把公司真正的寄信帳密送過去。LDAP 同理:指到攻擊者的帳號目錄,就能控制所有用 LDAP 登入的人。
  • 為什麼擋不住:唯一的檢查是能力點 smtp-config.update/ldap-config.update,而這兩個是客戶層級、每家客戶的系統管理員開通時自動拿到;_platform_admin_only(:252-257)只列 AI 與雲端硬碟兩組。寫入走繞過資料庫隔離的 write_root_config_value。
  • 面板:打不打得到 TRUE(高)、後果 TRUE(高)、防線 TRUE(中)。最終高。
  • 首腦既有結論:=總表第 74 項(FR-096 H1 F2 已登記,並在 FR-115 W7 重現過一次)。本棒工具再撿到一次、補了一個具體後果:重設密碼信被攔截 → 可接管平台管理員帳號。主線現況未變(見疑點 ②)。
  • 我自己核對:開檔確認 :458、:651-657、:607-616 三處寫入都沒有平台管理員檢查;DEV 唯讀查三個能力點 is_platform=f(2026-09-25 15:30);寄信讀取端 app/notification/service/notification_service.py:58 確實讀 read_root_config_value("SMTP","*")。屬實。

F2 🟠 任何登入者讀得到物件儲存帳密明文(中,3:0)=總表第 72 項

  • 在哪:app/system_config/service/guarded_system_config_service.py:554(get_system_config_by_key);入口 api/system_config/routes/system_config_route.py:131 只有 @jwt_required。
  • 怎麼打:任一登入者(例如沒有 storage-config.* 能力點的稽核員)打 GET /api/1.0/system/config/STORAGE_CONFIG/CONFIG,回應裡 access_key/secret_key 是明文。
  • 為什麼擋不住:_mask_secrets(:234-245)只遮 AI 與雲端硬碟兩組;套件的遮罩名單沒有 STORAGE_CONFIG。
  • 面板:三個角度都 TRUE(中)。前提是攻擊者連得到物件儲存伺服器——落地版 SeaweedFS 只在內網。
  • 首腦既有結論:=總表第 72 項(與第 39 項同一件)。修正線 CM-2063 已補儲存設定遮罩(_mask_storage),但修正線的 system_config_route.py:131 讀取入口仍然沒補能力點(我開 fix/security-b1 那一行確認)——遮罩補了,門還是開的;拿不到密鑰了,但端點、帳號、儲存桶名稱仍然看得到。

C3(被否決,0:3)即時通知連線不驗身分

  • 工具報的內容與我的 U12-1 相同:連線不驗、房間任意進、reload 原樣轉發。
  • 三位檢查員都同意「門沒鎖」是真的,但都判定「沒有實害」,理由三點:①伺服器唯一的廣播(flow_engine_route.py:121)送往 /socket/process-discussion,不是這個頻道,所以進了房間也聽不到任何東西;②前端唯一連這個頻道的元件 ProcessDiscussBox.vue 沒有任何地方引用,是死碼;③就算接回去,收到 reload 也只是用受害者自己的登入身分重抓留言,不顯示廣播內容。
  • 我核對:grep FE repo,ProcessDiscussBox 除了元件本身零引用;/socket/notification 在 jedi 套件裡也沒有任何人往裡送。否決理由屬實,我原本的「中」降為「低」(見 U12-1 與疑點 ⑥)。

密鑰掃描

零發現。

§5

卡片點名的疑點逐條回答

① guarded_system_config_service.py:守門的涵蓋名單完不完整?其他設定群組有沒有兩道?

先講結論:AI 金鑰(AI_PROVIDER_CONFIG)和 Google 雲端硬碟應用程式設定(GOOGLE_DRIVE_APP_CONFIG)兩組有「能力點+平台管理員」兩道;其他群組只有能力點一道,這是設計,不是漏。

逐組對照(主線 feature/review):

設定群組 畫面 第一道:能力點 第二道 實體放在哪 只有一道要不要緊
AI_PROVIDER_CONFIG AI 服務設定 泛用端點走 system_config.*(誰都沒有,只有超級管理員);選單綁 storage-config.* ✅ 平台管理員(:252-277) 每家客戶一列 —
GOOGLE_DRIVE_APP_CONFIG 雲端硬碟應用程式設定 同上 ✅ 平台管理員 平台那一列 —
STORAGE_CONFIG 儲存設定 storage-config.*(非平台層) 只有一道 每家客戶一列,資料庫隔離擋別家 不要緊:改的是自己那列
NOTIFY_CONFIG 通知設定 notify_config.*(非平台層) 只有一道 每家客戶一列(讀取端 tenant_notify_config_reader.py:37 按租戶讀) 不要緊
ISSUE_INTEGRATE_CONFIG 議題整合(GitLab/GitHub) issue-integrate-config.*(平台層,DEV 實查 is_platform=t) 能力點本身就只發給平台 平台那一列 不要緊:客戶拿不到能力點
SMTP 寄信伺服器 smtp-config.*(非平台層,DEV 實查) ❌ 主線沒有 全站只有平台那一列(shared_config_app_service.py:38-41) 🔴 要緊=總表第 74 項
THIRD_PARTY_LOGIN/LDAP LDAP 帳號目錄 ldap-config.*(非平台層) ❌ 主線沒有 全站只有平台那一列 🔴 同上
RUNTIME_CONFIG 登入安全政策 security-policy.*(非平台層) ❌ 主線沒有(專屬端點 security_policy_app_service.py:162 也沒有) 全站只有平台那一列 🔴 同上
PASSWORD_POLICY、WEB_IDEL_CONFIG 等不在對照表的 — 退回 system_config.*(平台層,DEV 實查 is_platform=t) 能力點本身就只發給平台 平台那一列 不要緊

判斷方法:「只有一道要不要緊」取決於這組設定的實體是每家一列、還是全站一列。每家一列的,能力點加資料庫隔離就夠;全站一列的(SMTP、LDAP、登入規則),能力點發給了每家客戶的管理員,就一定需要第二道。守門服務裡的 _platform_admin_only(:252-257)名單只列 AI 與雲端硬碟兩組,這份名單的判準是「第一版不開放客戶自填」,不是「全站共用一列」——所以 SMTP/LDAP 不在裡面是對的,它們的第二道本來就應該是另一條規則(總部層級),而那條規則主線還沒有。

另外看到兩件相關但不在本棒範圍的事:

  • 讀取端的三個入口只檢查登入(api/system_config/routes/system_config_route.py:131 與套件的兩支)=總表第 72 項/第 39 項,修正線上 :131 仍然沒補(我開修正線 fix/security-b1 那一行確認過)。本棒守門服務的讀取路徑對 AI/雲端硬碟兩組有補平台管理員,所以這兩組的讀取是安全的;其他組靠第 72 項那張工單。
  • AI 儀表板的「查系統設定」(di_containers/dashboard_apis/system_config.py)沒有申報 required_capabilities,AI 儀表板套件的新版規則是「沒申報就拒絕」,所以這支目前是拒絕狀態,不是公開。

② 總表第 74 項那三組(登入規則、LDAP、寄信)現在是不是還讓子公司管理員寫進全站共用那一列?

主線:是,還是寫得進去。修正線:子公司擋住了,但另一家客戶的總部還寫得進去。

主線(feature/review,本棒掃描版本):

  • 寫入入口有四條:宿主 PUT /system/config/<group>/<key>(system_config_route.py:142-150)、套件 by-uid PUT/DELETE、套件 POST、登入政策專屬端點。
  • 四條都只做能力點檢查(core/plugins/system_core.py:98-115 的 assert_config_capability)。守門服務裡的 _assert_restricted_group_access 對這三組直接 return(因為不在 _platform_admin_only 名單)。
  • 之後 update_config_by_group_key(:651-657)與 update_system_config(:607-616)看到是共用設定,就走 _write_shared_root_config 寫進平台那一列,繞過資料庫隔離(infra/system_config/system_config_root_reader.py 的 write_root_config_value)。
  • DEV 唯讀實查(2026-09-25 15:30):smtp-config.update、ldap-config.update、security-policy.update 三個能力點 is_platform 都是 f。
  • 結論:=總表第 74 項,主線現況未變。

修正線(fix/security-b1,CM-2054 e90d8e3cb):

  • 新增「軸⑦ 總部層級」common/authz/tenant_hq.py,名單 common/constant/company_wide_config.py 列了 SMTP、THIRD_PARTY_LOGIN、RUNTIME_CONFIG 三組。

  • 守門服務的六個寫入方法、core/plugins/system_core.py 的能力點分流、登入政策專屬端點,全部都補了,連 by-uid 寫共用列那條提前 return 的分支(update_system_config 裡)也補了。覆蓋面我逐條對過,沒有漏的入口。

  • 但判準有一個洞:viewer_is_tenant_hq() 的判準是「可見租戶路徑最短那條剛好兩層」。我寫了一段小程式實測:

    身分 路徑 能不能寫全站共用那一列
    客戶 A 總部 /1/102/ 能
    客戶 A 子公司 /1/102/152/ 擋住 ✅
    另一家客戶的總部 /1/131/ 能 ⚠️

    寄信與 LDAP 的共用列是全站只有一列(SHARED_ROOT_CONFIGS 寫入一律落平台那列,讀取端 read_root_config_value("SMTP","*") 也只讀那列)。所以客戶 131 的總部管理員改寄信伺服器,改到的是客戶 102 和平台自己在用的那一台。第 74 項原本的危害在修正線上只從「任何客戶的子公司管理員」縮小成「任何客戶的總部管理員」,沒有消失。

  • 這個洞的根源是產品定義還沒說清楚:第 74 項討論時補的「方向④」寫的是「只給客戶自己的第一層租戶改」,這句話在「每家客戶有自己一份」的前提下才成立;但寄信/LDAP 目前的資料模型是全站一份。修正線照方向④寫,卻沒有把資料模型改成每家一份。

  • 修法要先做產品決策(二選一):①寄信/LDAP 真的是全站一份 → 這三組的寫入改成只有平台管理員(require_platform_admin,現成);②每家客戶該有自己一份 → 要把 SHARED_ROOT_CONFIGS 的落點從「平台那列」改成「該客戶總部那列」,讀取端(寄信、LDAP 登入)也要跟著改成按客戶讀,這是一件資料模型的改動,不是守門。登入規則(RUNTIME_CONFIG)同理。

③ tenant_storage_config_seeder.py:AI 設定寫入時有沒有限定租戶?

有。 寫入走 infra/system_config/tenant_config_seed_writer.py:70-84 的 INSERT ... ON CONFLICT DO NOTHING,tenant_id 是呼叫端傳進來的新客戶編號,而資料庫的新增規則是 app_tenant_allowed_for_session(tenant_id)(沒有超級管理員例外),建立者的身分必須看得到那家新客戶才寫得進去。只會新增、永遠不會蓋掉既有列。

真正的問題是「抄了什麼」:主線把原廠 AI_PROVIDER_CONFIG 整格(含金鑰)照抄給每家新客戶(:52-55)=總表第 161 項。修正線 CM-2192(21874f869)已加 _strip_ai_secrets 只抄非機密欄位,主線尚未合回。

④ common/integrity/adapters.py:雜湊鏈寫入 api_logs(沒有客戶隔離)時的租戶歸屬?

前提不成立:這支不寫 api_logs。 全檔唯一碰資料庫的地方是 _active_sessions_snapshot(:284-327),對 public.api_logs 做唯讀 SELECT,目的是竄改事件成立當下,記一份「最近 30 分鐘有哪些帳號在線」當旁證。它不分客戶是對的——竄改是整台主機的事件,不屬於任何一家客戶;而這份旁證只寫進防竄改套件自己的事件表,不回給任何 API。

沒有雜湊鏈寫入;驗章用的是套件內建公鑰(LicenseEngineVerifier),環境變數只能改「標記檔放哪」「要不要在源碼模式強制」這類部署參數,註解明寫「可設定=可關閉」的參數刻意不接環境變數。

順帶看到一個小問題(U12-6):_current_request_context(:269-273)的來源 IP 優先讀 X-Forwarded-For 標頭,呼叫端可以自己帶假的。影響只是竄改旁證被誤導,修正線已改成讀 request.remote_addr(由 ProxyFix 設定)。

⑤ 兩支解析工作單 repo:已知資料庫保護規則寫壞,這兩支就是讀寫它們的程式

這兩張(ssp_docx_parse_jobs/ssp_excel_parse_jobs)的讀/改/刪規則是對的;寫壞的是另外兩張。

  • DEV 唯讀查 pg_policies:兩張表的 SELECT/UPDATE/DELETE 都是 is_super_admin='t' OR app_tenant_allowed_for_session(tenant_id)——這是 5 月 2026-05-01-fix-ssp-docx-parse-jobs-rls.sql 修好的正確寫法。
  • 總表第 178 項說的壞規則是 ap_docx_parse_jobs/ar_xlsx_parse_jobs(稽核計畫與稽核結果),不是這兩張。盤點檔 6 節把 SSP 這兩張也標「RLS 規則寫壞」是誤植。
  • 仍有一個小縫(U12-5):INSERT 規則是 WITH CHECK (true)——任何身分都能新增任何公司編號的工作單。目前打不到,因為建立工作單時公司編號一律取自登入身分(ssp_docx_import_app_service.py:170 tenant_id=user_context.tenant_id),請求內容裡沒有這個欄位。
  • 程式本身:update/deactivate(ssp_docx_parse_job_repo_impl.py:31-54)只用編號查、不帶公司條件,靠資料庫隔離擋;呼叫端(ssp_docx_import_app_service.py:256-300)讀、捨棄、確認三步都先核 job.tenant_id != user_context.tenant_id,外加專案參與者/管理員檢查。update 會跳過 tenant_id、created_user 等保護欄位(:12)。Excel 那支同構(多一個 metadata 欄位名對映),:253/:294/:320 同樣三步都核。不成立。

⑥ notification_socketio_handler.py:連線時怎麼認身分?能不能訂閱別人的通知頻道?

不認身分;能訂閱任何頻道;還能對任何頻道廣播——程式碼層面成立,我親手重現。但今天這個頻道上沒有任何東西,所以沒有實害(工具 C3 被面板 0:3 否決,否決理由我核對屬實)。評低(U12-1)。

程式碼:

  • :19 class NotificationSocketioHandler(Namespace)——繼承的是 flask-socketio 原生的 Namespace,不是 jedi_iam.middleware.socketio_auth.AuthenticatedNamespace。同伺服器上另一支 FillSurveySocketioHandler 繼承的是後者,它會在連線時驗登入憑證(authenticate_socket_connection,驗不過拒連)、每個事件前重新確認身分。
  • :23-31 on_connect:印 log、回一句「Connected」。沒驗任何東西。
  • :34-41 on_join:join_room(data.get('room')),房間名稱完全由呼叫端決定。
  • :43-52 on_reload:emit('reload', data, room=data.get('room')),把呼叫端送的整包內容原樣廣播給該房間所有人。
  • 註冊在 config/socketio_namespaces.py:41-45,路徑 /socket/notification;伺服器 core/app_factory.py:180-186 設定 cors_allowed_origins="*"(任何網站都能連)。落地版 docker/production/docker-compose.yml:314 由 nginx 把 /socket.io 代到 socketio 服務,與前端同網址、對外可達。

誰在用——沒有人:前端唯一連這個頻道的是 ProcessDiscussBox.vue:37(流程討論串,加入房間 process-discussion:<流程編號>、收到 reload 就重抓留言),但這個元件整個 FE repo 沒有任何地方引用(面板指出、我 grep 確認)。伺服器端在有人留言時由 api/flow_engine/routes/flow_engine_route.py:115-121 廣播 {room, user_name, message}(留言者帳號與留言全文),但送的是 /socket/process-discussion,這個頻道沒註冊,也不是 /socket/notification。jedi 套件也沒有人往 /socket/notification 送東西。

所以今天的狀態:門完全沒鎖,但房間是空的——連得進來的只有其他匿名連線。會變成真問題的時機:有人把討論串元件接回畫面、並把伺服器那行 namespace 對齊成 /socket/notification(兩件都像是「修個即時更新」會順手做的事)。那一刻起,不用帳號就能偷聽任何知道編號的流程討論串。建議趁現在沒人用,把門先裝上。

實測(scratchpad sock_test.py,用 flask-socketio 的 test client 直接載入這支處理器):

attacker connected w/o token: True
victim received: [('reload', [{'room': 'process-discussion:SOME-OTHER-TENANT-WORKFLOW-ID', 'user_name': 'boss', 'message': 'FAKE'}])]

攻擊者不帶任何登入憑證就連上、加入房間、送出 reload,同房間的其他連線收到一則冒名「boss」的內容。這證明「不驗身分、房間任意進、內容原樣轉發」三件都成立;但如上所述,今天房間裡不會有真的使用者。

打折說明:用 test client 在單一行程內模擬,不是對真實落地版的 8002 連線;沒有接 Redis message queue。處理器程式碼就是那 53 行,沒有其他地方可以補身分檢查,所以結論不受影響。

修法:①:19 改繼承 AuthenticatedNamespace(一行,連線與事件身分就都有了);②on_join 加入前核對 process-discussion:<id> 這個流程是不是呼叫者看得到的(比照問卷那支的 _assert_participant);③on_reload 整支刪掉,前端沒有任何地方送 reload(我 grep 過 FE repo,唯一的 emit('reload') 是 Vue 元件事件,不是 socket);④順便把 flow_engine_route.py:121 的 namespace 對齊。

⑦ 人員對帳:跨公司比對時拿別家的人名 email 來配對,配對結果會不會把別家的人寫進自己的 SSP?

會有條件地發生:一般客戶身分被資料庫隔離擋住;平台管理員身分下會。寫進 SSP 的是「附註」,不是權限。

程式碼:

  • 入口 party_reconciliation_service.py:21-30 把 tenant_id 傳給人員與組織兩個比對器。
  • 組織比對(organization_reconciler.py:26-27、:44、:62、:83)四個查詢都有 if tenant_id is not None: q.tenant_id = tenant_id。✅
  • 人員比對(person_reconciler.py)四個查詢——人工指定(:26 用帳號名稱)、完全相同(:41 用 email)、正規化(:58)、模糊(:76 抓全部使用者當候選)——全部沒帶 tenant_id。參數收到了、沒用。

實測(scratchpad recon_test.py,假的使用者服務記下收到的查詢條件):

person query filters: {'email': 'alice@vendor.com'} -> matched_user_id 777 exact
org query tenant filter: ('org', 5)
label query filters: {'login_name': 'alice_b'} -> matched 777 user_selected

替公司 5 做比對,人員查詢沒帶公司條件,直接配到公司 7 的使用者 777;組織查詢有帶公司 5。

資料庫隔離擋不擋(DEV 唯讀交易,cm_app 身分,2026-09-25 15:4x):

模擬身分 看得到的使用者 其中客戶 131 的人
客戶 102 的一般使用者(allowed_tenant_paths=/1/102/) 38 0
平台管理員(is_super_admin=t) 42 2

users 表的讀取規則是 is_super_admin OR app_tenant_allowed_for_session(tenant_id) OR 是自己。一般客戶看不到別家;平台管理員(路徑 /1/)的隔離是全開的(jedi_common/session/database/db.py:190-195 路徑是根就設 is_super_admin=t)。DEV 另有 1 組 email 同時存在於兩家客戶。

所以:平台管理員替客戶 102 匯入一份 SSP,文件上寫的 email 若在客戶 131 也有帳號,完全相同比對可能先拿到 131 那個人(資料庫回傳順序決定)。模糊比對更寬:同網域+名字相同就配。

配到了會怎樣:

  • 解析時寫進工作單的 parsed_result(ssp_docx_import_app_service.py:629、ssp_excel_import_app_service.py:885-889),預覽畫面會顯示那個人的暱稱與帳號(party_match_enrich.py)。
  • 確認匯入後寫進 SSP 人員的 OSCAL 附註欄 matched-user-id(import_adapter/_common.py:121-123)。之後 SSP 人員清單(ssp_party_app_service.py:70-74)與 Excel 範本下載(ssp_import_template_app_service.py:1111)會用這個編號反查出名字與帳號。
  • 不會把人加進專案參與者、不會給任何權限,全 codebase 沒有任何地方拿 matched-user-id 做授權判斷。
  • 客戶 102 的使用者日後打開 SSP,反查時是用他自己的身分查,資料庫隔離會擋住客戶 131 的人 → 名字顯示空白;但編號本身已寫進文件,匯出 OSCAL 時會帶出去。

另一條相鄰的路(不在本棒範圍,順帶記):SSP 人員新增/編輯 API(ssp_party.py:21-22 的 matched_user_id/matched_org_unit_id)接受呼叫端直接給任意整數,ssp_party_app_service.py:98-110 原樣寫進附註,不核這個人是不是同公司。一般客戶身分之後反查會被資料庫擋住,只能寫進一個「看不到名字的編號」,影響同上但更小。

嚴重度 🟢 低(U12-4):需要平台管理員自己操作匯入、需要剛好有同 email 的別家帳號;後果是文件附註洩漏一個別家帳號的名字與 email 給客戶,而且只有平台管理員當下看得到名字。修法:person_reconciler.py 四處查詢比照組織比對加 tenant_id;SSP 人員 API 寫入 matched_user_id 前核對同公司。

⑧「有人守了一半」逐型對照

型態 本棒有沒有 在哪
只驗「你登入了沒」、沒驗「資料是不是你的」 ✅ 有 F2 讀取入口只驗登入(=第 72 項);U12-1 即時通知連線連登入都沒驗(但目前沒東西經過)
只檢查列表、沒檢查單筆 ❌ 沒有 守門服務讀寫七條路徑都各自補了
權限寫在 route 裝飾器、有第二支路由沒掛 ⚠️ 有類似的 修正線四條寫入入口全補,但主線 :131 讀取端仍只驗登入(=總表第 72 項)
守門條件用 or 串起來其中一個永遠成立 ❌ 沒有 _platform_admin_only 兩個條件都綁群組名稱
例外處理把「查不到」與「沒權限」混成同一個回應 ✅ 刻意的 get_system_configs 對非平台管理員把受限列當作不存在,註解寫明理由(不讓泛用查詢整支失敗),合理
背景作業假設呼叫者是自己人 ❌ 沒有 seeder 用建立者身分寫,資料庫擋
註解寫「這裡刻意不檢查」但上一層沒真的檢查 ⚠️ 有 套件 SystemConfigListRoute 註解「讀取只要登入即可;受 RLS 與宿主 service 兩層限制」——但宿主 service 只限制 AI/雲端硬碟兩組(=第 72 項)
判準抓對了層級、沒抓對「是哪一家」 ✅ 有,本棒新的型態 U12-3 修正線總部守門
§6

可信度分兩層

第一層:工具正式報告——驗證章 verified:研究員 2 派 2 回、3 候選去重後 3 條、9 票全投完、零漏投、零失敗。F1(高)與 F2(中)3:0 通過,兩條都是總表既有項(第 74、72 項),淨新增 0;C3 0:3 否決。密鑰掃描零發現。

第二層:runner 人工——本報告 U12-1~U12-6 全部是 runner 依卡片逐支開檔、實測或 DEV 唯讀查核追出來的,沒有經過三人面板投票:

  • U12-1:開檔+test client 實測重現;與工具 C3 同一件,面板否決理由(前端元件是死碼)我 grep 核對屬實,故評低。
  • U12-3:開修正線檔案+小程式實測判準。
  • U12-4:開檔+假服務實測查詢條件+DEV 唯讀交易模擬兩種身分。
  • U12-5:DEV 唯讀查 pg_policies。
  • U12-6:開檔;修正線 diff 確認已修。
  • 16 支檔案全部通讀。

DEV 查核時間:2026-09-25 15:30~15:45(本機 guidant_ai_dev,唯讀交易、ROLLBACK 結束,未寫入任何資料)。

§7

執行概況

項目 數值
範圍 16 檔/1,921 行(wc -l 實跑)
工具指令 /claude-security scan codebase ... --scope <16 檔> --effort low(focus attack-surface)
run ID wf_a2018933-ffc
掃描版本 e1c9230334051bb91cc77a87e5a8b8ea404ea8c4(feature/review)
驗證章 verified(stamp CLAUDE-SECURITY-REVISION-e1c923033405-dirty.json,-dirty 是工作區有平行線未 commit 的改動,不在本棒範圍)
候選數/票數 3 候選 → 去重 3 → 面板 9 票全投完(F1 3:0、F2 3:0、C3 0:3),零漏投
研究員 派 2(研究 1+密鑰掃描 1)回 2,零失敗、零重試
耗時 約 1 小時 33 分(研究約 1 小時 22 分、面板約 11 分)
子代理 token 約 113 萬