AegisDB
EnglishGitHub
安全与合规

每一条机制,连同它的参数和它失败时的样子

这一页是写给要逐条核我们的人的:算法、参数、失败行为一条条列出来,包括每道闸够不到什么,以及还欠着的几笔账。

先动手试一次:按「校验链」,逐行打勾;按「模拟篡改第 3 行」,第 3 行之后全部变红。变红的不是被改的那一行,是它之后的每一行 —— 这就是 prev_hash 那一列在做的事。

审计哈希链
  1. #12026-09-19 02:11:03PRODlinwei@vela.io一致哈希对不上
    SELECT * FROM orders LIMIT 10prevhash60b8bc35
  2. #22026-09-19 02:14:20PRODlinwei@vela.io一致哈希对不上
    UPDATE orders SET status = 1 WHERE id = 42prev60b8bc35hash049bfebb
  3. #32026-09-19 03:02:45PRODzhaoyun@vela.io一致哈希对不上
    DELETE FROM orders WHERE id = 42prev049bfebbhash013e12a9
  4. #42026-09-19 03:20:11PRODzhaoyun@vela.io一致哈希对不上
    ALTER TABLE orders ADD COLUMN note varchar(64)prev013e12a9hash64620042
  5. #52026-09-19 04:00:02PRODlinwei@vela.io一致哈希对不上
    SELECT count(*) FROM ordersprev64620042hashe436ab86

控制台界面复刻 · 非截图

留痕

审计哈希链

每条审计记录携带前一条的哈希:hash = SHA256(prev_hash 后面接上 payload)。payload 是这一行若干字段的 JSON,键按字典序序列化,时间统一归到 UTC。创世行的 prev_hash 是空串。写入侧与校验侧共用同一段 payload 构造函数 —— 两边各写一份,迟早算出两个不一样的前像,而那时候校验报出来的「被改过」是假的。

链不分叉由数据库保证,不靠应用自觉:prev_hash 上建的是单列唯一索引。每一行同时钉住环境与控制分层两个快照,而且两者都进哈希 —— 一个改了不会破链的快照,证明不了任何事。校验是控制台上一个人工触发的动作,从创世行一路重算到链尾,报出第一条对不上的行;它不拦截任何写入、不发告警,产出的是一份带结论的报告。

可核验的参数

摘要算法
SHA-256,输出 64 位小写十六进制
前像
JSON 对象,键按字典序;当前版 11 个键
时间格式
RFC3339,强制归一到 UTC
创世行
prev_hash 为空串
防分叉
prev_hash 上的单列唯一索引,由数据库层面拒绝
写入冲突
进程内互斥锁排队;跨进程冲突重挂链尾,最多 5 次
前像版本
5 版(自 v1 起),校验从新到旧逐版尝试,不放宽任何一版
校验入口
GET /api/v1/audit/verify,人工触发;后台定时任务里没有链校验这一项
结果出网关之前

脱敏在网关侧

脱敏发生在结果离开网关之前:回给浏览器的那个 JSON 里,身份证号已经不在了。整个网关只有 2 处把用户 SQL 的结果行读出来:RealRun(终端与发布代执行)与 RealQueryEach(导出流式)。唯一的旁路叫 RealQueryEachRaw,名字刺眼是故意的 —— 换成一个默认为 false 的参数,「这一次到底脱没脱敏」就藏进一个布尔值里了。

匹配分两步:先按回传的列名匹配,SELECT * 落在这一步,也是最安全的形态;再按选择列表第 k 项的表达式匹配,兜住 AS 别名与函数包装。每个 UNION 分支都看 —— 结果集的列名只来自第一个分支。失败行为在这一节尤其要紧:读库失败时沿用上一次的规则而不是返回空,空意味着什么都不脱敏,一次数据库抖动就会让敏感数据明文回传。

可核验的参数

读结果集的路径
2 处:RealRun(终端与发布代执行)、RealQueryEach(导出流式);异步执行那条路不返回结果集
唯一旁路
RealQueryEachRaw,1 个调用点:已批准的敏感字段导出
规则粒度
表名 + 字段名;表名留空即所有表
打码方式
留头留尾(长于 8 的值留前 3 后 4,短值整体抹掉)· 整体抹掉(固定 6 个星号)· 同值同码(SHA-256 前 10 位十六进制,带井号前缀)
规则缓存
5 秒;读库失败沿用上一次,不返回空
角色豁免
没有,管理员也一样;停用规则会写审计
谁在操作

会话与认证

