Skip to content

ci: GHA workflow security cleanup#703

Open
emptyhammond wants to merge 4 commits into
mainfrom
worktree-fixup-workflows
Open

ci: GHA workflow security cleanup#703
emptyhammond wants to merge 4 commits into
mainfrom
worktree-fixup-workflows

Conversation

@emptyhammond
Copy link
Copy Markdown

@emptyhammond emptyhammond commented May 26, 2026

Routine hygiene pass over the GitHub Actions workflows in this repo, addressing findings from a workflow security audit. Changes are split into four commits, one per finding type:

  • Disable credential persistence on actions/checkout steps so the default GITHUB_TOKEN is not left in the local git config after checkout.
  • Scope each job's permissions explicitly: top-level permissions: {}, with each job granted only the GITHUB_TOKEN scopes it actually needs.
  • Move workflow-context expansions (${{ matrix.protocol }}, step outputs) out of run: shell source and into env: bindings.
  • Pin all third-party actions to commit SHAs (with the tag preserved as a comment) so an upstream tag move can't silently change what runs in CI.

No behavioural changes intended — the workflows run the same checks against the same inputs.

Summary by CodeRabbit

  • Chores
    • Pinned GitHub Actions and third-party actions to specific commit SHAs for enhanced security and build consistency across multiple workflows.
    • Added explicit permission scoping to workflows to enforce principle of least privilege.
    • Updated workflow configurations for improved environment variable handling and credential management.

Review Change Stack

Set persist-credentials: false on every actions/checkout step so the
default GITHUB_TOKEN is not left in the local git config after checkout.
None of these jobs push or write back through the checked-out clone, so
keeping the credential available was unused exposure.
Add a top-level `permissions: {}` to each workflow and grant each job
only the scopes it actually needs:

  - check / integration-test: `contents: read` for checkout,
    `checks: write` for the JUnit test reporter.
  - docs: `contents: read` for checkout, alongside the existing
    `deployments: write` and `id-token: write` used by the SDK
    upload and AWS OIDC steps.
  - features: `contents: read` for the reusable workflow it calls.

This replaces the default token scope (which depends on repo settings
and historically defaults to read/write across the board).
Move workflow-context interpolations (`${{ ... }}`) out of `run:`
shell source and into per-step `env:` bindings. The shell now sees
ordinary environment variables instead of pre-expanded text, so values
sourced from matrix entries or step outputs cannot terminate the
surrounding quotes and inject shell.

  - docs.yml: `steps.sdk-upload-prempt.outputs.url-base` (a step
    output) becomes $BASE_URL. `github.sha` is dropped in favour of
    the $GITHUB_SHA env var GitHub already exports to every step.
  - integration-test.yml: `matrix.protocol` and the JSON/msgpack
    derivation become $PROTOCOL and $ABLY_PROTOCOL.
Replace tag-based action references with the matching commit SHA, with
the original tag preserved as a trailing comment. Tags are mutable and
can be repointed by the upstream owner at any time, so pinning to SHAs
keeps the running code stable until we deliberately bump it.

Pinned in this commit:

  - actions/checkout                  (v2)
  - actions/setup-go                  (v2)
  - ably/test-observability-action    (v1)
  - dorny/test-reporter               (v1)
  - ably/sdk-upload-action            (v2)
  - aws-actions/configure-aws-credentials (v1)
  - ably/features/.github/workflows/sdk-features.yml (main)

The pinned SHAs were taken from each repository's current resolution of
the tag/branch, so behaviour matches what was running before this
commit. Future updates flow through a normal version bump PR.
@emptyhammond emptyhammond requested a review from sacOO7 May 26, 2026 12:16
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 26, 2026

Walkthrough

This PR hardens four GitHub Actions workflows by pinning third-party actions to specific commit SHAs instead of floating version tags. Workflow and job-level permissions are explicitly declared. The integration test workflow refactors environment variable handling and the docs workflow updates build output references.

Changes

CI/CD Workflow Action Pinning and Permissions

Layer / File(s) Summary
Check workflow action pinning and permissions
.github/workflows/check.yml
actions/checkout, actions/setup-go, ably/test-observability-action, and dorny/test-reporter are pinned to commit SHAs with explicit permission declarations.
Features workflow permissions and reusable workflow pinning
.github/workflows/features.yml
Explicit workflow/job permissions (permissions: {} and contents: read) are added; the reusable workflow reference is pinned from @main to a specific commit SHA.
Documentation workflow action pinning and output reference updates
.github/workflows/docs.yml
actions/checkout, actions/setup-go, ably/sdk-upload-action, and aws-actions/configure-aws-credentials are pinned to commit SHAs. The gopages build step now references steps.sdk-upload-prempt.outputs.url-base for BASE_URL and uses GITHUB_SHA for the source-link URL instead of github.sha.
Integration test workflow permissions, action pinning, and environment variable refactoring
.github/workflows/integration-test.yml
Explicit permissions (contents: read, checks: write) are declared. actions/checkout and actions/setup-go are pinned to commit SHAs with credential persistence disabled. PROTOCOL and ABLY_PROTOCOL are refactored from inline exports to step-level environment variables. Observability and test-reporter actions are pinned to commit SHAs.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Poem

🐰 Actions pinned down so tight,
No floating tags in the night,
Permissions blessed with care so true,
Workflows hardened, shiny new! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'ci: GHA workflow security cleanup' directly and concisely summarizes the main change: a security-focused update to GitHub Actions workflows addressing audit findings.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-fixup-workflows

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/features.yml:
- Line 18: Replace the broad "secrets: inherit" entry in the caller workflow
with an explicit mapping for only the secret consumed by the reusable workflow
(the reusable workflow ably/features/.github/workflows/sdk-features.yml declares
on.workflow_call.secrets and uses ABLY_AWS_ACCOUNT_ID_SDK); remove "secrets:
inherit" and add a single secrets mapping that passes ABLY_AWS_ACCOUNT_ID_SDK
from the caller (ABLY_AWS_ACCOUNT_ID_SDK: ${{ secrets.ABLY_AWS_ACCOUNT_ID_SDK
}}), ensuring least-privilege secret propagation to the reusable workflow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ff4a2fe3-03d7-4274-8d09-d3a44c0a26dd

📥 Commits

Reviewing files that changed from the base of the PR and between 439d9f3 and 7c06abf.

📒 Files selected for processing (4)
  • .github/workflows/check.yml
  • .github/workflows/docs.yml
  • .github/workflows/features.yml
  • .github/workflows/integration-test.yml

uses: ably/features/.github/workflows/sdk-features.yml@6b3fc7a8ede2ebdd7a6325314f3a96c6466f1453 # main
with:
repository-name: ably-go
secrets: inherit
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Fetch the reusable workflow definition to identify required secrets
gh api repos/ably/features/contents/.github/workflows/sdk-features.yml --jq '.content' | base64 -d | grep -A 10 'secrets:'

Repository: ably/ably-go

Length of output: 241


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Caller workflow snippet (.github/workflows/features.yml around line 18) ==="
ls -la .github/workflows || true
nl -ba .github/workflows/features.yml | sed -n '1,120p' | sed -n '10,60p'

echo
echo "=== Reusable workflow: extract full on.workflow_call.secrets block ==="
REPO="ably/ably-go"
TARGET="ably/features/contents/.github/workflows/sdk-features.yml"

# Fetch content from GitHub via gh (as in original comment) and decode
CONTENT_B64="$(gh api "repos/${TARGET}" --jq '.content' 2>/dev/null || true)"
if [[ -z "${CONTENT_B64}" ]]; then
  echo "Failed to fetch reusable workflow via gh api."
  exit 1
fi

WORKFLOW_YML="$(printf '%s' "${CONTENT_B64}" | base64 -d)"
# Print only the secrets block context if possible
echo "${WORKFLOW_YML}" | rg -n "on:\s*$|workflow_call:|secrets:" -n || true

