E2 檢查結果:批次分類主幹——上傳/封口/分類/審核/歸檔/清理(jedi-evidence-classification)

E2 檢查結果:批次分類主幹——上傳/封口/分類/審核/歸檔/清理(jedi-evidence-classification)

檢查日期 2026-09-19|對應卡片 CM-1948|檢查範圍 2 個檔案 1,900 行

§1

🔴 一句話結論

工具在這兩個檔裡一條發現都沒有,人工逐項把卡片七個重點全部開檔核完,也沒有找到可以讓人越權看到或改到別人資料的漏洞——守門是完整的。 唯一通過投票的那條(連內部套件庫用明文連線)根本不在這次檢查的兩個檔裡,是研究員跑出範圍撞到的 pyproject.toml,而且已經有卡在追(CM-1634,這是第四次撞到同一件事),所以標「重複不計」、不列為本棒的發現。

白話講這一棒的結果:證據批次這條主線寫得相當紮實。 十個端點每一支都有守門、審核結果存回來時會擋掉不存在的檢查點代號、歸檔時用的是資料庫自己的檔案清單(所以前端就算偷塞別人的檔案編號也掛不上去)、背景執行緒每一次碰資料庫前都有把身分帶過去(含另外一條心跳執行緒)、跨租戶取檔在宿主那側有擋。

但有三件事值得決策者知道,都不是「現在有人能打進來」,而是「未來很容易踩到」或「留了不該留的東西」:

  1. 分類失敗時,錯誤訊息的原文會直接存進資料庫給前端看——目前經手的例外訊息裡沒有密碼金鑰,但這等於把上游套件的錯誤訊息無條件轉出去,哪天某支套件的錯誤訊息裡帶了主機路徑或連線字串,就會跟著出現在畫面上,而沒有人會發現。
  2. 容器印出來的畫面訊息(含錯誤輸出)原文存進資料庫,而且整個專案的成員都讀得到——這把 E1 那棒「容器輸出原文落地」的影響面確定下來了:不是只有管理者看得到,是同專案的每一位參與者。
  3. 批次刪掉之後,後端主機上的工作目錄不會被清——證據檔的副本在分類當下被拉到 ~/.cm-jobs/<批次>-<run>/files/ 下,那一份分類完會刪;但同目錄的 _state.json(完整的判定結果)、_report-original.json、_container-log.txt 永久留著,使用者把整批刪掉也不會消失。

三件事都記在下面「卡片重點逐項查證」裡,不另立成發現——理由逐條寫明。

§2

這一棒在檢查什麼

證據批次是使用者實際在用的主線(舊的 Google 雲端硬碟那條已經標為過時)。整條流程是:稽核專案的管理者建一個批次 → 上傳一批證據檔 → 按「完成上傳」把批次封口 → 按「分類」起一個背景工作去跑 AI 容器 → 跑完進審核階段,人工核對 AI 判的對不對、可以增刪 → 按「歸檔」把每份檔案掛進對應的稽核任務當證據 → 最後「清理」把批次裡沒用到的檔刪掉,或「刪除」整批砍掉。

這一棒看的就是這條流程的服務本體與它的十個端點。

選它當第二棒的理由:守門的形狀在這裡是「先用批次編號查出它屬於哪個專案,再判你是不是這個專案的管理者或參與者」——判斷對象要先解出來才知道,這種守門沒辦法寫在路由那一層,全部散在服務的各支方法裡,所以只要有一支漏寫,就是一個誰都能打的洞,而且不會有任何錯誤訊息。另外有三個地方各是一種「採信誰」的問題:分類跑在背景執行緒(身分要自己帶過去,帶漏了資料庫就什麼都看不到)、審核結果由前端整包送回來覆蓋(前端說什麼就是什麼嗎)、歸檔時採信那份審核結果(那份結果能不能被動手腳)。

