iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
Kubernetes

防範軟體供應鏈攻擊:從零打造具備硬性阻擋能力的雲原生 CI/CD 流水線系列 第 19

Day 19:靜態分析的規則從哪來 —— semgrep 與一份會過期的規則快照

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260819/20183337vZGzQ2CyVJ.png
圖:semgrep-scan task的log
(D17的並行設計,設備記憶體不足時會讓CRC把自己餓死,後續會改成限制同時並行的task數量)

今日目的

Day 18 的 gitleaks 認得「像機密的字串」,看不懂程式邏輯。今天接第二道掃描:semgrep,抓 XSS、不安全的動態執行、路徑穿越這類邏輯層級的漏洞模式。

怎麼跑 semgrep 只佔一小段。這篇的重心在規則從哪來

  • --config=p/typescript 每次 CI 都去 semgrep.dev 抓,那是一條在建構時執行、內容由外部決定的輸入
  • 改成規則快照放在內部 Nexus,就得回答:誰能寫這個路徑、規則有多舊、舊了你查不查得到

這篇的走法:§2 先手動跑一次 semgrep 把行為摸清楚,§3–§7 把它搬進流水線,§8 是踩到的坑,§9–§11 講為什麼要這樣做。

下篇(Day 20)處理 npm-build 與 ESLint。

實測環境版本表

CRC 2.61.0+6eb443
OpenShift 4.21.14 / Kubernetes v1.34.6(單節點 crc)
Red Hat OpenShift Pipelines Operator 1.23.1
semgrep 1.172.0(image 內含 git 2.52.0 / Python 3.12.13)
Nexus Repository 3.93.0-06(COMMUNITY)
用戶端:Windows 11 Pro + PowerShell 5.1

1. semgrep 是什麼

用看起來像原始碼的語法寫規則,去比對原始碼的 AST。要找 eval(...) 就寫 eval(...),不必學另一套查詢語言。

規則長這樣:

rules:
  - id: probe-console-log
    patterns:
      - pattern: console.log(...)
    message: probe rule
    languages: [typescript, javascript]
    severity: WARNING

... 是萬用字元,比對任意引數。

這篇會用到的四個行為,都在 §2 有實際跑過的輸出:

行為 實測在
--error 不加的話,findings 照印、exit 0 2.4
離開碼 2 / 7 設定錯誤,代表防線根本沒跑 2.4
--severity 篩掉的是規則本身,不是輸出 2.5
--config 四種來源,只有 registry 代號會連外 2.7

遙測預設是 auto,規則從 semgrep registry 拉就會送。用內部快照時本來就不會觸發,但明確關掉比較保險:SEMGREP_SEND_METRICS=off

semgrep scan 是本地掃描(社群版)。另有一個 semgrep ci,那是搭配 Semgrep AppSec Platform 的,需要登入,本篇不用。


快樂路徑

物件清單:

Nexus
  repository/semgrep-rules       raw hosted,放規則快照
  role/semgrep-rules-writer      browse + read + add + edit
  user/semgrep-sync              給同步用(寫)
  role/semgrep-rules-reader      browse + read
  user/semgrep-reader            給 Task 用(讀)

ci
  secret/semgrep-reader-credentials
  Task/semgrep-scan
  Pipeline/d15-happy-path        加一個 task

nexus-proxy
  secret/semgrep-sync-credentials
  CronJob/semgrep-ruleset-mirror

2. 先跑一次 semgrep,再談流水線

§5 要把 semgrep 包成 Task。包之前先手動跑一次。這一節所有輸出都是在 semgrep/semgrep:latest(1.172.0)裡實際跑出來的。

2.1 共用前置

$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"
$NEXUS_SVC   = "nexus-nexus3.nexus-proxy.svc.cluster.local:8081"
$nexusUrl    = "https://$NEXUS_ROUTE"

Invoke-NexusApi 沿用 Day 15 §1.2。

2.2 一個檔案、一條規則

被掃的 app.tseval() 是故意留的:

export function run(userInput: string) {
  console.log("running", userInput);
  return eval(userInput);
}

規則 rule.yml。一條規則就是這五個欄位:

rules:
  - id: no-eval                       # 輸出裡顯示的名字,也是抑制註解要寫的字串
    patterns:
      - pattern: eval(...)            # 比對什麼:寫得跟原始碼一樣
    message: eval() 會執行任意字串      # 命中時印給人看的話
    languages: [typescript, javascript] # 決定這條規則會不會被套到某個檔案
    severity: ERROR                   # 決定 --severity 篩不篩掉它

pattern 寫的就是原始碼的樣子,... 是省略號運算子,比對任意引數。

languagesseverity 不是註解,它們直接決定這條規則會不會被執行。 2.5 節就是踩這裡。

semgrep scan --config=rule.yml --metrics=off app.ts

2.3 讀懂輸出

完整輸出長這樣,分三塊:

┌─────────────┐
│ Scan Status │
└─────────────┘
  Scanning 1 file tracked by git with 1 Code rule:
  Scanning 1 file.

┌────────────────┐
│ 1 Code Finding │
└────────────────┘

    app.ts
   ❯❯❱ no-eval
          ❰❰ Blocking ❱❱
          eval() 會執行任意字串

            3┆ return eval(userInput);

┌──────────────┐
│ Scan Summary │
└──────────────┘
✅ Scan completed successfully.
 • Findings: 1 (1 blocking)
 • Rules run: 1
 • Targets scanned: 1
 • Parsed lines: ~100.0%
 • No ignore information available
Ran 1 rule on 1 file: 1 finding.

三塊各自要看的東西:

