Skip to content

Commit ac09470

Browse files
TheLarkInnselarkinCopilot
authored
[rush] Prevent shell injection in publish commit details (#5862)
* [rush] Prevent shell injection in publish commit details Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 054aa0b3-8963-4ef3-a1f3-b7bf9d8b3479 * [rush] Preserve Git wrapper support Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 054aa0b3-8963-4ef3-a1f3-b7bf9d8b3479 * [rush] Address publish review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 054aa0b3-8963-4ef3-a1f3-b7bf9d8b3479 --------- Co-authored-by: selarkin <selarkin+odspmdb@microsoft.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 012bae7 commit ac09470

3 files changed

Lines changed: 146 additions & 9 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"changes": [
3+
{
4+
"packageName": "@microsoft/rush",
5+
"comment": "Pass change file paths as discrete Git arguments when adding commit details during publishing.",
6+
"type": "patch"
7+
}
8+
],
9+
"packageName": "@microsoft/rush"
10+
}

libraries/rush-lib/src/logic/PublishUtilities.ts

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import * as path from 'node:path';
10-
import { execSync } from 'node:child_process';
10+
import type child_process from 'node:child_process';
1111

1212
import * as semver from 'semver';
1313

@@ -17,7 +17,8 @@ import {
1717
FileConstants,
1818
Text,
1919
Enum,
20-
InternalError
20+
InternalError,
21+
Executable
2122
} from '@rushstack/node-core-library';
2223

