Bagian 9: Proses Exception untuk Temuan Security yang Belum Bisa Diperbaiki

· 19 menit baca

English
//TUTORIAL · 9 DARI 10 — Secure CI/CD Pipeline dengan Go, GitHub Actions, Vault, dan Dokploy

Sampai Bagian 8, pipeline kita sudah galak. Ada secret, CVE, misconfig Dockerfile, atau kode berbahaya, PR-nya merah dan nggak bisa di-merge. Deploy juga berhenti kalau image punya CVE CRITICAL yang sudah ada fix-nya.

Masalahnya, dunia nyata nggak serapi itu. Kadang ada CVE di base image yang belum ada patch-nya, padahal fitur harus rilis. Kadang scanner salah paham: string tes tetap di kode dianggap API key. Kalau nggak ada jalur resmi untuk kasus begini, orang akan cari jalan pintas: mematikan scanner, menurunkan severity, atau menambah ignore diam-diam. Begitu itu terjadi, gate tinggal formalitas.

Di bagian ini saya bikin proses exception: cara resmi untuk menerima satu temuan, sementara, dengan nama orang yang bertanggung jawab dan tanggal kapan izinnya habis. Ini juga pertanyaan klasik auditor, apalagi di bank: "Temuan yang nggak diperbaiki ini, siapa yang menyetujui, dan sampai kapan?" Setelah bagian ini, jawabannya ada di Git history.

Konsep dulu: exception yang sehat

Analogi yang saya pakai: surat izin nggak masuk kerja. Ada suratnya, ada alasannya, ada yang tanda tangan, dan berlaku sampai tanggal tertentu. Lewat tanggal itu, kamu dianggap bolos lagi. Bukan izin seumur hidup.

Dari situ saya turunkan empat syarat:

Syarat

Kenapa

Ditegakkan oleh

Lewat PR, tersimpan di repo

Git history jadi audit trail: siapa, kapan, kenapa

Ruleset dari Bagian 6: semua perubahan wajib lewat PR

Owner + alasan

Ada orang yang bertanggung jawab memantau

scripts/check-exceptions.sh

Kedaluwarsa maksimal 90 hari

Risiko diterima sementara. Setelah lewat, perbaiki atau perpanjang dengan alasan baru

Script + exp: milik Trivy + workflow mingguan

Sempit: 1 CVE, 1 fingerprint, 1 rule di 1 baris

Exception lebar (seluruh rule, seluruh folder) bisa menyembunyikan temuan baru yang belum pernah dinilai

Script melarang suppression yang lebar

Bentuk akhirnya seperti ini:

text
 Temuan scanner di PR   │   ├─ bisa diperbaiki? ──► perbaiki. Selesai.   │   └─ belum bisa (belum ada patch / false positive)        │        ▼   PR exception: 1 entry = 1 temuan, plus baris di atasnya:   exception: owner=@user exp=YYYY-MM-DD reason="..."        │        ▼   check "exceptions" (scripts/check-exceptions.sh), wajib di ruleset     ✗ tanpa owner/alasan/tanggal      ✗ sudah kedaluwarsa     ✗ lebih dari 90 hari              ✗ nosemgrep tanpa rule-id     ✗ gitleaks:allow, .semgrepignore, .gitleaks.toml, trivy.yaml        │        ▼   merge → tercatat di Git history        │        ▼   tiap Senin: workflow "exception-expiry" → kedaluwarsa? merah   Trivy juga melapor lagi sendiri setelah exp:

Kenapa butuh script sendiri

Awalnya saya pikir cukup pakai fitur bawaan scanner. Setelah saya coba satu per satu di lokal, ternyata nggak:

Scanner

Cara suppress

Punya tanggal kedaluwarsa?

Trivy

.trivyignore, entry CVE-... exp:YYYY-MM-DD

Ya. Setelah exp:, temuan muncul lagi

Semgrep

// nosemgrep: <rule-id>

Nggak

Gitleaks

Fingerprint di .gitleaksignore

Nggak