每个账户上有一个单调递增的会话代次,签发 JWT 时写进 tv 声明,中间件每个请求拿它跟库里的当前值比 —— 不一致当场 401「会话已失效」。登出、管理员重置口令、把用户移出角色这三处会让它加一,已签发的令牌在那一刻全部作废。改角色(不是移出角色)刻意不加:权限每个请求实时查库,降权不靠踢下线生效。

登录频控锁的是来源 IP,不是账号。代码注释把这件事说成有意为之:攻击者只能锁掉自己那个地址,永远锁不掉受害者的账号。只有跨过阈值的那一次会武装锁定,后续失败不会无限延长 —— 否则一个攻击者就能把一段共享 NAT 出口永久锁死。TOTP 步进按 RFC 6238 做,用过的时间步会被消费;要不要步进由目标分层自己的开关决定,不是靠「prod」这个名字判的,而分层解析不出来一律当作要步进。

可核验的参数

令牌
HS256 JWT;声明只有 uid / name / tv 与 iat / exp / iss,刻意不带角色
会话有效期
只接受 4h / 8h / 24h,默认 8h
代次加一
登出 · 管理员重置口令 · 把用户移出角色
登录频控
按来源 IP 计数;5 分钟窗口内第 5 次失败锁 5 分钟;成功清零;未输动态码那次不计失败
MFA 算法
RFC 6238:SHA-1、6 位、30 秒;密钥 160 位随机;容忍前后各 1 个时间步;用过的时间步被消费
步进宽限
默认 30 分钟,上限 720 分钟,设 0 表示每条命令都验;键是用户 × 会话代次 × 实例;只存内存
强制 MFA
总开关默认开;「未注册 TOTP 就不能执行」默认关,且服务账号永远豁免
钥匙存在哪

凭据与密钥

目标库口令以 AES-256-GCM 加密落库。密钥由一个口令短语经 SHA-256 派生成 32 字节;nonce 每次加密现取 12 字节随机数,与密文和认证标签一起存,整体 base64 之后加 enc:v1: 前缀。一份数据库备份泄露,拿不到任何一把可以直接使用的钥匙。

两把密钥分开:签名 JWT 用 VELA_JWT_SECRET,静态加密用 VELA_SECRET_KEY。这里最容易讲反:分开设置之后可以独立轮换的是 JWT 密钥,而不是静态加密密钥 —— VELA_SECRET_KEY 一经设定不可更改,换了它存量口令就解不开。轮换 JWT 密钥的代价是所有在线令牌当场解析失败,等于全员下线:代码里没有密钥版本号,也没有双密钥并行解密。

可核验的参数

静态加密
AES-256-GCM;密钥 = SHA-256(口令短语) 取 32 字节;nonce 每次现取 12 字节随机数并前置存储;无 AAD;密文前缀 enc:v1:
加密覆盖面
实例口令 · 导出压缩包口令 · 外部审批的调用令牌与回调密钥 · Webhook 密钥
两把密钥
VELA_JWT_SECRET 签名,VELA_SECRET_KEY 静态加密;后者不设会回落到前者并打 WARN
能不能轮换
VELA_SECRET_KEY 一经设定不可更改;JWT 密钥可换,代价是在线令牌全部失效;没有密钥版本号,没有双密钥并行解密
生产密钥强度
不在 10 条弱值黑名单里、至少 32 个字符、至少 8 种不同字节;任一不满足则拒绝启动
开放接口凭据
key 是 ak_ 加 16 位十六进制,secret 是 32 字符 base64url;库里只有 secret 的 bcrypt 哈希,cost 10;不可就地修改
导出归档
WinZip AES-256 加密的 ZIP(不是已被已知明文攻击打穿的 ZipCrypto);口令 20 位、去掉形近字符、拒绝采样保证无偏;口令本身再加密落库
三扇门

三套鉴权面,不能互认

网关上有三扇门,各管各的。控制台走 /api/v1 下除另外两面之外的全部路由,认 Authorization 头里的 JWT,调它的是登录的人;开放接口走 /api/v1/open,认 key 加点加 secret 的令牌,调它的是 CI 与 DevOps 平台;回调只有一条路径,认共享密钥,调它的是外部审批服务。

开放接口不是第二条流水线,是同一条流水线的另一扇门:/api/v1/open 下的每个写路径最终都落到与控制台同一个提交函数,同样的流程模板、规则库、审批链、执行前复判与审计链。审计行因此有两个名字:actor 是服务账号,operator 记成 API 加上凭据名。(ADR 0006)

