KV-CLI:用 Rust 实现亚 200ms 的本地 dbt/数据管道静态分析
Show HN: KV-CLI – Fast, local dbt/pipeline static analysis in Rust
KV-CLI 是一款用 Rust 编写的本地数据管道静态分析工具,通过 tree-sitter AST 在本地解析 Python 代码,检测参数契约缺失(KV001)、硬编码凭证(KV002)和固定基础设施标识符(KV003),在 47 文件仓库上典型运行时间低于 200ms。
面向数据管道的亚 200ms 本地契约护栏。 Airflow、dbt、PySpark、Databricks、Snowpark、Flink、Polars。
这是针对本仓库中 samples/deploy.py 的一次真实会话——初始化、失败、修复、通过。有五点值得注意:
init编写契约并说明它启用了什么——两个必需参数、16 个密钥检测器、按文件自动检测的六个框架配置。规则在首次扫描前就可见,而不是埋在默认值里。deploy_service缺少target_environment。 该函数运行正常并通过测试;在生产环境中它却静默地采用开发默认值。没有任何 linter 有针对这一点的规则。- 密钥值在输出中被脱敏——
value "s3…" (<redacted>, 20 chars)。一个在 CI 日志中报告凭据的工具只是转移了泄露,而非堵住它。 fix重写它而非描述它:DB_PASSWORD现在读取os.environ["DB_PASSWORD"],并将import os插入到现有导入之上。18 处更改自动落地;第 90 行硬编码的 default 被有意留作manual:项,因为默认值中的os.environ[...]在导入时绑定一次,而非每次调用时绑定。你自己处理那一行,重新运行就会报告PASS。- 运行会说明自身的范围。
10 in scope, 0 framework-exempt, 2 user-exempt, 1 out of scope是结果背后的分母——没有它,一个每个函数都归框架所有的仓库看起来与一个合规的仓库完全相同。
在你自己的代码上运行它
无需账户、无需克隆、无需配置。分析路径不进行任何网络调用。
# 1. Get the binary — no toolchain, ~2MB, no runtime dependencies # (pick your platform's archive from the Releases page) curl -sSL https://github.com/kovallent/kv-cli/releases/latest/download/kv-cli-x86_64-unknown-linux-gnu.tar.gz | tar xz # 2. Point it at a pipeline repository ./kv-cli audit ~/your-pipeline-repo
典型运行:在 47 个文件的仓库上低于 200ms。 如果合规则退出码为 0,如果有发现则退出码为 1。
或者克隆并运行捆绑的示例
samples/ 附带的管道会触发每一条规则,因此你可以在信任它处理自己的代码之前先阅读真实输出:
git clone https://github.com/kovallent/kv-cli && cd kv-cli ./kv-cli init # created wrote ./.kovallent.yaml ./kv-cli audit --strict samples/ # FAIL 13 errors, 6 warnings — by design ./kv-cli fix samples/deploy.py # FIXED applied 18 changes across 1 file ./kv-cli audit samples/compliant.py # PASS — must always be clean
samples/deploy.py 演练每个检测器和每条抑制路径。samples/frameworks/ 为每个受支持的栈各有一个文件,每个文件都演示签名豁免和该框架自身的检测器。
如果你从源码构建而非下载二进制文件,请为首次编译预留几分钟——
cargo会构建 tree-sitter 语法并需要 C 编译器。make demo一步完成构建并审计samples/。
它什么都没找到。它在工作吗?
三种常见原因,全都是有意设计的:
- 你的模型是 SQL。 分析仅限 Python。
.sql文件中硬编码的关系不会被看到。 - 没有契约文件,因此应用了默认值。 运行
kv-cli init并启用你想要的规则。 - 框架拥有你的签名。 检查输出中的范围行——如果
in scope相对于总数偏低,那是豁免在起作用,而非漏报。参见 框架支持。
什么是 kv-cli?
kv-cli 在 Python 数据管道中强制执行参数契约:每个受治理的函数都必须声明所需的 schema 参数(例如 target_environment),不得硬编码任何凭据,也不得将任何基础设施标识符固定到单一环境。
它使用 tree-sitter AST 在本地解析你的代码——不传输任何内容、不需要账户,且分析路径中没有网络调用——以在坏代码离开开发者工作站之前检测参数契约破坏(KV001)、硬编码凭据(KV002)和固定的基础设施标识符(KV003)。
它针对的这类 bug 正是 linter 会漏掉的:语法上没问题、通过测试,只有在作者笔记本电脑以外的地方运行时才会出错。
安装
预构建二进制文件——无需工具链。 从 Releases 下载适用于你平台的压缩包,然后:
chmod +x kv-cli && sudo mv kv-cli /usr/local/bin/
kv-cli --version每个发布版本都附带 SHA256SUMS 用于验证。
使用 Cargo:
cargo install --git https://github.com/kovallent/kv-cli
从源码构建:
git clone https://github.com/kovallent/kv-cli && cd kv-cli make release # -> target/release/kv-cli
从源码构建需要 C 编译器,用于 tree-sitter 语法——macOS 上需要 Xcode Command Line Tools,Debian/Ubuntu 上需要
build-essential或等效工具,CI 中也是如此。这适用于cargo install和make release,不适用于预构建二进制文件,后者没有运行时依赖。
目前没有 PyPI 或 Homebrew 分发。pip install kv-cli 将无法使用。
命令
| 命令 | 用途 |
|---|---|
kv-cli init |
写入默认的 .kovallent.yaml 契约并报告它启用了什么——必需参数、密钥检测器数量、框架配置文件。--force 会覆盖。从这里开始。 |
kv-cli audit [PATHS] |
扫描违规。--format json、--strict、--config、--expect-contract。 |
kv-cli fix [PATHS] |
应用标准修复。--dry-run、--no-backup。 |
kv-cli frameworks |
显示内置配置文件以及每个配置文件的贡献。 |
kv-cli schema |
打印此构建输出的 JSON 负载模式。 |
审计 CI 门禁和退出码
audit 是 CI 门禁。退出码是文档化的接口——每个退出码会升级到不同的人:
| 代码 | 含义 | 升级至 |
|---|---|---|
| 0 | 合规 | — |
| 1 | 发现 | 拉取请求作者 |
| 2 | 工具错误 | 运行门禁的人 |
| 3 | 契约漂移 | 契约所有者 |
解决顺序为 2、3、1、0,先匹配者胜出。无法验证的运行永远不会通过,且漂移优先于发现——基于错误契约计算出的发现不是有意义的结果。
Git pre-commit 集成
将 kv-cli 添加到你的 .pre-commit-config.yaml:
repos: - repo: https://github.com/kovallent/kv-cli rev: v0.4.1 hooks: - id: kv-cli
该钩子使用 language: rust 声明,因此 pre-commit 会在首次运行时在隔离环境中构建一次 crate。因此每位贡献者都需要 Rust 和 C 编译器,并等待一次编译。如果这对你的团队不可接受,请直接调用预构建二进制文件:
repos: - repo: local hooks: - id: kv-cli name: kv-cli contract audit entry: kv-cli audit language: system types_or: [python, yaml]
钩子在构造上就是建议性的——--no-verify 会跳过它,而全新克隆不会安装任何钩子。它是修复发现成本最低的地方,而不是保证合规的地方。那才是 CI 门禁的用途。
诊断和规则
KV001— 参数契约违规(错误)。 受治理的函数缺少契约参数。KV002— 硬编码密钥(错误)。 以字面量形式写入的凭据或 API 密钥。KV003— 固定基础设施标识符(默认警告)。 固定到某个环境的基础设施标识符:仓库、目录、集群 ID、存储桶或端点。默认警告,以便采用它不会在第一天就破坏 CI;fix从不重写这些,因为正确的替换是部署决策。
自动修复行为
fix 将缺失参数插入签名(在 **kwargs 之前,保留多行布局),并将硬编码密钥重写为 os.environ["NAME"],在需要时添加 import os。audit 和 fix 共享一个被标记绑定的列表,因此它们永远不会对什么是密钥产生分歧。备份保存到 .kvbak。
抑制
使用行尾的 # kovallent:allow-secret 抑制单行,或使用 # kovallent:allow-infra 抑制 KV003。
框架支持
运行 kv-cli frameworks 获取实时列表。每个配置文件都限定于实际使用该框架的文件——从导入中检测,对于 dbt 则从 model(dbt, session) 签名中检测。这种限定使激进规则变得安全:account 和 warehouse 在全局范围内会很嘈杂,但在导入 snowflake 的文件中它们很精确。
| 配置文件 | 检测方式 | 贡献 |
|---|---|---|
| dbt | def model(dbt, session) |
拥有模型签名;profiles.yml 凭据扫描 |
| polars | import polars |
storage_options 凭据;对象存储路径标识符 |
| flink | import pyflink |
拥有 @udf/@udtf/@udaf;broker 和 JDBC 端点 |
| databricks | import dlt / databricks / pyspark |
拥有 @dlt.table/@dlt.view;PAT;工作区 URL、集群 ID、DBFS 路径 |
| snowpark | import snowflake |
拥有 @sproc/@udf;会话配置 account、warehouse、role |
| airflow | import airflow |
拥有 @dag/@task_group/@setup/@teardown;治理 @task(仅报告);Fernet 密钥;conn_id、pool、queue |
框架自有的签名不受 KV001 约束。@dlt.table 函数不接受参数,因为 DLT 会调用它;def model(dbt, session) 由 dbt 固定。在那里注入 target_environment 会破坏管道,因此该豁免会覆盖用户自己的名称模式——名为 daily_sales_job 的函数会匹配 *_job,但在 @dlt.table 下它会被放过。Airflow 的 @dag 出于同样的原因被纳入自有范围:它的参数会成为 UI 和 {{ params.* }} 中显示的 DAG 参数,因此添加它们会静默地改变 DAG。
用户 exempt_name_patterns 仍然优先于框架规则,因此 @task def _internal_step() 保持在范围之外。
profile 也可以反向工作:governed_decorators 将函数纳入 KV001 范围。Airflow 的 @task 是唯一的情况——TaskFlow 任务从你自己的 DAG 主体中调用,因此契约参数可以在调用点传入。在 profile 上声明它意味着该行为不再取决于你的契约恰好列出了一个名为 task 的装饰器。
受治理的装饰器可以仅为报告模式(governed_auto_fix: false),Airflow 的 @task 就是如此。audit 会标记缺失的参数,但 fix 拒绝插入它,而是打印一个 manual: 项——extract_orders("s3://...") 不会传入新参数,因此插入的默认值在每个环境中都会读取为 "dev",同时看起来正确。因此,一个重度使用 Airflow 的仓库仅靠 fix 无法达到干净的审计;你必须自己在调用点传入该参数。这仅限于该框架:同一 DAG 文件中的普通函数仍会正常修复。
如果愿意,可以在契约中显式选择 profile:
frameworks: enable: [auto] # or [snowpark, dbt] to apply everywhere disable: [databricks] # never apply, even when detected
dbt YAML
profiles.yml、dbt_project.yml 以及任何其他被扫描的 YAML 仅检查明文凭据。{{ env_var('DBT_PASSWORD') }} 和 ${VAR} 被识别为正确,永远不会被标记。KV003 有意不应用于 YAML:按目标区分的配置文件正是环境特定值应该存在的地方。
技术栈兼容性
- Python:3.9、3.10、3.11、3.12+
- dbt:
def model(dbt, session)模型签名;profiles.yml凭据扫描。 - Apache Airflow:
@dag、@task_group、@setup、@teardown、@task治理;Fernet 密钥;conn_id、pool、queue。 - PySpark / Databricks:
@dlt.table、@dlt.view;PAT;工作区 URL、集群 ID、DBFS 路径。 - Snowpark:
@sproc、@udf;会话配置 account、warehouse、role。 - Flink:
@udf、@udtf、@udaf;通过pyflink的 broker 和 JDBC 端点。 - Polars:
storage_options凭据和对象存储路径标识符。
契约配置(.kovallent.yaml)
.kovallent.yaml 驱动一切:扫描哪些文件、哪些参数是必需的(带有 fix 写入的注解和默认值)、哪些函数受治理(按名称模式、装饰器或 all_functions),以及密钥检测器。运行 kv-cli init 获取带完整注释的模板。
version: "1" rules: KV001: enabled: true severity: error strict_parameters: true KV002: enabled: true severity: critical ignore_paths: - "tests/fixtures/*"
契约分发与验证
契约保持本地。audit --format json 在 run.contract_sha256 下生成它的语义指纹,服务器将其与为该仓库部署的内容进行比较;漂移会作为独立的结果报告,而不是作为代码失败。这使 audit 可以离线、在免费本地层级以及气隙部署中工作——这些在强制获取的情况下都无法实现。
哈希覆盖的是解析后的契约,而不是文件字节,因此重新格式化或编辑注释不算漂移,而编辑规则则算。当运行使用内置默认值时,run.contract_path 为 null。
如今强制执行固定
不需要任何服务。预期哈希必须存放在打开拉取请求的开发者无法编辑的地方——Actions 组织变量正是如此:
- name: Kovallent gate run: kv-cli audit --strict --format json --expect-contract ${{ vars.KOVALLENT_CONTRACT_SHA }}
不匹配时,运行以 3 退出,区别于发现的问题(1)和工具错误(2)。
把预期哈希提交到仓库反而更弱——任何削弱契约的人都能在同一次提交中更新锁文件。
已知缺口:任何有写权限的人都可以编辑工作流来去掉该标志。可通过在 .github/workflows/ 上使用 CODEOWNERS 加上分支保护,或要求该检查的组织规则集来堵住。理由、被否决的替代方案和分阶段计划见 ADR 0001。
JSON 载荷模式
schema/findings.v1.json 由 kv-cli schema 从 Rust 类型生成;有测试断言已提交的文档与之匹配,并且真实运行会依据它进行校验,因此两者不会偏离。
- 版本与身份。
run携带schema_version(仅在载荷发生破坏性变更时递增,与tool_version无关)、运行身份(repo、commit、branch、timestamp)、契约来源以及一个 scope 块。身份从不猜测:repo和commit来自 CI 环境(GITHUB_REPOSITORY、CI_COMMIT_SHA、……)或来自--repo/--commit/--branch,而identity_source记录的是哪一种——"flag"、"env:GITHUB_SHA"或缺失。 - 严重性报告。严重性按发出的原样报告。
errors和warnings统计的是固有严重性,与--strict无关;该标志会被发出,以便消费者自行应用策略。否则,严格运行会把每个发现都报告为错误,载荷也就无法说明其中有多少是警告。 - 完整性。任何文件都不能从载荷中消失。
kv-cli为扫描而解析出的每个文件,要么出现在findings/files_scanned中,要么在run.skipped[]中列为{path, reason, severity, detail},原因为"syntax_error"或"unreadable"。语法错误是警告——与KV003的引入方式一致,默认不会使 CI 失败,并会在--strict下被提升。不可读文件始终是错误,因为对“工具无法检查此文件”不存在宽松的解读。 - Scope 块。scope 块是绿色结果背后的证据。没有它,一个所有受管控函数都归框架所有的仓库,看起来与一个完全合规的仓库完全相同:
functions: 10 in scope, 0 framework-exempt, 2 user-exempt, 1 out of scope
框架所有权与契约的 exempt_name_patterns 是分开统计的——前者是我们的,后者是客户的选择。
AST 扫描器如何工作
src/python.rs 使用 tree-sitter(tree-sitter-python)解析每个 Python 文件,并从解析树上读取一切。Python 分析中没有任何文本匹配。
这对 fix 最为重要,因为它会重写源代码。解析器产出的字符串字面量已经与绑定它们的内容配对,因此 a, b = "x", "y" 按元素配对。早先的文本扫描实现会向后遍历字节来猜测绑定,在那一行上它把 b 与 "x" 配了对——然后重写了错误的字面量,破坏了 a 并把密钥留在了原处。绑定以五种形式被识别:赋值(包括带注解和元组目标)、字典条目、关键字参数、海象表达式以及参数默认值。
签名也来自解析树,因此多行参数列表、async def、lambda 默认值、/ 和 * 分隔符,以及跨行展开的装饰器调用都能正常工作,无需特殊处理。
两个刻意的限制
- 参数默认值(
def f(password="..."))会被报告,但从不自动修复:默认值中的os.environ[...]在导入时求值一次,而非每次调用时求值,因此重写会改变行为。fix会将其打印为manual:项。 - 存在语法错误的文件会基于 tree-sitter 的部分解析进行审计,并在输出中被标记,但
fix拒绝重写它。
YAML 由 src/yamlscan.rs 单独处理,它是行扫描器而非解析器:serde_yaml 能给出结构但给不出行号,而没有行号的发现不可操作。它会跟踪块标量,因此 | 块内的散文不会被误认为映射键。
这与其他工具有何不同
| Linter(Ruff、Flake8、SQLFluff) | 密钥扫描器(detect-secrets、gitleaks) | 应用安全(CodeQL) | kv-cli | |
|---|---|---|---|---|
| 检查代码是否格式良好 | 是 | — | 部分 | — |
| 检查代码是否可跨环境移植 | 否 | 否 | 否 | 是 |
| 数据栈感知(Airflow、dbt、Spark、Snowpark) | 否 | 否 | 否 | 是 |
| 强制实施必需参数契约 | 否 | 否 | 否 | 是 |
| 本地运行,不传输任何内容 | 是 | 是 | 通常不 | 是 |
| 典型运行时间 | 即时 | 即时 | 数分钟 | 在 47 个文件的示例仓库上 <200ms |
detect-secrets 查找高熵字符串;KV002 查找赋值给契约禁止名称的字面量,这也能捕获 password = "dev"。两者都没有“此函数必须接受 target_environment”的概念。
开发
make test # unit tests (121) make check # fmt --check + clippy -D warnings + tests make demo # build, then audit samples/ (exits 1 by design)
samples/compliant.py 必须始终审计通过。cargo run --example dump <file.py> 打印带字段名的解析树——当添加需要尚未处理的节点类型的规则时很有用。
依赖说明
- 构建依赖。构建需要 C 编译器(
cc)用于 tree-sitter 语法。输出仍是单个二进制文件,无运行时依赖。 - serde_yaml。固定在其最终 0.9 版本,上游不再维护。将其替换为
serde_norway或serde_yml是直接替换;契约指纹依赖它,因此golden_fingerprint必须在替换过程中保持绿色。
贡献
欢迎贡献——新的 AST 规则、解析改进、错误修复。
- Fork 并创建分支(
git checkout -b feature/new-rule)。 - 如果你想先讨论,请在 Discussions 中开一个帖子。
- 运行
make check并打开拉取请求。
在 MIT License 下发布。
社区
- 公告——发布说明和路线图。
- 想法和功能请求——提出
KV规则或框架配置。 - 问答——设置、AST 规则、自定义配置。
- Kovallent Enterprise 等候名单——团队级策略治理和记分卡。
来源:Hacker News · github.com