D2-3 檢查結果:解析與外抓(jedi-detection)

D2-3 檢查結果:解析與外抓(jedi-detection)

檢查日期 2026-09-19|對應卡片 CM-1874(D2 第 3 小棒)|檢查範圍 4 個檔案 1,677 行

§1

🔴 一句話結論

找到 2 條,最嚴重是高風險:客戶上傳的掃描規則包會被當成程式碼執行,整台後端主機等於交出去。

系統把使用者上傳的壓縮檔原封不動交給外部程式 cinc-auditor 解析,而那支程式讀設定檔 inspec.yml 時,會先把檔案內容當 Ruby 樣板跑一遍再解析。上傳驗證器只檢查壓縮檔的「形狀」不看「內容」,所以一份檔名正常、結構正常、放了 inspec.yml 的惡意包一道防線都不會碰到。得手之後拿到的是後端行程的全部權限:.env 裡的資料庫/JWT/Redis 三把密鑰、以繞過租戶隔離的管理員身分讀寫全部客戶的資料、代理程式與檔案儲存的憑證,以及往內網橫向移動的跳板。一個租戶管理員就能打下整台主機與全部客戶資料。

第二條是中風險:網址型來源整個跳過壓縮檔驗證器。防炸彈的三道上限只裝在「上傳檔案」那條路,「填網址讓平台去抓」那條路只檢查開頭是不是 https:// 就放行,下載回來直接解開,可以把後端記憶體吃光。這與 D2-1b 那條是同一個病的兩種長法——同一套防護,一條路有、另一條沒有。

卡片指定的重點②(外抓呼叫端有沒有用對)與③(外部程式子程序)工具都沒有單獨報,我逐項開檔查證:②呼叫端全部用對,五道防線一道沒繞;③子程序的參數組法、逾時、暫存檔清理、執行身分全部正確——唯一的問題不在「怎麼啟動這支程式」,而在「這支程式拿到檔案之後做了什麼」,也就是 F1。

§2

這一棒在檢查什麼

客戶要做弱點掃描,得先給系統一包「掃描規則」。給法有兩種:上傳一個壓縮檔,或填一個網址讓平台自己去抓。系統拿到之後要把裡面的規則一條一條讀出來存進資料庫,這個動作叫「抽取」,實際執行的是一支叫 cinc-auditor 的外部程式(業界標準的合規檢測工具)。

這一棒看的就是「拿到規則包之後到存進資料庫之前」這一整段,四個檔案:

  1. detection_profile_extraction_service.py(692 行) — 抽取管線的主幹。決定什麼時候開始抽、開背景執行緒、網址型的話負責下載、抽完落庫、失敗怎麼收。
  2. profile_extractor/inspec.py(433 行) — 真正跟 cinc-auditor 打交道的那一支:把壓縮檔重新打包成工具吃得下的格式、落成暫存檔、啟動子程序、讀回 JSON、轉成控制項清單。
  3. profile_extractor/base.py(95 行) — 抽取器的介面約定(「吃 bytes、吐結果、不准拋例外」)與結果的資料形狀,不含任何格式細節。
  4. safe_http_fetch.py(457 行) — 網址型來源的下載器,含五道防「被拿去打內網」的防線(SSRF 防護)。這支的五道防線 D3-1 那棒已經核過了,所以這一棒不重驗它本身,改看呼叫端有沒有用對。

掃描目標 /Users/chouraymond/Projects/Jedicogy/module/jedi-python-package/jedi-detection,revision 955e40964baa(branch feature/review,工作區有未追蹤的 .claude/、程式碼乾淨),mode scan,effort low,範圍 4 檔 1,677 行。

§3

Coverage

low 強度:一位研究員把這 4 個檔案讀完就提報候選,未做元件盤點、未做威脅建模、未跑額外密鑰專項掃描,completenessCheckOutcome 為 not-applicable(低強度本來就不跑盤點,而且範圍是指定檔案不是整棵樹)。驗證跑了 1 輪,2 個候選去重後仍是 2 個,6 票全數投出,沒有候選遺失、沒有被略過、沒有嚴重度被降低、沒有候選被交到下一輪。

研究員零重試——派 1 位、回 1 位,沒有任何一位被工具判定停滯而砍掉。這是 D2 四小棒裡跑得最順的一次(對照:D2-1 六位全滅、D2-1a 四位陣亡三位)。

研究員為了證明可達性另外讀了範圍外的幾處(基準 route、基準服務、壓縮檔驗證器),以及本機安裝的 cinc-auditor 套件原始碼,那些只當佐證、沒有納入稽核範圍。

研究員沒有回報「哪些檔案沒讀完」的自述(coverage.research 為 null),所以工具端沒有做讀取完整度的交叉檢查——4 個檔案不多,這個缺口不大,但它不是量測過的保證。

