R1b 檢查結果:密碼學核心補掃(jedi-remote-agent)

R1b 檢查結果:密碼學核心補掃(jedi-remote-agent)

檢查日期 2026-09-16|對應卡片 CM-1599|檢查範圍 10 個檔案

§1

🔴 一句話結論

兩條發現通過、都是中等,另有三條候選被檢查員駁回。 F1(註冊用的通行碼永遠不會過期、整家客戶共用同一組)跟 R1 已經開好的 CM-1597 是同一件事、不另計,但這次的價值是把 CM-1597 的前提從「引用別處說法」變成「逐檔實證」;F2 是越界的新發現——GitLab 的第二把寫死在程式碼裡的存取權杖,從來沒有被撤銷過,跟先前 CM-1573 處理掉的那把是不同的兩把。此外,這一棒回答了 R1 留下的那個問題:「簽憑證這一塊零發現」到底是「讀過了、沒問題」還是「根本沒讀」——答案是前者,讀過了。

§2

這一棒在檢查什麼

遠端 agent(裝在客戶機器上、負責跑掃描工具的小程式)要跟雲端講話,得先證明「我是誰」。這一棒專看證明身分的那一整套機制:agent 第一次安裝時拿什麼通行碼來註冊、雲端怎麼幫它簽一張身分憑證、之後每次講話怎麼用短效通行證(JWT)表明身分、連線怎麼加密、心跳怎麼判斷這台還活著。

要確認的是:那張通行碼會不會被撿走還能用、簽出來的憑證權限是不是太寬、短效通行證有沒有寫錯、連線的驗證會不會出錯就放行。這十個檔是整套身分機制的核心,等於整個遠端 agent 的門鎖與鑰匙。

掃描目標 ~/Projects/Jedicogy/module/jedi-python-package(掃描根目錄是整個 monorepo,但檢查範圍鎖在 jedi-remote-agent 的 10 個檔),revision 3cc966f8(branch feature/review,工作區乾淨),mode scan,effort low,focus 設在攻擊面。這 10 個檔從 R1 那次的 cdb0d0f 到本棒的 3cc966f8 零漂移(首腦用 git diff 確認),所以兩棒比得起來。

§3

Coverage

範圍就是那 10 個檔、沒有別的——common/agent_auth/ 下的六支(簽憑證、簽短效通行證、TLS 連線、心跳、設定)加上註冊通行碼那條四檔鏈(app service、domain service、ORM model、repository)。因為是窄範圍的低強度跑法,沒有做元件盤點、沒有做威脅建模,completenessCheckOutcome 為 not-applicable;這份報告完全不能拿來說 monorepo 其他地方沒事。

派出兩位研究員、兩位都回報。驗證跑一輪,5 個候選去重後仍是 5 個,沒有候選遺失、沒有嚴重度被降低。研究員為了看懂脈絡讀了範圍外的檔案——這是工具的正常行為,也正因如此才撈到 F2 與其中兩條被駁回的候選(那三個都指向範圍外的檔)。

這份報告沒有逐檔紀錄:coverage.research 是 null,工具沒有留下「哪位研究員把哪個檔讀到什麼程度」的帳,所以無法逐檔宣稱誰被深讀、誰只是掃過。下方「卡片八個重點」段用候選與駁回論證引用的行號回推,再由首腦人工補查沒被提及的四支。

工具沒有實際執行任何程式碼:沒跑測試、沒發請求、沒示範攻擊,兩條發現全部是讀原始碼與 git 歷史推出來的。

關於驗證章的狀態(第三種型態)

stamp CLAUDE-SECURITY-REVISION-3cc966f8488d.json 蓋的是 status: unverified、拒收理由 reason_kind: findings-refused。這不是面板失敗——理由是渲染器把 F2 退掉,原話是「its file does not exist in the scanned tree」(它指到的檔案不存在於被掃描的樹裡)。F2 講的是 git 歷史裡的舊檔,現在的程式碼樹已經沒有那個字串,渲染器的檢查規則認不得歷史檔,就整份退件。

面板本身完整跑完、15 票全投出、兩條各 3:0 通過。所以本棒以工具報告 CLAUDE-SECURITY-RESULTS.md(內含 F2 全文)為準。 這是交接文件(STATE)自檢第 6 題所列的第三種型態:前兩種是面板真的沒跑完、面板中途斷線;這一種是面板跑完了、只是最後渲染那一步退件。

