SSD Nodes Learn 🎉 VPS เริ่ม $5.50/เดือน
คู่มือ Matt Connorโดย Matt Connor

วิธีป้องกัน npm supply-chain attack บน Node.js server

เรียนรู้วิธีที่การโจมตีผ่าน npm เข้าถึง Node.js บน VPS ของคุณ ทั้งจาก malicious patch, postinstall scripts และ typosquats พร้อมวิธีตั้งค่า deploy ที่ปลอดภัยที่สุด

การโจมตีผ่านห่วงโซ่อุปทานของ npm บนเซิร์ฟเวอร์ของคุณ

การโจมตีผ่านห่วงโซ่อุปทาน (supply-chain attack) ของ npm เข้าถึงเซิร์ฟเวอร์ของคุณผ่านแพ็กเกจที่คุณเลือกติดตั้ง โดยไม่มีการเปิดพอร์ตหรือขั้นตอนการเจาะระบบเข้ามาเกี่ยวข้อง npm (node package manager) ทำหน้าที่ติดตั้งโค้ด และการติดตั้งโค้ดคือการรันโค้ด ดังนั้นแอปพลิเคชัน Node ขนาดเล็กจึงดึงแพ็กเกจหลายร้อยรายการที่คุณไม่เคยอ่านโค้ดเข้ามา และแพ็กเกจใดก็ตามในนั้นสามารถเผยแพร่เวอร์ชันใหม่ได้ทุกเมื่อ

กระบวนการ deploy ของคุณจะดึงเวอร์ชันที่เป็นอันตรายเข้ามาเพราะคำสั่งติดตั้งของคุณระบุให้ใช้เวอร์ชันล่าสุดที่ตรงตามเงื่อนไข โค้ดดังกล่าวจะทำงานด้วยสิทธิ์ของผู้ที่รันคำสั่งติดตั้ง ทุกสิ่งที่ตามมาหลังจากนี้ล้วนเป็นผลลัพธ์จากสองประโยคข้างต้น

รูปแบบการโจมตีเหล่านี้เรียงลำดับตามความถี่ที่ส่งผลกระทบต่อบุคคลที่ deploy แอป Node หนึ่งแอปไปยัง VPS หนึ่งเครื่อง ลำดับนี้ไม่ใช่ลำดับที่บริษัทขนาดใหญ่ใช้ เนื่องจากบริษัทขนาดใหญ่มี registry ภายใน มีทีมตรวจสอบ และมี mirror ของ public registry ส่วนคุณมีเพียงสคริปต์สำหรับ deploy เท่านั้น

รูปแบบที่ 1: บัญชีผู้ดูแลถูกบุกรุกและเผยแพร่แพตช์

npm registry ไม่อนุญาตให้ผู้ใดเปลี่ยนแปลงเนื้อหาของเวอร์ชันที่มีอยู่แล้ว ผู้โจมตีที่ใช้วิธีฟิชชิงผู้ดูแลหรือขโมย publish token จึงไม่สามารถเขียนทับ 4.18.2 ได้ พวกเขาจึงเผยแพร่ 4.18.3 แทน

ให้ตรวจสอบ package.json ของคุณ บรรทัดอย่างเช่น "express": "^4.18.2" ไม่ได้หมายถึงเวอร์ชัน 4.18.2 เพียงอย่างเดียว เครื่องหมาย caret หมายถึง "เวอร์ชัน 4.x ใดๆ ที่เท่ากับหรือสูงกว่าเวอร์ชันนี้" และ ~4.18.2 หมายถึง "เวอร์ชัน 4.18.x ใดๆ" npm install จะทำการระบุเวอร์ชันที่เจาะจงจากช่วงดังกล่าวในขณะที่รันคำสั่ง ดังนั้น git commit เดียวกันที่ถูก deploy สองครั้งในช่วงบ่ายวันเดียวกัน อาจติดตั้งชุดโค้ดที่แตกต่างกันได้ ช่องว่างนี้คือพื้นที่ที่ถูกโจมตีได้ โดยที่คุณไม่จำเป็นต้องถูกบุกรุกเครื่องเลยแม้แต่น้อย

การเผยแพร่ซอฟต์แวร์ที่เป็นอันตรายมักจะถูกรายงานและลบออก แต่การลบจะเกิดขึ้นหลังจากที่มีคนติดตั้งไปแล้ว ใครก็ตามที่ deploy ในช่วงเวลานั้นจะมีโค้ดดังกล่าวอยู่ในดิสก์ ระบบ pipeline ที่ทำการระบุเวอร์ชันจากช่วง (range) ในทุกครั้งที่รัน จะเข้าสู่ช่วงเวลาเสี่ยงนี้โดยอัตโนมัติสัปดาห์ละหลายครั้ง โดยที่ไม่มีใครตัดสินใจให้เกิดขึ้น

รูปแบบที่ 2: สคริปต์ติดตั้งทำงานในฐานะผู้ใช้ที่ทำการ deploy

package.json ของแพ็กเกจสามารถประกาศ preinstall, install, postinstall และ prepare ไว้ในบล็อก scripts ได้ npm จะรันคำสั่งเหล่านี้ระหว่างการติดตั้ง สคริปต์เหล่านี้ไม่มีการทำ sandbox และไม่มีใครตรวจสอบเนื้อหาภายใน มันคือคำสั่ง shell ที่ทำงานภายใต้สิทธิ์ของผู้ใช้ที่พิมพ์คำสั่งติดตั้ง โดยทำงานใน home directory ของผู้ใช้นั้น เข้าถึงเครือข่ายได้ตามสิทธิ์ของผู้ใช้ และใช้ environment ทั้งหมดของ shell นั้น

