AiSOC AI 资产发现:用三条既有遥测路径构建可观测的 AI 资产清单

发布时间:2026/11/3 10:27:58
AiSOC AI 资产发现:用三条既有遥测路径构建可观测的 AI 资产清单
  • 人工智能
  • AI Agent
  • 网络安全
  • 后端
  • 前端

【免费下载链接】AiSOC

Open-source AI Security Operations Center: alert fusion, LLM-agent triage, MITRE ATT&CK investigation, and a replayable decision ledger for every agent step. Self-hostable, runs with no API keys, MIT licensed. Ships an MCP server for Claude, Cursor and Continue.

项目地址:https://gitcode.com/GitHub_Trending/ai/AiSOC
点击查看免费下载

本文围绕 AiSOC 的 AI 资产发现(AI estate discovery)能力展开,说明它如何在不新增端点代理的前提下,通过 AI 运行时遥测、端点遥测和模型供应商审计日志三条路径,把 AI 应用、编码代理、MCP 服务器、IDE 扩展、供应商密钥和 OAuth 授权记录进aisoc_ai_assets表。读完之后,你将能够判断每条来源"能看见什么、永远看不见什么",独立部署aisoc-ai-estateosquery pack,并读懂清单页面上一行资产背后的写入链路、风险评分和权限模型。

核心设计前提:观测值而非声明值

AiSOC 的 AI 清单完全建立在"已经在采集的遥测"之上,不需要部署新 agent。三个发现路径分别是:

  1. 你可能已经在路由 AI 流量的 SDK 或网关(ai_gateway连接器、packages/aisoc-ai-sdk);
  2. 机群已经在运行的端点 agent(FleetDM、osctrl、auditd、Windows 事件转发);
  3. 模型供应商自己的审计日志(llm_usage连接器,以及 Workspace / Entra 审计流)。

清单在控制台中位于AI Estate页面,落地存储在aisoc_ai_assets表中。它记录的是 AiSOC观测到的事实,这与/api/v1/mcp-servers记录"运维人员声明的"事实刻意区分开。实现上这是一个独立的表:services/api/migrations/110_ai_assets.sql 在文件头明确写着aisoc_mcp_servers(迁移 069)是声明、本表是观测,"有意思的那一行,是存在于本表而不存在于那张表的行"。

这句话在 AI 治理评审中有直接的操作含义:一个没人注册、跑在某台笔记本上、手里还握着供应商密钥的编码代理,正是这套清单存在的理由。也正因如此,接口层没有提供"创建资产"的路由——services/api/app/api/v1/endpoints/ai_assets.py 的模块文档说明,如果清单可以手工录入,它就不再回答"有哪些东西在运行而没人告诉我们"这个问题;要声明请走/api/v1/mcp-servers。

每条来源能看见什么、看不见什么

这是规划覆盖面之前最值得读的部分。三条来源看到的是真正不同的东西,而且盲区并不对称。因此清单页面上某一列空白,可能意味着"没有人对此负责",也可能意味着"这条来源从来就无法提供这个信息"——只有前者才算一个发现。

AI 运行时遥测

连接器:ai_gateway连接器,以及packages/aisoc-ai-sdk投递到ai-runtime/ai-findinginbox 模板的数据。

能看见看不见
代理的身份与名称设备——除非应用自己上报了 hostname
模型与供应商任何未插桩、也不经过网关的代理
调用了哪些工具、作用于哪些资源从未调用过模型的进程
代理所代理的那个人(若存在)
提示词包含哪些密钥形态,而提示词本身从未离开进程

表格最后一行的实现依据在 ingest 侧模板里:services/ingest/internal/normalizer/templates/ai-runtime.yaml 把prompt_secret_kinds映射到finding.evidence.prompt_secret_kinds,并在注释中说明 SDK 在本地计算形态、只发送提示词摘要,因此即便在默认的哈希捕获模式下,凭证进入提示词的检测依然可用。同一份模板还解释了class_uid: 6003(API Activity)的选择:类别 6 不属于 Findings,所以should_promote()要求severity_id >= 4,日常活动留在 ClickHouse 湖里供狩猎使用,而护栏违规走ai-finding.yaml(类别 2)必定提升为告警。

