mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
co-authored by
Claude Opus 4.8
parent
8694424807
commit
535297280a
@@ -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
@@ -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 30–90s).
|
||||
- **Output:** a product launch / SaaS **promo** as a HyperFrames composition → MP4 (sweet spot 30–90s) — 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`
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)};
|
||||
|
||||
Reference in New Issue
Block a user