From 7a0da6806c76929a78f2b9d354fbc862e1325cf3 Mon Sep 17 00:00:00 2001 From: Andy Date: Thu, 5 Feb 2026 04:10:43 +0800 Subject: [PATCH] Hardening: workflows safety/robustness + installer integrity - Reduce injection risk in workflows (quoting, env use)\n- Add timeouts, concurrency, repo guards\n- Pin actions to SHAs where applicable\n- Harden install.sh (prerelease selection, checksum, trap, pipefail)\n- Add public repo privacy notice to bug report template --- .github/ISSUE_TEMPLATE/bug_report.yml | 9 +- .github/workflows/close-invalid.yml | 21 +++-- .../workflows/close-single-word-issues.yml | 9 +- .github/workflows/feature-request-comment.yml | 6 +- .github/workflows/no-response.yml | 4 +- .github/workflows/on-issue-close.yml | 2 + .github/workflows/remove-triage-label.yml | 5 +- .github/workflows/stale-issues.yml | 4 +- .github/workflows/triage-issues.yml | 12 ++- .../workflows/unable-to-reproduce-comment.yml | 3 +- .github/workflows/winget.yml | 56 +++++++++--- README.md | 4 + install.sh | 87 +++++++++++++------ 13 files changed, 163 insertions(+), 59 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index f488ac30..98aa60db 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -8,7 +8,14 @@ body: attributes: value: |
- Thank you for taking the time to report a bug! Please answer each question below to the best of your ability; it is okay to leave questions blank if you have to. + Thank you for taking the time to report a bug! Please answer each question below to the best of your ability; it is okay to leave questions blank if you have to. + + ⚠️ **Security notice**: This is a public repository. Before sharing logs or files, please review them for: + - API keys, tokens, or credentials + - Personal identifiable information (PII) + - Internal URLs, hostnames, or filesystem paths + + Please avoid sharing contents from `~/.copilot/` unless you've reviewed and redacted sensitive data. - type: textarea id: description attributes: diff --git a/.github/workflows/close-invalid.yml b/.github/workflows/close-invalid.yml index 4078bd87..eb451915 100644 --- a/.github/workflows/close-invalid.yml +++ b/.github/workflows/close-invalid.yml @@ -15,22 +15,29 @@ permissions: jobs: close-on-adding-invalid-label: - if: - github.repository == 'github/copilot-cli' && github.event.label.name == - 'invalid' + if: | + github.repository == 'github/copilot-cli' && + github.event.label.name == 'invalid' && + ( + github.event_name == 'issues' || + github.event.pull_request.head.repo.full_name == github.repository + ) runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Close issue if: ${{ github.event_name == 'issues' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - URL: ${{ github.event.issue.html_url }} - run: gh issue close $URL + NUMBER: ${{ github.event.issue.number }} + REPO: ${{ github.repository }} + run: gh issue close "$NUMBER" --repo "$REPO" - name: Close PR if: ${{ github.event_name == 'pull_request_target' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - URL: ${{ github.event.pull_request.html_url }} - run: gh pr close $URL + NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: gh pr close "$NUMBER" --repo "$REPO" diff --git a/.github/workflows/close-single-word-issues.yml b/.github/workflows/close-single-word-issues.yml index f2ef0dae..111f285c 100644 --- a/.github/workflows/close-single-word-issues.yml +++ b/.github/workflows/close-single-word-issues.yml @@ -11,10 +11,11 @@ permissions: jobs: close-issue: runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Close Single-Word Issue - uses: actions/github-script@v7 + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -25,8 +26,10 @@ jobs: const issueNumber = context.payload.issue.number; const repo = context.repo.repo; + console.log(`Closing single-word issue #${issueNumber}: "${issueTitle}"`); + // Close the issue and add the invalid label - github.rest.issues.update({ + await github.rest.issues.update({ owner: context.repo.owner, repo: repo, issue_number: issueNumber, @@ -41,4 +44,6 @@ jobs: issue_number: issueNumber, body: `This issue may have been opened accidentally. I'm going to close it now, but feel free to open a new issue with a more descriptive title.` }); + + console.log(`Successfully closed issue #${issueNumber}`); } diff --git a/.github/workflows/feature-request-comment.yml b/.github/workflows/feature-request-comment.yml index f347eb6d..f8cf311c 100644 --- a/.github/workflows/feature-request-comment.yml +++ b/.github/workflows/feature-request-comment.yml @@ -9,8 +9,9 @@ permissions: jobs: add-comment-to-enhancement-issues: - if: github.event.label.name == 'enhancement' + if: github.repository == 'github/copilot-cli' && github.event.label.name == 'enhancement' runs-on: ubuntu-latest + timeout-minutes: 5 env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} @@ -29,3 +30,6 @@ jobs: contribute. steps: - run: gh issue comment "$NUMBER" --body "$BODY" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} diff --git a/.github/workflows/no-response.yml b/.github/workflows/no-response.yml index 2a864ddd..83c62f92 100644 --- a/.github/workflows/no-response.yml +++ b/.github/workflows/no-response.yml @@ -11,9 +11,11 @@ permissions: jobs: noResponse: + if: github.repository == 'github/copilot-cli' runs-on: ubuntu-latest + timeout-minutes: 15 steps: - - uses: actions/stale@v9 + - uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} only-issue-labels: 'more-info-needed' diff --git a/.github/workflows/on-issue-close.yml b/.github/workflows/on-issue-close.yml index e768226d..24eb6219 100644 --- a/.github/workflows/on-issue-close.yml +++ b/.github/workflows/on-issue-close.yml @@ -5,7 +5,9 @@ on: - closed jobs: label_issues: + if: github.repository == 'github/copilot-cli' runs-on: ubuntu-latest + timeout-minutes: 5 permissions: issues: write steps: diff --git a/.github/workflows/remove-triage-label.yml b/.github/workflows/remove-triage-label.yml index b4834e05..b9700e89 100644 --- a/.github/workflows/remove-triage-label.yml +++ b/.github/workflows/remove-triage-label.yml @@ -9,10 +9,9 @@ permissions: jobs: remove-triage-label-from-issues: - if: - github.event.label.name != 'triage' && github.event.label.name != - 'more-info-needed' + if: github.repository == 'github/copilot-cli' && github.event.label.name != 'triage' && github.event.label.name != 'more-info-needed' runs-on: ubuntu-latest + timeout-minutes: 5 steps: - run: gh issue edit "$NUMBER" --remove-label "$LABELS" env: diff --git a/.github/workflows/stale-issues.yml b/.github/workflows/stale-issues.yml index 3ec537c4..f2dc9c79 100644 --- a/.github/workflows/stale-issues.yml +++ b/.github/workflows/stale-issues.yml @@ -8,9 +8,11 @@ permissions: jobs: stale: + if: github.repository == 'github/copilot-cli' runs-on: ubuntu-latest + timeout-minutes: 15 steps: - - uses: actions/stale@v9 + - uses: actions/stale@28ca1036281a5e5922ead5184a1bbf96e5fc984e # v9.0.0 with: stale-issue-label: 'stale, triage' # The label that will be added to the issues when automatically marked as stale start-date: '2025-01-01T00:00:00Z' # Skip stale action for issues created before it diff --git a/.github/workflows/triage-issues.yml b/.github/workflows/triage-issues.yml index f73bb297..e15a9ce7 100644 --- a/.github/workflows/triage-issues.yml +++ b/.github/workflows/triage-issues.yml @@ -9,10 +9,15 @@ on: permissions: issues: write +concurrency: + group: triage-${{ github.event.issue.number }} + cancel-in-progress: true + jobs: label_incoming_issues: + if: github.repository == 'github/copilot-cli' && (github.event.action == 'opened' || github.event.action == 'reopened') runs-on: ubuntu-latest - if: github.event.action == 'opened' || github.event.action == 'reopened' + timeout-minutes: 5 steps: - run: gh issue edit "$NUMBER" --add-label "$LABELS" env: @@ -21,10 +26,9 @@ jobs: NUMBER: ${{ github.event.issue.number }} LABELS: triage label_more_info_issues: - if: - github.event.action == 'unlabeled' && github.event.label.name == - 'more-info-needed' + if: github.repository == 'github/copilot-cli' && github.event.action == 'unlabeled' && github.event.label.name == 'more-info-needed' runs-on: ubuntu-latest + timeout-minutes: 5 steps: - run: gh issue edit "$NUMBER" --add-label "$LABELS" env: diff --git a/.github/workflows/unable-to-reproduce-comment.yml b/.github/workflows/unable-to-reproduce-comment.yml index 195739a3..f6c119dd 100644 --- a/.github/workflows/unable-to-reproduce-comment.yml +++ b/.github/workflows/unable-to-reproduce-comment.yml @@ -9,8 +9,9 @@ permissions: jobs: add-comment-to-unable-to-reproduce-issues: - if: github.event.label.name == 'unable-to-reproduce' + if: github.repository == 'github/copilot-cli' && github.event.label.name == 'unable-to-reproduce' runs-on: ubuntu-latest + timeout-minutes: 5 steps: - name: Update issue env: diff --git a/.github/workflows/winget.yml b/.github/workflows/winget.yml index 094defe6..26e9b479 100644 --- a/.github/workflows/winget.yml +++ b/.github/workflows/winget.yml @@ -6,39 +6,75 @@ on: jobs: publish-winget: + if: github.repository == 'github/copilot-cli' name: Submit to WinGet repository # GitHub token permissions needed for winget-create to submit a PR permissions: contents: read - pull-requests: write # winget-create is only supported on Windows runs-on: windows-latest + timeout-minutes: 30 # winget-create will read the following environment variable to access the GitHub token needed for submitting a PR # See https://aka.ms/winget-create-token env: WINGET_CREATE_GITHUB_TOKEN: ${{ secrets.WINGET_CREATE_GITHUB_TOKEN }} + RELEASE_ASSETS_JSON: ${{ toJSON(github.event.release.assets) }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + IS_PRERELEASE: ${{ github.event.release.prerelease }} steps: + - name: Validate required secret + shell: pwsh + run: | + if ([string]::IsNullOrWhiteSpace($env:WINGET_CREATE_GITHUB_TOKEN)) { + Write-Error "WINGET_CREATE_GITHUB_TOKEN secret is not configured" + exit 1 + } + - name: Submit package using wingetcreate + shell: pwsh run: | # Set the package ID based on the release info - $packageId = if ('${{ !github.event.release.prerelease }}' -eq 'true') { 'GitHub.Copilot' } else { 'GitHub.Copilot.Prerelease' } - - # Get installer info from release event - $assets = '${{ toJSON(github.event.release.assets) }}' | ConvertFrom-Json - $packageVersion = (${{ toJSON(github.event.release.tag_name) }}) - + $isPrerelease = ($env:IS_PRERELEASE -eq 'true') + $packageId = if ($isPrerelease) { 'GitHub.Copilot.Prerelease' } else { 'GitHub.Copilot' } + + # Get installer info from release event (passed via env to avoid script injection patterns) + $assets = $env:RELEASE_ASSETS_JSON | ConvertFrom-Json + $packageVersion = $env:RELEASE_TAG + # Find the download URLs for the x64 and arm64 installers separately # This allows overrides to be used so that wingetcreate does not have to guess the architecture from the filename $installerUrlx64 = $assets | Where-Object -Property name -like '*win32-x64.zip' | Select-Object -ExpandProperty browser_download_url $installerUrlarm64 = $assets | Where-Object -Property name -like '*win32-arm64.zip' | Select-Object -ExpandProperty browser_download_url - + + if ([string]::IsNullOrWhiteSpace($installerUrlx64) -or [string]::IsNullOrWhiteSpace($installerUrlarm64)) { + Write-Error "Could not determine installer URLs from release assets" + exit 1 + } + + # Download wingetcreate (with retries) + $maxRetries = 3 + $retryDelaySeconds = 5 + for ($i = 1; $i -le $maxRetries; $i++) { + try { + curl.exe -JLO https://aka.ms/wingetcreate/latest + if (Test-Path "wingetcreate.exe") { break } + } catch { + Write-Warning "Download attempt $i failed: $($_.Exception.Message)" + } + Start-Sleep -Seconds $retryDelaySeconds + } + + if (-not (Test-Path "wingetcreate.exe")) { + Write-Error "Failed to download wingetcreate after $maxRetries attempts" + exit 1 + } + # Update package using wingetcreate - curl.exe -JLO https://aka.ms/wingetcreate/latest .\wingetcreate.exe update $packageId ` - --version $packageVersion ` + --version "$packageVersion" ` --urls "$installerUrlx64|x64" "$installerUrlarm64|arm64" ` --submit diff --git a/README.md b/README.md index 28050a4e..5a7fc34b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # GitHub Copilot CLI (Public Preview) +## Workflow Status +![Stale Issues](https://github.com/github/copilot-cli/actions/workflows/stale-issues.yml/badge.svg) +![No Response](https://github.com/github/copilot-cli/actions/workflows/no-response.yml/badge.svg) + The power of GitHub Copilot, now in your terminal. GitHub Copilot CLI brings AI-powered coding assistance directly to your command line, enabling you to build, debug, and understand code through natural language conversations. Powered by the same agentic harness as GitHub's Copilot coding agent, it provides intelligent assistance while staying deeply integrated with your GitHub workflow. diff --git a/install.sh b/install.sh index fb3d3d49..2b5f2fb2 100755 --- a/install.sh +++ b/install.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -set -e +set -euo pipefail # GitHub Copilot CLI Installation Script # Usage: curl -fsSL https://gh.io/copilot-install | bash @@ -39,16 +39,34 @@ if [ "${VERSION}" = "latest" ] || [ -z "$VERSION" ]; then DOWNLOAD_URL="https://github.com/github/copilot-cli/releases/latest/download/copilot-${PLATFORM}-${ARCH}.tar.gz" CHECKSUMS_URL="https://github.com/github/copilot-cli/releases/latest/download/SHA256SUMS.txt" elif [ "${VERSION}" = "prerelease" ]; then - # Get the latest prerelease tag - if ! command -v git >/dev/null 2>&1; then - echo "Error: git is required to install prerelease versions" >&2 - exit 1 + # Get the latest prerelease tag. + # Prefer GitHub Releases API; fallback to git tags with a conservative prerelease regex. + VERSION="" + + if command -v curl >/dev/null 2>&1; then + VERSION="$(curl -fsSL https://api.github.com/repos/github/copilot-cli/releases \ + | awk -F'"' '/"prerelease": true/ {p=1} p && /"tag_name":/ {print $4; exit}')" || true fi - VERSION="$(git ls-remote --tags https://github.com/github/copilot-cli | tail -1 | awk -F/ '{print $NF}')" - if [ -z "$VERSION" ]; then + + if [ -z "${VERSION}" ]; then + if ! command -v git >/dev/null 2>&1; then + echo "Error: git is required to install prerelease versions (or install curl)." >&2 + exit 1 + fi + + VERSION="$(git ls-remote --tags https://github.com/github/copilot-cli \ + | awk -F/ '{print $NF}' \ + | sed 's/\^{}$//' \ + | grep -E '^v?[0-9].*-(rc|beta|alpha|pre|preview)' \ + | sort -V \ + | tail -1)" || true + fi + + if [ -z "${VERSION}" ]; then echo "Error: Could not determine prerelease version" >&2 exit 1 fi + echo "Latest prerelease version: $VERSION" DOWNLOAD_URL="https://github.com/github/copilot-cli/releases/download/${VERSION}/copilot-${PLATFORM}-${ARCH}.tar.gz" CHECKSUMS_URL="https://github.com/github/copilot-cli/releases/download/${VERSION}/SHA256SUMS.txt" @@ -65,14 +83,14 @@ echo "Downloading from: $DOWNLOAD_URL" # Download and extract with error handling TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT TMP_TARBALL="$TMP_DIR/copilot-${PLATFORM}-${ARCH}.tar.gz" if command -v curl >/dev/null 2>&1; then - curl -fsSL "$DOWNLOAD_URL" -o "$TMP_TARBALL" + curl -fsSL --retry 3 --retry-delay 2 "$DOWNLOAD_URL" -o "$TMP_TARBALL" elif command -v wget >/dev/null 2>&1; then - wget -qO "$TMP_TARBALL" "$DOWNLOAD_URL" + wget --tries=3 -qO "$TMP_TARBALL" "$DOWNLOAD_URL" else - echo "Error: Neither curl nor wget found. Please install one of them." - rm -rf "$TMP_DIR" + echo "Error: Neither curl nor wget found. Please install one of them." >&2 exit 1 fi @@ -80,37 +98,49 @@ fi TMP_CHECKSUMS="$TMP_DIR/SHA256SUMS.txt" CHECKSUMS_AVAILABLE=false if command -v curl >/dev/null 2>&1; then - curl -fsSL "$CHECKSUMS_URL" -o "$TMP_CHECKSUMS" 2>/dev/null && CHECKSUMS_AVAILABLE=true + curl -fsSL --retry 3 --retry-delay 2 "$CHECKSUMS_URL" -o "$TMP_CHECKSUMS" 2>/dev/null && CHECKSUMS_AVAILABLE=true elif command -v wget >/dev/null 2>&1; then - wget -qO "$TMP_CHECKSUMS" "$CHECKSUMS_URL" 2>/dev/null && CHECKSUMS_AVAILABLE=true + wget --tries=3 -qO "$TMP_CHECKSUMS" "$CHECKSUMS_URL" 2>/dev/null && CHECKSUMS_AVAILABLE=true fi if [ "$CHECKSUMS_AVAILABLE" = true ]; then - if command -v sha256sum >/dev/null 2>&1; then - if (cd "$TMP_DIR" && sha256sum -c --ignore-missing SHA256SUMS.txt >/dev/null 2>&1); then - echo "✓ Checksum validated" + target_filename="copilot-${PLATFORM}-${ARCH}.tar.gz" + expected="$(grep -E "${target_filename}$" "$TMP_CHECKSUMS" | head -n1 | awk '{print $1}')" || true + + if [ -z "$expected" ]; then + echo "Warning: checksum entry not found for ${target_filename}; skipping checksum validation." >&2 + else + if command -v sha256sum >/dev/null 2>&1; then + actual="$(sha256sum "$TMP_TARBALL" | awk '{print $1}')" + elif command -v shasum >/dev/null 2>&1; then + actual="$(shasum -a 256 "$TMP_TARBALL" | awk '{print $1}')" else - echo "Error: Checksum validation failed." >&2 - rm -rf "$TMP_DIR" - exit 1 + echo "Error: No sha256sum or shasum found. Cannot validate download integrity." >&2 + echo "Install sha256sum or shasum, or set SKIP_CHECKSUM=1 to bypass (not recommended)." >&2 + if [ "${SKIP_CHECKSUM:-0}" != "1" ]; then + exit 1 + fi + actual="" fi - elif command -v shasum >/dev/null 2>&1; then - if (cd "$TMP_DIR" && shasum -a 256 -c --ignore-missing SHA256SUMS.txt >/dev/null 2>&1); then + + if [ -n "${actual}" ] && [ "$expected" = "$actual" ]; then echo "✓ Checksum validated" - else + elif [ -n "${actual}" ]; then echo "Error: Checksum validation failed." >&2 - rm -rf "$TMP_DIR" exit 1 fi - else - echo "Warning: No sha256sum or shasum found, skipping checksum validation." fi fi # Check that the file is a valid tarball if ! tar -tzf "$TMP_TARBALL" >/dev/null 2>&1; then echo "Error: Downloaded file is not a valid tarball or is corrupted." >&2 - rm -rf "$TMP_DIR" + exit 1 +fi + +# Validate tarball contents (avoid extracting unexpected files into bin/) +if ! tar -tzf "$TMP_TARBALL" | grep -qx 'copilot'; then + echo "Error: tarball contents unexpected (expected a single 'copilot' binary)." >&2 exit 1 fi @@ -134,7 +164,6 @@ fi tar -xz -C "$INSTALL_DIR" -f "$TMP_TARBALL" chmod +x "$INSTALL_DIR/copilot" echo "✓ GitHub Copilot CLI installed to $INSTALL_DIR/copilot" -rm -rf "$TMP_DIR" # Check if installed binary is accessible if ! command -v copilot >/dev/null 2>&1; then @@ -144,7 +173,9 @@ if ! command -v copilot >/dev/null 2>&1; then # Detect shell rc file case "$(basename "${SHELL:-/bin/sh}")" in zsh) RC_FILE="$HOME/.zshrc" ;; - bash) RC_FILE="$HOME/.bashrc" ;; + bash) + if [ -f "$HOME/.bashrc" ]; then RC_FILE="$HOME/.bashrc"; else RC_FILE="$HOME/.bash_profile"; fi + ;; *) RC_FILE="$HOME/.profile" ;; esac