§4

Findings

F1 — 註冊用的通行碼永遠不會過期,而且整家客戶共用同一組(MEDIUM,3:0)

現況:🚫 裁定不修(SUMMARY M01-3,落地版僅內網可達;原卡 CM-1597 作廢)。

這是什麼問題。 客戶要在自己機器上裝 agent,安裝時得貼一組通行碼(enroll token,就是「安裝時用來證明你是這家客戶」的一次性口令)。但這組口令沒有有效期限、沒有使用次數上限、也不綁定任何一台特定機器——發出去之後除非有人手動按「停用」,否則永遠有效。而且同一時間整家客戶只有一組活著、大家共用。

出事會怎樣。 這組口令會出現在管理介面的對話框、被貼進安裝程式的提示、寫進每一台裝過 agent 的客戶機器的設定檔——流出去的面只會越來越大,不會縮小。任何人撿到一份(退役機器的設定檔、支援工單裡的安裝紀錄、共用文件),就能:

  • 在這家客戶底下註冊新的 agent;
  • 更嚴重的是接管一台既有的 agent——把雲端派工的目的地改成攻擊者自己的主機。而派工的內容裡帶著這家客戶掃描工具的明文帳密,所以撿到一組舊口令,最後換到的是那些帳密。

要先有什麼才打得到。

  • 手上有一組曾經發出去的通行碼(從退役主機、舊的安裝檔、日誌或共用文件撿到都算)
  • 打得到註冊端點(網路連得到)
  • 若要做「接管既有 agent」這一步,還要知道目標 agent 的 device_uuid,而且那台 agent 已經離線超過 heartbeat_interval_sec * 3(心跳間隔的三倍時間)——因為指紋撞號的守門對「離線的既有持有者」是放行的
  • 這段期間沒有管理員去按停用

在哪裡。 缺口是「四層都沒有到期欄位」:

  • jedi-remote-agent/jedi_remote_agent/app/service/agent_enroll_token_service.py:63 —— AgentEnrollTokenService.generate(),用 secrets.token_urlsafe(32) 產碼(亂數強度本身沒問題)、存 sha256、enabled=True,但沒有任何地方可以設到期
  • jedi_remote_agent/infra/remote_agent/model/remote_agent_enroll_token.py —— ORM model 只宣告 uid/token_hash/label/enabled 四欄
  • migrations/001-remote-agent-tables.sql:53-65 —— 建表 SQL 也沒有到期欄
  • remote_agent_enroll_token_domain_service.py:24-28 —— 兌換時的 get_enabled_by_hash() 只比對 token_hash 與 enabled=True,沒有時間條件可比

generate() 確實會先把舊的停用再發新的,所以同時只有一組活著——但活著的那一組是無限期、無限次、不綁機器。程式碼註解裡寫的兩項「緩解措施」是文件敘述,不是實際執行的檢查。

怎麼修。 給 remote_agent_enroll_tokens 加一個 expires_at 欄位,發碼時就寫進有界的效期(安裝是一次性的開機動作,幾小時到幾天就夠),並且在 get_enabled_by_hash() 的查詢條件裡比對它——讓過期由查詢強制執行,而不是靠慣例。再強一點:改成單次使用或限制使用次數,並把每張碼綁定它註冊的那台機器,這樣一張外流的碼就不能無限註冊或接管任意機器。另外記錄「最後一次被兌換的時間」並顯示出來,管理員才看得到「預定的安裝期間已經過了,這張碼還在被用」。

驗證。 3/3 三位檢查員(reachability/impact/defenses,就是分別從「打不打得到」「打到會怎樣」「有沒有防線擋住」三個角度獨立審)一致確認成立,嚴重度維持 MEDIUM。為什麼是 MEDIUM 不是 HIGH:要先撿到一組流出的口令,而且接管既有 agent 還得等目標離線。

首腦核對註記與判定。 屬實,逐檔開過。但這與 R1 的 F6 是同一件事、已經開了 CM-1597(「註冊通行碼永久有效 + 重註冊接管」),標「重複 CM-1597」、不另計新項。

本棒真正的價值在這裡:R1 當時自己就寫了「agent_enroll_token_service.py 本身未被實質審查」,F6 只是引用「沒有到期機制」這個說法當前提。這一次三位檢查員逐檔確認了 entity、ORM model、建表 SQL、查詢條件四層都沒有到期欄位——CM-1597 的前提從「引用」升級成「實證」。這句話要補進 CM-1597 卡裡。