端点遥测

连接器:fleetdm、osctrl、auditd、windows_event。

能看见看不见
设备调用的是哪个模型
二进制及其完整命令行调用了哪些工具
代理是否在启动时关闭了权限确认提示词内容
MCP 客户端配置文件——服务器被接入的证据任何没有端点 agent 的机器上运行的代理
AI IDE 扩展目录,含发布者与版本
躺在笔记本上的模型供应商凭证文件

:::caution EDR 连接器并不提供这些数据crowdstrike、sentinelone、azure_defender轮询的是厂商自己的检测结果,不是原始进程遥测。crowdstrike.py读取/detects/queries/detects/v1,返回的是判定结论。因此它们被归类为 OCSF Security Findings,无法提供进程启动事件。

端点发现因此只能来自 osquery(经由 FleetDM 或 osctrl)、Linux 上的auditd,以及 Windows 事件转发。如果你只跑 EDR,这一半清单会是空的——那是一个覆盖缺口,而不是一片安静的资产面。 :::

供应商与目录审计日志

连接器:llm_usage,以及 Workspace / Entra 的审计流。

能看见看不见
供应商账号与组织任何设备,永远
谁签发或使用了 API Key是哪个应用使用了该 Key
角色、日志与 MFA 变更在受审计组织之外签发的密钥
哪个 AI 应用被授予了 OAuth 访问权及其 scope该应用拿到授权后做了什么

这三张表在代码里也有一份结构化镜像:services/fusion/app/services/ai_asset_recorder.py 中的SOURCE_CAPABILITIES以SourceCapability(sees, cannot_see, connectors)的形式保存了同样的语义,并在 docstring 里点明为什么要写"看不见"这一半——只列"能看见什么"的表格读起来像完整覆盖,而分析师面对空列时真正需要的恰是盲区说明。

osquery pack:端点侧的五个查询

packs/aisoc-ai-estate.yaml 是端点侧的一半。可以像其他 pack 一样分配给租户,也可以直接渲染后导入:

GET /api/v1/osquery/packs/aisoc-ai-estate/render?format=osctrl GET /api/v1/osquery/packs/aisoc-ai-estate/render?format=fleetdm

这两个路由由 osquery TLS 服务提供:services/osquery-tls/app/api/v1/endpoints/packs.py 中的render_pack接受format参数,支持osquery-json、osctrl、fleetdm三种取值,分别返回to_osquery_json()、to_osctrl_format()、to_fleetdm_format()的结果(见 services/osquery-tls/app/services/pack_loader.py)。路由前缀/api/v1/osquery定义在 services/osquery-tls/app/api/v1/init.py。

pack 内含五个查询:

查询间隔回答的问题
coding_agent_processes5 分钟哪些编码代理正在运行,命令行是什么
mcp_server_processes5 分钟哪些本地 MCP 服务器正在运行
mcp_client_configurations1 小时配置了哪些 MCP 客户端,配置在何处
ai_ide_extensions1 小时装了哪些 AI 助手扩展,版本是多少
provider_key_files1 小时这台笔记本上是否存在模型供应商凭证材料

进程查询的 SQL 与显式名单

coding_agent_processes: sql: > SELECT p.name AS name, p.path AS path, p.cmdline AS cmdline, p.uid AS uid, p.start_time AS start_time, u.username AS username FROM processes p LEFT JOIN users u ON p.uid = u.uid WHERE p.name IN ( 'claude', 'codex', 'aider', 'goose', 'continue', 'opencode', 'gemini', 'copilot', 'copilot-language-server', 'amazonq', 'cline', 'claude.exe', 'codex.exe', 'aider.exe', 'gemini.exe', 'cline.exe' ) interval: 300 severity: info mitre: [T1059]

MCP 服务器查询则用前缀匹配,而非包含匹配:

mcp_server_processes: sql: > SELECT p.name AS name, p.path AS path, p.cmdline AS cmdline, p.uid AS uid, p.start_time AS start_time, u.username AS username FROM processes p LEFT JOIN users u ON p.uid = u.uid WHERE p.name LIKE 'mcp-server%%' OR p.name LIKE 'mcp_server%%' OR p.name LIKE 'mcp-proxy%%'

文件类查询

mcp_client_configurations只SELECT path, directory, filename, uid, mtime, size FROM file,覆盖 Claude Desktop、Claude Code、Cursor、Windsurf、VS Code、Continue、Amazon Q、Gemini CLI、Codex CLI 等客户端各自公布的配置路径;ai_ide_extensions在.vscode/、.vscode-insiders/、.cursor/、.windsurf/的扩展目录里按命名发布者集合过滤;provider_key_files只匹配~/.config/anthropic/%%、~/.openai/%%、~/.config/gcloud/application_default_credentials.json这类路径,并且同样只取元数据。

三个设计取舍,而不是疏漏

进程名是一份显式名单,不是模式匹配。一句LIKE '%agent%'会命中半个 EDR 机群,而这里的一次误报会把某人的杀毒软件送上安全评审。代价是名单之外的代理对进程查询不可见——不过它仍可能通过 SDK 路径出现,如果是编辑器扩展也会被扩展查询捕获。

配置文件的内容永不采集。一份 MCP 配置经常携带第三方服务器的 bearer token。文件的存在、路径和 mtime 就是证据;清单没有理由持有凭证。

每个查询的 severity 都是info。发现某位开发者运行编码代理属于资产盘点,而不是一个发现。真正让一行变得有意思的,是清单从这一行计算出来的风险——无人负责、权限确认被关闭、来源未经审查——而这个判断只存在一处,不被复制进 pack。

pack 文件头的注释还给出了一个容易被忽略的工程约束:结果经由 fleetdm / osctrl 连接器与 fusion recorder 落进aisoc_ai_assets,recorder 依据这里选出的列(name、path、cmdline、directory)对每一行分类,悄悄修改被选中的列名会静默破坏分类。因此这些查询刻意多选了 recorder 要读的列,而不是只选 osquery 必需的最小集合。

让查询真正下发

pack 的加载与下发由 osquery TLS 服务负责。pack_loader从<repo_root>/packs/读取所有 YAML(可用AISOC_PACKS_DIR覆盖,缓存 TTL 由AISOC_PACKS_CACHE_TTL控制,默认 300 秒),pack_resolver在租户没有显式分配时回落到默认 pack 集合:

_DEFAULT_PACK_IDS: list[str] = [ "aisoc-fim-baseline", "aisoc-inventory-baseline", ]

这意味着aisoc-ai-estate不在默认集合里,需要显式分配。分配接口是POST /api/v1/osquery/tenants/{tenant_id}/packs,请求体为{"pack_id": "aisoc-ai-estate", "enabled": true};该接口会先校验 pack 是否存在于目录中,再按(tenant_id, pack_id)做 upsert,且租户 ID 会与调用者凭证做交叉校验(scoped_tenant_or_403)。合并进 osquery TLS 配置时,查询名会加上{pack_id}__前缀,避免多 pack 合并时的名称冲突。

另外注意 packs/aisoc-ai-estate.yaml 末尾的file_paths段只列出了 MCP 配置与扩展目录的一个子集,其中%%按 packs/README.md 的定义是递归 glob。若你希望 FIM 也覆盖其余客户端,需要自行补齐该段。

一行资产如何被链接到人和设备

每条来源贡献它拥有的那部分,而且后一次观测永远不会抹掉前一次学到的内容:

  • 人来自运行时遥测的actor.user.name、端点事件的操作用户,或供应商审计记录中的账号。
  • 设备来自端点主机名。运行时遥测只有在被插桩应用主动上报时才提供,供应商审计日志则永远不会提供。
  • 权限是最近一次观测时,有效权限解析器为所链接用户返回结果的快照,而不是 join——这样后续的权限变更不会改写历史。