ดังนั้น คำถามที่สำคัญไม่ใช่ว่าแพ็กเกจสามารถทำอะไรได้บ้าง แต่คือผู้ใช้นั้นสามารถอ่านไฟล์ใดได้บ้าง บนเครื่อง deploy ทั่วไป คำตอบจะรวมถึง ~/.npmrc ที่เก็บ registry token, ~/.ssh/id_ed25519 ที่ใช้เป็น deploy key สำหรับ SSH (secure shell), ~/.aws/credentials, ~/.docker/config.json และตัวแปรทุกตัวที่ถูก export ไว้ใน shell ซึ่งมักเป็นที่อยู่ของ DATABASE_URL

payload ลักษณะนี้ไม่จำเป็นต้องคงอยู่ถาวรและไม่จำเป็นต้องยกระดับสิทธิ์ มันเพียงแค่อ่านไฟล์ไม่กี่ไฟล์ ส่งไปยังโฮสต์ผ่าน HTTPS แล้วจบการทำงานด้วยสถานะ 0 คุณจะไม่เห็นสิ่งผิดปกติใดๆ เพราะ npm ซ่อน output ของสคริปต์ติดตั้งไว้เป็นค่าเริ่มต้น ให้ปิดการตั้งค่านี้เพื่อดูว่ามีอะไรทำงานอยู่จริง:

npm ci --foreground-scripts

foreground-scripts จะใช้ standard input, output และ error ร่วมกับกระบวนการของ npm ดังนั้น build script จะพิมพ์ข้อความลงใน terminal ของคุณโดยตรง แทนที่จะไปอยู่ใน buffer ที่ npm จะลบทิ้งเมื่อการติดตั้งสำเร็จ

รูปแบบที่ 3: typosquats และชื่อที่คุณพิมพ์ผิดไปเพียงเล็กน้อย

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

รูปแบบที่มักจะส่งผลกระทบต่อทีมมากกว่ารายบุคคลคือ dependency confusion หากแพ็กเกจภายในของคุณชื่อ billing-utils และเก็บไว้ใน registry ส่วนตัว แต่ไม่มีแพ็กเกจชื่อ billing-utils อยู่ใน registry สาธารณะ ใครก็ตามสามารถเผยแพร่แพ็กเกจชื่อนั้นขึ้นไปได้ โดยปกติ npm จะค้นหาชื่อที่ไม่มี scope ผ่าน registry สาธารณะที่เป็นค่าเริ่มต้น ทำให้แพ็กเกจที่อยู่ใน registry สาธารณะอาจถูกดึงมาใช้แทน วิธีแก้ไขคือการใช้ scope ที่คุณเป็นเจ้าของร่วมกับการกำหนด registry mapping สำหรับ scope นั้นในไฟล์ .npmrc:

@yourorg:registry=https://npm.yourorg.example/
//npm.yourorg.example/:_authToken=${NPM_TOKEN}

ตอนนี้ @yourorg/billing-utils จะถูกดึงมาจากโฮสต์นั้นเสมอ เพราะระบบจะตรวจสอบการจับคู่ระหว่าง scope กับ registry ก่อนที่จะไปที่ registry หลัก ส่วนชื่อภายในที่ไม่มี scope จะไม่มีการกำหนด mapping ไว้ จึงไม่มีการป้องกันในส่วนนี้

ก่อนที่คุณจะเพิ่ม dependency ใหม่ ให้ตรวจสอบรายละเอียดของแพ็กเกจนั้นแทนที่จะดูเพียงแค่ป้ายแสดงจำนวนการดาวน์โหลด:

npm view some-lib repository.url maintainers time.created time.modified

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

รูปแบบที่ 4: dependency ที่เจ้าของเปลี่ยนไปโดยไม่มีการแจ้งเตือน

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

นี่เป็นรูปแบบที่เกิดขึ้นช้าที่สุดและตรวจพบได้ยากที่สุด โดยไม่มีคำสั่งใดที่สามารถตรวจสอบเรื่องนี้ได้โดยตรง มีสองสิ่งที่ช่วยจำกัดขอบเขตปัญหานี้ได้ ก่อนที่คุณจะเลือกใช้แพ็กเกจใด ให้ตรวจสอบว่าใครมีสิทธิ์เผยแพร่แพ็กเกจนั้นด้วยบรรทัด npm view ที่ระบุไว้ข้างต้น จากนั้นให้ตรวจสอบความแตกต่าง (diff) เมื่อแพ็กเกจที่คุณใช้งานจริงมีการอัปเดต:

npm diff --diff-name-only --diff=some-lib@1.4.2 --diff=some-lib@1.4.3
npm diff --diff=some-lib@1.4.2 --diff=some-lib@1.4.3

รูปแบบแรกจะแสดงเฉพาะชื่อไฟล์ที่มีการเปลี่ยนแปลง ซึ่งรวดเร็วพอที่จะทำได้ทุกครั้งที่มีการอัปเกรดแพ็กเกจที่คุณให้ความสำคัญ การอัปเดตแบบ patch release ที่มีการแก้ไข build script, การเพิ่มไฟล์ที่ root ของแพ็กเกจ หรือการแก้ไขบล็อก scripts เป็นสิ่งที่ควรตรวจสอบอย่างละเอียดก่อนที่จะนำไปใช้งานบนเซิร์ฟเวอร์ของคุณ

การสร้างโปรเจกต์จาก lockfile ที่คอมมิตไว้ด้วย npm ci