掃描目標 ~/Projects/Jedicogy/module/jedi-python-package/jedi-evidence-classification,branch feature/review,版本 955e409(工作區乾淨),mode scan,effort low,focus attack-surface。

範圍兩個檔案:

檔案 行數 角色
jedi_evidence_classification/app/service/evidence_batch_service.py 1,653 服務本體:狀態機、守門、分類背景工作、歸檔、清理
jedi_evidence_classification/api/routes/evidence_batch_route.py 247 十二個路由類別:解析請求、呼叫服務、組回應

本套件的第一棒 E1 已掃完(容器執行鏈,三條發現一中二低)。E2 是 E1 的上游(誰去啟動容器、容器回來的東西怎麼入庫),E5 是 E2 的資料層(查詢條件、儲存庫、資料庫的租戶隔離)。

§3

Coverage

兩個檔案全部讀完,就是範圍給的全部。

沒讀的部分要講清楚:領域服務、儲存庫實作、查詢條件物件、容器執行器(E1 已掃)、儲存後端轉接器,研究員為了追資料從哪裡來有讀進去當背景,但沒有審。宿主這側的守門轉接器(compliance-manager-be/core/plugins/evidence_classification.py,裡面的 is_project_manager 才是真正去查「你是不是管理者」的那段)不在本棒範圍,本棒只看套件有沒有正確呼叫它——呼叫是正確的,但那支自己寫得對不對本棒沒驗。

低強度跑法不做元件盤點、不做威脅建模、不跑額外廣度掃描,completenessCheckOutcome 是 not-applicable(指定範圍的掃描本就不適用)。派兩位研究員、兩位都回報。沒有候選被上限砍掉、沒有候選未被審、沒有元件被丟棄。

🔴 這次工具的表現要如實講:範圍內零發現。 三個候選裡通過投票的那一條在 pyproject.toml——那個檔不在指定範圍內,是研究員追著程式碼跑出去撞到的。另外兩條被三位檢查員一致駁回(0:3)。所以這份報告的主體不是工具產出,是首腦逐項開檔查的結果,下方七項每一項都標明了是誰查的。

本棒把行數壓在 2,000 以下(1,900)的判準有效:沒有中斷、沒有重試、沒有撞額度,研究員沒有被停滯偵測砍掉。

工具沒有執行任何程式碼:沒有打過任何端點、沒有起過容器、沒有連過資料庫、沒有跑測試。所有結論都是讀原始碼推出來的。

§4

掃到什麼(總覽)

本棒範圍內:零條發現。

工具唯一通過投票的一條,性質是越界+重複:

# 嚴重度 這是什麼問題(白話) 出事會怎樣 要先有什麼才打得到 位置 處置
— 🟡 中等 連公司內部套件庫用的是沒有加密的明文連線 同一個網路裡有心人可以在安裝套件的當下掉包內容,被掉包的版本編號還會被記進鎖定檔,之後重裝會一直裝到同一份 攻擊者要先在公司網段上(例如接進同一個區網、或某台網路設備被入侵);而且要剛好有人在跑會重新解析套件版本的指令 pyproject.toml:48 重複不計,見下

為什麼標「重複不計」: 兩個理由都成立。其一,越界——這個檔不在本棒指定的兩個檔內,是研究員自己跑出去讀到的。其二,早就有卡在追——這是跨 arc 總表的 CM-1634,之前已經在 FR-081 I5、FR-085 C3-1(jedi-common)、FR-088 P1 F2(jedi-flow-engine)撞到過三次,本棒是第四次。整個 monorepo 26 個專案的設定都一樣,屬於全域性的供應鏈問題,不是這個套件的疏漏,也不該記在這一棒的帳上。

不過這次撞到有一個額外的資訊值得補進 CM-1634:jedi-evidence-classification 的鎖定檔(poetry.lock)是有進版控的、而且帶著雜湊值,所以在這個套件上,單純的重新安裝(poetry install)是有第二道防線的——與 FR-088 那輪發現「整個 monorepo 把鎖定檔排除在版控外」的情況不同。真正沒有防線的是專案自己文件裡寫的那條更新路徑(poetry update),那個動作會重新解析並改寫鎖定檔,掉包的雜湊值就這樣被寫成「正確答案」。

