diff --git a/.github/scripts/check-tree-sitter-upgrade-readiness.py b/.github/scripts/check-tree-sitter-upgrade-readiness.py index f54afd7f08..5b0fad09e5 100644 --- a/.github/scripts/check-tree-sitter-upgrade-readiness.py +++ b/.github/scripts/check-tree-sitter-upgrade-readiness.py @@ -32,6 +32,7 @@ import pathlib import re import sys import urllib.error +import urllib.parse import urllib.request REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] @@ -190,7 +191,17 @@ def fetch_text(url: str, timeout: int = 8) -> str | None: set (raises the rate limit from 60 to 5 000 requests/hour). """ headers: dict[str, str] = {} - if _GITHUB_TOKEN and ("github.com" in url or "githubusercontent.com" in url): + # Parse the URL and check the hostname rather than substring-matching + # on the full URL string (CodeQL py/incomplete-url-substring-sanitization). + # `https://evil.com/?u=github.com` would have passed the substring check. + try: + parsed_host = urllib.parse.urlparse(url).hostname or "" + except ValueError: + parsed_host = "" + is_github_host = parsed_host == "github.com" or parsed_host.endswith( + (".github.com", ".githubusercontent.com") + ) or parsed_host == "githubusercontent.com" + if _GITHUB_TOKEN and is_github_host: headers["Authorization"] = f"Bearer {_GITHUB_TOKEN}" try: req = urllib.request.Request(url, headers=headers) diff --git a/gitnexus-web/src/core/llm/agent.ts b/gitnexus-web/src/core/llm/agent.ts index c8cfa8d7cc..49862a9e6a 100644 --- a/gitnexus-web/src/core/llm/agent.ts +++ b/gitnexus-web/src/core/llm/agent.ts @@ -277,8 +277,10 @@ const extractInstanceName = (endpoint: string): string => { try { const url = new URL(endpoint); const hostname = url.hostname; - // Extract the first part before .openai.azure.com - const match = hostname.match(/^([^.]+)\.openai\.azure\.com/); + // Extract the first part before .openai.azure.com. The trailing `$` + // anchor is required (CodeQL js/regex/missing-regexp-anchor): without + // it `evil.openai.azure.com.attacker.tld` would match. + const match = hostname.match(/^([^.]+)\.openai\.azure\.com$/); if (match) { return match[1]; } diff --git a/gitnexus-web/src/core/llm/tools.ts b/gitnexus-web/src/core/llm/tools.ts index cd0b5a8008..5a595049e0 100644 --- a/gitnexus-web/src/core/llm/tools.ts +++ b/gitnexus-web/src/core/llm/tools.ts @@ -278,8 +278,11 @@ export const createGraphRAGTools = (backend: GraphRAGBackend) => { const val = row[col]; if (val === null || val === undefined) return ''; if (typeof val === 'object') return JSON.stringify(val); - // Truncate long values and escape pipe characters - const str = String(val).replace(/\|/g, '\\|'); + // Truncate long values and escape pipe characters. Escape + // backslashes FIRST so the subsequent pipe escape isn't + // unescaped by a trailing backslash (CodeQL + // js/incomplete-sanitization). + const str = String(val).replace(/\\/g, '\\\\').replace(/\|/g, '\\|'); return str.length > 60 ? str.slice(0, 57) + '...' : str; }); return `| ${values.join(' | ')} |`; diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index b301d7f38f..9f9ff70e2a 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -347,7 +347,12 @@ async function installClaudeCodeHooks(result: SetupResult): Promise { } const hookPath = path.join(destHooksDir, 'gitnexus-hook.cjs').replace(/\\/g, '/'); - const hookCmd = `node "${hookPath.replace(/"/g, '\\"')}"`; + // Escape backslashes FIRST, then quotes (CodeQL js/incomplete-sanitization). + // The previous shape `replace(/"/g, '\\"')` alone would let `path\with"quote` + // become `path\with\"quote`, where the trailing `\` before `"` could + // unescape the quote inside the surrounding double-quoted shell context. + const escapedHookPath = hookPath.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); + const hookCmd = `node "${escapedHookPath}"`; // Check which hook events need entries (idempotent: skip if already registered) const parsed = await (async () => { diff --git a/gitnexus/src/cli/wiki.ts b/gitnexus/src/cli/wiki.ts index ccd1cae4ec..0555109444 100644 --- a/gitnexus/src/cli/wiki.ts +++ b/gitnexus/src/cli/wiki.ts @@ -610,10 +610,22 @@ function publishGist(htmlPath: string): { url: string; rawUrl: string } | null { ).trim(); // gh gist create prints the gist URL as the last line + // Find the line that's a parseable URL whose hostname is gist.github.com + // — avoids the substring-bypass class CodeQL js/incomplete-url-substring- + // sanitization flags (e.g. `https://evil.com/?u=gist.github.com` would + // pass `.includes('gist.github.com')` but isn't actually a Gist URL). const lines = output.split('\n'); - const gistUrl = lines.find((l) => l.includes('gist.github.com')) || lines[lines.length - 1]; + const isGistUrl = (line: string): boolean => { + const trimmed = line.trim(); + try { + return new URL(trimmed).hostname === 'gist.github.com'; + } catch { + return false; + } + }; + const gistUrl = lines.find(isGistUrl) || lines[lines.length - 1]; - if (!gistUrl || !gistUrl.includes('gist.github.com')) return null; + if (!gistUrl || !isGistUrl(gistUrl)) return null; // Build a raw viewer URL via gist.githack.com // gist URL format: https://gist.github.com/{user}/{id} diff --git a/gitnexus/src/core/ingestion/vue-sfc-extractor.ts b/gitnexus/src/core/ingestion/vue-sfc-extractor.ts index 382417c566..544060697e 100644 --- a/gitnexus/src/core/ingestion/vue-sfc-extractor.ts +++ b/gitnexus/src/core/ingestion/vue-sfc-extractor.ts @@ -23,7 +23,12 @@ interface ScriptBlock { lang: string; } -const SCRIPT_RE = /]*)?>([^]*?)<\/script>/g; +// Allow whitespace before the closing `>` in `` (CodeQL +// js/bad-tag-filter). Browsers and Vue's SFC parser accept ``, +// so a strict `<\/script>` match would miss valid SFC content and could +// be exploited by a crafted input that hides a script close from this +// extractor while remaining valid to the runtime parser. +const SCRIPT_RE = /]*)?>([^]*?)<\/script\s*>/g; const TEMPLATE_COMPONENT_RE = /<([A-Z][A-Za-z0-9]+)/g; // Greedy: matches from the first . // This is intentional — nested