2324
import { type IChangeInfo, ChangeType, type IVersionPolicyChangeInfo } from '../api/ChangeManagement';
@@ -83,13 +84,14 @@ export class PublishUtilities {
8384
// Add the minimum changes defined by the change descriptions.
8485
for (const changeFilePath of files) {
8586
const changeRequest: IChangeInfo = JsonFile.load(changeFilePath);
87+
const changes: IChangeInfo[] = changeRequest.changes!;
8688

8789
if (includeCommitDetails) {
8890
const git: Git = new Git(rushConfiguration);
89-
PublishUtilities._updateCommitDetails(git, changeFilePath, changeRequest.changes);
91+
await PublishUtilities._updateCommitDetailsAsync(git, changeFilePath, changes);
9092
}
9193

92-
for (const change of changeRequest.changes!) {
94+
for (const change of changes) {
9395
PublishUtilities._addChange({
9496
change,
9597
changeFilePath,
@@ -374,16 +376,31 @@ export class PublishUtilities {
374376
);
375377
}
376378

377-
private static _updateCommitDetails(git: Git, filename: string, changes: IChangeInfo[] | undefined): void {
379+
private static async _updateCommitDetailsAsync(
380+
git: Git,
381+
filename: string,
382+
changes: IChangeInfo[]
383+
): Promise<void> {
378384
try {
379385
const gitPath: string = git.getGitPathOrThrow();
380-
const fileLog: string = execSync(`${gitPath} log -n 1 ${filename}`, {
381-
cwd: path.dirname(filename)
382-
}).toString();
386+
const gitProcess: child_process.ChildProcess = Executable.spawn(
387+
gitPath,
388+
['log', '-n', '1', '--', filename],
389+
{
390+
currentWorkingDirectory: path.dirname(filename)
391+
}
392+
);
393+
const { stdout: fileLog, exitCode, signal } = await Executable.waitForExitAsync(gitProcess, {
394+
encoding: 'utf8'
395+
});
396+
if (exitCode !== 0 || signal) {
397+
return;
398+
}
399+
383400
const author: string = fileLog.match(/Author: (.*)/)![1];
384401
const commit: string = fileLog.match(/commit (.*)/)![1];
385402

386-
changes!.forEach((change) => {
403+
changes.forEach((change) => {
387404
change.author = author;
388405
change.commit = commit;
389406
});

libraries/rush-lib/src/logic/test/PublishUtilities.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,34 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
22
// See LICENSE in the project root for license information.
33

4+
import * as path from 'node:path';
5+
import type { ChildProcess } from 'node:child_process';
6+
7+
import { Executable, type IWaitForExitResult } from '@rushstack/node-core-library';
48
import { type IChangeInfo, ChangeType } from '../../api/ChangeManagement';
59
import { RushConfiguration } from '../../api/RushConfiguration';
610
import type { RushConfigurationProject } from '../../api/RushConfigurationProject';
711
import { PublishUtilities, type IChangeRequests } from '../PublishUtilities';
812
import { ChangeFiles } from '../ChangeFiles';
13+
import { Git } from '../Git';
914

1015
function createChangeFiles(changesFolder: string): ChangeFiles {
1116
return new ChangeFiles({ changesFolder } as unknown as RushConfiguration);
1217
}
1318

19+
function createGitResult(
20+
stdout: string,
21+
exitCode: IWaitForExitResult<string>['exitCode'] = 0,
22+
signal: IWaitForExitResult<string>['signal'] = null
23+
): IWaitForExitResult<string> {
24+
return {
25+
stdout,
26+
stderr: '',
27+
exitCode,
28+
signal
29+
};
30+
}
31+
1432
function generateChangeSnapshot(
1533
allPackages: ReadonlyMap<string, RushConfigurationProject>,
1634
allChanges: IChangeRequests
@@ -83,6 +101,10 @@ describe(PublishUtilities.findChangeRequestsAsync.name, () => {
83101
repoRushConfiguration = RushConfiguration.loadFromConfigurationFile(`${__dirname}/repo/rush.json`);
84102
});
85103

104+
afterEach(() => {
105+
jest.restoreAllMocks();
106+
});
107+
86108
it('returns no changes in an empty change folder', async () => {
87109
const allPackages: ReadonlyMap<string, RushConfigurationProject> =
88110
packagesRushConfiguration.projectsByName;
@@ -96,6 +118,94 @@ describe(PublishUtilities.findChangeRequestsAsync.name, () => {
96118
expect(allChanges.versionPolicyChanges.size).toEqual(0);
97119
});
98120

121+
it('passes change file paths as discrete Git arguments', async () => {
122+
const gitPath: string = path.resolve('git with spaces', 'git.exe');
123+
const changeFilePath: string = path.resolve(
124+
'repo with spaces',
125+
'common',
126+
'changes',
127+
'change & echo injected.json'
128+
);
129+
const changes: IChangeInfo[] = [{ packageName: 'd' }];
130+
const git: Git = new Git(packagesRushConfiguration);
131+
const gitProcess: ChildProcess = {} as ChildProcess;
132+
133+
jest.spyOn(git, 'getGitPathOrThrow').mockReturnValue(gitPath);
134+
const spawnSpy: jest.SpyInstance = jest.spyOn(Executable, 'spawn').mockReturnValue(gitProcess);
135+
const waitForExitSpy: jest.SpyInstance = jest
136+
.spyOn(Executable, 'waitForExitAsync')
137+
.mockResolvedValue(
138+
createGitResult('commit 0123456789abcdef\nAuthor: Test Author <test@example.com>\n')
139+
);
140+
141+
await PublishUtilities['_updateCommitDetailsAsync'](git, changeFilePath, changes);
142+
143+
expect(spawnSpy).toHaveBeenCalledWith(
144+
gitPath,
145+
['log', '-n', '1', '--', changeFilePath],
146+
{ currentWorkingDirectory: path.dirname(changeFilePath) }
147+
);
148+
expect(waitForExitSpy).toHaveBeenCalledWith(gitProcess, { encoding: 'utf8' });
149+
expect(changes).toEqual([
150+
{
151+
packageName: 'd',
152+
author: 'Test Author <test@example.com>',
153+
commit: '0123456789abcdef'
154+
}
155+
]);
156+
});
157+
158+
it('delegates Git wrapper paths to Executable', async () => {
159+
const gitPath: string = path.resolve('git-wrapper', 'git.cmd');
160+
const changeFilePath: string = path.resolve('repo', 'common', 'changes', 'change.json');
161+
const changes: IChangeInfo[] = [{ packageName: 'd' }];
162+
const git: Git = new Git(packagesRushConfiguration);
163+
const gitProcess: ChildProcess = {} as ChildProcess;
164+
165+
jest.spyOn(git, 'getGitPathOrThrow').mockReturnValue(gitPath);
166+
const spawnSpy: jest.SpyInstance = jest.spyOn(Executable, 'spawn').mockReturnValue(gitProcess);
167+
jest
168+
.spyOn(Executable, 'waitForExitAsync')
169+
.mockResolvedValue(
170+
createGitResult('commit 0123456789abcdef\nAuthor: Test Author <test@example.com>\n')
171+
);
172+
173+
await PublishUtilities['_updateCommitDetailsAsync'](git, changeFilePath, changes);
174+
175+
expect(spawnSpy).toHaveBeenCalledWith(
176+
gitPath,
177+
['log', '-n', '1', '--', changeFilePath],
178+
{ currentWorkingDirectory: path.dirname(changeFilePath) }
179+
);
180+
expect(changes[0].commit).toEqual('0123456789abcdef');
181+
});
182+
183+
it.each([
184+
{ exitCode: 1, signal: null },
185+
{ exitCode: null, signal: 'SIGTERM' }
186+
])(
187+
'does not use Git output from an unsuccessful process ($exitCode, $signal)',
188+
async ({ exitCode, signal }) => {
189+
const changes: IChangeInfo[] = [{ packageName: 'd' }];
190+
const git: Git = new Git(packagesRushConfiguration);
191+
const gitProcess: ChildProcess = {} as ChildProcess;
192+
193+
jest.spyOn(git, 'getGitPathOrThrow').mockReturnValue(path.resolve('git.exe'));
194+
jest.spyOn(Executable, 'spawn').mockReturnValue(gitProcess);
195+
jest.spyOn(Executable, 'waitForExitAsync').mockResolvedValue(
196+
createGitResult(
197+
'commit 0123456789abcdef\nAuthor: Test Author <test@example.com>\n',
198+
exitCode,
199+
signal
200+
)
201+
);
202+
203+
await PublishUtilities['_updateCommitDetailsAsync'](git, path.resolve('change.json'), changes);
204+
205+
expect(changes).toEqual([{ packageName: 'd' }]);
206+
}
207+
);
208+
99209
it('returns 1 change when changing a leaf package', async () => {
100210
const allPackages: ReadonlyMap<string, RushConfigurationProject> =
101211
packagesRushConfiguration.projectsByName;

0 commit comments

Comments
 (0)