//BAGIAN 3 DARI 10
Bagian 3: Aplikasi Go Kecil yang Membaca Secret dari File dan Menolak Jalan Kalau Config Salah
· 17 menit baca
English//TUTORIAL · 3 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
Di Bagian 2 kita sudah punya secret, policy, dan AppRole di Vault, plus bukti bahwa kredensial dev nggak bisa membaca secret production. Sekarang kita butuh sesuatu yang memakai secret itu.
Di bagian ini saya membangun aplikasi Go-nya. Saya sebut ini "pasien" untuk pipeline: kecil, tapi cukup nyata supaya setiap security gate di seri ini punya bahan untuk diperiksa. Ada dua secret yang benar-benar dipakai (kunci JWT dan kunci AES), ada satu dependency asli untuk bahan scan dependency, dan ada Dockerfile yang nanti diperiksa Trivy.
Yang dihasilkan di akhir bagian ini: API yang jalan di laptop dengan secret dummy, semua test hijau, dan image Docker distroless non-root yang healthy. Belum ada push ke GitHub. Itu urusan Bagian 5.
Catatan
Kode lengkapnya ada di github.com/dimss113/secure-cicd-go. Potongan kode di artikel ini diambil dari repo itu, beberapa saya pangkas supaya fokus.
Aplikasinya ngapain saja
Sengaja sedikit. Setiap endpoint punya alasan ada:
Endpoint | Fungsi | Secret yang dipakai |
|---|---|---|
| Cek hidup, untuk healthcheck Docker dan Dokploy | – |
| Mengembalikan | – |
| Membuat JWT HS256 untuk |
|
| Memverifikasi |
|
| Enkripsi teks dengan AES-256-GCM |
|
| Dekripsi ciphertext dari |
|
Aturan yang saya pasang dari awal:
Hanya standard library, plus satu dependency:
github.com/golang-jwt/jwt/v5. Satu dependency asli itu cukup jadi bahan demo SCA (govulncheck dan dependency-review) nanti.Nilai secret nggak pernah muncul di log, di respons HTTP, atau di pesan error.
Config yang nggak valid berarti aplikasi berhenti sebelum listen. Nggak ada "jalan dulu, nanti dicek".
Strukturnya satu paket per tanggung jawab:
secure-cicd-go/├── cmd/api/main.go # merakit semuanya + subcommand healthcheck├── internal/│ ├── buildinfo/ # Commit & BuildTime, diisi lewat -ldflags│ ├── config/ # baca & validasi file secret (fail closed)│ ├── token/ # JWT HS256│ ├── cryptobox/ # AES-256-GCM│ └── httpapi/ # handler HTTP + log request├── testdata/config.json # secret dummy untuk test & jalan lokal├── Makefile├── Dockerfile└── .dockerignoreRouter-nya cukup net/http bawaan. Sejak Go 1.22, ServeMux sudah mengerti pola seperti "POST /v1/tokens", jadi nggak perlu library router.
Kenapa secret dibaca dari file, bukan env var
Ini keputusan desain paling penting di bagian ini, dan alasannya ada di Bagian 4.
Di server nanti, aplikasi ini jalan berdampingan dengan container Vault Agent. Agent itu yang login ke Vault pakai AppRole, mengambil secret, lalu me-render template menjadi config.json di volume tmpfs (RAM). Container aplikasi cukup membaca file itu, read-only. Aplikasinya nggak tahu Vault itu ada.
Kenapa file, bukan environment variable?
Env var | File dari Vault Agent | |
|---|---|---|
Siapa yang memegang secret | Siapa pun yang bisa melihat konfigurasi container ( | Hanya proses yang bisa membaca file di volume itu |
Bocor lewat |
| Lebih sempit: file |
Aplikasi butuh SDK Vault | – | Nggak. Cukup |
Ganti sumber secret nanti | Ubah cara deploy | Aplikasi nggak berubah sama sekali |
Bentuk file-nya sederhana:
{"env":"test","jwtSecret":"dev-only-not-a-secret-0123456789abcdef","aesKey":"000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"}Isi file di atas dummy, sengaja ditulis "dev-only-not-a-secret" supaya jelas bagi siapa pun yang membaca repo. Lokasinya diatur env CONFIG_FILE, default-nya /run/config/config.json, yaitu tempat volume dari Vault Agent nanti di-mount.
Config yang gagal berarti aplikasi berhenti
Di sinilah validasi secret yang sebenarnya terjadi. Kalau Vault Agent gagal render, template salah, atau ada orang yang mengisi key AES dengan panjang salah, saya mau aplikasinya langsung mati dengan pesan jelas. Bukan jalan dengan kunci kosong lalu mengeluarkan token yang bisa dipalsukan siapa saja.
Istilahnya fail closed: kalau ragu, tolak.
type fileConfig struct { Env *string `json:"env"` JWTSecret *string `json:"jwtSecret"` AESKey *string `json:"aesKey"`} // Load reads and validates the config file; errors name the field but never its value.func Load(path string) (Config, error) { f, err := os.Open(path) if err != nil { return Config{}, fmt.Errorf("open config: %w", err) } defer f.Close() var raw fileConfig dec := json.NewDecoder(f) dec.DisallowUnknownFields() if err := dec.Decode(&raw); err != nil { if _, ok := errors.AsType[*json.SyntaxError](err); ok { return Config{}, errors.New("parse config: invalid JSON") } return Config{}, fmt.Errorf("parse config: %w", err) } if raw.Env == nil || *raw.Env == "" { return Config{}, errors.New("config: env is missing or empty") } if raw.JWTSecret == nil || len(*raw.JWTSecret) < minJWTSecretLen { return Config{}, fmt.Errorf("config: jwtSecret is missing or shorter than %d characters", minJWTSecretLen) } if raw.AESKey == nil { return Config{}, errors.New("config: aesKey is missing") } key, err := hex.DecodeString(*raw.AESKey) if err != nil || len(key) != 32 { return Config{}, errors.New("config: aesKey must be 64 hex characters (32 bytes)") } return Config{Env: *raw.Env, JWTSecret: []byte(*raw.JWTSecret), AESKey: key}, nil}Beberapa detail kecil yang penting:
Field bertipe
*string, bukanstring. Denganstringbiasa,"jwtSecret": nulldan field yang hilang sama-sama jadi""tanpa ketahuan. Dengan pointer,nilberarti memang nggak ada.DisallowUnknownFields. Typo seperti"jwtSecrte"langsung ditolak, bukan diam-diam diabaikan sambiljwtSecret-nya kosong.jwtSecretminimal 32 karakter danaesKeytepat 32 byte (64 karakter hex). AES-256 memang butuh kunci 32 byte, dan secret HMAC yang pendek gampang ditebak.Pesan error menyebut nama field, tapi nggak pernah nilainya.
json.SyntaxErrorjuga sengaja diganti pesan generik, karena pesan aslinya bisa mengutip isi file.
Lalu di main.go, error dari config.Load langsung mengakhiri proses:
func run(logger *slog.Logger, port string) error { cfg, err := config.Load(envOr("CONFIG_FILE", defaultConfigFile)) if err != nil { logger.Error("config invalid", "error", err.Error()) os.Exit(1) } // ... cryptobox, token issuer, http.Server}Exit code 1 itu penting untuk bagian berikutnya. Di Compose (Bagian 4), container yang keluar dengan error terlihat gagal dan di-restart oleh restart: unless-stopped, jadi masalahnya kelihatan di Dokploy, bukan tersembunyi di balik aplikasi yang "jalan tapi aneh".
Test-nya table-driven. Yang saya cek bukan cuma "ada error", tapi juga bahwa error-nya menyebut field yang benar dan nggak memuat nilainya:
{"null secret", `{"env":"x","jwtSecret":null,"aesKey":"` + validKey + `"}`, "jwtSecret", ""},{"short secret", `{"env":"x","jwtSecret":"tiny","aesKey":"` + validKey + `"}`, "jwtSecret", "tiny"},{"non hex key", `{"env":"x","jwtSecret":` + longSecret + `,"aesKey":"` + strings.Repeat("z", 64) + `"}`, "aesKey", "zzzz"},{"31 byte key", `{"env":"x","jwtSecret":` + longSecret + `,"aesKey":"` + validKey[:62] + `"}`, "aesKey", validKey[:62]},Kolom ketiga harus ada di pesan error, kolom keempat nggak boleh ada. (Dipangkas: ada juga kasus JSON rusak, field tak dikenal, env kosong, dan file nggak ada.)
Perhatian
Nilai uji yang bertabrakan dengan pesan error
Awalnya nilai secret pendek di test saya tulis "short". Test-nya gagal terus, padahal kodenya benar. Ternyata pesan error-nya berisi kata "shorter", dan "shorter" memuat "short". Jadi cek "error nggak boleh memuat nilai secret" selalu merah. Nilainya saya ganti "tiny". Pelajarannya: untuk test "nggak boleh bocor", pilih nilai uji yang nggak mungkin muncul secara kebetulan.
JWT HS256 yang nggak bisa diakali
Paket token menerbitkan dan memverifikasi JWT berumur 15 menit, dengan klaim sub, iat, exp, dan iss = "secure-cicd-go". Bagian yang penting ada di Verify:
func (i *Issuer) Verify(tok string) (string, error) { var claims jwt.RegisteredClaims _, err := jwt.ParseWithClaims(tok, &claims, func(*jwt.Token) (any, error) { return i.secret, nil }, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), jwt.WithIssuer(issuerName), jwt.WithTimeFunc(i.now), jwt.WithExpirationRequired(), ) if err != nil { return "", fmt.Errorf("%w: %w", ErrInvalid, err) } if claims.Subject == "" { return "", fmt.Errorf("%w: missing subject", ErrInvalid) } return claims.Subject, nil}Kenapa tiap opsi itu ada:
Opsi | Mencegah |
|---|---|
| Serangan klasik |
| Token yang ditandatangani untuk aplikasi lain dengan secret yang kebetulan sama |
| Token tanpa |
| Bukan keamanan, tapi bikin test kedaluwarsa bisa pakai jam palsu, tanpa |
Semua kegagalan dibungkus ErrInvalid, dan handler cuma membalas 401 {"error":"invalid token"}. Penyerang nggak dapat petunjuk kenapa token-nya ditolak.
Test-nya mencakup round trip, token kedaluwarsa (jam dimajukan 16 menit), secret salah, issuer salah, string sampah, dan yang paling penting, token alg: none:
tok, err := jwt.NewWithClaims(jwt.SigningMethodNone, claims).SignedString(jwt.UnsafeAllowNoneSignatureType)// ..._, err = NewIssuer(secretA, 15*time.Minute, clock(t0)).Verify(tok)if !errors.Is(err, ErrInvalid) { t.Fatalf("err = %v, want ErrInvalid", err)}AES-256-GCM untuk encrypt dan decrypt
Paket cryptobox membungkus crypto/aes dan cipher.NewGCM. GCM itu authenticated encryption: kalau ciphertext diubah satu byte saja, decrypt gagal, bukan menghasilkan teks rusak.
// Encrypt returns base64(nonce || ciphertext || tag).func (b *Box) Encrypt(plaintext []byte) (string, error) { nonce := make([]byte, b.aead.NonceSize()) if _, err := rand.Read(nonce); err != nil { return "", fmt.Errorf("cryptobox: nonce: %w", err) } return base64.StdEncoding.EncodeToString(b.aead.Seal(nonce, nonce, plaintext, nil)), nil} // Decrypt reverses Encrypt; every failure is ErrDecrypt.func (b *Box) Decrypt(encoded string) ([]byte, error) { raw, err := base64.StdEncoding.DecodeString(encoded) if err != nil { return nil, ErrDecrypt } n := b.aead.NonceSize() if len(raw) < n+b.aead.Overhead() { return nil, ErrDecrypt } plain, err := b.aead.Open(nil, raw[:n], raw[n:], nil) if err != nil { return nil, ErrDecrypt } return plain, nil}Dua hal yang saya jaga. Pertama, nonce baru dari crypto/rand setiap kali, jadi teks yang sama dienkripsi dua kali hasilnya beda (ada test-nya). Memakai ulang nonce dengan kunci yang sama di GCM itu fatal. Kedua, cek panjang sebelum memotong raw[:n]. Tanpa itu, input pendek bikin panic. Semua kegagalan jadi satu error, ErrDecrypt, dan handler membalas 400 {"error":"cannot decrypt"}.
Handler: batas ukuran dan log yang nggak bocor
Semua body request lewat satu fungsi:
func readJSON(w http.ResponseWriter, r *http.Request, dst any) bool { dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)) dec.DisallowUnknownFields() if err := dec.Decode(dst); err != nil { if _, tooBig := errors.AsType[*http.MaxBytesError](err); tooBig { writeError(w, http.StatusRequestEntityTooLarge, "request body larger than 1 MiB") return false } writeError(w, http.StatusBadRequest, "invalid JSON body") return false } return true}Body lebih dari 1 MiB dapat 413, JSON rusak atau field asing dapat 400. Nggak ada jalur yang berakhir 500 atau panic hanya karena input aneh.
Middleware log-nya mencatat method, path, status, dan durasi. Nggak mencatat header dan body, karena header Authorization berisi token dan body /v1/encrypt berisi plaintext. Ini bukan cuma janji, ada test-nya:
func TestLogs_NoSecrets(t *testing.T) { var logs bytes.Buffer h := newTestHandler(t, &logs) tok := issue(t, h, "alice") do(t, h, "GET", "/v1/me", "", map[string]string{"Authorization": "Bearer " + tok}) do(t, h, "POST", "/v1/encrypt", `{"plaintext":"jangan-dicatat"}`, nil) if strings.Contains(logs.String(), tok) || strings.Contains(logs.String(), "jangan-dicatat") { t.Fatalf("logs leak a token or request body:\n%s", logs.String()) } // ... dan baris log request-nya memang ada}Kalau suatu hari ada yang menambahkan "log semua header buat debug", test ini yang merah duluan.
/version: aplikasi tahu dirinya dibangun dari commit mana
Paket buildinfo isinya cuma dua variabel:
var ( Commit = "dev" BuildTime = "unknown")Saat build, linker Go mengganti nilai itu lewat -ldflags "-X .../buildinfo.Commit=<sha>". Hasilnya, GET /version bisa menjawab "saya dibangun dari commit ini, jam segini". Mirip nomor batch dan tanggal produksi di kemasan obat.
Kenapa ini penting? Di Bagian 8, setelah Dokploy men-deploy, CI memanggil /version dan membandingkan commit dengan SHA yang baru saja di-scan dan di-deploy. Kalau beda, artinya Dokploy masih menjalankan image lama, dan workflow-nya gagal. Tanpa ini, "deploy sukses" cuma berarti "API Dokploy membalas 200", belum tentu versi baru yang jalan.
Bonusnya untuk insiden: kalau ada CVE baru, saya langsung tahu commit mana yang jalan di production, lalu bisa mencocokkannya dengan hasil scan dan SBOM commit itu.
Server, graceful shutdown, dan subcommand healthcheck
Sisa main.go cukup standar, tapi ada dua hal yang layak disebut.
Pertama, http.Server diberi timeout: ReadHeaderTimeout 5s, ReadTimeout 10s, WriteTimeout 10s, IdleTimeout 60s. Server Go tanpa timeout rentan Slowloris: klien yang mengirim header pelan-pelan bisa menahan koneksi selamanya. SIGINT/SIGTERM memicu Shutdown dengan batas 10 detik, jadi request yang sedang jalan sempat selesai saat container dihentikan.
Kedua, binary yang sama punya subcommand healthcheck:
func main() { port := envOr("PORT", defaultPort) if len(os.Args) > 1 && os.Args[1] == "healthcheck" { os.Exit(healthcheck(port)) } // ...} func healthcheck(port string) int { client := &http.Client{Timeout: 2 * time.Second} resp, err := client.Get("http://127.0.0.1:" + port + "/health") if err != nil { return 1 } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return 1 } return 0}Image akhirnya distroless, dan distroless nggak punya shell, curl, atau wget. Jadi HEALTHCHECK CMD curl ... nggak bisa dipakai. Solusinya, binary-nya mengecek dirinya sendiri. Subcommand ini juga sengaja nggak membaca config, karena dia cuma perlu tahu port.
Makefile
COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null || echo dev)BUILD_TIME ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)LDFLAGS := -s -w -X github.com/dimss113/secure-cicd-go/internal/buildinfo.Commit=$(COMMIT) -X github.com/dimss113/secure-cicd-go/internal/buildinfo.BuildTime=$(BUILD_TIME) .PHONY: test lint run build test: go test -race ./... lint: @test -z "$$(gofmt -l .)" || (gofmt -l . && exit 1) go vet ./... # Dummy secrets from testdata; never a real config.run: CONFIG_FILE=./testdata/config.json go run ./cmd/api build: CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o bin/api ./cmd/apimake lint sengaja meniru cek di CI nanti: gofmt -l harus kosong. Kalau ada file yang belum diformat, nama file-nya dicetak dan target-nya gagal.
Menjalankan di laptop dengan secret dummy
Mulai dari test dan lint:
make lint testCek hasil: gofmt nggak mencetak apa pun, go vet bersih, dan semua paket ok.
Lalu buktikan fail closed. Arahkan CONFIG_FILE ke file yang nggak ada:
CONFIG_FILE=/tidak/ada.json go run ./cmd/api; echo "exit=$?"Cek hasil: ada log JSON dengan "msg":"config invalid" dan "error":"open config: ...", lalu exit=1. Server nggak pernah listen.
Sekarang jalankan dengan config dummy. Saya pakai binary hasil make build, bukan make run &:
make buildCONFIG_FILE=./testdata/config.json ./bin/api & curl -s localhost:8080/health # {"status":"ok"}curl -s localhost:8080/version # {"buildTime":"...","commit":"<sha HEAD>"} TOK=$(curl -s -XPOST localhost:8080/v1/tokens -d '{"subject":"alice"}' | sed -E 's/.*"token":"([^"]+)".*/\1/')curl -s localhost:8080/v1/me -H "Authorization: Bearer $TOK" # {"subject":"alice"}curl -s localhost:8080/v1/me -H "Authorization: Bearer x" # {"error":"invalid token"} CT=$(curl -s -XPOST localhost:8080/v1/encrypt -d '{"plaintext":"halo"}' | sed -E 's/.*"ciphertext":"([^"]+)".*/\1/')curl -s -XPOST localhost:8080/v1/decrypt -d "{\"ciphertext\":\"$CT\"}" # {"plaintext":"halo"} ./bin/api healthcheck; echo "exit=$?" # exit=0kill %1Tips
Kenapa bukan `make run &`
go run meng-compile ke file sementara lalu menjalankannya sebagai proses anak. kill %1 menghentikan make, tapi proses aplikasinya bisa tertinggal dan tetap memegang port 8080. Dengan ./bin/api, yang kamu jalankan dan yang kamu matikan adalah proses yang sama. Bonusnya, /version sudah menampilkan SHA commit, bukan "dev".
Dockerfile: multi-stage, distroless, non-root, base dipatok
# syntax=docker/dockerfile:1 # Both bases are pinned by digest so a rebuild never pulls an unscanned image.FROM golang:1.27.3-alpine@sha256:d200d2dd76b03a99b52651fd1325aff7dcd35422c35c01bac56571b0507f3deb AS buildWORKDIR /srcCOPY go.mod go.sum ./RUN go mod downloadCOPY . .ARG COMMIT=devARG BUILD_TIME=unknownRUN CGO_ENABLED=0 go build -trimpath \ -ldflags="-s -w -X github.com/dimss113/secure-cicd-go/internal/buildinfo.Commit=${COMMIT} -X github.com/dimss113/secure-cicd-go/internal/buildinfo.BuildTime=${BUILD_TIME}" \ -o /out/app ./cmd/api FROM gcr.io/distroless/static-debian12:nonroot@sha256:afa5c872c891853ca7fcf1f12c3edb23f7eeef36189728842dd51042ff57f7abCOPY --from=build /out/app /appUSER 65532:65532EXPOSE 8080# Distroless has no shell or curl, so the binary probes itself.HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD ["/app", "healthcheck"]ENTRYPOINT ["/app"]Tiap keputusan di file ini punya alasan keamanan:
Bagian | Kenapa |
|---|---|
Dua stage | Compiler Go, source code, dan cache module tinggal di stage |
Base dipatok | Tag bisa dipindah ke image lain. Digest nggak. Rebuild selalu menarik image yang sama dengan yang sudah di-scan |
| Layer dependency di-cache. Ubah kode nggak memaksa download ulang semua module |
| Binary statically linked. Distroless/static nggak punya |
| Menghapus path mesin build (misalnya |
| Membuang symbol table dan info debug DWARF. Binary lebih kecil |
| Menanam identitas versi untuk |
| Nggak ada shell, package manager, atau tool lain. Sedikit isi, sedikit CVE, dan penyerang yang masuk nggak punya alat |
| Jalan sebagai non-root. Trivy config akan memblokir Dockerfile yang jalan sebagai root (Bagian 5) |
Saya sempat mengukur efek flag-flag itu di binary ini (linux/arm64): go build biasa menghasilkan 9.8 MB dengan 20 kemunculan path /Users/... di dalamnya. Dengan -trimpath, path-nya jadi 0. Ditambah -s -w, ukurannya turun ke 6.6 MB. Image akhirnya sekitar 17 MB.
Catatan
Versi Go di Dockerfile
Rencana awal saya memakai Go 1.27.1. Waktu menjalankan govulncheck sebelum push pertama, ternyata ada dua vuln di standard library 1.27.1 yang memang terpanggil kode ini. Go dinaikkan ke 1.27.3 di go.mod (baris toolchain) dan di Dockerfile. Ceritanya lengkap di Bagian 5. Yang penting di sini: versinya harus sama di dua tempat itu.
Kenapa git rev-parse nggak dijalankan di dalam Dockerfile
Commit dikirim dari luar lewat --build-arg. Di dalam build, .git memang nggak ada, karena sengaja dikecualikan di .dockerignore:
.git.superpowersbincoverage.out*.mdtestdata.env*config.local.jsonAlasannya dua. History repo nggak ikut masuk ke build context (dan nggak mungkin bocor ke image), dan cache build nggak rusak setiap kali ada commit baru. testdata dan .env* juga dikecualikan supaya nggak ada file config, dummy sekalipun, yang ikut terbawa ke image.
Alurnya jadi begini:
git rev-parse HEAD → --build-arg COMMIT → ARG COMMIT → -ldflags -X → buildinfo.Commit → GET /versionBuild dan jalankan container-nya
docker build -t secure-cicd-go:local \ --build-arg COMMIT=$(git rev-parse HEAD) \ --build-arg BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ) .Jalankan dengan config dummy yang di-mount read-only, persis seperti nanti volume Vault Agent di-mount ke aplikasi. Sekalian saya jalankan dengan pengaturan seketat mungkin, supaya ketahuan dari sekarang kalau aplikasinya ternyata butuh menulis ke disk atau butuh capability tertentu:
docker run --rm -d --name scg -p 8080:8080 \ --read-only --cap-drop ALL --security-opt no-new-privileges \ -v "$PWD/testdata/config.json:/run/config/config.json:ro" \ secure-cicd-go:local sleep 12docker inspect --format '{{.State.Health.Status}} user={{.Config.User}}' scgcurl -s localhost:8080/versiondocker rm -f scgCek hasil: docker inspect mencetak healthy user=65532:65532, dan /version menampilkan SHA commit yang sama dengan git rev-parse HEAD. sleep 12 itu memberi waktu --start-period=10s lewat dan healthcheck pertama berjalan.
Lalu fail closed di dalam container, tanpa mount config:
docker run --rm secure-cicd-go:local; echo "exit=$?"Cek hasil: log config invalid dan exit=1. Ini perilaku yang nanti kita andalkan di Bagian 4: kalau Vault Agent belum selesai render, aplikasi nggak akan jalan dengan config kosong.
Kalau Trivy dari Bagian 1 sudah terpasang, coba gate yang nanti jalan di CI:
trivy config --severity HIGH,CRITICAL --exit-code 1 .trivy image --severity CRITICAL --ignore-unfixed --exit-code 1 secure-cicd-go:localCek hasil: dua-duanya exit 0. Dockerfile nol misconfig HIGH/CRITICAL, image nol CVE CRITICAL yang sudah ada fix-nya.
Yang sengaja saya tunda
Setelah selesai, kode ini saya minta di-review ulang. Hasilnya nggak ada temuan kritis, tapi ada beberapa hal kecil yang saya catat dan sengaja belum dibereskan. Ini bahan bagus untuk PR latihan nanti, waktu pipeline-nya sudah jalan:
404/405 bawaan
ServeMuxmasihtext/plain, belum JSON{"error":...}.Data sisa setelah objek JSON pertama masih diterima, baik di body request maupun di config.
Klaim
iatdi JWT belum divalidasi, jadiiatdi masa depan masih diterima.Batas subject menghitung byte, padahal pesan error-nya bilang "characters".
# syntax=docker/dockerfile:1menarik frontend BuildKit lewat tag yang bisa berubah, bukan digest.
Saya tulis di sini supaya jujur: aplikasi demo ini "cukup aman untuk tujuannya", bukan sempurna.
Rangkuman
Yang sekarang sudah ada:
API Go kecil dengan
/health,/version, token JWT HS256, dan encrypt/decrypt AES-256-GCM, dengan satu dependency eksternal.Secret dibaca dari file
CONFIG_FILE(default/run/config/config.json), siap diisi Vault Agent.Validasi config yang fail closed: field hilang,
null, terlalu pendek, atau salah panjang berarti exit 1 sebelum listen, dengan pesan error yang menyebut field tanpa nilainya.Test untuk config, JWT (termasuk
alg: none), AES-GCM, handler, dan log yang nggak membocorkan token./versionyang melaporkan commit dan waktu build lewat-ldflags, untuk smoke test nanti.Makefileuntuktest,lint,run,build.Image multi-stage distroless non-root dengan base dipatok digest,
healthy, dan lolos Trivy.
Di Bagian 4, kita menyambungkan aplikasi ini ke Vault. Container Vault Agent login pakai AppRole, me-render config.json ke volume tmpfs, dan aplikasi membacanya tanpa tahu Vault itu ada. Kita juga akan lihat kenapa Vault Agent dan aplikasi harus memakai UID yang sama, 65532.