工具沒有實際執行任何專案程式碼:沒跑測試、沒發請求、沒造惡意壓縮檔、沒示範攻擊,所有判斷都是讀原始碼推出來的。唯一的例外是首腦核對 F1 時開了本機安裝的 cinc-auditor 原始碼(/opt/cinc-auditor/embedded/lib/ruby/gems/3.4.0/gems/inspec-core-7.1.7/lib/inspec/metadata.rb)確認那一行確實存在——那是讀檔,沒有執行任何東西。

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

  • F1(規則包被當 Ruby 樣板執行)=M03 第 1 條,✅ 已修(CM-2057)。
  • F2(網址型來源跳過壓縮檔驗證)=M03 第 3 條,✅ 已修(CM-2057)。
§4

掃到什麼:總覽

編號 這是什麼問題 出事會怎樣 要先有什麼 在哪裡 嚴重度
F1 上傳的規則包會被外部程式當 Ruby 程式碼執行 後端主機被完全控制:三把密鑰外洩、繞過租戶隔離讀寫全客戶資料、內網跳板 租戶管理員身分 + 自製壓縮檔(或一個自己的網址) inspec.py:167 HIGH
F2 網址型來源完全跳過壓縮檔驗證器,可用解壓炸彈打爆記憶體 後端行程被系統砍掉或長時間無回應,全平台所有客戶一起中斷 租戶管理員身分 + 一個自己的 https 網址 inspec.py:399 MEDIUM

零駁回——兩條候選全部三票通過,沒有越界候選。

§5

Findings

F1 — 上傳的規則包會被 cinc-auditor 當成 Ruby 樣板執行(HIGH,confidence medium)

這是什麼問題。 系統把使用者給的壓縮檔重新打包之後,交給外部程式處理:cinc-auditor json <檔案路徑>。問題不在這個啟動動作——它寫得很乾淨(參數用陣列不是字串、沒有 shell=True、有逾時上限)——問題在被啟動的那支程式拿到檔案之後做了什麼。

cinc-auditor 讀一份規則包時,第一件事是讀裡面的 inspec.yml(規則包的身分證,寫著名稱、版本、支援平台)。它的讀法是:

res.params = YAML.load(ERB.new(content).result)

ERB 是 Ruby 的樣板引擎。ERB.new(content).result 的意思是「把這個檔案的內容當程式碼跑一遍,把跑出來的結果當成 YAML」。所以只要 inspec.yml 裡面寫了樣板標記包起來的指令,那個指令就會在讀設定檔的當下被執行。

這行程式碼我開本機安裝的套件原始碼核對過,確實存在:inspec-core-7.1.7/lib/inspec/metadata.rb:259。

而系統這一側完全沒有任何一道防線在看檔案內容。 壓縮檔驗證器自己在檔頭就寫明「只驗結構不驗語意」——它檢查的是:副檔名對不對、有沒有超過 50MB、magic number 像不像壓縮檔、裡面的檔名會不會解出去寫到別的目錄、有沒有 symlink、entry 數量與解壓比例有沒有超標、有沒有一個叫 inspec.yml 的檔。沒有一項在看 inspec.yml 裡面寫了什麼。

出事會怎樣。 指令會以後端應用程式行程的身分執行。那個身分拿得到:

  • .env 檔:DB_SECRET(資料庫帳密)、JWT_SECRET(簽發登入憑證用的密鑰,拿到就能偽造任何人的登入)、REDIS_SECRET
  • 資料庫的 cmmgr 帳號:這個帳號是設計來繞過租戶隔離的系統管理員,拿到它等於讀寫全部客戶的全部資料
  • 檔案儲存(MinIO)與代理程式的憑證
  • 內網位置:後端主機能連到的每一台機器,都成了下一步的目標

也就是說,一個租戶管理員可以取得整台後端主機,以及平台上全部客戶的資料。這是這個 arc 到目前為止影響面最大的一條。

怎麼打(具體走一遍)。 攻擊者是某個客戶的管理員,在畫面上做的事完全正常:

  1. 造一個 tar.gz,裡面放一個 inspec.yml,內容除了正常的 name: 之外,多一行樣板標記包起來的 shell 指令——例如把 .env 編碼後送到自己的伺服器。
  2. 其餘檔案照常放(要不要放真的規則都行)。
  3. 到「檢測基準」頁面建一支新基準,上傳這個檔。
  4. 上傳驗證全部通過:它是合法的 tar.gz、沒超過 50MB、magic 對、檔名都正常沒有 ..、沒有 symlink、entry 數與壓縮比正常、頂層有 inspec.yml。
  5. 建立成功後系統自動排一次抽取(不需要任何人再按什麼)。背景執行緒把檔案重新打包成暫存檔,啟動 cinc-auditor json /tmp/xxx.tar.gz。
  6. cinc-auditor 讀 inspec.yml 時,樣板引擎執行了那行指令。