package-lock.json จะบันทึกเวอร์ชันที่แน่นอนของทุกแพ็กเกจใน dependency tree, URL ต้นทาง, ค่า sha512 integrity hash ของแต่ละ tarball และระบุว่าแพ็กเกจใดเป็นตัวเรียกใช้งาน ให้คอมมิตไฟล์นี้ไว้ เพราะเป็นไฟล์เดียวที่ยืนยันได้ว่าคุณได้ทดสอบอะไรไปจริง ๆ

จากนั้นให้ติดตั้งด้วย npm ci เสมอ ห้ามใช้ npm install บนเครื่องใดก็ตามที่ไม่ใช่เครื่องของนักพัฒนา:

npm ci --omit=dev --ignore-scripts

npm ci แตกต่างจาก npm install ในประเด็นสำคัญทั้งหมดนี้ โดย npm ci จำเป็นต้องมี lockfile อยู่ก่อนเสมอ มันจะลบโฟลเดอร์ node_modules ที่มีอยู่เดิมทิ้งก่อนเริ่มการทำงาน เพื่อป้องกันไม่ให้ไฟล์ตกค้างจากการ deploy ครั้งก่อนหลุดรอดเข้ามาในการ deploy ครั้งนี้ นอกจากนี้มันจะไม่เขียนข้อมูลลงใน package.json หรือแก้ไข lockfile ดังนั้นการติดตั้งจะไม่ทำให้เวอร์ชันของแพ็กเกจขยับขึ้นโดยที่คุณไม่รู้ตัว หาก lockfile และ package.json ไม่ตรงกัน ระบบจะหยุดทำงานและแสดงข้อผิดพลาดแทนที่จะพยายามแก้ไขความแตกต่างนั้นเอง

ข้อผิดพลาดดังกล่าวคือฟีเจอร์ ไม่ใช่เรื่องน่ารำคาญ เพราะมันหมายความว่าการเปลี่ยนแปลง dependency จะต้องเกิดขึ้นผ่านการคอมมิตที่มีคนตรวจสอบแล้วเท่านั้น ไม่ใช่ผลข้างเคียงที่เกิดขึ้นโดยไม่ตั้งใจระหว่างการ deploy ตอนตี 2

ค่า integrity hash จะถูกตรวจสอบทุกครั้งที่มีการดึงข้อมูล หาก tarball ใดมีไบต์ไม่ตรงกับ hash ที่บันทึกไว้ การติดตั้งจะล้มเหลวด้วย code EINTEGRITY แทนที่จะแตกไฟล์ออกมา เพื่อให้เข้าใจชัดเจนว่าสิ่งนี้ช่วยคุณได้อย่างไร: มันพิสูจน์ว่าไฟล์ที่คุณได้รับคือไฟล์เดียวกับที่ lockfile ระบุไว้ ซึ่งเป็นการรับประกันแบบเดียวกับที่ การตรวจสอบไฟล์ดาวน์โหลดด้วย checksum มอบให้ แต่ก็มีข้อจำกัดในลักษณะเดียวกัน คือมันไม่ได้บอกว่าเวอร์ชันที่ถูกระบุไว้นั้นมีเจตนาร้ายแฝงมาตั้งแต่ตอนเผยแพร่หรือไม่

รายละเอียดเพิ่มเติมเกี่ยวกับ --omit=dev: แพ็กเกจเหล่านั้นจะยังคงถูก resolve และบันทึกลงใน lockfile เพียงแต่จะไม่ถูกติดตั้งลงบนดิสก์ การมีแพ็กเกจบนดิสก์น้อยลงหมายถึงการรัน install script น้อยลงและมีการโหลดโค้ดขณะรันไทม์น้อยลง จึงเป็นสิ่งที่ควรทำ ทั้งนี้การตั้งค่าดังกล่าวไม่ได้เป็นการลบ dependency ออกจาก tree ของคุณแต่อย่างใด

ปฏิบัติต่อสคริปต์ติดตั้งเสมือนเป็นโค้ด และรู้วิธีปฏิเสธการใช้งาน

คุณสามารถปิดการทำงานของสคริปต์ติดตั้งได้ โดยเพิ่มค่านี้ลงใน .npmrc ของโปรเจกต์และคอมมิตไฟล์นี้ไว้คู่กับ lockfile:

ignore-scripts=true
save-exact=true

ignore-scripts=true จะหยุดไม่ให้ npm รันสคริปต์ที่ประกาศไว้ใน dependencies ส่วน save-exact=true จะทำให้ npm install some-lib เขียน 1.4.2 ลงใน package.json แทนที่จะเป็น ^1.4.2 เพื่อป้องกันไม่ให้ช่วงเวอร์ชันที่ผ่านการ resolve แล้วหลุดเข้าไปใน manifest ของคุณโดยไม่ตั้งใจ

การตั้งค่านี้อาจทำให้บางอย่างทำงานไม่ได้ และคุณควรทราบสาเหตุล่วงหน้าก่อนเปิดใช้งาน แพ็กเกจที่ต้องคอมไพล์ native addon หรือดาวน์โหลด binary ที่สร้างไว้ล่วงหน้าจะทำงานเหล่านั้นผ่านสคริปต์ติดตั้ง เมื่อปิดสคริปต์ การติดตั้งจะดูเหมือนสำเร็จ แต่จะเกิดข้อผิดพลาดในภายหลังขณะรันไทม์ เนื่องจากโมดูลไม่สามารถโหลดไฟล์ binding ได้ วิธีแก้ไขคือการใช้ allowlist:

npm ci --ignore-scripts
npm rebuild better-sqlite3

npm rebuild <package> จะรันสคริปต์ build เฉพาะสำหรับแพ็กเกจนั้นๆ คุณได้ตัดสินใจเลือกเป็นรายแพ็กเกจ แทนที่จะอนุญาตให้สิทธิ์การรันโค้ดแบบเหมาเข่งแก่คนแปลกหน้าหลายร้อยคนที่คุณไม่เคยรู้จัก

