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.
- package/dist/cli/setup.js +6 -1
- package/dist/cli/wiki.js +37 -5
- package/dist/core/group/bridge-db.js +196 -170
- package/dist/core/group/storage.js +59 -3
- package/dist/core/ingestion/vue-sfc-extractor.js +18 -1
- package/dist/core/wiki/llm-client.js +4 -2
- package/package.json +1 -1
- package/web/assets/{agent-DGm5BiXg.js → agent-Dl2d-mDi.js} +3 -3
- package/web/assets/architecture-YZFGNWBL-S5CXDPWN-BYXnTu07.js +1 -0
- package/web/assets/{architectureDiagram-EMZXCZ2Q-DLWvvvUB.js → architectureDiagram-EMZXCZ2Q-uiiIna83.js} +1 -1
- package/web/assets/{blockDiagram-IGV67L2C-B47EUMKY.js → blockDiagram-IGV67L2C-uBKdDzXA.js} +1 -1
- package/web/assets/{c4Diagram-DFAF54RM-DvvVQASF.js → c4Diagram-DFAF54RM-DTxaX43T.js} +1 -1
- package/web/assets/{chunk-3GS5O3IE-0H2zS7RE.js → chunk-3GS5O3IE-BxKKu7d7.js} +1 -1
- package/web/assets/{chunk-3YCYZ6SJ-DokVoiwM.js → chunk-3YCYZ6SJ-BJZnGFjg.js} +1 -1
- package/web/assets/{chunk-6NTNNK5N-B9jhOxT0.js → chunk-6NTNNK5N-CIL_eO2d.js} +1 -1
- package/web/assets/{chunk-A34GCYZU-DfTldqlz.js → chunk-A34GCYZU-Dv4Vkem9.js} +1 -1
- package/web/assets/{chunk-DJ7UZH7F-npvsBmvn.js → chunk-DJ7UZH7F-Cskl-nb3.js} +1 -1
- package/web/assets/{chunk-DKKBVRCY-DbFEYV2a.js → chunk-DKKBVRCY-D3S2BD56.js} +2 -2
- package/web/assets/{chunk-DU5LTGQ6-B1Mj73sD.js → chunk-DU5LTGQ6-B8Zh51LG.js} +1 -1
- package/web/assets/{chunk-FXACKDTF-CNljDI1M.js → chunk-FXACKDTF-Cb4xHVUz.js} +1 -1
- package/web/assets/{chunk-H3VCZNTA-j4ZGeRTZ.js → chunk-H3VCZNTA-BOGhqvxW.js} +1 -1
- package/web/assets/{chunk-HN6EAY2L-CEc2xC-J.js → chunk-HN6EAY2L-CitKFy1e.js} +1 -1
- package/web/assets/{chunk-O5ABG6QK-B_00CmQB.js → chunk-O5ABG6QK-V3sk7Bps.js} +1 -1
- package/web/assets/{chunk-PK6DOVAG-lOMJKFOu.js → chunk-PK6DOVAG-FvG7aKfZ.js} +1 -1
- package/web/assets/{chunk-RNJOYNJ4-BKKlhT6X.js → chunk-RNJOYNJ4-BPANsy8V.js} +1 -1
- package/web/assets/{chunk-RWUO3TPN-BhUzGpFd.js → chunk-RWUO3TPN-BHusg2jI.js} +1 -1
- package/web/assets/{chunk-TBF5ZNIQ-BAsGZFMI.js → chunk-TBF5ZNIQ-CXamrpH3.js} +1 -1
- package/web/assets/{chunk-TYMNRAUI-B47lwFjp.js → chunk-TYMNRAUI-BnMe6HfP.js} +1 -1
- package/web/assets/{chunk-W7ZLLLMY-DGb6u0pB.js → chunk-W7ZLLLMY-BqZFKM5j.js} +1 -1
- package/web/assets/{chunk-WSB5WSVC-BiaAMFmd.js → chunk-WSB5WSVC-EKS7-Nnc.js} +1 -1
- package/web/assets/{chunk-XGPFEOL4-Dqk5iHgi.js → chunk-XGPFEOL4-BBab-enK.js} +1 -1
- package/web/assets/classDiagram-PPOCWD7C-B-LkKU0P.js +1 -0
- package/web/assets/classDiagram-v2-23LJLIIU-_NGI4VEh.js +1 -0
- package/web/assets/{cose-bilkent-PNC4W37J-CnOlxR0_.js → cose-bilkent-PNC4W37J-DjEQkTZ4.js} +1 -1
- package/web/assets/{dagre-E77IOHMT-Cy3z5r06.js → dagre-E77IOHMT-D6Xacg1x.js} +1 -1
- package/web/assets/{diagram-H7BISOXX-ChLdgktB.js → diagram-H7BISOXX-CMuISoJL.js} +1 -1
- package/web/assets/{diagram-JC5VWROH-DdzGpm08.js → diagram-JC5VWROH-DHq_eoeM.js} +1 -1
- package/web/assets/{diagram-LXUTUG65-Bq_ye_lS.js → diagram-LXUTUG65-C0NczIgW.js} +1 -1
- package/web/assets/{diagram-WEHSV5V5-WrNO_VoZ.js → diagram-WEHSV5V5-WsBnpSuc.js} +1 -1
- package/web/assets/{erDiagram-GCSMX5X6-Cx3Aq4SN.js → erDiagram-GCSMX5X6-DxHFHFR6.js} +1 -1
- package/web/assets/{flowDiagram-OTCZ4VVT-DRe-9T0b.js → flowDiagram-OTCZ4VVT-_G1VxyQS.js} +1 -1
- package/web/assets/{ganttDiagram-MUNLMDZQ-CvtFMhi8.js → ganttDiagram-MUNLMDZQ-BIHObj0t.js} +1 -1
- package/web/assets/gitGraph-7Q5UKJZL-54BCDZD5-BXJXsixL.js +1 -0
- package/web/assets/{gitGraphDiagram-3HKGZ4G3-BxDd7tFR.js → gitGraphDiagram-3HKGZ4G3-COZUkHTD.js} +1 -1
- package/web/assets/{index-ChQJsgDb.js → index-DDcYO1IJ.js} +5 -5
- package/web/assets/info-OMHHGYJF-BF2H5H6G-C820LK0J.js +1 -0
- package/web/assets/infoDiagram-MN7RKWGX-BeOjfPc_.js +2 -0
- package/web/assets/{ishikawaDiagram-YMYX4NHK-TUICrJKY.js → ishikawaDiagram-YMYX4NHK-C-JI2Ih7.js} +1 -1
- package/web/assets/{journeyDiagram-SO5T7YLQ-C4Y1ypn8.js → journeyDiagram-SO5T7YLQ-IPX4h3iD.js} +1 -1
- package/web/assets/{kanban-definition-LJHFXRCJ--cIG9jHd.js → kanban-definition-LJHFXRCJ-B23LtshQ.js} +1 -1
- package/web/assets/{mindmap-definition-2EUWGEK5-BK54zuWp.js → mindmap-definition-2EUWGEK5-DJKUsrCo.js} +1 -1
- package/web/assets/packet-4T2RLAQJ-EV4IVRXR-BvWtM5jq.js +1 -0
- package/web/assets/pie-ZZUOXDRM-N23DN5KN-BnDgj0TQ.js +1 -0
- package/web/assets/{pieDiagram-3IATQBI2-CMgPhL6Y.js → pieDiagram-3IATQBI2-Bj26KTKk.js} +1 -1
- package/web/assets/{quadrantDiagram-E256RVCF-CNmFSP0m.js → quadrantDiagram-E256RVCF-VAk5oIRr.js} +1 -1
- package/web/assets/radar-PYXPWWZC-P6TP7ZYP-2IuDlZ6r.js +1 -0
- package/web/assets/{requirementDiagram-M5DCFWZL-C6e8zmrF.js → requirementDiagram-M5DCFWZL-CmlTJ_vQ.js} +1 -1
- package/web/assets/{sankeyDiagram-L3NBLAOT-BDcHIgVE.js → sankeyDiagram-L3NBLAOT-C-f_DdcX.js} +1 -1
- package/web/assets/{sequenceDiagram-ZOUHS735-CGAlp5GL.js → sequenceDiagram-ZOUHS735-DmtNf1NF.js} +1 -1
- package/web/assets/{stateDiagram-MLPALWAM-CCKt0fuW.js → stateDiagram-MLPALWAM-Lq7UVrkj.js} +1 -1
- package/web/assets/stateDiagram-v2-B5LQ5ZB2-DCiXBPeS.js +1 -0
- package/web/assets/{timeline-definition-5SPVSISX-D74jeyVQ.js → timeline-definition-5SPVSISX-BkpqZxt2.js} +1 -1
- package/web/assets/treeView-SZITEDCU-5DXDK3XO-Ipq1EIaF.js +1 -0
- package/web/assets/treemap-W4RFUUIX-WYLRDWKO-DESFxovu.js +1 -0
- package/web/assets/{vennDiagram-IE5QUKF5-ChHw0kJO.js → vennDiagram-IE5QUKF5-iAJ08JBe.js} +1 -1
- package/web/assets/wardley-RL74JXVD-BCRCBASE-BVkhckBY.js +1 -0
- package/web/assets/{wardleyDiagram-XU3VSMPF-D5PsSVSp.js → wardleyDiagram-XU3VSMPF-BDiKTkL4.js} +1 -1
- package/web/assets/{xychartDiagram-ZHJ5623Y-B7E396Z-.js → xychartDiagram-ZHJ5623Y-CknyZyQK.js} +1 -1
- package/web/index.html +1 -1
- package/web/assets/architecture-YZFGNWBL-S5CXDPWN-CzpqonpT.js +0 -1
- package/web/assets/classDiagram-PPOCWD7C-W4Gq4oYa.js +0 -1
- package/web/assets/classDiagram-v2-23LJLIIU-CbyGxpD-.js +0 -1
- package/web/assets/gitGraph-7Q5UKJZL-54BCDZD5-DZfbulFG.js +0 -1
- package/web/assets/info-OMHHGYJF-BF2H5H6G-Dehqc8IA.js +0 -1
- package/web/assets/infoDiagram-MN7RKWGX-B0B5J4EY.js +0 -2
- package/web/assets/packet-4T2RLAQJ-EV4IVRXR-BjetiCTk.js +0 -1
- package/web/assets/pie-ZZUOXDRM-N23DN5KN-zkxhGq7v.js +0 -1
- package/web/assets/radar-PYXPWWZC-P6TP7ZYP-CHxbGrvk.js +0 -1
- package/web/assets/stateDiagram-v2-B5LQ5ZB2-OC7lFsht.js +0 -1
- package/web/assets/treeView-SZITEDCU-5DXDK3XO-8EO9YZIQ.js +0 -1
- package/web/assets/treemap-W4RFUUIX-WYLRDWKO-y8YnclBi.js +0 -1
- 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
|
-
|
|
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
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
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
|
-
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
417
|
-
|
|
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(`${
|
|
443
|
-
await retryRename(`${
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
//
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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.${
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
52
|
-
|
|
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