網址型更省事:把同一份檔案放在自己的 https 伺服器上,建基準時選「網址」填進去即可——連上傳都不用。

要先有什麼才打得到。

  • 攻擊者得是已登入、且持有「新增檢測基準」能力點的使用者(detection-profile.create)。這顆是客戶層能力點(is_platform=false),租戶管理員就有——這是刻意的設計(客戶要能管自己的基準庫),不是設定錯誤。
  • 該客戶的授權要包含弱點掃描模組。
  • 後端主機裝了 cinc-auditor。專案文件記載四台機器都裝了;本機也裝了(這次核對就是開它的原始碼)。沒裝的話抽取只會落 failed,打不到。
  • 不需要任何人配合。抽取在建基準、版更、改來源、手動重抽之後都會自動觸發。

在哪裡。

  • 缺口本體:jedi-detection/jedi_detection/common/profile_extractor/inspec.py:167 —— subprocess.run([cmd, "json", path], ...),把使用者內容交給會求值的工具
  • 外部工具求值那一行:/opt/cinc-auditor/embedded/lib/ruby/gems/3.4.0/gems/inspec-core-7.1.7/lib/inspec/metadata.rb:259 —— YAML.load(ERB.new(content).result)
  • 內容原封不動的證據:同檔 :330-358 _repack_flat() —— 只改路徑前綴、時間戳、權限位元,每個 entry 的 bytes 原樣寫回(tf.addfile(info, io.BytesIO(data)))
  • 上傳這條路的入口:detection_profile_route.py:184(新增基準)、:359(改某版來源)、:410(版更)、:542(重新抽取)
  • 網址那條路的入口:detection_profile_service.py:946-956 —— 只檢查 http:// / https:// 前綴
  • 驗證器自陳「不驗語意」:detection_profile_archive.py 檔頭