หากต้องการดูว่าปัจจุบันคุณให้สิทธิ์การรันโค้ดกว้างขวางเพียงใด ให้ใช้คำสั่ง npm:

npm query ":attr(scripts, [postinstall])"

คำสั่งนี้จะแสดงรายการแพ็กเกจทั้งหมดในโครงสร้างที่ติดตั้งไว้ซึ่งมีสคริปต์ postinstall อยู่ ในแอปพลิเคชันทั่วไป รายการนี้มักจะสั้นกว่าที่หลายคนคาดไว้ ซึ่งเป็นเหตุผลที่ทำให้การใช้ allowlist เป็นแนวทางที่ทำได้จริง

แยกกระบวนการ build ออกจากกระบวนการให้บริการ traffic

ผู้ใช้ที่ทำหน้าที่ deploy จำเป็นต้องมีสิทธิ์เขียนใน node_modules แต่กระบวนการที่ตอบสนองคำขอ HTTP ไม่จำเป็นต้องมีสิทธิ์นั้น หากทั้งสองกระบวนการใช้บัญชีผู้ใช้เดียวกัน โค้ดที่ทำงานระหว่างการติดตั้งจะสามารถเขียนทับโค้ดที่ให้บริการแก่ผู้ใช้ของคุณได้ และโค้ดที่ทำงานขณะรันไทม์ก็สามารถทำเช่นนั้นได้เช่นกัน

คุณควรแยกบัญชีผู้ใช้ โดยใช้ผู้ใช้หนึ่งสำหรับ build และอีกผู้ใช้หนึ่งสำหรับให้บริการ พร้อมทั้งกำหนดให้ไดเรกทอรีที่ให้บริการมีสิทธิ์อ่านได้อย่างเดียว (read-only) สำหรับบัญชีผู้ใช้ที่ให้บริการ:

sudo useradd --system --home-dir /srv/nodeapp --shell /usr/sbin/nologin nodeapp
sudo chown -R deploy:nodeapp /srv/nodeapp
sudo chmod -R o-rwx /srv/nodeapp

จากนั้นให้ systemd บังคับใช้กฎดังกล่าว โดยเขียนไฟล์ /etc/systemd/system/nodeapp.service ดังนี้:

[Unit]
Description=Node application
After=network-online.target

[Service]
User=nodeapp
Group=nodeapp
WorkingDirectory=/srv/nodeapp/current
EnvironmentFile=/etc/nodeapp/env
ExecStart=/usr/bin/node server.js
Restart=on-failure

NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/srv/nodeapp/shared
NoExecPaths=/srv/nodeapp/shared
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX

[Install]
WantedBy=multi-user.target

ProtectSystem=strict จะทำการ mount ระบบไฟล์ทั้งหมดเป็นแบบอ่านได้อย่างเดียวสำหรับ service นี้ ยกเว้น /dev, /proc, /sys และรายการใดก็ตามที่คุณระบุไว้ใน ReadWritePaths ดังนั้นหากแอปพลิเคชันพยายามเขียนข้อมูลลงใน node_modules จะล้มเหลวด้วยข้อผิดพลาด EROFS: read-only file system ซึ่งคุณสามารถตรวจสอบและจำลองเหตุการณ์นี้ได้ใน log ของคุณภายในเวลาไม่กี่นาที NoExecPaths จะครอบคลุมถึงไดเรกทอรีสำหรับอัปโหลดที่เขียนได้ โดย service จะสามารถเขียนไฟล์ลงในนั้นได้ แต่ kernel จะปฏิเสธการสั่งรันไฟล์เหล่านั้น ตัวเลือกนี้ต้องการ systemd เวอร์ชัน 249 ขึ้นไป ซึ่ง Ubuntu 24.04 มาพร้อมกับเวอร์ชัน 255

มีข้อควรระวังสองประการในไฟล์ unit นี้ ประการแรก ห้าม เพิ่ม MemoryDenyWriteExecute=yes ลงไป เนื่องจากมันปรากฏอยู่ในรายการ hardening ของ systemd ส่วนใหญ่ และมันจะทำให้ Node ไม่สามารถเริ่มทำงานได้ เพราะ V8 ต้องคอมไพล์ JavaScript เป็น machine code ในขณะรันไทม์ ซึ่งจำเป็นต้องใช้หน้าหน่วยความจำที่ทั้งเขียนได้และสั่งรันได้ ประการที่สอง ให้ใช้ path ของ ExecStart จาก command -v node หาก Node ถูกติดตั้งผ่านตัวจัดการเวอร์ชัน (version manager) มันจะอยู่ใน home directory ของผู้ใช้ที่ deploy ซึ่ง ProtectHome=yes จะซ่อนไดเรกทอรีนั้นจาก service ทำให้ unit ล้มเหลวทันทีด้วย status=203/EXEC และมีบรรทัด log แจ้งว่าไม่พบไฟล์ executable

ตรวจสอบผลลัพธ์แทนการเชื่อถือไฟล์เพียงอย่างเดียว:

sudo systemctl daemon-reload
sudo systemctl enable --now nodeapp
sudo systemd-analyze security nodeapp.service
sudo -u nodeapp touch /srv/nodeapp/current/probe

systemd-analyze security จะแสดงรายการการตั้งค่า hardening ทั้งหมดพร้อมระดับความเสี่ยง เพื่อให้คุณเห็นว่าการตั้งค่าใดที่ยังคงเป็นค่าเริ่มต้น touch ควรจะล้มเหลวด้วย Permission denied เพราะ nodeapp ไม่ได้เป็นเจ้าของไฟล์ใดๆ ภายใต้ current หากคำสั่งทำงานสำเร็จ แสดงว่าการกำหนดความเป็นเจ้าของไฟล์ของคุณไม่ถูกต้อง และการตั้งค่า systemd กำลังปกปิดปัญหานี้อยู่