可核验的参数

控制台
/api/v1(除下面两面)· Authorization: Bearer JWT · 链:JWT 校验 → 来源 IP 白名单(默认关闭 = 直通)→ 菜单守卫 →(部分路由)平台管理员
开放接口
/api/v1/open · Authorization: Bearer key.secret,或 X-Vela-Key + X-Vela-Secret · 链:凭据校验 → scope 校验;每份凭据自带来源 IP 白名单(默认空 = 不限来源)
回调
/api/v1/approvals/lark/callback 单一路径 · 共享密钥(Authorization: Bearer,或 ?secret= 兜底)+ 可选来源 IP 白名单 · 零中间件,鉴权在处理函数里
不需要凭据
GET /healthz · GET /openapi.yaml · GET /docs
打错门
一律 40100;scope 不够是 40300;凭据不许绑平台管理员,创建与换绑两处都堵;服务账号登录控制台直接拒
全局
默认不信任任何代理,所以 X-Forwarded-For 默认不认;代理清单配错会拒绝启动
网关往外发

出站防护:签名在出站,入站是共享密钥

网关主动发起的每一次出站(事件中心 Webhook、飞书机器人卡片、外部审批 API)都走同一个受控 HTTP 客户端。目标地址检查两次 —— 保存配置时做一次 DNS 解析预检,真正拨号时再检查实际要连的那个 IP,把「名字在这两次之间重新解析到内网」那个时间窗关掉。解析不出来算拒绝,不是放行。

出站带签名。发给事件中心的请求带 X-Vela-Signature 头,值是 sha256= 加上用密钥对请求体算出的 HMAC-SHA256 十六进制;飞书卡片带飞书官方那套签名。这两处都是网关发出去的签名。接收方要知道的一条:密钥为空时网关不盖章,那时请求上根本没有签名头 —— 按「一定带签名」写校验的接收方,会在对方没配密钥的那一刻收到一个自己判不了的请求。

入站不是签名。审批回调没有签名校验,也没有时间戳防重放:它校验的是一个共享密钥,用常量时间比较,外加一份可选的来源 IP 白名单 —— 而那份白名单的默认值是空串,空串等于不限来源。密钥是主控制,IP 白名单是可选的第二道。总开关关着一律拒,密钥没配一律拒。

可核验的参数

出站客户端
只允许 http / https;生产强制 https;禁止重定向;5 秒超时
地址检查
保存时 DNS 预检 + 拨号时按实际 IP 再判一次;解析不出来即拒绝
默认拦截
环回 · RFC1918 私网 · IPv6 唯一本地 · 链路本地(含 169.254.169.254)· 未指定地址 · 100.64.0.0/10 · NAT64 64:ff9b::/96(拆出内嵌 IPv4 再判一次)
出站签名
事件中心:X-Vela-Signature: sha256= 加请求体的 HMAC-SHA256 十六进制;飞书机器人:base64(HMAC-SHA256(key = 时间戳 + 换行 + 密钥, 消息 = 空串));两者都是密钥为空时不盖章
入站回调
共享密钥常量时间比较 + 可选来源 IP 白名单;白名单默认空串 = 不限来源;没有签名,没有时间戳防重放
回调载荷
必须有外部任务号且对得上;approved 必须带 approver;approver 截断到 128 字节
投递重试
退避 2 的幂次、封顶 30 秒、默认最多 5 次、协程寿命上限 2 分钟;每次结果落表
一以贯之

失败朝严:逐条列出来

下面每一条都是代码里真实存在的分支,不是一句原则声明。代码里给这条原则编了号 —— ED3:一条读不出来的规则不等于没有规则。

判定层读不出来就拒绝

能力矩阵与高危命令字典都在数据库里,一次瞬时故障会同时让它们失效:没配规则的格子读作放行、空字典读作没开,于是生产上的 DROP TABLE 会一路畅通。所以任何一层读不出来,结论是拒绝加最高风险,终端上打出来的那句话是「风险控制暂时不可用 · 已按最严处理」。

分层解析不出来,整条命令不执行

分层没有一个说得通的零值:拿空分层去扫脚本,字典一行都匹配不到,每条语句包括 DROP TABLE 都会报安全。终端、批量、审批执行、脚本扫描四条路都把它当拒绝处理,MFA 也一样。

认不出来就按危险的那一边读

认不出的 SQL 动词归入写入维度;认不出的 MongoDB 方法按写处理;引擎认不出就两种读法都试,任一读出「没有 WHERE」就按没有 WHERE 判;打码列匹配不确定时宁可多打一列 —— 漏打一列是敏感数据直接回传。

