career-ops 安全策略解析:本地运行工具的攻击面划定与源码级纵深防御
career-ops 的 SECURITY.md 定义了这条开源 AI 求职流水线(扫描职位、生成 A-H 分级报告、定制简历、跟踪投递)的完整安全模型:漏洞如何报告、哪些组件算攻击面、"本地运行"为何不意味着"不可达"。本篇以该安全策略文档为骨架,结合仓库中 providers/_ip-guard.mjs、web/src/lib/origin-guard.mjs 等真实防护实现,把策略中每一类攻击面拆解到具体的代码防线,帮助贡献者理解项目如何评估安全问题,也帮助用户(尤其是自托管 Web Dashboard 的读者)理解哪些默认值是安全的、哪些配置项会改变攻击面。
漏洞报告渠道:不走公开 Issue
安全策略的第一条硬性规则是:不要为安全漏洞公开打开 Issue。公开 Issue 会先于修复把利用方式传播出去,这正是策略要求私有渠道的原因。
报告方式是通过邮件联系 hi@santifer.io,并附四要素:
- 漏洞描述(Description of the vulnerability);
- 复现步骤(Steps to reproduce);
- 潜在影响(Potential impact);
- 建议修复方案(Suggested fix,如有)。
维护方承诺在 72 小时内响应,并在任何公开披露之前与报告者协作理解与修复问题。这一流程对应下文"协调披露(coordinated disclosure)"章节。
Scope:什么算 career-ops 的安全问题
策略文档按仓库的真实代码布局划定了五个在范围内的攻击面。逐一对照源码,可以看出每一类范围都有对应的防御实现,而非纸面声明。
脚本(*.mjs):命令注入、路径穿越、SSRF
仓库根目录散布上百个 .mjs 脚本(scan.mjs、tracker.mjs、generate-pdf.mjs 等),它们运行在求职者的机器上,直接读取简历、投递记录等敏感文件,并抓取大量不可信的第三方网站内容——这决定了策略将 命令注入、路径穿越、SSRF 三类列为脚本攻击面。
SSRF 是这一层防御最完整的部分,证据在 providers/_ip-guard.mjs:
- 模块内维护了一张 IPv4 禁拨网段表
V4_BLOCKED,覆盖0.0.0.0/8、RFC1918 私网、100.64.0.0/10CGNAT、127.0.0.0/8回环、169.254.0.0/16链路本地、198.18.0.0/15、组播与保留段。源码注释明确指出169.254.169.254是"最有牙齿"的一段——它是 AWS/GCP/Azure 的云实例元数据端点,正是 SSRF 攻击通常瞄准的地址; - 校验时机刻意选在 DNS 解析(lookup)时 而非请求前检查:如果域名"先返回公网 IP、再返回内网 IP"(DNS rebinding),前置检查会放行而实际拨号命中内网,而在 lookup 时校验则堵住了这个窗口。因为项目不依赖 undici,无法走 Agent 层,所以 providers/_dns-cache.mjs 直接对
node:dns的lookup做了进程级补丁,由_ip-guard.mjs在补丁中执行判定; - 判定范围刻意用
AsyncLocalStorage限定:补丁是进程级的,若无差别拒绝私网地址,仓库中 10 多个起本地 HTTP 服务器做测试的文件(例如tests/trust-validator.test.mjs所在测试套件大量使用的 loopback)会全部被误杀。因此 providers/_http.mjs 用providerFetchContext.run(...)给"provider 抓取流量"打上上下文标记,被补丁的lookup只在该上下文内校验解析结果,其余流量(本地开发服务器、测试)照常工作。AsyncLocalStorage.run包裹的是整个 fetch 生命周期,因为 DNS lookup 发生在fetch()同步返回之后的 connect 阶段; - 地址解析本身也有防绕过细节:
v4ToInt只接受规范十进制点分四段,显式拒绝前导零——因为0177.0.0.1在部分解析器里按八进制读是127.0.0.1,"校验器读成公网、拨号器读成回环"正是完整的绕过路径;IPv4-mapped IPv6(::ffff:127.0.0.1)会被解包后按 IPv4 判定,避免两种写法被区别对待。被拦截的解析抛出带ECAREEROPS_BLOCKED_ADDRESS错误码的异常,且源码注释强调它不被计入"DNS 解析器故障"的负缓存或宕机信号——这是对单个主机名的判决,不是解析器生病。
重定向是 SSRF 的另一条通道:providers/_http.mjs 中的 fetchWithTimeout 支持 redirect: 'error' 选项,源码注释说明每个 provider 的强制 SSRF 守卫都依赖它——3xx 若指向私有 IP,拒绝跟随而不是被静默跳过去。tests/providers/_http.test.mjs 还固定了 undici 在 redirect: 'error' 遭遇 3xx 时的 err.cause.message === 'unexpected redirect' 这一未文档化行为,防止未来 Node/undici 升级悄悄改变错误形状后重试分类失效。
对"不可信外部内容"的信任评估落在 providers/_trust-validator.mjs:它不丢弃任何职位,只给每条抓取结果打 trustScore(0-100)与 flag 集合,惩罚项包括非法 URL(-50)、缺失投递链接(-40)、可疑域名如链接短站 bit.ly/tinyurl.com(-25)、公司与域名不匹配(-15);等级划分为 >=90 high、>=60 medium、其余 low。这套启发式可通过 templates/portals.example.yml 中的 trust_filter 块配置(enabled、suspicious_domains、ats_allowlist),默认短链域名清单与 ATS 白名单(greenhouse、lever、workday 等 16 个域)均在源码中给出,配套测试为 tests/trust-validator.test.mjs。
Dashboard(dashboard/):Go 二进制
策略将 dashboard/ 下任何 Go 二进制的漏洞列入范围。这是一个基于 bubbletea 的终端 TUI(dashboard/main.go),数据面是读取用户工作区的 applications.md 与报告文件。从源码结构看,它的对外动作有两条值得关注的边:
- dashboard/main.go#L267-L288 的
runGeneratePDF用exec.Command("node", args...)生成 PDF——固定解释器、固定脚本名generate-pdf.mjs、参数以列表形式传递(不经过 shell),cmd.Dir锚定在解析出的 career-ops 根目录; getRepoRoot/resolveEnvPath的根目录解析逻辑(dashboard/main.go#L316-L340):优先读CAREER_OPS_ROOT/CAREER_OPS_DATA_DIR环境变量或.career-ops-data标记文件,相对路径一律拼接到仓库根下——这类"从环境变量/标记文件拼路径"的代码正是策略中路径穿越一类问题需要持续盯防的位置。
Web Dashboard(web/):一切运行期间可达的东西
这是策略中范围写得最宽的一条:"它运行期间任何可达的东西,都算在内——包括用户访问的某个页面发起的跨源请求、同网络上其他主机的请求,以及通过其 API 的命令注入。" 这句宽泛措辞不是留白,源码里能找到与之逐句对应的两层防线。
第一层:统一 API 闸门。 web/src/proxy.ts 是一个 matcher: "/api/:path*" 的中间件,注释直白地说明了动机:"/api 路由会 spawn 子进程、读写用户文件,因此任何无鉴权即应答的路由都是远程代码执行原语"。它把每个请求的 Sec-Fetch-Site、Origin、Host 与 CAREER_OPS_WEB_ALLOWED_HOSTS 环境变量交给纯函数 checkRequest 做决策,不通过就返回 403,不进入任何路由处理。
第二层:同源自适应 + 回环主机双重校验。 web/src/lib/origin-guard.mjs 的 checkRequest 分两层,必须同时通过:
| 层 | 针对威胁 | 判定逻辑 |
|---|---|---|
| Host 层 | F2:LAN 可达 | 默认只应答回环 Host(localhost、127.0.0.0/8 全段、IPv6 ::1);要额外放行主机必须显式设置 CAREER_OPS_WEB_ALLOWED_HOSTS(逗号/空格分隔),未设置即"仅回环" |
| Origin 层 | F1:drive-by CSRF | 浏览器发送 Sec-Fetch-Site 时以其为准:same-origin 放行、none(直接导航/书签,攻击者无法伪造)放行、same-site/cross-site 拒绝;无 Fetch Metadata 的老客户端退化为比较 Origin 与 Host 是否一致;完全没有 Origin 头(curl、服务端 fetch)不可能构成浏览器 CSRF,放行;不可解析的 Origin(含不透明 null)拒绝 |
Host 规范化(origin-guard.mjs#L22-L33)处理了端口剥离与裸 IPv6 字面量(一个冒号是 host:port,两个以上是 IPv6 无端口)这类容易写歪的边界。
命令注入面的收口。 API 路由确实会启动子进程——例如 web/src/app/api/assistant/route.ts 与 web/src/app/api/cv/ingest/route.ts 通过 spawn 无头 AI CLI,web/src/app/api/doctor/route.ts 用 execFile("node", [doctor, "--json"], { timeout: 10_000 })。从源码结构看,项目把这些调用收口到单一入口 web/src/lib/spawn-cli.mjs 的 spawnHeadlessCli:所有 CLI 调用路由"应当"只经它 spawn(注释明言这是唯一 spawn 路径,"so the fix can't drift"),它关闭 stdin 防止 codex exec 类 CLI 等待管道输入,且参数以数组传递而非 shell 字符串——配合上面的 origin 闸门,"API 命令注入"这一攻击面被限制在"同源自适应 + 回环 Host"的请求内部、且只作用于固定参数形态的子进程。
模板(templates/):生成物中的 XSS
范围条目覆盖"生成 HTML/PDF 中的 XSS"。这一面涉及 templates/ 下的 HTML 简历/CV 模板与 cv-templates.mjs 的渲染逻辑:简历内容源自用户填写的事实与抓取的公司信息,若渲染时对注入内容转义不足,生成产物(以及任何预览它的浏览器)就承载了存储型脚本。仓库为这条面保留了对应的测试资产(如 tests/cv-templates.test.mjs),docs/CV_VISUAL_TESTING.md 则描述了基于 Playwright 的可视化回归流程(npm run test:cv-visual,见 package.json)。
配置:密钥暴露与不安全默认值
范围中的"Configuration"对应仓库里所有 *.example.* 模板:config/cv-facts.example.json、config/local-paths.example.txt、config/plugins.example.yml、config/profile.example.yml。从测试命名(tests/user-layer-gitignored.test.mjs、tests/user-layer-untracked.test.mjs)可以推断,用户真实配置层被要求保持在 git 追踪之外——这本身就是"密钥暴露"防线的组成部分:example 文件进仓库,真实值留在用户层。依赖侧,package.json 引入 dotenv 承载环境密钥。安全视角下的"不安全默认值"审查对象还包括本文前面提到的那些默认策略:回环-only 的 API 默认、trust_filter 缺席时全员 100 分的默认、以及 CAREER_OPS_WEB_ALLOWED_HOSTS 必须显式设置才生效的默认——它们都遵循"默认收窄、显式放宽"的方向。
Out of Scope:范围之外的四类问题
策略同样明确列出了不在范围内的情况:
- 第三方依赖的问题——向上游报告(upstream),而非本项目;
- 需要物理接触用户机器的问题;
- 社会工程攻击;
- 针对托管基础设施的攻击——原话是"career-ops 运行在本地,没有可供攻击的我们自己的服务器"。
最后一条值得展开:项目虽然提供 Dockerfile 与 docker-compose.yml 用于本地容器化部署,但部署主体仍然是用户自己的环境,不存在项目方运营的后端服务。这与"本地 ≠ 不可达"的告诫并不矛盾:没有项目方的服务器,不代表用户本地的服务器不可达。
"Local" does not mean "unreachable"
这是整份策略中最关键的一段,值得单独成节:
Web dashboard 是一个本地 HTTP 服务器,而本地服务器仍然可以被用户碰巧访问的某个跨源页面、以及同一网络上的任何设备触达。只要问题需要的只是"用户运行了 career-ops 并且正常浏览网页",它就在范围内——请报告,而不是假定"本地工具"这条线把它排除在外。
翻译成两个具体攻击场景(与 origin-guard.mjs 注释中的 F1/F2 一一对应):
- Drive-by(F1):受害者访问任意恶意网页,该页面在后台向
http://localhost:PORT/api/...发 POST。由于 API 无鉴权且能触发子进程,这是经典 CSRF 升级为 RCE 的路径——对应 Origin 层防线; - 同网段直连(F2):若开发服务器绑定
0.0.0.0,同一 LAN 上的任何设备都可以直接命中这些路由——对应 Host 层防线与CAREER_OPS_WEB_ALLOWED_HOSTS的显式 opt-in 设计。
给用户的实操结论可以直接从源码读出:保持 Web Dashboard 仅监听/访问回环地址;CAREER_OPS_WEB_ALLOWED_HOSTS 只在确有受信任局域网设备需要访问时才设置,且只加最小主机集合;理解到"本地工具"并不豁免跨源与网络层面的安全问题,发现此类问题应走前述邮件渠道报告。
协调披露(Coordinated Disclosure)
披露流程与报告渠道闭环:项目遵循 coordinated disclosure——修复版本发布前不公开细节;修复发布后,会在 release notes 中署名感谢报告者(除非报告者希望匿名)。结合"72 小时响应"的承诺,一个完整的报告生命周期是:邮件提交(四要素)→ 72 小时内响应 → 协作确认与修复 → 修复发布 → release notes 署名。
小结:一份与代码互相印证的安全策略
career-ops(当前仓库版本 v1.31.0,见 package.json)的 SECURITY.md 把攻击面精确地切在代码目录上:.mjs 脚本(SSRF/注入/路径穿越)、Go dashboard、Web API、生成模板与配置,并对每一项给出了"默认收窄 + 显式放宽"的实现——lookup 时机的私网地址守卫(providers/_ip-guard.mjs)、回环-only 的 API 双闸门(web/src/lib/origin-guard.mjs、web/src/proxy.ts)、单一收口的子进程 spawn 路径(web/src/lib/spawn-cli.mjs)、只标注不丢弃的抓取信任分(providers/_trust-validator.mjs)。对贡献者而言,这份文档是评估自己改动是否触及攻击面的检查清单;对报告者而言,它是明确承诺响应时效与披露方式的合同。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00