



Day 17 把順序定下來了,今天把第一個掃描 Task 接上去,並且走完整條鏈路:改一個 commit、push、webhook 觸發、Pipeline 跑完。
要說明的是一件反直覺的事:掃「現在的檔案」幾乎抓不到東西。 內容被 commit 進去、下一個 commit 改掉,工作目錄乾乾淨淨,但原來那份永遠留在 git 歷史裡。
而選對命令之後還有第二層:git-clone 的 DEPTH 預設值會讓「掃歷史」只剩一個 commit。這段在第 9 節。
| 常見的做法 | 這篇的做法 |
|---|---|
| 掃描原始碼就是掃工作目錄 | dir 對已刪除的內容完全無效,要用 git |
加上 --exit-code 1 才會擋下 CI |
預設就是 1,明寫只是自我文件化 |
用 --report-path=/dev/null 明確丟掉報告 |
會讓整個 Task 失敗,見第 6 節 |
--redact=20 是「加強一點遮蔽」 |
N 是遮蔽掉的百分比,20 代表只蓋 20%、露出 80% |
Task 的 image: 可以寫 Service 位址 |
不行。拉 image 的是節點上的 kubelet,不在 Pod 網路內 |
| 掃描 Task 綠燈就代表沒有洩漏 | DEPTH=1 之下只掃了一個 commit |
加 --ignore-gitleaks-allow 就堵住所有靜音手段 |
它只管 inline 註解,.gitleaksignore 是另一個開關,見 12.2 |
| 用手動建的 Pod 驗 Task 的行為 | SCC 准入會併入建立者權限,手動 Pod 拿到的 SCC 跟 TaskRun 不同 |
CRC 2.61.0+6eb443
OpenShift 4.21.14 / Kubernetes v1.34.6(單節點 crc)
Red Hat OpenShift Pipelines Operator 1.23.1
gitleaks v8.30.1(image 內建 git 2.49.1)
Nexus Repository 3.93.0-06(COMMUNITY)
Gitea 1.27.0
用戶端:Windows 11 Pro + PowerShell 5.1
gitleaks 的 CLI 在 v8.19.0 換過一次:detect / protect 被 git / dir / stdin 取代。本文的命令只對 8.19.0 以上成立。
Go 寫的 CLI,MIT 授權,只做一件事:找 hardcoded secret——API key、token、密碼、私鑰。功能單一所以快,快到可以掛 pre-commit hook。
兩層,缺一不可。
一、regex 比對已知格式。 AWS access key 一定是 AKIA 開頭加 16 碼,GitHub PAT 是 ghp_ 開頭。這類規則誤判率極低,因為格式是官方文件寫死的。
二、Shannon entropy 算亂度。 這是它跟 grep 的差別。your_api_key_here 亂度低,放行;一串隨機的 40 字元亂度高,抓。
輸出裡的 Entropy 欄位就是這個數字:
Secret: NWNp1RxTIuqaa49aUT6V6bt2O8jLVCkumnp/c2Zz
RuleID: generic-api-key
Entropy: 4.853056
generic-api-key 這條規則沒有特定前綴可比對,完全靠 entropy 撐。
預設規則在 gitleaks repo 的 config/gitleaks.toml,TOML 格式,內建在 image 裡,執行時不連外。要自訂就在專案根目錄放 .gitleaks.toml:
[extend]
useDefault = true # 保留內建規則
[[rules]]
id = "internal-api-key"
regex = '''(?i)INTERNAL_API_KEY\s*[=:]\s*['"]?([a-zA-Z0-9]{32,})['"]?'''
secretGroup = 1
沒有辨識特徵、亂度又不夠高的東西。 例如自家服務發的 32 碼十六進位 token——沒前綴、entropy 普通,不加規則就滑過去。
這是所有 regex 掃描器的共同上限。反過來,誤判來源也很固定:base64 編碼的設定區塊、文件裡的雜湊值、機器產生的 ID,三者亂度都高。
gitleaks git 掃 git 歷史(每個 commit 的 diff)
gitleaks dir 掃工作目錄的檔案
gitleaks stdin 掃管線輸入
選哪一個決定這道防線有沒有用,第 5 節說明。
這篇分兩半,可以分兩次讀。
快樂路徑(第 2–8 節) 照著做就能跑完,不需要理解為什麼。
技術說明(第 9–13 節) 是為什麼,之後再看不影響你把 Task 接上去。唯一的例外是第 9 節。 不看那節,你會拿到一個綠燈的掃描 Task,並且以為防線生效了。
改動範圍只有兩個物件:新增 Task/gitleaks-scan、修改 Pipeline/d15-happy-path。
EventListener、TriggerBinding、TriggerTemplate、PVC、Gitea webhook 一個都不動——TriggerTemplate 只指名 Pipeline,不列舉 Task。
$OC = (Get-ChildItem "$env:USERPROFILE\.crc\cache" -Recurse -Filter "oc.exe" | Select-Object -First 1).FullName
$kc = "$env:USERPROFILE\.crc\machines\crc\kubeconfig"
$NEXUS_ROUTE = "nexus-nexus-proxy.apps-crc.testing"
image 走 Day 15 建的 docker-proxy,不必新建任何 repository:
nexus-nexus-proxy.apps-crc.testing/docker-proxy/zricethezav/gitleaks:latest
latest 目前是 v8.30.1。
zricethezav/gitleaks就是官方發布位置之一——官方 README 的 Docker Hub 安裝指令寫的就是它,ghcr.io/gitleaks/gitleaks是並列的另一個來源。(zricethezav是作者 Zachary Rice 的個人帳號,不是舊組織名。Docker Hub 上不存在的是gitleaks/gitleaks,404。)「鏡像會不會落後」也不成問題。這顆 image 的 OCI label 是
org.opencontainers.image.source=https://github.com/gitleaks/gitleaks、version=v8.30.1,而它的created時間比 GitHub 上 v8.30.1 的發布時間晚 17 秒——同一次 release pipeline 推的。不必另建 ghcr proxy。
位址必須用 Route,不能用 Service,成因在第 10 節。
Nexus 的 anonymous 是關的,拉 image 一定要帶憑證。--docker-server 要跟 image: 裡的主機名逐字相同。
$ns = "nexus-proxy"
$u = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(
(& $OC --kubeconfig $kc get secret ci-docker-credentials -n $ns -o jsonpath='{.data.username}')))
$p = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(
(& $OC --kubeconfig $kc get secret ci-docker-credentials -n $ns -o jsonpath='{.data.password}')))
& $OC --kubeconfig $kc create secret docker-registry nexus-route-credentials -n ci `
--docker-server=$NEXUS_ROUTE --docker-username=$u --docker-password="$p"
& $OC --kubeconfig $kc secrets link pipeline nexus-route-credentials --for=pull -n ci
secrets link 這一步是必要的,不是可選的。 在 Pod spec 裡寫 imagePullSecrets 只對自己建的 Pod 有效;TaskRun 產生的 Pod 走的是 ServiceAccount。少了它第一次跑會得到 TaskRunImagePullFailed。
& $OC --kubeconfig $kc get sa pipeline -n ci -o jsonpath='{.imagePullSecrets[*].name}'
# pipeline-dockercfg-xxxxx nexus-route-credentials
ci裡現在有兩份 Nexus 憑證,用途不同:
Secret server 誰在用 |
nexus-route-credentials| Route | kubelet 拉 Task 的image:|
|nexus-docker-credentials| Service:8081 | Pod 內的 skopeo / buildah 推拉 image |
apiVersion: tekton.dev/v1
kind: Task
metadata:
name: gitleaks-scan
namespace: ci
spec:
workspaces:
- name: source
steps:
- name: scan
image: nexus-nexus-proxy.apps-crc.testing/docker-proxy/zricethezav/gitleaks:latest
workingDir: $(workspaces.source.path)
script: |
#!/bin/sh
set -e
echo "=== 工作區裡實際有幾個 commit ==="
git rev-list --count HEAD
echo "=== gitleaks ==="
gitleaks git . \
--redact \
--ignore-gitleaks-allow \
--no-banner
三個決定:
| 項目 | 理由 |
|---|---|
git . 不是 dir . |
工作目錄乾淨不代表歷史乾淨——內容刪掉了,diff 還在(第 5 節有實測) |
--redact 不帶數字 |
整串換成 REDACTED。不要寫成 --redact=N,N 是遮蔽掉的百分比,20 代表只蓋 20%(12.1 有實測) |
--ignore-gitleaks-allow |
原始碼裡加一行 # gitleaks:allow 就能讓發現消失,而且 Pipeline 這一側看不出來(12.2 有實測) |
三件刻意沒寫的:
--exit-code 1 —— 那是預設值。寫了不會錯,但別把它當成防線的一部分。--report-path / --report-format —— 不給就不會產生報告檔。報告檔一定帶 Secret 欄位,是不是明文則完全跟著 --redact 走,所以問題不在「一定是明文」,而在多了一個會落地、會被當成 artifact 保留、遮蔽程度取決於後人有沒有動旗標的檔案(12.1 有三種形態的實測)。(初版曾寫成 --report-path=/dev/null,壞的是那個值不是那個旗標,第 6 節整節在講這件事。)git config --global --add safe.directory —— 這台不需要,但不是因為 git 對 root 寬容,是因為 gitleaks 的 image 裡燒了一行 safe.directory=*。換一顆 image 就要自己加(11.1)。git rev-list --count HEAD 那行不是裝飾:它是唯一能證明「歷史只剩一層」的數字,gitleaks 自己的 commits scanned 證明不了(第 9 節)。
git 不是 dir用一個實驗 repo:第一個 commit 塞進一段高 entropy 的字串,第二個 commit 刪掉它,工作目錄檔案數 = 0。
=== gitleaks dir .(只看工作目錄)===
exit=0
INF scanned ~0 bytes (0) in 2.82ms
INF no leaks found
=== gitleaks git .(掃歷史)===
exit=1
INF 1 commits scanned.
INF scanned ~63 bytes (63 bytes) in 48.1ms
WRN leaks found: 1
同一個 repo、同一組 commit,兩個子命令的結論完全相反,連 exit code 都相反。
dir 沒有錯——它掃了 0 bytes,因為工作目錄真的是空的。問題是「已刪除」不等於「不存在」。
這裡有個數字要先講清楚,第 9 節會用到。 實驗 repo 有 2 個 commit,gitleaks 卻只說
1 commits scanned——因為它只掃 diff 的新增側,第二個 commit 是純刪除,沒有東西可掃。commits scanned是「掃過的 commit」,不是「repo 的 commit」。 換到兩個 commit 都有新增內容的真實 repo 上,這個數字就等於 2。
還有第二層差異:在真實 repo 上,兩者掃的位元組數差 16 倍。
git 模式 scanned ~1,062,448 bytes (1.06 MB)
dir 模式 scanned ~66,488 bytes (66.49 KB)
repo 有 37 個追蹤檔案,其中 package-lock.json 就佔 1,011,046 bytes——dir 顯然沒掃它。
但**「dir 少掃一個 package-lock.json」解釋不了全部差額**:
git 1,062,448 − package-lock 1,011,046 = 51,402
dir = 66,488 ← 反而多出 15,086
dir 還看到了約 15 KB 是 git 模式沒看到的東西。兩邊掃的檔案集合不是包含關係,成因沒有查證(gitleaks dir . --log-level debug 會列出被跳過的檔案,一次就查得清)。
所以 dir 不只「看不到歷史」,掃描範圍本身也跟 git 對不起來。
--report-path=/dev/null 會讓 Task 失敗——壞的是那個值,不是那個旗標這一節是實際跑起來才發現的,靜態看 YAML 看不出來。
初版 Task 為了「明確把報告丟掉」寫了 --report-path=/dev/null。第一次跑:
FTL Unknown report format:
--report-format 的預設值是空字串,不是 json。但「所以 --report-path 必須搭配 --report-format」是當時的誤讀——官方 README 的 baseline 範例就只給 --report-path gitleaks-report.json,沒給 format。真正的規則是格式判定不出來才會失敗,而 /dev/null 沒有副檔名可判。路徑改成 .json 結尾就不必手動指定。
當時補上 --report-format=json 之後,換一個錯:
FTL could not create Git log cmd error="open /dev/null: no such file or directory"
而 /dev/null 明明存在:
crw-rw-rw- 1 root root 1, 3 /dev/null
用一顆乾淨的探測 TaskRun 重測——每種寫法各一個命令、set -x 印出實際 argv、/dev/null 那組故意排最後免得污染前面:
| 寫法 | exit | 結果 |
|---|---|---|
| 完全不給 report 旗標 | 0 | no leaks found,不產生報告檔 |
--report-path=/tmp/b.json(不給 format) |
0 | 正常產生報告——副檔名推得出來就不必給 format |
--report-format=json --report-path=/tmp/c.json |
0 | 正常產生報告 |
--report-format=json --report-path=/dev/null |
1 | could not create Git log cmd error="open /dev/null: no such file or directory" |
+ gitleaks git . --redact --no-banner '--report-format=json' '--report-path=/tmp/c.json'
INF no leaks found
C_exit=0
-rw-r--r-- 1 root root 3 /tmp/c.json
問題自始至終都在 /dev/null 這個值,不在 --report-path 這個旗標。
初版那張表的第三列(「換成 /tmp/r.json 也是同一個錯」)是探測腳本沒換乾淨造成的假象。那顆 image 的 /bin/sh 是 busybox ash、不支援 PIPESTATUS,三種寫法只能各寫成「重導向到檔案 → echo $? → tail」的重複區塊——正是最容易漏改一處的形狀。
/dev/null 為什麼會回 no such file or directory(它明明是個 crw-rw-rw- 的字元裝置)仍然沒有解釋,但範圍已經縮到一個沒有人該去踩的邊角。
這一節留下的教訓因此換了一個,而且比原來的更值錢:
/dev/null 不是「安全的丟棄目標」。 想丟掉輸出就別產生它,不要塞一個特殊路徑進去。/dev/null 沒有副檔名可判),一則說 /dev/null 找不到(它存在)。--report-path 在 Task 裡不能用」卻是假的——真證據,假結論。 推翻它只花了一顆拋棄式 TaskRun。第 4 節的 Task 仍然不給 report 旗標,但理由換成 12.1 那個:報告會落地,而遮蔽程度取決於後人有沒有動 --redact。不是因為它會讓 Task 失敗。
依 Day 17 的 DAG,gitleaks-scan 直接接在 git-clone 之後。用 JSON patch 附加,不重寫整份 spec:
[{"op":"add","path":"/spec/tasks/-","value":{
"name":"gitleaks-scan",
"runAfter":["git-clone"],
"taskRef":{"name":"gitleaks-scan"},
"workspaces":[{"name":"source","workspace":"shared-workspace"}]
}}]
& $OC --kubeconfig $kc patch pipeline.v1.tekton.dev d15-happy-path -n ci `
--type=json --patch-file pipeline-patch.json
JSON patch 一定要走
--patch-file,不要用-p '[...]'。PowerShell 5.1 會把內層雙引號吃掉,錯誤訊息是 YAML 解析失敗,看起來像 patch 內容寫錯。這是 Day 15 §1.3 第二點的第五次出現。
workspace 的映射照 Day 13 的四層命名:name 是 Task 內部的,workspace 是對回 Pipeline 頂層的。
確認:
& $OC --kubeconfig $kc get pipeline.v1.tekton.dev d15-happy-path -n ci `
-o jsonpath="{range .spec.tasks[*]}{.name}{' <- '}{.runAfter}{'\n'}{end}"
git-clone <-
workspace-probe <- ["git-clone"]
gitleaks-scan <- ["git-clone"]
TriggerTemplate 不用改。 它的 pipelineRef 只寫 name: d15-happy-path,不列舉 Task。
推一個真的 commit(改 README),不用 Test Delivery:
git commit -am "docs: note Day 18 gitleaks scan"
git push origin main
Gitea 送 webhook → EventListener 驗簽並過濾 → TriggerTemplate 產生 PipelineRun。
Start-Sleep -Seconds 20
& $OC --kubeconfig $kc get pipelinerun -n ci --sort-by=.metadata.creationTimestamp | Select-Object -Last 1
三個 TaskRun:
TASK STATUS START END
git-clone Succeeded 2026-08-15T01:21:35Z 2026-08-15T01:21:43Z
gitleaks-scan Succeeded 2026-08-15T01:21:43Z 2026-08-15T01:21:49Z
workspace-probe Succeeded 2026-08-15T01:21:43Z 2026-08-15T01:22:30Z
workspace-probe 與 gitleaks-scan 在 git-clone 結束的同一秒(01:21:43Z)起跑——Day 17 §2「能並行的並行」在這裡直接看得到。
gitleaks-scan 的 log:
=== 工作區裡實際有幾個 commit ===
1
=== gitleaks ===
INF Unknown SCM platform. Use --platform to include links in findings. host=gitea-http.gitea.svc.cluster.local
INF 1 commits scanned.
INF scanned ~1062506 bytes (1.06 MB) in 72.8ms
INF no leaks found
綠燈。但那個 1 是這篇最重要的數字——repo 有 4 個 commit,工作區只有 1 個。
Unknown SCM platform是 gitleaks 認不出 Gitea,所以 findings 裡不會有可點擊的連結。不影響偵測。
上面那組輸出的來源要說清楚。 push 確實觸發了 PipelineRun,但頭兩次都撞上第 6 節那個
--report-path的坑而失敗。這組綠燈的輸出是修好 Task 之後、用 Test Delivery 重新觸發的——mainHEAD 沒變,clone 出來的東西完全相同,Pipeline 的行為也一樣,差別只在 payload 的before/after欄位,而這條 Pipeline 沒有用到它們。
1:DEPTH 預設值讓歷史只剩一層log 有兩個數字要並排看:
git rev-list --count HEAD = 1 ← 工作區裡的
repo 實際 commit 數 = 4
gitleaks: 1 commits scanned
scanned ~1.06 MB
檔案全都在——1.06 MB 掃完了,包含那個一百萬 bytes 的 package-lock.json。只有歷史沒有。
為什麼要並排看兩個數字。 第 5 節說過,
commits scanned只算「有新增內容的 commit」。所以「歷史只剩一層」和「四個 commit 但只有一個有新增」在 gitleaks 的輸出上長得一模一樣——單看1 commits scanned證明不了 shallow clone,是git rev-list --count HEAD = 1把它釘死的。要判斷這道防線掃了多少歷史,這兩個數字缺一不可。
成因是 git-clone 的預設值:
git-clone 的 DEPTH default = "1"
d15-happy-path 沒有覆寫
DEPTH=1 是 shallow clone,只抓最新一個 commit。工作區的 .git 只有一層,gitleaks git 就只有一個 commit 可掃。
這跟直接用 dir 的差別非常有限。 前面所有設定都對,這道防線仍然只看了最新一次提交。
- name: git-clone
params:
- name: DEPTH
value: "0" # 0 = 不做 shallow clone
這帖藥後來補驗過了,結果是一個很小的數字:commits scanned 從 1 變成 7,而多掃到的內容只有 7,884 bytes。 同一個 repo、同一個時間點,只差 DEPTH 這一個參數,各跑一次:
DEPTH=1 |
DEPTH=0 |
|
|---|---|---|
git rev-list --count HEAD |
1 | 7 |
gitleaks commits scanned |
1 | 7 |
scanned bytes |
1,062,103 | 1,069,987 |
| gitleaks 耗時 | 59.4ms | 95.6ms |
7,884 bytes,佔 0.74%。 這個數字把「檔案在、歷史不在」量化了:DEPTH=1 的時候,1.06 MB 的檔案內容一個都沒少,少掉的是那 6 個 commit 的差異——不到 8 KB,卻正是「有人塞了金鑰、下一個 commit 又刪掉」唯一會留下痕跡的地方。這道防線漏掉的從來不是體積,是那 0.74%。
這組數字的來源要說清楚。 補驗是 Day 18 之後才做的,那時 repo 已經從 4 個 commit 長到 7 個。§8 那組輸出仍然是當天
DEPTH=1的真實紀錄。補驗之後,d15-happy-path的git-clone已經實際加上DEPTH: "0"——這一節開的藥,自己吃了。
至於代價,在這個 repo 上量不出來。 兩次 clone 分別是 25 秒與 10 秒,但 DEPTH=1 那次排在前面、要付 image pull 的錢,這組時間不能拿來比較。能確定的只有:7 個 commit、1 MB 的 repo,深度差別小到看不見。
真正的代價要在歷史長的 repo 上才浮現,隨長度成長。實務上的折衷是設一個有限深度搭配定期全量掃描——但那個數字要有依據,取決於 repo 的 commit 密度與上次全量掃描的時間點。
「沒設定」不等於「沒限制」。 git-clone 沒填 DEPTH 不代表它抓全部,它會套用一個你沒看到的預設值,而那個值正好讓這道防線失效。
image: 不能用 Service 位址Pod 內的 skopeo 可以經 Service 位址拉推 image,那個結論仍然成立。但拉 Task 自己的 image 是另一回事。
寫 Service 位址會得到 ImagePullBackOff:
pinging container registry nexus-nexus3.nexus-proxy.svc.cluster.local:8081:
Get "https://nexus-nexus3.nexus-proxy.svc.cluster.local:8081/v2/":
dial tcp: lookup nexus-nexus3.nexus-proxy.svc.cluster.local on 192.168.127.1:53: no such host
image: 由 kubelet 在節點上處理,那個動作發生在 Pod 存在之前,不在 Pod 網路裡。兩個問題同時成立:
一、DNS。 節點的 resolver 是 192.168.127.1(主機側),不是 cluster DNS。*.svc.cluster.local 只有 Pod 內解析得到。
二、協定。 CRI-O 預設打 https://,而 Nexus 的 8081 只有 HTTP。
Route 一次解掉兩個:有公開 DNS、有 edge TLS,而 CA 在 Day 15 就裝進節點的 /etc/containers/certs.d/——那份是為 podman 裝的,CRI-O 讀的是同一個位置。
| 誰在拉 | 執行位置 | Service:8081 | Route |
|---|---|---|---|
kubelet(image: 欄位) |
節點 | 不行 | 可以 |
| Pod 內的 skopeo / buildah | Pod 網路內 | 可以 | 可以 |
| 節點的 podman | 節點 | 不行 | 可以 |
之後每一篇都會用到:Task 的 image: 走 Route,Task 裡面的工具推拉 image 走 Service。
safe.directory——image 裡早就寫死了 safe.directory=*先給答案。 在 step 裡問 git 這個設定是從哪來的,它直接回答:
$ git config --list --show-origin | grep -i safe
file:/root/.gitconfig safe.directory=*
那份 /root/.gitconfig 只有 22 bytes,是 build 時就燒進 image 的——gitleaks 的 Dockerfile 有一行 RUN git config --global --add safe.directory '*':
[safe]
directory = *
檢查不是「沒觸發」,是早就被全域放行了。
以下是這件事為什麼值得追,以及為什麼「git 對 root 有例外」是個錯的解釋。
git 2.35 之後,操作「別人擁有的 repo」會被拒絕:
fatal: detected dubious ownership in repository at '/workspace/source'
這在 Tekton 裡很容易踩到——git-clone 和 gitleaks-scan 是兩顆不同的 Pod,寫的人跟讀的人未必是同一個 UID。
這台實測沒觸發:
step 執行身分 uid=0(root) gid=0(root) groups=…,1000860000
HOME /root
workspace 根 drwxrwsr-x 0 1000860000
.git 的屬主 drwxrwsr-x 65532 1000860000 ← git-clone 寫的
git rev-parse --is-inside-work-tree = true
.git 確實屬於 65532,而 step 跑在 root。原因不是「git 對 root 有例外」——git 的檢查是這樣寫的:
euid = geteuid();
if (euid == ROOT_UID)
{
if (st.st_uid == ROOT_UID)
return 1;
else
extract_id_from_env("SUDO_UID", &euid);
}
return st.st_uid == euid;
root 只有在檔案本身也屬於 root、或 SUDO_UID 對得上時才放行。.git 屬於 65532、容器裡又沒有 SUDO_UID,照這段程式碼應該要噴才對。
真正的原因就是節首那份 /root/.gitconfig。
那兩根支柱仍然在承重,只是承的東西換了——它們決定那份設定檔讀不讀得到:
USER 是 root → --global 在 build 時寫進的是 /root/.gitconfig,得以 root 身分跑、HOME 指向 /root 才讀得到pipelines-scc 的 runAsUser 是 RunAsAny → image 宣告的 USER 被沿用;換成強制指定 UID 的 SCC,/root 底下那份就讀不到了(第 2 點依賴 /root 的權限位元,這一步沒有實際量過。)
所以換掉任何一根——image 改成 non-root、Task 加 securityContext.runAsUser、或換一個 SCC——就要自己加:
git config --global --add safe.directory "$(workspaces.source.path)"
而真正該警戒的還多一種:換一顆沒有那行 safe.directory 的 image。 前三種在 YAML 上看得見,這一種看不見。
這台不加,是因為驗過不需要,不是因為「先不加看看」。
驗證 11.1 時踩到的獨立坑。
第一次用手動建的 Pod 測,拿到的 SCC 是 anyuid;改用真的 TaskRun 跑,拿到的是 pipelines-scc。
原因是 SCC 准入會併入建立者的權限。手動 Pod 以 kubeadmin 建,TaskRun 產生的 Pod 以 pipeline SA 建,兩者能用的 SCC 集合不同。
這次的結論剛好一樣(兩個 SCC 的 runAsUser 都是 RunAsAny),但那是運氣。要驗 Task 在 Pipeline 裡的實際行為,就得跑真的 TaskRun。
--redact 的方向與直覺相反--redact uint[=100] redact secrets ... percent value from 0..100 (default 100%)
N 是「遮蔽掉的百分比」,不是「保留的」。 一組 40 字元的字串:
不加 --redact Secret: NWNp1RxTIuqaa49aUT6V6bt2O8jLVCkumnp/c2Zz
--redact(=100) Secret: REDACTED
--redact=20 Secret: NWNp1RxTIuqaa49aUT6V6bt2O8jLVCku...
--redact=20 顯示 32 字元,保留了 80%。數字愈小愈危險,看起來像「輕度遮蔽」,實際上是「幾乎沒遮」。
另一件更重要的:console 預設不印機密,JSON 報告一律帶 Secret 欄位。
console 預設(無 -v) 只有 "leaks found: 1",不含內容
console 加 -v 印出 Secret 欄位
JSON 報告 不管有沒有 -v,一律含 Secret 欄位
但「有欄位」不等於「是明文」——報告忠實反映 --redact:
不遮蔽 "Secret": "NWNp1RxTIuqaa49aUT6V6bt2O8jLVCkumnp/c2Zz"
--redact "Secret": "REDACTED"
--redact=20 "Secret": "NWNp1RxTIuqaa49aUT6V6bt2O8jLVCku..."
這反而讓上面那條更嚴重。 --redact=20 印在 console 只是難看,寫進報告檔就是 32 個字元的明文永久落地——而報告正是會被存檔、上傳、當成 artifact 保留的東西。
所以第 4 節的 Task 根本不產生報告。這剛好與第 6 節那個坑的修法一致:不給 report 旗標,既避開錯誤,也不必依賴「之後沒有人動到 --redact」這個假設。
gitleaks:allow 能靜音,Pipeline 看不出來在原始碼那一行後面加上 # gitleaks:allow,該筆發現就被跳過。
實驗 repo 的第三個 commit 加入一行帶 gitleaks:allow 的相同字串:
不加旗標 exit=1 leaks found: 1
--ignore-gitleaks-allow exit=1 leaks found: 2
1 → 2,被靜音的那一筆重新出現。
注意兩次的 exit code 都是 1——從 Pipeline 這一側完全看不出有東西被靜音。如果 repo 裡只有被靜音的那一筆,Task 會直接綠燈通過。
這跟 Day 17 §5「前提二」是同一個形狀:改東西就能繞過防線,不用碰叢集。 差別在那裡改的是 Pipeline 定義,這裡改的是應用程式原始碼——後者門檻更低,任何有 push 權限的人都做得到。
--ignore-gitleaks-allow 是工具內建的對策,但它只管 inline 註解——旗標的說明就是字面上的 ignore gitleaks:allow comments。
靜音還有第二條路,這個旗標堵不住: repo 根目錄的 .gitleaksignore(fingerprint 清單),歸另一個旗標 -i, --gitleaks-ignore-path 管,預設值是 .。兩條路各有各的開關,繞過門檻卻一模一樣低——都是改倉庫裡的檔案,有 push 權限就做得到。要一起堵,得把 --gitleaks-ignore-path 指到受控路徑,或把 .gitleaksignore 納入 branch protection 與 code review。
.gitleaksignore這條本篇沒有實測,只核對過旗標定義。上面 1 → 2 那個實驗只涵蓋 inline 註解那條路。
代價是失去正當的例外機制(測試用的假字串會一直被報)。這裡選擇關掉 inline 那條,因為現階段沒有需要例外的正當案例。
detect 還在,但不要用gitleaks detect --help 仍然回 0,只是不列在 Available Commands 裡。官方在 v8.19.0 的公告說「何時移除未定」。
gitleaks detect --source={repo} → gitleaks git {repo}
gitleaks detect --no-git --source={repo} → gitleaks dir {directory}
gitleaks detect --no-git --pipe → gitleaks stdin
新寫的 Task 一律用新命令。網路上大量教學文還停在 detect,照抄會得到一個隨時可能失效的 Task。
寫這篇的過程中弄壞了一次 README,形態跟「不小心推了機密」完全一樣。
要改 README 之前得先取原檔,我打了:
.../api/v1/repos/gitea_admin/frontend-nx-mono/raw/branch/main/README.md
/api/v1 這個前綴沒錯,錯的是 branch/main 那一段——那是 web 路徑的寫法。三種形式的實際狀態碼:
/api/v1/repos/{owner}/{repo}/raw/branch/main/README.md 404 ← 我打的
/api/v1/repos/{owner}/{repo}/raw/README.md 200 ← API 的 raw 端點(分支用 ?ref= 指定)
/{owner}/{repo}/raw/branch/main/README.md 200 ← web 路徑
回來的是 80 bytes 的 JSON:
{"message":"not found","url":"https://gitea-gitea.apps-crc.testing/api/swagger"}
狀態碼一直在那裡說 404。 我用的是 curl -s,沒有 -f、也沒有 -w '%{http_code}'——錯誤 body 照樣寫進輸出檔,一聲不吭。沒檢查大小就當成原檔內容、附加一行註解推上去,README 從 4829 bytes 變成 138 bytes。
還原時加了一道檢查:
if [ "$(wc -c < /tmp/orig.md)" -lt 4000 ]; then exit 1; fi
那道檢查應該一開始就有。 這正是 Day 11 §6 慣例一的另一個形態:任何「取回來的東西」都要有一個不該成立的下界當守門員。
一、curl -s 會把錯誤 body 當成內容交給你。 狀態碼明明是 404,但沒有 -f 也沒有 -w '%{http_code}' 的話,那則錯誤 JSON 會安安靜靜寫進輸出檔。取檔案回來,狀態碼和大小至少要看一個。 這跟「查詢回空 body 被讀成沒設定」是同一族的問題——訊號都在,只是沒去讀。
二、工作目錄修好了,歷史還在。
docs: restore README and note Day 18 gitleaks scan ← 還原
docs: note Day 18 gitleaks scan ← 壞掉的那次
chore: nx angular monorepo scaffold for CI
(初始)
repo 現在 4 個 commit,那個 138 bytes 的版本永遠在裡面。要真的清掉得 git reset + force push,不是再推一個 commit 蓋過去。
這就是本篇為什麼用 gitleaks git 而不是 dir。 差別在這裡不是理論。
如果那次推上去的是真的金鑰,處理順序是:
1. 作廢那把金鑰 ← 唯一真正有效的
2. 清 git 歷史(reset + force push)
3. 通知所有 clone 過的人重新 clone ← 他們本機的 .git 還有
4. 補上 pre-commit hook
第 1 步為什麼優先:從 push 到發現,中間可能有人 clone、fork、CI log 存過、備份掃過。那些副本你控制不了。
所以 CI 這道防線擋下來,只代表「這次沒進到後面的階段」,不代表沒洩漏——金鑰在 push 的那一刻就已經出去了。真正該補的是更前面的 pre-commit hook。
# 1. Task 用的是 git 不是 dir
& $OC --kubeconfig $kc get task gitleaks-scan -n ci -o jsonpath='{.spec.steps[0].script}' |
Select-String -Pattern "gitleaks (git|dir)"
# 2. 沒有 report 旗標(本篇的選擇:根本不產生報告檔)
& $OC --kubeconfig $kc get task gitleaks-scan -n ci -o jsonpath='{.spec.steps[0].script}' |
Select-String -Pattern "report-(path|format)"
# 期望:無輸出
# 3. image 是 Route 位址
& $OC --kubeconfig $kc get task gitleaks-scan -n ci -o jsonpath='{.spec.steps[0].image}'
# 4. pull secret 綁在 SA 上(不是只寫在 Pod spec)
& $OC --kubeconfig $kc get sa pipeline -n ci -o jsonpath='{.imagePullSecrets[*].name}'
# 5. Pipeline 的 DAG
& $OC --kubeconfig $kc get pipeline.v1.tekton.dev d15-happy-path -n ci `
-o jsonpath="{range .spec.tasks[*]}{.name}{' <- '}{.runAfter}{'\n'}{end}"
# 6. DEPTH 有沒有覆寫 —— 沒有的話只掃一個 commit
& $OC --kubeconfig $kc get pipeline.v1.tekton.dev d15-happy-path -n ci -o json | ConvertFrom-Json |
ForEach-Object { $_.spec.tasks } | Where-Object { $_.name -eq 'git-clone' } |
Select-Object -ExpandProperty params | Format-Table name, value
# 7. 實際跑出來的 commit 數(Task log 的第一行)
& $OC --kubeconfig $kc logs -n ci -l tekton.dev/pipelineTask=gitleaks-scan --tail=20
第 6 項最容易漏。 前面五項全對,但 DEPTH 沒覆寫的話,這道防線只掃了一個 commit——而 Task 會綠燈。
三件值得帶走的。
「已刪除」不等於「不存在」。 dir 對已刪除的內容完全無效,git 才掃得到。第 13 節那次 README 意外把這件事演了一遍——工作目錄修好了,那個 138 bytes 的版本仍然在歷史裡。
預設值決定這道防線有沒有作用。 git-clone 的 DEPTH 預設是 1。真實跑出來的證據是:repo 有 4 個 commit,工作區只有 1 個,1.06 MB 的檔案全部掃完了,歷史卻只剩一層。檔案在,歷史不在。
修法只有一個參數,而且補驗過:DEPTH: "0" 之後 commits scanned 從 1 變 7,多掃到的內容只有 7,884 bytes。這道防線原本漏掉的,就是那 0.74%。
看起來合理的旗標「值」可能讓整個 Task 失敗。 --report-path=/dev/null 意圖正確(不讓報告落地),實際上直接讓 Task 跑不起來,而兩則錯誤訊息都不指向真正的原因。
更值得帶走的是後續:我從三次紅燈推出「--report-path 在 Task 裡不能用」,重測之後發現通則是錯的——壞掉的是那個值,不是那個旗標。三個失敗的 PipelineRun 是真證據,卻撐起了一個假結論。這篇一路在講「預設值、旗標、訊息會騙你」,最後連自己的歸納也演了一次。
另外修正兩個常見說法:--exit-code 1 是預設值,不是防護的必要條件;--redact=N 的 N 是遮蔽百分比,數字愈小露出愈多。
明天接第二道掃描。
gitleaks
| 文件 | 用到的結論 |
|---|---|
| gitleaks/gitleaks | 三個子命令(git / dir / stdin);旗標與預設值:--exit-code(default 1)、--redact uint[=100]、--ignore-gitleaks-allow(只管 gitleaks:allow 註解)、-i, --gitleaks-ignore-path(.gitleaksignore,default .)、--baseline-path;baseline 範例只給 --report-path 不給 --report-format;輸出欄位含 Entropy / RuleID / Fingerprint。頁首的 Warning:作者已宣告 gitleaks feature complete,不再併入新功能、只出安全修補,重心移往後繼專案 Betterleaks —— 不影響本篇任何結論,但長期選型要算進去 |
| v8.19.0 release | detect / protect 棄用,改為三個子命令;三組命令的對照關係 |
| config/gitleaks.toml | 預設規則集;[[rules]] 的 regex / entropy / allowlist 結構 |
| zricethezav/gitleaks(Docker Hub) | 本篇使用的 image 來源,也是官方 README 給的 Docker Hub 安裝位置(ghcr.io/gitleaks/gitleaks 是並列的另一個來源);image 的 OCI label 指回 github.com/gitleaks/gitleaks、version=v8.30.1;Dockerfile 內含 git config --global --add safe.directory '*',即 11.1 的成因 |
git / Tekton
| 文件 | 用到的結論 |
|---|---|
| git 2.35.2 安全公告(CVE-2022-24765) | safe.directory 與 detected dubious ownership 的由來 |
git is_path_owned_by_current_uid() |
root 沒有一般性豁免:euid 為 0 時,只有檔案也屬於 root 才直接放行,否則退回比對 SUDO_UID。11.1 的機制依據 |
| Tekton Tasks | workspaces / steps / workingDir 的結構 |
| Tekton Pipelines | runAfter 的語意 |
Gitea
| 文件 | 用到的結論 |
|---|---|
| Gitea API 文件 | API 的 raw 端點是 /repos/{owner}/{repo}/raw/{filepath}(分支用 ?ref= 指定);raw/branch/{br}/... 是 web 路徑的寫法,打到 /api/v1 底下回 404。第 13 節那次意外的成因 |