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
+
+