§5

卡片重點逐項查證

卡片列了七個重點。工具在範圍內零發現,所以以下七項全部是首腦開檔人工查的,每項都標了來源。查法是實際開檔讀、grep 原始碼、一路追到宿主的呼叫端核對。

① 十個端點逐支對守門(工具未報,人工查證)

結論:十二個端點全部有守門,沒有一支漏掉。 逐支列出來給新手工程師核對:

端點 路由位置 服務方法 守門 掛在哪一行
POST /evidence-batches 建批次 route.py:51 create_batch_for_round 管理者 service.py:400
GET /evidence-batches 列批次 route.py:75 list_batches 參與者 service.py:424/:428(兩條分支各一道)
GET /evidence-batches/<uid> 詳情 route.py:101 get_batch_detail 參與者 service.py:450
DELETE /evidence-batches/<uid> 刪整批 route.py:115 delete_batch 管理者 service.py:720
POST .../files 上傳 route.py:122 upload_files 管理者 service.py:501
DELETE .../files/<file_uid> 刪檔 route.py:137 delete_file 管理者 service.py:530
POST .../seal 封口 route.py:145 seal 管理者 service.py:540
POST .../classify 分類 route.py:153 classify 管理者 service.py:982(首腦已核)
POST .../archive 歸檔 route.py:174 archive 管理者 service.py:565
POST .../purge 清理 route.py:181 purge 管理者 service.py:657
GET /classification-run/<uid>/state 讀審核 route.py:195 get_run_state 參與者 service.py:810
PUT /classification-run/<uid>/state 存審核 route.py:200 put_run_state 管理者 service.py:825(首腦已核)
GET .../file/<file_uid> 預覽 route.py:210 get_run_file_preview 參與者 service.py:871(首腦已核)
GET .../validation-report 報告① route.py:228 get_run_validation_report 參與者 service.py:895
GET .../adjudication-report 報告② route.py:234 get_run_adjudication_report 參與者 service.py:906

分不清「管理者」跟「參與者」的話:寫入類動作(建、傳、刪、封口、分類、歸檔、清理、存審核結果)一律要專案管理者;讀取類(看清單、看詳情、預覽檔、看報告、讀審核結果)只要是專案參與者即可。這個分法本身合理。

卡片特別點名要查的 list_batches(:413)——沒有問題,而且防得很明確。 它強制必須帶 round_uid 或 project_uid 其中一個,兩個都沒帶直接回 400(service.py:428-430)。程式碼自己寫了理由:「沒有範圍就沒有專案可判授權,全開清單等於『拿到 token 就看得到全租戶的批次』」。這正是總表第 70 項那個同型問題,這裡有防到。

兩個補充觀察:

補充一:create_batch/add_file/remove_file/complete_upload 這四支沒有守門,但這是正確的。 它們不是端點——沒有任何路由直接叫得到。它們是內層方法,一律由有守門的外層方法(create_batch_for_round、upload_files、delete_file、seal)呼叫。程式碼把這件事寫在分隔註解上(service.py:384-386:「API 入口(route 直接呼叫這幾支;守門與防呆都在這層)」)。不過這是一個需要看註解才知道的約定——未來若有人把其中一支直接掛成端點,守門就沒了。建議在這四支的說明裡各加一句「內層方法,呼叫端須已完成守門」(E1 報告提過的同類建議)。

補充二:認證(你是不是登入使用者)不在這個檔裡,但確實有。 路由檔案自己說明了(route.py:14-17):認證由 mount_routes() 套上宿主注入的裝飾器。我開了 api/routing.py 核對——十二個路由類別全部經過 R() 包裝(:128-138、:66-78),沒有一個漏掉。

② 審核結果整包覆寫與歸檔採信(工具未報,人工查證)

這是卡片最擔心的一項:前端把整份審核結果送回來覆蓋,歸檔時又照著它把檔案掛進任務。查完的結論是:關鍵的三道都有擋,塞不進別人的東西。

檢查點代號(part_id)——有驗,而且是整批拒絕。 put_run_state(service.py:832-841)拿這次分類當下的目標集快照建一份合法清單,逐筆比對;只要有一筆對不上就整個請求退回 400,不會部分成功。程式碼寫了理由:「沉到 per-file 錯誤裡會看起來部分成功」。所以塞別專案的檢查點代號塞不進去。

檔案編號(file_uid)——put_run_state 沒驗,但歸檔那側擋掉了,實際效果等同有驗。 這一點要講得精確:put_run_state 確實沒有比對送進來的 file_uid 是不是屬於這個批次,理論上可以在審核結果裡塞一個別批次的檔案編號進去。但它塞不出任何效果,因為歸檔(archive,service.py:576)的做法是:從資料庫撈出「這個批次自己的檔案列」逐列跑,再拿每一列的 file_uid 去審核結果裡查(placements_by_uid.get(row.file_uid))。方向是反過來的——不是照審核結果去找檔案,而是照資料庫的檔案去找審核結果。所以審核結果裡多出來的編號是一把打不開任何鎖的鑰匙,完全沒有被使用。

同樣的設計也出現在容器跑完回寫那裡(_finalize_batch_run,service.py:1176-1180):一樣是撈資料庫的檔案列、拿 row.file_uid 去查容器回來的結果,對不上的直接跳過。兩處都是同一個正確形狀。

這算缺口嗎——不算漏洞,但算一個未來的陷阱。 目前沒有實際影響,因為兩個消費端都是「以資料庫為準」。風險在於這是一個沒有寫下來的隱含約定:第三個消費端只要改成「照審核結果去跑」就會出事。建議在 put_run_state 加一道 file_uid 白名單比對(跟 part_id 用同一個形狀,成本很低),把約定變成強制。為什麼不另立成發現:目前沒有可用的攻擊路徑,立成發現會讓決策者以為現在有洞。

歸檔還有一道值得一提的把關: attach 收的是資料庫那一列自己的 file_id(service.py:601),不是審核結果裡的任何值。所以「掛哪一份實體檔上去」這件事,前端從頭到尾插不了手。

順帶核了卡片沒問但同一區的一件事:_placements_by_file_uid(service.py:758)對標成「不適用」或「已刪除」的檔一律回空(:772-774),程式碼寫明理由是審核頁標旗標時不會一併清掉放置清單。這個處理是對的,否則使用者標了「不適用」的檔還是會被掛上任務。

③ 背景執行緒的身分(工具未報,人工查證)

結論:帶得很完整,而且是本套件寫得最謹慎的一段。 卡片提到主專案那邊 CM-1912 剛踩過「子執行緒不繼承身分、資料庫擋成 0 列還標成功」,這裡沒有這個問題。

逐一核對:

身分從哪來——在請求內取好再交出去。 classify() 在還有請求脈絡的時候呼叫 get_user_context() 當參數傳給執行緒(service.py:1060),旁邊寫了紅字說明為什麼不能到執行緒裡才取。執行緒本體一進去就 set_user_context(user_ctx) 重建身分(service.py:1096-1097)。

執行緒內每個碰資料庫的地方前面有沒有身分——有,三處全部在 set_user_context 之後。 拉檔(service.py:1113)、回寫結果(:1126)、標記失敗(_fail_batch,:1217)。

