fix: ensure exec usage does not allow poisoning

Previous usage was vulnerable to "poisoned" tags
which could enduce commands to be run when a
`detectChanges` command was hit. This was primarily
fixed in `local-backend.ts` however I changes the
`execSync` usages where any injection was potentially
able to be performed (e.g. staleness).

Skipped touching wiki and generator as those use static
input, though these should potentially be changed over
in the future.
This commit is contained in:
Tim Strazzere 2026-02-24 13:42:29 -08:00
parent dc66bb83ef
commit bb7ade91ab
4 changed files with 31 additions and 24 deletions

View file

@ -7,7 +7,7 @@
import path from 'path';
import readline from 'readline';
import { execSync } from 'child_process';
import { execSync, execFileSync } from 'child_process';
import cliProgress from 'cli-progress';
import { getGitRoot, isGitRepo } from '../storage/git.js';
import { getStoragePaths, loadMeta, loadCLIConfig, saveCLIConfig } from '../storage/repo-manager.js';
@ -343,10 +343,11 @@ function hasGhCLI(): boolean {
function publishGist(htmlPath: string): { url: string; rawUrl: string } | null {
try {
const output = execSync(
`gh gist create "${htmlPath}" --desc "Repository Wiki — generated by GitNexus" --public`,
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
).trim();
const output = execFileSync('gh', [
'gist', 'create', htmlPath,
'--desc', 'Repository Wiki — generated by GitNexus',
'--public',
], { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
// gh gist create prints the gist URL as the last line
const lines = output.split('\n');

View file

@ -12,7 +12,7 @@
import fs from 'fs/promises';
import path from 'path';
import { execSync } from 'child_process';
import { execSync, execFileSync } from 'child_process';
import {
initWikiDb,
@ -712,8 +712,8 @@ export class WikiGenerator {
private getChangedFiles(fromCommit: string, toCommit: string): string[] {
try {
const output = execSync(
`git diff ${fromCommit}..${toCommit} --name-only`,
const output = execFileSync(
'git', ['diff', `${fromCommit}..${toCommit}`, '--name-only'],
{ cwd: this.repoPath },
).toString().trim();
return output ? output.split('\n').filter(Boolean) : [];

View file

@ -988,30 +988,30 @@ export class LocalBackend {
await this.ensureInitialized(repo.id);
const scope = params.scope || 'unstaged';
const { execSync } = await import('child_process');
// Build git diff command based on scope
let diffCmd: string;
const { execFileSync } = await import('child_process');
// Build git diff args based on scope (using execFileSync to avoid shell injection)
let diffArgs: string[];
switch (scope) {
case 'staged':
diffCmd = 'git diff --staged --name-only';
diffArgs = ['diff', '--staged', '--name-only'];
break;
case 'all':
diffCmd = 'git diff HEAD --name-only';
diffArgs = ['diff', 'HEAD', '--name-only'];
break;
case 'compare':
if (!params.base_ref) return { error: 'base_ref is required for "compare" scope' };
diffCmd = `git diff ${params.base_ref} --name-only`;
diffArgs = ['diff', params.base_ref, '--name-only'];
break;
case 'unstaged':
default:
diffCmd = 'git diff --name-only';
diffArgs = ['diff', '--name-only'];
break;
}
let changedFiles: string[];
try {
const output = execSync(diffCmd, { cwd: repo.repoPath, encoding: 'utf-8' });
const output = execFileSync('git', diffArgs, { cwd: repo.repoPath, encoding: 'utf-8' });
changedFiles = output.trim().split('\n').filter(f => f.length > 0);
} catch (err: any) {
return { error: `Git diff failed: ${err.message}` };
@ -1185,9 +1185,15 @@ export class LocalBackend {
// Simple text search across the repo for the old name (in files not already covered by graph)
try {
const { execSync } = await import('child_process');
const rgCmd = `rg -l --type-add "code:*.{ts,tsx,js,jsx,py,go,rs,java}" -t code "\\b${oldName}\\b" .`;
const output = execSync(rgCmd, { cwd: repo.repoPath, encoding: 'utf-8', timeout: 5000 });
const { execFileSync } = await import('child_process');
const rgArgs = [
'-l',
'--type-add', 'code:*.{ts,tsx,js,jsx,py,go,rs,java}',
'-t', 'code',
`\\b${oldName}\\b`,
'.',
];
const output = execFileSync('rg', rgArgs, { cwd: repo.repoPath, encoding: 'utf-8', timeout: 5000 });
const files = output.trim().split('\n').filter(f => f.length > 0);
for (const file of files) {

View file

@ -5,7 +5,7 @@
* Returns a hint for the LLM to call analyze if stale.
*/
import { execSync } from 'child_process';
import { execFileSync } from 'child_process';
import path from 'path';
export interface StalenessInfo {
@ -20,8 +20,8 @@ export interface StalenessInfo {
export function checkStaleness(repoPath: string, lastCommit: string): StalenessInfo {
try {
// Get count of commits between lastCommit and HEAD
const result = execSync(
`git rev-list --count ${lastCommit}..HEAD`,
const result = execFileSync(
'git', ['rev-list', '--count', `${lastCommit}..HEAD`],
{ cwd: repoPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
).trim();