echo
echo "---- secrets: block (from first secrets: to next top-level key) ----"
# Use awk to roughly extract the block starting at "secrets:" with indentation, until indentation decreases.
# This is robust enough for workflow YAML where secrets are under on.workflow_call with consistent indent.
printf '%s' "${WORKFLOW_YML}" | awk '
  BEGIN{inblock=0; blockindent=-1}
  {
    # detect "secrets:" line
    if ($0 ~ /^[[:space:]]*secrets:[[:space:]]*$/) {
      # compute indent
      match($0, /^[[:space:]]*/); ind=RLENGTH;
      inblock=1; blockindent=ind;
      print "---- START ----";
      print FNR ":" $0;
      next;
    }
    if (inblock==1) {
      # stop when indentation goes back to secrets indent level or less AND line looks like a new key
      match($0, /^[[:space:]]*/); ind=RLENGTH;
      if (ind <= blockindent && $0 ~ /^[[:space:]]*[A-Za-z0-9_]+:[[:space:]]*$/) {
        print "---- END ----";
        inblock=0;
      } else {
        print FNR ":" $0;
      }
    }
  }
' | sed -n '1,200p'

echo
echo "=== List all secrets with required: true ==="
# Extract secret names under the secrets block with required: true
printf '%s' "${WORKFLOW_YML}" | awk '
  BEGIN{in=0}
  /^[[:space:]]*secrets:[[:space:]]*$/ {in=1; next}
  in==1 {
    # stop when leaving secrets block (indent decreases and line is a new key)
    match($0, /^[[:space:]]*/); ind=RLENGTH;
    if ($0 ~ /^[[:space:]]*[A-Za-z0-9_]+:[[:space:]]*$/ && ind <= 2) {in=0}
    # capture secret key lines like "  SECRET_NAME:" (indent 4-ish)
    if ($0 ~ /^[[:space:]]*([A-Za-z0-9_]+):[[:space:]]*$/) {
      key=$0; gsub(/^[[:space:]]+/,"",key); gsub(/:.*$/,"",key);
      lastkey=key
    }
    if ($0 ~ /required:[[:space:]]*true/) {
      if (lastkey!="") print lastkey
    }
  }
' | sort -u

echo
echo "=== List all secrets keys (regardless of required) ==="
printf '%s' "${WORKFLOW_YML}" | awk '
  BEGIN{in=0}
  /^[[:space:]]*secrets:[[:space:]]*$/ {in=1; next}
  in==1 {
    # capture secret key lines
    if ($0 ~ /^[[:space:]]*([A-Za-z0-9_]+):[[:space:]]*$/) {
      key=$0; gsub(/^[[:space:]]+/,"",key); gsub(/:.*$/,"",key);
      print key
    }
    # naive stop condition: leaving the secrets mapping when indent decreases sharply
    match($0, /^[[:space:]]*/); ind=RLENGTH;
    if ($0 ~ /^[A-Za-z0-9_]+:[[:space:]]*$/ && ind==0) in=0
  }
' | sort -u

Repository: ably/ably-go

Length of output: 558


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Caller workflow: .github/workflows/features.yml (lines 1-120) ==="
cat -n .github/workflows/features.yml | sed -n '1,120p'

echo
echo "=== Reusable workflow: fetch full on.workflow_call.secrets block ==="
TARGET="ably/features/contents/.github/workflows/sdk-features.yml"

CONTENT_B64="$(gh api "repos/${TARGET}" --jq '.content' 2>/dev/null || true)"
if [[ -z "${CONTENT_B64}" ]]; then
  echo "Failed to fetch reusable workflow via gh api."
  exit 1
fi

WORKFLOW_YML="$(printf '%s' "${CONTENT_B64}" | base64 -d)"