心跳那條執行緒——另外再傳一次,而且有人踩過坑留了紅字。 心跳跑在自己的執行緒(不是主工作那條),身分變數不會被新執行緒繼承,所以建立時再傳一次(service.py:1101),心跳的迴圈一進去也自己 set_user_context(service.py:1636-1637)。類別說明裡的紅字把後果寫得非常清楚:少了這個就是無身分 → 資料庫看不到那一列 → 更新命中 0 列 → 而 0 列會被讀成「批次已結束」,於是心跳從第一拍就自己停掉,整個機制靜默失效,資料庫上看不出任何異常(service.py:1616-1621)。這正是 CM-1912 那個坑,這裡不但避開了還留了警語。

其他相關的謹慎設計(一併記錄,都是好的):

  • 交給執行緒的東西全部打包成純值(service.py:1045-1058),不傳資料庫物件過去——傳過去會在另一條執行緒上用到已經關掉的連線。
  • 租戶編號一律用打包好的 job["tenant_id"],不在執行緒裡重讀設定。這正是 memory feedback_background_job_storage_config_no_context_trap 記的那個坑。
  • 宿主脈絡包住整段含錯誤處理(service.py:1086-1087),程式碼寫明理由:標記失敗本身也要查資料庫,只包住正常路徑的話失敗處理會因為同一個原因再失敗一次,批次就真的卡死了。
  • 整段包在 try/except 裡,任何漏接的例外都會走到標記失敗,不會讓批次永遠卡在「分類中」。
  • 另有一支開機掃描(sweep_stale_classifying,service.py:1237)處理「後端被砍掉重啟」的情況。

這一項沒有任何問題。

④ 失敗原因原文入庫(工具未報,人工查證)

卡片的擔心成立:錯誤訊息的原文確實會存進資料庫、前端讀得到。 但目前經手的例外訊息裡沒有查到金鑰或密碼,所以記為陷阱不記為漏洞。

事實部分。 非預期失敗時組的字串是 f"{type(exc).__name__}: {exc}"(service.py:1179),交給 _fail_batch 存進批次的 failure_reason 欄位(service.py:1223,截斷到 2000 字)。這個欄位會回給前端顯示。

逐一追哪些例外會走到這裡:

  • 拉檔階段(stage_to_dir)——這段在宿主,會碰儲存後端。若儲存後端連不上,例外訊息可能帶主機位址、桶名、路徑。是否帶帳密取決於底層套件(minio/seaweedfs 的客戶端),本棒沒有往下追到那一層,這一點是不確定的,不下斷言。
  • 容器失敗(ContainerRunError)——走的是另一條分支(service.py:1175),內容來自 E1 那棒已經查過的容器執行器。
  • 程式自己的錯誤(型別錯、鍵值不存在)——這類訊息裡通常是變數名與資料值,不是憑證。

為什麼記為陷阱而不是漏洞: 這個寫法的本質是把上游套件的例外訊息無條件轉給使用者看。今天安全是因為現在經手的那幾支套件不把敏感資訊放進例外訊息——但這是依賴別人的行為,不是自己的保證。哪天某支套件改了寫法,或換了一個儲存後端,敏感資訊就會靜靜出現在畫面上,而不會有任何人發現。這與 E1 報告②那條「依賴上游 AI 套件的例外訊息行為」是同一個形狀的問題。

建議修法(低成本):在 _fail_batch 存進資料庫之前套一層過濾,把已知的敏感值(儲存後端密碼、AI 金鑰)以字面比對遮成 ***;更保險的做法是分兩欄——給使用者看的用固定的分類訊息(「拉取檔案失敗」「容器執行失敗」),完整原文只寫進後端的紀錄檔。

這一項與 ⑤ 應該一起修,兩者是同一種「原文落地」。

⑤ 容器紀錄入庫與可讀範圍(工具未報,人工查證)

卡片問的那句「participant(不是 manager)能不能讀到容器 stderr 原文」——答案是能,本棒把它確認下來了。