區塊 要看的 為什麼
Scan Status with N Code rules 實際載入幾條規則。 這個數字被 languages--severity 兩道篩過,跟規則檔裡有幾條不是同一件事
Code Finding 規則 id、命中行號 要修的時候看這個;app.ts 下面那行 3┆ 是原始碼行號
Scan Summary Rules run / Targets scanned 收尾的自我回報。Ran 1 rule on 1 file 那一行是同樣的資訊再講一次

❰❰ Blocking ❱❱1 blocking 跟離開碼無關。 那是給 Semgrep 平台用的分類,本機 CLI 擋不擋看的是下一節。

2.4 離開碼才是關卡

CI 只讀離開碼。這四個實測過:

有 findings,加 --error       exit=1      ← 唯一會擋住 CI 的情況
有 findings,不加 --error     exit=0
掃描根目錄不存在              exit=2      [ERROR] Invalid scanning root: apps
規則檔 schema 壞掉            exit=7      semgrep error: Invalid rule schema

三件事:

一、--error 不是選配。 不加的話,上面那份「1 Code Finding」的輸出照印,然後 exit 0。log 看起來很兇,關卡沒有作用。

二、27 是設定錯誤,不是掃出問題。 2 是路徑不存在(§5 的 apps libs 就是這個),7 是規則檔本身壞掉(少一個 message 欄位就會)。

這兩個要跟 1 分開看:1 代表防線有作用,27 代表防線根本沒跑。

三、0 有兩種意思。 真的沒問題,或是根本沒檢查。下一節就是後者。

2.5 --severity 篩的是規則,不是輸出

rule.yml 那條規則寫的是 severity: ERROR。指定 --severity=WARNING 去跑它:

semgrep scan --config=rule.yml --severity=WARNING --metrics=off app.ts

  Scanning 1 file tracked by git:
  Nothing to scan.

exit=0

Nothing to scan. 然後 exit 0。

這不是「掃了但沒發現」,是一條規則都沒有留下來。--severity 在載入階段就把不符合的規則整批丟掉,它們的 findings 不會出現在輸出裡,也不會有任何警告說「你篩掉了全部」。

這是 §5 那道關卡最容易無聲失效的方式:

  • --severity 打錯字
  • 規則檔改版把 ERROR 降成 WARNING
  • 換一份只寫 INFO 的規則檔

以上任何一種,結果都是 Task 還在、Pipeline 還是綠的、一條規則都沒跑。 所以 §6 要盯 Ran N rules 那個數字,不能只看有沒有失敗。

2.6 pattern 可以寫到多細

... 除了當引數萬用字元,還能跨陳述式。$X 是 metavariable,比對任意運算式並記住它:

rules:
  - id: log-then-eval
    patterns:
      - pattern: |
          console.log(...)
          ...
          eval($X)
    message: 同一個區塊裡先 log 再 eval
    languages: [typescript, javascript]
    severity: ERROR

命中的是兩行,中間隔幾行都算:

   ❯❯❱ log-then-eval
            2┆ console.log("running", userInput);
            3┆ return eval(userInput);

這是 semgrep 跟 grep 的分界:它比對的是 AST,換行、空白、引號種類、中間插幾行都不影響。

但它仍然是語法比對,不是資料流分析。 它不知道 userInput 從哪來,只知道長相。所以它認得「這裡有 eval」,認不得「這個 eval 吃到的是外部輸入」。

2.7 --config 的四種來源

--config=./rule.yml              本地單檔
--config=./rules/               本地目錄,底下的 yml 全載入
--config=https://host/x.yml     URL
--config=p/typescript           registry 代號,會被轉成 semgrep.dev 的 URL

前三種都是你控制得了的輸入,第四種不是。每一次 CI 都會去 semgrep.dev 抓一次。

這篇接下來做的事,就是把第四種換成第三種,而且那個 host 是自己的 Nexus。§3 先把存放處建起來。

3. 建規則的存放處

3.1 raw hosted repository

$rawBody = [ordered]@{
    name    = "semgrep-rules"
    online  = $true
    storage = [ordered]@{
        blobStoreName               = "default"
        strictContentTypeValidation = $false
        writePolicy                 = "ALLOW"
    }
    raw = [ordered]@{ contentDisposition = "ATTACHMENT" }
} | ConvertTo-Json -Depth 10 -Compress

