业务风控系统 · 开源版 实时识别撞库、盗号、恶意注册、刷单、薅羊毛、爬虫等业务风险。
星云是一套业务风控系统 —— 重心不是「这个请求有没有 SQL 注入」,而是:
- 同一个 IP 在 10 分钟内登录失败 50 次 → 撞库
- 一个账号同时在 5 个城市登录 → 盗号
- 500 个新注册账号用了同一个设备指纹 → 批量注册
- 一批订单下单后从不支付 → 恶意占库存
- 活动开始 3 秒内被领走 80% 的券 → 薅羊毛
它通过旁路采集业务流量或日志,还原成标准化的业务事件,在事件流上做实时统计与 策略判定,产出风险名单和处置决策,再由业务系统消费。
整套系统围绕四层模型运转:
flowchart LR
A["<b>事件 Event</b><br/>业务行为<br/><br/><code>ACCOUNT_LOGIN</code><br/>c_ip · uid · result=F"]
B["<b>变量 Variable</b><br/>统计特征<br/><br/>该 IP 10 分钟内<br/>登录失败 6 次"]
C["<b>策略 Strategy</b><br/>判定规则<br/><br/>失败次数 > 5<br/>且本次失败"]
D["<b>名单 Notice</b><br/>风险结论<br/><br/>IP 标记 review<br/>有效期 5 分钟"]
A --> B --> C --> D
一条告警是怎么产生的 —— 以撞库为例:
sequenceDiagram
participant U as 攻击者
participant W as 业务系统
participant C as collector
participant E as Flink 引擎
participant R as Redis 名单
U->>W: 登录请求(第 6 次失败)
W->>C: 上报 ACCOUNT_LOGIN<br/>c_ip=198.51.100.77, result=F
Note over C: 口令 / Cookie 就地脱敏<br/>IP / 账号保留原值
C->>E: 事件进入 Kafka
Note over E: 变量:该 IP 10 分钟内<br/>失败次数 = 6
Note over E: 策略「IP多次登录失败」<br/>6 > 5 且 result == F
E->>R: 写入名单<br/>key=198.51.100.77, review, TTL 5min
W->>R: 下一次请求前查询 /checkRisk
R-->>W: review + 判定依据
脱敏发生在数据离开你的网络边界之前,不是「先收上来再说」。实际效果:
echo '{"name":"ACCOUNT_LOGIN","timestamp":1784944800000,"c_ip":"198.51.100.1",
"uid":"alice","password":"hunter2","cookie":"sid=abc123","result":"F"}' \
| nebula-collector -events seeds/events{
"name": "ACCOUNT_LOGIN", "timestamp": 1784944800000,
"c_ip": "198.51.100.1", "uid": "alice", ← 保留原值
"password": "<REDACTED>", "cookie": "<REDACTED>", ← 就地脱敏
"result": "F"
}口令和 Cookie 被抹掉,IP 和账号保留 —— 这不是疏漏。c_ip 一旦在采集端哈希,地理
定位、IP 信誉、跨维度关联就全部失效,风控直接废掉。所以分成两条界限:sensitive
在采集端就地脱敏(原文不出边界),pii 保持原值、由存储层加密保护。完整论证见
隐私设计。
内置 17 类事件模型、253 个统计变量、170 条策略模板 —— 这些是从 Nebula 1.x(2019 年开源,1098 star) 继承并经过审计的风控知识沉淀。技术栈完全重写,只继承领域资产,不继承代码。
内置策略中也有 19 条基于特征匹配的 WAF 类规则(SQL 注入、XSS、目录遍历、恶意扫描 等),但它们是补充而非重点 —— 星云不能替代专业 WAF,它的价值在于跨事件、跨时间 窗、跨主体维度的行为分析,这正是 WAF 做不到的。
0.x 早期阶段。可以起一套完整系统做评估,但不能承接生产级流量。
| 能做什么 | docker compose up 起完整链路(采集 → Kafka → Flink → 告警 → 控制面),接入自己的流量评估效果,在控制面管理策略与账号、查告警 |
| 不能做什么 | 承接生产流量。全部组件单节点、无高可用;Helm / K8s 编排尚未开始 |
逐项实现状态(点击展开)
数据与模型
| 领域模型定义(JSON Schema)与强制校验 | ✅ |
| 风控资产:17 事件 / 253 变量 / 170 策略 / 15 标签 | ✅ 已审计 |
采集
| 采集器(Go 单二进制,含脱敏引擎) | ✅ 接入 stdin / 文件 / HTTP / syslog,输出 stdout / 文件 / Kafka |
| syslog 接入(RFC 3164 / 5424,UDP 与 TCP) | ✅ |
| Kafka 输出(franz-go,按主体分区) | ✅ |
| OpenResty 埋点(网关侧,敏感字段不出网关) | ✅ |
| Kafka 作为数据源 | 🚧 |
计算
| 参考引擎(零依赖,验证语义规格) | ✅ |
| 计算引擎(算子、条件、窗口、变量图、规则引擎) | ✅ |
| Flink 接入、多维度并行拓扑(并行度 1/2/4 结果一致)、Checkpoint | ✅ |
| 序列检测(「做了 A 但 N 秒内没做 B」) | ✅ |
| 多步序列检测(A → B → C,分组 + 时间窗) | ✅ |
| 序列的分支模式(A 之后 B 或 C) | 🚧 |
控制面
策略管理与 /checkRisk、审计日志 |
✅ |
| 认证授权(Argon2id、角色、服务令牌 + 网段绑定) | ✅ |
| 告警查询与趋势(分级脱敏) | ✅ |
| 策略编辑(schema 校验、乐观并发、修订历史) | ✅ |
| 元数据下发(引擎从控制面加载,单一事实来源) | ✅ |
| 管理界面(登录、策略管理、告警查询、变量浏览、账号与令牌) | ✅ |
| 策略热更新(改完无需重启作业,窗口状态不丢) | ✅ |
| 数据主体权利(按主体导出与删除) | ✅ |
| 表单化策略编辑器(字段与取值从 domain-schema 派生) | ✅ |
存储与部署
| ClickHouse 落库(明细、告警、物化视图小时聚合、TTL) | ✅ |
| 事件明细的 pii 保护(写库前 HMAC,计算仍用原值) | ✅ |
| PostgreSQL 元数据(JSONB + 约束 + 审计分区) | ✅ |
| 可观测性(引擎 / 控制面 / 采集器三端指标) | ✅ |
| Lite 部署(三个组件容器化,compose 一键起全栈) | ✅ |
| Helm / Kubernetes 编排(单节点 k3s 上实测装通) | ✅ |
| 存储组件的高可用(Operator / 托管服务) | 🚧 |
如果你现在就需要一个经过生产检验的完整实现,可以参考 Nebula 1.x —— 但它已于 2019 年停止维护, 依赖的技术栈(Python 2、OpenResty 1.11、Esper 6、commons-collections 3.2.1)均已 EOL 且存在公开的已知漏洞,不建议用于生产环境。
这几条是这个项目在取舍时反复回到的判断,也是它与「再写一个规则引擎」的区别所在。
| 告警必须可解释 | 每条告警附带判定依据:哪个指标、当前值多少、超了什么阈值。运营看到告警却看不到依据,就只能靠猜 |
| 领域模型只有一份 | 事件、变量、策略的结构由 JSON Schema 定义,引擎与控制面共读同一份,CI 强制校验。同一个模型在两处各写一份,迟早会漂移 |
| 隐私是采集端的事 | 敏感字段在数据离开客户网络边界之前就地脱敏,而不是「先收上来再说」。事件模型的 184 个字段全部分级,漏标会导致校验失败 |
| 不把计划写成现状 | 文档里标 ✅ 的每一项,读者都能自己验证;没实现的一律标 🚧。这条看起来是废话,但它是本项目返工最多的一条 |
| 语义变更必须被证明 | 参考引擎与生产引擎读同一批测试向量、跑同一批用例,两套实现之间的语义漂移在结构上不可能发生 |
虚线框 = 尚未实现,逐项状态见上方项目状态。
flowchart TB
subgraph src["数据来源"]
T["业务流量 / 日志"]
Z["Zeek 旁路 · syslog"]
end
C["collector<br/>Go 单二进制<br/>采集端脱敏"]
K["Kafka / Redpanda"]
subgraph flink["Flink 计算作业"]
V["① 变量引擎<br/>DAG 算子 · 有状态"]
R["② 规则引擎<br/>条件树 · CEL"]
P["③ 画像更新"]
end
subgraph ctrl["控制面"]
API["console-api<br/>策略管理 · 认证 · 审计"]
WEB["console-web<br/>管理界面"]
end
PG[("PostgreSQL<br/>元数据")]
CH[("ClickHouse<br/>事件明细 · 告警 · 聚合")]
RD[("Redis<br/>风险名单")]
BIZ["业务系统"]
T --> C
Z -.-> C
C --> K
K --> V
V --> R
R -.-> P
R --> CH
R --> RD
V --> CH
API -- "元数据下发" --> flink
API --- PG
API -- "告警查询" --> CH
WEB -.- API
BIZ -- "/checkRisk" --> API
API --> RD
classDef todo stroke-dasharray: 5 5,color:#888
class Z,P,WEB todo
元数据只有一个事实来源。 策略与变量存在 PostgreSQL,引擎启动时从控制面拉取 —— 而不是各自读一份本地文件。这是 1.x 领域模型漂移的直接教训,见 ADR-0005。
| 层 | 选型 | 说明 |
|---|---|---|
| 采集 | Go 单二进制 + OpenResty Lua 埋点 | 接入 stdin / 文件 / HTTP / syslog(Zeek 配成 JSON 后走这两条),输出 stdout / 文件 / Kafka;Kafka 作为数据源 🚧 |
| 计算 | Apache Flink | 事件时间语义、精确一次、状态可恢复;统一了 1.x 的实时与离线两套引擎 |
| 规则表达式 | CEL | 类型安全、沙箱执行、跨语言实现一致 |
| 消息 | Kafka / Redpanda | 兼作事件持久化与重放 |
| 分析存储 | ClickHouse | 事件明细 + 小时聚合 + TTL 自动过期 |
| 元数据 | PostgreSQL 16 | 策略/变量以 JSONB 存储 |
| 名单/热状态 | Redis / Valkey | 黑白名单 TTL |
| 控制面 | Java 21 + Spring Boot 3 | 与计算层共享领域模型 |
| 前端 | React 19 + TypeScript + ECharts | |
| 可观测 | OpenTelemetry + Prometheus + Grafana | 使用官方组件,不做魔改 |
设计取舍的完整推理见 架构决策记录(ADR)。
nebula2/
├── apps/
│ ├── collector/ # Go 采集器
│ ├── engine/ # Flink 计算作业(变量引擎 + 规则引擎)
│ ├── console-api/ # 控制面 API(Spring Boot 3)
│ └── console-web/ # 管理界面(React 19 + TS + Vite)
├── packages/
│ ├── domain-schema/ # 领域模型 JSON Schema —— 单一真相源
│ ├── reference-engine/# 参考引擎(零依赖,语义规格的可执行版本)
│ └── cel-functions/ # CEL 扩展函数的规范(三个函数两个引擎均已实现)
├── seeds/ # 内置风控资产:事件/变量/策略/标签
├── deploy/
│ ├── compose/ # Lite 部署(可用)
│ ├── schema/ # 建表脚本
│ └── helm/ # Cluster 部署(单节点验证过,HA 见 README 的取舍说明)
├── docs/ # 文档站
├── tests/golden/ # 新旧引擎语义回归测试
└── tools/ # 迁移与开发工具
packages/domain-schema 是整个项目的地基。 事件、变量、策略的结构由 JSON Schema 定义,Java 与 TypeScript 类型均由它生成,文档中的字段参考也由它生成。这样做是为了根除 1.x 的一个顽疾:元数据层声明的能力远多于引擎实际实现的,用户在界面上配得出来的变量,引擎跑不了。2.0 要求声明即实现,CI 强制校验。
从 1.x 完整继承并经过审计的风控知识沉淀,位于 seeds/:
| 资产 | 数量 | 说明 |
|---|---|---|
| 事件模型 | 17 | 以 HTTP_DYNAMIC(30 个基础字段)为根,继承出登录/注册/改密/下单/支付/活动等业务事件 |
| 统计变量 | 253 | 四层窗口:5 分钟实时、1 小时槽、当天、长期画像 |
| 策略模板 | 170 | 订单 70 / 账号 60 / 访客 40,按 IP、设备、账号三维度镜像设计 |
| 风险标签 | 15 | 撞库、盗号、刷单、羊毛党、恶意扫描、爬虫等 |
一条策略长什么样 —— 以 IP多次登录失败 为例(已简化):
同一个风险模式通常有 IP / 设备 / 账号三条镜像策略 —— 攻击者能绕开其中任何一个维度, 三个同时绕开的成本高得多。详见策略开发指南。
数据来源声明:这些资产源自 1.x 的出厂配置。迁移过程中已剔除全部真实客户与个人信息,所有示例数据均为合成数据。详见
seeds/INVENTORY.md。
两条路径,按你想验证什么来选。
git clone https://github.com/threathunterX/nebula2.git
cd nebula2/packages/reference-engine
node run.js会看到:
场景 credential-stuffing:策略 170 条、变量 253 个、事件 160 条,耗时 21ms
变量图节点 49 个(按策略引用的依赖闭包构建)
求值 12320 次,命中 114 条,去重抑制 1486 条
告警分布:
42 条 IP请求登录前未访问必要资源
主体: 198.51.100.77, 198.51.100.78, 198.51.100.35 等 42 个
42 条 设备请求登录前未访问必要资源
2 条 IP多次登录失败
主体: 198.51.100.77, 198.51.100.78
2 条 IP关联多个用户
2 条 IP大量请求登录接口
...
160 条合成事件里,IP多次登录失败 恰好挑出了 2 个攻击 IP,40 个正常用户一个没碰。
排在最前面那两条各命中 42 次的策略把所有主体都打中了 —— 这不是 bug,而是内置模板
的已知问题:它们含未配置的占位符,判定条件退化成恒真。这正是「内置模板不能直接上
生产」最直观的例证,见 seeds/PLACEHOLDERS.md。
每条告警都带判定依据 —— 这是 2.0 相对 1.x 的实质改进(1.x 设计了这个字段,但代码 里被写死为空字符串,运营看到告警却看不到依据):
node run.js --strategy "IP多次登录失败" --json{
"key": "198.51.100.77",
"check_type": "IP",
"strategy_name": "IP多次登录失败",
"decision": "review",
"expire": 1784945120144,
"variable_values": {
"result": { "value": "F", "operator": "==", "threshold": "F" },
"count(c_ip) by c_ip in 600s": { "value": 6, "operator": ">", "threshold": "5" }
}
}读作:因为本次登录失败,且该 IP 在过去 10 分钟内已失败 6 次,超过阈值 5。
node run.js --scenario crawler # 爬虫场景:6 条策略交叉印证同一个 IP
node --test 'test/*.test.js' # 139 个规格符合性与端到端测试参考引擎是零依赖的最小实现,用于验证语义规格、作为回归基线,不用于生产。
cd nebula2/deploy/compose
./gen-env.sh # 生成随机凭据,不含任何默认口令
docker compose up -d启动顺序是有依赖的:
flowchart LR
I["PostgreSQL · ClickHouse<br/>Redis · Redpanda"] --> S["schema-init<br/>建表"]
S --> D["seed-load<br/>导入 17 事件 / 253 变量 / 170 策略"]
D --> A["console-api<br/>控制面"]
A --> F["jobmanager + taskmanager<br/>Flink 集群"]
console-api 等的是 seed-load 执行完成,不是"已启动" —— 库空着的时候控制面
能起来、能通过健康检查、能登录,然后每个管理请求都返回空。「服务健康但什么也管不了」
是最难判断的一种状态。
管理员口令只在首次启动时打印一次:
docker compose logs console-api | grep -A4 已创建初始管理员账号系统里没有默认口令,也不从配置文件读口令。提交引擎作业、灌入事件、查看告警的完整 步骤(以及错过口令怎么重置)见 Lite 部署说明。
完整步骤与结果解读见快速开始。
| 风控数据模型 | 建议第一篇。事件 → 变量 → 策略 → 名单 |
| 系统架构 | 组件职责、数据流、两种部署形态 |
| 接入指南 | 数据源、字段映射、脱敏配置、/checkRisk、接入后验证 |
| 策略开发 | 条件三形式、三维度镜像、阈值校准、生命周期 |
| 算子语义规格 | 规范性文档。每个算子的精确定义与 1.x 差异对照 |
| 类型推导规则 | 窗口 × 类型 × 算子的合法组合 |
| 变量参考 | 253 个内置变量 + 画像变量详解(自动生成) |
| 策略模板参考 | 170 条内置策略逐条说明(自动生成) |
| API 参考 | 全部接口、权限矩阵、错误码 |
| 配置项参考 | 三个组件的全部配置项与默认值 |
| 部署 | 形态选择、凭据注入、暴露面、升级 |
| 隐私设计与合规 | 数据分级、脱敏、保留期、主体权利、法规对齐 |
| 1.x → 2.0 迁移 | 资产迁移与 6 处语义差异 |
| 架构决策记录 | 每个关键选型背后的推理与代价 |
| 路线图 | 下一个版本做什么、为什么、代价与风险 |
| Golden 回归测试 | 如何证明语义继承是正确的 |
| 全部文档 | 文档索引(含尚未编写文档的提纲) |
本项目是 Nebula 1.x 的重写版本,不是升级版。
1.x 于 2019 年开源(1000+ star),2019 年后停止维护。它验证了产品形态并沉淀了大量风控知识,但技术栈已整体 EOL,且存在架构层面的历史包袱(自研 RPC/序列化/存储格式、实时与离线两套语义不一致的引擎、Python 2 与 Java 双份领域模型漂移)。
2.0 采用全新代码库重写,只继承领域资产,不继承代码:
- ✅ 继承:事件模型、变量定义、策略模板、风险标签、
/checkRisk对外契约、变量命名规范 - ❌ 不继承:全部实现代码、自研基础设施、存储格式
从 1.x 迁移时有 6 处语义差异必须了解(去重计数精度、标准差定义、离线重算取消等),详见 迁移指南。
见 CONTRIBUTING.md,并遵守行为准则。 安全问题请按 SECURITY.md 私下报告,不要提交公开 issue。
此阶段最有价值的贡献是对设计的意见与实战验证过的策略模板,两者都有专门的 issue 模板。
变更记录见 CHANGELOG.md。 项目距离可发布状态还差什么,逐项列在发布就绪度审查里 —— 那份文档是用来列不足的,不是用来自夸的。
本项目的风控领域资产派生自同样采用 Apache-2.0 的 Nebula 1.x,许可证兼容;不包含 1.x 的任何实现代码。第三方算法的出处声明见 NOTICE。
{ "name": "IP多次登录失败", "category": "ACCOUNT", "condition": { // 条件树,支持任意嵌套的 and / or / not "conditions": [ { "left": { "kind": "event_field", "field": "result" }, "op": "==", "right": { "kind": "constant", "value": "F" } }, { "left": { "kind": "counter", "counter": { "algorithm": "count", // 算子语义由规范性文档定义 "event": "ACCOUNT_LOGIN", "groupby": ["c_ip"], // 按 IP 维度聚合 "window": 600, // 10 分钟滑动窗口 "filter": { "object": "result", "operation": "==", "value": "F" } } }, "op": ">", "right": { "kind": "constant", "value": "5" } } ] }, "action": { "check_type": "IP", "check_value": "c_ip", // 风险主体:从哪个字段取 "decision": "review", "ttl": 300 // 处置动作与名单有效期 } }