Yang sempat bikin saya kaget: nosemgrep tanpa rule-id juga diterima Semgrep, dan itu mematikan semua rule di baris tersebut. Jadi syarat "owner, alasan, tanggal" untuk Semgrep dan Gitleaks cuma bisa ditegakkan oleh check tambahan. Sekalian, script-nya menyamakan format exception untuk semua scanner.

Catatan

govulncheck dan dependency-review sengaja tanpa exception

govulncheck hanya melapor kalau kode kamu benar-benar memanggil fungsi yang rentan. Itu risiko nyata: upgrade, atau berhenti memanggilnya. dependency-review hanya mengecek dependency yang ditambahkan PR, jadi solusinya pilih versi lain. Nggak semua gate harus bisa dikecualikan.

Kebijakannya ditulis dulu

Aturan yang nggak tertulis itu cuma kebiasaan. Saya tulis kebijakannya di docs/exception-process.md, dalam bahasa Inggris karena repo-nya publik. Ini versi yang dipangkas, bagian contoh dan batasan saya lewati:

docs/exception-process.md markdown
# Security finding exceptions The PR gates block a merge on any finding. Sometimes a finding has to be accepted for a while: a CVEwith no fixed version yet, or a scanner false positive. This is the only allowed way to do that. ## Rules 1. **Fix first.** An exception is for a finding that cannot be fixed now, not one that is inconvenient.2. **Through a PR.** Exceptions live in the repo, so Git history records who added one, when and why.   The PR description links an issue with the details.3. **One finding per entry.** One CVE ID, one Gitleaks fingerprint, or one `nosemgrep` with a rule id.   Never a whole rule, a whole severity, or a whole path.4. **Owner, reason and expiry.** Every entry has this line directly above it:    ```   exception: owner=@github-user exp=YYYY-MM-DD reason="at least 10 characters, link to the issue"   ```    `exp` is at most **90 days** away. To keep an exception longer, renew it in a new PR with a fresh reason.5. **Real secrets are never excepted.** A Gitleaks finding that is a real credential is revoked and   rotated. Gitleaks exceptions are for false positives only. ## Per scanner | Scanner | Where | Entry || --- | --- | --- || Trivy (image, config) | `.trivyignore` | `CVE-2026-12345 exp:2026-12-31` or `DS-0002 exp:2026-12-31` || Gitleaks | `.gitleaksignore` | Fingerprint `<commit>:<file>:<rule-id>:<line>` || Semgrep | the source line | `// nosemgrep: <rule-id>`, with the exception line directly above || govulncheck | none | No suppression || dependency-review | none | No suppression | Not allowed, because they suppress findings without an owner or expiry: `.semgrepignore`, `.gitleaks.toml`,`.trivyignore.yaml`, `trivy.yaml`, a bare `nosemgrep`, and `gitleaks:allow`.

Aturan nomor 5 itu yang paling penting. Kalau Gitleaks menemukan secret asli, jawabannya bukan exception. Cabut, rotasi, selesai. Exception Gitleaks hanya untuk false positive.

File exception resmi

Dua file ini saya buat sekarang, walau masih kosong, isinya cuma header komentar. Alasannya praktis: workflow nanti menunjuk .trivyignore secara eksplisit, dan kalau file-nya nggak ada, trivy-action berhenti dengan error cannot find ignorefile.

.trivyignore text
# Trivy exceptions (image CVEs and config checks). Rules: docs/exception-process.md# Each entry needs this line directly above it, and the same date as Trivy's exp:#   # exception: owner=@github-user exp=YYYY-MM-DD reason="why it is accepted, link to the issue"#   CVE-YYYY-NNNNN exp:YYYY-MM-DD
.gitleaksignore text
# Gitleaks exceptions, for false positives only. A real leaked secret is rotated, never ignored.# Rules: docs/exception-process.md. Each fingerprint needs this line directly above it:#   # exception: owner=@github-user exp=YYYY-MM-DD reason="why it is not a secret, link to the issue"#   <commit>:<file>:<rule-id>:<line>

Baris yang diawali # dianggap komentar oleh Trivy dan Gitleaks. Sudah saya uji, jadi header ini nggak mengecualikan apa pun.

