tombaldwin/candor-java
GitHub: tombaldwin/candor-java
candor-java 是一个基于 ASM 的 JVM bytecode 静态分析工具,通过传递性地计算每个方法的副作用映射,在 CI 中强制执行可配置的架构边界不变量。
Stars: 0 | Forks: 0
# candor-java
`.** 与其手动扫描每个
依赖 jar 包,不如将 `--deps` 指向 classpath(一个以 `:` 分隔的 jar 列表,或一个
jar 目录):wrapper 会将每个 jar 扫描到 `.candor/deps/` 下的同级报告中,然后链式地在此基础上运行应用扫描 —— 这是依赖扫描最终方案在 JVM 端的体现(一个依赖项的副作用来源于*它自身*对
JDK 边界的调用,因此精选的分类器会向仅限内置的方向缩减)。已缓存:未更改的
jar 的报告会被重用。*Shell* 负责展开扫描,因此引擎本身从不生成进程
(它自身的边界仅为 Fs/Env,spec §7.12)。
```
./candor --deps libs/ build/classes/java/main --json /tmp/app.json # scan libs/*.jar, then the app
```
*解析深度:* 一个具体类型的跨 jar 调用通过 `hash` 直接解析;一个 **interface** 类型的
调用,如果其实现在依赖项中,则无法仅凭报告进行去虚化(报告不包含
class hierarchy)。为了实现跨边界的完全解析,请将应用**和**其依赖*一起*分析
(作为一个 classpath)—— 这样本地 CHA 就能看透 dispatch。`CANDOR_DEPS` 是当你只拥有依赖项的*报告*而非其 bytecode 时使用的模式。
**暂未实现(如实推迟 — PRINCIPLES #7):**
- 对分类器无法识别的**非项目(JDK/库)类型**的 dispatch 被假定为
pure 而不是 `Unknown` —— 否则每个 `list.add()` 都会淹没报告(这是 Rust 实现中学习到的校准方法)。已知具有副作用的库会被分类器捕获;其余部分是
已记录的遗留缺口。
- 跨 jar 包的 **``**(位于*依赖项*中,仅能通过报告触达的 static initializer)未被归因 —— 因为下文提到的本地触发边需要依赖项的 bytecode 位于 classpath 上。
- **对通用 interface 方法的 CHA 会在充满接口的库上引发副作用泛滥。** 对
interface 类型接收者的调用(`Iterator.next`,`Function.apply`)会通过 Class Hierarchy Analysis 解析为分析集中的*每一个*实现 —— 这在理论上是可靠的(任何实现都有可能在那里),但当其中一个实现执行了 I/O 且该方法普遍调用时,副作用就会泛滥。自我分析 `scala-library` 时,会报告约 74% 的
函数具有 `Net`/`Fs` 副作用,因为一个读取 URL 的 `Iterator` 实现可以通过 CHA 从每一个
iterator 遍历中触达。Rust 实现通过将其*去虚化*为具体类型来避免这一点(编译器为其提供了单态化的接收者);bytecode 的 CHA 在没有类型流分析的情况下无法做到同等效果。在
实践中这是有界的:一个真实的 Scala/Kotlin *应用*将 stdlib 作为未分析的依赖项,因此这些
调用是 `Unknown`,而不是泛滥 —— 它只在库的*自我*分析时才会发作。(在这里计数会产生误导,这正是 candor 报告结构而非“分数”的原因 —— 参见 `containment`。)
**Constructors、static initializer 以及 lambda/method reference** *已得到处理* —— 具有副作用的部分会
将其传播到使用点,而不是看起来很 pure:
- **constructor**(``)body 的副作用会触达每一个 `new X()` 的位置;
- **static initializer**(``)body 的副作用会触达每一个触发类加载的位置 ——
`new C`,对 C 的 static 方法调用,或对 C 的 static 字段访问(JVM 在首次使用时运行一次 ``;candor 从每个触发器连边到它,进行了可靠的过近似);
- **lambda / method reference** 的实现方法(一个项目 `lambda$…` synthetic 或被引用的方法)
会从外围方法连边过来,因此具有副作用的 `() -> …` 或 `Foo::bar` 会传播其副作用。
最深层的 JVM 天花板在于,即使是 declarations + CHA 也无法捕获的内容:**自定义**的 AOP aspect
(`@Aspect`/`@Around`)和通过 reflection 装配的 bean(`getBean`)。解决这些问题的重量级路线是
Spring Boot 3 **AOT metadata**(Spring 自身的提前处理会在构建时解析 bean 装配、proxy
和 reflection)。参见 candor-spec CLASSIFIER.md。
## 构建与运行
**零安装(推荐):** 通过 [jbang](https://www.jbang.dev) 使用发布的 fat-jar —— 无需 clone,
无需 Gradle:
```
jbang candor@tombaldwin/candor-java /path/to/classes-or-jar --json /tmp/report.json
```
**AI agent?** 将其指向 [AGENTS.md](AGENTS.md) —— 自包含的生成/查询/信任
说明。**持怀疑态度?** [PROVE-IT.md](PROVE-IT.md) 是你的 agent 在你自己的代码库上运行的 15 分钟自我实验。
从源码构建:需要 JDK 21 + Gradle。
```
# 将你想要分析的代码编译到一个包含 .class 文件的目录中(或指向一个 jar),然后:
gradle run --args="/path/to/classes --json /tmp/report.json"
# …或者使用 ./candor 便捷包装器(构建一次,在源码更改时自愈):
./candor /path/to/classes --json /tmp/report.json # analyze
./candor show /tmp/report.json myMethod # query a report
```
它会打印每个方法的副作用审计并写入一份 candor JSON 报告。`bash test/smoke.sh` 运行
行为测试套件,而 `bash soundness/run.sh` 运行[对抗性 soundness fuzzer](soundness/) ——
它会将一个已知的副作用穿透每一种 JVM 调用形式(direct / lambda / method-ref / constructor /
static-init / interface dispatch / anon class),并断言 candor-java 绝不会将可达方法报告为 pure(只能是 effect-or-`Unknown`)。两者都在 CI 中运行;fuzzer 的有效性已通过牙齿验证(撤销修复会导致其对应的通道失败)。
## 查询(只读,针对已写入的报告)
一旦你写入了一份报告(`--json report.json`),你就可以**无需重新分析**就能回答关于它的问题 —— 这是 Rust 实现中 `candor-query` 的同胞工具。它们读取报告自身的字段(`inferred`/`direct`,以及 `calls` 效果图),外加完整的 call-graph **sidecar**
(`report.callgraph.json`,会自动生成在报告旁边),用于评估编辑前的爆炸半径:
```
gradle run --args="show report.json " # a function's effects (* = own body; Fs(read,write) detail)
gradle run --args="where report.json " # who performs an effect (direct vs inherited)
gradle run --args="callers report.json " # the blast radius: who TRANSITIVELY calls a fn —
# works for ANY fn incl. PURE ones (pre-edit:
# "who is affected if I add an effect here?")
gradle run --args="map report.json" # class → effects overview, most-effectful first
gradle run --args="diff report.json " # per-function effect delta (+gained / -lost)
gradle run --args="containment report.json [baseline.json]" # effect-leakage diagnostic + a ratchet
gradle run --args="reachable report.json" # what the app DOES at runtime (union over entry points)
gradle run --args="path report.json " # the call chain by which a fn comes to perform an effect
gradle run --args="impact report.json " # blast radius: transitive callers + downstream entry points
gradle run --args="whatif report.json [policy]" # PRE-EDIT verdict: if I add here, what
# propagates AND does it break the deny/pure gate? (exit 1 if so)
gradle run --args="rewire report.json " # DE-WIRING: which methods dropped a call vs the
# baseline — catch a 'fix' that games the gate by disconnecting
```
为任何查询添加 `--json` 即可获得机器可读的输出 —— 即 AI agent / MCP server 消费的形式,其结构与 Rust 引擎完全一致(SPEC §3.1):`show`→`[{fn,inferred,direct,unresolved,fs?,hosts?}]`,
`where`→`{effect,directly,inherited}`,`callers`→`{of,direct,transitive}`,
`map`→`{module:{effects,functions}}`,`diff`→`{changes:[{fn,gained,introduced,inherited,lost,status}]}`,
`whatif`→`{of,effect,affected,violations,ok}`,`rewire`→`{dropped:[{caller,no_longer_calls}]}`。
### `containment` —— 一种并非“分数”的架构质量信号
原始的副作用*计数*是依赖于领域的(一个数据库应用会有大量的 `Db` —— 这并不是缺陷),因此不存在单一的“candor 分数”。但是,跨越各层的边界副作用的**分散程度**确实*是*一种与领域无关的信号:一个重度使用 DB 的应用,如果所有的 `Db` 都位于 `dao` 中,那它的架构就是良好的;而如果 `Db` 弥漫在 `model`、`actions` *以及* `dao` 中,那它就是有泄漏的 —— 无论它执行了多少 DB 操作。
`containment` 正是衡量这一点的 —— 对于每一个*边界*副作用(`Db`/`Net`/`Exec`/`Fs`/`Ipc`),衡量位于其主要层中的比例(`Log`/`Clock` 是环境性的 —— 会被报告,但不计分)。层是根据公共根之后的 package 推断出来的,无需配置:
```
effect contained layers owner ← leaked into
Db 49% 4 model (838) ← dao:833, spring:6, actions:1 # ← half the DB is in `model`, not `dao`
Exec 100% 1 utils (1)
```
给定一个**基线**后,它就是一个*棘轮*(ratchet)—— 将情况变**糟**时设为阻断,将情况变**好**时仅作记录:
```
[containment] a boundary effect leaked into a layer it wasn't in: ← exit 1, fail the PR
Db → actions
✓ improved — a boundary effect left a layer: ← informational
Db ⊘ legacy
```
它被刻意设计为一种**诊断 + 棘轮工具,而不是单一的评级** —— 其绝对水平是依赖于领域且可被操纵的,但其趋势(某个边界副作用是否泄漏到了新层?)是一个真实的、可执行的质量门禁。在 CI 中将它与 `cargo candor snapshot` 风格的基线配合使用。
## 模式
| 模式 | 如何操作 | 输出 |
|---|---|---|
| **audit** (默认) | `gradle run --args=""` | 每个方法的副作用映射 |
| **JSON** | 添加 `--json ` | candor JSON 报告 —— 针对每个方法:`inferred`,`direct`,以及 **conformance**(`declared`/`undeclared`/`overdeclared`,从类的注入依赖中投射而来),以便 agent 能够获取 conformance 信息,而不仅仅是诊断结果 |
| **regression guard** | `CANDOR_BASELINE= gradle run --args=""` | `AS-EFF-005` + 如果有任何函数相较于快照增加了副作用则 **exit 1** |
| **no-ambient** | `CANDOR_NO_AMBIENT=1` (或名称前缀) | 针对直接使用环境权限的 `AS-EFF-004`(应将其路由至注入的协作者) |
| **conformance** | `CANDOR_STRICT=1` (或类名前缀) | `AS-EFF-001/002/003` — 类执行了其注入依赖未提供的副作用(或注入了从未使用的依赖) |
| **policy** | `CANDOR_POLICY= gradle run --args=""` | `AS-EFF-006/008/009` + **exit 1** — 架构即代码:某方法(在传递意义上)违反了 `deny`/`pure`/`allow`/`forbid` 边界 |
| **taint** (建议性) | `CANDOR_TAINT=1` | `AS-EFF-007` — 注入类的副作用(`Exec`/`Fs`/`Db`/`Net`/`Env`/`Ipc`)作用于**由调用者派生**的参数上(命令/路径/SQL 注入,SSRF)。过程内污染数据流;基于启发式算法,永远不会导致 CI 失败 |
### Policy:架构即代码(`CANDOR_POLICY`)
随着模型在局部推理方面变得越来越强大,这种执行方式证明了其存在的价值:模型只是提供建议,而只有掌握了整个效果图的工具才能*阻止 PR*。Policy 文件用于声明不变量;candor-java 会标记任何**传递性**违规(原因可能存在于另一个方法或被本地 diff 隐藏的层中):
```
# .candor/policy
deny Net Db Fs domain # the domain layer must reach no I/O — even through a helper
pure parse # parsing must be side-effect-free
deny Exec # nothing may spawn a subprocess (no scope = whole project)
allow Net in billing api.stripe.com # billing may reach the network — but ONLY Stripe
allow Exec in build git # the build layer may run subprocesses — but ONLY git
allow Fs in config /etc/app # config code may read the filesystem — but ONLY under /etc/app
allow Db in billing ledger.* # billing may touch the database — but ONLY the ledger schema
forbid domain -> infra # the domain layer must not depend on the infrastructure layer
```
```
[AS-EFF-006] `app.domain.Checkout.run` performs { Fs }, forbidden by policy (scope `domain`): `deny Net Db Fs domain`
[AS-EFF-008] `billing.Pay.leak` reaches { metrics.growthtracker.io } outside the allowlist, forbidden by policy (scope `billing`): `allow Net … api.stripe.com`
[AS-EFF-009] `app.domain.Order.place` reaches into a forbidden layer (via `app.infra.Repo.save`), violating policy: `forbid domain -> infra`
```
- **`deny` / `pure`** (`AS-EFF-006`) — 某一层可以做的*内容*。一个方法不必直接执行该副作用;candor 会标记它通过任何被调用方触达该副作用的情况。`pure` 会禁止所有副作用。
- **`allow in `** (`AS-EFF-008`) — 某个副作用可以触达*哪些字面量*,跨越**传递性**表面(字面量通常存在于深层的被调用方中)。对于 `Db` 表,其表面以两种方式输入:SQL 字符串字面量中的表位置标识符,**以及 JPA 的声明** —— 实体上的字面量 `@Table(name = "users")` 加上 repository 的泛型签名(`extends CrudRepository`)会将 `users` 带入每个 Spring-Data 调用的 `tables` 中,无论何处都不需要 SQL 字符串(光秃秃的 `@Entity` 依赖于命名策略且不会提供任何信息 —— 绝不作猜测)。这是模型无法自检的供应链边界。四种副作用带有字面量表面:`Net` 主机(“billing 只能与 Stripe 通信”,通过主机名匹配),`Exec` 命令(“build 只能运行 git”,通过程序 basename 匹配),`Fs` 路径(“config 只能读取 /etc/app”,通过边界处的路径前缀匹配),以及 `Db` 表(“billing 只能触及 `ledger.*`”,通过来自 SQL 字符串字面量的限定表名匹配)。仅认证*可见的*表面 —— 字面量是从携带它的调用中读取的(`ProcessBuilder`/`exec` 程序,`Path.of`/`File`/stream-ctor 路径,scheme-URL/`host:port`/IP host);runtime 计算的值是诚实地不可见的,从不被过度声明(已在真实的 Spring 应用上验证过 —— 提取器获取的是*第一个*参数,因此 `ProcessBuilder("git","clone")` 是 `git`,而 `RandomAccessFile(path,"r")` 的模式永远不会被误认为是路径)。
- **`forbid -> `** (`AS-EFF-009`) — 某一层可以依赖的*对象*。作用域 A 中的方法绝不能*传递性地*触达作用域 B 中的方法(在 call graph 上进行反向可达性查询)。
Scope 通过点分**片段**进行匹配(因此 `domain` 匹配 `app.domain.Svc.handle` 和 `domain_logic` 包,但不匹配 `subdomain`)—— 这与 Rust 实现中的 `scope_matches` 规则相同。设置了但无法读取的 policy 会**响亮地**失败(“policy NOT enforced”),绝不会悄悄变绿。
### Conformance:依赖注入*就是*一种能力系统
candor 的 Rust 实现通过能力 token(`&Fs`)来声明副作用。Java 的惯用做法是**依赖注入**:一个 bean 被注入的协作者(其字段)就是它所拥有的能力。因此:
- **declared(class)** = 其字段类型可以提供的副作用(`RestTemplate` → `Net`,`@Repository` →
`Db`,注入的项目服务 → 该服务的副作用)。
- **performed(class)** = 跨越其自身方法的副作用。
- **`AS-EFF-001`** — 执行了*任何*注入依赖都不提供的副作用 → 它伸手获取了环境权限,而不是接收注入。**`AS-EFF-002`** — 注入了一个从未使用的协作者。
**`AS-EFF-003`** — 存在 `Unknown`,无法认证。
在 `conf-sample/` 上:`GoodService`(持有并使用 `RestTemplate`)是符合规范的;`LeakyService` 在没有 Fs 依赖的情况下调用了环境权限 `Files.readString` → `AS-EFF-001`;`IdleService` 注入了一个从未调用过的 `RestTemplate` → `AS-EFF-002`。这项检查是由一个类的签名(其依赖项)告诉你它的副作用表面 —— 这正是 candor 的核心论点,只是采用了 Java 的颗粒度。
### CI regression guard(阻力最小的采用方式)
无需声明,无需重构 —— 快照记录副作用表面,提交它,并让增加了副作用的 PR 失败:
```
# 在已知的良好构建上执行一次 —— 提交快照:
gradle run --args="build/classes/java/main --json .candor/baseline.json"
# 在 CI 中:
CANDOR_BASELINE=.candor/baseline.json gradle run --args="build/classes/java/main"
# 如果某个函数新增了 effect,则以非零值退出(AS-EFF-005);缺失/损坏的 baseline
# 会发出显著警报失败(“guard is NOT active”),而不是静默通过。
```
### 尝试示例
## 工作原理
ASM 将每个 `.class` 读取为节点树;对于每个方法,每个已解析的调用(`MethodInsnNode`)都会根据其目标的 class + 方法名进行分类;对项目方法的调用成为 call graph 的边;一个不动点运算将所有被调用者的副作用合并到调用者中。与 Rust 引擎采用相同的架构,这是刻意为之的 ——
并且它沿着相同的路径成长:`Unknown`(§4 信任契约),针对项目类型的 CHA,Spring 的声明性表面,policy 门禁,以及只读查询都已加入(如上所述)。
## 许可证
根据您的选择,双重许可于 [MIT](LICENSE-MIT) 或 [Apache-2.0](LICENSE-APACHE)。
标签:Java虚拟机, JS文件枚举, 后台面板检测, 域名枚举, 架构规范, 错误基检测, 静态代码分析