Jika tim Anda mengelola basis kode C/C++, cara paling realistis untuk menurunkan risiko bug memory safety bukanlah berharap semua bug ditemukan saat code review, melainkan menjadikan deteksi bug sebagai bagian dari continuous integration. Audit CI untuk C/C++ berarti membangun pipeline yang secara otomatis menolak perubahan berisiko lewat kombinasi warning compiler yang ketat, sanitizer, fuzzing, simbol debug yang benar, dan aturan merge gate yang jelas.

Belakangan, diskusi tentang perbedaan CVE memory safety pada Rust vs C/C++ makin sering muncul. Konteks ini berguna sebagai motivasi: banyak kerentanan di C/C++ berasal dari kelas bug yang sama, seperti use-after-free, out-of-bounds, double free, integer overflow yang memicu alokasi salah, dan undefined behavior lain yang tidak selalu terlihat dalam pengujian biasa. Namun, untuk tim yang masih harus memelihara C/C++ dalam produksi, langkah paling bernilai adalah memperketat workflow teknis sekarang juga, bukan berdebat soal bahasa.

Tujuan artikel ini bukan menyatakan bahwa sanitizer atau fuzzing dapat menggantikan desain yang aman. Tujuannya adalah membangun baseline CI yang konsisten agar bug memory safety lebih cepat terlihat, lebih mudah ditriase, dan lebih sulit lolos ke branch utama.

Mengapa audit CI untuk C/C++ perlu dijadikan baseline

Masalah memory safety di C/C++ sering lolos karena beberapa alasan praktis:

  • Bug hanya muncul pada jalur eksekusi tertentu atau input tertentu.
  • Perilaku salah bisa tersembunyi pada build release dan baru tampak pada build debug, atau sebaliknya.
  • Undefined behavior dapat menghasilkan gejala yang berubah-ubah antar mesin, compiler, atau optimisasi.
  • Crash tanpa simbol debug sering sulit dipetakan ke sumber masalah yang sebenarnya.

CI yang baik memecah masalah ini menjadi beberapa lapisan deteksi:

  1. Warning compiler ketat untuk menghentikan bug sederhana dan pola rawan sejak kompilasi.
  2. Sanitizer untuk menemukan akses memori ilegal, kebocoran, dan UB saat test berjalan.
  3. Fuzzing untuk mengeksplorasi input yang tidak terpikir saat menulis test biasa.
  4. Simbol crash dan artefak agar temuan bisa direproduksi dan dianalisis.
  5. Merge gate agar branch utama tidak menerima regresi memory safety.

Baseline pipeline yang sebaiknya dimiliki

1. Warning compiler ketat sebagai pagar pertama

Mulailah dari warning yang agresif, lalu perlakukan warning sebagai error untuk target CI tertentu. Ini tidak menjamin aman dari bug memori, tetapi mengurangi banyak kesalahan yang berujung ke perilaku tidak terdefinisi.

Contoh flag yang umum dipakai:

-Wall -Wextra -Wpedantic -Werror

Pada banyak proyek, Anda juga dapat menambah warning yang relevan dengan gaya kode dan profil bug tim, misalnya deteksi konversi implisit berbahaya atau penggunaan API lama. Namun, jangan asal menyalakan semua warning tanpa rencana. Terlalu banyak warning yang tidak dipahami akan membuat tim tergoda mematikan semuanya.

Praktik yang lebih sehat:

  • Aktifkan baseline warning yang stabil untuk semua target.
  • Tambahkan warning tambahan di branch internal atau job audit.
  • Perbaiki warning bertahap sampai target utama dapat memakai -Werror.

2. Test matrix minimal: debug, release, dan sanitizer

Satu konfigurasi build tidak cukup. Untuk CI, setidaknya sediakan:

  • Debug: memudahkan diagnosis, menjaga simbol lengkap, sering lebih cocok untuk sanitizer.
  • Release: memastikan perubahan tetap lolos dengan optimisasi produksi.
  • Sanitizer build: biasanya debug atau relwithdebinfo dengan ASan/UBSan, dan bila cocok LSan.

Alasannya sederhana: beberapa bug hanya muncul pada optimisasi tertentu, sementara sanitizer lebih efektif jika frame pointer dan informasi debug tersedia.

3. ASan, UBSan, dan LSan untuk gate memory safety

AddressSanitizer (ASan) adalah alat utama untuk mendeteksi heap buffer overflow, stack buffer overflow, use-after-free, dan sejumlah akses memori ilegal lain. Untuk proyek C/C++, ini biasanya memberi rasio nilai tertinggi dibanding usaha adopsi.

UndefinedBehaviorSanitizer (UBSan) menangkap berbagai bentuk undefined behavior, seperti integer overflow tertentu, dereference tidak valid, alignment salah, atau operasi lain yang semestinya tidak diasumsikan aman. Tidak semua UB langsung berarti bug keamanan, tetapi banyak bug serius berawal dari sini.

LeakSanitizer (LSan) berguna untuk mendeteksi kebocoran memori pada test dan proses jangka pendek. Pada layanan yang sengaja hidup lama atau menyimpan cache global, hasilnya perlu dibaca dengan konteks.

Trade-off penting:

  • Sanitizer menambah overhead runtime dan memori.
  • Tidak semua bug muncul di bawah sanitizer.
  • Beberapa dependency pihak ketiga bisa menghasilkan noise atau butuh suppressions.
  • Build sanitizer tidak selalu identik dengan build produksi.

Meski begitu, untuk merge gate, ASan + UBSan pada test inti biasanya merupakan kompromi yang sangat baik.

Contoh implementasi build: Makefile dan CMake

Contoh target Makefile sederhana

Jika proyek masih memakai Makefile, buat target eksplisit untuk mode audit. Jangan mencampur semua flag ke satu mode agar developer tahu kapan mereka sedang membangun artefak produksi dan kapan audit.

CC ?= clang
CXX ?= clang++
CFLAGS_COMMON := -Wall -Wextra -Wpedantic -g -fno-omit-frame-pointer
CXXFLAGS_COMMON := $(CFLAGS_COMMON)
LDFLAGS_COMMON :=

SAN_FLAGS := -fsanitize=address,undefined

all: app

app: main.o parser.o
	$(CXX) $(CXXFLAGS_COMMON) -O2 -o $@ $^ $(LDFLAGS_COMMON)

asan: clean
	$(MAKE) CC=$(CC) CXX=$(CXX) \
	  CFLAGS_COMMON="$(CFLAGS_COMMON) $(SAN_FLAGS)" \
	  CXXFLAGS_COMMON="$(CXXFLAGS_COMMON) $(SAN_FLAGS)" \
	  LDFLAGS_COMMON="$(SAN_FLAGS)" app

test: app
	./tests/run_tests

test-asan: asan
	ASAN_OPTIONS=detect_leaks=1:halt_on_error=1 \
	UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1 \
	./tests/run_tests

clean:
	rm -f *.o app

Poin penting pada contoh di atas:

  • -fno-omit-frame-pointer membantu stack trace lebih jelas.
  • Flag sanitizer dipasang saat compile dan link.
  • Opsi runtime seperti halt_on_error=1 membuat CI gagal segera saat ada temuan.

Contoh target CMake yang lebih rapi

Pada CMake, lebih baik pisahkan opsi sanitizer dalam flag konfigurasi agar bisa dipakai di CI maupun lokal.

cmake_minimum_required(VERSION 3.16)
project(example C CXX)

option(ENABLE_SANITIZERS "Enable sanitizers" OFF)

add_executable(app main.cpp parser.cpp)

target_compile_features(app PRIVATE cxx_std_17)
target_compile_options(app PRIVATE -Wall -Wextra -Wpedantic)

if(ENABLE_SANITIZERS)
  target_compile_options(app PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer -g)
  target_link_options(app PRIVATE -fsanitize=address,undefined)
endif()

Build yang umum dipakai di CI:

cmake -S . -B build-debug -DCMAKE_BUILD_TYPE=Debug
cmake --build build-debug --parallel

cmake -S . -B build-asan -DCMAKE_BUILD_TYPE=Debug -DENABLE_SANITIZERS=ON
cmake --build build-asan --parallel

Contoh workflow CI: GitHub Actions dan GitLab CI

Contoh GitHub Actions sederhana

name: ci