怎麼修。 兩件事,都建議做:

  1. 在交給工具之前,先自己看一眼 inspec.yml 的內容。 抽取管線本來就已經把每個 entry 的 bytes 拿在手上了(_read_entries() 回的就是 [(檔名, bytes), ...]),在重新打包之前多一步:找出 inspec.yml,若內容含 ERB 樣板標記(<%、<%=、<%#)就直接拒收,回一個明確的錯誤訊息。更嚴格的做法是自己用安全的 YAML 解析器(yaml.safe_load)讀出 metadata,只把規則檔交給工具——但那要確認 cinc-auditor 吃不吃「沒有 inspec.yml 的規則包」,成本較高。先擋樣板標記是最小可行的修法,落點就在 _repack_flat() 裡面,一處改完兩條路(上傳與網址)一起好。
  2. 把 cinc-auditor 關進沙箱跑。 獨立容器或低權限使用者、唯讀檔案系統、不給網路、限制記憶體與 CPU。這件事的價值不只擋這一次——它讓「這支外部工具未來又冒出什麼求值行為」從致命變成有界。第 1 點擋的是已知的這一條,第 2 點擋的是還不知道的下一條。

⚠️ 修的時候注意:兩條來源(上傳、網址)必須套同一道檢查。只在上傳那條路加,網址那條路照樣打得進來——這正是 F2 的病因,不要重蹈。落在 _repack_flat() 或 extract() 裡面就自然涵蓋兩條,落在 detection_profile_service.py 的上傳分支就會漏。

驗證。 3/3 三位檢查員確認成立(可達性、影響、既有防護三個角度),嚴重度 HIGH 未被調整。三位各自獨立追了完整路徑:可達性那位從 route 的 multipart 參數一路追到暫存檔、確認內容全程未被改寫;影響那位確認能力點是 is_platform=false(租戶管理員即可)且抽取自動觸發;防護那位逐一檢查了路徑上每一道關卡,確認沒有一道在看檔案內容。

為什麼是 HIGH 不是 CRITICAL: 要先是登入的租戶管理員,不是外面的人打一個網址就中——那一道是真實的門檻。但門檻只有這一道,過了之後沒有第二道,且拿到的是整台主機,所以是 HIGH 不是 MEDIUM。

為什麼可信度是 medium 不是 high: 沒有實際造檔驗證過。結論建立在兩件讀出來的事實上——系統把內容原樣交出去(讀專案程式碼)、工具會把內容當樣板跑(讀工具原始碼)。兩件都核對過,但沒有人真的送一份惡意檔進去看它會不會執行。要升到 high 就得實測,而實測要在隔離環境做(見下方手測清單)。

首腦核對註記。 屬實。我開了本機 /opt/cinc-auditor/.../metadata.rb:259,YAML.load(ERB.new(content).result) 一字不差。也核對了 _repack_flat() 確實原樣搬 bytes(只動 mtime/mode/路徑前綴),以及驗證器檔頭的「只驗結構不驗語意」自陳。與前面棒次不重複:D2-1b 查的是同一支驗證器的數量上限問題,這條是內容問題,兩條在同一個模組但不是同一件事。淨新增 1 條,登記跨 arc 總表。


F2 — 網址型來源完全跳過壓縮檔驗證器,解壓炸彈可打爆記憶體(MEDIUM,confidence medium)

這是什麼問題。 壓縮檔驗證器有三道防「解壓炸彈」的上限:最多一萬個檔、解開後總量最多 500MB、壓縮比最多 200 倍。但這支驗證器全專案只有一個呼叫點,而且在「上傳檔案」那個分支裡面。

「填網址」那個分支長這樣:檢查字串開頭是不是 http:// 或 https://,是就存起來,回傳。沒有別的了。 之後背景執行緒把網址交給下載器抓回來,下載回來的 bytes 直接進解析器,三道上限一道都沒經過。

下載器本身有一道 50MB 上限,但那管的是壓縮之後的傳輸量——一個 45MB 的壓縮檔解開變成幾十 GB 是完全做得到的(高度重複的內容壓縮比可以上千倍)。而解析器讀 tar 的寫法是逐筆 fh.read() 把內容累積進一個 list,沒有任何累計上限。

出事會怎樣。 後端行程的記憶體被吃光,被系統的 OOM killer 砍掉,或長時間卡住沒有回應。後端是所有客戶共用的,所以一個客戶的管理員可以讓全平台一起中斷。

更糟的是可以疊加:排抽取的那支方法每收一次請求就無條件開一條新的背景執行緒(schedule(),extraction_service.py:167-173),沒有併發上限、也沒有「這一版已經在跑就別再排」的判斷——連續建幾個版本就有幾條執行緒同時在吃記憶體。(⚠️ 這個併發問題 D2-1a 那棒已經報過,是那棒兩條發現裡的第二條;這裡只是指出它會放大 F2 的後果,不另計為新發現。)

怎麼打(具體走一遍)。

  1. 攻擊者在自己的 https 主機上放一份約 45MB 的 tar.gz,裡面裝高度重複的填充內容,解開後總量數十 GB,另外放一個頂層 inspec.yml(讓後續的結構檢查過得了)。
  2. 到「檢測基準」建一支新基準,來源選「網址」,填自己那個網址。
  3. 後端背景執行緒去抓:通過 50MB 傳輸上限(壓縮後只有 45MB)。
  4. 解析器逐筆把成員讀進記憶體,數十 GB 灌進去,行程被砍。

要先有什麼才打得到。

  • 攻擊者得是已登入、且持有「新增檢測基準」能力點的租戶管理員(同 F1)。
  • 需要一個能公開連到的 https 網址。SSRF 防線擋的是私網/本機/保留位址,公網位址一律放行——這是決策者裁定的設計(不做網域白名單),不是缺陷。
  • 不需要任何人配合,建版之後自動觸發。

在哪裡。

  • 缺口本體:inspec.py:399 —— out.append((_normalize_name(member.name), fh.read())),逐筆讀進 list 無累計上限(zip 那條路 :384 同理)
  • 驗證器唯一的呼叫點:detection_profile_service.py:941 —— 在 normalized == SOURCE_FILE 分支裡面
  • 網址分支放行的地方:同檔 :946-956 —— 只驗 http(s):// 前綴就 return
  • 下載回來直接進解析器:extraction_service.py:413-419 —— _download() 拿到 raw 就 extractor.extract(raw, file_name)
  • 只管壓縮後大小的那道上限:safe_http_fetch.py:73 DEFAULT_MAX_MB = 50
  • 併發無上限(放大後果,D2-1a 已報):extraction_service.py:167-173

怎麼修。 建議改在解析器裡面而不是補在服務層:

  • 在 _read_entries()(inspec.py:361)加上累計解壓量上限與 entry 數上限,超限就中止、回 ExtractionResult.failed。
  • 為什麼選這裡而不是「網址分支也呼叫一次驗證器」:驗證器吃的是 Flask 的 FileStorage 物件,網址這條路拿到的是 bytes,要重用得先包一層轉接,而且未來任何繞過服務層直接呼叫解析器的新程式碼還是會漏。防護長在解析器裡面,兩條來源、以及未來的第三條,天然都涵蓋。
  • 上限值直接沿用驗證器那三個常數即可(一萬個檔/500MB/200 倍),維持兩邊語意一致。

⚠️ 這條與 D2-1b 那條是同一個模組的兩種病,但修法不同、不要混為一談:D2-1b 是「上限存在但太晚生效」(zip 開檔就展開清單),這條是「上限根本沒裝在這條路上」。兩條都修完,才算兩條來源都有完整的炸彈防護。

驗證。 3/3 三位檢查員確認成立,嚴重度 MEDIUM 未被調整。三位都用同一個方式確認:全專案搜尋驗證器的呼叫點,確認只有一處、且在 file 分支裡——這不是「推測沒有防護」,是「查過了確實只有一個呼叫點」。

為什麼是 MEDIUM 不是 HIGH: 打不到任何資料(不外洩、不竄改),只會讓服務倒掉;而且要先是租戶管理員。

首腦核對註記。 屬實。我自己跑了一次全套件搜尋 ProfileArchiveValidator,命中只有三類:服務層的 import/建構/那一個呼叫點(:59/:115/:941),以及三處註解引用。確實只有一個呼叫點,確實在 file 分支裡。也開檔確認了網址分支(:946-956)從頭到尾只有 startswith(("http://", "https://")) 這一個檢查。淨新增 1 條,登記跨 arc 總表。

§6

卡片重點逐項人工查證

卡片指定本棒要看重點②(SSRF 受限下載的呼叫端)與③(外部程式子程序)。工具兩條都沒有單獨提報,以下全部是人工開檔查證(標「(工具未報,人工查證)」者即此)。

② 外抓:呼叫端有沒有用對 —(工具未報,人工查證)✅ 全部用對

卡片寫明「safe_http_fetch 五道防線 D3-1 已核過,這棒看呼叫端有沒有用對」,所以我不重驗防線本身,只查呼叫關係。

全套件只有兩個呼叫點,都在本棒範圍內的抽取服務裡:

呼叫點 用哪支 做什麼
extraction_service.py:493 fetch_url_bytes(url) 背景抽取時真正下載內容
extraction_service.py:337 probe_url_validators(url) 開明細頁時輕量探測「來源變了沒」

逐項查證結果:

  • 沒有任何地方繞過這支去自己發請求。 全套件搜尋 httpx / requests / urlopen,除了 safe_http_fetch.py 自己之外零命中。也就是說「讓後端去打一個外部網址」這件事,全套件只有這一個入口。這是最重要的一項——防線再好,只要旁邊有第二條沒防護的路就全白做。
  • 兩支都走同一套防線。 探測那支(HEAD)在自己的 docstring 寫明理由:「若這裡只做個簡化版檢查,攻擊者只要挑會用 HEAD 的那條路徑就繞過了全部防護」。實查程式碼屬實——probe_url_validators() 的迴圈與 fetch_url_bytes() 結構完全一致,每一跳都呼叫同一支 _validate_and_pin()、同一支 _open(),唯一的差別是動詞 GET/HEAD 與「沒有大小上限」(HEAD 本來就沒有 body)。
  • 兩個呼叫點都用預設參數,沒有一個放寬上限。 fetch_url_bytes(url) 沒傳 max_mb/timeout_sec/max_redirects,吃的就是 50MB/60 秒/3 跳;probe_url_validators(url) 同理,吃 5 秒/3 跳。沒有呼叫端偷偷把上限調大。
  • 兩支的「不可在 DB session 內呼叫」紅字,呼叫端都遵守了。 下載那支跑在 _run_worker() 的中段,程式碼註解明寫「全部在 session 之外」,前後各自包 session_scope()(:404-419);探測那支的呼叫點在 _check_source_freshness(),它是 @staticmethod 且不碰 DB,caller 側也標了紅字。這不是資安問題是可用性問題(握著連線等外部網站 60 秒等於把連線池的命交給別人),但既然自陳了就一併核,結論是做到了。
  • 錯誤訊息沒有把內網資訊漏給前端。 _download()(:492-504)只把 SafeFetchError.message(我們自己寫的中文)落進 extraction_error,httpx 的原始例外與目標 IP 留在 log 裡;下載器那一側也明寫「不把 httpx 的訊息帶進使用者看得到的字串——它含內部位址與連線細節」。探測那支更保守:失敗一律吞掉回 False(判「無法判定」而不是「有變更」),連錯誤都不回顯。判定:沒有透過錯誤訊息做內網探測的縫隙。
  • 探測回給前端的是什麼(卡片特別問「probe_url_validators 回給前端的內容會不會把內網回應帶出來」):只回兩個 HTTP header(ETag、Last-Modified),_read_validators()(:217-233)只取這兩個鍵,不碰 body(HEAD 本來就沒 body)。而且這兩個值也不是直接回給前端——呼叫端拿它們跟資料庫記的舊值比對,只回一個布林「變了沒」。判定:沒有內容外洩的路徑。

順帶核了一項 D3-1 沒特別提的:下載完成的 log 會印 url 與 etag(:499-503)。網址是使用者自己填的、ETag 是對方伺服器給的,都不含憑證(這條路本來就不代管憑證,服務層註解寫明「平台不驗證可達性、不代管憑證」)。判定:不構成第 22 項(密碼原文進 log)的同題。

② 的總結論:呼叫端全部用對,五道防線一道沒被繞過,也沒有第二條沒防護的外連路徑。

③ 外部程式子程序 —(工具未報,人工查證)✅ 啟動方式全部正確,問題在被啟動的程式(=F1)

卡片列了六個小問題,逐一回答:

a) 參數怎麼組,有沒有 shell=True、路徑可控? —— 組法正確。 subprocess.run([cmd, "json", path], capture_output=True, timeout=self._timeout)(:167-171)。用參數陣列不是字串,沒有 shell=True,所以就算檔名含分號、反引號、$() 都不會被殼層解讀。

