งูการ์ตูนสีเขียวถือแว่นขยายส่องเครื่องจักรสีมิ้นต์ที่มีไฟหมุนสีส้มด้านบน ข้างคลิปบอร์ดรายการตรวจบนโต๊ะไม้ในห้องสว่างที่มีต้นไม้

ตรวจ Python startup: อ่าน .pth และหา sitecustomize ก่อนโค้ดเริ่มรัน

น้องกายEngineering

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

WHY: ตรวจสิ่งที่รันก่อนแอป เพื่อหาต้นตอให้ตรงจุด

  • site ถูกนำเข้าอัตโนมัติระหว่างเริ่ม interpreter หรือตัวรันภาษา Python จึงมีงานเกิดขึ้นก่อนสคริปต์หลัก
  • บรรทัด executable ใน .pth รันทุกครั้งที่เริ่ม Python ตามปกติ แม้แอปไม่ได้ใช้แพ็กเกจนั้นในรอบนี้

พฤติกรรม startup นี้อธิบายไว้ใน เอกสาร site บทความนี้ยึด Python 3.14 ไม่รวมกติกาของรุ่นใหม่กว่าในเอกสาร

ทำงานยังไง

site-packages คือโฟลเดอร์เก็บแพ็กเกจเพิ่มเติม ส่วน sys.path คือรายการตำแหน่งที่ Python ใช้ค้นหาโมดูล เมื่อ site ประมวลผล .pth จะข้ามบรรทัดว่างและบรรทัดที่ขึ้นต้นด้วย # บรรทัดที่ขึ้นต้นด้วย import หรือ import\t จะถูกประมวลผลเป็นโค้ด ส่วนบรรทัดอื่นเป็นรายการพาธ ถ้าตำแหน่งนั้นไม่มีอยู่จะไม่ถูกเพิ่ม และพาธซ้ำจะไม่ถูกเพิ่มซ้ำ ตาม กติกา .pth

นึกถึงสมุดเปิดร้าน บางบรรทัดบอกว่าให้ไปหยิบของจากห้องไหน อีกบรรทัดสั่งให้เปิดเครื่องคิดเงินทันที .pth จึงไม่ได้เป็นแค่รายชื่อห้องอย่างที่นามสกุลชวนให้คิด หลังจัดพาธแล้ว Python ยังพยายามนำเข้า sitecustomize และจะพยายามนำเข้า usercustomize เมื่อ ENABLE_USER_SITE เป็นจริงด้วย

python -S หยุดการนำเข้า site อัตโนมัติ และเมื่อใช้ตัวเลือกนี้ เรายัง import site เพื่อเรียกฟังก์ชันข้อมูลได้โดยไม่เรียกขั้นตอนปรับพาธอัตโนมัติ อย่าเรียก site.main() หรือ site.addsitedir() ระหว่างตรวจ เพราะเราไม่ต้องการประมวลผล hooks ที่กำลังตรวจอยู่

ส่วน -I คือ isolated mode ผมตรวจบนเครื่องนี้แล้วว่าได้ธง isolated, ignore_environment, no_user_site และ safe_path แต่ไม่ได้เปิด no_site ให้เอง จึงต้องใช้ -I -S คู่กัน ต้องใช้ interpreter และ standard library ที่เชื่อถือได้ด้วย

โค้ดตัวอย่างพร้อมเทสต์

บันทึกเป็น audit_startup.py แล้วรันเทสต์ด้วย python3 -I -B -S audit_startup.py ใช้ standard library และ -B ปิดการเขียน bytecode cache ตาม เอกสาร sys.dont_write_bytecode เทสต์ใช้ข้อมูลร้านสมมติและลบโฟลเดอร์ชั่วคราวเอง

ตัวตรวจรวมพาธจาก site.getsitepackages(), site.getusersitepackages() และ sysconfig.get_paths()['purelib'] ซึ่ง purelib หมายถึงตำแหน่งติดตั้งโมดูล Python ที่ไม่ขึ้นกับแพลตฟอร์ม ตาม เอกสาร sysconfig ตำแหน่งที่รวบรวมอาจไม่ได้เปิดใช้งานใน process นี้

import importlib.util
import locale
from pathlib import Path
import site
import sys
import sysconfig
import tempfile
import unittest


def classify(line):
    text = line.rstrip()
    if not text or text.startswith("#"):
        return "skip"
    if text.startswith(("import ", "import\t")):
        return "review-code"
    return "path"


