---
title: B1 掃描結果：jedi-bulletin 套件本體
---

# B1 掃描結果：jedi-bulletin 套件本體

> **掃描日期**：2026-09-08
> **掃描版本**：jedi-python-package `feature/FR-075` @ `8aa6f0609c44`（乾淨，無未 commit 變更）
> **工具**：Claude Code `claude-security` plugin（`claude-security:scan` workflow）
> **範圍**：`jedi-bulletin/jedi_bulletin/` ＋ `harness/`，共 **29 個受版控 `.py`**（工具實際計入 31 檔，多的兩個是 `harness/docker-compose.yml` 與非 `.py` 檔；其中 13 個是空的 `__init__.py`，實質內容 16 檔、847 行）
> **effort**：`low`，**focus**：`attack-surface`
> **模型**：主 session Opus 5 (1M context)，研究員繼承
> **狀態**：✅ **驗證面板完整跑完，stamp 為 `verification.status: verified`**——**工具零發現**（2 條候選全被面板 0/3 否決）
> **對應卡片**：CM-1611（母卡 CM-1609）

## 一句話結論

**工具在這個套件裡沒找到任何成立的資安問題，這個結果與預期一致——這半邊確實只有沒有守門的 CRUD，安全決策全外包給宿主。** 但「沒有資安問題」不等於「沒有問題」：人工逐項核對卡片十個重點時，找到 **一個會讓程式當場崩潰的真 bug**（`update()` 檢查錯對象）與 **兩個健壯性缺口**，都不是權限問題、也都因為「這支 service 現階段沒有任何呼叫者」而**目前打不到**。**最重要的一條是重點 ⑧ 的答案：公告 uid 用的是 `uuid.uuid4()`，密碼學等級隨機、不可預測——這對 B2 那條無守門的單筆讀取是好消息，把它的嚴重度往下拉。**

## 🔴 掃到什麼：一覽

**工具面板存活發現：0 條。** 下表是**人工核對十個重點時自己找到的**，全部**未經驗證面板**（面板只驗研究員提出的候選，這些不在其中），可信度標示見表後。

| # | 嚴重度 | 這是什麼問題（白話） | 出事會怎樣 | 要先有什麼才打得到 | 位置行號 | 修正卡 |
|---|---|---|---|---|---|---|
| [M1](#m1low--更新公告時檢查錯了對象查無此筆會當場崩潰) | 🔵 LOW | 更新公告時，程式想確認「這筆公告存在嗎」，卻**檢查了傳進來的參數、而不是查詢的結果**。查無此筆時下一行直接對 `None` 設值 | 該次請求 500 崩潰（`AttributeError`），不是資料外洩也不是權限繞過。**純錯誤處理缺陷** | 要有人呼叫套件版 `update_bulletin()` 並帶一個不存在的 uid——**而現在沒有任何呼叫者**（主專案走自己那支），所以目前打不到 | `infra/repository/bulletin_repo_impl.py:56`（`if not bulletin` 應為 `if not bulletin_model`） | 未開（見下方建議） |
| [M2](#m2low--沒有登入者時建立公告會崩潰而不是留下無主資料) | 🔵 LOW | 建立／更新公告時直接取登入者的 uid，但取不到登入者時回的是 `None`，對 `None` 取 `.uid` 會崩 | 背景執行緒／排程若呼叫此路徑會 500。**好消息是它「炸」而不是「靜默寫進 NULL 建立者」**——後者才是稽核惡夢 | 同上，需要有呼叫者，且在無 user context 的情境下呼叫 | `app/service/bulletin_service.py:50`、`:62` | 未開 |
| [M3](#m3info--更新不覆寫-is_delete也不做任何歸屬檢查) | ⚪ INFO | `update()` 拿到 uid 就改，不看這筆公告屬於誰、也不看是否已軟刪除 | 套件層本身無守門是**刻意設計**（安全決策外包給宿主）。真正要回答「能不能改別人的公告」的是 B2 那一棒 | — | `infra/repository/bulletin_repo_impl.py:51-69` | 屬 B2 範疇 |

### ⚠️ 這份結果的可信度

**要分三層講，混在一起會誤導。**

**第一層——「工具報的 0 條」可信度高。** 兩條候選（一條 dead code、一條 harness 密碼）各拿 0/3 全否，六票全數投出，`unreviewed_candidate_sites: 0`，stamp 記 `verification.status: verified`。面板不是敷衍否決：三個 lens 各自去讀了 `pyproject.toml` 的打包設定、`plugin.py` 的空 blueprint、主專案那支真正被呼叫的 service，才判定「無可達路徑」。這是本 arc 至今第二次拿到面板完整跑完（前一次是 FR-078 N1）。

**第二層——上表 M1／M2／M3 是我人工開檔核對的，未經面板。** 它們的「存在性」可信（打開那幾行就是那樣寫的，是事實陳述），但「嚴重度判定」只有我一個人的判斷。三條都因「無呼叫者」而目前無法觸發，所以我沒有為它們開修正卡（建議見末段）。

**第三層——「只有這些嗎」不可信。** 這是 `low` effort 單次快篩，沒有 inventory、沒有威脅模型、沒有廣度掃蕩，工具自己把 `completenessCheckOutcome` 標成 `not-applicable`。範圍很小（實質 16 檔），但一次 low 掃過不等於逐行讀完。**安靜不等於乾淨。**

另外：掃描過程**沒有執行任何被掃的程式碼**——沒跑測試、沒發攻擊、沒驗 PoC。所有結論都是讀源碼推導出來的。

## 執行概況

| 項目 | 數字 |
|---|---|
| 研究員派出 / 回報 | 2 / 2（一支掃源碼、一支密鑰專項）|
| 候選發現 | 2 條 → 去重後 2 條 |
| 面板票數 | **6 / 6**（2 條 × 3 票，全數投出）|
| 面板存活 | **0 條**（兩條皆 0/3 否決）|
| 未投票候選 | **0**（`unreviewed_candidate_sites: 0`）|
| 驗證輪次 | 1 |
| 總耗時 | 約 42 分鐘（2,554 秒）|
| Agent 數 | 8 個（含面板），全部完成、0 錯誤 |

工具的完整性欄位全部乾淨：`skippedComponents`、`droppedComponents`、`prunedBuckets`、`lostCandidates`、`severityLowered` 皆為空，`dispatchRefusals: 0`，沒有候選被交棒到下一輪。

**報告產物落點**：`~/Projects/Jedicogy/module/jedi-python-package/jedi-bulletin/CLAUDE-SECURITY-20260908-125155/`（含 `.jsonl` / `.sarif` / stamp；該目錄自帶 `.gitignore`，不入版控）。

---

## 面板否決的兩條候選（記錄在案）

零發現的報告若不寫下「差點成為發現的是什麼」，接手者無從判斷掃描到底看了什麼。

### C1（否決）— `_gen_filters()` 裡有兩個永遠不生效的過濾條件

研究員指出 `infra/repository/bulletin_repo_impl.py:23` 的動態過濾器，其 `hasattr(self.model, field)` 對 `release_time__gt_now` / `expire_time__le_now` 這兩個欄位**永遠是 False**——`Bulletin` model（`infra/models/bulletin.py:22-71`）根本沒有這兩個欄位，`BaseModel` 也沒有 `__getattr__`。所以第 27-30 行那段「對齊主專案修過的正確版」的時間窗過濾**一行都不會執行**。

**事實部分成立**（我獨立核對過：model 只有 `uid/title/content/category/release_time/expire_time/enable/is_delete`，確實沒有那兩個帶 `__` 的名字）。**面板三個 lens 全判 FALSE_POSITIVE**，理由一致且我認同：套件掛出來的 blueprint 沒有任何 resource（`plugin.py:224`，且被 `tests/unittest/test_plugin_contract.py:129` 焊死），沒有任何外部入口；主專案那唯一的 consumer 只 import model／DTO／entity，過濾器是它自己重寫的一份。**這是正確性缺陷不是資安漏洞，而且落在一段沒人執行的程式碼上。**

> ⚠️ **但這條對 B2 有交叉價值**：主專案自己那支 `_gen_filters()` 是**另一份實作**，B2 那一棒要獨立確認它的 `hasattr` 白名單有沒有同樣的空轉問題——套件這半邊空轉無害，主專案那半邊空轉就是「時間窗過濾沒生效」。

### C2（否決）— `harness/docker-compose.yml:12` 有一組明文資料庫密碼

密鑰專項掃到 `POSTGRES_PASSWORD: bulletin_harness_local`（`harness/dev_app.py:44` 的連線字串裡有同一份鏡像）。

**字面屬實，但面板三票全否，我認同**：`pyproject.toml:38-41` 的 `packages = [{ include = "jedi_bulletin" }]` **只打包 `jedi_bulletin/`，`harness/` 不進發行包**；而且這組密碼保護的容器就是同一個 compose 檔**當場創造出來的**——庫裡只有 harness 自己寫的假資料（`dev_app.py:111-128` 的「harness 公告」、name='harness' 的假 tenant／org_unit），用完 `down -v` 銷毀。**它不是任何既存系統的憑證。** 檔案第 12 行原本就有註解「僅本機 harness 用，非任何環境憑證」——這次的獨立核對證實該註解是真的，不是自我辯護。

唯一值得記一筆的小事：`ports: - "5491:5432"` 是綁在所有介面上的（Docker 預設行為），harness 執行期間同網段可連。考量到裡面只有假資料、且是開發者手動起停的臨時容器，**不值得開卡**，但寫進 README「常見坑」是合理的。

---

## 十個重點逐項回答

卡片列了十個「思考起點」，以下逐條給結論。**每條都標明是「工具報的」「我人工核對的」還是「本棒未觸及」**——這個區分比結論本身更重要。

### ① 套件層完全沒有授權守門，且沒有 api 層——「刻意留白」有沒有留下可繞過的入口？

**有結論：沒有可繞過的入口，因為根本沒有入口。**（人工核對 `plugin.py` 全 226 行）

四個插槽逐個看過：

- **`mount_api=True` 掛出的 blueprint 是空的**（`plugin.py:191-193` 只建了 Blueprint 並 `record_once` 寫 `app.extensions`，沒有 `add_url_rule`、沒有 resource）。`mount_api=False` 與 `True` 的**對外行為完全相同**，差別只在 `app.extensions` 有沒有那個 key——檔頭這句自陳屬實。
- **`adapters`／`config` 都是純資料 dataclass**，沒有可執行的預設值，也沒有任何地方把它們當 callable 呼叫。
- **`schema_extensions` 是唯一「會被呼叫」的插槽**（`build_context` / `enrich` 呼叫 consumer 傳進來的 callable）。**這是宿主自己傳進來的函式，不是外部可影響的輸入**——而且現階段沒有任何產品在用。要出事得先有宿主傳進一個惡意 callable，那時攻擊者已經在宿主的程式碼裡了。
- **`create_blueprint` 的 `record_once` 用 lambda 捕獲 context**，每個 app 各自一份，多 app 掛載不互相污染——這一點檔頭寫的與實作相符。

**風險反而在未來**：`BulletinAdapters` 的 docstring 自己標了紅字「日後加認證 decorator 欄位時不可給預設值」（`plugin.py:121-123`）。這條警告是對的，但**目前只是註解、沒有任何機制強制**。真的長出 route 的那一棒若忘記，就會是一整組無聲的公開端點。**建議把它變成測試**（照 jedi-iam 的 `_assert_api_wiring()` 形狀），而不是留在註解裡。

### ② 模組層級單例，import 時就實例化——會不會違反「repo session 必須 lazy」鐵則？

**有結論：不違反，而且這個單例沒有人在用。**（人工核對，跨 repo 追到 jedi-common）

`bulletin_service.py:74` 確實在 import 時就建了 `BulletinService(BulletinDomainService(BulletinRepoImpl()))`。但追下去：

```
BulletinRepoImpl → BaseRepositoryImpl → BaseRepository（轉發） → SessionMixin
                                                                  └ @property session: return get_session()
```

`jedi_common/session/database/session_mixin.py:49-51` 是**標準的 lazy `@property`**，`BaseRepositoryImpl.__init__`（`base_repository_impl.py:24-35`）只設 `self.model` / `self.mapper` / 幾個翻譯屬性，**沒有任何 `get_session()` 呼叫**。所以 import 時實例化不會炸——CLAUDE.md 那條鐵則描述的正是這個陷阱，而 jedi-common 的底座已經避開了（該檔 docstring 第 44-46 行明寫這個理由）。

**跨請求共用狀態的疑慮也不成立**：`BulletinRepoImpl` 的實例狀態只有 model／mapper 這些無狀態的類別引用，session 每次存取都重新向 context 取。

**但這個單例是死的**：全 BE repo grep `jedi_bulletin`，只有三處 import——`infra/bulletin/models/bulletin.py`（拿 model）、`app/bulletin/dto/bulletin.py`（拿 DTO）、`domain/bulletin/entities/bulletin_entity.py`（拿 entity），**沒有任何一處 import 套件的 service 或那個單例**。它存在但無人使用。

### ③ 查詢條件的動態組裝——未預期的鍵會拋錯還是靜默吞掉？

**有結論：會拋 `TypeError`，是 fail-closed 的好行為。**（人工核對）

`BulletinQueryEntity.__init__`（`domain/entities/bulletin_query_entity.py:2`）只收五個具名參數 `(auth, is_delete, release_time__gt_now, expire_time__le_now, enable)`，**沒有 `**kwargs`**。所以 `bulletin_service.py:20` 與 `:36` 的 `BulletinQueryEntity(**kwargs)` 收到未預期的鍵時，Python 直接 `TypeError: unexpected keyword argument`——**請求失敗，不會靜默把任意鍵變成 SQL 條件**。

這是套件端比主專案端安全的地方（卡片的推測正確）。**B2 那一棒要確認主專案那支收不收 `**kwargs`**——收了就是另一回事。

還有一個附帶觀察：`domain/service/bulletin_domain_service.py:16-19` 的 `if _filter.auth:` 分支會**強制覆寫** `enable=1` 與兩個時間旗標，而 `auth` 的預設值是 `False`。也就是說**「只顯示當前生效的公告」這個限制是呼叫端自願開啟的，不是伺服器強制的**——套件層這樣設計合理（它不知道業務規則），但這正是 B2 重點 ③ 講的那件事在套件端的源頭。

### ④ 五個方法各自 new 一份 repo，繞過建構子注入——有沒有因此跳過某層處理？

**有結論：沒有跳過任何處理，但這是真的重複、且讓注入形同虛設。**（人工核對）

`get_bulletins`（:35）、`get_bulletin`（:44）、`add_bulletin`（:51）、`update_bulletin`（:61）、`delete_bulletin`（:71）各自 `BulletinDomainService(BulletinRepoImpl())` 就地新建，**只有 `get_bulletins_and_pager`（:21）用了 `self.bulletin_domain_service`**。

新建的那份與注入的那份**走的是完全相同的類別與路徑**，沒有任何一層被跳過，所以**沒有資安後果**。但後果有兩個：①**建構子注入形同虛設**——想換一份 repo 實作（測試用 fake、加了審計的 wrapper）對六分之五的方法無效；②`BulletinService` 的建構子參數看起來是契約，實際上不是。屬於設計債，不是漏洞。

### ⑤ `add`／`update` 的手寫欄位搬運——漏了什麼？

**有結論：`update()` 有一個會當場崩潰的真 bug，且不覆寫 `is_delete`、無歸屬檢查。**（人工核對，見上表 M1／M3）

#### M1（LOW）— 更新公告時檢查錯了對象，查無此筆會當場崩潰

**Impact.** 對套件版 `update_bulletin()` 傳一個不存在的 uid，該次請求 500 崩潰。不是資料外洩、不是權限繞過，**純粹是錯誤處理寫錯**。

**Where.** `jedi_bulletin/infra/repository/bulletin_repo_impl.py:56`

**What.** 第 55 行查出 `bulletin_model`，第 56 行卻寫 `if not bulletin:`——`bulletin` 是**傳進來的參數**（前一行第 52 行才剛檢查過它的 `uid`），不是查詢結果。查無此筆時 `bulletin_model` 是 `None`，而 `bulletin` 是個有效物件，所以檢查通過、繼續往下，第 59 行 `bulletin_model.title = ...` 直接 `AttributeError: 'NoneType' object has no attribute 'title'`。

**Preconditions.** 需要有人呼叫套件版的 `update_bulletin()` 並帶不存在的 uid。**現階段沒有任何呼叫者**——主專案走自己那支 `app/bulletin/service/bulletin_service.py`。所以**目前無法觸發**，這是它嚴重度只有 LOW 的唯一理由。

**Fix.** `if not bulletin:` → `if not bulletin_model:`。一個字的修正。

#### M3（INFO）— `update()` 不覆寫 `is_delete`、也不做任何歸屬檢查

`update()`（:59-65）覆寫 title/content/category/enable/release_time/expire_time/updated_user 七個欄位，**不動 `is_delete`**——這其實是**對的**（軟刪除旗標不該被一般更新順手改掉，否則更新一次就會把已刪的公告復活）。而 `filter_by(uid=...)` 拿到就改、不看歸屬，是**套件層刻意不做安全決策**的設計，真正要回答「能不能改別人的公告」的是宿主，屬 B2 範疇。

另外 `add()`（:36-46）**沒有設定 `tenant_id` / `org_unit_id`**——這兩個欄位靠 jedi-common 的 `before_flush` 事件（`db_mw.py:63-85`）從 user context 自動填。**沒有 user context 時不會填**，而 DB 的 `tenant_id` 是 `nullable=False`，結果是 `NotNullViolation`——同樣是「炸」而不是「寫進錯誤的租戶」，行為上安全。

### ⑥ `add_bulletin` 用 `get_user_context().uid`——無 user context 時會怎樣？

**有結論：會 `AttributeError` 崩潰，不會寫進 NULL。**（人工核對，見上表 M2）

`jedi_common/session/auth/auth_context.py:17-22`：`get_user_context()` 在 context 為空時**回 `None`**（那行 `raise RuntimeError` 被註解掉了）。所以 `bulletin_service.py:50` 的 `get_user_context().uid` 在背景執行緒／排程情境下是 `AttributeError: 'NoneType' object has no attribute 'uid'`，`:62` 的 `update_bulletin` 同樣。

**這是好消息不是壞消息**：崩潰比「靜默寫入 NULL 建立者」好得多——後者會讓稽核軌跡出現無主資料且無人察覺。**建議修法是明確拋業務例外**（帶 error code），而不是讓 `AttributeError` 冒到 500。

**語意差異確認屬實**：套件版存的是 `user_context.uid`，主專案版存的是 `login_name`。而主專案的 `ExtendedBulletin`（`infra/bulletin/models/bulletin.py:47-57`）**用 `created_user == User.login_name` 做 relationship join**——若哪天真有人改用套件版的 service 寫入，存進去的 uid 會讓那個 join 永遠對不上，`created_user_name` 全部變 `None`。**現階段無害（沒人用套件版），但這是一顆埋著的地雷**，值得在 README 註記。

### ⑦ tenant scope 掛了，但生效嗎？兩個 model 疊同一張表會不會失效？

**有結論：RLS 四條 policy 都在、欄位對得上，疊加不影響。但有一個 import 時序的隱性前提。**（人工核對 ＋ DEV DB 唯讀查證）

DEV 庫（`guidant_ai_dev`）實查（**唯讀 `SELECT`，未做任何異動**）：

```
bulletins | rowsecurity = t
bulletins_select | r | is_super_admin OR app_tenant_allowed_for_session(tenant_id)
bulletins_update | w | is_super_admin OR app_tenant_allowed_for_session(tenant_id)
bulletins_delete | d | is_super_admin OR app_tenant_allowed_for_session(tenant_id)
bulletins_insert | a | WITH CHECK: app_tenant_allowed_for_session(tenant_id)
                       AND (org_unit_id IS NULL OR app_org_allowed_for_session(org_unit_id))
```

四條齊全。**INSERT 那條的「不對稱」是設計不是缺陷**——INSERT 沒有 `USING` 子句（沒有既存列可檢查），只有 `WITH CHECK`，而且它比其餘三條**更嚴格**（多驗了 org_unit）。FR-079 README 引用的 migration 註解說它「與其餘三支不對稱」，實查結果是**這個不對稱方向是安全的**。

**兩個 model 疊同一張表沒有問題**：`ExtendedBulletin`（主專案）用 `extend_existing=True` 加的是 relationship 與 hybrid_property，**沒有動任何實體欄位**；tenant 欄位由 `TenantScopedMixinModel` 提供，兩邊都掛了同一個 mixin，欄位定義一致。

**但有一個隱性前提要記下來**：`TenantScopedMixinModel` 的 `tenant_id` / `org_unit_id` 是 `@declared_attr`，**在 class 定義的當下讀 `ENABLE_MULTI_TENANT` 環境變數決定要不要產生欄位**（`jedi_common/session/database/model/tenant_mixin_model.py:11-22`）。也就是說：**任何 import 這個 model 的程式，若在 import 之前沒設好該環境變數，ORM 端就沒有 tenant 欄位**，而 DB 端的 `NOT NULL` 與 RLS 依然在——症狀是寫入時 `NotNullViolation`，非常不直觀。BE 的 `.env` 有 `ENABLE_MULTI_TENANT=true`，harness 也在 import 前 `setdefault`（`dev_app.py:40`），**現況都對**。這是「會靜默失效的設定相依」，值得寫進 README 而不只留在 harness 的 docstring 裡。

### ⑧ 🔴 `generate_uuid` 是不是密碼學等級隨機？（卡片指定必答）

**有結論：是。用的是 `uuid.uuid4()`，不可預測。**（人工核對，卡片要求即使工具沒報也要看）

```python
# jedi_bulletin/common/utils/common_util.py:1-5
import uuid
def generate_uuid():
    return str(uuid.uuid4())
```

`uuid4` 在 CPython 的實作是 `os.urandom(16)` 取 122 bits 隨機（其餘 6 bits 是版本與變體標記），**走作業系統的 CSPRNG**。不是 `uuid1`（含 MAC 位址＋時間戳，可預測）、不是時間戳、不是序號。

`infra/models/bulletin.py:22-28` 確認 `uid` 欄位的 `default=generate_uuid`，也就是**每一則公告的 uid 都是這樣產的**。

**這條的跨棒意義**：B2 的重點 ② 指出「單筆讀取只靠 uid、不做任何範圍檢查」。若 uid 可預測，那條路徑等於「任何人都能列舉全部公告」，會是 HIGH；**現在確認 uid 是 122-bit 的密碼學隨機，列舉不可行**，那條路徑退化成「必須先取得某個 uid 才打得到那一則」——**B2 該條的嚴重度應據此下調**，但**不歸零**：uid 會出現在列表 API 的回應裡、也會出現在網址上，「合法看得到列表的人事後仍能讀到已被移出可見範圍的那則公告」這個問題依然存在。

### ⑨ `common/enum/code.py` 有沒有把內部資訊帶進對外訊息？

**有結論：沒有。但這整個檔案是與公告無關的殘留複製品。**（人工核對）

全檔 38 行，六個常數類別：`UserStatus`、`LogTypeCode`、`RoleStatusCode`、`EnableCode`、`DeleteCode`、`ChangePasswordTypeCode`、`ChangePasswordStatusCode`。**全是整數常數，沒有任何字串訊息、沒有表名、沒有 SQL、沒有路徑**——不可能洩漏內部資訊。

**但值得記一筆**：這個套件叫 jedi-bulletin，而這個檔案裡有 `ChangePasswordTypeCode`（改密碼流程）、`UserStatus`（使用者狀態）、`RoleStatusCode`（角色狀態）——**全部與公告無關**，明顯是從別的套件複製過來的殘留。實際被用到的只有 `EnableCode` / `DeleteCode` 的語意（而且程式裡是直接寫 `1` / `0` 字面值，連這兩個都沒真的 import）。**清掉它是衛生問題不是資安問題**，但殘留的複製品是「這個套件抽出來時抽得不乾淨」的訊號。

### ⑩ `harness/dev_app.py` 有沒有寫死憑證／debug 開關／會被誤帶進正式環境的設定？

**有結論：有寫死憑證，但打不進發行包，面板判定不成立（C2），我認同。**（工具報的 ＋ 人工核對）

完整分析見上方 [C2](#c2否決--harnessdocker-composeyml12-有一組明文資料庫密碼)。補充三點人工核對的結果：

- **`harness/` 確實不進發行包**——`pyproject.toml:38-41` 只 include `jedi_bulletin`。裝了這個套件的產品**拿不到這個檔案**。
- **`dev_app.py:40` 的 `os.environ.setdefault("ENABLE_MULTI_TENANT", "true")` 是唯一的環境變數寫入**，用 `setdefault`（不覆蓋既有值），且這是 harness 專用進入點，不會被套件本體 import。
- **沒有 debug 開關**：全檔沒有 `app.run(debug=True)`、沒有 `FLASK_DEBUG`。它只建 schema、跑一輪 CRUD assert、印結果，跑完就結束。

**順帶查證了 `plugin.py` 檔頭的兩個自陳**（卡片特別警告要獨立驗證，不可複述檔頭）：

| 檔頭宣稱 | 我的獨立查證 | 結果 |
|---|---|---|
| 跨 jedi 套件 import：0 處 | `grep -rn "^from jedi_\|^import jedi_" jedi_bulletin/ \| grep -v "jedi_common\|jedi_bulletin"` → 無輸出 | ✅ 屬實 |
| `os.getenv` / `os.environ`：0 處 | `grep -rn "os\.getenv\|os\.environ" jedi_bulletin/` → 只命中 plugin.py:50 那行**註解本身** | ✅ 屬實 |

**這次的自陳是真的。** 但要說清楚方法論：我是**自己跑 grep 驗證**得到這個結論，不是因為檔頭這樣寫就採信。FR-075 S2 的教訓（docstring 把真洞寫成「刻意設計」，研究員讀了就接受）在這裡不適用——不是因為檔頭可信，而是因為這次獨立查證的結果剛好與它一致。

---

## 建議

### 不建議為 M1／M2 開修正卡（現在）

三條人工發現都因「**套件版 service 沒有任何呼叫者**」而目前打不到。為打不到的程式碼開修正卡，會佔用修正佇列、也會讓 arc 的修正卡清單失真。

**但反悔條件很明確**：**哪一天有人開始使用套件版的 `BulletinService`（不論是主專案改接、還是第二個產品接走），M1 就從「打不到的 LOW」變成「一個字造成的 500」，M2 變成背景任務的雷。** 建議把這三條寫進套件 README 的「已知問題」段，讓下一個接手的人在動它之前就看到。

### 建議直接做的兩件小事（不必開卡）

1. **`bulletin_repo_impl.py:56` 的 `if not bulletin` → `if not bulletin_model`**。一個字，無行為風險，順手修掉比留著等它變成問題好。
2. **把 `plugin.py:121-123` 那條紅字警告變成測試**（「adapters 的認證欄位不可有預設值」），照 jedi-iam 的 `_assert_api_wiring()` 形狀。註解攔不住人，測試可以。

### 移交 B2 的三條線索

1. **主專案自己那支 `_gen_filters()` 要獨立查**——套件這份有兩個永遠不生效的過濾條件（C1）。套件端空轉無害，主專案端空轉就是「時間窗過濾沒生效」。
2. **主專案的 `BulletinQueryEntity` 收不收 `**kwargs` 要實查**——套件端不收（fail-closed），卡片說主專案端收。這是兩邊安全性的關鍵差異。
3. **B2 重點 ② 的嚴重度應下調但不歸零**——uid 是 122-bit CSPRNG 隨機（見 ⑧），列舉不可行；但「uid 洩漏後仍可讀」的問題還在。

---

## 附：本棒未觸及的

零發現的報告必須說清楚「讀過但沒報」與「根本沒讀到」的界線。

- **`jedi_bulletin/tests/` 與 repo 根的 `tests/`（含四支架構測試）**：`focus: attack-surface` 明確把測試樹當背景而非稽核目標。**架構測試本身沒有被當作安全目標審查過**——`test_plugin_contract.py` 焊死「blueprint 無 resource」這件事，我讀了那一行來確認結論（重點 ①），但沒有審查那些測試是否可被規避。
- **`publish.sh`**：不在 scope（`jedi_bulletin/` + `harness/` 之外）。發版腳本是密鑰常見藏身處，**本棒未讀**。
- **`README.md`、`pyproject.toml`、`poetry.toml`、`testarch_report/`**：同上，範圍外。`pyproject.toml` 我因為要判斷 C2 的打包範圍而讀了打包段落，但**沒有當作稽核目標**通讀（例如 Nexus source 設定的 `http://` 明文連線就沒有進一步分析）。
- **jedi-common 的實作**：我為了回答重點 ②⑥⑦ 跨 repo 讀了 `session_mixin.py`、`auth_context.py`、`db_mw.py`、`tenant_mixin_model.py` 的相關段落，**但那是為了判定本棒的問題，不是對 jedi-common 的稽核**。jedi-common 是否該獨立掃描是另一個決策。
