Skip to content

Commit f58f4ef

Browse files
Merge pull request #12 from coder/cat/api-v1-and-badges
feat(scanner): v1 public API + shields.io badge endpoints
2 parents 13d3554 + 913fdc3 commit f58f4ef

9 files changed

Lines changed: 898 additions & 3 deletions

File tree

.github/workflows/scan.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,17 @@ jobs:
322322
323323
# Regenerate the manifest so the React app sees every retained run.
324324
scanner index-history pages/history --output pages/history/index.json
325+
326+
# Build the stable v1 API surface (skills.json, per-skill detail,
327+
# badge endpoints, history.json). Derive the public base URL from
328+
# the runtime publishing context so forks get the right prefix
329+
# automatically; the Vite build uses the same logic.
330+
repo_short="${GITHUB_REPOSITORY#*/}"
331+
public_base_url="https://${GITHUB_REPOSITORY_OWNER}.github.io/${repo_short}"
332+
scanner build-api-v1 latest.json \
333+
--output pages/api/v1 \
334+
--public-base-url "${public_base_url}" \
335+
--history-index pages/history/index.json
325336
- name: Upload Pages artifact
326337
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
327338
with:

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,49 @@ Stable URLs, no auth required:
3939
+ Schema: `https://coder.github.io/coder-skill-scanner/schema.json` (v1)
4040
+ Per-scan history (JSON): `https://coder.github.io/coder-skill-scanner/history/index.json`
4141

42+
## Public API (v1)
43+
44+
Under `/api/v1/`, every URL is constructible from `(namespace, slug)` alone
45+
— no lookup against the index is required to render a badge or read a
46+
single skill. Field names and URL shapes are committed to the `v1`
47+
stability contract; breaking changes move to a `v2` prefix.
48+
49+
| URL | Shape | Use |
50+
| --- | --- | --- |
51+
| `/api/v1/index.json` | discovery manifest: URL templates + current `(ns, slug)` pairs | bootstrap a third-party consumer |
52+
| `/api/v1/skills.json` | compact index of every skill | listing / cache warmer |
53+
| `/api/v1/skills/<ns>/<slug>.json` | per-skill detail (reasons, findings, `links` block) | per-skill consumer |
54+
| `/api/v1/skills/<ns>/<slug>/badge/status.json` | shields.io endpoint payload | `img.shields.io/endpoint?url=...` |
55+
| `/api/v1/skills/<ns>/<slug>/badge/status.svg` | inline SVG | direct embed |
56+
| `/api/v1/skills/<ns>/<slug>/badge/score.json` | shields.io endpoint payload | same |
57+
| `/api/v1/skills/<ns>/<slug>/badge/score.svg` | inline SVG | direct embed |
58+
| `/api/v1/history.json` | reshape of history with absolute report URLs | history consumer |
59+
60+
Two badges per skill:
61+
62+
+ **`status`** — the categorical scan outcome (`clean`, `suspicious`,
63+
`malicious`, `unknown`). Colour follows the verdict 1:1.
64+
+ **`score`** — the numeric SkillSpector risk score (`0/100``100/100`).
65+
Colour is banded at the same 21 / 51 / 81 cutoffs the verdict policy
66+
uses.
67+
68+
Embed a status badge in a README:
69+
70+
```markdown
71+
![skill scan](https://coder.github.io/coder-skill-scanner/api/v1/skills/coder/setup/badge/status.svg)
72+
```
73+
74+
Or via shields.io if you want their renderer:
75+
76+
```markdown
77+
![skill scan](https://img.shields.io/endpoint?url=https://coder.github.io/coder-skill-scanner/api/v1/skills/coder/setup/badge/status.json)
78+
```
79+
80+
For a fork, swap the host: `https://<owner>.github.io/<repo>/api/v1/...`.
81+
The scanner derives the public base URL from `$GITHUB_REPOSITORY` at
82+
publish time, so the same URL pattern is correct for any fork without
83+
config changes.
84+
4285
## Running locally
4386

4487
Requires Python 3.12+, Node 22+ (via `mise`), pnpm, and `git`.

