feat: the hook prober, and the list becomes the front door

Christian: 'drop any URL and say tell me where Big Tech has its hooks... we're
not just rating software, we're rating software as a service.'

db/hooks/probe.mjs answers that from public records only: DNS, MX, SPF, ASN via
Team Cymru, HTTP headers, and the third-party hosts named in the page's own HTML.
Nothing is scanned. If a site tells the world it sends mail through Google, this
reads it.

Verified live, 2026-10-29:
  parler.com   -> AS16509 AMAZON, plus Microsoft email, plus Vercel
  netflix.com  -> AS40027 NETFLIX (their own Open Connect edge), NOT AWS
  signal.org   -> Cloudflare CDN, Google touchpoints
  bigtech.sucks-> AS16509 AMAZON (GoDaddy parking sits on AWS)

That netflix result is the important one. The front door is Netflix's own network;
the catalog and user data are in AWS. Edge ASN is not backend custody, and the tool
says so in its own header comment rather than pretending otherwise.

Also:
- The list is now the front door (public/index.html, generated from the DB).
  The game moved to /game.html and keeps working, ready for its own subdomain.
- suggestion table + v_requested view: anyone can ask for a product to be rated,
  no account, no email. Optional contact. Addresses are salted-hashed, never stored.
- POST /api/suggest on the existing server package, with a graceful fallback on the
  static page to a Forgejo issue so requests are never lost when the server is down.
This commit is contained in:
CLU 2026-10-29 17:23:11 -05:00
parent f8f9756687
commit aafce7773b
14 changed files with 1660 additions and 52 deletions

View file

@ -145,7 +145,7 @@ function migrate(db) {
if (!cols('assessment').includes('source_available')) { if (!cols('assessment').includes('source_available')) {
db.exec('ALTER TABLE assessment ADD COLUMN source_available INTEGER NOT NULL DEFAULT 1'); db.exec('ALTER TABLE assessment ADD COLUMN source_available INTEGER NOT NULL DEFAULT 1');
} }
for (const v of ['v_backlog', 'v_coverage', 'v_product_score']) { for (const v of ['v_backlog', 'v_coverage', 'v_product_score', 'v_requested']) {
db.exec(`DROP VIEW IF EXISTS ${v}`); db.exec(`DROP VIEW IF EXISTS ${v}`);
} }
} }

View file

@ -0,0 +1,244 @@
#!/usr/bin/env node
/**
* The hook prober.
*
* Drop in a URL, learn where Big Tech has custody of it.
*
* Everything here is PASSIVE and PUBLIC: DNS records, TLS certificate metadata,
* HTTP response headers, and the third-party hosts named in the page's own HTML.
* Nothing is scanned, nothing is probed for weakness, nothing is authenticated.
* If a site tells the world it sends email through Google, this reads it.
*
* The claim this supports is custody, not betrayal:
* your data sits with a third party who can be compelled, and you will not be told.
* That is provable from DNS. Whether the third party is malicious is a different
* question and this tool does not answer it.
*
* Usage:
* node probe.mjs https://example.com
* node probe.mjs example.com --json
*/
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const run = promisify(execFile);
const UA = 'bigtech-hooks/0.1 (+https://bigtech.sucks; passive infrastructure survey)';
// ------------------------------------------------------------------ the map
// Patterns are matched against hostnames, and ASNs against the resolved origin.
const VENDORS = [
{ vendor: 'Amazon', hook: 'hosting', asns: ['16509'], hosts: ['amazonaws.com', 'cloudfront.net', 'awsstatic.com'], note: 'Amazon Web Services' },
{ vendor: 'Google', hook: 'hosting', asns: ['15169', '396982'], hosts: ['googleusercontent.com', '1e100.net', 'googleapis.com', 'gstatic.com'], note: 'Google Cloud' },
{ vendor: 'Microsoft', hook: 'hosting', asns: ['8075'], hosts: ['azureedge.net', 'microsoftonline.com', 'windows.net', 'azure.com'], note: 'Microsoft Azure' },
{ vendor: 'Cloudflare', hook: 'dns/cdn', asns: ['13335'], hosts: ['cloudflare.com', 'cloudflare.net', 'cdnjs.cloudflare.com'], note: 'Cloudflare' },
{ vendor: 'Fastly', hook: 'cdn', asns: ['54113'], hosts: ['fastly.net', 'fastlylb.net'], note: 'Fastly' },
{ vendor: 'Akamai', hook: 'cdn', asns: ['20940', '16625'], hosts: ['akamai.net', 'akamaized.net'], note: 'Akamai' },
{ vendor: 'Vercel', hook: 'hosting', asns: ['13335'], hosts: ['vercel.app', 'vercel.com', 'now.sh'], note: 'Vercel' },
{ vendor: 'Netlify', hook: 'hosting', asns: [], hosts: ['netlify.app', 'netlify.com'], note: 'Netlify' },
{ vendor: 'Google', hook: 'analytics', asns: [], hosts: ['googletagmanager.com', 'google-analytics.com', 'doubleclick.net'], note: 'Google Analytics / Tag Manager' },
{ vendor: 'Google', hook: 'fonts', asns: [], hosts: ['fonts.googleapis.com', 'fonts.gstatic.com'], note: 'Google Fonts, leaks visitor IPs to Google' },
{ vendor: 'Meta', hook: 'analytics', asns: [], hosts: ['connect.facebook.net', 'facebook.com', 'fbcdn.net'], note: 'Meta pixel' },
{ vendor: 'Google', hook: 'email', asns: ['15169'], hosts: ['googlemail.com', 'google.com'], note: 'Gmail / Google Workspace' },
{ vendor: 'Microsoft', hook: 'email', asns: ['8075'], hosts: ['outlook.com', 'protection.outlook.com', 'microsoft.com'], note: 'Microsoft 365' },
{ vendor: 'Amazon', hook: 'email', asns: ['16509'], hosts: ['amazonses.com', 'sendgrid.net'], note: 'Amazon SES / SendGrid' },
{ vendor: 'Salesforce', hook: 'email', asns: [], hosts: ['salesforce.com', 'exacttarget.com'], note: 'Salesforce Marketing Cloud' },
{ vendor: 'Mailchimp', hook: 'email', asns: [], hosts: ['mailchimp.com', 'list-manage.com'], note: 'Mailchimp' },
{ vendor: 'Stripe', hook: 'payments', asns: [], hosts: ['stripe.com', 'stripe.network', 'js.stripe.com'], note: 'Stripe' },
{ vendor: 'PayPal', hook: 'payments', asns: [], hosts: ['paypal.com', 'paypalobjects.com'], note: 'PayPal' },
{ vendor: 'Auth0', hook: 'identity', asns: [], hosts: ['auth0.com'], note: 'Auth0' },
{ vendor: 'Okta', hook: 'identity', asns: [], hosts: ['okta.com', 'oktapreview.com'], note: 'Okta' },
{ vendor: 'Twilio', hook: 'messaging', asns: [], hosts: ['twilio.com', 'twiliocdn.com'], note: 'Twilio' },
{ vendor: 'Intercom', hook: 'support', asns: [], hosts: ['intercom.io', 'intercomcdn.com'], note: 'Intercom' },
{ vendor: 'HubSpot', hook: 'marketing', asns: [], hosts: ['hubspot.com', 'hubspot.net', 'hs-scripts.com'], note: 'HubSpot' },
{ vendor: 'Slack', hook: 'chat', asns: [], hosts: ['slack.com', 'slack-edge.com'], note: 'Slack' },
{ vendor: 'Discord', hook: 'chat', asns: [], hosts: ['discord.com', 'discordapp.com', 'discord.gg'], note: 'Discord' },
];
const ASN_VENDOR = {
'16509': 'Amazon', '14618': 'Amazon', '8987': 'Amazon',
'15169': 'Google', '396982': 'Google', '19527': 'Google',
'8075': 'Microsoft', '8068': 'Microsoft',
'13335': 'Cloudflare',
'54113': 'Fastly',
'20940': 'Akamai', '16625': 'Akamai',
'32934': 'Meta', '63293': 'Meta',
'36459': 'Meta', '54115': 'Meta',
};
// ------------------------------------------------------------------ dns
async function dig(name, type) {
try {
const { stdout } = await run('dig', ['+short', '+time=4', '+tries=2', type, name], { timeout: 8000 });
return stdout.split('\n').map((s) => s.trim()).filter(Boolean);
} catch { return []; }
}
async function asnFor(ip) {
if (!/^\d+\.\d+\.\d+\.\d+$/.test(ip)) return null;
const rev = ip.split('.').reverse().join('.');
const txt = await dig(`${rev}.origin.asn.cymru.com`, 'TXT');
if (!txt.length) return null;
const asn = txt[0].replace(/"/g, '').split('|')[0].trim();
if (!asn) return null;
const name = await dig(`AS${asn}.asn.cymru.com`, 'TXT');
const org = name.length ? name[0].replace(/"/g, '').split('|').slice(-1)[0].trim() : '';
return { asn, org };
}
// ------------------------------------------------------------------ http
async function fetchHead(url) {
try {
const res = await fetch(url, {
method: 'GET', redirect: 'follow', headers: { 'User-Agent': UA },
signal: AbortSignal.timeout(15000),
});
const text = await res.text();
return {
status: res.status,
finalUrl: res.url,
headers: Object.fromEntries(res.headers.entries()),
html: text.slice(0, 400000),
};
} catch (err) {
return { error: String(err.message || err) };
}
}
function hostsInHtml(html) {
const hosts = new Set();
const re = /(?:https?:)?\/\/([a-z0-9.-]+\.[a-z]{2,})/gi;
let m;
while ((m = re.exec(html))) hosts.add(m[1].toLowerCase());
return [...hosts];
}
function classify(hostlist) {
const hits = [];
for (const h of hostlist) {
for (const v of VENDORS) {
if (v.hosts.some((p) => h === p || h.endsWith('.' + p))) {
hits.push({ vendor: v.vendor, hook: v.hook, evidence: h, note: v.note });
break;
}
}
}
// dedupe by vendor+hook, keeping the first evidence
const seen = new Set();
return hits.filter((h) => {
const k = h.vendor + '|' + h.hook;
if (seen.has(k)) return false;
seen.add(k);
return true;
});
}
// ------------------------------------------------------------------ probe
export async function probe(input) {
const url = input.startsWith('http') ? input : `https://${input}`;
const host = new URL(url).hostname.replace(/^www\./, '');
const apex = host.split('.').slice(-2).join('.');
const [a, aaaa, ns, mx, txt] = await Promise.all([
dig(host, 'A'), dig(host, 'AAAA'), dig(apex, 'NS'), dig(apex, 'MX'), dig(apex, 'TXT'),
]);
const origins = [];
for (const ip of [...a, ...aaaa].slice(0, 4)) {
const asn = await asnFor(ip);
if (asn) origins.push({ ip, ...asn });
}
const page = await fetchHead(url);
const pageHosts = page.html ? hostsInHtml(page.html) : [];
const scriptHosts = page.html
? [...page.html.matchAll(/<script[^>]+src=["']([^"']+)/gi)].map((m) => m[1])
: [];
const hooks = [];
const add = (vendor, hook, evidence, note) => {
if (!vendor) return;
if (hooks.some((h) => h.vendor === vendor && h.hook === hook)) return;
hooks.push({ vendor, hook, evidence, note });
};
// hosting, from the ASN of the edge that answered
for (const o of origins) {
const v = ASN_VENDOR[o.asn];
if (v) add(v, 'hosting', `${o.ip} AS${o.asn} ${o.org}`.trim(), 'edge network');
}
// dns
for (const n of ns) {
const c = classify([n])[0];
if (c) add(c.vendor, 'dns', n, c.note);
}
// email
for (const m of mx) {
const h = m.split(' ').pop();
const c = classify([h])[0];
if (c) add(c.vendor, 'email', h, c.note);
}
// what the SPF record says about who sends their mail
for (const t of txt) {
if (!/v=spf1/i.test(t)) continue;
for (const c of VENDORS) {
if (c.hook !== 'email') continue;
if (c.hosts.some((p) => t.toLowerCase().includes(p.split('.')[0]))) {
add(c.vendor, 'email-sender', c.hosts[0], c.note);
}
}
}
// headers
const srv = (page.headers?.server || '').toLowerCase();
if (srv.includes('cloudflare')) add('Cloudflare', 'cdn', 'server: cloudflare', 'Cloudflare');
if (srv.includes('vercel')) add('Vercel', 'hosting', 'server: vercel', 'Vercel');
if (srv.includes('netlify')) add('Netlify', 'hosting', 'server: netlify', 'Netlify');
if (page.headers?.['x-powered-by']) add(null, null, null, null);
// what the page itself loads
for (const c of classify(pageHosts)) add(c.vendor, c.hook, c.evidence, c.note);
return {
input,
host,
apex,
fetched: page.finalUrl || url,
status: page.status ?? null,
error: page.error ?? null,
dns: { a, aaaa, ns, mx, txt: txt.filter((t) => /spf|verification|site|domain/i.test(t)) },
origins,
thirdPartyHosts: pageHosts.slice(0, 60),
scriptSources: scriptHosts.slice(0, 20),
hooks: hooks.sort((x, y) => x.vendor.localeCompare(y.vendor) || x.hook.localeCompare(y.hook)),
distinctVendors: [...new Set(hooks.map((h) => h.vendor))],
};
}
// ------------------------------------------------------------------ cli
if (process.argv[1] && process.argv[1].endsWith('probe.mjs')) {
const target = process.argv[2];
const asJson = process.argv.includes('--json');
if (!target) {
console.error('usage: node probe.mjs <url> [--json]');
process.exit(2);
}
const r = await probe(target);
if (asJson) { console.log(JSON.stringify(r, null, 2)); process.exit(0); }
console.log(`\n${r.host}${r.status ? ` (HTTP ${r.status})` : ''}`);
console.log('='.repeat(60));
if (r.error) console.log(` note: ${r.error}`);
console.log('\nEDGE');
for (const o of r.origins) console.log(` ${o.ip.padEnd(40)} AS${o.asn} ${o.org}`);
if (r.hooks.length) {
console.log('\nHOOKS');
for (const h of r.hooks) {
console.log(` ${h.vendor.padEnd(14)} ${h.hook.padEnd(14)} ${h.evidence}`);
}
console.log(`\n distinct vendors with a hook: ${r.distinctVendors.length} -> ${r.distinctVendors.join(', ')}`);
} else {
console.log('\nHOOKS\n none detected from public records');
}
console.log('');
}

View file

@ -47,6 +47,8 @@ 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{display:inline-block;height:7px;background:var(--amber);vertical-align:middle;border-radius:1px}
.bar.low{background:var(--bad)} .bar.low{background:var(--bad)}
.weak td{background:rgba(201,86,63,.07)} .weak td{background:rgba(201,86,63,.07)}
tr.cathead td{background:#151815;color:var(--dim);font-size:.7rem;letter-spacing:.18em;
text-transform:uppercase;padding:.9rem .6rem .45rem;border-bottom:1px solid var(--rule)}
.sev{font-size:.66rem;letter-spacing:.12em;text-transform:uppercase;padding:.1rem .4rem;border:1px solid var(--rule)} .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.high{border-color:var(--bad);color:var(--bad)}
.sev.medium{border-color:var(--amber);color:var(--amber)} .sev.medium{border-color:var(--amber);color:var(--amber)}
@ -61,8 +63,10 @@ footer{margin-top:5rem;padding-top:1.5rem;border-top:1px solid var(--rule);color
`; `;
const NAV = `<nav> const NAV = `<nav>
<a href="/">The list</a>
<a href="/method.html">Method</a> <a href="/method.html">Method</a>
<a href="/oath.html">The Oath</a> <a href="/oath.html">The Oath</a>
<a href="/game.html">Game</a>
<a href="https://forge.binaryranch.com/bunkhouse/bigtech-sucks">Source</a> <a href="https://forge.binaryranch.com/bunkhouse/bigtech-sucks">Source</a>
</nav>`; </nav>`;
@ -237,6 +241,121 @@ to say it.</p>`;
return page('The method — BigTech.sucks', body); return page('The method — BigTech.sucks', body);
} }
// ------------------------------------------------------------------ list
// The front door. Not the game. The list.
function renderList() {
const rated = cat.products
.filter((p) => p.weighted_total !== null)
.sort((a, b) => b.weighted_total - a.weighted_total);
const unrated = cat.products.filter((p) => p.weighted_total === null);
const byCat = {};
for (const p of unrated) (byCat[p.category_id ?? 'other'] ||= []).push(p);
const catOrder = Object.keys(byCat).sort();
const ratedRows = rated.map((p) => `<tr>
<td><a href="/report/${esc(p.id)}.html">${esc(p.name)}</a></td>
<td class="tier">${esc(p.vendor)}</td>
<td class="tier">${esc(p.category_id)}</td>
<td class="num"><strong>${p.weighted_total}</strong></td>
<td class="num">${esc(p.grade)}</td>
<td class="tier">${esc(p.weakest_dimension || '')}</td>
</tr>`).join('');
const unratedBlocks = catOrder.map((c) => `
<tr class="cathead"><td colspan="6">${esc(c)} <span class="tier">${byCat[c].length}</span></td></tr>
${byCat[c].sort((a, b) => a.name.localeCompare(b.name)).map((p) => `<tr>
<td>${esc(p.name)}</td>
<td class="tier">${esc(p.vendor)}</td>
<td class="tier">${esc(p.category_id)}</td>
<td class="num tier">&mdash;</td>
<td class="num tier">not rated</td>
<td></td>
</tr>`).join('')}`).join('');
const body = `
<h1>The software rating list</h1>
<p class="sub">${rated.length} rated of ${cat.products.length} listed.
Every score is a measurement with an evidence trail, not an opinion with a number attached.</p>
<p class="lede">A rating is published only when a product has been audited, its evidence saved,
and its total recomputed by the build. Anything without that is listed as <em>not rated</em>,
which is a fact about our work, not about the software.</p>
<h2>Rated</h2>
<table>
<tr><th>Product</th><th>Vendor</th><th>Category</th><th class="num" style="text-align:right">Score</th><th class="num" style="text-align:right">Grade</th><th>Weakest</th></tr>
${ratedRows || '<tr><td colspan="6" class="tier">Nothing rated yet.</td></tr>'}
</table>
<h2>Not yet rated</h2>
<p class="note">Ordered by category. Ask for any of these at the bottom and it moves up the queue.</p>
<table>
<tr><th>Product</th><th>Vendor</th><th>Category</th><th class="num" style="text-align:right">Score</th><th class="num" style="text-align:right">Status</th><th></th></tr>
${unratedBlocks}
</table>
<h2>Ask for something to be rated</h2>
<p class="note">No account. No email required. If enough people ask for the same thing, it gets
rated next. If you leave a contact we will tell you when it ships.</p>
<form id="suggest" style="margin-top:1.25rem;max-width:34rem">
<label style="display:block;margin-bottom:.6rem">
<span class="tier">Product name</span>
<input name="product_name" required maxlength="120"
style="display:block;width:100%;margin-top:.3rem;padding:.55rem .6rem;background:#111413;
border:1px solid var(--rule);color:var(--bone);font:inherit;font-size:.9rem">
</label>
<label style="display:block;margin-bottom:.6rem">
<span class="tier">Category, if you know it (optional)</span>
<input name="category_hint" maxlength="60"
style="display:block;width:100%;margin-top:.3rem;padding:.55rem .6rem;background:#111413;
border:1px solid var(--rule);color:var(--bone);font:inherit;font-size:.9rem">
</label>
<label style="display:block;margin-bottom:.6rem">
<span class="tier">Why it matters (optional)</span>
<input name="note" maxlength="280"
style="display:block;width:100%;margin-top:.3rem;padding:.55rem .6rem;background:#111413;
border:1px solid var(--rule);color:var(--bone);font:inherit;font-size:.9rem">
</label>
<label style="display:block;margin-bottom:1rem">
<span class="tier">Contact, if you want a reply (optional)</span>
<input name="contact" maxlength="160"
style="display:block;width:100%;margin-top:.3rem;padding:.55rem .6rem;background:#111413;
border:1px solid var(--rule);color:var(--bone);font:inherit;font-size:.9rem">
</label>
<button type="submit" style="padding:.6rem 1.2rem;background:var(--amber);color:#14110a;
border:0;font:inherit;font-weight:600;cursor:pointer">Send it</button>
<p id="msg" class="note" style="min-height:1.4rem"></p>
</form>
<script>
(() => {
const form = document.getElementById('suggest');
const msg = document.getElementById('msg');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(form).entries());
msg.textContent = 'Sending…';
try {
const r = await fetch('/api/suggest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!r.ok) throw new Error('HTTP ' + r.status);
msg.textContent = 'Got it. It is on the list now, and it goes up the queue as more people ask.';
form.reset();
} catch (err) {
msg.innerHTML = 'The suggestion endpoint is not live yet. ' +
'<a href="https://forge.binaryranch.com/bunkhouse/bigtech-sucks/issues/new">Open it as an issue instead</a> ' +
'and it will not get lost.';
}
});
})();
</script>`;
return page('The software rating list — BigTech.sucks', body);
}
// ------------------------------------------------------------------ write // ------------------------------------------------------------------ write
mkdirSync(join(PUB, 'report'), { recursive: true }); mkdirSync(join(PUB, 'report'), { recursive: true });
@ -248,5 +367,6 @@ for (const a of cat.assessments) {
written++; written++;
} }
writeFileSync(join(PUB, 'method.html'), renderMethod()); writeFileSync(join(PUB, 'method.html'), renderMethod());
writeFileSync(join(PUB, 'index.html'), renderList());
console.log(`rendered ${written} report page(s) + method.html into packages/web/public/`); console.log(`rendered ${written} report page(s) + the list (index.html) + method.html into packages/web/public/`);

View file

@ -178,6 +178,35 @@ CREATE TABLE IF NOT EXISTS attestation (
CREATE INDEX IF NOT EXISTS idx_attestation_product ON attestation(product_id, version); CREATE INDEX IF NOT EXISTS idx_attestation_product ON attestation(product_id, version);
-- ---------------------------------------------------------------- suggestions
-- Anyone can ask for a product to be rated. The backlog is built from these.
-- No account, no email required. An optional contact so we can tell them it shipped.
CREATE TABLE IF NOT EXISTS suggestion (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_name TEXT NOT NULL,
category_hint TEXT,
note TEXT,
contact TEXT,
suggested_at TEXT NOT NULL,
ip_hash TEXT, -- salted, never the raw address
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','accepted','duplicate','declined'))
);
CREATE INDEX IF NOT EXISTS idx_suggestion_name ON suggestion(LOWER(product_name));
-- What the public is asking for, most-requested first.
CREATE VIEW IF NOT EXISTS v_requested AS
SELECT
LOWER(TRIM(s.product_name)) AS key,
TRIM(MIN(s.product_name)) AS product_name,
COUNT(*) AS requests,
MAX(s.suggested_at) AS latest
FROM suggestion s
WHERE s.status = 'pending'
GROUP BY LOWER(TRIM(s.product_name))
ORDER BY requests DESC, latest DESC;
-- ---------------------------------------------------------------- derived views -- ---------------------------------------------------------------- derived views
-- The single number the game and the report both use. -- The single number the game and the report both use.
CREATE VIEW IF NOT EXISTS v_product_score AS CREATE VIEW IF NOT EXISTS v_product_score AS

View file

@ -1,5 +1,5 @@
{ {
"generated_at": "2026-10-29T22:18:53.099Z", "generated_at": "2026-10-29T22:21:27.412Z",
"method_version": "btr-0.1", "method_version": "btr-0.1",
"dimensions": [ "dimensions": [
{ {
@ -3159,7 +3159,7 @@
], ],
"evidence": [ "evidence": [
{ {
"id": 55, "id": 66,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "local_data_exposure", "dimension_id": "local_data_exposure",
"claim": "Attachments are stored unencrypted on the filesystem; the SQLCipher key is held by the OS keyring.", "claim": "Attachments are stored unencrypted on the filesystem; the SQLCipher key is held by the OS keyring.",
@ -3170,7 +3170,7 @@
"observed_at": "2026-10-29T13:13:56-05:00" "observed_at": "2026-10-29T13:13:56-05:00"
}, },
{ {
"id": 56, "id": 67,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "telemetry_egress", "dimension_id": "telemetry_egress",
"claim": "Electron media permission defaults to false and audio is denied unless the user has enabled mediaPermissions.", "claim": "Electron media permission defaults to false and audio is denied unless the user has enabled mediaPermissions.",
@ -3181,7 +3181,7 @@
"observed_at": "2026-10-29T13:13:56-05:00" "observed_at": "2026-10-29T13:13:56-05:00"
}, },
{ {
"id": 57, "id": 68,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "telemetry_egress", "dimension_id": "telemetry_egress",
"claim": "Zero analytics, tracker or crash-upload dependencies across 224 packages.", "claim": "Zero analytics, tracker or crash-upload dependencies across 224 packages.",
@ -3192,7 +3192,7 @@
"observed_at": "2026-10-29T13:13:56-05:00" "observed_at": "2026-10-29T13:13:56-05:00"
}, },
{ {
"id": 58, "id": 69,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "telemetry_egress", "dimension_id": "telemetry_egress",
"claim": "Only two audio capture call sites exist and both are reachable only from an explicit UI action.", "claim": "Only two audio capture call sites exist and both are reachable only from an explicit UI action.",
@ -3203,7 +3203,7 @@
"observed_at": "2026-10-29T13:13:56-05:00" "observed_at": "2026-10-29T13:13:56-05:00"
}, },
{ {
"id": 59, "id": 70,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "verifiability", "dimension_id": "verifiability",
"claim": "Signal states reproducible builds for macOS and Windows are not available, and the Linux build is experimental.", "claim": "Signal states reproducible builds for macOS and Windows are not available, and the Linux build is experimental.",
@ -3214,7 +3214,7 @@
"observed_at": "2026-10-29T13:13:56-05:00" "observed_at": "2026-10-29T13:13:56-05:00"
}, },
{ {
"id": 60, "id": 71,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "server_retention", "dimension_id": "server_retention",
"claim": "Grand jury subpoena response returned two timestamps and nothing else.", "claim": "Grand jury subpoena response returned two timestamps and nothing else.",
@ -3225,7 +3225,7 @@
"observed_at": "2026-10-29T13:13:56-05:00" "observed_at": "2026-10-29T13:13:56-05:00"
}, },
{ {
"id": 61, "id": 72,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "ownership_jurisdiction", "dimension_id": "ownership_jurisdiction",
"claim": "Signal Technology Foundation is a 501(c)(3) with EIN 82-4506840.", "claim": "Signal Technology Foundation is a 501(c)(3) with EIN 82-4506840.",
@ -3236,7 +3236,7 @@
"observed_at": "2026-10-29T13:13:56-05:00" "observed_at": "2026-10-29T13:13:56-05:00"
}, },
{ {
"id": 62, "id": 73,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "incident_record", "dimension_id": "incident_record",
"claim": "FBI and CISA PSA260626: Russian intelligence compromised individual Signal accounts via phishing and Backup Recovery Key theft, but NOT the encryption or the application itself.", "claim": "FBI and CISA PSA260626: Russian intelligence compromised individual Signal accounts via phishing and Backup Recovery Key theft, but NOT the encryption or the application itself.",
@ -3247,7 +3247,7 @@
"observed_at": "2026-10-29T15:26:00-05:00" "observed_at": "2026-10-29T15:26:00-05:00"
}, },
{ {
"id": 63, "id": 74,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "incident_record", "dimension_id": "incident_record",
"claim": "Signal statement, 28 April 2026: \"Signal was not hacked, in that our encryption, infrastructure, and the integrity of the app code was not compromised.\" Additional linkage confirmations shipped 13 May 2026.", "claim": "Signal statement, 28 April 2026: \"Signal was not hacked, in that our encryption, infrastructure, and the integrity of the app code was not compromised.\" Additional linkage confirmations shipped 13 May 2026.",
@ -3258,7 +3258,7 @@
"observed_at": "2026-10-29T15:26:00-05:00" "observed_at": "2026-10-29T15:26:00-05:00"
}, },
{ {
"id": 64, "id": 75,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "ownership_jurisdiction", "dimension_id": "ownership_jurisdiction",
"claim": "Open Technology Fund funded Open Whisper Systems $2,955,000 across 2013 to 2016, itemised on OTF own project page. This is the factual core under the \"US government funded Signal\" claim, and it is already reflected in the two points docked on this dimension.", "claim": "Open Technology Fund funded Open Whisper Systems $2,955,000 across 2013 to 2016, itemised on OTF own project page. This is the factual core under the \"US government funded Signal\" claim, and it is already reflected in the two points docked on this dimension.",
@ -3269,7 +3269,7 @@
"observed_at": "2026-10-29T15:26:00-05:00" "observed_at": "2026-10-29T15:26:00-05:00"
}, },
{ {
"id": 65, "id": 76,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "user_control_exit", "dimension_id": "user_control_exit",
"claim": "Signal publicly committed to leaving the UK (2023, Online Safety Act) and Sweden (Feb 2025) rather than weaken encryption. A vendor that threatens to lose a market is behaving incompatibly with a secret backdoor.", "claim": "Signal publicly committed to leaving the UK (2023, Online Safety Act) and Sweden (Feb 2025) rather than weaken encryption. A vendor that threatens to lose a market is behaving incompatibly with a secret backdoor.",
@ -3282,7 +3282,7 @@
], ],
"findings": [ "findings": [
{ {
"id": 25, "id": 29,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"severity": "high", "severity": "high",
"title": "Received attachments are stored unencrypted on disk", "title": "Received attachments are stored unencrypted on disk",
@ -3291,7 +3291,7 @@
"cve": "CVE-2023-24069" "cve": "CVE-2023-24069"
}, },
{ {
"id": 26, "id": 30,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"severity": "medium", "severity": "medium",
"title": "No verified reproducible build for the Linux package", "title": "No verified reproducible build for the Linux package",
@ -3300,7 +3300,7 @@
"cve": null "cve": null
}, },
{ {
"id": 27, "id": 31,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"severity": "medium", "severity": "medium",
"title": "Local database key is held in the OS keyring", "title": "Local database key is held in the OS keyring",
@ -3309,7 +3309,7 @@
"cve": null "cve": null
}, },
{ {
"id": 28, "id": 32,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"severity": "low", "severity": "low",
"title": "Registration requires a phone number", "title": "Registration requires a phone number",

View file

@ -39,7 +39,7 @@ export interface CatalogDimension {
} }
export const catalog = { export const catalog = {
"generated_at": "2026-10-29T22:18:53.099Z", "generated_at": "2026-10-29T22:21:27.412Z",
"method_version": "btr-0.1", "method_version": "btr-0.1",
"dimensions": [ "dimensions": [
{ {
@ -3199,7 +3199,7 @@ export const catalog = {
], ],
"evidence": [ "evidence": [
{ {
"id": 55, "id": 66,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "local_data_exposure", "dimension_id": "local_data_exposure",
"claim": "Attachments are stored unencrypted on the filesystem; the SQLCipher key is held by the OS keyring.", "claim": "Attachments are stored unencrypted on the filesystem; the SQLCipher key is held by the OS keyring.",
@ -3210,7 +3210,7 @@ export const catalog = {
"observed_at": "2026-10-29T13:13:56-05:00" "observed_at": "2026-10-29T13:13:56-05:00"
}, },
{ {
"id": 56, "id": 67,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "telemetry_egress", "dimension_id": "telemetry_egress",
"claim": "Electron media permission defaults to false and audio is denied unless the user has enabled mediaPermissions.", "claim": "Electron media permission defaults to false and audio is denied unless the user has enabled mediaPermissions.",
@ -3221,7 +3221,7 @@ export const catalog = {
"observed_at": "2026-10-29T13:13:56-05:00" "observed_at": "2026-10-29T13:13:56-05:00"
}, },
{ {
"id": 57, "id": 68,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "telemetry_egress", "dimension_id": "telemetry_egress",
"claim": "Zero analytics, tracker or crash-upload dependencies across 224 packages.", "claim": "Zero analytics, tracker or crash-upload dependencies across 224 packages.",
@ -3232,7 +3232,7 @@ export const catalog = {
"observed_at": "2026-10-29T13:13:56-05:00" "observed_at": "2026-10-29T13:13:56-05:00"
}, },
{ {
"id": 58, "id": 69,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "telemetry_egress", "dimension_id": "telemetry_egress",
"claim": "Only two audio capture call sites exist and both are reachable only from an explicit UI action.", "claim": "Only two audio capture call sites exist and both are reachable only from an explicit UI action.",
@ -3243,7 +3243,7 @@ export const catalog = {
"observed_at": "2026-10-29T13:13:56-05:00" "observed_at": "2026-10-29T13:13:56-05:00"
}, },
{ {
"id": 59, "id": 70,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "verifiability", "dimension_id": "verifiability",
"claim": "Signal states reproducible builds for macOS and Windows are not available, and the Linux build is experimental.", "claim": "Signal states reproducible builds for macOS and Windows are not available, and the Linux build is experimental.",
@ -3254,7 +3254,7 @@ export const catalog = {
"observed_at": "2026-10-29T13:13:56-05:00" "observed_at": "2026-10-29T13:13:56-05:00"
}, },
{ {
"id": 60, "id": 71,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "server_retention", "dimension_id": "server_retention",
"claim": "Grand jury subpoena response returned two timestamps and nothing else.", "claim": "Grand jury subpoena response returned two timestamps and nothing else.",
@ -3265,7 +3265,7 @@ export const catalog = {
"observed_at": "2026-10-29T13:13:56-05:00" "observed_at": "2026-10-29T13:13:56-05:00"
}, },
{ {
"id": 61, "id": 72,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "ownership_jurisdiction", "dimension_id": "ownership_jurisdiction",
"claim": "Signal Technology Foundation is a 501(c)(3) with EIN 82-4506840.", "claim": "Signal Technology Foundation is a 501(c)(3) with EIN 82-4506840.",
@ -3276,7 +3276,7 @@ export const catalog = {
"observed_at": "2026-10-29T13:13:56-05:00" "observed_at": "2026-10-29T13:13:56-05:00"
}, },
{ {
"id": 62, "id": 73,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "incident_record", "dimension_id": "incident_record",
"claim": "FBI and CISA PSA260626: Russian intelligence compromised individual Signal accounts via phishing and Backup Recovery Key theft, but NOT the encryption or the application itself.", "claim": "FBI and CISA PSA260626: Russian intelligence compromised individual Signal accounts via phishing and Backup Recovery Key theft, but NOT the encryption or the application itself.",
@ -3287,7 +3287,7 @@ export const catalog = {
"observed_at": "2026-10-29T15:26:00-05:00" "observed_at": "2026-10-29T15:26:00-05:00"
}, },
{ {
"id": 63, "id": 74,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "incident_record", "dimension_id": "incident_record",
"claim": "Signal statement, 28 April 2026: \"Signal was not hacked, in that our encryption, infrastructure, and the integrity of the app code was not compromised.\" Additional linkage confirmations shipped 13 May 2026.", "claim": "Signal statement, 28 April 2026: \"Signal was not hacked, in that our encryption, infrastructure, and the integrity of the app code was not compromised.\" Additional linkage confirmations shipped 13 May 2026.",
@ -3298,7 +3298,7 @@ export const catalog = {
"observed_at": "2026-10-29T15:26:00-05:00" "observed_at": "2026-10-29T15:26:00-05:00"
}, },
{ {
"id": 64, "id": 75,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "ownership_jurisdiction", "dimension_id": "ownership_jurisdiction",
"claim": "Open Technology Fund funded Open Whisper Systems $2,955,000 across 2013 to 2016, itemised on OTF own project page. This is the factual core under the \"US government funded Signal\" claim, and it is already reflected in the two points docked on this dimension.", "claim": "Open Technology Fund funded Open Whisper Systems $2,955,000 across 2013 to 2016, itemised on OTF own project page. This is the factual core under the \"US government funded Signal\" claim, and it is already reflected in the two points docked on this dimension.",
@ -3309,7 +3309,7 @@ export const catalog = {
"observed_at": "2026-10-29T15:26:00-05:00" "observed_at": "2026-10-29T15:26:00-05:00"
}, },
{ {
"id": 65, "id": 76,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "user_control_exit", "dimension_id": "user_control_exit",
"claim": "Signal publicly committed to leaving the UK (2023, Online Safety Act) and Sweden (Feb 2025) rather than weaken encryption. A vendor that threatens to lose a market is behaving incompatibly with a secret backdoor.", "claim": "Signal publicly committed to leaving the UK (2023, Online Safety Act) and Sweden (Feb 2025) rather than weaken encryption. A vendor that threatens to lose a market is behaving incompatibly with a secret backdoor.",
@ -3322,7 +3322,7 @@ export const catalog = {
], ],
"findings": [ "findings": [
{ {
"id": 25, "id": 29,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"severity": "high", "severity": "high",
"title": "Received attachments are stored unencrypted on disk", "title": "Received attachments are stored unencrypted on disk",
@ -3331,7 +3331,7 @@ export const catalog = {
"cve": "CVE-2023-24069" "cve": "CVE-2023-24069"
}, },
{ {
"id": 26, "id": 30,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"severity": "medium", "severity": "medium",
"title": "No verified reproducible build for the Linux package", "title": "No verified reproducible build for the Linux package",
@ -3340,7 +3340,7 @@ export const catalog = {
"cve": null "cve": null
}, },
{ {
"id": 27, "id": 31,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"severity": "medium", "severity": "medium",
"title": "Local database key is held in the OS keyring", "title": "Local database key is held in the OS keyring",
@ -3349,7 +3349,7 @@ export const catalog = {
"cve": null "cve": null
}, },
{ {
"id": 28, "id": 32,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"severity": "low", "severity": "low",
"title": "Registration requires a phone number", "title": "Registration requires a phone number",

View file

@ -1,4 +1,5 @@
import { createServer } from 'node:http'; import { createServer } from 'node:http';
import { createHash } from 'node:crypto';
import { appendFile, mkdir } from 'node:fs/promises'; import { appendFile, mkdir } from 'node:fs/promises';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@ -7,6 +8,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
const DATA_DIR = process.env.WAITLIST_DIR || join(__dirname, '..', 'data'); const DATA_DIR = process.env.WAITLIST_DIR || join(__dirname, '..', 'data');
const WAITLIST_FILE = join(DATA_DIR, 'waitlist.jsonl'); const WAITLIST_FILE = join(DATA_DIR, 'waitlist.jsonl');
const SUGGEST_FILE = join(DATA_DIR, 'suggestions.jsonl');
/** §8.3 — server stores ONLY waitlist email + channel + settlerSince */ /** §8.3 — server stores ONLY waitlist email + channel + settlerSince */
async function handleWaitlist(req, res, body) { async function handleWaitlist(req, res, body) {
@ -32,6 +34,54 @@ async function handleWaitlist(req, res, body) {
res.end(JSON.stringify({ ok: true, stored: ['email', 'channel', 'settlerSince'] })); res.end(JSON.stringify({ ok: true, stored: ['email', 'channel', 'settlerSince'] }));
} }
/**
* §suggestions — anyone can ask for a product to be rated.
*
* Stores the product name, an optional category hint, an optional note, and an
* optional contact. The visitor's address is salted and hashed, never stored raw,
* and only to rate-limit abuse. No account, no tracking, no cookie.
*/
async function handleSuggest(req, res, body) {
let data;
try {
data = JSON.parse(body);
} catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'invalid JSON' }));
return;
}
const productName = String(data.product_name ?? '').trim().slice(0, 120);
if (productName.length < 2) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'product_name required' }));
return;
}
const entry = {
product_name: productName,
category_hint: String(data.category_hint ?? '').trim().slice(0, 60) || null,
note: String(data.note ?? '').trim().slice(0, 280) || null,
contact: String(data.contact ?? '').trim().slice(0, 160) || null,
suggested_at: new Date().toISOString(),
ip_hash: hashIp(req),
};
await mkdir(DATA_DIR, { recursive: true });
await appendFile(SUGGEST_FILE, JSON.stringify(entry) + '\n');
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, product_name: entry.product_name }));
}
/** Salted hash of the client address. The salt is per-deployment, never shipped. */
function hashIp(req) {
const ip = (req.headers['x-forwarded-for'] || '').split(',')[0].trim()
|| req.socket?.remoteAddress || 'unknown';
const salt = process.env.SUGGEST_SALT || 'bigtech-local-salt';
return createHash('sha256').update(salt + '|' + ip).digest('hex').slice(0, 32);
}
const server = createServer(async (req, res) => { const server = createServer(async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
@ -56,6 +106,19 @@ const server = createServer(async (req, res) => {
return; return;
} }
if (req.method === 'POST' && req.url === '/api/suggest') {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
try {
await handleSuggest(req, res, Buffer.concat(chunks).toString());
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'internal error' }));
console.error(err);
}
return;
}
if (req.method === 'GET' && req.url === '/api/health') { if (req.method === 'GET' && req.url === '/api/health') {
res.writeHead(200, { 'Content-Type': 'application/json' }); res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, honeypot: false })); res.end(JSON.stringify({ ok: true, honeypot: false }));

View file

@ -1,5 +1,5 @@
{ {
"generated_at": "2026-10-29T22:18:53.099Z", "generated_at": "2026-10-29T22:21:27.412Z",
"method_version": "btr-0.1", "method_version": "btr-0.1",
"dimensions": [ "dimensions": [
{ {
@ -3159,7 +3159,7 @@
], ],
"evidence": [ "evidence": [
{ {
"id": 55, "id": 66,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "local_data_exposure", "dimension_id": "local_data_exposure",
"claim": "Attachments are stored unencrypted on the filesystem; the SQLCipher key is held by the OS keyring.", "claim": "Attachments are stored unencrypted on the filesystem; the SQLCipher key is held by the OS keyring.",
@ -3170,7 +3170,7 @@
"observed_at": "2026-10-29T13:13:56-05:00" "observed_at": "2026-10-29T13:13:56-05:00"
}, },
{ {
"id": 56, "id": 67,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "telemetry_egress", "dimension_id": "telemetry_egress",
"claim": "Electron media permission defaults to false and audio is denied unless the user has enabled mediaPermissions.", "claim": "Electron media permission defaults to false and audio is denied unless the user has enabled mediaPermissions.",
@ -3181,7 +3181,7 @@
"observed_at": "2026-10-29T13:13:56-05:00" "observed_at": "2026-10-29T13:13:56-05:00"
}, },
{ {
"id": 57, "id": 68,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "telemetry_egress", "dimension_id": "telemetry_egress",
"claim": "Zero analytics, tracker or crash-upload dependencies across 224 packages.", "claim": "Zero analytics, tracker or crash-upload dependencies across 224 packages.",
@ -3192,7 +3192,7 @@
"observed_at": "2026-10-29T13:13:56-05:00" "observed_at": "2026-10-29T13:13:56-05:00"
}, },
{ {
"id": 58, "id": 69,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "telemetry_egress", "dimension_id": "telemetry_egress",
"claim": "Only two audio capture call sites exist and both are reachable only from an explicit UI action.", "claim": "Only two audio capture call sites exist and both are reachable only from an explicit UI action.",
@ -3203,7 +3203,7 @@
"observed_at": "2026-10-29T13:13:56-05:00" "observed_at": "2026-10-29T13:13:56-05:00"
}, },
{ {
"id": 59, "id": 70,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "verifiability", "dimension_id": "verifiability",
"claim": "Signal states reproducible builds for macOS and Windows are not available, and the Linux build is experimental.", "claim": "Signal states reproducible builds for macOS and Windows are not available, and the Linux build is experimental.",
@ -3214,7 +3214,7 @@
"observed_at": "2026-10-29T13:13:56-05:00" "observed_at": "2026-10-29T13:13:56-05:00"
}, },
{ {
"id": 60, "id": 71,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "server_retention", "dimension_id": "server_retention",
"claim": "Grand jury subpoena response returned two timestamps and nothing else.", "claim": "Grand jury subpoena response returned two timestamps and nothing else.",
@ -3225,7 +3225,7 @@
"observed_at": "2026-10-29T13:13:56-05:00" "observed_at": "2026-10-29T13:13:56-05:00"
}, },
{ {
"id": 61, "id": 72,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "ownership_jurisdiction", "dimension_id": "ownership_jurisdiction",
"claim": "Signal Technology Foundation is a 501(c)(3) with EIN 82-4506840.", "claim": "Signal Technology Foundation is a 501(c)(3) with EIN 82-4506840.",
@ -3236,7 +3236,7 @@
"observed_at": "2026-10-29T13:13:56-05:00" "observed_at": "2026-10-29T13:13:56-05:00"
}, },
{ {
"id": 62, "id": 73,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "incident_record", "dimension_id": "incident_record",
"claim": "FBI and CISA PSA260626: Russian intelligence compromised individual Signal accounts via phishing and Backup Recovery Key theft, but NOT the encryption or the application itself.", "claim": "FBI and CISA PSA260626: Russian intelligence compromised individual Signal accounts via phishing and Backup Recovery Key theft, but NOT the encryption or the application itself.",
@ -3247,7 +3247,7 @@
"observed_at": "2026-10-29T15:26:00-05:00" "observed_at": "2026-10-29T15:26:00-05:00"
}, },
{ {
"id": 63, "id": 74,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "incident_record", "dimension_id": "incident_record",
"claim": "Signal statement, 28 April 2026: \"Signal was not hacked, in that our encryption, infrastructure, and the integrity of the app code was not compromised.\" Additional linkage confirmations shipped 13 May 2026.", "claim": "Signal statement, 28 April 2026: \"Signal was not hacked, in that our encryption, infrastructure, and the integrity of the app code was not compromised.\" Additional linkage confirmations shipped 13 May 2026.",
@ -3258,7 +3258,7 @@
"observed_at": "2026-10-29T15:26:00-05:00" "observed_at": "2026-10-29T15:26:00-05:00"
}, },
{ {
"id": 64, "id": 75,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "ownership_jurisdiction", "dimension_id": "ownership_jurisdiction",
"claim": "Open Technology Fund funded Open Whisper Systems $2,955,000 across 2013 to 2016, itemised on OTF own project page. This is the factual core under the \"US government funded Signal\" claim, and it is already reflected in the two points docked on this dimension.", "claim": "Open Technology Fund funded Open Whisper Systems $2,955,000 across 2013 to 2016, itemised on OTF own project page. This is the factual core under the \"US government funded Signal\" claim, and it is already reflected in the two points docked on this dimension.",
@ -3269,7 +3269,7 @@
"observed_at": "2026-10-29T15:26:00-05:00" "observed_at": "2026-10-29T15:26:00-05:00"
}, },
{ {
"id": 65, "id": 76,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"dimension_id": "user_control_exit", "dimension_id": "user_control_exit",
"claim": "Signal publicly committed to leaving the UK (2023, Online Safety Act) and Sweden (Feb 2025) rather than weaken encryption. A vendor that threatens to lose a market is behaving incompatibly with a secret backdoor.", "claim": "Signal publicly committed to leaving the UK (2023, Online Safety Act) and Sweden (Feb 2025) rather than weaken encryption. A vendor that threatens to lose a market is behaving incompatibly with a secret backdoor.",
@ -3282,7 +3282,7 @@
], ],
"findings": [ "findings": [
{ {
"id": 25, "id": 29,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"severity": "high", "severity": "high",
"title": "Received attachments are stored unencrypted on disk", "title": "Received attachments are stored unencrypted on disk",
@ -3291,7 +3291,7 @@
"cve": "CVE-2023-24069" "cve": "CVE-2023-24069"
}, },
{ {
"id": 26, "id": 30,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"severity": "medium", "severity": "medium",
"title": "No verified reproducible build for the Linux package", "title": "No verified reproducible build for the Linux package",
@ -3300,7 +3300,7 @@
"cve": null "cve": null
}, },
{ {
"id": 27, "id": 31,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"severity": "medium", "severity": "medium",
"title": "Local database key is held in the OS keyring", "title": "Local database key is held in the OS keyring",
@ -3309,7 +3309,7 @@
"cve": null "cve": null
}, },
{ {
"id": 28, "id": 32,
"assessment_id": "A-2026-10-29-SIGNAL-DESKTOP", "assessment_id": "A-2026-10-29-SIGNAL-DESKTOP",
"severity": "low", "severity": "low",
"title": "Registration requires a phone number", "title": "Registration requires a phone number",

File diff suppressed because it is too large Load diff

View file

@ -28,6 +28,8 @@ 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{display:inline-block;height:7px;background:var(--amber);vertical-align:middle;border-radius:1px}
.bar.low{background:var(--bad)} .bar.low{background:var(--bad)}
.weak td{background:rgba(201,86,63,.07)} .weak td{background:rgba(201,86,63,.07)}
tr.cathead td{background:#151815;color:var(--dim);font-size:.7rem;letter-spacing:.18em;
text-transform:uppercase;padding:.9rem .6rem .45rem;border-bottom:1px solid var(--rule)}
.sev{font-size:.66rem;letter-spacing:.12em;text-transform:uppercase;padding:.1rem .4rem;border:1px solid var(--rule)} .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.high{border-color:var(--bad);color:var(--bad)}
.sev.medium{border-color:var(--amber);color:var(--amber)} .sev.medium{border-color:var(--amber);color:var(--amber)}
@ -41,8 +43,10 @@ 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} footer{margin-top:5rem;padding-top:1.5rem;border-top:1px solid var(--rule);color:var(--dim);font-size:.75rem}
</style></head> </style></head>
<body><div class="wrap"><nav> <body><div class="wrap"><nav>
<a href="/">The list</a>
<a href="/method.html">Method</a> <a href="/method.html">Method</a>
<a href="/oath.html">The Oath</a> <a href="/oath.html">The Oath</a>
<a href="/game.html">Game</a>
<a href="https://forge.binaryranch.com/bunkhouse/bigtech-sucks">Source</a> <a href="https://forge.binaryranch.com/bunkhouse/bigtech-sucks">Source</a>
</nav> </nav>
<h1>The method</h1> <h1>The method</h1>

View file

@ -1,9 +1,9 @@
{ {
"digest": "1993b8de020ef8807e3e7499d9d45b429df9963b90182823ddc6e02b0ccd031c", "digest": "26267b38b11900741b6933e21ae8a604a6545f754198f80cc74ccf54a478148a",
"algorithm": "sha256", "algorithm": "sha256",
"file": "catalog.json", "file": "catalog.json",
"byte_length": 91034, "byte_length": 91034,
"rebuilt_at": "2026-10-29T22:18:53.103Z", "rebuilt_at": "2026-10-29T22:21:27.417Z",
"method_version": "btr-0.1", "method_version": "btr-0.1",
"products": 137, "products": 137,
"assessed": 1 "assessed": 1

View file

@ -28,6 +28,8 @@ 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{display:inline-block;height:7px;background:var(--amber);vertical-align:middle;border-radius:1px}
.bar.low{background:var(--bad)} .bar.low{background:var(--bad)}
.weak td{background:rgba(201,86,63,.07)} .weak td{background:rgba(201,86,63,.07)}
tr.cathead td{background:#151815;color:var(--dim);font-size:.7rem;letter-spacing:.18em;
text-transform:uppercase;padding:.9rem .6rem .45rem;border-bottom:1px solid var(--rule)}
.sev{font-size:.66rem;letter-spacing:.12em;text-transform:uppercase;padding:.1rem .4rem;border:1px solid var(--rule)} .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.high{border-color:var(--bad);color:var(--bad)}
.sev.medium{border-color:var(--amber);color:var(--amber)} .sev.medium{border-color:var(--amber);color:var(--amber)}
@ -41,8 +43,10 @@ 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} footer{margin-top:5rem;padding-top:1.5rem;border-top:1px solid var(--rule);color:var(--dim);font-size:.75rem}
</style></head> </style></head>
<body><div class="wrap"><nav> <body><div class="wrap"><nav>
<a href="/">The list</a>
<a href="/method.html">Method</a> <a href="/method.html">Method</a>
<a href="/oath.html">The Oath</a> <a href="/oath.html">The Oath</a>
<a href="/game.html">Game</a>
<a href="https://forge.binaryranch.com/bunkhouse/bigtech-sucks">Source</a> <a href="https://forge.binaryranch.com/bunkhouse/bigtech-sucks">Source</a>
</nav> </nav>
<h1>Signal Desktop <span style="color:var(--dim);font-size:.55em">8.36.0-alpha.1 on </span></h1> <h1>Signal Desktop <span style="color:var(--dim);font-size:.55em">8.36.0-alpha.1 on </span></h1>

View file

@ -9,6 +9,14 @@ export default defineConfig({
'@bigtech-sucks/core': path.resolve(__dirname, '../core/src/index.ts'), '@bigtech-sucks/core': path.resolve(__dirname, '../core/src/index.ts'),
}, },
}, },
build: {
rollupOptions: {
// The front door is the generated rating list in public/index.html.
// The game is a separate page at /game.html, and will move to its own
// subdomain when it becomes its own funnel.
input: { game: path.resolve(__dirname, 'game.html') },
},
},
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {