border-customs 0.8.2 → 0.8.4
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 +8 -289
- package/dist/assets/exfil-src/rules.ts +5 -0
- package/dist/assets/exfil-src/rulesPath.ts +35 -6
- package/dist/assets/exfil-src/scan.ts +12 -1
- package/dist/assets/exfil-src/tsSpans.ts +88 -0
- package/dist/assets/package-lock.json +7 -8
- package/dist/index.js +1780 -825
- package/package.json +3 -3
- package/plugin/border.ts +1 -1
package/README.md
CHANGED
|
@@ -608,38 +608,10 @@ dropped file to stderr with the sha256 of its original bytes
|
|
|
608
608
|
(`border scan: crates envelope normalized: <file> sha256=<hex>`); the rebuild regenerates
|
|
609
609
|
them from the normalized manifest, so no scanned byte is silently lost.
|
|
610
610
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
so after `npm i -D border-customs` (or `-g`) the engine surface is:
|
|
616
|
-
|
|
617
|
-
- **publint** — installed automatically: it is an exact-pinned runtime dependency of the
|
|
618
|
-
package, invoked via bin resolution (package-local `node_modules/.bin`, the hoisted
|
|
619
|
-
project-level `node_modules/.bin`, then `PATH`, then `~/.local/bin`). The
|
|
620
|
-
`publint-fail` leg of the npm artifact stage works out of the box in a consumer install.
|
|
621
|
-
- **secretlint** — no consumer action, ever: the default lint runs **in-process** with the
|
|
622
|
-
`@secretlint/*` rule modules already declared as border's own runtime dependencies
|
|
623
|
-
(`src/engines/secretlint.ts` header). The `mode: "cli"` fallback that spawns a
|
|
624
|
-
`secretlint` binary is border-internal (test transport); it is not reachable from
|
|
625
|
-
`border.yaml` or any flag, so installing `secretlint` into your project changes nothing
|
|
626
|
-
for border. If the engine probe cannot read the bundled lock snapshot
|
|
627
|
-
(`dist/assets/package-lock.json`), the run fails closed as `DEGRADED-ENGINE` exit 2 —
|
|
628
|
-
never a silent pass.
|
|
629
|
-
- **gitleaks** — stays external, same posture as `git`: border vendors the 8.30.1 rule
|
|
630
|
-
config but the binary is yours (on `PATH` or `~/.local/bin/gitleaks`; see the
|
|
631
|
-
requirements paragraph at the top and `src/engines/ADAPTER-CONTRACT.md`). Missing
|
|
632
|
-
gitleaks means `border check` records a CRITICAL `DEGRADED-ENGINE` finding and exits 2,
|
|
633
|
-
and `border scan` exits 2 with `engine binary 'gitleaks' not found; border fails
|
|
634
|
-
closed`. Neither command degrades to a skip.
|
|
635
|
-
- **trufflehog** — optional third engine, external binary, same posture as gitleaks once
|
|
636
|
-
enabled: `engines.trufflehog: true` puts it in the required set, and an absent binary
|
|
637
|
-
degrades the run to exit 2 exactly like a missing gitleaks.
|
|
638
|
-
|
|
639
|
-
There is no "engine absent ⇒ leg silently skipped" state anywhere: missing required
|
|
640
|
-
engines are exit 2 (`2` = the gate could not answer, see
|
|
641
|
-
[Exit codes](#exit-codes-the-contract)), and the ledger refuses to write a PASS for a
|
|
642
|
-
degraded run.
|
|
611
|
+
## Engines
|
|
612
|
+
|
|
613
|
+
Engine posture per scanner — vendored vs external, version matrices,
|
|
614
|
+
consumer-install behavior: [docs/engines.md](docs/engines.md).
|
|
643
615
|
|
|
644
616
|
## Configuration
|
|
645
617
|
|
|
@@ -839,7 +811,7 @@ plugin entry ships as `exports["./server"]`):
|
|
|
839
811
|
```jsonc
|
|
840
812
|
{
|
|
841
813
|
// pin the exact version — an @latest channel hits the registry on every cold start
|
|
842
|
-
"plugin": ["border-customs@0.8.
|
|
814
|
+
"plugin": ["border-customs@0.8.4"]
|
|
843
815
|
}
|
|
844
816
|
```
|
|
845
817
|
|
|
@@ -969,262 +941,9 @@ and every pull request, plus version single-source, typecheck, build, and audit
|
|
|
969
941
|
|
|
970
942
|
## Changelog
|
|
971
943
|
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
unpatchable braces/micromatch + fast-uri chain) and `tool` — an identity function — is
|
|
976
|
-
vendored in `plugin/border.ts`, moving `@opencode-ai/plugin` (and its effect-beta → toml
|
|
977
|
-
chain) to devDependencies; live-host probe tests keep the activation proof. The CI audit
|
|
978
|
-
gate tightens from critical to HIGH so this cannot rot.
|
|
979
|
-
- `border sandbox init|pin|status|unpin` — the opt-in pin helper from the Q1 ruling
|
|
980
|
-
(machine store only, 0600 atomic writes, §3 self-verification with rollback of its own
|
|
981
|
-
writes; deliberately absent from the plugin's agent-reachable command set).
|
|
982
|
-
- Security tier for execution-deciding keys (`src/tier.ts`, sibyl §3⁗/§3⁖/§3‡):
|
|
983
|
-
`sandboxImage`/`allowBuilds` honor only from fd-chain-qualified machine-store or out-of-repo
|
|
984
|
-
`--config` sources; repo declarations, lax-mode files, and hijacked `XDG_CONFIG_HOME` are
|
|
985
|
-
named refusals (`sandbox-config-shadowed`/`sandbox-config-unqualified`); the
|
|
986
|
-
`BORDER_ALLOW_BUILDS` environment channel is REMOVED (never released, so no migration);
|
|
987
|
-
passwd-derived canonical store root — `$HOME` spoofing cannot move it.
|
|
988
|
-
- Sandboxed artifact builds: optional `targets.<ch>.sandboxImage` (digest-pinned, tags fail
|
|
989
|
-
closed at load) runs consented npm/pypi/crates/rubygems builds — and freshness re-packs —
|
|
990
|
-
inside `docker run` with `--network none` proved by an in-container DNS+TCP probe, a
|
|
991
|
-
read-only repo mount, tmpfs build, `--cap-drop ALL`, scrubbed env, and the ledger record
|
|
992
|
-
bound to the exact image digest (a rotated image invalidates cached PASS). Missing docker
|
|
993
|
-
with an image configured is a named exit 2, never a host fallback. New `test/sandbox.test.ts`
|
|
994
|
-
pins the docker argv contract token by token.
|
|
995
|
-
- Hardened the npm pack-phase guard (sibyl must_fix): ALL package.json manifests in the tree
|
|
996
|
-
are scanned, not just the root (a workspaces child's `prepare` is now refused on npm < 11),
|
|
997
|
-
and unreadable manifests or a walk-cap hit count as a hook — unknown is never assumed safe.
|
|
998
|
-
- Machine-side config (HR decoupling handoff 2026-10-23): resolution chain is now
|
|
999
|
-
`--config > $BORDER_CONFIG (fail-closed) > machine store (XDG/border/machines, keyed by
|
|
1000
|
-
canonical remote URL) > ./border.yaml > git-remote inference`, so gate config and waiver
|
|
1001
|
-
lists live on the checking machine and never need to be committed into a product repo.
|
|
1002
|
-
Allow entries gained `values` (fixture-set waivers that never cover a new value), `until`
|
|
1003
|
-
(UTC sunset, expired entries echoed never silently honored), `\!` archive-member escaping,
|
|
1004
|
-
and `rules.aliases` (rule renames no longer strand waivers). Border state was already
|
|
1005
|
-
porcelain-invisible (`.border/.gitignore` self-ignore; verified 2026-10-23).
|
|
1006
|
-
- Provenance: every check and push record carries `engineBuild`
|
|
1007
|
-
(`<version>+<commit>[.dirty]`, the string `border --version` prints), so a verdict names
|
|
1008
|
-
the exact border build that produced it. Motivated by a machine where every gate ran off
|
|
1009
|
-
an unpinned worktree bundle and no record said so; worktree use is now visible on the
|
|
1010
|
-
record face (see "Pinned consumption"). Bundles are stamped by `tools/build.mjs`.
|
|
1011
|
-
- Security (breaking): artifact build stages now require operator consent. A hostile clone can
|
|
1012
|
-
ship its own border.yaml declaring pypi/rubygems targets, and `border check` built them —
|
|
1013
|
-
PEP 517 backends and gemspecs ARE code (`python3 -m build --no-isolation` and `gem build`
|
|
1014
|
-
markers reproduced 2026-10-23). Default runs are now scan-only (zero build spawns, pinned by
|
|
1015
|
-
`test/build.execMarkers.test.ts`); consent comes only from outside the audited tree:
|
|
1016
|
-
`--allow-builds` or `BORDER_ALLOW_BUILDS=1`. A scan-only PASS records no artifact digests and
|
|
1017
|
-
therefore can never back a registry publish; `border scan` of third-party artifacts requires
|
|
1018
|
-
the same consent (named exit 2 without it).
|
|
1019
|
-
- Security: refuse npm < 11 packages that declare `prepack`/`prepare`/`postpack` before
|
|
1020
|
-
spawning `npm pack`. npm 10 executes `prepare` even with `--ignore-scripts` (reproduced on
|
|
1021
|
-
10.9.7; npm 11.19.1 is clean), so the old defense could execute code from the audited repo.
|
|
1022
|
-
The probe also runs before freshness re-packs. New regression tests pin the refusal and the
|
|
1023
|
-
never-fires hook marker.
|
|
1024
|
-
- Fix: the Windows drive-letter path pattern no longer fires on JS escapes in prose
|
|
1025
|
-
(`raw-repoPath:\n`); the UNC pattern no longer spans newlines.
|
|
1026
|
-
- Docs: the `--json` findings schema is stated explicitly (`path`, no `file` alias);
|
|
1027
|
-
npm-target prerequisites now name the npm 11 boundary.
|
|
1028
|
-
|
|
1029
|
-
### 0.8.1 (2026-10-06)
|
|
1030
|
-
- Maintenance release: certificate-chain rebuild off the scrubbed lineage (INCIDENT-20261006
|
|
1031
|
-
identity-exposure cleanup). No functional change vs 0.8.0; 0.8.0's tarball is byte-intact but
|
|
1032
|
-
its OIDC provenance pin moved with the history rewrite, so a fresh tag + publish restores a
|
|
1033
|
-
verifiable chain. Rotation: package.json + lock roots + plugin marker + scan UA + README pins +
|
|
1034
|
-
this entry.
|
|
1035
|
-
|
|
1036
|
-
### 0.8.0 (2026-09-30)
|
|
1037
|
-
- Add: **`border opencode inspect [--json]`** — the load-surface self-audit becomes a gate-owned
|
|
1038
|
-
surface: six read-only aspects (cache self-consistency, spec freshness vs pin/registry, identity
|
|
1039
|
-
handshake replay, Route A byte parity, Route A∧B dual-route collision, scope declaration),
|
|
1040
|
-
fail-closed aggregation FAIL 1 > CANNOT 2 > PASS 0. `--json` pins `scope:"v1"` — V2 load surfaces
|
|
1041
|
-
are a declared blind spot, not silently covered. Zero writes; `--purge` stays in the cron shell.
|
|
1042
|
-
- Change: **`exfil` joins the plugin's closed command list** (owner ruling R1 under the four-question
|
|
1043
|
-
eligibility gate — no mutation, no credentials, no foreign-byte execution, no local egress;
|
|
1044
|
-
packet: `.omo/evidence/F-EXFIL-ALLOWLIST-RULING-2026-09-30.md`). `border opencode …` verbs stay
|
|
1045
|
-
terminal-only: they mutate `$XDG_CONFIG_HOME` and fail the gate's first question.
|
|
1046
|
-
- Change: `tools/plugin-drift-watch.mjs` retires to a thin shell over `border opencode inspect --json`
|
|
1047
|
-
(single source of verdicts); the cron rc contract is byte-stable — FRESH 0, DRIFT 1, NOT-CACHED 0
|
|
1048
|
-
(special-cased per ruling B2), CANNOT 2 — and the crontab line is unchanged.
|
|
1049
|
-
|
|
1050
|
-
### 0.7.1 (2026-09-30)
|
|
1051
|
-
- Fix: **release-gate test ruler race** (`test/push.integration.test.ts` I4) — the
|
|
1052
|
-
"dry-run made ZERO verdaccio requests" invariant counted lines in verdaccio's stdout
|
|
1053
|
-
log, an eventually-consistent view: on a saturated CI runner the gate-leg probe lines
|
|
1054
|
-
of an earlier phase land after the snapshot, inflating the dry-run delta (v0.7.0 CI
|
|
1055
|
-
run 36671401844 failed `6 !== 2` while the identical bytes pass locally). The suite
|
|
1056
|
-
now takes every snapshot through a FIFO drain barrier (sentinel request whose log line
|
|
1057
|
-
must be visible before counting; sentinel traffic is excluded from the count). The
|
|
1058
|
-
zero-network dry-run contract itself is unchanged — verified request-free on both sides
|
|
1059
|
-
of the barrier; only the measuring instrument was repaired.
|
|
1060
|
-
|
|
1061
|
-
### 0.7.0 (2026-09-28)
|
|
1062
|
-
- Add: **OpenCode 2 (beta) plugin compatibility** — the plugin module is dual-shaped
|
|
1063
|
-
(`{id, setup}` for V2 hosts, frozen V1 `server()`), sharing the pre-spawn
|
|
1064
|
-
allowlist/`--yes` gates and the 0.5.1 identity handshake across both shapes;
|
|
1065
|
-
refusal/handshake/rendering byte-parity between edges is pinned by
|
|
1066
|
-
`test/opencode.v2shape.test.ts` while the V1 path stays 16/16 on unmodified
|
|
1067
|
-
tests. Root `index.ts` re-export shipped for V2 directory discovery (which
|
|
1068
|
-
bypasses the exports map; live-proven on `@opencode-ai/cli` beta-19271).
|
|
1069
|
-
- Fix: the live V1 probe is anti-vacuous — opting in (`BORDER_OPENCODE_PROBE=1`)
|
|
1070
|
-
without a resolvable binary or built dist now FAILS naming the remedy instead
|
|
1071
|
-
of skipping green-adjacent (`BORDER_OPENCODE_BIN` override added).
|
|
1072
|
-
- Docs: support matrix (V1 object entry ≥ 1.18.29; V2 project-local verified;
|
|
1073
|
-
`/border` command V1-only pending the V2 delivery API).
|
|
1074
|
-
|
|
1075
|
-
### 0.6.0 (2026-09-25)
|
|
1076
|
-
- Add: **the exfil lens** — five rules (`exfil-rfc1918`, `exfil-ssh-target`,
|
|
1077
|
-
`exfil-home-path`, `exfil-cred-location`, `exfil-host-profile`, `exfil-machine-binding`) that flag
|
|
1078
|
-
exfil-shaped literals on about-to-be-public surfaces, the gap classic secret
|
|
1079
|
-
scanners leave open because an internal IP is not a credential. The observed
|
|
1080
|
-
severity of every (rule × facet × channel) cell lives in one machine-readable
|
|
1081
|
-
home, `src/exfil/severity.ts`, rendered — never re-decided — by the check
|
|
1082
|
-
pipeline, the new read-only `border exfil <rev|url>` CLI (tip tree by default,
|
|
1083
|
-
`--deep` history, URL modes into a destroyed temp clone) and the README table,
|
|
1084
|
-
each cell pinned by a docs guard. The tree HIGH family ships as vendored
|
|
1085
|
-
twins (gitleaks TOML + secretlint patterns, ids byte-equal, verdict parity
|
|
1086
|
-
tested); the commit-message facet is native-exclusive with a `:message` id
|
|
1087
|
-
family so allow-list waivers can never cross facets; tag notes stay with the
|
|
1088
|
-
pre-existing `tag-message-secret` engine leg. All exfil sources are
|
|
1089
|
-
fingerprinted into `rulesHash`.
|
|
1090
|
-
- Add: **post-push landing verification** — after git legs move, the remote tip
|
|
1091
|
-
is re-observed and scanned with the same machinery; clean appends an
|
|
1092
|
-
append-only `t:"landing"` record and preserves the exit, a blocking hit exits
|
|
1093
|
-
1 ("ALREADY PUBLIC; border detects, never erases"), an unobservable tip exits
|
|
1094
|
-
2 with no record minted. Executed-push rows are never mutated; DRY-RUN lands
|
|
1095
|
-
nothing.
|
|
1096
|
-
- Add: **fixture band + assembly doctrine** — 10.200.0.0/16 is reserved as the
|
|
1097
|
-
always-exempt-on-tree test range (exemption mirrored natively in both twins,
|
|
1098
|
-
message facet deliberately not exempt), and the test corpus assembles every
|
|
1099
|
-
other leak shape at runtime (`assembleOctet`/`assembleHost`) so checked-out
|
|
1100
|
-
bytes carry no complete leak tokens: border's own lens is green against
|
|
1101
|
-
border's own corpus. Golden-suite coverage includes an opt-in truth lane
|
|
1102
|
-
(`BORDER_EXFIL_TRUTH=1`) with a documented red-anchor lifetime clause.
|
|
1103
|
-
- Note: this release line traces to the 0.5.1 impersonation-wave audit; that
|
|
1104
|
-
wave's H-3 follow-up — scrubbing this repo's own historical tip surfaces —
|
|
1105
|
-
landed here as byte edits with the extracted values withheld from public
|
|
1106
|
-
records by design (see the sanitized `.omo/evidence/` trail, not this log).
|
|
1107
|
-
|
|
1108
|
-
### 0.5.1 (2026-09-24)
|
|
1109
|
-
- Fix: **plugin impersonation — the `border` tool could spawn the host binary instead of the
|
|
1110
|
-
gate.** On Route B the 0.5.0 adapter resolved its packaged CLI as `process.execPath +
|
|
1111
|
-
dist/index.js`, but inside the opencode plugin host `process.execPath` is the opencode
|
|
1112
|
-
(bun-compiled) binary — sessions saw opencode's banner for `--help` and opencode usage
|
|
1113
|
-
errors for every other command, while the tool's schema still promised border's
|
|
1114
|
-
fail-closed contract. The resolution order is now: `BORDER_BIN` (wins alone; a broken
|
|
1115
|
-
override never silently falls back) → packaged `dist/index.js` exec'd directly via its
|
|
1116
|
-
shebang, else under `PATH` `node` → `border` on `PATH`. `process.execPath` is removed from
|
|
1117
|
-
the adapter, pinned by a source-level regression test, and **every candidate must pass an
|
|
1118
|
-
identity handshake** (`--help` answering with border's banner + `usage: border`, exit 0,
|
|
1119
|
-
20 s cap) before any user argv is spawned — failure reports `cannot-answer` at exit 2 and
|
|
1120
|
-
runs nothing else (spawn failures now map to 2, not 127, aligning the tool with the CLI's
|
|
1121
|
-
exit-code contract). Regression tests cover the unspawnable-override and the
|
|
1122
|
-
opencode-style-impostor fixtures; the impostor logs prove user argv never reaches it.
|
|
1123
|
-
Honest boundary: the handshake catches mistaken identity (host binaries, PATH accidents),
|
|
1124
|
-
not a deliberately forged banner.
|
|
1125
|
-
|
|
1126
|
-
### 0.5.0 (2026-09-12)
|
|
1127
|
-
- Add: OpenCode plugin adapter (`exports["./server"]` + `/border` + `border` tool) loadable
|
|
1128
|
-
two ways with zero manual install — config-declared `"plugin": ["border-customs@0.5.0"]`
|
|
1129
|
-
(opencode downloads the package; the plugin spawns its own packaged CLI via a
|
|
1130
|
-
package-relative `dist/index.js` resolution, `BORDER_BIN` override, PATH fallback) or
|
|
1131
|
-
`border opencode install` (marker-identified idempotent file drop, foreign files refused,
|
|
1132
|
-
never overwritten). Trigger: an agent session drives the gate without a shell-out or an
|
|
1133
|
-
install ritual; `push --yes` stays terminal-only by design — the tool refuses it.
|
|
1134
|
-
|
|
1135
|
-
### 0.4.2 (2026-09-12)
|
|
1136
|
-
- Fix: `npm publish` no longer prints the misleading "bin[border] script name
|
|
1137
|
-
dist/index.js was invalid and removed" warning — package.json now carries the
|
|
1138
|
-
canonical bare-relative bin form npm normalizes to anyway (mechanism traced to
|
|
1139
|
-
@npmcli/package-json publish-side; artifacts were never broken; an always-on
|
|
1140
|
-
test locks the form).
|
|
1141
|
-
- Change: RubyGems freshness (skip-ledger) parity compares a normalized CONTENT
|
|
1142
|
-
digest instead of raw file bytes — `gem build` stamps the building toolchain
|
|
1143
|
-
(RubyGems <3.6 embeds the build second in gzip headers), so raw-sha repack
|
|
1144
|
-
parity could never be honored on those machines. The ledger digest and the
|
|
1145
|
-
publish-time same-bytes re-hash stay RAW: what gets uploaded is still exactly
|
|
1146
|
-
the certified bytes, and any unparseable artifact fails closed to a full
|
|
1147
|
-
re-check.
|
|
1148
|
-
|
|
1149
|
-
### 0.4.1 (2026-09-11)
|
|
1150
|
-
- Add: release-coherence rule family — every version source inside a published artifact
|
|
1151
|
-
must agree (pyproject vs MODULE vs filename, gemspec vs gem metadata, Cargo.toml vs
|
|
1152
|
-
crate filename, package.json self-consistency; the git-tag half is a publish-workflow
|
|
1153
|
-
hard gate). Trigger: the aihr incident where a wheel labeled 0.2.2 shipped modules
|
|
1154
|
-
self-reporting 0.2.1 — every pinned consumer silently got old behavior.
|
|
1155
|
-
- Add: `border roundtrip` local-artifact input — an argument resolving to an existing
|
|
1156
|
-
file (.whl/.tgz/.crate/.gem, closed table) proves unpublished bytes end-to-end; the
|
|
1157
|
-
streaming sha256 of those exact bytes is the ledger identity and the report carries
|
|
1158
|
-
`source:local:<abs>` provenance. Honest boundary documented: the local PyPI-wheel lane
|
|
1159
|
-
is residue-INERT by construction (pip fully tracks and prunes; verified live on pip
|
|
1160
|
-
24.0 AND 25.0.1) — planted-detection demos belong to the hook-executing lanes
|
|
1161
|
-
(npm postinstall, gem extconf, crates build.rs).
|
|
1162
|
-
- Change: pypi roundtrip lane dep-closure calibration — pip's resolver report demotes
|
|
1163
|
-
dependency-storm files to LOW `dep-owned` rows, so genuine orphans stand alone; the
|
|
1164
|
-
demotion requires a legitimate claimant dist-info (pin Name+Version match, canonical
|
|
1165
|
-
dirname, unique, own site-packages root, not the target) after an independent verifier
|
|
1166
|
-
proved a shadow `*-9.9.9.dist-info` could otherwise launder hostile writes.
|
|
1167
|
-
|
|
1168
|
-
### 0.4.0 (2026-09-10)
|
|
1169
|
-
- Add: `border roundtrip <[ecosystem:]name@version>` — fact-proof valve. Installs the
|
|
1170
|
-
package inside a throwaway Docker container, content-hashes the whole filesystem before
|
|
1171
|
-
and after uninstall, and reports exactly what the uninstaller left behind. Persistence
|
|
1172
|
-
surfaces (rc/profile mutations) => CRITICAL; orphan files => HIGH. Verdicts record to
|
|
1173
|
-
the ledger by default (`--no-record` opts out); Docker absent => fail-closed, never clean.
|
|
1174
|
-
- Add: `residue.requireProof` — when on, a residue-capable artifact only passes `border
|
|
1175
|
-
check` with a fresh recorded roundtrip proof (bound to rulesHash; flipping the config
|
|
1176
|
-
invalidates cached verdicts). Missing proof => CRITICAL `roundtrip-proof-missing`.
|
|
1177
|
-
- Opt-in test legs: BORDER_ROUNDTRIP_DOCKER=1 (8 real container legs, 4 ecosystems).
|
|
1178
|
-
|
|
1179
|
-
### 0.3.2 (2026-09-10)
|
|
1180
|
-
- Fix: CLI invoked through the npm `.bin` shim silently exited 0 without running
|
|
1181
|
-
(entrypoint detection now realpath-canonicalizes both sides). Regression-locked by a
|
|
1182
|
-
pack -> install -> shim-invocation test (`BORDER_PACK_TEST=1`, wired into CI).
|
|
1183
|
-
- Fix: `publint` is an exact-pinned runtime dependency, so `border scan`/npm checks work
|
|
1184
|
-
out of the box in consumer installs; README documents the engine posture truthfully.
|
|
1185
|
-
- Add: release-coherence seed test pinning the scan User-Agent version to package.json.
|
|
1186
|
-
|
|
1187
|
-
### 0.3.1 (2026-09-10)
|
|
1188
|
-
- Add: `border scan [ecosystem:]name@version` — static residue inspection of third-party
|
|
1189
|
-
registry packages (npm/PyPI/crates.io/RubyGems), ledger-free by design.
|
|
1190
|
-
- Docs: README scan section; crates envelope normalization now logs scrubbed-file hashes.
|
|
1191
|
-
|
|
1192
|
-
### 0.3.0 (2026-09-09)
|
|
1193
|
-
|
|
1194
|
-
- **Residue gate (artifact stage).** The blanket install-hook CRITICAL becomes a classified
|
|
1195
|
-
family of seven closed `residue-*` rules across tiers T1–T4 plus cross-manager, pairing,
|
|
1196
|
-
and gem-unmatched lanes (see "The residue scan"): a hook matching the closed T1 safe-shape
|
|
1197
|
-
signature drops to MEDIUM; every unknown shape keeps the original CRITICAL row verbatim.
|
|
1198
|
-
Pairing (`# BEGIN <id>` marker blocks) is **detected only** — `residue-pairing-missing`
|
|
1199
|
-
fires on the orphaned WRITE, a verified pair still pays its T3 HIGH; the waiver downgrade
|
|
1200
|
-
path is the 0.4.0 roundtrip valve. Detection classes: C5 pairing discipline, C9 indirection
|
|
1201
|
-
spellings, C10 comment/quote boundaries — evidence frozen in `src/artifacts/RESIDUE-CONTRACT.md`.
|
|
1202
|
-
- New `residue: { enabled }` config key (default on; strict; the ONLY skip path for the family,
|
|
1203
|
-
and flipping it invalidates cached PASS rows because the toggle rides the fingerprint). The
|
|
1204
|
-
residue rule table and classifier sources are now part of the ledger `rulesHash`: editing a
|
|
1205
|
-
signature forces a re-check even at an unchanged commit and tree.
|
|
1206
|
-
- Boundary stated plainly: static analysis proves *capability*, not *fact*; border is not a
|
|
1207
|
-
malware sandbox and never executes what it reads.
|
|
1208
|
-
|
|
1209
|
-
### 0.2.0 (2026-09-05)
|
|
1210
|
-
|
|
1211
|
-
- Two new push channels, same doctrine end to end: **crates** (public crates.io only;
|
|
1212
|
-
literal `Cargo.toml` coordinates; pre-publish repackage digest-assert) and **rubygems**
|
|
1213
|
-
(rubygems.org or any `targets.rubygems.host` gemcutter-compatible host; literal-only
|
|
1214
|
-
gemspecs; `s.date` rejection).
|
|
1215
|
-
- Platforms are now descriptors in a channel registry: each channel owns its config schema,
|
|
1216
|
-
env expansion, coordinate reading, probe, artifact stage, publish argv, and ledger fields
|
|
1217
|
-
in one file. The git/npm/pypi legs share the same publish core they already used; their
|
|
1218
|
-
behavior is unchanged.
|
|
1219
|
-
- Ledger `confirmedVia` gained `crates-json` and `rubygems-json`; 0.1.x ledgers parse
|
|
1220
|
-
unchanged.
|
|
1221
|
-
- New prerequisites when those targets are configured: `cargo` on `PATH` (measured against
|
|
1222
|
-
1.93.1 via rustup) and `gem` (measured against RubyGems 3.6.7 on ruby 3.3.8); other
|
|
1223
|
-
versions are untested by the gate's same-bytes proofs.
|
|
1224
|
-
|
|
1225
|
-
### 0.1.0
|
|
1226
|
-
|
|
1227
|
-
- Initial release: git, npm, and PyPI push channels behind the fingerprint PASS gate.
|
|
944
|
+
Full per-version history with the motivating incident for each release:
|
|
945
|
+
[CHANGELOG.md](CHANGELOG.md). Release receipts (build provenance, gate transcripts):
|
|
946
|
+
[docs/release/](docs/release/).
|
|
1228
947
|
|
|
1229
948
|
## License
|
|
1230
949
|
|
|
@@ -31,6 +31,11 @@ export type RuleOptions = {
|
|
|
31
31
|
readonly hosts?: readonly string[];
|
|
32
32
|
/** exemption-band override (production default DEFAULT_EXEMPT_BANDS; [] forces in-range arms red in unit tests). */
|
|
33
33
|
readonly exemptBands?: readonly string[];
|
|
34
|
+
/** source path (tree facet only) — drives the syntax-aware credential-location
|
|
35
|
+
* guard (handoff b61b42a): JS/TS sources classify .env tokens by literal spans. */
|
|
36
|
+
readonly filePath?: string;
|
|
37
|
+
/** one-shot diagnostic sink (parse-failure notes; wired to stderr by the tree leg). */
|
|
38
|
+
readonly onNote?: (line: string) => void;
|
|
34
39
|
};
|
|
35
40
|
|
|
36
41
|
// ---------------------------------------------------------------- IPv4 machinery
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// Self-stealth: no absolute workspace paths and no dotted quads in this file
|
|
5
5
|
// (see rules.ts header for the 0.5.1 dogfood lesson this obeys).
|
|
6
6
|
import type { RuleHit, RuleOptions } from "./rules.ts";
|
|
7
|
+
import { inSpan, isJsTsPath, literalSpans, type LiteralSpan } from "./tsSpans.ts";
|
|
7
8
|
|
|
8
9
|
// ---------------------------------------------------------------- exfil-home-path
|
|
9
10
|
|
|
@@ -52,20 +53,48 @@ function envTokenIsLocation(tok: string): boolean {
|
|
|
52
53
|
* prefix, or has a real file-stem receiver (≥3 chars, never the process/os
|
|
53
54
|
* API names); the JS optional-chain read `x?.env` never counts.
|
|
54
55
|
*/
|
|
55
|
-
export function matchCredLocation(text: string,
|
|
56
|
+
export function matchCredLocation(text: string, o: RuleOptions): RuleHit[] {
|
|
56
57
|
const hits: RuleHit[] = [];
|
|
57
58
|
const push = (matched: string, line: number): void => {
|
|
58
59
|
hits.push({ rule: "exfil-cred-location", matched, line, message: "Reference to a credential-bearing location (env file / pypirc / SSHPASS); the location itself, value not required to fire." });
|
|
59
60
|
};
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
// Syntax-aware guard (handoff b61b42a §3): in JS/TS sources a .env token is a
|
|
62
|
+
// path candidate ONLY inside a string-like literal span. Property accesses
|
|
63
|
+
// (opts.env), short receivers and comment text are syntactically not paths —
|
|
64
|
+
// no lexical rule over receiver names can tell them apart (read-aloud proof
|
|
65
|
+
// in the handoff). Parse failure ⇒ old behavior + one stderr note, exit code
|
|
66
|
+
// untouched. pypirc/SSHPASS tokens are untouched by the guard, in any file.
|
|
67
|
+
const filePath = o.filePath ?? "";
|
|
68
|
+
let spans: LiteralSpan[] | null | undefined;
|
|
69
|
+
const spansFor = (): LiteralSpan[] | null => {
|
|
70
|
+
if (spans === undefined) {
|
|
71
|
+
spans = literalSpans(filePath, text);
|
|
72
|
+
if (spans === null) o.onNote?.(`${filePath}: JS/TS parse unavailable — credential-location matches reported by the pre-0.8.3 text rule for this file`);
|
|
73
|
+
}
|
|
74
|
+
return spans;
|
|
75
|
+
};
|
|
76
|
+
const guarded = isJsTsPath(filePath);
|
|
77
|
+
let lineOffset = 0;
|
|
78
|
+
for (const [idx, body] of text.split("\n").entries()) {
|
|
79
|
+
const line = idx + 1;
|
|
80
|
+
for (const m of body.matchAll(PYPIRC_RE)) push(m[0] ?? "", line);
|
|
81
|
+
for (const m of body.matchAll(SSHPASS_RE)) push(m[0] ?? "", line);
|
|
63
82
|
for (const m of body.matchAll(ENVFILE_RE)) {
|
|
64
83
|
const tok = m[0] ?? "";
|
|
65
84
|
if ((body[(m.index ?? 0) - 1] ?? "") === "?") continue;
|
|
66
|
-
if (envTokenIsLocation(tok))
|
|
85
|
+
if (!envTokenIsLocation(tok)) continue;
|
|
86
|
+
if (guarded) {
|
|
87
|
+
const sp = spansFor();
|
|
88
|
+
// token starts one char past the match (leading '.'); comment and
|
|
89
|
+
// identifier positions sit outside every literal span.
|
|
90
|
+
const offset = lineOffset + (m.index ?? 0) + 1;
|
|
91
|
+
if (sp === null) { /* parse-failure fallback: report (old behavior) */ }
|
|
92
|
+
else if (!inSpan(sp, offset)) continue;
|
|
93
|
+
}
|
|
94
|
+
push(tok, line);
|
|
67
95
|
}
|
|
68
|
-
|
|
96
|
+
lineOffset += body.length + 1;
|
|
97
|
+
}
|
|
69
98
|
return hits;
|
|
70
99
|
}
|
|
71
100
|
|
|
@@ -69,6 +69,12 @@ export type ExfilScanInput = {
|
|
|
69
69
|
readonly source: string;
|
|
70
70
|
/** operator's rules.hosts (config.ts:102) for the ssh-target predicate. */
|
|
71
71
|
readonly hosts?: readonly string[];
|
|
72
|
+
/** source file path (tree facet) — threaded to predicates for the syntax-aware
|
|
73
|
+
* credential-location guard; message facets keep it undefined (a commit message
|
|
74
|
+
* is not JS source). */
|
|
75
|
+
readonly filePath?: string;
|
|
76
|
+
/** parse-failure diagnostic sink for the tree leg. */
|
|
77
|
+
readonly onNote?: (line: string) => void;
|
|
72
78
|
/**
|
|
73
79
|
* Ingest hook mirroring the redact.ts adapter contract ("every adapter
|
|
74
80
|
* registers every value it ingests"): the check pipeline passes
|
|
@@ -111,7 +117,12 @@ const PREDICATES: Readonly<Record<ExfilRuleId, (text: string, o: RuleOptions) =>
|
|
|
111
117
|
*/
|
|
112
118
|
function scanFacet(input: ExfilScanInput, facet: Extract<ExfilFacet, "tree" | "message">): ExfilFinding[] {
|
|
113
119
|
const channel: ExfilChannel = "native";
|
|
114
|
-
const options: RuleOptions = {
|
|
120
|
+
const options: RuleOptions = {
|
|
121
|
+
facet,
|
|
122
|
+
...(input.hosts !== undefined ? { hosts: input.hosts } : {}),
|
|
123
|
+
...(input.filePath !== undefined ? { filePath: input.filePath } : {}),
|
|
124
|
+
...(input.onNote !== undefined ? { onNote: input.onNote } : {}),
|
|
125
|
+
};
|
|
115
126
|
const findings: ExfilFinding[] = [];
|
|
116
127
|
for (const rule of emitRulesFor(facet, channel)) {
|
|
117
128
|
const severity = observedSeverity(rule, facet, channel);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// provenance: Abathur handoff 2026-11-01 (rule-class-3 false positives, commit b61b42a).
|
|
2
|
+
//
|
|
3
|
+
// `.env` is a disk location inside a string literal and a property access inside
|
|
4
|
+
// code — the same characters, a SYNTACTIC distinction no receiver-shaped regex can
|
|
5
|
+
// draw (opts vs secrets are both plain identifiers; proven by the handoff's
|
|
6
|
+
// read-aloud round). This module answers one question for JS/TS sources: at which
|
|
7
|
+
// byte spans does the file contain string-like literal text?
|
|
8
|
+
//
|
|
9
|
+
// Contract (handoff §3, decided there):
|
|
10
|
+
// * string literals, no-substitution template literals, and template fragments
|
|
11
|
+
// (head/middle/tail) are literal spans — the ${...} regions are INCLUDED,
|
|
12
|
+
// deliberately conservative: a location inside an interpolation is still the
|
|
13
|
+
// author writing a location into source text;
|
|
14
|
+
// * comments are NOT literal spans (the AST visitor never yields them);
|
|
15
|
+
// * parse diagnostics ⇒ null ⇒ the CALLER keeps the old behavior and emits the
|
|
16
|
+
// required stderr diagnostic. This module stays silent and side-effect free.
|
|
17
|
+
// typescript is a real dependency resolved at runtime (esbuild keeps it external;
|
|
18
|
+
// the createRequire banner in the bundle resolves it from the installed package).
|
|
19
|
+
import { createRequire } from "node:module";
|
|
20
|
+
|
|
21
|
+
const JS_TS_KINDS: Readonly<Record<string, string>> = {
|
|
22
|
+
".js": "JS", ".jsx": "JSX", ".mjs": "JS", ".cjs": "JS",
|
|
23
|
+
".ts": "TS", ".tsx": "TSX", ".mts": "TS", ".cts": "TS",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type LiteralSpan = { readonly start: number; readonly end: number };
|
|
27
|
+
|
|
28
|
+
export function isJsTsPath(filePath: string): boolean {
|
|
29
|
+
const dot = filePath.lastIndexOf(".");
|
|
30
|
+
return dot >= 0 && JS_TS_KINDS[filePath.slice(dot).toLowerCase()] !== undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let tsApi: any; // eslint-disable-line @typescript-eslint/no-explicit-any -- compiler API is structural here
|
|
34
|
+
function loadTs(): any {
|
|
35
|
+
if (tsApi === undefined) {
|
|
36
|
+
try {
|
|
37
|
+
tsApi = createRequire(import.meta.url)("typescript");
|
|
38
|
+
} catch {
|
|
39
|
+
tsApi = null; // resolution failure behaves like a parse failure: caller keeps old behavior + diagnostic
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return tsApi;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Literal spans for a JS/TS source; null when no tree can be trusted. */
|
|
46
|
+
export function literalSpans(filePath: string, text: string): LiteralSpan[] | null {
|
|
47
|
+
const ts = loadTs();
|
|
48
|
+
if (ts === null) return null;
|
|
49
|
+
const dot = filePath.lastIndexOf(".");
|
|
50
|
+
const kind = JS_TS_KINDS[filePath.slice(dot).toLowerCase()] ?? "TS";
|
|
51
|
+
const scriptKind = ts.ScriptKind[kind as keyof typeof ts.ScriptKind];
|
|
52
|
+
let sf: any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
53
|
+
try {
|
|
54
|
+
sf = ts.createSourceFile(filePath, text, scriptKind, /* setParentNodes */ true);
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const parseDiagnostics: unknown[] = sf.parseDiagnostics ?? [];
|
|
59
|
+
if (parseDiagnostics.length > 0) return null;
|
|
60
|
+
const spans: LiteralSpan[] = [];
|
|
61
|
+
const visit = (node: any): void => { // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
62
|
+
const k = node.kind as number;
|
|
63
|
+
if (
|
|
64
|
+
k === ts.SyntaxKind.StringLiteral
|
|
65
|
+
|| k === ts.SyntaxKind.NoSubstitutionTemplateLiteral
|
|
66
|
+
|| k === ts.SyntaxKind.TemplateHead
|
|
67
|
+
|| k === ts.SyntaxKind.TemplateMiddle
|
|
68
|
+
|| k === ts.SyntaxKind.TemplateTail
|
|
69
|
+
) {
|
|
70
|
+
spans.push({ start: node.getStart(sf), end: node.getEnd() });
|
|
71
|
+
}
|
|
72
|
+
node.forEachChild(visit);
|
|
73
|
+
};
|
|
74
|
+
visit(sf);
|
|
75
|
+
spans.sort((a: LiteralSpan, b: LiteralSpan) => a.start - b.start);
|
|
76
|
+
return spans;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Membership: is offset inside any span? Linear with early exit — literal
|
|
80
|
+
* spans nest (template head inside full template), and nested intervals
|
|
81
|
+
* defeat a plain binary walk (a right sibling can shadow its left ancestor). */
|
|
82
|
+
export function inSpan(spans: readonly LiteralSpan[], offset: number): boolean {
|
|
83
|
+
for (const s of spans) {
|
|
84
|
+
if (s.start > offset) break;
|
|
85
|
+
if (offset < s.end) return true;
|
|
86
|
+
}
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "border-customs",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.4",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "border-customs",
|
|
9
|
-
"version": "0.8.
|
|
9
|
+
"version": "0.8.4",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@secretlint/core": "13.0.5",
|
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
"@secretlint/secretlint-rule-preset-recommend": "13.0.5",
|
|
16
16
|
"@textlint/regexp-string-matcher": "2.0.2",
|
|
17
17
|
"publint": "0.3.24",
|
|
18
|
+
"typescript": "5.9.3",
|
|
18
19
|
"yaml": "2.9.0",
|
|
19
|
-
"zod": "4.
|
|
20
|
+
"zod": "4.6.7"
|
|
20
21
|
},
|
|
21
22
|
"bin": {
|
|
22
23
|
"border": "dist/index.js"
|
|
@@ -27,7 +28,6 @@
|
|
|
27
28
|
"@types/node": "22.19.0",
|
|
28
29
|
"esbuild": "0.25.12",
|
|
29
30
|
"secretlint": "13.0.5",
|
|
30
|
-
"typescript": "5.9.3",
|
|
31
31
|
"verdaccio": "6.10.2"
|
|
32
32
|
},
|
|
33
33
|
"engines": {
|
|
@@ -5760,7 +5760,6 @@
|
|
|
5760
5760
|
"version": "5.9.3",
|
|
5761
5761
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
|
5762
5762
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
|
5763
|
-
"dev": true,
|
|
5764
5763
|
"license": "Apache-2.0",
|
|
5765
5764
|
"bin": {
|
|
5766
5765
|
"tsc": "bin/tsc",
|
|
@@ -6105,9 +6104,9 @@
|
|
|
6105
6104
|
}
|
|
6106
6105
|
},
|
|
6107
6106
|
"node_modules/zod": {
|
|
6108
|
-
"version": "4.
|
|
6109
|
-
"resolved": "https://registry.npmjs.org/zod/-/zod-4.
|
|
6110
|
-
"integrity": "sha512-
|
|
6107
|
+
"version": "4.6.7",
|
|
6108
|
+
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
|
|
6109
|
+
"integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
|
|
6111
6110
|
"license": "MIT",
|
|
6112
6111
|
"funding": {
|
|
6113
6112
|
"url": "https://github.com/sponsors/colinhacks"
|