scanner/api.py

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
"""Build the v1 public API surface from a generated ``latest.json``.
2+
3+
The v1 contract:
4+
5+
- ``api/v1/index.json`` Discovery manifest. Lists the URL
6+
templates (with ``{namespace}`` and
7+
``{slug}`` placeholders) needed to
8+
address every other endpoint, plus the
9+
current ``(namespace, slug)`` pairs.
10+
Bootstrap entry point for third-party
11+
consumers - no other fetch required to
12+
learn the URL conventions.
13+
- ``api/v1/skills.json`` Compact index of every skill in the most
14+
recent scan: namespace, slug, verdict,
15+
risk_score, source_repo, source_sha,
16+
scanned_at. Lightweight enough to fetch
17+
on every page render.
18+
- ``api/v1/skills/<ns>/<slug>.json`` Per-skill detail with the same fields
19+
plus reasons, findings by severity and
20+
rule, and a ``links`` object pointing
21+
at the badge endpoints and the
22+
immutable source-tree URL.
23+
- ``api/v1/skills/<ns>/<slug>/badge/status.{json,svg}``
24+
Categorical scan-outcome badge
25+
(clean/suspicious/malicious/unknown).
26+
Directly addressable from the
27+
``(namespace, slug)`` pair - no detail
28+
fetch required.
29+
- ``api/v1/skills/<ns>/<slug>/badge/score.{json,svg}``
30+
Numeric risk-score badge (0-100,
31+
colour-banded). Same direct-addressing
32+
contract as ``status``.
33+
- ``api/v1/history.json`` Reshape of ``history/index.json`` into
34+
a versioned shape with absolute
35+
``report_url`` fields so consumers do
36+
not have to know the Pages layout.
37+
38+
Stability: once shipped, ``v1`` field names and shapes do not change. New
39+
optional fields are allowed. Removed or renamed fields require a ``v2`` prefix
40+
with a deprecation window on ``v1``.
41+
"""
42+
43+
from __future__ import annotations
44+
45+
import json
46+
import re
47+
from datetime import UTC, datetime
48+
from pathlib import Path
49+
from typing import Any
50+
51+
from . import badges
52+
53+
API_SCHEMA_VERSION = 1
54+
55+
# Filesystem-safe identifier shape for ``namespace`` and ``slug`` segments.
56+
# Skill IDs in the registry are kebab-case ASCII; rejecting anything else here
57+
# is defence-in-depth against a malformed report writing outside ``output_dir``
58+
# via ``../`` or absolute paths.
59+
_SAFE_SEGMENT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
60+
61+
62+
def _check_safe_segment(kind: str, value: str) -> str:
63+
"""Return ``value`` unchanged if it is a safe path segment, else raise."""
64+
if not isinstance(value, str) or not _SAFE_SEGMENT.fullmatch(value):
65+
raise ValueError(f"unsafe {kind} segment: {value!r}")
66+
return value
67+
68+
69+
def _source_tree_url(skill: dict[str, Any]) -> str:
70+
"""Return the immutable ``github.com/<repo>/tree/<sha>/<path>`` link.
71+
72+
Always uses ``source_sha`` (not ``source_ref``) so the link survives
73+
upstream branch movement. Falls back to the bare repo URL when the SHA is
74+
missing.
75+
"""
76+
repo = skill.get("source_repo")
77+
sha = skill.get("source_sha")
78+
path = (skill.get("skill_path") or "").lstrip("/")
79+
if not repo:
80+
return ""
81+
if not sha:
82+
return f"https://github.com/{repo}"
83+
suffix = f"/{path}" if path else ""
84+
return f"https://github.com/{repo}/tree/{sha}{suffix}"
85+
86+
87+
def _badge_links(public_base_url: str, namespace: str, slug: str) -> dict[str, str]:
88+
"""Return the four badge URLs for a single skill."""
89+
root = public_base_url.rstrip("/")
90+
return {
91+
"status_badge_json": f"{root}/api/v1/skills/{namespace}/{slug}/badge/status.json",
92+
"status_badge_svg": f"{root}/api/v1/skills/{namespace}/{slug}/badge/status.svg",
93+
"score_badge_json": f"{root}/api/v1/skills/{namespace}/{slug}/badge/score.json",
94+
"score_badge_svg": f"{root}/api/v1/skills/{namespace}/{slug}/badge/score.svg",
95+
}
96+
97+
98+
def _skill_index_entry(skill: dict[str, Any]) -> dict[str, Any]:
99+
"""Compact shape for the index."""
100+
ss = (skill.get("scanners") or {}).get("skillspector") or {}
101+
return {
102+
"namespace": skill["namespace"],
103+
"slug": skill["slug"],
104+
"verdict": skill["verdict"],
105+
"risk_score": ss.get("risk_score", 0),
106+
"source_repo": skill.get("source_repo", ""),
107+
"source_sha": skill.get("source_sha", ""),
108+
"scanned_at": skill.get("scanned_at", ""),
109+
}
110+
111+
112+
def build_skills_index(
113+
report: dict[str, Any],
114+
*,
115+
generated_at: str | None = None,
116+
) -> dict[str, Any]:
117+
"""Build the ``api/v1/skills.json`` payload."""
118+
skills = sorted(
119+
(_skill_index_entry(s) for s in report.get("skills", [])),
120+
key=lambda r: (r["namespace"], r["slug"]),
121+
)
122+
return {
123+
"schema_version": API_SCHEMA_VERSION,
124+
"generated_at": generated_at or report.get("generated_at", ""),
125+
"summary": report.get("summary", {}),
126+
"skills": skills,
127+
}
128+
129+
130+
def build_skill_detail(
131+
skill: dict[str, Any],
132+
*,
133+
public_base_url: str,
134+
report_url: str,
135+
) -> dict[str, Any]:
136+
"""Build a single ``api/v1/skills/<ns>/<slug>.json`` payload."""
137+
ss = (skill.get("scanners") or {}).get("skillspector") or {}
138+
return {
139+
"schema_version": API_SCHEMA_VERSION,
140+
"namespace": skill["namespace"],
141+
"slug": skill["slug"],
142+
"verdict": skill["verdict"],
143+
"risk_score": ss.get("risk_score", 0),
144+
"risk_severity": ss.get("risk_severity", "unknown"),
145+
"source_repo": skill.get("source_repo", ""),
146+
"source_ref": skill.get("source_ref", ""),
147+
"source_sha": skill.get("source_sha", ""),
148+
"skill_path": skill.get("skill_path", ""),
149+
"scanned_at": skill.get("scanned_at", ""),
150+
"reasons": skill.get("reasons", []),
151+
"findings_by_severity": ss.get("findings_by_severity", {}),
152+
"findings_by_rule": ss.get("findings_by_rule", []),
153+
"links": {
154+
"report": report_url,
155+
"source_tree": _source_tree_url(skill),
156+
**_badge_links(public_base_url, skill["namespace"], skill["slug"]),
157+
},
158+
}
159+
160+
161+
def build_history_index(
162+
history_manifest: dict[str, Any],
163+
*,
164+
public_base_url: str,
165+
generated_at: str | None = None,
166+
) -> dict[str, Any]:
167+
"""Reshape ``history/index.json`` into the versioned API shape."""
168+
root = public_base_url.rstrip("/")
169+
entries = []
170+
for entry in history_manifest.get("entries", []):
171+
rel = entry.get("path", "").lstrip("/")
172+
entries.append(
173+
{
174+
"stamp": entry.get("stamp", ""),
175+
"generated_at": entry.get("generated_at", ""),
176+
"summary": entry.get("summary", {}),
177+
"report_url": f"{root}/{rel}" if rel else "",
178+
}
179+
)
180+
return {
181+
"schema_version": API_SCHEMA_VERSION,
182+
"generated_at": generated_at or datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"),
183+
"entries": entries,
184+
}
185+
186+
187+
def build_v1_index(
188+
report: dict[str, Any],
189+
*,
190+
public_base_url: str,
191+
has_history: bool,
192+
generated_at: str | None = None,
193+
) -> dict[str, Any]:
194+
"""Build the ``api/v1/index.json`` discovery manifest.
195+
196+
The manifest lists the URL templates a third-party consumer needs to
197+
address every endpoint without first parsing ``skills.json``, plus the
198+
current ``(namespace, slug)`` pairs so a programmatic consumer can iterate
199+
without a second fetch. Field shapes are part of the v1 contract.
200+
"""
201+
root = public_base_url.rstrip("/")
202+
api_root = f"{root}/api/v1"
203+
skills = sorted(
204+
({"namespace": s["namespace"], "slug": s["slug"]} for s in report.get("skills", [])),
205+
key=lambda s: (s["namespace"], s["slug"]),
206+
)
207+
urls = {
208+
"skills_index": f"{api_root}/skills.json",
209+
"skill_detail": f"{api_root}/skills/{{namespace}}/{{slug}}.json",
210+
"status_badge_json": f"{api_root}/skills/{{namespace}}/{{slug}}/badge/status.json",
211+
"status_badge_svg": f"{api_root}/skills/{{namespace}}/{{slug}}/badge/status.svg",
212+
"score_badge_json": f"{api_root}/skills/{{namespace}}/{{slug}}/badge/score.json",
213+
"score_badge_svg": f"{api_root}/skills/{{namespace}}/{{slug}}/badge/score.svg",
214+
}
215+
if has_history:
216+
urls["history"] = f"{api_root}/history.json"
217+
return {
218+
"schema_version": API_SCHEMA_VERSION,
219+
"generated_at": generated_at or report.get("generated_at", ""),
220+
"urls": urls,
221+
"skills": skills,
222+
}
223+
224+
225+
def write_api_v1(
226+
report: dict[str, Any],
227+
*,
228+
output_dir: Path,
229+
public_base_url: str,
230+
history_manifest: dict[str, Any] | None = None,
231+
) -> list[Path]:
232+
"""Write the full v1 API tree under ``output_dir`` and return paths written."""
233+
output_dir = Path(output_dir)
234+
output_dir.mkdir(parents=True, exist_ok=True)
235+
written: list[Path] = []
236+
237+
report_url = f"{public_base_url.rstrip('/')}/latest.json"
238+
239+
skills_index = build_skills_index(report)
240+
skills_index_path = output_dir / "skills.json"
241+
skills_index_path.write_text(json.dumps(skills_index, indent=2) + "\n", encoding="utf-8")
242+
written.append(skills_index_path)
243+
244+
discovery = build_v1_index(
245+
report,
246+
public_base_url=public_base_url,
247+
has_history=history_manifest is not None,
248+
)
249+
discovery_path = output_dir / "index.json"
250+
discovery_path.write_text(json.dumps(discovery, indent=2) + "\n", encoding="utf-8")
251+
written.append(discovery_path)
252+
253+
for skill in report.get("skills", []):
254+
ns = _check_safe_segment("namespace", skill["namespace"])
255+
slug = _check_safe_segment("slug", skill["slug"])
256+
detail = build_skill_detail(skill, public_base_url=public_base_url, report_url=report_url)
257+
detail_path = output_dir / "skills" / ns / f"{slug}.json"
258+
detail_path.parent.mkdir(parents=True, exist_ok=True)
259+
detail_path.write_text(json.dumps(detail, indent=2) + "\n", encoding="utf-8")
260+
written.append(detail_path)
261+
262+
badge_dir = detail_path.parent / slug / "badge"
263+
badge_dir.mkdir(parents=True, exist_ok=True)
264+
verdict = skill["verdict"]
265+
ss = (skill.get("scanners") or {}).get("skillspector") or {}
266+
risk = int(ss.get("risk_score", 0))
267+
v_json = badge_dir / "status.json"
268+
v_json.write_text(
269+
json.dumps(badges.status_badge_json(verdict), indent=2) + "\n",
270+
encoding="utf-8",
271+
)
272+
written.append(v_json)
273+
v_svg = badge_dir / "status.svg"
274+
v_svg.write_text(badges.status_badge_svg(verdict), encoding="utf-8")
275+
written.append(v_svg)
276+
r_json = badge_dir / "score.json"
277+
r_json.write_text(
278+
json.dumps(badges.score_badge_json(risk), indent=2) + "\n",
279+
encoding="utf-8",
280+
)
281+
written.append(r_json)
282+
r_svg = badge_dir / "score.svg"
283+
r_svg.write_text(badges.score_badge_svg(risk), encoding="utf-8")
284+
written.append(r_svg)
285+
286+
if history_manifest is not None:
287+
history_api = build_history_index(history_manifest, public_base_url=public_base_url)
288+
history_path = output_dir / "history.json"
289+
history_path.write_text(json.dumps(history_api, indent=2) + "\n", encoding="utf-8")
290+
written.append(history_path)
291+
292+
return written

0 commit comments

Comments
 (0)