Penegaknya: scripts/check-exceptions.sh

Ini inti bagian ini. Script-nya membaca semua tempat suppression bisa bersembunyi, lalu menolak yang nggak memenuhi aturan. Saya tampilkan utuh karena setiap bagiannya punya alasan:

scripts/check-exceptions.sh bash
#!/usr/bin/env bash# Enforces docs/exception-process.md: every scanner suppression carries an owner, a reason and an expiry# no more than MAX_DAYS away. Run from the repo root. Exits 1 on any violation.set -euo pipefail MAX_DAYS="${MAX_DAYS:-90}"WARN_DAYS="${WARN_DAYS:-14}"TODAY="${TODAY:-$(date -u +%F)}"META_RE='exception: owner=@[A-Za-z0-9-]+ exp=([0-9]{4}-[0-9]{2}-[0-9]{2}) reason="[^"]{10,}"'NOSEM_RE='nosemgrep: ?[A-Za-z0-9_.-]+'errors=0 fail() { echo "::error file=$1,line=$2::$3"; errors=$((errors + 1)); } # GNU date on the runner, BSD date on macOS. valid_date prints nothing for an impossible date.valid_date() { date -u -d "$1" +%F 2>/dev/null || date -u -j -f %F "$1" +%F 2>/dev/null || true; }add_days() { date -u -d "$1 + $2 days" +%F 2>/dev/null || date -u -j -v+"$2"d -f %F "$1" +%F; } LIMIT="$(add_days "$TODAY" "$MAX_DAYS")"WARN_FROM="$(add_days "$TODAY" "$WARN_DAYS")" # check_meta FILE LINE TEXT: TEXT is the line directly above the suppression. Sets $exp when it is valid.check_meta() {  exp=""  if [[ ! "$3" =~ $META_RE ]]; then    fail "$1" "$2" "suppression needs 'exception: owner=@user exp=YYYY-MM-DD reason=\"...\"' on the line directly above"    return 1  fi  exp="${BASH_REMATCH[1]}"  if [ "$(valid_date "$exp")" != "$exp" ]; then    fail "$1" "$2" "exp=$exp is not a real date"    return 1  elif [[ "$exp" < "$TODAY" ]]; then    fail "$1" "$2" "exception expired on $exp: fix the finding or renew it in a new PR"    return 1  elif [[ "$exp" > "$LIMIT" ]]; then    fail "$1" "$2" "exp=$exp is more than $MAX_DAYS days away (latest allowed $LIMIT)"    return 1  elif [[ "$exp" < "$WARN_FROM" ]]; then    echo "::warning file=$1,line=$2::exception expires on $exp"  fi} # Plain ignore files: every entry needs the exception line directly above it.# shellcheck disable=SC2094 # fail only prints the file name, it never writes to the filecheck_ignore_file() {  local file="$1" n=0 prev="" line  [ -f "$file" ] || return 0  while IFS= read -r line || [ -n "$line" ]; do    n=$((n + 1))    case "$line" in      '' | '#'*) prev="$line"; continue ;;    esac    # Trivy only re-reports a finding after its own exp: date, so it must match the exception line.    if check_meta "$file" "$n" "$prev" && [ "$file" = .trivyignore ] && [[ "$line" != *" exp:$exp" ]]; then      fail "$file" "$n" "Trivy entry must end with ' exp:$exp'"    fi    prev="$line"  done < "$file"} # These files suppress findings silently, with no owner or expiry, so they are not allowed at all.for f in .semgrepignore .gitleaks.toml .trivyignore.yaml trivy.yaml; do  if [ -e "$f" ]; then    fail "$f" 1 "not allowed: use the exception process in docs/exception-process.md"  fidone check_ignore_file .trivyignorecheck_ignore_file .gitleaksignore # Inline suppressions. gitleaks:allow is banned because a .gitleaksignore fingerprint pins one finding, not a whole line.while IFS=: read -r file n text; do  if [[ "$text" == *gitleaks:allow* ]]; then    fail "$file" "$n" "gitleaks:allow is not allowed: add the finding's fingerprint to .gitleaksignore"    continue  fi  if [[ ! "$text" =~ $NOSEM_RE ]]; then    fail "$file" "$n" "nosemgrep must name the rule id (nosemgrep: <rule-id>)"    continue  fi  prev=""  [ "$n" -gt 1 ] && prev="$(sed -n "$((n - 1))p" "$file")"  check_meta "$file" "$n" "$prev" || truedone < <(git grep -n -I -E 'nosemgrep|gitleaks:allow' -- . ':!*.md' ':!scripts/check-exceptions.sh' || true) if [ "$errors" -gt 0 ]; then  echo "$errors exception problem(s). See docs/exception-process.md" >&2  exit 1fiecho "exceptions OK (today $TODAY, latest allowed exp $LIMIT)"