"depth truncation = $($rawBody -match 'OrderedDictionary|Hashtable')"
Invoke-NexusApi -Url "$nexusUrl/service/rest/v1/repositories/raw/hosted" `
    -Method POST -JsonBody $rawBody | Out-Null
"exit=$lastExit"

storage.writePolicy 是 hosted repository 的必填欄位,漏了會回 400:

{"id":"storage","message":"Cannot construct instance of HostedStorageAttributes, problem: writePolicy is required"}

這個錯誤會連鎖。

repo 沒建成 → privileges 不存在 → 建 role 回 400(Privilege '…-browse' … not found)→ 建 user 回 400(Unable to locate roleId)。

三個 400 只有一個根因。 看到連續三個失敗時,先回頭確認第一個。

建 repository 成功回 201,但 body 是空的(Day 15 §4.2),所以看 $lastExit 不看輸出。

用獨立的 repo,不要跟 SBOM 共用。兩者的存取模式相反:SBOM 只增不減,規則是每天覆蓋同一個路徑。分開之後保留政策與寫入權限才能各自設定。

3.2 兩個帳號:寫的跟讀的分開

先講為什麼。

改用內部快照的實際效果,是把「信任 semgrep.dev」換成「信任 Nexus 上那個路徑的寫入權限」。信任對象被換掉,沒有被消掉。

如果 Task 拿的是寫入帳號,流水線就握有覆寫自己檢查規則的權限。攻擊面只是從外部搬到內部,而且內部這個更容易被忽略,因為它看起來像自己的東西。

讀寫分開是走「內部快照」這條路唯一真正收斂攻擊面的地方。 怎麼證明它真的生效,在 §9。

建 repo 時 Nexus 自動產生 privileges:

nx-repository-view-raw-semgrep-rules-{*,add,browse,delete,edit,read}

同步用的(寫):

$wRole = [ordered]@{
    id="semgrep-rules-writer"; name="semgrep-rules-writer"
    description="write semgrep ruleset snapshot only"
    privileges=@(
        "nx-repository-view-raw-semgrep-rules-browse",
        "nx-repository-view-raw-semgrep-rules-read",
        "nx-repository-view-raw-semgrep-rules-add",
        "nx-repository-view-raw-semgrep-rules-edit"
    )
    roles=@()
} | ConvertTo-Json -Depth 10 -Compress
Invoke-NexusApi -Url "$nexusUrl/service/rest/v1/security/roles" -Method POST -JsonBody $wRole | Out-Null

$syncPass = [guid]::NewGuid().ToString()
$wUser = [ordered]@{
    userId="semgrep-sync"; firstName="Semgrep"; lastName="Sync"
    emailAddress="semgrep-sync@example.local"
    password=$syncPass; status="active"; roles=@("semgrep-rules-writer")
} | ConvertTo-Json -Depth 10 -Compress
Invoke-NexusApi -Url "$nexusUrl/service/rest/v1/security/users" -Method POST -JsonBody $wUser | Out-Null

& $OC --kubeconfig $kc create secret generic semgrep-sync-credentials -n nexus-proxy `
    --from-literal=username=semgrep-sync --from-literal=password="$syncPass"

Task 用的(讀):

$rRole = [ordered]@{
    id="semgrep-rules-reader"; name="semgrep-rules-reader"
    description="read semgrep ruleset snapshot only"
    privileges=@(
        "nx-repository-view-raw-semgrep-rules-browse",
        "nx-repository-view-raw-semgrep-rules-read"
    )
    roles=@()
} | ConvertTo-Json -Depth 10 -Compress
Invoke-NexusApi -Url "$nexusUrl/service/rest/v1/security/roles" -Method POST -JsonBody $rRole | Out-Null

$readPass = [guid]::NewGuid().ToString()
$rUser = [ordered]@{
    userId="semgrep-reader"; firstName="Semgrep"; lastName="Reader"
    emailAddress="semgrep-reader@example.local"
    password=$readPass; status="active"; roles=@("semgrep-rules-reader")
} | ConvertTo-Json -Depth 10 -Compress
Invoke-NexusApi -Url "$nexusUrl/service/rest/v1/security/users" -Method POST -JsonBody $rUser | Out-Null

# Pod 只能掛自己 namespace 的 Secret,所以建在 ci
& $OC --kubeconfig $kc create secret generic semgrep-reader-credentials -n ci `
    --from-literal=username=semgrep-reader --from-literal=password="$readPass"

兩個 role 都不給 delete。覆蓋既有檔案理論上 edit 就夠。

這一句是推論,本篇沒有驗證。

灌進去的 typescript.yml 到目前為止只被寫過一次(Nexus 的 blobCreatedblobUpdated 相同),第二次 PUT 從來沒有跑過。

如果 Nexus 對 raw hosted 的覆寫其實需要 delete,那 CronJob 從第二天起就會每天 403。而在修掉 §7.2 那個缺 -f 的問題之前,這件事會以每天一個 Completed 的 Job 呈現。

要驗就是同一路徑連 PUT 兩次、比對兩次的狀態碼。

4. 灌入第一份規則快照

4.1 正確的 URL 不是 /p/typescript

https://semgrep.dev/p/typescript        content-type: text/html     21,358 bytes
https://semgrep.dev/c/p/typescript      content-type: text/yaml    214,313 bytes

/c/c 是 config。加 Accept: application/json/p/... 沒有用,仍然回 HTML。

第一個 URL 回 200、內容非空。curl -sftest -s 兩道檢查全部通過,然後存下一個網頁。§10 談這件事。

存進 Nexus 再讀出來,content-type 會變。

semgrep.dev/c/p/typescript          text/yaml
Nexus/repository/semgrep-rules/…    text/x-yaml

同一份位元組完全相同的檔案(214,313 bytes),型別標頭卻不一樣,因為 Nexus 依副檔名自己判定。

所以 §5 的檢查寫的是 case "$CT" in *yaml*) 這種萬用比對。寫成 = "text/yaml" 的等值比較,Task 每一次都會誤擋。

4.2 取檔並上傳

從主機下載(也可以從 Pod 內抓,這台兩條都通):

$rulesPath = "$env:TEMP\typescript.yml"
curl.exe -sSf -D "$env:TEMP\hdr.txt" -o $rulesPath "https://semgrep.dev/c/p/typescript"

# 三道檢查,缺一不可
$ct = (Get-Content "$env:TEMP\hdr.txt" | Select-String -Pattern '^content-type:' | Select-Object -First 1) -replace '.*:\s*',''
$sz = (Get-Item $rulesPath).Length
"content-type = $ct"
"size = $sz"
if ($ct -notmatch 'yaml') { throw "型別不對,中止" }
if ($sz -lt 100000)       { throw "大小不足,中止" }
if ((Get-Content $rulesPath -First 1) -notmatch '^rules:') { throw "內容不是規則檔,中止" }

上傳,憑證走 -K 設定檔不進命令列:

$u = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(
     (& $OC --kubeconfig $kc get secret semgrep-sync-credentials -n nexus-proxy -o jsonpath='{.data.username}')))
$p = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(
     (& $OC --kubeconfig $kc get secret semgrep-sync-credentials -n nexus-proxy -o jsonpath='{.data.password}')))

$cfg = Join-Path $env:TEMP ("nx-{0}.cfg" -f [guid]::NewGuid())
try {
    [IO.File]::WriteAllText($cfg, "user = `"$u`:$p`"", (New-Object Text.UTF8Encoding $false))
    curl.exe -K $cfg -k -sS -o NUL -w "PUT=%{http_code}`n" `
        --upload-file $rulesPath "$nexusUrl/repository/semgrep-rules/typescript.yml"
} finally {
    if (Test-Path $cfg) { Remove-Item $cfg -Force }
}

通過條件:

curl.exe -k -sS -I -u "$u`:$p" "$nexusUrl/repository/semgrep-rules/typescript.yml" |
    Select-String -Pattern "HTTP/|Content-Type|Content-Length|Last-Modified"
