guatxlabs/ocular
GitHub: guatxlabs/ocular
Ocular 是一款强化的 Web 捕获与恶意页面分析引擎,在高度隔离的临时容器中安全抓取、渲染并检测恶意 HTML 与钓鱼网页。
Stars: 0 | Forks: 0
# Ocular
强化的统一 Web 捕获与分析引擎(反机器人侦测 + 恶意 HTML 分析)。
设计请参阅 `docs/superpowers/specs/`。
## 概述
**判定与分解得分。** 每一项对分数的贡献都会被显示出来:
得分绝不是一个不透明的数字,它可以逐行复核。

**提交。** 粘贴 URL 或 HTML,并使用一个**受限的脚本化 DSL** —— 一段在页面加载后重放的 `click/fill/sleep/press/scroll/capture` 序列,绝不执行任何任意的 JavaScript。

**静态侦测**,按严重性分类,附带触发的代码片段及其所在行号。

**隔离的交互式会话。** 恶意页面在临时容器中渲染,拥有独立网络,并被远程控制:内容绝不会触达分析员的浏览器。

## 使用说明
### 本地运行(CLI,不使用 docker compose)
```
make analyze FILE=suspect.html
```
如果需要,会构建 `ocular-runner-analysis` 镜像,在强化的容器中运行分析(`--network none`,seccomp,`--read-only`,非 root 用户),并输出 JSON 结果。
### 分析 URL(实时侦测)
```
make analyze URL=https://exemple-suspect.tld
```
如果需要,会构建 `ocular-runner-analysis` **以及** `ocular-runner-recon` 镜像,随后在强化的容器中 —— `--cap-drop ALL`,专用 seccomp,`--read-only`,非 root 用户 —— 启动实时抓取(`capture` 配置:Camoufox 反检测 + Xvfb,通过视觉自动解析 Cloudflare Turnstile),并输出 JSON 结果(基于捕获的 DOM 计算出的静态判定)。
**Turnstile 自动解析。** 依赖于容器内 Xvfb 中的有头渲染(`runner_recon/vision.py` + `xdotool`):对截图进行模板匹配以定位复选框,随后执行 **OS** 级点击(真实的 X11,而非 `page.mouse`),以通过交互式组件的 `isTrusted` 验证。由于组件在异步 iframe 中加载,`solve_turnstile`(`runner_recon/capture.py`)会在约 4 秒内重试检测约 6 次,然后才得出无 Turnstile 的结论。点击坐标从**图像**参考系(截图的 viewport,即 `vision.detect()` 返回的结果)映射到**屏幕**参考系(`xdotool` 所需的输入),此过程通过 `window.mozInnerScreenX/Y`(Firefox chrome 偏移量,Gecko API)和 `devicePixelRatio`(`vision.image_to_screen`)完成 —— 如果没有此偏移量,点击会偏离复选框。点击后,会进行新的检测以验证复选框是否确实消失,然后再标记 `turnstile_solved`(绝不会盲目乐观地标记为未经证实的 `True`)。**已知限制**:该坐标映射、重试循环和验证逻辑已由单元测试覆盖(模拟的页面/视觉,参见 `tests/test_vision_coords.py` 和 `tests/test_capture_logic.py`),并由针对无 Turnstile 页面的 docker 集成冒烟测试覆盖(`tests/test_deploy_images.py::test_runner_recon_image_builds_and_navigates`),但在当前环境中无法端到端验证对**真实** Cloudflare 挑战的有效解析 —— 需针对真实目标进行确认。
**⚠️ 警告 —— IP 暴露。** 与 `analysis` 配置(`--network none`)不同,`capture` 配置**启用**了网络:容器 `ocular-runner-recon` 会向目标 URL 发起真实的出站请求,这会将执行 Ocular 的机器 IP 暴露给目标(及其加载的任何第三方服务)。要在不泄露真实 IP 的情况下分析目标(进攻性侦察、潜在恶意或受监控的目标),请通过 `HTTP_PROXY`/`HTTPS_PROXY` 变量将此流量路由到 VPN 或 Tor,这些变量由 `broker/launcher.py` 读取并传递给容器:
```
HTTPS_PROXY=socks5h://127.0.0.1:9050 make analyze URL=https://exemple-suspect.tld
```
SSRF 防护(`engine/ssrf.py`)会在上游拦截其主机解析为私有 IP(RFC1918)、loopback、link-local 或云元数据服务(`169.254.169.254`)的 URL —— 这是在提交时的尽力而为保护,并非针对 DNS-rebinding 的完整防护(参见模块文档字符串)。
### 通过 API(Web + Broker + Redis,使用 docker compose)
```
OCULAR_TOKEN= make up
curl -X POST http://localhost:8000/jobs \
-H "Authorization: Bearer $OCULAR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"profile": "analysis", "html": "..."}'
curl http://localhost:8000/jobs/ \
-H "Authorization: Bearer $OCULAR_TOKEN"
```
所有路由都要求提供 `Authorization: Bearer $OCULAR_TOKEN`;如果在服务器端未配置 `OCULAR_TOKEN`,API 会返回 `503`(默认拒绝原则),绝不会允许无身份验证的访问。
### 脚本化动态层级 (3c)
单独的 `capture` 配置只能看到**页面加载时**的内容。然而,许多恶意行为仅在**交互后**才会触发:多步骤钓鱼表单(凭证 → OTP → 重定向)、仅在点击时触发的跟踪标签(“beacon”)、只有在 `scroll` 或关闭同意框后才显示的内容。脚本化层级在同一个 `capture` 容器(`ocular-runner-recon`,无需额外镜像)中重放一段**声明式动作序列**,同时捕获网络流量,从而在一个临时的、一次性运行中揭示这些交互后的调用。
**DSL。** 一个包含 *steps* 的列表,每一步都是一个仅包含单一键的对象,键名必须属于白名单动词 `goto`、`fill`、`click`、`wait`、`press`、`capture`、`scroll` 中的一个:
```
[
{"click": "#accept-cookies"},
{"fill": {"sel": "#email", "value": "victime@exemple.tld"}},
{"click": "#submit"},
{"wait": 1000},
{"capture": "apres-soumission"}
]
```
`goto` 导航到新的 URL(重新验证 SSRF),`fill` 填充字段 (`{sel, value}`),`click`/`wait`(毫秒或 `{selector}`)/`press` (白名单内的按键) 控制交互,`scroll` 移动页面 (`"top"`/`"bottom"`/像素值),`capture` 拍摄带标签的截图。如果序列没有以此动作结尾,会**自动追加**一个最终的 `capture`,以始终获取最终状态。严格限制:步骤 ≤ 50,选择器 ≤ 500 字符,`fill` 值 ≤ 2000,`wait` ≤ 30000 毫秒,`scroll` ≤ 100000 像素,标签 ≤ 64 字符 —— 任何超出限制或使用不在白名单内的动词都会在执行前被拒绝。
**安全保证。**
- **无任意 JS,无 `eval`**:动词是由 `engine.steps.validate_steps` 验证的严格白名单(唯一数据源,同时被 Web 和 runner 导入 —— 不存在可能产生分歧的第二套实现);选择器和值通过 Playwright 的 **locator API**(`page.locator`, `page.fill`)传递,绝不会被插值到要执行的代码中。
- **步骤通过 stdin 传输,绝不通过参数或环境变量**:broker 将 `{"url": ..., "steps": [...]}` 写入容器的标准输入(`docker run --rm -i`)—— 步骤(以及输入的值)**不会出现在 `docker inspect` 或宿主机上其他进程可见的命令参数中**。
- **脱敏的 `fill` 值**:在返回给客户端的动作日志和系统日志中,所有字段的值都会被替换为 `"***"` —— 绝不会在执行后以明文存储或显示任何密码/凭证。
- **对初始 URL 和每个 `goto` 进行 SSRF 检查**:`engine.ssrf.validate_capture_url` 适用于起始 URL 以及序列期间请求的任何导航(与 `capture` 配置相同的规则 —— 私有/loopback/link-local/云元数据 IP 在上游被拦截)。
- **与 `capture` 配置 3a 相同的容器加固**,原样复用(不重复造轮子):`--cap-drop ALL`,专用 seccomp,`--read-only`,非 root,仅在连接目标时启用网络(与上述 IP/代理警告相同)。
**使用方法。**
```
cat > steps.json <<'EOF'
[{"click": "#accept-cookies"}, {"fill": {"sel": "#email", "value": "test@exemple.tld"}},
{"click": "#submit"}, {"wait": 1000}, {"capture": "apres-soumission"}]
EOF
OCULAR_TOKEN= make script URL=https://exemple-suspect.tld STEPS=steps.json
```
`make script` 读取 `STEPS` 文件,构造 `{"profile":"capture","url":$URL,"steps":<内容>}` 并将其提交给 `POST /jobs` —— 机制与上文“通过 API”章节中的 `Authorization: Bearer $OCULAR_TOKEN` 相同。无效步骤(未知动词、超出限制、对 `goto` 的 SSRF 拦截) → 返回 `422` 并附带拒绝原因。结果会输出**动作日志**(`dynamic_steps`:动作、成功与否、耗时、可能的错误 —— 值已脱敏)以及**带标签的截图库**,这些内容在 API 和 UI 的脚本表单(`http://localhost:8000`,capture 标签页 —— 即上述 JSON 格式的“脚本”字段)中均可查看。
### 通过 UI 使用
```
OCULAR_TOKEN= make up
```
然后打开 `http://localhost:8000` —— 使用令牌登录,提交作业,跟踪作业进度,并在可安装的 PWA 中查看详情(截图、DOM)。
### 交互式(手动导航)
对于自动分析(`analysis`/`capture` 配置)不足的情况 —— 目标检测到自动化、需要手动填写的表单、多步骤导航 —— Ocular 提供了一个交互式会话:一个持久化的有头 Camoufox 容器,通过内嵌在 UI 中的 noVNC 客户端,由分析员的鼠标/键盘进行远程控制。
**安全模型 —— 仅限像素。** 分析员从不直接与目标容器通信:
- **仅渲染像素**,通过 Web gateway:分析员的浏览器向 `web`(`/sessions/{id}/ws`,通过子协议进行身份验证 —— capability token 绝不会出现在 URL 或日志中)打开 WebSocket,由其在**专用于该会话**的 Docker 网络(`ocular-sess-net-{id}`,在启动时创建并在销毁时移除;为了代理的需要,`web` 会被动态挂载到该网络上)上将来自会话容器的 RFB/noVNC 流进行转发。目标端的任何 DOM、任何 cookie、任何下载的文件都永远不会触达分析员的机器 —— 传递的只有图像。
- **在源头切断剪贴板**:容器的 VNC 服务器使用 `x11vnc -noclipboard -nosetclipboard`(`runner_recon_vnc/entrypoint_vnc.sh`)运行 —— 无论浏览器端使用什么 noVNC 客户端,任何文本都无法在分析员的剪贴板和会话之间传递。
- **不发布任何宿主机端口**:会话容器(`ocular-runner-recon-vnc`)由 broker 启动时没有使用 `-p`/`--publish`(`broker/sessions.py::build_session_args`);`session_server` (8090) 和 websockify/noVNC (6080) 监听 `0.0.0.0` 只是因为它们不需要向宿主机发布任何内容 —— 这种隔离源于缺乏端口映射加上**专用于该会话**的内部网络,绝不是依靠 localhost 绑定。原始的 VNC 服务器 (5900) 仅在容器内部的 `localhost` 上监听;只有 websockify(在同一容器内)可以访问它。
- **会话间隔离**:每个会话都有其**独立的** docker 网络(`ocular-sess-net-{id}`),由 broker 在启动时创建并在销毁时移除。因此,两个会话位于**不相交的**网络上:被攻破的会话无法访问另一个会话的 `:6080`(websockify,自身无身份验证)或 `:8090` —— 它根本看不见它们。只有 `web` 被连接到每个会话网络(代理所必需的);而 broker 不连接到其中任何一个。这一点由
`tests/test_session_isolation_integration.py` 验证。
- **临时容器 + 清理程序**:每个会话的生命周期都是有限的 —— 绝对 TTL(`OCULAR_SESSION_TTL`,默认 1800 秒)和非活动超时(`OCULAR_SESSION_IDLE`,600 秒),由运行在 broker 中的清理程序(线程,间隔 `OCULAR_REAPER_INTERVAL`,60 秒)控制,它会销毁(`docker kill` + `docker rm -f`)所有过期的容器,即使是孤立的(确定性的名称 `ocular-sess-{id}`,不依赖注册表)。
- **扫描孤立项**:在 broker 启动时以及*定期*(守护线程,间隔 `OCULAR_SWEEP_INTERVAL`,600 秒)删除注册表中没有存活会话的 `ocular-sess-*` 容器和 `ocular-sess-net-*` 网络。如果没有这种定期扫描,在生命周期内产生的残留物(部分失败的销毁、在流程外被杀死的容器)将占用 Docker 地址池中的一个子网 —— 一种有限的资源 —— 直到 broker 下次重启。
**⚠️ 警告 —— IP 暴露及容器端渲染的内容。** 与 `capture` 配置一样,交互式会话会让 Camoufox 真实地导航到目标 URL(或渲染提供的 HTML):执行 Ocular 的机器 IP 会暴露给目标(如有必要,请使用 `HTTPS_PROXY`/Tor,参见上一章节)。目标上潜在的恶意内容(JS、弹窗、自动触发的下载)会在**强化的容器**(`--cap-drop ALL`,recon seccomp,`--read-only`,非 root)中执行和渲染 —— 绝不会在分析员的机器上执行,后者仅接收像素。
**打开会话 —— 创建过程是异步的 (202):**
```
curl -X POST http://localhost:8000/sessions \
-H "Authorization: Bearer $OCULAR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://exemple-suspect.tld"}'
# HTTP 202 Accepted (响应时间 < 1 s)
# -> {"session_id": "sess-…", "token": "…"} -- token 支持 WS,一次性使用
```
该路由过去是**同步的**:它会等待会话就绪(最多等待 `OCULAR_SESSION_READY_TIMEOUT`,30 秒)再做出响应。如果客户端在等待期间放弃 —— 超时、`Ctrl-C`、上游代理、关闭标签页 —— 将**永远**无法获取其 `session_id`,而此时会话却已创建完毕:它会一直占用一个容器(约 4 GB)和 docker 池中的一个子网,直到其 TTL 耗尽,**且没有任何人能够删除它**。现在,客户端会立即收到标识符,因此随时可以进行清理。
**轮询可用状态:** `GET /sessions/{session_id}`
```
curl http://localhost:8000/sessions/sess-… -H "Authorization: Bearer $OCULAR_TOKEN"
# -> {"session_id":"sess-…","state":"starting","ready":false,
# "kind":"recon-vnc","target":"https://exemple-suspect.tld/","created_at":…,"last_activity":…}
```
| `state` | 含义 |
| --- | --- |
| `pending` | 注册表条目存在,但容器**尚未启动** |
| `starting` | 容器已启动,其 `session_server` 尚未响应 `/health` |
| `ready` | 已就绪:noVNC WebSocket 和 `/capture` 可用 |
`ready`(布尔值)是 `state == "ready"` 的派生状态:应基于它进行判断,而不是基于硬编码的中间状态列表,以便在未来添加新状态时依然有效。
该响应**绝不**包含 WS capability token 或容器边界密钥(与 `GET /sessions` 过滤器相同);`owner` 仅对管理员可见。与会话的所有路由一样,对于未知会话或属于其他分析员的会话,它都会返回 **404**(故意使其无法区分:防止探测会话是否存在)—— 管理员可绕过此限制。
**清理 —— 202 状态的必然要求。** 如果在合理的时间内未达到可用状态,或者轮询失败,客户端**必须**调用 `DELETE /sessions/{session_id}`。轮询期间收到 404 表明服务器自身已放弃并销毁了会话(从同步契约中保留的安全网):这是一个终止性失败,而不是等待状态。
随后,UI 的 noVNC 客户端会使用此令牌作为 WebSocket 子协议连接到 `/sessions/{session_id}/ws`。更简单的方法是:在 UI 中(使用 `$OCULAR_TOKEN` 登录后)打开 `http://localhost:8000` 的 `#/interactive`,该界面会处理会话创建、轮询(带可见进度)、失败时的清理以及 noVNC 显示,而无需直接操作 API。
### 身份验证与机密可移植性
Ocular **不**强求任何特定的身份提供商或机密管理器 —— 这是一个刻意的可移植性选择:
- **身份验证**:所有受保护的路由(`/jobs*`, `/saved*`, `/sessions*`)都需要在服务器端验证(`web/app.py`)的简单令牌 `Authorization: Bearer $OCULAR_TOKEN`。这个不透明的令牌既可以单独使用(单用户部署),也可以放置在**任何**上游反向代理或 SSO 层(Authentik、Authelia、通用 OIDC、LDAP、Caddy/Nginx Basic Auth...)之后使用 —— Ocular 既不了解也不依赖任何特定的提供商;代理只需转发或固定 `Authorization` 标头即可。
- **机密**:`OCULAR_TOKEN`、`OCULAR_ADMIN_TOKEN`(以及任何其他机密)都是从环境变量中读取的(`deploy/.env`,参见 `deploy/.env.example`)。任何能够在容器启动时注入环境变量的机密管理器(Vault、SOPS、`docker compose --env-file`、Kubernetes secrets、VPS 管理器...)都可以直接使用,无需修改 —— **不需要任何特定的管理器**:对于单 VPS 部署,一个简单的本地 `.env` 文件就足够了。
#### IdP 身份标识(forward-auth) —— 可选
为了追踪**是谁**在进行检查/判定(分析员判定、保存来源),Ocular 可以从经过身份验证的反向代理注入的请求头中提取用户身份 —— 兼容**任何** IdP(Keycloak、Authentik、Authelia、oauth2-proxy、由代理前端的 LDAP...),且不会产生供应商锁定。
- **默认禁用。** 仅当 `OCULAR_TRUST_FORWARD_AUTH=1` 时才会读取身份标头。
如果没有此变量,则仅通过 `Bearer $OCULAR_TOKEN` 进行身份验证(行为保持不变)。
- 变量:`OCULAR_TRUST_FORWARD_AUTH`(启用项),`OCULAR_FORWARD_USER_HEADER`(默认 `X-Forwarded-User`),`OCULAR_FORWARD_EMAIL_HEADER`(默认 `X-Forwarded-Email`)。
- 启用后,携带身份标头的代理请求将被自动授权(IdP 后面的分析员**无需粘贴任何令牌**);该身份标识将作为 `saved_by` 并记录到分析员判定中。`GET /auth/whoami` 会返回调用者的身份。
- **审计日志中的客户端 IP**(同为启用项):`gateway` 前端拥有发布的端口并在 **L4** 进行中继,因此 `web` 看到的 TCP 对端始终是网关。当 `OCULAR_TRUST_FORWARD_AUTH=1` 时,审计日志行 `session create` 会从 `OCULAR_FORWARD_FOR_HEADER`(默认 `X-Forwarded-For`)中获取 IP,取**最左侧的元素**(即原始客户端;该列表是通过依次追加构建的)。如果不启用,将忽略该标头并记录对端 IP:一个已知且可靠的前端 IP,比由客户端选择的 IP 要好。由于网关属于 L4,它会**原样**传输标头 —— 必须由上游的反向代理负责对其进行设置并剥离客户端伪造的副本。
- **通过 IdP 组分配管理员角色**(可选,需要同时设置 `OCULAR_TRUST_FORWARD_AUTH=1`):设置 `OCULAR_ADMIN_GROUP=<组名>` 会将管理员角色(`DELETE /saved`)授予任何组标头(`OCULAR_FORWARD_GROUPS_HEADER`,默认 `X-Forwarded-Groups`,逗号分隔列表)**完全包含**该组的调用者(区分大小写,且不能有杂乱空格 —— 设置 `OCULAR_ADMIN_GROUP=" admins"` 将无法匹配 `admins`)。
作为替代方案,`X-Admin-Token` 仍然可以并行使用。如果为空(默认值) → 只能通过 `X-Admin-Token` 获取管理员权限。`GET /auth/whoami` 会公开 `groups` 和 `is_admin`(UI 会对非管理员隐藏管理控制 —— 但后端依然是真正的安全防线)。
## 部署
在 VPS 上:
1. 创建 `deploy/.env`(复制自 `deploy/.env.example`),至少包含 `OCULAR_TOKEN=<强随机令牌>`。
2. `make up` —— 自动构建 runner 镜像(依赖于 `build-runner`),然后通过 `docker compose` 启动 `redis`、`web` 和 `broker`。
3. `make down` 停止运行;`make gc` 清理孤立的工件(`ocular-artifacts` 卷中不再被任何 Redis 结果引用其 ref 的文件)。`make gc` 在 `broker` 容器中运行(通过 `docker compose exec`,该容器可访问正确的 Redis 和共享卷)—— 堆栈必须事先已启动(`make up`)。
`web` 层永远无法访问 `docker.sock`(只有 `broker` 可以访问),并以只读方式从共享卷 `ocular-artifacts` 读取工件。建议在进行任何公开暴露之前,在 `web` 前面部署带有 TLS 的反向代理(Caddy)以及额外的身份验证层。
## 安全性
Ocular **加载并执行恶意的 Web 内容**:这是它的功能。关于容器隔离(权限分离、强化的临时容器、会话级网络隔离、带有 IP 固定的 SSRF 防护)的说明请参阅 [`SECURITY.md`](SECURITY.md),该文件还记录了**已知并已确认的限制** —— 其中包括 `broker` 会挂载 Docker socket 这一事实。
如需报告漏洞:**请不要公开提交 issue**,请参阅 [`SECURITY.md`](SECURITY.md)。
部署前置条件(`DOCKER-USER` 规则,`default-address-pools`):
[`docs/DEPLOY-SECURITY.md`](docs/DEPLOY-SECURITY.md)。
## 许可证
**GNU Affero General Public License v3.0 或更高版本** (AGPL-3.0-or-later),适用于项目本身的代码。
对于像这样的工具,AGPL 增加了一条至关重要的 GPL 附加条款:如果您**将修改后的版本作为网络服务公开暴露**,则必须向其用户提供相应的源代码 —— 不仅仅是那些您分发了二进制文件的对象。
这是刻意为之的:Ocular 就是作为服务来运行的。
```
Copyright (C) 2026 GuatX
Ce programme est un logiciel libre : vous pouvez le redistribuer et/ou le
modifier selon les termes de la GNU Affero General Public License telle que
publiée par la Free Software Foundation, soit la version 3 de la licence,
soit (à votre choix) toute version ultérieure.
Ce programme est distribué dans l'espoir qu'il sera utile, mais SANS AUCUNE
GARANTIE, sans même la garantie implicite de QUALITÉ MARCHANDE ou
D'ADÉQUATION À UN USAGE PARTICULIER. Voir la GNU Affero General Public
License pour plus de détails.
Vous devriez avoir reçu une copie de la GNU Affero General Public License
avec ce programme. Si ce n'est pas le cas, voir .
```
全文请见:[`COPYING`](COPYING)。
包含的第三方组件(MPL-2.0 协议下的 noVNC,MIT 协议下的 pako)仍受**其各自许可证**的约束 —— 参见 [`THIRD-PARTY-NOTICES.md`](THIRD-PARTY-NOTICES.md)。
标签:DNS 反向解析, Web安全, 反欺诈, 威胁情报, 容器隔离, 开发者工具, 恶意代码分析, 搜索引擎查询, 搜索语句(dork), 沙箱环境, 浏览器自动化, 特征检测, 蓝队分析, 请求拦截, 逆向工具, 配置审计, 配置文件