当某条来源没有提供链接时,控制台会以"发现"的颜色渲染"not learned",而不是留一个空白单元格,并在 tooltip 里写明该来源对此是盲的。没有任何人负责的资产,正是这套清单要暴露出来的那一行。

这三点在数据库层的落地方式是:

CONSTRAINT aisoc_ai_assets_identity UNIQUE NULLS NOT DISTINCT (tenant_id, kind, external_id, device)

迁移注释解释了NULLS NOT DISTINCT为什么是承重的:在默认语义下 NULL 不等于 NULL,于是每一次供应商密钥的观测(它没有设备)都会插入新行而不是更新旧行,清单将按事件数无限增长,同时first_seen_at每次都被重置。同一处注释指出这个环境需要 PostgreSQL 15 及以上,仓库固定使用 16。

观测写入使用ON CONFLICT上的LEAST/GREATEST:

first_seen_at = LEAST(aisoc_ai_assets.first_seen_at, EXCLUDED.first_seen_at), last_seen_at = GREATEST(aisoc_ai_assets.last_seen_at, EXCLUDED.last_seen_at), observation_count = aisoc_ai_assets.observation_count + EXCLUDED.observation_count, principal = COALESCE(EXCLUDED.principal, aisoc_ai_assets.principal), attributes = aisoc_ai_assets.attributes || EXCLUDED.attributes,

注释写得很直白:重放的 Kafka 批次或迟到的 span,不能把first_seen_at往前推,因为那会擦掉清单存在的首要事实;而后一次没有学到人的观测,也不能抹掉前一次学到的。

写入链路:recorder 如何把事件变成资产

services/fusion/app/services/ai_asset_recorder.py 是aisoc_ai_assets的写入方。它挂在 fusion 消费者的原始事件分支上——模块文档解释了原因:这是默认 profile 下每一个规范化事件都会经过的路径;如果把 recorder 放在开关后面,或放在 CORE 不启动的服务里,就会得到"一张没人写的表"和"一个对明明有 AI 资产的租户显示『没有 AI 资产』的控制台页面"。

按 ingest 打的戳分流,而不是猜字段

classify()是纯函数,便于用厂商形状的载荷测试:

if source_type in ("ai-runtime", "ai-finding", "AI Runtime", "AI Guardrail"): out.extend(_from_ai_telemetry(tenant_id, ocsf, now)) elif source_type in ("llm_usage", "AI / LLM Usage Audit"): out.extend(_from_provider_audit(tenant_id, ocsf, now)) elif ocsf.get("class_uid") == 1007: out.extend(_from_endpoint(tenant_id, ocsf, now))

三条分支恰好对应文档里的三条来源:AI 运行时 / 网关遥测、供应商审计、以及携带已知编码代理或 MCP 服务器二进制的 OCSF Process Activity(class 1007)。每行都记录是哪条来源看见它的,因为分析师看到一条没有设备的资产时,需要区分"链接缺失"和"这条来源根本不可能提供"。

端点分支为什么不能读process.name

这是最容易踩的坑。services/ingest把连接器自己的规范化字典以JSON 字符串形式放在ocsf["raw_data"]下,而 OCSF 规范字段映射只携带title、external_id和身份别名,别无其他。因此_connector_fields()必须把那个命名空间还原出来:

raw = ocsf.get("raw_data") if isinstance(raw, str) and raw.strip(): parsed = json.loads(raw) # 解析失败则返回 {}

同一段注释记录了这一处曾经的真实缺陷:早期版本读process.name,对fleetdm、osctrl、auditd、windows_event全部返回None——也就是本产品所有的端点来源——端点发现永远产不出一行。测试 services/fusion/tests/test_ai_endpoint_discovery.py 用各连接器normalize()真实输出的形状复现并守住了这一点。

字段候选顺序是显式元组,来自对各连接器normalize()的阅读而非假设:

PROCESS_FIELDS: tuple[str, ...] = ("process_name", "Image", "exe", "name", "path", "process") COMMAND_LINE_FIELDS: tuple[str, ...] = ("CommandLine", "cmdline", "cmd_line", "command_line", "argv") DEVICE_FIELDS: tuple[str, ...] = ("hostname", "host_hostname", "computer", "Computer", "device_name", "host") PRINCIPAL_FIELDS: tuple[str, ...] = ("username", "user", "actor", "TargetUserName", "SubjectUserName", "user_name")

_first()会把列表拍平,因此auditd的argv列表会得到拼接后的命令行,而不是一个repr。

三类端点证据,先匹配者胜

_from_endpoint()依次尝试:已知编码代理二进制 → 本地 MCP 服务器进程 → 文件(MCP 客户端配置或 AI IDE 扩展目录)。之所以要这个顺序,是因为进程路径同时也是文件路径。

判定的具体规则:

  • 编码代理按可执行文件 basename(小写、去掉.exe)在CODING_AGENT_PROCESSES中查找,同时得到供应商映射;
  • MCP 服务器按前缀匹配("mcp-server", "mcp_server", "mcp-proxy")——注释说明mcp作为子串会命中mcpelauncher,把游戏启动器送上安全评审;
  • 命令行中若出现NO_PROMPT_FLAGS之一,就写入permission_prompts_disabled: true:
NO_PROMPT_FLAGS: tuple[str, ...] = ( "--dangerously-skip-permissions", "--yolo", "--auto-approve", "--no-confirm", "--yes-always", "--full-auto", )

注释称这是端点来源能提供的信号最强的单一属性,也是它唯一出现的地方——这样启动的代理在执行前不会得到任何人工确认。

端点分支还有一条刻意的克制:绝不设置模型或供应商。端点遥测看不到这两者,用默认值填充会让清单断言一个没有任何来源观测到的事实。

配置文件与扩展目录的识别

MCP_CLIENT_CONFIGS用路径后缀匹配,因为每个配置都在各平台形态不同的用户家目录下:

MCP_CLIENT_CONFIGS: tuple[tuple[str, str], ...] = ( ("/claude/claude_desktop_config.json", "Claude Desktop"), ("/.claude.json", "Claude Code"), ("/.cursor/mcp.json", "Cursor"), ("/.codeium/windsurf/mcp_config.json", "Windsurf"), ("/code/user/mcp.json", "VS Code"), ("/.vscode/mcp.json", "VS Code"), ("/.continue/config.json", "Continue"), ("/.aws/amazonq/mcp.json", "Amazon Q"), ("/.gemini/settings.json", "Gemini CLI"), ("/.codex/config.toml", "Codex CLI"), ("/.config/zed/settings.json", "Zed"), )

身份键取客户端名而不是文件路径:两台笔记本都配置了 Cursor 是两行资产,同一台笔记本的文件在版本升级后挪了位置仍是一行。对照 pack 的 SQL 可以看到两侧覆盖并不完全一致——recorder 还认识/.vscode/mcp.json与 Zed 的settings.json,而 pack 的file_paths/SQL 未列出后者;这意味着这类证据需要由 FIM 或其他采集路径送进来才会被识别。

扩展目录的解析用了一个细节正确的正则:

match = re.search(r"^(.*?)-(\d[\w.+-]*)$", folder)

即publisher.name-1.2.3拆成("publisher.name", "1.2.3")。注释解释了为什么按"最后一个连字符 + 数字"切分:发布者或扩展名本身可能含连字符(例如sourcegraph.cody-ai),按第一个连字符切会截断标识符。随后只有命中AI_IDE_EXTENSIONS白名单的扩展才会成行——一个编辑器有几百个扩展,其中少数才是 AI 助手,全部列出会把清单变成包清单。

缓冲与失败方向

繁忙的代理会为每次工具调用发一个 span。逐 span upsert 等于在部署中每个 AI 事件的热路径上写一次,只为更新一个没人按这个粒度读的last_seen_at。因此观测按身份在内存中合并,按大小或时长刷写:

_MAX_PENDING = 200 _MAX_AGE_SECONDS = 30.0

注释说明这个尺寸保证"只有一个 agent 的租户,也能在第一个 span 到达后一分钟内看到自己的第一条资产"。属性中的tools、models、prompt_secret_kinds走集合并集,其余键后者覆盖前者。

