gitnexus 1.6.4-rc.88 → 1.6.4-rc.89

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.
Files changed (82) hide show
  1. package/dist/cli/setup.js +6 -1
  2. package/dist/cli/wiki.js +37 -5
  3. package/dist/core/group/bridge-db.js +196 -170
  4. package/dist/core/group/storage.js +59 -3
  5. package/dist/core/ingestion/vue-sfc-extractor.js +18 -1
  6. package/dist/core/wiki/llm-client.js +4 -2
  7. package/package.json +1 -1
  8. package/web/assets/{agent-DGm5BiXg.js → agent-Dl2d-mDi.js} +3 -3
  9. package/web/assets/architecture-YZFGNWBL-S5CXDPWN-BYXnTu07.js +1 -0
  10. package/web/assets/{architectureDiagram-EMZXCZ2Q-DLWvvvUB.js → architectureDiagram-EMZXCZ2Q-uiiIna83.js} +1 -1
  11. package/web/assets/{blockDiagram-IGV67L2C-B47EUMKY.js → blockDiagram-IGV67L2C-uBKdDzXA.js} +1 -1
  12. package/web/assets/{c4Diagram-DFAF54RM-DvvVQASF.js → c4Diagram-DFAF54RM-DTxaX43T.js} +1 -1
  13. package/web/assets/{chunk-3GS5O3IE-0H2zS7RE.js → chunk-3GS5O3IE-BxKKu7d7.js} +1 -1
  14. package/web/assets/{chunk-3YCYZ6SJ-DokVoiwM.js → chunk-3YCYZ6SJ-BJZnGFjg.js} +1 -1
  15. package/web/assets/{chunk-6NTNNK5N-B9jhOxT0.js → chunk-6NTNNK5N-CIL_eO2d.js} +1 -1
  16. package/web/assets/{chunk-A34GCYZU-DfTldqlz.js → chunk-A34GCYZU-Dv4Vkem9.js} +1 -1
  17. package/web/assets/{chunk-DJ7UZH7F-npvsBmvn.js → chunk-DJ7UZH7F-Cskl-nb3.js} +1 -1
  18. package/web/assets/{chunk-DKKBVRCY-DbFEYV2a.js → chunk-DKKBVRCY-D3S2BD56.js} +2 -2
  19. package/web/assets/{chunk-DU5LTGQ6-B1Mj73sD.js → chunk-DU5LTGQ6-B8Zh51LG.js} +1 -1
  20. package/web/assets/{chunk-FXACKDTF-CNljDI1M.js → chunk-FXACKDTF-Cb4xHVUz.js} +1 -1
  21. package/web/assets/{chunk-H3VCZNTA-j4ZGeRTZ.js → chunk-H3VCZNTA-BOGhqvxW.js} +1 -1
  22. package/web/assets/{chunk-HN6EAY2L-CEc2xC-J.js → chunk-HN6EAY2L-CitKFy1e.js} +1 -1
  23. package/web/assets/{chunk-O5ABG6QK-B_00CmQB.js → chunk-O5ABG6QK-V3sk7Bps.js} +1 -1
  24. package/web/assets/{chunk-PK6DOVAG-lOMJKFOu.js → chunk-PK6DOVAG-FvG7aKfZ.js} +1 -1
  25. package/web/assets/{chunk-RNJOYNJ4-BKKlhT6X.js → chunk-RNJOYNJ4-BPANsy8V.js} +1 -1
  26. package/web/assets/{chunk-RWUO3TPN-BhUzGpFd.js → chunk-RWUO3TPN-BHusg2jI.js} +1 -1
  27. package/web/assets/{chunk-TBF5ZNIQ-BAsGZFMI.js → chunk-TBF5ZNIQ-CXamrpH3.js} +1 -1
  28. package/web/assets/{chunk-TYMNRAUI-B47lwFjp.js → chunk-TYMNRAUI-BnMe6HfP.js} +1 -1
  29. package/web/assets/{chunk-W7ZLLLMY-DGb6u0pB.js → chunk-W7ZLLLMY-BqZFKM5j.js} +1 -1
  30. package/web/assets/{chunk-WSB5WSVC-BiaAMFmd.js → chunk-WSB5WSVC-EKS7-Nnc.js} +1 -1
  31. package/web/assets/{chunk-XGPFEOL4-Dqk5iHgi.js → chunk-XGPFEOL4-BBab-enK.js} +1 -1
  32. package/web/assets/classDiagram-PPOCWD7C-B-LkKU0P.js +1 -0
  33. package/web/assets/classDiagram-v2-23LJLIIU-_NGI4VEh.js +1 -0
  34. package/web/assets/{cose-bilkent-PNC4W37J-CnOlxR0_.js → cose-bilkent-PNC4W37J-DjEQkTZ4.js} +1 -1
  35. package/web/assets/{dagre-E77IOHMT-Cy3z5r06.js → dagre-E77IOHMT-D6Xacg1x.js} +1 -1
  36. package/web/assets/{diagram-H7BISOXX-ChLdgktB.js → diagram-H7BISOXX-CMuISoJL.js} +1 -1
  37. package/web/assets/{diagram-JC5VWROH-DdzGpm08.js → diagram-JC5VWROH-DHq_eoeM.js} +1 -1
  38. package/web/assets/{diagram-LXUTUG65-Bq_ye_lS.js → diagram-LXUTUG65-C0NczIgW.js} +1 -1
  39. package/web/assets/{diagram-WEHSV5V5-WrNO_VoZ.js → diagram-WEHSV5V5-WsBnpSuc.js} +1 -1
  40. package/web/assets/{erDiagram-GCSMX5X6-Cx3Aq4SN.js → erDiagram-GCSMX5X6-DxHFHFR6.js} +1 -1
  41. package/web/assets/{flowDiagram-OTCZ4VVT-DRe-9T0b.js → flowDiagram-OTCZ4VVT-_G1VxyQS.js} +1 -1
  42. package/web/assets/{ganttDiagram-MUNLMDZQ-CvtFMhi8.js → ganttDiagram-MUNLMDZQ-BIHObj0t.js} +1 -1
  43. package/web/assets/gitGraph-7Q5UKJZL-54BCDZD5-BXJXsixL.js +1 -0
  44. package/web/assets/{gitGraphDiagram-3HKGZ4G3-BxDd7tFR.js → gitGraphDiagram-3HKGZ4G3-COZUkHTD.js} +1 -1
  45. package/web/assets/{index-ChQJsgDb.js → index-DDcYO1IJ.js} +5 -5
  46. package/web/assets/info-OMHHGYJF-BF2H5H6G-C820LK0J.js +1 -0
  47. package/web/assets/infoDiagram-MN7RKWGX-BeOjfPc_.js +2 -0
  48. package/web/assets/{ishikawaDiagram-YMYX4NHK-TUICrJKY.js → ishikawaDiagram-YMYX4NHK-C-JI2Ih7.js} +1 -1
  49. package/web/assets/{journeyDiagram-SO5T7YLQ-C4Y1ypn8.js → journeyDiagram-SO5T7YLQ-IPX4h3iD.js} +1 -1
  50. package/web/assets/{kanban-definition-LJHFXRCJ--cIG9jHd.js → kanban-definition-LJHFXRCJ-B23LtshQ.js} +1 -1
  51. package/web/assets/{mindmap-definition-2EUWGEK5-BK54zuWp.js → mindmap-definition-2EUWGEK5-DJKUsrCo.js} +1 -1
  52. package/web/assets/packet-4T2RLAQJ-EV4IVRXR-BvWtM5jq.js +1 -0
  53. package/web/assets/pie-ZZUOXDRM-N23DN5KN-BnDgj0TQ.js +1 -0
  54. package/web/assets/{pieDiagram-3IATQBI2-CMgPhL6Y.js → pieDiagram-3IATQBI2-Bj26KTKk.js} +1 -1
  55. package/web/assets/{quadrantDiagram-E256RVCF-CNmFSP0m.js → quadrantDiagram-E256RVCF-VAk5oIRr.js} +1 -1
  56. package/web/assets/radar-PYXPWWZC-P6TP7ZYP-2IuDlZ6r.js +1 -0
  57. package/web/assets/{requirementDiagram-M5DCFWZL-C6e8zmrF.js → requirementDiagram-M5DCFWZL-CmlTJ_vQ.js} +1 -1
  58. package/web/assets/{sankeyDiagram-L3NBLAOT-BDcHIgVE.js → sankeyDiagram-L3NBLAOT-C-f_DdcX.js} +1 -1
  59. package/web/assets/{sequenceDiagram-ZOUHS735-CGAlp5GL.js → sequenceDiagram-ZOUHS735-DmtNf1NF.js} +1 -1
  60. package/web/assets/{stateDiagram-MLPALWAM-CCKt0fuW.js → stateDiagram-MLPALWAM-Lq7UVrkj.js} +1 -1
  61. package/web/assets/stateDiagram-v2-B5LQ5ZB2-DCiXBPeS.js +1 -0
  62. package/web/assets/{timeline-definition-5SPVSISX-D74jeyVQ.js → timeline-definition-5SPVSISX-BkpqZxt2.js} +1 -1
  63. package/web/assets/treeView-SZITEDCU-5DXDK3XO-Ipq1EIaF.js +1 -0
  64. package/web/assets/treemap-W4RFUUIX-WYLRDWKO-DESFxovu.js +1 -0
  65. package/web/assets/{vennDiagram-IE5QUKF5-ChHw0kJO.js → vennDiagram-IE5QUKF5-iAJ08JBe.js} +1 -1
  66. package/web/assets/wardley-RL74JXVD-BCRCBASE-BVkhckBY.js +1 -0
  67. package/web/assets/{wardleyDiagram-XU3VSMPF-D5PsSVSp.js → wardleyDiagram-XU3VSMPF-BDiKTkL4.js} +1 -1
  68. package/web/assets/{xychartDiagram-ZHJ5623Y-B7E396Z-.js → xychartDiagram-ZHJ5623Y-CknyZyQK.js} +1 -1
  69. package/web/index.html +1 -1
  70. package/web/assets/architecture-YZFGNWBL-S5CXDPWN-CzpqonpT.js +0 -1
  71. package/web/assets/classDiagram-PPOCWD7C-W4Gq4oYa.js +0 -1
  72. package/web/assets/classDiagram-v2-23LJLIIU-CbyGxpD-.js +0 -1
  73. package/web/assets/gitGraph-7Q5UKJZL-54BCDZD5-DZfbulFG.js +0 -1
  74. package/web/assets/info-OMHHGYJF-BF2H5H6G-Dehqc8IA.js +0 -1
  75. package/web/assets/infoDiagram-MN7RKWGX-B0B5J4EY.js +0 -2
  76. package/web/assets/packet-4T2RLAQJ-EV4IVRXR-BjetiCTk.js +0 -1
  77. package/web/assets/pie-ZZUOXDRM-N23DN5KN-zkxhGq7v.js +0 -1
  78. package/web/assets/radar-PYXPWWZC-P6TP7ZYP-CHxbGrvk.js +0 -1
  79. package/web/assets/stateDiagram-v2-B5LQ5ZB2-OC7lFsht.js +0 -1
  80. package/web/assets/treeView-SZITEDCU-5DXDK3XO-8EO9YZIQ.js +0 -1
  81. package/web/assets/treemap-W4RFUUIX-WYLRDWKO-y8YnclBi.js +0 -1
  82. package/web/assets/wardley-RL74JXVD-BCRCBASE-1QZagPhJ.js +0 -1
