border-customs 0.3.1 → 0.4.0
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 +107 -1
- package/dist/assets/package-lock.json +4 -11
- package/dist/index.js +533 -96
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -214,6 +214,45 @@ restate. The empirical roundtrip (install → snapshot → uninstall → rc-delt
|
|
|
214
214
|
valve. **border is NOT a malware sandbox**: it never executes, intercepts, or sandboxes the
|
|
215
215
|
code it reads; it matches text and blocks the push.
|
|
216
216
|
|
|
217
|
+
#### Proof valve: `residue.requireProof` (0.4.0)
|
|
218
|
+
|
|
219
|
+
Static analysis stops at capability. `residue: { requireProof: true }` (strict; default **off**)
|
|
220
|
+
raises the bar for publish channels: when the residue scan flags a blocking-capable finding on a
|
|
221
|
+
staged artifact, `border check` can no longer PASS that channel on signatures alone — the
|
|
222
|
+
artifact's sha256 must also carry a **fresh empirical roundtrip verdict** in the ledger.
|
|
223
|
+
|
|
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 bytes, diffs the
|
|
226
|
+
filesystem, and appends a `t:"roundtrip"` ledger record — `{artifactSha256, verdict:
|
|
227
|
+
clean|residue, rulesHash, ts, rows}` — keyed by the exact bytes it fetched. A `residue` verdict
|
|
228
|
+
still counts as a proof-of-fact: the valve demands *evidence someone looked*, and the finding
|
|
229
|
+
itself keeps blocking until it stops tripping the scan.
|
|
230
|
+
|
|
231
|
+
- Missing proof ⇒ new native CRITICAL `roundtrip-proof-missing`; proof whose `rulesHash` no
|
|
232
|
+
longer matches (classifier, engine, or config changed — including the flag flip itself) ⇒
|
|
233
|
+
`roundtrip-proof-stale`. Freshness is exactly the fingerprint pattern: rotating the policy
|
|
234
|
+
invalidates cached verdicts, PASSes included.
|
|
235
|
+
- `border roundtrip` records by default (`--record` is the default; opt out with `--no-record`).
|
|
236
|
+
- Both new rules are ordinary findings: the allow-list can waive them and every waiver is
|
|
237
|
+
enumerated in `allowHits` — no hidden channel.
|
|
238
|
+
|
|
239
|
+
### Running `border roundtrip`
|
|
240
|
+
|
|
241
|
+
`border roundtrip <[ecosystem:]name@version>` fetches the real registry bytes, installs them in a
|
|
242
|
+
throwaway Docker container (per-ecosystem image, whole-filesystem content-hash manifest before
|
|
243
|
+
install and after the manager's own uninstall), prints the residue manifest, and records the
|
|
244
|
+
verdict. Known fidelity envelope, from the W2.0 spike and the W2.3 registrar-chain demo:
|
|
245
|
+
|
|
246
|
+
- Docker is required; absence or any step failure ⇒ exit 2, never a silent `clean`.
|
|
247
|
+
- npm/gem/crates lanes diff exactly; the pypi lane reports pip's left-behind **transitive
|
|
248
|
+
dependencies** as orphan rows (calibration to a target-only diff is a known follow-up —
|
|
249
|
+
the over-report direction is deliberately conservative).
|
|
250
|
+
- Only manager-lifecycle surfaces are observed: persistence performed by explicitly-invoked
|
|
251
|
+
bins (not npm hooks) is outside what the roundtrip watches — W2.3 measured exactly this
|
|
252
|
+
blind spot on our own registrar and documented its `{"plugin": []}` config residue.
|
|
253
|
+
- A `clean` verdict is proof-of-fact about one artifact version on one machine class, not a
|
|
254
|
+
security guarantee.
|
|
255
|
+
|
|
217
256
|
## Architecture: `border push`
|
|
218
257
|
|
|
219
258
|
`border push` is a state machine over per-target states, recomputed from live git queries,
|
|
@@ -375,6 +414,39 @@ dropped file to stderr with the sha256 of its original bytes
|
|
|
375
414
|
(`border scan: crates envelope normalized: <file> sha256=<hex>`); the rebuild regenerates
|
|
376
415
|
them from the normalized manifest, so no scanned byte is silently lost.
|
|
377
416
|
|
|
417
|
+
### Engines in consumer installs
|
|
418
|
+
|
|
419
|
+
`dist/index.js` is one self-contained esbuild bundle and every runtime asset (vendored
|
|
420
|
+
gitleaks rules, prompt template, the secretlint fingerprint snapshot) ships inside `dist/`,
|
|
421
|
+
so after `npm i -D border-customs` (or `-g`) the engine surface is:
|
|
422
|
+
|
|
423
|
+
- **publint** — installed automatically: it is an exact-pinned runtime dependency of the
|
|
424
|
+
package, invoked via bin resolution (package-local `node_modules/.bin`, the hoisted
|
|
425
|
+
project-level `node_modules/.bin`, then `PATH`, then `~/.local/bin`). The
|
|
426
|
+
`publint-fail` leg of the npm artifact stage works out of the box in a consumer install.
|
|
427
|
+
- **secretlint** — no consumer action, ever: the default lint runs **in-process** with the
|
|
428
|
+
`@secretlint/*` rule modules already declared as border's own runtime dependencies
|
|
429
|
+
(`src/engines/secretlint.ts` header). The `mode: "cli"` fallback that spawns a
|
|
430
|
+
`secretlint` binary is border-internal (test transport); it is not reachable from
|
|
431
|
+
`border.yaml` or any flag, so installing `secretlint` into your project changes nothing
|
|
432
|
+
for border. If the engine probe cannot read the bundled lock snapshot
|
|
433
|
+
(`dist/assets/package-lock.json`), the run fails closed as `DEGRADED-ENGINE` exit 2 —
|
|
434
|
+
never a silent pass.
|
|
435
|
+
- **gitleaks** — stays external, same posture as `git`: border vendors the 8.30.1 rule
|
|
436
|
+
config but the binary is yours (on `PATH` or `~/.local/bin/gitleaks`; see the
|
|
437
|
+
requirements paragraph at the top and `src/engines/ADAPTER-CONTRACT.md`). Missing
|
|
438
|
+
gitleaks means `border check` records a CRITICAL `DEGRADED-ENGINE` finding and exits 2,
|
|
439
|
+
and `border scan` exits 2 with `engine binary 'gitleaks' not found; border fails
|
|
440
|
+
closed`. Neither command degrades to a skip.
|
|
441
|
+
- **trufflehog** — optional third engine, external binary, same posture as gitleaks once
|
|
442
|
+
enabled: `engines.trufflehog: true` puts it in the required set, and an absent binary
|
|
443
|
+
degrades the run to exit 2 exactly like a missing gitleaks.
|
|
444
|
+
|
|
445
|
+
There is no "engine absent ⇒ leg silently skipped" state anywhere: missing required
|
|
446
|
+
engines are exit 2 (`2` = the gate could not answer, see
|
|
447
|
+
[Exit codes](#exit-codes-the-contract)), and the ledger refuses to write a PASS for a
|
|
448
|
+
degraded run.
|
|
449
|
+
|
|
378
450
|
## Configuration
|
|
379
451
|
|
|
380
452
|
Everything border can be told lives in `border.yaml` at the repo root. Unknown keys are
|
|
@@ -541,6 +613,30 @@ same one that wrote the commit) how to run the five subcommands, how to produce
|
|
|
541
613
|
|
|
542
614
|
## Changelog
|
|
543
615
|
|
|
616
|
+
### 0.4.0 (2026-09-10)
|
|
617
|
+
- Add: `border roundtrip <[ecosystem:]name@version>` — fact-proof valve. Installs the
|
|
618
|
+
package inside a throwaway Docker container, content-hashes the whole filesystem before
|
|
619
|
+
and after uninstall, and reports exactly what the uninstaller left behind. Persistence
|
|
620
|
+
surfaces (rc/profile mutations) => CRITICAL; orphan files => HIGH. Verdicts record to
|
|
621
|
+
the ledger by default (`--no-record` opts out); Docker absent => fail-closed, never clean.
|
|
622
|
+
- Add: `residue.requireProof` — when on, a residue-capable artifact only passes `border
|
|
623
|
+
check` with a fresh recorded roundtrip proof (bound to rulesHash; flipping the config
|
|
624
|
+
invalidates cached verdicts). Missing proof => CRITICAL `roundtrip-proof-missing`.
|
|
625
|
+
- Opt-in test legs: BORDER_ROUNDTRIP_DOCKER=1 (8 real container legs, 4 ecosystems).
|
|
626
|
+
|
|
627
|
+
### 0.3.2 (2026-09-10)
|
|
628
|
+
- Fix: CLI invoked through the npm `.bin` shim silently exited 0 without running
|
|
629
|
+
(entrypoint detection now realpath-canonicalizes both sides). Regression-locked by a
|
|
630
|
+
pack -> install -> shim-invocation test (`BORDER_PACK_TEST=1`, wired into CI).
|
|
631
|
+
- Fix: `publint` is an exact-pinned runtime dependency, so `border scan`/npm checks work
|
|
632
|
+
out of the box in consumer installs; README documents the engine posture truthfully.
|
|
633
|
+
- Add: release-coherence seed test pinning the scan User-Agent version to package.json.
|
|
634
|
+
|
|
635
|
+
### 0.3.1 (2026-09-10)
|
|
636
|
+
- Add: `border scan [ecosystem:]name@version` — static residue inspection of third-party
|
|
637
|
+
registry packages (npm/PyPI/crates.io/RubyGems), ledger-free by design.
|
|
638
|
+
- Docs: README scan section; crates envelope normalization now logs scrubbed-file hashes.
|
|
639
|
+
|
|
544
640
|
### 0.3.0 (2026-09-09)
|
|
545
641
|
|
|
546
642
|
- **Residue gate (artifact stage).** The blanket install-hook CRITICAL becomes a classified
|
|
@@ -584,4 +680,14 @@ MIT, see [LICENSE](LICENSE).
|
|
|
584
680
|
|
|
585
681
|
## 中文概要
|
|
586
682
|
|
|
587
|
-
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
|
|
683
|
+
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
|
+
`residue.requireProof`(默认关,strict):开启后,发布构件若触发阻断级 `residue-*` 发现,通道 PASS
|
|
685
|
+
还要求账本里存在按其 sha256 索引且 rulesHash 新鲜的 `border roundtrip` 实测记录——check 自身从不
|
|
686
|
+
运行 Docker,证据由 `border roundtrip` 离线写入(默认记账,`--no-record` 关闭),clean 与 residue
|
|
687
|
+
两种裁决都算"事实已在";缺记录判 `roundtrip-proof-missing`、rulesHash 过期判
|
|
688
|
+
`roundtrip-proof-stale`(均 CRITICAL/native,与普通发现同受白名单管辖并在 allowHits 枚举),翻转
|
|
689
|
+
该配置即轮换 rulesHash,所有缓存 PASS 自动失效。`border roundtrip` 本身保真边界:Docker 必需、
|
|
690
|
+
缺失即 exit 2 绝不假装干净;pypi 通道会把 pip 遗留的传递依赖如实报成孤儿行(收窄到目标包自身是
|
|
691
|
+
已知后续项);只有包管理器生命周期触发的写入被观测,显式调用的 bin 自写配置不在射程——W2.3 在
|
|
692
|
+
自家 registrar 链上实测到该盲区并留下了 `{"plugin": []}` 残留证据。clean 是"某一版本构件在某类
|
|
693
|
+
机器上"的事实证明,不是安全担保。可选 LLM 层 border 自身从不调用模型 API:`llm-request` 导出掩码审阅包,`llm-ingest` 严格校验 agent 结论并重算裁决。退出码即合同:0 通过、1 拦截、2 门禁无法作答,任何"工具不健康"都不可能被误读为干净。MIT 许可,无遥测,除你配置的注册表预检外不联网。
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "border-customs",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
|
-
"name": "border",
|
|
9
|
-
"version": "0.1
|
|
8
|
+
"name": "border-customs",
|
|
9
|
+
"version": "0.3.1",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@secretlint/core": "13.0.5",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"@secretlint/secretlint-rule-no-homedir": "13.0.5",
|
|
15
15
|
"@secretlint/secretlint-rule-pattern": "13.0.5",
|
|
16
16
|
"@secretlint/secretlint-rule-preset-recommend": "13.0.5",
|
|
17
|
+
"publint": "0.3.24",
|
|
17
18
|
"yaml": "2.9.0",
|
|
18
19
|
"zod": "4.5.4"
|
|
19
20
|
},
|
|
@@ -23,7 +24,6 @@
|
|
|
23
24
|
"devDependencies": {
|
|
24
25
|
"@types/node": "22.19.0",
|
|
25
26
|
"esbuild": "0.25.12",
|
|
26
|
-
"publint": "0.3.24",
|
|
27
27
|
"secretlint": "13.0.5",
|
|
28
28
|
"typescript": "5.9.3",
|
|
29
29
|
"verdaccio": "6.10.2"
|
|
@@ -601,7 +601,6 @@
|
|
|
601
601
|
"version": "0.1.7",
|
|
602
602
|
"resolved": "https://registry.npmjs.org/@publint/pack/-/pack-0.1.7.tgz",
|
|
603
603
|
"integrity": "sha512-4EDEmvxWtgsCnnVeBvtFIFZtUhPPt1+bA9JrSwU4Sa//6oKtzCSlGGXYJr44OD9aGISymbieJ4mCKHUygUDU+g==",
|
|
604
|
-
"dev": true,
|
|
605
604
|
"license": "MIT",
|
|
606
605
|
"dependencies": {
|
|
607
606
|
"tinyexec": "^1.3.0"
|
|
@@ -3821,7 +3820,6 @@
|
|
|
3821
3820
|
"version": "1.2.0",
|
|
3822
3821
|
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
|
3823
3822
|
"integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==",
|
|
3824
|
-
"dev": true,
|
|
3825
3823
|
"license": "MIT",
|
|
3826
3824
|
"engines": {
|
|
3827
3825
|
"node": ">=4"
|
|
@@ -3982,7 +3980,6 @@
|
|
|
3982
3980
|
"version": "1.8.0",
|
|
3983
3981
|
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz",
|
|
3984
3982
|
"integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==",
|
|
3985
|
-
"dev": true,
|
|
3986
3983
|
"license": "MIT"
|
|
3987
3984
|
},
|
|
3988
3985
|
"node_modules/pako": {
|
|
@@ -4073,7 +4070,6 @@
|
|
|
4073
4070
|
"version": "1.1.1",
|
|
4074
4071
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
|
4075
4072
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
|
4076
|
-
"dev": true,
|
|
4077
4073
|
"license": "ISC"
|
|
4078
4074
|
},
|
|
4079
4075
|
"node_modules/picomatch": {
|
|
@@ -4255,7 +4251,6 @@
|
|
|
4255
4251
|
"version": "0.3.24",
|
|
4256
4252
|
"resolved": "https://registry.npmjs.org/publint/-/publint-0.3.24.tgz",
|
|
4257
4253
|
"integrity": "sha512-9zS56KrKBoqi5Qt8h92uMP8TTM9AYZSgnmCo4u2priMqkOZvQnTsziZ2p5LJ2ywbYkAjoCDp2jda9u4cgFefIw==",
|
|
4258
|
-
"dev": true,
|
|
4259
4254
|
"license": "MIT",
|
|
4260
4255
|
"dependencies": {
|
|
4261
4256
|
"@publint/pack": "^0.1.7",
|
|
@@ -4551,7 +4546,6 @@
|
|
|
4551
4546
|
"version": "1.8.1",
|
|
4552
4547
|
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
|
4553
4548
|
"integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==",
|
|
4554
|
-
"dev": true,
|
|
4555
4549
|
"license": "MIT",
|
|
4556
4550
|
"dependencies": {
|
|
4557
4551
|
"mri": "^1.1.0"
|
|
@@ -5231,7 +5225,6 @@
|
|
|
5231
5225
|
"version": "1.3.1",
|
|
5232
5226
|
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
|
|
5233
5227
|
"integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
|
|
5234
|
-
"dev": true,
|
|
5235
5228
|
"license": "MIT",
|
|
5236
5229
|
"engines": {
|
|
5237
5230
|
"node": ">=18"
|
package/dist/index.js
CHANGED
|
@@ -13028,7 +13028,8 @@ var require_micromatch = __commonJS({
|
|
|
13028
13028
|
});
|
|
13029
13029
|
|
|
13030
13030
|
// src/index.ts
|
|
13031
|
-
import {
|
|
13031
|
+
import { realpathSync } from "node:fs";
|
|
13032
|
+
import { fileURLToPath as fileURLToPath5, pathToFileURL } from "node:url";
|
|
13032
13033
|
|
|
13033
13034
|
// src/cli.ts
|
|
13034
13035
|
import { parseArgs } from "node:util";
|
|
@@ -37896,10 +37897,14 @@ var NPM_TARGET_LABEL = "artifact";
|
|
|
37896
37897
|
var DEFAULT_TOOL_TIMEOUT_MS = 12e4;
|
|
37897
37898
|
var MESSAGE_TAIL_CHARS = 600;
|
|
37898
37899
|
var HERE = dirname3(fileURLToPath3(import.meta.url));
|
|
37899
|
-
|
|
37900
|
-
|
|
37901
|
-
|
|
37902
|
-
|
|
37900
|
+
function publintLocalBins(here) {
|
|
37901
|
+
return [
|
|
37902
|
+
join6(here, "..", "node_modules", ".bin", "publint"),
|
|
37903
|
+
join6(here, "..", "..", "node_modules", ".bin", "publint"),
|
|
37904
|
+
join6(here, "..", "..", ".bin", "publint")
|
|
37905
|
+
];
|
|
37906
|
+
}
|
|
37907
|
+
var PUBLINT_LOCAL_BINS = publintLocalBins(HERE);
|
|
37903
37908
|
function tail(text) {
|
|
37904
37909
|
const trimmed = text.trim();
|
|
37905
37910
|
return trimmed.length > MESSAGE_TAIL_CHARS ? `${trimmed.slice(-MESSAGE_TAIL_CHARS)}\u2026` : trimmed;
|
|
@@ -39839,6 +39844,20 @@ function buildPushRecord(i) {
|
|
|
39839
39844
|
ts: i.ts ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
39840
39845
|
};
|
|
39841
39846
|
}
|
|
39847
|
+
function buildRoundtripRecord(i) {
|
|
39848
|
+
if (!HEX64.test(i.artifactSha256)) bad("artifactSha256 is not hex-shaped");
|
|
39849
|
+
if (!HEX64.test(i.rulesHash)) bad("rulesHash is not hex-shaped");
|
|
39850
|
+
if (i.verdict !== "clean" && i.verdict !== "residue") bad(`verdict must be clean or residue, got '${i.verdict}'`);
|
|
39851
|
+
if (!Number.isInteger(i.rows) || i.rows < 0) bad("rows must be a non-negative integer");
|
|
39852
|
+
return {
|
|
39853
|
+
t: "roundtrip",
|
|
39854
|
+
artifactSha256: i.artifactSha256,
|
|
39855
|
+
verdict: i.verdict,
|
|
39856
|
+
rulesHash: i.rulesHash,
|
|
39857
|
+
rows: i.rows,
|
|
39858
|
+
ts: i.ts ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
39859
|
+
};
|
|
39860
|
+
}
|
|
39842
39861
|
function bad(msg) {
|
|
39843
39862
|
throw new Error(msg);
|
|
39844
39863
|
}
|
|
@@ -39929,6 +39948,20 @@ function parseLedgerRecord(value) {
|
|
|
39929
39948
|
ts: needString(o, "ts")
|
|
39930
39949
|
};
|
|
39931
39950
|
}
|
|
39951
|
+
if (t === "roundtrip") {
|
|
39952
|
+
const verdict = needString(o, "verdict");
|
|
39953
|
+
if (verdict !== "clean" && verdict !== "residue") bad(`verdict must be clean or residue, got '${verdict}'`);
|
|
39954
|
+
const rows = o.rows;
|
|
39955
|
+
if (typeof rows !== "number" || !Number.isInteger(rows) || rows < 0) bad("rows must be a non-negative integer");
|
|
39956
|
+
return {
|
|
39957
|
+
t: "roundtrip",
|
|
39958
|
+
artifactSha256: needHex(o, "artifactSha256", HEX64),
|
|
39959
|
+
verdict,
|
|
39960
|
+
rulesHash: needHex(o, "rulesHash", HEX64),
|
|
39961
|
+
rows,
|
|
39962
|
+
ts: needString(o, "ts")
|
|
39963
|
+
};
|
|
39964
|
+
}
|
|
39932
39965
|
return bad(`unknown record type ${JSON.stringify(t)}`);
|
|
39933
39966
|
}
|
|
39934
39967
|
function codeOf2(err) {
|
|
@@ -39981,6 +40014,13 @@ function latestPassCoveringTargets(records, key, targets) {
|
|
|
39981
40014
|
function pushRecords(records, key) {
|
|
39982
40015
|
return records.filter((r) => r.t === "push" && (key === void 0 || r.key === key));
|
|
39983
40016
|
}
|
|
40017
|
+
function latestRoundtripForSha(records, artifactSha256) {
|
|
40018
|
+
for (let i = records.length - 1; i >= 0; i -= 1) {
|
|
40019
|
+
const r = records[i];
|
|
40020
|
+
if (r !== void 0 && r.t === "roundtrip" && r.artifactSha256 === artifactSha256) return r;
|
|
40021
|
+
}
|
|
40022
|
+
return null;
|
|
40023
|
+
}
|
|
39984
40024
|
|
|
39985
40025
|
// src/ledger/retention.ts
|
|
39986
40026
|
import { existsSync as existsSync9, mkdirSync as mkdirSync6, readdirSync as readdirSync5, rmSync as rmSync6 } from "node:fs";
|
|
@@ -41950,8 +41990,17 @@ function buildBorderConfigSchema() {
|
|
|
41950
41990
|
// of the residue scan is this toggle; a scan that cannot RUN is a FAIL
|
|
41951
41991
|
// (fail-closed doctrine), and the digest seam keeps every cached PASS
|
|
41952
41992
|
// honest about the residue sources it was certified against.
|
|
41993
|
+
// W2.2 (border-inspect-roadmap): requireProof arms the PROOF VALVE — a
|
|
41994
|
+
// blocking residue-* row on a staged artifact then additionally demands a
|
|
41995
|
+
// fresh `t:"roundtrip"` ledger record for that artifact's sha256 before
|
|
41996
|
+
// the channel may PASS. Default FALSE (0.3.x behavior byte-identical);
|
|
41997
|
+
// the valve reads `residue?.requireProof === true`, so absence IS the
|
|
41998
|
+
// false default — same construction contract as the outer key. The flag
|
|
41999
|
+
// rides the whole-object configDigest (stableStringify), so flipping it
|
|
42000
|
+
// rotates the check rulesHash and the ledger refuses every cached PASS.
|
|
41953
42001
|
residue: external_exports.object({
|
|
41954
|
-
enabled: external_exports.boolean().default(true)
|
|
42002
|
+
enabled: external_exports.boolean().default(true),
|
|
42003
|
+
requireProof: external_exports.boolean().default(false)
|
|
41955
42004
|
}).strict().optional()
|
|
41956
42005
|
}).strict();
|
|
41957
42006
|
}
|
|
@@ -42402,6 +42451,44 @@ function applyAllowList(findings, allow, repoDir) {
|
|
|
42402
42451
|
return { kept, allowHits: [...hits.values()] };
|
|
42403
42452
|
}
|
|
42404
42453
|
|
|
42454
|
+
// src/check/proofValve.ts
|
|
42455
|
+
var ROUNDTRIP_PROOF_MISSING_RULE = "roundtrip-proof-missing";
|
|
42456
|
+
var ROUNDTRIP_PROOF_STALE_RULE = "roundtrip-proof-stale";
|
|
42457
|
+
var RESIDUE_RULE_IDS = new Set(Object.keys(RESIDUE_SEVERITIES));
|
|
42458
|
+
function hasBlockingResidueCapability(findings) {
|
|
42459
|
+
return findings.some((f) => RESIDUE_RULE_IDS.has(f.rule) && isBlocking(f.severity));
|
|
42460
|
+
}
|
|
42461
|
+
function proofFinding(rule, art, cause) {
|
|
42462
|
+
return {
|
|
42463
|
+
rule,
|
|
42464
|
+
severity: "CRITICAL",
|
|
42465
|
+
target: "artifact",
|
|
42466
|
+
path: art.file,
|
|
42467
|
+
engine: "native",
|
|
42468
|
+
message: `${art.file} (sha256 ${art.sha256.slice(0, 12)}\u2026) tripped the residue scan while residue.requireProof is on, but ${cause}. Run 'border roundtrip <spec>' for exactly these bytes to mint the ledger proof.`,
|
|
42469
|
+
...redact(`${art.sha256}:${cause}`),
|
|
42470
|
+
snippet: `${art.file} ${art.sha256.slice(0, 12)}`
|
|
42471
|
+
};
|
|
42472
|
+
}
|
|
42473
|
+
function proofFindings(o) {
|
|
42474
|
+
const out = [];
|
|
42475
|
+
for (const art of o.artifacts) {
|
|
42476
|
+
const rec = latestRoundtripForSha(o.records, art.sha256);
|
|
42477
|
+
if (rec === null) {
|
|
42478
|
+
out.push(proofFinding(ROUNDTRIP_PROOF_MISSING_RULE, art, "no roundtrip proof record exists for its digest"));
|
|
42479
|
+
} else if (rec.rulesHash !== o.rulesHash) {
|
|
42480
|
+
out.push(
|
|
42481
|
+
proofFinding(
|
|
42482
|
+
ROUNDTRIP_PROOF_STALE_RULE,
|
|
42483
|
+
art,
|
|
42484
|
+
`its proof record predates the current rules policy (recorded ${rec.rulesHash.slice(0, 12)}\u2026, required ${o.rulesHash.slice(0, 12)}\u2026)`
|
|
42485
|
+
)
|
|
42486
|
+
);
|
|
42487
|
+
}
|
|
42488
|
+
}
|
|
42489
|
+
return out;
|
|
42490
|
+
}
|
|
42491
|
+
|
|
42405
42492
|
// src/check/tagScan.ts
|
|
42406
42493
|
import { spawnSync as spawnSync12 } from "node:child_process";
|
|
42407
42494
|
import { mkdtempSync as mkdtempSync3, rmSync as rmSync10, writeFileSync as writeFileSync4 } from "node:fs";
|
|
@@ -42518,7 +42605,6 @@ function scanTagMessages(o) {
|
|
|
42518
42605
|
|
|
42519
42606
|
// src/check.ts
|
|
42520
42607
|
var TRACKED_BORDER_RULE = "repo-tracks-border-state";
|
|
42521
|
-
var RESIDUE_RULE_IDS = new Set(Object.keys(RESIDUE_SEVERITIES));
|
|
42522
42608
|
var GUARD_PATH_CAP = 50;
|
|
42523
42609
|
function trackedBorderStateFinding(repoDir, headSha, env2) {
|
|
42524
42610
|
const listing = runGitChecked(repoDir, ["ls-files", "--", `${BORDER_STATE_DIR}/`], { ...env2 !== void 0 ? { env: env2 } : {} }).split("\n").filter((p) => p !== "");
|
|
@@ -42607,13 +42693,16 @@ async function runPipeline(o, ctx, lockWarning) {
|
|
|
42607
42693
|
if (f !== void 0 && RESIDUE_RULE_IDS.has(f.rule)) findings.splice(i, 1);
|
|
42608
42694
|
}
|
|
42609
42695
|
}
|
|
42610
|
-
const exposure = [...exposureSet(o.cfg, { cwd: repoDir })];
|
|
42611
|
-
const allow = applyAllowList(findings, o.cfg.allow, repoDir);
|
|
42612
42696
|
const rulesHash = await computeCheckRulesHash({
|
|
42613
42697
|
engineVersions: probe.engineVersions,
|
|
42614
42698
|
configDigest: o.configDigest,
|
|
42615
42699
|
...o.env !== void 0 ? { env: o.env } : {}
|
|
42616
42700
|
});
|
|
42701
|
+
if (o.cfg.residue?.requireProof === true && ledgerArtifacts.length > 0 && hasBlockingResidueCapability(findings)) {
|
|
42702
|
+
findings.push(...proofFindings({ artifacts: ledgerArtifacts, records: readLedger(repoDir).records, rulesHash }));
|
|
42703
|
+
}
|
|
42704
|
+
const exposure = [...exposureSet(o.cfg, { cwd: repoDir })];
|
|
42705
|
+
const allow = applyAllowList(findings, o.cfg.allow, repoDir);
|
|
42617
42706
|
const report = {
|
|
42618
42707
|
schemaVersion: 1,
|
|
42619
42708
|
key: computeCheckKey({
|
|
@@ -43431,41 +43520,18 @@ async function executeYesPush(ctx, loaded) {
|
|
|
43431
43520
|
return EXIT_PASS;
|
|
43432
43521
|
}
|
|
43433
43522
|
|
|
43434
|
-
// src/
|
|
43435
|
-
import { mkdtempSync as mkdtempSync5 } from "node:fs";
|
|
43523
|
+
// src/roundtrip/orchestrate.ts
|
|
43524
|
+
import { mkdtempSync as mkdtempSync5, rmSync as rmSync12, writeFileSync as writeFileSync7 } from "node:fs";
|
|
43525
|
+
import { randomBytes } from "node:crypto";
|
|
43436
43526
|
import { tmpdir as tmpdir3 } from "node:os";
|
|
43437
|
-
import { join as
|
|
43438
|
-
|
|
43439
|
-
// src/scan/config.ts
|
|
43440
|
-
function buildScanConfigYaml(eco) {
|
|
43441
|
-
return [
|
|
43442
|
-
"version: 1",
|
|
43443
|
-
"targets:",
|
|
43444
|
-
" git:",
|
|
43445
|
-
" remotes: []",
|
|
43446
|
-
` ${eco}: {}`,
|
|
43447
|
-
"rules:",
|
|
43448
|
-
" authors:",
|
|
43449
|
-
" emails: []",
|
|
43450
|
-
" names: []",
|
|
43451
|
-
" hosts: []",
|
|
43452
|
-
" ips: []",
|
|
43453
|
-
" pathPatterns: []",
|
|
43454
|
-
"residue:",
|
|
43455
|
-
" enabled: true",
|
|
43456
|
-
""
|
|
43457
|
-
].join("\n");
|
|
43458
|
-
}
|
|
43459
|
-
function synthesizeScanConfig(eco) {
|
|
43460
|
-
return parseConfig(buildScanConfigYaml(eco), "<border scan>");
|
|
43461
|
-
}
|
|
43527
|
+
import { join as join25 } from "node:path";
|
|
43462
43528
|
|
|
43463
43529
|
// src/scan/fetch.ts
|
|
43464
43530
|
import { createHash as createHash15 } from "node:crypto";
|
|
43465
43531
|
import { basename as basename4 } from "node:path";
|
|
43466
43532
|
var SCAN_MAX_ARTIFACT_BYTES = 200 * 1024 * 1024;
|
|
43467
43533
|
var SCAN_REQUEST_TIMEOUT_MS = 6e4;
|
|
43468
|
-
var SCAN_USER_AGENT = "border-customs/0.
|
|
43534
|
+
var SCAN_USER_AGENT = "border-customs/0.4.0 (+https://github.com/sumteclab/border; border scan)";
|
|
43469
43535
|
var CRATES_DOWNLOAD_HOST = "https://static.crates.io";
|
|
43470
43536
|
var defaultScanFetcher = async (url2, init) => {
|
|
43471
43537
|
if (!/^https?:\/\//.test(url2)) throw new EngineRunError(`border scan refuses non-http(s) URL: ${url2}`, null);
|
|
@@ -43594,9 +43660,402 @@ function sha256Hex(data) {
|
|
|
43594
43660
|
return createHash15("sha256").update(data).digest("hex");
|
|
43595
43661
|
}
|
|
43596
43662
|
|
|
43663
|
+
// src/scan/spec.ts
|
|
43664
|
+
var ECOSYSTEMS = ["npm", "pypi", "crates", "rubygems"];
|
|
43665
|
+
var SCAN_SPEC_USAGE = "usage: border scan <[ecosystem:]name@version> \u2014 e.g. border scan puppeteer@23.11.1 | pypi:requests@2.32.3 | crates:serde@1.0.219 | rubygems:rails@7.1.1";
|
|
43666
|
+
var PLAIN_NAME = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
|
|
43667
|
+
var SCOPED_NAME = /^@[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
43668
|
+
var VERSION = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
|
|
43669
|
+
var bad2 = (why) => {
|
|
43670
|
+
throw new UnknownArgError(`${why}; ${SCAN_SPEC_USAGE}`);
|
|
43671
|
+
};
|
|
43672
|
+
function parseScanSpec(raw) {
|
|
43673
|
+
const text = raw.trim();
|
|
43674
|
+
if (text.length === 0) return bad2("empty spec");
|
|
43675
|
+
let ecosystem = "npm";
|
|
43676
|
+
let rest = text;
|
|
43677
|
+
const colon = text.indexOf(":");
|
|
43678
|
+
if (colon >= 0) {
|
|
43679
|
+
const prefix = text.slice(0, colon);
|
|
43680
|
+
const found = ECOSYSTEMS.find((e) => e === prefix);
|
|
43681
|
+
if (found === void 0) {
|
|
43682
|
+
return bad2(`unknown ecosystem '${prefix}' \u2014 expected one of: ${ECOSYSTEMS.join(", ")}`);
|
|
43683
|
+
}
|
|
43684
|
+
ecosystem = found;
|
|
43685
|
+
rest = text.slice(colon + 1);
|
|
43686
|
+
if (rest.length === 0) return bad2("missing package name after the ecosystem prefix");
|
|
43687
|
+
}
|
|
43688
|
+
const scoped = rest.startsWith("@");
|
|
43689
|
+
const at = rest.lastIndexOf("@");
|
|
43690
|
+
if (at <= 0) return bad2(`spec '${raw}' is missing '@version'`);
|
|
43691
|
+
const name = rest.slice(0, at);
|
|
43692
|
+
const version2 = rest.slice(at + 1);
|
|
43693
|
+
if (!(scoped ? SCOPED_NAME : PLAIN_NAME).test(name)) {
|
|
43694
|
+
return bad2(`invalid package name '${name}'`);
|
|
43695
|
+
}
|
|
43696
|
+
if (!VERSION.test(version2)) {
|
|
43697
|
+
return bad2(`invalid version '${version2}' in spec '${raw}'`);
|
|
43698
|
+
}
|
|
43699
|
+
return { ecosystem, name, version: version2 };
|
|
43700
|
+
}
|
|
43701
|
+
|
|
43702
|
+
// src/roundtrip/docker.ts
|
|
43703
|
+
var DOCKER_PROBE_TIMEOUT_MS = 15e3;
|
|
43704
|
+
var DOCKER_RUN_TIMEOUT_MS = 12e4;
|
|
43705
|
+
var DOCKER_CP_TIMEOUT_MS = 12e4;
|
|
43706
|
+
var SNAPSHOT_TIMEOUT_MS = 12e4;
|
|
43707
|
+
var PHASE_TIMEOUT_MS = 6e5;
|
|
43708
|
+
var DOCKER_RM_TIMEOUT_MS = 6e4;
|
|
43709
|
+
var CONTAINER_TTL_SEC = 900;
|
|
43710
|
+
var BASE_EXCLUDES = [
|
|
43711
|
+
"/proc",
|
|
43712
|
+
"/sys",
|
|
43713
|
+
"/dev",
|
|
43714
|
+
"/root/.npm",
|
|
43715
|
+
"/tmp/node-compile-cache",
|
|
43716
|
+
"/etc/hostname",
|
|
43717
|
+
"/etc/resolv.conf",
|
|
43718
|
+
"/etc/hosts"
|
|
43719
|
+
];
|
|
43720
|
+
var PYTHON_EXCLUDES = ["/root/.cache", "/var/log", "/var/lib/apt/lists", "*/__pycache__"];
|
|
43721
|
+
var CARGO_EXCLUDES = ["/root/.cargo", "/usr/local/cargo", "/usr/local/.crates2.json", "/usr/local/.crates.toml"];
|
|
43722
|
+
function snapshotScript(excludes) {
|
|
43723
|
+
const prune = `"(" ${excludes.map((p) => `-path "${p}"`).join(" -o ")} ")" -prune -o`;
|
|
43724
|
+
return [
|
|
43725
|
+
"set -e",
|
|
43726
|
+
'T=/dev/shm/rt-snap.$$; mkdir -p "$T"',
|
|
43727
|
+
`find / -xdev ${prune} -type f -print0 > "$T/f0"`,
|
|
43728
|
+
// || true: a file can vanish between find passes (package managers churn
|
|
43729
|
+
// temp state); the missing row is consistent noise, not a false residue.
|
|
43730
|
+
`xargs -0 -r sha256sum < "$T/f0" 2>/dev/null > "$T/hashes" || true`,
|
|
43731
|
+
// sha256sum text mode = 64 hex + 2 spaces + path; substr keeps spaced paths whole.
|
|
43732
|
+
`awk '{ printf "F\\t%s\\t%s\\n", substr($0,67), substr($0,1,64) }' "$T/hashes" > "$T/files"`,
|
|
43733
|
+
`find / -xdev ${prune} -type l -printf 'L\\t%p\\t%l\\n' > "$T/links"`,
|
|
43734
|
+
`find / -xdev ${prune} -type d -printf 'D\\t%p\\t-\\n' > "$T/dirs"`,
|
|
43735
|
+
'LC_ALL=C sort "$T/files" "$T/links" "$T/dirs"',
|
|
43736
|
+
'rm -rf "$T"'
|
|
43737
|
+
].join("\n");
|
|
43738
|
+
}
|
|
43739
|
+
function realDockerExec() {
|
|
43740
|
+
return (args, timeoutMs) => {
|
|
43741
|
+
const r = spawnEngine(binaryCandidates("docker", {}), [...args], { timeoutMs });
|
|
43742
|
+
return { status: r.status, stdout: r.stdout, stderr: r.stderr };
|
|
43743
|
+
};
|
|
43744
|
+
}
|
|
43745
|
+
|
|
43746
|
+
// src/roundtrip/manifest.ts
|
|
43747
|
+
import { createHash as createHash16 } from "node:crypto";
|
|
43748
|
+
var RT_ENGINE = "roundtrip";
|
|
43749
|
+
var RT_RESIDUE_RULE = "roundtrip-residue-file";
|
|
43750
|
+
var RT_MODIFY_RULE = "roundtrip-modified-file";
|
|
43751
|
+
var RT_PERSIST_RULE = "roundtrip-modified-persistence";
|
|
43752
|
+
var KEY_SEP = "";
|
|
43753
|
+
var keyOf = (kind, path4) => `${kind}${KEY_SEP}${path4}`;
|
|
43754
|
+
function parseManifest(text) {
|
|
43755
|
+
const out = /* @__PURE__ */ new Map();
|
|
43756
|
+
for (const line of text.split("\n")) {
|
|
43757
|
+
if (line.length === 0) continue;
|
|
43758
|
+
const parts = line.split(" ");
|
|
43759
|
+
if (parts.length < 3) continue;
|
|
43760
|
+
const kind = parts[0];
|
|
43761
|
+
const path4 = parts[1];
|
|
43762
|
+
const value = parts.slice(2).join(" ");
|
|
43763
|
+
if (kind !== "F" && kind !== "L" && kind !== "D") continue;
|
|
43764
|
+
out.set(keyOf(kind, path4), { kind, path: path4, value });
|
|
43765
|
+
}
|
|
43766
|
+
return out;
|
|
43767
|
+
}
|
|
43768
|
+
function diffManifests(before, after) {
|
|
43769
|
+
const added = [];
|
|
43770
|
+
const removed = [];
|
|
43771
|
+
const modified = [];
|
|
43772
|
+
for (const [key, entry] of after) {
|
|
43773
|
+
const old = before.get(key);
|
|
43774
|
+
if (old === void 0) added.push(entry);
|
|
43775
|
+
else if (old.value !== entry.value) modified.push(entry);
|
|
43776
|
+
}
|
|
43777
|
+
for (const [key, entry] of before) {
|
|
43778
|
+
if (!after.has(key)) removed.push(entry);
|
|
43779
|
+
}
|
|
43780
|
+
return { added, removed, modified };
|
|
43781
|
+
}
|
|
43782
|
+
var PERSISTENCE_PATTERNS = [
|
|
43783
|
+
/(^|\/)\.(bashrc|bash_profile|bash_login|profile|zshrc|zprofile|zshenv|kshrc|cshrc|login\.sh)$/,
|
|
43784
|
+
/^\/etc\/profile\.d\//,
|
|
43785
|
+
/^\/etc\/(bash\.bashrc|profile|environment|zsh\/zshrc|inputrc)$/,
|
|
43786
|
+
/^\/etc\/systemd\//,
|
|
43787
|
+
/(^|\/)\.config\/systemd\//,
|
|
43788
|
+
/(^|\/)systemd\/system\//,
|
|
43789
|
+
/(^|\/)(cron|cron\.d|cron\.[0-9dhmwy]+|spool\/cron)\//,
|
|
43790
|
+
/^\/etc\/crontab$/,
|
|
43791
|
+
/(^|\/)\.config\/fish\//,
|
|
43792
|
+
/^\/(usr\/)?(local\/)?(s?bin)\//,
|
|
43793
|
+
/(^|\/)\.local\/bin\//
|
|
43794
|
+
];
|
|
43795
|
+
function isPersistenceSurface(path4) {
|
|
43796
|
+
return PERSISTENCE_PATTERNS.some((re) => re.test(path4));
|
|
43797
|
+
}
|
|
43798
|
+
var digestOf = (what) => createHash16("sha256").update(what).digest("hex");
|
|
43799
|
+
function classifyResidue(label, diff) {
|
|
43800
|
+
const findings = [];
|
|
43801
|
+
for (const e of diff.added) {
|
|
43802
|
+
findings.push({
|
|
43803
|
+
rule: RT_RESIDUE_RULE,
|
|
43804
|
+
severity: "HIGH",
|
|
43805
|
+
target: label,
|
|
43806
|
+
path: e.path,
|
|
43807
|
+
engine: RT_ENGINE,
|
|
43808
|
+
message: `installed file survived uninstall (ADDED in post-uninstall manifest)`,
|
|
43809
|
+
valueDigest: digestOf(`ADDED:${e.path}:${e.value}`),
|
|
43810
|
+
snippet: `residue: ${e.path}`
|
|
43811
|
+
});
|
|
43812
|
+
}
|
|
43813
|
+
for (const e of diff.modified) {
|
|
43814
|
+
const persist = isPersistenceSurface(e.path);
|
|
43815
|
+
findings.push({
|
|
43816
|
+
rule: persist ? RT_PERSIST_RULE : RT_MODIFY_RULE,
|
|
43817
|
+
severity: persist ? "CRITICAL" : "HIGH",
|
|
43818
|
+
target: label,
|
|
43819
|
+
path: e.path,
|
|
43820
|
+
engine: RT_ENGINE,
|
|
43821
|
+
message: persist ? `uninstall mutated a persistence surface (rc/systemd/cron/PATH) \u2014 content differs pre-install vs post-uninstall` : `file content differs pre-install vs post-uninstall (MODIFIED, not reverted by uninstall)`,
|
|
43822
|
+
valueDigest: digestOf(`MODIFIED:${e.path}:${e.value}`),
|
|
43823
|
+
snippet: persist ? `persistence: ${e.path}` : `modified: ${e.path}`
|
|
43824
|
+
});
|
|
43825
|
+
}
|
|
43826
|
+
return findings;
|
|
43827
|
+
}
|
|
43828
|
+
|
|
43829
|
+
// src/roundtrip/orchestrate.ts
|
|
43830
|
+
function containerNameFor(label, rand = randomBytes(3).toString("hex")) {
|
|
43831
|
+
return `rt-${sha256Hex(label).slice(0, 8)}-${rand}`;
|
|
43832
|
+
}
|
|
43833
|
+
var shArgv = (name) => {
|
|
43834
|
+
if (!/^[A-Za-z0-9@._+/-]+$/.test(name)) {
|
|
43835
|
+
throw new EngineRunError(`roundtrip: package name '${name}' rejected for shell composition`, null);
|
|
43836
|
+
}
|
|
43837
|
+
return [name];
|
|
43838
|
+
};
|
|
43839
|
+
var PROFILES = {
|
|
43840
|
+
npm: {
|
|
43841
|
+
image: "node:24-slim",
|
|
43842
|
+
artifactPath: "/root/p.tgz",
|
|
43843
|
+
excludes: BASE_EXCLUDES,
|
|
43844
|
+
install: (p) => ["npm", "install", "-g", "--foreground-scripts", "--no-audit", "--no-fund", p],
|
|
43845
|
+
uninstall: (s) => ["npm", "uninstall", "-g", shArgv(s.name)[0]]
|
|
43846
|
+
},
|
|
43847
|
+
pypi: {
|
|
43848
|
+
image: "python:3.12-slim",
|
|
43849
|
+
artifactPath: "/root/p.tar.gz",
|
|
43850
|
+
excludes: [...BASE_EXCLUDES, ...PYTHON_EXCLUDES],
|
|
43851
|
+
install: (p) => ["pip", "install", "--no-input", "--root-user-action=ignore", p],
|
|
43852
|
+
uninstall: (s) => ["pip", "uninstall", "-y", shArgv(s.name)[0]]
|
|
43853
|
+
},
|
|
43854
|
+
rubygems: {
|
|
43855
|
+
image: "ruby:3.4-slim",
|
|
43856
|
+
artifactPath: "/root/p.gem",
|
|
43857
|
+
excludes: BASE_EXCLUDES,
|
|
43858
|
+
install: (p) => ["gem", "install", "--no-document", p],
|
|
43859
|
+
uninstall: (s) => ["gem", "uninstall", shArgv(s.name)[0], "-x", "--force"]
|
|
43860
|
+
},
|
|
43861
|
+
crates: {
|
|
43862
|
+
image: "rust:1-slim",
|
|
43863
|
+
artifactPath: "/root/p.crate",
|
|
43864
|
+
excludes: [...BASE_EXCLUDES, ...CARGO_EXCLUDES],
|
|
43865
|
+
// W2.1 docker legs, verified on rust:1-slim / cargo 1.98: a bare positional
|
|
43866
|
+
// is parsed as a CRATE@VER spec (a directory 400s on "invalid character `/`"),
|
|
43867
|
+
// so the unpacked staging dir goes through --path; --no-track must NOT be
|
|
43868
|
+
// used because it suppresses the $ROOT/.crates2.json install record, which is
|
|
43869
|
+
// the only thing `cargo uninstall` reads (with it, every uninstall dies on
|
|
43870
|
+
// "package ID specification did not match"); and border's own /opt/<dir>
|
|
43871
|
+
// staging tree is never owned by cargo, so the chain that creates it removes
|
|
43872
|
+
// it — after `cargo install` succeeded, keeping install failure fail-closed.
|
|
43873
|
+
install: (p, s) => {
|
|
43874
|
+
const name = shArgv(s.name)[0];
|
|
43875
|
+
const dir = `${name}-${s.version}`;
|
|
43876
|
+
return ["sh", "-c", `tar xf ${p} -C /opt && cargo install --root /usr/local --path "/opt/${dir}" && rm -rf "/opt/${dir}"`];
|
|
43877
|
+
},
|
|
43878
|
+
uninstall: (s) => ["cargo", "uninstall", "--root", "/usr/local", shArgv(s.name)[0]]
|
|
43879
|
+
}
|
|
43880
|
+
};
|
|
43881
|
+
function profileFor(eco) {
|
|
43882
|
+
return PROFILES[eco];
|
|
43883
|
+
}
|
|
43884
|
+
function execChecked(exec, args, timeoutMs, what) {
|
|
43885
|
+
const r = exec(args, timeoutMs);
|
|
43886
|
+
if (r.status !== 0) {
|
|
43887
|
+
const tail3 = r.stderr.replace(/\s+/g, " ").slice(-240);
|
|
43888
|
+
throw new EngineRunError(`roundtrip: ${what} failed (exit ${String(r.status)})${tail3 ? `: ${tail3}` : ""} \u2014 cannot verify (fail-closed)`, r.status);
|
|
43889
|
+
}
|
|
43890
|
+
return r;
|
|
43891
|
+
}
|
|
43892
|
+
function runPipeline2(exec, profile, spec, label, hostPath, container) {
|
|
43893
|
+
execChecked(exec, ["run", "-d", "--name", container, profile.image, "sleep", String(CONTAINER_TTL_SEC)], DOCKER_RUN_TIMEOUT_MS, "container start");
|
|
43894
|
+
try {
|
|
43895
|
+
execChecked(exec, ["cp", hostPath, `${container}:${profile.artifactPath}`], DOCKER_CP_TIMEOUT_MS, "artifact copy");
|
|
43896
|
+
const m1 = parseManifest(takeSnapshot(exec, container, profile.excludes));
|
|
43897
|
+
execChecked(exec, ["exec", container, ...profile.install(profile.artifactPath, spec)], PHASE_TIMEOUT_MS, `install ${label}`);
|
|
43898
|
+
const m2 = parseManifest(takeSnapshot(exec, container, profile.excludes));
|
|
43899
|
+
const installDiff = diffManifests(m1, m2);
|
|
43900
|
+
const installDelta = installDiff.added.length + installDiff.removed.length + installDiff.modified.length;
|
|
43901
|
+
if (installDelta === 0) {
|
|
43902
|
+
throw new EngineRunError(
|
|
43903
|
+
`roundtrip: install of ${label} produced an empty filesystem delta \u2014 cannot verify it ran, refusing to grade a no-op`,
|
|
43904
|
+
null
|
|
43905
|
+
);
|
|
43906
|
+
}
|
|
43907
|
+
execChecked(exec, ["exec", container, ...profile.uninstall(spec)], PHASE_TIMEOUT_MS, `uninstall ${label}`);
|
|
43908
|
+
const m3 = parseManifest(takeSnapshot(exec, container, profile.excludes));
|
|
43909
|
+
return {
|
|
43910
|
+
findings: classifyResidue(label, diffManifests(m1, m3)),
|
|
43911
|
+
installDelta,
|
|
43912
|
+
baselineDigest: sha256Hex([...m1.keys()].join("\n"))
|
|
43913
|
+
};
|
|
43914
|
+
} finally {
|
|
43915
|
+
try {
|
|
43916
|
+
exec(["rm", "-f", container], DOCKER_RM_TIMEOUT_MS);
|
|
43917
|
+
} catch {
|
|
43918
|
+
}
|
|
43919
|
+
}
|
|
43920
|
+
}
|
|
43921
|
+
function takeSnapshot(exec, container, excludes) {
|
|
43922
|
+
return execChecked(
|
|
43923
|
+
exec,
|
|
43924
|
+
["exec", container, "sh", "-c", snapshotScript(excludes)],
|
|
43925
|
+
SNAPSHOT_TIMEOUT_MS,
|
|
43926
|
+
"manifest snapshot"
|
|
43927
|
+
).stdout;
|
|
43928
|
+
}
|
|
43929
|
+
async function runRoundtripCore(ctx, deps = {}) {
|
|
43930
|
+
const raw = ctx.positionals[0];
|
|
43931
|
+
if (ctx.positionals.length !== 1 || raw === void 0) {
|
|
43932
|
+
throw new UnknownArgError(
|
|
43933
|
+
`border roundtrip expects exactly one <spec> argument (got ${String(ctx.positionals.length)}); ${SCAN_SPEC_USAGE}`
|
|
43934
|
+
);
|
|
43935
|
+
}
|
|
43936
|
+
const spec = parseScanSpec(raw);
|
|
43937
|
+
const profile = profileFor(spec.ecosystem);
|
|
43938
|
+
const label = `${spec.ecosystem}:${spec.name}@${spec.version}`;
|
|
43939
|
+
const exec = deps.exec ?? realDockerExec();
|
|
43940
|
+
if (!dockerAvailableGuard(exec)) {
|
|
43941
|
+
ctx.stderr("roundtrip: docker unavailable \u2014 cannot verify");
|
|
43942
|
+
return EXIT_ERROR;
|
|
43943
|
+
}
|
|
43944
|
+
const baseDir = mkdtempSync5(join25(tmpdir3(), "border-roundtrip-"));
|
|
43945
|
+
try {
|
|
43946
|
+
const { bytes } = await fetchArtifact(spec, { fetcher: deps.fetcher ?? defaultScanFetcher });
|
|
43947
|
+
const hostPath = join25(baseDir, "artifact");
|
|
43948
|
+
writeFileSync7(hostPath, bytes);
|
|
43949
|
+
const container = containerNameFor(label);
|
|
43950
|
+
const { findings, installDelta, baselineDigest } = runPipeline2(exec, profile, spec, label, hostPath, container);
|
|
43951
|
+
const verdict = computeVerdict(findings);
|
|
43952
|
+
const report = {
|
|
43953
|
+
schemaVersion: 1,
|
|
43954
|
+
key: sha256Hex(`roundtrip:${label}:${baselineDigest}:${verdict}`),
|
|
43955
|
+
head: baselineDigest.slice(0, 40),
|
|
43956
|
+
dirty: false,
|
|
43957
|
+
exposureSet: [label],
|
|
43958
|
+
refSet: [],
|
|
43959
|
+
rulesHash: sha256Hex(`${profile.image}|${profile.install(profile.artifactPath, spec).join(" ")}|${profile.uninstall(spec).join(" ")}`),
|
|
43960
|
+
verdict,
|
|
43961
|
+
counts: countFindings(findings),
|
|
43962
|
+
findings,
|
|
43963
|
+
ts: (/* @__PURE__ */ new Date()).toISOString()
|
|
43964
|
+
};
|
|
43965
|
+
const sanitizer = new TextSanitizer();
|
|
43966
|
+
const line = (text) => sanitizer.sanitize(text).replace(/\s+/g, " ").trim();
|
|
43967
|
+
if (ctx.flags.json) {
|
|
43968
|
+
ctx.stdout(renderReportJson(report));
|
|
43969
|
+
} else {
|
|
43970
|
+
for (const f of report.findings) {
|
|
43971
|
+
ctx.stdout(line(` ${f.severity} ${f.rule} [${f.engine}] ${f.path ?? f.target} ${f.message}`));
|
|
43972
|
+
}
|
|
43973
|
+
ctx.stdout(line(`roundtrip: ${String(report.findings.length)} residue row(s); install-delta ${String(installDelta)} files`));
|
|
43974
|
+
}
|
|
43975
|
+
if (ctx.flags.record !== false) {
|
|
43976
|
+
await recordRoundtripProof(ctx, sha256Hex(bytes), report.findings.length, report.findings.length === 0 ? "clean" : "residue");
|
|
43977
|
+
}
|
|
43978
|
+
return exitCodeFromVerdict(verdict);
|
|
43979
|
+
} finally {
|
|
43980
|
+
rmSync12(baseDir, { recursive: true, force: true });
|
|
43981
|
+
}
|
|
43982
|
+
}
|
|
43983
|
+
async function recordRoundtripProof(ctx, artifactSha256, rows, verdict) {
|
|
43984
|
+
const notice = (msg) => ctx.stderr(`roundtrip: ${msg}`);
|
|
43985
|
+
try {
|
|
43986
|
+
const env2 = ctx.env;
|
|
43987
|
+
const load = loadConfig({
|
|
43988
|
+
cwd: ctx.cwd,
|
|
43989
|
+
...ctx.flags.config !== void 0 ? { configPath: ctx.flags.config } : {},
|
|
43990
|
+
...env2 !== void 0 ? { env: env2 } : {}
|
|
43991
|
+
});
|
|
43992
|
+
let effective = null;
|
|
43993
|
+
if (load.kind === "loaded") effective = load;
|
|
43994
|
+
else if (load.explicit !== void 0) effective = { kind: "loaded", config: load.explicit.config, warnings: [], source: load.explicit.source };
|
|
43995
|
+
if (effective === null) {
|
|
43996
|
+
notice("no border.yaml here \u2014 proof NOT recorded");
|
|
43997
|
+
return;
|
|
43998
|
+
}
|
|
43999
|
+
const repoDir = resolveRepoDir(ctx.cwd, { ...env2 !== void 0 ? { env: env2 } : {} });
|
|
44000
|
+
const probe = await probeEngines(effective.config, { ...env2 !== void 0 ? { env: env2 } : {} });
|
|
44001
|
+
if (probe.degraded) {
|
|
44002
|
+
notice("engine probe degraded \u2014 cannot mint the rulesHash this proof must match; proof NOT recorded");
|
|
44003
|
+
return;
|
|
44004
|
+
}
|
|
44005
|
+
const rulesHash = await computeCheckRulesHash({
|
|
44006
|
+
engineVersions: probe.engineVersions,
|
|
44007
|
+
configDigest: computeConfigDigest(effective),
|
|
44008
|
+
...env2 !== void 0 ? { env: env2 } : {}
|
|
44009
|
+
});
|
|
44010
|
+
appendRecord(repoDir, buildRoundtripRecord({ artifactSha256, verdict, rulesHash, rows }));
|
|
44011
|
+
notice(`proof recorded for ${artifactSha256.slice(0, 12)}\u2026 (verdict ${verdict}, ${String(rows)} residue row(s))`);
|
|
44012
|
+
} catch (err) {
|
|
44013
|
+
notice(`proof NOT recorded (${err instanceof Error ? err.message : String(err)}) \u2014 'border check' with residue.requireProof will treat this artifact as unproven`);
|
|
44014
|
+
}
|
|
44015
|
+
}
|
|
44016
|
+
function dockerAvailableGuard(exec) {
|
|
44017
|
+
try {
|
|
44018
|
+
return exec(["--version"], DOCKER_PROBE_TIMEOUT_MS).status === 0;
|
|
44019
|
+
} catch {
|
|
44020
|
+
return false;
|
|
44021
|
+
}
|
|
44022
|
+
}
|
|
44023
|
+
|
|
44024
|
+
// src/commands/roundtrip.ts
|
|
44025
|
+
var runRoundtrip = (ctx) => runRoundtripCore(ctx);
|
|
44026
|
+
|
|
44027
|
+
// src/scan/scan.ts
|
|
44028
|
+
import { mkdtempSync as mkdtempSync6 } from "node:fs";
|
|
44029
|
+
import { tmpdir as tmpdir4 } from "node:os";
|
|
44030
|
+
import { join as join27 } from "node:path";
|
|
44031
|
+
|
|
44032
|
+
// src/scan/config.ts
|
|
44033
|
+
function buildScanConfigYaml(eco) {
|
|
44034
|
+
return [
|
|
44035
|
+
"version: 1",
|
|
44036
|
+
"targets:",
|
|
44037
|
+
" git:",
|
|
44038
|
+
" remotes: []",
|
|
44039
|
+
` ${eco}: {}`,
|
|
44040
|
+
"rules:",
|
|
44041
|
+
" authors:",
|
|
44042
|
+
" emails: []",
|
|
44043
|
+
" names: []",
|
|
44044
|
+
" hosts: []",
|
|
44045
|
+
" ips: []",
|
|
44046
|
+
" pathPatterns: []",
|
|
44047
|
+
"residue:",
|
|
44048
|
+
" enabled: true",
|
|
44049
|
+
""
|
|
44050
|
+
].join("\n");
|
|
44051
|
+
}
|
|
44052
|
+
function synthesizeScanConfig(eco) {
|
|
44053
|
+
return parseConfig(buildScanConfigYaml(eco), "<border scan>");
|
|
44054
|
+
}
|
|
44055
|
+
|
|
43597
44056
|
// src/scan/materialize.ts
|
|
43598
|
-
import { existsSync as existsSync17, mkdirSync as mkdirSync11, readdirSync as readdirSync10, readFileSync as readFileSync16, rmSync as
|
|
43599
|
-
import { join as
|
|
44057
|
+
import { existsSync as existsSync17, mkdirSync as mkdirSync11, readdirSync as readdirSync10, readFileSync as readFileSync16, rmSync as rmSync13, writeFileSync as writeFileSync8 } from "node:fs";
|
|
44058
|
+
import { join as join26 } from "node:path";
|
|
43600
44059
|
var CRATES_ENVELOPE_FILES = ["Cargo.toml.orig", ".cargo-ok", ".cargo_vcs_info.json"];
|
|
43601
44060
|
var fail2 = (why) => {
|
|
43602
44061
|
throw new Error(`border scan cannot materialize the artifact (${why}) \u2014 refusing to stage an unusable tree (fail-closed)`);
|
|
@@ -43613,12 +44072,12 @@ function unwrapSingleDir(dir) {
|
|
|
43613
44072
|
const entries = readdirSync10(dir, { withFileTypes: true });
|
|
43614
44073
|
const dirs = entries.filter((e) => e.isDirectory());
|
|
43615
44074
|
if (entries.length === 1 && dirs.length === 1 && dirs[0] !== void 0) {
|
|
43616
|
-
return
|
|
44075
|
+
return join26(dir, dirs[0].name);
|
|
43617
44076
|
}
|
|
43618
44077
|
return dir;
|
|
43619
44078
|
}
|
|
43620
44079
|
function requireManifest(repoDir, ecosystem, candidates) {
|
|
43621
|
-
if (!candidates.some((rel) => existsSync17(
|
|
44080
|
+
if (!candidates.some((rel) => existsSync17(join26(repoDir, rel)))) {
|
|
43622
44081
|
fail2(`${ecosystem} package tree has none of [${candidates.join(", ")}] at its root`);
|
|
43623
44082
|
}
|
|
43624
44083
|
}
|
|
@@ -43628,18 +44087,18 @@ function materializePackage(o) {
|
|
|
43628
44087
|
`);
|
|
43629
44088
|
});
|
|
43630
44089
|
const safe = safeArtifactName(o.filename, `${o.ecosystem}-artifact`);
|
|
43631
|
-
const archive =
|
|
43632
|
-
|
|
43633
|
-
const extractDir =
|
|
44090
|
+
const archive = join26(o.baseDir, safe);
|
|
44091
|
+
writeFileSync8(archive, o.bytes);
|
|
44092
|
+
const extractDir = join26(o.baseDir, "extract");
|
|
43634
44093
|
mkdirSync11(extractDir, { recursive: true });
|
|
43635
44094
|
extractFlat(archive, extractDir);
|
|
43636
44095
|
let repoDir = unwrapSingleDir(extractDir);
|
|
43637
44096
|
if (o.ecosystem === "rubygems") {
|
|
43638
44097
|
const outer = repoDir;
|
|
43639
44098
|
const dataCandidates = findNativeMissArchives(outer).filter((f) => f.endsWith("data.tar.gz"));
|
|
43640
|
-
const inner = dataCandidates[0] ??
|
|
44099
|
+
const inner = dataCandidates[0] ?? join26(outer, "data.tar.gz");
|
|
43641
44100
|
if (!existsSync17(inner)) fail2(".gem archive contains no data.tar.gz");
|
|
43642
|
-
const gemTree =
|
|
44101
|
+
const gemTree = join26(o.baseDir, "gem-tree");
|
|
43643
44102
|
mkdirSync11(gemTree, { recursive: true });
|
|
43644
44103
|
extractFlat(inner, gemTree);
|
|
43645
44104
|
repoDir = unwrapSingleDir(gemTree);
|
|
@@ -43650,10 +44109,10 @@ function materializePackage(o) {
|
|
|
43650
44109
|
} else {
|
|
43651
44110
|
requireManifest(repoDir, o.ecosystem, ["Cargo.toml"]);
|
|
43652
44111
|
for (const file2 of CRATES_ENVELOPE_FILES) {
|
|
43653
|
-
const path4 =
|
|
44112
|
+
const path4 = join26(repoDir, file2);
|
|
43654
44113
|
if (!existsSync17(path4)) continue;
|
|
43655
44114
|
const original = readFileSync16(path4);
|
|
43656
|
-
|
|
44115
|
+
rmSync13(path4, { force: true });
|
|
43657
44116
|
note(`border scan: crates envelope normalized: ${file2} sha256=${sha256Hex(original)}`);
|
|
43658
44117
|
}
|
|
43659
44118
|
}
|
|
@@ -43665,45 +44124,6 @@ function materializePackage(o) {
|
|
|
43665
44124
|
return repoDir;
|
|
43666
44125
|
}
|
|
43667
44126
|
|
|
43668
|
-
// src/scan/spec.ts
|
|
43669
|
-
var ECOSYSTEMS = ["npm", "pypi", "crates", "rubygems"];
|
|
43670
|
-
var SCAN_SPEC_USAGE = "usage: border scan <[ecosystem:]name@version> \u2014 e.g. border scan puppeteer@23.11.1 | pypi:requests@2.32.3 | crates:serde@1.0.219 | rubygems:rails@7.1.1";
|
|
43671
|
-
var PLAIN_NAME = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
|
|
43672
|
-
var SCOPED_NAME = /^@[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
43673
|
-
var VERSION = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
|
|
43674
|
-
var bad2 = (why) => {
|
|
43675
|
-
throw new UnknownArgError(`${why}; ${SCAN_SPEC_USAGE}`);
|
|
43676
|
-
};
|
|
43677
|
-
function parseScanSpec(raw) {
|
|
43678
|
-
const text = raw.trim();
|
|
43679
|
-
if (text.length === 0) return bad2("empty spec");
|
|
43680
|
-
let ecosystem = "npm";
|
|
43681
|
-
let rest = text;
|
|
43682
|
-
const colon = text.indexOf(":");
|
|
43683
|
-
if (colon >= 0) {
|
|
43684
|
-
const prefix = text.slice(0, colon);
|
|
43685
|
-
const found = ECOSYSTEMS.find((e) => e === prefix);
|
|
43686
|
-
if (found === void 0) {
|
|
43687
|
-
return bad2(`unknown ecosystem '${prefix}' \u2014 expected one of: ${ECOSYSTEMS.join(", ")}`);
|
|
43688
|
-
}
|
|
43689
|
-
ecosystem = found;
|
|
43690
|
-
rest = text.slice(colon + 1);
|
|
43691
|
-
if (rest.length === 0) return bad2("missing package name after the ecosystem prefix");
|
|
43692
|
-
}
|
|
43693
|
-
const scoped = rest.startsWith("@");
|
|
43694
|
-
const at = rest.lastIndexOf("@");
|
|
43695
|
-
if (at <= 0) return bad2(`spec '${raw}' is missing '@version'`);
|
|
43696
|
-
const name = rest.slice(0, at);
|
|
43697
|
-
const version2 = rest.slice(at + 1);
|
|
43698
|
-
if (!(scoped ? SCOPED_NAME : PLAIN_NAME).test(name)) {
|
|
43699
|
-
return bad2(`invalid package name '${name}'`);
|
|
43700
|
-
}
|
|
43701
|
-
if (!VERSION.test(version2)) {
|
|
43702
|
-
return bad2(`invalid version '${version2}' in spec '${raw}'`);
|
|
43703
|
-
}
|
|
43704
|
-
return { ecosystem, name, version: version2 };
|
|
43705
|
-
}
|
|
43706
|
-
|
|
43707
44127
|
// src/scan/scan.ts
|
|
43708
44128
|
async function runScanCore(ctx, deps = {}) {
|
|
43709
44129
|
const raw = ctx.positionals[0];
|
|
@@ -43719,7 +44139,7 @@ async function runScanCore(ctx, deps = {}) {
|
|
|
43719
44139
|
}
|
|
43720
44140
|
const cfg = synthesizeScanConfig(spec.ecosystem);
|
|
43721
44141
|
const fetcher = deps.fetcher ?? defaultScanFetcher;
|
|
43722
|
-
const baseDir =
|
|
44142
|
+
const baseDir = mkdtempSync6(join27(tmpdir4(), "border-scan-"));
|
|
43723
44143
|
try {
|
|
43724
44144
|
const { bytes, filename } = await fetchArtifact(spec, { fetcher });
|
|
43725
44145
|
const env2 = { ...ctx.env };
|
|
@@ -43843,11 +44263,12 @@ var handlers = {
|
|
|
43843
44263
|
status: runStatus,
|
|
43844
44264
|
"llm-request": runLlmRequest,
|
|
43845
44265
|
"llm-ingest": runLlmIngest,
|
|
43846
|
-
scan: runScan2
|
|
44266
|
+
scan: runScan2,
|
|
44267
|
+
roundtrip: runRoundtrip
|
|
43847
44268
|
};
|
|
43848
44269
|
|
|
43849
44270
|
// src/cli/types.ts
|
|
43850
|
-
var SUBCOMMANDS = ["check", "push", "status", "llm-request", "llm-ingest", "scan"];
|
|
44271
|
+
var SUBCOMMANDS = ["check", "push", "status", "llm-request", "llm-ingest", "scan", "roundtrip"];
|
|
43851
44272
|
|
|
43852
44273
|
// src/cli.ts
|
|
43853
44274
|
var VALID_TARGETS = targetIds();
|
|
@@ -43864,6 +44285,7 @@ function usage() {
|
|
|
43864
44285
|
" llm-request emit the provenance request for LLM-authored commits",
|
|
43865
44286
|
" llm-ingest record provenance captured by the harness",
|
|
43866
44287
|
" scan inspect a third-party package ([ecosystem:]name@version) for residue",
|
|
44288
|
+
" roundtrip install+uninstall a package in a throwaway container, report uninstall residue",
|
|
43867
44289
|
"",
|
|
43868
44290
|
"flags:",
|
|
43869
44291
|
" --config <path> config file (default: ./border.yaml, then git-remote fallback)",
|
|
@@ -43873,11 +44295,12 @@ function usage() {
|
|
|
43873
44295
|
" --require-engine <list> comma-separated engines that must be healthy (else exit 2)",
|
|
43874
44296
|
" --llm include the optional LLM review layer",
|
|
43875
44297
|
" --json machine-readable report on stdout",
|
|
44298
|
+
" --record / --no-record `border roundtrip` records a ledger proof by default; --no-record opts out",
|
|
43876
44299
|
" --help, -h this table",
|
|
43877
44300
|
"",
|
|
43878
44301
|
"exit codes: 0 pass (MEDIUM/INFO/LOW allowed) | 1 gate-blocked (CRITICAL/HIGH) or partial push | 2 config/tool error",
|
|
43879
44302
|
"",
|
|
43880
|
-
"subcommands: check, push, status, llm-request, llm-ingest, scan"
|
|
44303
|
+
"subcommands: check, push, status, llm-request, llm-ingest, scan, roundtrip"
|
|
43881
44304
|
].join("\n");
|
|
43882
44305
|
}
|
|
43883
44306
|
function isSubcommand(value) {
|
|
@@ -43908,10 +44331,15 @@ function parseFlags(argv) {
|
|
|
43908
44331
|
"require-engine": { type: "string" },
|
|
43909
44332
|
llm: { type: "boolean" },
|
|
43910
44333
|
json: { type: "boolean" },
|
|
44334
|
+
record: { type: "boolean" },
|
|
44335
|
+
"no-record": { type: "boolean" },
|
|
43911
44336
|
help: { type: "boolean" }
|
|
43912
44337
|
}
|
|
43913
44338
|
});
|
|
43914
44339
|
const { config: config2, targets, force, yes, llm, json: json2 } = values;
|
|
44340
|
+
if (values.record === true && values["no-record"] === true) {
|
|
44341
|
+
throw new UnknownArgError("--record and --no-record contradict each other");
|
|
44342
|
+
}
|
|
43915
44343
|
const requireEngine = values["require-engine"];
|
|
43916
44344
|
const flags = {
|
|
43917
44345
|
...config2 !== void 0 ? { config: config2 } : {},
|
|
@@ -43920,7 +44348,8 @@ function parseFlags(argv) {
|
|
|
43920
44348
|
yes: yes === true,
|
|
43921
44349
|
...requireEngine !== void 0 ? { requireEngine: splitList(requireEngine, { flag: "--require-engine", item: "engine" }) } : {},
|
|
43922
44350
|
llm: llm === true,
|
|
43923
|
-
json: json2 === true
|
|
44351
|
+
json: json2 === true,
|
|
44352
|
+
...values["no-record"] === true ? { record: false } : values.record === true ? { record: true } : {}
|
|
43924
44353
|
};
|
|
43925
44354
|
return { flags, positionals, help: values.help === true };
|
|
43926
44355
|
} catch (err) {
|
|
@@ -43986,11 +44415,18 @@ function run(argv, stdout = (line) => {
|
|
|
43986
44415
|
}
|
|
43987
44416
|
|
|
43988
44417
|
// src/index.ts
|
|
43989
|
-
function isMainModule() {
|
|
43990
|
-
|
|
43991
|
-
|
|
44418
|
+
function isMainModule({ argv1, moduleUrl, realpath }) {
|
|
44419
|
+
if (argv1 === void 0) return false;
|
|
44420
|
+
const canonical = (p) => {
|
|
44421
|
+
try {
|
|
44422
|
+
return realpath(p);
|
|
44423
|
+
} catch {
|
|
44424
|
+
return p;
|
|
44425
|
+
}
|
|
44426
|
+
};
|
|
44427
|
+
return pathToFileURL(canonical(argv1)).href === pathToFileURL(canonical(fileURLToPath5(moduleUrl))).href;
|
|
43992
44428
|
}
|
|
43993
|
-
if (isMainModule()) {
|
|
44429
|
+
if (isMainModule({ argv1: process.argv[1], moduleUrl: import.meta.url, realpath: realpathSync.native })) {
|
|
43994
44430
|
const outcome = run(process.argv.slice(2));
|
|
43995
44431
|
process.exitCode = outcome instanceof Promise ? await outcome : outcome;
|
|
43996
44432
|
}
|
|
@@ -43999,6 +44435,7 @@ export {
|
|
|
43999
44435
|
EXIT_ERROR,
|
|
44000
44436
|
EXIT_PASS,
|
|
44001
44437
|
SUBCOMMANDS,
|
|
44438
|
+
isMainModule,
|
|
44002
44439
|
run,
|
|
44003
44440
|
translateError,
|
|
44004
44441
|
usage
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "border-customs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"//": "publint is a runtime dep (exact pin): the npm-stage engine is invoked via bin resolution against the installed layout, so consumers get the publint-fail leg out of the box; the dev suite resolves the same node_modules/.bin/publint — no devDependencies entry is needed for it to stay green.",
|
|
4
5
|
"description": "Fail-closed git push gate: secret scanning + supply-chain checks before any remote leaves the machine",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"bin": {
|
|
@@ -30,13 +31,13 @@
|
|
|
30
31
|
"@secretlint/secretlint-rule-no-homedir": "13.0.5",
|
|
31
32
|
"@secretlint/secretlint-rule-pattern": "13.0.5",
|
|
32
33
|
"@secretlint/secretlint-rule-preset-recommend": "13.0.5",
|
|
34
|
+
"publint": "0.3.24",
|
|
33
35
|
"yaml": "2.9.0",
|
|
34
36
|
"zod": "4.5.4"
|
|
35
37
|
},
|
|
36
38
|
"devDependencies": {
|
|
37
39
|
"@types/node": "22.19.0",
|
|
38
40
|
"esbuild": "0.25.12",
|
|
39
|
-
"publint": "0.3.24",
|
|
40
41
|
"secretlint": "13.0.5",
|
|
41
42
|
"typescript": "5.9.3",
|
|
42
43
|
"verdaccio": "6.10.2"
|