def site_dirs():
    candidates = [*site.getsitepackages(), site.getusersitepackages(),
                  sysconfig.get_paths()["purelib"]]
    return list(dict.fromkeys(Path(p).absolute() for p in candidates))


def scan(root):
    rows = []
    if not root.exists():
        return [(str(root), 0, "missing-dir", "")]
    try:
        for file in sorted(root.glob("*.pth")):
            try:
                raw = file.read_bytes()
                try:
                    text = raw.decode("utf-8")
                except UnicodeDecodeError:
                    text = raw.decode(locale.getencoding())
                for number, line in enumerate(text.splitlines(), 1):
                    kind = classify(line)
                    if kind != "skip":
                        rows.append((str(file), number, kind, line))
            except (OSError, UnicodeError) as exc:
                rows.append((str(file), 0, "read-error", str(exc)))
    except OSError as exc:
        rows.append((str(root), 0, "scan-error", str(exc)))
    return rows


def locate(name, roots):
    # Probe top-level modules; do not import the target module.
    previous = sys.path[:]
    try:
        sys.path.extend(str(p) for p in roots if p.is_dir())
        spec = importlib.util.find_spec(name)
        if spec is None:
            return "not-found-in-probe"
        return {"origin": spec.origin,
                "locations": list(spec.submodule_search_locations or [])}
    except (ImportError, ValueError, OSError) as exc:
        return {"lookup-error": str(exc)}
    finally:
        sys.path[:] = previous


def audit():
    roots = site_dirs()
    print("Python:", sys.version.split()[0], "prefix:", sys.prefix)
    print("User site enabled:", site.ENABLE_USER_SITE)
    for root in roots:
        print("Directory:", str(root))
        for row in scan(root):
            print(repr(row))
    for name in ("sitecustomize", "usercustomize"):
        print(name, repr(locate(name, roots)))


class AuditTests(unittest.TestCase):
    def test_classifier(self):
        cases = {"": "skip", "  ": "skip", "# ร้านมะลิ": "skip",
                 "import os": "review-code", "import\tos": "review-code",
                 "importer": "path", " import os": "path",
                 " # comment": "path", "reports/ยอดขาย": "path"}
        for line, expected in cases.items():
            with self.subTest(line=line):
                self.assertEqual(classify(line), expected)

    def test_fake_pth(self):
        with tempfile.TemporaryDirectory(dir=".") as folder:
            root = Path(folder)
            file = root / "shop.pth"
            original = "# มะลิ ยอด 1250 บาท วันที่ 2026-10-22\n\nreports\nimport os\n"
            file.write_text(original, encoding="utf-8")
            self.assertEqual(scan(root), [
                (str(file), 3, "path", "reports"),
                (str(file), 4, "review-code", "import os")])
            self.assertEqual(file.read_text(encoding="utf-8"), original)

    def test_lookup_without_execution(self):
        with tempfile.TemporaryDirectory(dir=".") as folder:
            root = Path(folder).absolute()
            name = "thai_shop_audit_fixture"
            file = root / (name + ".py")
            file.write_text("raise RuntimeError('must not run')\n", encoding="utf-8")
            before = sys.path[:]
            self.assertEqual(locate(name, [root])["origin"], str(file))
            self.assertNotIn(name, sys.modules)
            self.assertEqual(sys.path, before)


if __name__ == "__main__":
    unittest.main()

ผมรันโค้ดนี้ด้วย Python 3.14.7 แล้ว เทสต์ทั้ง 3 รายการผ่านครับ

ตรวจ environment จริงด้วย python3 -I -B -S -c "import runpy; runpy.run_path('audit_startup.py')['audit']()" เลือก executable ของ environment เป้าหมาย เทสต์แยกจากการสแกนเครื่องจริง

review-code หมายถึงต้องทบทวน ส่วน path ต้องตรวจปลายทางต่อ ตัวตรวจไม่เช็กว่าพาธมีอยู่หรือจำลองการเพิ่ม sys.path การถอดรหัสลอง UTF-8 ก่อนแล้วใช้ locale ตามเอกสาร

locate() ใช้ find_spec กับชื่อโมดูลระดับบนสุด โดยเพิ่มโฟลเดอร์ที่รวบรวมไว้เข้ารายการค้นหาชั่วคราวแล้วคืนค่าเดิม ไม่ import โมดูลเป้าหมาย ผล origin คือจุดที่พบในการค้นหาทดลอง ไม่ใช่คำยืนยันว่า startup ปกติจะเลือกไฟล์เดียวกัน เพราะเราไม่ได้ใช้พาธภายใน .pth หรือจำลองลำดับ startup ทั้งหมด

