175 lines
8.2 KiB
TypeScript
175 lines
8.2 KiB
TypeScript
#!/usr/bin/env tsx
|
|
/**
|
|
* Morphit — IP-disclosure single-source smoke (v1.7.5, t.txt #10).
|
|
*
|
|
* Ken's rule, verbatim: "if a user leaks their ip one time to one of the rpc
|
|
* nodes because we made the conscious decision to do so, then i only want that
|
|
* bad news mentioned in one faq article, and nowhere else on the site."
|
|
*
|
|
* Morphit makes exactly ONE direct browser→Blurt-node request: the boot-time
|
|
* release-integrity check (`initRelease()` → `fetchVerifiedRelease()` →
|
|
* `getDirectChainClient()`). It is deliberate — it is what makes `staleBuild`
|
|
* meaningful, so an operator cannot pin a user to an old, genuinely-signed,
|
|
* backdoored build. The privacy cost is one node learning that an IP loaded a
|
|
* page.
|
|
*
|
|
* This guard pins the three things that make that honest:
|
|
* 1. The disclosure lives in exactly ONE user-facing string, in all 10 locales.
|
|
* 2. No string anywhere makes the absolute claims that this call falsifies.
|
|
* 3. A worried user actually FINDS the article — the words they type rank it
|
|
* first, not some other entry that happens to mention an IP.
|
|
*
|
|
* (3) is not decoration. Before this, "ip leak" ranked the VIDEO TUTORIAL entry
|
|
* first, because it says "expose your IP" — a user asking the scariest question
|
|
* got the wrong answer. The FAQ scorer weights QUESTION tokens 2x answer tokens
|
|
* and matches answers by set membership, so repeating a word in the body buys
|
|
* nothing: the user's words have to be in the question. That is why the question
|
|
* says "see or leak".
|
|
*/
|
|
import { readFileSync, readdirSync } from 'node:fs';
|
|
import { join, dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { searchEntries, type FaqEntry } from '../src/lib/utils/faqIndex';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const LOCALES = resolve(__dirname, '..', 'src', 'lib', 'i18n', 'locales');
|
|
const KEY = 'ip_address_and_rpc_nodes';
|
|
|
|
let pass = 0;
|
|
let fail = 0;
|
|
const check = (name: string, ok: boolean, detail = ''): void => {
|
|
if (ok) {
|
|
pass++;
|
|
console.log(` \u2713 ${name}`);
|
|
} else {
|
|
fail++;
|
|
console.log(` \u2717 ${name}${detail ? `: ${detail}` : ''}`);
|
|
}
|
|
};
|
|
|
|
type Json = Record<string, unknown>;
|
|
const leaves = (o: unknown, p: string[] = []): Array<[string, string]> => {
|
|
const out: Array<[string, string]> = [];
|
|
if (typeof o === 'string') out.push([p.join('.'), o]);
|
|
else if (o && typeof o === 'object')
|
|
for (const [k, v] of Object.entries(o as Json)) out.push(...leaves(v, [...p, k]));
|
|
return out;
|
|
};
|
|
|
|
const files = readdirSync(LOCALES).filter((f) => f.endsWith('.json'));
|
|
check('1 all 10 locales present', files.length === 10, `found ${files.length}`);
|
|
|
|
// ── 1. exactly ONE disclosure site ─────────────────────────────────
|
|
//
|
|
// Checked precisely in ENGLISH and structurally across all ten. A multilingual
|
|
// keyword heuristic was tried first and is the wrong tool: matching "node" +
|
|
// "IP" + "sees" across ten languages flags `privacy.guides.sol.caveats` ("your
|
|
// WALLET talks to a Solana RPC endpoint"), `why_multi_accounts_fail` ("we
|
|
// rate-limit signups to 2 per IP per day"), and the video-embed entry ("a
|
|
// PeerTube iframe would expose your IP") — three legitimate discussions of IPs
|
|
// on three unrelated subjects. A guard that cries wolf on those gets muted, and
|
|
// a muted guard protects nothing.
|
|
//
|
|
// What actually identifies THIS disclosure is its subject: Morphit's own browser
|
|
// asking a Blurt node for the release record. That is what is pinned.
|
|
// Pins the SUBJECT, not the phrasing: this browser, a network node, the release
|
|
// record. That triple is what makes it THIS disclosure and not the signup
|
|
// rate-limit note ("2 per IP per day"), the Solana wallet guide ("your wallet
|
|
// talks to an RPC endpoint"), or the video-embed entry ("a PeerTube iframe would
|
|
// expose your IP") — three legitimate discussions of IPs on unrelated subjects
|
|
// that a looser keyword heuristic flagged. A guard that cries wolf gets muted,
|
|
// and a muted guard protects nothing.
|
|
const DISCLOSURE_EN = /browser[\s\S]{0,140}?network node[\s\S]{0,260}?release record/i;
|
|
|
|
const enAll = leaves(JSON.parse(readFileSync(join(LOCALES, 'en.json'), 'utf8')));
|
|
const enSites = enAll.filter(([, v]) => DISCLOSURE_EN.test(v));
|
|
check(
|
|
'2 EN: the browser→Blurt-node disclosure appears in exactly ONE string',
|
|
enSites.length === 1 && enSites[0]![0] === `faq.entries.${KEY}.a`,
|
|
`found in: ${enSites.map(([k]) => k).join(', ') || '(nowhere — did the article lose it?)'}`
|
|
);
|
|
|
|
// Structural, all ten: the article must exist and actually carry the explanation.
|
|
// If a future edit stubs it out, the disclosure silently vanishes from the site
|
|
// while the direct call keeps happening — the exact failure this guard exists for.
|
|
for (const f of files) {
|
|
const loc = f.replace('.json', '');
|
|
const d = JSON.parse(readFileSync(join(LOCALES, f), 'utf8')) as Json;
|
|
const entry = ((d.faq as Json)?.entries as Json)?.[KEY] as { q?: string; a?: string } | undefined;
|
|
check(
|
|
`3.${loc} the disclosure article exists and carries the explanation`,
|
|
!!entry?.q && !!entry?.a && entry.a.length > 400,
|
|
entry ? `answer is ${entry.a?.length ?? 0} chars` : 'missing entirely'
|
|
);
|
|
// Every locale must name the recommendation, because that is the part that
|
|
// actually helps a Monero user: Tor or a VPN closes this and everything else.
|
|
check(
|
|
`3.${loc} …and names the Tor / VPN recommendation`,
|
|
/tor/i.test(entry?.a ?? '') && /vpn/i.test(entry?.a ?? '')
|
|
);
|
|
}
|
|
|
|
// ── 2. no surviving absolute claim that the direct call falsifies ───
|
|
for (const f of files) {
|
|
const loc = f.replace('.json', '');
|
|
const all = leaves(JSON.parse(readFileSync(join(LOCALES, f), 'utf8')));
|
|
// Pin the CLASS, not the phrasings. The first version of this guard listed the
|
|
// two sentences I had already found — and missed a third, `settings.endpoints
|
|
// .pool_note`, which told users "your browser never talks to these nodes
|
|
// directly" on the very panel that LISTS the node the release check calls.
|
|
// Hardcoding known-bad literals is how a guard ends up certifying the bug it
|
|
// was written to catch.
|
|
//
|
|
// The article itself is exempt: it says "your browser never touches third-party
|
|
// endpoints" and then immediately says "The one exception." — scoped, not false.
|
|
// "nowhere else" is only false UNSCOPED. `security.tracking_body` says "your
|
|
// orders, your chat, and your balances all go to Morphit and nowhere else",
|
|
// which is true and is a brag worth keeping — so the pattern requires the
|
|
// universal quantifier ("every request", "all traffic"), not the phrase alone.
|
|
const ABSOLUTE_CLAIM =
|
|
/(browser|you)\s+never\s+(talks?|touch\w*|reach\w*|contact\w*|connect\w*)[\s\S]{0,40}(node|endpoint|third[- ]party)|(every|all)\s+(request|traffic)[\s\S]{0,40}nowhere else|no third[- ]party services|we don'?t know you'?re here|handles all blurt network traffic/i;
|
|
const bad = all.filter(
|
|
([k, v]) => ABSOLUTE_CLAIM.test(v) && !k.startsWith(`faq.entries.${KEY}`)
|
|
);
|
|
check(
|
|
`4.${loc} no string outside the article claims the browser never reaches a node`,
|
|
bad.length === 0,
|
|
bad.map(([k]) => k).join(', ')
|
|
);
|
|
}
|
|
|
|
// ── 3. a worried user actually finds it ─────────────────────────────
|
|
interface EnShape {
|
|
faq: { entries: Record<string, { q: string; a: string }> };
|
|
}
|
|
const en = JSON.parse(readFileSync(join(LOCALES, 'en.json'), 'utf8')) as unknown as EnShape;
|
|
const entries: FaqEntry[] = Object.entries(en.faq.entries).map(([key, v]) => ({
|
|
key: key as FaqEntry['key'],
|
|
question: v.q,
|
|
answer: v.a,
|
|
related: []
|
|
}));
|
|
// The words someone types when they are worried, or when they opened the Network
|
|
// tab and saw one request that was not to Morphit.
|
|
const MUST_RANK_FIRST = [
|
|
'ip leak',
|
|
'ip address',
|
|
'is my ip exposed',
|
|
'who sees my ip',
|
|
'do you log my ip',
|
|
'hide my ip',
|
|
'rpc node ip',
|
|
'network tab request'
|
|
];
|
|
for (const q of MUST_RANK_FIRST) {
|
|
const hits = searchEntries(entries, q, 3);
|
|
const top = hits[0]?.entry.key ?? '(nothing)';
|
|
check(`5 "${q}" ranks the disclosure article FIRST`, top === KEY, `got ${top}`);
|
|
}
|
|
|
|
console.log('');
|
|
if (fail === 0) console.log(`\u2713 all ${pass} ip-disclosure-single-source checks passed`);
|
|
else {
|
|
console.error(`\u2717 ${fail} of ${pass + fail} ip-disclosure-single-source checks FAILED`);
|
|
process.exit(1);
|
|
}
|