Bagian-bagian yang penting:

Bagian

Fungsinya

META_RE

Regex baris exception: owner=@ plus username, exp= tanggal (ditangkap), dan reason="..." minimal 10 karakter, supaya nggak bisa diisi "x"

fail()

Mencetak ::error file=X,line=N::pesan. GitHub Actions membaca format ini dan menampilkannya sebagai anotasi merah di baris file pada tab Files changed

valid_date / add_days

Coba date versi GNU (runner Linux), kalau gagal pakai versi BSD (macOS). 2026-11-31 ditolak sebagai tanggal palsu

[[ "$exp" < "$TODAY" ]]

Perbandingan teks. Format YYYY-MM-DD urut secara teks sama dengan urut tanggal, jadi nggak perlu konversi

Peringatan 14 hari

Kalau sisa kurang dari 14 hari, keluar ::warning kuning. Nggak memblokir, cuma mengingatkan

Khusus .trivyignore

Entry wajib diakhiri exp:<tanggal yang sama>. Jadi Trivy sendiri juga berhenti mengabaikan di tanggal itu, bukan cuma script kita

Daftar file terlarang

.semgrepignore, .gitleaks.toml, .trivyignore.yaml, trivy.yaml bisa mematikan temuan secara luas tanpa owner atau tanggal. trivy.yaml dilarang karena Trivy otomatis membacanya dan bisa mengubah severity

git grep ... ':!*.md'

Cari nosemgrep dan gitleaks:allow di file yang di-track Git. Dokumentasi dan script ini sendiri dikecualikan karena menyebut kata itu sebagai teks

Kenapa gitleaks:allow dilarang padahal itu fitur resmi Gitleaks? Karena komentar itu mengabaikan satu baris, untuk selamanya, tanpa peduli isinya berubah apa. Fingerprint di .gitleaksignore jauh lebih sempit: satu temuan di satu commit.

Perhatian

`git grep` hanya melihat file yang di-track

File baru yang belum di-git add nggak ikut diperiksa. Di CI semua sudah ter-commit, jadi aman. Waktu uji lokal, git add dulu. Saya sempat bingung kenapa lokal exceptions OK tapi CI merah, ternyata ini penyebabnya.

Cek hasil: jalankan di root repo.

bash
chmod +x scripts/check-exceptions.shgit add -Abash scripts/check-exceptions.sh
text
exceptions OK (today 2026-10-31, latest allowed exp 2027-01-29)

Lalu uji negatif singkat: nosemgrep polos harus ditolak.

bash
printf 'package demo\n\nconst a = "x" // nosemgrep\n' > internal/tmp_test_nosem.gogit add internal/tmp_test_nosem.gobash scripts/check-exceptions.sh; echo "exit=$?"git rm -q --cached internal/tmp_test_nosem.go && rm internal/tmp_test_nosem.go
text
::error file=internal/tmp_test_nosem.go,line=3::nosemgrep must name the rule id (nosemgrep: <rule-id>)1 exception problem(s). See docs/exception-process.mdexit=1