ข้อควรทราบเกี่ยวกับ EnvironmentFile: systemd จะอ่านไฟล์นี้ในฐานะ root ก่อนที่จะเปลี่ยนสิทธิ์ไปเป็น User=nodeapp ดังนั้นไฟล์นี้จึงสามารถกำหนดสิทธิ์เป็น root:root ด้วยโหมด 600 ได้ โดยที่แอปพลิเคชันยังคงได้รับตัวแปรเหล่านั้น อย่างไรก็ตาม ผู้ที่มี shell ในฐานะ nodeapp ยังคงสามารถอ่านค่าจาก /proc/<pid>/environ ได้ ดังนั้นวิธีนี้จึงเป็นการป้องกันความลับในสถานะจัดเก็บ (at rest) ไม่ใช่การป้องกันกระบวนการที่กำลังทำงานอยู่

แยกข้อมูลรับรองสำหรับการ deploy ออกจากสภาพแวดล้อมการ build

สคริปต์ติดตั้งจะสืบทอดสภาพแวดล้อมการทำงานมาด้วย ข้อเท็จจริงนี้ควรเป็นตัวกำหนดว่าคุณควร build ซอฟต์แวร์ที่ใด

วิธีที่ปลอดภัยที่สุดคือการ build ในสถานที่อื่นที่ไม่ใช่เซิร์ฟเวอร์ production แล้วจึงคัดลอกไดเรกทอรีที่เสร็จสมบูรณ์ข้ามไป เครื่องที่ใช้ build ควรเก็บเพียง registry token แบบอ่านอย่างเดียวเท่านั้น โดยไม่มี SSH deploy key, cloud access key, รหัสผ่านฐานข้อมูล หรือข้อมูลล็อกอินของ container registry ใดๆ ทั้งสิ้น

npm token create --read-only

token แบบอ่านอย่างเดียวสามารถดึงแพ็กเกจได้แต่ไม่สามารถเผยแพร่ได้ หาก token นี้ถูกขโมยไปจากสภาพแวดล้อมการ build ความเสียหายที่เกิดขึ้นจะมีเพียงแค่การที่ผู้ไม่หวังดีสามารถดาวน์โหลดแพ็กเกจสาธารณะได้เท่านั้น

หากคุณจำเป็นต้อง build บนเซิร์ฟเวอร์ ให้ดำเนินการในฐานะผู้ใช้ deploy โดยจำกัดสภาพแวดล้อมให้แคบที่สุดเท่าที่จะทำได้ และเก็บข้อมูลลับที่ใช้ขณะรันไทม์ไว้ใน /etc/nodeapp/env ซึ่ง deploy ไม่สามารถอ่านได้ ตรรกะเดียวกันนี้ใช้กับระบบ build automation ที่คุณโฮสต์เอง: a self-hosted GitHub Actions runner จะถือครอง token และรันโค้ดที่เผยแพร่จากที่ใดก็ได้ในทุกงานที่ทำ ซึ่งทำให้มันกลายเป็นเครื่องที่มีมูลค่าสูงที่สุดในระบบขนาดเล็ก โปรแกรมใดก็ตามที่คุณไม่ได้เป็นผู้เขียนแต่ได้รับสภาพแวดล้อมทั้งหมดของคุณไป จะจัดอยู่ในหมวดหมู่เดียวกัน ซึ่งเป็นเหตุผลว่าทำไม การเก็บข้อมูลลับให้พ้นจากสภาพแวดล้อมของ AI agent จึงเป็นปัญหาเดียวกันนี้แต่เปลี่ยนโปรแกรมที่อยู่ตรงกลางไปเท่านั้น

ตรึงเวอร์ชันหรือทำ vendor สำหรับสิ่งที่คุณตรวจสอบไม่ได้

Dependency แบบตรึงเวอร์ชัน (pinned dependency) คือ dependency ที่เวอร์ชันจะไม่เปลี่ยนแปลงหากไม่มีการ commit ใหม่ ซึ่ง lockfile ที่ถูก commit ไว้จะทำหน้าที่นี้ให้กับ dependency ทั้งหมดใน tree อยู่แล้ว แต่มีสองกรณีที่ต้องการการจัดการเพิ่มเติม

กรณีแรกคือ transitive dependencies คุณไม่สามารถควบคุมได้ว่า dependency ของคุณจะเรียกใช้ package อะไรบ้าง overrides ใน package.json จะบังคับใช้เวอร์ชันที่กำหนดไว้ในทุกจุดของ tree:

{
  "overrides": {
    "some-transitive-lib": "1.4.2"
  }
}

ให้รัน npm install หนึ่งครั้งหลังจากเพิ่ม dependency เพื่อให้ lockfile บันทึกผลลัพธ์ จากนั้นจึง commit ไฟล์ทั้งสอง

กรณีที่สองคือ package ที่คุณไม่สามารถตรวจสอบโค้ดได้และไม่สามารถเลิกใช้งานได้ ให้ใช้วิธีทำ vendor (vendor it) คำสั่ง npm pack จะดาวน์โหลด tarball ฉบับเดียวกับที่ registry ให้บริการ และ dependency แบบ file: จะติดตั้งจากสำเนาที่คุณเก็บไว้:

npm pack some-lib@1.4.2
mkdir -p vendor && mv some-lib-1.4.2.tgz vendor/
{
  "dependencies": {
    "some-lib": "file:vendor/some-lib-1.4.2.tgz"
  }
}