not-found-in-probe ไม่ยืนยันว่าไม่มีโมดูล และ find_spec ใช้กลไกค้นหาของ process จึงอย่าเรียกตัวตรวจในแอปที่โหลด import hooks ภายนอกแล้ว

จุดที่คนพลาดบ่อย

  • สแกนแค่ venv เดียว — venv คือ environment แยกแพ็กเกจของแต่ละงาน ให้ตรวจ Python ของงานขาย งานบัญชี และงานตั้งเวลาทีละตัว พร้อมเก็บ prefix ไว้อ้างอิง
  • เชื่อชื่อไฟล์หรือเห็น import แล้วตัดสินทันที — ชื่อธรรมดาไม่ได้ยืนยันความปลอดภัย และแพ็กเกจถูกต้องก็ใช้บรรทัด executable ได้ เช่น งาน editable install หรือ coverage ให้ตรวจเจ้าของและเนื้อหาก่อนตัดสิน ไม่ลบทิ้งจากธงอย่างเดียว
  • คิดว่า -I ยังเห็น user site เหมือนเดิม — มันปิด user site ใน process นี้ ตัวอย่างจึงดึงตำแหน่ง user site มาอ่านด้วย แต่การค้นหาแบบทดลองไม่ได้แปลว่าตำแหน่งนั้นเปิดใช้งานจริง
  • เห็น sitecustomize ของระบบแล้วลบเลย — บน Debian ให้ตรวจว่าไฟล์นั้นเป็นส่วนปรับแต่งจากแพ็กเกจระบบหรือผู้ดูแลหรือไม่ก่อน ใช้ทะเบียนแพ็กเกจและการตั้งค่าของเครื่องประกอบ ไม่ตัดสินจากชื่อโมดูล
  • เปิด Python ปกติเพื่อเริ่มสืบ — hooks อาจรันก่อนตัวตรวจเริ่มแล้ว ใช้ -S ตั้งแต่เริ่ม process และอย่าเปลี่ยนจากการอ่านไฟล์ไปเป็น exec หรือ import เพื่อทดลองว่าไฟล์ทำอะไร

ใช้ตรงไหนในงานจริง

  • เก็บผลก่อนและหลังติดตั้ง dependency หรือแพ็กเกจที่แอปต้องใช้ เพื่อดูไฟล์ที่เปลี่ยน
  • ตรวจ image ที่เตรียมส่งขึ้นระบบ โดยใช้ Python ตัวเดียวกับ service เป้าหมาย
  • แนบพาธ เลขบรรทัด และข้อผิดพลาดให้ทีมไอทีตามต่อเมื่อพฤติกรรมเกิดก่อนแอปเริ่ม
  • ใช้เป็นรายการตรวจร่วมกับการทบทวนพาธปลายทางและแหล่งที่มาของแพ็กเกจ รายงานว่างยังไม่ยืนยันความปลอดภัย

เทียบกับทางเลือกอื่น

วิธีดีตรงไหนระวังอะไร
ไล่ ls และเปิดอ่านเองเริ่มเร็ว เห็นไฟล์โดยตรงต้องหาโฟลเดอร์ให้ครบและจำแนกบรรทัดเอง
ตัวตรวจในบทความได้ชนิดบรรทัด เลขบรรทัด และจุดค้นพบโมดูลไม่ตามพาธจาก .pth และไม่ตัดสินเจตนาของโค้ด
pip inspect / pip listเหมาะกับดูข้อมูลแพ็กเกจที่ติดตั้งต้องตรวจ startup files เพิ่มเพื่อเห็นเนื้อหาบรรทัด
เครื่องมือ SCAช่วยทบทวนส่วนประกอบซอฟต์แวร์ตามความสามารถของเครื่องมือต้องตรวจขอบเขตว่ารองรับ startup hooks แค่ไหน

Recap

  • รู้ว่า .pth มีทั้งรายการพาธและบรรทัดที่รันโค้ดได้
  • ใช้ -I -S กับ interpreter ที่เชื่อถือได้ก่อนเริ่มตรวจ
  • รวมตำแหน่ง site-packages และบันทึก environment ที่ตรวจ
  • แยก review-code ออกจากคำตัดสินว่าเป็นมัลแวร์
  • อ่าน origin พร้อมเข้าใจข้อจำกัดของการค้นหาทดลอง
  • ติดตาม read-error และพาธปลายทาง ไม่มองข้ามผลที่ยังไม่ครบ
  • ตรวจทุก venv ที่ใช้งานและทบทวนกับผู้ดูแลก่อนแก้ไฟล์