DEV Community

Cover image for Your GitHub profile shows your follower count. It also shows attackers your whole attack surface.
Rudratosh Shastri
Rudratosh Shastri

Posted on

Your GitHub profile shows your follower count. It also shows attackers your whole attack surface.

There's a fun little trend going around right now: wire up a GitHub Action that fetches a stat — your DEV follower count, your latest blog post, your Spotify track — and auto-commits it into your profile README. It looks great. It updates itself. People love it.

I love it too. I also can't stop seeing the other thing it does: it publishes, in a public repo, a working example of how you handle secrets, what permissions your workflows run with, and which third parties you trust with a token. That's not a badge. That's a recon document.

Let me show you what an attacker actually reads when they open your cute little profile workflow.

The workflow you copy-pasted

Most of these follow the same shape. Something like this lives at .github/workflows/update-profile.yml:

on:
  schedule:
    - cron: "0 * * * *"
  workflow_dispatch:

jobs:
  update:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Fetch stats
        run: node fetch-stats.js
        env:
          API_TOKEN: ${{ secrets.API_TOKEN }}
      - name: Commit
        run: |
          git add README.md
          git commit -m "update stats" || true
          git push
Enter fullscreen mode Exit fullscreen mode

Looks harmless. It runs hourly, grabs a number, writes it into your README. Ship it, right?

Here's what someone probing your account sees in those 20 lines.

Tell #1: your default token permissions

If your repo (or org) still uses the default GITHUB_TOKEN permissions, that token is granted read/write to the entire repository for the duration of the job — contents, issues, packages, the lot. This workflow only needs to write one file. But because nobody set permissions:, every step in it — including that node fetch-stats.js running third-party code — runs with a token that can push to your default branch.

An attacker doesn't have to guess your posture. The absence of a permissions: block tells them you're on defaults, which means the widest possible scope. You just told them the lock is the one that shipped in the box.

The fix is one block:

permissions:
  contents: write   # nothing else
Enter fullscreen mode Exit fullscreen mode

Scope it at the job level and every step drops to least privilege. The badge still updates. The token can no longer touch issues, packages, or actions.

Tell #2: checkout@v3, @v2, unpinned everything

uses: actions/checkout@v3 looks fine. It isn't wrong. But a mutable major-version tag means you're running whatever v3 points to today — and you've advertised that you pin to floating tags across the board. If any action in your chain gets compromised upstream (it happens — see the tj-actions/changed-files incident that leaked secrets across thousands of repos in 2025), your hourly cron pulls the poisoned version automatically and runs it with the token from Tell #1.

"Pinned to a version" and "pinned to a commit" are not the same sentence. A tag is a label someone else can move. A SHA is not.

The fix: pin to a full commit SHA.

- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1
Enter fullscreen mode Exit fullscreen mode

Ugly. Boring. Doesn't move under you at 3am.

Tell #3: pull_request_target and the workflows nearby

The profile workflow itself is usually safe on the trigger. But an attacker who's now interested in you goes and reads your other public workflows — and the trend of "automate my profile" tends to travel with a folder full of other copy-pasted actions. The one they're hunting for is pull_request_target, which runs with your repo's secrets on PRs from forks. Combine that with a checkout of the PR's head code and an attacker opens a pull request that exfiltrates your secrets on your own runner. Your profile automation didn't cause it — but it flagged you as someone who copies workflows without reading them, and that's the whole reason they kept looking.

Tell #4: the token in env: and the script that reads it

API_TOKEN: ${{ secrets.API_TOKEN }} hands the secret to node fetch-stats.js as an environment variable. Now the entire behavior of your secret depends on code you probably pasted from a gist. Does fetch-stats.js send that token only to the API you intended? Or does it also log it, or POST it somewhere on error, or pull in 40 transitive npm dependencies any one of which can read process.env? Every dependency in that script is now inside your secret's blast radius, on a schedule, unattended.

The fix: minimum-scope tokens (a read-only, single-purpose PAT — never a classic token with repo scope for a job that just reads a public number), and treat any script that touches a secret as security-critical code, not decoration.

The five-line version of all of this

You don't have to delete the badge. You have to stop shipping the recon doc. Add these to every workflow you run:

permissions:
  contents: write        # the least you need, nothing more

# pin every action to a SHA, not a tag
# use single-purpose, read-only tokens
# never pair pull_request_target with a checkout of untrusted code
Enter fullscreen mode Exit fullscreen mode

That's it. Same badge, same hourly update, none of the "here's my attack surface, gift-wrapped."

The real point

The follower-count trend isn't dangerous because a follower count is sensitive. It's dangerous because it normalizes running unattended, scheduled, token-bearing code that you copied and never audited — and doing it in public, where the config itself is the tell. The badge is fine. The habit it's teaching is the vulnerability.

Go open your .github/workflows/ folder right now. If there's no permissions: block and your actions are pinned to @v3, you've got about five minutes of work to do — and you'll still have the cool profile at the end of it.


Open your profile repo's workflows — do they have an explicit permissions: block, or are they running on defaults? Be honest in the comments. I'm curious how many of the fun-badge repos are one compromised action away from a bad day. 👇

I write about security and the honest ways things break. Follow me here if that's your lane. 👋

Top comments (2)

Collapse
 
respect17 profile image
Kudzai Murimi •

"A tag is a label someone else can move, a SHA is not" is the line that should end every argument about pinning actions. Framing the missing permissions block as telling an attacker you're running on defaults, rather than just a best practice you skipped, is what makes this land.

Collapse
 
rudratosh profile image
Rudratosh Shastri •

Exactly. The important distinction is that pinning isn’t just about version stability — it’s about removing someone else’s ability to silently change what your workflow executes.

And the permissions: point is the same idea from another angle: what you leave implicit is still visible. Thanks for calling that out.