ตอนนี้ tarball จะอยู่ใน repository ของคุณและจะไม่เปลี่ยนแปลงโดยที่คุณไม่รู้ตัว อย่างไรก็ตาม คุณจะต้องรับผิดชอบการอัปเดตด้วยตนเองตลอดไป ดังนั้นควรใช้วิธีนี้กับ package ขนาดเล็กที่ถูกทิ้งร้างซึ่งคุณจำเป็นต้องใช้เท่านั้น ไม่ควรใช้กับ web framework หลักของคุณ

นอกจากนี้ยังมีช่วงเวลาพัก (cooling-off period) ซึ่งไม่มีค่าใช้จ่ายในการใช้งาน:

npm install --before=2026-08-01

ตัวเลือก before จะสร้าง tree ขึ้นมาใหม่โดยใช้เฉพาะเวอร์ชันที่ถูกเผยแพร่ในหรือก่อนวันที่ระบุเท่านั้น ให้ตั้งค่าย้อนหลังไปหนึ่งหรือสองสัปดาห์เมื่อคุณอัปเดต dependency วิธีนี้จะช่วยให้คุณหลีกเลี่ยงช่วงเวลาที่ release ที่มีปัญหาถูกปล่อยออกมาแต่ยังไม่มีการรายงาน นี่เป็นเครื่องมือที่ค่อนข้างหยาบเพราะมันจะกั้นการอัปเดตความปลอดภัยที่จำเป็นไปด้วย ให้ใช้เพื่อแก้ไขช่วงเวอร์ชัน (ranges) อ่านสิ่งที่เปลี่ยนแปลง แล้วจึง commit lockfile

ฉันจะทราบได้อย่างไรว่าเวอร์ชันที่ติดตั้งจริงคือเวอร์ชันใด

ไฟล์ lockfile ใน git ระบุสิ่งที่ควรจะถูกติดตั้ง แต่สิ่งที่อยู่ในดิสก์คือสิ่งที่ถูกติดตั้งจริง หลักฐานที่เชื่อถือได้มีเพียงอย่างหลังเท่านั้น

npm ls some-lib
node -e "console.log(require('./node_modules/some-lib/package.json').version)"

npm ls จะอ่านค่าจาก node_modules จึงเป็นการรายงานสิ่งที่อยู่บนดิสก์จริงแทนที่จะเป็นสิ่งที่ lockfile ต้องการ บรรทัด node -e จะอ่าน manifest ที่ติดตั้งไว้ตาม path ซึ่งวิธีนี้ใช้ได้แม้กับแพ็กเกจที่ฟิลด์ exports ป้องกันการ import subpath และจะแสดงผลลัพธ์เป็นเวอร์ชันเดียวโดยไม่มีการวาดโครงสร้างต้นไม้

สำหรับการเปรียบเทียบอีกส่วนหนึ่ง ให้ตรวจสอบจาก git:

git log --oneline -- package-lock.json
git show <commit>:package-lock.json | grep -A3 '"node_modules/some-lib"'

สร้างความเชื่อมโยงระหว่างทั้งสองส่วนให้ถาวรโดยการนำ commit ไปไว้ใน deploy layout ทำการ release ลงใน /srv/nodeapp/releases/<short commit sha> แล้วชี้ /srv/nodeapp/current ไปยังตำแหน่งนั้นด้วย symlink คำตอบสำหรับคำถามที่ว่า "ขณะนี้กำลังรันเวอร์ชันใดอยู่" จะกลายเป็น readlink /srv/nodeapp/current ซึ่งบุคคลอื่นที่ไม่ใช่ผู้ deploy ก็สามารถตรวจสอบได้ในเวลา 03:00 น.

สุดท้าย ให้ตรวจสอบสิ่งที่ registry ยืนยัน:

npm audit signatures

คำสั่งนี้จะตรวจสอบลายเซ็นของ registry บนแพ็กเกจใน tree ที่ติดตั้งไว้ และตรวจสอบการรับรองแหล่งที่มา (provenance attestations) สำหรับแพ็กเกจที่มีข้อมูลดังกล่าว Provenance จะเชื่อมโยง tarball ที่เผยแพร่เข้ากับ build จากระบบ continuous integration (CI) ที่สร้างมันขึ้นมา ดังนั้นการรับรองที่ผ่านการตรวจสอบหมายความว่าคุณสามารถสืบย้อนโค้ดกลับไปยัง commit ได้ แทนที่จะเป็นแล็ปท็อปที่ไม่ทราบที่มา เนื่องจากข้อมูลนี้ไม่ได้ครอบคลุมทุกแพ็กเกจ หากพบว่าไม่มีการรับรอง ให้ถือว่า "ไม่มีข้อมูล" ไม่ใช่ "แพ็กเกจไม่ปลอดภัย"

สิ่งที่ควรทำเมื่อ release ที่มีปัญหาเข้าถึงเซิร์ฟเวอร์ของคุณได้

ให้เริ่มตรวจสอบจากสิ่งที่ทำงานอยู่และตรวจสอบว่าทำงานภายใต้ผู้ใช้ใด

หากโค้ดทำงานระหว่างการติดตั้ง ให้สันนิษฐานว่าทุกสิ่งที่ผู้ใช้ที่ทำหน้าที่ build สามารถอ่านได้นั้นถูกเข้าถึงแล้ว ให้ทำการหมุนเวียน (rotate) registry token, SSH keys ใน home directory นั้น, cloud credentials และความลับ (secret) ใดๆ ที่ถูก export ไว้ใน shell นั้น การหมุนเวียนข้อมูลเป็นวิธีเดียวที่แสดงความรับผิดชอบ เพราะคุณไม่สามารถพิสูจน์ได้ว่าไฟล์เหล่านั้นไม่ได้ถูกอ่านไป

