border-customs 0.8.1 → 0.8.3

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 CHANGED
@@ -10,7 +10,7 @@ cd /path/to/your/repo
10
10
  border check
11
11
  ```
12
12
 
13
- Requires Node >= 22 and the `gitleaks` binary on `PATH` (border vendors the rule config for
13
+ Requires Node >= 22.22.2 (the transitive `ini@7` dependency sets that floor) 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
15
  in-process as a bundled dependency. Each registry leg additionally shells out to its platform's
16
16
  own publisher, which must be on `PATH` whenever that target is configured; a missing publisher
@@ -18,14 +18,29 @@ binary is exit 2, never a skipped leg:
18
18
 
19
19
  | Registry target | Publisher border runs | Version the same-bytes proofs were measured on |
20
20
  | --- | --- | --- |
21
- | `npm` | `npm` | (unchanged since 0.1.0) |
21
+ | `npm` | `npm` | npm >= 11 for packages with pack-phase hooks (see below) |
22
22
  | `pypi` | `python3 -m build` / `python3 -m twine` | (unchanged since 0.1.0) |
23
23
  | `crates` | `cargo` | cargo 1.93.1 (rustup) |
24
24
  | `rubygems` | `gem` | RubyGems 3.6.7 on ruby 3.3.8 |
25
25
 
26
+ **npm pack safety probe.** `npm pack --ignore-scripts` is not trustworthy on npm 10: measured
27
+ on npm 10.9.7, the target package's `prepare` script still executes (npm 11.19.1 is clean).
28
+ Before packing, border runs `npm --version`. On npm < 11 it refuses, with a named exit 2, any
29
+ package that declares a `prepack`, `prepare`, or `postpack` script — border never executes the
30
+ code it audits. A package with none of those hooks has nothing for npm to run and stays
31
+ scannable on npm 10. Installing npm >= 11 removes the refusal.
32
+
26
33
  Other toolchain versions of `cargo` and `gem` are untested by those byte-determinism proofs.
27
34
  Optional engines are listed in [Configuration](#configuration).
28
35
 
36
+ **Pinned consumption.** Install an exact version (`npm install -g border-customs@<version>`;
37
+ `npx border-customs@<version> check` for a one-off) instead of running gate code out of a
38
+ source checkout. Every check and push record carries `engineBuild` — the identity
39
+ (`<version>+<commit>[.dirty]`) of the border build that produced the verdict, the same string
40
+ `border --version` prints. A `.dirty` suffix means the verdict came from uncommitted code, and
41
+ records from one repo carrying different `engineBuild` values mean the audit trail is mixed:
42
+ both are findings an auditor should chase, which is exactly why the identity is on the record face.
43
+
29
44
  ## The problem it solves
30
45
 
31
46
  The usual secret workflow is leak-then-repent: `git push`, notice the AWS key in the diff
@@ -207,7 +222,7 @@ an installer *does*, per tier — the split behind opencode-ai's in-tree `postin
207
222
  The pairing classes behind these rows (multi-channel PASS **C5** subset/ordering discipline;
208
223
  **C9** interpolated-and-indirected root spellings; **C10** comment/quote-boundary shapes that
209
224
  once silent-passed) are frozen with their evidence in