# Print only the workflow_call -> secrets subtree (best-effort, indentation-based)
# First locate "workflow_call:" and then within it locate "secrets:"
echo "${WORKFLOW_YML}" | awk '
  BEGIN{in_wc=0; in_secrets=0}
  /^[[:space:]]*workflow_call:[[:space:]]*$/ {in_wc=1; wc_indent=match($0,/^[[:space:]]*/); wc_indent=RLENGTH; print; next}
  in_wc==1 {
    # stop when leaving workflow_call (indent <= wc indent and new top-level key)
    match($0,/^[[:space:]]*/); ind=RLENGTH;
    if (ind<=wc_indent && $0 ~ /^[[:space:]]*[A-Za-z0-9_]+:[[:space:]]*$/) {in_wc=0}
  }
  in_wc==1 {
    if ($0 ~ /^[[:space:]]*secrets:[[:space:]]*$/) {in_secrets=1; secrets_indent=match($0,/^[[:space:]]*/); secrets_indent=RLENGTH}
    if (in_secrets==1) {
      print
      match($0,/^[[:space:]]*/); ind=RLENGTH;
      # stop secrets when indentation returns to secrets' parent level
      if (ind<=secrets_indent && $0 ~ /^[[:space:]]*[A-Za-z0-9_]+:[[:space:]]*$/ && $0 !~ /^[[:space:]]*secrets:[[:space:]]*$/) {in_secrets=0}
    }
  }
'

echo
echo "=== Extract required secrets (required: true) under on.workflow_call.secrets ==="
# Best-effort extraction:
# Track current secret name while inside secrets mapping; print last secret when a line "required: true" appears.
printf '%s' "${WORKFLOW_YML}" | awk '
  BEGIN{in_secrets=0; last_secret=""}
  /^[[:space:]]*secrets:[[:space:]]*$/ {in_secrets=1; next}
  in_secrets==1 {
    # stop when leaving secrets mapping: line with indent <= secrets indent and looks like a new key
    match($0,/^[[:space:]]*/); ind=RLENGTH;
    if ($0 ~ /^[A-Za-z0-9_]+:[[:space:]]*$/) {in_secrets=0; next}
    # detect secret key line like "  MY_SECRET:"
    if (match($0,/^[[:space:]]*([A-Za-z0-9_]+):[[:space:]]*$/, m)) {
      last_secret=m[1]
    }
    # required true
    if ($0 ~ /required:[[:space:]]*true/) {
      if (last_secret!="") print last_secret
    }
  }
' | sort -u

echo
echo "=== Done ==="

Repository: ably/ably-go

Length of output: 790


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Caller workflow snippet ==="
cat -n .github/workflows/features.yml | sed -n '1,80p'

echo
echo "=== Fetch & parse reusable workflow on.workflow_call.secrets ==="
REPO="ably/ably-go"
TARGET="ably/features/contents/.github/workflows/sdk-features.yml"

# Install PyYAML for reliable YAML parsing
python3 - <<'PY' 2>/dev/null || true
PY

pip show pyyaml >/dev/null 2>&1 || pip install -q pyyaml

python3 - <<'PY'
import os, base64, json, subprocess, sys
import yaml

target = "ably/features/contents/.github/workflows/sdk-features.yml"

# Fetch workflow file content via gh api
cmd = ["gh","api",f"repos/ably/features/contents/.github/workflows/sdk-features.yml","--jq",".content"]
try:
    b64 = subprocess.check_output(cmd, text=True).strip()
except subprocess.CalledProcessError as e:
    print("Failed to fetch via gh api:", e, file=sys.stderr)
    sys.exit(1)

if not b64:
    print("No content returned from gh api", file=sys.stderr)
    sys.exit(1)

yml_text = base64.b64decode(b64).decode("utf-8", errors="replace")
data = yaml.safe_load(yml_text) or {}

wc = (data.get("on") or {}).get("workflow_call") or {}
secrets = wc.get("secrets") or {}

required = []
all_secrets = []

# secrets is typically a mapping: { SECRET_NAME: { required: true/false, ... } }
if isinstance(secrets, dict):
    for name, spec in secrets.items():
        all_secrets.append(name)
        if isinstance(spec, dict) and spec.get("required") is True:
            required.append(name)

print("All secrets declared under on.workflow_call.secrets:", sorted(all_secrets))
print("Secrets with required: true:", sorted(required))
PY

Repository: ably/ably-go