on:
  pull_request:
  push:
    branches: [ main ]

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        config: [debug, release, asan-ubsan]

    steps:
      - uses: actions/checkout@v4

      - name: Install tools
        run: |
          sudo apt-get update
          sudo apt-get install -y cmake ninja-build clang

      - name: Configure
        run: |
          if [ "${{ matrix.config }}" = "debug" ]; then
            cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++
          elif [ "${{ matrix.config }}" = "release" ]; then
            cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++
          else
            cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug -DENABLE_SANITIZERS=ON -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++
          fi

      - name: Build
        run: cmake --build build --parallel

      - name: Test
        env:
          ASAN_OPTIONS: detect_leaks=1:halt_on_error=1
          UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1
        run: ctest --test-dir build --output-on-failure

      - name: Upload logs on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: logs-${{ matrix.config }}
          path: |
            build/Testing
            build/**/*.log

Struktur ini cukup untuk baseline awal:

  • debug dan release memvalidasi dua mode utama.
  • asan-ubsan dijadikan job wajib sebelum merge.
  • Artefak log diunggah saat gagal agar triage lebih cepat.

Contoh GitLab CI sederhana

stages:
  - build
  - test
  - fuzz

variables:
  BUILD_DIR: build

build:debug:
  stage: build
  script:
    - cmake -S . -B ${BUILD_DIR} -DCMAKE_BUILD_TYPE=Debug
    - cmake --build ${BUILD_DIR} --parallel

build:asan:
  stage: build
  script:
    - cmake -S . -B ${BUILD_DIR} -DCMAKE_BUILD_TYPE=Debug -DENABLE_SANITIZERS=ON
    - cmake --build ${BUILD_DIR} --parallel

test:asan:
  stage: test
  variables:
    ASAN_OPTIONS: "detect_leaks=1:halt_on_error=1"
    UBSAN_OPTIONS: "print_stacktrace=1:halt_on_error=1"
  script:
    - ctest --test-dir ${BUILD_DIR} --output-on-failure
  needs:
    - build:asan

fuzz:smoke:
  stage: fuzz
  script:
    - ./fuzz/fuzz_parser -max_total_time=60 ./fuzz/corpus
  allow_failure: true

Pada tahap awal, job fuzzing dapat dibuat non-blocking agar tim tidak langsung kewalahan. Setelah kualitas harness dan corpus membaik, Anda bisa menjadikannya gate untuk subset target yang paling kritis.

Fuzzing di CI: apa yang layak dijalankan dan apa yang tidak

Peran fuzzing dalam pipeline memory safety

Fuzzing tidak menggantikan unit test. Unit test memverifikasi perilaku yang diharapkan, sedangkan fuzzing mencoba merusak asumsi input dan transisi state. Untuk bug memory safety, fuzzing sangat efektif jika:

  • Ada parser, decoder, serializer, atau protokol biner/teks.
  • Ada format file atau input jaringan yang kompleks.
  • Banyak percabangan berdasarkan panjang, offset, atau tipe field.

Strategi praktis untuk CI

Jangan langsung menjalankan fuzzing berjam-jam di pipeline pull request. Pisahkan menjadi tiga lapis:

  1. PR smoke fuzz: durasi singkat, memastikan target fuzz masih bisa dijalankan dan corpus awal tidak memicu crash baru.
  2. Scheduled fuzzing: dijalankan berkala dengan waktu lebih panjang, misalnya malam hari atau pada branch khusus.
  3. Coverage-guided campaign: berjalan di infrastruktur terpisah jika proyek cukup besar.

Contoh target fuzz sederhana bergaya libFuzzer:

#include <cstddef>
#include <cstdint>

extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
    parse_message(data, size);
    return 0;
}

Build target semacam ini biasanya digabung dengan sanitizer agar crash langsung diperlakukan sebagai temuan serius. Kekuatan kombinasi ini adalah fuzzing menemukan input aneh, sanitizer menjelaskan pelanggaran memorinya.

Kesalahan umum saat adopsi fuzzing

  • Memilih target terlalu besar sehingga setiap iterasi lambat.
  • Membiarkan target bergantung pada I/O eksternal, jaringan, atau state global yang sulit direproduksi.
  • Tidak menyimpan corpus dan input penyebab crash sebagai artefak CI.
  • Mengabaikan crash yang “tidak bisa direproduksi”, padahal sering disebabkan race, uninitialized state, atau environment yang tidak konsisten.