完整鏈路:

  1. 容器跑完,執行器把命令列(金鑰已遮)、標準輸出、錯誤輸出原文寫進 _container-log.txt(E1 報告②已查明錯誤輸出沒有被遮)。
  2. 回寫時整份讀回來存進 classification_run.container_log(service.py:1201,經 _read_container_log :1409)。失敗時也存(service.py:1232)。
  3. get_run_validation_report(service.py:892)把它交給報告產生器(:897)。
  4. 報告產生器呼叫 parse_container_log(validation_report_builder.py:218)。
  5. 那支函式(report_common.py:196-197)做的事是:把 ===== STDERR ===== 之後的所有內容原封不動放進回傳值的 stderr 欄位;另外還逐行抓出含 error/failed/exception/traceback 等字樣的行放進 errors 陣列(:198-202)。
  6. 這份報告的端點守門是參與者(service.py:895)。

所以結論是:容器印出來的錯誤輸出原文,同專案的每一位參與者都讀得到,不限管理者。

這對 E1 的意義:E1 報告②的結論是「容器輸出原文落地並上傳雲端,但目前沒有已知的金鑰外流路徑」。本棒把那條的影響面確定下來了——一旦哪天真的有東西被印出來,看得到的不是只有管理者,是整個專案的成員。 這讓 E1②那條「建議在寫紀錄檔之前對輸出也套一次金鑰過濾」的優先度應該往上調。

為什麼不另立成發現:外流的來源在 E1 的範圍(容器印了什麼),本棒只是確認了下游的可讀範圍。立成新的一條會變成同一件事記兩次。記在這裡,並建議把它併進 E1② 的修正卡當作「影響面」那一段。

⑥ 清理與刪除(工具未報,人工查證)

這一項查出三件事,其中一件是本棒最該記的缺口。

儲存後端的檔——有實刪,而且兩條路徑共用同一段。 purge(service.py:645)與 delete_batch(:703)都走 _delete_stored_files(:683),那支逐列呼叫 self._storage.delete()。檔案是真的刪掉的。

順帶核了刪除的保護:已歸檔的檔案永遠不刪(PURGEABLE_FILE_STATUSES 不含 archived,service.py:102),程式碼寫了三次理由——那些檔背後有任務證據指著同一份檔案,刪掉等於把任務上的證據抽掉,而任務那側不會有任何錯誤訊息。delete_batch 對已歸檔的批次直接擋(:719),而且回的是專用錯誤碼不是一般的狀態碼,理由是「一般的那句話對不上使用者的處境——他需要知道的是改用清理」。這一段寫得很好。

🔴 工作目錄不清——這是真缺口。 分類期間,證據檔會被拉到後端主機的 ~/.cm-jobs/<批次編號>-<run編號>/files/ 下。這個目錄裡最後會有:

檔案 內容 分類跑完會不會清
files/(證據檔副本) 使用者上傳的原始證據檔 ✅ 會(_cleanup_staged_files,service.py:1418)
_state.json 完整的判定結果(每份檔對到哪些檢查點) ❌ 不會
_report-original.json 容器的原始報告 ❌ 不會
_container-log.txt 容器的畫面訊息與錯誤輸出 ❌ 不會
catalog.json/prompt.json/manifest.json 目標集、指令、檔案清單(含原始檔名) ❌ 不會

而且 purge 與 delete_batch 兩支都沒有碰工作目錄——我 grep 過整個套件,jobs_base_dir 只出現在執行器自己的四個地方(建目錄、讀報告、讀紀錄),沒有任何一處刪它。所以使用者按「刪除整批」之後,主機上仍留著那批的判定結果、原始檔名清單與容器紀錄,永久。

嚴重度怎麼看: 目錄權限是 0o700(只有擁有者可讀,classifier_container_runner.py:88),所以同機的其他一般帳號打不開;留下的也不是證據檔本身(那份有清)。真正的問題是「使用者以為刪乾淨了但沒有」——資料保留承諾與實際狀態不一致,這在合規稽核產品上是有意義的落差(客戶可能對稽核資料有保留期限的要求)。加上磁碟會無限成長,沒有任何清理機制。

