jules-orchestrator-kit 0.74.0 → 0.75.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -1
- package/JULES_RULES_TEMPLATE.md +3 -0
- package/README.md +4 -2
- package/bin/agentctl.mjs +395 -23
- package/package.json +1 -1
- package/scripts/ci-scope-guard.mjs +226 -23
- package/scripts/run-tests.mjs +5 -1
- package/src/bidi-guard.mjs +190 -15
- package/src/ci-templates.mjs +184 -21
- package/src/config.mjs +40 -1
- package/src/engine.mjs +342 -48
- package/src/evidence.mjs +22 -5
- package/src/git.mjs +15 -0
- package/src/migrate.mjs +493 -0
- package/src/ops/command-registry.mjs +93 -4
- package/src/ops/doctor-registry.mjs +184 -32
- package/src/ops/pr-harvest.mjs +22 -2
- package/src/ops/toolchain-diagnostics.mjs +253 -0
- package/src/ops/tui-menu.mjs +192 -0
- package/src/process-tree.mjs +146 -0
- package/src/prompt-guard.mjs +9 -0
- package/src/provider.mjs +14 -0
- package/src/scaffold.mjs +49 -1
- package/src/scope-guard.mjs +73 -5
- package/src/secret-scanner.mjs +14 -2
- package/src/security.mjs +41 -30
- package/src/self-audit.mjs +237 -3
- package/src/state.mjs +123 -13
- package/src/uninstall.mjs +227 -0
- package/src/wizard-init.mjs +11 -0
- package/src/wizard-task.mjs +17 -22
|
@@ -16,14 +16,18 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { execFileSync } from "node:child_process";
|
|
18
18
|
import { checkScope } from "../src/security.mjs";
|
|
19
|
-
import { normalizePath } from "../src/config.mjs";
|
|
19
|
+
import { normalizePath, canonicalizePath } from "../src/config.mjs";
|
|
20
20
|
|
|
21
21
|
/** Exit code 3 in the kit's registry: scope violation. */
|
|
22
22
|
const EXIT_SCOPE_VIOLATION = 3;
|
|
23
23
|
const EXIT_ERROR = 1;
|
|
24
24
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Primary label prefix that lets a human consciously land a protected-path change bound to commit SHA.
|
|
27
|
+
* Conforms to GitHub's 50-character maximum label limit: "allow-p:<40-hex-SHA>" is 48 characters.
|
|
28
|
+
*/
|
|
29
|
+
export const BYPASS_LABEL = "allow-p";
|
|
30
|
+
export const LEGACY_BYPASS_LABEL = "allow-protected-paths";
|
|
27
31
|
|
|
28
32
|
function gitShow(ref, path, cwd) {
|
|
29
33
|
return execFileSync("git", ["show", `${ref}:${path}`], {
|
|
@@ -70,45 +74,228 @@ export function loadProtectedPatterns(opts = {}) {
|
|
|
70
74
|
);
|
|
71
75
|
}
|
|
72
76
|
|
|
77
|
+
/** Where a symlink at `link` pointing at `target` lands, as a repo-relative or absolute path. */
|
|
78
|
+
export function resolveLinkTarget(link, target) {
|
|
79
|
+
const normLink = normalizePath(link);
|
|
80
|
+
const normTarget = normalizePath(target);
|
|
81
|
+
if (normTarget.startsWith("/") || /^[a-zA-Z]:[/\\]/.test(target)) {
|
|
82
|
+
return normTarget;
|
|
83
|
+
}
|
|
84
|
+
const linkDir = normLink.split("/").slice(0, -1).join("/");
|
|
85
|
+
return canonicalizePath(linkDir ? `${linkDir}/${normTarget}` : normTarget);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parses `git diff --raw -z` output into structured file change entries.
|
|
90
|
+
*
|
|
91
|
+
* Each record emitted by `--raw -z` is:
|
|
92
|
+
* ":<srcmode> <dstmode> <srcsha> <dstsha> <status>\0<path>\0"
|
|
93
|
+
* (or two paths if renames were enabled). With `--no-renames`, it is always 1 path.
|
|
94
|
+
*
|
|
95
|
+
* For symlinks (mode 120000), retrieves the link target blob via `git cat-file blob <dstSha>`
|
|
96
|
+
* without checking out the branch to disk.
|
|
97
|
+
*
|
|
98
|
+
* @param {string} raw - raw stdout from git diff --raw -z
|
|
99
|
+
* @param {string} [root] - repo root cwd
|
|
100
|
+
* @returns {Array<{ file: string, srcMode: string, dstMode: string, srcSha: string, dstSha: string, status: string, symlinkTarget?: string, symlinkUnreadable?: boolean }>}
|
|
101
|
+
*/
|
|
102
|
+
export function parseRawDiffEntries(raw, root = process.cwd()) {
|
|
103
|
+
if (!raw || typeof raw !== "string") return [];
|
|
104
|
+
const fields = raw.split("\0").filter((f) => f !== "");
|
|
105
|
+
const entries = [];
|
|
106
|
+
|
|
107
|
+
for (let i = 0; i < fields.length; i++) {
|
|
108
|
+
const meta = fields[i];
|
|
109
|
+
if (!meta.startsWith(":")) continue;
|
|
110
|
+
const parts = meta.slice(1).split(/\s+/);
|
|
111
|
+
const file = fields[i + 1];
|
|
112
|
+
i += 1;
|
|
113
|
+
if (!file) continue;
|
|
114
|
+
|
|
115
|
+
const srcMode = parts[0] || "";
|
|
116
|
+
const dstMode = parts[1] || "";
|
|
117
|
+
const srcSha = parts[2] || "";
|
|
118
|
+
const dstSha = parts[3] || "";
|
|
119
|
+
const status = (parts[4] || "").charAt(0);
|
|
120
|
+
|
|
121
|
+
let symlinkTarget = null;
|
|
122
|
+
let symlinkUnreadable = false;
|
|
123
|
+
|
|
124
|
+
if (dstMode === "120000") {
|
|
125
|
+
if (dstSha && !/^0+$/.test(dstSha)) {
|
|
126
|
+
try {
|
|
127
|
+
const rawTarget = execFileSync("git", ["cat-file", "blob", dstSha], {
|
|
128
|
+
cwd: root,
|
|
129
|
+
encoding: "utf-8",
|
|
130
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
131
|
+
}).trim();
|
|
132
|
+
if (rawTarget) {
|
|
133
|
+
symlinkTarget = resolveLinkTarget(file, rawTarget);
|
|
134
|
+
} else {
|
|
135
|
+
symlinkUnreadable = true;
|
|
136
|
+
}
|
|
137
|
+
} catch (_) {
|
|
138
|
+
symlinkUnreadable = true;
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
symlinkUnreadable = true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
entries.push({
|
|
146
|
+
file: normalizePath(file),
|
|
147
|
+
srcMode,
|
|
148
|
+
dstMode,
|
|
149
|
+
srcSha,
|
|
150
|
+
dstSha,
|
|
151
|
+
status,
|
|
152
|
+
symlinkTarget,
|
|
153
|
+
symlinkUnreadable,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return entries;
|
|
158
|
+
}
|
|
159
|
+
|
|
73
160
|
/**
|
|
74
161
|
* Lists the paths a pull request changes.
|
|
75
162
|
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
* `säkerhet/nyckel.pem` arrive as tokens that match no pattern — a protected
|
|
79
|
-
* file walking past the guard because of how it is spelled.
|
|
163
|
+
* Inspects `git diff --raw -z --no-renames` to capture all paths while
|
|
164
|
+
* preserving spaces and non-ASCII characters without quoting corruption.
|
|
80
165
|
*
|
|
81
166
|
* @param {{ baseSha: string, headSha: string, root?: string }} opts
|
|
82
167
|
* @returns {string[]}
|
|
83
168
|
*/
|
|
84
169
|
export function listChangedFiles(opts = {}) {
|
|
170
|
+
return listChangedEntries(opts).map((e) => e.file);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Returns raw diff entries including blob mode and symlink resolution.
|
|
175
|
+
*
|
|
176
|
+
* @param {{ baseSha: string, headSha: string, root?: string }} opts
|
|
177
|
+
* @returns {Array<{ file: string, srcMode: string, dstMode: string, srcSha: string, dstSha: string, status: string, symlinkTarget?: string, symlinkUnreadable?: boolean }>}
|
|
178
|
+
*/
|
|
179
|
+
export function listChangedEntries(opts = {}) {
|
|
85
180
|
const root = opts.root || process.cwd();
|
|
86
181
|
const raw = execFileSync(
|
|
87
182
|
"git",
|
|
88
|
-
["-c", "core.quotePath=false", "diff", "-z", "--
|
|
183
|
+
["-c", "core.quotePath=false", "diff", "-z", "--raw", "--no-renames", opts.baseSha, opts.headSha],
|
|
89
184
|
{ cwd: root, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 10 * 1024 * 1024 }
|
|
90
185
|
);
|
|
91
|
-
return raw
|
|
186
|
+
return parseRawDiffEntries(raw, root);
|
|
92
187
|
}
|
|
93
188
|
|
|
94
189
|
/**
|
|
95
190
|
* Pure evaluation core, so the decision is testable without a git repository.
|
|
96
191
|
*
|
|
97
|
-
*
|
|
192
|
+
* Evaluates changed files and entries against protected patterns:
|
|
193
|
+
* - Rejects Git submodules (mode 160000)
|
|
194
|
+
* - Evaluates symlinks (mode 120000): rejects traversal / root escape, fails closed on unreadable targets,
|
|
195
|
+
* and fails if the resolved symlink target points to a protected file.
|
|
196
|
+
*
|
|
197
|
+
* @param {Array<string|object>} filesOrEntries
|
|
98
198
|
* @param {string[]} patterns
|
|
99
|
-
* @param {{ labels?: string[] }} [opts]
|
|
199
|
+
* @param {{ labels?: string[], headSha?: string, symlinks?: Array<{ link: string, target: string }> }} [opts]
|
|
100
200
|
* @returns {{ ok: boolean, bypassed: boolean, violations: Array<object> }}
|
|
101
201
|
*/
|
|
102
|
-
export function evaluateScopeGuard(
|
|
202
|
+
export function evaluateScopeGuard(filesOrEntries = [], patterns = [], opts = {}) {
|
|
103
203
|
const labels = (opts.labels || []).map((l) => String(l).toLowerCase().trim());
|
|
104
|
-
const
|
|
204
|
+
const headSha = String(opts.headSha || "").trim().toLowerCase();
|
|
205
|
+
|
|
206
|
+
let bypassed = false;
|
|
207
|
+
if (headSha) {
|
|
208
|
+
if (/^[0-9a-f]{40}$/i.test(headSha)) {
|
|
209
|
+
const boundLabel = `${BYPASS_LABEL}:${headSha}`;
|
|
210
|
+
const legacyBoundLabel = `${LEGACY_BYPASS_LABEL}:${headSha}`;
|
|
211
|
+
bypassed = labels.includes(boundLabel) || labels.includes(legacyBoundLabel);
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
bypassed = labels.includes(BYPASS_LABEL) || labels.includes(LEGACY_BYPASS_LABEL);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const normalizedEntries = [];
|
|
218
|
+
for (const item of filesOrEntries) {
|
|
219
|
+
if (typeof item === "string") {
|
|
220
|
+
normalizedEntries.push({ file: item });
|
|
221
|
+
} else if (item && typeof item === "object") {
|
|
222
|
+
normalizedEntries.push(item);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (Array.isArray(opts.symlinks)) {
|
|
227
|
+
for (const s of opts.symlinks) {
|
|
228
|
+
if (s && s.link) {
|
|
229
|
+
normalizedEntries.push({
|
|
230
|
+
file: s.link,
|
|
231
|
+
dstMode: "120000",
|
|
232
|
+
symlinkTarget: s.target || null,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const explicitViolations = [];
|
|
239
|
+
const scopeCandidates = [];
|
|
240
|
+
const symlinkTargetOf = new Map();
|
|
241
|
+
|
|
242
|
+
for (const entry of normalizedEntries) {
|
|
243
|
+
const file = entry.file || "";
|
|
244
|
+
if (file) {
|
|
245
|
+
scopeCandidates.push(file);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// 1. Refuse Git submodules (mode 160000)
|
|
249
|
+
if (entry.dstMode === "160000") {
|
|
250
|
+
explicitViolations.push({
|
|
251
|
+
file,
|
|
252
|
+
reason: "Git submodule (mode 160000) is forbidden in agent pull requests",
|
|
253
|
+
rule: "deny",
|
|
254
|
+
pattern: "<submodule>",
|
|
255
|
+
});
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 2. Inspect Symlinks (mode 120000)
|
|
260
|
+
if (entry.dstMode === "120000") {
|
|
261
|
+
if (entry.symlinkUnreadable || !entry.symlinkTarget) {
|
|
262
|
+
explicitViolations.push({
|
|
263
|
+
file,
|
|
264
|
+
reason: "Unreadable or empty symlink target cannot be verified",
|
|
265
|
+
rule: "deny",
|
|
266
|
+
pattern: "<unreadable-symlink>",
|
|
267
|
+
});
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const target = entry.symlinkTarget;
|
|
272
|
+
scopeCandidates.push(target);
|
|
273
|
+
symlinkTargetOf.set(target, file);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
105
276
|
|
|
106
277
|
// Matching always runs at full strength; the label only decides whether a
|
|
107
278
|
// match blocks. Passing `allowProtected` into checkScope instead would make
|
|
108
279
|
// a bypassed run report zero violations, and the job log is the record of
|
|
109
280
|
// what a human waved through.
|
|
110
|
-
const res = checkScope(
|
|
111
|
-
|
|
281
|
+
const res = checkScope(scopeCandidates, { protect: patterns }, { allowProtected: false });
|
|
282
|
+
|
|
283
|
+
const mappedScopeViolations = (res.violations || []).map((v) => {
|
|
284
|
+
const link = symlinkTargetOf.get(v.file);
|
|
285
|
+
if (link) {
|
|
286
|
+
return {
|
|
287
|
+
...v,
|
|
288
|
+
link,
|
|
289
|
+
file: link,
|
|
290
|
+
target: v.file,
|
|
291
|
+
reason: `Symlink "${link}" -> "${v.file}": ${v.reason}`,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
return v;
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const allViolations = [...explicitViolations, ...mappedScopeViolations];
|
|
298
|
+
return { ok: bypassed || allViolations.length === 0, bypassed, violations: allViolations };
|
|
112
299
|
}
|
|
113
300
|
|
|
114
301
|
/**
|
|
@@ -142,27 +329,43 @@ function main() {
|
|
|
142
329
|
}
|
|
143
330
|
|
|
144
331
|
let patterns;
|
|
145
|
-
let
|
|
332
|
+
let entries = [];
|
|
146
333
|
try {
|
|
147
334
|
patterns = loadProtectedPatterns({ baseSha, baseRef, root });
|
|
148
|
-
|
|
335
|
+
entries = listChangedEntries({ baseSha, headSha, root });
|
|
149
336
|
} catch (err) {
|
|
150
337
|
console.error(`::error::Agent Scope Guard could not evaluate this pull request: ${err.message}`);
|
|
151
338
|
process.exit(EXIT_ERROR);
|
|
152
339
|
}
|
|
153
340
|
|
|
154
341
|
const labels = parseLabels(process.env.PR_LABELS);
|
|
155
|
-
const result = evaluateScopeGuard(
|
|
342
|
+
const result = evaluateScopeGuard(entries, patterns, { labels, headSha });
|
|
156
343
|
|
|
157
344
|
console.log(`Protected patterns (${patterns.length}): ${patterns.join(", ")}`);
|
|
158
|
-
console.log(`Changed files (${
|
|
159
|
-
for (const
|
|
345
|
+
console.log(`Changed files (${entries.length}):`);
|
|
346
|
+
for (const e of entries) {
|
|
347
|
+
if (e.dstMode === "120000") {
|
|
348
|
+
console.log(` ${e.file} -> ${e.symlinkTarget || "(unresolved)"} [symlink]`);
|
|
349
|
+
} else if (e.dstMode === "160000") {
|
|
350
|
+
console.log(` ${e.file} [submodule]`);
|
|
351
|
+
} else {
|
|
352
|
+
console.log(` ${e.file}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const labelName = headSha ? `${BYPASS_LABEL}:${headSha}` : BYPASS_LABEL;
|
|
357
|
+
const legacyLabelName = headSha ? `${LEGACY_BYPASS_LABEL}:${headSha}` : LEGACY_BYPASS_LABEL;
|
|
358
|
+
const activeLabel = labels.includes(labelName)
|
|
359
|
+
? labelName
|
|
360
|
+
: labels.includes(legacyLabelName)
|
|
361
|
+
? legacyLabelName
|
|
362
|
+
: labelName;
|
|
160
363
|
|
|
161
364
|
if (result.bypassed && result.violations.length > 0) {
|
|
162
365
|
for (const v of result.violations) {
|
|
163
|
-
console.log(`::warning file=${v.file}::Protected path modified under "${
|
|
366
|
+
console.log(`::warning file=${v.file}::Protected path modified under "${activeLabel}": ${v.reason}`);
|
|
164
367
|
}
|
|
165
|
-
console.log(`\nLabel "${
|
|
368
|
+
console.log(`\nLabel "${activeLabel}" is present — ${result.violations.length} protected-path match(es) allowed by human review.`);
|
|
166
369
|
process.exit(0);
|
|
167
370
|
}
|
|
168
371
|
|
|
@@ -176,7 +379,7 @@ function main() {
|
|
|
176
379
|
}
|
|
177
380
|
console.error(
|
|
178
381
|
`::error::PR modifies ${result.violations.length} protected file(s). ` +
|
|
179
|
-
`Apply the "${
|
|
382
|
+
`Apply the "${labelName}" label after human review to land this intentionally.`
|
|
180
383
|
);
|
|
181
384
|
process.exit(EXIT_SCOPE_VIOLATION);
|
|
182
385
|
}
|
package/scripts/run-tests.mjs
CHANGED
|
@@ -101,18 +101,22 @@ function reap(signal = "SIGKILL") {
|
|
|
101
101
|
|
|
102
102
|
// Ctrl-C, a CI cancellation and a harness teardown all arrive as signals; each
|
|
103
103
|
// must take the tree with it rather than detaching it from its parent.
|
|
104
|
+
let shuttingDown = false;
|
|
104
105
|
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
105
106
|
process.on(sig, () => {
|
|
107
|
+
if (shuttingDown) return;
|
|
108
|
+
shuttingDown = true;
|
|
106
109
|
reap("SIGTERM");
|
|
107
110
|
// Give the group a moment to unwind, then make sure.
|
|
108
111
|
setTimeout(() => {
|
|
109
112
|
reap("SIGKILL");
|
|
110
|
-
process.exit(130);
|
|
113
|
+
process.exit(sig === "SIGINT" ? 130 : 143);
|
|
111
114
|
}, 2000).unref();
|
|
112
115
|
});
|
|
113
116
|
}
|
|
114
117
|
|
|
115
118
|
child.on("exit", (code, signal) => {
|
|
119
|
+
if (shuttingDown) return;
|
|
116
120
|
// Stragglers outlive a clean exit too: a test that spawned a server and
|
|
117
121
|
// failed before its teardown leaves it running.
|
|
118
122
|
reap("SIGKILL");
|
package/src/bidi-guard.mjs
CHANGED
|
@@ -1,36 +1,211 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Unicode security detection for diffs and source text.
|
|
3
|
+
*
|
|
4
|
+
* Covers Trojan Source BiDi overrides (CVE-2021-42574), invisible/zero-width
|
|
5
|
+
* obfuscation characters, Unicode Plane 14 tags, and mixed-script confusable
|
|
6
|
+
* identifiers (Latin mixed with vetted Cyrillic/Greek lookalikes).
|
|
3
7
|
*
|
|
4
|
-
* Split out of src/security.mjs (P05). A Unicode directional override
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* else looks at the diff.
|
|
8
|
+
* Split out of src/security.mjs (P05). A Unicode directional override — or an
|
|
9
|
+
* invisible filler mid-token — can make the line an agent reads differ from
|
|
10
|
+
* the line the parser executes, so these checks run on every added line of
|
|
11
|
+
* every non-markdown file before anything else looks at the diff.
|
|
12
|
+
*
|
|
13
|
+
* Zero third-party deps; Node ESM builtins + the shared confusable table from
|
|
14
|
+
* secret-scanner.mjs only.
|
|
8
15
|
*/
|
|
9
16
|
|
|
10
|
-
|
|
11
|
-
if (!diffOrText || typeof diffOrText !== "string") return { ok: true, violations: [] };
|
|
17
|
+
import { CONFUSABLE_TO_ASCII } from "./secret-scanner.mjs";
|
|
12
18
|
|
|
13
|
-
|
|
14
|
-
|
|
19
|
+
/**
|
|
20
|
+
* BiDi directional controls — the CVE-2021-42574 Trojan Source set (12 chars).
|
|
21
|
+
* Exported so scanDiff can locate the offending line without duplicating the set.
|
|
22
|
+
*/
|
|
23
|
+
export const BIDI_CONTROL_REGEX =
|
|
24
|
+
/[\u202A\u202B\u202C\u202D\u202E\u2066\u2067\u2068\u2069\u061C\u200E\u200F]/;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Invisible ZW / Hangul fillers used to obfuscate identifiers on added lines:
|
|
28
|
+
* U+00AD, U+200B–U+200D, U+2060–U+2064, U+FEFF, U+034F, U+3164, U+FFA0,
|
|
29
|
+
* U+115F, U+1160.
|
|
30
|
+
*/
|
|
31
|
+
export const INVISIBLE_OBFUSCATION_REGEX =
|
|
32
|
+
/[\u00AD\u200B-\u200D\u2060-\u2064\uFEFF\u034F\u3164\uFFA0\u115F\u1160]/;
|
|
33
|
+
|
|
34
|
+
/** Unicode Plane 14 language tags U+E0000–U+E007F. */
|
|
35
|
+
export const PLANE14_TAG_REGEX = /[\u{E0000}-\u{E007F}]/u;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Cyrillic/Greek entries from the secret-scanner confusable table.
|
|
39
|
+
* Latin lookalikes outside those scripts (ſ, K) are intentionally excluded —
|
|
40
|
+
* they are not mixed-script substitutions.
|
|
41
|
+
*/
|
|
42
|
+
const MIXED_SCRIPT_CONFUSABLE_CHARS = [...CONFUSABLE_TO_ASCII.keys()].filter((ch) => {
|
|
43
|
+
const cp = ch.codePointAt(0);
|
|
44
|
+
return (cp >= 0x0370 && cp <= 0x03ff) || (cp >= 0x0400 && cp <= 0x04ff);
|
|
45
|
+
});
|
|
15
46
|
|
|
47
|
+
const CONFUSABLE_CLASS = MIXED_SCRIPT_CONFUSABLE_CHARS.map(
|
|
48
|
+
(ch) => `\\u${ch.codePointAt(0).toString(16).padStart(4, "0")}`,
|
|
49
|
+
).join("");
|
|
50
|
+
|
|
51
|
+
/** Identifier-like tokens built from ASCII id chars and vetted confusables. */
|
|
52
|
+
export const MIXED_SCRIPT_TOKEN_REGEX = new RegExp(
|
|
53
|
+
`[A-Za-z0-9_${CONFUSABLE_CLASS}]+`,
|
|
54
|
+
"g",
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const HAS_LATIN_ID = /[A-Za-z0-9_]/;
|
|
58
|
+
const HAS_CONFUSABLE = new RegExp(`[${CONFUSABLE_CLASS}]`);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* True when a line contains an identifier token that mixes Latin [A-Za-z0-9_]
|
|
62
|
+
* with at least one Cyrillic/Greek confusable (e.g. Cyrillic i or a in an ASCII token).
|
|
63
|
+
* Pure Cyrillic / Greek / multilingual text without intra-token Latin mixing
|
|
64
|
+
* does not match.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} line
|
|
67
|
+
* @returns {boolean}
|
|
68
|
+
*/
|
|
69
|
+
export function hasMixedScriptConfusable(line) {
|
|
70
|
+
if (!line || typeof line !== "string") return false;
|
|
71
|
+
MIXED_SCRIPT_TOKEN_REGEX.lastIndex = 0;
|
|
72
|
+
let match;
|
|
73
|
+
while ((match = MIXED_SCRIPT_TOKEN_REGEX.exec(line)) !== null) {
|
|
74
|
+
const token = match[0];
|
|
75
|
+
if (HAS_LATIN_ID.test(token) && HAS_CONFUSABLE.test(token)) return true;
|
|
76
|
+
}
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Select the lines to scan: added lines of a unified diff, or every line of
|
|
82
|
+
* plain text.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} diffOrText
|
|
85
|
+
* @returns {string[]}
|
|
86
|
+
*/
|
|
87
|
+
function targetLines(diffOrText) {
|
|
16
88
|
const lines = diffOrText.split("\n");
|
|
17
|
-
// Only filter diff syntax if we actually start with typical diff headers
|
|
89
|
+
// Only filter diff syntax if we actually start with typical diff headers.
|
|
18
90
|
// This avoids treating text containing "+++ b/" as a diff mistakenly.
|
|
19
|
-
const isDiff =
|
|
91
|
+
const isDiff =
|
|
92
|
+
diffOrText.startsWith("--- a/") ||
|
|
93
|
+
diffOrText.startsWith("+++ b/") ||
|
|
94
|
+
diffOrText.includes("\n+++ b/");
|
|
20
95
|
|
|
21
|
-
|
|
96
|
+
return lines.filter((line) => {
|
|
22
97
|
if (isDiff) {
|
|
23
98
|
return line.startsWith("+") && !line.startsWith("+++");
|
|
24
99
|
}
|
|
25
100
|
return true;
|
|
26
101
|
});
|
|
102
|
+
}
|
|
27
103
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Trojan Source (CVE-2021-42574) BiDi override detection.
|
|
106
|
+
* Backward-compatible: returns `{ ok, violations }` and only reports BiDi
|
|
107
|
+
* controls. Broader Unicode checks live in {@link checkUnicodeSecurity}.
|
|
108
|
+
*
|
|
109
|
+
* @param {string} [diffOrText=""]
|
|
110
|
+
* @param {object} [_options={}]
|
|
111
|
+
* @returns {{ ok: boolean, violations: Array<{ reason: string }> }}
|
|
112
|
+
*/
|
|
113
|
+
export function checkTrojanSource(diffOrText = "", _options = {}) {
|
|
114
|
+
if (!diffOrText || typeof diffOrText !== "string") return { ok: true, violations: [] };
|
|
115
|
+
|
|
116
|
+
const violations = [];
|
|
117
|
+
for (const line of targetLines(diffOrText)) {
|
|
118
|
+
if (BIDI_CONTROL_REGEX.test(line)) {
|
|
119
|
+
violations.push({
|
|
120
|
+
reason:
|
|
121
|
+
"Trojan Source BiDi override detected: contains invisible directional control characters (CVE-2021-42574).",
|
|
122
|
+
});
|
|
31
123
|
break; // One violation is enough for the file/diff block
|
|
32
124
|
}
|
|
33
125
|
}
|
|
34
126
|
|
|
35
127
|
return { ok: violations.length === 0, violations };
|
|
36
128
|
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Full Unicode security pass over a diff or plain text.
|
|
132
|
+
*
|
|
133
|
+
* Categories (at most one finding each):
|
|
134
|
+
* - TROJAN_SOURCE_DETECTED — BiDi controls (CVE-2021-42574)
|
|
135
|
+
* - UNICODE_OBFUSCATION_DETECTED — ZW/Hangul fillers or Plane 14 tags
|
|
136
|
+
* - MIXED_SCRIPT_CONFUSABLE_DETECTED — Latin+confusable identifier tokens
|
|
137
|
+
*
|
|
138
|
+
* @param {string} [diffOrText=""]
|
|
139
|
+
* @param {object} [_options={}]
|
|
140
|
+
* @returns {{
|
|
141
|
+
* ok: boolean,
|
|
142
|
+
* violations: Array<{ reason: string, type: string }>
|
|
143
|
+
* }}
|
|
144
|
+
*/
|
|
145
|
+
export function checkUnicodeSecurity(diffOrText = "", _options = {}) {
|
|
146
|
+
if (!diffOrText || typeof diffOrText !== "string") return { ok: true, violations: [] };
|
|
147
|
+
|
|
148
|
+
const violations = [];
|
|
149
|
+
let sawBidi = false;
|
|
150
|
+
let sawObfuscation = false;
|
|
151
|
+
let sawMixed = false;
|
|
152
|
+
|
|
153
|
+
for (const line of targetLines(diffOrText)) {
|
|
154
|
+
if (!sawBidi && BIDI_CONTROL_REGEX.test(line)) {
|
|
155
|
+
sawBidi = true;
|
|
156
|
+
violations.push({
|
|
157
|
+
type: "TROJAN_SOURCE_DETECTED",
|
|
158
|
+
reason:
|
|
159
|
+
"Trojan Source BiDi override detected: contains invisible directional control characters (CVE-2021-42574).",
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (
|
|
163
|
+
!sawObfuscation &&
|
|
164
|
+
(INVISIBLE_OBFUSCATION_REGEX.test(line) || PLANE14_TAG_REGEX.test(line))
|
|
165
|
+
) {
|
|
166
|
+
sawObfuscation = true;
|
|
167
|
+
violations.push({
|
|
168
|
+
type: "UNICODE_OBFUSCATION_DETECTED",
|
|
169
|
+
reason:
|
|
170
|
+
"Unicode obfuscation detected: invisible zero-width, Hangul filler, or Plane 14 tag characters on an added line.",
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (!sawMixed && hasMixedScriptConfusable(line)) {
|
|
174
|
+
sawMixed = true;
|
|
175
|
+
violations.push({
|
|
176
|
+
type: "MIXED_SCRIPT_CONFUSABLE_DETECTED",
|
|
177
|
+
reason:
|
|
178
|
+
"Mixed-script confusable identifier detected: Latin characters combined with Cyrillic/Greek lookalikes in one token.",
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
if (sawBidi && sawObfuscation && sawMixed) break;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return { ok: violations.length === 0, violations };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Locate the first added-line number that triggers a given Unicode finding type.
|
|
189
|
+
* Shared by scanDiff so it does not re-embed detection regexes.
|
|
190
|
+
*
|
|
191
|
+
* @param {Array<{ text: string, no: number|null }>} lines
|
|
192
|
+
* @param {string} type
|
|
193
|
+
* @returns {number|null}
|
|
194
|
+
*/
|
|
195
|
+
export function locateUnicodeFindingLine(lines, type) {
|
|
196
|
+
if (!Array.isArray(lines)) return null;
|
|
197
|
+
for (const l of lines) {
|
|
198
|
+
if (!l || typeof l.text !== "string") continue;
|
|
199
|
+
const text = l.text;
|
|
200
|
+
let hit = false;
|
|
201
|
+
if (type === "TROJAN_SOURCE_DETECTED") {
|
|
202
|
+
hit = BIDI_CONTROL_REGEX.test(text);
|
|
203
|
+
} else if (type === "UNICODE_OBFUSCATION_DETECTED") {
|
|
204
|
+
hit = INVISIBLE_OBFUSCATION_REGEX.test(text) || PLANE14_TAG_REGEX.test(text);
|
|
205
|
+
} else if (type === "MIXED_SCRIPT_CONFUSABLE_DETECTED") {
|
|
206
|
+
hit = hasMixedScriptConfusable(text);
|
|
207
|
+
}
|
|
208
|
+
if (hit) return l.no ?? null;
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
}
|