Selain ini saya jalankan 24 kasus uji lain: tanpa baris exception, baris kosong di antara, tanggal Trivy beda, exp hari ini, tepat 90 hari (lolos), 91 hari (ditolak), alasan terlalu pendek, gitleaks:allow, .semgrepignore, dan seterusnya. Semuanya lulus di macOS (bash 3.2) dan Ubuntu (bash 5.2, sama seperti runner GitHub). Script-nya juga lolos shellcheck.

Menyambungkan ke workflow

Job exceptions di PR

Di pr-checks.yml, saya tambah satu job di antara semgrep dan govulncheck:

.github/workflows/pr-checks.yml yaml
  exceptions:    runs-on: ubuntu-24.04    timeout-minutes: 5    steps:      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1        with:          persist-credentials: false      - name: Check scanner exceptions        run: bash scripts/check-exceptions.sh

Job ini nggak butuh permission tambahan, cukup contents: read dari level workflow.

Trivy membaca .trivyignore secara eksplisit

Trivy sebenarnya otomatis membaca .trivyignore. Tapi saya tetap menambahkan trivyignores: .trivyignore di kelima step trivy-action: empat di pr-checks.yml (config SARIF, config gate, image SARIF, image gate) dan satu di gate deploy.yml.

yaml
      - name: Trivy image (gate)        uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0        with:          version: v0.76.1          trivyignores: .trivyignore          scan-type: image          # ...sisanya sama seperti Bagian 5

Ada tiga alasan:

  • Kalau trivyignores diisi, action mencetak isi file ignore ke log (Found ignorefile '.trivyignore':). Setiap run jadi menunjukkan exception apa saja yang aktif. Bagus untuk audit.

  • Kalau file-nya hilang, action gagal, bukan diam-diam scan tanpa exception.

  • Gate di deploy.yml membaca file yang sama. Exception yang disetujui di PR juga berlaku saat build deploy. Satu sumber kebenaran.

Cek mingguan exception-expiry

PR check hanya jalan kalau ada PR. Kalau dua bulan nggak ada PR, exception yang kedaluwarsa nggak ketahuan. Jadi saya tambah workflow terjadwal:

.github/workflows/exception-expiry.yml yaml
name: exception-expiry # PR checks only run when someone opens a PR. This weekly run catches exceptions that expire in between,# and GitHub emails the failure.on:  schedule:    - cron: "0 1 * * 1" # Monday 01:00 UTC = 08:00 WIB  workflow_dispatch: permissions:  contents: read jobs:  exception-expiry:    runs-on: ubuntu-24.04    timeout-minutes: 5    steps:      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1        with:          persist-credentials: false      - name: Check scanner exceptions        run: bash scripts/check-exceptions.sh

Bagian

Artinya

cron: "0 1 * * 1"

Menit 0, jam 01 UTC, setiap Senin. Itu 08:00 WIB

workflow_dispatch

Bisa dijalankan manual: gh workflow run exception-expiry.yml

Branch yang dicek

Workflow terjadwal selalu jalan di default branch, yaitu development

Notifikasi

Kalau gagal, GitHub mengirim email ke orang yang terakhir mengubah cron ini

Nama job exception-expiry

Sengaja beda dari exceptions, supaya nggak tertukar dengan required check

Catatan

Jadwal GitHub bisa telat beberapa menit sampai satu jam waktu sibuk, dan dinonaktifkan kalau repo sepi 60 hari. Untuk cek mingguan, itu masih bisa diterima, karena Trivy exp: dan check exceptions tetap jalan di setiap PR dan deploy.

Jadikan exceptions required check

Di docs/rulesets/protect-development.json dan protect-main.json, saya tambahkan exceptions ke daftar required check, lalu terapkan dengan gh api -X PUT seperti di Bagian 6. Cek hasil:

bash
for f in docs/rulesets/protect-development.json docs/rulesets/protect-main.json; do  jq -c '[.rules[] | select(.type=="required_status_checks").parameters.required_status_checks[].context]' "$f"done
text
["test","gitleaks","semgrep","govulncheck","dependency-review","trivy-config","trivy-image","exceptions"]["test","gitleaks","semgrep","govulncheck","dependency-review","trivy-config","trivy-image","exceptions","source-branch"]

Perhatian