為什麼不另立成發現:沒有越權讀取的路徑(權限已收),性質是資料保留與維運問題而非入侵路徑。但建議開一張卡:在 purge 與 delete_batch 收尾時一併刪掉該批次的工作目錄,或至少加一支定期清理。決策者可裁。

狀態機擋不擋「分類中刪批次」——擋。 查表(resolve_transition,service.py:209)是所有會改狀態的動作的唯一入口,分類中的批次對刪除動作查不到目標狀態,直接回衝突並給專用的「已封口」錯誤碼(:218-219)。設計上這張表是「唯一的轉換真相」,程式碼說明寫了理由:散成各方法裡的 if 判斷會在新增狀態時漏改,而漏掉的那處不會報錯、只會讓某個非法動作悄悄成功。這個設計正確。

⑦ 檔案上傳(工具未報,人工查證)

兩個子項,一個是缺口、一個沒問題。

副檔名/大小/數量有沒有驗——套件側完全沒有驗,宿主只有一道總量限制。

套件這側(upload_files,service.py:494)收到檔案就直接交給儲存後端存,沒有檢查副檔名、沒有檢查單檔大小、沒有檢查一次傳幾個檔。_file_size(service.py:1527)只是讀出大小記進資料庫,不做任何判斷。_guess_mime(service.py:202)是預覽時用副檔名猜格式,也不是驗證。

宿主那側查到的唯一一道是整個請求的總大小上限 50MB(MAX_REQUEST_BODY_MB,config/config.py:105,在 core/app_factory.py:104 設成 Flask 的上限,另有一道前置檢查 :427-431)。我在主專案與 jedi-file-upload 裡 grep 過副檔名白名單與單檔大小限制,沒有找到。

這算多嚴重: 要打到這裡得先是專案管理者(守門有擋),所以不是任何人都能傳。實際影響有兩層:其一,可以傳任意型別的檔(例如可執行檔)進儲存後端——但後端不會去執行它,風險在於它之後被誰下載;其二,50MB 以內可以塞很多個檔,每個都會進分類容器解析,而容器沒有記憶體上限(E1-3 那條)——這兩條串起來就是一條完整的資源耗盡路徑:管理者(或帳號被盜的管理者)上傳構造過的檔案,容器解析時吃光主機記憶體。

為什麼不另立成發現:需要管理者權限,且真正的傷害發生在 E1-3 已經記的那條(容器沒有資源上限)。建議併進 E1-3 的修正卡當作「上游沒有把關」的補充,並建議套件側加一份副檔名白名單(分類器實際只認得 Word/Excel/簡報/PDF/圖片,白名單本來就該很窄)與單檔大小上限。

_same_content_hints 會不會洩漏別專案的檔名——不會,而且範圍收得很緊。 卡片擔心的是拿內容雜湊跨批次找重複時會把別人的檔名回給你。查完是安全的,兩個理由:

其一,範圍限在同一輪次。_same_content_hints(service.py:465)只對 batch.round_id 查,宿主的實作(core/plugins/evidence_classification.py:458-470)先取這一輪有哪些任務,再只在那些任務的證據裡找。同一輪次 = 同一個專案的同一次稽核,本來就是這批使用者看得到的範圍。

其二,回的內容裡沒有檔名。回的是 [(任務執行編號, 證據編號)] 兩個識別碼(service.py:487-490),不含檔名、不含內容。

另外這段整個被 try/except 包住(service.py:479-486),查詢壞掉只是不顯示提示、不會擋住批次詳情打不開;而且只吞「尚未實作」這一種例外,其他例外仍留紀錄——程式碼寫明理由是「否則真正的查詢壞掉會被當成本來就沒有提示」。這個處理是對的。

§6

可信度:分兩層看

「這些結論對不對」——中高。 七項全部是首腦實際開檔讀、grep 原始碼、追到宿主的呼叫端核對,不是憑印象;守門表那十五列是逐支對著行號抄下來的,可以照著開檔驗。但這些沒有經過三人投票——工具在範圍內零發現,所以面板從頭到尾沒有審過這兩個檔裡的任何判斷。單人判斷沒有對抗性審查。

