Petunjuk Keamanan untuk Rust

Batas unsafe dan risiko rantai pasokan crate.

Daftar isi

Topik ini membahas dua sumber risiko yang menentukan apakah jaminan keamanan Rust benar-benar berlaku pada sebuah proyek: kode yang keluar dari pengawasan kompilator melalui unsafe dan FFI, serta kode yang masuk dari luar proyek melalui dependensi crate. Pembahasan mencakup makna kontrak soundness pada blok unsafe, pola pembungkusan yang menjaga jaminan tetap utuh, vektor serangan pada rantai pasokan (supply chain) crate, serta kebiasaan praktis untuk menahan keduanya. Setelah membaca topik ini, keputusan memakai unsafe maupun menambah dependensi dapat diambil dengan dasar yang dapat diperiksa ulang.

Dua sumber risiko#

Jaminan keamanan memori pada Rust bukan sifat bawaan binari, melainkan hasil pemeriksaan yang berjalan pada waktu kompilasi atas kode yang dilihat kompilator. Dua pintu dapat melewati pemeriksaan itu. Pintu pertama adalah unsafe beserta FFI: penulis sendiri menyatakan bahwa sebuah operasi dijamin benar, sehingga kompilator berhenti memverifikasinya. Pintu kedua adalah dependensi: baris-baris kode asing ikut terkompilasi ke dalam binari dengan hak akses yang sama seperti kode sendiri. Tabel berikut merangkum wilayah risiko tersebut.

WilayahYang berhenti diperiksaYang memegang kendali
Blok unsafe pada kode sendiripemeriksaan batas dan aliasing pada operasi tertentukontrak yang ditulis dan diuji penulis
FFI ke pustaka C atau C++seluruh jaminan memori pada batas ABIdokumentasi pustaka dan disiplin pemanggil
Crate pihak ketigakepercayaan bahwa kode asing berperilaku baikproses pemilihan, penguncian versi, dan audit

Kedua pintu itu tidak setara derajatnya. Blok unsafe pada umumnya berukuran beberapa baris dan dapat ditinjau satu per satu, sedangkan satu dependensi kecil dapat menarik belasan crate transitif yang tidak pernah dibaca siapa pun. Karena itu pengelolaan keduanya memakai cara yang berbeda: unsafe dikelola dengan kontrak dan pengujian, sedangkan rantai pasokan dikelola dengan kebijakan dependensi. Bagian berikut membahas keduanya secara berurutan.

Batas unsafe dalam praktik#

Blok unsafe tidak mematikan pemeriksaan kompilator secara global. Blok itu hanya membuka segelintir kemampuan yang kode aman tidak boleh miliki, antara lain membaca dan menulis melalui pointer mentah, memanggil fungsi unsafe, mengakses static yang dapat diubah, dan mengimplementasikan trait unsafe. Selebihnya borrow checker, pemeriksaan tipe, dan pemeriksaan batas tetap bekerja seperti biasa, termasuk pada kode lain di dalam blok yang sama. Tanggung jawab tersebut menegakkan soundness, yaitu jaminan bahwa pembungkus unsafe memenuhi setiap kontrak operasinya, sehingga pemanggil pembungkus tetap tidak mungkin memicu undefined behavior.

Program berikut memperlihatkan pola pembungkus yang paling sederhana, yaitu pengambilalihan pemeriksaan batas dari kompilator. Metode get pada slice tetap memeriksa batas secara bawaan, sedangkan fungsi elemen_pada membangun jalur kedua lewat get_unchecked yang melewatkan pemeriksaan itu.

rust batas-unsafe.rs
fn main() {
    let nilai = [70, 80, 90, 100];

    // jalur aman: pemeriksaan batas dilakukan oleh metode get
    println!("get(2)           : {:?}", nilai.get(2));
    println!("get(100)         : {:?}", nilai.get(100));

    // jalur unsafe: pemeriksaan batas ditulis dengan tangan di dalam fungsi
    println!("elemen_pada(2)   : {:?}", elemen_pada(&nilai, 2));
    println!("elemen_pada(100) : {:?}", elemen_pada(&nilai, 100));
}

/// Mengambil elemen larik pada posisi tertentu.
///
/// Mengembalikan `None` apabila `posisi` tidak lebih kecil daripada panjang `data`.
fn elemen_pada(data: &[i32], posisi: usize) -> Option<i32> {
    if posisi < data.len() {
        // kontrak get_unchecked terpenuhi sehingga pemanggilannya sah
        Some(unsafe { *data.get_unchecked(posisi) })
    } else {
        None
    }
}
text Keluaran
get(2)           : Some(90)
get(100)         : None
elemen_pada(2)   : Some(90)
elemen_pada(100) : None

Keempat baris keluaran tersebut identik hasilnya, dan itulah inti polanya: selama kontrak dijaga, jalur unsafe berperilaku sama dengan jalur aman. Hapus baris pemeriksaan posisi < data.len() dan program tetap terkompilasi tanpa satu peringatan, karena yang dilanggar bukan lagi aturan bahasa yang diperiksa kompilator, melainkan kontrak get_unchecked yang hanya dibaca manusia. Fragmen berikut memperlihatkan pelanggaran semacam itu.

rust Kontrak yang dilanggar
// tidak-dijalankan karena membaca di luar batas adalah perilaku tak terdefinisi.
let nilai = [70, 80, 90, 100];
// 100 melebihi panjang larik sehingga kontrak get_unchecked dilanggar
let elemen = unsafe { *nilai.get_unchecked(100) };
println!("{elemen}");

