read-only triage for SCA (supply-chain attack) indicators across npm + rubygems + pypi. scans lockfiles + host indicators against a committed IOC baseline, plus GitHub-side persistence vectors.
active multi-ecosystem worm campaign (TeamPCP family). payload harvests creds from ~/.npmrc / ~/.aws/ / ~/.gem/credentials / etc, exfils via public GitHub repos under the victim's account, persists via GH Actions with on: discussion triggers.
dead-man switch wipes $HOME if it detects token revocation before the machine is cleaned. run this scan before rotating anything.
primary research this tool tracks:
- Lyrie -- RubyGems Under Siege -- rubygems wave (may-2026)
- Ox Security -- Shai-Hulud Goes Open Source -- worm internals, post-leak
upstream IOC feeds (datadog, wiz) cited per-source in update_iocs.rb.
runs entirely on your machine. no project source, scan findings, IOC hits, or vulnerabilities sent anywhere. no vendor telemetry, no SaaS, no POSTs.
credential files are never opened or read. the tool checks ~/.npmrc, ~/.aws/credentials, ~/.gem/credentials, etc. for existence + size + mtime only -- nothing inside ever touched.
no LLM/AI calls. no openai/anthropic/google/etc; logic is deterministic ruby. you can audit every line.
network calls (all GETs, no POSTs):
recent_releases-- public registry lookups by package name (npm + rubygems URL path; pypi includes version per endpoint shape). same callsnpm view/gem infomake.check_github.sh-- reads your OWN GitHub account (your existing auth token / relationship).update_iocs.rb-- downloads public IOC corpus. nothing about your machine sent.
| file | what |
|---|---|
scan.rb |
main scanner. yaml stdout. |
update_iocs.rb |
fetches IOC CSVs from datadog/wiz, writes iocs/*.txt. |
check_github.sh |
gh CLI checks: exfil repos, hulud branches, on: discussion workflows, recent workflow commits. |
parsers.rb |
Gemfile / yarn / pnpm / package-lock / bun.lock parsers. |
iocs.rb |
IOC baseline reader. |
release_dates.rb |
per-package registry release-date lookup with on-disk caching. |
iocs/*.txt |
committed IOC baseline. refresh via update_iocs.rb, commit the diff. |
iocs/last_updated |
timestamp of the last refresh (committed). |
iocs/release_dates.cache.json |
per-machine runtime cache, gitignored. |
- ruby 2.7+, stdlib only (rbenv users in legacy 2.6 projects:
RBENV_VERSION=<ver> ruby scan.rb ...) ghCLI (authed) +jqforcheck_github.sh- macOS host or docker container
clone once, anywhere you keep tools (eg. ~/tools/sca-scan):
mkdir -p ~/tools
git clone https://github.com/genuinecode/sca-scan.git ~/tools/sca-scan
cd ~/tools/sca-scanscan one or more project dirs (on host):
ruby scan.rb ~/code/project [~/code/other_project] # ~45s cold, ~10s warm
./check_github.sh ~/code/project # ~5s, needs gh CLI + jq$HOME is always scanned regardless of which dirs you pass (droppers, shell rc, ~/.claude, token paths, launch agents). lockfiles + package.json under the passed dirs scan against the IOC baseline.
no network needed for scanning; IOC corpus ships in the repo. recent_releases hits npm/rubygems/pypi registries, cached per-machine in iocs/release_dates.cache.json.
run on host (default). covers project lockfiles + host persistence ($HOME, launchd, ssh, shell rc) + host-installed dev tooling + GH. most of the typical dev surface.
also run inside container only when it matters: long-lived containers, persistent volumes, or images that install non-lockfile deps at runtime. dev containers that get rebuilt: skip.
mount the host clone into the container so you don't need to install ruby/git inside:
# docker compose (replace `app` with your service name; check your compose file)
docker compose run --rm -v ~/tools/sca-scan:/sca-scan app ruby /sca-scan/scan.rb /app
# plain docker
docker run --rm \
-v ~/tools/sca-scan:/sca-scan \
-v ~/code/project:/app:ro \
ruby:3-slim ruby /sca-scan/scan.rb /app(/app = wherever the project sits inside the container.)
each check has a status::
flag is not confirmed compromise. read the evidence -- bundle.js is also webpack output, bun is also a legit runtime.
- do not rotate tokens yet. dead-man switch.
- read the evidence. false positives are normal.
- if real: disconnect network, image the disk, rotate from a clean machine.
- can't wait + uncertain? image the disk for forensics + selective data recovery, then rotate from a clean machine. do NOT restore the image in-place -- re-infects.
ruby update_iocs.rb
git diff iocs/
git add iocs/ && git commit -m "ioc refresh $(date +%Y-%m-%d)"each refresh = one reviewable commit. daily during active campaigns, weekly otherwise. scan.rb warns when baseline is >3d old.
don't auto-schedule this in CI; refresh PRs are reviewed. if you want scheduled refreshes, write a GitHub Action that opens a PR, not one that auto-commits.
before adding a source: curl -sw '%{http_code} %{size_download}\n' URL (verify 200 + sane size), curl -s URL | head (check schema). each entry must trace to a real advisory; don't sub aggregator repos for missing official ones.
- waves that broke after the last refresh (baseline is point-in-time; check
iocs/last_updated) - production deploys beyond docker (capistrano / fly / ECS) -- ask devops
- other devs' machines; scans your local only
- pypi has weak public GHSA coverage compared to npm
- compromised secret managers outside the standard token paths
- launch_agents lookback is 30d
- lifecycle scripts in
package.jsonlisted not analysed;node bundle.jsandhusky installlook the same here bun.lockb(binary, pre-bun-v1.2 default) -- requires the bun CLI to parse.bun.lock(text, JSONC) is supported.
constants at the top of update_iocs.rb:
- CSV source with stable URL: add to
SOURCES, write an extractor matching its schema - per-entry-cited small set: append to the
QIX_SEPT_2025_NPM-style bundle
then ruby update_iocs.rb.
- stdlib only:
gem installduring a campaign is the attack vector - read-only: no writes outside
iocs/, no deletes, no POSTs, no interactive prompts - yaml stdout, evidence not verdicts
- per-source attribution: every IOC entry cites its origin URL or bundle
update_iocs.rbrefuses to overwrite if any network source failed -- a gutted baseline is worse than a stale one
PRs + issues welcome -- improvements, ecosystem additions, methodology fixes, all on the table.