失败方向是单向的:Postgres 不可用时丢弃缓冲的观测并记录日志,绝不阻塞消费者。理由写在模块文档里——清单是治理面,丢一小时可以承受,而阻塞告警提升不行;丢弃会以warning计数并记录(ai_asset_recorder.flush_failed、ai_asset_recorder.no_pool),让"安静的清单"可诊断而不是神秘。

风险评分:一个数字,十个信号,一句理由

评分只有一个计算点:services/api/app/services/ai_inventory.py 的score_risk()。recorder 在写入时调用它并存储结果,控制台读存储值,任何查询都不重算。迁移注释解释了原因:这个仓库已经出现过两个面各自拥有一段 SQL 表达式、从而对同一批行给出不同数字的事故。

分值表逐项如下(总分上限 100,因此新增信号不会静默重标定既有信号):

信号键分值触发条件
unlinked_principal15没有链接到人,无人对它的行为负责
unlinked_device10无设备,且 kind 为coding_agent/ide_extension/mcp_server,处置无处落地
undeclared20sanctioned为None,没人评审过
refused30sanctioned为False,评审拒绝却仍在运行
privileged_permissions25链接用户持有管理员权限
prompts_disabled25启动时关闭了权限确认
unvetted_source20install_source不在official/vendor/internal之内
secret_in_prompt20prompt_secret_kinds非空
high_risk_tool15调用过HIGH_RISK_TOOLS中的工具
broad_oauth_scope25持有覆盖整个邮箱、云盘或组织的 scope

HIGH_RISK_TOOLS是改变资产面而非只读的工具名集合(execute_command、run_shell、write_file、delete_file、send_email、create_user、grant_permission、deploy、terraform_apply、database_write);BROAD_OAUTH_SCOPES里每一条都是供应商参考文档中的真实 scope 字符串,例如https://mail.google.com/、Mail.ReadWrite、Files.ReadWrite.All、repo、admin:org。注释强调这些不是自造的分类。

两点容易错的语义值得单独记住:

  1. sanctioned是三态,三态分值不同。None表示没人看过(适度风险,因为未评审即未纳管);False表示有人看过并拒绝、但它仍在跑(风险最高);True表示已批准(不加分)。
  2. _is_privileged()在权限快照为None时返回False。注释称这是诚实的答案:把未知当作特权,会让没有采集姿态数据的部署里每一项资产都显得严重。

recorder 侧的_score()是这份逻辑的镜像,差异在于新建观测必然sanctioned=None、也没有权限快照,因此它固定记undeclared,并且不计算refused与privileged_permissions。两个服务各自打包为顶层app,无法互相 import,所以 services/fusion/tests/test_ai_asset_recorder.py 用一张共享用例表断言两份实现保持一致——这是让镜像保持为镜像的唯一办法。

读取与治理:API 与控制台

清单的读接口在 services/api/app/api/v1/endpoints/ai_assets.py,共三条路由。

# 全部资产,按最近观测倒序 curl -H "Authorization: Bearer $TOKEN" \ "https://aisoc.example/api/v1/ai-assets?limit=50&offset=0" # 只看编码代理 curl -H "Authorization: Bearer $TOKEN" \ "https://aisoc.example/api/v1/ai-assets?kind=coding_agent" # 只看没有人负责的资产——这是评审的起点 curl -H "Authorization: Bearer $TOKEN" \ "https://aisoc.example/api/v1/ai-assets?unlinked=true"

参数约束:kind必须是六个 kind 之一,否则返回422并在 detail 中列出合法取值;limit取值 1–200(_MAX_PAGE);offset非负。响应体为items / total / observedSources,其中observedSources是显式声明而不是从空列表推断出来的字段——同一份注释指出,没有 AI 遥测的租户与 AI 资产面确实为空的租户,在行数据上看起来完全一样,而只有前者才是需要去排查的理由。

单条资产用GET /api/v1/ai-assets/{asset_id};唯一的写操作是治理判定:

curl -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"sanctioned": false, "note": "Unregistered agent on a finance laptop"}' \ "https://aisoc.example/api/v1/ai-assets/<asset_id>/sanction"

sanctioned取true/false/null,三者语义不同:批准、评审后拒绝、退回未决。该路由会用同一个score_risk()重算风险分并写回——因为治理判定本身就是评分输入之一,一个刚被拒绝却仍在运行的资产,其暴露程度与没人看过的资产不同。

权限模型

读取需要ai_estate:read,写入需要ai_estate:write。模块文档说明了为什么在大多数读接口只做认证的这个服务里,这里要额外要权限:这份清单指明了哪台笔记本上哪个编码代理属于哪个人、握着哪个供应商密钥,那是一份目标清单,能读告警的会话不应该自动获得它。权限词表与角色持有关系见 services/api/app/core/security.py。

控制台页面

apps/web/src/components/ai-estate/AiEstateView.tsx 是页面实现,文件头列出三条渲染决策:

  1. 缺失的人或设备渲染为琥珀色"not learned",附 tooltip 说明该来源对此盲,而非空白格;
  2. 读取失败会明说失败,绝不渲染成"空资产面"——因为"你没有影子 AI"是这个页面可能说出的最危险的错误陈述;
  3. 不伪造任何数据:没有样例行、没有?? MOCK、没有 SWRfallbackData,因为编造的 AI 清单会成为读者据以行动的安全面。

页面按kind与unlinked过滤,30 秒刷新一次,展开行会显示逐条风险理由、观测次数、来源说明,以及批准/拒绝按钮。SOURCE_LIMITS为五种来源各准备了一句"能看见/永远看不见"的提示,与后端SOURCE_CAPABILITIES语义一致。

图谱投影

除关系表外,AI 资产还有一张图投影。schemas/graph-schema.yaml 定义了AIAsset标签,并说明"一个标签加kind属性,而不是六个标签"的理由与关系表完全一致:对这份数据提出的每个问题都是跨 kind 提问的(这个人跑什么、这台笔记本上跑什么),六个标签会把它们变成六路 union。schema 中同时指出,持久记录是 Postgres 里的aisoc_ai_assets,图只是调查时遍历的投影,由extractAIEstate从ai-runtime/ai-finding遥测以及命名已知代理二进制的进程活动写入。

上线检查:怎么确认这张表真的在被写

这套功能最大的失败模式不是报错,而是"代码能跑、测试能过、页面能渲染,但表里没有写入"。仓库为此提供了一个专用门禁:scripts/check_ai_asset_inventory.py。它用ast与纯文本解析而非 import,因为 CI 中它跑在任何服务 venv 之前。

它的规则集是:

RULES = ( ("MIGRATION", _check_migration), ("MODEL", _check_model), ("ROUTES", _check_routes), ("PERMISSIONS", _check_permissions), ("WRITER", _check_writer), ("KINDS", _check_kinds), ("GRAPH", _check_graph), ("CONSOLE", _check_console), )

