我正在阅读一本设计模式书籍。读到 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 testtracing 或编译器中显现。

这些差距看起来是这样的。

我在自己的代码中看不到的东西

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> 仍然拥有,所以它是组合,而不是聚合;聚合——一个具有独立生命周期的共享引用——是 &BArc<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

datapubchecked_data()——这个只在 CSRF 通过时才返回 body 的访问器——就在它旁边。当这两者出现在图表上的同一个框中时,问题就很明显了:为什么构建一个 fail-closed 门,然后在它旁边留下原始的公共字段?

Runique 中的 CSRF 强制执行并不存在于 HTML 表单的中间件中——它存在于访问点。req.form() 检查 csrf_valid,如果令牌无效则拒绝交出已验证的数据。但如果 Prisme::datapub,任何处理器——或者,更重要的是,任何基于 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

pubpub(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 的公共端点上,这意味着:

  1. 未经验证的文件写入。无论接下来发生什么,文件都会落在 MEDIA_ROOT 中。
  2. 该阶段没有扩展名过滤——只有大小上限。一个 .html.svg.js 可能会落在服务目录中。如果 MEDIA_ROOT 是静态服务的,那就是存储型 XSS 的路径。
  3. 拒绝时的孤儿。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

sessionshistoryreset_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.ioGitHub,文档位于 runique.io。这个审计的更多内容正在成为它自己的系列:通过可见性的安全、对每一个被吞噬错误的扫描,以及已验证假阳性的完整列表。

有一个问题给评论区,因为我真的想知道:你是否曾经使用建模形式主义来审计你已经写过的代码——它揭示了代码本身隐藏的什么?