|
1 | | -import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; |
| 1 | +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; |
2 | 2 | import { execSync } from 'child_process'; |
3 | 3 | import fs from 'fs'; |
| 4 | +import fsp from 'fs/promises'; |
4 | 5 | import path from 'path'; |
5 | 6 | import os from 'os'; |
6 | 7 | import GitDiffHelper from '../src/utils/gitDiffHelper.js'; |
@@ -1134,4 +1135,143 @@ describe('GitDiffHelper integration tests', () => { |
1134 | 1135 | ).rejects.toThrow(); |
1135 | 1136 | }); |
1136 | 1137 | }); |
| 1138 | + |
| 1139 | + // ───────────────────────────────────────────────────── |
| 1140 | + // SECURITY: user-supplied refs and symlinks |
| 1141 | + // ───────────────────────────────────────────────────── |
| 1142 | + describe('security', () => { |
| 1143 | + let outsideDir; |
| 1144 | + let startBranch; |
| 1145 | + |
| 1146 | + beforeEach(() => { |
| 1147 | + startBranch = git('rev-parse --abbrev-ref HEAD'); |
| 1148 | + git('checkout -- .'); |
| 1149 | + git('reset HEAD -- .'); |
| 1150 | + git('clean -fd'); |
| 1151 | + outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codeant-outside-')); |
| 1152 | + fs.writeFileSync(path.join(outsideDir, 'creds.txt'), 'OUTSIDE_SECRET\n'); |
| 1153 | + fs.mkdirSync(path.join(outsideDir, 'dir')); |
| 1154 | + fs.writeFileSync(path.join(outsideDir, 'dir', 'inner.txt'), 'OUTSIDE_DIR_SECRET\n'); |
| 1155 | + }); |
| 1156 | + |
| 1157 | + afterEach(() => { |
| 1158 | + git('reset HEAD -- .'); |
| 1159 | + git('checkout -- .'); |
| 1160 | + git('clean -fd'); |
| 1161 | + if (git('rev-parse --abbrev-ref HEAD') !== startBranch) { |
| 1162 | + git(`checkout ${startBranch}`); |
| 1163 | + } |
| 1164 | + fs.rmSync(outsideDir, { recursive: true, force: true }); |
| 1165 | + }); |
| 1166 | + |
| 1167 | + const leaked = diffs => diffs.filter(d => /OUTSIDE_(DIR_)?SECRET/.test(d.head_file_str + d.patch_str)); |
| 1168 | + |
| 1169 | + it('does not run shell metacharacters in a base branch or commit', async () => { |
| 1170 | + const marker = path.join(outsideDir, 'pwned'); |
| 1171 | + const helper = await createHelper(); |
| 1172 | + |
| 1173 | + await expect( |
| 1174 | + helper.getDiffBasedOnReviewConfig({ type: 'base-branch', baseBranch: `HEAD; touch ${marker}` }) |
| 1175 | + ).rejects.toThrow('Could not resolve branch'); |
| 1176 | + await expect( |
| 1177 | + helper.getDiffBasedOnReviewConfig({ type: 'base-commit', baseCommit: `HEAD$(touch ${marker})` }) |
| 1178 | + ).rejects.toThrow('Could not resolve commit'); |
| 1179 | + |
| 1180 | + expect(fs.existsSync(marker)).toBe(false); |
| 1181 | + }); |
| 1182 | + |
| 1183 | + it('rejects option-like refs instead of passing them to git', async () => { |
| 1184 | + const written = path.join(outsideDir, 'written.txt'); |
| 1185 | + const helper = await createHelper(); |
| 1186 | + |
| 1187 | + await expect( |
| 1188 | + helper.getDiffBasedOnReviewConfig({ type: 'base-commit', baseCommit: `--output=${written}` }) |
| 1189 | + ).rejects.toThrow('Could not resolve commit'); |
| 1190 | + |
| 1191 | + expect(fs.existsSync(written)).toBe(false); |
| 1192 | + }); |
| 1193 | + |
| 1194 | + it('does not read untracked symlinks that point outside the repo', async () => { |
| 1195 | + fs.symlinkSync(path.join(outsideDir, 'creds.txt'), path.join(testDir, 'src/link-file')); |
| 1196 | + fs.symlinkSync(path.join(outsideDir, 'dir'), path.join(testDir, 'src/link-dir')); |
| 1197 | + |
| 1198 | + const helper = await createHelper(); |
| 1199 | + for (const config of [ |
| 1200 | + { type: 'base-commit', baseCommit: 'HEAD' }, |
| 1201 | + { type: 'uncommitted' }, |
| 1202 | + { type: 'all' }, |
| 1203 | + ]) { |
| 1204 | + const diffs = await helper.getDiffBasedOnReviewConfig(config); |
| 1205 | + expect(leaked(diffs)).toEqual([]); |
| 1206 | + } |
| 1207 | + }); |
| 1208 | + |
| 1209 | + it('shows untracked symlinks that point outside the repo as links, not their contents', async () => { |
| 1210 | + const target = path.join(outsideDir, 'creds.txt'); |
| 1211 | + fs.symlinkSync(target, path.join(testDir, 'src/link-file')); |
| 1212 | + |
| 1213 | + const helper = await createHelper(); |
| 1214 | + const diffs = await helper.getDiffBasedOnReviewConfig({ type: 'base-commit', baseCommit: 'HEAD' }); |
| 1215 | + |
| 1216 | + const diff = diffs.find(d => d.filename_str === 'src/link-file'); |
| 1217 | + expect(diff).toBeDefined(); |
| 1218 | + expect(diff.edit_type_str).toBe('ADDED'); |
| 1219 | + expect(diff.head_file_str).toBe(target); |
| 1220 | + expect(diff.patch_str).toContain('new file mode 120000'); |
| 1221 | + expect(leaked(diffs)).toEqual([]); |
| 1222 | + }); |
| 1223 | + |
| 1224 | + it('does not follow a symlink swapped in after the path check', async () => { |
| 1225 | + writeFile('src/race/data.txt', 'inside\n'); |
| 1226 | + fs.writeFileSync(path.join(outsideDir, 'data.txt'), 'OUTSIDE_SECRET\n'); |
| 1227 | + const raceDir = path.join(testDir, 'src/race'); |
| 1228 | + const realOpen = fsp.open; |
| 1229 | + let swapped = false; |
| 1230 | + // Swap the parent directory for a link to outside the repo between the check and the open |
| 1231 | + const spy = vi.spyOn(fsp, 'open').mockImplementation(async (file, ...rest) => { |
| 1232 | + if (!swapped && String(file).endsWith(path.join('race', 'data.txt'))) { |
| 1233 | + swapped = true; |
| 1234 | + fs.renameSync(raceDir, raceDir + '-orig'); |
| 1235 | + fs.symlinkSync(outsideDir, raceDir); |
| 1236 | + } |
| 1237 | + return realOpen(file, ...rest); |
| 1238 | + }); |
| 1239 | + |
| 1240 | + try { |
| 1241 | + const helper = await createHelper(); |
| 1242 | + const diffs = await helper.getDiffBasedOnReviewConfig({ type: 'uncommitted' }); |
| 1243 | + expect(swapped).toBe(true); |
| 1244 | + expect(leaked(diffs)).toEqual([]); |
| 1245 | + } finally { |
| 1246 | + spy.mockRestore(); |
| 1247 | + fs.rmSync(raceDir, { recursive: true, force: true }); |
| 1248 | + fs.rmSync(raceDir + '-orig', { recursive: true, force: true }); |
| 1249 | + } |
| 1250 | + }); |
| 1251 | + |
| 1252 | + it('does not read committed symlinks that point outside the repo', async () => { |
| 1253 | + const baseHash = git('rev-parse HEAD'); |
| 1254 | + fs.symlinkSync(path.join(outsideDir, 'creds.txt'), path.join(testDir, 'src/committed-link')); |
| 1255 | + git('checkout -b security-committed-link'); |
| 1256 | + git('add src/committed-link'); |
| 1257 | + git('commit -m "committed link"'); |
| 1258 | + |
| 1259 | + const helper = await createHelper(); |
| 1260 | + const diffs = await helper.getDiffBasedOnReviewConfig({ type: 'base-commit', baseCommit: baseHash }); |
| 1261 | + |
| 1262 | + expect(diffs.map(d => d.filename_str)).toContain('src/committed-link'); |
| 1263 | + expect(leaked(diffs)).toEqual([]); |
| 1264 | + }); |
| 1265 | + |
| 1266 | + it('still reads symlinks that stay inside the repo', async () => { |
| 1267 | + fs.symlinkSync('index.js', path.join(testDir, 'src/inside-link.js')); |
| 1268 | + |
| 1269 | + const helper = await createHelper(); |
| 1270 | + const diffs = await helper.getDiffBasedOnReviewConfig({ type: 'base-commit', baseCommit: 'HEAD' }); |
| 1271 | + |
| 1272 | + const diff = diffs.find(d => d.filename_str === 'src/inside-link.js'); |
| 1273 | + expect(diff).toBeDefined(); |
| 1274 | + expect(diff.head_file_str).toBe(readFile('src/index.js')); |
| 1275 | + }); |
| 1276 | + }); |
1137 | 1277 | }); |
0 commit comments