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.
@@ -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
- /** Label that lets a human consciously land a protected-path change. */
26
- export const BYPASS_LABEL = "allow-protected-paths";
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
- * `-z` and `core.quotePath=false` matter here: without them git splits on
77
- * whitespace and octal-escapes non-ASCII names, so `docs/min plan.md` and
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", "--name-only", opts.baseSha, opts.headSha],
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.split("\0").map(normalizePath).filter(Boolean);
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
- * @param {string[]} files
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(files = [], patterns = [], opts = {}) {
202
+ export function evaluateScopeGuard(filesOrEntries = [], patterns = [], opts = {}) {
103
203
  const labels = (opts.labels || []).map((l) => String(l).toLowerCase().trim());
104
- const bypassed = labels.includes(BYPASS_LABEL);
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(files, { protect: patterns }, { allowProtected: false });
111
- return { ok: bypassed || res.ok, bypassed, violations: res.violations };
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 files;
332
+ let entries = [];
146
333
  try {
147
334
  patterns = loadProtectedPatterns({ baseSha, baseRef, root });
148
- files = listChangedFiles({ baseSha, headSha, root });
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(files, patterns, { labels });
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 (${files.length}):`);
159
- for (const f of files) console.log(` ${f}`);
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 "${BYPASS_LABEL}": ${v.reason}`);
366
+ console.log(`::warning file=${v.file}::Protected path modified under "${activeLabel}": ${v.reason}`);
164
367
  }
165
- console.log(`\nLabel "${BYPASS_LABEL}" is present — ${result.violations.length} protected-path match(es) allowed by human review.`);
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 "${BYPASS_LABEL}" label after human review to land this intentionally.`
382
+ `Apply the "${labelName}" label after human review to land this intentionally.`
180
383
  );
181
384
  process.exit(EXIT_SCOPE_VIOLATION);
182
385
  }
@@ -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");
@@ -1,36 +1,211 @@
1
1
  /**
2
- * Trojan Source (CVE-2021-42574) BiDi override detection.
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 can make
5
- * the line an agent reads differ from the line the parser executes, so the
6
- * check runs on every added line of every non-markdown file before anything
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
- export function checkTrojanSource(diffOrText = "", _options = {}) {
11
- if (!diffOrText || typeof diffOrText !== "string") return { ok: true, violations: [] };
17
+ import { CONFUSABLE_TO_ASCII } from "./secret-scanner.mjs";
12
18
 
13
- const violations = [];
14
- const bidiRegex = /[\u202A\u202B\u202C\u202D\u202E\u2066\u2067\u2068\u2069\u061C\u200E\u200F]/;
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 = diffOrText.startsWith("--- a/") || diffOrText.startsWith("+++ b/") || diffOrText.includes("\n+++ b/");
91
+ const isDiff =
92
+ diffOrText.startsWith("--- a/") ||
93
+ diffOrText.startsWith("+++ b/") ||
94
+ diffOrText.includes("\n+++ b/");
20
95
 
21
- const targetLines = lines.filter((line) => {
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
- for (const line of targetLines) {
29
- if (bidiRegex.test(line)) {
30
- violations.push({ reason: "Trojan Source BiDi override detected: contains invisible directional control characters (CVE-2021-42574)." });
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
+ }