其中三個判斷特別值得複核:②「file_uid 沒驗但兩個消費端都以資料庫為準,所以塞不出效果」——這是對所有消費端的斷言,我查的是本棒範圍內的兩處(歸檔、容器回寫),若 E5 或其他棒發現第三個消費端,這個結論要重算;④「目前經手的例外訊息裡沒有金鑰」——儲存後端底層套件那一層沒有往下追,這一點我標為不確定;⑦「主專案與 jedi-file-upload 沒有副檔名白名單」是 grep 的結果,grep 找不到不等於不存在(可能寫在設定檔或別的名字底下)。

「只有這些嗎」——不能這樣說。 三個限制要講明:其一,工具在範圍內零發現,所以這份報告等於是單人審查的結果,沒有第二雙眼睛;其二,本棒只看兩個檔,守門真正執行的那段程式在宿主(is_project_manager 到底怎麼查)本棒沒驗,那支若有問題,上面十五道守門全部一起失效;其三,資料層(查詢條件、儲存庫、資料庫的租戶隔離)在 E5,本棒看到的「有守門」是應用層的守門,資料層有沒有第二道防線本棒不知道。

不能說已經驗證過攻擊不可行——沒有打過任何端點、沒有起過服務、沒有連過資料庫。全部是讀程式碼推出來的。

§7

執行概況

項目 值
驗證章(verification.status) verified
候選數 → 去重後 3 → 3
面板票數 9 票(3 條 × 3 位檢查員),全數投出,零漏投
各條票型 通過 1 條 3:0(越界,pyproject.toml);駁回 2 條,各 0:3
本棒範圍內成立的發現 0 條
研究員 派 2 位、回 2 位
代理程式總數 11(全數完成,0 失敗、0 略過、1 空回報)
工具呼叫 603 次
掃描版本 955e409,工作區乾淨(戳記 CLAUDE-SECURITY-REVISION-955e40964baa.json,不帶 -dirty)
run ID wf_8541a72f-a9b
耗時 約 155 分鐘(9,273 秒)
設定 mode scan、effort low、focus attack-surface、2 檔 1,900 行

耗時對照:2 個檔 1,900 行跑了 155 分鐘,比 E1 的 7 檔 1,476 行/135 分鐘還久。再次證實「行數比檔數準、而檔數比強度檔位準」——1,900 行是目前訂的 2,000 行上限的最逼近值,跑起來就是兩個半小時。E1 報告的結論在這裡得到第二次驗證。

過程順利,沒有中斷、沒有重試、沒有撞額度、研究員沒有被停滯偵測砍掉。 本棒把行數壓在 2,000 以下的判準有效。

報告產物落在套件 repo 的 CLAUDE-SECURITY-20260919-020549/,含機器可讀的 JSONL、SARIF 與版本戳記。該目錄有自己的 .gitignore,不會進版控。

修正卡當時未開——依 FR-109/E1 的先例,全部掃完分類後統一開;後續已統一派工並修完(1.21.0 出貨)。現況:① 工作目錄刪不掉 → 已修(M07-6,CM-2227,1.21.0 出貨);② 失敗原因原文入庫 → M07-10,列為非資安功能缺陷,隨下次功能開發收,尚未修;③ 容器紀錄可讀範圍 → 併入 E1 影響面,E1-2 已修(M07-7);④ 上傳白名單 → 併入 E1-3,已修(M07-8,CM-2227)。以下為掃描當時的建議:① 工作目錄刪不掉(⑥,建議開卡);② 失敗原因原文入庫(④,與⑤同一種問題,建議合併一張);③ 容器紀錄參與者可讀(⑤,建議併進 E1② 當影響面);④ 上傳沒有副檔名/大小白名單(⑦,建議併進 E1-3)。