gitnexus 1.6.4-rc.54 → 1.6.4-rc.56

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.
@@ -132,17 +132,25 @@ export function c3Linearize(classId, parentMap, cache, inProgress) {
132
132
  }
133
133
  // Add the direct parents list as the final sequence
134
134
  const sequences = [...parentLinearizations, [...directParents]];
135
+ const heads = new Uint32Array(sequences.length); // head pointer per sequence
135
136
  const result = [];
137
+ // Tail-count map: how many sequences contain this id at index > head.
138
+ // O(1) membership check replaces O(n) indexOf scans.
139
+ const tailCount = new Map();
140
+ for (const seq of sequences) {
141
+ for (let i = 1; i < seq.length; i++) {
142
+ tailCount.set(seq[i], (tailCount.get(seq[i]) ?? 0) + 1);
143
+ }
144
+ }
145
+ let remaining = sequences.reduce((n, s) => n + s.length, 0);
136
146
  let inconsistent = false;
137
- while (sequences.some((s) => s.length > 0)) {
138
- // Find a good head: one that doesn't appear in the tail of any other sequence
147
+ while (remaining > 0) {
139
148
  let head = null;
140
- for (const seq of sequences) {
141
- if (seq.length === 0)
149
+ for (let si = 0; si < sequences.length; si++) {
150
+ if (heads[si] >= sequences[si].length)
142
151
  continue;
143
- const candidate = seq[0];
144
- const inTail = sequences.some((other) => other.length > 1 && other.indexOf(candidate, 1) !== -1);
145
- if (!inTail) {
152
+ const candidate = sequences[si][heads[si]];
153
+ if ((tailCount.get(candidate) ?? 0) === 0) {
146
154
  head = candidate;
147
155
  break;
148
156
  }
@@ -152,10 +160,22 @@ export function c3Linearize(classId, parentMap, cache, inProgress) {
152
160
  break;
153
161
  }
154
162
  result.push(head);
155
- // Remove the chosen head from all sequences
156
- for (const seq of sequences) {
157
- if (seq.length > 0 && seq[0] === head) {
158
- seq.shift();
163
+ // Advance head pointers past the chosen head; update tail counts
164
+ for (let si = 0; si < sequences.length; si++) {
165
+ if (heads[si] >= sequences[si].length)
166
+ continue;
167
+ if (sequences[si][heads[si]] === head) {
168
+ heads[si]++;
169
+ remaining--;
170
+ // promoted was in this sequence's active tail; now it's the new head — remove from tailCount
171
+ if (heads[si] < sequences[si].length) {
172
+ const promoted = sequences[si][heads[si]];
173
+ const prev = tailCount.get(promoted);
174
+ if (prev <= 1)
175
+ tailCount.delete(promoted);
176
+ else
177
+ tailCount.set(promoted, prev - 1);
178
+ }
159
179
  }
160
180
  }
161
181
  }
@@ -48,5 +48,25 @@ export declare const staticCacheControlSetHeaders: (res: express.Response, fileP
48
48
  export declare const registerWebUI: (app: express.Express, staticDir: string | null) => void;
49
49
  export declare const writeNdjsonRecord: (res: express.Response, record: GraphStreamRecord, signal?: AbortSignal) => Promise<void>;
50
50
  export declare const streamGraphNdjson: (res: express.Response, includeContent?: boolean, signal?: AbortSignal) => Promise<void>;
51
+ /**
52
+ * Handle a GET /api/file request body. Extracted from createServer's route
53
+ * registration so it can be unit-tested without spinning up an HTTP server
54
+ * — calling app.get(...) inside a test triggers CodeQL's
55
+ * js/missing-rate-limiting query, which is appropriate for production
56
+ * route handlers but a false positive for tests of the handler logic.
57
+ *
58
+ * The function takes the express req and res (typed loosely so test code
59
+ * can pass minimal mocks) plus the resolved repo path. All path-traversal
60
+ * containment is done inline at the readFile sink with the canonical
61
+ * path.relative idiom for CodeQL js/path-injection recognition.
62
+ */
63
+ export declare const handleFileRequest: (req: {
64
+ query: any;
65
+ }, res: {
66
+ status: (code: number) => {
67
+ json: (body: any) => void;
68
+ };
69
+ json: (body: any) => void;
70
+ }, repoPath: string) => Promise<void>;
51
71
  export declare const createServer: (port: number, host?: string) => Promise<void>;
52
72
  export {};
@@ -25,6 +25,7 @@ import { mountMCPEndpoints } from './mcp-http.js';
25
25
  import { fork } from 'child_process';
26
26
  import { fileURLToPath, pathToFileURL } from 'url';
27
27
  import { JobManager } from './analyze-job.js';
28
+ import { assertString, escapeRegExp, BadRequestError } from './validation.js';
28
29
  import { extractRepoName, getCloneDir, cloneOrPull } from './git-clone.js';
29
30
  const _require = createRequire(import.meta.url);
30
31
  const pkg = _require('../../package.json');
@@ -429,6 +430,10 @@ const mountSSEProgress = (app, routePath, jm) => {
429
430
  });
430
431
  };
431
432
  const statusFromError = (err) => {
433
+ // Validation helpers throw BadRequestError / ForbiddenError with a typed
434
+ // .status field — honor it before falling back to message-string matching.
435
+ if (err instanceof BadRequestError)
436
+ return err.status;
432
437
  const msg = String(err?.message ?? '');
433
438
  if (msg.includes('No indexed repositories') || msg.includes('not found'))
434
439
  return 404;
@@ -445,6 +450,77 @@ const requestedRepo = (req) => {
445
450
  }
446
451
  return undefined;
447
452
  };
453
+ /**
454
+ * Handle a GET /api/file request body. Extracted from createServer's route
455
+ * registration so it can be unit-tested without spinning up an HTTP server
456
+ * — calling app.get(...) inside a test triggers CodeQL's
457
+ * js/missing-rate-limiting query, which is appropriate for production
458
+ * route handlers but a false positive for tests of the handler logic.
459
+ *
460
+ * The function takes the express req and res (typed loosely so test code
461
+ * can pass minimal mocks) plus the resolved repo path. All path-traversal
462
+ * containment is done inline at the readFile sink with the canonical
463
+ * path.relative idiom for CodeQL js/path-injection recognition.
464
+ */
465
+ export const handleFileRequest = async (req, res, repoPath) => {
466
+ try {
467
+ // Type-confusion guard — req.query.path is `string | string[] | ParsedQs`.
468
+ // Without this, an attacker could pass `?path=a&path=b` to bypass the
469
+ // length-bound traversal check below (CodeQL js/type-confusion-through-
470
+ // parameter-tampering, same class as the /api/grep critical fix).
471
+ const rawFilePath = req.query.path;
472
+ if (rawFilePath === undefined || rawFilePath === '') {
473
+ res.status(400).json({ error: 'Missing path' });
474
+ return;
475
+ }
476
+ const filePath = assertString(rawFilePath, 'path');
477
+ // Path-injection containment — inline at the sink with the canonical
478
+ // path.relative idiom that CodeQL's js/path-injection sanitizer
479
+ // recognizes. assertSafePath in validation.ts performs the equivalent
480
+ // check, but cross-module helpers are not followed by CodeQL's
481
+ // interprocedural analysis for path-traversal sanitization in JS, so
482
+ // the barrier must be visible inline at the readFile sink.
483
+ const repoRoot = path.resolve(repoPath);
484
+ const fullPath = path.resolve(repoRoot, filePath);
485
+ const fullRel = path.relative(repoRoot, fullPath);
486
+ if (fullRel.startsWith('..') || path.isAbsolute(fullRel)) {
487
+ res.status(403).json({ error: 'Path traversal denied' });
488
+ return;
489
+ }
490
+ const raw = await fs.readFile(fullPath, 'utf-8');
491
+ // Optional line-range support: ?startLine=10&endLine=50
492
+ // Returns only the requested slice (0-indexed), plus metadata.
493
+ const startLine = req.query.startLine !== undefined ? Number(req.query.startLine) : undefined;
494
+ const endLine = req.query.endLine !== undefined ? Number(req.query.endLine) : undefined;
495
+ if (startLine !== undefined && Number.isFinite(startLine)) {
496
+ const lines = raw.split('\n');
497
+ const start = Math.max(0, startLine);
498
+ const end = endLine !== undefined && Number.isFinite(endLine)
499
+ ? Math.min(lines.length, endLine + 1)
500
+ : lines.length;
501
+ res.json({
502
+ content: lines.slice(start, end).join('\n'),
503
+ startLine: start,
504
+ endLine: end - 1,
505
+ totalLines: lines.length,
506
+ });
507
+ }
508
+ else {
509
+ res.json({ content: raw, totalLines: raw.split('\n').length });
510
+ }
511
+ }
512
+ catch (err) {
513
+ if (err.code === 'ENOENT') {
514
+ res.status(404).json({ error: 'File not found' });
515
+ }
516
+ else {
517
+ // statusFromError returns err.status for BadRequestError / ForbiddenError
518
+ // (assertString → 400 on array-form ?path=a&path=b; ForbiddenError → 403
519
+ // on traversal). Falls back to 500 for unrecognized failures.
520
+ res.status(statusFromError(err)).json({ error: err.message || 'Failed to read file' });
521
+ }
522
+ }
523
+ };
448
524
  export const createServer = async (port, host = '127.0.0.1') => {
449
525
  const app = express();
450
526
  app.disable('x-powered-by');
@@ -910,54 +986,12 @@ export const createServer = async (port, host = '127.0.0.1') => {
910
986
  });
911
987
  // Read file — with path traversal guard
912
988
  app.get('/api/file', async (req, res) => {
913
- try {
914
- const entry = await resolveRepo(requestedRepo(req));
915
- if (!entry) {
916
- res.status(404).json({ error: 'Repository not found' });
917
- return;
918
- }
919
- const filePath = req.query.path;
920
- if (!filePath) {
921
- res.status(400).json({ error: 'Missing path' });
922
- return;
923
- }
924
- // Prevent path traversal — resolve and verify the path stays within the repo root
925
- const repoRoot = path.resolve(entry.path);
926
- const fullPath = path.resolve(repoRoot, filePath);
927
- if (!fullPath.startsWith(repoRoot + path.sep) && fullPath !== repoRoot) {
928
- res.status(403).json({ error: 'Path traversal denied' });
929
- return;
930
- }
931
- const raw = await fs.readFile(fullPath, 'utf-8');
932
- // Optional line-range support: ?startLine=10&endLine=50
933
- // Returns only the requested slice (0-indexed), plus metadata.
934
- const startLine = req.query.startLine !== undefined ? Number(req.query.startLine) : undefined;
935
- const endLine = req.query.endLine !== undefined ? Number(req.query.endLine) : undefined;
936
- if (startLine !== undefined && Number.isFinite(startLine)) {
937
- const lines = raw.split('\n');
938
- const start = Math.max(0, startLine);
939
- const end = endLine !== undefined && Number.isFinite(endLine)
940
- ? Math.min(lines.length, endLine + 1)
941
- : lines.length;
942
- res.json({
943
- content: lines.slice(start, end).join('\n'),
944
- startLine: start,
945
- endLine: end - 1,
946
- totalLines: lines.length,
947
- });
948
- }
949
- else {
950
- res.json({ content: raw, totalLines: raw.split('\n').length });
951
- }
952
- }
953
- catch (err) {
954
- if (err.code === 'ENOENT') {
955
- res.status(404).json({ error: 'File not found' });
956
- }
957
- else {
958
- res.status(500).json({ error: err.message || 'Failed to read file' });
959
- }
989
+ const entry = await resolveRepo(requestedRepo(req));
990
+ if (!entry) {
991
+ res.status(404).json({ error: 'Repository not found' });
992
+ return;
960
993
  }
994
+ await handleFileRequest(req, res, entry.path);
961
995
  });
962
996
  // Grep — regex search across file contents in the indexed repo
963
997
  // Uses filesystem-based search for memory efficiency (never loads all files into memory)
@@ -968,20 +1002,33 @@ export const createServer = async (port, host = '127.0.0.1') => {
968
1002
  res.status(404).json({ error: 'Repository not found' });
969
1003
  return;
970
1004
  }
971
- const pattern = req.query.pattern;
972
- if (!pattern) {
1005
+ // Type-confusion guard (CodeQL js/type-confusion-through-parameter-tampering):
1006
+ // req.query.pattern is `string | string[] | ParsedQs` — without an explicit
1007
+ // type check, the `.length` guard below counts array elements instead of
1008
+ // characters, allowing arbitrarily long patterns through.
1009
+ const rawPattern = req.query.pattern;
1010
+ if (rawPattern === undefined) {
1011
+ res.status(400).json({ error: 'Missing "pattern" query parameter' });
1012
+ return;
1013
+ }
1014
+ const pattern = assertString(rawPattern, 'pattern');
1015
+ if (pattern.length === 0) {
973
1016
  res.status(400).json({ error: 'Missing "pattern" query parameter' });
974
1017
  return;
975
1018
  }
976
- // ReDoS protection: reject overly long or dangerous patterns
1019
+ // Length cap: applies to both literal and regex modes as a defense-in-depth
1020
+ // bound against pathological input.
977
1021
  if (pattern.length > 200) {
978
1022
  res.status(400).json({ error: 'Pattern too long (max 200 characters)' });
979
1023
  return;
980
1024
  }
981
- // Validate regex syntax
1025
+ // Treat user input as a literal substring in all cases to prevent
1026
+ // regex-injection/ReDoS via attacker-controlled regex syntax.
1027
+ const effectivePattern = escapeRegExp(pattern);
1028
+ // Validate regex syntax (catches both opt-in user regex and any escapeRegExp bug)
982
1029
  let regex;
983
1030
  try {
984
- regex = new RegExp(pattern, 'gim');
1031
+ regex = new RegExp(effectivePattern, 'gim');
985
1032
  }
986
1033
  catch {
987
1034
  res.status(400).json({ error: 'Invalid regex pattern' });
@@ -1025,7 +1072,7 @@ export const createServer = async (port, host = '127.0.0.1') => {
1025
1072
  res.json({ results });
1026
1073
  }
1027
1074
  catch (err) {
1028
- res.status(500).json({ error: err.message || 'Grep failed' });
1075
+ res.status(statusFromError(err)).json({ error: err.message || 'Grep failed' });
1029
1076
  }
1030
1077
  });
1031
1078
  // List all processes
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Server-side input validation helpers.
3
+ *
4
+ * Convention: helpers throw BadRequestError (or its 403 subclass ForbiddenError)
5
+ * when user input fails validation. Existing route handlers wrap their bodies in
6
+ * try/catch and translate the error to res.status(err.status).json({error: err.message}).
7
+ * This pattern was chosen over an asyncHandler middleware to stay compatible with
8
+ * Express 4's non-propagation of async-thrown errors and to match the existing
9
+ * try/catch shape used throughout api.ts.
10
+ *
11
+ * Scope (this PR — U1 of the security remediation plan):
12
+ * - assertString: closes js/type-confusion-through-parameter-tampering (api.ts:1118)
13
+ * - assertSafePath: consolidates the path-traversal guard from api.ts:1067-1077
14
+ * for reuse across other path-injection findings (U2/U3)
15
+ * - escapeRegExp: utility for upcoming regex-injection fix at /api/grep (U5)
16
+ *
17
+ * Helpers added in later units (U3 git-clone hardening, U4 rate-limiting) live
18
+ * in this module too but are introduced with the dependency they require.
19
+ */
20
+ /**
21
+ * Thrown by validation helpers when user input is rejected.
22
+ * Routes catch via existing try/catch and convert with err.status / err.message.
23
+ */
24
+ export declare class BadRequestError extends Error {
25
+ readonly status: number;
26
+ constructor(message: string, status?: number);
27
+ }
28
+ export declare class ForbiddenError extends BadRequestError {
29
+ constructor(message: string);
30
+ }
31
+ /**
32
+ * Type guard for HTTP request parameters that must be a single string.
33
+ *
34
+ * Express's req.query and req.body parsers return `string | string[] | ParsedQs`
35
+ * for any field, but route handlers commonly cast to `string` and operate on
36
+ * `.length`. When the caller passes the same key twice (?x=a&x=b) the value
37
+ * arrives as an array, and a `.length` check intended for the string ends up
38
+ * counting array elements — bypassing length-based guards (CodeQL
39
+ * js/type-confusion-through-parameter-tampering, alert at api.ts:1118).
40
+ *
41
+ * @throws BadRequestError when value is not a string (array, object, undefined, etc.)
42
+ */
43
+ export declare function assertString(value: unknown, fieldName: string): string;
44
+ /**
45
+ * Resolve a user-supplied relative path against an allowed root and verify it
46
+ * stays inside that root. Mirrors the existing guard at api.ts:1067-1077.
47
+ *
48
+ * Returns the absolute resolved path. Rejects empty paths, null bytes, and
49
+ * paths that resolve outside the root (e.g., `../../../etc/passwd`).
50
+ *
51
+ * @throws BadRequestError when the path is empty or contains a null byte
52
+ * @throws ForbiddenError when the resolved path escapes the root
53
+ */
54
+ export declare function assertSafePath(rawPath: string, root: string): string;
55
+ /**
56
+ * Escape regex metacharacters in a user-supplied string so it can be safely
57
+ * embedded as a literal in `new RegExp(...)`. Used by /api/grep's literal mode
58
+ * and any future endpoint that constructs a regex from caller input.
59
+ */
60
+ export declare function escapeRegExp(input: string): string;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Server-side input validation helpers.
3
+ *
4
+ * Convention: helpers throw BadRequestError (or its 403 subclass ForbiddenError)
5
+ * when user input fails validation. Existing route handlers wrap their bodies in
6
+ * try/catch and translate the error to res.status(err.status).json({error: err.message}).
7
+ * This pattern was chosen over an asyncHandler middleware to stay compatible with
8
+ * Express 4's non-propagation of async-thrown errors and to match the existing
9
+ * try/catch shape used throughout api.ts.
10
+ *
11
+ * Scope (this PR — U1 of the security remediation plan):
12
+ * - assertString: closes js/type-confusion-through-parameter-tampering (api.ts:1118)
13
+ * - assertSafePath: consolidates the path-traversal guard from api.ts:1067-1077
14
+ * for reuse across other path-injection findings (U2/U3)
15
+ * - escapeRegExp: utility for upcoming regex-injection fix at /api/grep (U5)
16
+ *
17
+ * Helpers added in later units (U3 git-clone hardening, U4 rate-limiting) live
18
+ * in this module too but are introduced with the dependency they require.
19
+ */
20
+ import path from 'node:path';
21
+ /**
22
+ * Thrown by validation helpers when user input is rejected.
23
+ * Routes catch via existing try/catch and convert with err.status / err.message.
24
+ */
25
+ export class BadRequestError extends Error {
26
+ status;
27
+ constructor(message, status = 400) {
28
+ super(message);
29
+ this.name = 'BadRequestError';
30
+ this.status = status;
31
+ }
32
+ }
33
+ export class ForbiddenError extends BadRequestError {
34
+ constructor(message) {
35
+ super(message, 403);
36
+ this.name = 'ForbiddenError';
37
+ }
38
+ }
39
+ /**
40
+ * Type guard for HTTP request parameters that must be a single string.
41
+ *
42
+ * Express's req.query and req.body parsers return `string | string[] | ParsedQs`
43
+ * for any field, but route handlers commonly cast to `string` and operate on
44
+ * `.length`. When the caller passes the same key twice (?x=a&x=b) the value
45
+ * arrives as an array, and a `.length` check intended for the string ends up
46
+ * counting array elements — bypassing length-based guards (CodeQL
47
+ * js/type-confusion-through-parameter-tampering, alert at api.ts:1118).
48
+ *
49
+ * @throws BadRequestError when value is not a string (array, object, undefined, etc.)
50
+ */
51
+ export function assertString(value, fieldName) {
52
+ if (typeof value !== 'string') {
53
+ if (Array.isArray(value)) {
54
+ throw new BadRequestError(`Parameter "${fieldName}" must be a single string, got an array`);
55
+ }
56
+ throw new BadRequestError(`Parameter "${fieldName}" must be a string`);
57
+ }
58
+ return value;
59
+ }
60
+ /**
61
+ * Resolve a user-supplied relative path against an allowed root and verify it
62
+ * stays inside that root. Mirrors the existing guard at api.ts:1067-1077.
63
+ *
64
+ * Returns the absolute resolved path. Rejects empty paths, null bytes, and
65
+ * paths that resolve outside the root (e.g., `../../../etc/passwd`).
66
+ *
67
+ * @throws BadRequestError when the path is empty or contains a null byte
68
+ * @throws ForbiddenError when the resolved path escapes the root
69
+ */
70
+ export function assertSafePath(rawPath, root) {
71
+ if (rawPath.length === 0) {
72
+ throw new BadRequestError('Path must not be empty');
73
+ }
74
+ if (rawPath.includes('\0')) {
75
+ throw new BadRequestError('Path must not contain null bytes');
76
+ }
77
+ const resolvedRoot = path.resolve(root);
78
+ const fullPath = path.resolve(resolvedRoot, rawPath);
79
+ if (fullPath !== resolvedRoot && !fullPath.startsWith(resolvedRoot + path.sep)) {
80
+ throw new ForbiddenError('Path traversal denied');
81
+ }
82
+ return fullPath;
83
+ }
84
+ /**
85
+ * Escape regex metacharacters in a user-supplied string so it can be safely
86
+ * embedded as a literal in `new RegExp(...)`. Used by /api/grep's literal mode
87
+ * and any future endpoint that constructs a regex from caller input.
88
+ */
89
+ export function escapeRegExp(input) {
90
+ return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.4-rc.54",
3
+ "version": "1.6.4-rc.56",
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",