Pada praktik yang sehat, setiap fungsi yang membungkus unsafe menuliskan kontraknya sebagai bagian # Safety pada dokumentasi, dan jalur tersebut ditutupi pengujian khusus. Alat Miri turut membantu dengan menjalankan pengujian pada penerjemah yang mendeteksi pelanggaran umum seperti akses di luar batas dan aliasing ilegal, sehingga kesalahan yang tidak menimbulkan gejala pada mesin asli tertangkap sebelum dikirim.

Rantai pasokan crate#

Cargo membuat penambahan kode asing menjadi sangat mudah, dan kemudahan itu mempunyai sisi yang perlu dikelola. Setiap dependensi dijalankan dengan hak yang sama seperti kode sendiri: membaca berkas, membuka jaringan, menjalankan proses. Serangan pada ekosistem paket modern umumnya tidak menyerang kode korban, melainkan jalur pengirimannya, sehingga tiga vektor berikut termasuk yang paling sering dibahas pada ekosistem Rust.

VektorCara kerjaPengurangan risiko
Pemalsuan nama (typosquatting)nama yang mirip crate populer diunggah ke crates.ioperiksa ejaan dan riwayat unduhan sebelum menambah crate
Akun perawat yang dibajakversi berisi muatan jahat diterbitkan dari akun sahkunci versi lewat Cargo.lock, naik versi secara sadar
Skrip build.rskode dependensi dijalankan pada saat kompilasitinjau crate baru dan kecilkan jumlah dependensi

Alat utama untuk memeriksa dependensi adalah cargo-audit: subcommand Cargo yang membandingkan daftar dependensi proyek dengan basis data advisori RustSec, lalu melaporkan setiap versi yang diketahui bermasalah beserta tingkat keparahannya. Perintahnya sederhana, tetapi pemasangannya membutuhkan unduhan crate eksternal sehingga tidak dijalankan pada lingkungan topik ini.

bash
# tidak-dijalankan karena cargo-audit belum terpasang di lingkungan ini.
cargo install cargo-audit
cargo audit

Sejumlah alat melengkapi cargo-audit untuk kebijakan yang lebih ketat. Perintah cargo tree menggambar seluruh pohon dependensi transitif sehingga jumlah crate yang sebenarnya ikut terkompilasi tampak jelas. Alat cargo-deny menolak dependensi berdasarkan daftar terlarang dan ketentuan lisensi, sedangkan cargo-vet menuntun pencatatan peninjauan manual antar anggota tim. Ketiganya umumnya dijalankan sebagai gerbang pada pipeline CI bersama pemeriksaan bawaan Cargo.

Kebijakan praktis#

Kebiasaan berikut menahan kedua sumber risiko sekaligus tanpa membutuhkan alat yang rumit. Sebagian diterapkan sekali pada awal proyek, sedangkan sisanya menjadi gerbang yang berjalan pada setiap perubahan.

  1. Nyatakan niat unsafe sejak awal: crate murni aman memasang #![forbid(unsafe_code)] agar setiap pelanggaran menggagalkan kompilasi, sedangkan crate yang memang membutuhkannya mengumpulkan unsafe pada modul sempit dengan kontrak terdokumentasi.
  2. Kecilkan permukaan dependensi: tinjau kebutuhan, riwayat unduhan, dan pemeliharaan setiap crate baru sebelum masuk, karena pohon transitif ikut membawa risikonya.
  3. Kunci versi lewat berkas Cargo.lock dan komit berkas itu pada proyek aplikasi, sehingga naik versi menjadi keputusan yang disengaja alih-alih kejutan pada pembangunan ulang.
  4. Jalankan pemeriksaan sebagai gerbang: cargo fmt --check, cargo clippy, pengujian, dan pemindaian dependensi dijalankan pada setiap perubahan sebelum digabungkan.
  5. Uji jalur unsafe dengan Miri pada pengujian lokal agar pelanggaran kontrak yang tidak menimbulkan gejala tetap tertangkap sebelum pengiriman.

Kesalahan umum#

Menaruh unsafe tanpa kontrak tertulis. Pembungkus yang tidak mendokumentasikan syaratnya memindahkan beban pemeriksaan ke setiap pemanggil, dan satu pemanggil yang lengah cukup untuk merusak memori. Tuliskan bagian # Safety pada dokumentasi fungsi yang membungkus unsafe, lalu jaga agar bloknya sekecil mungkin.

Menganggap Cargo.lock dapat diabaikan pada aplikasi. Kunci yang tidak dikomit membuat mesin CI dan mesin rekan kerja membangun dengan versi dependensi yang berbeda-beda, sehingga hasil pemindaian audit pun tidak menggambarkan binari yang benar-benar dikirim. Komit berkas itu pada proyek aplikasi, dan perlakukan pembaruan versi sebagai perubahan yang ditinjau.

Menyamakan keamanan bahasa dengan keamanan ekosistem. Keamanan memori Rust berlaku atas kode yang diperiksa kompilator, bukan atas niat pembuat crate, sehingga jumlah unduhan yang besar tidak menjamin apa pun. Tetapkan proses pemeriksaan dependensi yang sama ketatnya dengan proses tinjauan kode sendiri.

Ringkasan#

  • Jaminan Rust berlaku atas kode yang diperiksa kompilator, dan dua pintu keluarnya adalah unsafe bersama FFI serta dependensi pihak ketiga.
  • Pembungkus unsafe yang sehat menjaga kontrak operasinya sehingga pemanggil tetap tidak mungkin memicu undefined behavior, dan kontrak itu ditulis sebagai dokumentasi.
  • Rantai pasokan crate dikelola dengan meminimalkan dependensi, mengunci versi pada Cargo.lock, dan memindai advisori melalui cargo-audit.
  • Gerbang CI yang memuat fmt, clippy, pengujian, dan pemindaian dependensi menjaga kedua sumber risiko tetap terpantau pada setiap perubahan.

Referensi#