path 完全不受使用者控制——它是 tempfile.NamedTemporaryFile(suffix=".tar.gz") 產生的隨機路徑(:152-154),使用者給的原始檔名只用在 log 與錯誤訊息裡。

cmd 也不受使用者控制:resolve_cinc_cmd()(:113-126)在一份寫死的候選清單裡挑——絕對路徑要檔案存在、相對名稱要 shutil.which() 找得到,都找不到就回清單第一個(讓它以 FileNotFoundError 明確失敗)。清單來源是常數不是設定值。⚠️ 唯一的理論前提是 PATH 乾淨——但能改後端行程 PATH 的人已經在主機上了,不構成新的攻擊面。

b) 逾時。 —— 有,900 秒(_DEFAULT_TIMEOUT_SEC = 900,:85)。subprocess.TimeoutExpired 有接、轉成給使用者看的中文 failed(:175-179)。實例是模組載入時建的單例(profile_extractor/__init__.py:38),沒有任何呼叫端傳自訂 timeout,所以實際值就是 900 秒。⚠️ 900 秒偏長(文件記載正常抽取 28~117 秒),它本身不是漏洞,但它是 F2 那類資源耗盡的放大器——一條卡住的抽取會佔住執行緒 15 分鐘。建議修 F2 時一併考慮調短。

