tag-drift
Live dashboard for GitHub Action tag SHA mutations. Watches the SHA pinned by every release tag of the most-used marketplace actions. When a tag's SHA changes — that is, a maintainer (or attacker) force-pushed history — we log it. Includes a paste-and-rewrite tool that converts your floatingactions/checkout@v4into an immutableactions/checkout@<sha> # v4pin in one click.
Public, zero-auth, runs at <https://holyai.me/tag-drift/>.
---
Why this exists (May 2026)
Tag mutability is the single most underrated GitHub Actions attack class of the year:
- March 2026 — LiteLLM / Trivy: an attacker with maintainer access force-pushed malicious code over existing release tags of
aquasecurity/trivy-action. Every workflow pinned to a floating tag ran the malicious version on the next CI cycle. See "The Comforting Lie of SHA Pinning" and "Pinning GitHub Actions to a tag is mass negligence". - May 11, 2026 — TanStack worm: 84 malicious versions across 42
@tanstack/*npm packages, chained from apull_request_target+ cache-poisoning + OIDC-extraction pattern in workflows that pinned third-party actions by floating tag. - May 14, 2026 — node-ipc: 3 malicious versions on a 10M-weekly-downloads npm library.
- May 19, 2026 — Mini Shai-Hulud wave: 639 malicious package versions across 323 npm packages in a 22-minute automated burst, fed by tokens stolen from CI runs that pinned actions by tag.
GitHub's August 2025 changelog added org-level SHA-pin enforcement, and the 2026 Actions Security Roadmap makes immutable references first-class. But teams still need (a) a way to detect tag mutations across the public marketplace, and (b) a per-action SHA lookup tool. That's tag-drift.
What it does
- Watches ~200+ popular GitHub Actions (seeded from a curated list of canonical names like
actions/checkout, expanded daily via GitHub repository search fortopic:github-actions stars:>=200). - Polls each action's tag list on a tiered schedule (top-50 every 30 min, next 150 every 2 h, rest daily) and records every (tag, sha) it sees.
- Emits a mutation row whenever a previously observed tag is seen with a different SHA. Severity is graded:
critical— LTS major tag (v1,v2,4) on a tier-1 or tier-2 action. High blast radius.warn— semver tag (v4.2,v4.2.1).info— build IDs, RC tags, nightly tags.- Surfaces a Pin Board of every tracked action with its latest stable tag, current SHA, mutation count, and a one-click "copy pin" button that yields a ready-to-paste
actions/checkout@<sha> # v4line. - Paste-and-rewrite tool at
/pintakes a workflow YAML and returns it with every floatinguses:rewritten to a SHA pin (resolved from the local DB or, as a fallback, from a live GitHub call with a 2s timeout). - GHSA advisories for tracked action repos are pulled hourly (global feed) and every 4 hours (per-action) and shown on each action's detail page.
- No accounts. No login. No paywall. Everything is read-only and public.
Real data sources (no mocks)
| Source | URL pattern | Refresh | Notes |
|---|---|---|---|
| GitHub /repos/.../tags | https://api.github.com/repos/{owner}/{repo}/tags?per_page=100 | tier-1 30 min, tier-2 2 h, tier-3 daily | Primary mutation detection signal |
| GitHub /repos/.../releases/latest | https://api.github.com/repos/{owner}/{repo}/releases/latest | same as tags | Identifies canonical "latest stable" tag |
| GitHub /repos/.../git/ref/tags/{tag} | https://api.github.com/repos/{owner}/{repo}/git/ref/tags/{tag} | on-demand (paste-and-rewrite fallback) | Resolves a single tag → SHA |
| GitHub /repos/.../security-advisories | https://api.github.com/repos/{owner}/{repo}/security-advisories | every 4 h | Per-action GHSAs |
| GitHub /advisories | https://api.github.com/advisories?per_page=100&sort=published | hourly | Global GHSA stream |
| GitHub /search/repositories | https://api.github.com/search/repositories?q=topic:github-actions+stars:>=200 | daily | Auto-discover newly popular actions |
| GitHub /repos/{o}/{r} | https://api.github.com/repos/{owner}/{repo} | weekly | Stars, description, archived flag |
If GITHUB_TOKEN is set in the environment, all calls go authenticated (5000 req/h budget). Without a token the unauth limit (60 req/h) is enough for the top-50 tier but throttles the rest.
HTTP API (all under /tag-drift, all unauth)
| Verb | Path | Returns |
|---|---|---|
| GET | /tag-drift/health | { ok, version, uptime_s, actions_tracked, mutations_30d } — health probe |
| GET | /tag-drift/api/stats | Dashboard counters + per-day mutation series + top-mutated list |
| GET | /tag-drift/api/actions | Tracked actions; supports ?q=, ?tier=1|2|3, ?limit=, ?offset= |
| GET | /tag-drift/api/actions/:owner/:repo | Detail: metadata + last 100 observations + last 50 mutations + advisories |
| GET | /tag-drift/api/mutations | Mutation feed; supports ?severity=critical|warn|info, ?since=ISO, ?limit=, ?offset= |
| GET | /tag-drift/api/advisories | Recent action-affecting GHSAs |
| GET | /tag-drift/api/fetch-log | Last 100 fetch attempts (transparency) |
| POST | /tag-drift/api/pin | Body { "yaml": "<workflow>" } → { ok, yaml, summary, changes, unresolved, already_pinned, skipped } |
| GET | /tag-drift/api/recommend/:owner/:repo | { tag, sha, comment_form } for the latest stable tag |
| GET | /tag-drift/api/export.json | Full snapshot, 5-min cached |
| GET | /tag-drift/api/export.csv | Same, CSV |
How to interpret severities
critical— A tier-1 or tier-2 action's major tag (v1,v2,v3,4) changed SHA. Every workflow that pins by major tag will silently re-execute the new code. This is the LiteLLM/Trivy attack pattern. Treat as P0 unless the maintainer publicly announced a force-push.warn— A semver tag (v4.2,v4.2.1,v4.2-beta) changed SHA. Smaller blast radius but still indicates a non-conventional release process; treat as P1.info— A build-ID, RC, or branch-tracking tag changed. Often expected (CI build pipelines reassignnightlydaily). Logged for transparency.
Run locally
npm install
# optional, but recommended:
echo "GITHUB_TOKEN=$YOUR_PAT" >> .env
node server.js
# open http://127.0.0.1:4837/tag-drift/
On first boot the actions table is empty and gets seeded from config/seed-actions.json (≈80 known popular actions). The first cron tick (~30 seconds after boot) starts populating tag observations.
Stack
- Node.js ≥ 18 (uses global
fetch) - Express 4 + Helmet + Compression
- better-sqlite3 with WAL journal mode
- node-cron for the four periodic refreshers
- js-yaml for the paste-and-rewrite engine
- Vanilla JS + hand-rolled SVG bar chart in the UI (no CDNs, no frameworks)
Tier policy
- Tier 1 (top-50 by stars, plus seed list): refreshed every 30 min.
- Tier 2 (51–200): refreshed every 2 h.
- Tier 3 (rest): refreshed daily.
- Tiers are recalculated nightly by the discovery job.
Differentiation vs. sibling fleet products
action-shield: static analyzer for arbitrary workflow YAML —pull_request_targetPwn Request, cache poisoning, etc. Different lane.shai-watch/supply-pulse: post-disclosure malware feeds across npm/PyPI/Maven. Different layer.provenance-watch: npm SLSA provenance. Package layer, not Action layer.tag-drift: continuous append-only log of tag SHA mutations on the marketplace — plus the paste-and-rewrite tool.
License
MIT. Built by Cowork (Claude Opus 4.7) on 2026-05-21 as part of Arda Kutsal's daily R&D pipeline.