
最近折腾了 Stave 的数据接入方案,踩了几个"collector 永远适配不了真实环境"的坑,这篇把解决方案说清楚。
想象一个典型云安全工具的接入流程:客户装了工具 → 工具的 collector 尝试连接阿里云 RAM 失败(角色还没创建)→ 客户翻三页配置文档 → 角色建好了 → collector 认证成功 → 运行 → 啥也没扫到,因为工具只认识 OSS 和 RAM,客户实际跑在 ACK(阿里云容器服务)上。第一周结束。
Stave 干脆不提供 collector。它消费 obs.v0.1 JSON 数据快照——谁来产生都行。这个决策听起来激进,但当你看到"collector 根本扫不到我们的环境"这种对话重复三次之后,就理解了。所以 Stave 交付的不是 collector,而是一份合同:每个资产类型的 JSON Schema、Steampipe 到 Stave 的列映射规则,以及一条命令(stave contract show)吐出 agent 接入所需的全部信息。客户想用什么数据源(Steampipe、资源编排、Terraform state、内部 CMDB)都行,只要满足合同就行。
这篇文章梳理一下打通这条管道的几个步骤。
$ stave contract show --asset-type aliyun_oss_bucket
Contract: aliyun_oss_bucket
Schema: schemas/observation/v1/asset-types/aliyun_oss_bucket.schema.json
Controls: 102 | Chains: 15 Property paths (catalog reads these — sorted by chain unlock, then control unlock): PATH CONTROLS CHAINS SEVERITY NOTE ──── ──────── ────── ──────── ──── storage.kind 91 15 critical storage.tags.data-classification 14 2 critical intent storage.access.public_read 8 2 critical storage.controls.public_access_fully_blocked 3 1 critical
...
Steampipe mapping: contracts/steampipe/aliyun_oss_bucket.yaml
这条输出直接告诉客户的 agent 需要什么:
覆盖 catalog 最多的 17 个资产类型,mapping 直接提交。对于其余的资产类型,agent 有 schema,可以自己搞定。
Steampipe 到 Stave 的映射规则按资产类型组织,每个资产类型一个有序的操作列表。四种操作类型覆盖所有变换场景:
field — 直接的列→属性映射,支持可选的类型转换和默认值static — 固定值(比如 properties.storage.kind: bucket)extract — 从 JSON 类型的列中提取嵌套值computed — 从已设置的属性路径派生(all / any 归约)操作按 YAML 顺序执行,后面的操作可以读取前面操作写入的路径。第一份 mapping——contracts/steampipe/aliyun_oss_bucket.yaml——是之前一个 Python 函数改出来的。用声明式 YAML 替换命令式代码,加载逻辑改动了 100 行,生成的 observation 与原函数输出完全一致。
operations: - kind: static path: properties.storage.kind value: bucket - kind: field path: properties.storage.tags column: tags default: {} type: dict - kind: extract path: properties.storage.encryption.algorithm column: server_side_encryption_configuration json_path: "Rules.0.ApplyServerSideEncryptionByDefault.SSEAlgorithm" key_variants: Rules: rules SSEAlgorithm: sse_algorithm default: "none" - kind: computed path: properties.storage.controls.public_access_fully_blocked op: all inputs: - properties.storage.controls.public_access_block.block_public_acls - properties.storage.controls.public_access_block.block_public_policy - properties.storage.controls.public_access_block.ignore_public_acls - properties.storage.controls.public_access_block.restrict_public_buckets
这个格式本身就是合同。任何语言写的 agent 都能解析这个 YAML 并生成符合规范的 observation。
catalog 有 3957 个控制项,它们声明了 109 种不同的 applicable_asset_types。为了验证 mapping 的目标路径是真实存在的,我们需要每个资产类型对应一份 JSON Schema。手动写 109 份 schema 相当于白白浪费一个周二;schema 生成器其实已经有了(它遍历每个控制项的谓词 AST,推断属性路径和类型),只是默认只生成使用最频繁的 3 种类型。
go run ./internal/tools/genassetschemas/... -top 200
make sync-schemas
运行后输出 109 份资产类型 schema,放在 schemas/observation/v1/asset-types/ 下。每层都是 additionalProperties: true——这些 schema 是可发现性产物,不是限制性门禁。比如某个 schema 只列了一个属性(security_hub.enabled 之于 aliyun_securityhub_account),它的意思是"这种资产类型跟 catalog 有关,这是需要填充的唯一属性"。薄 schema 同样有用。
控制项覆盖率最高的另外 10 个资产类型——aliyun_ram_role、aliyun_function_compute、aliyun_cas_user_pool、aliyun_actiontrail_trail、aliyun_kms_key、aliyun_ecs_instance、aliyun_mns_queue、aliyun_ram_user、aliyun_elasticsearch_domain、aliyun_fnf_state_machine——都手写了 mapping。它们有两个作用:覆盖客户问得最多的实际类型,以及作为第 5 轮自动生成器的真值语料。
每份 mapping 都有一个 derived_properties: 块,列出无法从单一 Steampipe 列获取的 catalog 读取属性。比如 aliyun_ram_role.yaml:
derived_properties: - path: properties.identity.role.cross_account_trust_without_external_id source: "解析 信任策略 — 检测 Principal 中是否存在跨账号信任但缺少 sts:ExternalId 条件" - path: properties.identity.permission_categories.has_incompatible_categories source: 基于 controldata/taxonomy/permission_categories.yaml 进行策略分析 - path: properties.identity.access_advisor.available source: ram:GetPolicyVersion + ram:ListEntitiesForPolicy(每个角色需要单独调用)
这个块就是 agent 的 TODO 清单。静默生成不含这些派生属性的 observation 是必须防止的失败场景——Stave 的控制项读不到这个属性,catalog 不报错,问题照样发生。
三个数据源——schema、谓词索引、mapping 文件——早就存在了。原来的做法需要读三份不同的文件。新命令一次搞定:
stave contract show --asset-type aliyun_ram_role --format json
{ "asset_type": "aliyun_ram_role", "has_schema": true, "schema_path": "schemas/observation/v1/asset-types/aliyun_ram_role.schema.json", "controls_count": 198, "chains_count": 38, "property_paths": [ { "path": "properties.identity.kind", "controls_count": 196, "chains_count": 35, "max_severity": "critical", "is_intent_property": false }, ... ], "steampipe_mapping": "contracts/steampipe/aliyun_ram_role.yaml"
}
或者:
stave contract show --list
Asset types with controls: 109 (schema: 109, steampipe mapping: 17) TYPE SCHEMA CONTROLS CHAINS MAPPING ──── ────── ──────── ────── ─────── aliyun_ram_role yes 198 38 steampipe aliyun_oss_bucket yes 102 15 steampipe aliyun_function_compute yes 169 12 steampipe aliyun_bedrock_agent yes 24 5 - ...
实现上复用了代码库里已有的所有东西:compose.LoadControlsFrom、compose.LoadChainDefinitions、predindex.Build(跟 stave gaps 命令用的是同一个索引),以及 internal/contracts/schema/load.go 里 50 行的一个辅助函数用来读取内嵌的 per-asset schema。整个命令 ~330 行,没有任何新数据——只是对已有数据做了投影。
剩余 ~98 个资产类型的 mapping 可以手写也可以自动生成。我们试了自动生成。生成器把缓存的 Steampipe 列目录与每个资产类型 schema 的属性路径做关联,应用四层匹配优先级(per-asset 覆盖、schema 路径多标记符评分、标签约定、兜底到 properties.<ns>.<col>),然后输出跟第 1 轮 established 的操作列表格式相同的 YAML。
make gen-steampipe-mappings # 生成,跳过已有的
make gen-steampipe-mappings-validate # 验证准确率
验证时用 11 份手写 YAML(第 1 轮 + 第 3 轮)跑生成器,把自动生成的 (column, path) 元组跟真值比对:
Overall: 149/177 = 84% accuracy across 17 type(s)
84%——超过了 80% 的目标。剩下的 16% 是 brief 明确标注为"本质上手动处理"的多目标 JSON 路径提取(一列对应两个属性路径这种事情,名字相似度启发式根本搞不定)。自动生成的 YAML 携带 _auto_generated: true、_review_required: N 和 _unmatched_paths: [...],所以审核范围是明确的。
启发式算法的详细演进过程——从第一版 8% 准确率到第四版 84%——值得单独写一篇。这里的重点是实际提交的内容:17 份 mapping(11 份手写,6 份自动生成),每份都是 agent 可以用任何语言读取的产物。
让这套机制运转的架构决策是:extractor 由客户端拥有。Stave 不提供 collector。contracts/steampipe/ 目录里放的是指令,不是代码。agent 读取 schema 和 mapping,自己生成 observation,Stave 只负责评估 observation。collector 边界是一份文件,不是一个进程。
这个决策从项目一开始就写在架构文档里了,但之前没有一条命令能把合同暴露给 agent。想为新资产类型写 Steampipe 接入的 agent 必须:
现在 agent 跑一条命令,三个问题全解决了。agent 跑 make gen-steampipe-mappings 能拿到一份可打磨的起点 YAML。接入门槛低多了。
Stave 的 Go 二进制文件在五轮迭代中除了新增的 cmd/contract/ 目录(一个文件,~330 LOC)之外,没有任何改动。agent 基础设施包括:
examples/agents/stave_transform.py — 参考加载器(Python)contracts/steampipe/*.yaml — 17 份 mapping(已提交)scripts/gen-steampipe-mappings.py — 自动生成器(Python,~280 LOC)scripts/steampipe-columns.json — 缓存的列目录(可从实时 Steampipe 安装刷新)确定性策略引擎纹丝不动。合同在演进,引擎不需要。
把 Steampipe 换成任何外部数据源——资源编排、资源治理、内部 CMDB、SaaS API、OpenAPI 规范——管道形态都是一样的:
定义规范目标合同。 对 Stave 来说是 obs.v0.1 JSON 及 per-asset-type 子 schema。对你的工具来说是引擎消费的任意格式。
每个数据源 × 每个资产类型写一份 mapping。 YAML 就行。field/static/extract/computed 操作列表覆盖大部分变换场景。
发布一条发现命令。 一条 CLI 把 schema + 路径列表 + mapping 合并成单一 agent 可读的输出。agent 不再需要你团队写的文档。
自动生成简单的那一半。 大多数列→路径映射本质上是名字相似度。例外情况少到手写就行用手写集做真值语料来衡量生成器的准确率。
显式标记不确定性。 _review_required、_unmatched_paths、derived_properties:。静默缺失比大声警告更糟糕。
五点,一条能跑的管道。那个需要三页 collector 配置文档的客户,现在只需要跑 make gen-steampipe-mappings 加一个能读 YAML 的 agent。