c) stdout 上限。 —— 沒有上限(capture_output=True 會把子程序的 stdout 全部收進記憶體)。但這不構成獨立問題:輸出是 cinc-auditor 自己產生的 JSON,大小由規則包的規則數決定,而規則包本身已經被上限管住了(F2 修好之後更是)。錯誤訊息那一側有截斷(_MAX_ERROR_CHARS = 2000,:189),避免把工具的長篇 stderr 整包落進資料庫欄位。判定:不另計為發現,但修 F2 時可順手加一個保守的 stdout 上限。

d) 暫存目錄怎麼清。 —— 清得正確。 try/finally 保證刪除(:156-161),finally 在 _run() 之外層,所以子程序逾時、崩潰、丟例外都照樣刪。刪不掉只寫 warning 不讓整次抽取失敗(合理:留一個暫存檔比讓使用者看到失敗好)。用 NamedTemporaryFile(delete=False) 建檔,檔名隨機、權限預設 0600(只有行程自己讀得到),沒有用可預測的固定路徑,沒有 symlink 攻擊的縫。 ⚠️ 一個小觀察:delete=False 之後手動刪,若行程在寫檔與 finally 之間被 SIGKILL,暫存檔會留在 /tmp。這是磁碟殘留不是安全問題(內容就是使用者自己上傳的東西、權限 0600),不計。

e) _run_worker 是背景執行緒還是排程?用什麼身分? —— 背景執行緒,身分有正確傳遞。 schedule()(:154-175)開的是 threading.Thread(daemon=True),不是排程器。身分的傳遞是對的:呼叫端在 HTTP 脈絡裡 get_user_context() 捕捉(:166),worker 進去第一件事 set_user_context(user_ctx) 還原(:399-400)。這很重要——沒傳的話背景執行緒就沒有租戶脈絡,資料庫的租戶隔離會拿不到 app.user_id 這些變數,寫入會 silent fail 或寫錯租戶。_current_login_name()(:665-667)也是從還原後的脈絡取,用來填稽核欄位。 ⚠️ 順帶確認:schedule() 刻意不加 @transaction,docstring 寫明理由(worker 是另一條執行緒另一個 session,太早排會讀不到還沒 commit 的那一列,所以呼叫端必須在 commit 之後才排)。這是對的,而且這個「本方法不碰 DB」的前提我核過——schedule() 內確實只有捕捉脈絡與開執行緒。

