起因:一個旗標,讓 AI 一秒喊出了錯的資安結論

那天清掉一顆「砍了 EC2(Elastic Compute Cloud,AWS 的雲端虛擬機器)卻忘了收」的閒置 Elastic IP(彈性 IP — AWS 可保留、可重複指派的固定公網位址)後,我順手請我的 AI 助手盤點帳號還開著哪些服務。掃到一台正式環境的 RDS(Relational Database Service,AWS 託管的關聯式資料庫)PostgreSQL,設定裡寫著 PubliclyAccessible: true

AI 第一個反應就回我:「正式資料庫直接掛公網,有資安風險。」這句話聽起來很合理——但它是錯的。真相要等到實際讀了 Security Group 規則、再從本機跑一次連線測試才浮現。

這篇文章講的就是這個落差:一個旗標的字面意思,跟它實際造成的曝露,是兩回事。 也順帶示範——在「AI 已經會幫你讀設定、寫指令」的年代,為什麼這種誤判依然會發生。

先說清楚:Security Group 是什麼

Security Group(安全群組,以下簡稱 SG)是 AWS 的虛擬防火牆,掛在資源的網路介面 ENI(Elastic Network Interface,彈性網路介面)上。它有三個關鍵性質:

  • 預設全部拒絕:沒有明確放行的流量一律擋掉。你只能「加白名單」,沒有「黑名單」這種東西。
  • 來源可以是 IP 網段,也可以是另一個 SG:後者意思是「凡是掛了某個 SG 的資源一律放行」,不必管它的 IP 是多少——這在 VPC(Virtual Private Cloud,虛擬私有雲)內部互連時非常好用。
  • stateful(狀態感知 — 放行了進來的連線,回程流量自動允許):不必另外開一條出站規則。

SG 的逐步設定我在 如何使用 psql 連線 AWS RDS 那篇有完整圖解(「SG 就像 RDS 的防火牆」)。這裡只談它跟「公開存取」旗標之間,那個最容易被誤會的互動。

旗標的陷阱:PubliclyAccessible=true 只給門牌,不給鑰匙

關鍵誤解就在這裡。PubliclyAccessible 這個旗標只決定一件事:RDS 要不要拿到一個公網 IP,以及一個可從網際網路解析的 DNS(網域名稱)。它完全不決定「誰連得進來」

誰連得進來,是 SG 說了算。

所以 Public=true + SG 只放行 VPC 內網,實際結果是:這台 DB 有對外門牌(公網 IP),但大門被鎖死——任何外部 IP 走到門口就被擋下。

怎麼自己查:把「我覺得」變成「我看到」

與其腦補,不如打開 SG 的 inbound(入站)規則看一眼。AWS CLI 兩行就夠:

Step 1:找出 RDS 綁了哪些 SG

aws ec2 describe-db-instances --db-instance-identifier mydb \
  --query 'DBInstances[0].VpcSecurityGroups[].VpcSecurityGroupId'
# → ["sg-0a1b2c3d4e5f6a7b8"]

Step 2:把每個 SG 的 inbound 規則攤開

aws ec2 describe-security-groups --group-ids sg-0a1b2c3d4e5f6a7b8 \
  --query 'SecurityGroups[0].IpPermissions'

看的時候只問一個問題:inbound 來源裡,有沒有 0.0.0.0/0、或任何不是你預期的公網網段? 我那台查出來的 5432(PostgreSQL 連接埠)規則長這樣:

192.168.0.0/16              # 只放 VPC 內網段
sg-0a1b2c3d... (EKS 工作負載) # 加上掛了該 SG 的 pod
# ── 沒有任何公網 IP,更沒有 0.0.0.0/0

到這裡答案其實已經很清楚:門牌是公開的,門鎖只認內網。但「讀設定」跟「實際連得上嗎」之間,還差一個真正的測試。

實測打臉:同一台筆電,兩種結果

我從同一台筆電、同一個公網 IP(198.51.100.50),分別敲 RDS 和 EKS(Elastic Kubernetes Service,AWS 託管的 Kubernetes)的 API——前者用 nc(netcat — 測試 TCP 連接埠通不通的小工具),後者用 kubectl(Kubernetes 官方命令列工具):

