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 +10 -1
- 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
|
@@ -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.
|
|
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,
|
|
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.3",
|
|
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.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.
|
|
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"
|