Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
### [Report an Issue](https://github.com/atlassian/atlascode/issues)

## What's new in 4.0.32

### Security

- Fixed arbitrary code execution via `core.fsmonitor` in repository `.git/config`

## What's new in 4.0.31

### Bug Fixes
Expand Down
50 changes: 49 additions & 1 deletion src/util/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,64 @@ const mockedChildProcess = {
on: jest.fn(),
};

const mockSpawn = jest.fn((..._args: any[]) => mockedChildProcess);

jest.mock('child_process', () => ({
spawn: () => mockedChildProcess,
spawn: (...args: any[]) => mockSpawn(...args),
}));

describe('shell', () => {
beforeEach(() => {
mockSpawn.mockClear();
mockSpawn.mockReturnValue(mockedChildProcess);
});

it('constructor works', () => {
const instance = new Shell('workingDirectory');
expect(instance).toBeDefined();
});

describe('git hardening (VULN-2216191)', () => {
it('prepends core.fsmonitor and core.hooksPath neutralisation flags for git commands', () => {
const instance = new Shell('workingDirectory');
instance.exec('git', 'blame', '--root', '-l', '-L1,1', '--', 'file.ts');

expect(mockSpawn).toHaveBeenCalledWith(
'git',
['-c', 'core.fsmonitor=', '-c', 'core.hooksPath=', 'blame', '--root', '-l', '-L1,1', '--', 'file.ts'],
expect.objectContaining({ shell: false }),
);
});

it('sets GIT_CONFIG_NOSYSTEM=1 and GIT_CONFIG_GLOBAL="" in the env for git commands', () => {
const instance = new Shell('workingDirectory');
instance.exec('git', 'rev-parse', '--show-toplevel');

const spawnOptions = (mockSpawn.mock.calls[0] as any[])[2] as { env: NodeJS.ProcessEnv };
expect(spawnOptions.env).toBeDefined();
expect(spawnOptions.env!['GIT_CONFIG_NOSYSTEM']).toBe('1');
expect(spawnOptions.env!['GIT_CONFIG_GLOBAL']).toBe('');
});

it('does NOT prepend git hardening flags for non-git commands', () => {
const instance = new Shell('workingDirectory');
instance.exec('echo', 'hello');

expect(mockSpawn).toHaveBeenCalledWith('echo', ['hello'], expect.objectContaining({ shell: false }));

const spawnArgs = (mockSpawn.mock.calls[0] as any[])[1] as string[];
expect(spawnArgs).not.toContain('core.fsmonitor=');
});

it('does NOT set GIT_CONFIG_NOSYSTEM for non-git commands', () => {
const instance = new Shell('workingDirectory');
instance.exec('echo', 'hello');

const spawnOptions = (mockSpawn.mock.calls[0] as any[])[2] as { env?: NodeJS.ProcessEnv };
expect(spawnOptions?.env?.['GIT_CONFIG_NOSYSTEM']).toBeUndefined();
});
});

describe('exec', () => {
it('registers to stdout, stderr, close, and error events', () => {
const instance = new Shell('workingDirectory');
Expand Down
32 changes: 31 additions & 1 deletion src/util/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,51 @@ export interface Result {
stderr: string;
}

/**
* Git-specific hardening: environment variables and config flags that prevent
* repository-controlled .git/config from executing arbitrary programs.
*
* Without these safeguards a malicious repository can set `core.fsmonitor` or
* `core.hooksPath` in `.git/config` to point at an attacker-supplied executable,
* which git then runs automatically during index refresh operations (blame, status,
* diff, etc.), achieving arbitrary code execution simply by opening the repository.
*
* References: VULN-2216191
*/
const GIT_SAFE_ENV: NodeJS.ProcessEnv = {
...process.env,
// Disable system-wide and global git config so only the command-line -c flags apply.
GIT_CONFIG_NOSYSTEM: '1',
GIT_CONFIG_GLOBAL: '',
};

const GIT_SAFE_ARGS_PREFIX = ['-c', 'core.fsmonitor=', '-c', 'core.hooksPath='];

export class Shell {
constructor(private readonly workingDirectory: string) {}

/**
* Execute a command with arguments safely, without spawning a shell.
* Arguments are passed directly to the process, preventing shell injection attacks.
*
* When the command is `git`, extra hardening flags and environment variables are
* automatically prepended to neutralise repository-local config keys that could
* otherwise cause git to execute attacker-supplied programs (e.g. `core.fsmonitor`,
* `core.hooksPath`).
*
* @param command - The executable to run (e.g. 'git')
* @param args - Arguments passed directly to the process (not interpreted by a shell)
*/
public async exec(command: string, ...args: string[]): Promise<Result> {
const isGit = command === 'git';
const safeArgs = isGit ? [...GIT_SAFE_ARGS_PREFIX, ...args] : args;
const spawnEnv = isGit ? GIT_SAFE_ENV : process.env;

return new Promise<Result>((resolve, reject) => {
const proc = spawn(command, args, {
const proc = spawn(command, safeArgs, {
cwd: this.workingDirectory,
shell: false,
env: spawnEnv,
});

let stdout = '';
Expand Down
Loading