f) _repack_flat 重打包時檔名 _normalize_name 有沒有洗乾淨? —— 有,而且重打包這個設計本身就是一道額外防護。 _normalize_name()(:424-426)把反斜線折成斜線、去掉 ./ 前綴。更關鍵的是重打包的方式:_repack_flat()(:339-358)不是解壓到磁碟再重壓,而是在記憶體裡建全新的 TarInfo,只填 rel(去掉共同前綴後的相對路徑)、size、mtime=0、mode=0o644。 這意味著:原始壓縮檔裡的 symlink、hardlink、device node、詭異權限位元、絕對路徑,全部不可能進到重打包後的檔案裡——因為新的 entry 是程式自己造的,只有「一般檔案」這一種型別。讀 tar 那一側也明寫只收 member.isfile()(:392-394)。判定:這一段是這個模組做得最紮實的地方,路徑類攻擊在這裡天然斷掉。 ⚠️ 但也正因為它只重建「容器」不碰「內容」(tf.addfile(info, io.BytesIO(data)) 原樣搬 bytes),F1 那條才得以穿過——路徑安全與內容安全是兩件事,這裡做到了前者、沒有後者。

③ 的總結論:子程序的啟動方式(參數、逾時、暫存檔、身分、路徑清洗)逐項查證全部正確,寫得相當謹慎。唯一的問題不在「怎麼啟動這支程式」,而在「這支程式拿到檔案之後做了什麼」——那就是 F1。

附帶:base.py(95 行)查證 —(工具未報,人工查證)✅ 沒問題

這支是純介面與資料形狀,沒有可執行邏輯,查三點:

  • 「實作不可 raise」的約定(檔尾紅字):理由寫明「呼叫端是背景執行緒,未捕捉的例外會讓那一版永遠卡在 running」。實查 inspec.py 有遵守——extract() 把重打包的例外收成 failed(:145-148),_run() 把三種子程序例外全部收成 failed,worker 最外層再包一層全捕(:414-421)。三層都在,卡在 running 的路徑不存在。
  • 「靜默失敗」那一分支:cinc-auditor 遇到規則檔語法錯誤時會 exit 0、stderr 全空、輸出合法 JSON、但 controls 是空陣列,整包規則被默默丟掉。這個模組把它當成第三種失敗明確處理。這不是資安問題,是正確的可靠性設計,值得記一筆。
  • source_sha256 與從檔 sha256 刻意不同名(檔尾紅字):前者是抽取器自算的內容雜湊,後者是原始壓縮檔 bytes 的雜湊、是代理程式對帳的信任根。刻意取不同 key 名避免落庫時覆蓋。判定:正確,這種「兩個都叫 sha256 遲早有人寫錯」的預防值得肯定。
§7

這份結果可信到什麼程度

分兩層講:

「報出來的這兩條存在嗎」——F1 可信度中高、F2 可信度高。

F2 我自己跑過全套件搜尋,驗證器只有一個呼叫點、就在 file 分支裡,這是可以直接查證的事實,不是推論;三位檢查員也是用同一個方式確認的。

F1 的兩個環節我都核對過原始碼——系統原樣交出內容(讀專案程式碼)、工具會把內容當樣板跑(讀本機安裝的 cinc-auditor 原始碼,行號一字不差)。但沒有實際造一份惡意檔送進去看它會不會執行。 理論上的殘餘不確定性是:cinc-auditor json <tar.gz> 這條路徑是否一定會走到 from_yaml() 那一支(該檔另有 from_ruby() 等分支)。三位檢查員都判定會,但這一點只有實測能定論。這就是為什麼它掛 medium 而不是 high。

「只有這兩條嗎」——可信度中等,三個已知限制。

  1. 強度是 low,一位研究員讀一遍,不是多位分工交叉讀。低強度設計目的是快速篩不是窮盡。
  2. 重點②③的結論幾乎全是人工查的,沒有三票背書。 工具只提了 2 條候選(都成立、零駁回),但它一條都沒碰卡片指定的兩個重點——②的呼叫端關係、③的六個子問題,全部是我開檔查的。這些「沒問題」的結論可信度低於 F1/F2 那兩條。
  3. 這一棒跑得順,反而暴露另一件事:零重試、6 票全投、1 小時 22 分跑完——但產出仍然只有 2 條,而報告裡大部分篇幅是人工查證。這與 D2-1b、D3-4b 的觀察一致:切棒保證的是工具跑得完,不保證它找得更多。

verification.status 為 verified:三位檢查員對 2 條候選各投一票,6 票全數投出,沒有漏投,票數由工具自己的程式碼統計、不是任何 agent 自報。

