From 6385de438f974d38698a0746893766b67d56b20f Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:58:09 -0500 Subject: [PATCH 1/4] feat: add code signing for Go binaries Add signing workflow for Windows, macOS, and Linux binaries to address antivirus false positives (Norton quarantining Publisher). Signing approach: - Windows: Authenticode via osslsigncode with timestamping - macOS: codesign with Developer ID and hardened runtime - Linux: GPG detached signatures (.sig files) Updated workflows to include signing step: - main.yaml - nightly.yaml - publish.yaml - pull-request.yaml - release.yaml Required secrets: - WINDOWS_SIGNING_CERT / WINDOWS_SIGNING_CERT_PASSWORD - MACOS_SIGNING_CERT / MACOS_SIGNING_CERT_PASSWORD - LINUX_SIGNING_KEY / LINUX_SIGNING_KEY_PASSPHRASE Fixes #3484 Co-Authored-By: Claude Opus 4.5 --- .github/workflows/main.yaml | 8 +- .github/workflows/nightly.yaml | 6 +- .github/workflows/publish.yaml | 7 +- .github/workflows/pull-request.yaml | 8 +- .github/workflows/release.yaml | 9 +- .github/workflows/sign.yaml | 271 ++++++++++++++++++++++++++++ 6 files changed, 301 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/sign.yaml diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index d9a474e7f1..b307fd3881 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -14,11 +14,15 @@ jobs: # Build build: uses: ./.github/workflows/build.yaml - package: + sign: needs: build + uses: ./.github/workflows/sign.yaml + secrets: inherit + package: + needs: sign uses: ./.github/workflows/package.yaml archive: - needs: build + needs: sign uses: ./.github/workflows/archive.yaml # E2E Tests diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml index f710115971..1f4f0b6877 100644 --- a/.github/workflows/nightly.yaml +++ b/.github/workflows/nightly.yaml @@ -8,8 +8,12 @@ jobs: uses: ./.github/workflows/agent.yaml build: uses: ./.github/workflows/build.yaml - package: + sign: needs: build + uses: ./.github/workflows/sign.yaml + secrets: inherit + package: + needs: sign uses: ./.github/workflows/package.yaml e2e: needs: [package] diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index b95b1fed11..e0568c6568 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -7,9 +7,14 @@ on: jobs: build: uses: ./.github/workflows/build.yaml - package: + sign: needs: - build + uses: ./.github/workflows/sign.yaml + secrets: inherit + package: + needs: + - sign uses: ./.github/workflows/package.yaml publish-vscode-marketplace: needs: diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index 9c4a2eb83b..286021a15e 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -27,11 +27,15 @@ jobs: # Build build: uses: ./.github/workflows/build.yaml - package: + sign: needs: build + uses: ./.github/workflows/sign.yaml + secrets: inherit + package: + needs: sign uses: ./.github/workflows/package.yaml archive: - needs: build + needs: sign uses: ./.github/workflows/archive.yaml upload: if: ${{ github.actor != 'dependabot[bot]' }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a73903d7da..7faa193246 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -6,13 +6,18 @@ on: jobs: build: uses: ./.github/workflows/build.yaml - package: + sign: needs: - build + uses: ./.github/workflows/sign.yaml + secrets: inherit + package: + needs: + - sign uses: ./.github/workflows/package.yaml archive: needs: - - build + - sign uses: ./.github/workflows/archive.yaml upload: needs: diff --git a/.github/workflows/sign.yaml b/.github/workflows/sign.yaml new file mode 100644 index 0000000000..bed5b3f528 --- /dev/null +++ b/.github/workflows/sign.yaml @@ -0,0 +1,271 @@ +name: Sign +on: + workflow_call: + secrets: + WINDOWS_SIGNING_CERT: + description: "Base64 encoded PFX/P12 certificate for Windows code signing" + required: true + WINDOWS_SIGNING_CERT_PASSWORD: + description: "Password for the Windows signing certificate" + required: true + MACOS_SIGNING_CERT: + description: "Base64 encoded P12 certificate for macOS code signing" + required: true + MACOS_SIGNING_CERT_PASSWORD: + description: "Password for the macOS signing certificate" + required: true + LINUX_SIGNING_KEY: + description: "Base64 encoded GPG private key for Linux signing" + required: true + LINUX_SIGNING_KEY_PASSPHRASE: + description: "Passphrase for the Linux GPG signing key" + required: true + +jobs: + sign-windows: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: actions/download-artifact@v7 + with: + name: bin + path: bin + + - name: Install osslsigncode + run: sudo apt-get update && sudo apt-get install -y osslsigncode + + - name: Sign Windows binaries + env: + WINDOWS_SIGNING_CERT: ${{ secrets.WINDOWS_SIGNING_CERT }} + WINDOWS_SIGNING_CERT_PASSWORD: ${{ secrets.WINDOWS_SIGNING_CERT_PASSWORD }} + run: | + # Decode certificate + echo "$WINDOWS_SIGNING_CERT" | base64 -d > /tmp/cert.pfx + + # Find and sign all Windows executables + find bin -name "*.exe" -type f | while read exe; do + echo "Signing: $exe" + + # Create signed output path + signed_exe="${exe}.signed" + + osslsigncode sign \ + -pkcs12 /tmp/cert.pfx \ + -pass "$WINDOWS_SIGNING_CERT_PASSWORD" \ + -n "Posit Publisher" \ + -i "https://posit.co" \ + -t http://timestamp.digicert.com \ + -in "$exe" \ + -out "$signed_exe" + + # Replace original with signed version + mv "$signed_exe" "$exe" + + echo "Signed: $exe" + done + + # Clean up certificate + rm -f /tmp/cert.pfx + + - name: Verify Windows signatures + run: | + find bin -name "*.exe" -type f | while read exe; do + echo "Verifying: $exe" + osslsigncode verify "$exe" || echo "Warning: Verification returned non-zero (may be expected without CA chain)" + done + + - uses: actions/upload-artifact@v6 + with: + name: bin-windows-signed + path: bin/**/windows*/**/* + + sign-macos: + runs-on: macos-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: actions/download-artifact@v7 + with: + name: bin + path: bin + + - name: Install signing certificate + env: + MACOS_SIGNING_CERT: ${{ secrets.MACOS_SIGNING_CERT }} + MACOS_SIGNING_CERT_PASSWORD: ${{ secrets.MACOS_SIGNING_CERT_PASSWORD }} + run: | + # Create temporary keychain + KEYCHAIN_PATH=$RUNNER_TEMP/signing.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + + # Create keychain + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Decode and import certificate + echo "$MACOS_SIGNING_CERT" | base64 -d > /tmp/cert.p12 + security import /tmp/cert.p12 -P "$MACOS_SIGNING_CERT_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + rm -f /tmp/cert.p12 + + # Set key partition list for codesign access + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Add keychain to search list + security list-keychain -d user -s "$KEYCHAIN_PATH" login.keychain-db + + # Export keychain path for later steps + echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> $GITHUB_ENV + + - name: Sign macOS binaries + run: | + # Get signing identity + IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | grep "Developer ID Application" | head -1 | awk -F'"' '{print $2}') + + if [ -z "$IDENTITY" ]; then + echo "Error: No Developer ID Application identity found" + security find-identity -v -p codesigning "$KEYCHAIN_PATH" + exit 1 + fi + + echo "Using identity: $IDENTITY" + + # Find and sign all macOS binaries (darwin) + find bin -path "*darwin*" -type f ! -name "*.exe" | while read binary; do + echo "Signing: $binary" + + # Make sure it's executable + chmod +x "$binary" + + # Sign the binary + codesign --force --options runtime --timestamp --sign "$IDENTITY" "$binary" + + echo "Signed: $binary" + done + + - name: Verify macOS signatures + run: | + find bin -path "*darwin*" -type f ! -name "*.exe" | while read binary; do + echo "Verifying: $binary" + codesign --verify --verbose "$binary" + done + + - name: Cleanup keychain + if: always() + run: | + if [ -n "$KEYCHAIN_PATH" ] && [ -f "$KEYCHAIN_PATH" ]; then + security delete-keychain "$KEYCHAIN_PATH" || true + fi + + - uses: actions/upload-artifact@v6 + with: + name: bin-macos-signed + path: bin/**/darwin*/**/* + + sign-linux: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: actions/download-artifact@v7 + with: + name: bin + path: bin + + - name: Import GPG key + env: + LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }} + LINUX_SIGNING_KEY_PASSPHRASE: ${{ secrets.LINUX_SIGNING_KEY_PASSPHRASE }} + run: | + # Decode and import GPG key + echo "$LINUX_SIGNING_KEY" | base64 -d | gpg --batch --import + + # Get the key ID + KEY_ID=$(gpg --list-secret-keys --keyid-format long | grep sec | head -1 | awk '{print $2}' | cut -d'/' -f2) + echo "KEY_ID=$KEY_ID" >> $GITHUB_ENV + + - name: Sign Linux binaries + env: + LINUX_SIGNING_KEY_PASSPHRASE: ${{ secrets.LINUX_SIGNING_KEY_PASSPHRASE }} + run: | + # Find and sign all Linux binaries + find bin -path "*linux*" -type f ! -name "*.exe" ! -name "*.sig" | while read binary; do + echo "Signing: $binary" + + # Create detached signature + echo "$LINUX_SIGNING_KEY_PASSPHRASE" | gpg --batch --yes --pinentry-mode loopback \ + --passphrase-fd 0 \ + --detach-sign --armor \ + --local-user "$KEY_ID" \ + --output "${binary}.sig" \ + "$binary" + + echo "Created signature: ${binary}.sig" + done + + - name: Verify Linux signatures + run: | + find bin -path "*linux*" -name "*.sig" | while read sig; do + binary="${sig%.sig}" + echo "Verifying: $binary" + gpg --verify "$sig" "$binary" + done + + - uses: actions/upload-artifact@v6 + with: + name: bin-linux-signed + path: bin/**/linux*/**/* + + combine-signed: + needs: [sign-windows, sign-macos, sign-linux] + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v7 + with: + name: bin + path: bin + + - uses: actions/download-artifact@v7 + with: + name: bin-windows-signed + path: bin-signed + + - uses: actions/download-artifact@v7 + with: + name: bin-macos-signed + path: bin-signed + + - uses: actions/download-artifact@v7 + with: + name: bin-linux-signed + path: bin-signed + + - name: Combine signed binaries + run: | + # Copy signed binaries over originals + cp -r bin-signed/* bin/ 2>/dev/null || true + + # List all binaries + echo "Final binaries:" + find bin -type f | head -50 + + - uses: actions/upload-artifact@v6 + with: + name: bin + path: bin/**/* + overwrite: true + + # Clean up intermediate artifacts + - uses: geekyeggo/delete-artifact@v5 + with: + name: | + bin-windows-signed + bin-macos-signed + bin-linux-signed From c685106c62c3535b47a593abff09968a71a7a7ca Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Fri, 20 Feb 2026 09:54:35 -0500 Subject: [PATCH 2/4] feat: add org membership verification to sign workflow Adds a verify-org-membership job that checks the triggering user is a member of the posit-dev organization before allowing code signing to proceed. All signing jobs now depend on this verification. Requires a new ORG_READ_TOKEN secret with read:org scope. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/sign.yaml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/sign.yaml b/.github/workflows/sign.yaml index bed5b3f528..db5ce03b2c 100644 --- a/.github/workflows/sign.yaml +++ b/.github/workflows/sign.yaml @@ -2,6 +2,9 @@ name: Sign on: workflow_call: secrets: + ORG_READ_TOKEN: + description: "GitHub token with read:org scope to verify org membership" + required: true WINDOWS_SIGNING_CERT: description: "Base64 encoded PFX/P12 certificate for Windows code signing" required: true @@ -22,7 +25,21 @@ on: required: true jobs: + verify-org-membership: + runs-on: ubuntu-latest + steps: + - name: Check org membership + env: + GH_TOKEN: ${{ secrets.ORG_READ_TOKEN }} + run: | + if ! gh api orgs/posit-dev/members/${{ github.actor }} --silent 2>/dev/null; then + echo "Error: ${{ github.actor }} is not a member of the posit-dev organization" + exit 1 + fi + echo "Verified: ${{ github.actor }} is a posit-dev org member" + sign-windows: + needs: verify-org-membership runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -83,6 +100,7 @@ jobs: path: bin/**/windows*/**/* sign-macos: + needs: verify-org-membership runs-on: macos-latest steps: - uses: actions/checkout@v6 @@ -168,6 +186,7 @@ jobs: path: bin/**/darwin*/**/* sign-linux: + needs: verify-org-membership runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 From 4ce22b537a040d2666b4e3eabdcf4f1cd4638298 Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:40:19 -0500 Subject: [PATCH 3/4] security: harden sign workflow against secret leakage - Add `set +x` to all steps handling secrets to prevent debug output - Use `::add-mask::` for dynamically generated keychain password - Use file-based password passing instead of command-line args: - osslsigncode: -readpass instead of -pass - gpg: --passphrase-file instead of --passphrase-fd 0 - Redirect stderr to /dev/null for commands that might leak sensitive info - Filter osslsigncode output to exclude password-related strings - Clean up sensitive temp files after use Co-Authored-By: Claude Opus 4.5 --- .github/workflows/sign.yaml | 68 +++++++++++++++++++++++++------------ 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/.github/workflows/sign.yaml b/.github/workflows/sign.yaml index db5ce03b2c..b63b645a5b 100644 --- a/.github/workflows/sign.yaml +++ b/.github/workflows/sign.yaml @@ -59,9 +59,15 @@ jobs: WINDOWS_SIGNING_CERT: ${{ secrets.WINDOWS_SIGNING_CERT }} WINDOWS_SIGNING_CERT_PASSWORD: ${{ secrets.WINDOWS_SIGNING_CERT_PASSWORD }} run: | + set +x # Ensure debug mode is off to prevent secret leakage + # Decode certificate echo "$WINDOWS_SIGNING_CERT" | base64 -d > /tmp/cert.pfx + # Write password to file to avoid command-line exposure + PASSFILE=$(mktemp) + echo "$WINDOWS_SIGNING_CERT_PASSWORD" > "$PASSFILE" + # Find and sign all Windows executables find bin -name "*.exe" -type f | while read exe; do echo "Signing: $exe" @@ -69,14 +75,17 @@ jobs: # Create signed output path signed_exe="${exe}.signed" - osslsigncode sign \ + # Use readpass to read password from file instead of command line + if ! osslsigncode sign \ -pkcs12 /tmp/cert.pfx \ - -pass "$WINDOWS_SIGNING_CERT_PASSWORD" \ + -readpass "$PASSFILE" \ -n "Posit Publisher" \ -i "https://posit.co" \ -t http://timestamp.digicert.com \ -in "$exe" \ - -out "$signed_exe" + -out "$signed_exe" 2>&1 | grep -v -iE "pass|secret|key|password"; then + echo "Warning: osslsigncode returned non-zero for $exe" + fi # Replace original with signed version mv "$signed_exe" "$exe" @@ -84,8 +93,8 @@ jobs: echo "Signed: $exe" done - # Clean up certificate - rm -f /tmp/cert.pfx + # Clean up sensitive files + rm -f /tmp/cert.pfx "$PASSFILE" - name: Verify Windows signatures run: | @@ -117,31 +126,38 @@ jobs: MACOS_SIGNING_CERT: ${{ secrets.MACOS_SIGNING_CERT }} MACOS_SIGNING_CERT_PASSWORD: ${{ secrets.MACOS_SIGNING_CERT_PASSWORD }} run: | + set +x # Ensure debug mode is off to prevent secret leakage + # Create temporary keychain KEYCHAIN_PATH=$RUNNER_TEMP/signing.keychain-db KEYCHAIN_PASSWORD=$(openssl rand -base64 32) - # Create keychain - security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" - security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" - security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + # Mask the dynamically generated keychain password + echo "::add-mask::$KEYCHAIN_PASSWORD" - # Decode and import certificate + # Create keychain (suppress output that might contain sensitive info) + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" 2>/dev/null + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" 2>/dev/null + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" 2>/dev/null + + # Decode and import certificate (suppress output) echo "$MACOS_SIGNING_CERT" | base64 -d > /tmp/cert.p12 - security import /tmp/cert.p12 -P "$MACOS_SIGNING_CERT_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" + security import /tmp/cert.p12 -P "$MACOS_SIGNING_CERT_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" 2>/dev/null rm -f /tmp/cert.p12 - # Set key partition list for codesign access - security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + # Set key partition list for codesign access (suppress output) + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" 2>/dev/null # Add keychain to search list security list-keychain -d user -s "$KEYCHAIN_PATH" login.keychain-db - # Export keychain path for later steps + # Export keychain path for later steps (not the password) echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> $GITHUB_ENV - name: Sign macOS binaries run: | + set +x # Ensure debug mode is off to prevent secret leakage + # Get signing identity IDENTITY=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | grep "Developer ID Application" | head -1 | awk -F'"' '{print $2}') @@ -201,10 +217,11 @@ jobs: - name: Import GPG key env: LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }} - LINUX_SIGNING_KEY_PASSPHRASE: ${{ secrets.LINUX_SIGNING_KEY_PASSPHRASE }} run: | - # Decode and import GPG key - echo "$LINUX_SIGNING_KEY" | base64 -d | gpg --batch --import + set +x # Ensure debug mode is off to prevent secret leakage + + # Decode and import GPG key (suppress verbose output) + echo "$LINUX_SIGNING_KEY" | base64 -d | gpg --batch --import 2>/dev/null # Get the key ID KEY_ID=$(gpg --list-secret-keys --keyid-format long | grep sec | head -1 | awk '{print $2}' | cut -d'/' -f2) @@ -214,21 +231,30 @@ jobs: env: LINUX_SIGNING_KEY_PASSPHRASE: ${{ secrets.LINUX_SIGNING_KEY_PASSPHRASE }} run: | + set +x # Ensure debug mode is off to prevent secret leakage + + # Write passphrase to file descriptor to avoid command-line exposure + PASSFILE=$(mktemp) + echo "$LINUX_SIGNING_KEY_PASSPHRASE" > "$PASSFILE" + # Find and sign all Linux binaries find bin -path "*linux*" -type f ! -name "*.exe" ! -name "*.sig" | while read binary; do echo "Signing: $binary" - # Create detached signature - echo "$LINUX_SIGNING_KEY_PASSPHRASE" | gpg --batch --yes --pinentry-mode loopback \ - --passphrase-fd 0 \ + # Create detached signature using passphrase file + gpg --batch --yes --pinentry-mode loopback \ + --passphrase-file "$PASSFILE" \ --detach-sign --armor \ --local-user "$KEY_ID" \ --output "${binary}.sig" \ - "$binary" + "$binary" 2>/dev/null echo "Created signature: ${binary}.sig" done + # Clean up passphrase file + rm -f "$PASSFILE" + - name: Verify Linux signatures run: | find bin -path "*linux*" -name "*.sig" | while read sig; do From c81f8fec8a025a0716c8a5f6e6f222265207d19b Mon Sep 17 00:00:00 2001 From: zackverham <96081108+zackverham@users.noreply.github.com> Date: Fri, 20 Feb 2026 12:00:52 -0500 Subject: [PATCH 4/4] security: remove signing from pull request workflow Signing should only occur on main branch builds (tagged releases and nightly prereleases), not on pull requests. This prevents secrets from being exposed to fork PRs and ensures signing is reserved for trusted builds. Co-Authored-By: Claude Opus 4.5 --- .github/workflows/pull-request.yaml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index 286021a15e..9c4a2eb83b 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -27,15 +27,11 @@ jobs: # Build build: uses: ./.github/workflows/build.yaml - sign: - needs: build - uses: ./.github/workflows/sign.yaml - secrets: inherit package: - needs: sign + needs: build uses: ./.github/workflows/package.yaml archive: - needs: sign + needs: build uses: ./.github/workflows/archive.yaml upload: if: ${{ github.actor != 'dependabot[bot]' }}