# 1) 直接連 RDS 公網 endpoint 的 5432
$ nc -vz mydb.abc123xyz.ap-northeast-1.rds.amazonaws.com 5432
mydb...rds.amazonaws.com [203.0.113.71] 5432 (postgresql): Operation timed out   # ❌ 不通

# 2) 用 kubectl 連 EKS(背後同樣是一個公網 endpoint)
$ kubectl get nodes
NAME                            STATUS   ROLES    AGE    VERSION
ip-192-168-x-x.compute.internal Ready    <none>   169d   v1.32.9-eks   # ✅ 成功
比較項RDS(PostgreSQL)EKS API(kubectl)
對外有公網位址?
從我的筆電連❌ timeout✅ 成功

兩個都掛在公網,為什麼一個通、一個不通?

答案:兩種把關層級——網路層 vs 身分層

差別不在「有沒有對外」,而在**「靠什麼把關」**:

比較項RDSEKS API
把關層級網路層(L3/L4,OSI 的網路/傳輸層)身分層(L7,OSI 的應用層)
機制Security Group 白名單IAM 身分 + Kubernetes RBAC
比喻門牌存在,但門鎖只認內網來的人,外人連門都敲不到門對所有人開,但進門要刷識別證
沒有合法身分時連 TCP 握手都不會成功(封包直接被丟棄)TCP 連得上,但 API 回 401/403

EKS 的 API endpoint 設定其實是 endpointPublicAccess: truepublicAccessCidrs: ["0.0.0.0/0"]——對全世界開放。它這樣開,是因為真正的門鎖在身分層:沒有有效的 IAM(Identity and Access Management,身分與存取管理)憑證,再加上 Kubernetes 的 RBAC(Role-Based Access Control,基於角色的存取控制),連得到 endpoint 也辦不了任何事。

而 RDS 的 PostgreSQL 通訊協定,沒有這種「先驗 AWS 身分、再決定放不放行」的網路層機制,它只能靠 SG 在封包進門之前就擋掉。

timeout 還是 refused?這個差別會告訴你把關在哪一層

為什麼 nc 的結果是 timeout,而不是「連上後被拒絕」?因為 SG 在網路層直接把封包丟棄,你的 TCP SYN(建立連線的第一個握手封包)連一個回應都收不到,只能一路等到逾時。而 kubectl 是 TCP 先通、再由 IAM/RBAC 在應用層決定放不放行,所以會明確回你 401/403。

到底是 timeout 還是 refused/unauthorized,本身就在告訴你把關發生在哪一層。 這是排查連線問題時很實用的第一個分岔點。

flowchart LR
    Laptop["我的筆電<br/>198.51.100.50"]
    subgraph AWS["AWS VPC"]
        EKS["EKS API endpoint<br/>公網, 0.0.0.0/0"]
        RDS["RDS PostgreSQL<br/>公網 IP 203.0.113.71"]
        IAM{"IAM + RBAC<br/>身分驗證"}
        SG{"Security Group<br/>只放 VPC 內網"}
    end
    Laptop -->|"kubectl:TCP 先通"| EKS --> IAM -->|"憑證有效"| OK1["允許操作 ✅"]
    Laptop -->|"nc 5432"| SG -->|"非內網來源"| Drop["封包丟棄<br/>→ timeout ❌"]

    classDef ok fill:#d4edda,stroke:#28a745,color:#155724
    classDef bad fill:#f8d7da,stroke:#dc3545,color:#721c24
    classDef gate fill:#fff3cd,stroke:#856404,color:#856404
    class OK1 ok
    class Drop bad
    class IAM,SG gate

真正該擔心的,反而是那個你以為安全的 endpoint

這趟盤點有個反轉:真正對 0.0.0.0/0 全開的不是一開始被 AI 點名的 RDS,而是 EKS 的 API endpoint。它沒出事,純粹是因為背後有 IAM + RBAC 雙重把關。但「靠身分層擋住」不代表「網路層就不必收斂」——多一道網路白名單,等於在憑證外洩時還有一層緩衝。

如果要收斂,方向有兩個:

做法怎麼做代價
限制來源 CIDRpublicAccessCidrs0.0.0.0/0 改成已知辦公室/VPN 出口 IP(CIDR 是 IP 網段寫法,如 203.0.113.0/24浮動 IP 一變動就連不上,維護成本高
改走私有 endpoint關掉 public access、開 private access,本機透過 VPN 進 VPC要先有 VPN/跳板,設定較重

選擇邏輯其實跟前面同一條:先問「這個對外資源靠哪一層把關」,再決定要補網路層白名單、還是強化身分層。

flowchart TD
    Start["看到一個有公網位址的資源"] --> Q{"它靠哪一層把關?"}
    Q -->|"網路層 (SG)"| Net["檢查 SG inbound:<br/>有沒有 0.0.0.0/0 或非預期公網段"]
    Q -->|"身分層 (IAM/RBAC)"| Id["確認憑證機制夠強,<br/>再評估是否收斂來源 CIDR"]
    Net --> NetOk["白名單只有內網/已知來源<br/>→ 安全"]
    Net --> NetBad["有 0.0.0.0/0<br/>→ 真的曝露,要收"]
    Id --> IdAdv["可選:限制 CIDR<br/>或改 private + VPN"]

    classDef start fill:#cce5ff,stroke:#007bff,color:#004085
    classDef good fill:#d4edda,stroke:#28a745,color:#155724
    classDef bad fill:#f8d7da,stroke:#dc3545,color:#721c24
    classDef gate fill:#fff3cd,stroke:#856404,color:#856404
    class Start start
    class Q gate
    class NetOk,IdAdv good
    class NetBad bad

AI 時代的反思:為什麼一個旗標就能騙過 AI?

回到開頭——一個會讀設定、會寫 code 的 AI,為什麼看到 Public=true 就脫口而出「有資安風險」?

因為生成式判斷最擅長的,就是從局部訊號補出一套聽起來完整、自信的結論Public=true →「公開」→「資安風險」,這條推理鏈在統計上太常見、太順了,順到不需要查證就能講得頭頭是道。但它跳過了一個關鍵事實:曝露與否的決定權不在這個旗標,而在 SG。

這正是「AI 都會寫 code 了,為什麼還會出事」的答案:AI 縮短的是「把訊號變成一句判斷」的距離,沒縮短「確認那句判斷為真」的距離。 局部資訊餵給模型,它能很快給你一個答案;但那個答案是「看起來對」還是「真的對」,只有能被推翻(falsifiable,可證偽)的驗證能分辨——以這篇來說,就是實際跑一次 nc 連連看。

而且驗證方式本身也有講究:「從真實的 IP,敲那個真實的 port」會明確回你 timeout 或 success,這才能證實或推翻那個判斷;但若改成截一張 VPC 架構圖來「確認安全」,那只是確認式驗證,根本看不出問題。能 falsify 的才算驗證,否則只是幫自己的直覺背書。

收斂:旗標是線索,連線測試才是真相

要判斷的事不可靠的依據可靠的做法
RDS 是否真的對外曝露PubliclyAccessible 旗標讀 SG inbound 有沒有 0.0.0.0/0 或非預期公網網段
某個 port 通不通憑設定腦補從外部實際連,看 timeout(網路層擋)還是 refused/unauthorized(身分層擋)
公開 endpoint 安不安全「有公網就危險」看它靠什麼把關——網路白名單,還是身分憑證

最後一個務實建議:即使 SG 已經鎖好,不需要公開的 RDS 仍建議設成 Public=false。這是 defense-in-depth(縱深防禦 — 多疊一層保險,不押注在單一機制上)——就算哪天有人手滑在 SG 加了一條 0.0.0.0/0,因為根本沒有公網路徑,也不會立刻被打穿。

下次再看到一個聽起來很可怕的旗標,先別急著下結論——敲一下那個 port,讓事實說話。