package/dist/cli/setup.js CHANGED
@@ -309,7 +309,12 @@ async function installClaudeCodeHooks(result) {
309
309
  // Script not found in source — skip
310
310
  }
311
311
  const hookPath = path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/');
312
- const hookCmd = `node "${hookPath.replace(/"/g, '\\"')}"`;
312
+ // Escape backslashes FIRST, then quotes (CodeQL js/incomplete-sanitization).
313
+ // The previous shape `replace(/"/g, '\\"')` alone would let `path\with"quote`
314
+ // become `path\with\"quote`, where the trailing `\` before `"` could
315
+ // unescape the quote inside the surrounding double-quoted shell context.
316
+ const escapedHookPath = hookPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
317
+ const hookCmd = `node "${escapedHookPath}"`;
313
318
  // Check which hook events need entries (idempotent: skip if already registered)
314
319
  const parsed = await (async () => {
315
320
  try {
package/dist/cli/wiki.js CHANGED
@@ -509,15 +509,47 @@ function hasGhCLI() {
509
509
  return false;
510
510
  }
511
511
  }
512
+ /**
513
+ * Strict Gist URL predicate. Rejects:
514
+ * - any URL that does not parse (URL constructor throws)
515
+ * - schemes other than https (drops `http:`, `file:`, `gist:`-style spoofs)
516
+ * - hostnames that are not exactly `gist.github.com` (drops substring spoofs
517
+ * like `https://evil.com/?u=gist.github.com` and userinfo-prefixed shapes
518
+ * like `https://[email protected]/...` — note that URL.hostname
519
+ * strips userinfo, so the equality check rejects the userinfo-prefixed
520
+ * spoof if the actual host differs from gist.github.com)
521
+ * - any URL containing userinfo (`username[:password]@`), which the URL
522
+ * parser exposes via `.username` / `.password`. Defense-in-depth: even
523
+ * when hostname matches, a credential-bearing URL is suspect and not
524
+ * produced by `gh gist create`.
525
+ *
526
+ * Closes the substring-bypass class CodeQL `js/incomplete-url-substring-
527
+ * sanitization` flags.
528
+ */
529
+ function isGistUrl(line) {
530
+ const trimmed = line.trim();
531
+ try {
532
+ const u = new URL(trimmed);
533
+ return (u.protocol === 'https:' &&
534
+ u.hostname === 'gist.github.com' &&
535
+ u.username === '' &&
536
+ u.password === '');
537
+ }
538
+ catch {
539
+ return false;
540
+ }
541
+ }
512
542
  function publishGist(htmlPath) {
513
543
  try {
514
544
  const output = execFileSync('gh', ['gist', 'create', htmlPath, '--desc', 'Repository Wiki — generated by GitNexus', '--public'], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
515
- // gh gist create prints the gist URL as the last line
516
- const lines = output.split('\n');
517
- const gistUrl = lines.find((l) => l.includes('gist.github.com')) || lines[lines.length - 1];
518
- if (!gistUrl || !gistUrl.includes('gist.github.com'))
545
+ // `gh gist create` prints the gist URL as a line in the output. Find the
546
+ // first parseable Gist URL — if no line is a valid Gist URL, fail closed
547
+ // (do NOT fall back to lines[last]: a non-Gist last line would propagate
548
+ // through the regex below and produce a malformed `rawUrl`).
549
+ const gistUrl = output.split('\n').find(isGistUrl);
550
+ if (!gistUrl)
519
551
  return null;
520
- // Build a raw viewer URL via gist.githack.com
552
+ // Build a raw viewer URL via gist.githack.com.
521
553
  // gist URL format: https://gist.github.com/{user}/{id}
522
554
  const match = gistUrl.match(/gist\.github\.com\/([^/]+)\/([a-f0-9]+)/);
523
555
  let rawUrl = gistUrl;
@@ -35,24 +35,6 @@ async function removeLbugFile(basePath) {
35
35
  }
36
36
  }
37
37
  }
38
- /**
39
- * Remove all stale `bridge.lbug.tmp.*` files (and their sidecars) from a
40
- * group directory. With randomBytes-based temp names, a crashed writeBridge
41
- * leaves behind a uniquely-named tmp file that no future run will target by
42
- * name — so we glob for the prefix and clean up everything matching.
43
- */
44
- async function cleanStaleBridgeTmpFiles(groupDir) {
45
- try {
46
- const entries = await fsp.readdir(groupDir);
47
- const staleBases = entries.filter((e) => e.startsWith('bridge.lbug.tmp.') && !LBUG_SIDECAR_SUFFIXES.some((s) => e.endsWith(s)));
48
- for (const name of staleBases) {
49
- await removeLbugFile(path.join(groupDir, name));
50
- }
51
- }
52
- catch {
53
- /* best-effort: directory may not exist yet */
54
- }
55
- }
56
38
  export function contractNodeId(repo, contractId, role, filePath) {
57
39
  return createHash('sha256').update(`${repo}\0${contractId}\0${role}\0${filePath}`).digest('hex');
58
40
  }
@@ -231,8 +213,25 @@ export async function retryRename(src, dst, attempts = 3) {
231
213
  /* ------------------------------------------------------------------ */
232
214
  export async function writeBridgeMeta(groupDir, meta) {
233
215
  const target = path.join(groupDir, 'meta.json');
216
+ // Unpredictable suffix + O_EXCL via `'wx'` flag closes the symlink/
217
+ // pre-create attack window. The third argument `0o600` is the
218
+ // user-only mode mask — CodeQL's `js/insecure-temporary-file` query
219
+ // sources its verdict from the `mode` argument, NOT from `flags`:
220
+ // its `isSecureMode(mode)` predicate requires the low 6 bits to be
221
+ // zero (no group/world bits). Without an explicit mode the file is
222
+ // created with the process umask (typically 0o644 = group/world
223
+ // readable), which the query treats as the actual vulnerability.
224
+ // Both `'wx'` (runtime O_EXCL) AND `0o600` (CodeQL-credited mode)
225
+ // are needed: one closes the symlink race, the other closes the
226
+ // permissions exposure.
234
227
  const tmp = `${target}.tmp.${randomBytes(8).toString('hex')}`;
235
- await fsp.writeFile(tmp, JSON.stringify(meta, null, 2), 'utf-8');
228
+ const handle = await fsp.open(tmp, 'wx', 0o600);
229
+ try {
230
+ await handle.writeFile(JSON.stringify(meta, null, 2), 'utf-8');
231
+ }
232
+ finally {
233
+ await handle.close();
234
+ }
236
235
  // Use retryRename for consistency with writeBridge's atomic swap — on
237
236
  // Windows a concurrent reader can cause EBUSY/EPERM even on a tiny
238
237
  // meta.json, and we don't want meta write to be less robust than the
@@ -264,7 +263,19 @@ export async function writeBridge(groupDir, input) {
264
263
  const contracts = dedupeContracts(input.contracts);
265
264
  const crossLinks = dedupeCrossLinks(input.crossLinks);
266
265
  const finalPath = path.join(groupDir, 'bridge.lbug');
267
- const tmpPath = path.join(groupDir, `bridge.lbug.tmp.${randomBytes(8).toString('hex')}`);
266
+ // Stage the temp database inside a unique mkdtemp directory rather than
267
+ // a fixed `bridge.lbug.tmp` name. The previous shape was flagged by
268
+ // CodeQL js/insecure-temporary-file as a predictable path: a co-located
269
+ // attacker (or a parallel writeBridge call into the same group) could
270
+ // pre-create or symlink that path before this writer opens it. mkdtemp
271
+ // returns a directory whose suffix is filled with cryptographically
272
+ // random bytes, so the staging path is unguessable AND collision-free
273
+ // across parallel callers. We anchor the staging directory inside
274
+ // `groupDir` so the subsequent rename of `bridge.lbug` (and its
275
+ // `.wal` / `.shadow` sidecars) into place stays on the same filesystem
276
+ // and remains atomic — moving across `os.tmpdir()` could trip EXDEV.
277
+ const stagingDir = await fsp.mkdtemp(path.join(groupDir, 'bridge-tmp-'));
278
+ const tmpPath = path.join(stagingDir, 'bridge.lbug');
268
279
  const bakPath = path.join(groupDir, 'bridge.lbug.bak');
269
280
  const report = {
270
281
  contractsInserted: 0,
@@ -281,38 +292,37 @@ export async function writeBridge(groupDir, input) {
281
292
  report.sampleErrors.push({ kind, id, message: errMessage(err) });
282
293
  }
283
294
  };
284
- // Clean up stale tmp files left behind by previously crashed writeBridge
285
- // runs. With randomBytes-based names each run picks a unique path, so
286
- // the old fixed-name `removeLbugFile(tmpPath)` was a no-opstale
287
- // artifacts accumulated. The glob-based helper finds *all* leftover
288
- // `bridge.lbug.tmp.*` entries and removes them (including sidecars).
289
- await cleanStaleBridgeTmpFiles(groupDir);
290
- // 1. Create temp DB, insert all data.
291
- //
292
- // Everything after `openBridgeDb` must run inside a try/finally so that
293
- // if ANY step before the explicit `closeBridgeDb` throws — schema
294
- // creation, a contract insert loop that rethrows, a snapshot write, the
295
- // cross-link loop, or anything else — the handle is still released. A
296
- // leaked handle holds the native LadybugDB file lock on tmpPath, which
297
- // (a) leaks a FD and (b) prevents the next writeBridge call from
298
- // reusing the same tmp slot.
299
- const handle = await openBridgeDb(tmpPath);
300
- let handleClosed = false;
295
+ // The mkdtemp staging directory above is freshly created with a unique
296
+ // random suffix, so there are no leftover `bridge.lbug.tmp` / `.wal` /
297
+ // `.shadow` sidecars from a previous crashed run to clean up here the
298
+ // directory is empty by construction.
301
299
  try {
302
- await ensureBridgeSchema(handle);
303
- // Build the lookup index incrementally as contracts are inserted, so
304
- // failed inserts are never in the index (and therefore never resolved
305
- // by the cross-link loop below). This replaces a previous N+1 query
306
- // pattern where each link made up to 6 DB round-trips to find its
307
- // endpointssee ContractLookupIndex.
308
- const lookupIndex = createContractLookupIndex();
309
- // Insert contracts tolerate individual failures (e.g., a corrupt meta
310
- // that can't be serialized). The whole sync must not fail because one
311
- // contract is broken.
312
- for (const c of contracts) {
313
- const id = contractNodeId(c.repo, c.contractId, c.role, c.symbolRef.filePath);
314
- try {
315
- await queryBridge(handle, `CREATE (n:Contract {
300
+ // 1. Create temp DB, insert all data.
301
+ //
302
+ // Everything after `openBridgeDb` must run inside a try/finally so that
303
+ // if ANY step before the explicit `closeBridgeDb` throws schema
304
+ // creation, a contract insert loop that rethrows, a snapshot write, the
305
+ // cross-link loop, or anything else the handle is still released. A
306
+ // leaked handle holds the native LadybugDB file lock on tmpPath, which
307
+ // (a) leaks a FD and (b) prevents the next writeBridge call from
308
+ // reusing the same tmp slot.
309
+ const handle = await openBridgeDb(tmpPath);
310
+ let handleClosed = false;
311
+ try {
312
+ await ensureBridgeSchema(handle);
313
+ // Build the lookup index incrementally as contracts are inserted, so
314
+ // failed inserts are never in the index (and therefore never resolved
315
+ // by the cross-link loop below). This replaces a previous N+1 query
316
+ // pattern where each link made up to 6 DB round-trips to find its
317
+ // endpoints — see ContractLookupIndex.
318
+ const lookupIndex = createContractLookupIndex();
319
+ // Insert contracts — tolerate individual failures (e.g., a corrupt meta
320
+ // that can't be serialized). The whole sync must not fail because one
321
+ // contract is broken.
322
+ for (const c of contracts) {
323
+ const id = contractNodeId(c.repo, c.contractId, c.role, c.symbolRef.filePath);
324
+ try {
325
+ await queryBridge(handle, `CREATE (n:Contract {
316
326
  id: $id,
317
327
  contractId: $contractId,
318
328
  type: $type,
@@ -325,69 +335,69 @@ export async function writeBridge(groupDir, input) {
325
335
  confidence: $confidence,
326
336
  meta: $meta
327
337
  })`, {
328
- id,
329
- contractId: c.contractId,
330
- type: c.type,
331
- role: c.role,
332
- repo: c.repo,
333
- service: c.service ?? '',
334
- symbolUid: c.symbolUid,
335
- filePath: c.symbolRef.filePath,
336
- symbolName: c.symbolName,
337
- confidence: c.confidence,
338
- meta: JSON.stringify(c.meta),
339
- });
340
- report.contractsInserted++;
341
- // Only index on successful insert — the cross-link loop must never
342
- // resolve to a row that isn't actually in the DB.
343
- indexContract(lookupIndex, c, id);
344
- }
345
- catch (err) {
346
- report.contractsFailed++;
347
- recordError('contract', id, err);
338
+ id,
339
+ contractId: c.contractId,
340
+ type: c.type,
341
+ role: c.role,
342
+ repo: c.repo,
343
+ service: c.service ?? '',
344
+ symbolUid: c.symbolUid,
345
+ filePath: c.symbolRef.filePath,
346
+ symbolName: c.symbolName,
347
+ confidence: c.confidence,
348
+ meta: JSON.stringify(c.meta),
349
+ });
350
+ report.contractsInserted++;
351
+ // Only index on successful insert — the cross-link loop must never
352
+ // resolve to a row that isn't actually in the DB.
353
+ indexContract(lookupIndex, c, id);
354
+ }
355
+ catch (err) {
356
+ report.contractsFailed++;
357
+ recordError('contract', id, err);
358
+ }
348
359
  }
349
- }
350
- // Insert repo snapshots
351
- for (const [repoId, snap] of Object.entries(input.repoSnapshots)) {
352
- try {
353
- await queryBridge(handle, `CREATE (s:RepoSnapshot {
360
+ // Insert repo snapshots
361
+ for (const [repoId, snap] of Object.entries(input.repoSnapshots)) {
362
+ try {
363
+ await queryBridge(handle, `CREATE (s:RepoSnapshot {
354
364
  id: $id,
355
365
  indexedAt: $indexedAt,
356
366
  lastCommit: $lastCommit
357
367
  })`, {
358
- id: repoId,
359
- indexedAt: snap.indexedAt,
360
- lastCommit: snap.lastCommit,
361
- });
362
- report.snapshotsInserted++;
363
- }
364
- catch (err) {
365
- report.snapshotsFailed++;
366
- recordError('snapshot', repoId, err);
367
- }
368
- }
369
- // Insert cross-links (tolerating missing nodes).
370
- //
371
- // `findContractNode` consults the in-memory lookup index built above,
372
- // not the DB — that's an O(1) pure-function lookup per endpoint instead
373
- // of the previous 2-3 DB queries. For M cross-links, the previous code
374
- // issued up to 6M round-trips; this version issues zero.
375
- //
376
- // `link.contractId` may differ between the consumer and provider sides
377
- // (e.g. wildcard consumer `grpc::Service/*` → method-level provider
378
- // `grpc::Service/Method`) — that's why we resolve each endpoint
379
- // independently via its own `(repo, role, symbolUid, filePath, symbolName)`
380
- // tuple rather than matching on contractId.
381
- for (const link of crossLinks) {
382
- const linkId = `${link.from.repo}::${link.contractId}->${link.to.repo}::${link.contractId}`;
383
- try {
384
- const fromId = findContractNode(lookupIndex, link.from.repo, 'consumer', link.from.symbolUid, link.from.symbolRef.filePath, link.from.symbolRef.name);
385
- const toId = findContractNode(lookupIndex, link.to.repo, 'provider', link.to.symbolUid, link.to.symbolRef.filePath, link.to.symbolRef.name);
386
- if (!fromId || !toId) {
387
- report.linksDroppedMissingNode++;
388
- continue;
368
+ id: repoId,
369
+ indexedAt: snap.indexedAt,
370
+ lastCommit: snap.lastCommit,
371
+ });
372
+ report.snapshotsInserted++;
373
+ }
374
+ catch (err) {
375
+ report.snapshotsFailed++;
376
+ recordError('snapshot', repoId, err);
389
377
  }
390
- await queryBridge(handle, `
378
+ }
379
+ // Insert cross-links (tolerating missing nodes).
380
+ //
381
+ // `findContractNode` consults the in-memory lookup index built above,
382
+ // not the DB — that's an O(1) pure-function lookup per endpoint instead
383
+ // of the previous 2-3 DB queries. For M cross-links, the previous code
384
+ // issued up to 6M round-trips; this version issues zero.
385
+ //
386
+ // `link.contractId` may differ between the consumer and provider sides
387
+ // (e.g. wildcard consumer `grpc::Service/*` → method-level provider
388
+ // `grpc::Service/Method`) — that's why we resolve each endpoint
389
+ // independently via its own `(repo, role, symbolUid, filePath, symbolName)`
390
+ // tuple rather than matching on contractId.
391
+ for (const link of crossLinks) {
392
+ const linkId = `${link.from.repo}::${link.contractId}->${link.to.repo}::${link.contractId}`;
393
+ try {
394
+ const fromId = findContractNode(lookupIndex, link.from.repo, 'consumer', link.from.symbolUid, link.from.symbolRef.filePath, link.from.symbolRef.name);
395
+ const toId = findContractNode(lookupIndex, link.to.repo, 'provider', link.to.symbolUid, link.to.symbolRef.filePath, link.to.symbolRef.name);
396
+ if (!fromId || !toId) {
397
+ report.linksDroppedMissingNode++;
398
+ continue;
399
+ }
400
+ await queryBridge(handle, `
391
401
  MATCH (a:Contract), (b:Contract)
392
402
  WHERE a.id = $fromId AND b.id = $toId
393
403
  CREATE (a)-[:ContractLink {
@@ -398,82 +408,93 @@ export async function writeBridge(groupDir, input) {
398
408
  toRepo: $toRepo
399
409
  }]->(b)
400
410
  `, {
401
- fromId,
402
- toId,
403
- matchType: link.matchType,
404
- confidence: link.confidence,
405
- contractId: link.contractId,
406
- fromRepo: link.from.repo,
407
- toRepo: link.to.repo,
411
+ fromId,
412
+ toId,
413
+ matchType: link.matchType,
414
+ confidence: link.confidence,
415
+ contractId: link.contractId,
416
+ fromRepo: link.from.repo,
417
+ toRepo: link.to.repo,
418
+ });
419
+ report.linksInserted++;
420
+ }
421
+ catch (err) {
422
+ report.linksFailed++;
423
+ recordError('link', linkId, err);
424
+ }
425
+ }
426
+ // 2. Close temp DB (happy path). The finally block also calls
427
+ // closeBridgeDb if we threw above; `handleClosed` prevents a
428
+ // double-close on the native handle.
429
+ await closeBridgeDb(handle);
430
+ handleClosed = true;
431
+ }
432
+ finally {
433
+ if (!handleClosed) {
434
+ await closeBridgeDb(handle).catch(() => {
435
+ /* ignore: cleanup path, best effort */
408
436
  });
409
- report.linksInserted++;
410
437
  }
411
- catch (err) {
412
- report.linksFailed++;
413
- recordError('link', linkId, err);
438
+ }
439
+ // 3. Atomic swap: old→.bak, tmp→final, rm .bak
440
+ //
441
+ // The current database file (with its `.wal` / `.shadow` sidecars) is
442
+ // moved aside, then the freshly built tmp database takes its place.
443
+ // We move the sidecars together with the main file so the open below
444
+ // and any external readers see a consistent set; orphan sidecars from
445
+ // the tmp namespace are then removed because LadybugDB looks for them
446
+ // under the renamed-to base name and would reject mismatching IDs.
447
+ try {
448
+ await fsp.access(finalPath);
449
+ await retryRename(finalPath, bakPath);
450
+ for (const suffix of LBUG_SIDECAR_SUFFIXES) {
451
+ try {
452
+ await fsp.access(`${finalPath}${suffix}`);
453
+ await retryRename(`${finalPath}${suffix}`, `${bakPath}${suffix}`);
454
+ }
455
+ catch {
456
+ /* sidecar absent — nothing to move */
457
+ }
414
458
  }
415
459
  }
416
- // 2. Close temp DB (happy path). The finally block also calls
417
- // closeBridgeDb if we threw above; `handleClosed` prevents a
418
- // double-close on the native handle.
419
- await closeBridgeDb(handle);
420
- handleClosed = true;
421
- }
422
- finally {
423
- if (!handleClosed) {
424
- await closeBridgeDb(handle).catch(() => {
425
- /* ignore: cleanup path, best effort */
426
- });
460
+ catch {
461
+ /* no existing db */
427
462
  }
428
- }
429
- // 3. Atomic swap: old→.bak, tmp→final, rm .bak
430
- //
431
- // The current database file (with its `.wal` / `.shadow` sidecars) is
432
- // moved aside, then the freshly built tmp database takes its place.
433
- // We move the sidecars together with the main file so the open below
434
- // and any external readers see a consistent set; orphan sidecars from
435
- // the tmp namespace are then removed because LadybugDB looks for them
436
- // under the renamed-to base name and would reject mismatching IDs.
437
- try {
438
- await fsp.access(finalPath);
439
- await retryRename(finalPath, bakPath);
463
+ await retryRename(tmpPath, finalPath);
440
464
  for (const suffix of LBUG_SIDECAR_SUFFIXES) {
465
+ // Rename — not delete — so the WAL (which may carry uncommitted-at-
466
+ // close-time pages on a graceful close, depending on
467
+ // `autoCheckpoint` / `checkpointThreshold`) and the `.shadow`
468
+ // checkpoint snapshot stay paired with the database file under its
469
+ // final name. LadybugDB 0.16.0's database-id check rejects an open
470
+ // when the sidecars belong to a different base name.
441
471
  try {
442
- await fsp.access(`${finalPath}${suffix}`);
443
- await retryRename(`${finalPath}${suffix}`, `${bakPath}${suffix}`);
472
+ await fsp.access(`${tmpPath}${suffix}`);
473
+ await retryRename(`${tmpPath}${suffix}`, `${finalPath}${suffix}`);
444
474
  }
445
475
  catch {
446
476
  /* sidecar absent — nothing to move */
447
477
  }
448
478
  }
479
+ await removeLbugFile(bakPath);
480
+ // 4. Write meta.json
481
+ await writeBridgeMeta(groupDir, {
482
+ version: BRIDGE_SCHEMA_VERSION,
483
+ generatedAt: new Date().toISOString(),
484
+ missingRepos: input.missingRepos,
485
+ });
486
+ return report;
449
487
  }
450
- catch {
451
- /* no existing db */
452
- }
453
- await retryRename(tmpPath, finalPath);
454
- for (const suffix of LBUG_SIDECAR_SUFFIXES) {
455
- // Rename not delete so the WAL (which may carry uncommitted-at-
456
- // close-time pages on a graceful close, depending on
457
- // `autoCheckpoint` / `checkpointThreshold`) and the `.shadow`
458
- // checkpoint snapshot stay paired with the database file under its
459
- // final name. LadybugDB 0.16.0's database-id check rejects an open
460
- // when the sidecars belong to a different base name.
461
- try {
462
- await fsp.access(`${tmpPath}${suffix}`);
463
- await retryRename(`${tmpPath}${suffix}`, `${finalPath}${suffix}`);
464
- }
465
- catch {
466
- /* sidecar absent — nothing to move */
467
- }
488
+ finally {
489
+ // Always remove the mkdtemp staging directory. On the happy path the
490
+ // main file and sidecars have been renamed out of it, so it's empty;
491
+ // on any error path it may still contain a partial database — either
492
+ // way `recursive: true, force: true` removes it without surfacing
493
+ // "directory not empty" or ENOENT.
494
+ await fsp.rm(stagingDir, { recursive: true, force: true }).catch(() => {
495
+ /* best-effort cleanup */
496
+ });
468
497
  }
469
- await removeLbugFile(bakPath);
470
- // 4. Write meta.json
471
- await writeBridgeMeta(groupDir, {
472
- version: BRIDGE_SCHEMA_VERSION,
473
- generatedAt: new Date().toISOString(),
474
- missingRepos: input.missingRepos,
475
- });
476
- return report;
477
498
  }
478
499
  /* ------------------------------------------------------------------ */
479
500
  /* openBridgeDbReadOnly */
@@ -568,6 +589,11 @@ export async function openBridgeDbReadOnly(groupDir) {
568
589
  await new Promise((r) => setTimeout(r, delay));
569
590
  }
570
591
  }
592
+ // Pino's NDJSON serialization is structurally injection-resistant
593
+ // (CodeQL js/log-injection): groupDir and err.message are JSON-escaped
594
+ // by the serializer, so no manual CRLF / U+2028 / ANSI sanitization is
595
+ // needed. Demoted to debug — only fires when the bridge truly gave up
596
+ // after retries, and operators only need it at debug verbosity.
571
597
  bridgeLogger.debug({ groupDir, err: lastErr, attempts: LBUG_OPEN_RETRY_ATTEMPTS }, 'openBridgeDbReadOnly gave up');
572
598
  return null;
573
599
  }
@@ -3,6 +3,14 @@ import * as fsp from 'node:fs/promises';
3
3
  import * as path from 'node:path';
4
4
  import * as os from 'node:os';
5
5
  import { randomBytes } from 'node:crypto';
6
+ /**
7
+ * Build an unpredictable suffix for atomic-write tmp files. Replaces the
8
+ * previous `Date.now()` pattern which CodeQL flagged as
9
+ * js/insecure-temporary-file: a guessable suffix in a writable directory
10
+ * lets a co-located attacker pre-create or symlink the tmp path before the
11
+ * write lands.
12
+ */
13
+ const tmpSuffix = () => randomBytes(8).toString('hex');
6
14
  const CONTRACTS_FILE = 'contracts.json';
7
15
  export function getDefaultGitnexusDir() {
8
16
  return process.env.GITNEXUS_HOME || path.join(os.homedir(), '.gitnexus');
@@ -22,8 +30,21 @@ export function getGroupDir(gitnexusDir, groupName) {
22
30
  }
23
31
  export async function writeContractRegistry(groupDir, registry) {
24
32
  const targetPath = path.join(groupDir, CONTRACTS_FILE);
25
- const tmpPath = `${targetPath}.tmp.${randomBytes(8).toString('hex')}`;
26
- await fsp.writeFile(tmpPath, JSON.stringify(registry, null, 2), 'utf-8');
33
+ const tmpPath = `${targetPath}.tmp.${tmpSuffix()}`;
34
+ // O_EXCL via `'wx'` flag + explicit `0o600` mode — closes both halves
35
+ // of the CodeQL js/insecure-temporary-file finding: `'wx'` rejects a
36
+ // pre-planted symlink at the path, and `0o600` (user-only) prevents
37
+ // the file from being created group/world readable while it briefly
38
+ // contains contract data en route to the rename. The query's
39
+ // `isSecureMode` predicate inspects ONLY the mode argument, not the
40
+ // flags, so the explicit mode is what credits the fix.
41
+ const handle = await fsp.open(tmpPath, 'wx', 0o600);
42
+ try {
43
+ await handle.writeFile(JSON.stringify(registry, null, 2), 'utf-8');
44
+ }
45
+ finally {
46
+ await handle.close();
47
+ }
27
48
  await fsp.rename(tmpPath, targetPath);
28
49
  }
29
50
  export async function readContractRegistry(groupDir) {
@@ -89,6 +110,41 @@ matching:
89
110
  # exclude_links_paths: [/ping, /health, /healthcheck]
90
111
  # exclude_links_param_only_paths: false
91
112
  `;
92
- await fsp.writeFile(path.join(groupDir, 'group.yaml'), template, 'utf-8');
113
+ // Always write group.yaml with O_EXCL via `fsp.open(..., 'wx')` —
114
+ // refuses to follow a pre-planted symlink at the target path, closing
115
+ // the TOCTOU window between the existence check (line ~98) and the
116
+ // write that CodeQL js/insecure-temporary-file flags. Under
117
+ // `force=true` we unlink the existing file first (best-effort, no-op
118
+ // when absent) so the subsequent O_EXCL open succeeds AND the same
119
+ // symlink-rejection guarantee holds — this is strictly safer than
120
+ // the previous `flag: force ? 'w' : 'wx'` shape, which silently
121
+ // followed symlinks under force. CodeQL's rule does not recognize
122
+ // the `writeFile(path, content, { flag: 'wx' })` shape as O_EXCL;
123
+ // the explicit open() handle below is what credits the mitigation.
124
+ const yamlPath = path.join(groupDir, 'group.yaml');
125
+ if (force) {
126
+ try {
127
+ await fsp.unlink(yamlPath);
128
+ }
129
+ catch (err) {
130
+ // ENOENT (file absent) is expected on first run; rethrow anything
131
+ // else so we don't silently mask permission/EBUSY failures.
132
+ if (err.code !== 'ENOENT')
133
+ throw err;
134
+ }
135
+ }
136
+ // `'wx'` rejects a pre-planted symlink at the path; `0o600` is
137
+ // user-only (no group/world bits) — gitnexus storage is per-user
138
+ // (`~/.gitnexus/...`), so any "other user wants to read this" case is
139
+ // a misconfiguration, not a feature. Keeping the file user-only also
140
+ // satisfies CodeQL's `isSecureMode` predicate (low 6 bits == 0) and
141
+ // closes the js/insecure-temporary-file alert at this site.
142
+ const handle = await fsp.open(yamlPath, 'wx', 0o600);
143
+ try {
144
+ await handle.writeFile(template, 'utf-8');
145
+ }
146
+ finally {
147
+ await handle.close();
148
+ }
93
149
  return groupDir;
94
150
  }
@@ -6,7 +6,24 @@
6
6
  *
7
7
  * Pure function — no tree-sitter dependency, safe for worker threads.
8
8
  */
9
- const SCRIPT_RE = /<script(\s[^>]*)?>([^]*?)<\/script>/g;
9
+ // Closing-tag pattern accepts:
10
+ // - whitespace before `>` — `</script >`, `</script\t\n>`
11
+ // - attribute-like junk after `script` — `</script foo="bar">`,
12
+ // `</script\t\n bar>`
13
+ // - any case — `</SCRIPT>`, `</Script>`
14
+ //
15
+ // HTML5 parses `</script foo>` as a valid close tag (attributes on
16
+ // close tags are ignored by the parser but still terminate the script
17
+ // block). A strict `<\/script\s*>` would miss those forms and let a
18
+ // crafted Vue file hide content from this extractor — exactly the
19
+ // CodeQL `js/bad-tag-filter` failure mode (the published test cases
20
+ // it checks include `</script foo="bar">` and `</script\t\n bar>`).
21
+ //
22
+ // `[^>]*` after `</script` accepts everything up to the next `>`,
23
+ // matching the HTML parser's actual close-tag behaviour. The `i` flag
24
+ // covers the case axis. PR #1330 CI surfaced both the case and
25
+ // attribute axes; this expression closes both at once.
26
+ const SCRIPT_RE = /<script(\s[^>]*)?>([^]*?)<\/script[^>]*>/gi;
10
27
  const TEMPLATE_COMPONENT_RE = /<([A-Z][A-Za-z0-9]+)/g;
11
28
  // Greedy: matches from the first <template> to the *last* </template>.
12
29
  // This is intentional — nested <template v-slot:...> tags are valid Vue
@@ -48,8 +48,10 @@ export function isAzureProvider(baseUrl) {
48
48
  return hostname.endsWith('.openai.azure.com') || hostname.endsWith('.services.ai.azure.com');
49
49
  }
50
50
  catch {
51
- // If URL is malformed, fall back to substring check
52
- return baseUrl.includes('.openai.azure.com') || baseUrl.includes('.services.ai.azure.com');
51
+ // Malformed URL refuse to call this Azure rather than fall back to a
52
+ // substring check, which is bypassable by `https://evil.com/?u=.openai.azure.com`
53
+ // (CodeQL js/incomplete-url-substring-sanitization).
54
+ return false;
53
55
  }
54
56
  }
55
57
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.4-rc.88",
3
+ "version": "1.6.4-rc.89",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",