fix(skills): clear two Snyk Fails and harden the network + supply-chain surface (#1804)

* fix(skills): clear Snyk findings and harden supply-chain surface

Address the security-audit findings on the published skills with no change to
any skill's behaviour.

- media-use: resolve.test.mjs runs resolve.mjs via execFileSync with an argv
  array instead of execSync(`node … "${tmp}" …`), removing the command-injection
  (CWE-78) sink that drove the Snyk Fail.
- music-to-video: replace dynamic `element.innerHTML = <var>` with a setSvg()
  helper (DOMParser image/svg+xml + importNode, text fallback) in the
  intro-kinetic-cascade and logo-split-lockup-pulse frame templates, clearing the
  DOM-XSS (CWE-79) Snyk Fail. Renders identical SVG.
- pr-to-video: fetch-people-avatars.mjs refuses any avatar URL that is not https
  on a GitHub avatar host (SSRF guard) and only writes under the project dir
  (path-traversal guard); best-effort, always-exit-0 behaviour is unchanged.
- embedded-captions: pin `uvx --from whisperx==3.8.6` (overridable via
  $WHISPERX_VERSION) so transcription no longer resolves "latest" at runtime.
- gsap: add Subresource Integrity (integrity + crossorigin) to the 8 render-time
  CDN GSAP <script> tags across embedded-captions, music-to-video,
  faceless-explainer, pr-to-video and product-launch-video.
- hyperframes-animation / hyperframes-creative: document package-loader's
  defense-in-depth and note that the installLine strings are display-only.

Verified: media-use resolve (12/12), probe injection (1/1) and manifest (19/19)
tests pass; avatar host-allowlist checks pass; all changed JS passes node --check
and oxfmt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(skills): clarify product-launch-video vs website-to-video routing

Sharpen the router's product-vs-site decision in hyperframes/SKILL.md: the
split is now "is the site selling a product?" — yes (SaaS / app / product /
company site) → /product-launch-video (a promo; the default for any commercial
URL, even if the site is only named); no, or the user just wants the site shown
as-is (portfolio / blog / docs / personal / event) → /website-to-video (a tour).
Updates the workflow table, the disambiguation bullet, and both workflows'
Input/Output blurbs to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(skills): satisfy oxfmt in the two music-to-video templates

The CI Format job runs `oxfmt --check .`, which also formats embedded <script> in .html. Reflow the setSvg() blocks added for the DOM-XSS fix to oxfmt's wrapping — no logic change. Regenerate the music-to-video manifest hash to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(skills): sanitize SVG in music-to-video templates (real CWE-79 fix)

Addresses @Magi's review: the previous setSvg() only swapped the sink
(innerHTML → DOMParser + importNode) but did NOT sanitize, so active SVG
content still executed on insertion into the live document. Verified in
headless Chrome that the old shape fired both an svg `onload` handler and an
inline `<script>`.

setSvg() now runs a default-deny cleanSvg() over the parsed tree before it ever
enters the document: only an allow-list of inert drawing elements
(svg/g/path/line/rect/circle/… ) and presentation attributes
(d/fill/stroke/viewBox/…) survives. Every other element (`<script>`, `<image>`,
`<use>`, `<foreignObject>`, `<a>`, `<animate>`, …), every `on*` handler, and
href/xlink:href/style are stripped — on the root node too. Non-SVG or malformed
input still falls back to textContent.

Trusted content (the bundled icon library + the default spark/cloud marks)
renders byte-identically; only hostile markup in vars.icon / leftMark / rightMark
is neutralized.

Browser-verified (headless Chrome, both templates' helper):
  old setSvg → fired ["script","onload"]
  new setSvg → fired []  · trusted icon still renders · 0 danger nodes · 0 on* attrs

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
WaterrrForever
2026-07-01 12:39:42 +08:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 8694424807
commit 535297280a
17 changed files with 327 additions and 69 deletions
+9 -9
View File
@@ -2,11 +2,11 @@
"source": "heygen-com/hyperframes",
"skills": {
"embedded-captions": {
"hash": "7fa25370f9fbb4b3",
"hash": "62aec45830eda54b",
"files": 144
},
"faceless-explainer": {
"hash": "edbe47dd738d14d8",
"hash": "844c54a06fd16d3c",
"files": 17
},
"general-video": {
@@ -14,11 +14,11 @@
"files": 1
},
"hyperframes": {
"hash": "55f1e72887f8f983",
"hash": "e0cf1bb2843bc834",
"files": 1
},
"hyperframes-animation": {
"hash": "f789d7f95d9ad2fe",
"hash": "3b592d54ca1d5b6e",
"files": 116
},
"hyperframes-cli": {
@@ -30,7 +30,7 @@
"files": 13
},
"hyperframes-creative": {
"hash": "8573b34712e14ab1",
"hash": "18a14a79da6cbc06",
"files": 68
},
"hyperframes-media": {
@@ -42,7 +42,7 @@
"files": 10
},
"media-use": {
"hash": "f0edb0fd7cd513a5",
"hash": "fba6e0963e431b1e",
"files": 19
},
"motion-graphics": {
@@ -50,15 +50,15 @@
"files": 23
},
"music-to-video": {
"hash": "c188d0d159b926c2",
"hash": "0c5738fac0fe622f",
"files": 132
},
"pr-to-video": {
"hash": "ef4a3aa5a943aeec",
"hash": "132b44ddda774fef",
"files": 21
},
"product-launch-video": {
"hash": "b7bee220096f2ae2",
"hash": "32cab842cc7a6b76",
"files": 18
},
"remotion-to-hyperframes": {
@@ -583,7 +583,7 @@ function fontCssFor(pageParts) {
}
return out.join("\n");
}
const GSAP = `<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>`;
const GSAP = `<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js" integrity="sha384-sG0Hv1tP1lZCk9KQmrIbY/XNwi+OY84GQqhMscbnsoBFqAz8KNCil1kvfL3Hbbk2" crossorigin="anonymous"></script>`;
function bgSkeleton(stageHtml, css, js) {
const FONT_CSS = fontCssFor(stageHtml + css);
@@ -175,11 +175,15 @@ function main() {
const outDir = path.join(project, "_wx_out");
fs.mkdirSync(outDir, { recursive: true });
const wxModel = model.replace(/\.en$/, ""); // whisperx model names are multilingual ids
// Pin whisperx so `uvx` fetches a reproducible build instead of resolving
// "latest" on every run (a supply-chain + determinism foot-gun). Override
// with $WHISPERX_VERSION if you've validated a different release.
const whisperxSpec = `whisperx==${process.env.WHISPERX_VERSION || "3.8.6"}`;
const wxArgs = [
"--python",
"3.12",
"--from",
"whisperx",
whisperxSpec,
"whisperx",
wav,
"--model",
@@ -523,7 +523,7 @@ const html = `<!doctype html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=${WIDTH}, height=${HEIGHT}" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js" integrity="sha384-sG0Hv1tP1lZCk9KQmrIbY/XNwi+OY84GQqhMscbnsoBFqAz8KNCil1kvfL3Hbbk2" crossorigin="anonymous"></script>
<style>
${headStyle}
</style>
@@ -466,7 +466,7 @@ function buildCaptionsHtml(groups, total, W, H) {
color: rgba(255, 255, 255, 0.55);
}
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js" integrity="sha384-sG0Hv1tP1lZCk9KQmrIbY/XNwi+OY84GQqhMscbnsoBFqAz8KNCil1kvfL3Hbbk2" crossorigin="anonymous"></script>
<script>
(function () {
var GROUPS = ${JSON.stringify(groups)};
@@ -1,3 +1,13 @@
// package-loader — bootstrap optional helper packages only when missing, with
// defense-in-depth so a malicious or typo'd dependency can't run on install:
// • specs are version-pinned (assertPinnedPackageSpecs) — no floating "latest"
// • install runs `npm install --ignore-scripts` — package lifecycle scripts
// never execute
// • `--no-save` into a throwaway tmp dir — the host project is left untouched
// • requires an interactive y/N (or an explicit $HYPERFRAMES_SKILL_BOOTSTRAP_DEPS=1)
// • npm is spawned with an argv array (no shell) — never a built command string
// The `installLine` strings below are DISPLAY ONLY (shown in the prompt / error
// text); they are never handed to a shell or executed.
import { spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { createRequire } from "node:module";
@@ -1,3 +1,13 @@
// package-loader — bootstrap optional helper packages only when missing, with
// defense-in-depth so a malicious or typo'd dependency can't run on install:
// • specs are version-pinned (assertPinnedPackageSpecs) — no floating "latest"
// • install runs `npm install --ignore-scripts` — package lifecycle scripts
// never execute
// • `--no-save` into a throwaway tmp dir — the host project is left untouched
// • requires an interactive y/N (or an explicit $HYPERFRAMES_SKILL_BOOTSTRAP_DEPS=1)
// • npm is spawned with an argv array (no shell) — never a built command string
// The `installLine` strings below are DISPLAY ONLY (shown in the prompt / error
// text); they are never handed to a shell or executed.
import { spawnSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { createRequire } from "node:module";
+17 -17
View File
@@ -51,24 +51,24 @@ Routing needs to know **what the video is about** — its input and subject. If
## Workflow cheat-sheet
| Workflow | Use it for |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/product-launch-video` | Marketing / launching / promoting a **product** — from its URL, a brief, or a script (even if the site is only named) |
| `/website-to-video` | Turning a **general website** into a video — site tour, portfolio / landing-page showcase, social clip from the site's visuals |
| `/faceless-explainer` | **Explaining a topic / concept** from text — no product, no URL; every visual is LLM-invented |
| `/pr-to-video` | A **GitHub PR / code change** → changelog / feature-reveal / fix / refactor explainer |
| `/embedded-captions` | Adding **captions / subtitles** to an existing talking-head video (footage untouched) |
| `/talking-head-recut` | Packaging an existing talking-head video with **designed graphic overlays** — lower-thirds, data callouts, kinetic titles, pull-quotes |
| `/motion-graphics` | A short, **unnarrated, design-led motion graphic** — kinetic type, a stat / chart hit, a logo sting, a lower-third overlay |
| `/music-to-video` | A **music track** → a **beat-synced** video — lyric video, slideshow, or kinetic promo; the music drives pacing (optional user images / videos cut onto the beat grid) |
| `/slideshow` | A **presentation / pitch deck / interactive deck** — discrete slides, fragments, branching, hotspots; output is a navigable **deck**, not a rendered video |
| `/general-video` | **Anything else** — longer or multi-scene pieces, a static loop / poster, a custom composition |
| `/remotion-to-hyperframes` | **Porting an existing Remotion (React) composition** to HyperFrames (migration, not creation) |
| Workflow | Use it for |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/product-launch-video` | **Selling a product** (SaaS, app, company / product site) — from a URL, brief, or script → a **promo**. The default for any commercial URL, even if the site is only named. |
| `/website-to-video` | **Showing a site itself** — a tour / showcase built from the site's own screenshots. For non-commercial sites (portfolio, blog, docs, personal, event), or when the user wants a tour, not a promo. |
| `/faceless-explainer` | **Explaining a topic / concept** from text — no product, no URL; every visual is LLM-invented |
| `/pr-to-video` | A **GitHub PR / code change** → changelog / feature-reveal / fix / refactor explainer |
| `/embedded-captions` | Adding **captions / subtitles** to an existing talking-head video (footage untouched) |
| `/talking-head-recut` | Packaging an existing talking-head video with **designed graphic overlays** — lower-thirds, data callouts, kinetic titles, pull-quotes |
| `/motion-graphics` | A short, **unnarrated, design-led motion graphic** — kinetic type, a stat / chart hit, a logo sting, a lower-third overlay |
| `/music-to-video` | A **music track** → a **beat-synced** video — lyric video, slideshow, or kinetic promo; the music drives pacing (optional user images / videos cut onto the beat grid) |
| `/slideshow` | A **presentation / pitch deck / interactive deck** — discrete slides, fragments, branching, hotspots; output is a navigable **deck**, not a rendered video |
| `/general-video` | **Anything else** — longer or multi-scene pieces, a static loop / poster, a custom composition |
| `/remotion-to-hyperframes` | **Porting an existing Remotion (React) composition** to HyperFrames (migration, not creation) |
**Disambiguation (only where confusable):**
- **Motion-first & unnarrated** (under ~10s, the motion _is_ the message) → `/motion-graphics`, regardless of input.
- **A URL or script**markets a specific product (even just naming the site) → `/product-launch-video`; a general non-product site`/website-to-video`; a GitHub PR link → `/pr-to-video`; explains a concept with no product / site → `/faceless-explainer`. Genuinely unclear product-vs-topic, or launch-vs-general-site → ask one question.
- **A URL or script**ask one thing: _is the site selling a product?_ **Yes** (SaaS / app / product / company site) → `/product-launch-video` a promo, and the default for any commercial URL even if the site is only named. **No**, or the user just wants the site shown as-is (portfolio / blog / docs / personal / event)`/website-to-video` a tour. A GitHub PR link → `/pr-to-video`; a concept with no product or site → `/faceless-explainer`.
- **Existing footage** — plain spoken-word subtitles → `/embedded-captions`; designed overlay cards → `/talking-head-recut`. Neither edits the footage itself (re-timing / recolor / reframe / reorder / audio is NLE editing — out of scope).
- **A music track is the input** (an audio file, or a video to pull audio from) with **no narration**`/music-to-video` — the music's beats/energy drive the pacing. (Narrated pieces stay with the input-matched workflow above; `/motion-graphics` is for short unnarrated motion that isn't music-driven.)
- **A presentation / pitch deck / interactive deck** (discrete slides, navigation, presenter mode) → `/slideshow` — output is a navigable deck, not a rendered video. An explicit "slideshow" request proceeds directly; an adjacent trigger ("deck / slides / presentation / convert this page") makes `/slideshow` confirm it's a slideshow before authoring, and switch to the appropriate non-slideshow workflow if not.
@@ -99,13 +99,13 @@ The CLI also surfaces a one-line reminder when a `render` / `lint` / `validate`
### `/product-launch-video`
- **Input:** A product being marketed — **(a)** a product URL (crawled with headless Chrome for assets + brand tokens), **(b)** a script / brief that names the product's site even without a link (PLV resolves + crawls it, unless the user opts out), or **(c)** a script with no derivable site / "don't scrape" (no-capture mode — pick a style preset that supplies palette + design system). A supplied script can be the **verbatim** voice-over or **restructured** per scene — PLV asks.
- **Output:** product launch / SaaS promo as a HyperFrames composition → MP4. (sweet spot 3090s).
- **Output:** a product launch / SaaS **promo** as a HyperFrames composition → MP4 (sweet spot 3090s) — the product's value is the subject, not a walkthrough of the site. For a plain tour of the site, use `/website-to-video`.
- **Triggers:** "launch video for X", "promo for our site", "explain my SaaS in a minute", "turn my script into a 60s promo", "text-only launch video, don't scrape".
### `/website-to-video`
- **Input:** A **general website / URL** to turn into a video — when the goal is a video _of_ the site, not a product launch. Captured with headless Chrome for real screenshots + brand assets.
- **Output:** a site tour / portfolio / landing-page showcase / social clip built from the site's own visuals → MP4.
- **Input:** A website / URL whose goal is to show **the site itself**, not to sell a product. Best for non-commercial sites (portfolio, blog, docs, personal, event), or when the user explicitly wants a tour of a site as-is. Captured with headless Chrome for real screenshots + brand assets. If the site is selling something and the user wants a promo, use `/product-launch-video`.
- **Output:** a site tour / showcase / social clip built from the site's own visuals → MP4.
- **Triggers:** "turn this website into a video", "site tour from ", "social clip from our homepage", "I just have a URL — make something".
### `/faceless-explainer`
+28 -29
View File
@@ -2,7 +2,7 @@ import { strict as assert } from "node:assert";
import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync, existsSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { execSync } from "node:child_process";
import { execFileSync } from "node:child_process";
import { appendRecord, readManifest } from "./lib/manifest.mjs";
import { regenerateIndex } from "./lib/index-gen.mjs";
import { getProvider } from "./lib/providers.mjs";
@@ -10,6 +10,7 @@ import { freezeLocalFile } from "./lib/freeze.mjs";
import { cachePut, cacheGet, importFromCache } from "./lib/cache.mjs";
const REPO_ROOT = join(import.meta.dirname, "..", "..", "..");
const RESOLVE_CLI = join(import.meta.dirname, "resolve.mjs");
let tmp;
function setup() {
@@ -33,8 +34,15 @@ function makeRecord(overrides = {}) {
};
}
function resolveCmd(args) {
return `node skills/media-use/scripts/resolve.mjs ${args}`;
// Run resolve.mjs with argv passed as a literal array (no shell). Each token is
// a separate argv entry, so a value with spaces or shell metacharacters can't
// break out — never build a command string and hand it to a shell.
function runResolve(args, opts = {}) {
return execFileSync(process.execPath, [RESOLVE_CLI, ...args], {
cwd: REPO_ROOT,
encoding: "utf8",
...opts,
});
}
const tests = [];
@@ -52,10 +60,7 @@ test("project manifest hit skips providers", () => {
mkdirSync(join(filePath, ".."), { recursive: true });
writeFileSync(filePath, "cached audio");
const out = execSync(resolveCmd(`--type bgm --intent "cached query" --project "${tmp}" --json`), {
cwd: REPO_ROOT,
encoding: "utf8",
});
const out = runResolve(["--type", "bgm", "--intent", "cached query", "--project", tmp, "--json"]);
const parsed = JSON.parse(out.trim());
assert.equal(parsed.ok, true);
assert.equal(parsed.id, "bgm_001");
@@ -124,10 +129,7 @@ test("--adopt registers existing assets/ files", () => {
writeFileSync(join(tmp, "assets/bgm/track.mp3"), "fake mp3");
writeFileSync(join(tmp, "assets/icons/logo.svg"), "fake svg");
const out = execSync(resolveCmd(`--adopt --project "${tmp}" --json`), {
cwd: REPO_ROOT,
encoding: "utf8",
});
const out = runResolve(["--adopt", "--project", tmp, "--json"]);
const parsed = JSON.parse(out.trim());
assert.equal(parsed.ok, true);
assert.equal(parsed.adopted, 2);
@@ -144,11 +146,8 @@ test("--adopt skips already-registered assets", () => {
mkdirSync(join(tmp, "assets/bgm"), { recursive: true });
writeFileSync(join(tmp, "assets/bgm/track.mp3"), "fake mp3");
execSync(resolveCmd(`--adopt --project "${tmp}" --json`), { cwd: REPO_ROOT, encoding: "utf8" });
const out = execSync(resolveCmd(`--adopt --project "${tmp}" --json`), {
cwd: REPO_ROOT,
encoding: "utf8",
});
runResolve(["--adopt", "--project", tmp, "--json"]);
const out = runResolve(["--adopt", "--project", tmp, "--json"]);
const parsed = JSON.parse(out.trim());
assert.equal(parsed.adopted, 0);
@@ -162,10 +161,15 @@ test("resolve finds existing unregistered asset before hitting providers", () =>
mkdirSync(join(tmp, "assets/bgm"), { recursive: true });
writeFileSync(join(tmp, "assets/bgm/ambient-track.mp3"), "existing bgm");
const out = execSync(
resolveCmd(`--type bgm --intent "ambient track" --project "${tmp}" --json`),
{ cwd: REPO_ROOT, encoding: "utf8" },
);
const out = runResolve([
"--type",
"bgm",
"--intent",
"ambient track",
"--project",
tmp,
"--json",
]);
const parsed = JSON.parse(out.trim());
assert.equal(parsed.ok, true);
assert.equal(parsed.path, "assets/bgm/ambient-track.mp3");
@@ -176,14 +180,14 @@ test("resolve finds existing unregistered asset before hitting providers", () =>
// --- CLI interface ---
test("--help exits 0", () => {
const out = execSync(resolveCmd("--help"), { cwd: REPO_ROOT, encoding: "utf8" });
const out = runResolve(["--help"]);
assert.ok(out.includes("media-use resolve"));
assert.ok(out.includes("--type"));
});
test("missing required args exits 2", () => {
try {
execSync(resolveCmd(""), { cwd: REPO_ROOT, encoding: "utf8", stdio: "pipe" });
runResolve([], { stdio: "pipe" });
assert.fail("should have exited");
} catch (err) {
assert.equal(err.status, 2);
@@ -193,9 +197,7 @@ test("missing required args exits 2", () => {
test("--json returns error JSON on stub provider failure", () => {
setup();
try {
execSync(resolveCmd(`--type bgm --intent "stub fail" --project "${tmp}" --json`), {
cwd: REPO_ROOT,
encoding: "utf8",
runResolve(["--type", "bgm", "--intent", "stub fail", "--project", tmp, "--json"], {
stdio: "pipe",
});
assert.fail("should have exited");
@@ -216,10 +218,7 @@ test("one-line output format matches contract", () => {
mkdirSync(join(filePath, ".."), { recursive: true });
writeFileSync(filePath, "format check");
const out = execSync(resolveCmd(`--type bgm --intent "format test" --project "${tmp}"`), {
cwd: REPO_ROOT,
encoding: "utf8",
});
const out = runResolve(["--type", "bgm", "--intent", "format test", "--project", tmp]);
assert.match(out.trim(), /^resolved bgm_001 → .media\/audio\/bgm\/bgm_001\.wav \(bgm/);
cleanup();
});
@@ -236,6 +236,104 @@
"</svg>"
);
}
// Insert SVG markup as SANITIZED parsed nodes — never innerHTML, and never
// a raw appendChild. DOMParser builds the tree, then cleanSvg() hard-strips
// everything that isn't inert drawing: only allow-listed shape elements +
// presentation attributes survive, so <script>, <image>/<use>/<foreignObject>,
// javascript: hrefs and every on* handler are removed before the nodes ever
// enter the live document. A custom icon SVG therefore can't smuggle active
// content (CWE-79 DOM-XSS). Non-SVG / malformed input falls back to text.
var SVG_OK_TAGS = {
svg: 1,
g: 1,
path: 1,
line: 1,
polyline: 1,
polygon: 1,
rect: 1,
circle: 1,
ellipse: 1,
defs: 1,
lineargradient: 1,
radialgradient: 1,
stop: 1,
clippath: 1,
title: 1,
desc: 1,
text: 1,
tspan: 1,
};
var SVG_OK_ATTRS = {
viewbox: 1,
xmlns: 1,
width: 1,
height: 1,
fill: 1,
"fill-rule": 1,
"fill-opacity": 1,
stroke: 1,
"stroke-width": 1,
"stroke-linecap": 1,
"stroke-linejoin": 1,
"stroke-dasharray": 1,
"stroke-dashoffset": 1,
"stroke-opacity": 1,
opacity: 1,
d: 1,
x: 1,
y: 1,
x1: 1,
y1: 1,
x2: 1,
y2: 1,
cx: 1,
cy: 1,
r: 1,
rx: 1,
ry: 1,
points: 1,
transform: 1,
offset: 1,
"stop-color": 1,
"stop-opacity": 1,
gradientunits: 1,
gradienttransform: 1,
"clip-path": 1,
"clip-rule": 1,
class: 1,
id: 1,
};
function cleanSvg(node) {
var attrs = Array.prototype.slice.call(node.attributes || []);
for (var a = 0; a < attrs.length; a++) {
if (!SVG_OK_ATTRS[attrs[a].name.toLowerCase()]) node.removeAttribute(attrs[a].name);
}
var kids = Array.prototype.slice.call(node.childNodes);
for (var k = 0; k < kids.length; k++) {
var c = kids[k];
if (c.nodeType === 1) {
if (SVG_OK_TAGS[(c.localName || c.nodeName).toLowerCase()]) cleanSvg(c);
else node.removeChild(c);
} else if (c.nodeType !== 3) {
node.removeChild(c);
}
}
}
function setSvg(el, markup) {
el.textContent = "";
var doc = new DOMParser().parseFromString(String(markup), "image/svg+xml");
var root = doc.documentElement;
if (
!root ||
root.nodeName.toLowerCase() !== "svg" ||
doc.getElementsByTagName("parsererror").length
) {
el.textContent = String(markup);
return;
}
cleanSvg(root);
el.appendChild(document.importNode(root, true));
}
// ── 4. Default program (reversed from act0-intro-bell) ──
var DEFAULT_PHRASES = [
@@ -381,9 +479,9 @@
glyph.style.height = iconPx + "px";
glyph.style.color = TH.ink;
if (ICONS[iconSpec]) {
glyph.innerHTML = iconSVG(iconSpec); // library icon
setSvg(glyph, iconSVG(iconSpec)); // library icon
} else if (iconSpec.slice(0, 4).toLowerCase() === "<svg") {
glyph.innerHTML = iconSpec; // custom inline SVG
setSvg(glyph, iconSpec); // custom inline SVG
} else {
glyph.style.fontSize = Math.round(iconPx * 0.86) + "px"; // emoji / text glyph
glyph.textContent = iconSpec;
@@ -361,10 +361,109 @@
document.documentElement.style.setProperty("--mark", vars.markColor);
document.documentElement.style.setProperty("--text", vars.textColor);
// Insert SVG markup as SANITIZED parsed nodes — never innerHTML, and never
// a raw appendChild. DOMParser builds the tree, then cleanSvg() hard-strips
// everything that isn't inert drawing: only allow-listed shape elements +
// presentation attributes survive, so <script>, <image>/<use>/<foreignObject>,
// javascript: hrefs and every on* handler are removed before the nodes ever
// enter the live document. A custom mark SVG therefore can't smuggle active
// content (CWE-79 DOM-XSS). Non-SVG / malformed input falls back to text.
var SVG_OK_TAGS = {
svg: 1,
g: 1,
path: 1,
line: 1,
polyline: 1,
polygon: 1,
rect: 1,
circle: 1,
ellipse: 1,
defs: 1,
lineargradient: 1,
radialgradient: 1,
stop: 1,
clippath: 1,
title: 1,
desc: 1,
text: 1,
tspan: 1,
};
var SVG_OK_ATTRS = {
viewbox: 1,
xmlns: 1,
width: 1,
height: 1,
fill: 1,
"fill-rule": 1,
"fill-opacity": 1,
stroke: 1,
"stroke-width": 1,
"stroke-linecap": 1,
"stroke-linejoin": 1,
"stroke-dasharray": 1,
"stroke-dashoffset": 1,
"stroke-opacity": 1,
opacity: 1,
d: 1,
x: 1,
y: 1,
x1: 1,
y1: 1,
x2: 1,
y2: 1,
cx: 1,
cy: 1,
r: 1,
rx: 1,
ry: 1,
points: 1,
transform: 1,
offset: 1,
"stop-color": 1,
"stop-opacity": 1,
gradientunits: 1,
gradienttransform: 1,
"clip-path": 1,
"clip-rule": 1,
class: 1,
id: 1,
};
function cleanSvg(node) {
var attrs = Array.prototype.slice.call(node.attributes || []);
for (var a = 0; a < attrs.length; a++) {
if (!SVG_OK_ATTRS[attrs[a].name.toLowerCase()]) node.removeAttribute(attrs[a].name);
}
var kids = Array.prototype.slice.call(node.childNodes);
for (var k = 0; k < kids.length; k++) {
var c = kids[k];
if (c.nodeType === 1) {
if (SVG_OK_TAGS[(c.localName || c.nodeName).toLowerCase()]) cleanSvg(c);
else node.removeChild(c);
} else if (c.nodeType !== 3) {
node.removeChild(c);
}
}
}
function setSvg(el, markup) {
el.textContent = "";
var doc = new DOMParser().parseFromString(String(markup), "image/svg+xml");
var root = doc.documentElement;
if (
!root ||
root.nodeName.toLowerCase() !== "svg" ||
doc.getElementsByTagName("parsererror").length
) {
el.textContent = String(markup);
return;
}
cleanSvg(root);
el.appendChild(document.importNode(root, true));
}
var markLeft = document.getElementById("markLeft");
var markRight = document.getElementById("markRight");
markLeft.innerHTML = (vars.leftMark && vars.leftMark.trim()) || DEFAULT_LEFT;
markRight.innerHTML = (vars.rightMark && vars.rightMark.trim()) || DEFAULT_RIGHT;
setSvg(markLeft, (vars.leftMark && vars.leftMark.trim()) || DEFAULT_LEFT);
setSvg(markRight, (vars.rightMark && vars.rightMark.trim()) || DEFAULT_RIGHT);
var wordsEl = document.getElementById("words");
var wordList = [vars.word1, vars.word2, vars.word3, vars.word4].filter(function (w) {
@@ -180,7 +180,7 @@ const html = `<!doctype html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=${WIDTH}, height=${HEIGHT}" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js" integrity="sha384-sG0Hv1tP1lZCk9KQmrIbY/XNwi+OY84GQqhMscbnsoBFqAz8KNCil1kvfL3Hbbk2" crossorigin="anonymous"></script>
<style>
${headStyle}
</style>
@@ -523,7 +523,7 @@ const html = `<!doctype html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=${WIDTH}, height=${HEIGHT}" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js" integrity="sha384-sG0Hv1tP1lZCk9KQmrIbY/XNwi+OY84GQqhMscbnsoBFqAz8KNCil1kvfL3Hbbk2" crossorigin="anonymous"></script>
<style>
${headStyle}
</style>
+1 -1
View File
@@ -466,7 +466,7 @@ function buildCaptionsHtml(groups, total, W, H) {
color: rgba(255, 255, 255, 0.55);
}
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js" integrity="sha384-sG0Hv1tP1lZCk9KQmrIbY/XNwi+OY84GQqhMscbnsoBFqAz8KNCil1kvfL3Hbbk2" crossorigin="anonymous"></script>
<script>
(function () {
var GROUPS = ${JSON.stringify(groups)};
@@ -12,6 +12,10 @@
// - a missing/deleted user, a network blip, an offline run → log + skip
// - it ALWAYS exits 0 (a failed avatar must never block the build)
//
// Network is constrained on purpose: only https GitHub avatar hosts are fetched
// (SSRF guard), and bytes are only ever written under the project dir (no path
// traversal), so a tampered people.json can't redirect the fetch or the write.
//
// Reads:
// --people <path> capture/extracted/people.json (from ingest.mjs)
// Writes:
@@ -28,7 +32,7 @@
// node fetch-people-avatars.mjs --people ./capture/extracted/people.json
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs";
import { resolve, join, dirname } from "node:path";
import { resolve, join, dirname, sep } from "node:path";
const argv = process.argv.slice(2);
const flag = (name, def) => {
@@ -40,6 +44,30 @@ const peoplePath = resolve(flag("people", "./capture/extracted/people.json"));
const projectDir = resolve(flag("project-dir", "."));
const TIMEOUT = parseInt(flag("timeout", "8000"), 10);
// SSRF guard: avatars only ever come from GitHub's avatar hosts, so refuse any
// other URL rather than fetching whatever string people.json happens to carry.
// `github.com/<login>.png` 302s to avatars.githubusercontent.com (redirect stays
// on-host, controlled by GitHub).
const AVATAR_HOSTS = new Set(["avatars.githubusercontent.com", "github.com", "www.github.com"]);
function isAllowedAvatarUrl(u) {
let parsed;
try {
parsed = new URL(u);
} catch {
return false;
}
if (parsed.protocol !== "https:") return false;
const host = parsed.hostname.toLowerCase();
return AVATAR_HOSTS.has(host) || host.endsWith(".githubusercontent.com");
}
// Path guard: the written file must stay inside the project dir, so a crafted
// avatarFile ("../../etc/…") can't escape via join().
function isUnderProject(p) {
const r = resolve(p);
return r === projectDir || r.startsWith(projectDir + sep);
}
// Soft-exit helper — avatars are optional, so every early-out is exit 0.
function softExit(msg) {
console.log(`• fetch-avatars: ${msg}`);
@@ -61,9 +89,19 @@ if (!people.length) softExit("no contributors in people.json — skipping");
async function fetchOne(person) {
const { login, avatarUrl } = person;
if (!login || !avatarUrl) return "skip";
if (!isAllowedAvatarUrl(avatarUrl)) {
person.avatarFetched = false;
console.log(` (skip avatar @${login}: not a GitHub avatar URL)`);
return "fail";
}
// avatarFile is project-root-relative ("assets/<login>.png"); anchor on the
// project root so it stays under the project's assets/ dir.
const dest = join(projectDir, person.avatarFile || `assets/${login}.png`);
if (!isUnderProject(dest)) {
person.avatarFetched = false;
console.log(` (skip avatar @${login}: avatar path escapes the project dir)`);
return "fail";
}
mkdirSync(dirname(dest), { recursive: true });
// Idempotent: a non-empty file from a prior run is reused (re-runs are free).
if (existsSync(dest) && statSync(dest).size > 0) {
@@ -523,7 +523,7 @@ const html = `<!doctype html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=${WIDTH}, height=${HEIGHT}" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js" integrity="sha384-sG0Hv1tP1lZCk9KQmrIbY/XNwi+OY84GQqhMscbnsoBFqAz8KNCil1kvfL3Hbbk2" crossorigin="anonymous"></script>
<style>
${headStyle}
</style>
@@ -466,7 +466,7 @@ function buildCaptionsHtml(groups, total, W, H) {
color: rgba(255, 255, 255, 0.55);
}
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js" integrity="sha384-sG0Hv1tP1lZCk9KQmrIbY/XNwi+OY84GQqhMscbnsoBFqAz8KNCil1kvfL3Hbbk2" crossorigin="anonymous"></script>
<script>
(function () {
var GROUPS = ${JSON.stringify(groups)};