Tuần này trên Dev.to có một bài khá thú vị: tác giả cho local LLM gợi ý dependency, kết quả là AI "bịa" ra 162 Python package không hề tồn tại. Chạy lại từ đầu thì 161 cái tên cũ quay lại y nguyên. Nghe thì buồn cười, nhưng nhìn từ góc độ security thì đáng sợ. Nếu AI bịa tên một cách ổn định và lặp lại được, attacker chỉ cần đăng ký đúng những cái tên đó lên PyPI hoặc npm rồi ngồi chờ. Kiểu tấn công này có tên là slopsquatting. Mình đã gặp trường hợp junior trong team copy nguyên lệnh pip install từ chat AI mà không check gì. Lần đó package không tồn tại nên chỉ báo lỗi. Lần sau chưa chắc đã may như vậy.
Bài này chia sẻ cách mình đang phòng thủ cho team: từ script check nhanh, lockfile có hash, đến chặn trong CI.
Slopsquatting hoạt động như thế nào
Typosquatting thì ai cũng biết rồi: đăng ký reqeusts để chờ người gõ nhầm requests. Slopsquatting nguy hiểm hơn ở chỗ attacker không phải đoán lỗi chính tả của người. Họ chỉ cần hỏi các model phổ biến hàng nghìn câu kiểu "làm sao parse PDF bằng Python", gom lại những tên package bị bịa ra lặp đi lặp lại, rồi đăng ký lên registry.
Nghiên cứu năm 2024 "We Have a Package for You!" đã test 16 model sinh code. Khoảng 20% package được gợi ý là không tồn tại, và gần một nửa số tên bịa này xuất hiện lại khi chạy lại cùng prompt. Trước đó, một researcher cũng đã thử đăng ký một package rỗng trùng tên với package mà AI hay bịa (huggingface-cli). Package đó nhận được hàng chục nghìn lượt download thật, trong đó có cả lượt tải từ repo của các công ty lớn.
flowchart LR A[Attacker hỏi LLM hàng nghìn prompt] --> B[Gom tên package bị bịa lặp lại] B --> C[Đăng ký tên đó lên PyPI/npm] C --> D[Chèn payload vào setup.py / postinstall] E[Dev hỏi AI, nhận lệnh pip install] --> F[Copy-paste chạy luôn] F --> C D --> G[Code chạy trên máy dev / CI runner] Lý do nguy hiểm: pip install một sdist sẽ chạy setup.py, còn npm install sẽ chạy postinstall script. Tức là chỉ cần install là code của attacker đã chạy rồi, chưa cần import. Trên CI runner có sẵn AWS_ACCESS_KEY_ID, NPM_TOKEN thì hậu quả không cần nói thêm.
Lớp 1: Kiểm tra package trước khi install
Cách rẻ nhất là check metadata trên registry. Một package thật, có người dùng, thường có lịch sử release, có repo, và đã tồn tại khá lâu. Package slopsquat thì thường mới tạo vài ngày, có 1 release và không có link repo.
Script dưới đây mình dùng làm pre-commit hook cho các project Python (cần Python 3.10+ và requests):
#!/usr/bin/env python3 """check_deps.py - kiểm tra package trong requirements có thật và đáng tin không""" import re import sys from datetime import datetime, timezone import requests MIN_AGE_DAYS = 90 MIN_RELEASES = 3 def check(name: str) -> list[str]: r = requests.get(f"https://pypi.org/pypi/{name}/json", timeout=10) if r.status_code == 404: return ["KHÔNG TỒN TẠI trên PyPI - nhiều khả năng là AI bịa"] r.raise_for_status() data = r.json() warnings = [] releases = {v: files for v, files in data["releases"].items() if files} if len(releases) < MIN_RELEASES: warnings.append(f"chỉ có {len(releases)} release") uploads = [ datetime.fromisoformat(f["upload_time_iso_8601"].replace("Z", "+00:00")) for files in releases.values() for f in files ] if uploads: age = (datetime.now(timezone.utc) - min(uploads)).days if age < MIN_AGE_DAYS: warnings.append(f"mới xuất hiện {age} ngày trước") if not data["info"].get("project_urls"): warnings.append("không có link repo/homepage") return warnings def main(path: str) -> int: failed = False for line in open(path, encoding="utf-8"): m = re.match(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)", line) if not m or line.lstrip().startswith(("#", "-")): continue name = m.group(1) if issues := check(name): failed = True print(f"⚠️ {name}: " + "; ".join(issues)) return 1 if failed else 0 if __name__ == "__main__": sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "requirements.txt")) Ngưỡng 90 ngày và 3 release là con số mình chọn theo kinh nghiệm. Nó sẽ báo nhầm vài package mới nhưng hợp lệ, và đó là điều mình muốn: bắt con người nhìn lại trước khi merge, chứ không phải tự động chặn hết.
Với Node.js, npm view đã đủ dùng, không cần viết nhiều:
#!/usr/bin/env bash # check_npm_deps.sh - cần jq >= 1.6 set -euo pipefail for pkg in $(jq -r '(.dependencies // {}) + (.devDependencies // {}) | keys[]' package.json); do if ! created=$(npm view "$pkg" time.created 2>/dev/null); then echo "❌ $pkg: không tồn tại trên npm registry" continue fi versions=$(npm view "$pkg" versions --json | jq 'if type == "array" then length else 1 end') repo=$(npm view "$pkg" repository.url 2>/dev/null || true) age_days=$(( ( $(date +%s) - $(date -d "$created" +%s 2>/dev/null || date -j -f '%Y-%m-%dT%H:%M:%S' "${created%%.*}" +%s) ) / 86400 )) if (( age_days < 90 || versions < 3 )) || [[ -z "$repo" ]]; then echo "⚠️ $pkg: tuổi ${age_days} ngày, ${versions} version, repo='${repo}'" fi done Đoạn date có hai nhánh để chạy được trên cả GNU (Linux CI) lẫn BSD (macOS).
Lớp 2: Lockfile có hash và tắt install script
Check metadata chỉ bắt được package lạ ở thời điểm thêm vào. Muốn chắc chắn thứ đã review chính là thứ được install thì phải pin cả hash.
Với Python, mình dùng uv (0.4 trở lên) để compile từ requirements.in:
# Chỉ khai báo dependency trực tiếp trong requirements.in uv pip compile requirements.in --generate-hashes -o requirements.txt # Khi install: hash nào không khớp thì pip từ chối pip install --require-hashes --only-binary :all: -r requirements.txt # Quét thêm CVE đã biết pip-audit -r requirements.txt Flag --only-binary :all: chỉ cho cài wheel nên setup.py không được chạy lúc install. Có vài package cũ không có wheel thì mình whitelist riêng bằng --no-binary <tên> sau khi đã review.
Với Node.js:
# Trên CI: dùng lockfile, không resolve lại npm ci --ignore-scripts # Trên máy dev: tắt lifecycle scripts mặc định npm config set ignore-scripts true Nếu dùng pnpm 10 thì mặc định lifecycle script của dependency đã bị tắt. Package nào thật sự cần build (ví dụ esbuild, sharp) thì khai báo trong onlyBuiltDependencies. Theo mình đây là default đúng nhất hiện nay.
Lớp 3: Chặn trong CI pipeline
Con người sẽ quên, nên phải để CI làm người gác cổng. Flow mình đang dùng:
flowchart TD A[PR thay đổi requirements.in / package.json] --> B{Dependency mới?} B -- Không --> E[Install bằng lockfile + hash] B -- Có --> C[Chạy check_deps / check_npm_deps] C -- Có cảnh báo --> D[Bắt buộc review từ security owner] C -- Sạch --> E D --> E E --> F[pip-audit / npm audit] F --> G[Merge] Vài điểm thực tế:
- CODEOWNERS cho
requirements*.txt,package.jsonvà lockfile, để mọi thay đổi dependency đều cần thêm một người approve. - Job install không có secret. Chỉ step deploy mới được inject token. Nếu lỡ cài phải package độc thì nó cũng không lấy được gì.
- Team lớn hơn thì cân nhắc dựng private proxy (devpi, Verdaccio) chỉ cho phép package nằm trong allowlist.
Kết luận
AI viết code nhanh thật, nhưng nó không biết package nào tồn tại, và attacker đã biết cách lợi dụng điều đó. Những việc bạn có thể làm ngay trong tuần này:
- Đừng bao giờ copy-paste lệnh
pip install/npm installtừ AI mà chưa check. Mở trang PyPI/npm, xem repo và ngày tạo, mất chưa tới 30 giây. - Gắn script check metadata vào pre-commit, chặn package mới tạo, ít release hoặc không có repo.
- Pin hash:
uv pip compile --generate-hashes+pip install --require-hashes, còn bên Node thì dùngnpm ci. - Tắt install script mặc định:
npm config set ignore-scripts true, chuyển sang pnpm 10, và ưu tiên wheel với--only-binary. - Tách secret khỏi bước install trong CI. Đây là lớp phòng thủ cuối cùng khi các lớp trên đều bị vượt qua.
Supply chain security không cần tool đắt tiền. Phần lớn là thói quen. Bắt đầu từ cái lockfile trước đã.