หากโค้ดทำงานในขณะรันไทม์ภายใต้ service account ที่มีการจำกัดสิทธิ์ ขอบเขตที่เข้าถึงได้จะเล็กลงมาก นั่นคือ environment variables ของแอปพลิเคชันเองและทุกสิ่งที่การเข้าถึงเครือข่ายของแอปสามารถไปถึงได้ นี่คือเหตุผลทั้งหมดของการ รันบริการในฐานะผู้ใช้ที่ไม่มีสิทธิ์พิเศษบน VPS ซึ่งไม่ได้เป็นการป้องกันการถูกบุกรุก แต่เป็นการกำหนดว่าการบุกรุกนั้นจะส่งผลกระทบต่อเครื่องได้มากน้อยเพียงใด และจะยังคงอยู่หลังจากรีสตาร์ทหรือไม่

จากนั้นให้สร้างใหม่ (rebuild) แทนการทำความสะอาดไฟล์เดิม ให้ลบ node_modules, กำหนดเวอร์ชันของแพ็กเกจที่ได้รับผลกระทบให้ต่ำกว่าเวอร์ชันที่มีปัญหาใน package.json, รัน npm install หนึ่งครั้งเพื่ออัปเดต lockfile, ทำการ commit และ deploy ด้วย npm ci อย่าพยายามซ่อมแซมโครงสร้างไฟล์ในที่เดิม เพราะคุณไม่สามารถระบุได้ว่าสคริปต์การติดตั้งได้เข้าไปแก้ไขส่วนใดบ้าง

จดบันทึกช่วงเวลาที่เกิดเหตุการณ์ไว้ด้วย ได้แก่ การ deploy ครั้งแรกที่อาจดึงเวอร์ชันนั้นมา และการ deploy ครั้งที่นำเวอร์ชันนั้นออก ช่วงเวลาดังกล่าวจะบอกคุณว่าต้องอ่าน log ส่วนใดของคุณ และจะสามารถระบุได้ก็ต่อเมื่อคุณตั้งชื่อ release ตาม commit เท่านั้น

สิ่งที่วิธีเหล่านี้แก้ไขไม่ได้

Lockfile ไม่ได้ทำให้ dependency ปลอดภัยขึ้น แต่มันเปลี่ยนช่วงเวลาที่คุณยอมรับ dependency นั้นให้กลายเป็นการตัดสินใจที่ผ่านการตรวจสอบและระบุวันที่ชัดเจน แทนที่จะเป็นผลพลอยได้จากการ deploy ทุกแนวทางปฏิบัติข้างต้นล้วนเปลี่ยนสิ่งที่เป็นอุบัติเหตุให้กลายเป็นการเลือกโดยเจตนา

npm audit ไม่ใช่การป้องกันในกรณีนี้ เครื่องมือนี้เพียงแค่เปรียบเทียบ dependency tree ของคุณกับฐานข้อมูลช่องโหว่ที่ถูกรายงานไว้แล้ว จึงตรวจพบได้เฉพาะปัญหาที่ถูกเผยแพร่และระบุชื่อไว้เท่านั้น แต่การโจมตีผ่าน supply-chain จะไม่มีชื่อเรียกตลอดช่วงเวลาที่มันยังคงใช้งานได้จริง ให้รัน npm audit เพื่อหาบั๊กเก่าที่ทราบกันดี และอย่าคาดหวังว่ามันจะช่วยอะไรได้กับ release ที่เพิ่งปล่อยออกมาเมื่อ 4 ชั่วโมงที่แล้ว

การลดจำนวน dependency ช่วยได้มากกว่าเครื่องมือใดๆ ในคู่มือนี้ แต่นี่เป็นคำแนะนำที่ได้รับความนิยมน้อยที่สุด แพ็กเกจทุกตัวที่คุณไม่ได้เพิ่มเข้าไป คือผู้เผยแพร่หนึ่งรายที่ไม่มีโอกาสถูกฟิชชิงเพื่อโจมตีคุณ และเป็น install script อีกหนึ่งตัวที่จะไม่ถูกรันในฐานะ deploy user ของคุณ

เรื่องทั้งหมดนี้ไม่ได้จำกัดอยู่แค่ npm เท่านั้น รูปแบบทั้ง 4 ประการนี้ยังใช้ได้กับ PyPI, RubyGems, container images และ package manager ของ distribution ที่คุณใช้งาน npm เป็นที่ที่เห็นปัญหาชัดเจนที่สุดเพราะมี dependency tree ที่ลึกและมีการรัน install script โดยค่าเริ่มต้น ส่วนเครื่องที่อยู่รอบข้างนั้นคุณต้องรับผิดชอบป้องกันมากน้อยเพียงใด ขึ้นอยู่กับว่ามันรันอยู่ที่ไหน ซึ่งเป็นส่วนหนึ่งของ คำถามที่กว้างกว่าว่า VPS hosting ปลอดภัยหรือไม่

FAQ

npm ci ช่วยป้องกันผมจาก npm package ที่ถูกบุกรุกหรือไม่?

