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

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

GET /health

Cek hidup, untuk healthcheck Docker dan Dokploy

–

GET /version

Mengembalikan commit dan buildTime yang ditanam saat build

–

POST /v1/tokens

Membuat JWT HS256 untuk subject, berlaku 15 menit

jwtSecret

GET /v1/me

Memverifikasi Authorization: Bearer <jwt>

jwtSecret

POST /v1/encrypt

Enkripsi teks dengan AES-256-GCM

aesKey

POST /v1/decrypt

Dekripsi ciphertext dari /v1/encrypt

aesKey

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:

text
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└── .dockerignore

Router-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 (docker inspect, panel Dokploy)

Hanya proses yang bisa membaca file di volume itu

Bocor lewat

docker inspect, dump env saat crash, proses anak

Lebih sempit: file 0400, di RAM, mount read-only

Aplikasi butuh SDK Vault

–

Nggak. Cukup os.Open

Ganti sumber secret nanti

Ubah cara deploy

Aplikasi nggak berubah sama sekali

Bentuk file-nya sederhana:

testdata/config.json json
{"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.

internal/config/config.go go
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, bukan string. Dengan string biasa, "jwtSecret": null dan field yang hilang sama-sama jadi "" tanpa ketahuan. Dengan pointer, nil berarti memang nggak ada.

  • DisallowUnknownFields. Typo seperti "jwtSecrte" langsung ditolak, bukan diam-diam diabaikan sambil jwtSecret-nya kosong.

  • jwtSecret minimal 32 karakter dan aesKey tepat 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.SyntaxError juga sengaja diganti pesan generik, karena pesan aslinya bisa mengutip isi file.

Lalu di main.go, error dari config.Load langsung mengakhiri proses:

cmd/api/main.go go
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:

internal/config/config_test.go go
{"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:

internal/token/token.go go
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

WithValidMethods(HS256)

Serangan klasik alg: none atau ganti algoritma. Token dengan algoritma lain langsung ditolak

WithIssuer

Token yang ditandatangani untuk aplikasi lain dengan secret yang kebetulan sama

WithExpirationRequired

Token tanpa exp yang berlaku selamanya

WithTimeFunc(i.now)

Bukan keamanan, tapi bikin test kedaluwarsa bisa pakai jam palsu, tanpa sleep

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:

internal/token/token_test.go go
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.

internal/cryptobox/cryptobox.go go
// 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:

internal/httpapi/httpapi.go go
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:

internal/httpapi/httpapi_test.go go
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:

internal/buildinfo/buildinfo.go go
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:

cmd/api/main.go go
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

Makefile 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/api

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

bash
make lint test

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

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

bash
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 %1

Tips

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

Dockerfile dockerfile
# 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 build. Image akhir cuma berisi satu binary

Base dipatok tag@sha256:digest

Tag bisa dipindah ke image lain. Digest nggak. Rebuild selalu menarik image yang sama dengan yang sudah di-scan

COPY go.mod go.sum lalu go mod download

Layer dependency di-cache. Ubah kode nggak memaksa download ulang semua module

CGO_ENABLED=0

Binary statically linked. Distroless/static nggak punya libc, jadi binary yang butuh libc gagal jalan dengan error "no such file or directory" yang membingungkan

-trimpath

Menghapus path mesin build (misalnya /Users/<nama>/...) dari binary

-s -w

Membuang symbol table dan info debug DWARF. Binary lebih kecil

-X ...Commit=${COMMIT}

Menanam identitas versi untuk /version

distroless/static-debian12:nonroot

Nggak ada shell, package manager, atau tool lain. Sedikit isi, sedikit CVE, dan penyerang yang masuk nggak punya alat

USER 65532:65532

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:

.dockerignore text
.git.superpowersbincoverage.out*.mdtestdata.env*config.local.json

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

text
git rev-parse HEAD → --build-arg COMMIT → ARG COMMIT → -ldflags -X → buildinfo.Commit → GET /version

Build dan jalankan container-nya

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

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

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

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

bash
trivy config --severity HIGH,CRITICAL --exit-code 1 .trivy image --severity CRITICAL --ignore-unfixed --exit-code 1 secure-cicd-go:local

Cek 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 ServeMux masih text/plain, belum JSON {"error":...}.

  • Data sisa setelah objek JSON pertama masih diterima, baik di body request maupun di config.

  • Klaim iat di JWT belum divalidasi, jadi iat di masa depan masih diterima.

  • Batas subject menghitung byte, padahal pesan error-nya bilang "characters".

  • # syntax=docker/dockerfile:1 menarik 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.

  • /version yang melaporkan commit dan waktu build lewat -ldflags, untuk smoke test nanti.

  • Makefile untuk test, 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.

Artikel terkait