多个结论并存时取最严

一条语句多次命中字典取最严那条,ALTER TABLE t DROP PARTITION p 不会因为 ALTER 排在前面就按中风险算;批量粘贴由最严的那一条统辖整批;EXPLAIN 同时受「执行计划」与「查询数据」两维约束,取更严的那一档,拆出一个权限不能发出原权限拒绝过的东西;多角色取并集时,读不懂的那一档是拒绝。

权限检查跑不起来就拒绝

标签授权查询失败时绝不报「不受限」:空结果的含义恰恰是「没有限制」,把错误吞掉,等于在一次数据库抖动里把整个资产交给一个受限角色。菜单表读不出来是 403。

外部服务不可达或者认不清就不认

外部审批总开关关着拒、回调密钥没配拒、密钥不匹配拒、来源 IP 不在白名单拒;没拿到厂商任务号就不认外部回调;说通过却没有审批人拒。开放接口凭据在每一项上同样朝严。

唯一的放宽机制,每条失败路径都不放宽

执行窗口是这套系统里唯一往松走的东西,而它自己的失败一律不放宽:说不清是哪个库就不放行;窗口表读不到当没有窗口;时区认不出窗口不生效,不拿服务器时区顶上。

数据与流程上的同一个方向

脱敏规则读不出来沿用上一次;发布能力矩阵读不出来当场报错;审批链为空当场失败并说清楚,不造一张永远没人能批的死单;运维写的正则编译失败时把自己报成一条 finding 而不是被跳过,因为一条永远不触发的规则和一条永远通过的规则分不出来;从库延迟上限没写等于 30 秒而不是不限,要关掉必须写一个负数,因为 0 同时也是「这一段根本不存在」。

两条刻意不朝严的例外

一份只列优点的清单说服不了任何人。下面这两条是代码里刻意不朝严的地方,理由都写在注释里。

判断不出发起人账号时,不判为自审

禁自审那道网比对的是审批人与发起人。比不出来的时候代码选了放过,注释写的是不要过度拦截 —— 一个匹配不上的账号名不该把一张合法的审批挡在外面。它与上面那条「说通过却没有审批人就拒」是配套的:完全没有审批人是拒,认不出发起人才是放。

角色查询失败时,保留原有的那组角色 id

注释写得很直白:一次数据库抖动不该让所有人瞬间失去全部角色。方向其实仍然是安全的 —— 保留的是真实存在过的 id,不是把未知当成放行。同一类还有一处:读不懂的设置开关保持它的默认值,而安全相关的那几个默认本身都选在严的一侧。

分寸写在一行测试注释里:多拦在判定层是安全余量,多拦在报告层是假警报。所以上面这些朝严的地方全都在判定层,而扫描报告那一侧刻意不跟着收紧。

记在账上

有账但还没还的

下面这些是现在还欠着的。写在这里不是为了显得坦诚 —— 是因为安全团队一定会问到它们,而等被问出来比自己先写出来难看得多。

  • 没有内容安全策略(CSP)

    原话是:整个后端没有下发 Content-Security-Policy,也就是说 XSS 这一层目前没有任何纵深防御。(ADR 0017)

  • JWT 存在 localStorage 里,不是 httpOnly Cookie

    键名是 aegis_token。ADR 0017 把它记成一笔记在账上的取舍,并写明了三条偿还触发条件:页面开始加载任何第三方脚本、出现任何一份 XSS 报告、或者要接外部用户。

  • 白盒审计:四条点名未修,另有 18 条低危未动

    ADR 0012 的表里列着:发布流水线的备份与校验两步不过判定引擎;MFA 宽限按用户代次而不是按单个令牌;自助换绑 MFA 没有重认证、也不写审计;子查询别名能绕过脱敏;另有 18 条低危未动。

  • 审计链校验只有人工触发,没有定时任务

    网关的后台协程里没有链校验这一项。所以「链是完好的」这句话的有效期,是上一次有人去按那个按钮的时刻。

  • 链断裂只返回报告

    不告警、不阻断、也不发 Webhook 事件。校验接口回的是一份带结论的报告,看不看、看没看见,取决于有没有人去看。

  • 没有 Prometheus 端点

    可观测面只有无鉴权的 /healthz、一个统计接口(最近 512 次请求的 p50 / p95,以及生产拦截计数)、gin 的访问日志和 slog 的 JSON 输出。