Terapkan ruleset setelah merge, bukan sebelum

Kalau exceptions dijadikan wajib sebelum job-nya ada di development, PR lain yang dibuat dari development lama nggak pernah melaporkan check itu. PR-nya akan menunggu selamanya dengan status "Expected — Waiting for status to be reported". Jadi urutannya: merge PR yang menambah job, baru PUT ruleset.

PR yang menambah semua ini (9 file) saya buka ke development. Yang muncul 7 gate lama ditambah exceptions, semuanya hijau, lalu merge. Setelah itu baru ruleset-nya diterapkan.

Demo A: accepted risk yang lalu kedaluwarsa

Sekarang buktinya. Skenario: container terpaksa jalan sebagai root, misalnya aplikasi lama yang butuh port 80. Temuan ini ditangkap dua scanner: Trivy config (DS-0002) dan Semgrep (dockerfile.security.last-user-is-root.last-user-is-root). Satu temuan berarti exception di kedua scanner. Sengaja repot, supaya exception jarang dipakai.

PR ini demo, nanti ditutup tanpa merge.

bash
git switch development && git pull --ff-onlygit switch -c demo/exception-accepted-riskEXP=$(date -v+30d +%F); echo "$EXP"    # macOS. Di Linux: date -d '+30 days' +%F

Di Dockerfile, baris USER 65532:65532 saya ganti dengan tiga baris ini (ganti <EXP> dengan tanggal dari perintah di atas):

Dockerfile dockerfile
# exception: owner=@dimss113 exp=<EXP> reason="demo: root needed to bind port 80, tracked in issue"# nosemgrep: dockerfile.security.last-user-is-root.last-user-is-rootUSER root

Urutannya penting: baris exception: langsung di atas nosemgrep, dan nosemgrep langsung di atas USER root.

Perhatian

Error `suppression needs 'exception: ...'` di Dockerfile

Ini yang sempat kena ke saya. Hampir pasti <EXP> belum diganti. Script hanya menerima tanggal berformat YYYY-MM-DD, jadi exp=<EXP> nggak dianggap baris exception. Jalankan sed -i '' "s/exp=<EXP>/exp=$EXP/" Dockerfile. Kalau terminalnya baru dan $EXP kosong, isi dulu variabelnya.

Lalu entry Trivy, dengan tanggal yang sama:

bash
printf '# exception: owner=@dimss113 exp=%s reason="demo: root needed to bind port 80, tracked in issue"\nDS-0002 exp:%s\n' "$EXP" "$EXP" >> .trivyignoregit add -A && bash scripts/check-exceptions.sh     # harus: exceptions OKgit commit -m "demo: accept root user with an expiring exception"git push -u origin demo/exception-accepted-riskgh pr create --base development --title "demo: accepted risk with expiry" \  --body "Demo only, not to be merged."gh pr checks --watch

Cek hasil: kedelapan required check hijau, termasuk trivy-config, semgrep, dan exceptions. PR-nya bisa di-merge.

Ada satu hal yang awalnya bikin saya bingung: check Code scanning results / Semgrep OSS tetap merah. Itu bukan job semgrep kita. Itu check terpisah yang dibuat GitHub dari file SARIF. Semgrep tetap menulis temuan yang di-nosemgrep ke SARIF dengan tanda suppressions: inSource, tapi code scanning GitHub mengabaikan tanda itu dan membuka alert baru. Check ini nggak ada di ruleset, jadi nggak memblokir:

bash
gh pr view --json mergeStateStatus,mergeable
text
{"mergeStateStatus":"UNSTABLE","mergeable":"MERGEABLE"}

UNSTABLE artinya ada check opsional yang merah, tapi tetap bisa merge. Justru bagus untuk audit: exception tetap terlihat di tab Security. Untuk exception sungguhan, alert-nya ditutup lewat Dismiss alert → Won't fix dengan alasan dan tanggal yang sama, jadi keputusan itu juga tercatat di GitHub, lengkap dengan siapa yang memutuskan.

Kedaluwarsa = merah lagi