Simbol crash, artefak, dan reproduksi temuan

Temuan sanitizer atau fuzzing hanya berguna jika mudah direproduksi. Karena itu, pastikan pipeline menghasilkan informasi berikut:

  • Binary dengan simbol debug untuk job audit.
  • Stack trace lengkap dari sanitizer.
  • Input penyebab crash untuk fuzz target.
  • Commit SHA, compiler, dan mode build.

Prinsip utamanya: orang yang melakukan triage harus dapat menjalankan ulang temuan secara lokal dengan satu atau dua perintah, bukan menebak-nebak environment CI.

Contoh pola reproduksi yang baik:

cmake -S . -B build-asan -DCMAKE_BUILD_TYPE=Debug -DENABLE_SANITIZERS=ON
cmake --build build-asan --parallel
ASAN_OPTIONS=detect_leaks=1:halt_on_error=1 ./build-asan/fuzz_parser crash-input.bin

Jika proyek Anda menghasilkan core dump, pastikan penyimpanan artefak dan kebijakan retensinya sejalan dengan ukuran file dan sensitivitas data. Jangan mengunggah input crash mentah jika mungkin berisi data rahasia pengguna.

Triage temuan: membedakan blocker, debt, dan false positive

Klasifikasi yang sebaiknya dipakai tim

Agar merge gate tidak menjadi sumber friksi tanpa arah, temuan perlu diklasifikasikan:

  • Blocker: use-after-free, out-of-bounds, double free, invalid free, UB yang jelas memengaruhi keamanan atau integritas memori.
  • High priority: leak pada jalur penting, UB yang belum terbukti eksploitabel namun dapat menyebabkan crash atau korupsi state.
  • Known issue/debt: masalah yang sudah dipahami, punya tiket, dan sementara disuppress dengan alasan jelas.
  • False positive atau external issue: berasal dari dependency, toolchain, atau pola yang memang aman namun tidak dipahami tool.

Aturan triage yang sehat

  • Setiap temuan harus punya owner dan tiket.
  • Suppression harus minimal, terdokumentasi, dan ditinjau berkala.
  • Jangan menandai flaky crash sebagai false positive tanpa bukti reproduksi yang memadai.
  • Jika crash berasal dari dependency, catat versi dan status mitigasinya, misalnya patch lokal atau upgrade terjadwal.

Sanitizer memang bisa menghasilkan noise, tetapi pola paling berbahaya justru saat tim terlalu cepat menganggap semua noise sebagai gangguan. Biasakan membaca stack trace sampai frame aplikasi yang relevan, bukan berhenti pada frame library runtime.

Aturan merge gate yang realistis

Merge gate harus cukup tegas untuk mencegah regresi, tetapi cukup realistis agar tidak dilanggar lewat kebiasaan bypass.

Gate minimum yang direkomendasikan

  • Build debug dan release harus sukses.
  • Semua test inti pada job ASan/UBSan harus lulus.
  • Temuan sanitizer baru pada kode yang diubah harus memblokir merge.
  • Crash pada smoke fuzz target kritis harus memblokir merge.

Gate bertahap untuk basis kode lama

Pada sistem lama, sering sudah ada debt yang banyak. Dalam kasus ini, gunakan strategi no new failures:

  • Catat baseline temuan yang sudah ada.
  • Blokir hanya regresi baru terlebih dahulu.
  • Kurangi baseline sedikit demi sedikit lewat backlog yang diprioritaskan.

Ini lebih efektif daripada menunggu semua bug lama selesai sebelum menyalakan gate, karena penantian seperti itu biasanya berakhir dengan gate tidak pernah aktif.

Metrik yang perlu dipantau

Tanpa metrik, tim sulit tahu apakah audit CI benar-benar menurunkan risiko. Pantau beberapa indikator yang sederhana namun berguna:

  • Jumlah temuan sanitizer per minggu.
  • Waktu rata-rata dari temuan ke perbaikan.
  • Jumlah crash fuzz yang unik, bukan hanya total eksekusi.
  • Persentase target kritis yang sudah punya job sanitizer.
  • Persentase parser/protocol surface yang sudah punya harness fuzz.
  • Jumlah suppression aktif dan usianya.
  • Rasio kegagalan CI karena memory safety sebelum dan sesudah intervensi tertentu.

