ภาพกราฟ dependency เป็นกล่อง package สีเขียว มี package สีอำพันหนึ่งก้อนถูกหยุดไว้หน้าด่านตรวจโปร่งใสก่อนเข้าส่วนที่เชื่อถือได้

arrayref ถูกแทรก Dependency อันตราย: เช็ก Rust Supply Chain อย่างไรไม่ให้ตื่นตูม

พี่โชว์Engineering

Dependency ที่เราไม่ได้เรียกตรง ๆ ก็รันโค้ดตอน build ได้ นี่คือเหตุผลที่ข่าว package เล็กหนึ่งตัวอาจกลายเป็น incident ของทีมใหญ่ แม้ source code ของแอปเราไม่เปลี่ยนแม้แต่บรรทัดเดียวครับ

Rust Security Response Team รายงานเมื่อ 20 สิงหาคม 2026 ว่า crate ชื่อ proc-macro1 มี build script ดาวน์โหลด payload อันตราย ทีมพบต่อว่า arrayref, internment และ append-only-vec ถูก republish ให้พึ่งพา crate ดังกล่าว โดยเชื่อว่าเครื่องหรือ credential ของผู้ดูแลเดิมน่าจะถูก compromise ไม่ใช่ว่าผู้ดูแลตั้งใจโจมตี

เวอร์ชันที่ระบุชัดคือ [email protected], [email protected] และ [email protected] ซึ่งอยู่บน crates.io ราว 86–107 นาทีก่อนถูกลบ ยังมีชื่อ package เลียนแบบอีกหกชื่อที่ประกาศว่าไม่ควรมีทุกเวอร์ชัน ได้แก่ proc-macro1, proc-macro-en, aovine, arone, aronenao และ tinymember

WHY: Lockfile ล็อกของดีและของร้ายได้เท่ากัน

Cargo.lock ทำให้ build วันนี้ใช้ dependency graph เดิมกับเมื่อวาน นี่ดีต่อ reproducibility แต่ถ้าช่วงที่ resolve dependency ดึงเวอร์ชันอันตรายเข้ามา lockfile ก็จะจำมันอย่างซื่อสัตย์ การ yank หรือลบจาก registry ช่วยหยุดการ resolve ใหม่ แต่ไม่ใช่เครื่องย้อนเวลาไปลบไฟล์ใน cache หรือหลักฐานว่า build เก่าไม่เคยรัน script นั้น

จึงต้องถามสามคำถามแยกกัน: graph ของโครงการอ้างถึงเวอร์ชันอันตรายไหม, cache เครื่อง build เคยรับ archive นั้นไหม และมี build ใดรันระหว่างหน้าต่างเหตุการณ์หรือไม่ อย่าใช้คำตอบ “ตอนนี้ cargo update ไม่ดึงแล้ว” แทนทั้งสามข้อ

HOW: สแกน Cargo.lock แบบทดสอบได้

ตัวอย่างนี้อ่าน package stanza จากข้อความ Cargo.lock แล้วตรวจทั้งคู่ชื่อ–เวอร์ชันที่ยืนยัน และชื่อเลียนแบบซึ่งถือว่าอันตรายทุกเวอร์ชัน Test แรกพิสูจน์ว่าเจอรายการจริง Test ที่สองกัน false positive กับเวอร์ชันปลอดภัย และ test ที่สามตรวจกลุ่มชื่ออันตรายทุกเวอร์ชัน

const BAD_EXACT: &[(&str, &str)] = &[
    ("append-only-vec", "0.1.9"),
    ("arrayref", "0.3.10"),
    ("internment", "0.8.7"),
];

const BAD_ANY_VERSION: &[&str] = &[
    "proc-macro1",
    "proc-macro-en",
    "aovine",
    "arone",
    "aronenao",
    "tinymember",
];

fn field<'a>(stanza: &'a str, key: &str) -> Option<&'a str> {
    stanza.lines().find_map(|line| {
        let (left, right) = line.split_once('=')?;
        (left.trim() == key).then(|| right.trim().trim_matches('"'))
    })
}

