我正在阅读一本设计模式书籍。读到 UML 类图时——我停了下来。
因为页面上的图表描述的东西,Rust 已经用它自己的语法表达了。struct 就是类。Option<T> 就是 0..1 关联。Vec<T> 就是 1..N。所有权就是组合。我所看到的用框和菱形绘制而成的形式主义,与编译器已经拥有的信息是相同的——只是采用了不同的表示法。
这引出了一个令人不安的想法。如果类型系统已经编码了 UML 所绘制的内容,那么将我的代码翻译成 UML 不应该告诉我任何新东西。它应该是多余的。除非翻译结果不一致——而在每一处不一致的地方,都会隐藏着一个 bug。
于是我尝试了。我以 Runique——我的 Rust Web 框架,v2.1.21——为例,逐个模块用 UML 建模框架结构,用 Merise 建模数据。这个直觉是我自己的;我用 Claude 逐行帮助验证每一次翻译与实际代码是否相符。我们发现了二十多个潜在 bug。其中三个与安全相关。它们都没有在 cargo test、tracing 或编译器中显现。
这些差距看起来是这样的。
我在自己的代码中看不到的东西
Runique 是一个受 Django 启发的框架:Axum、SeaORM、Tera、edition 2024。它能用。它为一家真实餐厅的预订网站以及 runique.io 本身提供了数月的生产环境支持——这是我的自用。但我所描述的审计是针对框架本身的,而不是针对那些网站。这正是问题所在——“它能用”正是最难发现 bug 的状态。编译器很高兴。测试是绿的。tracing 什么都没显示,因为什么都没抛出。
我每天使用的工具都是局部的。cargo check 看到一个函数。单元测试看到一条路径。tracing 看到一个请求。它们都无法退后一步,一次性展示整个事物的形状——而有些 bug 只存在于形状中。一个本应是 pub(crate) 的字段却被标记为 pub。一个文件在序列中被过早写入。一个类型在三个文件之外、特性标志后面,与它本应匹配的类型不一致。
我需要一个代码的外部视角——同时不离开代码。不是重写,不是新工具,不是 linter。而是对同一事物的一种不同的表示,精确到足以机械地发现差异。
三种表示法,一种结构
以下是这本书教给我的想法,明确表达。
Rust 的类型系统、UML 类图和 Merise 数据模型是三种表示法,用于描述同一底层事实:实体、它们的关系、基数、不变式。GoF 模式只是跨这三种表示法反复出现的形状。Rust 在类型系统中编码它们,由编译器强制执行。UML 和 Merise 在单独的图形形式主义中编码它们,由人来阅读。两者之间的翻译几乎是机械的:
| 概念 | Rust | UML / Merise |
|---|---|---|
| 实体 | struct |
Class / Entity |
| 强制 1..1 | 按值字段:T
|
基数 1..1
|
| 可选 0..1 | Option<T> |
基数 0..1
|
| 1..N |
Vec<T> / HashMap<K,V>
|
基数 1..N
|
| 组合(拥有) |
struct A { b: B } 或 Box<B>
|
实心菱形 |
| 聚合(共享) |
&B / Arc<B> / Rc<B>
|
空心菱形 |
| 精化 | AST → HIR → MIR | MCD → MLD → MPD |
有一个注意事项,因为 Rustaceans 会(理所当然地)猛烈抨击:所有权到组合的映射并不完美。Box<B> 仍然拥有,所以它是组合,而不是聚合;聚合——一个具有独立生命周期的共享引用——是 &B、Arc<B>、Rc<B>。这个类比是一个透镜,而不是定律。但它是一个足够清晰的透镜,当代码和图表不一致时,这种不一致就值得调查。
这就是整个方法的总结:用类型系统不原生理解的形式主义对代码建模,并审计两个表示法拒绝一致的地方。
Merise 在这里值得一提,因为它对大多数英语读者来说是不熟悉的。它是 20 世纪 70 年代的法国系统建模方法,至今仍在法国计算机科学课程中教授。它将数据模型分为概念(MCD)、逻辑(MLD)和物理(MPD)层——至关重要的是,在逻辑层,它迫使你写下每个关联的基数和具体类型。这第二个要求捕获了下面其中一个 bug。
方法:三种透镜,无抽样
我没有抽查。审计的要点是覆盖率,所以我用三种透镜逐个模块检查了整个框架,每种透镜都匹配它擅长的领域:
-
Merise 用于数据——每个
eihwaz_*框架表的 MCD 和 MLD,包含基数、外键和物理类型。 - UML 类图用于结构——每一层一个:引擎、表单、管理、认证、中间件、迁移、配置。
- 序列图用于动态——请求/CSRF/上传流程、登录/会话、管理 CRUD。这是向你展示顺序的透镜,代码和类图都无法使其可见。
一切都是 Markdown 中的 Mermaid,与代码一起版本控制。没有二进制工具,没有单独的应用程序——图表在 GitHub 上渲染,并与仓库一起传输。每个图表都以“异常”部分结尾,合并到单个 anomalies.md 中,每个发现都有严重程度和 file:line。
现在是有趣的部分。
Bug 1 — UML:一个悄悄绕过 CSRF 的 pub 字段
我将以最有趣的一个开始——不是最严重的,而是最有趣的,因为它是最好地展示了整个练习真正意义所在的一个 bug。
我在为 Prisme 绘制 UML 类图——这个类型保存请求的已解析且 CSRF 检查过的 body。图表让我为每个字段写下其可见性。就在那里:
Prisme
+ data: StrMap ← public
+ csrf_valid: bool
+ checked_data() -> Option<&StrMap> ← fail-closed accessor
Enter fullscreen mode Exit fullscreen mode
data 是 pub。checked_data()——这个只在 CSRF 通过时才返回 body 的访问器——就在它旁边。当这两者出现在图表上的同一个框中时,问题就很明显了:为什么构建一个 fail-closed 门,然后在它旁边留下原始的公共字段?
Runique 中的 CSRF 强制执行并不存在于 HTML 表单的中间件中——它存在于访问点。req.form() 检查 csrf_valid,如果令牌无效则拒绝交出已验证的数据。但如果 Prisme::data 是 pub,任何处理器——或者,更重要的是,任何基于 Runique 构建的第三方代码——都可以直接读取原始 body,完全跳过这个门,而不知道它做了什么。
我想准确说明严重程度,因为诚实的版本比可怕的版本更有趣。我自己的代码是干净的:admin、login 和 form() 都在接触 body 之前检查 CSRF。这从来不是 Runique 本身被利用的漏洞。它是一个公共 API 中的陷阱——一种让基于该框架构建的人意外地绕过 CSRF 的形状。这是一个框架作者的 bug,而不是入侵。
修复只是一个关键字,这就是本文的全部要点:
pub struct Prisme {
/// Parsed body/query data. **Crate-private**: user code cannot read the raw
/// body without going through the CSRF gate (anomaly C2). External access only
/// via checked_data() (fail-closed) or req.form().
pub(crate) data: StrMap,
pub csrf_valid: bool,
}
impl Prisme {
/// Fail-closed accessor: returns body data only if CSRF is valid.
pub fn checked_data(&self) -> Option<&StrMap> {
if self.csrf_valid { Some(&self.data) } else { None }
}
}
Enter fullscreen mode Exit fullscreen mode
pub → pub(crate)。Rust 可见性系统现在在结构上保证请求 body 的唯一入口是那两个首先检查 CSRF 的入口。这不是约定,不是 lint,不是某人必须记住的代码审查规则。编译器将拒绝绕过。可见性作为安全机制——这就是 UML 图表所揭示的,因为类图迫使你写下代码让你略过的那个属性(pub vs pub(crate))。
Bug 2 — 序列图:一个文件在被允许之前就被写入
第二个透镜是展示时间的。以下是我根据真实管道绘制的 multipart 表单 POST 序列图:
sequenceDiagram
participant C as Client
participant MW as csrf_middleware
participant P as prisme_pipeline
participant AG as parse_multipart
participant H as Handler
C->>MW: POST multipart (csrf_token as a field, no header)
Note over MW: mutating method, no X-CSRF-Token,<br/>form content-type → LET THROUGH<br/>("Prisme will validate")
MW->>P: next.run()
P->>AG: parse_multipart(req)
AG->>AG: stream file → tmp (size cap, NO extension check)
AG->>AG: rename tmp → MEDIA_ROOT/uuid.ext (COMMIT)
Note over AG: file committed to its final,<br/>servable location BEFORE CSRF,<br/>validation, and the handler
AG-->>P: final paths
P->>P: check_csrf() → csrf_valid (flag only)
P-->>H: Request
H->>H: if form.is_valid() { ... } else { reject }
Note over H: file already on disk in MEDIA_ROOT,<br/>never removed on rejection
Enter fullscreen mode Exit fullscreen mode
从上到下阅读,bug 就自己显现了。文件被重命名为 MEDIA_ROOT——它的最终、可能公开服务的位置——在 CSRF 检查之前,在表单验证之前,在扩展名过滤器(它存在于后面的 FileField::validate 中)运行之前。在任何提取 Request 并接受 multipart 的公共端点上,这意味着:
-
未经验证的文件写入。无论接下来发生什么,文件都会落在
MEDIA_ROOT中。 -
该阶段没有扩展名过滤——只有大小上限。一个
.html、.svg或.js可能会落在服务目录中。如果MEDIA_ROOT是静态服务的,那就是存储型 XSS 的路径。 - 拒绝时的孤儿。CSRF 失败,验证失败,蜜罐触发——文件已经写入,没有任何东西清理它。
没有单元测试捕获到这一点,因为没有单元测试的形状像请求生命周期。类图也没有捕获到它——每个单独的方法在隔离状态下都是正确的。只有时间视图,这个展示顺序的视图,才使“在验证之前写入”作为一个单一的视线可见。
修复将提交移到最后。parse_multipart 现在写入一个非服务暂存目录,而 FileField::finalize——在 CSRF 和验证之后运行——是唯一允许提交的东西:
// No commit here: files stay in staging. FileField::finalize (the only committer)
// moves them to their served destination after CSRF + validation. On rejection,
// staging is purged by sweep_stale_staging (best-effort, TTL). Errors are logged,
// never swallowed.
Ok(data)
Enter fullscreen mode Exit fullscreen mode
一个提交者,一个门,在正确的顺序中运行——以及一个从未被提升的暂存的 TTL 清理,每一次失败都被记录而不是掉在地上。
Bug 3 — Merise:一个与其目标不匹配的外键
最后一个是 Merise 的“写下类型”纪律捕获的 bug,它清楚地展示了为什么单独的形式主义值得保留。
以下是用户和组之间连接表的 MLD 片段——逻辑模型:
erDiagram
USER ||--o{ USER_GROUPE : "belongs to"
GROUPE ||--o{ USER_GROUPE : "groups"
USER {
pk id "int OR bigint under big-pk"
}
USER_GROUPE {
pk user_id FK "→ users.id"
int groupe_id FK
}
Enter fullscreen mode Exit fullscreen mode
Merise 让我在 user_id 旁边写两件事:它的基数(一个强制性的 FK 到 users.id)和它的物理类型。而 users.id 有一个脚注——它通常是 integer,但在 big-pk 特性标志下是 bigint。所以我去检查 FK 列是否遵循相同的规则。它没有:
// user_id was hardcoded .integer() — it must follow eihwaz_users.id, which becomes
// BIGINT under the big-pk feature, or the FK is a type mismatch and fails to create.
let mut user_id_col = ColumnDef::new(Alias::new("user_id"));
user_id_col.integer(); // ← always integer, even when users.id is bigint
Enter fullscreen mode Exit fullscreen mode
sessions、history 和 reset_tokens 都将特性标志传播到它们的 user_id 列。这个连接表没有。在 big-pk 下,在像 Postgres 这样的严格数据库上,外键是类型不匹配,无法创建——一个只对翻转该标志的用户子集显示的损坏迁移。一旦你看到它,修复就是机械的:
let mut user_id_col = ColumnDef::new(Alias::new("user_id"));
#[cfg(feature = "big-pk")]
user_id_col.big_integer();
#[cfg(not(feature = "big-pk"))]
user_id_col.integer();
user_id_col.not_null();
Enter fullscreen mode Exit fullscreen mode
编译器永远无法捕获这一点——两个分支都可以正常编译;它们只是描述了一个不会构建的数据库。Merise 捕获到它是因为它使类型成为我必须明确写下并比较的东西,而不是埋在距离其对应物三个文件之外的 ColumnDef 构建器调用中的东西。
真正的发现不是 bug
这是我没想到的转折。bug 是练习的目标。但它们并不是它产生的最有价值的东西。
最有价值的东西是地图。
到最后,我对 Runique 有了一个架构理解——作为其作者的我——在开始时并没有。不是一种模糊的“我知道我的代码库”的感觉,而是一个精确的、绘制的、有版本控制的模型,涵盖每一层和每一个数据流。bug 是构建这张地图的副产品。地图才是资产。
有两件事让我相信了这一点。首先是已验证的假阳性。我不仅仅记录了真正的 bug;我记录了我确信但最终证明是错误的每一个假设。我确信 makemigrations 无法处理 ALTER COLUMN——错误,它使用了一个我忘记自己写的完整模式差异。我确信两条会话写入路径产生了不同的 session_id——错误,on_conflict 子句使其确定性。仔细记录你错误的事情是将审计与庆祝区分开来的东西,也是大多数人会悄悄删除的部分。
其次是横向主题。一旦 bug 都在一页上,它们就不再看起来像二十个单独的 bug,而开始看起来像四个重复的反模式:错误被默默吞噬;一个事实的两个来源;顺序错误的安全操作;一个没有传播到它需要的所有地方的特性标志。这就是从“修复 bug”到“提取规则”的跳跃——只有当你能一次看到所有实例时才能做到。地图就是让我一次看到它们的东西。
如何对自己的项目这样做
如果你想尝试,这个方法可以压缩成几条规则:
- 从一个模块、一层开始。不要在第一天就对整个事物建模;对你开始不信任的部分建模。
- 将透镜与问题匹配。数据完整性 → Merise。结构和可见性 → UML 类图。顺序和生命周期 → 序列图。大多数 bug 只存在于这三个视图中的一个中,而在其他两个中不可见。
- 保持工具简单。Markdown 中的 Mermaid,与代码一起提交。它在 GitHub 上渲染,像源代码一样 diff,这意味着图表保持诚实,而不是在 wiki 中腐烂。
- 记录你的假阳性要像记录你的 bug 一样仔细。纪律就是全部价值。只记录胜利的审计是营销。
还有一个诚实的警告:这需要真正的时间。它不值得用于周末项目。它值得用于你开始不信任的生产代码库——那个能用、通过测试,但你仍然有某种不安感觉的代码库。这种感觉通常是对的,这就是你找出它指向什么的方法。
结语
回到启动它的那本书。 modeled Runique 时,我发现了其中已经存在的模式——Builder、Template Method、Composite、Strategy——这些我从未刻意放置。它们自己出现了,因为它们是这类问题将你推向的形状。这本书没有教我添加它们。它给了我看到已经存在的那些的词汇,而建模给了我它们存在的视觉证明。
完整的图表集——每一个 UML 类图、Merise 数据模型和序列流,每一个都有自己的异常部分——位于 GitHub 上的 diagramme/ 文件夹中。Runique 本身位于 crates.io 和 GitHub,文档位于 runique.io。这个审计的更多内容正在成为它自己的系列:通过可见性的安全、对每一个被吞噬错误的扫描,以及已验证假阳性的完整列表。
有一个问题给评论区,因为我真的想知道:你是否曾经使用建模形式主义来审计你已经写过的代码——它揭示了代码本身隐藏的什么?
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.