From 074a283490b4d91939db971f889b992fc6638c2d Mon Sep 17 00:00:00 2001 From: DevCop95 Date: Sun, 18 Oct 2026 01:27:50 -0500 Subject: [PATCH] fix(site): read digests as the API ships them, add a favicon, un-box the UI The SHA-256 column used to fetch checksums.txt from github.com/.../releases/download/... That origin 302s to a CDN whose response carries no Access-Control-Allow-Origin, so every real browser blocked the request and rendered the table hashless. (The API asset endpoint fails the same way - the final response is the one the browser checks, and it has no ACAO header either.) The browser-side fix is to stop re-downloading the file at all: every release asset already carries a digest field in the /releases/latest response, which matches checksums.txt exactly and arrives over api.github.com, the one origin that sends CORS headers. The page is now exactly one request to one CORS-safe origin, and a regression test asserts that request budget offline. Also un-box the visual design: hard #262c37 borders read as dirt on the dark palette and made cards, pills and callouts look flat. Surfaces are now separated by gradient, hairline and shadow, and hover states arrive as tints. The favicon is a hand-drawn SVG plus a four-size ICO, linked from all five pages. --- docs/audit.html | 2 + docs/capabilities.html | 2 + docs/favicon.ico | Bin 0 -> 5955 bytes docs/favicon.svg | 8 ++ docs/index.html | 2 + docs/security.html | 2 + docs/site.css | 108 +++++++++++++++++------ docs/site.js | 53 +++++++----- docs/site.test.js | 192 +++++++++++++++++++++++++++-------------- docs/verify.html | 2 + 10 files changed, 255 insertions(+), 116 deletions(-) create mode 100644 docs/favicon.ico create mode 100644 docs/favicon.svg diff --git a/docs/audit.html b/docs/audit.html index 6f94794..0579c1f 100644 --- a/docs/audit.html +++ b/docs/audit.html @@ -5,6 +5,8 @@ Audit checks — OBLITERATUS + + diff --git a/docs/capabilities.html b/docs/capabilities.html index b3aac6a..02b8d2e 100644 --- a/docs/capabilities.html +++ b/docs/capabilities.html @@ -5,6 +5,8 @@ Capabilities & gates — OBLITERATUS + + diff --git a/docs/favicon.ico b/docs/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..99299f942173e9db6cf84ab6ae51ecfe77eff3c2 GIT binary patch literal 5955 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 acPY*{li~W|k)=G$N^1s+0s4O%qW=K~6%)?@ literal 0 HcmV?d00001 diff --git a/docs/favicon.svg b/docs/favicon.svg new file mode 100644 index 0000000..4adde88 --- /dev/null +++ b/docs/favicon.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/docs/index.html b/docs/index.html index 8b5d936..b36fe90 100644 --- a/docs/index.html +++ b/docs/index.html @@ -5,6 +5,8 @@ OBLITERATUS — Windows credential exposure auditor + + diff --git a/docs/security.html b/docs/security.html index dd2b67d..b797b12 100644 --- a/docs/security.html +++ b/docs/security.html @@ -5,6 +5,8 @@ Security model — OBLITERATUS + + diff --git a/docs/site.css b/docs/site.css index 20c1a40..9aa7288 100644 --- a/docs/site.css +++ b/docs/site.css @@ -4,12 +4,23 @@ --bg: #0c0e12; --bg-1: #12151b; --bg-2: #171b23; + --bg-3: #1c212b; --fg: #dfe4ec; --fg-dim: #939cab; - --line: #262c37; + /* Hairlines, not borders: pure white at ~6% reads as a soft edge on the + dark palette; the old #262c37 was a saturated 1px line that boxed + everything in and made the cards look flat and dirty. */ + --line: rgba(255, 255, 255, 0.06); + --line-strong: rgba(255, 255, 255, 0.1); --accent: #e2544a; --accent-2: #f0a04b; + --accent-dim: rgba(226, 84, 74, 0.14); + --accent-2-dim: rgba(240, 160, 75, 0.13); --ok: #4fb477; + --ok-dim: rgba(79, 180, 119, 0.13); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-md: 0 2px 4px rgba(0, 0, 0, 0.3), 0 10px 28px rgba(0, 0, 0, 0.28); + --shadow-lg: 0 4px 8px rgba(0, 0, 0, 0.35), 0 18px 44px rgba(0, 0, 0, 0.36); --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; --sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; } @@ -25,6 +36,18 @@ body { font: 16px/1.65 var(--sans); } +/* A single soft glow at the top of the page. Costs nothing (no image, no + request) and stops the background from reading as a flat dead slab; the + red is the brand accent, kept faint so text contrast does not move. */ +body::before { + content: ""; + position: fixed; + inset: 0; + background: radial-gradient(900px 460px at 50% -8%, rgba(226, 84, 74, 0.07), transparent 70%); + pointer-events: none; + z-index: -1; +} + a { color: var(--accent-2); text-decoration: none; } a:hover { text-decoration: underline; } @@ -32,7 +55,6 @@ code, kbd, pre, .mono { font-family: var(--mono); } code { background: var(--bg-2); - border: 1px solid var(--line); border-radius: 4px; padding: 1px 5px; font-size: 0.88em; @@ -42,11 +64,12 @@ pre { background: var(--bg-1); border: 1px solid var(--line); border-left: 3px solid var(--accent); - border-radius: 6px; + border-radius: 10px; padding: 14px 16px; overflow-x: auto; font-size: 13.5px; line-height: 1.6; + box-shadow: var(--shadow-sm); } pre code { background: none; border: 0; padding: 0; font-size: inherit; } @@ -56,10 +79,11 @@ pre code { background: none; border: 0; padding: 0; font-size: inherit; } header.top { border-bottom: 1px solid var(--line); - background: rgba(12, 14, 18, 0.92); + background: rgba(12, 14, 18, 0.82); position: sticky; top: 0; - backdrop-filter: blur(6px); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); z-index: 10; } @@ -93,6 +117,7 @@ footer { color: var(--fg-dim); font-size: 13.5px; padding: 22px 0 40px; + margin-top: 20px; } footer .wrap { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; } @@ -106,9 +131,11 @@ h1 { } h2 { font-size: 22px; - margin: 40px 0 12px; - padding-top: 14px; - border-top: 1px solid var(--line); + margin: 44px 0 12px; + padding-top: 20px; + /* Kept as a section rule — long pages need one — but at 5% white it reads + as a soft break, not a line ruled across the page. */ + border-top: 1px solid rgba(255, 255, 255, 0.05); } h3 { font-size: 17px; margin: 26px 0 8px; } p { margin: 12px 0; } @@ -119,24 +146,32 @@ p { margin: 12px 0; } /* -------------------------------------------------------------------- pills */ +/* Pills carry a tint, not a stroke: a 1px box around a 12px chip reads as a + cage. The background does the work, and the tint doubles as the meaning. */ .pills { display: flex; gap: 8px; flex-wrap: wrap; margin: 14px 0 0; } .pill { font-family: var(--mono); font-size: 12px; color: var(--fg-dim); - border: 1px solid var(--line); - background: var(--bg-1); + background: rgba(255, 255, 255, 0.04); border-radius: 999px; padding: 3px 10px; white-space: nowrap; } -.pill.hot { color: var(--accent); border-color: #40221f; } -.pill.warm { color: var(--accent-2); border-color: #3d2c19; } -.pill.ok { color: var(--ok); border-color: #1e3a2a; } +.pill.hot { color: var(--accent); background: var(--accent-dim); } +.pill.warm { color: var(--accent-2); background: var(--accent-2-dim); } +.pill.ok { color: var(--ok); background: var(--ok-dim); } /* ------------------------------------------------------------------- tables */ -.tw { overflow-x: auto; margin: 16px 0; border: 1px solid var(--line); border-radius: 6px; } +.tw { + overflow-x: auto; + margin: 16px 0; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--bg-1); + box-shadow: var(--shadow-sm); +} table { border-collapse: collapse; width: 100%; font-size: 14.5px; } @@ -148,7 +183,7 @@ caption { padding: 10px 12px 0; } -th, td { text-align: left; padding: 9px 12px; border-bottom: 1px solid var(--line); vertical-align: top; } +th, td { text-align: left; padding: 9px 12px; border-bottom: 1px solid rgba(255, 255, 255, 0.05); vertical-align: top; } thead th { background: var(--bg-2); @@ -161,7 +196,7 @@ thead th { } tbody tr:last-child td { border-bottom: 0; } -tbody tr:hover { background: var(--bg-1); } +tbody tr:hover { background: rgba(255, 255, 255, 0.025); } td.rt, th.rt { font-family: var(--mono); font-size: 13px; white-space: nowrap; } td.sev { font-family: var(--mono); font-size: 12.5px; white-space: nowrap; } @@ -176,22 +211,43 @@ td.sev { font-family: var(--mono); font-size: 12.5px; white-space: nowrap; } /* --------------------------------------------------------------- components */ -.shot { width: 100%; border: 1px solid var(--line); border-radius: 8px; margin: 20px 0 6px; } +.shot { width: 100%; border: 1px solid var(--line); border-radius: 10px; margin: 20px 0 6px; box-shadow: var(--shadow-sm); } +/* Callouts keep the left accent bar — it carries the meaning (amber = note, + red = warning) — but drop the full grey stroke that used to box them. */ .callout { - border: 1px solid var(--line); border-left: 3px solid var(--accent-2); - background: var(--bg-1); - border-radius: 6px; - padding: 14px 16px; + background: linear-gradient(90deg, var(--accent-2-dim), rgba(240, 160, 75, 0.03)); + border-radius: 10px; + padding: 14px 18px; margin: 18px 0; font-size: 14.5px; } -.callout.warn { border-left-color: var(--accent); } +.callout.warn { + border-left-color: var(--accent); + background: linear-gradient(90deg, var(--accent-dim), rgba(226, 84, 74, 0.03)); +} .callout strong { color: var(--fg); } -.cardgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 14px; margin: 18px 0; } -.card { border: 1px solid var(--line); background: var(--bg-1); border-radius: 6px; padding: 14px 16px; } +/* Cards: the old rule drew a 1px #262c37 box on a #12151b background — the + same value family, so the edge read as dirt rather than as a line, and the + flat fill gave the card no place to sit. Now the surface itself does the + separating: a vertical gradient, a barely-there hairline, and a real + shadow. Hover lifts the card and the accent arrives as a tint, not a cage. */ +.cardgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 16px; margin: 18px 0; } +.card { + border: 1px solid var(--line); + background: linear-gradient(180deg, var(--bg-3), var(--bg-1)); + border-radius: 12px; + padding: 16px 18px; + box-shadow: var(--shadow-sm); + transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease; +} +.card:hover { + transform: translateY(-2px); + border-color: var(--line-strong); + box-shadow: var(--shadow-md); +} .card h3 { margin: 0 0 6px; font-size: 15px; } .card p { margin: 0; font-size: 14px; color: var(--fg-dim); } @@ -206,14 +262,14 @@ td.sev { font-family: var(--mono); font-size: 12.5px; white-space: nowrap; } width: 22px; height: 22px; border-radius: 50%; - border: 1px solid var(--line); - background: var(--bg-2); + background: var(--bg-3); color: var(--fg-dim); font-family: var(--mono); font-size: 12px; display: flex; align-items: center; justify-content: center; + box-shadow: inset 0 0 0 1px var(--line); } dl.entry { margin: 0; } diff --git a/docs/site.js b/docs/site.js index d5cf33c..d378334 100644 --- a/docs/site.js +++ b/docs/site.js @@ -1,5 +1,16 @@ // Reads the newest release from the GitHub API so the download table never -// goes stale between releases. +// goes stale between releases. One request, one origin. +// +// The SHA-256 column comes from the `digest` field that every release asset +// already carries in that same response. It deliberately does NOT fetch +// checksums.txt to parse it: that file sits behind +// github.com/.../releases/download/..., which answers with a 302 to a CDN that +// sends no Access-Control-Allow-Origin — so a browser refuses to read it and +// the console reports "blocked by CORS policy". (Following the redirect from +// the API asset endpoint does not help either: the *final* response is the one +// the browser checks, and it has no ACAO header.) The api.github.com origin +// does send `Access-Control-Allow-Origin: *`, so everything on this page is +// readable in exactly one request, from one origin. // // Every string that comes from the API is written with textContent, never // innerHTML, and every href is resolved and checked against this repo's own @@ -55,21 +66,24 @@ return (bytes / (1024 * 1024)).toFixed(1) + " MiB"; } - function parseSums(text) { - var out = {}; - String(text || "").split("\n").forEach(function (line) { - var m = line.trim().split(/\s+/); - if (m.length >= 2) out[m[1]] = m[0]; - }); - return out; + // GitHub reports digests as "sha256:<64 hex>". Anything else — absent, a + // different algorithm, a truncated value — reads as no hash rather than as + // a plausible-looking one, so a wrong column never looks right. + var NO_HASH = "\u2014"; + function hashOf(digest) { + if (typeof digest !== "string" || digest.indexOf("sha256:") !== 0) return NO_HASH; + var hex = digest.slice(7); + return /^[0-9a-f]{64}$/.test(hex) ? hex : NO_HASH; } - function render(release, sums) { + function render(release) { var assets = (release.assets || []).filter(function (a) { return safeURL(a.browser_download_url); }); if (!assets.length) throw new Error("no release asset survived the URL check"); + var withHash = assets.filter(function (a) { return hashOf(a.digest) !== NO_HASH; }).length; + var table = el("table"); table.appendChild(el("caption", "Latest release: " + release.tag_name + ", published " + String(release.published_at || "").slice(0, 10))); @@ -91,7 +105,7 @@ tr.appendChild(td); tr.appendChild(el("td", human(a.size))); - tr.appendChild(el("td", sums[a.name] || "\u2014", "hash")); + tr.appendChild(el("td", hashOf(a.digest), "hash")); tbody.appendChild(tr); }); table.appendChild(tbody); @@ -99,8 +113,11 @@ while (mount.firstChild) mount.removeChild(mount.firstChild); mount.appendChild(table); if (fallback) fallback.hidden = true; - status.textContent = "Read live from the GitHub API, so this table follows " + - "the newest release without a rebuild."; + + status.textContent = withHash === assets.length + ? "Read live from the GitHub API — filenames, sizes and SHA-256 digests all come from this one response." + : "Read live from the GitHub API. " + withHash + " of " + assets.length + + " assets carry an SHA-256 digest in the release metadata; the rest show an em dash."; } fetch(API, { headers: { Accept: "application/vnd.github+json" } }) @@ -108,17 +125,7 @@ if (!r.ok) throw new Error("the API answered " + r.status); return r.json(); }) - .then(function (release) { - var sums = (release.assets || []).filter(function (a) { - return a.name === "checksums.txt"; - })[0]; - var sumsURL = sums ? safeURL(sums.browser_download_url) : null; - if (!sumsURL) return render(release, {}); - return fetch(sumsURL) - .then(function (r) { return r.ok ? r.text() : ""; }) - .then(function (t) { render(release, parseSums(t)); }) - .catch(function () { render(release, {}); }); - }) + .then(render) .catch(function (err) { status.textContent = "Could not build the live table automatically (" + err.message + "). The pinned list below is the v2.0.2 snapshot; the " + diff --git a/docs/site.test.js b/docs/site.test.js index 964404c..54fd146 100644 --- a/docs/site.test.js +++ b/docs/site.test.js @@ -3,20 +3,26 @@ // 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 +// it. Three failures that all showed up only once the file was actually run: // -// https://github.com/DevCop95/pullgoscript/releases/download/v9/../../../evil +// 1. a `startsWith` URL check accepted +// ".../releases/download/v9/../../../evil", which resolves to +// https://github.com/DevCop95/evil +// 2. the SHA-256 column was fetched from github.com/.../releases/download/ +// checksums.txt, which sends no Access-Control-Allow-Origin — every real +// browser logged "blocked by CORS policy" and the hashes never rendered +// 3. an element id renamed in index.html makes site.js return at its guard, +// which reads as a passing test and an empty page // -// which resolves to https://github.com/DevCop95/evil. Running the file found -// it; reading it did not. So the file gets run. +// (2) is the one a DOM stub alone cannot catch: stubs do not implement CORS. +// So the test asserts the *request budget* instead — exactly one fetch, to the +// only origin that returns CORS headers. That is checkable offline and it is +// the property that actually matters in a browser. // // 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 +// 2026-10-17), including its real `digest` fields, 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"; @@ -32,6 +38,12 @@ 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; +const API = "https://api.github.com/repos/DevCop95/pullgoscript/releases/latest"; +// The only origin in this page that answers with Access-Control-Allow-Origin. +// github.com/.../releases/download/... does not, and neither does the CDN it +// redirects to — that is the whole reason the page must not ask them. +const CORS_ORIGIN = "https://api.github.com/"; + let checks = 0; function ok(label, detail) { @@ -51,31 +63,40 @@ function eq(label, actual, expected) { } // --- 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"; +// `digest` is what the API returns for each asset. The four values that also +// appear in the published checksums.txt match it exactly, which is what makes +// the column trustworthy: the page is not parsing a file it re-downloads, it +// is quoting GitHub's own record of what it signed the release with. 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" }, + { name: "checksums.txt", size: 428, browser_download_url: PREFIX + "v2.0.2/checksums.txt", + digest: "sha256:c50ddb6e302151ac17c43f7dae1811d6d0dec2a342f1731ab176bc2f8916057a" }, + { name: "checksums.txt.pem", size: 3268, browser_download_url: PREFIX + "v2.0.2/checksums.txt.pem", + digest: "sha256:9c53fa9a38fd70d6ad965bc902a72bdced21c5b0a05bea2dbba40676a32b73d5" }, + { name: "checksums.txt.sig", size: 96, browser_download_url: PREFIX + "v2.0.2/checksums.txt.sig", + digest: "sha256:0be6c93eb5ae7e033f374ef255af3eda613a8314fa1202d33a59d30a9b79d842" }, + { name: "obliteratus_2.0.2_windows_amd64.zip", size: 6131480, + browser_download_url: PREFIX + "v2.0.2/obliteratus_2.0.2_windows_amd64.zip", + digest: "sha256:90b2e6e5794ed17892ccded42df81ea74840ca40b50370bd9fc7e117d9cff3c8" }, + { 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", + digest: "sha256:ce40c67281c31eb540e8f4639894bf471b110f1fb35136ed413344dd64477c66" }, + { name: "obliteratus_2.0.2_windows_arm64.zip", size: 5622388, + browser_download_url: PREFIX + "v2.0.2/obliteratus_2.0.2_windows_arm64.zip", + digest: "sha256:ae47be1d2bbce867b6e5f08de35be2e3d90649ffa8bf9c7bdecc1930cb077160" }, + { 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", + digest: "sha256:68b9ab079ed67a5b5dba381ec2cff53f3b9dcf6ebe74de1a57a778d442a3662c" }, ], }; +// What the browser is allowed to read from a third-party origin. Kept here as +// a plain table so the request-budget assertion is a comparison, not a hope. +const CORS_OK = [CORS_ORIGIN]; + // --- the DOM the page would build, plus a trap for unsafe hrefs ---------- let violations = []; @@ -113,9 +134,16 @@ function makeEl(tag) { // rather than a passing run. const idsInHTML = new Set([...HTML.matchAll(/\bid="([^"]+)"/g)].map(m => m[1])); +// Records every URL the page asks for, and flags as a violation anything +// outside api.github.com — a real browser would refuse it, so a stub that +// silently "succeeds" would be hiding the failure. This is scenario 2 of the +// bug list above, encoded. +let corsViolations = []; + async function run(payload, opts) { opts = opts || {}; violations = []; + corsViolations = []; const dom = { "dl-live": makeEl("div"), "dl-status": makeEl("p"), "dl-fallback": makeEl("table") }; const fetched = []; @@ -125,12 +153,14 @@ async function run(payload, opts) { }; 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 (typeof url !== "string" || !CORS_OK.some(ok => url.indexOf(ok) === 0)) { + corsViolations.push(String(url)); + // Fail the way a browser fails: opaque, no data. The test then has to + // notice that the page went empty. + return Promise.reject(new TypeError("Failed to fetch (CORS)")); } - 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 || "") }); + if (opts.httpStatus) { + return Promise.resolve({ ok: false, status: opts.httpStatus, json: () => Promise.reject(new Error("no body")) }); } return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(payload) }); }; @@ -157,17 +187,14 @@ 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" }, - }); +// Live mode exists for a human running it by hand (SITE_TEST_LIVE=1), to +// confirm GitHub still sends the fields this test assumes. It reads only the +// API, never the download path — a curl here cannot reproduce a browser's CORS +// rules, so hitting github.com would prove nothing even if it succeeded. +async function loadLive() { + const r = await fetch(API, { 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() : ""; + return r.json(); } (async function main() { @@ -193,12 +220,11 @@ async function loadLive(what) { }); 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; + const release = live() ? await loadLive() : RELEASE; // --- A. the real shape ------------------------------------------------- - console.log("\nA. real payload + real checksums.txt"); - const a = await run(release, { sumsText }); + console.log("\nA. real payload (digests, one request)"); + const a = await run(release); 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); @@ -206,25 +232,32 @@ async function loadLive(what) { 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. + // The request budget: exactly one, to the one origin that sends CORS + // headers. This is the assertion that fails for the bug a browser reported + // and a DOM stub did not — the old code fetched checksums.txt from + // github.com/.../releases/download/... and every real user saw no hashes. + eq("no request to an origin browsers block (CORS)", corsViolations.length, 0); + eq("exactly one network request", a.fetched.length, 1); + eq("the one request goes to api.github.com", a.fetched[0], API); + + // The hash column comes from `digest` in that same response: 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])); + const expected = String(release.assets.find( + x => x.name === a.names[amd64]).digest).replace(/^sha256:/, ""); + eq("SHA-256 column matches the asset's own digest for " + a.names[amd64], + a.hashes[amd64], expected); + eq("digest arrives with the sha256: prefix stripped", a.hashes[amd64].length, 64); 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"); + // Every asset in this fixture carries a digest, so every cell is a hash and + // none is the "no hash" marker. + eq("no asset shows the em dash in the all-digests release", + a.hashes.filter(h => h === "\u2014").length, 0); + eq("status states where the digests came from", /one response/.test(a.status), true); // --- B. hostile payload ------------------------------------------------ console.log("\nB. hostile payload"); @@ -238,17 +271,18 @@ async function loadLive(what) { { name: "traversal", size: 1, browser_download_url: PREFIX + "v9.9.9/../../../evil" }, ], }; - const b = await run(evil, { sumsText: "" }); + const b = await run(evil); eq("only the one asset on the real download path survives", b.names, [""]); eq("no href outside the download path", violations.length, 0); + eq("still only one request, and still to the API", b.fetched.length, 1); 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: "" }); + const c = await run({ tag_name: "v0", assets: [{ name: "x", size: 1, browser_download_url: "javascript:1" }] }); 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'"); @@ -261,11 +295,35 @@ async function loadLive(what) { 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); + // --- E. digests missing or malformed ----------------------------------- + console.log("\nE. release where digests are absent, truncated or wrong-shaped"); + const thin = { + tag_name: "v0.0.1", + published_at: "2026-01-01T00:00:00Z", + assets: [ + // normal + { name: "a.zip", size: 10, browser_download_url: PREFIX + "v0.0.1/a.zip", + digest: "sha256:1111111111111111111111111111111111111111111111111111111111111111" }, + // absent + { name: "b.zip", size: 11, browser_download_url: PREFIX + "v0.0.1/b.zip" }, + // truncated: must not read as a valid hash + { name: "c.zip", size: 12, browser_download_url: PREFIX + "v0.0.1/c.zip", + digest: "sha256:1111111" }, + // a different algorithm: not claimed to be sha256 + { name: "d.zip", size: 13, browser_download_url: PREFIX + "v0.0.1/d.zip", + digest: "sha512:aaaa" }, + ], + }; + const e = await run(thin); + eq("all four rows render", e.rows.length, 4); + eq("the one well-formed digest renders", e.hashes[0], + "1111111111111111111111111111111111111111111111111111111111111111"); + eq("an absent digest reads as an em dash", e.hashes[1], "\u2014"); + eq("a truncated digest reads as an em dash, not a short hash", e.hashes[2], "\u2014"); + eq("a non-sha256 algorithm reads as an em dash", e.hashes[3], "\u2014"); + assert.ok(/1 of 4/.test(e.status), "status must say how many digests arrived"); + ok("status reports the partial digest count"); + eq("fallback stays hidden: the table is live, just partly hashless", e.fallbackHidden, true); // --- F. the static fallback must not drift ------------------------------ // Only meaningful against the fixture: once a newer release ships, the pinned @@ -274,12 +332,12 @@ async function loadLive(what) { 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]); + const fromDigest = RELEASE.assets.map(x => x.digest.replace(/^sha256:/, "")); eq("pinned row count", pinned.length, 2); pinned.forEach(h => { - assert.ok(published.includes(h), "pinned hash not in the published checksums.txt: " + h); + assert.ok(fromDigest.includes(h), "pinned hash not among the release's digests: " + h); }); - ok("both pinned hashes match the published checksums.txt"); + ok("both pinned hashes match the digests the API reports"); 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"); diff --git a/docs/verify.html b/docs/verify.html index ef7c4c3..c6581f4 100644 --- a/docs/verify.html +++ b/docs/verify.html @@ -5,6 +5,8 @@ Verify a release — OBLITERATUS + +