HTTP/1.1 200 OK
Content-Type: text/x-yaml
Content-Length: 214313
Last-Modified: Sat, 15 Aug 2026 02:34:20 GMT

Last-Modified 記下來,§7 整節靠它。

這條驗證指令用了 -u,密碼會進 curl.exe 的命令列。

跟 §5「憑證走 -K 不進引數」不是同一個威脅模型:那裡是容器裡長期執行的第三方掃描器,這裡是自己工作站上跑一次的驗證。

但這是不對稱、不是豁免。 共用主機或會被側錄的環境裡,這一行同樣要改成 -K

5. Task 定義:兩個 step,憑證只給第一個

apiVersion: tekton.dev/v1
kind: Task
metadata:
  name: semgrep-scan
  namespace: ci
spec:
  params:
    - name: RULES_URL
      type: string
      default: http://nexus-nexus3.nexus-proxy.svc.cluster.local:8081/repository/semgrep-rules/typescript.yml
    - name: MAX_AGE_DAYS
      type: string
      default: "7"
  workspaces:
    - name: source
  volumes:
    - name: rules
      emptyDir: {}
  stepTemplate:
    volumeMounts:
      - name: rules
        mountPath: /rules
  steps:
    - name: fetch-rules
      image: nexus-nexus-proxy.apps-crc.testing/docker-proxy/semgrep/semgrep:latest
      env:
        - name: NEXUS_USER
          valueFrom:
            secretKeyRef: { name: semgrep-reader-credentials, key: username }
        - name: NEXUS_PASS
          valueFrom:
            secretKeyRef: { name: semgrep-reader-credentials, key: password }
      script: |
        #!/bin/sh
        set -e
        CFG=$(mktemp)
        printf 'user = "%s:%s"\n' "$NEXUS_USER" "$NEXUS_PASS" > "$CFG"
        trap 'rm -f "$CFG"' EXIT

        curl -K "$CFG" -sS -D /tmp/hdr.txt -o /rules/typescript.yml "$(params.RULES_URL)"

        CT=$(grep -i '^content-type:' /tmp/hdr.txt | tr -d '\r' | cut -d' ' -f2-)
        SZ=$(wc -c < /rules/typescript.yml)
        LM=$(grep -i '^last-modified:' /tmp/hdr.txt | tr -d '\r' | cut -d' ' -f2-)

        echo "content-type = $CT"
        echo "size         = $SZ"
        echo "last-modified= $LM"

        case "$CT" in
          *yaml*) : ;;
          *) echo "型別不對,中止"; exit 1 ;;
        esac
        [ "$SZ" -gt 100000 ] || { echo "大小不足,中止"; exit 1; }
        head -1 /rules/typescript.yml | grep -q '^rules:' || { echo "內容不是規則檔,中止"; exit 1; }

        # 規則有多舊:只警告,不擋
        # 不能用 date -d,理由見 8.3
        AGE=$(python3 -c "import email.utils,time,sys;print(int((time.time()-email.utils.parsedate_to_datetime(sys.argv[1]).timestamp())//86400))" "$LM")
        echo "規則快照年齡 = ${AGE} 天"
        if [ "$AGE" -gt "$(params.MAX_AGE_DAYS)" ]; then
          echo "WARNING: 規則快照超過 $(params.MAX_AGE_DAYS) 天,同步可能已中斷"
        fi

    - name: scan
      image: nexus-nexus-proxy.apps-crc.testing/docker-proxy/semgrep/semgrep:latest
      workingDir: $(workspaces.source.path)
      env:
        - name: SEMGREP_SEND_METRICS
          value: "off"
      script: |
        #!/bin/sh
        semgrep scan \
          --config=/rules/typescript.yml \
          --error \
          --severity=ERROR \
          --exclude=node_modules \
          --exclude=dist \
          --metrics=off \
          apps

掃描根目錄要真的存在。

這個 repo 只有 apps,沒有 libs。寫成 apps libs 的話 semgrep 回 exit 2:

[ERROR] Invalid scanning root: libs

它不會忽略不存在的路徑。Nx workspace 之後長出 libs 再加,或直接用 . 搭配上面的 --exclude

為什麼拆兩個 step

Tekton 的 env 是 per-step 的。 憑證只注入 fetch-rulesscan 那一步從頭到尾看不到 Nexus 密碼。一個 step 做不到這件事。

規則放 emptyDir 掛在 /rules,跟 workspace 分開,避免污染被掃的原始碼樹。

幾個旗標的理由

旗標 理由
--error 有 findings 時 exit 1。不加的話 semgrep 印完照樣 exit 0
--severity=ERROR 規則層級的過濾,不是輸出層級。 WARNING 級的規則整批不執行,它們的 findings 不會出現在輸出裡。換掉誤判噪音的代價是那些發現連看都看不到
--exclude=node_modules --exclude=dist 第三方套件與建構產物,掃了只會產生自己修不動的雜訊
--metrics=off 環境變數已經設了,旗標是明示。兩個都留

