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 0000000..99299f9 Binary files /dev/null and b/docs/favicon.ico differ 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 + +