diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index db2e3b4..91bd0ed 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -88,6 +88,27 @@ jobs:
- name: unit tests (platform-neutral packages)
run: go test ./internal/token ./internal/audit ./internal/ui ./internal/server
+ # The docs site's JS (docs/site.js) is the file GitHub Pages actually serves,
+ # and no Go test can see it. A wrong API field name, a broken URL check or a
+ # renamed element id in index.html all fail *silently*: the reader gets an
+ # empty download table and no error on either side. That is not hypothetical —
+ # the first shipped URL check accepted ".../releases/download/v9/../../../evil",
+ # which resolves to https://github.com/DevCop95/evil, and it was found by
+ # running the file, not reading it. So the shipped file is run in node against
+ # a stub DOM, with hostile payloads. No install, no network: fixtures are
+ # trimmed copies of a real API response, so this gate cannot go red because
+ # GitHub was slow.
+ site-test:
+ name: site js test
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: actions/setup-node@v7
+ with:
+ node-version: 20
+ - name: run docs/site.test.js against docs/site.js
+ run: node docs/site.test.js
+
# Fuzzing on every push/PR. Two groups of targets:
# * the module parsers — registry hives, KDC replies, payload envelopes —
# which read bytes this tool did not author;
diff --git a/SECURITY.md b/SECURITY.md
index eea9c30..d4419e7 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -40,6 +40,39 @@ protects are:
**Out of scope:** physical access, kernel-level attackers, and the (by design)
operator console once the token holder is trusted.
+## Documented limits
+
+These are boundaries of the current design, not bugs, and they are written
+here so they are not rediscovered as surprises.
+
+### The token is a lock on the door, not on the room
+
+`GET /` hands the token to the page that runs the dashboard, and it cannot tell
+your browser apart from any other local process that asks. Anything running in
+your session with loopback access can fetch the token and then call the API
+with it. There is no privilege boundary between the console and a process
+running as the same user, and none is claimed.
+
+- **Single-user workstation** (the intended deployment): a process that already
+ runs as you has everything the console can do, so the token adds no exposure
+ beyond what that process already has.
+- **Shared host, terminal server, or a lower-privilege local process:** when
+ `--enable-collect` is set the console is elevated, so a lower-privilege
+ process that reads the token can ask the elevated server to export SAM/SYSTEM
+ or read browser stores. Do not run the console elevated with those modules
+ unlocked on a host where another account can reach loopback.
+
+If this boundary needs to be hard rather than documented, the fix is a named
+pipe with an ACL restricted to the operator's SID instead of an HTTP listener;
+that is tracked in issue #6.
+
+### `POST /v1/sys/opt` changes machine policy with no flag
+
+It writes registry policy and disables `DiagTrack` with no startup flag and no
+request body, so anyone holding the token can change machine policy without any
+of the module gates. It is documented here rather than gated, because the token
+holder is trusted by design (see above).
+
## Known intentional capabilities
These are features, not bugs — they are the reason the tool exists:
diff --git a/docs/security.html b/docs/security.html
index d3b3e5f..dd2b67d 100644
--- a/docs/security.html
+++ b/docs/security.html
@@ -144,9 +144,11 @@
Two limits, documented rather than hidden
- Both are recorded as open design questions in
- issue #6, with the options and
- their trade-offs, rather than left to be discovered by whoever reads the code next.
+ Both are written into the repository's
+ SECURITY.md
+ under "Documented limits" rather than left to be discovered by whoever reads the code next.
+ The discussion, the trade-offs and the rejected options live in
+ issue #6, which is closed.
Intentional capabilities
diff --git a/docs/site.test.js b/docs/site.test.js
new file mode 100644
index 0000000..964404c
--- /dev/null
+++ b/docs/site.test.js
@@ -0,0 +1,294 @@
+// Runs docs/site.js — the file Pages actually serves — against a minimal DOM
+// stub, so what it reads from the GitHub API and the URL check it applies are
+// asserted instead of assumed.
+//
+// Why this exists: site.js renders the download table, and no Go test can see
+// it. A renamed field, a broken URL check or an element id changed in
+// index.html all fail *quietly* — the reader gets an empty table and no error
+// on either side. That is not hypothetical: the first shipped version of the
+// URL check accepted
+//
+// https://github.com/DevCop95/pullgoscript/releases/download/v9/../../../evil
+//
+// which resolves to https://github.com/DevCop95/evil. Running the file found
+// it; reading it did not. So the file gets run.
+//
+// No dependencies and no network: `node docs/site.test.js` in CI, nothing to
+// install. The fixtures are trimmed copies of a real
+// /repos/DevCop95/pullgoscript/releases/latest response (v2.0.2, published
+// 2026-10-17) and of that release's checksums.txt, so the shapes are the ones
+// GitHub actually sends. SITE_TEST_LIVE=1 hits the live API instead; CI never
+// does, so a GitHub hiccup cannot turn this gate red.
+"use strict";
+
+const fs = require("fs");
+const path = require("path");
+const assert = require("assert/strict");
+
+const DIR = __dirname;
+const SRC = fs.readFileSync(path.join(DIR, "site.js"), "utf8");
+const HTML = fs.readFileSync(path.join(DIR, "index.html"), "utf8");
+
+const ORIGIN = "https://github.com";
+const ASSET_PATH = "/DevCop95/pullgoscript/releases/download/";
+const PREFIX = ORIGIN + ASSET_PATH;
+
+let checks = 0;
+function ok(label, detail) {
+ checks++;
+ console.log(" ok " + label + (detail !== undefined ? " (" + detail + ")" : ""));
+}
+function eq(label, actual, expected) {
+ checks++;
+ assert.deepEqual(
+ actual,
+ expected,
+ label +
+ "\n expected: " + JSON.stringify(expected) +
+ "\n actual: " + JSON.stringify(actual)
+ );
+ console.log(" ok " + label);
+}
+
+// --- fixtures: real values, not invented ones ----------------------------
+// Sizes are the byte counts the API reported, which is also what pins the
+// human-readable column: 6131480 must render as "5.8 MiB", the same string the
+// static fallback table in index.html carries by hand.
+
+const CHECKSUMS = [
+ "90b2e6e5794ed17892ccded42df81ea74840ca40b50370bd9fc7e117d9cff3c8 obliteratus_2.0.2_windows_amd64.zip",
+ "ce40c67281c31eb540e8f4639894bf471b110f1fb35136ed413344dd64477c66 obliteratus_2.0.2_windows_amd64.zip.sbom.json",
+ "ae47be1d2bbce867b6e5f08de35be2e3d90649ffa8bf9c7bdecc1930cb077160 obliteratus_2.0.2_windows_arm64.zip",
+ "68b9ab079ed67a5b5dba381ec2cff53f3b9dcf6ebe74de1a57a778d442a3662c obliteratus_2.0.2_windows_arm64.zip.sbom.json",
+].join("\n") + "\n";
+
+const RELEASE = {
+ tag_name: "v2.0.2",
+ published_at: "2026-10-17T13:36:41Z",
+ assets: [
+ { name: "checksums.txt", size: 428, browser_download_url: PREFIX + "v2.0.2/checksums.txt" },
+ { name: "checksums.txt.pem", size: 3268, browser_download_url: PREFIX + "v2.0.2/checksums.txt.pem" },
+ { name: "checksums.txt.sig", size: 96, browser_download_url: PREFIX + "v2.0.2/checksums.txt.sig" },
+ { name: "obliteratus_2.0.2_windows_amd64.zip", size: 6131480, browser_download_url: PREFIX + "v2.0.2/obliteratus_2.0.2_windows_amd64.zip" },
+ { name: "obliteratus_2.0.2_windows_amd64.zip.sbom.json", size: 26548, browser_download_url: PREFIX + "v2.0.2/obliteratus_2.0.2_windows_amd64.zip.sbom.json" },
+ { name: "obliteratus_2.0.2_windows_arm64.zip", size: 5622388, browser_download_url: PREFIX + "v2.0.2/obliteratus_2.0.2_windows_arm64.zip" },
+ { name: "obliteratus_2.0.2_windows_arm64.zip.sbom.json", size: 26548, browser_download_url: PREFIX + "v2.0.2/obliteratus_2.0.2_windows_arm64.zip.sbom.json" },
+ ],
+};
+
+// --- the DOM the page would build, plus a trap for unsafe hrefs ----------
+
+let violations = [];
+
+function makeEl(tag) {
+ const el = {
+ tagName: tag,
+ children: [],
+ textContent: "",
+ className: "",
+ hidden: false,
+ _href: null,
+ appendChild(c) { el.children.push(c); return c; },
+ removeChild(c) { el.children = el.children.filter(x => x !== c); return c; },
+ get firstChild() { return el.children[0] || null; },
+ };
+ Object.defineProperty(el, "href", {
+ get() { return el._href; },
+ set(v) {
+ if (v === null || v === undefined) {
+ // A real DOM turns `a.href = null` into the string "null" and the link
+ // points at /pullgoscript/null. Nothing should ever assign it.
+ violations.push("href assigned " + v);
+ } else if (String(v).indexOf(PREFIX) !== 0) {
+ violations.push("href outside the download path: " + v);
+ }
+ el._href = v;
+ },
+ });
+ return el;
+}
+
+// The ids are read from index.html, not hard-coded: rename one there and
+// site.js returns at its guard, which this test reports as an empty table
+// rather than a passing run.
+const idsInHTML = new Set([...HTML.matchAll(/\bid="([^"]+)"/g)].map(m => m[1]));
+
+async function run(payload, opts) {
+ opts = opts || {};
+ violations = [];
+ const dom = { "dl-live": makeEl("div"), "dl-status": makeEl("p"), "dl-fallback": makeEl("table") };
+ const fetched = [];
+
+ const document = {
+ getElementById: id => (idsInHTML.has(id) ? dom[id] || null : null),
+ createElement: makeEl,
+ };
+ const fetch = url => {
+ fetched.push(url);
+ if (opts.httpStatus && url.indexOf("/releases/latest") !== -1) {
+ return Promise.resolve({ ok: false, status: opts.httpStatus, json: () => Promise.reject(new Error("no body")), text: () => Promise.resolve("") });
+ }
+ if (url.indexOf("checksums.txt") !== -1) {
+ if (opts.sumsBroken) return Promise.reject(new Error("network down"));
+ return Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve(opts.sumsText || "") });
+ }
+ return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(payload) });
+ };
+
+ new Function("document", "fetch", SRC)(document, fetch);
+ await new Promise(r => setTimeout(r, 120));
+
+ const table = dom["dl-live"].children[0] || null;
+ const tbody = table ? table.children.find(c => c.tagName === "tbody") : null;
+ const rows = tbody ? tbody.children : [];
+ return {
+ table, rows, fetched,
+ fallbackHidden: dom["dl-fallback"].hidden,
+ status: dom["dl-status"].textContent,
+ caption: table && table.children[0] ? table.children[0].textContent : "",
+ hrefs: rows.map(r => r.children[0].children[0]._href),
+ names: rows.map(r => r.children[0].children[0].textContent),
+ sizes: rows.map(r => r.children[1].textContent),
+ hashes: rows.map(r => r.children[2].textContent),
+ };
+}
+
+function live() {
+ return process.env.SITE_TEST_LIVE === "1";
+}
+
+async function loadLive(what) {
+ const r = await fetch("https://api.github.com/repos/DevCop95/pullgoscript/releases/latest", {
+ headers: { Accept: "application/vnd.github+json" },
+ });
+ if (!r.ok) throw new Error("the API answered " + r.status);
+ const release = await r.json();
+ if (what === "release") return release;
+ const sums = (release.assets || []).find(a => a.name === "checksums.txt");
+ if (!sums) return "";
+ const t = await fetch(sums.browser_download_url);
+ return t.ok ? t.text() : "";
+}
+
+(async function main() {
+ console.log("site.test.js — " + (live() ? "live API" : "fixtures") + "\n");
+
+ // --- static discipline -------------------------------------------------
+ // The comment in site.js promises API strings are never written as markup.
+ // This keeps that promise from being quietly dropped in a later edit. The
+ // check runs on code only (comments stripped), because the rationale comment
+ // at the top of site.js itself names innerHTML as the mistake to avoid.
+ const codeOnly = SRC
+ .replace(/\/\*[\s\S]*?\*\//g, "")
+ .split("\n")
+ .filter(l => !/^\s*\/\//.test(l))
+ .join("\n");
+ assert.ok(!/innerHTML|outerHTML|insertAdjacentHTML/.test(codeOnly),
+ "site.js must not build markup from strings (innerHTML found in code)");
+ ok("site.js contains no innerHTML/outerHTML/insertAdjacentHTML (code only)");
+
+ const needed = ["dl-live", "dl-status", "dl-fallback"];
+ needed.forEach(id => {
+ assert.ok(idsInHTML.has(id), "index.html is missing id=\"" + id + "\"");
+ });
+ ok("index.html carries every element id site.js looks up", needed.join(", "));
+
+ const release = live() ? await loadLive("release") : RELEASE;
+ const sumsText = live() ? await loadLive("checksums") : CHECKSUMS;
+
+ // --- A. the real shape -------------------------------------------------
+ console.log("\nA. real payload + real checksums.txt");
+ const a = await run(release, { sumsText });
+ const assetNames = (release.assets || []).map(x => x.name);
+ eq("every asset in the release gets a row", a.rows.length, assetNames.length);
+ eq("fallback list is hidden once the live table renders", a.fallbackHidden, true);
+ eq("no href outside the download path", violations.length, 0);
+ assert.ok(a.caption.indexOf(release.tag_name) !== -1, "caption must name the tag");
+ ok("caption names the release", a.caption);
+
+ // The hash column is filled from checksums.txt, keyed by asset name: this is
+ // the check that would have caught a misnamed field.
+ const amd64 = a.names.findIndex(n => /_windows_amd64\.zip$/.test(n));
+ if (amd64 >= 0) {
+ const published = new Map(
+ sumsText.trim().split("\n").map(l => l.trim().split(/\s+/)).filter(p => p.length >= 2)
+ .map(p => [p[1], p[0]])
+ );
+ eq("SHA-256 column matches checksums.txt for " + a.names[amd64],
+ a.hashes[amd64], published.get(a.names[amd64]));
+ eq("size column is the byte count, humanised", a.sizes[amd64], "5.8 MiB");
+ } else {
+ ok("no amd64 zip in this release, hash-column check skipped");
+ }
+
+ // Assets with no line in checksums.txt must read as "no hash", not as empty
+ // (an empty cell looks like a layout bug; an em dash reads as intentional).
+ const txt = a.names.findIndex(n => n === "checksums.txt");
+ if (txt >= 0) eq("asset absent from checksums.txt shows an em dash", a.hashes[txt], "\u2014");
+
+ // --- B. hostile payload ------------------------------------------------
+ console.log("\nB. hostile payload");
+ const evil = {
+ tag_name: "v9.9.9",
+ published_at: "2026-01-01T00:00:00Z",
+ assets: [
+ { name: "
", size: null, browser_download_url: PREFIX + "v9.9.9/x" },
+ { name: "js", size: 1, browser_download_url: "javascript:alert(1)" },
+ { name: "foreign", size: 1, browser_download_url: "https://evil.com/pullgoscript/releases/download/v9/x" },
+ { name: "traversal", size: 1, browser_download_url: PREFIX + "v9.9.9/../../../evil" },
+ ],
+ };
+ const b = await run(evil, { sumsText: "" });
+ eq("only the one asset on the real download path survives", b.names,
+ ["
"]);
+ eq("no href outside the download path", violations.length, 0);
+ ok("javascript: URL dropped", "js not in " + JSON.stringify(b.names));
+ ok("foreign host dropped", "foreign not in " + JSON.stringify(b.names));
+ ok("traversal dropped after URL resolution", "traversal not in " + JSON.stringify(b.names));
+
+ // --- C. nothing usable -------------------------------------------------
+ console.log("\nC. payload where no asset survives the URL check");
+ const c = await run({ tag_name: "v0", assets: [{ name: "x", size: 1, browser_download_url: "javascript:1" }] }, { sumsText: "" });
+ eq("no table is built", c.rows.length, 0);
+ eq("the pinned list stays visible", c.fallbackHidden, false);
+ assert.ok(/URL check/.test(c.status), "status must name the reason, not just 'could not read'");
+ ok("status names the URL check as the reason");
+
+ // --- D. the API refuses ------------------------------------------------
+ console.log("\nD. API answers 403 (rate limit)");
+ const d = await run(null, { httpStatus: 403 });
+ eq("the pinned list stays visible", d.fallbackHidden, false);
+ assert.ok(/403/.test(d.status), "status must carry the HTTP status");
+ ok("status carries the HTTP status");
+
+ // --- E. checksums.txt unreachable --------------------------------------
+ console.log("\nE. release readable, checksums.txt not");
+ const e = await run(RELEASE, { sumsBroken: true });
+ eq("the table still renders without hashes", e.rows.length, RELEASE.assets.length);
+ eq("fallback stays hidden: the table is live, just hashless", e.fallbackHidden, true);
+
+ // --- F. the static fallback must not drift ------------------------------
+ // Only meaningful against the fixture: once a newer release ships, the pinned
+ // table in index.html is stale by definition and the fixture is what keeps
+ // this check honest.
+ if (!live()) {
+ console.log("\nF. pinned fallback table in index.html");
+ const pinned = [...HTML.matchAll(/([0-9a-f]{64})<\/td>/g)].map(m => m[1]);
+ const published = CHECKSUMS.trim().split("\n").map(l => l.split(/\s+/)[0]);
+ eq("pinned row count", pinned.length, 2);
+ pinned.forEach(h => {
+ assert.ok(published.includes(h), "pinned hash not in the published checksums.txt: " + h);
+ });
+ ok("both pinned hashes match the published checksums.txt");
+ assert.ok(HTML.indexOf("5.8 MiB") !== -1 && HTML.indexOf("5.4 MiB") !== -1,
+ "pinned sizes must match the API byte counts humanised");
+ ok("pinned sizes match the API sizes humanised");
+ } else {
+ console.log("\nF. skipped: pinned table is compared against the fixture, not the live API");
+ }
+
+ console.log("\n" + checks + " assertions, all passed");
+})().catch(err => {
+ console.error("\nFAILED: " + (err && err.message ? err.message : err));
+ process.exit(1);
+});
|