border-customs 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -12
- package/dist/assets/release-src/releaseCoherence.ts +423 -0
- package/dist/assets/residue-src/crates.ts +13 -0
- package/dist/assets/residue-src/npm.ts +14 -0
- package/dist/assets/residue-src/pypi.ts +20 -0
- package/dist/assets/residue-src/rubygems.ts +12 -0
- package/dist/index.js +1269 -563
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -222,9 +222,10 @@ staged artifact, `border check` can no longer PASS that channel on signatures al
|
|
|
222
222
|
artifact's sha256 must also carry a **fresh empirical roundtrip verdict** in the ledger.
|
|
223
223
|
|
|
224
224
|
The check gate NEVER runs Docker itself (there is nothing to trust it to run); the proof is
|
|
225
|
-
pre-supplied out-of-band by `border roundtrip <spec>`, which installs the real
|
|
225
|
+
pre-supplied out-of-band by `border roundtrip <spec-or-local-file>`, which installs the real
|
|
226
|
+
bytes — fetched from a registry or read from a local artifact (W2.4(b)) — diffs the
|
|
226
227
|
filesystem, and appends a `t:"roundtrip"` ledger record — `{artifactSha256, verdict:
|
|
227
|
-
clean|residue, rulesHash, ts, rows}` — keyed by the exact bytes it
|
|
228
|
+
clean|residue, rulesHash, ts, rows}` — keyed by the exact bytes it proved. A `residue` verdict
|
|
228
229
|
still counts as a proof-of-fact: the valve demands *evidence someone looked*, and the finding
|
|
229
230
|
itself keeps blocking until it stops tripping the scan.
|
|
230
231
|
|
|
@@ -236,17 +237,76 @@ itself keeps blocking until it stops tripping the scan.
|
|
|
236
237
|
- Both new rules are ordinary findings: the allow-list can waive them and every waiver is
|
|
237
238
|
enumerated in `allowHits` — no hidden channel.
|
|
238
239
|
|
|
240
|
+
### Release coherence (0.4.1)
|
|
241
|
+
|
|
242
|
+
Founding case — **aihr**: the wheel published on PyPI under version `0.2.2` carried
|
|
243
|
+
`__init__.__version__ = "0.2.1"` while `pyproject.toml` declared `0.2.2`. The artifact's name,
|
|
244
|
+
its metadata and its actual module behaviour disagreed, and every consumer who pinned `==0.2.2`
|
|
245
|
+
silently installed the older behaviour. The same drift class bit this repo twice: the published
|
|
246
|
+
npm tarball's User-Agent string said `0.3.0` while `package.json` said `0.3.1`, and
|
|
247
|
+
`package-lock.json`'s root version sat at `0.1.0` through two releases. Version truth must AGREE
|
|
248
|
+
across every source **inside the artifact** before anything ships — so the publish-stage artifact
|
|
249
|
+
scanners cross-check those sources statically. No second source of truth outside the artifact is
|
|
250
|
+
consulted: no registry, no git tag.
|
|
251
|
+
|
|
252
|
+
| Rule | Severity | Fires when |
|
|
253
|
+
| --- | --- | --- |
|
|
254
|
+
| `release-coherence-version-drift` | CRITICAL | two version sources packed in one artifact disagree: `package.json` vs a force-packed `package-lock.json` root or `packages[""]` (npm); wheel filename vs `*-*.dist-info` directory vs `METADATA Version:` vs a literal `__version__` in any `__init__.py`; sdist `pyproject.toml [project].version` vs `PKG-INFO` / `setup.cfg` / `setup.py` string literals vs `__init__`; `Cargo.toml [package].version` vs the `Cargo.lock` entry when the lock is packed; `.gemspec` first-literal vs the built gem's `metadata.gz` name/version; `dist` filename vs the packed root `PKG-INFO` (sdist) |
|
|
255
|
+
| `release-coherence-unverifiable-source` | MEDIUM | a second version source is present but not statically parseable — a computed/dynamic `__version__`, a `setup.cfg` `attr:`/interpolation, a `dynamic = ["version"]` pyproject, a malformed lockfile, a missing `Cargo.lock` entry, an unreadable gemspec block. Fail-closed beats silent-clean: the gate refuses to call uncheckable "clean" |
|
|
256
|
+
| `release-coherence-twin-drift` | CRITICAL | opt-in `release.twin` pairs (see Configuration): a configured PyPI↔npm twin pair publishes different versions. This is the one cross-manager equality that is statically enforceable — both artifacts are already in `.border/dist/` in the same run |
|
|
257
|
+
|
|
258
|
+
The absent-source boundary is load-bearing: a missing `package-lock.json`/`Cargo.lock`/`PKG-INFO`
|
|
259
|
+
means nothing was packed — **not** that anything drifted — and no row is emitted. Publishing
|
|
260
|
+
gatekeepers that cry wolf train users to pass `--force`; these rules fire only on evidence inside
|
|
261
|
+
the shipped bytes. Like the residue rule table, this family's source folds into `rulesHash`, so
|
|
262
|
+
editing a matcher invalidates every cached PASS.
|
|
263
|
+
|
|
239
264
|
### Running `border roundtrip`
|
|
240
265
|
|
|
241
|
-
`border roundtrip <[ecosystem:]name@version>` fetches the real registry
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
266
|
+
`border roundtrip <[ecosystem:]name@version | local-artifact-file>` fetches the real registry
|
|
267
|
+
bytes — or, since W2.4(b), accepts a LOCAL artifact file (`border roundtrip
|
|
268
|
+
dist/foo-1.0.0-py3-none-any.whl`) — installs them in a throwaway Docker container
|
|
269
|
+
(per-ecosystem image, whole-filesystem content-hash manifest before install and after the
|
|
270
|
+
manager's own uninstall), prints the residue manifest, and records the verdict.
|
|
271
|
+
|
|
272
|
+
The local lane exists so unpublished/hardened artifacts can be proven **pre-publish**. Its rules
|
|
273
|
+
are deliberately narrow: the ecosystem comes from the extension alone, a closed table
|
|
274
|
+
(`.whl`→pypi, `.tgz`→npm, `.crate`→crates, `.gem`→rubygems); anything else exits 2, because
|
|
275
|
+
content-sniffing would grade an artifact through a guessed manager's install/uninstall contract.
|
|
276
|
+
Local mode touches no network: the file passes one streaming sha256 pass (the 200 MiB cap fails
|
|
277
|
+
closed mid-stream), the exact bytes are staged and `docker cp`'d, and every later leg — closure
|
|
278
|
+
resolution, m1/m2/m3, residue rows, ledger proof — runs the identical registry pipeline.
|
|
279
|
+
Provenance stays honest: human and `--json` output stamp `source: "local:<abs-path>"` plus the
|
|
280
|
+
`artifactSha256` — the digest is the identity, and the requireProof valve consumes a local proof
|
|
281
|
+
by the same digest it uses for a fetched one. A directory argument, or a path-shaped argument
|
|
282
|
+
that does not resolve to an existing file, exits 2 before anything is fetched.
|
|
283
|
+
|
|
284
|
+
Wheel-lane boundary, stated honestly: the LOCAL PyPI-wheel lane is residue-INERT by
|
|
285
|
+
construction — pip fully records what it installs and prunes its own directories, so even a
|
|
286
|
+
wheel planted with hostile `.data/data/...` entries leaves a zero survivor set after uninstall
|
|
287
|
+
(verified live on pip 25.0.1 AND 24.0, 2026-09-11, verifier harness); a PASS on such a plant is
|
|
288
|
+
pip's contract working, not a missed detection. The local wheel roundtrip still proves
|
|
289
|
+
installability, benign-clean, and exact-bytes identity — but planted-detection demos belong to
|
|
290
|
+
the lanes whose managers execute install hooks: npm (`.tgz` postinstall EXECUTES under
|
|
291
|
+
`npm install -g --foreground-scripts`), gem (extconf custom writes) and crates (build.rs).
|
|
292
|
+
|
|
293
|
+
Known fidelity envelope, from the W2.0 spike and the W2.3 registrar-chain demo:
|
|
245
294
|
|
|
246
295
|
- Docker is required; absence or any step failure ⇒ exit 2, never a silent `clean`.
|
|
247
|
-
-
|
|
248
|
-
|
|
249
|
-
|
|
296
|
+
- A local run proves its own bytes and says so: `source`/`artifactSha256` in the report and the
|
|
297
|
+
`t:"roundtrip"` ledger record all key on the streamed digest of the file given — never phrased
|
|
298
|
+
as registry-verified.
|
|
299
|
+
- npm/gem/crates lanes diff exactly; the pypi lane calibrates pip's left-behind **transitive
|
|
300
|
+
dependencies** (W2.4): before install, `pip install --dry-run --report` resolves the closure in a
|
|
301
|
+
throwaway resolver container, and post-uninstall rows still claimed by a still-installed closure
|
|
302
|
+
dist demote to LOW `residue-roundtrip-dep-owned` — non-blocking, owner named, still printed.
|
|
303
|
+
A claim only counts from a legitimate claimant: its dist-info must match the resolver's
|
|
304
|
+
name-version pin exactly, be canonically named and the unique dist-info for that name, and may
|
|
305
|
+
only claim paths inside its own site-packages root — a shadow or forged `X-Y.dist-info` planted
|
|
306
|
+
by install code launders nothing. What the demotion cannot see stays blocking, deliberately:
|
|
307
|
+
dependency writes outside their own RECORD, paths ambiguously claimed by two dists, every MODIFIED
|
|
308
|
+
row (the W2.3 founding run's genuine `/usr/local/share/aihr` orphan is exactly this shape), and if the
|
|
309
|
+
closure itself cannot be resolved the run exits 2 — cannot-verify is never clean.
|
|
250
310
|
- Only manager-lifecycle surfaces are observed: persistence performed by explicitly-invoked
|
|
251
311
|
bins (not npm hooks) is outside what the roundtrip watches — W2.3 measured exactly this
|
|
252
312
|
blind spot on our own registrar and documented its `{"plugin": []}` config residue.
|
|
@@ -510,6 +570,8 @@ engines:
|
|
|
510
570
|
require: [gitleaks, secretlint] # trufflehog: true adds the third-party engine
|
|
511
571
|
residue:
|
|
512
572
|
enabled: true # default; false hides ONLY the residue-* rows
|
|
573
|
+
release:
|
|
574
|
+
twin: [] # opt-in [{pypi, npm}] pairs — see Release coherence
|
|
513
575
|
```
|
|
514
576
|
|
|
515
577
|
Notes that change behavior:
|
|
@@ -613,6 +675,25 @@ same one that wrote the commit) how to run the five subcommands, how to produce
|
|
|
613
675
|
|
|
614
676
|
## Changelog
|
|
615
677
|
|
|
678
|
+
### 0.4.1 (2026-09-11)
|
|
679
|
+
- Add: release-coherence rule family — every version source inside a published artifact
|
|
680
|
+
must agree (pyproject vs MODULE vs filename, gemspec vs gem metadata, Cargo.toml vs
|
|
681
|
+
crate filename, package.json self-consistency; the git-tag half is a publish-workflow
|
|
682
|
+
hard gate). Trigger: the aihr incident where a wheel labeled 0.2.2 shipped modules
|
|
683
|
+
self-reporting 0.2.1 — every pinned consumer silently got old behavior.
|
|
684
|
+
- Add: `border roundtrip` local-artifact input — an argument resolving to an existing
|
|
685
|
+
file (.whl/.tgz/.crate/.gem, closed table) proves unpublished bytes end-to-end; the
|
|
686
|
+
streaming sha256 of those exact bytes is the ledger identity and the report carries
|
|
687
|
+
`source:local:<abs>` provenance. Honest boundary documented: the local PyPI-wheel lane
|
|
688
|
+
is residue-INERT by construction (pip fully tracks and prunes; verified live on pip
|
|
689
|
+
24.0 AND 25.0.1) — planted-detection demos belong to the hook-executing lanes
|
|
690
|
+
(npm postinstall, gem extconf, crates build.rs).
|
|
691
|
+
- Change: pypi roundtrip lane dep-closure calibration — pip's resolver report demotes
|
|
692
|
+
dependency-storm files to LOW `dep-owned` rows, so genuine orphans stand alone; the
|
|
693
|
+
demotion requires a legitimate claimant dist-info (pin Name+Version match, canonical
|
|
694
|
+
dirname, unique, own site-packages root, not the target) after an independent verifier
|
|
695
|
+
proved a shadow `*-9.9.9.dist-info` could otherwise launder hostile writes.
|
|
696
|
+
|
|
616
697
|
### 0.4.0 (2026-09-10)
|
|
617
698
|
- Add: `border roundtrip <[ecosystem:]name@version>` — fact-proof valve. Installs the
|
|
618
699
|
package inside a throwaway Docker container, content-hashes the whole filesystem before
|
|
@@ -683,11 +764,30 @@ MIT, see [LICENSE](LICENSE).
|
|
|
683
764
|
border 是一个 fail-closed(失败即拦截)的推送前门禁 CLI:`npm install -g border-customs` 安装,在仓库里跑 `border check`。它扫描 git 历史、工作区(未跟踪文件同样是一等输入)、归档、tag 注释和将要发布的 npm/PyPI/crates/RubyGems 字节,检出密钥与供应链风险;只有当"当前状态指纹"存在新鲜且完整的 PASS 记录时才允许 `border push --yes` 放行。指纹是 sha256(head、porcelain 摘要、规则哈希、暴露面、ref 集合、有效目标)六元组,任何一处变动,旧的 PASS 立即失效,必须重查。流水线:gitleaks(历史+工作区+tag,内置 8.30.1 规则,仓库自带的 ignore 文件直接判 CRITICAL)+ secretlint(进程内,AWS Key 规则强制开启)+ 原生规则(AI 会话产物闭集、提交身份白名单含传输对象检查)+ 注册表预检(版本已存在=必须 bump,名称被外人占有=拒绝;空响应/超时/解析失败一律 exit 2,沉默绝不等于不存在)。构件只构建一次进 `.border/dist/`,扫描的就是发布的字节,发布时再哈希比对,不一致直接拒发。跳过台账让重复检查不到 1 秒,但回放前先重算指纹并重新 pack 验证新鲜度。报告只输出掩码片段(sha256 摘要 + 前4…后4)。push 是多目标状态机,多 remote 先做全有或全无的 fast-forward 预检,永不 force-push;npm/twine/cargo/gem 的凭据经 stdio 透传,border 从不触碰。0.2.0 新增 crates.io 与 RubyGems 通道(公开 crates.io 固定、rubygems 可用 host 覆盖私有镜像),既有配置行为不变。0.3.0 新增残留扫描(residue gate):发布字节里的安装期钩子按 T0-T4 闭集签名表分类(闭集 T1 树内钩子降为 MEDIUM,未知形态原样保留 CRITICAL),外加跨包管理器写入、配对标记缺失(`# BEGIN` 块只检测不豁免)与 gem 不可解析扩展共七条 `residue-*` 规则;`residue.enabled` 是唯一豁免开关且改动指纹使旧 PASS 失效,规则表与分类器源码进入 rulesHash,改一个签名即强制重查。静态分析只证明"能力"不证明"事实",border 不是恶意软件沙箱,从不执行被读代码。0.4.0 新增证明阀
|
|
684
765
|
`residue.requireProof`(默认关,strict):开启后,发布构件若触发阻断级 `residue-*` 发现,通道 PASS
|
|
685
766
|
还要求账本里存在按其 sha256 索引且 rulesHash 新鲜的 `border roundtrip` 实测记录——check 自身从不
|
|
686
|
-
运行 Docker,证据由 `border roundtrip` 离线写入(默认记账,`--no-record` 关闭),clean 与 residue
|
|
767
|
+
运行 Docker,证据由 `border roundtrip`(注册表 spec 或本地构件文件,W2.4b)离线写入(默认记账,`--no-record` 关闭),clean 与 residue
|
|
687
768
|
两种裁决都算"事实已在";缺记录判 `roundtrip-proof-missing`、rulesHash 过期判
|
|
688
769
|
`roundtrip-proof-stale`(均 CRITICAL/native,与普通发现同受白名单管辖并在 allowHits 枚举),翻转
|
|
689
770
|
该配置即轮换 rulesHash,所有缓存 PASS 自动失效。`border roundtrip` 本身保真边界:Docker 必需、
|
|
690
|
-
缺失即 exit 2
|
|
691
|
-
|
|
771
|
+
缺失即 exit 2 绝不假装干净;本地构件输入(W2.4b)——参数解析为已存在的文件即走本地模式,生态仅由扩展名闭表判定
|
|
772
|
+
(.whl→pypi、.tgz→npm、.crate→crates、.gem→rubygems),表外一律 exit 2:内容嗅探等于猜测某个管理器的装卸契约,
|
|
773
|
+
按猜测定级不予采纳;本地模式全程不触网,文件流式过一遍 sha256(200 MiB 上限在流内超限即闭),同一批字节暂存后
|
|
774
|
+
docker cp,闭包解析、m1/m2/m3、残留行与账本证明走的都是与注册表模式完全相同的流水线;出处保持诚实:人机与
|
|
775
|
+
--json 输出都盖上 `source: "local:<绝对路径>"` 与 artifactSha256——摘要即身份,requireProof 阀按同一摘要原样
|
|
776
|
+
消费本地证明,本地证明绝不表述为注册表已验证;目录参数、或形如路径却并不存在的参数,在任何拉取发生之前即 exit 2;
|
|
777
|
+
wheel 通道诚实边界:本地 PyPI-wheel 通道天然残留惰性(residue-INERT)——pip 完整登记所装文件并清理自建目录,
|
|
778
|
+
即便往 wheel 里种入恶意 `.data/data/...` 条目,卸载后幸存集也为空(2026-09-11 在 pip 25.0.1 与 24.0 上实机验证,
|
|
779
|
+
验证器工装);对这种种植判 PASS 是 pip 契约在起作用,不是漏检。本地 wheel 往返仍证明可安装性、benign-clean
|
|
780
|
+
与字节级同一性,但种植检测演示属于会执行安装钩子的通道:npm(.tgz 的 postinstall 在 `npm install -g
|
|
781
|
+
--foreground-scripts` 下真实执行)、gem(extconf 自定义写入)与 crates(build.rs);
|
|
782
|
+
pypi 通道已做目标收窄校准(W2.4)——安装前先在一次性解析容器里用
|
|
783
|
+
`pip install --dry-run --report` 解析依赖闭包,卸载后仍被闭包内在册依赖认领的残留行降级为 LOW
|
|
784
|
+
`residue-roundtrip-dep-owned`(非阻断,注明归属依赖,仍照常打印);认领只对合法主张者生效:其 dist-info 必须与
|
|
785
|
+
解析器的 name-version 钉值完全一致、目录名规范且为该名字唯一发行版,且只能认领自身 site-packages 根内的路径——
|
|
786
|
+
安装代码私设的伪影 shadow dist-info 洗白不了任何残留;分类器看不见的刻意继续阻断:
|
|
787
|
+
依赖写在自身 RECORD 之外的路径、被两个发行包同时认领的歧义路径、一切 MODIFIED 行(W2.3 首轮那条真实孤儿
|
|
788
|
+
`/usr/local/share/aihr` 正是此类),闭包解析失败则整轮 exit 2,不可验证绝不等于干净;
|
|
789
|
+
只有包管理器生命周期触发的写入被观测,显式调用的 bin 自写配置不在射程——W2.3 在
|
|
692
790
|
自家 registrar 链上实测到该盲区并留下了 `{"plugin": []}` 残留证据。clean 是"某一版本构件在某类
|
|
693
791
|
机器上"的事实证明,不是安全担保。可选 LLM 层 border 自身从不调用模型 API:`llm-request` 导出掩码审阅包,`llm-ingest` 严格校验 agent 结论并重算裁决。退出码即合同:0 通过、1 拦截、2 门禁无法作答,任何"工具不健康"都不可能被误读为干净。MIT 许可,无遥测,除你配置的注册表预检外不联网。
|
|
792
|
+
|
|
793
|
+
发布一致性(0.4.1):起因是 aihr 事故——PyPI 上标为 0.2.2 的 wheel 里 `__init__.__version__` 却写着 0.2.1,构件名、元数据与实际模块行为三者不一致,所有 `==0.2.2` 的用户静默装上了旧行为;同类漂移在本仓库也出现过两次(npm tarball 的 User-Agent 停在 0.3.0 而 package.json 是 0.3.1;package-lock.json 根版本两个 release 一直躺在 0.1.0)。发布阶段扫描器因此在构件内部逐源交叉核对版本号:`release-coherence-version-drift` 为 CRITICAL(package.json 与被强制打包的 package-lock、wheel 文件名对 dist-info 目录对 METADATA `Version:` 对 `__init__` 字面量、sdist 的 pyproject 对 PKG-INFO/setup 系字面量、Cargo.toml 对已打包的 Cargo.lock、.gemspec 对 metadata.gz、dist 文件名对 sdist 内 PKG-INFO);版本源存在但无法静态解析(动态 `__version__`、`attr:`、`dynamic = ["version"]`、坏 lockfile)判 MEDIUM `release-coherence-unverifiable-source`——不可核查绝不静默算干净;第二版本源根本没被打包时不发现在内,缺源不是漂移,这条边界是承重的,误报的门禁会把用户训练成 `--force`。唯一可静态强制的跨包管理器声明是 opt-in `release.twin`(`{pypi, npm}` 严格 zod 对列表,未知键 exit 2):同一 run 的 dist 里孪生版本不等 ⇒ CRITICAL `release-coherence-twin-drift`,消息点名两个构件。规则源文件与 residue 指纹表一样并入 rulesHash,改一个匹配器即令全部缓存 PASS 失效。
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// provenance: border-inspect-roadmap.md W4.1/W4.2 — the release-coherence rule family.
|
|
2
|
+
//
|
|
3
|
+
// FOUNDING CASE (aihr incident): a wheel labeled version 0.2.2 on PyPI shipped a package whose
|
|
4
|
+
// `__init__.__version__` said 0.2.1 (pyproject itself said 0.2.2) — users installed "0.2.2"
|
|
5
|
+
// bytes carrying older module behavior. A second drift class bit border directly: the published
|
|
6
|
+
// npm tarball's User-Agent said 0.3.0 while package.json said 0.3.1, and package-lock.json's
|
|
7
|
+
// root version sat at 0.1.0 through two releases. Version truth must AGREE across every source
|
|
8
|
+
// packed INSIDE the artifact before anything ships. This module is the closed-table analyzers;
|
|
9
|
+
// the ecosystem stages (artifacts/{npm,pypi,crates,rubygems}.ts) route their extracted trees
|
|
10
|
+
// here and the check pipeline routes release.twin pairs after the channel loop (src/check.ts).
|
|
11
|
+
//
|
|
12
|
+
// ABSENT-SOURCE BOUNDARY (load-bearing, documented here per plan W4.1): an absent SECOND
|
|
13
|
+
// version source is NOT drift and NEVER a finding. package-lock.json / Cargo.lock / PKG-INFO
|
|
14
|
+
// are usually not packed at all (npm `files` whitelists, cargo default ignores) — a rule that
|
|
15
|
+
// fired on "not packed" would false-green-block honest releases and train users to pass
|
|
16
|
+
// --force, which is exactly how gate credibility dies. Conversely, a source that IS present
|
|
17
|
+
// but statically unreadable (dynamic `__version__`, malformed lock) is 'cannot-verify' — it
|
|
18
|
+
// emits release-coherence-unverifiable-source MEDIUM and is NEVER silently clean.
|
|
19
|
+
//
|
|
20
|
+
// Doctrine (mirrors rules/residueMatchers.ts + artifacts/manifestDiff.ts):
|
|
21
|
+
// * regex/text extraction ONLY — no python/ruby/toml parsing, nothing is ever executed;
|
|
22
|
+
// * every rule id in RELEASE_COHERENCE_SEVERITIES is closed-table (no user-configurable
|
|
23
|
+
// patterns); severities fixed: drift CRITICAL, twin-drift CRITICAL, unverifiable MEDIUM;
|
|
24
|
+
// * findings flow through the native pipeline (residueFindings-style digest keying, then
|
|
25
|
+
// applyAllowList/report as for every other rule);
|
|
26
|
+
// * this file rides computeCheckRulesHash's fingerprint (RELEASE_FINGERPRINT_SOURCES in
|
|
27
|
+
// src/check/rulesHash.ts) so any edit here rotates rulesHash and invalidates proofs,
|
|
28
|
+
// exactly like the residue sources.
|
|
29
|
+
import { spawnSync } from "node:child_process";
|
|
30
|
+
import { basename, join } from "node:path";
|
|
31
|
+
|
|
32
|
+
import { type Finding, type Severity } from "../findings.ts";
|
|
33
|
+
import { type LedgerArtifact } from "../ledger/records.ts";
|
|
34
|
+
import { redact, type TextSanitizer } from "../redact.ts";
|
|
35
|
+
|
|
36
|
+
export const RELEASE_COHERENCE_DRIFT_RULE = "release-coherence-version-drift";
|
|
37
|
+
export const RELEASE_COHERENCE_TWIN_RULE = "release-coherence-twin-drift";
|
|
38
|
+
export const RELEASE_COHERENCE_UNVERIFIABLE_RULE = "release-coherence-unverifiable-source";
|
|
39
|
+
|
|
40
|
+
/** Closed severity table — the ONLY rule ids this family may emit. */
|
|
41
|
+
export const RELEASE_COHERENCE_SEVERITIES: Readonly<Record<string, Severity>> = {
|
|
42
|
+
[RELEASE_COHERENCE_DRIFT_RULE]: "CRITICAL",
|
|
43
|
+
[RELEASE_COHERENCE_TWIN_RULE]: "CRITICAL",
|
|
44
|
+
[RELEASE_COHERENCE_UNVERIFIABLE_RULE]: "MEDIUM",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const TARGET = "artifact";
|
|
48
|
+
const ENGINE = "native";
|
|
49
|
+
const FOUNDING = "version truth must AGREE across every source packed in the artifact (aihr: a wheel named 0.2.2 shipped __version__ 0.2.1)";
|
|
50
|
+
|
|
51
|
+
// --------------------------------------------------------------- source probes (pure text)
|
|
52
|
+
|
|
53
|
+
type Probe = { readonly status: "literal"; readonly value: string } | { readonly status: "dynamic"; readonly reason: string } | null;
|
|
54
|
+
|
|
55
|
+
/** `__version__ = "X"` literal on its own line; ANY other assignment/import shape ⇒ dynamic. */
|
|
56
|
+
export function probeDunderVersion(text: string): Probe {
|
|
57
|
+
for (const line of text.split(/\r?\n/)) {
|
|
58
|
+
const assign = /^\s*__version__\s*=\s*(.+?)\s*$/.exec(line);
|
|
59
|
+
if (assign?.[1] !== undefined) {
|
|
60
|
+
const lit = /^(['"])((?:(?!\1).)+)\1(?:\s*#.*)?$/.exec(assign[1]);
|
|
61
|
+
return lit?.[2] !== undefined ? { status: "literal", value: lit[2] } : { status: "dynamic", reason: `__version__ assigned a non-literal (${assign[1].slice(0, 60)})` };
|
|
62
|
+
}
|
|
63
|
+
if (/^\s*(?:from\s+\S+\s+)?import\b.*\b__version__\b/.test(line)) {
|
|
64
|
+
return { status: "dynamic", reason: "__version__ imported from another module" };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Core metadata `Version:` header (PKG-INFO / wheel METADATA). */
|
|
71
|
+
export function probeHeaderVersion(text: string): Probe {
|
|
72
|
+
const m = /^Version:[ \t]*(.*?)[ \t]*$/m.exec(text);
|
|
73
|
+
if (m === null) return null;
|
|
74
|
+
return m[1] !== undefined && m[1] !== "" ? { status: "literal", value: m[1] } : { status: "dynamic", reason: "Version: header present but empty" };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** pyproject `[project].version` literal; [project] absent ⇒ null (legacy setup.* project). */
|
|
78
|
+
export function probePyprojectVersion(text: string): Probe {
|
|
79
|
+
const lines = text.split(/\r?\n/);
|
|
80
|
+
let inProject = false;
|
|
81
|
+
const slice: string[] = [];
|
|
82
|
+
for (const line of lines) {
|
|
83
|
+
if (/^\s*\[/.test(line)) {
|
|
84
|
+
if (inProject) break;
|
|
85
|
+
inProject = /^\s*\[project\]\s*$/.test(line);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (inProject) slice.push(line);
|
|
89
|
+
}
|
|
90
|
+
if (!inProject && slice.length === 0 && !text.includes("[project]")) return null;
|
|
91
|
+
const body = slice.join("\n");
|
|
92
|
+
const lit = /^\s*version\s*=\s*(['"])((?:(?!\1).)+)\1\s*(?:#.*)?$/m.exec(body);
|
|
93
|
+
if (lit?.[2] !== undefined) return { status: "literal", value: lit[2] };
|
|
94
|
+
const dyn = /dynamic\s*=\s*\[([\s\S]*?)\]/.exec(`${body}\n]`);
|
|
95
|
+
if (dyn?.[1] !== undefined && /["']version["']/.test(dyn[1])) return { status: "dynamic", reason: 'project.version listed in dynamic = ["version"]' };
|
|
96
|
+
return { status: "dynamic", reason: "[project] declares no statically-readable version" };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** setup.cfg `[metadata] version =` — bare scalars literal, attr:/interp ⇒ dynamic. */
|
|
100
|
+
export function probeSetupCfgVersion(text: string): Probe {
|
|
101
|
+
// line-slice the [metadata] section (a lazy [\s\S] regex + /m $ would stop at the first
|
|
102
|
+
// newline and silently lose every later key — classic multiline-anchor trap)
|
|
103
|
+
const lines = text.split(/\r?\n/);
|
|
104
|
+
let inSection = false;
|
|
105
|
+
const slice: string[] = [];
|
|
106
|
+
for (const line of lines) {
|
|
107
|
+
if (/^\s*\[/.test(line)) {
|
|
108
|
+
if (inSection) break;
|
|
109
|
+
inSection = /^\s*\[metadata\]\s*$/.test(line);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (inSection) slice.push(line);
|
|
113
|
+
}
|
|
114
|
+
const v = /^[ \t]*version[ \t]*=[ \t]*(.+?)[ \t]*$/m.exec(slice.join("\n"));
|
|
115
|
+
if (v?.[1] === undefined) return null;
|
|
116
|
+
return /^[\w.+-]+$/.test(v[1]) ? { status: "literal", value: v[1] } : { status: "dynamic", reason: `setup.cfg version = '${v[1].slice(0, 40)}' is not a bare literal` };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** setup.py `version="X"` string-literal kwarg ONLY (never executed). */
|
|
120
|
+
export function probeSetupPyVersion(text: string): Probe {
|
|
121
|
+
const lit = /(?:^|[,(]\s*)version\s*=\s*(['"])((?:(?!\1).)+)\1/.exec(text);
|
|
122
|
+
if (lit?.[2] !== undefined) return { status: "literal", value: lit[2] };
|
|
123
|
+
return /(?:^|[,(]\s*)version\s*=/.test(text) ? { status: "dynamic", reason: "setup.py version= is not a string literal" } : null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Wheel filename `name-version-py-abi-plat.whl` ⇒ version segment (legacy dash form). */
|
|
127
|
+
export function parseWheelFilenameVersion(fileName: string): { name: string; version: string } | null {
|
|
128
|
+
const base = basename(fileName).replace(/\.whl$/i, "");
|
|
129
|
+
const parts = base.split("-");
|
|
130
|
+
if (parts.length < 5) return null;
|
|
131
|
+
const name = parts[0] as string;
|
|
132
|
+
const version = parts[1] as string;
|
|
133
|
+
return name !== "" && version !== "" ? { name, version } : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** `pkg-0.2.2.dist-info` directory segment ⇒ version (text after the last dash). */
|
|
137
|
+
export function parseDistInfoDirVersion(relPath: string): string | null {
|
|
138
|
+
const dir = /(?:^|\/)([^/]+\.dist-info)\//.exec(relPath)?.[1];
|
|
139
|
+
if (dir === undefined) return null;
|
|
140
|
+
const stem = dir.slice(0, -".dist-info".length);
|
|
141
|
+
const idx = stem.lastIndexOf("-");
|
|
142
|
+
return idx >= 0 ? stem.slice(idx + 1) : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** PEP 503 name canonicalization for cross-artifact matching. */
|
|
146
|
+
const normName = (s: string): string => s.toLowerCase().replace(/[-_.]+/g, "_");
|
|
147
|
+
|
|
148
|
+
// --------------------------------------------------------------- hit → Finding plumbing
|
|
149
|
+
|
|
150
|
+
type Hit = { readonly rule: string; readonly message: string; readonly path: string; readonly disc: string };
|
|
151
|
+
|
|
152
|
+
function toFindings(hits: readonly Hit[], identity: string, sanitizer?: TextSanitizer): Finding[] {
|
|
153
|
+
return hits.map((hit) => {
|
|
154
|
+
// digest key mirrors residueFindings: identity:family:rule:disc keeps two independent
|
|
155
|
+
// drift rows on one (rule, file) pair separately allow-listable.
|
|
156
|
+
const { valueDigest, snippet } = redact(`${identity}:release-coherence:${hit.rule}:${hit.disc}`);
|
|
157
|
+
return {
|
|
158
|
+
rule: hit.rule,
|
|
159
|
+
severity: RELEASE_COHERENCE_SEVERITIES[hit.rule] as Severity,
|
|
160
|
+
target: TARGET,
|
|
161
|
+
path: hit.path,
|
|
162
|
+
engine: ENGINE,
|
|
163
|
+
message: sanitizer?.sanitize(hit.message) ?? hit.message,
|
|
164
|
+
valueDigest,
|
|
165
|
+
snippet,
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** One collected version declaration inside an artifact. */
|
|
171
|
+
type Src = { readonly label: string; readonly path: string; readonly probe: Probe };
|
|
172
|
+
|
|
173
|
+
/** Primary = first LITERAL source in ecosystem priority order; every other literal is compared
|
|
174
|
+
* to it (one CRITICAL per disagreeing source); every dynamic source ⇒ one MEDIUM row. */
|
|
175
|
+
function analyze(sources: readonly Src[], pathPrefix: string, hits: Hit[]): void {
|
|
176
|
+
const primary = sources.find((s) => s.probe?.status === "literal");
|
|
177
|
+
for (const s of sources) {
|
|
178
|
+
if (s.probe === null) continue;
|
|
179
|
+
if (s.probe.status === "dynamic") {
|
|
180
|
+
hits.push({
|
|
181
|
+
rule: RELEASE_COHERENCE_UNVERIFIABLE_RULE,
|
|
182
|
+
message: `release coherence: ${s.label} (${s.path}) carries a version that cannot be verified statically — ${s.probe.reason}; surfaced, never silent-clean`,
|
|
183
|
+
path: `${pathPrefix}${s.path}`,
|
|
184
|
+
disc: `${s.path}:unverifiable`,
|
|
185
|
+
});
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (primary === undefined || s === primary) continue;
|
|
189
|
+
if (s.probe.value !== (primary.probe as { value: string }).value) {
|
|
190
|
+
hits.push({
|
|
191
|
+
rule: RELEASE_COHERENCE_DRIFT_RULE,
|
|
192
|
+
message: `release coherence: ${primary.label} '${(primary.probe as { value: string }).value}' disagrees with ${s.label} '${s.probe.value}' — ${FOUNDING}`,
|
|
193
|
+
path: `${pathPrefix}${s.path}`,
|
|
194
|
+
disc: `${s.path}:vs:${primary.label}`,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// --------------------------------------------------------------- npm
|
|
201
|
+
|
|
202
|
+
/** package.json vs package-lock.json root + packages[""] — ONLY when the lock is packed.
|
|
203
|
+
* lockText null ⇒ absent source ⇒ zero rows (see boundary note at the header). */
|
|
204
|
+
export function npmPackCoherenceFindings(o: {
|
|
205
|
+
packedVersion: string | null;
|
|
206
|
+
lockText: string | null;
|
|
207
|
+
lockRelPath: string;
|
|
208
|
+
identity: string;
|
|
209
|
+
sanitizer?: TextSanitizer;
|
|
210
|
+
}): Finding[] {
|
|
211
|
+
if (o.lockText === null || o.packedVersion === null) return [];
|
|
212
|
+
let lock: Record<string, unknown>;
|
|
213
|
+
try {
|
|
214
|
+
lock = JSON.parse(o.lockText) as Record<string, unknown>;
|
|
215
|
+
} catch {
|
|
216
|
+
return toFindings([{ rule: RELEASE_COHERENCE_UNVERIFIABLE_RULE, message: `release coherence: ${o.lockRelPath} is packed but not parseable JSON — cannot verify, never silent-clean`, path: o.lockRelPath, disc: "malformed" }], o.identity, o.sanitizer);
|
|
217
|
+
}
|
|
218
|
+
const hits: Hit[] = [];
|
|
219
|
+
const rootVer = typeof lock["version"] === "string" ? (lock["version"] as string) : null;
|
|
220
|
+
const pkgs = lock["packages"];
|
|
221
|
+
const rootPkg = typeof pkgs === "object" && pkgs !== null ? (pkgs as Record<string, unknown>)[""] : undefined;
|
|
222
|
+
const pkgEmptyVer = typeof rootPkg === "object" && rootPkg !== null ? ((rootPkg as Record<string, unknown>)["version"]) : null;
|
|
223
|
+
const cmp = (label: string, value: string | null, disc: string): void => {
|
|
224
|
+
if (value !== null && value !== o.packedVersion) {
|
|
225
|
+
hits.push({ rule: RELEASE_COHERENCE_DRIFT_RULE, message: `release coherence: packed package.json version '${String(o.packedVersion)}' disagrees with ${label} '${value}' — ${FOUNDING}`, path: o.lockRelPath, disc });
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
cmp("package-lock.json root version", rootVer, "root");
|
|
229
|
+
cmp('package-lock.json packages[""] version', typeof pkgEmptyVer === "string" ? pkgEmptyVer : null, "packages-empty");
|
|
230
|
+
return toFindings(hits, o.identity, o.sanitizer);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// --------------------------------------------------------------- pypi (wheel + sdist)
|
|
234
|
+
|
|
235
|
+
export function pypiArtifactCoherenceFindings(o: {
|
|
236
|
+
kind: "wheel" | "sdist";
|
|
237
|
+
archiveName: string;
|
|
238
|
+
files: readonly string[];
|
|
239
|
+
read: (rel: string) => string | null;
|
|
240
|
+
identity: string;
|
|
241
|
+
sanitizer?: TextSanitizer;
|
|
242
|
+
}): Finding[] {
|
|
243
|
+
const sources: Src[] = [];
|
|
244
|
+
const inTree = (rel: string): boolean => o.files.includes(rel);
|
|
245
|
+
const textOf = (rel: string): string | null => (inTree(rel) ? o.read(rel) : null);
|
|
246
|
+
|
|
247
|
+
if (o.kind === "wheel") {
|
|
248
|
+
const metaRel = o.files.find((f) => /\.dist-info\/METADATA$/.test(f));
|
|
249
|
+
if (metaRel !== undefined) {
|
|
250
|
+
const body = o.read(metaRel);
|
|
251
|
+
sources.push({ label: "wheel METADATA Version:", path: metaRel, probe: body === null ? null : probeHeaderVersion(body) ?? { status: "dynamic", reason: "METADATA has no Version: header" } });
|
|
252
|
+
const dirVer = parseDistInfoDirVersion(metaRel);
|
|
253
|
+
if (dirVer !== null) sources.push({ label: "wheel .dist-info directory name", path: metaRel, probe: { status: "literal", value: dirVer } });
|
|
254
|
+
}
|
|
255
|
+
const fn = parseWheelFilenameVersion(o.archiveName);
|
|
256
|
+
if (fn !== null) sources.push({ label: "wheel filename", path: metaRel ?? o.archiveName, probe: { status: "literal", value: fn.version } });
|
|
257
|
+
} else {
|
|
258
|
+
const pyproject = textOf("pyproject.toml");
|
|
259
|
+
if (pyproject !== null) sources.push({ label: "pyproject [project].version", path: "pyproject.toml", probe: probePyprojectVersion(pyproject) });
|
|
260
|
+
const cfg = textOf("setup.cfg");
|
|
261
|
+
if (cfg !== null) sources.push({ label: "setup.cfg [metadata] version", path: "setup.cfg", probe: probeSetupCfgVersion(cfg) });
|
|
262
|
+
const py = textOf("setup.py");
|
|
263
|
+
if (py !== null) sources.push({ label: "setup.py version literal", path: "setup.py", probe: probeSetupPyVersion(py) });
|
|
264
|
+
const pkgInfoRel = o.files.find((f) => f === "PKG-INFO" || f.endsWith(".egg-info/PKG-INFO"));
|
|
265
|
+
if (pkgInfoRel !== undefined) {
|
|
266
|
+
const body = o.read(pkgInfoRel);
|
|
267
|
+
if (body !== null) sources.push({ label: "PKG-INFO Version:", path: pkgInfoRel, probe: probeHeaderVersion(body) ?? { status: "dynamic", reason: "PKG-INFO has no Version: header" } });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
for (const initRel of o.files.filter((f) => /(?:^|\/)__init__\.py$/.test(f)).sort()) {
|
|
271
|
+
const body = o.read(initRel);
|
|
272
|
+
if (body === null) continue;
|
|
273
|
+
const probe = probeDunderVersion(body);
|
|
274
|
+
if (probe !== null) sources.push({ label: `__version__ literal in ${initRel}`, path: initRel, probe });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const hits: Hit[] = [];
|
|
278
|
+
analyze(sources, `${o.archiveName}!`, hits);
|
|
279
|
+
return toFindings(hits, o.identity, o.sanitizer);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// --------------------------------------------------------------- crates
|
|
283
|
+
|
|
284
|
+
export function cratesCoherenceFindings(o: {
|
|
285
|
+
name: string;
|
|
286
|
+
version: string;
|
|
287
|
+
lockText: string | null;
|
|
288
|
+
lockRelPath: string;
|
|
289
|
+
identity: string;
|
|
290
|
+
sanitizer?: TextSanitizer;
|
|
291
|
+
}): Finding[] {
|
|
292
|
+
if (o.lockText === null) return []; // Cargo.lock not packed ⇒ absent source, not drift.
|
|
293
|
+
const entries = o.lockText
|
|
294
|
+
.split(/^\[\[package\]\][ \t]*$/m)
|
|
295
|
+
.map((block) => ({ name: /^\s*name\s*=\s*"([^"]*)"/m.exec(block)?.[1], version: /^\s*version\s*=\s*"([^"]*)"/m.exec(block)?.[1] }))
|
|
296
|
+
.filter((e): e is { name: string; version: string } => e.name !== undefined && e.version !== undefined)
|
|
297
|
+
.filter((e) => e.name === o.name);
|
|
298
|
+
if (entries.length === 0) {
|
|
299
|
+
return toFindings([{ rule: RELEASE_COHERENCE_UNVERIFIABLE_RULE, message: `release coherence: ${o.lockRelPath} is packed but names no [[package]] '${o.name}' — cannot verify, never silent-clean`, path: o.lockRelPath, disc: "no-entry" }], o.identity, o.sanitizer);
|
|
300
|
+
}
|
|
301
|
+
const hits: Hit[] = [];
|
|
302
|
+
for (const v of [...new Set(entries.map((e) => e.version))].sort()) {
|
|
303
|
+
if (v !== o.version) {
|
|
304
|
+
hits.push({ rule: RELEASE_COHERENCE_DRIFT_RULE, message: `release coherence: Cargo.toml [package].version '${o.version}' disagrees with Cargo.lock entry '${o.name}' version '${v}' — ${FOUNDING}`, path: o.lockRelPath, disc: v });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return toFindings(hits, o.identity, o.sanitizer);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// --------------------------------------------------------------- rubygems
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The .gem FORMAT derives the registry name-version from the spec itself, so for a
|
|
314
|
+
* conventionally-built gem filename↔metadata drift looks vacuous — but it is REACHABLE
|
|
315
|
+
* through border's own identity seam: channels/gemspec.ts GEMSPEC_VERSION (no /m flag)
|
|
316
|
+
* takes the FIRST literal `s.version = "..."`, while Ruby evaluates assignments in order
|
|
317
|
+
* (the LAST wins). A gemspec with two literal s.version assignments therefore produces a
|
|
318
|
+
* border-guessed `.gem` filename disagreeing with metadata.gz — the shipped bytes' own
|
|
319
|
+
* claim. metadata.gz is authoritative; this closes exactly that divergence.
|
|
320
|
+
*/
|
|
321
|
+
export function gemCoherenceFindings(o: {
|
|
322
|
+
specName: string;
|
|
323
|
+
specVersion: string;
|
|
324
|
+
metadataText: string | null;
|
|
325
|
+
identity: string;
|
|
326
|
+
sanitizer?: TextSanitizer;
|
|
327
|
+
}): Finding[] {
|
|
328
|
+
if (o.metadataText === null) return []; // non-gem container layout ⇒ absent source, not drift.
|
|
329
|
+
const name = /^name:[ \t]*(\S+)[ \t]*$/m.exec(o.metadataText)?.[1];
|
|
330
|
+
const version = /^version:[ \t]*!ruby\/object:Gem::Version[ \t]*\n[ \t]+version:[ \t]*(\S+)[ \t]*$/m.exec(o.metadataText)?.[1];
|
|
331
|
+
if (name === undefined || version === undefined) {
|
|
332
|
+
return toFindings([{ rule: RELEASE_COHERENCE_UNVERIFIABLE_RULE, message: "release coherence: metadata.gz is present but its Gem::Specification name/version could not be read statically — cannot verify, never silent-clean", path: "metadata.gz", disc: "unparseable" }], o.identity, o.sanitizer);
|
|
333
|
+
}
|
|
334
|
+
const hits: Hit[] = [];
|
|
335
|
+
if (name !== o.specName) hits.push({ rule: RELEASE_COHERENCE_DRIFT_RULE, message: `release coherence: built gem '${o.identity}' names '${o.specName}' but metadata.gz Gem::Specification name is '${name}' — ${FOUNDING}`, path: "metadata.gz", disc: "name" });
|
|
336
|
+
if (version !== o.specVersion) hits.push({ rule: RELEASE_COHERENCE_DRIFT_RULE, message: `release coherence: built gem '${o.identity}' names version '${o.specVersion}' but metadata.gz Gem::Version is '${version}' — ${FOUNDING}`, path: "metadata.gz", disc: "version" });
|
|
337
|
+
return toFindings(hits, o.identity, o.sanitizer);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// --------------------------------------------------------------- release.twin (cross-manager)
|
|
341
|
+
|
|
342
|
+
export type TwinPair = { readonly pypi: string; readonly npm: string };
|
|
343
|
+
|
|
344
|
+
/** Read a packed tgz's package.json name+version straight from the STAGED BYTES (tar -xzO;
|
|
345
|
+
* no extraction, nothing executed). Returns null when the member/JSON is unreadable. */
|
|
346
|
+
function packedNpmIdentity(archiveAbs: string, env?: Readonly<Record<string, string | undefined>>): { name: string; version: string } | null {
|
|
347
|
+
const opts = { encoding: "utf8" as const, timeout: 30_000, ...(env !== undefined ? { env: env as NodeJS.ProcessEnv } : {}) };
|
|
348
|
+
const listing = spawnSync("tar", ["-tzf", archiveAbs], opts);
|
|
349
|
+
if (listing.status !== 0) return null;
|
|
350
|
+
const members = (listing.stdout ?? "").split("\n").filter((l) => /^[^/]+\/package\.json$/.test(l));
|
|
351
|
+
const member = members.includes("package/package.json") ? "package/package.json" : members.length === 1 ? members[0] : undefined;
|
|
352
|
+
if (member === undefined) return null;
|
|
353
|
+
const body = spawnSync("tar", ["-xzOf", archiveAbs, member], opts);
|
|
354
|
+
if (body.status !== 0) return null;
|
|
355
|
+
try {
|
|
356
|
+
const pkg = JSON.parse(body.stdout as string) as Record<string, unknown>;
|
|
357
|
+
return typeof pkg["name"] === "string" && typeof pkg["version"] === "string" ? { name: pkg["name"], version: pkg["version"] } : null;
|
|
358
|
+
} catch {
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* release.twin: the ONE place a cross-manager version claim is statically enforceable
|
|
365
|
+
* (plan W4.1) — when a project declares {pypi, npm} twins, the wheel's version and the npm
|
|
366
|
+
* twin's PACKED package.json version must be string-equal. Wheels are read by filename
|
|
367
|
+
* (rule-3 already reconciled filename↔METADATA↔__version__ in the same run); the npm side
|
|
368
|
+
* is read from the staged tarball bytes. Either side missing/unreadable ⇒ MEDIUM — a
|
|
369
|
+
* declared obligation that cannot be proven is never silent-clean.
|
|
370
|
+
*/
|
|
371
|
+
export async function twinCoherenceFindings(o: {
|
|
372
|
+
pairs: readonly TwinPair[];
|
|
373
|
+
artifacts: readonly LedgerArtifact[];
|
|
374
|
+
repoDir: string;
|
|
375
|
+
env?: Readonly<Record<string, string | undefined>>;
|
|
376
|
+
sanitizer?: TextSanitizer;
|
|
377
|
+
}): Promise<Finding[]> {
|
|
378
|
+
const wheels = o.artifacts.filter((a) => a.file.endsWith(".whl")).sort((a, b) => a.file.localeCompare(b.file));
|
|
379
|
+
const tgzs = o.artifacts.filter((a) => a.file.endsWith(".tgz")).sort((a, b) => a.file.localeCompare(b.file));
|
|
380
|
+
const hits: Hit[] = [];
|
|
381
|
+
for (const pair of o.pairs) {
|
|
382
|
+
const wh = wheels.find((a) => {
|
|
383
|
+
const parsed = parseWheelFilenameVersion(basename(a.file));
|
|
384
|
+
return parsed !== null && normName(parsed.name) === normName(pair.pypi);
|
|
385
|
+
});
|
|
386
|
+
if (wh === undefined) {
|
|
387
|
+
hits.push({ rule: RELEASE_COHERENCE_UNVERIFIABLE_RULE, message: `release coherence: release.twin pair '${pair.pypi}'/'${pair.npm}' — no staged wheel for '${pair.pypi}' in this run, so the declared cross-manager equality cannot be verified`, path: `${pair.pypi}(missing).whl`, disc: `twin:${pair.pypi}:no-wheel` });
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
const wheelVersion = (parseWheelFilenameVersion(basename(wh.file)) as { version: string }).version;
|
|
391
|
+
let npmSide: { file: string; version: string } | null = null;
|
|
392
|
+
let npmUnreadable = false;
|
|
393
|
+
for (const t of tgzs) {
|
|
394
|
+
const id = packedNpmIdentity(join(o.repoDir, t.file), o.env);
|
|
395
|
+
if (id === null) {
|
|
396
|
+
npmUnreadable = true;
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
if (id.name === pair.npm) {
|
|
400
|
+
npmSide = { file: t.file, version: id.version };
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
if (npmSide === null) {
|
|
405
|
+
hits.push({
|
|
406
|
+
rule: RELEASE_COHERENCE_UNVERIFIABLE_RULE,
|
|
407
|
+
message: `release coherence: release.twin pair '${pair.pypi}'/'${pair.npm}' — ${npmUnreadable ? `npm twin '${pair.npm}' is staged but its package.json could not be read from the tarball bytes` : `no staged npm artifact for twin '${pair.npm}' in this run`}, so the declared cross-manager equality cannot be verified`,
|
|
408
|
+
path: basename(wh.file),
|
|
409
|
+
disc: `twin:${pair.pypi}:no-npm`,
|
|
410
|
+
});
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (wheelVersion !== npmSide.version) {
|
|
414
|
+
hits.push({
|
|
415
|
+
rule: RELEASE_COHERENCE_TWIN_RULE,
|
|
416
|
+
message: `release coherence: release.twin pair '${pair.pypi}'/'${pair.npm}' — wheel '${basename(wh.file)}' says version '${wheelVersion}' but npm twin '${basename(npmSide.file)}' packed package.json says '${npmSide.version}' — ${FOUNDING}`,
|
|
417
|
+
path: basename(wh.file),
|
|
418
|
+
disc: `twin:${pair.pypi}:${basename(npmSide.file)}`,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return toFindings(hits, "release.twin", o.sanitizer);
|
|
423
|
+
}
|
|
@@ -24,6 +24,7 @@ import { BORDER_STATE_DIR, ensureStateDir } from "../check/lock.ts";
|
|
|
24
24
|
import { extractArchive, removeSandbox } from "./extract.ts";
|
|
25
25
|
import { residueFindings } from "./residue.ts";
|
|
26
26
|
import { residueCratesHits } from "./residueRust.ts";
|
|
27
|
+
import { cratesCoherenceFindings } from "../rules/releaseCoherence.ts";
|
|
27
28
|
import { scanTree } from "../engines/gitleaks.ts";
|
|
28
29
|
import { scanPaths, type SecretlintMode } from "../engines/secretlint.ts";
|
|
29
30
|
import { EngineMissingError, EngineRunError, type EngineOptions } from "../engines/support.ts";
|
|
@@ -214,6 +215,18 @@ export async function runCargoArtifactStage(o: CrateStageOptions): Promise<Crate
|
|
|
214
215
|
identity: crateName,
|
|
215
216
|
...(o.sanitizer !== undefined ? { sanitizer: o.sanitizer } : {}),
|
|
216
217
|
}));
|
|
218
|
+
// W4.1 release coherence: Cargo.toml [package].version ↔ the packed Cargo.lock's
|
|
219
|
+
// [[package]] entry for this crate. cargo only ships Cargo.lock when packed —
|
|
220
|
+
// ABSENT-SOURCE BOUNDARY: unpacked lock is not drift, never a finding.
|
|
221
|
+
const lockRel = `${identity.name}-${identity.version}/Cargo.lock`;
|
|
222
|
+
findings.push(...cratesCoherenceFindings({
|
|
223
|
+
name: identity.name,
|
|
224
|
+
version: identity.version,
|
|
225
|
+
lockText: relPaths.includes(lockRel) ? readFileSync(join(extractDir, lockRel), "utf8") : null,
|
|
226
|
+
lockRelPath: lockRel,
|
|
227
|
+
identity: crateName,
|
|
228
|
+
...(o.sanitizer !== undefined ? { sanitizer: o.sanitizer } : {}),
|
|
229
|
+
}));
|
|
217
230
|
} finally {
|
|
218
231
|
removeSandbox(extractDir);
|
|
219
232
|
}
|