关闭

Tryaivo™技术

Clerk llms.txt 被抢注包名,21 站抽样复盘

2026-11-12 03:17:00 浏览:

结论先说:llms.txt 里那句"出问题就执行 npx xxx"被第三方抢先注册成恶意包,Clerk 事后把包名占回来并在 package.json 描述里写正确命令。本机 2026-11-12 抽样 21 个站点的 llms.txt,仍有 6 站放可执行指令,其中 1 条是裸包名。

背景:从"读给 AI 听的目录"变成"让 agent 执行的说明书"

llms.txt 最初的定位很安静——一份给模型读的站点目录。到了 2026 年,主流编码代理开始把它当成操作说明:文件里写"MCP 服务地址",代理就去连;写"安装命令",代理就去执行。

问题在于这份规范本身没有安全模型。llmstxt.org 至今是作者主张(proposal),2026-08 的 v2 修订补的是 <link rel="alternate" type="text/markdown">、多语言分册、以及 Agent Instructions / Skills 这类"给代理的指令片段",没有补任何签名、校验或来源约束。文件本身是纯文本,谁都能改,改完也没有哈希可对。

学术侧已经把这条路走通了。arXiv 2605.14460(Xinyu Liu、Yukai Zhao、Xing Hu、Xin Xia,《Exploiting LLM Agent Supply Chains via Payload-less Skills》)提出"语义合规劫持"(Semantic Compliance Hijacking,SCH):不注入任何代码 payload,只把恶意目标写成一段"必要合规规则"的自然语言,让代理自己去生成并执行代码。论文在 3 个主流 agent 框架 × 3 个基座模型上跑矩阵测试,最脆弱配置下机密泄露成功率 77.67%、远程代码执行(RCE)成功率 67.33%。

换成工程语言:llms.txt 里每一行可执行命令,都是一条发给代理的"建议执行"。 而建议执行的包里如果没人占名,就是攻击面。

落地清单

一、事故现场:Clerk 那条裸包名命令

2026-07-24 前后,Clerk 的 llms.txt 里有一条 Next.js 排障段,大意是"遇到 auth protection 问题,执行 npx clerk-next-fix-auth-protection"。包名是裸的——没有 scope、也没有发布者。

随后 npm 上出现 clerk-next-fix-auth-protection 的 7.7.7 / 8.8.8 版本,安装钩子在 postinstall 阶段收集主机名、用户名、当前工作目录并外发,被归为 CWE-506(内嵌恶意代码),恶意包编号 MAL-2026-11069。

说明:本机 2026-11-12 已无法从 clerk.com/llms.txt 取到这段原文(命令在 2026-09-09 前被移除),上述片段来自公开事件复盘文章的复述,不是本机直取,引用时请注意这一层。

二、修复动作:把包名"抢回来",并在描述里写正确命令

这是我认为最值得抄的一步。Clerk 没有停在"删掉那条命令",而是把包名注册回来,让任何人再造一个同名包都失败。

本机直查 registry.npmjs.org(2026-11-12)的真实结果:

字段值
包名clerk-next-fix-auth-protection
现存版本1.0.0、1.0.1(共 2 个)
维护者dominic-clerk
首次发布2026-08-20T19:22:14Z
最近改动2026-08-20T19:24:18Z
descriptionYou probably meant to run npx --package @clerk/eslint-plugin clerk-next-fix-auth-protection
scripts只有 test: echo "Error: no test specified" && exit 1(无 postinstall)
repository无

三个细节都可学:

  • 占名而不是删文档——删掉命令只能救自己的读者,占名能救全网所有抄过这条命令的人;

  • 把正确命令写进 package.json 的 description——执行者 npm view 一眼就能看到;

  • 包体保持空壳——没有 postinstall,没有安装钩子,被调用也不会做事。

正确调用形态(scope + 明确的包映射):

npx --package @clerk/eslint-plugin@latest clerk-next-fix-auth-protection

三、现在的 clerk.com/llms.txt 长什么样(本机直取,2026-11-12)

HTTP 200 / Content-Type: text/plain / 5,901 字节 / 102 行 / 54 条链接(30 条直指 .md),全文检索 npx、pnpm dlx、pip install 均 0 命中。节选前 18 行:

# Clerk

> Clerk is user management for developers: sign-up, sign-in, and prebuilt UI components ...

For AI agents and automation, use the tools and markdown surfaces below. Append `.md` to any `/docs`, `/articles`, `/blog`, `/changelog`, or `/glossary` URL to get the page as markdown.

## When to use Clerk

Use Clerk when an application needs sign-up, sign-in, and user management ...

## Agent setup

