Skip to content

Commit 08d8e53

Browse files
committed
fix: #30 call python3 with execFileSync to block shell injection in path
Replace the /bin/bash -c execSync invocation in src/aibolit.ts with execFileSync('python3', [...args, path]) so the path argument is passed as a single argv element and shell metacharacters in user-supplied paths can no longer execute. The catch branch keeps the previous tolerance of a non-zero exit by parsing whatever stdout the failing call already produced. Tests mock execFileSync alongside execSync, add a regression case asserting the argv shape, and cover both arms of the catch fallback.
1 parent 6a356aa commit 08d8e53

2 files changed

Lines changed: 61 additions & 12 deletions

File tree

src/aibolit.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// SPDX-FileCopyrightText: Copyright (c) 2025 Yegor Bugayenko
22
// SPDX-License-Identifier: MIT
33

4-
import { execSync } from 'child_process';
4+
import { execFileSync, execSync } from 'child_process';
55
import fs from 'fs';
66
import semver from 'semver';
77
import { to_gpt } from './to_gpt';
@@ -32,11 +32,16 @@ export const aibolit = async function(path: string): Promise<string> {
3232
if (!fs.existsSync(path)) {
3333
return `File does not exist: ${path}`;
3434
}
35-
const warns = execSync(
36-
`
37-
/bin/bash -c "set -o pipefail; (python3 -m aibolit check --full --filenames ${path} || true)"
38-
`
39-
).toString();
35+
let warns: string;
36+
try {
37+
warns = execFileSync(
38+
'python3',
39+
['-m', 'aibolit', 'check', '--full', '--filenames', path]
40+
).toString();
41+
} catch (e) {
42+
const err = e as { stdout?: Buffer | string };
43+
warns = err.stdout == null ? '' : err.stdout.toString();
44+
}
4045
const lines = warns.trim().split('\n').filter(line => line.trim());
4146
const parsed = lines.map(line => {
4247
const match = line.match(/^.+?\[(\d+)\]:\s(.+?)\s\(P\d+:\s(\d+(?:\.\d+)?)\)$/);

test/aibolit.test.ts

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ import { join } from 'path';
88
import { tmpdir } from 'os';
99

1010
jest.mock('child_process', () => ({
11+
execFileSync: jest.fn(),
1112
execSync: jest.fn()
1213
}));
1314

14-
import { execSync } from 'child_process';
15+
import { execFileSync, execSync } from 'child_process';
1516
const mockExecSync = execSync as jest.MockedFunction<typeof execSync>;
17+
const mockExecFileSync = execFileSync as jest.MockedFunction<typeof execFileSync>;
1618

1719
describe('aibolit', () => {
1820
beforeEach(() => {
@@ -35,7 +37,7 @@ describe('aibolit', () => {
3537
`
3638
);
3739
mockExecSync.mockReturnValueOnce(Buffer.from('aibolit 1.3.0\n'));
38-
mockExecSync.mockReturnValueOnce(Buffer.from(''));
40+
mockExecFileSync.mockReturnValueOnce(Buffer.from(''));
3941
try {
4042
const issue = await aibolit(path);
4143
expect(issue).toContain('Your code is perfect');
@@ -60,7 +62,7 @@ describe('aibolit', () => {
6062
`
6163
);
6264
mockExecSync.mockReturnValueOnce(Buffer.from('aibolit 1.3.0\n'));
63-
mockExecSync.mockReturnValueOnce(
65+
mockExecFileSync.mockReturnValueOnce(
6466
Buffer.from('Test.java[44]: Missing final keyword (P13: 5.0)\n'));
6567
try {
6668
const issue = await aibolit(path);
@@ -116,7 +118,7 @@ describe('aibolit', () => {
116118

117119
test('executes normally when version is exactly required', async () => {
118120
mockExecSync.mockReturnValueOnce(Buffer.from('aibolit 1.3.0\n'));
119-
mockExecSync.mockReturnValueOnce(Buffer.from(''));
121+
mockExecFileSync.mockReturnValueOnce(Buffer.from(''));
120122
const path = '/tmp/test.java';
121123
writeFileSync(path, 'public class Test {}');
122124
const result = await aibolit(path);
@@ -125,7 +127,7 @@ describe('aibolit', () => {
125127

126128
test('executes normally when version is newer than required', async () => {
127129
mockExecSync.mockReturnValueOnce(Buffer.from('aibolit 1.4.0\n'));
128-
mockExecSync.mockReturnValueOnce(Buffer.from(''));
130+
mockExecFileSync.mockReturnValueOnce(Buffer.from(''));
129131
const path = '/tmp/test.java';
130132
writeFileSync(path, 'public class Test {}');
131133
const result = await aibolit(path);
@@ -173,11 +175,53 @@ describe('aibolit', () => {
173175

174176
test('handles aibolit output with invalid warning format', async () => {
175177
mockExecSync.mockReturnValueOnce(Buffer.from('aibolit 1.3.0\n'));
176-
mockExecSync.mockReturnValueOnce(
178+
mockExecFileSync.mockReturnValueOnce(
177179
Buffer.from('Invalid warning format\nAnother invalid line\n'));
178180
const path = '/tmp/test.java';
179181
writeFileSync(path, 'public class Test {}');
180182
const result = await aibolit(path);
181183
expect(result).toBe('Your code is perfect');
182184
});
185+
186+
test('parses warnings captured on stdout when aibolit exits non-zero', async () => {
187+
mockExecSync.mockReturnValueOnce(Buffer.from('aibolit 1.3.0\n'));
188+
mockExecFileSync.mockImplementationOnce(() => {
189+
const err = new Error('Command failed') as Error & { stdout: Buffer };
190+
err.stdout = Buffer.from('Test.java[44]: Missing final keyword (P13: 5.0)\n');
191+
throw err;
192+
});
193+
const path = '/tmp/test.java';
194+
writeFileSync(path, 'public class Test {}');
195+
const result = await aibolit(path);
196+
expect(result).not.toContain('Your code is perfect');
197+
expect(result).toContain('Missing final keyword');
198+
});
199+
200+
test('returns perfect code when aibolit throws without stdout', async () => {
201+
mockExecSync.mockReturnValueOnce(Buffer.from('aibolit 1.3.0\n'));
202+
mockExecFileSync.mockImplementationOnce(() => {
203+
throw new Error('Command failed');
204+
});
205+
const path = '/tmp/test.java';
206+
writeFileSync(path, 'public class Test {}');
207+
const result = await aibolit(path);
208+
expect(result).toBe('Your code is perfect');
209+
});
210+
211+
test('passes path as separate argv element so shell metacharacters do not execute', async () => {
212+
const tmp = mkdtempSync(join(tmpdir(), 'aibolit-test-'));
213+
const path = join(tmp, 'Test.java; touch pwned.java');
214+
writeFileSync(path, 'public class Test {}');
215+
mockExecSync.mockReturnValueOnce(Buffer.from('aibolit 1.3.0\n'));
216+
mockExecFileSync.mockReturnValueOnce(Buffer.from(''));
217+
try {
218+
await aibolit(path);
219+
expect(mockExecFileSync).toHaveBeenCalledWith(
220+
'python3',
221+
['-m', 'aibolit', 'check', '--full', '--filenames', path]
222+
);
223+
} finally {
224+
rmSync(tmp, { recursive: true, force: true });
225+
}
226+
});
183227
});

0 commit comments

Comments
 (0)