253 lines
13 KiB
JavaScript
253 lines
13 KiB
JavaScript
|
|
#!/usr/bin/env node
|
||
|
|
/**
|
||
|
|
* Render the public site from the living database.
|
||
|
|
*
|
||
|
|
* Nothing here is hand-written HTML. Every number on these pages comes out of
|
||
|
|
* catalog.json, which comes out of bigtech.db, which fails its build if a score
|
||
|
|
* does not recompute from its evidence. That chain is the product.
|
||
|
|
*
|
||
|
|
* Emits:
|
||
|
|
* packages/web/public/report/<product>.html
|
||
|
|
* packages/web/public/method.html
|
||
|
|
*/
|
||
|
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||
|
|
import { join, dirname } from 'node:path';
|
||
|
|
import { fileURLToPath } from 'node:url';
|
||
|
|
|
||
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
||
|
|
const CORE = join(HERE, '..');
|
||
|
|
const PUB = join(CORE, '..', 'web', 'public');
|
||
|
|
const cat = JSON.parse(readFileSync(join(CORE, 'src', 'data', 'generated', 'catalog.json'), 'utf8'));
|
||
|
|
|
||
|
|
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||
|
|
|
||
|
|
const CSS = `
|
||
|
|
:root{--ink:#0d0f0e;--bone:#e8e2d4;--dim:#8a8272;--amber:#d9a441;--good:#7fae7a;--bad:#c9563f;--rule:#2a2d2a}
|
||
|
|
*{box-sizing:border-box}
|
||
|
|
body{margin:0;padding:3rem 1.25rem 7rem;background:var(--ink);color:var(--bone);
|
||
|
|
font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;font-size:15px;line-height:1.7;
|
||
|
|
-webkit-font-smoothing:antialiased}
|
||
|
|
.wrap{max-width:58rem;margin:0 auto}
|
||
|
|
nav{font-size:.72rem;letter-spacing:.18em;text-transform:uppercase;color:var(--dim);margin-bottom:3rem;display:flex;gap:1.5rem;flex-wrap:wrap}
|
||
|
|
nav a{color:var(--dim);text-decoration:none;border-bottom:1px solid transparent}
|
||
|
|
nav a:hover{color:var(--amber);border-bottom-color:var(--amber)}
|
||
|
|
h1{font-size:clamp(1.3rem,3.6vw,2.1rem);font-weight:400;line-height:1.2;margin:0 0 .6rem;letter-spacing:-.01em}
|
||
|
|
h2{font-size:.78rem;letter-spacing:.22em;text-transform:uppercase;color:var(--dim);font-weight:400;
|
||
|
|
margin:3.5rem 0 1rem;padding-bottom:.5rem;border-bottom:1px solid var(--rule)}
|
||
|
|
.sub{color:var(--dim);font-size:.85rem;margin:0 0 2.5rem}
|
||
|
|
.score{display:flex;align-items:baseline;gap:1rem;flex-wrap:wrap;margin:0 0 .5rem}
|
||
|
|
.score .n{font-size:clamp(2.6rem,9vw,4.4rem);line-height:1;color:var(--amber);letter-spacing:-.03em}
|
||
|
|
.score .g{font-size:1rem;color:var(--dim);letter-spacing:.2em}
|
||
|
|
.lede{font-size:1.02rem;margin:1.5rem 0 0;padding-left:1rem;border-left:2px solid var(--amber);color:var(--bone)}
|
||
|
|
table{width:100%;border-collapse:collapse;font-size:.84rem;margin:0}
|
||
|
|
th{text-align:left;color:var(--dim);font-weight:400;font-size:.7rem;letter-spacing:.14em;text-transform:uppercase;
|
||
|
|
padding:.5rem .6rem;border-bottom:1px solid var(--rule)}
|
||
|
|
td{padding:.55rem .6rem;border-bottom:1px solid #1b1e1b;vertical-align:top}
|
||
|
|
td.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
|
||
|
|
.bar{display:inline-block;height:7px;background:var(--amber);vertical-align:middle;border-radius:1px}
|
||
|
|
.bar.low{background:var(--bad)}
|
||
|
|
.weak td{background:rgba(201,86,63,.07)}
|
||
|
|
.sev{font-size:.66rem;letter-spacing:.12em;text-transform:uppercase;padding:.1rem .4rem;border:1px solid var(--rule)}
|
||
|
|
.sev.high{border-color:var(--bad);color:var(--bad)}
|
||
|
|
.sev.medium{border-color:var(--amber);color:var(--amber)}
|
||
|
|
.sev.low,.sev.info{color:var(--dim)}
|
||
|
|
.tier{color:var(--dim);font-size:.72rem}
|
||
|
|
a{color:var(--amber);text-decoration:none;border-bottom:1px solid rgba(217,164,65,.35)}
|
||
|
|
a:hover{border-bottom-color:var(--amber)}
|
||
|
|
.note{color:var(--dim);font-size:.8rem;line-height:1.65;margin:1rem 0 0}
|
||
|
|
pre{overflow-x:auto;background:#111413;border:1px solid var(--rule);padding:1rem;font-size:.76rem;line-height:1.5;color:var(--bone)}
|
||
|
|
code{font-size:.86em;color:var(--bone)}
|
||
|
|
footer{margin-top:5rem;padding-top:1.5rem;border-top:1px solid var(--rule);color:var(--dim);font-size:.75rem}
|
||
|
|
`;
|
||
|
|
|
||
|
|
const NAV = `<nav>
|
||
|
|
<a href="/method.html">Method</a>
|
||
|
|
<a href="/oath.html">The Oath</a>
|
||
|
|
<a href="https://forge.binaryranch.com/bunkhouse/bigtech-sucks">Source</a>
|
||
|
|
</nav>`;
|
||
|
|
|
||
|
|
function page(title, body) {
|
||
|
|
return `<!doctype html>
|
||
|
|
<html lang="en"><head><meta charset="utf-8"/>
|
||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||
|
|
<title>${esc(title)}</title>
|
||
|
|
<style>${CSS}</style></head>
|
||
|
|
<body><div class="wrap">${NAV}${body}
|
||
|
|
<footer>Generated from <code>bigtech.db</code> on ${esc(cat.generated_at.slice(0, 10))}.
|
||
|
|
Method ${esc(cat.method_version)}. Every number recomputes from its evidence or the build fails.</footer>
|
||
|
|
</div></body></html>`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------------------------------------------------ report
|
||
|
|
function renderReport(a) {
|
||
|
|
const product = cat.products.find((p) => p.id === a.product_id) ?? {};
|
||
|
|
const scores = cat.scores.filter((s) => s.assessment_id === a.id);
|
||
|
|
const evidence = cat.evidence.filter((e) => e.assessment_id === a.id);
|
||
|
|
const findings = cat.findings.filter((f) => f.assessment_id === a.id);
|
||
|
|
const maxW = Math.max(...scores.map((s) => s.weight));
|
||
|
|
|
||
|
|
const rows = scores.map((s) => {
|
||
|
|
const cls = s.dimension_id === a.weakest_dimension ? 'weak' : '';
|
||
|
|
return `<tr class="${cls}">
|
||
|
|
<td>${esc(s.label)}</td>
|
||
|
|
<td class="num">${s.weight}</td>
|
||
|
|
<td class="num">${s.value}</td>
|
||
|
|
<td style="width:150px"><span class="bar${s.value <= 5 ? ' low' : ''}" style="width:${(s.value * 14).toFixed(0)}px"></span></td>
|
||
|
|
</tr>`;
|
||
|
|
}).join('');
|
||
|
|
|
||
|
|
const evRows = evidence.map((e) => `<tr>
|
||
|
|
<td>${esc(e.claim)}</td>
|
||
|
|
<td class="tier">T${e.tier}</td>
|
||
|
|
<td>${e.artifact && e.artifact.startsWith('http')
|
||
|
|
? `<a href="${esc(e.artifact)}">source</a>`
|
||
|
|
: `<code>${esc((e.artifact || '').split('/').slice(-1)[0])}</code>`}</td>
|
||
|
|
</tr>`).join('');
|
||
|
|
|
||
|
|
const fRows = findings.map((f) => `<tr>
|
||
|
|
<td><span class="sev ${esc(f.severity)}">${esc(f.severity)}</span></td>
|
||
|
|
<td>${esc(f.title)}${f.cve ? ` <span class="tier">${esc(f.cve)}</span>` : ''}</td>
|
||
|
|
<td class="tier">${esc(f.status)}</td>
|
||
|
|
</tr>`).join('');
|
||
|
|
|
||
|
|
const body = `
|
||
|
|
<h1>${esc(product.name)} <span style="color:var(--dim);font-size:.55em">${esc(a.version)} on ${esc(a.platform)}</span></h1>
|
||
|
|
<p class="sub">Assessed ${esc(a.assessed_on)} by CLU, Binary Ranch. Source commit
|
||
|
|
<code>${esc((a.source_commit || '').slice(0, 12))}</code>. Method ${esc(a.method_version)}.</p>
|
||
|
|
|
||
|
|
<div class="score"><span class="n">${a.weighted_total}</span><span class="g">/ 100 GRADE ${esc(a.grade)}</span></div>
|
||
|
|
<p class="lede">${esc(a.headline)}</p>
|
||
|
|
|
||
|
|
<h2>The score</h2>
|
||
|
|
<table>
|
||
|
|
<tr><th>Dimension</th><th class="num" style="text-align:right">Weight</th><th class="num" style="text-align:right">Score</th><th></th></tr>
|
||
|
|
${rows}
|
||
|
|
</table>
|
||
|
|
<p class="note">The weighted average is not the headline. <strong>The weakest dimension is</strong>, because a
|
||
|
|
high average with one bad rung is an average that hides the thing that will hurt you.</p>
|
||
|
|
|
||
|
|
<h2>Findings</h2>
|
||
|
|
<table>
|
||
|
|
<tr><th>Severity</th><th>Finding</th><th>Status</th></tr>
|
||
|
|
${fRows}
|
||
|
|
</table>
|
||
|
|
|
||
|
|
<h2>Evidence</h2>
|
||
|
|
<table>
|
||
|
|
<tr><th>Claim</th><th>Tier</th><th>Artifact</th></tr>
|
||
|
|
${evRows}
|
||
|
|
</table>
|
||
|
|
<p class="note">Tier 1 is something we observed and saved. Tier 2 is a primary source anyone can open.
|
||
|
|
Tier 3 is credible second-hand. Tier 4 is a claim with no artifact, and <strong>no tier 4 claim is
|
||
|
|
allowed to move a score</strong>.</p>
|
||
|
|
|
||
|
|
<h2>What was not tested</h2>
|
||
|
|
<p class="note">${esc(a.not_tested || 'Not recorded.')}</p>
|
||
|
|
|
||
|
|
<h2>Check it yourself</h2>
|
||
|
|
<p class="note">The audit script that produced the tier 1 rows is public and re-runnable.
|
||
|
|
The database that holds this score is public. The build refuses to pass if the total does not
|
||
|
|
recompute from these weights and these scores.</p>
|
||
|
|
<pre>git clone ssh://git@forge.binaryranch.com:2222/bunkhouse/bigtech-sucks.git
|
||
|
|
cd bigtech-sucks && npm run db:build</pre>`;
|
||
|
|
return page(`${product.name} scored ${a.weighted_total} — BigTech.sucks`, body);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------------------------------------------------ method
|
||
|
|
function renderMethod() {
|
||
|
|
const dims = cat.dimensions.map((d) => `<tr>
|
||
|
|
<td>${esc(d.label)}</td>
|
||
|
|
<td class="num">${d.weight}</td>
|
||
|
|
<td class="tier">${esc(d.anchor_10)}</td>
|
||
|
|
</tr>`).join('');
|
||
|
|
|
||
|
|
const body = `
|
||
|
|
<h1>The method</h1>
|
||
|
|
<p class="sub">How every score on this site is produced, and what we refuse to do to get one.</p>
|
||
|
|
|
||
|
|
<p class="lede">A privacy claim that cannot be checked is a slogan. We publish the method so a
|
||
|
|
stranger can catch us lying.</p>
|
||
|
|
|
||
|
|
<h2>Eight dimensions, weighted to 100</h2>
|
||
|
|
<table>
|
||
|
|
<tr><th>Dimension</th><th class="num" style="text-align:right">Weight</th><th>What a 10 requires</th></tr>
|
||
|
|
${dims}
|
||
|
|
</table>
|
||
|
|
|
||
|
|
<h2>Evidence tiers</h2>
|
||
|
|
<table>
|
||
|
|
<tr><th>Tier</th><th>Meaning</th></tr>
|
||
|
|
<tr><td>T1</td><td>We observed it, saved the artifact, and the procedure is re-runnable.</td></tr>
|
||
|
|
<tr><td>T2</td><td>A primary source anyone can open: a vendor repository, a court filing, a regulator advisory.</td></tr>
|
||
|
|
<tr><td>T3</td><td>Credible second-hand: reporting, an academic paper, an independent audit summary.</td></tr>
|
||
|
|
<tr><td>T4</td><td>A claim with no artifact. <strong>Never allowed to move a score.</strong> It appears in an appendix, labelled, and that is all.</td></tr>
|
||
|
|
</table>
|
||
|
|
|
||
|
|
<h2>The caps</h2>
|
||
|
|
<p class="note">These are the only two places where a score is limited by something other than evidence.
|
||
|
|
Both exist because the alternative is pretending to know something we cannot.</p>
|
||
|
|
|
||
|
|
<h3 style="font-size:.9rem;margin:1.8rem 0 .4rem">Reproducible builds</h3>
|
||
|
|
<pre>no reproducible build verifiability <= 3
|
||
|
|
experimental, unverified verifiability <= 6
|
||
|
|
verified, bit-identical verifiability <= 10</pre>
|
||
|
|
<p class="note">Reading the source proves what the code does. It does not prove the binary running on
|
||
|
|
your machine is that code. Reproducible builds are the only bridge, and nothing else is.</p>
|
||
|
|
|
||
|
|
<h3 style="font-size:.9rem;margin:1.8rem 0 .4rem">Closed source</h3>
|
||
|
|
<p class="note">Closed source is not penalised. It starts lower and climbs a public ladder. The ceiling
|
||
|
|
is the rung you are standing on.</p>
|
||
|
|
<pre>L0 unattested -> 3 nothing but your word
|
||
|
|
L1 declared -> 5 SBOM + signed hash + published destination allowlist
|
||
|
|
L2 observed -> 7 someone other than you watched this exact binary
|
||
|
|
L3 reproducible -> 9 anyone can rebuild it and get the same bytes
|
||
|
|
L4 contested -> 10 someone tried to break it and published the attempt</pre>
|
||
|
|
<p class="note">A court filing lifts the ceiling entirely, because it needs no vendor cooperation.</p>
|
||
|
|
|
||
|
|
<h3 style="font-size:.9rem;margin:1.8rem 0 .4rem">And the rule that makes it honest</h3>
|
||
|
|
<pre>a vendor running our harness produces a DECLARATION, not an OBSERVATION.</pre>
|
||
|
|
<p class="note">A self-run test is a claim about oneself, so it caps at L1. But L3 and L4 survive a
|
||
|
|
self-run, because a public rebuild recipe and a published adversarial review can be re-executed by
|
||
|
|
anyone. That is the whole escape hatch: reproducibility converts a claim into a check.</p>
|
||
|
|
|
||
|
|
<h2>The dynamic test</h2>
|
||
|
|
<table>
|
||
|
|
<tr><th>Run</th><th>What it does</th></tr>
|
||
|
|
<tr><td>D1</td><td>Cold idle. Fresh install, no account, one hour. Capture every packet.</td></tr>
|
||
|
|
<tr><td>D2</td><td>Normal use, 24 hours. Capture all egress.</td></tr>
|
||
|
|
<tr><td>D3</td><td>Device access trace: audio, camera, location, clipboard, contacts, screen.</td></tr>
|
||
|
|
<tr><td>D4</td><td>Update cycle. Capture the channel, verify the signature, check the pinning.</td></tr>
|
||
|
|
</table>
|
||
|
|
<p class="note">The vendor publishes a destination allowlist. Every destination observed in D1 and D2
|
||
|
|
must appear on it. <strong>Anything unlisted is a defect, regardless of what it turns out to be.</strong></p>
|
||
|
|
|
||
|
|
<h2>What this method cannot do</h2>
|
||
|
|
<p class="note">It cannot prove the absence of a backdoor. Rice's theorem makes non-trivial behavioural
|
||
|
|
properties of arbitrary programs undecidable, and a backdoor triggered by remote configuration or by
|
||
|
|
message content is invisible to static inspection.</p>
|
||
|
|
<p class="note">So the honest claim is not purity. It is this: <strong>we can prove what a specific
|
||
|
|
build did while we watched, and we can make a false statement expensive to sign.</strong> An attestation
|
||
|
|
raises the cost of a backdoor and shortens its shelf life. It is not a certificate of innocence, and
|
||
|
|
anyone selling you one is selling you nothing.</p>
|
||
|
|
|
||
|
|
<h2>Our own stack goes first</h2>
|
||
|
|
<p class="note">brainix will be scored by this instrument before it is advertised by it. "Zero telemetry"
|
||
|
|
will mean a captured packet log with nothing in it, published, dated, and re-runnable, or we do not get
|
||
|
|
to say it.</p>`;
|
||
|
|
return page('The method — BigTech.sucks', body);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------------------------------------------------ write
|
||
|
|
mkdirSync(join(PUB, 'report'), { recursive: true });
|
||
|
|
|
||
|
|
let written = 0;
|
||
|
|
for (const a of cat.assessments) {
|
||
|
|
const product = cat.products.find((p) => p.id === a.product_id);
|
||
|
|
if (!product) continue;
|
||
|
|
writeFileSync(join(PUB, 'report', `${a.product_id}.html`), renderReport(a));
|
||
|
|
written++;
|
||
|
|
}
|
||
|
|
writeFileSync(join(PUB, 'method.html'), renderMethod());
|
||
|
|
|
||
|
|
console.log(`rendered ${written} report page(s) + method.html into packages/web/public/`);
|