210
- [src/artifacts/RESIDUE-CONTRACT.md](src/artifacts/RESIDUE-CONTRACT.md).
225
+ [src/artifacts/RESIDUE-CONTRACT.md](https://github.com/TachikomaGundam/Border-Customs/blob/main/src/artifacts/RESIDUE-CONTRACT.md).
211
226
 
212
227
  **Fail-closed:** a residue scan whose rule table or classifier sources cannot be read is exit 2,
213
228
  never a silent pass — and the classifier source bytes are part of the ledger `rulesHash`, so
@@ -222,10 +237,30 @@ clean residue scan is an absence of matched signatures, never a certificate of b
222
237
  The signature table is deliberately closed, and the list of shapes it **cannot see** —
223
238
  content-writes, handle-variants, dead spellings, interpolation-then-slash, span-quirk
224
239
  over-blocks, IO-instance writers, user-shadowed bare verbs — lives in
225
- [RESIDUE-CONTRACT.md §8](src/artifacts/RESIDUE-CONTRACT.md); this README points, it does not
240
+ [RESIDUE-CONTRACT.md §8](https://github.com/TachikomaGundam/Border-Customs/blob/main/src/artifacts/RESIDUE-CONTRACT.md); this README points, it does not
226
241
  restate. The empirical roundtrip (install → snapshot → uninstall → rc-delta-zero) is the 0.4.0
227
- valve. **border is NOT a malware sandbox**: it never executes, intercepts, or sandboxes the
228
- code it reads; it matches text and blocks the push.
242
+ valve. **border does not execute the code it audits.** Every scan leg (gitleaks, secretlint,
243
+ the native rules, the exfil lens) is pure byte inspection; the cross-channel marker fixture
244
+ (`test/build.execMarkers.test.ts`) pins that a consentless `border check` spawns ZERO build
245
+ toolchains. Registry artifact builds (npm pack, `python -m build`, `gem build`, `cargo
246
+ package`) DO execute repo-supplied build code, so they run only on operator consent:
247
+ `--allow-builds`, or `allowBuilds: true` in the machine store / an explicit `--config` file
248
+ outside the repo (the environment is deliberately NOT a consent source — a hostile `.envrc`
249
+ owns it). Both sources live OUTSIDE the audited tree, and a repo's own border.yaml can declare
250
+ targets but can never enable building them. Without
251
+ consent the run is scan-only: no artifact digests are recorded (visible as MEDIUM
252
+ `build-stage-withheld` findings), and a scan-only PASS can never back a registry publish.
253
+ Consented builds can additionally run inside a **pinned container** (`targets.<ch>.sandboxImage`,
254
+ see Configuration): the container is launched with the network disabled — the `--network none`
255
+ argv the daemon applies is the enforcement anchor, and an in-container probe re-checks DNS and
256
+ TCP from inside as a tripwire — the repo mounted read-only and built from a tmpfs copy, host
257
+ credentials never entering the container, and every ledger record bound to the exact image
258
+ digest that certified its bytes. `sandboxImage` itself is a security-tier key: honored only
259
+ from the machine store or a `--config` file outside the repo whose ownership and modes pass
260
+ the fd-chain qualification, and any competing declaration (repo config, lax-mode file, hijacked
261
+ `XDG_CONFIG_HOME`) is a hard refusal, never a silent fallback. Border is
262
+ containment-by-consent plus optional container containment — it is not a malware sandbox and
263
+ not a VM: a kernel-level escape from the container remains in the threat model.
229
264
 
230
265
  #### Proof valve: `residue.requireProof` (0.4.0)
231
266
 
@@ -267,7 +302,7 @@ lives inside. The exfil lens adds five rules over the surfaces about to become p
267
302
  | `exfil-machine-binding` | vendor inference-model ids pinned on portable surfaces next to model/default/provider context (L-MACHINE-LOCAL; born 2026-10-02) |
268
303
 
269
304
  The severity a reader **observes** is the (rule × facet × channel) matrix, and its single
270
- home is [src/exfil/severity.ts](src/exfil/severity.ts) — the check pipeline, the
305
+ home is [src/exfil/severity.ts](https://github.com/TachikomaGundam/Border-Customs/blob/main/src/exfil/severity.ts) — the check pipeline, the
271
306
  `border exfil` CLI and this table all render that table, never re-decide it; the
272
307
  `EXFIL-DOC` guard test fails the build if a cell is restated wrong:
273
308
 
@@ -608,11 +643,60 @@ degraded run.
608
643
 
609
644
  ## Configuration
610
645
 
611
- Everything border can be told lives in `border.yaml` at the repo root. Unknown keys are
646
+ Border is a property of the **checking machine**, not of the checked repo. Config resolves
647
+ in this order, and the machine-side rungs exist so gate config (including waivers) never has
648
+ to be committed into a product repo:
649
+
650
+ 1. `--config <path>` (explicit);
651
+ 2. `$BORDER_CONFIG` — fail-closed: set-but-empty or unreadable is exit 2, never a fall-through;
652
+ 3. the **machine store**: `$BORDER_CONFIG_DIR` (else `$XDG_CONFIG_HOME/border/machines`, else
653
+ `~/.config/border/machines`) holding `<sha256-16>.yaml` files keyed by the repo's canonical
654
+ remote URL (`host/path`, credentials/`.git`/scp-vs-https spellings collapsed);
655
+ 4. `./border.yaml` (cwd, then git toplevel) — kept for repos that *choose* to carry their config;
656
+ 5. git-remote inference (targets only, no waivers) when nothing above exists.
657
+
658
+ Unknown keys are
612
659
  rejected at load (a typo in a gate config is a gate you did not ask for). `${VAR}`
613
660
  expansion applies only to remote URLs and registry/repository/host values, and an unset variable
614
661
  is a hard error, never an empty string.
615
662
 
663
+ #### Sandboxed builds (`targets.<ch>.sandboxImage`)
664
+
665
+ Configure pins with the helper instead of hand-editing the machine store:
666
+ `border sandbox init --lane npm --image node@sha256:<64hex>` (writes only the
667
+ 0600 machine-store file under the §3-qualified root, refuses tmp/unowned dirs and
668
+ tag refs); `border sandbox status` shows what the loader will actually honor;
669
+ `unpin` removes keys. The helper never writes into the audited repo.
670
+
671
+ When a lane declares `sandboxImage`, that lane's consented build runs inside `docker run`
672
+ instead of on the host: `--network none` (the daemon-applied argv is the enforcement anchor;
673
+ a DNS+TCP probe re-checks from *inside* as a tripwire that fails the build closed), read-only repo mount, build on a tmpfs snapshot, `--cap-drop ALL`,
674
+ `no-new-privileges`, and a scrubbed env (image defaults + `HOME=/tmp` only). The image MUST be
675
+ digest-pinned:
676
+
677
+ ```yaml
678
+ targets:
679
+ npm:
680
+ sandboxImage: node@sha256:<64 hex> # tags are rejected at load — a mutable tag
681
+ # would let check and push certify different bytes
682
+ ```
683
+
684
+ `sandboxImage` is a **security-tier key**: it is honored only from the machine store or an
685
+ explicit `--config` file outside the repo, and only after fd-chain qualification (every path
686
+ component verified by open file descriptor: root-owned or euid-owned above your trust anchor,
687
+ euid-owned `0700`-below files `0600` below it — symlink or mode violations fail closed).
688
+ Any other declaration of the same key — the repo's own border.yaml, a lax-mode (group/world
689
+ readable or writable) store file, an `XDG_CONFIG_HOME` override pointing somewhere
690
+ unqualifiable — is a named hard refusal (`sandbox-config-shadowed` /
691
+ `sandbox-config-unqualified`), never a silent ignore and never a host fallback.
692
+
693
+ Freshness and push carry the same binding: a PASS certified inside image X is re-proved only
694
+ inside X (config drift forces a full re-check), and docker being *absent* while an image is
695
+ configured is exit 2 with the remedy named — never a silent host fallback. No `sandboxImage`
696
+ means today's host build, unchanged. The crates lane keeps its registry cache in
697
+ `.border/cache/` (the one writable bind); the pypi image must ship the build backend's
698
+ dependencies (there is no network inside).
699
+
616
700
  Minimal, a git-only repo:
617
701
 
618
702
  ```yaml
@@ -663,8 +747,15 @@ rules:
663
747
  ips: ["192.0.2.1"]
664
748
  pathPatterns: ["/Users/*", "*.pfx"]
665
749
  maxFileKB: 500 # oversized-file threshold (default)
750
+ aliases: # optional: rule-rename migration for allow pins
751
+ old-rule-name: current-rule-name # {renamed-from: current} — waivers survive renames
666
752
  allow: # enumerated suppressions, never blanket
667
753
  - { rule: "junk-artifact", match: "*", file: "test/fixtures/**" }
754
+ - rule: "gitleaks:*" # fixture-set waiver: values pins the exact
755
+ file: "tests/fixtures/**" # value digests; ANY value outside stays red
756
+ values: ["<64hex>", "<64hex>"]
757
+ until: "2027-01-01" # optional UTC sunset; after it the entry is
758
+ # inert and echoed as allow-entry-expired
668
759
  engines:
669
760
  require: [gitleaks, secretlint] # trufflehog: true adds the third-party engine
670
761
  residue:
@@ -675,6 +766,15 @@ release:
675
766
 
676
767
  Notes that change behavior:
677
768
 
769
+ - **Archive member addressing in `allow.file`:** findings inside archives are attributed
770
+ `archive!<inner>`. A plain `file` glob matches only the truncated OUTER path (`archive`),
771
+ so member-level pins need the escape form `archive\\!inner` (the `\!` switches the entry to
772
+ full-attribution matching). Members are addressable; forgetting the escape silently matches
773
+ the whole archive instead.
774
+ - **Waiver floors:** a `values` entry suppresses only the listed value digests — a new value
775
+ under the same path/rule stays red; an `until` entry is inert after its UTC date and is
776
+ echoed as an INFO `allow-entry-expired` finding instead of quietly keeping work.
777
+
678
778
  - `targets.git.remotes: []` (explicit, empty) is a *deliberate* repo-local scope: all
679
779
  history/tree/identity/artifact legs still run, only the exposure set is empty. A
680
780
  completely missing config with real remotes falls back to config-from-`git remote` with
@@ -719,7 +819,10 @@ the hook must first pass. Drop `--force` to get sub-second skip-ledger replay in
719
819
  re-scan on every `git push`.
720
820
 
721
821
  **CI.** `border check --json` emits the stable report object; gate on the exit code, parse
722
- `verdict`/`counts` for dashboards. In CI you typically want `--force` on a cache-less
822
+ `verdict`/`counts` for dashboards. Each finding is
823
+ `{ rule, severity, target, path?, line?, commit?, engine, message, valueDigest, snippet }` —
824
+ the file location is **`path`** (absent when the finding is commit-only, e.g. an identity
825
+ finding); there is no `file` field. In CI you typically want `--force` on a cache-less
723
826
  runner, plus your `gitleaks` binary provisioned in the image (a degraded run exits 2, which
724
827
  most CI maps to red; that is the point).
725
828
 
@@ -736,7 +839,7 @@ plugin entry ships as `exports["./server"]`):
736
839
  ```jsonc
737
840
  {
738
841
  // pin the exact version — an @latest channel hits the registry on every cold start
739
- "plugin": ["border-customs@0.8.1"]
842
+ "plugin": ["border-customs@0.8.3"]
740
843
  }
741
844
  ```
742
845
 
@@ -832,6 +935,10 @@ which is now a thin consumer of `inspect --json` with the rc contract unchanged
832
935
  - **Fail-closed doctrine.** "Silence never means absent" is applied mechanically: empty
833
936
  registry stdout, unparsed versions, non-translatable exit codes, unresolvable ownership,
834
937
  corrupt-but-present rules inputs, all become exit 2 or a CRITICAL, never a pass.
938
+ - **Platform behaviour is probed, not assumed.** The npm stage reads `npm --version` before
939
+ packing. On npm < 11 a declared pack-phase hook is a named exit 2, because that npm line
940
+ executes `prepare` despite `--ignore-scripts` (reproduced on npm 10.9.7, 2026-10-19); a
941
+ missing or unparseable version is also exit 2.
835
942
  - **No telemetry, no callbacks.** `border check` touches the network only for the
836
943
  registry pre-flight, against the URLs the channels define: `registry.npmjs.org`,
837
944
  `pypi.org`, and `rubygems.org` are overridable (private indexes; `targets.rubygems.host`),
@@ -843,9 +950,91 @@ which is now a thin consumer of `inspect --json` with the rc contract unchanged
843
950
  it, and which every scan leg refuses to treat as a finding source or an allow-list
844
951
  target. A repo that *tracks* `.border/**` is itself a CRITICAL finding. A single-writer
845
952
  lock makes concurrent runs exit 2 instead of racing.
953
+ - **Reports go through a private channel.** See [SECURITY.md](SECURITY.md) for the
954
+ reporting route and scope. Do not put exploit details or live secrets in a public issue.
955
+
956
+ ## Development
957
+
958
+ Contributor setup, the exact external-engine list, subset test commands, and the version
959
+ rotation procedure live in [CONTRIBUTING.md](CONTRIBUTING.md). In short:
960
+
961
+ ```bash
962
+ npm ci
963
+ npm run typecheck
964
+ npm test
965
+ ```
966
+
967
+ CI runs the full suite on Node 22 (npm 10) and Node 24 (npm 11) for every push to `main`
968
+ and every pull request, plus version single-source, typecheck, build, and audit gates.
846
969
 
847
970
  ## Changelog
848
971
 
972
+ ### 0.8.3 (2026-11-02)
973
+ - Fix: `exfil-cred-location` is now syntax-aware in JS/TS sources (handoff b61b42a) —
974
+ `.env` tokens count only inside string-literal spans (TypeScript compiler API, shipped
975
+ as a runtime dependency, bundle stays external-pinned); member accesses (`opts.env`),
976
+ short receivers and comment text are no longer reported. Markdown/text keep the
977
+ bare-mention behavior; every other rule is untouched. All tree faces (check, history,
978
+ tip lens) share the guard; parse failure ⇒ old behavior + one stderr note, exit codes
979
+ unchanged. Rules fingerprint rotated (cached PASSes re-verify once).
980
+
981
+ ### 0.8.2 (2026-11-01)
982
+ - Production dependency graph now measures ZERO advisories (`npm audit --omit=dev`): the
983
+ pattern rule is vendored (`src/engines/borderPatternRule.ts`, MIT attribution; drops the
984
+ unpatchable braces/micromatch + fast-uri chain) and `tool` — an identity function — is
985
+ vendored in `plugin/border.ts`, moving `@opencode-ai/plugin` (and its effect-beta → toml
986
+ chain) to devDependencies; live-host probe tests keep the activation proof. The CI audit
987
+ gate tightens from critical to HIGH so this cannot rot.
988
+ - `border sandbox init|pin|status|unpin` — the opt-in pin helper from the Q1 ruling
989
+ (machine store only, 0600 atomic writes, §3 self-verification with rollback of its own
990
+ writes; deliberately absent from the plugin's agent-reachable command set).
991
+ - Security tier for execution-deciding keys (`src/tier.ts`, sibyl §3⁗/§3⁖/§3‡):
992
+ `sandboxImage`/`allowBuilds` honor only from fd-chain-qualified machine-store or out-of-repo
993
+ `--config` sources; repo declarations, lax-mode files, and hijacked `XDG_CONFIG_HOME` are
994
+ named refusals (`sandbox-config-shadowed`/`sandbox-config-unqualified`); the
995
+ `BORDER_ALLOW_BUILDS` environment channel is REMOVED (never released, so no migration);
996
+ passwd-derived canonical store root — `$HOME` spoofing cannot move it.
997
+ - Sandboxed artifact builds: optional `targets.<ch>.sandboxImage` (digest-pinned, tags fail
998
+ closed at load) runs consented npm/pypi/crates/rubygems builds — and freshness re-packs —
999
+ inside `docker run` with `--network none` proved by an in-container DNS+TCP probe, a
1000
+ read-only repo mount, tmpfs build, `--cap-drop ALL`, scrubbed env, and the ledger record
1001
+ bound to the exact image digest (a rotated image invalidates cached PASS). Missing docker
1002
+ with an image configured is a named exit 2, never a host fallback. New `test/sandbox.test.ts`
1003
+ pins the docker argv contract token by token.
1004
+ - Hardened the npm pack-phase guard (sibyl must_fix): ALL package.json manifests in the tree
1005
+ are scanned, not just the root (a workspaces child's `prepare` is now refused on npm < 11),
1006
+ and unreadable manifests or a walk-cap hit count as a hook — unknown is never assumed safe.
1007
+ - Machine-side config (HR decoupling handoff 2026-10-23): resolution chain is now
1008
+ `--config > $BORDER_CONFIG (fail-closed) > machine store (XDG/border/machines, keyed by
1009
+ canonical remote URL) > ./border.yaml > git-remote inference`, so gate config and waiver
1010
+ lists live on the checking machine and never need to be committed into a product repo.
1011
+ Allow entries gained `values` (fixture-set waivers that never cover a new value), `until`
1012
+ (UTC sunset, expired entries echoed never silently honored), `\!` archive-member escaping,
1013
+ and `rules.aliases` (rule renames no longer strand waivers). Border state was already
1014
+ porcelain-invisible (`.border/.gitignore` self-ignore; verified 2026-10-23).
1015
+ - Provenance: every check and push record carries `engineBuild`
1016
+ (`<version>+<commit>[.dirty]`, the string `border --version` prints), so a verdict names
1017
+ the exact border build that produced it. Motivated by a machine where every gate ran off
1018
+ an unpinned worktree bundle and no record said so; worktree use is now visible on the
1019
+ record face (see "Pinned consumption"). Bundles are stamped by `tools/build.mjs`.
1020
+ - Security (breaking): artifact build stages now require operator consent. A hostile clone can
1021
+ ship its own border.yaml declaring pypi/rubygems targets, and `border check` built them —
1022
+ PEP 517 backends and gemspecs ARE code (`python3 -m build --no-isolation` and `gem build`
1023
+ markers reproduced 2026-10-23). Default runs are now scan-only (zero build spawns, pinned by
1024
+ `test/build.execMarkers.test.ts`); consent comes only from outside the audited tree:
1025
+ `--allow-builds` or `BORDER_ALLOW_BUILDS=1`. A scan-only PASS records no artifact digests and
1026
+ therefore can never back a registry publish; `border scan` of third-party artifacts requires
1027
+ the same consent (named exit 2 without it).
1028
+ - Security: refuse npm < 11 packages that declare `prepack`/`prepare`/`postpack` before
1029
+ spawning `npm pack`. npm 10 executes `prepare` even with `--ignore-scripts` (reproduced on
1030
+ 10.9.7; npm 11.19.1 is clean), so the old defense could execute code from the audited repo.
1031
+ The probe also runs before freshness re-packs. New regression tests pin the refusal and the
1032
+ never-fires hook marker.
1033
+ - Fix: the Windows drive-letter path pattern no longer fires on JS escapes in prose
1034
+ (`raw-repoPath:\n`); the UNC pattern no longer spans newlines.
1035
+ - Docs: the `--json` findings schema is stated explicitly (`path`, no `file` alias);
1036
+ npm-target prerequisites now name the npm 11 boundary.
1037
+
849
1038
  ### 0.8.1 (2026-10-06)
850
1039
  - Maintenance release: certificate-chain rebuild off the scrubbed lineage (INCIDENT-20261006
851
1040
  identity-exposure cleanup). No functional change vs 0.8.0; 0.8.0's tarball is byte-intact but
@@ -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, _o: RuleOptions): RuleHit[] {
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
- text.split("\n").forEach((body, idx) => {
61
- for (const m of body.matchAll(PYPIRC_RE)) push(m[0] ?? "", idx + 1);
62
- for (const m of body.matchAll(SSHPASS_RE)) push(m[0] ?? "", idx + 1);
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)) push(tok, idx + 1);
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 = { facet, ...(input.hosts !== undefined ? { hosts: input.hosts } : {}) };
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,3 +1,27 @@
1
+ # Vendored from gitleaks v8.30.1 (assets/default_config.toml), MIT License.
2
+ #
3
+ # MIT License
4
+ #
5
+ # Copyright (c) 2019 Zachary Rice
6
+ #
7
+ # Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ # of this software and associated documentation files (the "Software"), to deal
9
+ # in the Software without restriction, including without limitation the rights
10
+ # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ # copies of the Software, and to permit persons to whom the Software is
12
+ # furnished to do so, subject to the following conditions:
13
+ #
14
+ # The above copyright notice and this permission notice shall be included in all
15
+ # copies or substantial portions of the Software.
16
+ #
17
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ # SOFTWARE.
24
+
1
25
  # This file has been auto-generated. Do not edit manually.
2
26
  # If you would like to contribute new rules, please use
3
27
  # cmd/generate/config/main.go and follow the contributing guidelines