攻擊鏈的部分(首腦核對過,與 R1 F6 描述一致):撿到舊口令 → 打註冊端點、帶上某台離線超過心跳三倍時間的 agent 的 device_uuid → 指紋撞號守門對離線持有者放行 → 那一列的 base_url 被改成攻擊者主機 → 之後以那台身分心跳、拿到派工內容(含租戶掃描工具的明文憑證)。


F2 — GitLab 的存取權杖曾被寫死在原始碼裡,至今仍留在 git 歷史(MEDIUM,3:0,渲染器退件)

現況:✅ 已處置(該權杖已撤銷,2026-09-20;SUMMARY M01-6)。

⚠️ 這條越界。 檔案屬於 jedi-issue,不在 R1b 的十檔範圍內——是研究員為了建立脈絡追 git 歷史時撈到的。

這是什麼問題。 一把 gitlab.com 的個人存取權杖(personal access token,就是「代替帳號密碼去操作 GitLab 的長期通行證」)曾經被直接打字寫在程式碼裡跟著版控送出去。現在的程式碼已經改成從環境變數讀、不再寫死了,但把檔案刪掉並不會把秘密從 git 歷史裡拿掉——任何 clone 過這個 repo 的人都還撈得回來。

出事會怎樣。 撿到的人可以直接用那把權杖以帳號本人的身分操作 GitLab——依它當初被授予的權限,讀寫議題、成員、專案都有可能。更麻煩的是:當時那個檔案就在 jedi_issue/ 套件目錄裡面,所以那段期間發佈到內部 Nexus 的每一份 jedi-issue wheel / sdist 都把這把權杖編進去了——不只「看得到 git 歷史的人」會撿到,「拿得到舊版套件檔的人」也會。

要先有什麼才打得到。

  • 有這個 repo 的 clone 權限(或手上有一份 commit 5e3b9a3 或更早建出來的 jedi-issue 套件檔)
  • 這把權杖沒有被撤銷過——目前沒有任何證據顯示它被撤銷過

在哪裡。

  • jedi-issue/jedi_issue/infra/issue_adapter/gitlab/gitlab_adapter.py:14(commit b740989,2025-04-16)
  • jedi-issue/jedi_issue/infra/project_member/adapter/gitlab/member_adapter.py:12(commit 8742059 與 5e3b9a3,2025-06-11)

寫法是模組層級的字串常數,直接拿去建 gitlab.Gitlab 用戶端。現在的樹已經沒有這個字串(git grep glpat- HEAD -- jedi-issue 零命中),改成 os.getenv("GITLAB_PRIVATE_TOKEN") 或由呼叫端注入——介面本身是對的。檢查員用 git merge-base --is-ancestor 確認三個 commit 都是 HEAD 的祖先、存在於十個分支,所以每一份 clone 都帶著這個值。

(權杖明文不記錄在本報告內,僅以雜湊前十碼識別。)

怎麼修(這不是改程式碼的事)。

  1. 立刻到 gitlab.com 把這把權杖撤銷,並調閱該帳號的稽核紀錄(看從 commit 落地之後有沒有被用過)。這需要 GitLab 後台權限,屬決策者動作、不是 runner 能做的。
  2. git 歷史不重寫——與 .env.test 事件同一裁定:值已經散在已發佈的套件檔與每一份既有 clone 裡,重寫歷史救不回來,輪換才是有效的控制。
  3. 加 pre-commit 秘密掃描(gitleaks / trufflehog)擋住 glpat-、ghp_ 這類字串再被 commit——這屬總表 §7 第 24 項的既有待裁事項,不在本棒另開。

驗證。 3/3 三位檢查員一致確認成立。其中一位特別去查了「宣稱已處理」的那個 commit,確認它輪換掉的是另一把不同的權杖,這把仍然無人認領。

首腦核對註記與判定。 🔴 屬實,而且是新發現,不是 CM-1573 的重複。 首腦用 sha256 比對(不印明文)確認:

雜湊前十碼 出處
本棒 F2 這把 5910b7b954… 寫死在 adapter 原始碼裡(三個 commit)
CM-1573 已處置那把 23060f9979… .env 檔裡,commit d6a8d09 移除並宣稱已撤銷

兩把不同。 CM-1573 卡上寫的是「.env 裡那兩把」,從來沒有涵蓋這把寫死在 adapter 原始碼裡的。沒有任何證據顯示這把曾被撤銷。

處置:登記為總表新項(中),出處記 FR-077 R1b F2(越界,jedi-issue);在 CM-1573 卡尾 append「還有一把沒涵蓋」;不另開修正卡(決策者裁只記錄)——但「去 gitlab.com 撤銷」這個動作要當場提給決策者。


§5

三條被駁回的候選

這一段正是本棒存在的理由——R1 收尾時留下的問題是「簽憑證那一塊零發現,到底是讀過沒問題、還是根本沒讀」。這三條被駁回的候選證明了:簽憑證那套程式碼確實被深讀過,而且駁回的理由是有論證的,不是沒看到。

候選一:簽憑證時直接沿用 agent 自己送上來的身分資料

候選說什麼。 雲端幫 agent 簽身分憑證時,subject(憑證上的「這是誰」欄位)直接照抄 CSR(就是 agent 送上來、請雲端簽章的憑證申請書)裡寫的;SAN(憑證上的「這張憑證可以代表哪些網址」欄位)是從 agent 自己回報的 base_url 推出來的;簽出來的憑證同時帶 SERVER_AUTH 與 CLIENT_AUTH 兩種用途(既能當伺服器、也能當客戶端去對別人做身分驗證);效期 3650 天(十年)。候選主張「不用任何認證就能觸發」。

為何駁回。 面板確認行為描述全部屬實,駁回的是「無認證即可觸發」這個前提——簽憑證這段是包在 agent_enrollment_service._register 裡面的,前面有註冊通行碼那道門擋著(_tenant_id_for_token),檢查員實際追過這條呼叫路徑。另外也駁回影響的說法:找不到任何消費端會去信任憑證上的 subject 名稱(雲端連 agent 時只驗 CA 鏈與 SAN)。

首腦同意理由。 首腦自己開檔 ca.py:52-78 核對過,事實全部屬實,駁回的是前提不是事實。

候選二:同一件事的另一個角度——CLIENT_AUTH 可以拿去對誰做 mTLS

候選說什麼。 既然簽出來的憑證帶 CLIENT_AUTH,那拿到憑證的人可以拿它去對別的服務做 mTLS(雙向憑證驗證)。

為何駁回 / 首腦同意理由。 同理——要先過註冊通行碼那道門才拿得到憑證,前提不成立;且同樣找不到信任 subject 名稱的消費端。

候選三:jedi-common/.env 受版控

候選說什麼。 jedi-common 底下有一份 .env 進了版控。

為何駁回 / 首腦同意理由。 裡面只有 localhost 的 PostgreSQL 預設佔位值,沒有洩漏任何東西。而且這是 CM-1598 的重複,不另計。

🔴 但這三條駁回是「暫時的」——兩個結論互相倚靠

工具報告自己也寫了這一點,首腦同意並要求記進來:

簽憑證那套行為,只靠一道門擋著——就是註冊通行碼。而 F1 說的就是:那道門是一把永不過期、整家客戶共用的鑰匙。

兩個結論各自都正確(面板照候選自己宣稱的前提駁回,是對的),但它們互相倚靠:

  • CM-1597(通行碼到期)修好之前,ca.py 那三條「駁回」只是暫時成立。
  • CM-1597 的修法必須涵蓋三件事:到期、單次使用、綁定機器——否則那道門仍然是一把可以被撿走的萬用鑰匙,ca.py 的問題(照抄 subject、自報 SAN、雙用途、十年效期)就會整組回來。

這一點要同步記進總表 §1 與交接文件(STATE)。


§6

卡片八個重點 vs 工具實際讀到的

R1b 開卡時要求「額外交一段:讀過但判定沒問題的檔」。但 stamp 的 research_coverage 是 null,工具沒有留下逐檔紀錄,所以只能從候選與駁回論證引用的行號回推。

六支確實被深讀(候選與駁回論證都引用了具體行號):ca.py、settings.py、agent_enroll_token_service.py、ORM model、domain service、repository。

四支完全沒有任何候選或駁回提及——這四支由首腦自己開檔人工補查,結論如下:

檔案 首腦人工查證結論
jwt_util.py mint() 用 RS256 簽章,claim 有 iss/aud/iat/exp/op/tenant_id/bound_fp,有效期由 settings 給、預設 60 秒。op(操作名稱)由呼叫端組成 f"{method} {path}",呼叫端屬 R3 範圍,套件內部沒有注入面;私鑰路徑由 settings 給、每次簽章重讀檔(沒有快取,效能面不是本棒議題)。乾淨。
tls.py build_cloud_mtls_context 用 create_default_context(cafile=ca_cert_path or None)——ca_cert_path 為空時 cafile=None 會退回系統信任庫。在 mode=full 下若漏設 CA 路徑,雲端連 agent 會拿系統 CA 去驗自簽憑證 → 連線失敗(出錯時預設擋下來),不是放行。乾淨。
heartbeat.py 純時間工具,UTC naive 單一時間源,負值視為離線。乾淨。
settings.py mode 預設 none=資料面不加認證。這是 R3 重點③的落點(要看宿主 installer 有沒有強制設成 full),套件端只是個資料容器、自己不決定。標「屬 R3」,不在本棒判定。

§7

這份結果可信到什麼程度

「F1 / F2 是真的嗎」→ ✅ 可信

兩條各 3:0 全票通過,首腦逐條開檔核對(agent_enroll_token_service.py 的 generate()、ORM model 四欄、建表 SQL 行號、get_enabled_by_hash() 的查詢條件;F2 的三個 commit 與現況樹的 grep)。F2 首腦另外用 sha256 雜湊比對確認它與 CM-1573 處置掉的那把不是同一把。

「十個檔只有這些問題嗎」→ ⚠️ 比 R1 那次好很多,但仍不能宣稱完整

好的地方:兩位研究員都回報(R1 那次有派出去沒回來的)、15 票全數投出、沒有漏投、沒有中斷、三條駁回的論證顯示密碼學核心六支確實被深讀(論證裡引用得出具體行號,不是空泛帶過)。

不能宣稱完整的地方:低強度跑法、沒有元件盤點、沒有威脅建模、工具沒留逐檔紀錄(coverage.research 是 null),四支沒被提及的是首腦人工補查的結論、不是工具給的。

但這一棒確實回答了 R1 留下的那個問題:「簽憑證/簽短效通行證這一塊零發現」是「讀過了、沒問題」,不是「沒讀」。 前提是——那道門(CM-1597)要修。


§8

執行概況

項目 數字
檢查範圍 10 個檔案(jedi-remote-agent 密碼學核心六支 + 註冊通行碼四檔鏈)
檢查強度 最低(low),focus 設在攻擊面
候選問題 → 去除重複 5 → 5
投票數 15(5 條候選 × 3 位檢查員),全數投出,沒有漏投、沒有中斷
F1 / F2 投票結果 3:0(MEDIUM)/ 3:0(MEDIUM)
被駁回候選 3(ca.py 兩條、jedi-common/.env 一條)
沒投到票的 / 投票中斷的 / 被降低嚴重度的 0 / 0 / 0
研究員派出 / 回收 2 / 2
驗證章狀態 unverified,拒收理由 findings-refused(渲染器退掉 F2:「its file does not exist in the scanned tree」——它是 git 歷史裡的檔,現在的樹沒有。面板本身完整跑完,不算面板失敗)
掃描耗時 6791 秒(約 1 小時 53 分)
掃描當下的程式碼版本 commit 3cc966f8,branch feature/review,工作區乾淨;10 檔自 cdb0d0f(R1 當時)到本棒零漂移
scan_id 46607b07-434a-43da-835a-c28c460a25d3
首腦人工補查項目 四支沒被工具提及的檔(jwt_util.py/tls.py/heartbeat.py/settings.py)逐檔開檔核對
對總表的影響 F1 併入 CM-1597(不另計,補「前提由引用變實證」);F2 §3.1 新增一項(中);§7 C 組加一項「GitLab 第二把寫死的權杖要撤銷」;CM-1573 卡尾 append

§9

交付狀況

🔴 本棒 runner 四件全未交:沒有寫報告、沒有 commit、沒有回寫 Notion(CM-1599 仍停在「未開始」)、沒有回報。工具產物(stamp 與 CLAUDE-SECURITY-RESULTS.md)是首腦自己到掃描目錄撈出來的,F2 與 CM-1573 那把權杖的雜湊比對、四支未被提及檔案的人工查證、三條駁回候選的核對,也全部由首腦完成。本報告由首腦補寫,這是全計畫第九次 runner 交付掛零。