Skip to content

Commit c5b2785

Browse files
pranavcodeantclaude
andcommitted
Block ref injection and out-of-repo symlink reads in --base (0.5.13)
Follow-up to #50, addressing its two CodeAnt review comments, which were resolved on the PR but whose fixes were not pushed before merge. - --base / --base-commit refs (also reachable through the MCP codeant_review_local tool) were interpolated into a shell command, so "main; <cmd>" ran <cmd>, and "--output=<file>" made git write a file. Refs are now resolved with execFile + rev-parse --verify <ref>^{commit}, leading "-" is rejected, and later git commands only see the SHA. - Working-tree reads followed symlinks, uploading files from outside the repo: untracked links, files under untracked symlinked directories, and (since #50) committed links in --base mode. _readRepoFile now skips anything whose real path is outside the repo or isn't a regular file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 8d1eb94 commit c5b2785

5 files changed

Lines changed: 142 additions & 15 deletions

File tree

‎changelog.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## [0.5.13] - 13/10/2026
4+
- Security: `--base` / `--base-commit` values (including from the MCP `codeant_review_local` tool) are resolved without a shell, and option-like values are rejected, so they can no longer run commands or pass options to git
5+
- Security: symlinks that point outside the repository, and files reached through them, are no longer read into review or secrets payloads. In 0.5.12, `--base` / `--base-commit` sent a committed symlink's target contents
6+
37
## [0.5.12] - 13/10/2026
48
- `review` / `secrets` with `--base` or `--base-commit` now include staged, unstaged, and untracked files (respecting `.gitignore`), not just commits. Previously `review --all --base main` silently skipped new files
59
- With `--base` / `--base-commit`, file content is read from the working tree so it matches the reviewed diff

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codeant-cli",
3-
"version": "0.5.12",
3+
"version": "0.5.13",
44
"description": "Code review CLI tool",
55
"type": "module",
66
"repository": {

‎src/utils/gitDiffHelper.js‎

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import fs from 'fs/promises';
22
import path from 'path';
33
import { promisify } from 'util';
4-
import { exec } from 'child_process';
4+
import { exec, execFile } from 'child_process';
55

66
const execAsync = promisify(exec);
7+
const execFileAsync = promisify(execFile);
78

89
/**
910
* Expand untracked paths from `git status --porcelain`.
@@ -468,14 +469,27 @@ class GitDiffHelper {
468469
return diffs;
469470
}
470471

472+
/**
473+
* Read a working-tree file. Returns null if it resolves outside the repository (a symlink
474+
* pointing elsewhere) or isn't a regular file, so those contents are never uploaded.
475+
*/
476+
async _readRepoFile(relPath) {
477+
const realRoot = await fs.realpath(this.gitRoot);
478+
const realPath = await fs.realpath(path.resolve(this.gitRoot, relPath));
479+
const rel = path.relative(realRoot, realPath);
480+
if (!rel || rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel)) return null;
481+
if (!(await fs.stat(realPath)).isFile()) return null;
482+
return fs.readFile(realPath, 'utf8');
483+
}
484+
471485
/**
472486
* Build a synthetic "new file" diff for an untracked file, which git diff can't see.
473487
* Returns null if the file can't be read.
474488
*/
475489
async _buildUntrackedFileDiff(file) {
476490
try {
477-
const absolutePath = path.resolve(this.gitRoot, file);
478-
const content = await fs.readFile(absolutePath, 'utf8');
491+
const content = await this._readRepoFile(file);
492+
if (content === null) return null;
479493
const lines = content.split('\n');
480494

481495
let patchStr = `diff --git a/${file} b/${file}\n`;
@@ -549,8 +563,7 @@ class GitDiffHelper {
549563
let resolvedRef;
550564
for (const candidate of [branch, `origin/${branch}`]) {
551565
try {
552-
const { stdout } = await execAsync(`git rev-parse ${candidate}`, { cwd: this.gitRoot });
553-
resolvedRef = stdout.trim();
566+
resolvedRef = await this._resolveCommitSha(candidate);
554567
break;
555568
} catch (err) {
556569
// try next
@@ -589,15 +602,29 @@ class GitDiffHelper {
589602
// Resolve the commit ref (supports HEAD~3, sha, tag, etc.)
590603
let resolvedCommit;
591604
try {
592-
const { stdout } = await execAsync(`git rev-parse ${commit}`, { cwd: this.gitRoot });
593-
resolvedCommit = stdout.trim();
605+
resolvedCommit = await this._resolveCommitSha(commit);
594606
} catch (err) {
595607
throw new Error(`Could not resolve commit: ${commit}`);
596608
}
597609

598610
return this._getWorkingTreeDiffAgainst(resolvedCommit);
599611
}
600612

613+
/**
614+
* Resolve a user-supplied ref (from the CLI or MCP) to a commit SHA without going through a
615+
* shell, so later git commands only ever see the SHA. Throws if it doesn't name a commit.
616+
*/
617+
async _resolveCommitSha(ref) {
618+
// A leading "-" would be parsed as a git option (e.g. --output=<file>)
619+
if (ref.startsWith('-')) throw new Error(`Invalid ref: ${ref}`);
620+
const { stdout } = await execFileAsync(
621+
'git',
622+
['rev-parse', '--verify', '--quiet', `${ref}^{commit}`],
623+
{ cwd: this.gitRoot }
624+
);
625+
return stdout.trim();
626+
}
627+
601628
/**
602629
* Diff a base commit against the working tree: commits since base, staged and unstaged
603630
* changes (including staged new files), plus untracked files not ignored by .gitignore.
@@ -1011,8 +1038,8 @@ class GitDiffHelper {
10111038
} catch (err) {
10121039
// Handle untracked files
10131040
try {
1014-
const absoluteFilePath = path.resolve(this.gitRoot, filePath);
1015-
const headFileStr = await fs.readFile(absoluteFilePath, 'utf8');
1041+
const headFileStr = await this._readRepoFile(filePath);
1042+
if (headFileStr === null) throw new Error('Not a regular file inside the repository');
10161043
const lines = headFileStr.split('\n');
10171044

10181045
let patchStr = `diff --git a/${filePath} b/${filePath}\n`;
@@ -1069,9 +1096,9 @@ class GitDiffHelper {
10691096
{ cwd: this.gitRoot }
10701097
).then(r => r.stdout).catch(() => '');
10711098
} else {
1072-
headFileStr = await fs.readFile(
1073-
path.join(this.gitRoot, filePath), 'utf8'
1074-
).catch(() => '');
1099+
headFileStr = await this._readRepoFile(filePath)
1100+
.then(content => content ?? '')
1101+
.catch(() => '');
10751102
}
10761103

10771104
// Get patch

‎tests/gitDiffHelper.test.js‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1134,4 +1134,100 @@ describe('GitDiffHelper integration tests', () => {
11341134
).rejects.toThrow();
11351135
});
11361136
});
1137+
1138+
// ─────────────────────────────────────────────────────
1139+
// SECURITY: user-supplied refs and symlinks
1140+
// ─────────────────────────────────────────────────────
1141+
describe('security', () => {
1142+
let outsideDir;
1143+
let startBranch;
1144+
1145+
beforeEach(() => {
1146+
startBranch = git('rev-parse --abbrev-ref HEAD');
1147+
git('checkout -- .');
1148+
git('reset HEAD -- .');
1149+
git('clean -fd');
1150+
outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codeant-outside-'));
1151+
fs.writeFileSync(path.join(outsideDir, 'creds.txt'), 'OUTSIDE_SECRET\n');
1152+
fs.mkdirSync(path.join(outsideDir, 'dir'));
1153+
fs.writeFileSync(path.join(outsideDir, 'dir', 'inner.txt'), 'OUTSIDE_DIR_SECRET\n');
1154+
});
1155+
1156+
afterEach(() => {
1157+
git('reset HEAD -- .');
1158+
git('checkout -- .');
1159+
git('clean -fd');
1160+
if (git('rev-parse --abbrev-ref HEAD') !== startBranch) {
1161+
git(`checkout ${startBranch}`);
1162+
}
1163+
fs.rmSync(outsideDir, { recursive: true, force: true });
1164+
});
1165+
1166+
const leaked = diffs => diffs.filter(d => /OUTSIDE_(DIR_)?SECRET/.test(d.head_file_str + d.patch_str));
1167+
1168+
it('does not run shell metacharacters in a base branch or commit', async () => {
1169+
const marker = path.join(outsideDir, 'pwned');
1170+
const helper = await createHelper();
1171+
1172+
await expect(
1173+
helper.getDiffBasedOnReviewConfig({ type: 'base-branch', baseBranch: `HEAD; touch ${marker}` })
1174+
).rejects.toThrow('Could not resolve branch');
1175+
await expect(
1176+
helper.getDiffBasedOnReviewConfig({ type: 'base-commit', baseCommit: `HEAD$(touch ${marker})` })
1177+
).rejects.toThrow('Could not resolve commit');
1178+
1179+
expect(fs.existsSync(marker)).toBe(false);
1180+
});
1181+
1182+
it('rejects option-like refs instead of passing them to git', async () => {
1183+
const written = path.join(outsideDir, 'written.txt');
1184+
const helper = await createHelper();
1185+
1186+
await expect(
1187+
helper.getDiffBasedOnReviewConfig({ type: 'base-commit', baseCommit: `--output=${written}` })
1188+
).rejects.toThrow('Could not resolve commit');
1189+
1190+
expect(fs.existsSync(written)).toBe(false);
1191+
});
1192+
1193+
it('does not read untracked symlinks that point outside the repo', async () => {
1194+
fs.symlinkSync(path.join(outsideDir, 'creds.txt'), path.join(testDir, 'src/link-file'));
1195+
fs.symlinkSync(path.join(outsideDir, 'dir'), path.join(testDir, 'src/link-dir'));
1196+
1197+
const helper = await createHelper();
1198+
for (const config of [
1199+
{ type: 'base-commit', baseCommit: 'HEAD' },
1200+
{ type: 'uncommitted' },
1201+
{ type: 'all' },
1202+
]) {
1203+
const diffs = await helper.getDiffBasedOnReviewConfig(config);
1204+
expect(leaked(diffs)).toEqual([]);
1205+
}
1206+
});
1207+
1208+
it('does not read committed symlinks that point outside the repo', async () => {
1209+
const baseHash = git('rev-parse HEAD');
1210+
fs.symlinkSync(path.join(outsideDir, 'creds.txt'), path.join(testDir, 'src/committed-link'));
1211+
git('checkout -b security-committed-link');
1212+
git('add src/committed-link');
1213+
git('commit -m "committed link"');
1214+
1215+
const helper = await createHelper();
1216+
const diffs = await helper.getDiffBasedOnReviewConfig({ type: 'base-commit', baseCommit: baseHash });
1217+
1218+
expect(diffs.map(d => d.filename_str)).toContain('src/committed-link');
1219+
expect(leaked(diffs)).toEqual([]);
1220+
});
1221+
1222+
it('still reads symlinks that stay inside the repo', async () => {
1223+
fs.symlinkSync('index.js', path.join(testDir, 'src/inside-link.js'));
1224+
1225+
const helper = await createHelper();
1226+
const diffs = await helper.getDiffBasedOnReviewConfig({ type: 'base-commit', baseCommit: 'HEAD' });
1227+
1228+
const diff = diffs.find(d => d.filename_str === 'src/inside-link.js');
1229+
expect(diff).toBeDefined();
1230+
expect(diff.head_file_str).toBe(readFile('src/index.js'));
1231+
});
1232+
});
11371233
});

0 commit comments

Comments
 (0)