Length of output: 679


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import base64, subprocess, sys
target = "ably/features/contents/.github/workflows/sdk-features.yml"
cmd = ["gh","api",f"repos/ably/features/contents/.github/workflows/sdk-features.yml","--jq",".content"]
b64 = subprocess.check_output(cmd, text=True).strip()
yml = base64.b64decode(b64).decode("utf-8","replace")
print("=== Raw contains ===")
for s in ["workflow_call","secrets:","ABLY_AWS_ACCOUNT_ID_SDK","required: true"]:
    print(s, "=>", s in yml)

print("\n=== Extract lines around workflow_call ===")
lines = yml.splitlines()
for i,l in enumerate(lines):
    if "workflow_call" in l:
        start=max(0,i-5); end=min(len(lines), i+40)
        print(f"\n--- lines {start+1}-{end} around {i+1} ---")
        for j in range(start,end):
            print(f"{j+1:5d}: {lines[j]}")
        break

print("\n=== Extract lines around ABLY_AWS_ACCOUNT_ID_SDK ===")
for i,l in enumerate(lines):
    if "ABLY_AWS_ACCOUNT_ID_SDK" in l:
        start=max(0,i-10); end=min(len(lines), i+40)
        print(f"\n--- lines {start+1}-{end} around {i+1} ---")
        for j in range(start,end):
            print(f"{j+1:5d}: {lines[j]}")
        break
PY

Repository: ably/ably-go

Length of output: 3423


🌐 Web query:

GitHub reusable workflows secrets: inherit behavior when on.workflow_call.secrets is not declared

💡 Result:

When using the secrets: inherit keyword in a calling workflow, all secrets available to the calling workflow are passed to the reusable workflow [1][2]. If on.workflow_call.secrets is not declared (or if it is declared but does not explicitly list a specific secret), those inherited secrets are still available for use within the reusable workflow [1][2]. Specifically, the behavior works as follows: 1. Inheritance: By specifying secrets: inherit in the jobs.<job_id>.secrets section of the caller workflow, you authorize the reusable workflow to access all repository, environment, and organization-level secrets that the calling workflow itself can access [1][2][3]. 2. Availability: Once inherited, these secrets can be referenced directly within the steps of the reusable workflow (e.g., using ${{ secrets.MY_SECRET }}) [1][2]. You do not need to explicitly declare them in the on.workflow_call.secrets block for them to be usable [1][2]. 3. Scope: Secrets are only passed to the directly called workflow [1][2]. If you have a chain of workflows (A calls B, B calls C), workflow C will only receive secrets from A if they are explicitly passed from A to B and subsequently from B to C [1][2]. This approach simplifies the management of reusable workflows, as it removes the need to define a static list of secrets in the on.workflow_call interface, allowing the reusable workflow to remain agnostic of the specific secrets required by the caller [4][5].

Citations:


Scope secrets: inherit in .github/workflows/features.yml to the reusable workflow’s required secret(s).

The reusable workflow ably/features/.github/workflows/sdk-features.yml declares and uses only ABLY_AWS_ACCOUNT_ID_SDK (on.workflow_call.secrets). Using secrets: inherit passes all caller-accessible secrets, breaking least-privilege.

Update to explicitly pass only what’s needed:

secrets:
  ABLY_AWS_ACCOUNT_ID_SDK: ${{ secrets.ABLY_AWS_ACCOUNT_ID_SDK }}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/features.yml at line 18, Replace the broad "secrets:
inherit" entry in the caller workflow with an explicit mapping for only the
secret consumed by the reusable workflow (the reusable workflow
ably/features/.github/workflows/sdk-features.yml declares
on.workflow_call.secrets and uses ABLY_AWS_ACCOUNT_ID_SDK); remove "secrets:
inherit" and add a single secrets mapping that passes ABLY_AWS_ACCOUNT_ID_SDK
from the caller (ABLY_AWS_ACCOUNT_ID_SDK: ${{ secrets.ABLY_AWS_ACCOUNT_ID_SDK
}}), ensuring least-privilege secret propagation to the reusable workflow.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant