border-customs 0.8.2 → 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
@@ -839,7 +839,7 @@ plugin entry ships as `exports["./server"]`):
839
839
  ```jsonc
840
840
  {
841
841
  // pin the exact version — an @latest channel hits the registry on every cold start
842
- "plugin": ["border-customs@0.8.2"]
842
+ "plugin": ["border-customs@0.8.3"]
843
843
  }
844
844
  ```
845
845
 
@@ -969,6 +969,15 @@ and every pull request, plus version single-source, typecheck, build, and audit
969
969
 
970
970
  ## Changelog
971
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
+
972
981
  ### 0.8.2 (2026-11-01)
973
982
  - Production dependency graph now measures ZERO advisories (`npm audit --omit=dev`): the
974
983
  pattern rule is vendored (`src/engines/borderPatternRule.ts`, MIT attribution; drops the
@@ -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,12 +1,12 @@
1
1
  {
2
2
  "name": "border-customs",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "border-customs",
9
- "version": "0.8.2",
9
+ "version": "0.8.3",
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.5.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.5.4",
6109
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.5.4.tgz",
6110
- "integrity": "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==",
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"