刻意沒寫 --no-git-ignore。 舊做法有帶這個旗標。在這個 DAG 位置(git-clone 之後、npm-build 之前)它是空轉的:node_modules 還不存在,而工作區裡每個檔案都被 git 追蹤。

實測加不加的檔案數相同,只有措辭不同:

加       Scanning 21 files
不加     Scanning 21 files tracked by git

Scanning 21 files 跟後面的 Ran 24 rules on 12 files 是兩個不同的計數器,同一次執行會同時印出來。

21 是掃描目標下的檔案總數,12 是其中被規則語言涵蓋、真的送去比對的檔案數。看到兩個數字不一樣不是哪裡出錯了。

憑證用 -K 設定檔而不是塞進 URL。 semgrep 的 --config 其實接受 http://user:pass@host/... 這種形式(實測可行),但那會讓密碼出現在 semgrep 的 process 引數裡。多兩行換掉這個代價。

但這條防線只擋 process 表,不擋線路。

RULES_URL 的預設值是 http://nexus-nexus3...:8081,純 HTTP,沒有 TLS。semgrep-reader 的帳密以 Base64(不是加密)走在叢集網路上,任何能側錄 Pod 網路的東西都讀得到。

單節點 crc 上這是可接受的簡化,正式環境要嘛走 Route 的 TLS、要嘛上 service mesh 的 mTLS。

「憑證不進引數」跟「憑證不上明文線路」是兩件事,這篇只做了前者。

6. 加進 Pipeline,直接接 git-clone

semgrep 不需要 node_modules 它做的是靜態語法比對,不解析依賴樹。實測在一個沒有 node_modules 的工作區上跑,exit 0、掃描正常。

所以它跟 gitleaks-scan 一樣直接接在 git-clone 之後,兩者並行:

[{"op":"add","path":"/spec/tasks/-","value":{
  "name":"semgrep-scan",
  "runAfter":["git-clone"],
  "taskRef":{"name":"semgrep-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 內容寫錯。

確認:

& $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"]
semgrep-scan <- ["git-clone"]

實際跑起來長這樣

從 Gitea push 一個 commit 觸發,semgrep-scan 的兩個 step:

fetch-rules
  content-type  = text/x-yaml
  size          = 214313
  last-modified = Sat, 15 Aug 2026 02:34:20 GMT
  規則快照年齡 = 0 天

scan
  Scan was limited to files tracked by git
  Ran 24 rules on 12 files: 0 findings.

整個 Task 16 秒,與 gitleaks-scan 同時起跑。

Ran 24 rules 這個數字值得看一眼

「24 從哪裡少下來的」不能用猜的。semgrep 會先按語言篩一次規則,再按 --severity 篩一次,兩道都會讓數字變小。

要歸因就得有對照組:同一份規則檔、同一個工作區,只拿掉 --severity 再跑一次。

規則檔內含規則數                    74
不加 --severity      Rules run:    74      Ran 74 rules on 12 files
加 --severity=ERROR  Rules run:    24      Ran 24 rules on 12 files

語言篩選在這個 repo 上一條都沒砍(74 → 74),所以 74 → 24 這個縮減完全來自 --severity=ERROR

這是這個工作區的結果,不是通則。換一個含 Python 或 Go 的 repo,語言那一道就會開始吃掉數字,屆時要重跑這個對照。

順帶修正一個直覺錯誤:214 KB 的規則檔裡只有 74 條規則,不是兩百多條。檔案大是因為每條規則都帶了長 message 與 metadata,檔案大小跟規則條數沒有關係。

「我灌了一份 214 KB 的規則檔」「檔案裡有 74 條規則」「我這次跑了 24 條」是三件事。只有最後一件看得出這道關卡實際的涵蓋範圍,而且只能從那一行輸出讀,--config 指向的檔案大小完全看不出來。

這推翻了 Day 17 §2 的 DAG 圖。

那張圖把 semgrep-scan 畫在 npm-build 底下,理由是「需要依賴樹與產物」。那對 ESLint 成立,對 semgrep 不成立。

而且那個做法自相矛盾:Task 明明帶了 --exclude=node_modules,卻又等它產生完才跑。

7. 規則同步:CronJob 是一個概念的簡化代表

7.1 它代表什麼、不代表什麼

嚴謹的做法是在 DMZ 區統一處理各類快取的出站同步,讓「誰可以連外」有清楚的邊界治理。那會顯著增加架構複雜度,不符合這套 PoC 的規模。

這個 CronJob 是用排程表達「定期出站同步」這個概念,本質上等同資安人員定期手動同步,只是把觸發改成自動。

它不是 DMZ 分區架構。 這個邊界要講清楚,不然讀者會以為離線閉環已經完成了。

7.2 CronJob

apiVersion: batch/v1
kind: CronJob
metadata:
  name: semgrep-ruleset-mirror
  namespace: nexus-proxy
spec:
  schedule: "0 4 * * *"
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          imagePullSecrets:
            - name: nexus-route-credentials
          containers:
            - name: mirror
              # 用已經內含 curl 的 image。原本是 alpine:3.20 + apk add curl,
              # 那是在執行期從 Alpine CDN 抓一個沒釘版本的套件——正好是本篇
              # 開頭在批評的那種「建構時執行、內容由外部決定的輸入」。
              # semgrep image 本身就帶 curl(第 5 節的 fetch-rules 已經在用),
              # 代價是 1 GB 的 image 拿來跑一個 curl;換成任何內含 curl 的
              # 小 image 都可以,重點是不要在執行期 apk add。
              image: nexus-nexus-proxy.apps-crc.testing/docker-proxy/semgrep/semgrep:latest
              env:
                - name: NEXUS_USER
                  valueFrom:
                    secretKeyRef: { name: semgrep-sync-credentials, key: username }
                - name: NEXUS_PASS
                  valueFrom:
                    secretKeyRef: { name: semgrep-sync-credentials, key: password }
              command: ["/bin/sh", "-c"]
              args:
                - |
                  set -e
                  curl -sSf -D /tmp/hdr.txt -o /tmp/rules.yml \
                    "https://semgrep.dev/c/p/typescript"

                  CT=$(grep -i '^content-type:' /tmp/hdr.txt | tr -d '\r' | cut -d' ' -f2-)
                  SZ=$(wc -c < /tmp/rules.yml)
                  echo "抓取:content-type=$CT size=$SZ"

                  case "$CT" in *yaml*) : ;; *) echo "型別不對,不覆蓋"; exit 1 ;; esac
                  [ "$SZ" -gt 100000 ] || { echo "大小不足,不覆蓋"; exit 1; }
                  head -1 /tmp/rules.yml | grep -q '^rules:' || { echo "內容不對,不覆蓋"; exit 1; }

                  CFG=$(mktemp)
                  printf 'user = "%s:%s"\n' "$NEXUS_USER" "$NEXUS_PASS" > "$CFG"
                  trap 'rm -f "$CFG"' EXIT

                  # -f 不能省:沒有它,403/401 一樣是 curl exit 0
                  CODE=$(curl -K "$CFG" -sSf -o /dev/null -w "%{http_code}" \
                    --upload-file /tmp/rules.yml \
                    "http://nexus-nexus3.nexus-proxy.svc.cluster.local:8081/repository/semgrep-rules/typescript.yml")
                  echo "PUT=$CODE"
                  case "$CODE" in
                    201|204) : ;;
                    *) echo "上傳未被接受,視為同步失敗"; exit 1 ;;
                  esac