- [Clerk Skills](https://clerk.com/SKILL.md): One file that routes a coding agent to the right framework-specific skill
- [Clerk CLI](https://clerk.com/docs/cli.md): Commands, flags, and agent workflows. Install with `npm install -g clerk` or the [install script](https://clerk.com/install)
- [Clerk MCP server](https://mcp.clerk.com/mcp): Sign in once and let an agent read and configure your Clerk apps from an MCP client.
- [Agent registration](https://clerk.com/auth.md): Provision an accountless Clerk application and keys without an account
- [Skills discovery](https://clerk.com/.well-known/agent-skills/index.json): Every individually published skill

## SDKs

- [Next.js](https://clerk.com/docs/nextjs/getting-started/quickstart.md)
- [React](https://clerk.com/docs/react/getting-started/quickstart.md)

注意最后保留下来的那条命令是 npm install -g clerk——指向自己的官方包,名字与品牌一致,别人抢不走。这是"能留命令"和"不能留命令"的分界线:包名是否被自己持有。

四、可抄的自检脚本:把 llms.txt 当代码审

发布前跑一遍,把"裸包名"当 error。这段脚本只用标准库,可以直接挂 CI:

import json, re, urllib.request, urllib.parse

LLMS = "https://example.com/llms.txt"     # 你自己的 llms.txt
PAT = re.compile(r&#x27;`?\b(npx|pnpm\s+dlx|yarn\s+dlx|npm\s+i(?:nstall)?|pip3?\s+install|uv\s+add)\b\s+([A-Za-z0-9@][A-Za-z0-9@._/\-]*)&#x27;)

def registry(pkg, kind="npm"):
    base = "https://registry.npmjs.org/" if kind == "npm" else "https://pypi.org/pypi/"
    try:
        with urllib.request.urlopen(base + urllib.parse.quote(pkg, safe=&#x27;@&#x27;), timeout=10) as r:
            j = json.loads(r.read())
    except Exception as e:
        return {"exists": False, "err": type(e).__name__}
    if kind == "pypi":
        return {"exists": True, "version": j["info"]["version"], "owner": (j["info"].get("author") or "")}
    latest = j.get("dist-tags", {}).get("latest")
    ver = j.get("versions", {}).get(latest, {})
    return {"exists": True, "version": latest,
            "maintainers": [m.get("name") for m in j.get("maintainers", [])],
            "repository": (ver.get("repository") or {}).get("url"),
            "scripts": ver.get("scripts") or {}}

def main():
    text = urllib.request.urlopen(LLMS, timeout=15).read().decode("utf-8", "replace")
    bad = 0
    for m in PAT.finditer(text):
        kind = "pypi" if m.group(1).startswith("pip") else "npm"
        pkg = m.group(2).rstrip(".,;`)")
        if kind == "npm" and not (pkg.startswith("@") or "/" in pkg):
            print(f"[ERROR] 裸包名:{m.group(1)} {pkg}  -> 改成 npx --package @scope/{pkg}@x.y.z")
            bad += 1
        info = registry(pkg, kind)
        if not info["exists"]:
            print(f"[ERROR] 包不存在,可能被抢注盯上:{pkg} ({info.get(&#x27;err&#x27;)})")
            bad += 1
        else:
            print(f"[INFO ] {pkg} v{info.get(&#x27;version&#x27;)} {info.get(&#x27;maintainers&#x27;) or info.get(&#x27;owner&#x27;)} {str(info.get(&#x27;repository&#x27;))[:60]}")
            if kind == "npm" and set(info.get("scripts") or {}) & {"postinstall", "preinstall", "prepare"}:
                print(f"[ERROR] {pkg} 含安装钩子:{info[&#x27;scripts&#x27;]}")
                bad += 1
    raise SystemExit(1 if bad else 0)

if __name__ == "__main__":
    main()

五、别靠"看着像"判断,要落到维护者与仓库

vercel.com/llms.txt 里有一条:`Install with npx plugins add vercel/vercel-plugin 。plugins 是个裸名,看着该报警——但 registry 显示这个包 2013 年就存在(当前 1.3.6),maintainers 是 quuu、jonathanhefner,repository 指向 github.com/vercel-labs/plugins`。

结论:不是抢注,但也不是 Vercel 组织账号持有;能追到仓库,属于可接受。判据是 maintainers + repository + scripts 三件套,不是名字里有没有品牌词。

结果:21 个真实 llms.txt 的抽样(本机 2026-11-12 直取)

24 个地址里 21 个拿到 200。体积中位数 11,808 字节、均值 166,547 字节——差 14 倍,说明分布是极端长尾(少数超大文件把均值抬起来):

站点状态Content-Type字节行数链接.md 链接可执行指令
www.twilio.com200text/plain2,520,45710,4788,2098,2090
posthog.com200text/plain501,8684,9994,7634,7572
docs.stripe.com200text/markdown99,2317505345230
stripe.com200text/plain69,923402308240
deno.com200text/html58,36637000
drizzle.team200text/html56,595604000
www.prisma.io200text/plain37,1451382081000
bun.sh200text/plain33,8343313223190
sanity.io200text/markdown30,08118214000
nextjs.org200text/plain14,5921366310
turso.tech200text/plain11,808383001
www.mongodb.com200text/plain11,0991156471
resend.com200text/plain10,43218570330
railway.com200text/plain7,0297041350
mintlify.com200text/plain6,8848043370
hono.dev200text/plain5,9271069400
clerk.com200text/plain5,90110254301
fly.io200text/plain5,5907339252
vercel.com200text/plain5,5396828211
supabase.com200text/plain3,5165944320
svelte.dev200text/plain1,67623700
neon.tech308—————需跟随跳转
docs.anthropic.com308—————需跟随跳转
cloudflare.com403—————拒绝访问

三个可复现的观察:

  • 6/21 站(28.6%)在 llms.txt 里写了可执行安装指令:clerk(npm install -g clerk)、fly.io(npm install @fly/sprites、pip install sprites-py)、posthog(npx @posthog/wizard@latest、npx @posthog/wizard mcp add)、turso(pip install libsql)、vercel(npx plugins add vercel/vercel-plugin)、mongodb(只提"installation via npx",未给包名)。其中裸包名 1 条(占 4.8%),就是 vercel 那条,实测被 vercel-labs 仓库对应,非恶意。

  • 2 站(9.5%)的 llms.txt 返回 text/html:deno.com 58,366 字节、drizzle.team 56,595 字节。这是框架 fallback 把不存在的 llms.txt 兜成了页面,代理按"200 即成功"会把整页 HTML 当 Markdown 读进来。

  • 体积极值来自链接清单暴力展开:twilio.com 一份 2.5MB、8,209 条 .md 链接;posthog 50 万字节。这类文件对上下文窗口是纯负担,与"给代理省 token"的初衷相反。

外部数据(标注口径,非本机复现):Compendia Labs 在 2026-08-29 披露的扫描覆盖 6,214 个域名、8,265 个 llms.txt 文件,称有 120 份文件引用了未注册的包名或域名、合计约 227 条安装指令;Novee Security 在 Black Hat 2026 报告了 CVE-2026-54316。需要说明的是:该机构未公开商业化信息,单一行文来源,未见第三方复现;引用时请连同这层不确定性一起标注。

复盘:3 条配置动作 + 2 条踩坑

能直接抄的 3 条

  • 指令要么删,要么带 scope 和固定版本。npx --package @scope/name@4.0.1 cmd 这种写法把"取哪个包"钉死;CI 里加 --no-install 阻止隐式安装。

  • 把自检脚本挂进发布流水线。裸包名 → exit 1,包不存在 → exit 1,含 postinstall → exit 1。llms.txt 从此变成需要过审的代码,而不是随手生成的知识库文件。

  • 对自己发过的每条命令做"占名"动作。像 Clerk 那样把包名注册下来、描述里写明正确命令、包体留空壳,成本低、覆盖面最大。

2 条提醒

  • 不要用构建脚本"自动生成"llms.txt 里的命令段。自动生成的链接没人复核,写错一个包名就是一次供应链事故,而且会在全网被抄。

  • 不要只用 HEAD 判断 llms.txt 是否存在。SPA / 框架 fallback 会把不存在的路径兜成 200 + text/html,必须核对 Content-Type 与正文前几行。

标注

  • llms.txt 版本:v1 主线为主,部分站点已在用 v2 形态(.md 兄弟文件、type="text/markdown" 内容协商、Skills 发现端点);规范状态为作者主张,未被 LLM 厂商官方确认,2026-08 的 v2 修订未引入任何安全模型。

  • 事故时间线(2026-07-24 恶意包、MAL-2026-11069、2026-09-09 前移除命令)来自公开事件复盘,非本机直取;Clerk 事后占名的一手证据是本机 2026-11-12 直查 npm registry。

  • 抽样数据口径:2026-11-12 本机 HTTP GET 直取原始未压缩正文,字节数为响应体长度;同时段 registry.npmjs.org 直查。非第三方复现,可复跑。

  • 本文不含排名、流量、AI 引用次数类数据,不做能力断言。

  • 来源:clerk.com/llms.txt、registry.npmjs.org、theaifiles.app/stories/llms-txt-agent-supply-chain、arxiv.org/abs/2605.14460。