Sekarang tanggalnya saya mundurkan ke kemarin, di dua tempat sekaligus:

bash
OLD=$(date -v-1d +%F)sed -i '' "s/$EXP/$OLD/g" Dockerfile .trivyignoregit commit -am "demo: let the exception expire"git pushgh pr checks --watch

Cek hasil:

Check

Status

Kenapa

exceptions

merah

exception expired on <kemarin> di .trivyignore dan Dockerfile, muncul sebagai anotasi di Files changed

trivy-config

merah

Trivy sendiri berhenti mengabaikan DS-0002 setelah exp:

semgrep

hijau

Semgrep nggak kenal tanggal. Tanpa check exceptions, nosemgrep ini berlaku selamanya

Baris semgrep hijau itu adalah bukti paling jelas kenapa check exceptions dibutuhkan. Setelah itu PR-nya saya tutup:

bash
gh pr close --delete-branch --comment "Demo only: exception accepted, then expired and blocked."

Demo B: false positive Gitleaks lewat fingerprint

Skenario kedua: ada nilai tes tetap di kode yang bentuknya mirip API key. Gitleaks dan Semgrep (p/secrets) menandainya. Karena ini false positive, boleh di-exception.

bash
git switch development && git pull --ff-onlygit switch -c demo/exception-false-positivemkdir -p internal/demoV=$(openssl rand -hex 20)printf 'package demo\n\n// Demo only: a fixed test value that looks like an API key.\nconst apiKey = "%s"\n\nfunc Key() string { return apiKey }\n' "$V" > internal/demo/vector.gounset Vgit add -A && git commit -m "demo: add a fixed test value that looks like an api key"

openssl rand -hex 20 menghasilkan 40 karakter hex acak, jelas bukan kredensial siapa pun.

Ambil fingerprint

bash
gitleaks git . --no-banner --redact -v --log-opts="origin/development..HEAD"

--log-opts="origin/development..HEAD" membuat Gitleaks hanya memindai commit di branch ini, sama seperti CI yang memindai commit PR saja. -v mencetak detail termasuk fingerprint:

text
RuleID:      generic-api-key...Fingerprint: <commit-sha>:internal/demo/vector.go:generic-api-key:4

Format fingerprint: <commit>:<file>:<rule>:<baris>. Artinya exception ini hanya berlaku untuk satu temuan di satu commit. Kalau nilai yang sama ditempel lagi di commit lain, Gitleaks tetap menangkapnya. Itu yang saya mau.

Simpan ke variabel supaya nggak perlu copy manual:

bash
FP=$(gitleaks git . --no-banner --redact --log-opts="origin/development..HEAD" \  --report-format json --report-path /tmp/gl.json >/dev/null 2>&1; jq -r '.[0].Fingerprint' /tmp/gl.json)rm /tmp/gl.json

Tambah exception Gitleaks dan Semgrep

bash
EXP=$(date -v+30d +%F)printf '# exception: owner=@dimss113 exp=%s reason="demo: fixed test value, not a real credential"\n%s\n' "$EXP" "$FP" >> .gitleaksignore

Untuk Semgrep, dua baris ditambahkan di atas const apiKey:

internal/demo/vector.go go
// exception: owner=@dimss113 exp=<EXP> reason="demo: fixed test value, not a real credential"// nosemgrep: generic.secrets.security.detected-generic-api-key.detected-generic-api-keyconst apiKey = "..."

Perhatian

Jangan taruh `nosemgrep` di ujung baris `const`

Kalau baris const diubah, commit baru ikut menambahkan ulang baris berisi nilai itu. Gitleaks melihatnya sebagai temuan baru dengan SHA commit baru, dan fingerprint tadi nggak cocok lagi. Taruh di baris terpisah di atasnya. Semgrep tetap mengenali nosemgrep di baris atas, sudah saya uji. Hal yang sama berlaku kalau kamu amend atau rebase: SHA berubah, ambil fingerprint lagi.

bash
gofmt -l internal/demo      # harus kosonggit add -A && bash scripts/check-exceptions.shgit commit -m "demo: except the false positive by fingerprint"git push -u origin demo/exception-false-positivegh pr create --base development --title "demo: false positive exception" --body "Demo only, not to be merged."gh pr checks --watch

