Periodic, GitHub-Actions-as-SaaS security scanner for agent skills declared in the Coder registry catalogue.
Every 6 hours, the scheduled workflow in this repo:
- Enumerates every skill in
coder/registry(both the in-tree.agents/skills/format and the future external-sources format). - Shallow-clones each source repo.
- Runs NVIDIA SkillSpector over
the upstream content. The scheduled scan runs SkillSpector's LLM
semantic pass when the workflow's LLM credential secret is
configured, and falls back to
--no-llmstatic-only mode otherwise. - Builds a per-skill verdict (
clean,suspicious,malicious,unknown) fromrisk_scoreplus the thresholds inconfig.yaml. - Builds the React SPA in
site/and ships it together withlatest.json,schema.json, and a rolling history of prior snapshots to GitHub Pages. Also publishes a versioned GitHub Release for archival.
The public site is the same React app that registry-server hosts at
registry.coder.com, scoped down to scan results. Same Vite, Tailwind,
Radix, react-router-dom, and tanstack-query stack.
The registry site reads the public report through a small proxy endpoint
in coder/registry-server (separate PR) and shows a per-skill badge.
The registry's deploys are not gated on the scan result.
Stable URLs, no auth required:
- Public site:
https://scanner.registry.coder.com/ - Per-skill detail:
https://scanner.registry.coder.com/skills/<namespace>/<slug> - Run history:
https://scanner.registry.coder.com/history - CDN-cached JSON:
https://scanner.registry.coder.com/latest.json - Tagged release:
https://github.com/coder/coder-skill-scanner/releases/latest/download/latest.json - Schema:
https://scanner.registry.coder.com/schema.json(v1) - Per-scan history (JSON):
https://scanner.registry.coder.com/history/index.json
The custom domain is configured via site/public/CNAME; the legacy
project-page URL (https://coder.github.io/coder-skill-scanner/) is
still redirected by GitHub Pages but should not be used in new code.
Under /api/v1/, every URL is constructible from (namespace, slug) alone
— no lookup against the index is required to render a badge or read a
single skill. Field names and URL shapes are committed to the v1
stability contract; breaking changes move to a v2 prefix.
| URL | Shape | Use |
|---|---|---|
/api/v1/index.json |
discovery manifest: URL templates + current (ns, slug) pairs |
bootstrap a third-party consumer |
/api/v1/skills.json |
compact index of every skill | listing / cache warmer |
/api/v1/skills/<ns>/<slug>.json |
per-skill detail (reasons, findings, links block) |
per-skill consumer |
/api/v1/skills/<ns>/<slug>/badge/status.json |
shields.io endpoint payload | img.shields.io/endpoint?url=... |
/api/v1/skills/<ns>/<slug>/badge/status.svg |
inline SVG | direct embed |
/api/v1/skills/<ns>/<slug>/badge/score.json |
shields.io endpoint payload | same |
/api/v1/skills/<ns>/<slug>/badge/score.svg |
inline SVG | direct embed |
/api/v1/history.json |
reshape of history with absolute report URLs | history consumer |
Two badges per skill:
status— the categorical scan outcome (clean,suspicious,malicious,unknown). Colour follows the verdict 1:1.score— the numeric SkillSpector risk score (0/100…100/100). Colour is banded at the same 21 / 51 / 81 cutoffs the verdict policy uses.
Embed a status badge in a README:
Or via shields.io if you want their renderer:
For a fork, swap the host: https://<your-host>/api/v1/.... The scanner
picks the public base URL at publish time in this order:
site/public/CNAME(the custom Pages domain, if set),- otherwise
$GITHUB_REPOSITORY->https://<owner>.github.io/<repo>.
So a fork that just sets a CNAME gets the right URLs everywhere without touching workflow code.
Requires Python 3.12+, Node 22+ (via mise), pnpm, and git.
make install # creates .venv, installs scanner + dev deps
make test # ruff + pytest
make schema # validate report schema is a valid JSON Schema
# Smoke-test the enumerator against a local catalogue checkout:
.venv/bin/scanner enumerate --clone-dir /path/to/coder-registry
# Run the React site against a local pages tree. In two terminals:
make site-install
cd /path/to/pages && python3 -m http.server 8765 # serve scanner output
make site-dev # vite proxies :5173 -> :8765Vite's dev proxy (see site/vite.config.ts) forwards latest.json,
schema.json, and history/*.json to the static server, so the React
app sees real scanner output without CORS shenanigans. SPA routes such
as /skills/coder/setup stay client-side.
.
|-- config.yaml # the only user-facing knob
|-- schema/report.schema.json # v1 report contract
|-- scanner/ # Python module (CLI + enumerate + combine + aggregate + history)
|-- tests/ # pytest, no on-disk fixtures
|-- site/ # React SPA (Vite + Tailwind + Radix + react-router-dom)
| `-- public/CNAME # custom Pages domain (drop or change for a fork)
|-- pyproject.toml
|-- Makefile
|-- mise.toml # pinned Python + Node versions
|-- AGENTS.md # contributor + agent conventions
`-- .github/
|-- workflows/
| |-- ci.yaml # validate config + schema + ruff + pytest + site lint/test/build
| |-- scan.yaml # the scheduled scanner; also builds and publishes the SPA
| `-- prune.yaml # weekly release retention pruner
|-- ISSUE_TEMPLATE/
| `-- scanner-down.md # single rolling tracker
`-- dependabot.yml # weekly pip + github-actions bumps
No scripts/ directory. No testdata/ directory. No committed sample
reports. Runtime data lives in workflow artifacts, Releases, and Pages,
not in the repo.
This scanner is data-driven. To run it against a different registry:
- Fork
coder/coder-skill-scanner. - Edit
config.yaml'scatalogue.registry_repoblock. - Configure GitHub Pages on your fork (Settings, Pages, source: "GitHub Actions").
- Optional: set a custom domain by editing
site/public/CNAME(one line, the bare host). Delete the file to publish at the github.io project-page URL instead. Whichever you choose, DNS for the host needs to point at<owner>.github.ioseparately. - Set Actions workflow permissions to "Read and write" so the publish-release job can create releases.
- To enable the LLM semantic pass, set the credential secret matching
config.yaml'sscanners.skillspector.llm.provideron your fork (for the defaultanthropicprovider,ANTHROPIC_API_KEY), AND confirm.github/workflows/scan.yamlexports that secret into the SkillSpector step. Static-only mode (without the secret) is the default and works out of the box. - Enable Actions.
No source changes required for catalogue changes.
Today's policy lives in config.yaml:
verdict:
malicious_risk_score: 81
suspicious_risk_score: 51SkillSpector's risk_score (0-100) is the only input. The thresholds
are aligned to SkillSpector's own HIGH and CRITICAL bands.
The architecture keeps room for additional scanners (gitleaks, Semgrep,
VirusTotal Premium, etc.); adding one is a new module under scanner/,
a new threshold field here, and a minor schema bump.
When any scheduled run fails, JasonEtco/create-an-issue opens or
updates a single rolling tracker labelled scanner-down. If the
SLACK_WEBHOOK_URL secret is set, a Slack alert is also posted.
Apache-2.0. See LICENSE.