-f 是這段唯一擋得住靜默失敗的東西。

原本的寫法是 curl -sS -o /dev/null -w "PUT=%{http_code}\n"。它把狀態碼印進 log,然後沒有人比對它。

實測(拿只有 browse+readsemgrep-reader 帳號對這個 repo PUT):

不加 -f    PUT=403    curl exit code = 0
加   -f    PUT=403    curl exit code = 22

set -e 看的是離開碼。不加 -f 的那版,權限被撤、密碼過期、role 被改壞,Job 全部顯示 Completed403 只是 log 裡一行沒人看的字。

這比 §11 原本說的「不會標紅」更糟:不是要主動翻才看得到,是翻了也還是綠的

三道檢查在覆蓋之前跑,任何一道不過就不覆蓋。 抓到壞東西時,Nexus 上留的是舊規則。舊規則比壞規則好。

同一段檢查放在 CronJob 與 Task,後果不同,這是刻意的。

CronJob 裡不過 = 不覆蓋(保住舊規則);Task 裡不過 = 整條流水線停。看起來跟 §11「規則過期只警告不擋」相反,其實是兩種狀態:

  • 規則舊但可用 → 還擋得住舊的漏洞模式 → 警告,不擋
  • 規則不可用(不是 YAML、大小不對、首行不是 rules:)→ 沒有規則可掃 → 擋

而且 Task 這三道會觸發,本身就是一個訊號:正常路徑上,壞檔案在 CronJob 那關就被擋掉了,根本傳不到 Nexus。Task 這裡擋下來,代表有人繞過 CronJob 直接寫了那個路徑,那正是 §3.2 講的威脅模型,硬擋才對。

nexus-proxy 這個 namespace 需要自己的 pull secret。

Day 18 §3 建的 nexus-route-credentialsci,而 Pod 只掛得到自己 namespace 的 Secret。這個 namespace 原本只有 Operator 自動產生的 default-dockercfg-*(那是給內部 registry 的),拉不動 Nexus 上的 image。

& $OC --kubeconfig $kc create secret docker-registry nexus-route-credentials -n nexus-proxy `
    --docker-server=$NEXUS_ROUTE --docker-username=$u --docker-password="$p"

上面的 podSpec 直接寫了 imagePullSecrets,所以不必 secrets link,CronJob 產生的 Pod 用的是 podSpec 裡的那份。

這跟 Day 18 §3 的情況不同:那裡是 TaskRun,Pod 由 Tekton 產生、走 ServiceAccount,所以非 link 不可。

7.3 這個 CronJob 用寫入帳號,Task 用讀取帳號

CronJob(同步)   semgrep-sync     browse / read / add / edit
Task(掃描)      semgrep-reader   browse / read

為什麼要分在 §3.2 講過了。怎麼證明它生效,在 §9。


踩到的坑

8. 三個實作上的坑

這三個都是「在 Windows 上想當然的寫法,到那個容器或那個 API 版本裡不成立」。趕時間可以跳過,撞到再回來查。

8.1 Tekton v1 把 step 的 resources 改名了

admission webhook "webhook.pipeline.tekton.dev" denied the request:
  mutation failed: cannot decode incoming new object: json: unknown field "resources"

tekton.dev/v1 不是拿掉這個能力,是把欄位改名computeResources。三個層級都寫得了:

step.computeResources                    單一 step
stepTemplate.computeResources            Task 內所有 step 的預設
TaskRun 的 stepSpecs[].computeResources  單次執行覆寫(v1beta1 叫 stepOverrides)

所以修法是把 resources: 改成 computeResources:,不必搬到別的層級。Day 18 的 Task 沒設資源所以沒撞到。

8.2 第一次拉 image 可能 504

Failed to pull image "…/docker-proxy/semgrep/semgrep:latest":
  reading blob sha256:7fc22c96d532…: fetching blob:
  received unexpected HTTP status: 504 Gateway Time-out

semgrep image 有 13 層、400 MB 壓縮,最大的一層 192.9 MB,失敗的那個 blob 159.5 MB。

Day 15 把「Route 對大型 blob 的行為」列為未驗證,註明「目前推過最大的是 23.7 MB」。這次撞到了。

504 發生在 Nexus 還在向 Docker Hub 抓的期間,客戶端等不到就斷。重拉會成功。

但「重拉成功」的因果沒有證成。 那時節點的 CRI-O 也已經有本地快取,Pulled in 72ms 是本地速度不是走 Route 的速度。要做乾淨的對照得先從節點移除 image,而 crictl rmi 被已停止的容器擋住。

官方的解法方向是 Route 的 haproxy.router.openshift.io/timeout annotation。本篇沒有調它,那會改到既有的 Route 物件。

8.3 semgrep image 是 Alpine,date -d 吃不了 Last-Modified

§5 要算規則快照的年齡,直覺寫法是:

AGE=$(( ( $(date +%s) - $(date -d "$LM" +%s) ) / 86400 ))

在這個 image 上會失敗:

NAME="Alpine Linux"
date 是誰: /bin/busybox

date -d "Sat, 15 Aug 2026 02:34:20 GMT" +%s
  → date: invalid date 'Sat, 15 Aug 2026 02:34:20 GMT'

busybox 的 date -d 不接受 RFC-1123,而 Last-Modified 標頭就是那個格式。

失敗的方式很難看:$(date -d …) 回空字串,算術式變成 $(( ( 1755… - ) / 86400 )),語法錯誤。配上 set -e,一個「只想印個警告」的步驟會讓整個 Task 硬失敗。

image 裡有 Python 3.12.13,用它解:

AGE=$(python3 -c "import email.utils,time,sys;print(int((time.time()-email.utils.parsedate_to_datetime(sys.argv[1]).timestamp())//86400))" "$LM")

寫 Task 的 script 之前先確認 image 的 base 與 shell。 這一族的坑在這系列已經出現三次:Day 18 的 gitleaks image 是 busybox ash(${PIPESTATUS[0]} 不支援)、本篇的 date -d,以及 §5 那個 apps libs


技術說明

9. 最小權限要怎麼證明

列出 privileges 清單只是宣告。要證明它生效,得試著越權,而且要有對照組

同一份檔案、同一種路徑形態,兩個帳號各 PUT 一次:

repo semgrep-sync admin
docker-hosted 400 400
maven-releases 403 400
nuget-hosted 400 400
semgrep-rules 201 201

重點在 maven-releases 那一列。

  • 400 是格式拒絕。 那些 repo 不接受對任意路徑 PUT 一個純檔案,用 admin 也一樣被拒。
  • 403 是授權拒絕。 semgrep-sync 在格式檢查之前就被擋下;admin 過了授權才撞到格式,所以是 400。

只跑 semgrep-sync 的話,三個 400 看起來像權限擋的,實際上不是。那個實驗會給出正確的結論加上錯誤的理由,下次 repo 換成接受任意 PUT 的型別,結論就翻了。

兩個帳號在同一個 repo 上給出不同狀態碼,這才是直接證據。

10. 三道檢查,每一道都是上一次的補丁

/p/typescript 回 200、內容非空、是一份 HTML 網頁。

這是同一族問題在這系列的第三次出現,而每次的識別依據都往下退一層

現象 當時的對策
Day 16 打錯端點,回 404 的空 body,被讀成「沒有設定」 先確認打對了地方
Day 18 /api/v1 路徑寫錯,回 404 加一則 JSON,被當成檔案內容 看狀態碼或大小
Day 19 /p/... 回 200、21 KB 的 HTML 狀態碼是 200、大小非零,兩道都過了

Day 18 的教訓是「狀態碼和大小至少要看一個」。這次兩個都看了,還是中。

這次的識別依據是 content-type

/p/typescript      text/html    21,358
/c/p/typescript    text/yaml   214,313

但這個依據本身也有一層。 同一份位元組完全相同的檔案,經過 Nexus 之後型別標頭就變了:

semgrep.dev 直接回      text/yaml
Nexus 存完再讀回        text/x-yaml

所以檢查要寫成 *yaml* 的萬用比對,不能寫 = "text/yaml"。判斷準則對了,比對方式寫死一樣會壞。「看 content-type」這個對策本身還需要一道對策。

即使如此,本篇的 Task 與 CronJob 還是做了三道(型別、大小、首行 rules:)。

三道不保證夠。做三道是因為前兩次的經驗顯示:每次以為補好了,下一次就從新的縫鑽進來。 檢查的成本很低,而缺一道的代價是一份靜默失效的規則。

11. 規則過期是靜默的

同步失敗不會讓 CI 停下來。semgrep 照樣拿 Nexus 上現有的(可能是舊的)規則跑完,正常回報通過。

這是「防線在但沒作用」的又一個實例,跟 Day 16 的 Nexus cleanup(六個任務全綠、回收量為零)、Day 18 的 gitleaks:allow(靜音某筆發現、exit code 照樣正常)同族。

而 CronJob 的失敗本身也難以察覺,這裡有四層,一層比一層深:

  1. oc get cronjob 不會標紅
  2. 要主動翻 Job 清單才看得到
  3. 失敗紀錄只保留 failedJobsHistoryLimit 筆(這裡是 3),證據本身也在倒數計時
  4. 最深的一層:翻了 Job 清單也可能還是綠的。 上傳那一步若沒帶 -f,Nexus 回 403 時 curl 仍然 exit 0(§7.2 有實測),set -e 不觸發、Job 顯示 Completed

第四層會讓前三層一起失效,因為前三層都建立在「失敗會被記成失敗」這個前提上。寫檢查清單之前,要先確認失敗真的會表現成失敗。

所以 §5 的 Task 在 fetch-rules 那一步印出規則快照的年齡,超過門檻在 log 標警告:

規則快照年齡 = 3 天

只警告,不擋。

擋下來會讓「同步中斷」升級成「全線停擺」,那比舊規則更糟。舊規則至少還擋得住舊的漏洞模式。

這個取捨要明講,因為它跟這系列「硬性阻擋」的立場看起來相反。


收工前檢查清單

# 1. 規則檔在,而且是 YAML
curl.exe -k -sS -I -u "$u`:$p" "$nexusUrl/repository/semgrep-rules/typescript.yml" |
    Select-String -Pattern "HTTP/|Content-Type|Content-Length|Last-Modified"
# 期望 200 / text/x-yaml / 二十萬 bytes 級

# 2. Task 用的是讀取帳號,不是同步帳號
& $OC --kubeconfig $kc get task semgrep-scan -n ci -o json | ConvertFrom-Json |
    ForEach-Object { $_.spec.steps } |
    Select-Object name, @{n='secret';e={$_.env.valueFrom.secretKeyRef.name}}
# 期望:fetch-rules 掛 semgrep-reader-credentials;scan 沒有 env secret

# 3. 讀取帳號真的只有讀
Invoke-NexusApi -Url "$nexusUrl/service/rest/v1/security/roles/semgrep-rules-reader" |
    ConvertFrom-Json | Select-Object -ExpandProperty privileges
# 期望:只有 browse 與 read

# 4. --error 有帶(沒帶的話 findings 照樣 exit 0)
& $OC --kubeconfig $kc get task semgrep-scan -n ci -o jsonpath='{.spec.steps[1].script}' |
    Select-String -Pattern '\-\-error'

# 5. DAG 位置
& $OC --kubeconfig $kc get pipeline.v1.tekton.dev d15-happy-path -n ci `
    -o jsonpath="{.spec.tasks[?(@.name=='semgrep-scan')].runAfter}"
# 期望 ["git-clone"]

# 6. CronJob 上次跑的結果(不會自己告警,要主動看)
& $OC --kubeconfig $kc get cronjob semgrep-ruleset-mirror -n nexus-proxy
& $OC --kubeconfig $kc get job -n nexus-proxy -l job-name --sort-by=.metadata.creationTimestamp |
    Select-Object -Last 5

第 6 項要排進定期檢查,而且不能只看 Job 的狀態欄。

同步斷了沒有任何地方會亮紅燈,失敗紀錄只留 3 筆。更麻煩的是,上傳被拒(403)時 Job 仍然是 Completed(§7.2),所以真正該比對的是規則檔的 Last-Modified 有沒有往前走:

# 6b. 規則檔昨天有沒有被更新過——這是唯一不依賴 Job 狀態的證據
curl.exe -K $cfg -k -sS -I "$nexusUrl/repository/semgrep-rules/typescript.yml" |
    Select-String -Pattern "Last-Modified"

收尾

三件值得帶走的。

一、改成內部快照,是把信任對象換掉,不是消掉。

從「信任 semgrep.dev」變成「信任 Nexus 上那個路徑的寫入權限」。所以同步用寫入帳號、Task 用讀取帳號。不分開的話,流水線就握有覆寫自己檢查規則的權限。

而要證明分權生效,得試著越權並且有對照組:semgrep-sync 拿 403、admin 拿 400,那個差異才是證據。

二、檢查每次都要多加一道。

Day 16 是端點打錯、Day 18 是狀態碼沒看、這次是 200 加非空加 HTML。三次的識別依據一路往下退到 content-type

三道檢查不保證夠。做三道只是因為前兩次的經驗顯示:以為補好了的地方,下次會從新的縫鑽進來。

三、規則過期是靜默的,而且這篇選擇不擋。

同步失敗時 semgrep 拿舊規則跑完並回報通過。Task 會印出快照年齡、超過門檻警告,但不讓 Task 失敗。擋下來會讓同步中斷升級成全線停擺,舊規則至少還擋得住舊的漏洞模式。

這個取捨跟「硬性阻擋」的立場看起來相反,所以要寫明。

下篇處理 npm-build 與 ESLint:有了 node_modules 之後,才輪得到用自己的規則檢查自己的碼。


參考資料

semgrep

文件 用到的結論
Semgrep CLI reference semgrep scan 的旗標;--metrics 的 auto/on/off 與 SEMGREP_SEND_METRICS 環境變數的關係;semgrep ci 是搭配平台的另一條路
Semgrep CLI reference — --config --config 接受本地檔、目錄、URL、registry 代號;registry 代號會被轉成 semgrep.dev 的 URL
Semgrep PRIVACY.md 遙測預設 auto(規則從 registry 拉才送);原始碼與私有規則不外傳
semgrep/semgrep(Docker Hub) 本篇使用的 image 來源

Kubernetes / Tekton

文件 用到的結論
Tekton Tasks stepsenv 是 per-step;stepTemplatevolumes 的用法
CronJob successfulJobsHistoryLimit / failedJobsHistoryLimit 的語意——失敗紀錄會被覆蓋

Nexus

文件 用到的結論
Repositories API /v1/repositories/raw/hosted 的 body;storage.writePolicy 為必填
Security Management API roles / users 端點

上一篇
Day 18:第一道掃描 —— gitleaks 掃的是 git 歷史
下一篇
Day 20:npm-build 與 ESLint
系列文
防範軟體供應鏈攻擊:從零打造具備硬性阻擋能力的雲原生 CI/CD 流水線21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言