其中WRITER是承重规则,它要求三件事同时成立:

  • RECORDER_PATH存在且文本中出现了表名;
  • fusion 消费者的_process_message函数体内真的调用了_ai_assets.observe(用 AST 限定在单函数内,因为"顶部 import、只在开关分支里调用"正是那个缺陷);
  • services/fusion/app/main.py真的构造了AiAssetRecorder(,否则消费者的引用为None,每个调用点都会静默跳过。

第三点在 services/fusion/app/main.py 中是以可调用对象传入的:

ai_assets = AiAssetRecorder(lambda: sink._pool) if sink is not None else None

注释解释了为什么传 lambda 而不是直接传sink._pool:连接池是在worker.start()期间由sink.start()打开的,提前捕获会拿到None,从而在整个进程生命周期里静默丢弃每一次观测。

消费者的调用点分布在三处(见 services/fusion/app/workers/consumer.py):主循环中await self._ai_assets.flush_if_stale()、收尾时await self._ai_assets.flush()、以及消息处理中累加ai_assets_observed指标。

单元测试证明不了"写入路径通着",所以还有一套针对真实 PostgreSQL 的隔离测试:tests/isolation/test_ai_estate_live.py。它的"完成标准"直接取自计划——用一台编码代理、一个 MCP 服务器和一个供应商密钥组成的夹具资产面,三者在清单页面上都出现,并链接到正确的人与设备。该套件必须连真库,因为它验证的都是 PostgreSQL 的性质:UNIQUE NULLS NOT DISTINCT的合并语义、LEAST/GREATEST阻止重放批次推进first_seen_at、以及只有以aisoc_app(仅 DML)角色连接时 RLS 才真正生效。运行方式:

ISOLATION_AI_ESTATE_DSN=postgresql://aisoc_app:...@localhost:5432/aisoc \ pytest tests/isolation/test_ai_estate_live.py

文件头明确:变量设置后测试失败是硬失败,绝不跳过——会跳过的在线套件,只是在一无所有之上盖了一个绿勾。

覆盖规划与排障要点

  • 清单为空先查来源,不要下结论。页面对"读了但失败"和"没有资产"给出不同文案;API 的observedSources会告诉你究竟有哪些来源在工作。若列表为空且observedSources也为空,说明三条采集路径都没接通,而不是资产面干净。
  • 只跑 EDR 会得到半张表。crowdstrike/sentinelone/azure_defender返回的是厂商判定,无法提供进程启动。要端点侧证据,需要 osquery(FleetDM 或 osctrl)、Linuxauditd或 Windows 事件转发。
  • osquery pack 要显式分配。默认 pack 集合只含aisoc-fim-baseline与aisoc-inventory-baseline。分配后可用GET /api/v1/osquery/packs/aisoc-ai-estate/render?format=osctrl核对查询是否如预期下发。
  • not learned是发现,不是缺数据。先看discoverySource:ai_runtime天生没有设备、provider_audit天生没有设备,这两类的空设备列属于盲区说明;而一台端点侧观测到的编码代理没有人,才是需要处理的问责缺口。
  • 调风险分先看riskReasons。分数是暴露度而非定性结论;展开行能看到每条理由,评审应当反驳一句话而不是反驳一个数字。未评审的资产必然带undeclared的 20 分,把它降下来的正确方式是做出sanctioned判定,而不是改评分逻辑。
  • 改 pack 的 SELECT 列要小心。name、path、cmdline、directory是 recorder 分类所依赖的列,改名会静默破坏分类而不是报错。

小结

AiSOC 的 AI 资产发现没有引入任何新组件,它把"AI 治理"这个通常停留在问卷层面的问题,还原成了三条各有明确盲区的遥测路径加一张有主键约束的表:ai-runtime告诉你代理是谁、用了什么模型和工具,端点告诉你哪台机器上跑着哪个二进制以及是否关闭了权限确认,供应商审计告诉你谁签发了哪把密钥以及谁授予了什么 scope。三者以(tenant_id, kind, external_id, device)为身份在NULLS NOT DISTINCT下合并,缺失的链接以 "not learned" 显式呈现,风险分由唯一一处函数在写入时计算并附带可反驳的理由。剩下的工作属于使用者:把 pack 分配给租户,观察observedSources,然后从"没有人负责"的那一列开始处理。

  • 人工智能
  • AI Agent
  • 网络安全
  • 后端
  • 前端

【免费下载链接】AiSOC

Open-source AI Security Operations Center: alert fusion, LLM-agent triage, MITRE ATT&CK investigation, and a replayable decision ledger for every agent step. Self-hostable, runs with no API keys, MIT licensed. Ships an MCP server for Claude, Cursor and Continue.

项目地址:https://gitcode.com/GitHub_Trending/ai/AiSOC
点击查看免费下载

相关推荐

上一篇:Llama-Guard-3-1B性能实测:M1到M4 Ultra芯片183-549 tokens/s速度大揭秘
下一篇:InternVL3-2B-Pretrained 推理进阶:批量推理、流式输出的 3 种实现方法详解

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考