Hindari metrik vanity seperti sekadar jumlah test atau durasi fuzz tanpa korelasi ke kualitas temuan. Yang lebih penting adalah cakupan area rawan dan kemampuan reproduksi crash.

Checklist adopsi bertahap untuk tim C/C++

  1. Minggu 1-2: aktifkan warning compiler yang disepakati, rapikan build debug dan release, pastikan simbol debug tersedia.
  2. Minggu 2-3: tambahkan job ASan/UBSan untuk unit test dan integration test yang paling stabil.
  3. Minggu 3-4: aktifkan LSan jika cocok, buat artefak log dan prosedur reproduksi lokal.
  4. Minggu 4-6: pilih 1-3 target fuzz bernilai tinggi, misalnya parser file atau endpoint decoder.
  5. Minggu 6+: terapkan merge gate untuk regresi baru, mulai baseline debt lama, tambah target fuzz bertahap.
  6. Berikutnya: pantau metrik, kurangi suppression, dan dokumentasikan pola bug yang berulang.

Jika tim Anda belum pernah memakai sanitizer, jangan memulai dari semua test sekaligus. Mulailah dari jalur yang deterministik dan cepat agar sinyal awal bersih. Setelah itu, perluas cakupan secara bertahap.

Kapan sebagian modul layak dipindah ke Rust

Artikel ini fokus pada tindakan teknis di C/C++, tetapi ada kondisi saat sebagian modul memang layak dipindah ke Rust. Keputusannya sebaiknya berbasis risiko dan biaya, bukan tren semata.

Kandidat yang paling masuk akal

  • Parser input tak tepercaya: format file, payload jaringan, decoder, codec, atau komponen deserialisasi.
  • Komponen dengan riwayat bug memory safety berulang.
  • Modul dengan batas API yang jelas, sehingga integrasi FFI dapat dikontrol.
  • Bagian yang tidak terlalu terikat ke ABI internal yang rumit.

Kapan belum layak

  • Modul sangat bergantung pada pointer aliasing dan callback lama yang sulit dibungkus aman.
  • Tim belum siap memelihara boundary FFI dengan disiplin.
  • Masalah utama justru ada pada desain protokol, race condition, atau validasi bisnis, bukan memory safety murni.

Poin pentingnya: migrasi parsial ke Rust paling efektif bila dipakai untuk mengecilkan attack surface pada area input tak tepercaya. Sementara itu, pipeline sanitizer dan fuzzing tetap diperlukan untuk sisa basis kode C/C++ dan untuk boundary integrasi itu sendiri.

Debugging tips yang sering membantu

  • Jika stack trace sanitizer pendek atau aneh, pastikan simbol debug dan frame pointer tidak hilang.
  • Jika hasil sanitizer flaky, periksa apakah test bergantung pada urutan, thread scheduling, atau state global.
  • Jika leak terlalu banyak, fokus dulu pada leak di jalur test yang deterministik sebelum membersihkan semuanya.
  • Jika fuzz target lambat, kecilkan ruang lingkup fungsi yang diuji dan kurangi I/O.
  • Jika CI terlalu mahal, jalankan subset sanitizer pada PR dan campaign yang lebih berat secara terjadwal.

Penutup

Perbedaan tren CVE memory safety antara Rust dan C/C++ memberi pengingat yang jelas: kelas bug memori di C/C++ nyata, berulang, dan mahal jika baru ditemukan setelah rilis. Namun, respons yang paling berguna untuk tim pemelihara C/C++ adalah membangun audit CI untuk C/C++ yang disiplin: warning ketat, matrix debug/release, ASan/UBSan/LSan, fuzzing untuk surface input kritis, simbol crash yang dapat direproduksi, triage yang jelas, dan merge gate yang menolak regresi.

Jika Anda harus memilih prioritas, mulailah dari tiga hal: aktifkan ASan/UBSan pada test CI, simpan artefak crash yang dapat direproduksi, dan terapkan aturan no new memory-safety failures. Tiga langkah ini biasanya memberi dampak paling cepat sebelum tim melangkah ke fuzzing yang lebih matang atau migrasi modul berisiko tinggi ke Rust.