§8

被駁回的候選

這一棒零駁回——兩條候選全部三票一致通過,也沒有出現越界候選(範圍外的檔案被當成本棒發現報上來)。

這值得記一筆:D2-1b 與 D3-4b 都出現過研究員被鄰近大檔吸走的情形(D2-1b 那棒範圍只有 2 檔,研究員仍跑去讀了三個套件外的檔案並報了一條越界候選)。這一棒沒有。可能的原因是本棒四個檔自成一條完整的鏈(下載 → 解析 → 結果形狀),研究員不必往外追就能下判斷——與 D2-1a 那棒的觀察互相印證:含 route 的範圍會把研究員引向宿主層,自包含的模組不會。

§9

手測清單(給驗收用)

⚠️ F1 的驗證必須在隔離環境做(獨立的測試機或容器,不要在 DEV 以外的任何環境,更不要在 STG/POC)。這是會真的執行指令的測試。

F1(規則包被當程式碼執行):

  1. 在隔離環境造一個 tar.gz,裡面放一個 inspec.yml,除正常的 name: 之外加一行無害的樣板指令(例如寫一個檔到 /tmp,不要做任何對外連線或讀密鑰的事)。
  2. 以租戶管理員身分建一支新檢測基準,上傳這個檔。
  3. 預期現況:上傳成功、抽取自動觸發,然後去看 /tmp 那個檔案出現了——出現就代表指令真的被執行了,F1 成立。
  4. 修好之後重跑第 2 步,應該在抽取階段就回明確的失敗訊息(「規則包內含不允許的樣板語法」之類),且 /tmp 不會出現那個檔。
  5. 對照組:同一份檔案改成走「網址」型(放自己的 https 伺服器),確認修法兩條路都擋得住——只擋上傳那條就是沒修好。

F2(網址型跳過炸彈防護):

  1. 在隔離環境造一個約 45MB 的 tar.gz,內容用高度重複的填充(解開後數 GB 即可,不必真的做到數十 GB),另放一個頂層 inspec.yml。
  2. 放到一個可連的 https 位置,以租戶管理員身分建基準、來源選「網址」填進去。
  3. 預期現況:下載通過(壓縮後 45MB < 50MB),接著看後端行程的記憶體跳升(docker stats 或 ps)——跳升就代表三道上限確實沒套到這條路。
  4. 對照組:把同一份檔案改成上傳,應該在上傳驗證階段就被「解壓總量超標」擋下,記憶體不會跳。兩邊行為不一致,就是這條的本質。
  5. 修好之後重跑第 2 步,應該在解析階段中止並落 failed,記憶體不會失控。
§10

執行概況

項目 值
run ID wf_1ce16de4-05f
報告 docs/features/FR-108-2609-detection-security-scan/scan-D2-3-extraction-fetch.md
掃描 commit 955e40964baa3cfc0e8766078db7d65075d3c50b(jedi-detection,branch feature/review)
範圍 4 檔 1,677 行
強度 low(一位研究員 + 三票面板)
研究員 派 1 位、回 1 位、零重試
面板 2 條候選 × 3 位檢查員 = 6 票,全數投出
耗時 約 82 分鐘
候選 → 成立 2 → 2(零駁回)
驗證章 verified
§11

這一棒的觀察(給後續小棒)

① 「不含 route 就跑得動」這個假設再得一個樣本。 D2-1a 那棒提出的判讀是「檔案性質比行數關鍵,含 route 的範圍會把研究員引向宿主層」。本棒 1,677 行、四個檔、不含 route,結果零重試、82 分鐘跑完、零越界候選——與該判讀一致。D2-4(16 檔 1,609 行,同樣不含 route)可照原切法派。

② 工具找到的與卡片要查的,是兩組不同的東西。 卡片點名重點②③,工具一條都沒碰;工具報的兩條卻是卡片沒預期到的(F1 根本不在卡片的六個重點裡——卡片重點③問的是「參數怎麼組、timeout、暫存目錄」這些啟動面的事,而真正的洞在被啟動的程式的行為)。這不是工具失職,是「列清單」與「找洞」本來就是兩種活動。給後續:卡片重點仍要逐項人工查(它保證覆蓋),但不要期待工具照清單走。

③ 同一個模組的第二條、第三條病,形狀會不一樣。 detection_profile_archive.py 這支驗證器現在身上有三條記錄:D2-1b 的「上限太晚生效」、本棒 F2 的「上限沒裝在這條路」、本棒 F1 的「上限管不到的維度(內容)」。三條都是同一個防護模組,但沒有一條是同一件事。 收口修正卡時要分開開,不要合併成「修一下驗證器」。