border-customs 0.1.0 → 0.3.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 +256 -40
- package/dist/assets/residue-src/crates.ts +228 -0
- package/dist/assets/residue-src/npm.ts +294 -0
- package/dist/assets/residue-src/pypi.ts +333 -0
- package/dist/assets/residue-src/residue.ts +348 -0
- package/dist/assets/residue-src/residueGem.ts +371 -0
- package/dist/assets/residue-src/residueMatchers.ts +417 -0
- package/dist/assets/residue-src/residuePy.ts +26 -0
- package/dist/assets/residue-src/residueRust.ts +252 -0
- package/dist/assets/residue-src/rubygems.ts +278 -0
- package/dist/index.js +5126 -2825
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# border
|
|
2
2
|
|
|
3
|
-
A fail-closed pre-push gate for git, npm, and
|
|
3
|
+
A fail-closed pre-push gate for git, npm, PyPI, crates.io, and RubyGems. It scans your repo for secrets and
|
|
4
4
|
supply-chain risk, then refuses to let anything leave the machine unless a fresh, unbroken
|
|
5
5
|
check passed for exactly the state you are about to publish.
|
|
6
6
|
|
|
@@ -12,8 +12,19 @@ border check
|
|
|
12
12
|
|
|
13
13
|
Requires Node >= 22 and the `gitleaks` binary on `PATH` (border vendors the rule config for
|
|
14
14
|
gitleaks 8.30.1; the binary itself is yours to install, same as `git`). secretlint runs
|
|
15
|
-
in-process as a bundled dependency.
|
|
16
|
-
|
|
15
|
+
in-process as a bundled dependency. Each registry leg additionally shells out to its platform's
|
|
16
|
+
own publisher, which must be on `PATH` whenever that target is configured; a missing publisher
|
|
17
|
+
binary is exit 2, never a skipped leg:
|
|
18
|
+
|
|
19
|
+
| Registry target | Publisher border runs | Version the same-bytes proofs were measured on |
|
|
20
|
+
| --- | --- | --- |
|
|
21
|
+
| `npm` | `npm` | (unchanged since 0.1.0) |
|
|
22
|
+
| `pypi` | `python3 -m build` / `python3 -m twine` | (unchanged since 0.1.0) |
|
|
23
|
+
| `crates` | `cargo` | cargo 1.93.1 (rustup) |
|
|
24
|
+
| `rubygems` | `gem` | RubyGems 3.6.7 on ruby 3.3.8 |
|
|
25
|
+
|
|
26
|
+
Other toolchain versions of `cargo` and `gem` are untested by those byte-determinism proofs.
|
|
27
|
+
Optional engines are listed in [Configuration](#configuration).
|
|
17
28
|
|
|
18
29
|
## The problem it solves
|
|
19
30
|
|
|
@@ -39,9 +50,9 @@ pushed" includes the six things people forget:
|
|
|
39
50
|
transmit, not just HEAD.
|
|
40
51
|
- **Archives.** Tarballs and zips inside the repo are unpacked and scanned with
|
|
41
52
|
`<archive>!<inner-path>` attribution.
|
|
42
|
-
- **The published bytes, not the source tree.** `npm pack
|
|
43
|
-
what ships. border packs/builds once into
|
|
44
|
-
re-hashes them again at publish time.
|
|
53
|
+
- **The published bytes, not the source tree.** `npm pack`, `python -m build`,
|
|
54
|
+
`cargo package`, and `gem build` rewrite what ships. border packs/builds once into
|
|
55
|
+
`.border/dist/`, scans those exact bytes, and re-hashes them again at publish time.
|
|
45
56
|
- **The registry.** Pushing `package.json` at version `1.2.3` when `1.2.3` already exists
|
|
46
57
|
on the registry is a supply-chain event (silent stale artifact, or squatted name). border
|
|
47
58
|
fails the check on "version exists, bump required" and on foreign ownership of your name.
|
|
@@ -57,9 +68,9 @@ One sentence: **a PASS is a statement about a fingerprint, not about the past.**
|
|
|
57
68
|
checkKey = sha256( headSha
|
|
58
69
|
⊕ porcelainDigest # every tracked/untracked/staged byte-change
|
|
59
70
|
⊕ rulesHash # config digest + vendored rules + engine versions + prompt template
|
|
60
|
-
⊕ exposureSet # sanitized remote URLs + npm/pypi name@version
|
|
71
|
+
⊕ exposureSet # sanitized remote URLs + npm/pypi/crates/rubygems name@version
|
|
61
72
|
⊕ refSet # branch being pushed + every local tag
|
|
62
|
-
⊕ effectiveTargets ) # git, npm, pypi as configured for this run
|
|
73
|
+
⊕ effectiveTargets ) # git, npm, pypi, crates, rubygems as configured for this run
|
|
63
74
|
```
|
|
64
75
|
|
|
65
76
|
Move *any* of those six and the key moves, the stored PASS no longer covers the state, and
|
|
@@ -107,22 +118,33 @@ it, and the two registry-facing legs (pre-flight and publish) both have to agree
|
|
|
107
118
|
against the history of every ref *and* against the object set a remote does not already
|
|
108
119
|
have (the transmit set). Oversized-file, checked-in-binary, and notebook-output rules
|
|
109
120
|
round it out.
|
|
110
|
-
7. **Registry pre-flight** (npm
|
|
111
|
-
already published ⇒ CRITICAL `version-exists` ("bump version required");
|
|
112
|
-
someone outside your `rules.authors` allow-list ⇒ CRITICAL
|
|
113
|
-
ambiguous ownership signals ⇒ also CRITICAL, because guessing wrong
|
|
114
|
-
Critically, **silence never means absent**: an empty stdout from
|
|
115
|
-
non-404 PyPI response, a
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
121
|
+
7. **Registry pre-flight** (npm, PyPI, crates.io, and RubyGems targets). Three outcomes per
|
|
122
|
+
target: version already published ⇒ CRITICAL `version-exists` ("bump version required");
|
|
123
|
+
name owned by someone outside your `rules.authors` allow-list ⇒ CRITICAL
|
|
124
|
+
`name-foreign-owner`; ambiguous ownership signals ⇒ also CRITICAL, because guessing wrong
|
|
125
|
+
is how squats ship. Critically, **silence never means absent**: an empty stdout from
|
|
126
|
+
`npm view`, a non-200 non-404 PyPI response, a crates.io 403 (the API rejects requests
|
|
127
|
+
without a self-identifying User-Agent; border sends one, and a 403 still stops the gate
|
|
128
|
+
instead of being read as "not published"), a rubygems 404 whose body text does not match
|
|
129
|
+
one of that API's exact *absent* messages (its 404s are text, not JSON, and an
|
|
130
|
+
unrecognized one is never guessed), a timeout, or unparseable JSON all fail closed as
|
|
131
|
+
exit 2. An unreachable registry blocks the push instead of letting it run blind.
|
|
132
|
+
8. **Artifact stage.** If registry targets are configured, packages are built **once** into
|
|
133
|
+
`.border/dist/` (`npm pack --ignore-scripts`, `python -m build --no-isolation`,
|
|
134
|
+
`cargo package --allow-dirty --no-verify`, `gem build <name>.gemspec -o .border/dist/`),
|
|
135
|
+
that exact byte-stream is scanned (gitleaks + secretlint over the extracted contents),
|
|
136
|
+
manifest-diffed (lifecycle `preinstall`/`install`/`postinstall`/`prepare` hooks are
|
|
137
|
+
CRITICAL; entries outside the `files` whitelist are HIGH; PyPI sdists get a
|
|
138
|
+
`sdist-unexpected-file` HIGH because setuptools builds from the working tree and is
|
|
139
|
+
`.gitignore`-blind), the npm and PyPI artifacts are additionally checked with
|
|
140
|
+
`publint` / `twine check --strict`, and everything is recorded as
|
|
141
|
+
`{file, sha256, bytes}` in the ledger.
|
|
142
|
+
At publish time the *same bytes* are re-hashed: any mismatch is exit 2, before the wire.
|
|
143
|
+
The crates leg carries one extra assertion because `cargo publish` repackages from the
|
|
144
|
+
manifest instead of uploading the staged file: immediately before the spawn, border
|
|
145
|
+
re-runs the check's exact `cargo package` into a throwaway directory and refuses to
|
|
146
|
+
publish if the fresh digest differs from the certified `.crate`. `gem push` uploads the
|
|
147
|
+
recorded `.gem` byte-for-byte, so it needs no such re-assertion.
|
|
126
148
|
9. **Report.** Findings carry `valueDigest` (sha256 of the matched value) and a masked
|
|
127
149
|
snippet: fully blocked for short values, else `first4…last4`. Raw secret bytes never
|
|
128
150
|
leave process memory. A per-run `TextSanitizer` holds the digest-to-value registry and
|
|
@@ -142,12 +164,56 @@ The **skip-ledger** is what makes the gate cheap enough to run on every push: a
|
|
|
142
164
|
`border check` on an unchanged fingerprint replays the recorded verdict in well under a
|
|
143
165
|
second. The replay is not blind trust. Before honoring a skip border re-derives the live
|
|
144
166
|
fingerprint (including engine probes), refuses skips recorded in the other LLM mode, and
|
|
145
|
-
re-packs npm artifacts to prove the recorded
|
|
146
|
-
(a dirty tree, a drifted `package.json`, or a changed npm version all force a
|
|
147
|
-
|
|
148
|
-
|
|
167
|
+
re-packs npm, crates, and rubygems artifacts to prove the recorded digests still match fresh
|
|
168
|
+
byte-for-byte (a dirty tree, a drifted `package.json`, or a changed npm version all force a
|
|
169
|
+
full re-check; `cargo package` is deterministic per HEAD and stamps the sha into
|
|
170
|
+
`.cargo_vcs_info.json`, and `gem build` is byte-deterministic only because RubyGems embeds a
|
|
171
|
+
fixed default date, which is why a gemspec that assigns `s.date` is rejected outright rather
|
|
172
|
+
than certified; PyPI builds are not byte-reproducible, so their skip proof is head+porcelain
|
|
173
|
+
equality, and publish still re-hashes the exact dist files). A corrupt ledger line is dropped with a loud
|
|
149
174
|
warning, never a crash, and never a silently empty history.
|
|
150
175
|
|
|
176
|
+
### The residue scan (0.3.0)
|
|
177
|
+
|
|
178
|
+
The artifact stage no longer treats every install-time hook as one blanket CRITICAL. Packed
|
|
179
|
+
and built bytes are additionally run through a **closed signature table** that classifies what
|
|
180
|
+
an installer *does*, per tier — the split behind opencode-ai's in-tree `postinstall.mjs`
|
|
181
|
+
(legit) vs. a `curl … | bash` rc-appender (not):
|
|
182
|
+
|
|
183
|
+
| Tier | Rule | Severity | One-liner |
|
|
184
|
+
| --- | --- | --- | --- |
|
|
185
|
+
| T0 | *(no finding)* | — | Silent install: recorded in the ledger, never flagged. |
|
|
186
|
+
| T1 | `residue-in-tree-hook` | MEDIUM | Hook that only touches its own package tree and matches one closed safe-shape signature (the opencode-ai anatomy). Unknown hook shapes keep the original CRITICAL row verbatim — T1 is the ONLY downgrade in 0.3.0. |
|
|
187
|
+
| T2 | `residue-install-download` | HIGH | Install-time network fetch: bytes that never passed registry review run on every consumer install. |
|
|
188
|
+
| T3 | `residue-out-of-tree-write` | HIGH | Writes outside the package dir: shell rc files, `profile.d`, `git config --global`, `authorized_keys`, `%APPDATA%`, PATH-carrier lines (`setx /F PATH`, `HKCU\Environment`, …). |
|
|
189
|
+
| T4 | `residue-persistence-primitive` | CRITICAL | Any reference to cron / `systemctl --user` / `launchctl` / XDG autostart / `schtasks` / registry Run keys in install-time code or anything it reaches. Malware class: no legitimate pass exists. |
|
|
190
|
+
| cross | `residue-cross-manager-write` | HIGH | A lifecycle/build script spawning a *foreign* package manager (`pip install --user` from npm…): files land on another ledger and reclaim by this ledger is impossible by construction. |
|
|
191
|
+
| pairing | `residue-pairing-missing` | HIGH | A `# BEGIN <id>` marker-block WRITE whose symmetric REMOVE path or CLI inverse is absent. Detection only: a verified pair still pays the T3 HIGH — waiver downgrade is 0.4.0's roundtrip valve. |
|
|
192
|
+
| gem | `residue-gem-unmatched-extension` | MEDIUM | A Ruby `extensions:`/build hook the classifier cannot resolve — surfaced, never silently clean. |
|
|
193
|
+
|
|
194
|
+
The pairing classes behind these rows (multi-channel PASS **C5** subset/ordering discipline;
|
|
195
|
+
**C9** interpolated-and-indirected root spellings; **C10** comment/quote-boundary shapes that
|
|
196
|
+
once silent-passed) are frozen with their evidence in
|
|
197
|
+
[src/artifacts/RESIDUE-CONTRACT.md](src/artifacts/RESIDUE-CONTRACT.md).
|
|
198
|
+
|
|
199
|
+
**Fail-closed:** a residue scan whose rule table or classifier sources cannot be read is exit 2,
|
|
200
|
+
never a silent pass — and the classifier source bytes are part of the ledger `rulesHash`, so
|
|
201
|
+
editing any signature invalidates every cached PASS even at the same commit.
|
|
202
|
+
**Opt-out:** `residue: { enabled: false }` in `border.yaml` (strict; unknown sibling keys exit 2)
|
|
203
|
+
hides exactly the rows above from the report and verdict and nothing else; flipping it changes
|
|
204
|
+
the fingerprint and forces a re-check rather than honoring a stale PASS.
|
|
205
|
+
|
|
206
|
+
**Boundary honesty.** Static analysis proves *capability*, not *fact*: a T2 row says the
|
|
207
|
+
installer *can* fetch and run remote bytes at install time, not that it shipped malware, and a
|
|
208
|
+
clean residue scan is an absence of matched signatures, never a certificate of benign intent.
|
|
209
|
+
The signature table is deliberately closed, and the list of shapes it **cannot see** —
|
|
210
|
+
content-writes, handle-variants, dead spellings, interpolation-then-slash, span-quirk
|
|
211
|
+
over-blocks, IO-instance writers, user-shadowed bare verbs — lives in
|
|
212
|
+
[RESIDUE-CONTRACT.md §8](src/artifacts/RESIDUE-CONTRACT.md); this README points, it does not
|
|
213
|
+
restate. The empirical roundtrip (install → snapshot → uninstall → rc-delta-zero) is the 0.4.0
|
|
214
|
+
valve. **border is NOT a malware sandbox**: it never executes, intercepts, or sandboxes the
|
|
215
|
+
code it reads; it matches text and blocks the push.
|
|
216
|
+
|
|
151
217
|
## Architecture: `border push`
|
|
152
218
|
|
|
153
219
|
`border push` is a state machine over per-target states, recomputed from live git queries,
|
|
@@ -160,8 +226,9 @@ never from cache:
|
|
|
160
226
|
| `BLOCKED` | no PASS for this state (run `border check`), or a registry finding says stop |
|
|
161
227
|
|
|
162
228
|
- Bare `border push` is a **DRY-RUN**: it prints the exact `git push --dry-run` /
|
|
163
|
-
`npm publish` / `twine upload` lines it would run, refuses
|
|
164
|
-
gate's verdict. `--yes` is what executes.
|
|
229
|
+
`npm publish` / `twine upload` / `cargo publish` / `gem push` lines it would run, refuses
|
|
230
|
+
to act, and exits with the gate's verdict. `--yes` is what executes. The real line shapes
|
|
231
|
+
per channel are in [What `border push` actually runs](#what-border-push-actually-runs).
|
|
165
232
|
- Multi-remote git push is **all-or-nothing before anything moves**: every remote × every
|
|
166
233
|
ref must be a fast-forward, checked up front. If any remote diverged, border prints
|
|
167
234
|
`DIVERGED` with the offending shas and pushes nothing. **It never force-pushes; there is
|
|
@@ -169,12 +236,44 @@ never from cache:
|
|
|
169
236
|
- Registry legs go through the same gate as push: a PASS record for the current key, then
|
|
170
237
|
the artifact re-hash match against `.border/dist/`, then an immediate version-exists
|
|
171
238
|
re-probe (the registry is allowed to change between check and publish), then the upload
|
|
172
|
-
with `stdio: inherit` so `npm
|
|
173
|
-
border never reads, stores, or handles registry tokens
|
|
239
|
+
with `stdio: inherit` so `npm`, `twine`, `cargo`, and `gem` OTP and credential prompts
|
|
240
|
+
reach you untouched. border never reads, stores, or handles registry tokens (including
|
|
241
|
+
`CARGO_REGISTRY_TOKEN` and `GEM_HOST_API_KEY`, which the publisher tools own). Published versions are not
|
|
174
242
|
retried on failure because a half-published version can never be republished.
|
|
175
243
|
- If one remote of several succeeded before a failure, border says so explicitly (PARTIAL)
|
|
176
244
|
and exits 1; a rerun of `border push --yes` picks up only the still-PENDING targets.
|
|
177
245
|
|
|
246
|
+
### What `border push` actually runs
|
|
247
|
+
|
|
248
|
+
Every DRY-RUN line is rendered from the same argv source as the real publish spawn (one line
|
|
249
|
+
per recorded artifact row, execution order `git` remotes, then npm, PyPI, crates, rubygems).
|
|
250
|
+
For a repo configured like the five-target `border.yaml` in
|
|
251
|
+
[Configuration](#configuration), the plan prints as:
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
$ border push
|
|
255
|
+
border DRY-RUN: no --yes, so nothing runs — this is the plan (m-R5-a) contract
|
|
256
|
+
DRY-RUN: git push --dry-run origin --follow-tags (https://github.com/acme/widgets.git)
|
|
257
|
+
DRY-RUN: npm publish .border/dist/widgets-1.2.3.tgz --registry https://registry.npmjs.org
|
|
258
|
+
DRY-RUN: twine upload --repository-url https://pypi.org .border/dist/widgets-1.2.3-py3-none-any.whl .border/dist/widgets-1.2.3.tar.gz
|
|
259
|
+
DRY-RUN: cargo publish --allow-dirty --no-verify
|
|
260
|
+
DRY-RUN: gem push .border/dist/widgets-1.2.3.gem --host https://gems.acme.example
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
- `cargo publish` is the one line with no filename: the command takes no positional `.crate`
|
|
264
|
+
(verified against cargo 1.93.1 `--help`; it always repackages from the manifest), so the
|
|
265
|
+
pre-publish repackage digest-assert described in step 8 of the check pipeline is what pins
|
|
266
|
+
the upload back to the certified bytes. Divergence is exit 2 before anything is sent.
|
|
267
|
+
- `twine upload` is a single row carrying every recorded `.whl`/`.tar.gz`; `npm publish` and
|
|
268
|
+
`gem push` get one row per staged file.
|
|
269
|
+
- A `--registry` / `--repository-url` / `--host` flag appears only when the config sets it.
|
|
270
|
+
With `targets.npm.registry`, `targets.pypi.repository`, and `targets.rubygems.host` unset,
|
|
271
|
+
the lines are the bare `npm publish <file>`, `twine upload <files...>`, and
|
|
272
|
+
`gem push <file>`, and the tool's own defaults decide where bytes go.
|
|
273
|
+
- If the fingerprint has no PASS covering a target, dry-run cannot list its artifacts and
|
|
274
|
+
prints `DRY-RUN: <target> registry leg: run 'border check --force' first — dry-run cannot
|
|
275
|
+
list artifacts` in place of the command lines.
|
|
276
|
+
|
|
178
277
|
## Architecture: the LLM layer (optional, agent-executed)
|
|
179
278
|
|
|
180
279
|
border itself **never calls any model API**. The `--llm` layer is a two-file handoff with
|
|
@@ -214,18 +313,19 @@ border <command> [options]
|
|
|
214
313
|
| `status` | newest gate records per target |
|
|
215
314
|
| `llm-request` | emit the masked review bundle for LLM-authored commits |
|
|
216
315
|
| `llm-ingest <findings.json>` | validate agent findings and record the combined verdict |
|
|
316
|
+
| `scan <[ecosystem:]name@version>` | inspect a third-party published artifact for residue before installing it |
|
|
217
317
|
|
|
218
318
|
Every subcommand accepts the same global flags (verified against `border <cmd> --help`):
|
|
219
319
|
|
|
220
320
|
| Flag | Effect |
|
|
221
321
|
| --- | --- |
|
|
222
322
|
| `--config <path>` | config file; default `./border.yaml` |
|
|
223
|
-
| `--targets <list>` | comma-separated `git,npm,pypi` subset restricting this run's scope; a named target that is not configured is exit 2 |
|
|
323
|
+
| `--targets <list>` | comma-separated `git,npm,pypi,crates,rubygems` subset restricting this run's scope; a named target that is not configured is exit 2 |
|
|
224
324
|
| `--force` | ignore the skip-ledger, re-run the full check |
|
|
225
325
|
| `--yes` | execute mutations (push only); without it a push is always DRY-RUN |
|
|
226
326
|
| `--require-engine <list>` | replaces the required-engine set from config; unknown or unprobeable names degrade the run (exit 2) |
|
|
227
327
|
| `--llm` | opt this check into the LLM review layer (a plain check can never satisfy an llm-recorded skip) |
|
|
228
|
-
| `--json` | machine-readable report on stdout (check) |
|
|
328
|
+
| `--json` | machine-readable report on stdout (check, scan) |
|
|
229
329
|
| `--help, -h` | usage table |
|
|
230
330
|
|
|
231
331
|
### Exit codes (the contract)
|
|
@@ -241,11 +341,45 @@ rests on a leg that silently skipped. Engine exit codes are translated against a
|
|
|
241
341
|
matrix (`gitleaks` 0/1, `trufflehog` 0/183, `secretlint` 0/1); *anything* else, including a
|
|
242
342
|
126 from gitleaks, is exit 2, never "clean".
|
|
243
343
|
|
|
344
|
+
### `border scan` — inspect a third-party package before installing it
|
|
345
|
+
|
|
346
|
+
```bash
|
|
347
|
+
border scan puppeteer@23.11.1 # npm is the default ecosystem
|
|
348
|
+
border scan pypi:requests@2.32.3
|
|
349
|
+
border scan crates:serde@1.0.219
|
|
350
|
+
border scan rubygems:rails@7.1.1
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
`border scan <[ecosystem:]name@version>` fetches that exact published artifact from its
|
|
354
|
+
registry, materializes it in a throwaway temp directory, and runs the same engine stack
|
|
355
|
+
`check` runs — secrets plus the T0–T4 residue classifiers — over the bytes you are about
|
|
356
|
+
to install. `--json` emits the standard machine-readable report.
|
|
357
|
+
|
|
358
|
+
**Capability, not fact.** A scan verdict proves what the package *can* do — persist past
|
|
359
|
+
uninstall, write outside the install tree, run hooks on every consumer install — because
|
|
360
|
+
those shapes are readable in the published bytes. Proof that a package *did* persist is
|
|
361
|
+
the uninstall-roundtrip valve, and it ships in 0.4.0; until then treat a scan as pre-install
|
|
362
|
+
risk triage, never as post-mortem evidence.
|
|
363
|
+
|
|
364
|
+
Scan touches neither side of your work: it reads no ledger and writes no skip records (every
|
|
365
|
+
scan is a full scan), and it creates no `.border/` in your repo — the fetched artifact and
|
|
366
|
+
its temp git tree live in the sandbox and are destroyed when the run ends. Exit codes follow
|
|
367
|
+
the same contract: `0` clean, `1` blocking (HIGH/CRITICAL) findings, `2` the scan could not
|
|
368
|
+
genuinely run — unreachable registry, malformed spec, a tree shape the engines cannot
|
|
369
|
+
consume. A scan that could not run never prints a clean verdict.
|
|
370
|
+
|
|
371
|
+
crates artifacts get one **envelope normalization** before staging: a registry `.crate`
|
|
372
|
+
ships three files — `Cargo.toml.orig`, `.cargo-ok`, `.cargo_vcs_info.json` — that
|
|
373
|
+
`cargo package` rejects as reserved, so scan drops them before the rebuild and logs each
|
|
374
|
+
dropped file to stderr with the sha256 of its original bytes
|
|
375
|
+
(`border scan: crates envelope normalized: <file> sha256=<hex>`); the rebuild regenerates
|
|
376
|
+
them from the normalized manifest, so no scanned byte is silently lost.
|
|
377
|
+
|
|
244
378
|
## Configuration
|
|
245
379
|
|
|
246
380
|
Everything border can be told lives in `border.yaml` at the repo root. Unknown keys are
|
|
247
381
|
rejected at load (a typo in a gate config is a gate you did not ask for). `${VAR}`
|
|
248
|
-
expansion applies only to remote URLs and registry/repository values, and an unset variable
|
|
382
|
+
expansion applies only to remote URLs and registry/repository/host values, and an unset variable
|
|
249
383
|
is a hard error, never an empty string.
|
|
250
384
|
|
|
251
385
|
Minimal, a git-only repo:
|
|
@@ -268,7 +402,8 @@ rules:
|
|
|
268
402
|
|
|
269
403
|
`rules` is required by design: a gate with silent defaults is a gate you did not ask for.
|
|
270
404
|
|
|
271
|
-
A repo that publishes a package, with the full surface
|
|
405
|
+
A repo that publishes a package, with the full surface (every one of the five channels, new in
|
|
406
|
+
0.2.0; a config without `crates`/`rubygems` behaves exactly as it did on 0.1.0):
|
|
272
407
|
|
|
273
408
|
```yaml
|
|
274
409
|
version: 1
|
|
@@ -281,6 +416,13 @@ targets:
|
|
|
281
416
|
registry: https://registry.npmjs.org # optional; any npm-protocol registry works
|
|
282
417
|
pypi:
|
|
283
418
|
repository: https://pypi.org # optional; TestPyPI or private indexes work
|
|
419
|
+
crates: # optional; public crates.io only — there is
|
|
420
|
+
name: widgets # deliberately no url/host key; overrides
|
|
421
|
+
# the Cargo.toml [package] name when set
|
|
422
|
+
rubygems: # optional; exactly one *.gemspec at HEAD,
|
|
423
|
+
name: widgets # this only disambiguates multi-gemspec repos
|
|
424
|
+
host: "${GEM_HOST}" # optional; any gemcutter-compatible push host,
|
|
425
|
+
# ${VAR} expansion allowed on this field only
|
|
284
426
|
rules:
|
|
285
427
|
authors: # identity allow-list
|
|
286
428
|
emails: [devs@acme.example]
|
|
@@ -294,6 +436,8 @@ allow: # enumerated suppressions, never bla
|
|
|
294
436
|
- { rule: "junk-artifact", match: "*", file: "test/fixtures/**" }
|
|
295
437
|
engines:
|
|
296
438
|
require: [gitleaks, secretlint] # trufflehog: true adds the third-party engine
|
|
439
|
+
residue:
|
|
440
|
+
enabled: true # default; false hides ONLY the residue-* rows
|
|
297
441
|
```
|
|
298
442
|
|
|
299
443
|
Notes that change behavior:
|
|
@@ -302,11 +446,32 @@ Notes that change behavior:
|
|
|
302
446
|
history/tree/identity/artifact legs still run, only the exposure set is empty. A
|
|
303
447
|
completely missing config with real remotes falls back to config-from-`git remote` with
|
|
304
448
|
a loud stderr warning.
|
|
449
|
+
- `targets.crates` reads its coordinates from `git show HEAD:Cargo.toml` and accepts only a
|
|
450
|
+
literal `[package] version`; a workspace-inherited or computed version is exit 2 telling
|
|
451
|
+
you to set a literal one. There is no crates.io host/registry key *by design*: the channel
|
|
452
|
+
is pinned to public crates.io, and an unknown key there is rejected at load rather than
|
|
453
|
+
quietly ignored (or silently trusted as a private-registry config the pre-flight cannot
|
|
454
|
+
probe).
|
|
455
|
+
- `targets.rubygems` discovers `*.gemspec` files at HEAD; exactly one may exist unless
|
|
456
|
+
`targets.rubygems.name` selects one, and `s.name`/`s.version` must be literals (computed
|
|
457
|
+
versions are exit 2, border never evaluates gemspec code to find out). A gemspec assigning
|
|
458
|
+
`s.date` is rejected wholesale: `gem build` is byte-deterministic precisely because it
|
|
459
|
+
embeds a fixed default date, so an explicit `s.date` breaks the digest proof border would
|
|
460
|
+
otherwise be certifying. `host` redirects both the probes and `gem push --host`, and is
|
|
461
|
+
the only `${VAR}`-expandable field of the two new targets.
|
|
305
462
|
- Every `allow` entry must be scoped (a `file` pin); `{rule: "*", match: "*"}` with no
|
|
306
463
|
file is rejected at load. Suppressed findings are enumerated in the report's
|
|
307
464
|
`allow-hits` section, so an exit 0 never hides what it hid.
|
|
308
465
|
- `rules.hosts/ips/pathPatterns` feed the same matcher pipeline as the built-ins, so your
|
|
309
466
|
patterns get the same archive attribution and the same allow-listing semantics.
|
|
467
|
+
- `residue.enabled` (0.3.0) is the only sanctioned skip of the residue scan; its default is
|
|
468
|
+
`true`, an unknown sibling under `residue:` is a typed exit 2, and — unlike an `allow`
|
|
469
|
+
entry, which suppresses findings while *listing* them — turning it off changes the
|
|
470
|
+
fingerprint, so no PASS certified with residue on can ever skip a run that has it off.
|
|
471
|
+
- 0.2.0 adds the `crates` and `rubygems` channels; existing configs are unchanged. Channels
|
|
472
|
+
are opt-in by the presence of their `targets.<id>` section, and the ledger's append-only
|
|
473
|
+
format means old records keep parsing: `confirmedVia` gained two enum members
|
|
474
|
+
(`crates-json`, `rubygems-json`), 0.1.x ledgers are accepted as-is and never rewritten.
|
|
310
475
|
|
|
311
476
|
## Integration
|
|
312
477
|
|
|
@@ -340,6 +505,17 @@ same one that wrote the commit) how to run the five subcommands, how to produce
|
|
|
340
505
|
- **Not an escape hatch.** There is no force-push flag, no `--i-know-what-im-doing`, no
|
|
341
506
|
per-run expiry of a missing PASS. The refusal paths are the product. If a check cannot be
|
|
342
507
|
trusted (engine gone, registry unreachable), the result is stop, not proceed.
|
|
508
|
+
- **Not a config runner.** You cannot define a custom channel or override what border
|
|
509
|
+
shells out to (a `push.command`-style escape hatch is deliberately absent). A user-defined
|
|
510
|
+
command would void the two properties the gate exists to provide: the same-bytes proof
|
|
511
|
+
(only a built-in channel descriptor knows which artifact bytes the check certified and can
|
|
512
|
+
re-hash exactly those before the wire) and the fail-closed registry pre-flight (only a
|
|
513
|
+
built-in knows each registry's response polarity: which status and which body string mean
|
|
514
|
+
*absent*, and that everything else is silence, which never means absent). Free-form
|
|
515
|
+
commands would turn "a PASS covers this exact state" into "a PASS covers whatever your
|
|
516
|
+
script happened to run": a config runner wearing a gate's clothes. Channels are built-in
|
|
517
|
+
only; adding one means shipping a descriptor whose probe, packaging, and byte-determinism
|
|
518
|
+
were measured against the real registry first.
|
|
343
519
|
- **Not a replacement for review or branch protection.** It gates *this machine's* push
|
|
344
520
|
surface; server-side controls still own everything else.
|
|
345
521
|
|
|
@@ -352,20 +528,60 @@ same one that wrote the commit) how to run the five subcommands, how to produce
|
|
|
352
528
|
registry stdout, unparsed versions, non-translatable exit codes, unresolvable ownership,
|
|
353
529
|
corrupt-but-present rules inputs, all become exit 2 or a CRITICAL, never a pass.
|
|
354
530
|
- **No telemetry, no callbacks.** `border check` touches the network only for the
|
|
355
|
-
registry pre-flight, against URLs
|
|
356
|
-
overridable
|
|
531
|
+
registry pre-flight, against the URLs the channels define: `registry.npmjs.org`,
|
|
532
|
+
`pypi.org`, and `rubygems.org` are overridable (private indexes; `targets.rubygems.host`),
|
|
533
|
+
and the crates.io leg is pinned to public `crates.io` with no override by design.
|
|
357
534
|
- **No credential handling.** Subprocess publishes inherit stdio so tokens and OTP prompts
|
|
358
|
-
go between you and `npm`/`twine`, never through border.
|
|
535
|
+
go between you and `npm`/`twine`/`cargo`/`gem`, never through border.
|
|
359
536
|
- **State is inert.** Everything border writes lives in `.border/` (ledger, run archives,
|
|
360
537
|
dist, lock), which carries its own `.gitignore` with `*` so a `git add -A` cannot stage
|
|
361
538
|
it, and which every scan leg refuses to treat as a finding source or an allow-list
|
|
362
539
|
target. A repo that *tracks* `.border/**` is itself a CRITICAL finding. A single-writer
|
|
363
540
|
lock makes concurrent runs exit 2 instead of racing.
|
|
364
541
|
|
|
542
|
+
## Changelog
|
|
543
|
+
|
|
544
|
+
### 0.3.0 (2026-09-09)
|
|
545
|
+
|
|
546
|
+
- **Residue gate (artifact stage).** The blanket install-hook CRITICAL becomes a classified
|
|
547
|
+
family of seven closed `residue-*` rules across tiers T1–T4 plus cross-manager, pairing,
|
|
548
|
+
and gem-unmatched lanes (see "The residue scan"): a hook matching the closed T1 safe-shape
|
|
549
|
+
signature drops to MEDIUM; every unknown shape keeps the original CRITICAL row verbatim.
|
|
550
|
+
Pairing (`# BEGIN <id>` marker blocks) is **detected only** — `residue-pairing-missing`
|
|
551
|
+
fires on the orphaned WRITE, a verified pair still pays its T3 HIGH; the waiver downgrade
|
|
552
|
+
path is the 0.4.0 roundtrip valve. Detection classes: C5 pairing discipline, C9 indirection
|
|
553
|
+
spellings, C10 comment/quote boundaries — evidence frozen in `src/artifacts/RESIDUE-CONTRACT.md`.
|
|
554
|
+
- New `residue: { enabled }` config key (default on; strict; the ONLY skip path for the family,
|
|
555
|
+
and flipping it invalidates cached PASS rows because the toggle rides the fingerprint). The
|
|
556
|
+
residue rule table and classifier sources are now part of the ledger `rulesHash`: editing a
|
|
557
|
+
signature forces a re-check even at an unchanged commit and tree.
|
|
558
|
+
- Boundary stated plainly: static analysis proves *capability*, not *fact*; border is not a
|
|
559
|
+
malware sandbox and never executes what it reads.
|
|
560
|
+
|
|
561
|
+
### 0.2.0 (2026-09-05)
|
|
562
|
+
|
|
563
|
+
- Two new push channels, same doctrine end to end: **crates** (public crates.io only;
|
|
564
|
+
literal `Cargo.toml` coordinates; pre-publish repackage digest-assert) and **rubygems**
|
|
565
|
+
(rubygems.org or any `targets.rubygems.host` gemcutter-compatible host; literal-only
|
|
566
|
+
gemspecs; `s.date` rejection).
|
|
567
|
+
- Platforms are now descriptors in a channel registry: each channel owns its config schema,
|
|
568
|
+
env expansion, coordinate reading, probe, artifact stage, publish argv, and ledger fields
|
|
569
|
+
in one file. The git/npm/pypi legs share the same publish core they already used; their
|
|
570
|
+
behavior is unchanged.
|
|
571
|
+
- Ledger `confirmedVia` gained `crates-json` and `rubygems-json`; 0.1.x ledgers parse
|
|
572
|
+
unchanged.
|
|
573
|
+
- New prerequisites when those targets are configured: `cargo` on `PATH` (measured against
|
|
574
|
+
1.93.1 via rustup) and `gem` (measured against RubyGems 3.6.7 on ruby 3.3.8); other
|
|
575
|
+
versions are untested by the gate's same-bytes proofs.
|
|
576
|
+
|
|
577
|
+
### 0.1.0
|
|
578
|
+
|
|
579
|
+
- Initial release: git, npm, and PyPI push channels behind the fingerprint PASS gate.
|
|
580
|
+
|
|
365
581
|
## License
|
|
366
582
|
|
|
367
583
|
MIT, see [LICENSE](LICENSE).
|
|
368
584
|
|
|
369
585
|
## 中文概要
|
|
370
586
|
|
|
371
|
-
border 是一个 fail-closed(失败即拦截)的推送前门禁 CLI:`npm install -g border-customs` 安装,在仓库里跑 `border check`。它扫描 git 历史、工作区(未跟踪文件同样是一等输入)、归档、tag 注释和将要发布的 npm/PyPI 字节,检出密钥与供应链风险;只有当"当前状态指纹"存在新鲜且完整的 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 的凭据经 stdio 透传,border
|
|
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 不是恶意软件沙箱,从不执行被读代码。可选 LLM 层 border 自身从不调用模型 API:`llm-request` 导出掩码审阅包,`llm-ingest` 严格校验 agent 结论并重算裁决。退出码即合同:0 通过、1 拦截、2 门禁无法作答,任何"工具不健康"都不可能被误读为干净。MIT 许可,无遥测,除你配置的注册表预检外不联网。
|