Cek hasil: semua required check hijau, termasuk gitleaks, semgrep, dan exceptions. Code scanning / Semgrep OSS merah dengan alasan yang sama seperti Demo A. Code scanning / gitleaks tetap hijau, karena temuan yang cocok dengan fingerprint nggak ditulis ke SARIF.

Tips

Mau screenshot "sebelum dan sesudah"? Push commit pertama saja dulu, lihat gitleaks dan semgrep merah, baru tambah exception-nya.

Tutup demo-nya dengan gh pr close --delete-branch.

Kalau ada masalah

Gejala

Penyebab

Solusi

Trivy gagal: cannot find ignorefile '.trivyignore'

.trivyignore nggak ikut di-commit

git add .trivyignore

suppression needs 'exception: ...' on the line directly above

Baris exception nggak tepat di atas, alasan kurang dari 10 karakter, atau tanpa owner=@

Taruh tepat di atas, format persis

Trivy entry must end with ' exp:...'

Tanggal entry Trivy beda dengan baris exception

Samakan tanggalnya

more than 90 days away

exp terlalu jauh

Maksimal 90 hari, perpanjang lewat PR baru

gitleaks tetap merah setelah fingerprint ditambah

Commit di-amend/rebase, atau baris berisi nilai ikut diubah

Ambil fingerprint lagi dari commit terakhir

semgrep tetap merah setelah nosemgrep

Rule-id salah ketik

Ambil rule-id persis dari log Semgrep

Batasan yang saya terima

Proses ini nggak sempurna, dan lebih baik ditulis jujur:

  • Owner bisa diisi siapa saja. Script hanya mengecek format, bukan apakah orang itu setuju. Di tim, pasang CODEOWNERS untuk .trivyignore, .gitleaksignore, script, dan workflow, plus "require code owner review" di ruleset.

  • Penulis PR bisa mengubah script atau workflow di PR yang sama, karena pull_request memakai workflow dari branch PR. Mitigasinya review ketat untuk .github/ dan scripts/, atau required workflows di level organisasi.

  • Fingerprint Gitleaks tetap menyembunyikan satu temuan lama setelah kedaluwarsa, karena Gitleaks nggak punya tanggal. Check exceptions tetap merah sampai entry-nya dihapus, dan fingerprint cuma cocok untuk satu commit.

  • Belum ada dashboard terpusat. Exception tersebar di tiga tempat, tapi git grep -n "exception:" menampilkan semuanya.

Rangkuman

  • docs/exception-process.md: kebijakan tertulis. Perbaiki dulu, satu temuan per entry, owner + alasan + kedaluwarsa maksimal 90 hari, secret asli nggak pernah di-exception.

  • .trivyignore dan .gitleaksignore sebagai satu-satunya file exception resmi.

  • scripts/check-exceptions.sh yang menolak exception tanpa metadata, kedaluwarsa, terlalu panjang, nosemgrep polos, gitleaks:allow, dan file suppression lebar.

  • Job exceptions sebagai required check di development dan main, plus workflow mingguan exception-expiry.

  • Bukti: exception sah membuat PR hijau, dan begitu kedaluwarsa, gate merah lagi. Termasuk bukti bahwa Semgrep sendiri nggak akan pernah merah lagi tanpa check ini.

Di Bagian 10, bagian terakhir, kita bereskan satu-satunya kredensial yang masih tersimpan di GitHub: DOKPLOY_API_KEY. Secret itu saya hapus, dan CI mengambil token Dokploy langsung dari Vault lewat GitHub OIDC.

Artikel terkait

DevSecOps · 19 menit baca

Bagian 7: Menyiapkan Environment Dev dan Production di Dokploy

Dua service Compose di Dokploy, satu untuk dev dan satu untuk production, masing-masing dengan AppRole Vault sendiri, domain HTTPS lewat Traefik, dan auto-deploy yang sengaja dimatikan supaya hanya CI yang boleh deploy.