มันช่วยป้องกันไม่ให้เวอร์ชันเปลี่ยนไปโดยที่คุณไม่รู้ตัว npm ci จะติดตั้งสิ่งที่ package-lock.json บันทึกไว้เท่านั้น โดยจะตรวจสอบ tarball แต่ละไฟล์เทียบกับค่า sha512 integrity hash และจะหยุดทำงานพร้อมแสดงข้อผิดพลาดหาก package.json และ lockfile ไม่ตรงกัน แทนที่จะพยายามแก้ไขความแตกต่างนั้น อย่างไรก็ตาม มันไม่ได้ระบุว่าเวอร์ชันที่ล็อกไว้นั้นปลอดภัยหรือไม่ หากคุณ commit lockfile ที่ล็อกเวอร์ชันที่เป็นอันตรายไว้ npm ci ก็จะติดตั้งเวอร์ชันนั้นลงบนทุกเซิร์ฟเวอร์ที่คุณมีอย่างซื่อตรงในทุกครั้ง

ผมควรตั้งค่า ignore-scripts=true สำหรับทุกอย่างหรือไม่?

ควรตั้งค่าไว้ก่อนแล้วค่อยทำ allowlist การตั้งค่า ignore-scripts=true ใน .npmrc ของโปรเจกต์จะหยุดการทำงานของสคริปต์ติดตั้ง dependency ซึ่งเป็นการตัดเส้นทางที่ตรงที่สุดที่ package อันตรายจะเข้าถึงข้อมูลประจำตัว (credentials) ของผู้ใช้ที่ทำหน้าที่ deploy แพ็กเกจที่ต้องคอมไพล์ native addon หรือดึง prebuilt binary จำเป็นต้องใช้สคริปต์เหล่านี้จริงๆ ซึ่งเมื่อปิดสคริปต์ไปแล้ว แพ็กเกจเหล่านั้นจะล้มเหลวในขั้นตอน runtime ด้วยข้อผิดพลาดไฟล์ binding หาย แทนที่จะล้มเหลวในขั้นตอนติดตั้ง ให้รัน npm ci --ignore-scripts แล้วตามด้วย npm rebuild <package> สำหรับแพ็กเกจจำนวนน้อยที่คุณตัดสินใจเชื่อถือ ส่วน npm query ":attr(scripts, [postinstall])" จะแสดงให้เห็นว่ามีแพ็กเกจเหล่านั้นอยู่จริงกี่รายการ

ผมจะทราบได้อย่างไรว่าเซิร์ฟเวอร์ติดตั้งแพ็กเกจเวอร์ชันใดอยู่จริงๆ?

ให้ตรวจสอบจากดิสก์ ไม่ใช่จาก lockfile โดย npm ls <package> จะรายงานสิ่งที่อยู่ใน node_modules และ node -e "console.log(require('./node_modules/<package>/package.json').version)" จะแสดงเฉพาะสตริงเวอร์ชันออกมา lockfile ใน git ตอบคำถามที่ต่างออกไป คือคำถามที่ว่าควรจะติดตั้งอะไร และการเปรียบเทียบทั้งสองอย่างนี้คือประเด็นสำคัญ การ deploy ลงในไดเรกทอรีที่ตั้งชื่อตาม git commit จะช่วยให้คุณเก็บคำตอบทั้งสองไว้ได้แม้เวลาจะผ่านไปหลายเดือนเมื่อคุณต้องการตรวจสอบ

npm audit สามารถตรวจพบการโจมตีแบบ supply-chain ได้หรือไม่?

ไม่ได้ npm audit จะเปรียบเทียบโครงสร้างแพ็กเกจของคุณกับฐานข้อมูลช่องโหว่ที่ได้รับรายงานมาเท่านั้น จึงตรวจพบเฉพาะปัญหาที่ถูกเผยแพร่และมีหมายเลขระบุไว้แล้วเท่านั้น ส่วนการปล่อยแพ็กเกจที่เป็นอันตรายนั้นจะยังไม่มีการรายงานในช่วงเวลาหลายชั่วโมงหรือหลายวันที่สำคัญต่อการติดตั้ง npm audit signatures เป็นคำสั่งที่มีประโยชน์มากกว่า โดยจะตรวจสอบลายเซ็น registry ทั่วทั้งโครงสร้างแพ็กเกจที่ติดตั้งไว้ และตรวจสอบ provenance attestations ในกรณีที่ผู้เผยแพร่ได้จัดทำไว้ ซึ่งจะช่วยยืนยันว่า tarball นั้นมาจากกระบวนการ build สาธารณะ ไม่ใช่จากเครื่องที่ไม่รู้จัก

ทำไมการรันแอปด้วยผู้ใช้ที่ไม่มีสิทธิ์ (unprivileged user) ถึงสำคัญหากการโจมตีเกิดขึ้นในขั้นตอนติดตั้ง?

เพราะความล้มเหลวทั้งสองรูปแบบมีขอบเขตการเข้าถึงที่ต่างกัน และคุณกำลังป้องกันทั้งสองทาง โค้ดที่ทำงานในขั้นตอนติดตั้งจะรันในฐานะผู้ใช้ที่ทำหน้าที่ deploy ซึ่งสามารถอ่าน SSH keys, registry tokens และ cloud credentials ของผู้ใช้นั้นได้ ส่วนโค้ดในขั้นตอน runtime จะรันในฐานะ service account และหากมีการใช้ User=nodeapp, ProtectSystem=strict และไม่มี credentials เก็บไว้บนดิสก์ที่มันสามารถอ่านได้ ขอบเขตการเข้าถึงของมันก็จะหยุดอยู่แค่ที่ environment ของแอปพลิเคชันและฐานข้อมูลของมันเท่านั้น การแยกบัญชีผู้ใช้ยังหมายความว่ากระบวนการที่ให้บริการ traffic ไม่สามารถเขียนทับ node_modules ได้ ดังนั้นหากเกิดการบุกรุกในขั้นตอน runtime ปัญหานั้นจะหายไปเมื่อมีการรีสตาร์ทครั้งถัดไป แทนที่จะกลายเป็นการฝังตัวอย่างถาวร