Skip to content

Commit 5382599

Browse files
committed
Fix shell injection vulnerabilities and null check in workflow
- Replace execSync with spawnSync for git commands to prevent shell injection - Add runGit() helper that passes args as array (no shell interpretation) - Add null check for pr.head.repo when detecting fork PRs - Addresses Copilot review feedback on PR dotnet#13558
1 parent 7f753d2 commit 5382599

File tree

1 file changed

+40
-27
lines changed

1 file changed

+40
-27
lines changed

.github/workflows/apply-test-attributes.yml

Lines changed: 40 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -298,9 +298,9 @@ jobs:
298298
return;
299299
}
300300
301-
// Check if PR is from a fork (not supported)
302-
if (pr.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`) {
303-
const msg = `PR #${targetPrNumber} is from a fork. Cannot push to fork branches.`;
301+
// Check if PR is from a fork (not supported) - also handle deleted fork repos
302+
if (!pr.head.repo || pr.head.repo.full_name !== `${context.repo.owner}/${context.repo.repo}`) {
303+
const msg = `PR #${targetPrNumber} is from a fork or the source repository was deleted. Cannot push to fork branches.`;
304304
core.setOutput('error_message', msg);
305305
core.setFailed(msg);
306306
return;
@@ -381,14 +381,13 @@ jobs:
381381
IFS=' ' read -ra TEST_ARRAY <<< "$TEST_NAMES"
382382
383383
# Run the tool and capture output
384-
echo "Running: dotnet run --project ${{ github.workspace }}/tools/QuarantineTools/QuarantineTools.csproj -- $FLAG -m $MODE -i \"$ISSUE_URL\" ${TEST_ARRAY[*]}"
385-
386384
if [ "$ACTION" = "quarantine" ]; then
387-
OUTPUT=$(dotnet run --project ${{ github.workspace }}/tools/QuarantineTools/QuarantineTools.csproj -- $FLAG -m "$MODE" -i "$ISSUE_URL" "${TEST_ARRAY[@]}" 2>&1 || true)
385+
echo "Running: dotnet run --project ${{ github.workspace }}/tools/QuarantineTools/QuarantineTools.csproj -- $FLAG -m $MODE -i \"$ISSUE_URL\" ${TEST_ARRAY[*]}"
386+
OUTPUT=$(dotnet run --project ${{ github.workspace }}/tools/QuarantineTools/QuarantineTools.csproj -- $FLAG -m "$MODE" -i "$ISSUE_URL" "${TEST_ARRAY[@]}" 2>&1) && EXIT_CODE=0 || EXIT_CODE=$?
388387
else
389-
OUTPUT=$(dotnet run --project ${{ github.workspace }}/tools/QuarantineTools/QuarantineTools.csproj -- $FLAG -m "$MODE" "${TEST_ARRAY[@]}" 2>&1 || true)
388+
echo "Running: dotnet run --project ${{ github.workspace }}/tools/QuarantineTools/QuarantineTools.csproj -- $FLAG -m $MODE ${TEST_ARRAY[*]}"
389+
OUTPUT=$(dotnet run --project ${{ github.workspace }}/tools/QuarantineTools/QuarantineTools.csproj -- $FLAG -m "$MODE" "${TEST_ARRAY[@]}" 2>&1) && EXIT_CODE=0 || EXIT_CODE=$?
390390
fi
391-
EXIT_CODE=$?
392391
393392
echo "$OUTPUT"
394393
@@ -425,7 +424,7 @@ jobs:
425424
IS_NEW_PR: ${{ steps.determine-target.outputs.is_new_pr }}
426425
with:
427426
script: |
428-
const { execSync } = require('child_process');
427+
const { execSync, spawnSync } = require('child_process');
429428
const fs = require('fs');
430429
const path = require('path');
431430
@@ -447,13 +446,26 @@ jobs:
447446
const testList = testNames.length === 1 ? testNames[0] : `${testNames.length} tests`;
448447
const commitMessage = `[automated] ${actionVerb} ${testList}`;
449448
450-
// Helper to run git commands safely (avoids shell injection)
449+
// Helper to run git commands safely using spawnSync (avoids shell injection)
450+
function runGit(args) {
451+
const result = spawnSync('git', args, { encoding: 'utf-8', stdio: 'pipe' });
452+
if (result.status !== 0) {
453+
const error = new Error(result.stderr || result.stdout || 'Git command failed');
454+
error.status = result.status;
455+
throw error;
456+
}
457+
return result.stdout;
458+
}
459+
460+
// Helper to commit with a message (uses temp file for safety)
451461
function gitCommit(message) {
452-
// Write commit message to a temp file to avoid shell injection
453462
const msgFile = path.join(process.env.RUNNER_TEMP || '/tmp', 'commit-msg.txt');
454463
fs.writeFileSync(msgFile, message);
455-
execSync(`git commit -F "${msgFile}"`);
456-
fs.unlinkSync(msgFile);
464+
try {
465+
runGit(['commit', '-F', msgFile]);
466+
} finally {
467+
fs.unlinkSync(msgFile);
468+
}
457469
}
458470
459471
let prUrl = '';
@@ -468,12 +480,12 @@ jobs:
468480
prNumber = targetPrNumber;
469481
470482
// Stage and commit
471-
execSync('git add -A');
483+
runGit(['add', '-A']);
472484
gitCommit(commitMessage);
473485
474-
// Push to the PR branch
486+
// Push to the PR branch (checkoutRef comes from PR, use spawnSync for safety)
475487
try {
476-
execSync(`git push origin HEAD:${checkoutRef}`);
488+
runGit(['push', 'origin', `HEAD:${checkoutRef}`]);
477489
} catch (pushError) {
478490
core.setFailed(`Failed to push to PR #${targetPrNumber}: ${pushError.message}. The branch may be protected or have been force-pushed.`);
479491
return;
@@ -484,18 +496,19 @@ jobs:
484496
// Create a new PR
485497
createdNewPr = true;
486498
const timestamp = Date.now();
499+
// Branch name is safe: actionVerb is from controlled lookup (Quarantine/Disable/etc), timestamp is numeric
487500
const branchName = `automated/${actionVerb.toLowerCase()}-test-${timestamp}`;
488501
489-
// Create and checkout branch
490-
execSync(`git checkout -b ${branchName}`);
502+
// Create and checkout branch (use spawnSync for safety)
503+
runGit(['checkout', '-b', branchName]);
491504
492505
// Stage and commit changes
493-
execSync('git add -A');
506+
runGit(['add', '-A']);
494507
gitCommit(commitMessage);
495508
496509
// Push branch
497510
try {
498-
execSync(`git push origin ${branchName}`);
511+
runGit(['push', 'origin', branchName]);
499512
} catch (pushError) {
500513
core.setFailed(`Failed to push branch '${branchName}': ${pushError.message}`);
501514
return;
@@ -537,7 +550,7 @@ jobs:
537550
owner: context.repo.owner,
538551
repo: context.repo.repo,
539552
issue_number: prNumber,
540-
labels: ['automated', 'area-codeflow']
553+
labels: ['area-codeflow']
541554
});
542555
} catch (labelError) {
543556
console.log(`Note: Could not add labels: ${labelError.message}`);
@@ -600,18 +613,23 @@ jobs:
600613
- name: Post failure comment
601614
if: failure()
602615
uses: actions/github-script@v7
616+
env:
617+
ACTION_VERB: ${{ steps.extract-command.outputs.action_verb || '' }}
618+
TOOL_OUTPUT: ${{ steps.run-tool.outputs.tool_output || '' }}
619+
EXTRACT_ERROR: ${{ steps.extract-command.outputs.error_message || '' }}
620+
TARGET_ERROR: ${{ steps.determine-target.outputs.error_message || '' }}
603621
with:
604622
script: |
605623
const comment_user = context.payload.comment.user.login;
606624
const workflow_run_url = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
607625
const actionVerb = process.env.ACTION_VERB || 'Operation';
608626
const toolOutput = process.env.TOOL_OUTPUT || '';
609627
const extractError = process.env.EXTRACT_ERROR || '';
628+
const targetError = process.env.TARGET_ERROR || '';
610629
611630
let body = `@${comment_user} ❌ ${actionVerb} failed.\n\n`;
612631
613632
// Check for errors from various steps
614-
const targetError = process.env.TARGET_ERROR || '';
615633
const errorMessage = extractError || targetError;
616634
if (errorMessage) {
617635
body += `**Error:** ${errorMessage}\n\n`;
@@ -639,11 +657,6 @@ jobs:
639657
repo: context.repo.repo,
640658
body: body
641659
});
642-
env:
643-
ACTION_VERB: ${{ steps.extract-command.outputs.action_verb }}
644-
TOOL_OUTPUT: ${{ steps.run-tool.outputs.tool_output }}
645-
EXTRACT_ERROR: ${{ steps.extract-command.outputs.error_message }}
646-
TARGET_ERROR: ${{ steps.determine-target.outputs.error_message }}
647660
648661
- name: Re-lock issue/PR if it was locked
649662
if: github.event.issue.locked == true && (success() || failure())

0 commit comments

Comments
 (0)