fn contains_known_bad(lock: &str) -> bool {
    lock.split("[[package]]").skip(1).any(|stanza| {
        let Some(name) = field(stanza, "name") else {
            return false;
        };
        let Some(version) = field(stanza, "version") else {
            return false;
        };

        BAD_ANY_VERSION.contains(&name)
            || BAD_EXACT.contains(&(name, version))
    })
}

fn main() {}

#[test]
fn finds_confirmed_arrayref_version() {
    let lock = r#"
[[package]]
name = "arrayref"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
"#;
    assert!(contains_known_bad(lock));
}

#[test]
fn allows_unaffected_arrayref_version() {
    let lock = r#"
[[package]]
name = "arrayref"
version = "0.3.9"
"#;
    assert!(!contains_known_bad(lock));
}

#[test]
fn rejects_typosquat_at_any_version() {
    let lock = r#"
[[package]]
name = "proc-macro1"
version = "9.9.9"
"#;
    assert!(contains_known_bad(lock));
}

โค้ดนี้เป็น detector แบบแคบตาม advisory วันที่ตรวจสอบ ไม่ใช่ antivirus และไม่ควรแทนรายการจาก Rust team ในอนาคต จุดที่ตั้งใจพิสูจน์คือเราแปลง bulletin เป็น regression test ได้ โดยมีเคสที่ควรเจอและไม่ควรเจอจริง

ใน repository จริง cargo metadata ให้ dependency graph ในรูป machine-readable เหมาะกับ CI มากกว่าการเดาจากชื่อ directory ส่วน cargo tree -i <crate> ช่วยไล่ย้อนว่า package ไหนดึง dependency นั้นมา ควรเก็บผลพร้อม commit SHA และ lockfile ที่ตรวจ เพื่อไม่ให้รายงานเหตุปะปนหลาย revision

แผนรับมือโดยไม่ล้างทุกอย่างทิ้ง

เริ่มจากหยุด build ที่กำลัง resolve dependency ใหม่และเก็บหลักฐานก่อน ตรวจ Cargo.lock, cache ตามคำสั่งใน advisory และ log ของ runner แยกเครื่อง developer กับ CI ถ้าพบ indicator ให้ quarantine runner นั้นและหาว่า build script รันเมื่อใด ก่อนหมุน credential ตามขอบเขตที่ runner เข้าถึง อย่าหมุนทุก secret ของบริษัทโดยไม่มีแผน เพราะอาจทำระบบล่มและทำลาย timeline ที่กำลังสืบสวน

หลังยืนยันผล ให้ pin dependency กลับเวอร์ชันที่ปลอดภัย ตรวจ diff ของ lockfile และ rebuild บน runner สะอาดที่ใช้ frozen/locked dependency ต้นทางได้ลบเวอร์ชันอันตรายและจัดการบัญชีแล้ว แต่ทีมเรายังต้องตอบให้ได้ว่า artifact ใดสร้างจาก graph ช่วงนั้นบ้าง

ระยะยาวให้แยกสิทธิ์ build จาก deploy, ใช้ credential อายุสั้น, จำกัด network ของ build script, เก็บ provenance ของ artifact และตั้ง policy ว่า dependency ใหม่ต้องมีเวลารอหรือ review เพิ่ม สิ่งเหล่านี้ไม่ทำให้ supply chain ปลอดภัยสมบูรณ์ แต่ลดระยะที่ package หนึ่งตัวเดินจาก registry ไปถึง production

Recap

เหตุ arrayref เตือนว่า package ยอดนิยมก็ถูกโจมตีผ่านบัญชีหรือเครื่องของผู้ดูแลได้ และเวลาที่ package ออนไลน์เพียงชั่วโมงกว่าไม่ได้แปลว่าไม่มีใครดึงไป

วิธีที่ไม่ตื่นตูมคือแยก graph, cache และ execution ออกจากกัน ตรวจด้วย revision ที่ระบุได้ เก็บหลักฐานก่อนแก้ และหมุน credential ตาม blast radius เมื่อ WHY คือรู้ว่าอะไรเกิดขึ้นจริง HOW ก็ต้องมากกว่าการลบ cache แล้วหวังว่าเรื่องจบครับ