Safeguard
Supply Chain Security•

Inventory Every GitHub Action in Your Org, Then Enforce SHA Pins

Two compromised actions came back in September with malicious tags intact. Inventory action refs across your org, trace past runs, then require full commit SHAs.

Safeguard Security Team
6 min read

On 16 September 2026, actions-cool/issues-helper and actions-cool/maintain-one-comment became reachable again, four months after GitHub disabled them over a Mini Shai-Hulud compromise. Socket reported on 24 September that their tags still pointed at the May payload, so every workflow that referenced them by tag ran it again until both were disabled once more around 25 September. On 5 October, @subql/common 5.8.3 showed a related gap: a changed release step downloaded a tarball and published it in place of the project's own build.

Can you say which of your repositories use a given action, and whether any ran it last week? This guide builds that answer with gh and the REST API, then turns on GitHub's policy that refuses actions not pinned to a full commit SHA.

What's affected

Any organization whose workflows reference third-party actions by tag (@v2) or branch (@main). GitHub's documentation is direct about the mechanism: a tag or branch reference resolves to whatever commit it points to at run time, so a repointed tag runs new code with your secrets and your GITHUB_TOKEN.

Socket noted that GitHub's dependency graph listed about 15,000 dependents for issues-helper alone. Two limits: the pinning policy covers actions, but GitHub says reusable workflows "can still be referenced by tag", and a SHA pin only protects you if the pinned commit was clean to begin with.

How to check if you're exposed

1. Pull every uses: line from every repository. This reads workflow files on each default branch through the contents API.

ORG=your-org
gh repo list "$ORG" --limit 1000 --json nameWithOwner --jq '.[].nameWithOwner' > repos.txt

while read -r REPO; do
  gh api "repos/$REPO/contents/.github/workflows" \
    --jq '.[] | select(.type=="file") | .path' 2>/dev/null |
  while read -r WF; do
    gh api -H "Accept: application/vnd.github.raw+json" "repos/$REPO/contents/$WF" |
      grep -oE 'uses:[[:space:]]*["'\'']?[^[:space:]"'\''#]+' |
      sed -E 's/uses:[[:space:]]*["'\'']?//' |
      awk -v r="$REPO" -v w="$WF" '{print r "\t" w "\t" $0}'
  done
done < repos.txt > uses.tsv

2. Classify each reference. A 40 character hex ref is a full SHA. Local actions start with ./. Everything else is mutable.

awk -F'\t' 'BEGIN{OFS="\t"} {
  n = split($3, a, "@"); ref = a[n]
  if ($3 ~ /^\.\// || $3 ~ /^docker:/) kind = "local"
  else if (length(ref) == 40 && ref ~ /^[0-9a-f]+$/) kind = "sha"
  else kind = "mutable"
  print kind, $0 }' uses.tsv > classified.tsv

cut -f1 classified.tsv | sort | uniq -c

3. Split mutable refs into tags and branches. The Git references API returns a ref only if it exists, so a lookup under tags/ and then heads/ tells you which one you are trusting.

awk -F'\t' '$1=="mutable"{print $4}' classified.tsv | sort -u |
while read -r U; do
  ACTION=${U%@*}; REF=${U##*@}
  SRC=$(echo "$ACTION" | cut -d/ -f1-2)
  if gh api "repos/$SRC/git/ref/tags/$REF" --silent 2>/dev/null; then echo -e "tag\t$U"
  elif gh api "repos/$SRC/git/ref/heads/$REF" --silent 2>/dev/null; then echo -e "branch\t$U"
  else echo -e "missing\t$U"; fi
done > mutable-kinds.tsv

A missing result is worth a look on its own. It can mean the source repository is disabled, as issues-helper is today.

4. Find one specific action fast. When the next advisory lands, filter the inventory:

grep -E 'actions-cool/(issues-helper|maintain-one-comment)@' classified.tsv

5. Check whether it actually ran. For each repository and workflow file the inventory flagged, list runs inside the exposure window and search their logs:

gh run list -R "$REPO" --workflow "$WF_FILE" --created "2026-09-16..2026-09-25" \
  --json databaseId,createdAt,conclusion,url
gh run view "$RUN_ID" -R "$REPO" --log | grep -n 'actions-cool'

Socket's tell for this incident: runs that had failed at job setup since May and suddenly succeeded, with durations jumping from seconds to minutes.

What to do now

  1. Replace mutable refs with full SHAs. GitHub's secure use guide says to confirm the SHA comes from the action's own repository, not a fork. Keep the tag in a trailing comment so reviewers can read it.

  2. Read your current Actions policy first. The PUT endpoint requires enabled_repositories, so you need to send back the value you already have.

    gh api orgs/$ORG/actions/permissions
    
  3. Turn on the pinning requirement. The sha_pinning_required field is described in the REST docs as "Whether actions must be pinned to a full-length commit SHA."

    gh api -X PUT orgs/$ORG/actions/permissions \
      -f enabled_repositories=all -f allowed_actions=selected \
      -F sha_pinning_required=true
    

    Replace all and selected with the values step 2 returned. In the UI the setting is Settings, Actions, General, "Require actions to be pinned to a full-length commit SHA". Enterprise owners have the same option under enterprise Actions policies, and repositories have it on PUT /repos/OWNER/REPO/actions/permissions.

  4. Block known bad actions by name. If you use the "Allow OWNER, and select non-OWNER" policy, add an entry with a ! prefix, such as !actions-cool/issues-helper@*. GitHub evaluates the blocklist last.

  5. Look at workflow lockfiles. GitHub's lockfile feature, in public preview, writes .github/workflows/actions.lock with the resolved commit for every action, including transitive ones. Locked dependencies satisfy the pinning policy without editing workflow files.

  6. Keep the inventory. GitHub notes Dependabot alerts don't fire for SHA-pinned actions, so rerun step 4 against new advisories.

Why it matters

Disabling a compromised repository paused the attack. It didn't fix the tags, and every tag reference stayed one switch away from running the payload. A pinned SHA turns open ended trust into a reviewed, fixed version.

Roll the policy out after the inventory, not before. According to GitHub's changelog, any workflow that uses an unpinned action will fail once the policy is on. And remember what the policy can't see: the SubQuery swap was a plain run step calling curl, with no uses: line involved. Pinning governs the actions you import. Reviewing what your release jobs download is a separate job.

Try it yourself

Run steps 1 and 2 against a single repository you own. Count how many references land in mutable, then check whether any of them point at a branch rather than a tag.

Click through it at your own pace, or open it full screen.

Quick check

Does the SHA pinning policy cover reusable workflows?

No. GitHub's documentation says reusable workflows can still be referenced by tag when the policy is on.

Why read the current permissions before calling the PUT endpoint?

enabled_repositories is a required body field. Sending a different value would change which repositories can run Actions at all.

Would the pinning policy have stopped the @subql/common 5.8.3 release?

No. The tarball came from a shell step using curl, not from an action reference.

Sources

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.