mirror of
https://github.com/hanzoai/trust.git
synced 2026-10-30 14:54:58 +00:00
318 lines
13 KiB
JavaScript
Executable File
318 lines
13 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
// What a visitor's browser actually reaches, measured against what we disclose.
|
|
//
|
|
// bin/surface.mjs hanzo.ai hanzo.works trust.hanzo.ai …
|
|
//
|
|
// The subprocessor list answers "who touches the data". For everything that
|
|
// happens on a server that is a claim a reader has to take on trust, and the
|
|
// evidence is a path in a repository they may not be able to open. For the
|
|
// BROWSER half it is not a claim at all: a page either loads a third party or it
|
|
// does not, and anyone can check. So this measures it, on every release.
|
|
//
|
|
// TWO RULES, and the second is the one that keeps the list honest:
|
|
//
|
|
// 1. No advertising or product-analytics tag may be REACHED. This is a
|
|
// denylist and it is the narrow, loud rule — those parties exist to follow
|
|
// a person between sites, and one appearing here is never an accident.
|
|
// 2. Any OTHER third-party origin that is reached must be declared in
|
|
// subprocessors.json, as an `evidence[].origin`. That is the rule that
|
|
// cannot go stale: a party added to a page is a party added to the
|
|
// disclosure, in the same change, or there is no release.
|
|
//
|
|
// REACHED IS NOT THE SAME AS PRESENT, and getting that wrong in either
|
|
// direction gives the wrong answer. A tag sitting in the markup behind an
|
|
// enforcing policy that omits its origin is never fetched, so nothing about the
|
|
// visitor reaches it — counting it would report a leak that does not happen. And
|
|
// a policy sent `-Report-Only` enforces NOTHING, so a page carrying only that
|
|
// permits everything however strict it reads — counting it as protection would
|
|
// miss a leak that does. So a third party is reached when the markup names it
|
|
// AND the enforced policy would allow it, and the two halves are measured
|
|
// together because neither alone is the fact.
|
|
//
|
|
// Node's own fetch and one regex pass. No parser, no dependency, no browser: a
|
|
// gate that needs a headless browser to run does not run.
|
|
|
|
import { readFileSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import { dirname, join } from "node:path";
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const root = join(here, "..");
|
|
|
|
// Parties whose product IS following a person between sites. Matched on the
|
|
// registrable domain, so a regional or versioned host of the same service is
|
|
// caught by the same entry. Keeping this narrow is deliberate: it is the list of
|
|
// things that may never appear, so every addition has to be defensible, and the
|
|
// SECOND rule already covers everything not named here.
|
|
const TRACKERS = [
|
|
"google-analytics.com", "googletagmanager.com", "analytics.google.com",
|
|
"doubleclick.net", "googlesyndication.com", "googleadservices.com",
|
|
"facebook.net", "facebook.com", "connect.facebook.net",
|
|
"hotjar.com", "hotjar.io", "segment.com", "segment.io",
|
|
"posthog.com", "mixpanel.com", "amplitude.com", "fullstory.com",
|
|
"clarity.ms", "logrocket.com", "logrocket.io", "heap.io", "heapanalytics.com",
|
|
"intercom.io", "intercomcdn.com", "matomo.cloud", "plausible.io",
|
|
"quantserve.com", "scorecardresearch.com", "adroll.com", "criteo.com",
|
|
"taboola.com", "outbrain.com", "bing.com", "clarity.microsoft.com",
|
|
"tiktok.com", "analytics.tiktok.com", "snapchat.com", "sc-static.net",
|
|
"linkedin.com", "licdn.com", "twitter.com", "ads-twitter.com", "t.co",
|
|
"pinterest.com", "pinimg.com", "reddit.com", "redditstatic.com",
|
|
];
|
|
|
|
// A subresource is a fetch the page makes on its own. A LINK is not one: an
|
|
// anchor to another site sends nothing until a person clicks it, and counting
|
|
// hrefs would file every party we merely link to as one that receives data.
|
|
// That distinction is the whole reason this reads attributes rather than
|
|
// grepping the document for URLs.
|
|
const SUBRESOURCE = [
|
|
/<script\b[^>]*?\bsrc\s*=\s*["']([^"']+)["']/gi,
|
|
/<link\b[^>]*?\bhref\s*=\s*["']([^"']+)["']/gi,
|
|
/<img\b[^>]*?\bsrc\s*=\s*["']([^"']+)["']/gi,
|
|
/<iframe\b[^>]*?\bsrc\s*=\s*["']([^"']+)["']/gi,
|
|
/<source\b[^>]*?\bsrc\s*=\s*["']([^"']+)["']/gi,
|
|
/<video\b[^>]*?\b(?:src|poster)\s*=\s*["']([^"']+)["']/gi,
|
|
/<audio\b[^>]*?\bsrc\s*=\s*["']([^"']+)["']/gi,
|
|
/<embed\b[^>]*?\bsrc\s*=\s*["']([^"']+)["']/gi,
|
|
/<object\b[^>]*?\bdata\s*=\s*["']([^"']+)["']/gi,
|
|
];
|
|
|
|
const UA =
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
|
|
"(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36";
|
|
|
|
// The registrable domain, near enough for this: the last two labels, or three
|
|
// where the second-to-last is a known two-part public suffix. This decides
|
|
// whether a host is ours and whether it is a tracker, and both questions want
|
|
// "the same organization" rather than "the same string".
|
|
const TWO_PART = new Set(["co", "com", "net", "org", "ac", "gov", "edu"]);
|
|
export function site(host) {
|
|
const p = String(host).toLowerCase().replace(/\.$/, "").split(".");
|
|
if (p.length <= 2) return p.join(".");
|
|
return TWO_PART.has(p[p.length - 2]) && p[p.length - 1].length === 2
|
|
? p.slice(-3).join(".")
|
|
: p.slice(-2).join(".");
|
|
}
|
|
|
|
const host = (u) => {
|
|
const m = /^(?:https?:)?\/\/([^/?#"'\s\\]+)/i.exec(String(u).trim());
|
|
if (!m) return ""; // relative — same origin, so nobody new
|
|
const h = m[1].split("@").pop().split(":")[0].toLowerCase();
|
|
return h.indexOf(".") > 0 ? h : "";
|
|
};
|
|
|
|
// Every host the markup NAMES as a subresource. Precise, and precision is what
|
|
// this one is for: it drives the disclosure rule, and a rule that fires on
|
|
// something a page merely links to would be turned off within a week.
|
|
export function reached(html) {
|
|
const out = new Set();
|
|
for (const re of SUBRESOURCE) {
|
|
re.lastIndex = 0;
|
|
for (let m; (m = re.exec(html)); ) {
|
|
const h = host(m[1] || "");
|
|
if (h) out.add(h);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Every host named ANYWHERE, inline script bodies included. Broad, and breadth
|
|
// is what THIS one is for.
|
|
//
|
|
// The two exist because one measure cannot serve both rules. A tag is a fetch;
|
|
// a URL sitting in an inline script may be a fetch or may be the href of a link
|
|
// the page will render, and nothing in the text tells them apart — so the
|
|
// broad measure over-reports and may not decide the disclosure rule. But the
|
|
// ordinary way a tag gets installed is exactly an inline script that builds a
|
|
// script element at run time, which the tag measure cannot see at all. So the
|
|
// broad one decides the DENYLIST, where over-reporting costs a sentence in a
|
|
// review and under-reporting costs the claim: those names have no innocent
|
|
// reason to be in the document, linked or fetched.
|
|
export function mentioned(html) {
|
|
const out = new Set();
|
|
for (const u of html.match(/(?:https?:)?\/\/[a-z0-9][-a-z0-9.]*\.[a-z]{2,}/gi) || []) {
|
|
const h = host(u);
|
|
if (h) out.add(h);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// The ENFORCED policy. A `-Report-Only` header is deliberately not read: it
|
|
// changes nothing a browser does, and treating it as protection is exactly how
|
|
// a surface comes to look defended while permitting everything.
|
|
export function policy(headers, html) {
|
|
const parts = [];
|
|
const h = headers.get("content-security-policy");
|
|
if (h) parts.push(h);
|
|
const meta =
|
|
/<meta\b[^>]*http-equiv\s*=\s*["']content-security-policy["'][^>]*content\s*=\s*["']([^"']+)["']/i.exec(html);
|
|
if (meta) parts.push(meta[1]);
|
|
if (!parts.length) return null;
|
|
|
|
const directives = new Map();
|
|
for (const p of parts) {
|
|
for (const clause of p.split(";")) {
|
|
const t = clause.trim().split(/\s+/).filter(Boolean);
|
|
if (!t.length) continue;
|
|
const name = t.shift().toLowerCase();
|
|
// Several policies apply together and a browser must satisfy every one,
|
|
// so the narrowest wins: intersect rather than overwrite.
|
|
const had = directives.get(name);
|
|
directives.set(name, had ? had.filter((s) => t.indexOf(s) >= 0) : t);
|
|
}
|
|
}
|
|
return directives;
|
|
}
|
|
|
|
// Would this policy let the page fetch from that host? Asked per host and not
|
|
// per directive, because we do not know which kind of subresource it will be —
|
|
// so a host any fetching directive admits counts as permitted. Erring open is
|
|
// the safe direction for a gate: it reports a party as reached when it might
|
|
// not be, never the reverse.
|
|
const FETCHING = [
|
|
"script-src", "script-src-elem", "style-src", "style-src-elem", "img-src",
|
|
"font-src", "connect-src", "frame-src", "media-src", "object-src", "child-src",
|
|
"worker-src", "manifest-src",
|
|
];
|
|
|
|
export function permits(directives, host) {
|
|
if (!directives) return true; // no enforced policy is no restriction
|
|
const lists = [];
|
|
for (const d of FETCHING) if (directives.has(d)) lists.push(directives.get(d));
|
|
if (directives.has("default-src")) lists.push(directives.get("default-src"));
|
|
if (!lists.length) return true;
|
|
|
|
for (const list of lists) {
|
|
for (const raw of list) {
|
|
const s = raw.replace(/^['"]|['"]$/g, "").toLowerCase();
|
|
if (s === "*") return true;
|
|
if (s === "https:" || s === "http:") return true;
|
|
if (s.startsWith("'")) continue; // 'self', 'none', 'unsafe-inline', a hash
|
|
const bare = s.replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/:\d+$/, "");
|
|
if (!bare || bare.indexOf(".") < 0) continue;
|
|
if (bare === host) return true;
|
|
if (bare.startsWith("*.") && host.endsWith(bare.slice(1))) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function look(host) {
|
|
const res = await fetch(`https://${host}`, {
|
|
redirect: "follow",
|
|
headers: { "user-agent": UA, accept: "text/html,application/xhtml+xml" },
|
|
});
|
|
const html = await res.text();
|
|
return { host, status: res.status, html, directives: policy(res.headers, html) };
|
|
}
|
|
|
|
function declared() {
|
|
const file = JSON.parse(readFileSync(join(root, "subprocessors.json"), "utf8"));
|
|
const rows = Array.isArray(file) ? file : file.subprocessors;
|
|
const byOrigin = new Map();
|
|
for (const s of rows) {
|
|
for (const e of s.evidence || []) {
|
|
if (e && typeof e.origin === "string" && e.origin.trim()) {
|
|
byOrigin.set(e.origin.trim().toLowerCase(), s);
|
|
}
|
|
}
|
|
}
|
|
return byOrigin;
|
|
}
|
|
|
|
async function main(surfaces) {
|
|
if (!surfaces.length) {
|
|
console.error(
|
|
"surface: name the hosts to measure — the surfaces are a property of the\n" +
|
|
" deployment, not of this repository, so there is no default list\n" +
|
|
" here to fall out of step with the one that is deployed.\n" +
|
|
" usage: bin/surface.mjs <host> [host …]",
|
|
);
|
|
process.exit(2);
|
|
}
|
|
|
|
const disclosed = declared();
|
|
// Ours is DERIVED from what we were asked to measure: the zones these
|
|
// surfaces live in are ours by definition, so there is no second list of our
|
|
// own domains to keep in step with the first.
|
|
const ours = new Set(surfaces.map(site));
|
|
|
|
const pages = await Promise.all(
|
|
surfaces.map((h) =>
|
|
look(h).catch((err) => ({ host: h, error: String((err && err.message) || err) })),
|
|
),
|
|
);
|
|
|
|
const failures = [];
|
|
const notes = [];
|
|
|
|
for (const p of pages) {
|
|
if (p.error) {
|
|
failures.push(`${p.host}: could not be read — ${p.error}`);
|
|
continue;
|
|
}
|
|
if (p.status >= 400) {
|
|
failures.push(`${p.host}: answered ${p.status}, so nothing was measured`);
|
|
continue;
|
|
}
|
|
|
|
const outside = (h) => !ours.has(site(h));
|
|
const third = [...reached(p.html)].filter(outside).sort();
|
|
const enforced = p.directives ? "enforced" : "NO ENFORCED POLICY";
|
|
const live = third.filter((h) => permits(p.directives, h));
|
|
const refused = third.filter((h) => !permits(p.directives, h));
|
|
|
|
console.log(
|
|
`${p.host} ${p.status} ${enforced} ` +
|
|
`${live.length} reached, ${refused.length} refused by policy`,
|
|
);
|
|
|
|
for (const h of refused) {
|
|
notes.push(`${p.host}: ${h} is in the markup and the enforced policy refuses it`);
|
|
}
|
|
|
|
// Rule 1, over the broad measure.
|
|
for (const h of [...mentioned(p.html)].filter(outside).sort()) {
|
|
const s = site(h);
|
|
if (!TRACKERS.some((t) => s === t || h === t)) continue;
|
|
if (!permits(p.directives, h)) {
|
|
notes.push(`${p.host}: names ${h} and the enforced policy refuses it`);
|
|
continue;
|
|
}
|
|
failures.push(
|
|
`${p.host}: reaches ${h} — an advertising or analytics tag. ` +
|
|
`The trust centre says none is reached from any Hanzo surface.`,
|
|
);
|
|
}
|
|
|
|
// Rule 2, over the precise one.
|
|
for (const h of live) {
|
|
const row = disclosed.get(h);
|
|
if (!row) {
|
|
failures.push(
|
|
`${p.host}: reaches ${h}, which no party in subprocessors.json declares. ` +
|
|
`Add it as an evidence origin on the party that receives it, or stop reaching it.`,
|
|
);
|
|
} else {
|
|
console.log(` ${h} → ${row.name} (${row.role})`);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const n of notes) console.log(` note ${n}`);
|
|
|
|
if (failures.length) {
|
|
console.error("");
|
|
for (const f of failures) console.error(`surface: ${f}`);
|
|
console.error(`\nsurface: ${failures.length} finding(s) across ${surfaces.length} surface(s)`);
|
|
process.exit(1);
|
|
}
|
|
console.log(
|
|
`\nsurface: OK — ${surfaces.length} surface(s); every third party reached is a declared one, ` +
|
|
`and no advertising or analytics tag is reached from any of them`,
|
|
);
|
|
}
|
|
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
await main(process.argv.slice(2).filter((a) => !a.startsWith("-")));
|
|
}
|